| """SFX retrieval/generation interface with resolution traces. |
| |
| Implements the ARCHITECTURE.md policy: embed a scene's SFX prompt, cosine-match |
| against a manifest, use the cached clip when the score clears a threshold, |
| otherwise generate a new clip and append it so the library learns. Every |
| resolution returns a `SFXTrace` the UI/loader can surface or log. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| from dataclasses import dataclass, field |
| from pathlib import Path |
| from typing import Protocol |
|
|
| import numpy as np |
|
|
| from .embeddings import Embedder, HashingEmbedder, cosine_similarity |
|
|
| DEFAULT_MATCH_THRESHOLD = 0.62 |
|
|
|
|
| @dataclass(frozen=True) |
| class SFXAsset: |
| id: str |
| prompt: str |
| file: str = "" |
| kind: str = "sfx" |
| embedding: tuple[float, ...] | None = None |
|
|
|
|
| @dataclass(frozen=True) |
| class SFXTrace: |
| """A record of how one SFX prompt was resolved.""" |
|
|
| query: str |
| status: str |
| asset_id: str |
| score: float |
|
|
| @property |
| def is_hit(self) -> bool: |
| return self.status == "cache_hit" |
|
|
|
|
| class SFXGenerator(Protocol): |
| """Produces a new SFX asset for a cache miss (real path: Stable Audio Open).""" |
|
|
| def generate(self, prompt: str) -> SFXAsset: ... |
|
|
|
|
| class StubSFXGenerator: |
| """Dependency-free generator that registers a deferred clip for a prompt.""" |
|
|
| def __init__(self) -> None: |
| self._count = 0 |
|
|
| def generate(self, prompt: str) -> SFXAsset: |
| self._count += 1 |
| asset_id = f"gen-{self._count:03d}" |
| return SFXAsset(id=asset_id, prompt=prompt, file="", kind="sfx") |
|
|
|
|
| class SFXLibrary: |
| """Embedding-matched SFX cache that generates and learns on a miss.""" |
|
|
| def __init__( |
| self, |
| assets: list[SFXAsset] | None = None, |
| embedder: Embedder | None = None, |
| *, |
| threshold: float = DEFAULT_MATCH_THRESHOLD, |
| generator: SFXGenerator | None = None, |
| ) -> None: |
| self.embedder = embedder or HashingEmbedder() |
| self.threshold = threshold |
| self.generator = generator or StubSFXGenerator() |
| self.assets: list[SFXAsset] = [] |
| self._vectors: list[np.ndarray] = [] |
| for asset in assets or []: |
| self._register(asset) |
|
|
| def _register(self, asset: SFXAsset) -> SFXAsset: |
| if asset.embedding is not None: |
| vector = np.asarray(asset.embedding, dtype=np.float32) |
| else: |
| vector = self.embedder.embed(asset.prompt) |
| asset = SFXAsset( |
| id=asset.id, |
| prompt=asset.prompt, |
| file=asset.file, |
| kind=asset.kind, |
| embedding=tuple(float(x) for x in vector), |
| ) |
| self.assets.append(asset) |
| self._vectors.append(vector) |
| return asset |
|
|
| def best_match(self, prompt: str) -> tuple[SFXAsset | None, float]: |
| if not self.assets: |
| return None, 0.0 |
| query = self.embedder.embed(prompt) |
| scores = [cosine_similarity(query, vector) for vector in self._vectors] |
| best_index = int(np.argmax(scores)) |
| return self.assets[best_index], float(scores[best_index]) |
|
|
| def resolve(self, prompt: str) -> tuple[SFXAsset, SFXTrace]: |
| """Return the chosen asset and a trace; generate-and-learn on a miss.""" |
| match, score = self.best_match(prompt) |
| if match is not None and score >= self.threshold: |
| return match, SFXTrace(prompt, "cache_hit", match.id, round(score, 4)) |
| generated = self._register(self.generator.generate(prompt)) |
| return generated, SFXTrace(prompt, "generated", generated.id, round(score, 4)) |
|
|
| def resolve_all(self, prompts: list[str]) -> tuple[list[SFXAsset], list[SFXTrace]]: |
| assets: list[SFXAsset] = [] |
| traces: list[SFXTrace] = [] |
| for prompt in prompts: |
| asset, trace = self.resolve(prompt) |
| assets.append(asset) |
| traces.append(trace) |
| return assets, traces |
|
|
| @classmethod |
| def from_manifest( |
| cls, |
| path: str | Path, |
| embedder: Embedder | None = None, |
| **kwargs, |
| ) -> "SFXLibrary": |
| data = json.loads(Path(path).read_text(encoding="utf-8")) |
| entries = data.get("assets", data) if isinstance(data, dict) else data |
| assets = [ |
| SFXAsset( |
| id=str(entry["id"]), |
| prompt=str(entry.get("prompt", "")), |
| file=str(entry.get("file", "")), |
| kind=str(entry.get("kind", "sfx")), |
| embedding=( |
| tuple(float(x) for x in entry["embedding"]) |
| if entry.get("embedding") is not None |
| else None |
| ), |
| ) |
| for entry in entries |
| if str(entry.get("kind", "sfx")) == "sfx" |
| ] |
| return cls(assets, embedder, **kwargs) |
|
|