From d5eea0027d0e9c4d80a14fce765cd10e760f1d12 Mon Sep 17 00:00:00 2001 From: Lvigentini Date: Thu, 6 Aug 2026 19:31:16 +1000 Subject: [PATCH 01/24] fix(backend): honor VOICEBOX_DATA_DIR environment variable backend/README.md documents VOICEBOX_DATA_DIR alongside --data-dir as a way to override the data directory, but config.py never read it -- only VOICEBOX_MODELS_DIR was wired up, so the default stayed hardcoded to ./data. This matters in development: `just dev` starts the backend with a bare `uvicorn backend.main:app`, which never reaches main.py's argparse, so --data-dir is unavailable there and the dev server always writes to ./data inside the repo. Pointing a dev server at the packaged app's data directory meant editing the justfile. Read the variable when computing the default. --data-dir still takes precedence, since it calls set_data_dir() after import. Co-Authored-By: Claude Opus 5 --- backend/config.py | 6 ++- backend/tests/test_data_dir_env.py | 78 ++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_data_dir_env.py diff --git a/backend/config.py b/backend/config.py index cb6bc168c..0cb47caf3 100644 --- a/backend/config.py +++ b/backend/config.py @@ -18,8 +18,10 @@ os.environ["HF_HUB_CACHE"] = _custom_models_dir logger.info("Model download path set to: %s", _custom_models_dir) -# Default data directory (used in development) -_data_dir = Path("data").resolve() +# Default data directory (used in development). VOICEBOX_DATA_DIR lets a bare +# `uvicorn backend.main:app` point at the packaged app's data dir without a CLI +# flag. The --data-dir argument still wins: it calls set_data_dir() after import. +_data_dir = Path(os.environ.get("VOICEBOX_DATA_DIR") or "data").resolve() def _path_relative_to_any_data_dir(path: Path) -> Path | None: diff --git a/backend/tests/test_data_dir_env.py b/backend/tests/test_data_dir_env.py new file mode 100644 index 000000000..cef81949e --- /dev/null +++ b/backend/tests/test_data_dir_env.py @@ -0,0 +1,78 @@ +""" +Unit tests for the ``VOICEBOX_DATA_DIR`` environment variable. + +``backend/README.md`` documents ``VOICEBOX_DATA_DIR`` alongside ``--data-dir``, +but the default was previously hardcoded to ``./data``, so a bare +``uvicorn backend.main:app`` (how ``just dev`` starts the backend) always wrote +to the repo instead of the app data dir. + +The variable is read at import time, so each test reloads the module under a +patched environment. + +NOTE: These tests reload ``config``, which rebinds its module-global +``_data_dir``. Import the module fresh rather than holding a reference across +reloads. +""" + +import importlib +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import config # noqa: E402 + + +@pytest.fixture +def reload_config(monkeypatch): + """Reload ``config`` with a patched environment, restoring it afterwards.""" + + def _reload(data_dir: str | None): + if data_dir is None: + monkeypatch.delenv("VOICEBOX_DATA_DIR", raising=False) + else: + monkeypatch.setenv("VOICEBOX_DATA_DIR", data_dir) + return importlib.reload(config) + + yield _reload + # Leave the module matching the real process environment for later tests. + importlib.reload(config) + + +def test_env_var_sets_data_dir(reload_config, tmp_path): + target = tmp_path / "appdata" + cfg = reload_config(str(target)) + + assert cfg.get_data_dir() == target.resolve() + + +def test_defaults_to_local_data_dir_when_unset(reload_config): + cfg = reload_config(None) + + assert cfg.get_data_dir() == Path("data").resolve() + + +def test_empty_env_var_falls_back_to_default(reload_config): + cfg = reload_config("") + + assert cfg.get_data_dir() == Path("data").resolve() + + +def test_relative_env_var_is_resolved_to_absolute(reload_config): + cfg = reload_config("relative/data") + + assert cfg.get_data_dir().is_absolute() + assert cfg.get_data_dir() == Path("relative/data").resolve() + + +def test_set_data_dir_still_wins_over_env_var(reload_config, tmp_path): + """``--data-dir`` calls set_data_dir() after import, so it must take + precedence over the environment variable.""" + cfg = reload_config(str(tmp_path / "from-env")) + explicit = tmp_path / "from-flag" + + cfg.set_data_dir(explicit) + + assert cfg.get_data_dir() == explicit.resolve() From c065bf5ba78c1965ed2f7b6fe6a16c98a55da847 Mon Sep 17 00:00:00 2001 From: Lvigentini Date: Thu, 6 Aug 2026 22:11:41 +1000 Subject: [PATCH 02/24] feat(backend): add folders for voices and clips, and profile duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related additions, sharing changes to the profile models and service. Folders ------- A `folders` table plus a folder_id column on profiles and generations. One table serves both, discriminated by kind: - voice — flat. Voices are a small stable set that reads better as one level, so the routes reject a parent_id. - generation — nests to arbitrary depth, since clips accumulate per project and need real hierarchy. Nesting is enforced in the routes rather than the schema so the constraint can relax later without a migration. Reparenting is guarded against cycles: moving a folder inside its own descendant would detach the subtree from the root and strand it in the tree UI. Deleting a folder never deletes its contents. Members become uncategorised and child folders rise to the deleted folder's parent, so a folder is only ever a view over items, not an owner of them. The folders table itself is created by Base.metadata.create_all(), which runs right after migrations; only the columns on pre-existing tables need the hand-rolled migration. Those are declared without a REFERENCES clause, matching story_items.version_id, because SQLite cannot add a column with a foreign key to a table that does not exist yet. Duplication ----------- POST /profiles/{id}/duplicate, deliberately not implemented as an export-then-import round-trip. The transfer format carries only name/description/language, so a round-trip silently drops personality, effects_chain, default_engine and every preset/designed field -- and export rejects profiles with no samples, which is every preset voice. Copying the row directly keeps all of it. Sample audio is copied byte-for-byte rather than re-encoded through add_profile_sample(), so a duplicate sounds like its original instead of being resampled. _get_unique_profile_name moves from export_import to services.profiles so duplication can reuse it without importing the export/import layer; the old name stays as an alias. Also threads a folder filter through GET /history. Its limit/offset bounds are now declared as Query constraints: building HistoryQuery from raw ints inside the handler surfaced its ValidationError as an opaque 500 instead of a 422. Co-Authored-By: Claude Opus 5 --- backend/database/migrations.py | 22 ++ backend/database/models.py | 32 +++ backend/models.py | 70 ++++++ backend/routes/__init__.py | 2 + backend/routes/folders.py | 266 +++++++++++++++++++++ backend/routes/history.py | 15 +- backend/routes/profiles.py | 20 ++ backend/services/export_import.py | 28 +-- backend/services/folders.py | 29 +++ backend/services/history.py | 15 +- backend/services/profiles.py | 111 +++++++++ backend/tests/test_folders.py | 271 ++++++++++++++++++++++ backend/tests/test_profile_duplicate.py | 294 ++++++++++++++++++++++++ 13 files changed, 1148 insertions(+), 27 deletions(-) create mode 100644 backend/routes/folders.py create mode 100644 backend/services/folders.py create mode 100644 backend/tests/test_folders.py create mode 100644 backend/tests/test_profile_duplicate.py diff --git a/backend/database/migrations.py b/backend/database/migrations.py index d353b58c8..149f5a7ca 100644 --- a/backend/database/migrations.py +++ b/backend/database/migrations.py @@ -43,6 +43,7 @@ def run_migrations(engine) -> None: _migrate_generation_versions(engine, inspector, tables) _migrate_capture_settings(engine, inspector, tables) _migrate_mcp_bindings(engine, inspector, tables) + _migrate_folders(engine, inspector, tables) _normalize_storage_paths(engine, tables) @@ -334,3 +335,24 @@ def _normalize_storage_paths(engine, tables: set[str]) -> None: if total_fixed > 0: conn.commit() logger.info("Normalized %d stored file paths", total_fixed) + + +def _migrate_folders(engine, inspector, tables: set[str]) -> None: + """Add folder_id to profiles and generations. + + The ``folders`` table itself is left to ``Base.metadata.create_all()``, + which runs straight after migrations and creates missing tables. Only + the columns on pre-existing tables need adding by hand. + + Declared without a REFERENCES clause, matching story_items.version_id: + SQLite cannot add a column with a foreign key to a table that does not + exist yet, and on a fresh database ``folders`` is created after this + runs. The relationship is still declared on the ORM models. + """ + if "profiles" in tables: + if "folder_id" not in _get_columns(inspector, "profiles"): + _add_column(engine, "profiles", "folder_id VARCHAR", "folder_id") + + if "generations" in tables: + if "folder_id" not in _get_columns(inspector, "generations"): + _add_column(engine, "generations", "folder_id VARCHAR", "folder_id") diff --git a/backend/database/models.py b/backend/database/models.py index b85a55b17..fb31fe6bd 100644 --- a/backend/database/models.py +++ b/backend/database/models.py @@ -14,6 +14,31 @@ Base = declarative_base() +class Folder(Base): + """A user-created folder for organising voices or generated clips. + + One table serves both, discriminated by ``kind``: + - "voice" — groups profiles. Flat: parent_id is always NULL. + - "generation" — groups generations. Nests to arbitrary depth. + + The asymmetry is a product decision, not a schema limit — voices are a + small, stable set that reads better as one level, while clips accumulate + per project and need real hierarchy. Nesting is enforced in the routes + rather than here so the constraint can relax without a migration. + """ + + __tablename__ = "folders" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + name = Column(String, nullable=False) + kind = Column(String, nullable=False, default="voice") # "voice" | "generation" + parent_id = Column(String, ForeignKey("folders.id"), nullable=True) + # Manual ordering within a parent. Ties break by name in the routes. + position = Column(Integer, nullable=False, default=0) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + class VoiceProfile(Base): """Voice profile. @@ -44,6 +69,11 @@ class VoiceProfile(Base): # cloning metadata above). personality = Column(Text, nullable=True) + # NULL means "Uncategorised" — the absence of a folder, not a missing + # reference. Deleting a folder nulls this rather than cascading, so a + # folder is never a way to lose voices. + folder_id = Column(String, ForeignKey("folders.id"), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) @@ -82,6 +112,8 @@ class Generation(Base): # profile's personality LLM before TTS. Future sources (bulk import, # agent replies, etc.) can extend this. source = Column(String, nullable=False, default="manual") + # NULL means "Uncategorised". See VoiceProfile.folder_id. + folder_id = Column(String, ForeignKey("folders.id"), nullable=True) created_at = Column(DateTime, default=datetime.utcnow) diff --git a/backend/models.py b/backend/models.py index 7970ce41e..61f9e59da 100644 --- a/backend/models.py +++ b/backend/models.py @@ -12,6 +12,64 @@ ) +FOLDER_KIND_PATTERN = "^(voice|generation)$" + + +class FolderCreate(BaseModel): + """Request model for creating a folder.""" + + name: str = Field(..., min_length=1, max_length=100) + kind: str = Field(default="voice", pattern=FOLDER_KIND_PATTERN) + # Only meaningful for kind="generation"; voice folders are flat and the + # route rejects a non-null parent for them. + parent_id: Optional[str] = None + + +class FolderUpdate(BaseModel): + """Request model for renaming or reparenting a folder. + + Every field is optional so a rename doesn't have to restate the parent. + ``parent_id`` therefore can't distinguish "unset" from "move to root" — + use the dedicated move endpoint to detach a folder to the root. + """ + + name: Optional[str] = Field(None, min_length=1, max_length=100) + parent_id: Optional[str] = None + position: Optional[int] = Field(None, ge=0) + + +class FolderResponse(BaseModel): + """Response model for a folder.""" + + id: str + name: str + kind: str + parent_id: Optional[str] = None + position: int = 0 + # Direct members only — a parent folder does not count its children's items. + item_count: int = 0 + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class FolderAssign(BaseModel): + """Request model for moving an item into a folder (null = uncategorised).""" + + folder_id: Optional[str] = None + + +class ProfileDuplicateRequest(BaseModel): + """Optional overrides when duplicating a profile. + + Omit entirely to accept the default " (copy)" name. + """ + + name: Optional[str] = Field(None, min_length=1, max_length=100) + + class VoiceProfileCreate(BaseModel): """Request model for creating a voice profile.""" @@ -26,6 +84,7 @@ class VoiceProfileCreate(BaseModel): design_prompt: Optional[str] = Field(None, max_length=2000) default_engine: Optional[str] = Field(None, max_length=50) personality: Optional[str] = Field(None, max_length=2000) + folder_id: Optional[str] = None class VoiceProfileResponse(BaseModel): @@ -43,6 +102,7 @@ class VoiceProfileResponse(BaseModel): design_prompt: Optional[str] = None default_engine: Optional[str] = None personality: Optional[str] = None + folder_id: Optional[str] = None generation_count: int = 0 sample_count: int = 0 created_at: datetime @@ -119,6 +179,7 @@ class GenerationResponse(BaseModel): error: Optional[str] = None is_favorited: bool = False source: str = "manual" + folder_id: Optional[str] = None created_at: datetime versions: Optional[List["GenerationVersionResponse"]] = None active_version_id: Optional[str] = None @@ -132,6 +193,14 @@ class HistoryQuery(BaseModel): profile_id: Optional[str] = None search: Optional[str] = None + # Folder filter. A plain absent/None folder_id means "no filter, show + # everything"; selecting the Uncategorised bucket is a distinct request + # that a nullable field can't express, hence the explicit flag below. + folder_id: Optional[str] = None + uncategorised_only: bool = False + # Whether folder_id also matches clips in that folder's descendants. + # Clip folders nest, so a parent should be able to show the whole subtree. + include_subfolders: bool = True limit: int = Field(default=50, ge=1, le=100) offset: int = Field(default=0, ge=0) @@ -153,6 +222,7 @@ class HistoryResponse(BaseModel): status: str = "completed" error: Optional[str] = None is_favorited: bool = False + folder_id: Optional[str] = None created_at: datetime versions: Optional[List["GenerationVersionResponse"]] = None active_version_id: Optional[str] = None diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py index 42999d2d1..dab0aa13a 100644 --- a/backend/routes/__init__.py +++ b/backend/routes/__init__.py @@ -25,9 +25,11 @@ def register_routers(app: FastAPI) -> None: from .mcp_bindings import router as mcp_bindings_router from .events import router as events_router from .cloud import router as cloud_router + from .folders import router as folders_router app.include_router(health_router) app.include_router(profiles_router) + app.include_router(folders_router) app.include_router(channels_router) app.include_router(generations_router) app.include_router(history_router) diff --git a/backend/routes/folders.py b/backend/routes/folders.py new file mode 100644 index 000000000..cb2978eb5 --- /dev/null +++ b/backend/routes/folders.py @@ -0,0 +1,266 @@ +"""REST endpoints for organising voices and generated clips into folders. + +One ``folders`` table backs both, discriminated by ``kind``: + + - ``voice`` — flat. A voice folder never has a parent, because the + voice list reads better as one level (see Folder in database/models.py). + - ``generation`` — nests to arbitrary depth, since clips accumulate per + project and need real hierarchy. + +Deleting a folder never deletes its contents. Members are moved to +Uncategorised and child folders are re-parented to the deleted folder's +parent, so a folder is only ever a view over items, never an owner of them. +""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import func +from sqlalchemy.orm import Session + +from .. import models +from ..database import get_db +from ..database.models import Folder, Generation, ProfileSample, VoiceProfile +from ..services.folders import folder_and_descendants +from ..services.profiles import _profile_to_response + +router = APIRouter() + +# Maps a folder kind to the table whose rows it groups. +_MEMBER_MODEL = { + "voice": VoiceProfile, + "generation": Generation, +} + + +def _get_folder_or_404(folder_id: str, db: Session) -> Folder: + folder = db.query(Folder).filter(Folder.id == folder_id).first() + if folder is None: + raise HTTPException(status_code=404, detail="Folder not found") + return folder + + +def _member_counts(kind: str, db: Session) -> dict[str, int]: + """Direct member count per folder id, for one kind. + + One grouped query rather than a count per folder — the folder list is + rendered on every voice/clip panel render. + """ + model = _MEMBER_MODEL[kind] + rows = ( + db.query(model.folder_id, func.count(model.id)) + .filter(model.folder_id.isnot(None)) + .group_by(model.folder_id) + .all() + ) + return {folder_id: count for folder_id, count in rows} + + +def _to_response(folder: Folder, counts: dict[str, int]) -> models.FolderResponse: + return models.FolderResponse( + id=folder.id, + name=folder.name, + kind=folder.kind, + parent_id=folder.parent_id, + position=folder.position, + item_count=counts.get(folder.id, 0), + created_at=folder.created_at, + updated_at=folder.updated_at, + ) + + +@router.get("/folders", response_model=list[models.FolderResponse]) +async def list_folders(kind: str = "voice", db: Session = Depends(get_db)): + """List folders of one kind, ordered for direct rendering.""" + if kind not in _MEMBER_MODEL: + raise HTTPException(status_code=400, detail=f"Unknown folder kind: {kind}") + + folders = ( + db.query(Folder) + .filter(Folder.kind == kind) + .order_by(Folder.position, Folder.name) + .all() + ) + counts = _member_counts(kind, db) + return [_to_response(f, counts) for f in folders] + + +@router.post("/folders", response_model=models.FolderResponse) +async def create_folder(data: models.FolderCreate, db: Session = Depends(get_db)): + """Create a folder. Voice folders must be top-level.""" + if data.parent_id is not None: + if data.kind == "voice": + raise HTTPException( + status_code=400, detail="Voice folders cannot be nested" + ) + parent = _get_folder_or_404(data.parent_id, db) + if parent.kind != data.kind: + raise HTTPException( + status_code=400, detail="Parent folder has a different kind" + ) + + folder = Folder( + name=data.name.strip(), + kind=data.kind, + parent_id=data.parent_id, + position=_next_position(data.kind, data.parent_id, db), + ) + db.add(folder) + db.commit() + db.refresh(folder) + return _to_response(folder, {}) + + +@router.patch("/folders/{folder_id}", response_model=models.FolderResponse) +async def update_folder( + folder_id: str, + data: models.FolderUpdate, + db: Session = Depends(get_db), +): + """Rename, reposition, or reparent a folder.""" + folder = _get_folder_or_404(folder_id, db) + + if data.name is not None: + folder.name = data.name.strip() + + if data.position is not None: + folder.position = data.position + + if data.parent_id is not None: + if folder.kind == "voice": + raise HTTPException( + status_code=400, detail="Voice folders cannot be nested" + ) + if data.parent_id == folder_id: + raise HTTPException( + status_code=400, detail="A folder cannot be its own parent" + ) + parent = _get_folder_or_404(data.parent_id, db) + if parent.kind != folder.kind: + raise HTTPException( + status_code=400, detail="Parent folder has a different kind" + ) + # Reparenting under your own descendant would detach the whole + # subtree from the root and make it unreachable in the tree UI. + if data.parent_id in folder_and_descendants(folder_id, db): + raise HTTPException( + status_code=400, detail="Cannot move a folder inside itself" + ) + folder.parent_id = data.parent_id + + db.commit() + db.refresh(folder) + return _to_response(folder, _member_counts(folder.kind, db)) + + +@router.post("/folders/{folder_id}/detach", response_model=models.FolderResponse) +async def detach_folder(folder_id: str, db: Session = Depends(get_db)): + """Move a folder back to the root. + + Separate from PATCH because FolderUpdate.parent_id=None is + indistinguishable from "field omitted". + """ + folder = _get_folder_or_404(folder_id, db) + folder.parent_id = None + db.commit() + db.refresh(folder) + return _to_response(folder, _member_counts(folder.kind, db)) + + +@router.delete("/folders/{folder_id}") +async def delete_folder(folder_id: str, db: Session = Depends(get_db)): + """Delete a folder, preserving everything inside it. + + Members become uncategorised; child folders rise to this folder's + parent. Nothing the user made is removed. + """ + folder = _get_folder_or_404(folder_id, db) + model = _MEMBER_MODEL[folder.kind] + + released = ( + db.query(model) + .filter(model.folder_id == folder_id) + .update({model.folder_id: None}, synchronize_session=False) + ) + reparented = ( + db.query(Folder) + .filter(Folder.parent_id == folder_id) + .update({Folder.parent_id: folder.parent_id}, synchronize_session=False) + ) + + db.delete(folder) + db.commit() + return { + "deleted": folder_id, + "items_released": released, + "folders_reparented": reparented, + } + + +def _next_position(kind: str, parent_id: str | None, db: Session) -> int: + """Append position — one past the highest sibling.""" + highest = ( + db.query(func.max(Folder.position)) + .filter(Folder.kind == kind, Folder.parent_id == parent_id) + .scalar() + ) + return 0 if highest is None else highest + 1 + + +# ── Membership ─────────────────────────────────────────────────────── + + +@router.put("/profiles/{profile_id}/folder", response_model=models.VoiceProfileResponse) +async def set_profile_folder( + profile_id: str, + data: models.FolderAssign, + db: Session = Depends(get_db), +): + """Move a voice into a folder, or out of one when folder_id is null.""" + profile = db.query(VoiceProfile).filter(VoiceProfile.id == profile_id).first() + if profile is None: + raise HTTPException(status_code=404, detail="Profile not found") + + if data.folder_id is not None: + folder = _get_folder_or_404(data.folder_id, db) + if folder.kind != "voice": + raise HTTPException( + status_code=400, detail="Target folder does not hold voices" + ) + + profile.folder_id = data.folder_id + db.commit() + db.refresh(profile) + + generation_count = ( + db.query(func.count(Generation.id)) + .filter(Generation.profile_id == profile.id) + .scalar() + ) + sample_count = ( + db.query(func.count(ProfileSample.id)) + .filter(ProfileSample.profile_id == profile.id) + .scalar() + ) + return _profile_to_response(profile, generation_count, sample_count) + + +@router.put("/history/{generation_id}/folder") +async def set_generation_folder( + generation_id: str, + data: models.FolderAssign, + db: Session = Depends(get_db), +): + """Move a clip into a folder, or out of one when folder_id is null.""" + generation = db.query(Generation).filter(Generation.id == generation_id).first() + if generation is None: + raise HTTPException(status_code=404, detail="Generation not found") + + if data.folder_id is not None: + folder = _get_folder_or_404(data.folder_id, db) + if folder.kind != "generation": + raise HTTPException( + status_code=400, detail="Target folder does not hold clips" + ) + + generation.folder_id = data.folder_id + db.commit() + return {"id": generation_id, "folder_id": generation.folder_id} diff --git a/backend/routes/history.py b/backend/routes/history.py index 694d35beb..428c4f69e 100644 --- a/backend/routes/history.py +++ b/backend/routes/history.py @@ -2,7 +2,7 @@ import io -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from fastapi.responses import FileResponse, StreamingResponse from sqlalchemy.orm import Session @@ -18,14 +18,23 @@ async def list_history( profile_id: str | None = None, search: str | None = None, - limit: int = 50, - offset: int = 0, + folder_id: str | None = None, + uncategorised_only: bool = False, + include_subfolders: bool = True, + # Bounds mirror HistoryQuery so FastAPI rejects out-of-range values with + # a 422 before the model is constructed. Building HistoryQuery from raw + # ints instead surfaced its ValidationError as an opaque 500. + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ): """List generation history with optional filters.""" query = models.HistoryQuery( profile_id=profile_id, search=search, + folder_id=folder_id, + uncategorised_only=uncategorised_only, + include_subfolders=include_subfolders, limit=limit, offset=offset, ) diff --git a/backend/routes/profiles.py b/backend/routes/profiles.py index 68e5f2af0..eb273dc1e 100644 --- a/backend/routes/profiles.py +++ b/backend/routes/profiles.py @@ -134,6 +134,26 @@ async def update_profile( raise HTTPException(status_code=400, detail=str(e)) +@router.post("/profiles/{profile_id}/duplicate", response_model=models.VoiceProfileResponse) +async def duplicate_profile( + profile_id: str, + data: models.ProfileDuplicateRequest | None = None, + db: Session = Depends(get_db), +): + """Duplicate a voice profile, including its samples, avatar and settings. + + Unlike an export/import round-trip this preserves personality, effects + chain, default engine and preset/designed fields, and works for profiles + with no samples. Defaults the new name to " (copy)". + """ + try: + return await profiles.duplicate_profile( + profile_id, db, name=data.name if data else None + ) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + @router.delete("/profiles/{profile_id}") async def delete_profile( profile_id: str, diff --git a/backend/services/export_import.py b/backend/services/export_import.py index 514eaacda..3c49223e7 100644 --- a/backend/services/export_import.py +++ b/backend/services/export_import.py @@ -14,32 +14,14 @@ from ..models import VoiceProfileResponse from ..database import VoiceProfile as DBVoiceProfile, ProfileSample as DBProfileSample, Generation as DBGeneration, GenerationVersion as DBGenerationVersion -from .profiles import create_profile, add_profile_sample +from .profiles import create_profile, add_profile_sample, get_unique_profile_name from ..models import VoiceProfileCreate from .. import config - -def _get_unique_profile_name(name: str, db: Session) -> str: - """ - Get a unique profile name by appending a number if needed. - - Args: - name: Original profile name - db: Database session - - Returns: - Unique profile name - """ - base_name = name - counter = 1 - - while True: - existing = db.query(DBVoiceProfile).filter_by(name=name).first() - if not existing: - return name - - name = f"{base_name} ({counter})" - counter += 1 +# Kept as a module-level alias: this helper started life here, and moved to +# services.profiles so profile duplication could reuse it without importing +# the export/import layer. +_get_unique_profile_name = get_unique_profile_name def export_profile_to_zip(profile_id: str, db: Session) -> bytes: diff --git a/backend/services/folders.py b/backend/services/folders.py new file mode 100644 index 000000000..fe54ccf1a --- /dev/null +++ b/backend/services/folders.py @@ -0,0 +1,29 @@ +"""Folder tree helpers shared by the folders routes and history queries. + +Clip folders nest, so both "list this folder's clips" and "don't let a +folder be moved inside itself" need the same subtree walk. It lives here +so the two callers can't drift apart. +""" + +from sqlalchemy.orm import Session + +from ..database.models import Folder + + +def folder_and_descendants(folder_id: str, db: Session) -> set[str]: + """Return ``folder_id`` plus every folder beneath it. + + Breadth-first over ``parent_id``. The seen-set both prevents revisiting + shared subtrees and stops a cycle -- which the reparent guard should make + impossible, but which a hand-edited database could still contain -- from + looping forever. + """ + seen = {folder_id} + frontier = [folder_id] + + while frontier: + rows = db.query(Folder.id).filter(Folder.parent_id.in_(frontier)).all() + frontier = [child_id for (child_id,) in rows if child_id not in seen] + seen.update(frontier) + + return seen diff --git a/backend/services/history.py b/backend/services/history.py index 3062f7d6a..dac081aff 100644 --- a/backend/services/history.py +++ b/backend/services/history.py @@ -13,6 +13,7 @@ from ..models import GenerationRequest, GenerationResponse, HistoryQuery, HistoryResponse, HistoryListResponse, GenerationVersionResponse, EffectConfig from ..database import Generation as DBGeneration, GenerationVersion as DBGenerationVersion, VoiceProfile as DBVoiceProfile from .. import config +from .folders import folder_and_descendants def _get_versions_for_generation(generation_id: str, db: Session) -> tuple: @@ -192,7 +193,18 @@ async def list_generations( if query.search: search_pattern = f"%{query.search}%" q = q.filter(DBGeneration.text.like(search_pattern)) - + + # Apply folder filter. uncategorised_only is checked first because it + # is the one folder request a plain folder_id cannot express. + if query.uncategorised_only: + q = q.filter(DBGeneration.folder_id.is_(None)) + elif query.folder_id: + if query.include_subfolders: + folder_ids = folder_and_descendants(query.folder_id, db) + q = q.filter(DBGeneration.folder_id.in_(folder_ids)) + else: + q = q.filter(DBGeneration.folder_id == query.folder_id) + # Get total count before pagination total_count = q.count() @@ -224,6 +236,7 @@ async def list_generations( status=generation.status or "completed", error=generation.error, is_favorited=bool(generation.is_favorited), + folder_id=generation.folder_id, created_at=generation.created_at, versions=versions, active_version_id=active_version_id, diff --git a/backend/services/profiles.py b/backend/services/profiles.py index d7d32fa0f..cf4d063ed 100644 --- a/backend/services/profiles.py +++ b/backend/services/profiles.py @@ -55,6 +55,7 @@ def _profile_to_response( design_prompt=getattr(profile, "design_prompt", None), default_engine=getattr(profile, "default_engine", None), personality=getattr(profile, "personality", None), + folder_id=getattr(profile, "folder_id", None), generation_count=generation_count, sample_count=sample_count, created_at=profile.created_at, @@ -135,6 +136,24 @@ def validate_profile_engine(profile, engine: str) -> None: raise ValueError(f"Engine '{engine}' does not support cloned voice profiles") +def get_unique_profile_name(name: str, db: Session) -> str: + """Return ``name``, or the first free "name (n)" variant. + + ``profiles.name`` is UNIQUE, so anything that creates a profile from an + existing one -- import, duplicate -- has to resolve collisions first. + """ + base_name = name + counter = 1 + + while True: + existing = db.query(DBVoiceProfile).filter_by(name=name).first() + if not existing: + return name + + name = f"{base_name} ({counter})" + counter += 1 + + async def create_profile( data: VoiceProfileCreate, db: Session, @@ -183,6 +202,7 @@ async def create_profile( design_prompt=data.design_prompt, default_engine=default_engine, personality=data.personality, + folder_id=data.folder_id, created_at=datetime.utcnow(), updated_at=datetime.utcnow(), ) @@ -708,3 +728,94 @@ async def delete_avatar( db.commit() return True + + +async def duplicate_profile( + profile_id: str, + db: Session, + name: str | None = None, +) -> VoiceProfileResponse: + """Copy a profile, its samples, and its avatar. + + Deliberately not implemented as export-then-import. The transfer format + carries only name/description/language (see export_import.py), so a + round-trip silently drops personality, effects_chain, default_engine and + every preset/designed field -- and refuses profiles with no samples, + which is every preset voice. Copying the row directly keeps all of it. + + Sample audio is copied byte-for-byte rather than re-encoded through + add_profile_sample(): the source files were already validated when they + were first added, and a duplicate should be identical, not resampled. + """ + source = db.query(DBVoiceProfile).filter_by(id=profile_id).first() + if not source: + raise ValueError(f"Profile {profile_id} not found") + + new_id = str(uuid.uuid4()) + new_name = get_unique_profile_name(name.strip() if name else f"{source.name} (copy)", db) + + duplicate = DBVoiceProfile( + id=new_id, + name=new_name, + description=source.description, + language=source.language, + effects_chain=source.effects_chain, + voice_type=source.voice_type, + preset_engine=source.preset_engine, + preset_voice_id=source.preset_voice_id, + design_prompt=source.design_prompt, + default_engine=source.default_engine, + personality=source.personality, + folder_id=source.folder_id, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + db.add(duplicate) + + new_dir = config.get_profiles_dir() / new_id + new_dir.mkdir(parents=True, exist_ok=True) + + # Samples: copy each file under a fresh id, then point a new row at it. + samples = db.query(DBProfileSample).filter_by(profile_id=profile_id).all() + for sample in samples: + source_audio = config.resolve_storage_path(sample.audio_path) + if source_audio is None or not source_audio.exists(): + # A profile can outlive its audio (moved data dir, manual + # cleanup). Skip the orphan rather than fail the whole copy. + logger.warning( + "Skipping sample %s while duplicating %s: audio missing at %s", + sample.id, + profile_id, + sample.audio_path, + ) + continue + + new_sample_id = str(uuid.uuid4()) + dest = new_dir / f"{new_sample_id}{source_audio.suffix}" + shutil.copy2(source_audio, dest) + db.add( + DBProfileSample( + id=new_sample_id, + profile_id=new_id, + audio_path=config.to_storage_path(dest), + reference_text=sample.reference_text, + ) + ) + + if source.avatar_path: + source_avatar = config.resolve_storage_path(source.avatar_path) + if source_avatar is not None and source_avatar.exists(): + dest_avatar = new_dir / source_avatar.name + shutil.copy2(source_avatar, dest_avatar) + duplicate.avatar_path = config.to_storage_path(dest_avatar) + + db.commit() + db.refresh(duplicate) + + sample_count = ( + db.query(func.count(DBProfileSample.id)) + .filter(DBProfileSample.profile_id == new_id) + .scalar() + ) + # A fresh copy has no generations of its own. + return _profile_to_response(duplicate, generation_count=0, sample_count=sample_count) diff --git a/backend/tests/test_folders.py b/backend/tests/test_folders.py new file mode 100644 index 000000000..9faef6e2b --- /dev/null +++ b/backend/tests/test_folders.py @@ -0,0 +1,271 @@ +""" +Tests for folder organisation of voices and generated clips. + +Covers the asymmetry between the two folder kinds: voice folders are flat, +clip folders nest. Also covers the guarantee that deleting a folder never +deletes what is inside it. + +VOICEBOX_DATA_DIR is set before importing the app so the whole suite runs +against a throwaway data directory and never touches a real install. + +Usage: + python -m pytest backend/tests/test_folders.py -v +""" + +import os +import sys +import tempfile +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +_DATA_DIR = tempfile.mkdtemp(prefix="voicebox-folders-test-") +os.environ["VOICEBOX_DATA_DIR"] = _DATA_DIR + +from starlette.testclient import TestClient # noqa: E402 + +from backend.app import app # noqa: E402 + + +@pytest.fixture(scope="module") +def client(): + # Context-manager form triggers the lifespan, which runs init_db(). + with TestClient(app) as c: + yield c + + +@pytest.fixture +def voice_folder(client): + r = client.post("/folders", json={"name": "Podcast voices", "kind": "voice"}) + assert r.status_code == 200, r.text + folder = r.json() + yield folder + client.delete(f"/folders/{folder['id']}") + + +@pytest.fixture +def profile(client): + """A preset profile — deliberately one with no samples, since those are + exactly the profiles export/import cannot round-trip.""" + r = client.post( + "/profiles", + json={ + "name": "Folder Test Voice", + "description": "fixture", + "language": "en", + "voice_type": "preset", + "preset_engine": "kokoro", + "preset_voice_id": "af_bella", + "personality": "Speaks in short, dry sentences.", + }, + ) + assert r.status_code == 200, r.text + created = r.json() + yield created + client.delete(f"/profiles/{created['id']}") + + +# ── Folder CRUD ────────────────────────────────────────────────────── + + +def test_create_and_list_voice_folder(client, voice_folder): + assert voice_folder["kind"] == "voice" + assert voice_folder["parent_id"] is None + + listed = client.get("/folders", params={"kind": "voice"}).json() + assert voice_folder["id"] in [f["id"] for f in listed] + + +def test_voice_folders_are_excluded_from_generation_listing(client, voice_folder): + listed = client.get("/folders", params={"kind": "generation"}).json() + assert voice_folder["id"] not in [f["id"] for f in listed] + + +def test_unknown_kind_is_rejected(client): + assert client.get("/folders", params={"kind": "nonsense"}).status_code == 400 + + +def test_rename_folder(client, voice_folder): + r = client.patch(f"/folders/{voice_folder['id']}", json={"name": "Renamed"}) + assert r.status_code == 200 + assert r.json()["name"] == "Renamed" + + +def test_folder_name_is_trimmed(client): + r = client.post("/folders", json={"name": " Padded ", "kind": "voice"}) + assert r.json()["name"] == "Padded" + client.delete(f"/folders/{r.json()['id']}") + + +def test_missing_folder_is_404(client): + assert client.patch("/folders/nope", json={"name": "x"}).status_code == 404 + assert client.delete("/folders/nope").status_code == 404 + + +# ── Nesting rules ──────────────────────────────────────────────────── + + +def test_voice_folders_cannot_nest(client, voice_folder): + r = client.post( + "/folders", + json={"name": "Child", "kind": "voice", "parent_id": voice_folder["id"]}, + ) + assert r.status_code == 400 + assert "nested" in r.json()["detail"].lower() + + +def test_generation_folders_nest(client): + parent = client.post("/folders", json={"name": "Season 1", "kind": "generation"}).json() + child = client.post( + "/folders", + json={"name": "Episode 1", "kind": "generation", "parent_id": parent["id"]}, + ).json() + + assert child["parent_id"] == parent["id"] + + grandchild = client.post( + "/folders", + json={"name": "Takes", "kind": "generation", "parent_id": child["id"]}, + ) + assert grandchild.status_code == 200 + + client.delete(f"/folders/{grandchild.json()['id']}") + client.delete(f"/folders/{child['id']}") + client.delete(f"/folders/{parent['id']}") + + +def test_parent_must_share_kind(client, voice_folder): + r = client.post( + "/folders", + json={"name": "Mismatched", "kind": "generation", "parent_id": voice_folder["id"]}, + ) + assert r.status_code == 400 + + +def test_folder_cannot_be_its_own_parent(client): + folder = client.post("/folders", json={"name": "Loop", "kind": "generation"}).json() + r = client.patch(f"/folders/{folder['id']}", json={"parent_id": folder["id"]}) + assert r.status_code == 400 + client.delete(f"/folders/{folder['id']}") + + +def test_folder_cannot_move_into_its_own_descendant(client): + """The cycle that would orphan a whole subtree from the root.""" + parent = client.post("/folders", json={"name": "Outer", "kind": "generation"}).json() + child = client.post( + "/folders", + json={"name": "Inner", "kind": "generation", "parent_id": parent["id"]}, + ).json() + + r = client.patch(f"/folders/{parent['id']}", json={"parent_id": child["id"]}) + assert r.status_code == 400 + assert "inside itself" in r.json()["detail"].lower() + + client.delete(f"/folders/{child['id']}") + client.delete(f"/folders/{parent['id']}") + + +def test_detach_moves_folder_to_root(client): + parent = client.post("/folders", json={"name": "P", "kind": "generation"}).json() + child = client.post( + "/folders", json={"name": "C", "kind": "generation", "parent_id": parent["id"]} + ).json() + + r = client.post(f"/folders/{child['id']}/detach") + assert r.status_code == 200 + assert r.json()["parent_id"] is None + + client.delete(f"/folders/{child['id']}") + client.delete(f"/folders/{parent['id']}") + + +# ── Membership ─────────────────────────────────────────────────────── + + +def test_assign_and_unassign_profile(client, voice_folder, profile): + r = client.put( + f"/profiles/{profile['id']}/folder", json={"folder_id": voice_folder["id"]} + ) + assert r.status_code == 200 + assert r.json()["folder_id"] == voice_folder["id"] + + r = client.put(f"/profiles/{profile['id']}/folder", json={"folder_id": None}) + assert r.json()["folder_id"] is None + + +def test_profile_cannot_go_into_a_clip_folder(client, profile): + clip_folder = client.post( + "/folders", json={"name": "Clips", "kind": "generation"} + ).json() + + r = client.put( + f"/profiles/{profile['id']}/folder", json={"folder_id": clip_folder["id"]} + ) + assert r.status_code == 400 + + client.delete(f"/folders/{clip_folder['id']}") + + +def test_item_count_reflects_members(client, voice_folder, profile): + client.put(f"/profiles/{profile['id']}/folder", json={"folder_id": voice_folder["id"]}) + + listed = client.get("/folders", params={"kind": "voice"}).json() + entry = next(f for f in listed if f["id"] == voice_folder["id"]) + assert entry["item_count"] == 1 + + +# ── Deletion preserves contents ────────────────────────────────────── + + +def test_deleting_folder_releases_members_but_keeps_them(client, profile): + folder = client.post("/folders", json={"name": "Temp", "kind": "voice"}).json() + client.put(f"/profiles/{profile['id']}/folder", json={"folder_id": folder["id"]}) + + r = client.delete(f"/folders/{folder['id']}") + assert r.status_code == 200 + assert r.json()["items_released"] == 1 + + # The voice itself must survive, now uncategorised. + survivor = client.get(f"/profiles/{profile['id']}") + assert survivor.status_code == 200 + assert survivor.json()["folder_id"] is None + + +def test_deleting_parent_reparents_children_rather_than_orphaning(client): + grandparent = client.post("/folders", json={"name": "GP", "kind": "generation"}).json() + parent = client.post( + "/folders", json={"name": "P", "kind": "generation", "parent_id": grandparent["id"]} + ).json() + child = client.post( + "/folders", json={"name": "C", "kind": "generation", "parent_id": parent["id"]} + ).json() + + r = client.delete(f"/folders/{parent['id']}") + assert r.json()["folders_reparented"] == 1 + + listed = client.get("/folders", params={"kind": "generation"}).json() + moved = next(f for f in listed if f["id"] == child["id"]) + assert moved["parent_id"] == grandparent["id"] + + client.delete(f"/folders/{child['id']}") + client.delete(f"/folders/{grandparent['id']}") + + +# ── History filtering ──────────────────────────────────────────────── + + +def test_history_rejects_out_of_range_limit_with_422(client): + """Previously surfaced as a 500 — HistoryQuery was built from raw ints + inside the handler, so its ValidationError escaped as a server error.""" + assert client.get("/history", params={"limit": 500}).status_code == 422 + + +def test_history_accepts_folder_filters(client): + folder = client.post("/folders", json={"name": "F", "kind": "generation"}).json() + + assert client.get("/history", params={"folder_id": folder["id"]}).status_code == 200 + assert client.get("/history", params={"uncategorised_only": True}).status_code == 200 + + client.delete(f"/folders/{folder['id']}") diff --git a/backend/tests/test_profile_duplicate.py b/backend/tests/test_profile_duplicate.py new file mode 100644 index 000000000..fb4d9b267 --- /dev/null +++ b/backend/tests/test_profile_duplicate.py @@ -0,0 +1,294 @@ +""" +Tests for POST /profiles/{id}/duplicate. + +The point of the endpoint is that it is *not* an export/import round-trip. +export_import.py writes only name/description/language into its manifest, so +importing an exported profile silently drops personality, effects_chain, +default_engine and the preset/designed fields -- and export refuses profiles +with no samples, which is every preset voice. These tests pin the fields a +round-trip would have lost. + +Usage: + python -m pytest backend/tests/test_profile_duplicate.py -v +""" + +import os +import sys +import tempfile +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +_DATA_DIR = tempfile.mkdtemp(prefix="voicebox-duplicate-test-") +os.environ["VOICEBOX_DATA_DIR"] = _DATA_DIR + +from starlette.testclient import TestClient # noqa: E402 + +from backend.app import app # noqa: E402 + +EFFECTS = [{"type": "reverb", "params": {"room_size": 0.4}}] +PERSONALITY = "Speaks in short, dry sentences. Never uses exclamation marks." + + +@pytest.fixture(scope="module") +def client(): + with TestClient(app) as c: + yield c + + +def _delete(client, profile_id: str) -> None: + client.delete(f"/profiles/{profile_id}") + + +@pytest.fixture +def preset_profile(client): + """A preset (Kokoro) profile: rich metadata, zero samples.""" + r = client.post( + "/profiles", + json={ + "name": "Duplicate Source", + "description": "original description", + "language": "en", + "voice_type": "preset", + "preset_engine": "kokoro", + "preset_voice_id": "af_bella", + "personality": PERSONALITY, + }, + ) + assert r.status_code == 200, r.text + created = r.json() + yield created + _delete(client, created["id"]) + + +def test_duplicate_preserves_personality(client, preset_profile): + """The field an export/import round-trip loses most damagingly.""" + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert copy["personality"] == PERSONALITY + finally: + _delete(client, copy["id"]) + + +def test_duplicate_preserves_preset_fields(client, preset_profile): + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert copy["voice_type"] == "preset" + assert copy["preset_engine"] == "kokoro" + assert copy["preset_voice_id"] == "af_bella" + # default_engine is auto-derived from preset_engine on create. + assert copy["default_engine"] == "kokoro" + finally: + _delete(client, copy["id"]) + + +def test_duplicate_works_for_profiles_without_samples(client, preset_profile): + """Export raises ValueError for a sample-less profile, so this case is + unreachable via export/import.""" + r = client.post(f"/profiles/{preset_profile['id']}/duplicate") + assert r.status_code == 200, r.text + try: + assert r.json()["sample_count"] == 0 + finally: + _delete(client, r.json()["id"]) + + +def test_duplicate_preserves_description_and_language(client, preset_profile): + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert copy["description"] == "original description" + assert copy["language"] == "en" + finally: + _delete(client, copy["id"]) + + +def test_duplicate_gets_a_new_id_and_copy_suffixed_name(client, preset_profile): + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert copy["id"] != preset_profile["id"] + assert copy["name"] == "Duplicate Source (copy)" + finally: + _delete(client, copy["id"]) + + +def test_repeated_duplicates_get_distinct_names(client, preset_profile): + """profiles.name is UNIQUE, so the second copy must not collide.""" + first = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + second = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert first["name"] == "Duplicate Source (copy)" + assert second["name"] == "Duplicate Source (copy) (1)" + finally: + _delete(client, first["id"]) + _delete(client, second["id"]) + + +def test_duplicate_accepts_an_explicit_name(client, preset_profile): + copy = client.post( + f"/profiles/{preset_profile['id']}/duplicate", json={"name": "Chosen Name"} + ).json() + try: + assert copy["name"] == "Chosen Name" + finally: + _delete(client, copy["id"]) + + +def test_duplicate_starts_with_no_generations(client, preset_profile): + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert copy["generation_count"] == 0 + finally: + _delete(client, copy["id"]) + + +def test_duplicate_preserves_effects_chain(client, preset_profile): + set_effects = client.put( + f"/profiles/{preset_profile['id']}/effects", json={"effects_chain": EFFECTS} + ) + assert set_effects.status_code == 200, set_effects.text + + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert copy["effects_chain"], "effects chain was dropped by duplicate" + assert copy["effects_chain"][0]["type"] == "reverb" + finally: + _delete(client, copy["id"]) + + +def test_duplicate_inherits_folder(client, preset_profile): + folder = client.post("/folders", json={"name": "Dup Folder", "kind": "voice"}).json() + client.put(f"/profiles/{preset_profile['id']}/folder", json={"folder_id": folder["id"]}) + + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert copy["folder_id"] == folder["id"] + finally: + _delete(client, copy["id"]) + client.delete(f"/folders/{folder['id']}") + + +def test_editing_the_copy_leaves_the_original_untouched(client, preset_profile): + """A duplicate must be independent, not a shared reference.""" + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + client.put( + f"/profiles/{copy['id']}", + json={ + "name": "Edited Copy", + "description": "changed", + "language": "en", + "voice_type": "preset", + "preset_engine": "kokoro", + "preset_voice_id": "af_bella", + "personality": "Totally different.", + }, + ) + original = client.get(f"/profiles/{preset_profile['id']}").json() + assert original["personality"] == PERSONALITY + assert original["description"] == "original description" + finally: + _delete(client, copy["id"]) + + +def test_duplicating_a_missing_profile_is_404(client): + assert client.post("/profiles/does-not-exist/duplicate").status_code == 404 + + +# ── Sample copying ─────────────────────────────────────────────────── + + +def _write_reference_wav(path: Path) -> None: + """A 3s tone: long enough for the 2s minimum, loud enough for the RMS floor.""" + import numpy as np + import soundfile as sf + + sr = 24000 + t = np.linspace(0, 3.0, int(sr * 3.0), endpoint=False) + tone = (0.3 * np.sin(2 * np.pi * 220 * t)).astype("float32") + sf.write(str(path), tone, sr) + + +@pytest.fixture +def cloned_profile_with_sample(client, tmp_path): + r = client.post( + "/profiles", + json={"name": "Cloned Source", "description": "has a sample", "language": "en"}, + ) + assert r.status_code == 200, r.text + created = r.json() + + # Delete unconditionally: if the upload below fails, the profile would + # otherwise survive and collide with the next test's UNIQUE name. + try: + wav = tmp_path / "reference.wav" + _write_reference_wav(wav) + with wav.open("rb") as fh: + upload = client.post( + f"/profiles/{created['id']}/samples", + files={"file": ("reference.wav", fh, "audio/wav")}, + data={"reference_text": "This is the reference transcript."}, + ) + assert upload.status_code == 200, upload.text + + yield created + finally: + _delete(client, created["id"]) + + +def test_duplicate_copies_samples(client, cloned_profile_with_sample): + copy = client.post(f"/profiles/{cloned_profile_with_sample['id']}/duplicate").json() + try: + assert copy["sample_count"] == 1 + + original_samples = client.get( + f"/profiles/{cloned_profile_with_sample['id']}/samples" + ).json() + copy_samples = client.get(f"/profiles/{copy['id']}/samples").json() + + assert copy_samples[0]["reference_text"] == original_samples[0]["reference_text"] + # Distinct rows pointing at distinct files — not a shared reference. + assert copy_samples[0]["id"] != original_samples[0]["id"] + assert copy_samples[0]["audio_path"] != original_samples[0]["audio_path"] + finally: + _delete(client, copy["id"]) + + +def test_duplicated_sample_audio_is_byte_identical(client, cloned_profile_with_sample): + """Copied rather than re-encoded, so the clone sounds like the original.""" + from backend import config + + copy = client.post(f"/profiles/{cloned_profile_with_sample['id']}/duplicate").json() + try: + original_samples = client.get( + f"/profiles/{cloned_profile_with_sample['id']}/samples" + ).json() + copy_samples = client.get(f"/profiles/{copy['id']}/samples").json() + + original_bytes = config.resolve_storage_path( + original_samples[0]["audio_path"] + ).read_bytes() + copy_bytes = config.resolve_storage_path( + copy_samples[0]["audio_path"] + ).read_bytes() + + assert copy_bytes == original_bytes + finally: + _delete(client, copy["id"]) + + +def test_deleting_the_copy_leaves_the_originals_audio_intact(client, cloned_profile_with_sample): + """Copied files must be independent — deleting one must not take the + other's audio with it.""" + from backend import config + + copy = client.post(f"/profiles/{cloned_profile_with_sample['id']}/duplicate").json() + original_samples = client.get( + f"/profiles/{cloned_profile_with_sample['id']}/samples" + ).json() + original_audio = config.resolve_storage_path(original_samples[0]["audio_path"]) + + _delete(client, copy["id"]) + + assert original_audio.exists() From 6c1c4ee9950bb715b2963c5730e024f6bb1312b4 Mon Sep 17 00:00:00 2001 From: Lvigentini Date: Thu, 6 Aug 2026 22:27:17 +1000 Subject: [PATCH 03/24] feat(ui): list view, folders, and duplicate in the Generate tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voices as a list ---------------- Two-line rows: name, language and trait badges on the first line, the description on the second — enough to tell apart clones that differ only by a suffix (v2_slower_deeper vs v3_podcast). Cards remain available behind a toggle, remembered across restarts. Row actions sit behind a menu rather than always-visible icon buttons: at list density a row is ~48px, too tight for four buttons beside the text. The selectable area is a real + ) : ( + // Keeps leaf labels aligned with their expandable siblings. + + )} + + + + + + + + + openCreate(folder.id)}> + + {t('folders.clip.newSubfolder')} + + { + setDraftName(folder.name); + setDialog({ mode: 'rename', folder }); + }} + > + + {t('folders.rename')} + + {folder.parent_id && ( + detachFolder.mutate(folder.id)}> + + {t('folders.clip.moveToRoot')} + + )} + + setDialog({ mode: 'delete', folder })} + > + + {t('folders.delete')} + + + + + + {!collapsed && children.map((child) => renderNode(child, depth + 1))} + + ); + }; + + return ( +
+
+ + {t('folders.clip.filterTitle')} + + +
+ + + + {tree.map((node) => renderNode(node, 0))} + + + + setDialog(null)} + > + + + + {dialog?.mode === 'rename' + ? t('folders.renameDialog.title') + : t('folders.newDialog.title')} + + + setDraftName(e.target.value)} + placeholder={t('folders.newDialog.placeholder')} + onKeyDown={(e) => { + if (e.key === 'Enter') submitDialog(); + }} + aria-label={t('folders.newDialog.title')} + /> + + + + + + + + setDialog(null)}> + + + {t('folders.deleteDialog.title')} + + {dialog?.mode === 'delete' && + t('folders.deleteDialog.body', { name: dialog.folder.name })} + + + + + + + + +
+ ); +} diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index aeeae4ece..ea434913a 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -4,6 +4,7 @@ import { AudioLines, Download, FileArchive, + FolderInput, Loader2, MoreHorizontal, Play, @@ -18,6 +19,7 @@ import { useTranslation } from 'react-i18next'; import { AudioBars } from '@/components/AudioBars'; import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor'; +import { type ClipFolderSelection, ClipFolderTree } from '@/components/History/ClipFolderTree'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -31,6 +33,11 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { @@ -45,6 +52,7 @@ import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; import type { EffectConfig, GenerationVersionResponse, HistoryResponse } from '@/lib/api/types'; import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; +import { useFolders, useSetGenerationFolder } from '@/lib/hooks/useFolders'; import { useClearFailedGenerations, useDeleteGeneration, @@ -86,6 +94,10 @@ export function HistoryTable() { const { toast } = useToast(); const queryClient = useQueryClient(); + const [folderSelection, setFolderSelection] = useState({ kind: 'all' }); + const { data: clipFolders } = useFolders('generation'); + const setGenerationFolder = useSetGenerationFolder(); + const { data: historyData, isLoading, @@ -93,6 +105,8 @@ export function HistoryTable() { } = useHistory({ limit, offset: page * limit, + folder_id: folderSelection.kind === 'folder' ? folderSelection.folderId : undefined, + uncategorised_only: folderSelection.kind === 'uncategorised' || undefined, }); const deleteGeneration = useDeleteGeneration(); @@ -144,6 +158,15 @@ export function HistoryTable() { } }, [historyData, page]); + // Changing the folder filter changes what page 0 even means, so the + // accumulated pages have to be dropped — otherwise clips from the previous + // filter stay on screen underneath the new results. + // biome-ignore lint/correctness/useExhaustiveDependencies: folderSelection is the trigger, not a value the effect reads + useEffect(() => { + setPage(0); + setAllHistory([]); + }, [folderSelection]); + // Reset to page 0 when deletions, imports, or generation completions occur const pendingCount = useGenerationStore((state) => state.pendingGenerationIds.size); const prevPendingCountRef = useRef(pendingCount); @@ -430,9 +453,13 @@ export function HistoryTable() { return (
+ {/* Rendered outside the empty-state branch below: filtering to an empty + folder must not remove the only control that can clear the filter. */} + + {history.length === 0 ? (
- {t('history.empty')} + {folderSelection.kind === 'all' ? t('history.empty') : t('folders.clip.emptyFilter')}
) : ( <> @@ -678,6 +705,45 @@ export function HistoryTable() { {t('history.actions.regenerate')} + + + + {t('folders.clip.moveTo')} + + + {t('folders.clip.label')} + + + setGenerationFolder.mutate({ + generationId: gen.id, + folderId: null, + }) + } + > + {t('folders.uncategorised')} + + {(clipFolders ?? []).map((folder) => ( + + setGenerationFolder.mutate({ + generationId: gen.id, + folderId: folder.id, + }) + } + > + {folder.name} + + ))} + {(clipFolders ?? []).length === 0 && ( + {t('folders.none')} + )} + + + handleDeleteClick(gen.id, gen.profile_name)} disabled={deleteGeneration.isPending} diff --git a/app/src/components/VoiceProfiles/FolderSection.tsx b/app/src/components/VoiceProfiles/FolderSection.tsx new file mode 100644 index 000000000..31ba98827 --- /dev/null +++ b/app/src/components/VoiceProfiles/FolderSection.tsx @@ -0,0 +1,166 @@ +import { ChevronDown, ChevronRight, MoreHorizontal, Pencil, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; + +interface FolderSectionProps { + /** Null renders the Uncategorised bucket, which has no menu and no id. */ + folderId: string | null; + name: string; + count: number; + collapsed: boolean; + onToggle: () => void; + onRename?: (name: string) => void; + onDelete?: () => void; + children: React.ReactNode; +} + +/** + * A collapsible group header with its members underneath. + * + * The delete copy is explicit that only the folder goes — the server + * releases members to Uncategorised rather than cascading, and a header + * that just says "Delete" over a group of voices reads far more alarming + * than what actually happens. + */ +export function FolderSection({ + folderId, + name, + count, + collapsed, + onToggle, + onRename, + onDelete, + children, +}: FolderSectionProps) { + const { t } = useTranslation(); + const [renameOpen, setRenameOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [draftName, setDraftName] = useState(name); + + const Chevron = collapsed ? ChevronRight : ChevronDown; + + const submitRename = () => { + const trimmed = draftName.trim(); + if (trimmed && trimmed !== name) onRename?.(trimmed); + setRenameOpen(false); + }; + + return ( +
+
+ + + {folderId && ( + + + + + + { + setDraftName(name); + setRenameOpen(true); + }} + > + + {t('folders.rename')} + + + setDeleteOpen(true)} + > + + {t('folders.delete')} + + + + )} +
+ + {!collapsed &&
{children}
} + + + + + {t('folders.renameDialog.title')} + + setDraftName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') submitRename(); + }} + aria-label={t('folders.renameDialog.title')} + /> + + + + + + + + + + + {t('folders.deleteDialog.title')} + {t('folders.deleteDialog.body', { name })} + + + + + + + +
+ ); +} diff --git a/app/src/components/VoiceProfiles/ProfileCard.tsx b/app/src/components/VoiceProfiles/ProfileCard.tsx index e9042a571..7c905a081 100644 --- a/app/src/components/VoiceProfiles/ProfileCard.tsx +++ b/app/src/components/VoiceProfiles/ProfileCard.tsx @@ -1,4 +1,4 @@ -import { Download, Edit, Sparkles, Trash2, Wand2 } from 'lucide-react'; +import { Copy, Download, Edit, Sparkles, Trash2, Wand2 } from 'lucide-react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Badge } from '@/components/ui/badge'; @@ -14,7 +14,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import type { VoiceProfileResponse } from '@/lib/api/types'; -import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles'; +import { useDeleteProfile, useDuplicateProfile, useExportProfile } from '@/lib/hooks/useProfiles'; import { cn } from '@/lib/utils/cn'; import { useUIStore } from '@/stores/uiStore'; @@ -35,6 +35,7 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) { const deleteProfile = useDeleteProfile(); const exportProfile = useExportProfile(); + const duplicateProfile = useDuplicateProfile(); const setEditingProfileId = useUIStore((state) => state.setEditingProfileId); const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen); const selectedProfileId = useUIStore((state) => state.selectedProfileId); @@ -126,11 +127,18 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) { {profile.effects_chain && profile.effects_chain.length > 0 && ( )} - {profile.personality?.trim() && ( - - )} + {profile.personality?.trim() && }
+ { + e.stopPropagation(); + duplicateProfile.mutate({ profileId: profile.id }); + }} + disabled={duplicateProfile.isPending} + aria-label={t('profiles.card.duplicate')} + /> state.setProfileDialogOpen); const selectedEngine = useUIStore((state) => state.selectedEngine); const selectedProfileId = useUIStore((state) => state.selectedProfileId); + const viewMode = useUIStore((state) => state.voiceViewMode); + const setViewMode = useUIStore((state) => state.setVoiceViewMode); + const collapsedIds = useUIStore((state) => state.collapsedFolderIds.voice); + const toggleCollapsed = useUIStore((state) => state.toggleFolderCollapsed); + + const createFolder = useCreateFolder('voice'); + const updateFolder = useUpdateFolder('voice'); + const deleteFolder = useDeleteFolder('voice'); + + const [newFolderOpen, setNewFolderOpen] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + const cardRefs = useRef>(new Map()); // Scroll to the selected profile after engine/sort changes + // biome-ignore lint/correctness/useExhaustiveDependencies: selectedEngine reorders the list, so it must re-trigger the scroll even though the effect never reads it useEffect(() => { if (!selectedProfileId) return; let timeoutId: ReturnType | null = null; @@ -40,6 +76,42 @@ export function ProfileList() { }; }, [selectedProfileId, selectedEngine]); + const allProfiles = useMemo(() => profiles || [], [profiles]); + const voiceFolders = useMemo(() => folders || [], [folders]); + const isPresetEngine = PRESET_ENGINES.has(selectedEngine); + + /** Whether a profile is supported by the currently selected engine. */ + const isSupported = useMemo( + () => (p: VoiceProfileResponse) => + isPresetEngine + ? p.voice_type === 'preset' && p.preset_engine === selectedEngine + : p.voice_type !== 'preset', + [isPresetEngine, selectedEngine], + ); + + // Sort so supported profiles come first, then bucket by folder. Sorting + // before grouping keeps the supported-first ordering inside each folder. + const grouped = useMemo(() => { + const sorted = [...allProfiles].sort( + (a, b) => (isSupported(a) ? 0 : 1) - (isSupported(b) ? 0 : 1), + ); + + const buckets = new Map(); + buckets.set(UNCATEGORISED, []); + for (const folder of voiceFolders) buckets.set(folder.id, []); + + for (const profile of sorted) { + // A folder_id can outlive its folder if another window deleted it + // between renders — fall back rather than dropping the voice. + const key = + profile.folder_id && buckets.has(profile.folder_id) ? profile.folder_id : UNCATEGORISED; + buckets.get(key)?.push(profile); + } + return buckets; + }, [allProfiles, voiceFolders, isSupported]); + + const hasUnsupported = allProfiles.some((p) => !isSupported(p)); + if (isLoading) { return null; } @@ -54,21 +126,52 @@ export function ProfileList() { ); } - const allProfiles = profiles || []; - const isPresetEngine = PRESET_ENGINES.has(selectedEngine); + const renderProfiles = (items: VoiceProfileResponse[]) => { + if (items.length === 0) { + return ( +

{t('folders.emptyFolder')}

+ ); + } - /** Whether a profile is supported by the currently selected engine. */ - const isSupported = (p: (typeof allProfiles)[number]) => - isPresetEngine - ? p.voice_type === 'preset' && p.preset_engine === selectedEngine - : p.voice_type !== 'preset'; - - // Sort so supported profiles come first - const sortedProfiles = [...allProfiles].sort( - (a, b) => (isSupported(a) ? 0 : 1) - (isSupported(b) ? 0 : 1), - ); + return ( +
+ {items.map((profile) => ( +
{ + if (el) cardRefs.current.set(profile.id, el); + else cardRefs.current.delete(profile.id); + }} + > + {viewMode === 'card' ? ( + + ) : ( + + )} +
+ ))} +
+ ); + }; - const hasUnsupported = sortedProfiles.some((p) => !isSupported(p)); + const submitNewFolder = () => { + const trimmed = newFolderName.trim(); + if (!trimmed) return; + createFolder.mutate({ name: trimmed }); + setNewFolderName(''); + setNewFolderOpen(false); + }; return (
@@ -85,21 +188,67 @@ export function ProfileList() { ) : ( -
- {sortedProfiles.map((profile) => ( -
{ - if (el) cardRefs.current.set(profile.id, el); - else cardRefs.current.delete(profile.id); - }} +
+
+
+ + {t('folders.new')} + + +
+ + {voiceFolders.map((folder) => ( + toggleCollapsed('voice', folder.id)} + onRename={(name) => updateFolder.mutate({ folderId: folder.id, data: { name } })} + onDelete={() => deleteFolder.mutate(folder.id)} + > + {renderProfiles(grouped.get(folder.id) ?? [])} + ))} + + {/* Only worth a header once folders exist to contrast it with. */} + {voiceFolders.length > 0 ? ( + toggleCollapsed('voice', UNCATEGORISED)} + > + {renderProfiles(grouped.get(UNCATEGORISED) ?? [])} + + ) : ( + renderProfiles(grouped.get(UNCATEGORISED) ?? []) + )} + {hasUnsupported && ( -
+
{t('profiles.list.unsupportedNote')}
@@ -108,6 +257,31 @@ export function ProfileList() { )}
+ + + + {t('folders.newDialog.title')} + + setNewFolderName(e.target.value)} + placeholder={t('folders.newDialog.placeholder')} + onKeyDown={(e) => { + if (e.key === 'Enter') submitNewFolder(); + }} + aria-label={t('folders.newDialog.title')} + /> + + + + + + +
); diff --git a/app/src/components/VoiceProfiles/ProfileRow.tsx b/app/src/components/VoiceProfiles/ProfileRow.tsx new file mode 100644 index 000000000..268ad25cc --- /dev/null +++ b/app/src/components/VoiceProfiles/ProfileRow.tsx @@ -0,0 +1,274 @@ +import { + Copy, + Download, + Edit, + FolderInput, + MoreHorizontal, + Sparkles, + Trash2, + Wand2, +} from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { useToast } from '@/components/ui/use-toast'; +import type { FolderResponse, VoiceProfileResponse } from '@/lib/api/types'; +import { useSetProfileFolder } from '@/lib/hooks/useFolders'; +import { useDeleteProfile, useDuplicateProfile, useExportProfile } from '@/lib/hooks/useProfiles'; +import { cn } from '@/lib/utils/cn'; +import { useUIStore } from '@/stores/uiStore'; + +/** Human-readable display names for preset engine badges. */ +const ENGINE_DISPLAY_NAMES: Record = { + kokoro: 'Kokoro', + qwen_custom_voice: 'CustomVoice', +}; + +interface ProfileRowProps { + profile: VoiceProfileResponse; + /** Not usable by the selected engine — dimmed but still selectable. */ + disabled?: boolean; + /** Voice folders, for the "Move to" submenu. */ + folders: FolderResponse[]; +} + +/** + * One voice as a two-line row: name, language and trait icons on the first + * line, description on the second. + * + * Row actions live behind a menu rather than always-visible buttons — + * at list density a row is ~48px tall, too tight for four icon buttons + * without crowding the text. + */ +export function ProfileRow({ profile, disabled, folders }: ProfileRowProps) { + const { t } = useTranslation(); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const { toast } = useToast(); + + const deleteProfile = useDeleteProfile(); + const exportProfile = useExportProfile(); + const duplicateProfile = useDuplicateProfile(); + const setProfileFolder = useSetProfileFolder(); + + const setEditingProfileId = useUIStore((state) => state.setEditingProfileId); + const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen); + const selectedProfileId = useUIStore((state) => state.selectedProfileId); + const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId); + + const isSelected = selectedProfileId === profile.id; + + const handleSelect = () => { + // Re-selecting a disabled voice re-fires the selection so the generate + // form can surface its unsupported-engine hint again. + if (disabled && isSelected) { + setSelectedProfileId(null); + setTimeout(() => setSelectedProfileId(profile.id), 0); + return; + } + setSelectedProfileId(isSelected ? null : profile.id); + }; + + const handleDuplicate = () => { + duplicateProfile.mutate( + { profileId: profile.id }, + { + onSuccess: (copy) => { + toast({ + title: t('profiles.duplicate.successTitle'), + description: t('profiles.duplicate.successDescription', { name: copy.name }), + }); + }, + onError: (error) => { + toast({ + title: t('profiles.duplicate.failedTitle'), + description: error.message, + variant: 'destructive', + }); + }, + }, + ); + }; + + const selectLabel = t( + isSelected ? 'profiles.card.selectLabelSelected' : 'profiles.card.selectLabel', + { name: profile.name, language: profile.language }, + ); + + return ( + <> + {/* The row is a plain container rather than role="button": the actions + menu is itself a button, and nesting interactive elements is invalid. + The selectable area is a real + + + + + + e.stopPropagation()}> + { + setEditingProfileId(profile.id); + setProfileDialogOpen(true); + }} + > + + {t('profiles.card.edit')} + + + + {t('profiles.card.duplicate')} + + + + + + {t('profiles.row.moveTo')} + + + {t('folders.voice.label')} + + setProfileFolder.mutate({ profileId: profile.id, folderId: null })} + > + {t('folders.uncategorised')} + + {folders.map((folder) => ( + + setProfileFolder.mutate({ profileId: profile.id, folderId: folder.id }) + } + > + {folder.name} + + ))} + {folders.length === 0 && ( + {t('folders.none')} + )} + + + + + exportProfile.mutate(profile.id)} + disabled={exportProfile.isPending} + > + + {t('profiles.card.export')} + + + setDeleteDialogOpen(true)} + > + + {t('profiles.card.delete')} + + + +
+ + + + + {t('profiles.deleteDialog.title')} + + {t('profiles.deleteDialog.body', { name: profile.name })} + + + + + + + + + + ); +} diff --git a/app/src/i18n/locales/en/translation.json b/app/src/i18n/locales/en/translation.json index 7f96d9d05..be830ae55 100644 --- a/app/src/i18n/locales/en/translation.json +++ b/app/src/i18n/locales/en/translation.json @@ -9,7 +9,8 @@ "loading": "Loading…", "error": "Error", "unknown": "Unknown", - "unknownError": "Unknown error" + "unknownError": "Unknown error", + "create": "Create" }, "nav": { "generate": "Generate", @@ -399,14 +400,28 @@ "export": "Export profile", "edit": "Edit profile", "delete": "Delete profile", + "duplicate": "Duplicate profile", "selectLabel": "{{name}}, {{language}}. Select as voice for generation.", "selectLabelSelected": "{{name}}, {{language}}. Selected as voice for generation." }, + "row": { + "actions": "Actions for {{name}}", + "moveTo": "Move to folder", + "hasEffects": "Has an effects chain", + "hasPersonality": "Has a personality" + }, "list": { "errorLoading": "Error loading profiles: {{message}}", "empty": "No voice profiles yet. Create your first profile to get started.", "createVoice": "Create Voice", - "unsupportedNote": "Only supported voice profiles can be selected for the current model." + "unsupportedNote": "Only supported voice profiles can be selected for the current model.", + "showCards": "Show voices as cards", + "showList": "Show voices as a list" + }, + "duplicate": { + "successTitle": "Voice duplicated", + "successDescription": "Created \"{{name}}\" with the same samples, personality and effects.", + "failedTitle": "Could not duplicate voice" }, "deleteDialog": { "title": "Delete Profile", @@ -414,6 +429,39 @@ "deleting": "Deleting…" } }, + "folders": { + "new": "New folder", + "none": "No folders yet", + "uncategorised": "Uncategorised", + "emptyFolder": "Empty", + "rename": "Rename", + "delete": "Delete folder", + "actions": "Actions for folder {{name}}", + "voice": { + "label": "Voice folders" + }, + "clip": { + "label": "Clip folders", + "newSubfolder": "New subfolder", + "moveTo": "Move to folder", + "moveToRoot": "Move to top level", + "filterTitle": "Folders", + "allClips": "All clips", + "emptyFilter": "No clips in this folder yet." + }, + "newDialog": { + "title": "New folder", + "placeholder": "Folder name" + }, + "renameDialog": { + "title": "Rename folder" + }, + "deleteDialog": { + "title": "Delete folder", + "body": "Delete \"{{name}}\"? Everything inside it is kept — items move to Uncategorised and any subfolders move up one level.", + "confirm": "Delete folder" + } + }, "effects": { "title": "Effects", "newPreset": "New Preset", diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts index f89a17a3f..442ad8214 100644 --- a/app/src/lib/api/client.ts +++ b/app/src/lib/api/client.ts @@ -8,6 +8,10 @@ import type { EffectConfig, EffectPresetCreate, EffectPresetResponse, + FolderCreate, + FolderKind, + FolderResponse, + FolderUpdate, GenerationRequest, GenerationResponse, GenerationVersionResponse, @@ -134,6 +138,76 @@ class ApiClient { }); } + /** + * Copy a voice, including its samples, avatar, personality and effects. + * Not an export/import round-trip — that transfer format drops everything + * except name, description and language. + */ + async duplicateProfile(profileId: string, name?: string): Promise { + return this.request(`/profiles/${profileId}/duplicate`, { + method: 'POST', + body: JSON.stringify(name ? { name } : {}), + }); + } + + // ── Folders ──────────────────────────────────────────────────────── + + async listFolders(kind: FolderKind): Promise { + return this.request(`/folders?kind=${kind}`); + } + + async createFolder(data: FolderCreate): Promise { + return this.request('/folders', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async updateFolder(folderId: string, data: FolderUpdate): Promise { + return this.request(`/folders/${folderId}`, { + method: 'PATCH', + body: JSON.stringify(data), + }); + } + + /** Move a folder back to the root. Separate from updateFolder because a + * null parent_id there is indistinguishable from an omitted field. */ + async detachFolder(folderId: string): Promise { + return this.request(`/folders/${folderId}/detach`, { + method: 'POST', + }); + } + + /** Deletes the folder only — members become uncategorised and child + * folders rise to this folder's parent. */ + async deleteFolder(folderId: string): Promise { + await this.request(`/folders/${folderId}`, { + method: 'DELETE', + }); + } + + /** Pass null to move the voice out of any folder. */ + async setProfileFolder( + profileId: string, + folderId: string | null, + ): Promise { + return this.request(`/profiles/${profileId}/folder`, { + method: 'PUT', + body: JSON.stringify({ folder_id: folderId }), + }); + } + + /** Pass null to move the clip out of any folder. */ + async setGenerationFolder( + generationId: string, + folderId: string | null, + ): Promise<{ id: string; folder_id: string | null }> { + return this.request(`/history/${generationId}/folder`, { + method: 'PUT', + body: JSON.stringify({ folder_id: folderId }), + }); + } + // ── Personality-driven text generation ───────────────────────────── // Compose produces a fresh in-character utterance the UI drops into // the generate textarea. Rewrite now happens server-side inside @@ -301,6 +375,10 @@ class ApiClient { const params = new URLSearchParams(); if (query?.profile_id) params.append('profile_id', query.profile_id); if (query?.search) params.append('search', query.search); + if (query?.folder_id) params.append('folder_id', query.folder_id); + if (query?.uncategorised_only) params.append('uncategorised_only', 'true'); + // Server-side default is true, so only the opt-out needs sending. + if (query?.include_subfolders === false) params.append('include_subfolders', 'false'); if (query?.limit) params.append('limit', query.limit.toString()); if (query?.offset) params.append('offset', query.offset.toString()); diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts index d360ed1c4..8115e10c4 100644 --- a/app/src/lib/api/types.ts +++ b/app/src/lib/api/types.ts @@ -29,12 +29,42 @@ export interface VoiceProfileResponse { design_prompt?: string; default_engine?: string; personality?: string | null; + /** null / undefined means the voice sits in the Uncategorised bucket. */ + folder_id?: string | null; generation_count: number; sample_count: number; created_at: string; updated_at: string; } +/** What a folder groups. Voice folders are flat; clip folders nest. */ +export type FolderKind = 'voice' | 'generation'; + +export interface FolderResponse { + id: string; + name: string; + kind: FolderKind; + /** Always null for voice folders. */ + parent_id?: string | null; + position: number; + /** Direct members only — a parent does not count its children's items. */ + item_count: number; + created_at: string; + updated_at: string; +} + +export interface FolderCreate { + name: string; + kind: FolderKind; + parent_id?: string | null; +} + +export interface FolderUpdate { + name?: string; + parent_id?: string; + position?: number; +} + /** Response returned by /profiles/{id}/compose. */ export interface PersonalityTextResponse { text: string; @@ -121,6 +151,15 @@ export interface GenerationResponse { export interface HistoryQuery { profile_id?: string; search?: string; + /** Show only this folder's clips. Ignored when uncategorised_only is set. */ + folder_id?: string; + /** + * Show only clips in no folder at all. Distinct from an absent folder_id, + * which means "no folder filter" rather than "the Uncategorised bucket". + */ + uncategorised_only?: boolean; + /** Whether folder_id also matches clips in that folder's descendants. */ + include_subfolders?: boolean; limit?: number; offset?: number; } @@ -129,6 +168,8 @@ export interface HistoryResponse extends GenerationResponse { profile_name: string; versions?: GenerationVersionResponse[]; active_version_id?: string; + /** null / undefined means the clip sits in the Uncategorised bucket. */ + folder_id?: string | null; } export interface HistoryListResponse { diff --git a/app/src/lib/hooks/useFolders.ts b/app/src/lib/hooks/useFolders.ts new file mode 100644 index 000000000..254fb378c --- /dev/null +++ b/app/src/lib/hooks/useFolders.ts @@ -0,0 +1,96 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api/client'; +import type { FolderKind, FolderUpdate } from '@/lib/api/types'; + +/** + * Folders for voices and clips. + * + * Both kinds live in one table server-side, so every query is keyed by kind + * — otherwise the voice panel and the clip panel would evict each other's + * cache entry on every mutation. + */ + +export function useFolders(kind: FolderKind) { + return useQuery({ + queryKey: ['folders', kind], + queryFn: () => apiClient.listFolders(kind), + }); +} + +export function useCreateFolder(kind: FolderKind) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ name, parentId }: { name: string; parentId?: string | null }) => + apiClient.createFolder({ name, kind, parent_id: parentId ?? null }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['folders', kind] }); + }, + }); +} + +export function useUpdateFolder(kind: FolderKind) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ folderId, data }: { folderId: string; data: FolderUpdate }) => + apiClient.updateFolder(folderId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['folders', kind] }); + }, + }); +} + +export function useDetachFolder(kind: FolderKind) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (folderId: string) => apiClient.detachFolder(folderId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['folders', kind] }); + }, + }); +} + +export function useDeleteFolder(kind: FolderKind) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (folderId: string) => apiClient.deleteFolder(folderId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['folders', kind] }); + // Deleting a folder releases its members, so whichever list holds them + // is now stale too. + queryClient.invalidateQueries({ + queryKey: [kind === 'voice' ? 'profiles' : 'history'], + }); + }, + }); +} + +export function useSetProfileFolder() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ profileId, folderId }: { profileId: string; folderId: string | null }) => + apiClient.setProfileFolder(profileId, folderId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['profiles'] }); + // item_count changes on both the old and new folder. + queryClient.invalidateQueries({ queryKey: ['folders', 'voice'] }); + }, + }); +} + +export function useSetGenerationFolder() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ generationId, folderId }: { generationId: string; folderId: string | null }) => + apiClient.setGenerationFolder(generationId, folderId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['history'] }); + queryClient.invalidateQueries({ queryKey: ['folders', 'generation'] }); + }, + }); +} diff --git a/app/src/lib/hooks/useProfiles.ts b/app/src/lib/hooks/useProfiles.ts index f05fd999a..74d8eb261 100644 --- a/app/src/lib/hooks/useProfiles.ts +++ b/app/src/lib/hooks/useProfiles.ts @@ -55,6 +55,25 @@ export function useDeleteProfile() { }); } +/** + * Copy a voice in one step. Unlike export-then-import this keeps the + * personality, effects chain, default engine and preset fields, and works + * for voices with no samples. + */ +export function useDuplicateProfile() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ profileId, name }: { profileId: string; name?: string }) => + apiClient.duplicateProfile(profileId, name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['profiles'] }); + // The copy lands in the source's folder, changing that folder's count. + queryClient.invalidateQueries({ queryKey: ['folders', 'voice'] }); + }, + }); +} + export function useProfileSamples(profileId: string) { return useQuery({ queryKey: ['profiles', profileId, 'samples'], diff --git a/app/src/stores/uiStore.ts b/app/src/stores/uiStore.ts index dfaf6d2ff..11926a05e 100644 --- a/app/src/stores/uiStore.ts +++ b/app/src/stores/uiStore.ts @@ -1,8 +1,12 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import type { FolderKind } from '@/lib/api/types'; export type Theme = 'light' | 'dark' | 'system'; +/** How the Generate tab lists voices. */ +export type VoiceViewMode = 'list' | 'card'; + function resolveTheme(theme: Theme): 'light' | 'dark' { if (theme !== 'system') return theme; if (typeof window === 'undefined') return 'dark'; @@ -58,6 +62,16 @@ interface UIStore { profileFormDraft: ProfileFormDraft | null; setProfileFormDraft: (draft: ProfileFormDraft | null) => void; + // How the Generate tab renders voices + voiceViewMode: VoiceViewMode; + setVoiceViewMode: (mode: VoiceViewMode) => void; + + // Collapsed folder ids, keyed by folder kind. Collapsed rather than + // expanded ids so a newly created folder starts open without having to + // touch this set. + collapsedFolderIds: Record; + toggleFolderCollapsed: (kind: FolderKind, folderId: string) => void; + // Theme theme: Theme; setTheme: (theme: Theme) => void; @@ -89,6 +103,19 @@ export const useUIStore = create()( profileFormDraft: null, setProfileFormDraft: (draft) => set({ profileFormDraft: draft }), + voiceViewMode: 'list', + setVoiceViewMode: (mode) => set({ voiceViewMode: mode }), + + collapsedFolderIds: { voice: [], generation: [] }, + toggleFolderCollapsed: (kind, folderId) => + set((state) => { + const current = state.collapsedFolderIds[kind] ?? []; + const next = current.includes(folderId) + ? current.filter((id) => id !== folderId) + : [...current, folderId]; + return { collapsedFolderIds: { ...state.collapsedFolderIds, [kind]: next } }; + }), + theme: 'system', setTheme: (theme) => { set({ theme }); @@ -100,9 +127,15 @@ export const useUIStore = create()( partialize: (state) => ({ selectedProfileId: state.selectedProfileId, theme: state.theme, + voiceViewMode: state.voiceViewMode, + collapsedFolderIds: state.collapsedFolderIds, }), onRehydrateStorage: () => (state) => { if (state) applyTheme(state.theme); + // Persisted before folders existed, so an older store has no map. + if (state && !state.collapsedFolderIds) { + state.collapsedFolderIds = { voice: [], generation: [] }; + } }, }, ), From 0bcd5ccb6cd6813eeed1d1a727a1efe348bf4e43 Mon Sep 17 00:00:00 2001 From: Lvigentini Date: Fri, 7 Aug 2026 00:10:44 +1000 Subject: [PATCH 04/24] feat(stories): stereo project-rate mixdown, tracks, fades, speed, formats The story mixer flattened every source to 24 kHz mono (`load_audio` passes sr=24000, mono=True) and summed into a 1-D buffer, so an imported music bed came out at 12 kHz Nyquist with its stereo image folded flat. Imported audio also landed on track 0 and was appended after the last clip, so a music file played *after* the narration instead of underneath it. Mixing ------ Decode each source once at its native rate, derive a project rate from what the sources actually are (capped at 48 kHz), and mix into a (2, n) stereo buffer. Each lane renders to its own buffer first, which is what makes ducking possible -- a bed can be attenuated by the *finished* speech lane -- and is where track gain, mute and solo apply. Clip duration now comes from the decoded array rather than generation.duration. A clip pinned to a version of a different length was mistimed, and a NULL duration raised TypeError inside a bare `except: continue`, silently dropping the clip from the export. Tracks ------ New story_tracks table holding per-lane name, volume, mute, solo and an optional duck-under-lane. Keyed by (story_id, index) rather than owning clips: story_items.track stays a plain integer, so drag/move/reorder are untouched and a lane with no row simply mixes at unity gain. Deleting a row resets the lane; it never removes clips. Solo is evaluated globally -- once any lane is soloed the rest are silent, including lanes with no settings row. Treating a missing row as exempt rather than defaults let the un-configured voice lane play through a solo of the bed. Clips ----- Per-clip fade_in_ms/fade_out_ms as positional envelopes, deliberately not EFFECT_REGISTRY entries -- those are pedalboard plugins built as cls(**params), whereas a fade depends on where the clip starts and ends. Overlong fades scale down together instead of clipping, so a short clip stays monotonic. Splitting moves the fade-out to the tail so no dip appears at the seam. Per-clip speed via librosa.effects.time_stretch (phase vocoder, pitch preserved); pedalboard has no time-stretch. Because clips are absolutely positioned, a re-timed clip changes its own length without shifting neighbours, exactly as trim already does. Export formats -------------- MP3, OGG, Opus and FLAC alongside WAV, via the libsndfile already shipped with the app (1.2.2, with LAME/mpg123/Vorbis/Opus/FLAC compiled in). No ffmpeg, no new dependency. Default stays wav. ffmpeg ------ Optional and detected, never required: it adds EBU R128 loudness normalisation, and every path falls back cleanly. It was already a hidden dependency for m4a/aac/webm import, which libsndfile cannot open and which therefore fell through librosa to audioread; those now fail fast with a clear message instead of dying in the decoder. Availability is reported on /health. Co-Authored-By: Claude Opus 5 --- backend/database/migrations.py | 20 ++ backend/database/models.py | 60 +++- backend/models.py | 60 +++- backend/routes/generations.py | 13 + backend/routes/health.py | 2 + backend/routes/stories.py | 92 +++++- backend/services/stories.py | 452 +++++++++++++++++++++----- backend/tests/test_ffmpeg_optional.py | 146 +++++++++ backend/tests/test_story_mixdown.py | 330 +++++++++++++++++++ backend/utils/audio.py | 63 +++- backend/utils/ffmpeg.py | 110 +++++++ 11 files changed, 1255 insertions(+), 93 deletions(-) create mode 100644 backend/tests/test_ffmpeg_optional.py create mode 100644 backend/tests/test_story_mixdown.py create mode 100644 backend/utils/ffmpeg.py diff --git a/backend/database/migrations.py b/backend/database/migrations.py index 149f5a7ca..ab0670e8c 100644 --- a/backend/database/migrations.py +++ b/backend/database/migrations.py @@ -44,6 +44,7 @@ def run_migrations(engine) -> None: _migrate_capture_settings(engine, inspector, tables) _migrate_mcp_bindings(engine, inspector, tables) _migrate_folders(engine, inspector, tables) + _migrate_story_item_audio(engine, inspector, tables) _normalize_storage_paths(engine, tables) @@ -356,3 +357,22 @@ def _migrate_folders(engine, inspector, tables: set[str]) -> None: if "generations" in tables: if "folder_id" not in _get_columns(inspector, "generations"): _add_column(engine, "generations", "folder_id VARCHAR", "folder_id") + + +def _migrate_story_item_audio(engine, inspector, tables: set[str]) -> None: + """Add per-clip fade and speed columns to story_items. + + The ``story_tracks`` table is left to ``Base.metadata.create_all()``, which + runs straight after migrations and creates missing tables; only columns on + pre-existing tables need adding by hand. + """ + if "story_items" not in tables: + return + + columns = _get_columns(inspector, "story_items") + if "fade_in_ms" not in columns: + _add_column(engine, "story_items", "fade_in_ms INTEGER NOT NULL DEFAULT 0", "fade_in_ms") + if "fade_out_ms" not in columns: + _add_column(engine, "story_items", "fade_out_ms INTEGER NOT NULL DEFAULT 0", "fade_out_ms") + if "speed" not in columns: + _add_column(engine, "story_items", "speed FLOAT NOT NULL DEFAULT 1.0", "speed") diff --git a/backend/database/models.py b/backend/database/models.py index fb31fe6bd..bc81f2f13 100644 --- a/backend/database/models.py +++ b/backend/database/models.py @@ -3,7 +3,18 @@ from datetime import datetime import uuid -from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean, JSON +from sqlalchemy import ( + Column, + String, + Integer, + Float, + DateTime, + Text, + ForeignKey, + Boolean, + JSON, + UniqueConstraint, +) from sqlalchemy.ext.declarative import declarative_base from ..utils.capture_chords import ( @@ -117,6 +128,38 @@ class Generation(Base): created_at = Column(DateTime, default=datetime.utcnow) +class StoryTrack(Base): + """Mixer settings for one lane of a story's timeline. + + Keyed by ``(story_id, index)`` where ``index`` is the same integer held in + ``StoryItem.track`` -- this table is metadata *about* a lane, not its + owner. A lane can hold clips with no row here at all, in which case it + mixes at unity gain; rows are created lazily when a lane is first named or + adjusted. Deleting a row therefore only resets the lane to defaults, it + never removes clips. + + Solo is stored per track but evaluated globally at mix time: if any track + in the story is soloed, every non-soloed track is silent. + """ + + __tablename__ = "story_tracks" + __table_args__ = (UniqueConstraint("story_id", "index", name="uq_story_track_index"),) + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + story_id = Column(String, ForeignKey("stories.id"), nullable=False) + index = Column(Integer, nullable=False) + name = Column(String, nullable=True) + volume = Column(Float, nullable=False, default=1.0) + muted = Column(Boolean, nullable=False, default=False) + soloed = Column(Boolean, nullable=False, default=False) + # Lane index whose loudness ducks this one — how a music bed sits under + # narration. NULL disables ducking. Not a FK: it references a lane + # number, same as StoryItem.track. + duck_under_track = Column(Integer, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + class Story(Base): """A story that sequences multiple generations.""" @@ -139,10 +182,25 @@ class StoryItem(Base): generation_id = Column(String, ForeignKey("generations.id"), nullable=False) version_id = Column(String, ForeignKey("generation_versions.id"), nullable=True) start_time_ms = Column(Integer, nullable=False, default=0) + # Lane index. Deliberately a plain integer rather than a FK to + # story_tracks: clips are positioned by (track, start_time_ms) and the + # drag/move/reorder paths all treat the lane as a number. StoryTrack is + # metadata *keyed by* this value, so a lane can hold clips with no track + # row at all. track = Column(Integer, nullable=False, default=0) trim_start_ms = Column(Integer, nullable=False, default=0) trim_end_ms = Column(Integer, nullable=False, default=0) volume = Column(Float, nullable=False, default=1.0) + # Positional envelope over this clip, applied after trim and before the + # track gain. Not a pedalboard effect: EFFECT_REGISTRY entries are DSP + # plugins instantiated as cls(**params), whereas a fade depends on where + # the clip starts and ends. + fade_in_ms = Column(Integer, nullable=False, default=0) + fade_out_ms = Column(Integer, nullable=False, default=0) + # Playback rate. >1 plays faster and therefore *shorter*; because clips + # are absolutely positioned, a re-timed clip changes its own length + # without shifting its neighbours, exactly as trimming already does. + speed = Column(Float, nullable=False, default=1.0) created_at = Column(DateTime, default=datetime.utcnow) diff --git a/backend/models.py b/backend/models.py index 61f9e59da..34d7af51e 100644 --- a/backend/models.py +++ b/backend/models.py @@ -515,6 +515,10 @@ class HealthResponse(BaseModel): backend_variant: Optional[str] = None # Binary variant (cpu, cuda, or rocm) supports_rocm: bool = False # AMD GPU on Windows — the ROCm backend is applicable gpu_compatibility_warning: Optional[str] = None # Warning if GPU arch unsupported + # ffmpeg is optional; when absent, loudness normalisation is unavailable + # and m4a/aac/webm cannot be imported. The UI labels those rather than + # letting them fail silently. + ffmpeg_available: bool = False class DirectoryCheck(BaseModel): @@ -677,6 +681,9 @@ class StoryItemDetail(BaseModel): instruct: Optional[str] engine: Optional[str] = None volume: float = 1.0 + fade_in_ms: int = 0 + fade_out_ms: int = 0 + speed: float = 1.0 generation_created_at: datetime # Versions available for this generation versions: Optional[List["GenerationVersionResponse"]] = None @@ -705,7 +712,10 @@ class StoryItemCreate(BaseModel): generation_id: str start_time_ms: Optional[int] = None # If not provided, will be calculated automatically - track: Optional[int] = 0 # Track number (0 = main track) + # Lane index. None means "decide for me": TTS clips append to track 0, + # imported audio gets its own empty lane so it plays under the narration. + # Must stay nullable to tell "omitted" apart from an explicit track 0. + track: Optional[int] = None class StoryItemUpdateTime(BaseModel): @@ -764,6 +774,54 @@ class StoryItemVolumeUpdate(BaseModel): volume: float = Field(..., ge=0.0, le=2.0) +class StoryItemFadeUpdate(BaseModel): + """Request model for a story item's fade in/out lengths, in milliseconds. + + The mixer scales both down proportionally if together they exceed the + clip, so no cross-field validation is needed here. + """ + + fade_in_ms: int = Field(..., ge=0, le=60000) + fade_out_ms: int = Field(..., ge=0, le=60000) + + +class StoryItemSpeedUpdate(BaseModel): + """Request model for a story item's playback rate. + + Above 1.0 plays faster and therefore shorter. Bounded because the phase + vocoder smears badly on speech outside roughly half to double speed. + """ + + speed: float = Field(..., ge=0.25, le=4.0) + + +class StoryTrackUpsert(BaseModel): + """Request model for creating or updating a lane's mixer settings.""" + + name: Optional[str] = Field(None, max_length=100) + volume: float = Field(default=1.0, ge=0.0, le=2.0) + muted: bool = False + soloed: bool = False + # Lane index whose loudness ducks this one; null disables ducking. + duck_under_track: Optional[int] = None + + +class StoryTrackResponse(BaseModel): + """Response model for a lane's mixer settings.""" + + id: str + story_id: str + index: int + name: Optional[str] = None + volume: float = 1.0 + muted: bool = False + soloed: bool = False + duck_under_track: Optional[int] = None + + class Config: + from_attributes = True + + class EffectConfig(BaseModel): """A single effect in an effects chain.""" diff --git a/backend/routes/generations.py b/backend/routes/generations.py index fbbeece67..7ebc5c0de 100644 --- a/backend/routes/generations.py +++ b/backend/routes/generations.py @@ -14,6 +14,7 @@ from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db from ..services.generation import run_generation from ..services.task_queue import cancel_generation as cancel_generation_job, enqueue_generation +from ..utils import ffmpeg from ..utils.audio import load_audio from ..utils.tasks import get_task_manager @@ -431,6 +432,18 @@ async def import_audio( detail=f"Unsupported audio format '{suffix}'. Allowed: {sorted(IMPORT_AUDIO_EXTENSIONS)}", ) + # libsndfile cannot open these, so librosa falls through to audioread, + # which shells out to ffmpeg. Without it the decode fails much later with + # an opaque error, so say so up front. + if ffmpeg.requires_ffmpeg(suffix) and not ffmpeg.is_available(): + raise HTTPException( + status_code=400, + detail=( + f"Importing '{suffix}' files needs ffmpeg, which was not found on PATH. " + "Install ffmpeg, or convert the file to WAV, FLAC, OGG or MP3 first." + ), + ) + chunks: list[bytes] = [] total = 0 while True: diff --git a/backend/routes/health.py b/backend/routes/health.py index 1568455dc..5f98d2005 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -13,6 +13,7 @@ from .. import config, models from ..services import tts from ..database import get_db +from ..utils import ffmpeg from ..utils.platform_detect import get_backend_type, is_amd_gpu_windows router = APIRouter() @@ -188,6 +189,7 @@ async def health(): backend_variant=os.environ.get("VOICEBOX_BACKEND_VARIANT", default_variant), supports_rocm=is_amd_gpu_windows(), gpu_compatibility_warning=gpu_compat_warning, + ffmpeg_available=ffmpeg.is_available(), ) diff --git a/backend/routes/stories.py b/backend/routes/stories.py index 73757d34f..4cdd41b33 100644 --- a/backend/routes/stories.py +++ b/backend/routes/stories.py @@ -10,6 +10,8 @@ from ..services import stories from ..app import safe_content_disposition from ..database import get_db +from ..utils import ffmpeg +from ..utils.audio import EXPORT_FORMATS router = APIRouter() @@ -165,6 +167,66 @@ async def update_story_item_volume( return item +@router.put("/stories/{story_id}/items/{item_id}/fades", response_model=models.StoryItemDetail) +async def update_story_item_fades( + story_id: str, + item_id: str, + data: models.StoryItemFadeUpdate, + db: Session = Depends(get_db), +): + """Set a story item's fade in/out lengths (ms).""" + item = await stories.update_story_item_fades(story_id, item_id, data, db) + if item is None: + raise HTTPException(status_code=404, detail="Story item not found") + return item + + +@router.put("/stories/{story_id}/items/{item_id}/speed", response_model=models.StoryItemDetail) +async def update_story_item_speed( + story_id: str, + item_id: str, + data: models.StoryItemSpeedUpdate, + db: Session = Depends(get_db), +): + """Set a story item's playback rate (pitch-preserving).""" + item = await stories.update_story_item_speed(story_id, item_id, data, db) + if item is None: + raise HTTPException(status_code=404, detail="Story item not found") + return item + + +# ── Track mixer settings ───────────────────────────────────────────── + + +@router.get("/stories/{story_id}/tracks", response_model=list[models.StoryTrackResponse]) +async def list_story_tracks(story_id: str, db: Session = Depends(get_db)): + """Mixer settings for lanes that have them; others render at unity gain.""" + return await stories.list_story_tracks(story_id, db) + + +@router.put("/stories/{story_id}/tracks/{index}", response_model=models.StoryTrackResponse) +async def upsert_story_track( + story_id: str, + index: int, + data: models.StoryTrackUpsert, + db: Session = Depends(get_db), +): + """Create or update one lane's mixer settings.""" + track = await stories.upsert_story_track(story_id, index, data, db) + if track is None: + raise HTTPException(status_code=404, detail="Story not found") + return track + + +@router.delete("/stories/{story_id}/tracks/{index}") +async def delete_story_track(story_id: str, index: int, db: Session = Depends(get_db)): + """Reset a lane to defaults. Clips on the lane are kept.""" + ok = await stories.delete_story_track(story_id, index, db) + if not ok: + raise HTTPException(status_code=404, detail="Track settings not found") + return {"deleted": index} + + @router.post("/stories/{story_id}/items/{item_id}/split", response_model=list[models.StoryItemDetail]) async def split_story_item( story_id: str, @@ -209,26 +271,48 @@ async def set_story_item_version( @router.get("/stories/{story_id}/export-audio") async def export_story_audio( story_id: str, + format: str = "wav", + normalize_loudness: bool = False, db: Session = Depends(get_db), ): - """Export story as single mixed audio file.""" + """Export story as a single mixed audio file. + + ``format`` defaults to wav so existing callers are unaffected; every + supported container is handled by the bundled libsndfile, no ffmpeg. + + ``normalize_loudness`` applies EBU R128 normalisation and needs ffmpeg. It + is a no-op when ffmpeg is absent rather than an error — the export still + succeeds with the mixer's own peak normalisation. + """ + spec = EXPORT_FORMATS.get(format.lower()) + if spec is None: + raise HTTPException( + status_code=400, + detail=f"Unsupported export format '{format}'. Supported: {sorted(EXPORT_FORMATS)}", + ) + try: story = db.query(database.Story).filter_by(id=story_id).first() if not story: raise HTTPException(status_code=404, detail="Story not found") - audio_bytes = await stories.export_story_audio(story_id, db) + audio_bytes = await stories.export_story_audio(story_id, db, fmt=format.lower()) if not audio_bytes: raise HTTPException(status_code=400, detail="Story has no audio items") + if normalize_loudness: + normalized = ffmpeg.normalize_loudness(audio_bytes, suffix=spec["ext"]) + if normalized is not None: + audio_bytes = normalized + safe_name = "".join(c for c in story.name if c.isalnum() or c in (" ", "-", "_")).strip() if not safe_name: safe_name = "story" - filename = f"{safe_name}.wav" + filename = f"{safe_name}{spec['ext']}" return StreamingResponse( io.BytesIO(audio_bytes), - media_type="audio/wav", + media_type=spec["mime"], headers={"Content-Disposition": safe_content_disposition("attachment", filename)}, ) except HTTPException: diff --git a/backend/services/stories.py b/backend/services/stories.py index 44ae1cdc1..1fcadb177 100644 --- a/backend/services/stories.py +++ b/backend/services/stories.py @@ -4,6 +4,7 @@ from typing import List, Optional from datetime import datetime +import logging import uuid import tempfile from pathlib import Path @@ -21,8 +22,12 @@ StoryItemMove, StoryItemTrim, StoryItemVolumeUpdate, + StoryItemFadeUpdate, + StoryItemSpeedUpdate, StoryItemSplit, StoryItemVersionUpdate, + StoryTrackResponse, + StoryTrackUpsert, ) from ..database import ( Story as DBStory, @@ -30,10 +35,22 @@ Generation as DBGeneration, VoiceProfile as DBVoiceProfile, ) +from ..database.models import StoryTrack as DBStoryTrack from .history import _get_versions_for_generation -from ..utils.audio import load_audio, save_audio +from ..utils.audio import encode_audio, load_audio, save_audio +import librosa import numpy as np +# Mixdown never exceeds this even if a source is higher — 48 kHz is the +# practical ceiling for delivery, and resampling a 96 kHz bed up there costs +# memory for no audible gain. +MAX_PROJECT_SAMPLE_RATE = 48000 + +# Used when a story's sources give us nothing to go on (all unreadable). +FALLBACK_SAMPLE_RATE = 24000 + +logger = logging.getLogger(__name__) + def _build_item_detail( item: DBStoryItem, @@ -72,6 +89,9 @@ def _build_item_detail( instruct=generation.instruct, engine=generation.engine, volume=getattr(item, "volume", 1.0), + fade_in_ms=getattr(item, "fade_in_ms", 0) or 0, + fade_out_ms=getattr(item, "fade_out_ms", 0) or 0, + speed=getattr(item, "speed", 1.0) or 1.0, generation_created_at=generation.created_at, versions=versions, active_version_id=active_version_id, @@ -279,12 +299,25 @@ async def add_item_to_story( profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first() return _build_item_detail(existing, generation, profile.name if profile else "Unknown", db) - # Get track from data or default to 0 - track = data.track if data.track is not None else 0 + # Imported audio is a bed, not another line of dialogue: default it to its + # own empty lane starting at zero so it plays *under* the narration. + # Appending it to track 0 like a TTS clip put the music after the voice, + # which is never what someone dropping in a music file wants. + profile_for_default = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first() + is_imported = getattr(profile_for_default, "voice_type", None) == "import" + + if data.track is not None: + track = data.track + elif is_imported: + track = _next_free_track(story_id, db) + else: + track = 0 # Calculate start_time_ms if not provided if data.start_time_ms is not None: start_time_ms = data.start_time_ms + elif is_imported: + start_time_ms = 0 else: existing_items = ( db.query(DBStoryItem, DBGeneration) @@ -512,6 +545,139 @@ async def update_story_item_volume( return _build_item_detail(item, generation, profile.name if profile else "Unknown", db) +def _next_free_track(story_id: str, db: Session) -> int: + """Lowest lane index at or above 0 holding no clips. + + Lanes are sparse integers rather than a dense list, and negative indices + are legitimate (the editor shows [1, 0, -1] by default), so this scans + upward from 0 rather than taking a max. + """ + used = {row[0] for row in db.query(DBStoryItem.track).filter_by(story_id=story_id).distinct()} + index = 0 + while index in used: + index += 1 + return index + + +async def _update_story_item_fields( + story_id: str, + item_id: str, + db: Session, + **fields, +) -> Optional[StoryItemDetail]: + """Set fields on a story item and return the refreshed detail. + + Shared by the fade and speed endpoints, which differ only in what they + assign — the lookup, story timestamp bump and detail rebuild are identical. + """ + item = db.query(DBStoryItem).filter_by(id=item_id, story_id=story_id).first() + if not item: + return None + generation = db.query(DBGeneration).filter_by(id=item.generation_id).first() + if not generation: + return None + + for key, value in fields.items(): + setattr(item, key, value) + + story = db.query(DBStory).filter_by(id=story_id).first() + if story: + story.updated_at = datetime.utcnow() + + db.commit() + db.refresh(item) + + profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first() + return _build_item_detail(item, generation, profile.name if profile else "Unknown", db) + + +async def update_story_item_fades( + story_id: str, + item_id: str, + data: StoryItemFadeUpdate, + db: Session, +) -> Optional[StoryItemDetail]: + """Set a story item's fade in/out lengths.""" + return await _update_story_item_fields( + story_id, + item_id, + db, + fade_in_ms=data.fade_in_ms, + fade_out_ms=data.fade_out_ms, + ) + + +async def update_story_item_speed( + story_id: str, + item_id: str, + data: StoryItemSpeedUpdate, + db: Session, +) -> Optional[StoryItemDetail]: + """Set a story item's playback rate.""" + return await _update_story_item_fields(story_id, item_id, db, speed=data.speed) + + +# ── Track mixer settings ───────────────────────────────────────────── + + +async def list_story_tracks(story_id: str, db: Session) -> List[StoryTrackResponse]: + """Mixer settings for every lane that has them. + + Lanes without a row simply mix at unity gain, so the list is often + shorter than the number of lanes on screen. + """ + rows = ( + db.query(DBStoryTrack) + .filter_by(story_id=story_id) + .order_by(DBStoryTrack.index) + .all() + ) + return [StoryTrackResponse.model_validate(r) for r in rows] + + +async def upsert_story_track( + story_id: str, + index: int, + data: StoryTrackUpsert, + db: Session, +) -> Optional[StoryTrackResponse]: + """Create or update one lane's mixer settings.""" + story = db.query(DBStory).filter_by(id=story_id).first() + if not story: + return None + + row = db.query(DBStoryTrack).filter_by(story_id=story_id, index=index).first() + if row is None: + row = DBStoryTrack(story_id=story_id, index=index) + db.add(row) + + row.name = data.name + row.volume = data.volume + row.muted = data.muted + row.soloed = data.soloed + row.duck_under_track = data.duck_under_track + row.updated_at = datetime.utcnow() + + story.updated_at = datetime.utcnow() + db.commit() + db.refresh(row) + return StoryTrackResponse.model_validate(row) + + +async def delete_story_track(story_id: str, index: int, db: Session) -> bool: + """Reset a lane to defaults. + + Only the settings row goes — clips on that lane are untouched, and the + lane keeps rendering at unity gain. + """ + row = db.query(DBStoryTrack).filter_by(story_id=story_id, index=index).first() + if row is None: + return False + db.delete(row) + db.commit() + return True + + async def split_story_item( story_id: str, item_id: str, @@ -565,6 +731,12 @@ async def split_story_item( # Update original clip: trim from the end item.trim_end_ms = original_duration_ms - absolute_split_ms + # Fades split with the audio: the head keeps its fade-in, the tail keeps + # the fade-out. Leaving both on both halves would insert an audible dip at + # the seam of what the user hears as one continuous clip. + tail_fade_out = getattr(item, "fade_out_ms", 0) or 0 + item.fade_out_ms = 0 + # Create new clip: starts after the split, trimmed from the start new_item = DBStoryItem( id=str(uuid.uuid4()), @@ -576,6 +748,9 @@ async def split_story_item( trim_start_ms=absolute_split_ms, trim_end_ms=current_trim_end, volume=getattr(item, "volume", 1.0), + fade_in_ms=0, + fade_out_ms=tail_fade_out, + speed=getattr(item, "speed", 1.0) or 1.0, created_at=datetime.utcnow(), ) @@ -833,16 +1008,108 @@ async def set_story_item_version( return _build_item_detail(item, generation, profile.name if profile else "Unknown", db) +def _to_stereo(audio: np.ndarray) -> np.ndarray: + """Normalise any loaded clip to a ``(2, samples)`` float32 array. + + librosa hands back ``(samples,)`` for mono and ``(channels, samples)`` + otherwise. Mono is duplicated rather than panned so a voice clip sits + centred; anything above stereo is folded down to the first two channels. + """ + audio = np.asarray(audio, dtype=np.float32) + if audio.ndim == 1: + return np.stack([audio, audio]) + if audio.shape[0] == 1: + return np.repeat(audio, 2, axis=0) + return audio[:2] + + +def _apply_fades(audio: np.ndarray, sample_rate: int, fade_in_ms: int, fade_out_ms: int) -> np.ndarray: + """Apply linear fades to a ``(channels, samples)`` clip, in place-safe form. + + The two fades are scaled down together if they would overlap, so a short + clip with long fades still ends up monotonic rather than re-brightening in + the middle. + """ + n = audio.shape[1] + if n == 0 or (fade_in_ms <= 0 and fade_out_ms <= 0): + return audio + + fade_in = int(sample_rate * max(fade_in_ms, 0) / 1000) + fade_out = int(sample_rate * max(fade_out_ms, 0) / 1000) + + total = fade_in + fade_out + if total > n and total > 0: + scale = n / total + fade_in = int(fade_in * scale) + fade_out = int(fade_out * scale) + + audio = audio.copy() + if fade_in > 0: + audio[:, :fade_in] *= np.linspace(0.0, 1.0, fade_in, dtype=np.float32) + if fade_out > 0: + audio[:, n - fade_out :] *= np.linspace(1.0, 0.0, fade_out, dtype=np.float32) + return audio + + +def _duck_envelope( + source: np.ndarray, + sample_rate: int, + depth: float = 0.75, + attack_ms: int = 80, + release_ms: int = 400, +) -> np.ndarray: + """Gain curve that pulls a bed down while ``source`` is loud. + + A plain RMS follower with asymmetric smoothing: duck quickly when speech + starts, recover slowly so the bed doesn't pump between words. + """ + mono = source.mean(axis=0) + frame = max(1, sample_rate // 100) # 10 ms + + padded = np.pad(mono, (0, (-len(mono)) % frame)) + rms = np.sqrt((padded.reshape(-1, frame) ** 2).mean(axis=1)) + + peak = rms.max() + if peak <= 1e-6: + return np.ones(source.shape[1], dtype=np.float32) + + # 0 where silent, 1 where at peak, then invert into a gain reduction. + activity = np.clip(rms / peak, 0.0, 1.0) + gain = 1.0 - depth * activity + + attack = max(1, int(attack_ms / 10)) + release = max(1, int(release_ms / 10)) + smoothed = np.empty_like(gain) + current = 1.0 + for i, target in enumerate(gain): + coeff = 1.0 / (attack if target < current else release) + current += (target - current) * coeff + smoothed[i] = current + + envelope = np.repeat(smoothed, frame)[: source.shape[1]] + return envelope.astype(np.float32) + + async def export_story_audio( story_id: str, db: Session, + fmt: str = "wav", ) -> Optional[bytes]: """ Export story as single mixed audio file with timecode-based mixing. + Mixes in stereo at the highest sample rate any source actually uses + (capped at 48 kHz) rather than flattening everything to 24 kHz mono, so an + imported music bed keeps its bandwidth and stereo image. + + Each lane is rendered to its own buffer first. That is what makes ducking + possible — a bed can be attenuated by the *finished* speech lane — and it + is also where track volume, mute and solo apply. + Args: story_id: Story ID db: Database session + fmt: Output container; see ``utils.audio.EXPORT_FORMATS``. Returns: Audio file bytes or None if story not found @@ -863,12 +1130,14 @@ async def export_story_audio( if not items: return None - # Load all audio files and calculate total duration - audio_data = [] - sample_rate = 24000 # Default sample rate + tracks = {t.index: t for t in db.query(DBStoryTrack).filter_by(story_id=story_id).all()} + any_soloed = any(t.soloed for t in tracks.values()) + # --- decode once, at native rate --------------------------------------- + # Decoding at each file's own rate lets us pick the project rate from what + # the sources actually are, instead of forcing 24 kHz on a 48 kHz bed. + loaded = [] for item, generation in items: - # Resolve audio path: use pinned version if set, otherwise generation default resolved_audio_path = generation.audio_path if getattr(item, "version_id", None): from ..database import GenerationVersion as DBGenerationVersion @@ -879,100 +1148,113 @@ async def export_story_audio( audio_path = config.resolve_storage_path(resolved_audio_path) if audio_path is None or not audio_path.exists(): + logger.warning("Story %s: skipping item %s, audio missing", story_id, item.id) continue try: - audio, sr = load_audio(str(audio_path), sample_rate=sample_rate) - sample_rate = sr # Use actual sample rate from first file - - # Get trim values - trim_start_ms = getattr(item, "trim_start_ms", 0) - trim_end_ms = getattr(item, "trim_end_ms", 0) - - # Calculate effective duration - original_duration_ms = int(generation.duration * 1000) - effective_duration_ms = original_duration_ms - trim_start_ms - trim_end_ms - - # Slice audio based on trim values - trim_start_sample = int((trim_start_ms / 1000.0) * sample_rate) - trim_end_sample = int((trim_end_ms / 1000.0) * sample_rate) - - # Extract the trimmed portion - if trim_end_ms > 0: - trimmed_audio = ( - audio[trim_start_sample:-trim_end_sample] if trim_end_sample > 0 else audio[trim_start_sample:] - ) - else: - trimmed_audio = audio[trim_start_sample:] - - # Apply per-clip volume to the export mix. - volume = float(getattr(item, "volume", 1.0) or 1.0) - if volume != 1.0: - trimmed_audio = trimmed_audio * volume - - # Store audio with its timecode info - start_time_ms = item.start_time_ms - - audio_data.append( - { - "audio": trimmed_audio, - "start_time_ms": start_time_ms, - "duration_ms": effective_duration_ms, - } - ) - except Exception: - # Skip files that can't be loaded + audio, sr = librosa.load(str(audio_path), sr=None, mono=False) + except Exception as exc: + logger.warning("Story %s: skipping item %s, decode failed: %s", story_id, item.id, exc) continue - if not audio_data: + loaded.append((item, _to_stereo(audio), int(sr))) + + if not loaded: return None - # Calculate total duration: max(start_time_ms + duration_ms) - max_end_time_ms = max((data["start_time_ms"] + data["duration_ms"] for data in audio_data), default=0) + project_sr = min(max(sr for _item, _audio, sr in loaded), MAX_PROJECT_SAMPLE_RATE) + + # --- per-lane submixes -------------------------------------------------- + lanes: dict[int, np.ndarray] = {} + placements = [] - # Convert to samples - total_samples = int((max_end_time_ms / 1000.0) * sample_rate) + for item, audio, sr in loaded: + if sr != project_sr: + audio = librosa.resample(audio, orig_sr=sr, target_sr=project_sr) - # Create output buffer initialized to zeros - final_audio = np.zeros(total_samples, dtype=np.float32) + # Duration comes from the array we actually decoded, not from + # generation.duration: a pinned version can be a different length, and + # a NULL duration used to raise inside a bare except and silently drop + # the clip from the export. + trim_start = int(project_sr * max(getattr(item, "trim_start_ms", 0), 0) / 1000) + trim_end = int(project_sr * max(getattr(item, "trim_end_ms", 0), 0) / 1000) + audio = audio[:, trim_start : audio.shape[1] - trim_end if trim_end else None] + if audio.shape[1] == 0: + continue - # Mix each audio segment at its timecode position - for data in audio_data: - audio = data["audio"] - start_time_ms = data["start_time_ms"] + speed = float(getattr(item, "speed", 1.0) or 1.0) + if speed != 1.0: + # Phase vocoder, so pitch survives the tempo change. + audio = np.stack([librosa.effects.time_stretch(ch, rate=speed) for ch in audio]) - # Calculate start sample index - start_sample = int((start_time_ms / 1000.0) * sample_rate) + audio = _apply_fades( + audio, + project_sr, + int(getattr(item, "fade_in_ms", 0) or 0), + int(getattr(item, "fade_out_ms", 0) or 0), + ) - # Ensure we don't exceed buffer bounds - audio_length = len(audio) - end_sample = min(start_sample + audio_length, total_samples) + volume = float(getattr(item, "volume", 1.0) or 1.0) + if volume != 1.0: + audio = audio * volume - if start_sample < total_samples: - # Trim audio if it extends beyond buffer - audio_to_mix = audio[: end_sample - start_sample] + placements.append((item.track, int(item.start_time_ms), audio)) - # Mix: add audio to existing buffer (overlapping audio will sum) - # Normalize to prevent clipping (simple approach: divide by max) - final_audio[start_sample:end_sample] += audio_to_mix + if not placements: + return None - # Normalize to prevent clipping - max_val = np.abs(final_audio).max() - if max_val > 1.0: - final_audio = final_audio / max_val + total_samples = max( + int(project_sr * start_ms / 1000) + audio.shape[1] for _track, start_ms, audio in placements + ) - # Save to temporary file - with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: - tmp_path = tmp.name + for track_index, start_ms, audio in placements: + lane = lanes.get(track_index) + if lane is None: + lane = np.zeros((2, total_samples), dtype=np.float32) + lanes[track_index] = lane + + start = int(project_sr * start_ms / 1000) + end = min(start + audio.shape[1], total_samples) + if start < total_samples: + lane[:, start:end] += audio[:, : end - start] + + # --- track gain, mute and solo ----------------------------------------- + for index, lane in lanes.items(): + # A lane with no settings row means *defaults*, not *exempt* — it still + # has to be silenced when another lane is soloed. Skipping it here let + # the un-configured lane (usually the voice on track 0) play through a + # solo of the music bed. + track = tracks.get(index) + muted = bool(track.muted) if track else False + soloed = bool(track.soloed) if track else False + volume = float(track.volume) if track else 1.0 + + # Solo is a property of the whole story: once anything is soloed, + # everything else is silent regardless of its own mute flag. + if muted or (any_soloed and not soloed): + lane[:] = 0.0 + continue + if volume != 1.0: + lane *= volume + + # --- ducking ------------------------------------------------------------ + # Runs after gain so the envelope reflects what will actually be heard, + # and after mute/solo so a silenced lane ducks nothing. + for index, lane in lanes.items(): + track = tracks.get(index) + if track is None or track.duck_under_track is None: + continue + source = lanes.get(track.duck_under_track) + if source is None: + continue + lane *= _duck_envelope(source, project_sr) - try: - save_audio(final_audio, tmp_path, sample_rate) + final_audio = np.zeros((2, total_samples), dtype=np.float32) + for lane in lanes.values(): + final_audio += lane - # Read file bytes - with open(tmp_path, "rb") as f: - audio_bytes = f.read() + peak = np.abs(final_audio).max() + if peak > 1.0: + final_audio /= peak - return audio_bytes - finally: - # Clean up temp file - Path(tmp_path).unlink(missing_ok=True) + return encode_audio(final_audio, project_sr, fmt) diff --git a/backend/tests/test_ffmpeg_optional.py b/backend/tests/test_ffmpeg_optional.py new file mode 100644 index 000000000..bd18babb4 --- /dev/null +++ b/backend/tests/test_ffmpeg_optional.py @@ -0,0 +1,146 @@ +""" +Tests that ffmpeg stays optional. + +Voicebox does not bundle ffmpeg, so every path that can use it must still work +without it. These tests run the relevant behaviour twice — once as configured +on this machine, once with detection forced off — so a missing binary degrades +rather than breaks. + +Usage: + python -m pytest backend/tests/test_ffmpeg_optional.py -v +""" + +import io +import os +import sys +import tempfile +from pathlib import Path + +import numpy as np +import pytest +import soundfile as sf + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +_DATA_DIR = tempfile.mkdtemp(prefix="voicebox-ffmpeg-test-") +os.environ["VOICEBOX_DATA_DIR"] = _DATA_DIR + +from starlette.testclient import TestClient # noqa: E402 + +from backend.app import app # noqa: E402 +from backend.utils import ffmpeg # noqa: E402 + + +@pytest.fixture(scope="module") +def client(): + with TestClient(app) as c: + yield c + + +@pytest.fixture +def no_ffmpeg(monkeypatch): + """Pretend ffmpeg is not installed, however this machine is set up.""" + ffmpeg.reset_cache() + monkeypatch.setattr(ffmpeg.shutil, "which", lambda _name: None) + ffmpeg.reset_cache() + yield + ffmpeg.reset_cache() + + +@pytest.fixture +def story_with_audio(client, tmp_path): + story = client.post("/stories", json={"name": "ffmpeg test"}).json() + + sr = 48000 + t = np.linspace(0, 2.0, sr * 2, endpoint=False) + wav = tmp_path / "clip.wav" + sf.write(str(wav), (0.2 * np.sin(2 * np.pi * 440 * t)).astype(np.float32), sr) + + with wav.open("rb") as fh: + gen = client.post("/generate/import", files={"file": ("clip.wav", fh, "audio/wav")}).json() + client.post(f"/stories/{story['id']}/items", json={"generation_id": gen["id"]}) + + yield story + client.delete(f"/stories/{story['id']}") + + +# ── Detection ──────────────────────────────────────────────────────── + + +def test_detection_is_cached(): + ffmpeg.reset_cache() + first = ffmpeg.ffmpeg_path() + assert ffmpeg.ffmpeg_path() is first + + +def test_health_reports_availability(client): + body = client.get("/health").json() + assert "ffmpeg_available" in body + assert isinstance(body["ffmpeg_available"], bool) + + +def test_is_available_false_without_binary(no_ffmpeg): + assert ffmpeg.is_available() is False + + +# ── Export still works without ffmpeg ──────────────────────────────── + + +def test_export_succeeds_without_ffmpeg(client, story_with_audio, no_ffmpeg): + """The mixdown and all containers come from libsndfile, not ffmpeg.""" + for fmt in ("wav", "mp3", "ogg", "flac"): + r = client.get( + f"/stories/{story_with_audio['id']}/export-audio", params={"format": fmt} + ) + assert r.status_code == 200, f"{fmt} failed without ffmpeg: {r.text}" + assert len(r.content) > 0 + + +def test_loudness_request_degrades_rather_than_failing(client, story_with_audio, no_ffmpeg): + """Asking for normalisation without ffmpeg must still return audio.""" + r = client.get( + f"/stories/{story_with_audio['id']}/export-audio", + params={"format": "wav", "normalize_loudness": True}, + ) + assert r.status_code == 200 + data, sr = sf.read(io.BytesIO(r.content), dtype="float32", always_2d=True) + assert sr == 48000 + assert np.abs(data).max() > 0 + + +def test_normalize_loudness_returns_none_without_ffmpeg(no_ffmpeg): + assert ffmpeg.normalize_loudness(b"not really audio") is None + + +# ── Import formats are honest ──────────────────────────────────────── + + +def test_ffmpeg_only_extensions_are_identified(): + assert ffmpeg.requires_ffmpeg(".m4a") + assert ffmpeg.requires_ffmpeg(".webm") + assert not ffmpeg.requires_ffmpeg(".wav") + assert not ffmpeg.requires_ffmpeg(".mp3") + + +def test_m4a_import_rejected_clearly_without_ffmpeg(client, no_ffmpeg): + """Previously this got past validation and died deep in the decoder.""" + r = client.post( + "/generate/import", + files={"file": ("music.m4a", io.BytesIO(b"\x00" * 1024), "audio/mp4")}, + ) + assert r.status_code == 400 + assert "ffmpeg" in r.json()["detail"].lower() + + +def test_libsndfile_formats_need_no_ffmpeg(client, tmp_path, no_ffmpeg): + """WAV/FLAC/OGG/MP3 must import with ffmpeg absent.""" + sr = 24000 + t = np.linspace(0, 1.0, sr, endpoint=False) + tone = (0.2 * np.sin(2 * np.pi * 330 * t)).astype(np.float32) + + for name, fmt in (("a.wav", "WAV"), ("a.flac", "FLAC"), ("a.ogg", "OGG")): + path = tmp_path / name + sf.write(str(path), tone, sr, format=fmt) + with path.open("rb") as fh: + r = client.post("/generate/import", files={"file": (name, fh, "audio/*")}) + assert r.status_code == 200, f"{name} rejected without ffmpeg: {r.text}" diff --git a/backend/tests/test_story_mixdown.py b/backend/tests/test_story_mixdown.py new file mode 100644 index 000000000..5f737b509 --- /dev/null +++ b/backend/tests/test_story_mixdown.py @@ -0,0 +1,330 @@ +""" +Tests for the story mixdown: stereo/project-rate mixing, fades, speed, +track gain, mute/solo, ducking, and export formats. + +The mixer previously flattened everything to 24 kHz mono, which destroyed an +imported music bed (12 kHz Nyquist, stereo image folded flat). These tests pin +the new behaviour and the placement rules that let a bed sit under narration. + +Usage: + python -m pytest backend/tests/test_story_mixdown.py -v +""" + +import io +import os +import sys +import tempfile +from pathlib import Path + +import numpy as np +import pytest +import soundfile as sf + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +_DATA_DIR = tempfile.mkdtemp(prefix="voicebox-mixdown-test-") +os.environ["VOICEBOX_DATA_DIR"] = _DATA_DIR + +from starlette.testclient import TestClient # noqa: E402 + +from backend.app import app # noqa: E402 + + +@pytest.fixture(scope="module") +def client(): + with TestClient(app) as c: + yield c + + +def _tone(seconds: float, sr: int, freq: float, channels: int = 1, amp: float = 0.3): + t = np.linspace(0, seconds, int(sr * seconds), endpoint=False) + mono = (amp * np.sin(2 * np.pi * freq * t)).astype(np.float32) + if channels == 1: + return mono + # Distinct content per channel so a mono fold-down is detectable. + right = (amp * np.sin(2 * np.pi * (freq * 1.5) * t)).astype(np.float32) + return np.stack([mono, right], axis=1) + + +def _import_audio(client, tmp_path, name, seconds=2.0, sr=48000, freq=440.0, channels=1): + """Register an audio file as an importable generation.""" + path = tmp_path / name + sf.write(str(path), _tone(seconds, sr, freq, channels), sr) + with path.open("rb") as fh: + r = client.post("/generate/import", files={"file": (name, fh, "audio/wav")}) + assert r.status_code == 200, r.text + return r.json() + + +@pytest.fixture +def story(client): + r = client.post("/stories", json={"name": "Mixdown Test"}) + assert r.status_code == 200, r.text + created = r.json() + yield created + client.delete(f"/stories/{created['id']}") + + +def _export(client, story_id, fmt=None): + params = {"format": fmt} if fmt else None + r = client.get(f"/stories/{story_id}/export-audio", params=params) + assert r.status_code == 200, r.text + return r.content + + +def _decode(raw): + data, sr = sf.read(io.BytesIO(raw), dtype="float32", always_2d=True) + return data, sr + + +# ── Placement ──────────────────────────────────────────────────────── + + +def test_imported_audio_lands_on_its_own_lane_at_zero(client, story, tmp_path): + """The bug this fixes: music used to be appended after the narration on + track 0 instead of playing underneath it.""" + voice = _import_audio(client, tmp_path, "voice.wav", seconds=2.0) + client.post(f"/stories/{story['id']}/items", json={"generation_id": voice["id"], "track": 0}) + + bed = _import_audio(client, tmp_path, "bed.wav", seconds=2.0, freq=220.0) + r = client.post(f"/stories/{story['id']}/items", json={"generation_id": bed["id"]}) + assert r.status_code == 200, r.text + + item = r.json() + assert item["track"] != 0, "bed landed on the voice lane" + assert item["start_time_ms"] == 0, "bed did not start at the top of the timeline" + + +def test_explicit_track_zero_is_respected(client, story, tmp_path): + """track=0 must mean track 0, not 'unspecified' — the reason + StoryItemCreate.track had to become nullable.""" + clip = _import_audio(client, tmp_path, "explicit.wav") + r = client.post( + f"/stories/{story['id']}/items", + json={"generation_id": clip["id"], "track": 0, "start_time_ms": 500}, + ) + assert r.json()["track"] == 0 + assert r.json()["start_time_ms"] == 500 + + +# ── Project rate and channels ──────────────────────────────────────── + + +def test_mixdown_keeps_48k_stereo(client, story, tmp_path): + """A 48 kHz stereo bed must survive; it used to come out 24 kHz mono.""" + bed = _import_audio(client, tmp_path, "stereo48.wav", sr=48000, channels=2) + client.post(f"/stories/{story['id']}/items", json={"generation_id": bed["id"]}) + + data, sr = _decode(_export(client, story["id"])) + assert sr == 48000, f"project rate collapsed to {sr}" + assert data.shape[1] == 2 + # Channels carry different tones, so a mono fold-down would make them equal. + assert not np.allclose(data[:, 0], data[:, 1]), "stereo image was folded to mono" + + +def test_project_rate_is_capped_at_48k(client, story, tmp_path): + bed = _import_audio(client, tmp_path, "hires.wav", sr=96000) + client.post(f"/stories/{story['id']}/items", json={"generation_id": bed["id"]}) + + _data, sr = _decode(_export(client, story["id"])) + assert sr == 48000 + + +# ── Fades ──────────────────────────────────────────────────────────── + + +def test_fades_ramp_from_and_to_silence(client, story, tmp_path): + clip = _import_audio(client, tmp_path, "fade.wav", seconds=2.0, sr=48000) + item = client.post( + f"/stories/{story['id']}/items", json={"generation_id": clip["id"]} + ).json() + + r = client.put( + f"/stories/{story['id']}/items/{item['id']}/fades", + json={"fade_in_ms": 500, "fade_out_ms": 500}, + ) + assert r.status_code == 200, r.text + + data, sr = _decode(_export(client, story["id"])) + mono = data.mean(axis=1) + + head = np.abs(mono[: sr // 100]).max() + tail = np.abs(mono[-sr // 100 :]).max() + middle = np.abs(mono[len(mono) // 2 - sr // 20 : len(mono) // 2 + sr // 20]).max() + + assert head < 0.02, f"fade-in did not start near silence ({head:.4f})" + assert tail < 0.02, f"fade-out did not end near silence ({tail:.4f})" + assert middle > 0.1, "fades swallowed the whole clip" + + +def test_overlong_fades_are_scaled_not_clipped(client, story, tmp_path): + """Fades longer than the clip must stay monotonic rather than + re-brightening in the middle.""" + clip = _import_audio(client, tmp_path, "shortfade.wav", seconds=1.0, sr=48000) + item = client.post( + f"/stories/{story['id']}/items", json={"generation_id": clip["id"]} + ).json() + client.put( + f"/stories/{story['id']}/items/{item['id']}/fades", + json={"fade_in_ms": 5000, "fade_out_ms": 5000}, + ) + + data, _sr = _decode(_export(client, story["id"])) + mono = np.abs(data.mean(axis=1)) + peak_at = int(np.argmax(mono)) + # Peak should sit near the middle, where the two ramps cross. + assert 0.3 < peak_at / len(mono) < 0.7 + + +# ── Speed ──────────────────────────────────────────────────────────── + + +def test_double_speed_halves_the_clip(client, story, tmp_path): + clip = _import_audio(client, tmp_path, "speed.wav", seconds=4.0, sr=48000) + item = client.post( + f"/stories/{story['id']}/items", json={"generation_id": clip["id"]} + ).json() + + baseline, _sr = _decode(_export(client, story["id"])) + + r = client.put(f"/stories/{story['id']}/items/{item['id']}/speed", json={"speed": 2.0}) + assert r.status_code == 200, r.text + + faster, _sr = _decode(_export(client, story["id"])) + ratio = len(faster) / len(baseline) + assert 0.45 < ratio < 0.55, f"expected ~half length, got ratio {ratio:.2f}" + + +def test_speed_is_bounded(client, story, tmp_path): + clip = _import_audio(client, tmp_path, "speedbound.wav") + item = client.post( + f"/stories/{story['id']}/items", json={"generation_id": clip["id"]} + ).json() + assert ( + client.put( + f"/stories/{story['id']}/items/{item['id']}/speed", json={"speed": 99.0} + ).status_code + == 422 + ) + + +# ── Track gain, mute, solo ─────────────────────────────────────────── + + +def _two_lane_story(client, story, tmp_path, prefix): + voice = _import_audio(client, tmp_path, f"{prefix}-voice.wav", seconds=2.0, sr=48000, freq=440) + bed = _import_audio(client, tmp_path, f"{prefix}-bed.wav", seconds=2.0, sr=48000, freq=220) + client.post( + f"/stories/{story['id']}/items", json={"generation_id": voice["id"], "track": 0} + ) + client.post( + f"/stories/{story['id']}/items", + json={"generation_id": bed["id"], "track": 1, "start_time_ms": 0}, + ) + + +def test_track_volume_attenuates_that_lane(client, story, tmp_path): + _two_lane_story(client, story, tmp_path, "vol") + before, _ = _decode(_export(client, story["id"])) + + client.put( + f"/stories/{story['id']}/tracks/1", + json={"volume": 0.0, "muted": False, "soloed": False}, + ) + after, _ = _decode(_export(client, story["id"])) + + assert np.abs(after).max() < np.abs(before).max() + + +def test_mute_silences_a_lane(client, story, tmp_path): + _two_lane_story(client, story, tmp_path, "mute") + client.put( + f"/stories/{story['id']}/tracks/0", + json={"volume": 1.0, "muted": True, "soloed": False}, + ) + client.put( + f"/stories/{story['id']}/tracks/1", + json={"volume": 0.0, "muted": True, "soloed": False}, + ) + data, _ = _decode(_export(client, story["id"])) + assert np.abs(data).max() < 1e-6, "muting every lane should render silence" + + +def test_solo_silences_every_other_lane(client, story, tmp_path): + """Solo is global: one soloed lane mutes the rest regardless of their own + mute flags.""" + _two_lane_story(client, story, tmp_path, "solo") + client.put( + f"/stories/{story['id']}/tracks/1", + json={"volume": 1.0, "muted": False, "soloed": True}, + ) + data, sr = _decode(_export(client, story["id"])) + + # Only the 220 Hz bed should remain; check via a coarse spectrum. + spectrum = np.abs(np.fft.rfft(data.mean(axis=1))) + freqs = np.fft.rfftfreq(len(data), 1 / sr) + energy_220 = spectrum[(freqs > 200) & (freqs < 240)].sum() + energy_440 = spectrum[(freqs > 420) & (freqs < 460)].sum() + assert energy_220 > energy_440 * 5, "soloed lane did not dominate" + + +def test_deleting_track_settings_keeps_the_clips(client, story, tmp_path): + _two_lane_story(client, story, tmp_path, "del") + client.put( + f"/stories/{story['id']}/tracks/1", + json={"volume": 0.5, "muted": False, "soloed": False}, + ) + assert client.delete(f"/stories/{story['id']}/tracks/1").status_code == 200 + + detail = client.get(f"/stories/{story['id']}").json() + assert any(i["track"] == 1 for i in detail["items"]), "clips vanished with the settings row" + assert client.get(f"/stories/{story['id']}/tracks").json() == [] + + +def test_ducking_lowers_the_bed(client, story, tmp_path): + _two_lane_story(client, story, tmp_path, "duck") + before, _ = _decode(_export(client, story["id"])) + + client.put( + f"/stories/{story['id']}/tracks/1", + json={"volume": 1.0, "muted": False, "soloed": False, "duck_under_track": 0}, + ) + after, sr = _decode(_export(client, story["id"])) + + spectrum_before = np.abs(np.fft.rfft(before.mean(axis=1))) + spectrum_after = np.abs(np.fft.rfft(after.mean(axis=1))) + freqs = np.fft.rfftfreq(len(before), 1 / sr) + band = (freqs > 200) & (freqs < 240) + assert spectrum_after[band].sum() < spectrum_before[band].sum(), "bed was not ducked" + + +# ── Export formats ─────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("fmt", "magic"), + [ + ("wav", (b"RIFF",)), + ("mp3", (b"ID3", b"\xff\xfb", b"\xff\xf3", b"\xff\xf2")), + ("ogg", (b"OggS",)), + ("opus", (b"OggS",)), + ("flac", (b"fLaC",)), + ], +) +def test_export_formats(client, story, tmp_path, fmt, magic): + clip = _import_audio(client, tmp_path, f"fmt-{fmt}.wav", sr=48000) + client.post(f"/stories/{story['id']}/items", json={"generation_id": clip["id"]}) + + raw = _export(client, story["id"], fmt=fmt) + assert any(raw.startswith(m) for m in magic), f"{fmt} magic bytes wrong: {raw[:4]!r}" + + +def test_default_export_is_still_wav(client, story, tmp_path): + clip = _import_audio(client, tmp_path, "default.wav") + client.post(f"/stories/{story['id']}/items", json={"generation_id": clip["id"]}) + assert _export(client, story["id"]).startswith(b"RIFF") + + +def test_unknown_format_is_rejected(client, story): + r = client.get(f"/stories/{story['id']}/export-audio", params={"format": "aiff"}) + assert r.status_code == 400 diff --git a/backend/utils/audio.py b/backend/utils/audio.py index bccab2c80..10d2a6ecd 100644 --- a/backend/utils/audio.py +++ b/backend/utils/audio.py @@ -2,6 +2,8 @@ Audio processing utilities. """ +import io + import numpy as np import soundfile as sf import librosa @@ -51,12 +53,12 @@ def load_audio( ) -> Tuple[np.ndarray, int]: """ Load audio file with normalization. - + Args: path: Path to audio file sample_rate: Target sample rate mono: Convert to mono - + Returns: Tuple of (audio_array, sample_rate) """ @@ -64,6 +66,63 @@ def load_audio( return audio, sr +# Container -> (soundfile format, default subtype, MIME type, file extension). +# Every one of these is compiled into the libsndfile shipped with the app +# (1.2.2, with LAME, mpg123, Vorbis, Opus and FLAC), so none of them needs +# ffmpeg. +EXPORT_FORMATS: dict[str, dict[str, str]] = { + "wav": {"format": "WAV", "subtype": "PCM_16", "mime": "audio/wav", "ext": ".wav"}, + "mp3": {"format": "MP3", "subtype": "MPEG_LAYER_III", "mime": "audio/mpeg", "ext": ".mp3"}, + "ogg": {"format": "OGG", "subtype": "VORBIS", "mime": "audio/ogg", "ext": ".ogg"}, + "opus": {"format": "OGG", "subtype": "OPUS", "mime": "audio/ogg", "ext": ".opus"}, + "flac": {"format": "FLAC", "subtype": "PCM_16", "mime": "audio/flac", "ext": ".flac"}, +} + +# Opus only ever encodes at 48 kHz; libsndfile errors on anything else. +_OPUS_SAMPLE_RATE = 48000 + + +def encode_audio(audio: np.ndarray, sample_rate: int, fmt: str = "wav") -> bytes: + """Encode audio to a container's bytes. + + Args: + audio: ``(samples,)`` mono or ``(channels, samples)`` multi-channel. + sample_rate: Sample rate in Hz. + fmt: A key of :data:`EXPORT_FORMATS`. + + Returns: + Encoded file contents. + + Raises: + ValueError: If ``fmt`` is not a supported container. + """ + spec = EXPORT_FORMATS.get(fmt.lower()) + if spec is None: + raise ValueError(f"Unsupported export format '{fmt}'. Supported: {sorted(EXPORT_FORMATS)}") + + # soundfile wants (samples, channels); the mixer works in (channels, samples). + data = audio.T if audio.ndim > 1 else audio + + if fmt.lower() == "opus" and sample_rate != _OPUS_SAMPLE_RATE: + data = librosa.resample( + data.T if data.ndim > 1 else data, + orig_sr=sample_rate, + target_sr=_OPUS_SAMPLE_RATE, + ) + data = data.T if data.ndim > 1 else data + sample_rate = _OPUS_SAMPLE_RATE + + buffer = io.BytesIO() + sf.write( + buffer, + data.astype(np.float32), + sample_rate, + format=spec["format"], + subtype=spec["subtype"], + ) + return buffer.getvalue() + + def save_audio( audio: np.ndarray, path: str, diff --git a/backend/utils/ffmpeg.py b/backend/utils/ffmpeg.py new file mode 100644 index 000000000..f96d91879 --- /dev/null +++ b/backend/utils/ffmpeg.py @@ -0,0 +1,110 @@ +"""Optional ffmpeg integration. + +Voicebox does not bundle ffmpeg and must not require it: the mixdown, the +export formats, the time-stretch and the ducking all have working pure-Python +paths. ffmpeg is used only where it is genuinely better, and every call site +falls back when it is absent. + +Where it wins: + - ``loudnorm`` — EBU R128 loudness normalisation. Clips generated from + different voices land at noticeably different levels, and peak + normalisation (the fallback) does nothing about that. + +Where it is already load-bearing, whether we like it or not: + - Decoding ``.m4a`` / ``.aac`` / ``.webm``. libsndfile handles none of them, + so librosa falls through to audioread, which shells out to ffmpeg. Those + extensions are advertised by the import endpoint, so without ffmpeg they + fail deep in the decoder with an opaque message. :func:`requires_ffmpeg` + lets callers reject them up front instead. +""" + +from __future__ import annotations + +import logging +import shutil +import subprocess +import tempfile +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Containers libsndfile cannot open, so librosa must fall back to +# audioread -> ffmpeg. Keep in sync with IMPORT_AUDIO_EXTENSIONS. +FFMPEG_ONLY_EXTENSIONS = {".m4a", ".aac", ".webm"} + +# Resolved once — a PATH lookup per audio operation is wasteful, and the +# answer cannot change within a process run. +_cached_path: str | None = None +_probed = False + + +def ffmpeg_path() -> str | None: + """Absolute path to ffmpeg, or None when it isn't installed.""" + global _cached_path, _probed + if not _probed: + _cached_path = shutil.which("ffmpeg") + _probed = True + logger.info("ffmpeg %s", f"found at {_cached_path}" if _cached_path else "not found on PATH") + return _cached_path + + +def is_available() -> bool: + """Whether the optional ffmpeg paths can be used.""" + return ffmpeg_path() is not None + + +def reset_cache() -> None: + """Forget the cached lookup. Used by tests to exercise the fallback path.""" + global _cached_path, _probed + _cached_path = None + _probed = False + + +def requires_ffmpeg(suffix: str) -> bool: + """Whether decoding ``suffix`` needs ffmpeg that we may not have.""" + return suffix.lower() in FFMPEG_ONLY_EXTENSIONS + + +def normalize_loudness( + audio_bytes: bytes, + suffix: str = ".wav", + target_lufs: float = -16.0, + true_peak: float = -1.5, +) -> bytes | None: + """Loudness-normalise an encoded file to ``target_lufs`` (EBU R128). + + -16 LUFS is the usual target for spoken-word podcasts; -1.5 dBTP leaves + headroom for lossy codecs, which can overshoot on decode. + + Returns: + Normalised file bytes, or ``None`` if ffmpeg is unavailable or fails — + callers keep their existing output in that case. + """ + exe = ffmpeg_path() + if exe is None: + return None + + with tempfile.TemporaryDirectory(prefix="voicebox-loudnorm-") as tmp: + src = Path(tmp) / f"in{suffix}" + dst = Path(tmp) / f"out{suffix}" + src.write_bytes(audio_bytes) + + cmd = [ + exe, + "-hide_banner", + "-loglevel", "error", + "-nostdin", + "-y", + "-i", str(src), + "-af", f"loudnorm=I={target_lufs}:TP={true_peak}:LRA=11", + str(dst), + ] + try: + subprocess.run(cmd, check=True, capture_output=True, timeout=300) + except (subprocess.SubprocessError, OSError) as exc: + logger.warning("ffmpeg loudnorm failed, keeping un-normalised audio: %s", exc) + return None + + if not dst.exists() or dst.stat().st_size == 0: + return None + return dst.read_bytes() From c1ae72e74d0845a57e97455ad021584be639621d Mon Sep 17 00:00:00 2001 From: Lvigentini Date: Fri, 7 Aug 2026 00:23:07 +1000 Subject: [PATCH 05/24] feat(ui): per-lane mixer, clip fades and speed, export format picker Surfaces the timeline audio work in the Stories editor. Track mixer strip ----------------- Each lane gets mute, solo, volume and a duck-under-lane picker in the label column, which widens from 64px to fit them. The label cell is now sized from LABEL_COL_WIDTH rather than a Tailwind class, so the timeline's coordinate maths can never drift from what is rendered. A lane with no settings row renders the same controls at their defaults and creates the row on first change -- matching the mixer, which treats a missing row as defaults rather than as exempt. Solo is shown as a story-wide state: lanes silenced *because something else is soloed* are dimmed rather than marked muted, so it is clear why they went quiet. Clip fades and speed -------------------- Two popovers beside the existing volume control, following its local-state-then-commit shape so dragging a slider doesn't fire a request per pixel. getEffectiveDuration now divides by speed, matching the mixer's formula. Without it the timeline would draw a re-timed clip at its original length and the playhead would drift away from what the export actually contains. Export ------ The export button becomes a menu: WAV, MP3, OGG, Opus and FLAC, plus a loudness-normalised option that is disabled with an explanatory label when ffmpeg is absent rather than silently ignoring the request. It previously passed the click handler straight to onClick, which with the new signature would have handed a MouseEvent in as the format. Co-Authored-By: Claude Opus 5 --- .../components/StoriesTab/StoryContent.tsx | 62 +++- .../StoriesTab/StoryTrackEditor.tsx | 292 ++++++++++++++++-- .../StoriesTab/TrackMixerControls.tsx | 132 ++++++++ app/src/i18n/locales/en/translation.json | 16 + app/src/lib/api/client.ts | 70 ++++- app/src/lib/api/types.ts | 45 +++ app/src/lib/hooks/useStories.ts | 103 +++++- 7 files changed, 679 insertions(+), 41 deletions(-) create mode 100644 app/src/components/StoriesTab/TrackMixerControls.tsx diff --git a/app/src/components/StoriesTab/StoryContent.tsx b/app/src/components/StoriesTab/StoryContent.tsx index 3f19d81d5..a4bebbf31 100644 --- a/app/src/components/StoriesTab/StoryContent.tsx +++ b/app/src/components/StoriesTab/StoryContent.tsx @@ -20,11 +20,21 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import Loader from 'react-loaders'; import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; +import type { ExportAudioFormat } from '@/lib/api/types'; import { useHistory } from '@/lib/hooks/useHistory'; +import { useServerHealth } from '@/lib/hooks/useServer'; import { useAddStoryItem, useExportStoryAudio, @@ -37,6 +47,15 @@ import { useGenerationStore } from '@/stores/generationStore'; import { useStoryStore } from '@/stores/storyStore'; import { SortableStoryChatItem } from './StoryChatItem'; +/** Containers the bundled libsndfile writes; none of them need ffmpeg. */ +const EXPORT_FORMATS: { value: ExportAudioFormat; label: string }[] = [ + { value: 'wav', label: 'WAV — lossless' }, + { value: 'mp3', label: 'MP3 — widely compatible' }, + { value: 'ogg', label: 'OGG Vorbis' }, + { value: 'opus', label: 'Opus — smallest' }, + { value: 'flac', label: 'FLAC — lossless, compressed' }, +]; + export function StoryContent() { const { t } = useTranslation(); const selectedStoryId = useStoryStore((state) => state.selectedStoryId); @@ -45,6 +64,7 @@ export function StoryContent() { const reorderItems = useReorderStoryItems(); const exportAudio = useExportStoryAudio(); const addStoryItem = useAddStoryItem(); + const { data: health } = useServerHealth(); const { toast } = useToast(); const scrollRef = useRef(null); const importInputRef = useRef(null); @@ -213,13 +233,15 @@ export function StoryContent() { ); }; - const handleExportAudio = () => { + const handleExportAudio = (format: ExportAudioFormat = 'wav', normalizeLoudness = false) => { if (!story) return; exportAudio.mutate( { storyId: story.id, storyName: story.name, + format, + normalizeLoudness, }, { onError: (error) => { @@ -446,15 +468,35 @@ export function StoryContent() { {story.items.length > 0 && ( - + + + + + + {t('storyContent.export.format')} + + {EXPORT_FORMATS.map(({ value, label }) => ( + handleExportAudio(value)}> + {label} + + ))} + + {t('storyContent.export.mastering')} + handleExportAudio('mp3', true)} + > + {health?.ffmpeg_available + ? t('storyContent.export.normalized') + : t('storyContent.export.normalizedUnavailable')} + + + )}
diff --git a/app/src/components/StoriesTab/StoryTrackEditor.tsx b/app/src/components/StoriesTab/StoryTrackEditor.tsx index 1b945d152..c9e601753 100644 --- a/app/src/components/StoriesTab/StoryTrackEditor.tsx +++ b/app/src/components/StoriesTab/StoryTrackEditor.tsx @@ -2,6 +2,7 @@ import { Check, Copy, GalleryVerticalEnd, + Gauge, GripHorizontal, Minus, Pause, @@ -11,6 +12,7 @@ import { Scissors, Square, Trash2, + TrendingUp, Volume2, VolumeX, } from 'lucide-react'; @@ -34,12 +36,17 @@ import { useRemoveStoryItem, useSetStoryItemVersion, useSplitStoryItem, + useStoryTracks, useTrimStoryItem, + useUpdateStoryItemFades, + useUpdateStoryItemSpeed, useUpdateStoryItemVolume, + useUpsertStoryTrack, } from '@/lib/hooks/useStories'; import { cn } from '@/lib/utils/cn'; import { useGenerationStore } from '@/stores/generationStore'; import { useStoryStore } from '@/stores/storyStore'; +import { TrackMixerControls } from './TrackMixerControls'; // Clip waveform component with trim support function ClipWaveform({ @@ -136,6 +143,145 @@ function ClipWaveform({ ); } +// Per-clip fade popover. Same local-state-then-commit shape as the volume +// control, so dragging doesn't fire a request per pixel. +function ClipFadePopover({ + storyId, + itemId, + fadeInMs, + fadeOutMs, + onChange, +}: { + storyId: string; + itemId: string; + fadeInMs: number; + fadeOutMs: number; + onChange: (fadeIn: number, fadeOut: number) => void; +}) { + const [localIn, setLocalIn] = useState(fadeInMs); + const [localOut, setLocalOut] = useState(fadeOutMs); + + // Re-sync when the selected clip changes or the persisted value updates + // out-of-band (split carries the fade-out to the tail). + // biome-ignore lint/correctness/useExhaustiveDependencies: itemId/storyId are re-sync triggers, not values the effect reads + useEffect(() => { + setLocalIn(fadeInMs); + setLocalOut(fadeOutMs); + }, [fadeInMs, fadeOutMs, itemId, storyId]); + + const active = localIn > 0 || localOut > 0; + + return ( + + + + + +
+ Fade in + {localIn} ms +
+ setLocalIn(v)} + onValueCommit={([v]) => onChange(v, localOut)} + min={0} + max={5000} + step={50} + aria-label="Fade in" + /> +
+ Fade out + {localOut} ms +
+ setLocalOut(v)} + onValueCommit={([v]) => onChange(localIn, v)} + min={0} + max={5000} + step={50} + aria-label="Fade out" + /> +

+ Fades longer than the clip are scaled down together, so the clip never re-brightens in the + middle. +

+
+
+ ); +} + +// Per-clip speed popover. Changing speed changes the clip's own length; because +// clips are absolutely positioned, neighbours don't move. +function ClipSpeedPopover({ + storyId, + itemId, + speed, + onChange, +}: { + storyId: string; + itemId: string; + speed: number; + onChange: (value: number) => void; +}) { + const [localSpeed, setLocalSpeed] = useState(speed); + + // Re-sync when the selected clip changes or the persisted value updates + // out-of-band (split carries the speed forward to both halves). + // biome-ignore lint/correctness/useExhaustiveDependencies: itemId/storyId are re-sync triggers, not values the effect reads + useEffect(() => { + setLocalSpeed(speed); + }, [speed, itemId, storyId]); + + return ( + + + + + +
+ Speed + {localSpeed.toFixed(2)}x +
+ setLocalSpeed(v / 100)} + onValueCommit={([v]) => onChange(v / 100)} + min={50} + max={200} + step={5} + aria-label="Clip speed" + /> +
+ 0.5x + 1x + 2x +
+

+ Pitch is preserved. Faster clips get shorter without moving the clips around them. +

+
+
+ ); +} + // Per-clip volume popover. Local state drives the slider during a drag so // each pointer-move pixel doesn't fire a PATCH; commits on release. function ClipVolumePopover({ @@ -204,7 +350,10 @@ interface StoryTrackEditorProps { const TRACK_HEIGHT = 48; const TIME_RULER_HEIGHT = 24; // h-6 = 1.5rem = 24px const SCRUB_BAR_HEIGHT = 16; -const LABEL_COL_WIDTH = 64; // w-16 = 4rem = 64px +// Wide enough for the per-lane mixer strip (mute, solo, volume, duck). The +// label cell is sized from this constant rather than a Tailwind width so the +// timeline's coordinate maths can never drift from what is rendered. +const LABEL_COL_WIDTH = 190; // Zoom is expressed to the user as how many seconds of timeline are visible // at once. Min scope = the most you can zoom IN; max scope = the entire // project. Default scope is what we land on when the editor first measures. @@ -234,7 +383,20 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { const removeItem = useRemoveStoryItem(); const setItemVersion = useSetStoryItemVersion(); const updateVolume = useUpdateStoryItemVolume(); + const updateFades = useUpdateStoryItemFades(); + const updateSpeed = useUpdateStoryItemSpeed(); + const { data: storyTracks } = useStoryTracks(storyId); + const upsertTrack = useUpsertStoryTrack(); const { toast } = useToast(); + + // Lanes without a row mix at unity gain, so this map is usually sparse. + const trackSettings = useMemo( + () => new Map((storyTracks ?? []).map((t) => [t.index, t])), + [storyTracks], + ); + // Solo is a property of the whole story, matching the mixer: one soloed + // lane silences the rest, including lanes with no settings row. + const anySoloed = useMemo(() => (storyTracks ?? []).some((t) => t.soloed), [storyTracks]); const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration); // User-added empty tracks. Live in component state because a track only // earns its keep once a clip lands on it — no need to persist an unused @@ -322,7 +484,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { ...items.map((item) => { const trimStart = item.trim_start_ms || 0; const trimEnd = item.trim_end_ms || 0; - const effectiveDuration = item.duration * 1000 - trimStart - trimEnd; + // Same formula as getEffectiveDuration below — speed included, so a + // re-timed clip doesn't leave the story's length overstated. + const effectiveDuration = + (item.duration * 1000 - trimStart - trimEnd) / (item.speed || 1); return item.start_time_ms + effectiveDuration; }), 0, @@ -432,9 +597,12 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { return () => ro.disconnect(); }, []); - // Calculate effective duration (accounting for trims) + // Calculate effective duration (accounting for trims and speed). + // Must match the mixer's formula in services/stories.py, or the timeline + // will draw a re-timed clip at the wrong length and the playhead will drift. const getEffectiveDuration = (item: StoryItemDetail) => { - return item.duration * 1000 - (item.trim_start_ms || 0) - (item.trim_end_ms || 0); + const trimmed = item.duration * 1000 - (item.trim_start_ms || 0) - (item.trim_end_ms || 0); + return trimmed / (item.speed || 1); }; // Calculate total duration (using effective durations) @@ -466,7 +634,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { useEffect(() => { if (hasAppliedDefaultZoomRef.current) return; if (visibleTrackWidth <= 0) return; - const defaultScope = Math.min(DEFAULT_VISIBLE_SECONDS, Math.max(projectSeconds, MIN_VISIBLE_SECONDS)); + const defaultScope = Math.min( + DEFAULT_VISIBLE_SECONDS, + Math.max(projectSeconds, MIN_VISIBLE_SECONDS), + ); setPixelsPerSecond(visibleTrackWidth / defaultScope); hasAppliedDefaultZoomRef.current = true; }, [visibleTrackWidth, projectSeconds]); @@ -875,11 +1046,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { const rect = tracksRef.current.getBoundingClientRect(); const x = - e.clientX - - rect.left + - tracksRef.current.scrollLeft - - dragOffset.x - - LABEL_COL_WIDTH; + e.clientX - rect.left + tracksRef.current.scrollLeft - dragOffset.x - LABEL_COL_WIDTH; // Subtract ruler height since clips are positioned relative to tracks area const y = e.clientY - rect.top - dragOffset.y - TIME_RULER_HEIGHT; @@ -1032,8 +1199,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { // Recompute the thumb width that corresponded to the drag start, then // apply the mouse delta to the dragged edge. - const startTimelinePx = - (totalDurationMs / 1000) * drag.startPixelsPerSecond + 200; + const startTimelinePx = (totalDurationMs / 1000) * drag.startPixelsPerSecond + 200; const startThumbWidth = Math.max( 30, Math.min(scrollbarTrackWidth, (containerWidth / startTimelinePx) * scrollbarTrackWidth), @@ -1058,8 +1224,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { } : { type: 'right', - timeMs: - ((drag.startScrollLeft + containerWidth) / drag.startPixelsPerSecond) * 1000, + timeMs: ((drag.startScrollLeft + containerWidth) / drag.startPixelsPerSecond) * 1000, }; setPixelsPerSecond(newPps); @@ -1074,7 +1239,15 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); }; - }, [maxTimelineScroll, thumbRange, scrollbarTrackWidth, containerWidth, totalDurationMs, minPps, maxPps]); + }, [ + maxTimelineScroll, + thumbRange, + scrollbarTrackWidth, + containerWidth, + totalDurationMs, + minPps, + maxPps, + ]); if (items.length === 0) { return null; @@ -1173,6 +1346,57 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { } /> )} + {selectedItem && ( + + updateFades.mutate( + { + storyId, + itemId: selectedItem.id, + data: { fade_in_ms: fadeIn, fade_out_ms: fadeOut }, + }, + { + onError: (error) => { + toast({ + title: 'Failed to update fades', + description: error instanceof Error ? error.message : String(error), + variant: 'destructive', + }); + }, + }, + ) + } + /> + )} + {selectedItem && ( + + updateSpeed.mutate( + { + storyId, + itemId: selectedItem.id, + data: { speed: value }, + }, + { + onError: (error) => { + toast({ + title: 'Failed to update speed', + description: error instanceof Error ? error.message : String(error), + variant: 'destructive', + }); + }, + }, + ) + } + /> + )} + + + + patch({ volume: next })} + /> + + + + + + + {t('storyTracks.duckUnder')} + + patch({ duck_under_track: null })} + > + {t('storyTracks.duckOff')} + + {otherTracks.map((other) => ( + patch({ duck_under_track: other })} + > + {t('storyTracks.trackNumber', { index: other })} + + ))} + {otherTracks.length === 0 && ( + {t('storyTracks.noOtherTracks')} + )} + + + + + {Math.round(volume * 100)}% + + + ); +} diff --git a/app/src/i18n/locales/en/translation.json b/app/src/i18n/locales/en/translation.json index be830ae55..8bc795015 100644 --- a/app/src/i18n/locales/en/translation.json +++ b/app/src/i18n/locales/en/translation.json @@ -429,6 +429,16 @@ "deleting": "Deleting…" } }, + "storyTracks": { + "muteTrack": "Mute track {{index}}", + "soloTrack": "Solo track {{index}}", + "volumeTrack": "Volume for track {{index}}", + "duck": "Ducking", + "duckUnder": "Duck under track", + "duckOff": "No ducking", + "trackNumber": "Track {{index}}", + "noOtherTracks": "No other tracks" + }, "folders": { "new": "New folder", "none": "No folders yet", @@ -671,6 +681,12 @@ "searchNoMatches": "No matching generations found", "searchNoAvailable": "No available generations", "exportAudio": "Export Audio", + "export": { + "format": "Format", + "mastering": "Mastering", + "normalized": "MP3, loudness normalised", + "normalizedUnavailable": "Loudness normalising needs ffmpeg" + }, "empty": { "title": "No items in this story", "hint": "Generate speech using the box below to add items" diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts index 442ad8214..c5646dab0 100644 --- a/app/src/lib/api/client.ts +++ b/app/src/lib/api/client.ts @@ -27,16 +27,21 @@ import type { RocmStatus, StoryCreate, StoryDetailResponse, + ExportAudioFormat, StoryItemBatchUpdate, StoryItemCreate, StoryItemDetail, + StoryItemFadeUpdate, StoryItemMove, StoryItemReorder, + StoryItemSpeedUpdate, StoryItemSplit, StoryItemTrim, StoryItemVersionUpdate, StoryItemVolumeUpdate, StoryResponse, + StoryTrackResponse, + StoryTrackUpsert, TranscriptionResponse, VoiceProfileCreate, VoiceProfileResponse, @@ -880,6 +885,54 @@ class ApiClient { }); } + async updateStoryItemFades( + storyId: string, + itemId: string, + data: StoryItemFadeUpdate, + ): Promise { + return this.request(`/stories/${storyId}/items/${itemId}/fades`, { + method: 'PUT', + body: JSON.stringify(data), + }); + } + + async updateStoryItemSpeed( + storyId: string, + itemId: string, + data: StoryItemSpeedUpdate, + ): Promise { + return this.request(`/stories/${storyId}/items/${itemId}/speed`, { + method: 'PUT', + body: JSON.stringify(data), + }); + } + + // ── Story track mixer settings ───────────────────────────────────── + // Lanes without a row here mix at unity gain, so this list is often + // shorter than the number of lanes on screen. + + async listStoryTracks(storyId: string): Promise { + return this.request(`/stories/${storyId}/tracks`); + } + + async upsertStoryTrack( + storyId: string, + index: number, + data: StoryTrackUpsert, + ): Promise { + return this.request(`/stories/${storyId}/tracks/${index}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + } + + /** Resets the lane to defaults; clips on it are kept. */ + async deleteStoryTrack(storyId: string, index: number): Promise { + await this.request(`/stories/${storyId}/tracks/${index}`, { + method: 'DELETE', + }); + } + async splitStoryItem( storyId: string, itemId: string, @@ -908,8 +961,21 @@ class ApiClient { }); } - async exportStoryAudio(storyId: string): Promise { - const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`; + /** + * Mix a story down to one file. `format` defaults to wav server-side. + * `normalizeLoudness` needs ffmpeg and is silently skipped without it — + * check `ffmpeg_available` on /health before offering it. + */ + async exportStoryAudio( + storyId: string, + options?: { format?: ExportAudioFormat; normalizeLoudness?: boolean }, + ): Promise { + const params = new URLSearchParams(); + if (options?.format) params.append('format', options.format); + if (options?.normalizeLoudness) params.append('normalize_loudness', 'true'); + + const query = params.toString(); + const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio${query ? `?${query}` : ''}`; const response = await fetch(url); if (!response.ok) { diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts index 8115e10c4..fa469176d 100644 --- a/app/src/lib/api/types.ts +++ b/app/src/lib/api/types.ts @@ -312,6 +312,11 @@ export interface HealthResponse { backend_type?: string; backend_variant?: string; // "cpu", "cuda", or "rocm" supports_rocm?: boolean; // AMD GPU on Windows — the ROCm backend is applicable + /** + * ffmpeg is optional. Without it, loudness normalisation is unavailable and + * m4a/aac/webm cannot be imported — libsndfile cannot open those. + */ + ffmpeg_available?: boolean; } export interface CudaDownloadProgress { @@ -458,6 +463,10 @@ export interface StoryItemDetail { instruct?: string; engine?: string; volume: number; + fade_in_ms: number; + fade_out_ms: number; + /** >1 plays faster and therefore shorter. */ + speed: number; generation_created_at: string; versions?: GenerationVersionResponse[]; active_version_id?: string; @@ -467,6 +476,42 @@ export interface StoryItemVolumeUpdate { volume: number; } +export interface StoryItemFadeUpdate { + fade_in_ms: number; + fade_out_ms: number; +} + +export interface StoryItemSpeedUpdate { + speed: number; +} + +/** Containers the bundled libsndfile can write — none of them need ffmpeg. */ +export type ExportAudioFormat = 'wav' | 'mp3' | 'ogg' | 'opus' | 'flac'; + +/** + * Mixer settings for one timeline lane. A lane with no entry mixes at unity + * gain, so the list is often shorter than the lanes on screen. + */ +export interface StoryTrackResponse { + id: string; + story_id: string; + index: number; + name?: string | null; + volume: number; + muted: boolean; + soloed: boolean; + /** Lane whose loudness ducks this one; null disables ducking. */ + duck_under_track?: number | null; +} + +export interface StoryTrackUpsert { + name?: string | null; + volume: number; + muted: boolean; + soloed: boolean; + duck_under_track?: number | null; +} + export interface StoryItemVersionUpdate { version_id: string | null; } diff --git a/app/src/lib/hooks/useStories.ts b/app/src/lib/hooks/useStories.ts index 7c7eae6c9..d9ddc79ed 100644 --- a/app/src/lib/hooks/useStories.ts +++ b/app/src/lib/hooks/useStories.ts @@ -1,15 +1,19 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { apiClient } from '@/lib/api/client'; import type { + ExportAudioFormat, StoryCreate, StoryItemBatchUpdate, StoryItemCreate, + StoryItemFadeUpdate, StoryItemMove, StoryItemReorder, + StoryItemSpeedUpdate, StoryItemSplit, StoryItemTrim, StoryItemVersionUpdate, StoryItemVolumeUpdate, + StoryTrackUpsert, } from '@/lib/api/types'; import { usePlatform } from '@/platform/PlatformContext'; @@ -175,6 +179,87 @@ export function useUpdateStoryItemVolume() { }); } +export function useUpdateStoryItemFades() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + storyId, + itemId, + data, + }: { + storyId: string; + itemId: string; + data: StoryItemFadeUpdate; + }) => apiClient.updateStoryItemFades(storyId, itemId, data), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['stories'] }); + queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] }); + }, + }); +} + +export function useUpdateStoryItemSpeed() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + storyId, + itemId, + data, + }: { + storyId: string; + itemId: string; + data: StoryItemSpeedUpdate; + }) => apiClient.updateStoryItemSpeed(storyId, itemId, data), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['stories'] }); + queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] }); + }, + }); +} + +// ── Track mixer settings ───────────────────────────────────────────── + +export function useStoryTracks(storyId: string | null) { + return useQuery({ + queryKey: ['stories', storyId, 'tracks'], + queryFn: () => apiClient.listStoryTracks(storyId as string), + enabled: !!storyId, + }); +} + +export function useUpsertStoryTrack() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + storyId, + index, + data, + }: { + storyId: string; + index: number; + data: StoryTrackUpsert; + }) => apiClient.upsertStoryTrack(storyId, index, data), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId, 'tracks'] }); + }, + }); +} + +export function useDeleteStoryTrack() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ storyId, index }: { storyId: string; index: number }) => + apiClient.deleteStoryTrack(storyId, index), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId, 'tracks'] }); + }, + }); +} + export function useSplitStoryItem() { const queryClient = useQueryClient(); @@ -232,20 +317,30 @@ export function useExportStoryAudio() { const platform = usePlatform(); return useMutation({ - mutationFn: async ({ storyId, storyName }: { storyId: string; storyName: string }) => { - const blob = await apiClient.exportStoryAudio(storyId); + mutationFn: async ({ + storyId, + storyName, + format = 'wav', + normalizeLoudness = false, + }: { + storyId: string; + storyName: string; + format?: ExportAudioFormat; + normalizeLoudness?: boolean; + }) => { + const blob = await apiClient.exportStoryAudio(storyId, { format, normalizeLoudness }); // Create safe filename const safeName = storyName .substring(0, 50) .replace(/[^a-z0-9]/gi, '-') .toLowerCase(); - const filename = `${safeName || 'story'}.wav`; + const filename = `${safeName || 'story'}.${format}`; await platform.filesystem.saveFile(filename, blob, [ { name: 'Audio File', - extensions: ['wav'], + extensions: [format], }, ]); From 7cb37922ed4eaf9b109265f2549e1fd3e96d7b0e Mon Sep 17 00:00:00 2001 From: Lvigentini Date: Fri, 7 Aug 2026 00:27:31 +1000 Subject: [PATCH 06/24] fix(stories): delete a story's track settings with the story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit story_tracks rows are keyed by story_id but carry no FK cascade, so delete_story left them behind as unreachable rows — found when testing against a real database, which accumulated two orphans from deleted stories. Co-Authored-By: Claude Opus 5 --- backend/services/stories.py | 8 +++++--- backend/tests/test_story_mixdown.py | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/backend/services/stories.py b/backend/services/stories.py index 1fcadb177..279ff0b09 100644 --- a/backend/services/stories.py +++ b/backend/services/stories.py @@ -6,8 +6,6 @@ from datetime import datetime import logging import uuid -import tempfile -from pathlib import Path from sqlalchemy.orm import Session from sqlalchemy import func @@ -37,7 +35,7 @@ ) from ..database.models import StoryTrack as DBStoryTrack from .history import _get_versions_for_generation -from ..utils.audio import encode_audio, load_audio, save_audio +from ..utils.audio import encode_audio import librosa import numpy as np @@ -259,6 +257,10 @@ async def delete_story( # Delete all items db.query(DBStoryItem).filter_by(story_id=story_id).delete() + # Delete per-lane mixer settings. They are keyed by story_id but have no + # FK cascade, so without this they outlive the story as unreachable rows. + db.query(DBStoryTrack).filter_by(story_id=story_id).delete() + # Delete story db.delete(story) db.commit() diff --git a/backend/tests/test_story_mixdown.py b/backend/tests/test_story_mixdown.py index 5f737b509..bf31dc81f 100644 --- a/backend/tests/test_story_mixdown.py +++ b/backend/tests/test_story_mixdown.py @@ -268,6 +268,22 @@ def test_solo_silences_every_other_lane(client, story, tmp_path): assert energy_220 > energy_440 * 5, "soloed lane did not dominate" +def test_deleting_a_story_removes_its_track_settings(client, tmp_path): + """Track rows are keyed by story_id with no FK cascade, so deleting a + story has to clear them or they linger as unreachable rows.""" + doomed = client.post("/stories", json={"name": "Doomed"}).json() + clip = _import_audio(client, tmp_path, "doomed.wav") + client.post(f"/stories/{doomed['id']}/items", json={"generation_id": clip["id"], "track": 0}) + client.put( + f"/stories/{doomed['id']}/tracks/0", + json={"volume": 0.5, "muted": False, "soloed": False}, + ) + assert len(client.get(f"/stories/{doomed['id']}/tracks").json()) == 1 + + assert client.delete(f"/stories/{doomed['id']}").status_code == 200 + assert client.get(f"/stories/{doomed['id']}/tracks").json() == [] + + def test_deleting_track_settings_keeps_the_clips(client, story, tmp_path): _two_lane_story(client, story, tmp_path, "del") client.put( From 1210a7748f4e928d06ad9cd93d0d6bdb012b841f Mon Sep 17 00:00:00 2001 From: Lvigentini Date: Fri, 7 Aug 2026 10:06:34 +1000 Subject: [PATCH 07/24] docs: document folders, duplication and story mixdown backend/README.md: adds the Folders domain row, corrects the endpoint count (the table said 90; the tree has had more than that for a while and is now 135), documents the new folder/duplicate/export-format examples, and adds an optional-dependencies section for ffmpeg -- which is not bundled, is never required, and is the only decoder for the m4a/aac/webm imports the endpoint advertises. Also notes that --data-dir wins over VOICEBOX_DATA_DIR, and that the environment variable is the only one that reaches a bare uvicorn. CHANGELOG.md: drafted per .agents/skills/draft-release-notes into the [Unreleased] section, preserving the existing Linux entry. CONTRIBUTING asks contributors to edit this file directly, but the file's own header forbids manual edits and points at the skill; the skill wins. Co-Authored-By: Claude Opus 5 --- backend/README.md | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/backend/README.md b/backend/README.md index 170cab1cd..5fa653ec1 100644 --- a/backend/README.md +++ b/backend/README.md @@ -75,17 +75,18 @@ Detection is handled by `utils/platform_detect.py`. Both backends implement the ## API -90 endpoints organized by domain. Full interactive documentation available at `http://localhost:17493/docs` when the server is running. +135 endpoints organized by domain. Full interactive documentation available at `http://localhost:17493/docs` when the server is running. | Domain | Prefix | Description | |--------|--------|-------------| -| Health | `/`, `/health` | Server status, GPU info, filesystem checks | -| Profiles | `/profiles` | Voice profile CRUD, samples, avatars, import/export | +| Health | `/`, `/health` | Server status, GPU info, ffmpeg availability, filesystem checks | +| Profiles | `/profiles` | Voice profile CRUD, samples, avatars, duplication, import/export | +| Folders | `/folders` | Organising voices and generated clips | | Channels | `/channels` | Audio channel management and voice assignment | -| Generation | `/generate` | TTS generation, retry, regenerate, status SSE | +| Generation | `/generate` | TTS generation, retry, regenerate, status SSE, audio import | | History | `/history` | Generation history, search, favorites, export | | Transcription | `/transcribe` | Whisper-based audio-to-text | -| Stories | `/stories` | Multi-track timeline editor, audio export | +| Stories | `/stories` | Multi-track timeline editor, per-lane mixing, audio export | | Effects | `/effects` | Effect presets, preview, version management | | Audio | `/audio`, `/samples` | Audio file serving | | Models | `/models` | Load, unload, download, migrate, status | @@ -105,6 +106,17 @@ curl http://localhost:17493/profiles # Stream generation status (SSE) curl http://localhost:17493/generate/{id}/status + +# File a voice into a folder (null moves it back to Uncategorised) +curl -X PUT http://localhost:17493/profiles/{id}/folder \ + -H "Content-Type: application/json" \ + -d '{"folder_id": "..."}' + +# Duplicate a voice with its samples, personality and effects +curl -X POST http://localhost:17493/profiles/{id}/duplicate + +# Mix a story down to a single file (wav | mp3 | ogg | opus | flac) +curl "http://localhost:17493/stories/{id}/export-audio?format=mp3" -o story.mp3 ``` ## Data directory @@ -118,7 +130,13 @@ curl http://localhost:17493/generate/{id}/status backends/ # Downloaded CUDA binary (if applicable) ``` -Default location is the OS-specific app data directory. Override with `--data-dir` or the `VOICEBOX_DATA_DIR` environment variable. +Default location is the OS-specific app data directory. Override with `--data-dir` or the `VOICEBOX_DATA_DIR` environment variable. `--data-dir` wins when both are set; the environment variable is the only option that reaches a bare `uvicorn backend.main:app`, which never parses CLI arguments. + +## Optional dependencies + +**ffmpeg** is not bundled and never required. When present on `PATH` it adds EBU R128 loudness normalisation to story export (`?normalize_loudness=true`); without it that request still succeeds using peak normalisation. It is also the only decoder for `.m4a`, `.aac` and `.webm` imports — libsndfile cannot open those, so `POST /generate/import` rejects them with a clear message when ffmpeg is missing. `GET /health` reports `ffmpeg_available`. + +Every audio export container (WAV, MP3, OGG, Opus, FLAC) is written by the bundled libsndfile and needs no ffmpeg. ## Code quality From c65a2f4e4baba4ab826dad43c91eb650b2212c13 Mon Sep 17 00:00:00 2001 From: Lvigentini Date: Fri, 7 Aug 2026 10:55:23 +1000 Subject: [PATCH 08/24] fix(stories): clicking a clip selects it instead of moving it mousedown entered drag mode immediately and mouseup committed a move, so a plain click on a clip could drop it onto a neighbouring track -- selection was destructive. A press now has to travel 4px before it becomes a drag; below that it stays a click and commits nothing. The drag maths also ignored the timeline's scrollTop, so once the track area was scrolled vertically a drag resolved to the wrong lane. Both the initial position and the move now include it. Adds, in the same area: - Per-lane import and remove. A lane could be created with + but never removed, and there was no way to place audio on a *specific* lane -- the story-level import always picks a free lane at t=0, which is wrong for a stinger partway in. Remove only offers itself on empty non-default lanes, so it can never take clips with it, and it clears that lane's mixer settings so a later lane reusing the index doesn't inherit a stale mute or duck target. - Snapping, on by default, joining a dragged clip flush to its neighbours or to zero. The radius is in screen pixels, not milliseconds, so the feel stays constant across zoom levels. Toggle in the toolbar. - A live seconds readout above a clip while trimming. Co-Authored-By: Claude Opus 5 --- .../StoriesTab/StoryTrackEditor.tsx | 200 ++++++++++++++++-- 1 file changed, 187 insertions(+), 13 deletions(-) diff --git a/app/src/components/StoriesTab/StoryTrackEditor.tsx b/app/src/components/StoriesTab/StoryTrackEditor.tsx index c9e601753..57ce49633 100644 --- a/app/src/components/StoriesTab/StoryTrackEditor.tsx +++ b/app/src/components/StoriesTab/StoryTrackEditor.tsx @@ -4,6 +4,7 @@ import { GalleryVerticalEnd, Gauge, GripHorizontal, + Magnet, Minus, Pause, Play, @@ -13,8 +14,10 @@ import { Square, Trash2, TrendingUp, + Upload, Volume2, VolumeX, + X, } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import WaveSurfer from 'wavesurfer.js'; @@ -31,6 +34,8 @@ import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; import type { StoryItemDetail } from '@/lib/api/types'; import { + useAddStoryItem, + useDeleteStoryTrack, useDuplicateStoryItem, useMoveStoryItem, useRemoveStoryItem, @@ -350,10 +355,11 @@ interface StoryTrackEditorProps { const TRACK_HEIGHT = 48; const TIME_RULER_HEIGHT = 24; // h-6 = 1.5rem = 24px const SCRUB_BAR_HEIGHT = 16; -// Wide enough for the per-lane mixer strip (mute, solo, volume, duck). The -// label cell is sized from this constant rather than a Tailwind width so the -// timeline's coordinate maths can never drift from what is rendered. -const LABEL_COL_WIDTH = 190; +// Wide enough for the per-lane strip: import, remove, mute, solo, volume, +// duck and the percentage readout. The label cell is sized from this constant +// rather than a Tailwind width so the timeline's coordinate maths can never +// drift from what is rendered. +const LABEL_COL_WIDTH = 246; // Zoom is expressed to the user as how many seconds of timeline are visible // at once. Min scope = the most you can zoom IN; max scope = the entire // project. Default scope is what we land on when the editor first measures. @@ -363,11 +369,20 @@ const FALLBACK_PIXELS_PER_SECOND = 50; // used until containerWidth is measured const DEFAULT_TRACKS = [1, 0, -1]; // Default 3 tracks const MIN_EDITOR_HEIGHT = 120; const MAX_EDITOR_HEIGHT = 500; +// How far the pointer must travel before a press on a clip becomes a drag. +// Below this it stays a click, so selecting a clip can never move it. +const DRAG_THRESHOLD_PX = 4; +// Snap radius, in screen pixels rather than milliseconds so the feel stays the +// same at every zoom level. +const SNAP_TOLERANCE_PX = 8; export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { const [pixelsPerSecond, setPixelsPerSecond] = useState(FALLBACK_PIXELS_PER_SECOND); const hasAppliedDefaultZoomRef = useRef(false); const [draggingItem, setDraggingItem] = useState(null); + // Press that may or may not become a drag. A ref rather than state so the + // threshold check in handleDragMove sees it without waiting for a re-render. + const pendingDragRef = useRef<{ itemId: string; startX: number; startY: number } | null>(null); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); const [isResizing, setIsResizing] = useState(false); @@ -387,6 +402,8 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { const updateSpeed = useUpdateStoryItemSpeed(); const { data: storyTracks } = useStoryTracks(storyId); const upsertTrack = useUpsertStoryTrack(); + const deleteTrack = useDeleteStoryTrack(); + const addItem = useAddStoryItem(); const { toast } = useToast(); // Lanes without a row mix at unity gain, so this map is usually sparse. @@ -402,6 +419,9 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { // earns its keep once a clip lands on it — no need to persist an unused // row across reloads. const [extraTracks, setExtraTracks] = useState([]); + // Snap dragged clips flush to their neighbours. On by default because + // butt-joining by eye is the common case; hold it off for free placement. + const [snapEnabled, setSnapEnabled] = useState(true); // Selection state const selectedClipId = useStoryStore((state) => state.selectedClipId); @@ -541,6 +561,50 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { }); }, [items]); + /** Whether a lane can be removed: only ever the empty, non-default ones, so + * removing a lane can never take clips with it. */ + const canRemoveTrack = useCallback( + (trackNumber: number) => + !DEFAULT_TRACKS.includes(trackNumber) && !items.some((i) => i.track === trackNumber), + [items], + ); + + const handleRemoveTrack = useCallback( + (trackNumber: number) => { + setExtraTracks((prev) => prev.filter((t) => t !== trackNumber)); + // Drop any mixer settings for the lane too, so a later lane reusing the + // index doesn't silently inherit a mute or a duck target. + if (trackSettings.has(trackNumber)) { + deleteTrack.mutate({ storyId, index: trackNumber }); + } + }, + [trackSettings, deleteTrack, storyId], + ); + + /** Import an audio file straight onto a specific lane at the playhead. */ + const handleImportToTrack = useCallback( + async (trackNumber: number, file: File) => { + try { + const generation = await apiClient.importAudio(file); + await addItem.mutateAsync({ + storyId, + data: { + generation_id: generation.id, + track: trackNumber, + start_time_ms: Math.max(0, Math.round(currentTimeMs)), + }, + }); + } catch (error) { + toast({ + title: 'Import failed', + description: error instanceof Error ? error.message : String(error), + variant: 'destructive', + }); + } + }, + [storyId, addItem, currentTimeMs, toast], + ); + // Track container width for full-width minimum useEffect(() => { const container = tracksRef.current; @@ -600,10 +664,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { // Calculate effective duration (accounting for trims and speed). // Must match the mixer's formula in services/stories.py, or the timeline // will draw a re-timed clip at the wrong length and the playhead will drift. - const getEffectiveDuration = (item: StoryItemDetail) => { + const getEffectiveDuration = useCallback((item: StoryItemDetail) => { const trimmed = item.duration * 1000 - (item.trim_start_ms || 0) - (item.trim_end_ms || 0); return trimmed / (item.speed || 1); - }; + }, []); // Calculate total duration (using effective durations) const totalDurationMs = useMemo(() => { @@ -1035,20 +1099,39 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { tracksRef.current.scrollLeft - LABEL_COL_WIDTH, // Subtract ruler height since clips are positioned relative to tracks area, not the scrollable container - y: rect.top - tracksRef.current.getBoundingClientRect().top - TIME_RULER_HEIGHT, + y: + rect.top - + tracksRef.current.getBoundingClientRect().top + + tracksRef.current.scrollTop - + TIME_RULER_HEIGHT, }); - setDraggingItem(item.id); + // Arm the drag, but don't enter drag mode yet — see handleDragMove. A + // plain click must select the clip and nothing else. + pendingDragRef.current = { itemId: item.id, startX: e.clientX, startY: e.clientY }; }; const handleDragMove = useCallback( (e: React.MouseEvent) => { - if (!draggingItem || !tracksRef.current) return; + if (!tracksRef.current) return; + + // Promote an armed press into a real drag only once the pointer has + // travelled far enough. Without this, mousedown alone entered drag mode + // and mouseup committed a move, so simply clicking a clip could drop it + // on a neighbouring track — selection was destructive. + const pending = pendingDragRef.current; + if (pending && !draggingItem) { + const travelled = Math.hypot(e.clientX - pending.startX, e.clientY - pending.startY); + if (travelled < DRAG_THRESHOLD_PX) return; + setDraggingItem(pending.itemId); + } + if (!pendingDragRef.current) return; const rect = tracksRef.current.getBoundingClientRect(); const x = e.clientX - rect.left + tracksRef.current.scrollLeft - dragOffset.x - LABEL_COL_WIDTH; // Subtract ruler height since clips are positioned relative to tracks area - const y = e.clientY - rect.top - dragOffset.y - TIME_RULER_HEIGHT; + const y = + e.clientY - rect.top + tracksRef.current.scrollTop - dragOffset.y - TIME_RULER_HEIGHT; setDragPosition({ x: Math.max(0, x), y }); }, @@ -1056,6 +1139,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { ); const handleDragEnd = useCallback(() => { + // A press that never crossed the threshold was a click, not a drag: clear + // the arming and commit nothing. + pendingDragRef.current = null; + if (!draggingItem || !tracksRef.current) { setDraggingItem(null); return; @@ -1068,13 +1155,40 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { } // Calculate new time from x position - const newTimeMs = Math.max(0, Math.round(pixelsToMs(dragPosition.x))); + const rawTimeMs = Math.max(0, Math.round(pixelsToMs(dragPosition.x))); // Calculate new track from y position const trackIndex = Math.floor(dragPosition.y / TRACK_HEIGHT); const clampedTrackIndex = Math.max(0, Math.min(trackIndex, tracks.length - 1)); const newTrack = tracks[clampedTrackIndex] ?? 0; + // Snap flush against a neighbour on the destination track. Butt-joining + // clips by eye is fiddly at any zoom, and a few ms of silence between two + // lines is audible. Snap distance is in pixels so it stays consistent as + // you zoom rather than getting stickier the further out you go. + const duration = getEffectiveDuration(item); + const newTimeMs = snapEnabled + ? (() => { + const toleranceMs = pixelsToMs(SNAP_TOLERANCE_PX); + const edges: number[] = [0]; + for (const other of items) { + if (other.id === item.id || other.track !== newTrack) continue; + edges.push(other.start_time_ms + getEffectiveDuration(other)); // our start to their end + edges.push(Math.max(0, other.start_time_ms - duration)); // our end to their start + } + let best = rawTimeMs; + let bestGap = toleranceMs; + for (const edge of edges) { + const gap = Math.abs(edge - rawTimeMs); + if (gap <= bestGap) { + best = edge; + bestGap = gap; + } + } + return Math.max(0, Math.round(best)); + })() + : rawTimeMs; + // Check if position changed if (newTimeMs !== item.start_time_ms || newTrack !== item.track) { moveItem.mutate( @@ -1099,7 +1213,18 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { } setDraggingItem(null); - }, [draggingItem, dragPosition, items, tracks, pixelsToMs, storyId, moveItem, toast]); + }, [ + draggingItem, + dragPosition, + items, + tracks, + pixelsToMs, + storyId, + moveItem, + toast, + snapEnabled, + getEffectiveDuration, + ]); // Get track index for rendering const getTrackIndex = (trackNumber: number) => tracks.indexOf(trackNumber); @@ -1299,6 +1424,18 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { {/* Clip editing controls - center */} + + {selectedClipId && (
+ )} + {/* Live length while trimming — dragging a handle blind and + checking afterwards is the slow way to hit a target. */} + {isTrimming && ( + + {(effectiveDuration / 1000).toFixed(2)}s + + )} diff --git a/app/src/components/VoiceProfiles/FolderSection.tsx b/app/src/components/VoiceProfiles/FolderSection.tsx index 31ba98827..09eaf19fe 100644 --- a/app/src/components/VoiceProfiles/FolderSection.tsx +++ b/app/src/components/VoiceProfiles/FolderSection.tsx @@ -64,18 +64,22 @@ export function FolderSection({ return (
-
+ {/* Shaded and bold so the header reads as a container rather than + blending into the rows it holds. */} +
{folderId && ( @@ -113,7 +117,11 @@ export function FolderSection({ )}
- {!collapsed &&
{children}
} + {/* Indent and rule the members so they read as belonging to the header + above rather than as a flat continuation of the list. */} + {!collapsed && ( +
{children}
+ )} From 5a1bd5ccf316aed8c8d81d532f34dbd62db6deb9 Mon Sep 17 00:00:00 2001 From: Lvigentini Date: Fri, 7 Aug 2026 12:16:59 +1000 Subject: [PATCH 10/24] fix(stories): restore clip dragging The drag threshold added in 0e05d41 deadlocked. The move/up handlers were attached only while draggingItem was set, but the threshold that promotes a press into a drag lives inside handleDragMove -- so the handler was never attached, the threshold never ran, and no clip could be dragged at all. Track the armed press in state as well as the ref, and attach the handlers for either an armed press or a live drag. The ref still carries the coordinates so the threshold reads them without waiting on a re-render. Co-Authored-By: Claude Opus 5 --- .../StoriesTab/StoryTrackEditor.tsx | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/app/src/components/StoriesTab/StoryTrackEditor.tsx b/app/src/components/StoriesTab/StoryTrackEditor.tsx index 57ce49633..fef29a07f 100644 --- a/app/src/components/StoriesTab/StoryTrackEditor.tsx +++ b/app/src/components/StoriesTab/StoryTrackEditor.tsx @@ -380,9 +380,11 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { const [pixelsPerSecond, setPixelsPerSecond] = useState(FALLBACK_PIXELS_PER_SECOND); const hasAppliedDefaultZoomRef = useRef(false); const [draggingItem, setDraggingItem] = useState(null); - // Press that may or may not become a drag. A ref rather than state so the - // threshold check in handleDragMove sees it without waiting for a re-render. + // Press that may or may not become a drag. The ref carries the coordinates + // so the threshold check reads them without waiting for a re-render; the + // state exists purely to attach the move/up handlers on that same press. const pendingDragRef = useRef<{ itemId: string; startX: number; startY: number } | null>(null); + const [pressedItemId, setPressedItemId] = useState(null); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); const [isResizing, setIsResizing] = useState(false); @@ -1108,6 +1110,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { // Arm the drag, but don't enter drag mode yet — see handleDragMove. A // plain click must select the clip and nothing else. pendingDragRef.current = { itemId: item.id, startX: e.clientX, startY: e.clientY }; + setPressedItemId(item.id); }; const handleDragMove = useCallback( @@ -1142,6 +1145,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { // A press that never crossed the threshold was a click, not a drag: clear // the arming and commit nothing. pendingDragRef.current = null; + setPressedItemId(null); if (!draggingItem || !tracksRef.current) { setDraggingItem(null); @@ -1626,15 +1630,21 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
- {/* Timeline scroll container */} + {/* Timeline scroll container. + + The drag handlers below attach while a press is merely *armed* as + well as while a drag is live. Listening only on draggingItem + deadlocked: the threshold that promotes a press into a drag lives + in handleDragMove, which could never run because the handler was + not attached yet. */} {/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */}
{/* Ruler row: corner spacer + time ruler, sticky to top */}
Date: Fri, 7 Aug 2026 12:48:30 +1000 Subject: [PATCH 11/24] feat: story folders, drag-and-drop filing, ripple move, exact clip timing Story folders ------------- Stories gain nested folders, matching clips. The kind discriminator grows a third value and the flat-vs-nested rule moves from a hardcoded voice check to a _FLAT_KINDS set, so only voices stay one level deep. Selecting a parent folder shows the whole subtree, since a story records only its own folder. The clip folder tree is now generic over kind rather than being copied a third time; stories reuse it with their own labels. Drag and drop ------------- Voices, clips and stories can be dragged onto a folder header, or onto Uncategorised to unfile them. Uses native HTML5 drag and drop rather than dnd-kit: these lists need a drop target, not sortable reordering, and it keeps the folder headers as plain elements. The payload carries the folder kind so a voice dropped on a clip folder is ignored rather than failing server-side. Timeline -------- Ripple move, off by default: dragging a clip carries everything later on the same track by the same delta, so inserting an intro pushes the track along instead of leaving a hole. Off by default because it rewrites clips the user did not touch. Right-click a clip for exact timing. Length and speed are two views of one number, so editing either recomputes the other and only the resulting speed is stored -- which keeps it consistent with the mixer, where speed is what actually changes the rendered length. Co-Authored-By: Claude Opus 5 --- app/src/components/History/ClipFolderTree.tsx | 79 ++++++-- app/src/components/History/HistoryTable.tsx | 11 +- .../components/StoriesTab/StoryChatItem.tsx | 2 +- app/src/components/StoriesTab/StoryList.tsx | 123 ++++++++++-- .../StoriesTab/StoryTrackEditor.tsx | 177 ++++++++++++++++-- .../VoiceProfiles/FolderSection.tsx | 28 ++- .../components/VoiceProfiles/ProfileForm.tsx | 4 +- .../components/VoiceProfiles/ProfileList.tsx | 8 +- .../components/VoiceProfiles/ProfileRow.tsx | 3 + app/src/i18n/locales/en/translation.json | 6 + app/src/lib/api/client.ts | 11 ++ app/src/lib/api/types.ts | 6 +- app/src/lib/hooks/useFolders.ts | 13 ++ app/src/lib/utils/folderDrag.ts | 51 +++++ app/src/stores/uiStore.ts | 9 +- backend/database/migrations.py | 4 + backend/database/models.py | 2 + backend/models.py | 3 +- backend/routes/folders.py | 38 +++- backend/tests/test_folders.py | 59 ++++++ 20 files changed, 573 insertions(+), 64 deletions(-) create mode 100644 app/src/lib/utils/folderDrag.ts diff --git a/app/src/components/History/ClipFolderTree.tsx b/app/src/components/History/ClipFolderTree.tsx index fda0c6c54..eb63229c8 100644 --- a/app/src/components/History/ClipFolderTree.tsx +++ b/app/src/components/History/ClipFolderTree.tsx @@ -27,7 +27,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; -import type { FolderResponse } from '@/lib/api/types'; +import type { FolderKind, FolderResponse } from '@/lib/api/types'; import { useCreateFolder, useDeleteFolder, @@ -36,6 +36,7 @@ import { useUpdateFolder, } from '@/lib/hooks/useFolders'; import { cn } from '@/lib/utils/cn'; +import { isFolderDrag, readFolderDragData } from '@/lib/utils/folderDrag'; import { useUIStore } from '@/stores/uiStore'; /** @@ -53,8 +54,21 @@ export type ClipFolderSelection = interface ClipFolderTreeProps { selection: ClipFolderSelection; onSelect: (selection: ClipFolderSelection) => void; + /** Which folder kind to manage. Clips and stories share this tree because + * both nest and both need the same create/rename/move/delete affordances. */ + kind?: FolderKind; + /** Heading above the tree. */ + title?: string; + /** Label for the "no filter" row. */ + allLabel?: string; + /** Called when an item is dropped on a folder. folderId is null for the + * Uncategorised row. */ + onDropItem?: (itemId: string, folderId: string | null) => void; } +/** Sentinel for highlighting the Uncategorised row, which has no folder id. */ +const UNCATEGORISED_DROP_ID = '__uncategorised__'; + /** A folder plus its children, built once per folder list change. */ interface TreeNode { folder: FolderResponse; @@ -77,17 +91,24 @@ function buildTree(folders: FolderResponse[]): TreeNode[] { return roots; } -export function ClipFolderTree({ selection, onSelect }: ClipFolderTreeProps) { +export function ClipFolderTree({ + selection, + onSelect, + kind = 'generation', + title, + allLabel, + onDropItem, +}: ClipFolderTreeProps) { const { t } = useTranslation(); - const { data: folders } = useFolders('generation'); + const { data: folders } = useFolders(kind); - const collapsedIds = useUIStore((state) => state.collapsedFolderIds.generation); + const collapsedIds = useUIStore((state) => state.collapsedFolderIds[kind] ?? []); const toggleCollapsed = useUIStore((state) => state.toggleFolderCollapsed); - const createFolder = useCreateFolder('generation'); - const updateFolder = useUpdateFolder('generation'); - const detachFolder = useDetachFolder('generation'); - const deleteFolder = useDeleteFolder('generation'); + const createFolder = useCreateFolder(kind); + const updateFolder = useUpdateFolder(kind); + const detachFolder = useDetachFolder(kind); + const deleteFolder = useDeleteFolder(kind); const [dialog, setDialog] = useState< | { mode: 'create'; parentId: string | null } @@ -96,6 +117,7 @@ export function ClipFolderTree({ selection, onSelect }: ClipFolderTreeProps) { | null >(null); const [draftName, setDraftName] = useState(''); + const [dragOverId, setDragOverId] = useState(null); const tree = useMemo(() => buildTree(folders ?? []), [folders]); @@ -127,16 +149,32 @@ export function ClipFolderTree({ selection, onSelect }: ClipFolderTreeProps) { {/* Shaded and bold, matching the voice folders, so a folder never reads as just another row in the list. */}
{ + if (!onDropItem || !isFolderDrag(e)) return; + // preventDefault is what marks this a valid drop target. + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDragOverId(folder.id); + }} + onDragLeave={() => setDragOverId((id) => (id === folder.id ? null : id))} + onDrop={(e) => { + setDragOverId(null); + const payload = readFolderDragData(e); + if (!payload || payload.kind !== kind) return; + e.preventDefault(); + onDropItem?.(payload.id, folder.id); + }} className={cn( - 'group/node my-0.5 flex items-center gap-1 rounded border border-border/60 bg-muted/60 pr-1', + 'group/node my-0.5 flex items-center gap-1 rounded border border-border/60 bg-muted/60 pr-1 transition-colors', isSelected && 'border-accent/60 bg-accent/40', + dragOverId === folder.id && 'border-accent bg-accent/50 ring-1 ring-accent', )} style={{ marginLeft: `${depth * 12}px` }} > {children.length > 0 ? ( {tree.map((node) => renderNode(node, 0))} @@ -241,9 +279,24 @@ export function ClipFolderTree({ selection, onSelect }: ClipFolderTreeProps) { + + {selectedClipId && (
+ + setLengthTargetId(null)}> + + + Clip timing + + + {/* Length and speed are two views of the same number: editing one + recomputes the other, and only the resulting speed is stored. */} +
+
+ Target length +
+ { + setLengthDraft(e.target.value); + const target = Number.parseFloat(e.target.value); + const clip = items.find((i) => i.id === lengthTargetId); + if (clip && target > 0) { + const natural = + clip.duration * 1000 - (clip.trim_start_ms || 0) - (clip.trim_end_ms || 0); + setSpeedDraft((natural / 1000 / target).toFixed(2)); + } + }} + className="h-8" + inputMode="decimal" + aria-label="Target length in seconds" + /> + s +
+
+ +
+ Speed +
+ { + setSpeedDraft(e.target.value); + const rate = Number.parseFloat(e.target.value); + const clip = items.find((i) => i.id === lengthTargetId); + if (clip && rate > 0) { + const natural = + clip.duration * 1000 - (clip.trim_start_ms || 0) - (clip.trim_end_ms || 0); + setLengthDraft((natural / 1000 / rate).toFixed(2)); + } + }} + className="h-8" + inputMode="decimal" + aria-label="Speed multiplier" + /> + x +
+
+

+ Pitch is preserved. 0.25x to 4x; outside that the stretch smears speech. +

+
+ + + + + +
+
); } diff --git a/app/src/components/VoiceProfiles/FolderSection.tsx b/app/src/components/VoiceProfiles/FolderSection.tsx index 09eaf19fe..a78869ff8 100644 --- a/app/src/components/VoiceProfiles/FolderSection.tsx +++ b/app/src/components/VoiceProfiles/FolderSection.tsx @@ -18,6 +18,8 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils/cn'; +import { isFolderDrag, readFolderDragData } from '@/lib/utils/folderDrag'; interface FolderSectionProps { /** Null renders the Uncategorised bucket, which has no menu and no id. */ @@ -28,6 +30,8 @@ interface FolderSectionProps { onToggle: () => void; onRename?: (name: string) => void; onDelete?: () => void; + /** Called when an item is dropped on this header. */ + onDropItem?: (itemId: string) => void; children: React.ReactNode; } @@ -47,12 +51,14 @@ export function FolderSection({ onToggle, onRename, onDelete, + onDropItem, children, }: FolderSectionProps) { const { t } = useTranslation(); const [renameOpen, setRenameOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const [draftName, setDraftName] = useState(name); + const [dragOver, setDragOver] = useState(false); const Chevron = collapsed ? ChevronRight : ChevronDown; @@ -66,7 +72,27 @@ export function FolderSection({
{/* Shaded and bold so the header reads as a container rather than blending into the rows it holds. */} -
+
{ + if (!onDropItem || !isFolderDrag(e)) return; + // preventDefault is what marks this element as a valid drop target. + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={(e) => { + setDragOver(false); + const payload = readFolderDragData(e); + if (!payload || payload.kind !== 'voice') return; + e.preventDefault(); + onDropItem?.(payload.id); + }} + className={cn( + 'group/folder flex items-center gap-1 rounded-md border border-border/60 bg-muted/60 px-1 transition-colors', + dragOver && 'border-accent bg-accent/40 ring-1 ring-accent', + )} + >
+ { + e.stopPropagation(); + onPreview?.(profile); + }} + aria-label={t('profiles.preview.action')} + /> { diff --git a/app/src/components/VoiceProfiles/ProfileList.tsx b/app/src/components/VoiceProfiles/ProfileList.tsx index 0bb84a540..d7059eb5b 100644 --- a/app/src/components/VoiceProfiles/ProfileList.tsx +++ b/app/src/components/VoiceProfiles/ProfileList.tsx @@ -26,6 +26,7 @@ import { FolderSection } from './FolderSection'; import { ProfileCard } from './ProfileCard'; import { ProfileForm } from './ProfileForm'; import { ProfileRow } from './ProfileRow'; +import { VoicePreviewDialog } from './VoicePreviewDialog'; /** Engines that use preset (built-in) voices instead of cloned profiles. */ const PRESET_ENGINES = new Set(['kokoro', 'qwen_custom_voice']); @@ -53,6 +54,7 @@ export function ProfileList() { const [newFolderOpen, setNewFolderOpen] = useState(false); const [newFolderName, setNewFolderName] = useState(''); + const [previewProfile, setPreviewProfile] = useState(null); const cardRefs = useRef>(new Map()); @@ -153,12 +155,17 @@ export function ProfileList() { }} > {viewMode === 'card' ? ( - + ) : ( )}
@@ -288,6 +295,11 @@ export function ProfileList() { + !open && setPreviewProfile(null)} + /> +
); diff --git a/app/src/components/VoiceProfiles/ProfileRow.tsx b/app/src/components/VoiceProfiles/ProfileRow.tsx index 717f6b555..8788da641 100644 --- a/app/src/components/VoiceProfiles/ProfileRow.tsx +++ b/app/src/components/VoiceProfiles/ProfileRow.tsx @@ -3,9 +3,11 @@ import { Download, Edit, FolderInput, + GripVertical, MoreHorizontal, Sparkles, Trash2, + Volume2, Wand2, } from 'lucide-react'; import { useState } from 'react'; @@ -51,6 +53,8 @@ interface ProfileRowProps { disabled?: boolean; /** Voice folders, for the "Move to" submenu. */ folders: FolderResponse[]; + /** Open the preview dialog for this voice. */ + onPreview?: (profile: VoiceProfileResponse) => void; } /** @@ -61,7 +65,7 @@ interface ProfileRowProps { * at list density a row is ~48px tall, too tight for four icon buttons * without crowding the text. */ -export function ProfileRow({ profile, disabled, folders }: ProfileRowProps) { +export function ProfileRow({ profile, disabled, folders, onPreview }: ProfileRowProps) { const { t } = useTranslation(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const { toast } = useToast(); @@ -121,14 +125,26 @@ export function ProfileRow({ profile, disabled, folders }: ProfileRowProps) { menu is itself a button, and nesting interactive elements is invalid. The selectable area is a real - {tree.map((node) => renderNode(node, 0))} + {/* Folders scroll on their own. With a few dozen folders the list + otherwise pushes the clips off-screen entirely, and "All clips" and + "Uncategorised" stay pinned outside it so both are always reachable. */} +
+ {tree.map((node) => renderNode(node, 0))} +
+ )}
); } diff --git a/app/src/i18n/locales/en/translation.json b/app/src/i18n/locales/en/translation.json index bd2879cf1..bcefdc99f 100644 --- a/app/src/i18n/locales/en/translation.json +++ b/app/src/i18n/locales/en/translation.json @@ -1,6 +1,7 @@ { "common": { "cancel": "Cancel", + "clearSearch": "Clear search", "save": "Save", "delete": "Delete", "edit": "Edit", diff --git a/app/src/i18n/locales/es/translation.json b/app/src/i18n/locales/es/translation.json index 96359d349..9da90815c 100644 --- a/app/src/i18n/locales/es/translation.json +++ b/app/src/i18n/locales/es/translation.json @@ -1,6 +1,7 @@ { "common": { "cancel": "Cancelar", + "clearSearch": "Borrar búsqueda", "save": "Guardar", "delete": "Eliminar", "edit": "Editar", diff --git a/app/src/i18n/locales/fr/translation.json b/app/src/i18n/locales/fr/translation.json index bb97947be..b1aa76856 100644 --- a/app/src/i18n/locales/fr/translation.json +++ b/app/src/i18n/locales/fr/translation.json @@ -1,6 +1,7 @@ { "common": { "cancel": "Annuler", + "clearSearch": "Effacer la recherche", "save": "Enregistrer", "delete": "Supprimer", "edit": "Modifier", diff --git a/app/src/i18n/locales/it/translation.json b/app/src/i18n/locales/it/translation.json index 51605d1b6..5f46a267b 100644 --- a/app/src/i18n/locales/it/translation.json +++ b/app/src/i18n/locales/it/translation.json @@ -1,6 +1,7 @@ { "common": { "cancel": "Annulla", + "clearSearch": "Cancella ricerca", "save": "Salva", "delete": "Elimina", "edit": "Modifica", diff --git a/app/src/i18n/locales/ja/translation.json b/app/src/i18n/locales/ja/translation.json index 0f3fb1544..1bce7e9d6 100644 --- a/app/src/i18n/locales/ja/translation.json +++ b/app/src/i18n/locales/ja/translation.json @@ -1,6 +1,7 @@ { "common": { "cancel": "キャンセル", + "clearSearch": "検索をクリア", "save": "保存", "delete": "削除", "edit": "編集", diff --git a/app/src/i18n/locales/ko/translation.json b/app/src/i18n/locales/ko/translation.json index f53225736..c59d95a71 100644 --- a/app/src/i18n/locales/ko/translation.json +++ b/app/src/i18n/locales/ko/translation.json @@ -1,6 +1,7 @@ { "common": { "cancel": "취소", + "clearSearch": "검색 지우기", "save": "저장", "delete": "삭제", "edit": "편집", diff --git a/app/src/i18n/locales/pt-BR/translation.json b/app/src/i18n/locales/pt-BR/translation.json index 2267a1993..3a967f7c1 100644 --- a/app/src/i18n/locales/pt-BR/translation.json +++ b/app/src/i18n/locales/pt-BR/translation.json @@ -1,6 +1,7 @@ { "common": { "cancel": "Cancelar", + "clearSearch": "Limpar busca", "save": "Salvar", "delete": "Excluir", "edit": "Editar", diff --git a/app/src/i18n/locales/zh-CN/translation.json b/app/src/i18n/locales/zh-CN/translation.json index 2b2279dd6..fee22fa74 100644 --- a/app/src/i18n/locales/zh-CN/translation.json +++ b/app/src/i18n/locales/zh-CN/translation.json @@ -1,6 +1,7 @@ { "common": { "cancel": "取消", + "clearSearch": "清除搜索", "save": "保存", "delete": "删除", "edit": "编辑", diff --git a/app/src/i18n/locales/zh-TW/translation.json b/app/src/i18n/locales/zh-TW/translation.json index 7690feddb..e419c4051 100644 --- a/app/src/i18n/locales/zh-TW/translation.json +++ b/app/src/i18n/locales/zh-TW/translation.json @@ -1,6 +1,7 @@ { "common": { "cancel": "取消", + "clearSearch": "清除搜尋", "save": "儲存", "delete": "刪除", "edit": "編輯", diff --git a/app/src/lib/hooks/useHistory.ts b/app/src/lib/hooks/useHistory.ts index 51983339c..bc182dd61 100644 --- a/app/src/lib/hooks/useHistory.ts +++ b/app/src/lib/hooks/useHistory.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { apiClient } from '@/lib/api/client'; import type { HistoryQuery } from '@/lib/api/types'; import { usePlatform } from '@/platform/PlatformContext'; @@ -7,6 +7,12 @@ export function useHistory(query?: HistoryQuery) { return useQuery({ queryKey: ['history', query], queryFn: () => apiClient.listHistory(query), + // Keep showing the previous results while a new folder or search term + // loads. Without this the list empties on every keystroke and the UI + // flashes its empty state between each request. Callers can tell the + // difference via isPlaceholderData. No effect on callers whose query + // never changes. + placeholderData: keepPreviousData, }); } From 9a1a328d973e0ac9564cd0dcd34a071d0bc26fa0 Mon Sep 17 00:00:00 2001 From: Lvigentini Date: Sun, 9 Aug 2026 20:41:08 +1000 Subject: [PATCH 17/24] fix(stories): mixer slider, ducking determinism, and silent failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remaining review findings on #1007. Volume slider wrote on every step --------------------------------- `onValueChange` fires continuously while dragging, so one gesture issued dozens of PUTs, each invalidating the track query, and out-of-order responses snapped the thumb backwards mid-drag. Now drives local state while dragging and persists on `onValueCommit`, which is what ClipVolumePopover already did. Ducking depended on dict order ------------------------------ The mixdown applied each envelope in place while later iterations read the same lanes. With two lanes ducking under each other, whichever was processed first read an untouched source and the second read an already-ducked one — the same project mixed differently depending on dict order. Envelopes are now computed from the pre-ducking lanes, then applied. A lane could also duck under itself, attenuating by its own envelope, which is quietest exactly where the lane is loudest. Rejected at the route. Failures that looked like normal states --------------------------------------- Duplicating from a voice card had no success or error callbacks, so a failure just left the button idle — the list view already toasts both. And the preview dialog treated a failed samples request as "this voice has no reference audio", which is the normal state for preset and designed voices; a load error now says so. Not changed: `storyContent.export.normalized` was flagged for hardcoding MP3, but the handler passes 'mp3' explicitly, so the label matches the behaviour. Offering normalisation for other formats is a feature, not a fix. Co-Authored-By: Claude Opus 5 (1M context) --- .../StoriesTab/TrackMixerControls.tsx | 17 ++++++++++-- .../components/VoiceProfiles/ProfileCard.tsx | 27 ++++++++++++++++++- .../VoiceProfiles/VoicePreviewDialog.tsx | 8 +++++- app/src/i18n/locales/en/translation.json | 1 + backend/routes/stories.py | 4 +++ backend/services/stories.py | 13 +++++++-- 6 files changed, 64 insertions(+), 6 deletions(-) diff --git a/app/src/components/StoriesTab/TrackMixerControls.tsx b/app/src/components/StoriesTab/TrackMixerControls.tsx index 82e7af9df..d3348539b 100644 --- a/app/src/components/StoriesTab/TrackMixerControls.tsx +++ b/app/src/components/StoriesTab/TrackMixerControls.tsx @@ -1,4 +1,5 @@ import { Headphones, VolumeX, Waves } from 'lucide-react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import { @@ -52,6 +53,17 @@ export function TrackMixerControls({ // an explicit mute, so the user knows why a lane went quiet. const dimmedBySolo = anySoloed && !soloed && !muted; + // The slider drives local state while dragging and only persists on + // release. Writing on every step fired dozens of PUTs per drag, each + // invalidating the track query, and out-of-order responses snapped the + // thumb backwards mid-gesture. Same approach as ClipVolumePopover. + const [localVolume, setLocalVolume] = useState(volume); + // Re-sync when the persisted value changes from elsewhere, or when this + // row is reused for a different lane. + useEffect(() => { + setLocalVolume(volume); + }, [volume]); + const patch = (changes: Partial) => onChange({ volume, muted, soloed, duck_under_track: duckUnder, ...changes }); @@ -80,13 +92,14 @@ export function TrackMixerControls({ patch({ volume: next })} + onValueChange={([next]) => setLocalVolume(next)} + onValueCommit={([next]) => patch({ volume: next })} /> diff --git a/app/src/components/VoiceProfiles/ProfileCard.tsx b/app/src/components/VoiceProfiles/ProfileCard.tsx index 0fd5ae426..0cdd04d07 100644 --- a/app/src/components/VoiceProfiles/ProfileCard.tsx +++ b/app/src/components/VoiceProfiles/ProfileCard.tsx @@ -1,6 +1,7 @@ import { Copy, Download, Edit, Sparkles, Trash2, Volume2, Wand2 } from 'lucide-react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useToast } from '@/components/ui/use-toast'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -33,6 +34,7 @@ interface ProfileCardProps { export function ProfileCard({ profile, disabled, onPreview }: ProfileCardProps) { const { t } = useTranslation(); + const { toast } = useToast(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const deleteProfile = useDeleteProfile(); @@ -69,6 +71,29 @@ export function ProfileCard({ profile, disabled, onPreview }: ProfileCardProps) setDeleteDialogOpen(false); }; + // Same feedback as the list view. Without the callbacks a failed + // duplicate did nothing at all — the button just went idle again. + const handleDuplicate = () => { + duplicateProfile.mutate( + { profileId: profile.id }, + { + onSuccess: (copy) => { + toast({ + title: t('profiles.duplicate.successTitle'), + description: t('profiles.duplicate.successDescription', { name: copy.name }), + }); + }, + onError: (error) => { + toast({ + title: t('profiles.duplicate.failedTitle'), + description: error.message, + variant: 'destructive', + }); + }, + }, + ); + }; + const handleExport = (e: React.MouseEvent) => { e.stopPropagation(); exportProfile.mutate(profile.id); @@ -144,7 +169,7 @@ export function ProfileCard({ profile, disabled, onPreview }: ProfileCardProps) icon={Copy} onClick={(e) => { e.stopPropagation(); - duplicateProfile.mutate({ profileId: profile.id }); + handleDuplicate(); }} disabled={duplicateProfile.isPending} aria-label={t('profiles.card.duplicate')} diff --git a/app/src/components/VoiceProfiles/VoicePreviewDialog.tsx b/app/src/components/VoiceProfiles/VoicePreviewDialog.tsx index e2c4912df..b16d7c1bb 100644 --- a/app/src/components/VoiceProfiles/VoicePreviewDialog.tsx +++ b/app/src/components/VoiceProfiles/VoicePreviewDialog.tsx @@ -28,7 +28,7 @@ interface VoicePreviewDialogProps { */ export function VoicePreviewDialog({ profile, onOpenChange }: VoicePreviewDialogProps) { const { t } = useTranslation(); - const { data: samples, isLoading } = useProfileSamples(profile?.id ?? ''); + const { data: samples, isLoading, isError } = useProfileSamples(profile?.id ?? ''); return ( @@ -52,6 +52,12 @@ export function VoicePreviewDialog({ profile, onOpenChange }: VoicePreviewDialog
{isLoading ? (

{t('common.loading')}

+ ) : isError ? ( + // A failed request must not read as "this voice has no audio" — + // one is a problem to retry, the other is normal for presets. +

+ {t('profiles.preview.loadFailed')} +

) : !samples || samples.length === 0 ? ( // Preset and designed voices have no reference audio to play — // say so rather than showing an empty box. diff --git a/app/src/i18n/locales/en/translation.json b/app/src/i18n/locales/en/translation.json index bcefdc99f..e6cbf2e5a 100644 --- a/app/src/i18n/locales/en/translation.json +++ b/app/src/i18n/locales/en/translation.json @@ -407,6 +407,7 @@ }, "preview": { "action": "Preview voice", + "loadFailed": "Couldn't load this voice's samples.", "noSamples": "This voice has no reference audio to play. Preset and designed voices are generated on demand." }, "row": { diff --git a/backend/routes/stories.py b/backend/routes/stories.py index 4cdd41b33..15bcc4196 100644 --- a/backend/routes/stories.py +++ b/backend/routes/stories.py @@ -212,6 +212,10 @@ async def upsert_story_track( db: Session = Depends(get_db), ): """Create or update one lane's mixer settings.""" + # A lane ducking under itself would attenuate by its own envelope — quieter + # wherever it is loudest, which is never what anyone means. + if data.duck_under_track is not None and data.duck_under_track == index: + raise HTTPException(status_code=400, detail="A track cannot duck under itself") track = await stories.upsert_story_track(story_id, index, data, db) if track is None: raise HTTPException(status_code=404, detail="Story not found") diff --git a/backend/services/stories.py b/backend/services/stories.py index 279ff0b09..e846a83ba 100644 --- a/backend/services/stories.py +++ b/backend/services/stories.py @@ -1242,14 +1242,23 @@ async def export_story_audio( # --- ducking ------------------------------------------------------------ # Runs after gain so the envelope reflects what will actually be heard, # and after mute/solo so a silenced lane ducks nothing. - for index, lane in lanes.items(): + # Envelopes are computed from the pre-ducking lanes, before any are + # attenuated. Applying them inside the loop instead would make the result + # depend on dict order whenever two lanes duck under each other: whichever + # ran first would read an untouched source, the second an already-ducked + # one. Same input, different mixdown. + envelopes: dict[int, np.ndarray] = {} + for index in lanes: track = tracks.get(index) if track is None or track.duck_under_track is None: continue source = lanes.get(track.duck_under_track) if source is None: continue - lane *= _duck_envelope(source, project_sr) + envelopes[index] = _duck_envelope(source, project_sr) + + for index, envelope in envelopes.items(): + lanes[index] *= envelope final_audio = np.zeros((2, total_samples), dtype=np.float32) for lane in lanes.values(): From 453021788e03d745af37126a6aee2029d0328511 Mon Sep 17 00:00:00 2001 From: Lvigentini Date: Sun, 9 Aug 2026 22:38:21 +1000 Subject: [PATCH 18/24] fix(clips): reset paging on folder moves, localize the drag handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings CodeRabbit could only post outside the diff. Moved clips could linger ------------------------ Same bug as the one fixed on the folders PR, which this branch predates, so the fix never reached here. Past page 0 the refreshed first page is appended to allHistory rather than replacing it, leaving a clip visible in a folder it no longer belongs to. All three move paths — drag-and-drop onto a folder, Move To, and Uncategorised — now go through one handler that drops to page 0. This branch has the drag-and-drop path the other one doesn't, so it needed covering here regardless. history.dragHandle was English-only ----------------------------------- Defined only in the English bundle while the `history` block exists in all nine, so every other locale silently fell back. It is an aria-label, which is a worse thing to leave untranslated than visible text. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/components/History/HistoryTable.tsx | 22 ++++++++++----------- app/src/i18n/locales/es/translation.json | 1 + app/src/i18n/locales/fr/translation.json | 1 + app/src/i18n/locales/it/translation.json | 1 + app/src/i18n/locales/ja/translation.json | 1 + app/src/i18n/locales/ko/translation.json | 1 + app/src/i18n/locales/pt-BR/translation.json | 1 + app/src/i18n/locales/zh-CN/translation.json | 1 + app/src/i18n/locales/zh-TW/translation.json | 1 + 9 files changed, 19 insertions(+), 11 deletions(-) diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index b701be976..ff2417c82 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -102,6 +102,14 @@ export function HistoryTable() { const { data: clipFolders } = useFolders('generation'); const setGenerationFolder = useSetGenerationFolder(); + // Invalidating the query is not enough on its own: allHistory accumulates + // pages, and past page 0 the refreshed first page is appended rather than + // replacing it — so a clip moved out of the current folder stays visible. + // Dropping back to page 0 makes the next response replace the list. + const moveToFolder = (generationId: string, folderId: string | null) => { + setGenerationFolder.mutate({ generationId, folderId }, { onSuccess: () => setPage(0) }); + }; + // Folder first, then text — the folder is a scope, the search runs inside it. // Debounced because every keystroke would otherwise be a request; the search // runs server-side so it covers every clip in scope, not just loaded pages. @@ -487,9 +495,7 @@ export function HistoryTable() { - setGenerationFolder.mutate({ generationId, folderId }) - } + onDropItem={moveToFolder} /> {/* Also outside the empty-state branch, for the same reason as the tree: @@ -785,10 +791,7 @@ export function HistoryTable() { - setGenerationFolder.mutate({ - generationId: gen.id, - folderId: null, - }) + moveToFolder(gen.id, null) } > {t('folders.uncategorised')} @@ -798,10 +801,7 @@ export function HistoryTable() { key={folder.id} disabled={folder.id === gen.folder_id} onClick={() => - setGenerationFolder.mutate({ - generationId: gen.id, - folderId: folder.id, - }) + moveToFolder(gen.id, folder.id) } > {folder.name} diff --git a/app/src/i18n/locales/es/translation.json b/app/src/i18n/locales/es/translation.json index 9da90815c..bab41f549 100644 --- a/app/src/i18n/locales/es/translation.json +++ b/app/src/i18n/locales/es/translation.json @@ -646,6 +646,7 @@ } }, "history": { + "dragHandle": "Arrastrar a una carpeta", "empty": "Aún no hay generaciones de voz…", "searchPlaceholder": "Buscar clips…", "emptySearch": "Ningún clip coincide con esa búsqueda.", diff --git a/app/src/i18n/locales/fr/translation.json b/app/src/i18n/locales/fr/translation.json index b1aa76856..80f293a42 100644 --- a/app/src/i18n/locales/fr/translation.json +++ b/app/src/i18n/locales/fr/translation.json @@ -646,6 +646,7 @@ } }, "history": { + "dragHandle": "Faire glisser vers un dossier", "empty": "Encore aucune génération vocale…", "searchPlaceholder": "Rechercher des clips…", "emptySearch": "Aucun clip ne correspond à cette recherche.", diff --git a/app/src/i18n/locales/it/translation.json b/app/src/i18n/locales/it/translation.json index 5f46a267b..b9c07bc35 100644 --- a/app/src/i18n/locales/it/translation.json +++ b/app/src/i18n/locales/it/translation.json @@ -646,6 +646,7 @@ } }, "history": { + "dragHandle": "Trascina in una cartella", "empty": "Ancora nessuna generazione vocale…", "searchPlaceholder": "Cerca clip…", "emptySearch": "Nessuna clip corrisponde a questa ricerca.", diff --git a/app/src/i18n/locales/ja/translation.json b/app/src/i18n/locales/ja/translation.json index 1bce7e9d6..a5b694b29 100644 --- a/app/src/i18n/locales/ja/translation.json +++ b/app/src/i18n/locales/ja/translation.json @@ -646,6 +646,7 @@ } }, "history": { + "dragHandle": "フォルダーにドラッグ", "empty": "音声生成はまだありません…", "searchPlaceholder": "クリップを検索…", "emptySearch": "その検索に一致するクリップはありません。", diff --git a/app/src/i18n/locales/ko/translation.json b/app/src/i18n/locales/ko/translation.json index c59d95a71..f28cbcb59 100644 --- a/app/src/i18n/locales/ko/translation.json +++ b/app/src/i18n/locales/ko/translation.json @@ -646,6 +646,7 @@ } }, "history": { + "dragHandle": "폴더로 드래그", "empty": "아직 생성된 음성이 없습니다…", "searchPlaceholder": "클립 검색…", "emptySearch": "검색과 일치하는 클립이 없습니다.", diff --git a/app/src/i18n/locales/pt-BR/translation.json b/app/src/i18n/locales/pt-BR/translation.json index 3a967f7c1..55e1419a0 100644 --- a/app/src/i18n/locales/pt-BR/translation.json +++ b/app/src/i18n/locales/pt-BR/translation.json @@ -646,6 +646,7 @@ } }, "history": { + "dragHandle": "Arrastar para uma pasta", "empty": "Nenhuma geração de voz ainda…", "searchPlaceholder": "Buscar clipes…", "emptySearch": "Nenhum clipe corresponde a essa busca.", diff --git a/app/src/i18n/locales/zh-CN/translation.json b/app/src/i18n/locales/zh-CN/translation.json index fee22fa74..57f926c16 100644 --- a/app/src/i18n/locales/zh-CN/translation.json +++ b/app/src/i18n/locales/zh-CN/translation.json @@ -646,6 +646,7 @@ } }, "history": { + "dragHandle": "拖到文件夹", "empty": "暂无语音生成…", "searchPlaceholder": "搜索片段…", "emptySearch": "没有符合该搜索的片段。", diff --git a/app/src/i18n/locales/zh-TW/translation.json b/app/src/i18n/locales/zh-TW/translation.json index e419c4051..df39a3601 100644 --- a/app/src/i18n/locales/zh-TW/translation.json +++ b/app/src/i18n/locales/zh-TW/translation.json @@ -646,6 +646,7 @@ } }, "history": { + "dragHandle": "拖曳到資料夾", "empty": "尚無語音生成…", "searchPlaceholder": "搜尋片段…", "emptySearch": "沒有符合該搜尋的片段。", From b0fb14455400ecfa3e61044450edf496bc79af5a Mon Sep 17 00:00:00 2001 From: Lvigentini Date: Mon, 10 Aug 2026 00:42:51 +1000 Subject: [PATCH 19/24] feat(api): serve generations in any export container #869 asks for a format parameter on /audio so callers can get MP3 without transcoding themselves. The encoder this needs already landed in this branch for story export -- EXPORT_FORMATS and encode_audio in utils/audio.py -- so the endpoints only had to reach for it. GET /audio/{generation_id} and GET /audio/version/{version_id} now take ?format=wav|mp3|ogg|opus|flac. Omitted, the stored file is streamed by FileResponse exactly as before, so existing callers and range requests are untouched; a transcode has to buffer the whole encode, so it is only paid for when asked for. A file already in the requested container is handed back as-is rather than round-tripped through the encoder. Decoding uses librosa at the file native rate and channel count, matching the story mixdown, so a transcode is a container change and not a resample. Opus is the exception -- libsndfile only encodes it at 48 kHz -- and encode_audio already handles that resample. An unsupported container is a 400 naming what is on offer, not a silent fallback to WAV. Fixes #869 Co-Authored-By: Claude Opus 5 (1M context) --- backend/README.md | 6 +- backend/routes/audio.py | 91 ++++++++++++--- backend/tests/test_audio_format_param.py | 138 +++++++++++++++++++++++ 3 files changed, 221 insertions(+), 14 deletions(-) create mode 100644 backend/tests/test_audio_format_param.py diff --git a/backend/README.md b/backend/README.md index 5fa653ec1..b608ef170 100644 --- a/backend/README.md +++ b/backend/README.md @@ -88,7 +88,7 @@ Detection is handled by `utils/platform_detect.py`. Both backends implement the | Transcription | `/transcribe` | Whisper-based audio-to-text | | Stories | `/stories` | Multi-track timeline editor, per-lane mixing, audio export | | Effects | `/effects` | Effect presets, preview, version management | -| Audio | `/audio`, `/samples` | Audio file serving | +| Audio | `/audio`, `/samples` | Audio file serving, on-the-fly transcoding | | Models | `/models` | Load, unload, download, migrate, status | | Tasks | `/tasks`, `/cache` | Active task tracking, cache management | | CUDA | `/backend/cuda-*` | CUDA binary download and management | @@ -117,6 +117,10 @@ curl -X POST http://localhost:17493/profiles/{id}/duplicate # Mix a story down to a single file (wav | mp3 | ogg | opus | flac) curl "http://localhost:17493/stories/{id}/export-audio?format=mp3" -o story.mp3 + +# Fetch a single generation in a container of your choosing. +# Omit ?format to get the stored file untouched. +curl "http://localhost:17493/audio/{generation_id}?format=mp3" -o clip.mp3 ``` ## Data directory diff --git a/backend/routes/audio.py b/backend/routes/audio.py index 685136c78..699b968b5 100644 --- a/backend/routes/audio.py +++ b/backend/routes/audio.py @@ -1,15 +1,19 @@ """Audio file serving endpoints.""" +import asyncio +import io import mimetypes from pathlib import Path +import librosa from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, StreamingResponse from sqlalchemy.orm import Session from .. import config, models from ..services import history from ..database import get_db +from ..utils.audio import EXPORT_FORMATS, encode_audio router = APIRouter() @@ -24,9 +28,66 @@ def _audio_media_type(path: Path) -> str: return guessed or "audio/wav" +def _transcode(path: Path, fmt: str) -> bytes: + """Decode a stored file and re-encode it into ``fmt``. + + Decoded at the file's own rate and channel count, matching the story + mixdown, so a transcode is a container change rather than a resample.""" + audio, sr = librosa.load(str(path), sr=None, mono=False) + return encode_audio(audio, int(sr), fmt=fmt) + + +async def _serve_audio(path: Path, fmt: str | None, stem: str): + """Serve a stored audio file, optionally transcoded to ``fmt``. + + With no ``fmt`` the file is streamed untouched by ``FileResponse``, which + keeps range requests working for the player. A transcode has to buffer the + whole encode, so it is only paid for when a caller explicitly asks.""" + if fmt is None: + return FileResponse( + path, + media_type=_audio_media_type(path), + filename=f"{stem}{path.suffix}", + ) + + spec = EXPORT_FORMATS.get(fmt.lower()) + if spec is None: + raise HTTPException( + status_code=400, + detail=f"Unsupported format '{fmt}'. Supported: {sorted(EXPORT_FORMATS)}", + ) + + # Already in the requested container: hand back the bytes on disk rather + # than decoding and re-encoding, which would only lose quality. + if path.suffix.lower() == spec["ext"]: + return FileResponse( + path, + media_type=spec["mime"], + filename=f"{stem}{spec['ext']}", + ) + + try: + audio_bytes = await asyncio.to_thread(_transcode, path, fmt.lower()) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Transcode failed: {exc}") from exc + + return StreamingResponse( + io.BytesIO(audio_bytes), + media_type=spec["mime"], + headers={"Content-Disposition": f'attachment; filename="{stem}{spec["ext"]}"'}, + ) + + @router.get("/audio/version/{version_id}") -async def get_version_audio(version_id: str, db: Session = Depends(get_db)): - """Serve audio for a specific version.""" +async def get_version_audio( + version_id: str, + format: str | None = None, + db: Session = Depends(get_db), +): + """Serve audio for a specific version. + + ``format`` is one of :data:`EXPORT_FORMATS`; omitted, the stored file is + served as-is.""" from ..services import versions as versions_mod version = versions_mod.get_version(version_id, db) @@ -37,16 +98,24 @@ async def get_version_audio(version_id: str, db: Session = Depends(get_db)): if audio_path is None or not audio_path.is_file(): raise HTTPException(status_code=404, detail="Audio file not found") - return FileResponse( + return await _serve_audio( audio_path, - media_type=_audio_media_type(audio_path), - filename=f"generation_{version.generation_id}_{version.label}{audio_path.suffix}", + format, + f"generation_{version.generation_id}_{version.label}", ) @router.get("/audio/{generation_id}") -async def get_audio(generation_id: str, db: Session = Depends(get_db)): - """Serve generated audio file (serves the default version).""" +async def get_audio( + generation_id: str, + format: str | None = None, + db: Session = Depends(get_db), +): + """Serve generated audio file (serves the default version). + + ``format`` is one of :data:`EXPORT_FORMATS` — ``mp3``, ``ogg``, ``opus``, + ``flac`` or ``wav``. Omitted, the stored file is served as-is, so existing + callers and range requests are unaffected.""" generation = await history.get_generation(generation_id, db) if not generation: raise HTTPException(status_code=404, detail="Generation not found") @@ -60,11 +129,7 @@ async def get_audio(generation_id: str, db: Session = Depends(get_db)): ) raise HTTPException(status_code=404, detail=detail) - return FileResponse( - audio_path, - media_type=_audio_media_type(audio_path), - filename=f"generation_{generation_id}{audio_path.suffix}", - ) + return await _serve_audio(audio_path, format, f"generation_{generation_id}") @router.get("/samples/{sample_id}") diff --git a/backend/tests/test_audio_format_param.py b/backend/tests/test_audio_format_param.py new file mode 100644 index 000000000..f9ee7ee11 --- /dev/null +++ b/backend/tests/test_audio_format_param.py @@ -0,0 +1,138 @@ +""" +Tests for the ``format`` parameter on the audio-serving endpoints (#869). + +Generations are stored as WAV. Callers that want anything else previously had +to transcode themselves; these tests pin the container negotiation, that the +default path is untouched, and that a bad format is rejected rather than +silently served as WAV. + +Usage: + python -m pytest backend/tests/test_audio_format_param.py -v +""" + +import io +import os +import sys +import tempfile +from pathlib import Path + +import numpy as np +import pytest +import soundfile as sf + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +_DATA_DIR = tempfile.mkdtemp(prefix="voicebox-audio-format-test-") +os.environ["VOICEBOX_DATA_DIR"] = _DATA_DIR + +from starlette.testclient import TestClient # noqa: E402 + +from backend.app import app # noqa: E402 + + +@pytest.fixture(scope="module") +def client(): + with TestClient(app) as c: + yield c + + +def _tone(seconds: float, sr: int, freq: float = 440.0, amp: float = 0.3): + t = np.linspace(0, seconds, int(sr * seconds), endpoint=False) + return (amp * np.sin(2 * np.pi * freq * t)).astype(np.float32) + + +@pytest.fixture +def generation(client, tmp_path): + """A stored WAV generation to serve back in various containers.""" + path = tmp_path / "source.wav" + sf.write(str(path), _tone(1.0, 48000), 48000) + with path.open("rb") as fh: + r = client.post("/generate/import", files={"file": ("source.wav", fh, "audio/wav")}) + assert r.status_code == 200, r.text + return r.json() + + +# ── Default behaviour ──────────────────────────────────────────────── + + +def test_no_format_serves_the_stored_file(client, generation): + """Existing callers must be untouched: no query param, no transcode.""" + r = client.get(f"/audio/{generation['id']}") + assert r.status_code == 200, r.text + assert r.headers["content-type"].startswith("audio/") + + data, sr = sf.read(io.BytesIO(r.content), dtype="float32") + assert sr == 48000 + assert len(data) == pytest.approx(48000, rel=0.01) + + +def test_wav_requested_on_a_wav_file_is_not_re_encoded(client, generation): + """Same container: hand back the bytes on disk rather than round-tripping + them through the encoder.""" + plain = client.get(f"/audio/{generation['id']}") + as_wav = client.get(f"/audio/{generation['id']}", params={"format": "wav"}) + + assert as_wav.status_code == 200, as_wav.text + assert as_wav.content == plain.content + + +# ── Transcoding ────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "fmt,mime", + [ + ("mp3", "audio/mpeg"), + ("ogg", "audio/ogg"), + ("opus", "audio/ogg"), + ("flac", "audio/flac"), + ], +) +def test_format_returns_that_container(client, generation, fmt, mime): + r = client.get(f"/audio/{generation['id']}", params={"format": fmt}) + assert r.status_code == 200, r.text + assert r.headers["content-type"].startswith(mime) + assert f".{fmt}" in r.headers.get("content-disposition", "") + + # Decodable, and still about a second of audio. + data, sr = sf.read(io.BytesIO(r.content), dtype="float32") + assert len(data) / sr == pytest.approx(1.0, abs=0.05) + + +def test_lossless_transcode_preserves_the_sample_rate(client, generation): + """FLAC is the format where a resample would be a bug, not a trade-off.""" + r = client.get(f"/audio/{generation['id']}", params={"format": "flac"}) + _, sr = sf.read(io.BytesIO(r.content), dtype="float32") + assert sr == 48000 + + +def test_opus_is_served_at_48k(client, tmp_path): + """Opus only encodes at 48 kHz; a 24 kHz source has to be resampled rather + than erroring out of libsndfile.""" + path = tmp_path / "narrow.wav" + sf.write(str(path), _tone(1.0, 24000), 24000) + with path.open("rb") as fh: + created = client.post( + "/generate/import", files={"file": ("narrow.wav", fh, "audio/wav")} + ).json() + + r = client.get(f"/audio/{created['id']}", params={"format": "opus"}) + assert r.status_code == 200, r.text + _, sr = sf.read(io.BytesIO(r.content), dtype="float32") + assert sr == 48000 + + +# ── Rejections ─────────────────────────────────────────────────────── + + +def test_unsupported_format_is_a_400(client, generation): + """Not a silent fallback to WAV — a caller asking for m4a should learn that + it is not on offer.""" + r = client.get(f"/audio/{generation['id']}", params={"format": "m4a"}) + assert r.status_code == 400 + assert "m4a" in r.json()["detail"] + + +def test_missing_generation_still_404s_with_a_format(client): + r = client.get("/audio/does-not-exist", params={"format": "mp3"}) + assert r.status_code == 404 From 3addcb18fa3a285152473aa0d01e10ea65932834 Mon Sep 17 00:00:00 2001 From: Lvigentini Date: Mon, 10 Aug 2026 01:07:43 +1000 Subject: [PATCH 20/24] feat(voices): make the profile list search-aware so #1016 composes #1016 adds voice search and threads a query into ProfileList as a prop. This branch rewrites the same component for folders and a card/list toggle, so the two collide in exactly one file. Rather than leave that to whoever rebases second, ProfileList now takes the same `search` prop and does the filtering folder-aware. Filtering happens before bucketing, which is the part that only makes sense once both features exist: a match stays in the folder it belongs to instead of collapsing into one flat list. Folders with no matches are hidden while a query is active -- a wall of empty folder headers is noise -- but they come back when the query clears, since the header is also the drop target for filing a voice. The match predicate is #1016's (name, description, language, preset_engine, default_engine), and so is the alphabetical tiebreak in the sort; without it the order inside a folder is whatever the API returned. Their selected-profile-first rule is deliberately not adopted -- it reads differently once voices are grouped, and it interacts with the scroll-into-view effect above, so that is a UX call for the author of #1016 and the maintainer, not one to make silently here. `profiles.list.noVoicesMatch` is added in all nine locales rather than English only, so the string is not left half-translated. No search box is added here. The input, the VoicesTab filters and the layout work stay in #1016; this is only the seam it plugs into. Refs #1016, #966 Co-Authored-By: Claude Opus 5 (1M context) --- .../components/VoiceProfiles/ProfileList.tsx | 71 ++++++++++++++++--- app/src/i18n/locales/en/translation.json | 1 + app/src/i18n/locales/es/translation.json | 1 + app/src/i18n/locales/fr/translation.json | 1 + app/src/i18n/locales/it/translation.json | 1 + app/src/i18n/locales/ja/translation.json | 1 + app/src/i18n/locales/ko/translation.json | 1 + app/src/i18n/locales/pt-BR/translation.json | 1 + app/src/i18n/locales/zh-CN/translation.json | 1 + app/src/i18n/locales/zh-TW/translation.json | 1 + 10 files changed, 70 insertions(+), 10 deletions(-) diff --git a/app/src/components/VoiceProfiles/ProfileList.tsx b/app/src/components/VoiceProfiles/ProfileList.tsx index d7059eb5b..9fe4a8a99 100644 --- a/app/src/components/VoiceProfiles/ProfileList.tsx +++ b/app/src/components/VoiceProfiles/ProfileList.tsx @@ -1,4 +1,4 @@ -import { FolderPlus, Info, LayoutGrid, List, Mic, Sparkles } from 'lucide-react'; +import { FolderPlus, Info, LayoutGrid, List, Mic, Search, Sparkles } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; @@ -34,7 +34,22 @@ const PRESET_ENGINES = new Set(['kokoro', 'qwen_custom_voice']); /** Sentinel key for the Uncategorised bucket, which has no folder id. */ const UNCATEGORISED = '__uncategorised__'; -export function ProfileList() { +/** Fields a search query is matched against. Mirrors #1016. */ +const matchesQuery = (p: VoiceProfileResponse, q: string) => + p.name.toLowerCase().includes(q) || + p.description?.toLowerCase().includes(q) || + p.language.toLowerCase().includes(q) || + p.preset_engine?.toLowerCase().includes(q) || + p.default_engine?.toLowerCase().includes(q); + +interface ProfileListProps { + /** Active search query. Filtering happens before folder bucketing, so a + * match keeps its folder rather than collapsing into one flat list. */ + search?: string; + onClearSearch?: () => void; +} + +export function ProfileList({ search = '', onClearSearch }: ProfileListProps) { const { t } = useTranslation(); const { data: profiles, isLoading, error } = useProfiles(); const { data: folders } = useFolders('voice'); @@ -93,12 +108,24 @@ export function ProfileList() { [isPresetEngine, selectedEngine], ); + const query = search.trim().toLowerCase(); + + const visibleProfiles = useMemo( + () => (query ? allProfiles.filter((p) => matchesQuery(p, query)) : allProfiles), + [allProfiles, query], + ); + // Sort so supported profiles come first, then bucket by folder. Sorting - // before grouping keeps the supported-first ordering inside each folder. + // before grouping keeps the supported-first ordering inside each folder; + // filtering before bucketing keeps a match in the folder it belongs to. const grouped = useMemo(() => { - const sorted = [...allProfiles].sort( - (a, b) => (isSupported(a) ? 0 : 1) - (isSupported(b) ? 0 : 1), - ); + const sorted = [...visibleProfiles].sort((a, b) => { + const supported = (isSupported(a) ? 0 : 1) - (isSupported(b) ? 0 : 1); + if (supported !== 0) return supported; + // Alphabetical tiebreak, from #1016 — without it the order within a + // folder is whatever the API happened to return. + return a.name.localeCompare(b.name); + }); const buckets = new Map(); buckets.set(UNCATEGORISED, []); @@ -112,9 +139,17 @@ export function ProfileList() { buckets.get(key)?.push(profile); } return buckets; - }, [allProfiles, voiceFolders, isSupported]); + }, [visibleProfiles, voiceFolders, isSupported]); + + const hasUnsupported = visibleProfiles.some((p) => !isSupported(p)); - const hasUnsupported = allProfiles.some((p) => !isSupported(p)); + // A folder with no matches is noise while searching, but its header is how + // you drop a voice into it the rest of the time. + const searchableFolders = useMemo( + () => (query ? voiceFolders.filter((f) => (grouped.get(f.id)?.length ?? 0) > 0) : voiceFolders), + [voiceFolders, grouped, query], + ); + const showUncategorised = !query || (grouped.get(UNCATEGORISED)?.length ?? 0) > 0; if (isLoading) { return null; @@ -226,7 +261,23 @@ export function ProfileList() {
- {voiceFolders.map((folder) => ( + {query && visibleProfiles.length === 0 && ( + + + +

+ {t('profiles.list.noVoicesMatch', { query: search.trim() })} +

+ {onClearSearch && ( + + )} +
+
+ )} + + {searchableFolders.map((folder) => ( 0 ? ( + {!showUncategorised ? null : voiceFolders.length > 0 ? ( Date: Sun, 9 Aug 2026 16:43:07 +1000 Subject: [PATCH 21/24] fix(folders): validation and cache gaps found in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five findings from the review of #1005. Folder assignment on profile creation ------------------------------------- `create_profile` persisted `folder_id` without checking it. The folder-assignment endpoint enforces that a voice only lands in a voice folder, but creation bypassed that entirely — a client could file a new profile straight into a clip folder, or reference a folder that does not exist. Same two checks, raised as ValueError so the route returns 400 like its neighbours. Whitespace-only names --------------------- `" "` passed `min_length=1` because the raw string is three characters long, and the route then stored `.strip()` — an empty label. Same for the duplicate-profile override, which could produce a copy with no name at all. Both now use a `TrimmedName` type that strips first and length-checks the result. Left `AudioChannelUpdate` and `EffectPresetUpdate` alone; they have the same gap but are not this PR's concern. Moved clips lingering in the list --------------------------------- Moving a clip to another folder invalidated the history query but left `allHistory` intact. Past page 0 the refreshed first page is appended rather than replacing the list, so the clip stayed visible in a folder it no longer belonged to. Folder moves now go through one handler that drops back to page 0, which is what makes the next response replace the list. Detaching a folder ------------------ `useDetachFolder` invalidated only the folder tree. Member lists include subfolders, so anyone filtered by the former parent kept seeing the detached child's items until an unrelated refetch. The sixth finding — the flat `config` import in test_data_dir_env — is fixed on the #1004 branch and resolves here on merge. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/lib/hooks/useFolders.ts | 6 ++++++ backend/models.py | 16 ++++++++++++---- backend/services/profiles.py | 11 +++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/app/src/lib/hooks/useFolders.ts b/app/src/lib/hooks/useFolders.ts index ef899e88e..3f095a502 100644 --- a/app/src/lib/hooks/useFolders.ts +++ b/app/src/lib/hooks/useFolders.ts @@ -59,6 +59,12 @@ export function useDetachFolder(kind: FolderKind) { mutationFn: (folderId: string) => apiClient.detachFolder(folderId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['folders', kind] }); + // Detaching moves a folder out of its parent, and member lists include + // subfolders by default — so anyone filtered by the former parent is + // still being shown the detached child's items. + queryClient.invalidateQueries({ + queryKey: [kind === 'voice' ? 'profiles' : 'history'], + }); }, }); } diff --git a/backend/models.py b/backend/models.py index 80192d5b0..e06ac5960 100644 --- a/backend/models.py +++ b/backend/models.py @@ -2,7 +2,8 @@ Pydantic models for request/response validation. """ -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, StringConstraints +from typing_extensions import Annotated from typing import Optional, List from datetime import datetime @@ -14,11 +15,18 @@ FOLDER_KIND_PATTERN = "^(voice|generation|story)$" +# Names arrive from text inputs, so a value of " " passes a raw +# min_length check and then stores as an empty label once stripped. +# Strip first, then length-check the result. +TrimmedName = Annotated[ + str, StringConstraints(strip_whitespace=True, min_length=1, max_length=100) +] + class FolderCreate(BaseModel): """Request model for creating a folder.""" - name: str = Field(..., min_length=1, max_length=100) + name: TrimmedName kind: str = Field(default="voice", pattern=FOLDER_KIND_PATTERN) # Only meaningful for kind="generation"; voice folders are flat and the # route rejects a non-null parent for them. @@ -33,7 +41,7 @@ class FolderUpdate(BaseModel): use the dedicated move endpoint to detach a folder to the root. """ - name: Optional[str] = Field(None, min_length=1, max_length=100) + name: Optional[TrimmedName] = None parent_id: Optional[str] = None position: Optional[int] = Field(None, ge=0) @@ -67,7 +75,7 @@ class ProfileDuplicateRequest(BaseModel): Omit entirely to accept the default " (copy)" name. """ - name: Optional[str] = Field(None, min_length=1, max_length=100) + name: Optional[TrimmedName] = None class VoiceProfileCreate(BaseModel): diff --git a/backend/services/profiles.py b/backend/services/profiles.py index cf4d063ed..21c4a561c 100644 --- a/backend/services/profiles.py +++ b/backend/services/profiles.py @@ -12,6 +12,7 @@ from .. import config from ..database import Generation as DBGeneration, ProfileSample as DBProfileSample, VoiceProfile as DBVoiceProfile +from ..database.models import Folder as DBFolder from ..models import ( EffectConfig, ProfileSampleResponse, @@ -191,6 +192,16 @@ async def create_profile( if validation_error: raise ValueError(validation_error) + # The folder-assignment endpoint enforces that a voice only lands in a + # voice folder; creation has to enforce the same contract, or a client can + # file a new profile straight into a clip folder and bypass it. + if data.folder_id is not None: + folder = db.query(DBFolder).filter_by(id=data.folder_id).first() + if folder is None: + raise ValueError(f"Folder not found: {data.folder_id}") + if folder.kind != "voice": + raise ValueError("Target folder does not hold voices") + db_profile = DBVoiceProfile( id=str(uuid.uuid4()), name=data.name, From 3460a59aba6aa4d76332f1ce0378552688ccebaf Mon Sep 17 00:00:00 2001 From: Lvigentini Date: Mon, 10 Aug 2026 03:17:41 +1000 Subject: [PATCH 22/24] fix(stories): bound the duck target and preserve the exception chain Two live review findings on #1007. The rest of that review is stale -- the ruler offset, the mixer slider write-per-step, ducking determinism and the silent failures were fixed in later commits, and the whitespace-name and folder-cache findings arrived with the b74eb6a cherry-pick. `duck_under_track` had no lower bound, so a negative lane index was accepted and then silently never matched a lane at mix time -- a setting that looks saved and does nothing. Bounded with `ge=0`. Self-ducking was already rejected in the route, which is where the lane's own index is known; that half of the finding was already addressed. `duplicate_profile`'s ValueError was re-raised as an HTTPException without `from e`, dropping the original traceback. Three tests: self-ducking, a negative target, and a valid one. Co-Authored-By: Claude Opus 5 (1M context) --- backend/models.py | 5 ++++- backend/routes/profiles.py | 2 +- backend/tests/test_story_mixdown.py | 23 +++++++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/backend/models.py b/backend/models.py index e06ac5960..6fc514aaf 100644 --- a/backend/models.py +++ b/backend/models.py @@ -812,7 +812,10 @@ class StoryTrackUpsert(BaseModel): muted: bool = False soloed: bool = False # Lane index whose loudness ducks this one; null disables ducking. - duck_under_track: Optional[int] = None + # Bounded because lane indices are non-negative -- a negative value is not + # a lane, and would silently never match one at mix time. Self-ducking is + # rejected in the route, which is where the lane's own index is known. + duck_under_track: Optional[int] = Field(None, ge=0) class StoryTrackResponse(BaseModel): diff --git a/backend/routes/profiles.py b/backend/routes/profiles.py index eb273dc1e..0a1189441 100644 --- a/backend/routes/profiles.py +++ b/backend/routes/profiles.py @@ -151,7 +151,7 @@ async def duplicate_profile( profile_id, db, name=data.name if data else None ) except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) + raise HTTPException(status_code=404, detail=str(e)) from e @router.delete("/profiles/{profile_id}") diff --git a/backend/tests/test_story_mixdown.py b/backend/tests/test_story_mixdown.py index bf31dc81f..662af5b7f 100644 --- a/backend/tests/test_story_mixdown.py +++ b/backend/tests/test_story_mixdown.py @@ -344,3 +344,26 @@ def test_default_export_is_still_wav(client, story, tmp_path): def test_unknown_format_is_rejected(client, story): r = client.get(f"/stories/{story['id']}/export-audio", params={"format": "aiff"}) assert r.status_code == 400 + + +# ── Track validation (review findings on #1007) ────────────────────── + + +def test_a_lane_cannot_duck_under_itself(client, story): + """It would attenuate by its own envelope — quieter wherever it is loudest.""" + r = client.put(f"/stories/{story['id']}/tracks/1", json={"duck_under_track": 1}) + assert r.status_code == 400 + assert "itself" in r.json()["detail"] + + +def test_a_negative_duck_target_is_rejected(client, story): + """Lane indices are non-negative, so a negative target is not a lane — it + would just silently never match one at mix time.""" + r = client.put(f"/stories/{story['id']}/tracks/0", json={"duck_under_track": -1}) + assert r.status_code == 422 + + +def test_a_valid_duck_target_is_accepted(client, story): + r = client.put(f"/stories/{story['id']}/tracks/1", json={"duck_under_track": 0}) + assert r.status_code == 200, r.text + assert r.json()["duck_under_track"] == 0 From b0b865c7e16e3586b9df886e4cfa99c4f213457d Mon Sep 17 00:00:00 2001 From: Lvigentini Date: Mon, 10 Aug 2026 06:14:33 +1000 Subject: [PATCH 23/24] fix(profiles): settle duplicate names by the insert, and harden the drop target Three remaining review findings on #1007. Name allocation was check-then-act ---------------------------------- `get_unique_profile_name` only SELECTs, and the INSERT happens later, so two concurrent duplicates or imports could both be handed the same name -- the second then failed the UNIQUE constraint and surfaced as a 500 rather than falling through to the next suffix. `insert_profile_with_unique_name` settles the name by inserting it, retrying on IntegrityError with the next suffix, so the constraint arbitrates instead of erroring. `build_row` is a callback because a rolled-back commit expunges the instance and each attempt needs a fresh one. Called before any file work in `duplicate_profile`: retrying after the sample copies would mean undoing them, and the profile directory is keyed by the new id rather than the name, so a name retry does not touch it. `get_unique_profile_name` stays for callers that need a candidate name before they have a row, with its raciness documented rather than implied. Drop target ran the browser default on rejected payloads -------------------------------------------------------- `onDrop` returned before `preventDefault()` when the payload was missing or was not a voice, but `onDragOver` had already accepted the element as a drop target. In a webview a dropped file or URL then navigates the page away. Cancel first, decide second. `onDragLeave` also fired whenever the pointer crossed into the header's own children -- the toggle button, the count badge -- so the highlight flickered for the whole drag. Only a leave of the header itself counts now. soundfile floor did not match the promise ----------------------------------------- `EXPORT_FORMATS` documents MP3 and Opus writing with no ffmpeg, which holds only from the wheel bundling libsndfile 1.2.2. The floor was `>=0.12.0`, whose bundled 1.1.0 can write neither. Raised to `>=0.13.0` in both requirements files, with the reason recorded next to it. Co-Authored-By: Claude Opus 5 (1M context) --- .../VoiceProfiles/FolderSection.tsx | 14 ++- backend/requirements.txt | 5 +- backend/services/profiles.py | 88 +++++++++++++++---- backend/tests/test_profile_duplicate.py | 49 +++++++++++ requirements.txt | 2 +- 5 files changed, 135 insertions(+), 23 deletions(-) diff --git a/app/src/components/VoiceProfiles/FolderSection.tsx b/app/src/components/VoiceProfiles/FolderSection.tsx index fe9199e2d..798fb6aa4 100644 --- a/app/src/components/VoiceProfiles/FolderSection.tsx +++ b/app/src/components/VoiceProfiles/FolderSection.tsx @@ -87,12 +87,22 @@ export function FolderSection({ e.dataTransfer.dropEffect = 'move'; setDragOver(true); }} - onDragLeave={() => setDragOver(false)} + onDragLeave={(e) => { + // Fires again every time the pointer crosses into a child — the + // toggle button, the count badge — which makes the highlight flicker + // for the whole drag. Only a leave of the header itself counts. + if (e.currentTarget.contains(e.relatedTarget as Node | null)) return; + setDragOver(false); + }} onDrop={(e) => { + // Cancel first, decide second. onDragOver already accepted this + // element as a drop target, so returning early on an unwanted + // payload lets the browser default run — and in a webview a dropped + // file or URL then navigates the page away. + e.preventDefault(); setDragOver(false); const payload = readFolderDragData(e); if (!payload || payload.kind !== 'voice') return; - e.preventDefault(); onDropItem?.(payload.id); }} className={cn( diff --git a/backend/requirements.txt b/backend/requirements.txt index 3c6160a58..672bffb0f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -55,7 +55,10 @@ unidic-lite>=1.0.8 # Audio processing audioop-lts>=0.2.1; python_version >= "3.13" librosa>=0.10.0 -soundfile>=0.12.0 +# 0.13.0 is the first wheel bundling libsndfile 1.2.2, which is what makes +# the MP3/Opus export formats work without ffmpeg. 0.12.x ships 1.1.0, +# which cannot write either -- see EXPORT_FORMATS in utils/audio.py. +soundfile>=0.13.0 numpy>=1.24.0,<2.0 numba>=0.60.0,<0.61.0 pedalboard>=0.9.0 diff --git a/backend/services/profiles.py b/backend/services/profiles.py index 21c4a561c..2d020940d 100644 --- a/backend/services/profiles.py +++ b/backend/services/profiles.py @@ -4,10 +4,12 @@ import logging import shutil import uuid +from collections.abc import Callable from datetime import datetime from pathlib import Path from sqlalchemy import func +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from .. import config @@ -137,11 +139,22 @@ def validate_profile_engine(profile, engine: str) -> None: raise ValueError(f"Engine '{engine}' does not support cloned voice profiles") +# How many "name (n)" variants to try before giving up. Only reached under +# genuine contention -- a single caller finds a free name on the first miss. +_NAME_ALLOCATION_ATTEMPTS = 50 + + def get_unique_profile_name(name: str, db: Session) -> str: """Return ``name``, or the first free "name (n)" variant. ``profiles.name`` is UNIQUE, so anything that creates a profile from an existing one -- import, duplicate -- has to resolve collisions first. + + This only *reads*, so it is a check-then-act: two concurrent callers can + both be handed the same name and the second insert then fails the unique + constraint. Prefer :func:`insert_profile_with_unique_name`, which settles + the name by inserting it. This remains for callers that need a candidate + name before they have a row to insert. """ base_name = name counter = 1 @@ -155,6 +168,38 @@ def get_unique_profile_name(name: str, db: Session) -> str: counter += 1 +def insert_profile_with_unique_name( + base_name: str, + db: Session, + build_row: Callable[[str], DBVoiceProfile], +) -> DBVoiceProfile: + """Insert a profile under the first free variant of *base_name*. + + The name is settled by the insert rather than by a preceding SELECT, so + concurrent callers cannot both take it -- the unique constraint arbitrates + and the loser retries with the next suffix instead of surfacing a 500. + + ``build_row`` is called per attempt because a rolled-back commit expunges + the instance, so each try needs a fresh one. + """ + name = base_name + for counter in range(1, _NAME_ALLOCATION_ATTEMPTS + 1): + row = build_row(name) + db.add(row) + try: + db.commit() + db.refresh(row) + return row + except IntegrityError: + db.rollback() + name = f"{base_name} ({counter})" + + raise ValueError( + f"Could not find a free name for {base_name!r} after " + f"{_NAME_ALLOCATION_ATTEMPTS} attempts" + ) + + async def create_profile( data: VoiceProfileCreate, db: Session, @@ -763,25 +808,30 @@ async def duplicate_profile( raise ValueError(f"Profile {profile_id} not found") new_id = str(uuid.uuid4()) - new_name = get_unique_profile_name(name.strip() if name else f"{source.name} (copy)", db) - - duplicate = DBVoiceProfile( - id=new_id, - name=new_name, - description=source.description, - language=source.language, - effects_chain=source.effects_chain, - voice_type=source.voice_type, - preset_engine=source.preset_engine, - preset_voice_id=source.preset_voice_id, - design_prompt=source.design_prompt, - default_engine=source.default_engine, - personality=source.personality, - folder_id=source.folder_id, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), - ) - db.add(duplicate) + base_name = name.strip() if name else f"{source.name} (copy)" + + def _build(candidate: str) -> DBVoiceProfile: + return DBVoiceProfile( + id=new_id, + name=candidate, + description=source.description, + language=source.language, + effects_chain=source.effects_chain, + voice_type=source.voice_type, + preset_engine=source.preset_engine, + preset_voice_id=source.preset_voice_id, + design_prompt=source.design_prompt, + default_engine=source.default_engine, + personality=source.personality, + folder_id=source.folder_id, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + + # Reserve the name by inserting it, before any file work. Retrying after + # the copies would mean undoing them; the directory is keyed by new_id, so + # a name retry does not affect it. + duplicate = insert_profile_with_unique_name(base_name, db, _build) new_dir = config.get_profiles_dir() / new_id new_dir.mkdir(parents=True, exist_ok=True) diff --git a/backend/tests/test_profile_duplicate.py b/backend/tests/test_profile_duplicate.py index fb4d9b267..f6f7b3ebe 100644 --- a/backend/tests/test_profile_duplicate.py +++ b/backend/tests/test_profile_duplicate.py @@ -292,3 +292,52 @@ def test_deleting_the_copy_leaves_the_originals_audio_intact(client, cloned_prof _delete(client, copy["id"]) assert original_audio.exists() + + +# ── Name allocation under contention (review finding on #1007) ─────── + + +def test_duplicate_names_are_settled_by_the_insert(client): + """`get_unique_profile_name` was a check-then-act: it SELECTed a free name + and a later INSERT took it, so two concurrent duplicates could both be + handed the same name and the loser hit the unique constraint as a 500. + + Simulated here by pre-taking the name between allocation attempts, which is + what a racing request does.""" + import uuid as _uuid + + from backend.database import VoiceProfile as DBVoiceProfile, get_db + from backend.services.profiles import insert_profile_with_unique_name + + db = next(get_db()) + try: + base = f"Race Test {_uuid.uuid4().hex[:8]}" + + # Something else already holds the base name. + db.add(DBVoiceProfile(id=str(_uuid.uuid4()), name=base, language="en")) + db.commit() + + row = insert_profile_with_unique_name( + base, + db, + lambda candidate: DBVoiceProfile( + id=str(_uuid.uuid4()), name=candidate, language="en" + ), + ) + assert row.name == f"{base} (1)", "should fall through to the next suffix" + + # And again, so the counter keeps advancing rather than sticking. + row2 = insert_profile_with_unique_name( + base, + db, + lambda candidate: DBVoiceProfile( + id=str(_uuid.uuid4()), name=candidate, language="en" + ), + ) + assert row2.name == f"{base} (2)" + + for name in (base, f"{base} (1)", f"{base} (2)"): + db.query(DBVoiceProfile).filter_by(name=name).delete() + db.commit() + finally: + db.close() diff --git a/requirements.txt b/requirements.txt index ea444b9cf..2d9a50fd7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ fastapi sqlalchemy torch torchvision -soundfile +soundfile>=0.13.0 # libsndfile 1.2.2, for MP3/Opus export librosa python-multipart huggingface_hub From 7bc2ca7e2309c605e4125923655cbfce599628fd Mon Sep 17 00:00:00 2001 From: Lvigentini Date: Mon, 10 Aug 2026 23:36:00 +1000 Subject: [PATCH 24/24] fix(stories): WSOLA for per-clip speed, and plan splices once for stereo Per-clip speed used `librosa.effects.time_stretch`, a phase vocoder. It reconstructs from magnitudes and re-estimated phase, which on speech smears consonants and leaves a phasey ring -- rejected outright in listening tests while checking the same algorithm elsewhere. Pitch survived either way; only the artefacts differ. `time_stretch_speech` in utils/audio.py replaces it with WSOLA, which stays in the time domain and overlap-adds real waveform segments, choosing each splice by cross-correlation so pitch periods line up. Nothing is resynthesised, so consonants stay intact. numpy only, no new dependency. Stereo needed more than a loop ------------------------------ Stretching each channel separately looked obvious and was wrong twice over. The splice search is content-dependent, so left and right choose different points: the stereo image tears, and the channels come out different lengths, which `np.stack` rejects outright -- caught by a test asserting stereo keeps its shape. Splices are now planned once from the mono downmix and applied to every channel, so all channels are cut identically, the image survives, and the lengths match by construction. The mixer works in `(channels, samples)` throughout, so this is the shape it actually hits -- every story clip with a speed adjustment was going through the broken path. 6 tests: ratio accuracy across four rates, pitch preservation by comparing the dominant frequency before and after, and stereo keeping both its shape and its length. Same helper is on the prosody branch for ``; whichever merges second should point at this one rather than keeping a second copy. Co-Authored-By: Claude Opus 5 (1M context) --- backend/services/stories.py | 9 ++- backend/tests/test_story_mixdown.py | 38 +++++++++++ backend/utils/audio.py | 101 ++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 3 deletions(-) diff --git a/backend/services/stories.py b/backend/services/stories.py index e846a83ba..76e052778 100644 --- a/backend/services/stories.py +++ b/backend/services/stories.py @@ -35,7 +35,7 @@ ) from ..database.models import StoryTrack as DBStoryTrack from .history import _get_versions_for_generation -from ..utils.audio import encode_audio +from ..utils.audio import encode_audio, time_stretch_speech import librosa import numpy as np @@ -1186,8 +1186,11 @@ async def export_story_audio( speed = float(getattr(item, "speed", 1.0) or 1.0) if speed != 1.0: - # Phase vocoder, so pitch survives the tempo change. - audio = np.stack([librosa.effects.time_stretch(ch, rate=speed) for ch in audio]) + # WSOLA rather than a phase vocoder: the vocoder resynthesises from + # magnitude and estimated phase, which on speech smears consonants + # and leaves a phasey ring. Pitch survives either way; only the + # artefacts differ. + audio = time_stretch_speech(audio, speed, project_sr) audio = _apply_fades( audio, diff --git a/backend/tests/test_story_mixdown.py b/backend/tests/test_story_mixdown.py index 662af5b7f..87cba7d31 100644 --- a/backend/tests/test_story_mixdown.py +++ b/backend/tests/test_story_mixdown.py @@ -367,3 +367,41 @@ def test_a_valid_duck_target_is_accepted(client, story): r = client.put(f"/stories/{story['id']}/tracks/1", json={"duck_under_track": 0}) assert r.status_code == 200, r.text assert r.json()["duck_under_track"] == 0 + + +# ── Time stretching for per-clip speed ─────────────────────────────── + + +@pytest.mark.parametrize("rate", [0.5, 0.8, 1.25, 2.0]) +def test_speed_change_has_an_accurate_ratio(rate): + from backend.utils.audio import time_stretch_speech + + out = time_stretch_speech(_tone(1.0, 24000, 220.0), rate, 24000) + assert len(out) / 24000 == pytest.approx(1.0 / rate, rel=0.06) + + +def test_speed_change_preserves_pitch(): + """Resampling would transpose the voice, which is not what a speed control + means. WSOLA keeps the pitch and only changes the tempo.""" + from backend.utils.audio import time_stretch_speech + + sr = 24000 + original = _tone(1.0, sr, 220.0) + slower = time_stretch_speech(original, 0.5, sr) + + def dominant_hz(x): + return np.fft.rfftfreq(len(x), 1 / sr)[int(np.argmax(np.abs(np.fft.rfft(x))))] + + assert dominant_hz(slower) == pytest.approx(dominant_hz(original), rel=0.05) + + +def test_speed_change_handles_stereo(): + """The mixer works in (channels, samples); both channels must stretch by + the same amount or the image tears.""" + from backend.utils.audio import time_stretch_speech + + sr = 24000 + stereo = np.stack([_tone(1.0, sr, 220.0), _tone(1.0, sr, 330.0)]) + out = time_stretch_speech(stereo, 0.8, sr) + assert out.shape[0] == 2 + assert out.shape[1] == pytest.approx(sr / 0.8, rel=0.06) diff --git a/backend/utils/audio.py b/backend/utils/audio.py index 10d2a6ecd..23e261a12 100644 --- a/backend/utils/audio.py +++ b/backend/utils/audio.py @@ -123,6 +123,107 @@ def encode_audio(audio: np.ndarray, sample_rate: int, fmt: str = "wav") -> bytes return buffer.getvalue() +# WSOLA windowing. 30ms frames are long enough to hold a pitch period at any +# adult speaking F0 and short enough that a splice lands inside one phoneme. +_WSOLA_FRAME_MS = 30 +_WSOLA_SEARCH_MS = 10 + + +def _wsola_splices(reference: np.ndarray, rate: float, sr: int) -> tuple[list[int], int, int]: + """Plan the splice points for a WSOLA stretch of *reference*. + + Returns ``(offsets, frame, synthesis_hop)``. Planning separately from + applying is what lets every channel of a multi-channel clip use the *same* + splices: the search is content-dependent, so planning per channel would + choose different points for left and right and tear the stereo image. + """ + frame = max(2, int(sr * _WSOLA_FRAME_MS / 1000)) + search = max(1, int(sr * _WSOLA_SEARCH_MS / 1000)) + synthesis_hop = frame // 2 + analysis_hop = round(synthesis_hop * rate) + if analysis_hop < 1: + return [], frame, synthesis_hop + + offsets: list[int] = [] + read = 0 + expected = reference[:frame].astype(np.float32) + + while read + frame + search < len(reference): + lo = max(0, read - search) + hi = min(len(reference) - frame, read + search) + if hi <= lo: + offset = read + else: + candidates = np.arange(lo, hi + 1) + scores = [float(np.dot(reference[c : c + frame], expected)) for c in candidates] + offset = int(candidates[int(np.argmax(scores))]) + + offsets.append(offset) + nxt = reference[offset + synthesis_hop : offset + synthesis_hop + frame] + if len(nxt) < frame: + break + expected = nxt + # The nominal pointer advances by analysis_hop regardless of where the + # search landed. Folding the offset back in would let a run of + # forward-biased matches accelerate the read and cut the output short. + read += analysis_hop + + return offsets, frame, synthesis_hop + + +def _wsola_apply(channel: np.ndarray, offsets: list[int], frame: int, hop: int) -> np.ndarray: + """Overlap-add *channel* at the planned splice points.""" + if not offsets: + return channel + window = np.hanning(frame).astype(np.float32) + length = hop * len(offsets) + frame + out = np.zeros(length, dtype=np.float32) + weights = np.zeros(length, dtype=np.float32) + + for i, offset in enumerate(offsets): + segment = channel[offset : offset + frame] + if len(segment) < frame: + break + write = i * hop + out[write : write + frame] += segment * window + weights[write : write + frame] += window + + nonzero = weights > 1e-6 + out[nonzero] /= weights[nonzero] + return out + + +def time_stretch_speech(audio, rate: float, sr: int): + """Change tempo without changing pitch, tuned for speech. + + A phase vocoder (``librosa.effects.time_stretch``) reconstructs from + magnitudes and re-estimated phase; on speech that smears consonants and + leaves a phasey ring, audible enough to be rejected in listening tests. + WSOLA stays in the time domain and overlap-adds real waveform segments, so + nothing is resynthesised. + + Resampling would transpose the voice, which is not what a speed control + means, so it is not an option either. + + Accepts mono ``(samples,)`` or multi-channel ``(channels, samples)`` and + returns the same shape. Multi-channel input is planned once from the + downmix, so every channel is spliced identically and the stereo image + survives -- and every channel comes out the same length, which stacking + requires. + """ + audio = np.asarray(audio, dtype=np.float32) + if rate == 1.0 or audio.size == 0: + return audio + + if audio.ndim > 1: + reference = audio.mean(axis=0) + offsets, frame, hop = _wsola_splices(reference, rate, sr) + return np.stack([_wsola_apply(ch, offsets, frame, hop) for ch in audio]) + + offsets, frame, hop = _wsola_splices(audio, rate, sr) + return _wsola_apply(audio, offsets, frame, hop) + + def save_audio( audio: np.ndarray, path: str,