diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 00000000..f8bbbcd1 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,155 @@ +"""FastAPI application factory, middleware, and lifecycle events.""" + +import asyncio +import os +from pathlib import Path + +# AMD GPU environment variables must be set before torch import +if not os.environ.get("HSA_OVERRIDE_GFX_VERSION"): + os.environ["HSA_OVERRIDE_GFX_VERSION"] = "10.3.0" +if not os.environ.get("MIOPEN_LOG_LEVEL"): + os.environ["MIOPEN_LOG_LEVEL"] = "4" + +import torch +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from urllib.parse import quote + +from . import __version__, database, tts, transcribe, config +from .database import get_db +from .platform_detect import get_backend_type +from .utils.progress import get_progress_manager +from .services.task_queue import create_background_task, init_queue +from .routes import register_routers + + +def safe_content_disposition(disposition_type: str, filename: str) -> str: + """Build a Content-Disposition header safe for non-ASCII filenames. + + Uses RFC 5987 ``filename*`` parameter so browsers can decode UTF-8 + filenames while the ``filename`` fallback stays ASCII-only. + """ + ascii_name = ( + "".join(c for c in filename if c.isascii() and (c.isalnum() or c in " -_.")).strip() or "download" + ) + utf8_name = quote(filename, safe="") + return f'{disposition_type}; filename="{ascii_name}"; filename*=UTF-8\'\'{utf8_name}' + + +def create_app() -> FastAPI: + """Create and configure the FastAPI application.""" + application = FastAPI( + title="voicebox API", + description="Production-quality Qwen3-TTS voice cloning API", + version=__version__, + ) + + _configure_cors(application) + register_routers(application) + _register_lifecycle(application) + + return application + + +def _configure_cors(application: FastAPI) -> None: + """Set up CORS middleware with local-first defaults.""" + default_origins = [ + "http://localhost:5173", # Vite dev server + "http://127.0.0.1:5173", + "http://localhost:17493", + "http://127.0.0.1:17493", + "tauri://localhost", # Tauri webview (macOS) + "https://tauri.localhost", # Tauri webview (Windows/Linux) + "http://tauri.localhost", # Tauri webview (Windows, some builds) + ] + env_origins = os.environ.get("VOICEBOX_CORS_ORIGINS", "") + all_origins = default_origins + [o.strip() for o in env_origins.split(",") if o.strip()] + + application.add_middleware( + CORSMiddleware, + allow_origins=all_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + +def _get_gpu_status() -> str: + """Return a human-readable string describing GPU availability.""" + backend_type = get_backend_type() + if torch.cuda.is_available(): + device_name = torch.cuda.get_device_name(0) + is_rocm = hasattr(torch.version, "hip") and torch.version.hip is not None + if is_rocm: + return f"ROCm ({device_name})" + return f"CUDA ({device_name})" + elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): + return "MPS (Apple Silicon)" + elif backend_type == "mlx": + return "Metal (Apple Silicon via MLX)" + return "None (CPU only)" + + +def _register_lifecycle(application: FastAPI) -> None: + """Attach startup and shutdown event handlers.""" + + @application.on_event("startup") + async def startup_event(): + print("voicebox API starting up...") + database.init_db() + print(f"Database initialized at {database._db_path}") + + init_queue() + + # Mark stale "generating" records as failed -- leftovers from a killed process + try: + from sqlalchemy import text as sa_text + + db = next(get_db()) + result = db.execute( + sa_text( + "UPDATE generations SET status = 'failed', " + "error = 'Server was shut down during generation' " + "WHERE status = 'generating'" + ) + ) + if result.rowcount > 0: + print(f"Marked {result.rowcount} stale generation(s) as failed") + db.commit() + db.close() + except Exception as e: + print(f"Warning: Could not clean up stale generations: {e}") + + backend_type = get_backend_type() + print(f"Backend: {backend_type.upper()}") + print(f"GPU available: {_get_gpu_status()}") + + from .services.cuda import check_and_update_cuda_binary + + create_background_task(check_and_update_cuda_binary()) + + try: + progress_manager = get_progress_manager() + progress_manager._set_main_loop(asyncio.get_running_loop()) + print("Progress manager initialized with event loop") + except Exception as e: + print(f"Warning: Could not initialize progress manager event loop: {e}") + + try: + from huggingface_hub import constants as hf_constants + + cache_dir = Path(hf_constants.HF_HUB_CACHE) + cache_dir.mkdir(parents=True, exist_ok=True) + print(f"HuggingFace cache directory: {cache_dir}") + except Exception as e: + print(f"Warning: Could not create HuggingFace cache directory: {e}") + print("Model downloads may fail. Please ensure the directory exists and has write permissions.") + + @application.on_event("shutdown") + async def shutdown_event(): + print("voicebox API shutting down...") + tts.unload_tts_model() + transcribe.unload_whisper_model() + + +app = create_app() diff --git a/backend/build_binary.py b/backend/build_binary.py index db413b99..43de991b 100644 --- a/backend/build_binary.py +++ b/backend/build_binary.py @@ -67,7 +67,7 @@ def build_server(cuda=False): '--hidden-import', 'backend.utils.progress', '--hidden-import', 'backend.utils.hf_progress', '--hidden-import', 'backend.utils.validation', - '--hidden-import', 'backend.cuda_download', + '--hidden-import', 'backend.services.cuda', '--hidden-import', 'backend.effects', '--hidden-import', 'backend.utils.effects', '--hidden-import', 'backend.versions', diff --git a/backend/database.py b/backend/database.py deleted file mode 100644 index e516f9d5..00000000 --- a/backend/database.py +++ /dev/null @@ -1,487 +0,0 @@ -""" -SQLite database ORM using SQLAlchemy. -""" - -from sqlalchemy import create_engine, Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker, Session -from datetime import datetime -import uuid -from pathlib import Path - -from . import config - -Base = declarative_base() - - -class VoiceProfile(Base): - """Voice profile database model.""" - __tablename__ = "profiles" - - id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) - name = Column(String, unique=True, nullable=False) - description = Column(Text) - language = Column(String, default="en") - avatar_path = Column(String, nullable=True) - effects_chain = Column(Text, nullable=True) # JSON-serialized default effects chain - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - - -class ProfileSample(Base): - """Voice profile sample database model.""" - __tablename__ = "profile_samples" - - id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) - profile_id = Column(String, ForeignKey("profiles.id"), nullable=False) - audio_path = Column(String, nullable=False) - reference_text = Column(Text, nullable=False) - - -class Generation(Base): - """Generation history database model.""" - __tablename__ = "generations" - - id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) - profile_id = Column(String, ForeignKey("profiles.id"), nullable=False) - text = Column(Text, nullable=False) - language = Column(String, default="en") - audio_path = Column(String, nullable=True) - duration = Column(Float, nullable=True) - seed = Column(Integer) - instruct = Column(Text) - engine = Column(String, default="qwen") - model_size = Column(String, nullable=True) - status = Column(String, default="completed") # generating, completed, failed - error = Column(Text, nullable=True) - is_favorited = Column(Boolean, default=False) - created_at = Column(DateTime, default=datetime.utcnow) - - -class Story(Base): - """Story database model.""" - __tablename__ = "stories" - - id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) - name = Column(String, nullable=False) - description = Column(Text) - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - - -class StoryItem(Base): - """Story item database model (links generations to stories).""" - __tablename__ = "story_items" - - id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) - story_id = Column(String, ForeignKey("stories.id"), nullable=False) - generation_id = Column(String, ForeignKey("generations.id"), nullable=False) - version_id = Column(String, ForeignKey("generation_versions.id"), nullable=True) # Pin to specific version, null = use generation default - start_time_ms = Column(Integer, nullable=False, default=0) # Milliseconds from story start - track = Column(Integer, nullable=False, default=0) # Track number (0 = main track) - trim_start_ms = Column(Integer, nullable=False, default=0) # Milliseconds trimmed from start - trim_end_ms = Column(Integer, nullable=False, default=0) # Milliseconds trimmed from end - created_at = Column(DateTime, default=datetime.utcnow) - - -class Project(Base): - """Audio studio project database model.""" - __tablename__ = "projects" - - id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) - name = Column(String, nullable=False) - data = Column(Text) # JSON string - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - - -class GenerationVersion(Base): - """A version of a generation's audio (clean, processed, alternate takes).""" - __tablename__ = "generation_versions" - - id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) - generation_id = Column(String, ForeignKey("generations.id"), nullable=False) - label = Column(String, nullable=False) # "clean", "processed", or user-defined - audio_path = Column(String, nullable=False) - effects_chain = Column(Text, nullable=True) # JSON-serialized effects config, null for clean - source_version_id = Column(String, ForeignKey("generation_versions.id"), nullable=True) # Which version was used as input - is_default = Column(Boolean, default=False) - created_at = Column(DateTime, default=datetime.utcnow) - - -class EffectPreset(Base): - """Saved effect chain preset.""" - __tablename__ = "effect_presets" - - id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) - name = Column(String, unique=True, nullable=False) - description = Column(Text, nullable=True) - effects_chain = Column(Text, nullable=False) # JSON-serialized effects config - is_builtin = Column(Boolean, default=False) - sort_order = Column(Integer, default=100) - created_at = Column(DateTime, default=datetime.utcnow) - - -class AudioChannel(Base): - """Audio channel (bus) database model.""" - __tablename__ = "audio_channels" - - id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) - name = Column(String, nullable=False) - is_default = Column(Boolean, default=False) - created_at = Column(DateTime, default=datetime.utcnow) - - -class ChannelDeviceMapping(Base): - """Mapping between channels and OS audio devices.""" - __tablename__ = "channel_device_mappings" - - id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) - channel_id = Column(String, ForeignKey("audio_channels.id"), nullable=False) - device_id = Column(String, nullable=False) # OS device identifier - - -class ProfileChannelMapping(Base): - """Mapping between voice profiles and audio channels (many-to-many).""" - __tablename__ = "profile_channel_mappings" - - profile_id = Column(String, ForeignKey("profiles.id"), primary_key=True) - channel_id = Column(String, ForeignKey("audio_channels.id"), primary_key=True) - - -# Database setup will be initialized in init_db() -engine = None -SessionLocal = None -_db_path = None - - -def init_db(): - """Initialize database tables.""" - global engine, SessionLocal, _db_path - - _db_path = config.get_db_path() - _db_path.parent.mkdir(parents=True, exist_ok=True) - - engine = create_engine( - f"sqlite:///{_db_path}", - connect_args={"check_same_thread": False}, - ) - - SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - - # Run migrations before creating tables - _run_migrations(engine) - - Base.metadata.create_all(bind=engine) - - # Create default channel if it doesn't exist - db = SessionLocal() - try: - default_channel = db.query(AudioChannel).filter(AudioChannel.is_default == True).first() - if not default_channel: - default_channel = AudioChannel( - id=str(uuid.uuid4()), - name="Default", - is_default=True - ) - db.add(default_channel) - - # Assign all existing profiles to default channel - profiles = db.query(VoiceProfile).all() - for profile in profiles: - mapping = ProfileChannelMapping( - profile_id=profile.id, - channel_id=default_channel.id - ) - db.add(mapping) - - db.commit() - finally: - db.close() - - # Backfill: create "clean" GenerationVersion entries for existing generations - _backfill_generation_versions() - - # Seed built-in effect presets - _seed_builtin_presets() - - -def _run_migrations(engine): - """Run database migrations.""" - from sqlalchemy import inspect, text - - inspector = inspect(engine) - - # Check if story_items table exists - if 'story_items' not in inspector.get_table_names(): - return # Table doesn't exist yet, will be created fresh - - # Get columns in story_items table - columns = {col['name'] for col in inspector.get_columns('story_items')} - - # Migration: Remove position column and ensure start_time_ms exists - # SQLite doesn't support DROP COLUMN easily, so we recreate the table - if 'position' in columns: - print("Migrating story_items: removing position column, using start_time_ms") - - with engine.connect() as conn: - # Check if start_time_ms already exists - has_start_time = 'start_time_ms' in columns - - if not has_start_time: - # First, add the new column temporarily - conn.execute(text("ALTER TABLE story_items ADD COLUMN start_time_ms INTEGER DEFAULT 0")) - - # Calculate timecodes from position ordering - result = conn.execute(text(""" - SELECT si.id, si.story_id, si.position, g.duration - FROM story_items si - JOIN generations g ON si.generation_id = g.id - ORDER BY si.story_id, si.position - """)) - - rows = result.fetchall() - - current_story_id = None - current_time_ms = 0 - - for row in rows: - item_id, story_id, position, duration = row - - if story_id != current_story_id: - current_story_id = story_id - current_time_ms = 0 - - conn.execute( - text("UPDATE story_items SET start_time_ms = :time WHERE id = :id"), - {"time": current_time_ms, "id": item_id} - ) - - current_time_ms += int(duration * 1000) + 200 - - conn.commit() - - # Now recreate the table without the position column - # 1. Create new table - conn.execute(text(""" - CREATE TABLE story_items_new ( - id VARCHAR PRIMARY KEY, - story_id VARCHAR NOT NULL, - generation_id VARCHAR NOT NULL, - start_time_ms INTEGER NOT NULL DEFAULT 0, - created_at DATETIME, - FOREIGN KEY (story_id) REFERENCES stories(id), - FOREIGN KEY (generation_id) REFERENCES generations(id) - ) - """)) - - # 2. Copy data - conn.execute(text(""" - INSERT INTO story_items_new (id, story_id, generation_id, start_time_ms, created_at) - SELECT id, story_id, generation_id, start_time_ms, created_at FROM story_items - """)) - - # 3. Drop old table - conn.execute(text("DROP TABLE story_items")) - - # 4. Rename new table - conn.execute(text("ALTER TABLE story_items_new RENAME TO story_items")) - - conn.commit() - print("Migrated story_items table to use start_time_ms (removed position column)") - - # Migration: Add track column if it doesn't exist - # Re-check columns after potential position migration - columns = {col['name'] for col in inspector.get_columns('story_items')} - if 'track' not in columns: - print("Migrating story_items: adding track column") - with engine.connect() as conn: - conn.execute(text("ALTER TABLE story_items ADD COLUMN track INTEGER NOT NULL DEFAULT 0")) - conn.commit() - print("Added track column to story_items") - - # Migration: Add trim columns if they don't exist - # Re-check columns after potential track migration - columns = {col['name'] for col in inspector.get_columns('story_items')} - if 'trim_start_ms' not in columns: - print("Migrating story_items: adding trim_start_ms column") - with engine.connect() as conn: - conn.execute(text("ALTER TABLE story_items ADD COLUMN trim_start_ms INTEGER NOT NULL DEFAULT 0")) - conn.commit() - print("Added trim_start_ms column to story_items") - - columns = {col['name'] for col in inspector.get_columns('story_items')} - if 'trim_end_ms' not in columns: - print("Migrating story_items: adding trim_end_ms column") - with engine.connect() as conn: - conn.execute(text("ALTER TABLE story_items ADD COLUMN trim_end_ms INTEGER NOT NULL DEFAULT 0")) - conn.commit() - print("Added trim_end_ms column to story_items") - - # Migration: Add avatar_path to profiles table - if 'profiles' in inspector.get_table_names(): - columns = {col['name'] for col in inspector.get_columns('profiles')} - if 'avatar_path' not in columns: - print("Migrating profiles: adding avatar_path column") - with engine.connect() as conn: - conn.execute(text("ALTER TABLE profiles ADD COLUMN avatar_path VARCHAR")) - conn.commit() - print("Added avatar_path column to profiles") - - # Migration: Add status and error columns to generations table - if 'generations' in inspector.get_table_names(): - columns = {col['name'] for col in inspector.get_columns('generations')} - if 'status' not in columns: - print("Migrating generations: adding status column") - with engine.connect() as conn: - conn.execute(text("ALTER TABLE generations ADD COLUMN status VARCHAR DEFAULT 'completed'")) - conn.commit() - print("Added status column to generations") - if 'error' not in columns: - print("Migrating generations: adding error column") - with engine.connect() as conn: - conn.execute(text("ALTER TABLE generations ADD COLUMN error TEXT")) - conn.commit() - print("Added error column to generations") - if 'engine' not in columns: - print("Migrating generations: adding engine column") - with engine.connect() as conn: - conn.execute(text("ALTER TABLE generations ADD COLUMN engine VARCHAR DEFAULT 'qwen'")) - conn.commit() - print("Added engine column to generations") - # Re-read columns after engine migration (variable name shadows outer `engine`) - columns = {col['name'] for col in inspector.get_columns('generations')} - if 'model_size' not in columns: - print("Migrating generations: adding model_size column") - with engine.connect() as conn: - conn.execute(text("ALTER TABLE generations ADD COLUMN model_size VARCHAR")) - conn.commit() - print("Added model_size column to generations") - - # Migration: Add effects_chain to profiles table - if 'profiles' in inspector.get_table_names(): - columns = {col['name'] for col in inspector.get_columns('profiles')} - if 'effects_chain' not in columns: - print("Migrating profiles: adding effects_chain column") - with engine.connect() as conn: - conn.execute(text("ALTER TABLE profiles ADD COLUMN effects_chain TEXT")) - conn.commit() - print("Added effects_chain column to profiles") - - # Migration: Add sort_order to effect_presets table - if 'effect_presets' in inspector.get_table_names(): - columns = {col['name'] for col in inspector.get_columns('effect_presets')} - if 'sort_order' not in columns: - print("Migrating effect_presets: adding sort_order column") - with engine.connect() as conn: - conn.execute(text("ALTER TABLE effect_presets ADD COLUMN sort_order INTEGER DEFAULT 100")) - conn.commit() - print("Added sort_order column to effect_presets") - - # Migration: Add version_id column to story_items table - if 'story_items' in inspector.get_table_names(): - columns = {col['name'] for col in inspector.get_columns('story_items')} - if 'version_id' not in columns: - print("Migrating story_items: adding version_id column") - with engine.connect() as conn: - conn.execute(text("ALTER TABLE story_items ADD COLUMN version_id VARCHAR")) - conn.commit() - print("Added version_id column to story_items") - - # Migration: Add source_version_id to generation_versions table - if 'generation_versions' in inspector.get_table_names(): - columns = {col['name'] for col in inspector.get_columns('generation_versions')} - if 'source_version_id' not in columns: - print("Migrating generation_versions: adding source_version_id column") - with engine.connect() as conn: - conn.execute(text("ALTER TABLE generation_versions ADD COLUMN source_version_id VARCHAR")) - conn.commit() - print("Added source_version_id column to generation_versions") - - if 'generations' in inspector.get_table_names(): - columns = {col['name'] for col in inspector.get_columns('generations')} - if 'is_favorited' not in columns: - print("Migrating generations: adding is_favorited column") - with engine.connect() as conn: - conn.execute(text("ALTER TABLE generations ADD COLUMN is_favorited BOOLEAN DEFAULT 0")) - conn.commit() - print("Added is_favorited column to generations") - - # Migration: Create generation_versions for existing generations - # (populate after tables are created, handled in init_db) - - -def _backfill_generation_versions(): - """Create 'clean' version entries for existing generations that don't have any.""" - db = SessionLocal() - try: - from pathlib import Path as _Path - - # Find generations that have no version entries - existing_version_gen_ids = { - row[0] for row in db.query(GenerationVersion.generation_id).all() - } - generations = db.query(Generation).filter( - Generation.status == "completed", - Generation.audio_path.isnot(None), - Generation.audio_path != "", - ).all() - - count = 0 - for gen in generations: - if gen.id in existing_version_gen_ids: - continue - if not _Path(gen.audio_path).exists(): - continue - version = GenerationVersion( - id=str(uuid.uuid4()), - generation_id=gen.id, - label="clean", - audio_path=gen.audio_path, - effects_chain=None, - is_default=True, - ) - db.add(version) - count += 1 - - if count > 0: - db.commit() - print(f"Backfilled {count} generation version entries") - finally: - db.close() - - -def _seed_builtin_presets(): - """Ensure built-in effect presets exist in the database.""" - import json - from .utils.effects import BUILTIN_PRESETS - - db = SessionLocal() - try: - for idx, (key, preset_data) in enumerate(BUILTIN_PRESETS.items()): - sort_order = preset_data.get("sort_order", idx) - existing = db.query(EffectPreset).filter_by(name=preset_data["name"]).first() - if not existing: - preset = EffectPreset( - id=str(uuid.uuid4()), - name=preset_data["name"], - description=preset_data.get("description"), - effects_chain=json.dumps(preset_data["effects_chain"]), - is_builtin=True, - sort_order=sort_order, - ) - db.add(preset) - elif existing.sort_order != sort_order: - existing.sort_order = sort_order - db.commit() - finally: - db.close() - - -def get_db(): - """Get database session (generator for dependency injection).""" - db = SessionLocal() - try: - yield db - finally: - db.close() diff --git a/backend/database/__init__.py b/backend/database/__init__.py new file mode 100644 index 00000000..636333bc --- /dev/null +++ b/backend/database/__init__.py @@ -0,0 +1,44 @@ +"""Database package — ORM models, session management, and migrations. + +Re-exports all public symbols so that ``from .database import get_db`` +and ``from .database import Generation as DBGeneration`` continue to work +without changing any importers. +""" + +from .models import ( + Base, + AudioChannel, + ChannelDeviceMapping, + EffectPreset, + Generation, + GenerationVersion, + ProfileChannelMapping, + ProfileSample, + Project, + Story, + StoryItem, + VoiceProfile, +) +from .session import engine, SessionLocal, _db_path, init_db, get_db + +__all__ = [ + # Models + "Base", + "AudioChannel", + "ChannelDeviceMapping", + "EffectPreset", + "Generation", + "GenerationVersion", + "ProfileChannelMapping", + "ProfileSample", + "Project", + "Story", + "StoryItem", + "VoiceProfile", + # Session + "engine", + "SessionLocal", + "_db_path", + "init_db", + "get_db", +] diff --git a/backend/database/migrations.py b/backend/database/migrations.py new file mode 100644 index 00000000..56e0bf2b --- /dev/null +++ b/backend/database/migrations.py @@ -0,0 +1,164 @@ +"""Column-level migrations for the voicebox SQLite database. + +Why not Alembic? voicebox is a single-user desktop app shipping as a +PyInstaller binary. Every user has exactly one SQLite file. Alembic's +strengths -- migration tracking across environments, rollback, team +coordination -- don't apply here and would add bundling complexity +(alembic.ini, env.py, versions/ directory all need to survive +PyInstaller). The column-existence checks below are idempotent, run in +<50 ms on startup, and have worked reliably across 12 schema changes. +If the project ever moves to a server-based deployment or Postgres, this +decision should be revisited. + +Adding a new migration: + 1. Append a new ``_migrate_*`` helper at the bottom of this file. + 2. Call it from ``run_migrations()`` in the appropriate spot. + 3. The helper should check column/table existence before acting + (idempotent) and print a short message when it does real work. +""" + +import logging + +from sqlalchemy import inspect, text + +logger = logging.getLogger(__name__) + + +def run_migrations(engine) -> None: + """Run all schema migrations. Safe to call on every startup.""" + inspector = inspect(engine) + tables = set(inspector.get_table_names()) + + _migrate_story_items(engine, inspector, tables) + _migrate_profiles(engine, inspector, tables) + _migrate_generations(engine, inspector, tables) + _migrate_effect_presets(engine, inspector, tables) + _migrate_generation_versions(engine, inspector, tables) + + +# -- helpers --------------------------------------------------------------- + +def _get_columns(inspector, table: str) -> set[str]: + return {col["name"] for col in inspector.get_columns(table)} + + +def _add_column(engine, table: str, column_sql: str, label: str) -> None: + """Add a column if it doesn't already exist.""" + with engine.connect() as conn: + conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column_sql}")) + conn.commit() + logger.info("Added %s column to %s", label, table) + + +# -- per-table migrations -------------------------------------------------- + +def _migrate_story_items(engine, inspector, tables: set[str]) -> None: + if "story_items" not in tables: + return + + columns = _get_columns(inspector, "story_items") + + # Replace position-based ordering with absolute timecodes + if "position" in columns: + logger.info("Migrating story_items: removing position column, using start_time_ms") + with engine.connect() as conn: + if "start_time_ms" not in columns: + conn.execute(text( + "ALTER TABLE story_items ADD COLUMN start_time_ms INTEGER DEFAULT 0" + )) + result = conn.execute(text(""" + SELECT si.id, si.story_id, si.position, g.duration + FROM story_items si + JOIN generations g ON si.generation_id = g.id + ORDER BY si.story_id, si.position + """)) + current_story_id = None + current_time_ms = 0 + for item_id, story_id, _position, duration in result.fetchall(): + if story_id != current_story_id: + current_story_id = story_id + current_time_ms = 0 + conn.execute( + text("UPDATE story_items SET start_time_ms = :time WHERE id = :id"), + {"time": current_time_ms, "id": item_id}, + ) + current_time_ms += int(duration * 1000) + 200 + conn.commit() + + # Recreate table without the position column (SQLite lacks DROP COLUMN) + conn.execute(text(""" + CREATE TABLE story_items_new ( + id VARCHAR PRIMARY KEY, + story_id VARCHAR NOT NULL, + generation_id VARCHAR NOT NULL, + start_time_ms INTEGER NOT NULL DEFAULT 0, + created_at DATETIME, + FOREIGN KEY (story_id) REFERENCES stories(id), + FOREIGN KEY (generation_id) REFERENCES generations(id) + ) + """)) + conn.execute(text(""" + INSERT INTO story_items_new (id, story_id, generation_id, start_time_ms, created_at) + SELECT id, story_id, generation_id, start_time_ms, created_at FROM story_items + """)) + conn.execute(text("DROP TABLE story_items")) + conn.execute(text("ALTER TABLE story_items_new RENAME TO story_items")) + conn.commit() + + # Re-read after table recreation + columns = _get_columns(inspector, "story_items") + + if "track" not in columns: + _add_column(engine, "story_items", "track INTEGER NOT NULL DEFAULT 0", "track") + # Re-read so subsequent checks see new columns + columns = _get_columns(inspector, "story_items") + if "trim_start_ms" not in columns: + _add_column(engine, "story_items", "trim_start_ms INTEGER NOT NULL DEFAULT 0", "trim_start_ms") + if "trim_end_ms" not in columns: + _add_column(engine, "story_items", "trim_end_ms INTEGER NOT NULL DEFAULT 0", "trim_end_ms") + if "version_id" not in columns: + _add_column(engine, "story_items", "version_id VARCHAR", "version_id") + + +def _migrate_profiles(engine, inspector, tables: set[str]) -> None: + if "profiles" not in tables: + return + columns = _get_columns(inspector, "profiles") + if "avatar_path" not in columns: + _add_column(engine, "profiles", "avatar_path VARCHAR", "avatar_path") + if "effects_chain" not in columns: + _add_column(engine, "profiles", "effects_chain TEXT", "effects_chain") + + +def _migrate_generations(engine, inspector, tables: set[str]) -> None: + if "generations" not in tables: + return + columns = _get_columns(inspector, "generations") + if "status" not in columns: + _add_column(engine, "generations", "status VARCHAR DEFAULT 'completed'", "status") + if "error" not in columns: + _add_column(engine, "generations", "error TEXT", "error") + if "engine" not in columns: + _add_column(engine, "generations", "engine VARCHAR DEFAULT 'qwen'", "engine") + # Re-read after engine column (variable name shadows outer scope in old code) + columns = _get_columns(inspector, "generations") + if "model_size" not in columns: + _add_column(engine, "generations", "model_size VARCHAR", "model_size") + if "is_favorited" not in columns: + _add_column(engine, "generations", "is_favorited BOOLEAN DEFAULT 0", "is_favorited") + + +def _migrate_effect_presets(engine, inspector, tables: set[str]) -> None: + if "effect_presets" not in tables: + return + columns = _get_columns(inspector, "effect_presets") + if "sort_order" not in columns: + _add_column(engine, "effect_presets", "sort_order INTEGER DEFAULT 100", "sort_order") + + +def _migrate_generation_versions(engine, inspector, tables: set[str]) -> None: + if "generation_versions" not in tables: + return + columns = _get_columns(inspector, "generation_versions") + if "source_version_id" not in columns: + _add_column(engine, "generation_versions", "source_version_id VARCHAR", "source_version_id") diff --git a/backend/database/models.py b/backend/database/models.py new file mode 100644 index 00000000..19cefff2 --- /dev/null +++ b/backend/database/models.py @@ -0,0 +1,155 @@ +"""ORM model definitions for the voicebox SQLite database.""" + +from datetime import datetime +import uuid + +from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() + + +class VoiceProfile(Base): + """Voice profile.""" + + __tablename__ = "profiles" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + name = Column(String, unique=True, nullable=False) + description = Column(Text) + language = Column(String, default="en") + avatar_path = Column(String, nullable=True) + effects_chain = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class ProfileSample(Base): + """Audio sample attached to a voice profile.""" + + __tablename__ = "profile_samples" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + profile_id = Column(String, ForeignKey("profiles.id"), nullable=False) + audio_path = Column(String, nullable=False) + reference_text = Column(Text, nullable=False) + + +class Generation(Base): + """A single TTS generation.""" + + __tablename__ = "generations" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + profile_id = Column(String, ForeignKey("profiles.id"), nullable=False) + text = Column(Text, nullable=False) + language = Column(String, default="en") + audio_path = Column(String, nullable=True) + duration = Column(Float, nullable=True) + seed = Column(Integer) + instruct = Column(Text) + engine = Column(String, default="qwen") + model_size = Column(String, nullable=True) + status = Column(String, default="completed") + error = Column(Text, nullable=True) + is_favorited = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.utcnow) + + +class Story(Base): + """A story that sequences multiple generations.""" + + __tablename__ = "stories" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + name = Column(String, nullable=False) + description = Column(Text) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class StoryItem(Base): + """Links a generation to a story at a specific timecode.""" + + __tablename__ = "story_items" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + story_id = Column(String, ForeignKey("stories.id"), nullable=False) + 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) + 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) + created_at = Column(DateTime, default=datetime.utcnow) + + +class Project(Base): + """Audio studio project (JSON blob).""" + + __tablename__ = "projects" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + name = Column(String, nullable=False) + data = Column(Text) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class GenerationVersion(Base): + """A version of a generation's audio (original, processed, alternate takes).""" + + __tablename__ = "generation_versions" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + generation_id = Column(String, ForeignKey("generations.id"), nullable=False) + label = Column(String, nullable=False) + audio_path = Column(String, nullable=False) + effects_chain = Column(Text, nullable=True) + source_version_id = Column(String, ForeignKey("generation_versions.id"), nullable=True) + is_default = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.utcnow) + + +class EffectPreset(Base): + """Saved effect chain preset.""" + + __tablename__ = "effect_presets" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + name = Column(String, unique=True, nullable=False) + description = Column(Text, nullable=True) + effects_chain = Column(Text, nullable=False) + is_builtin = Column(Boolean, default=False) + sort_order = Column(Integer, default=100) + created_at = Column(DateTime, default=datetime.utcnow) + + +class AudioChannel(Base): + """Audio output channel (bus).""" + + __tablename__ = "audio_channels" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + name = Column(String, nullable=False) + is_default = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.utcnow) + + +class ChannelDeviceMapping(Base): + """Mapping between a channel and an OS audio device.""" + + __tablename__ = "channel_device_mappings" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + channel_id = Column(String, ForeignKey("audio_channels.id"), nullable=False) + device_id = Column(String, nullable=False) + + +class ProfileChannelMapping(Base): + """Many-to-many mapping between voice profiles and audio channels.""" + + __tablename__ = "profile_channel_mappings" + + profile_id = Column(String, ForeignKey("profiles.id"), primary_key=True) + channel_id = Column(String, ForeignKey("audio_channels.id"), primary_key=True) diff --git a/backend/database/seed.py b/backend/database/seed.py new file mode 100644 index 00000000..b62edc22 --- /dev/null +++ b/backend/database/seed.py @@ -0,0 +1,71 @@ +"""Post-migration data seeding and backfills.""" + +import json +import logging +import uuid +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def backfill_generation_versions(SessionLocal, Generation, GenerationVersion) -> None: + """Create 'clean' version entries for generations that predate the versions feature.""" + db = SessionLocal() + try: + existing_version_gen_ids = { + row[0] for row in db.query(GenerationVersion.generation_id).all() + } + generations = db.query(Generation).filter( + Generation.status == "completed", + Generation.audio_path.isnot(None), + Generation.audio_path != "", + ).all() + + count = 0 + for gen in generations: + if gen.id in existing_version_gen_ids: + continue + if not Path(gen.audio_path).exists(): + continue + version = GenerationVersion( + id=str(uuid.uuid4()), + generation_id=gen.id, + label="clean", + audio_path=gen.audio_path, + effects_chain=None, + is_default=True, + ) + db.add(version) + count += 1 + + if count > 0: + db.commit() + logger.info("Backfilled %d generation version entries", count) + finally: + db.close() + + +def seed_builtin_presets(SessionLocal, EffectPreset) -> None: + """Ensure built-in effect presets exist in the database.""" + from ..utils.effects import BUILTIN_PRESETS + + db = SessionLocal() + try: + for idx, (_key, preset_data) in enumerate(BUILTIN_PRESETS.items()): + sort_order = preset_data.get("sort_order", idx) + existing = db.query(EffectPreset).filter_by(name=preset_data["name"]).first() + if not existing: + preset = EffectPreset( + id=str(uuid.uuid4()), + name=preset_data["name"], + description=preset_data.get("description"), + effects_chain=json.dumps(preset_data["effects_chain"]), + is_builtin=True, + sort_order=sort_order, + ) + db.add(preset) + elif existing.sort_order != sort_order: + existing.sort_order = sort_order + db.commit() + finally: + db.close() diff --git a/backend/database/session.py b/backend/database/session.py new file mode 100644 index 00000000..de4cccd9 --- /dev/null +++ b/backend/database/session.py @@ -0,0 +1,78 @@ +"""Engine creation, initialization, and session management.""" + +import logging +import uuid + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from .. import config +from .models import ( + Base, + AudioChannel, + EffectPreset, + Generation, + GenerationVersion, + ProfileChannelMapping, + VoiceProfile, +) +from .migrations import run_migrations +from .seed import backfill_generation_versions, seed_builtin_presets + +logger = logging.getLogger(__name__) + +# Initialized by init_db() +engine = None +SessionLocal = None +_db_path = None + + +def init_db() -> None: + """Initialize the database engine, run migrations, create tables, and seed data.""" + global engine, SessionLocal, _db_path + + _db_path = config.get_db_path() + _db_path.parent.mkdir(parents=True, exist_ok=True) + + engine = create_engine( + f"sqlite:///{_db_path}", + connect_args={"check_same_thread": False}, + ) + + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + run_migrations(engine) + Base.metadata.create_all(bind=engine) + + # Create default audio channel if it doesn't exist + db = SessionLocal() + try: + default_channel = db.query(AudioChannel).filter(AudioChannel.is_default == True).first() + if not default_channel: + default_channel = AudioChannel( + id=str(uuid.uuid4()), + name="Default", + is_default=True, + ) + db.add(default_channel) + + for profile in db.query(VoiceProfile).all(): + db.add(ProfileChannelMapping( + profile_id=profile.id, + channel_id=default_channel.id, + )) + db.commit() + finally: + db.close() + + backfill_generation_versions(SessionLocal, Generation, GenerationVersion) + seed_builtin_presets(SessionLocal, EffectPreset) + + +def get_db(): + """Yield a database session (FastAPI dependency).""" + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/main.py b/backend/main.py index 7774a922..fa8f78f5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,2545 +1,14 @@ -""" -FastAPI application for voicebox backend. +"""Entry point for the voicebox backend. -Handles voice cloning, generation history, and server mode. +Imports the configured FastAPI app and provides a ``python -m backend.main`` +entry point for development. """ -from fastapi import FastAPI, Depends, UploadFile, File, Form, HTTPException -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, StreamingResponse -from fastapi.staticfiles import StaticFiles -from sqlalchemy.orm import Session -from typing import List, Optional -from datetime import datetime -import asyncio -import uvicorn import argparse -import tempfile -import io -from pathlib import Path -import uuid -import signal -import os - -# Set HSA_OVERRIDE_GFX_VERSION for AMD GPUs that aren't officially listed in ROCm -# (e.g., RX 6600 is gfx1032 which maps to gfx1030 target) -# This must be set BEFORE any torch.cuda calls -if not os.environ.get("HSA_OVERRIDE_GFX_VERSION"): - os.environ["HSA_OVERRIDE_GFX_VERSION"] = "10.3.0" - -# Suppress noisy MIOpen workspace warnings on AMD GPUs -if not os.environ.get("MIOPEN_LOG_LEVEL"): - os.environ["MIOPEN_LOG_LEVEL"] = "4" - -import torch -from urllib.parse import quote - - -def _safe_content_disposition(disposition_type: str, filename: str) -> str: - """Build a Content-Disposition header that is safe for non-ASCII filenames. - - Uses RFC 5987 ``filename*`` parameter so that browsers can decode - UTF-8 filenames while the ``filename`` fallback stays ASCII-only. - """ - ascii_name = "".join(c for c in filename if c.isascii() and (c.isalnum() or c in " -_.")).strip() or "download" - utf8_name = quote(filename, safe="") - return f"{disposition_type}; filename=\"{ascii_name}\"; filename*=UTF-8''{utf8_name}" - - -from . import ( - database, - models, - profiles, - history, - tts, - transcribe, - config, - export_import, - channels, - stories, - __version__, -) -from .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile -from .profiles import _profile_to_response -from .utils.progress import get_progress_manager -from .utils.tasks import get_task_manager -from .utils.cache import clear_voice_prompt_cache -from .platform_detect import get_backend_type -from .services.task_queue import create_background_task, enqueue_generation, init_queue -from .services.generation import run_generation - - -app = FastAPI( - title="voicebox API", - description="Production-quality Qwen3-TTS voice cloning API", - version=__version__, -) - -# CORS middleware - restrict to known local origins by default. -# Set VOICEBOX_CORS_ORIGINS env var to a comma-separated list of origins -# to allow additional origins (e.g. for remote server mode). -_default_origins = [ - "http://localhost:5173", # Vite dev server - "http://127.0.0.1:5173", - "http://localhost:17493", - "http://127.0.0.1:17493", - "tauri://localhost", # Tauri webview (macOS) - "https://tauri.localhost", # Tauri webview (Windows/Linux) - "http://tauri.localhost", # Tauri webview (Windows, some builds) -] -_env_origins = os.environ.get("VOICEBOX_CORS_ORIGINS", "") -_cors_origins = _default_origins + [o.strip() for o in _env_origins.split(",") if o.strip()] - -app.add_middleware( - CORSMiddleware, - allow_origins=_cors_origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - - -@app.get("/") -async def root(): - """Root endpoint.""" - return {"message": "voicebox API", "version": __version__} - - -@app.post("/shutdown") -async def shutdown(): - """Gracefully shutdown the server.""" - - async def shutdown_async(): - await asyncio.sleep(0.1) # Give response time to send - os.kill(os.getpid(), signal.SIGTERM) - - asyncio.create_task(shutdown_async()) - return {"message": "Shutting down..."} - - -@app.post("/watchdog/disable") -async def watchdog_disable(): - """Disable the parent process watchdog so the server keeps running.""" - from backend.server import disable_watchdog - - disable_watchdog() - return {"message": "Watchdog disabled"} - - -@app.get("/health", response_model=models.HealthResponse) -async def health(): - """Health check endpoint.""" - from huggingface_hub import hf_hub_download, constants as hf_constants - from pathlib import Path - import os - - tts_model = tts.get_tts_model() - backend_type = get_backend_type() - - # Check for GPU availability (CUDA, MPS, Intel Arc XPU, or DirectML) - has_cuda = torch.cuda.is_available() - has_mps = hasattr(torch.backends, "mps") and torch.backends.mps.is_available() - - # Intel Arc / Intel Xe via intel-extension-for-pytorch (IPEX) - has_xpu = False - xpu_name = None - try: - import intel_extension_for_pytorch as ipex # noqa: F401 - - if hasattr(torch, "xpu") and torch.xpu.is_available(): - has_xpu = True - try: - xpu_name = torch.xpu.get_device_name(0) - except Exception: - xpu_name = "Intel GPU" - except ImportError: - pass - - # DirectML backend (torch-directml) for any Windows GPU - has_directml = False - directml_name = None - try: - import torch_directml - - if torch_directml.device_count() > 0: - has_directml = True - try: - directml_name = torch_directml.device_name(0) - except Exception: - directml_name = "DirectML GPU" - except ImportError: - pass - - gpu_available = has_cuda or has_mps or has_xpu or has_directml or backend_type == "mlx" - - gpu_type = None - if has_cuda: - gpu_type = f"CUDA ({torch.cuda.get_device_name(0)})" - elif has_mps: - gpu_type = "MPS (Apple Silicon)" - elif backend_type == "mlx": - gpu_type = "Metal (Apple Silicon via MLX)" - elif has_xpu: - gpu_type = f"XPU ({xpu_name})" - elif has_directml: - gpu_type = f"DirectML ({directml_name})" - - vram_used = None - if has_cuda: - vram_used = torch.cuda.memory_allocated() / 1024 / 1024 # MB - - # Check if model is loaded - use the same logic as model status endpoint - model_loaded = False - model_size = None - try: - # Use the same check as model status endpoint - if tts_model.is_loaded(): - model_loaded = True - # Get the actual loaded model size - # Check _current_model_size first (more reliable for actually loaded models) - model_size = getattr(tts_model, "_current_model_size", None) - if not model_size: - # Fallback to model_size attribute (which should be set when model loads) - model_size = getattr(tts_model, "model_size", None) - except Exception: - # If there's an error checking, assume not loaded - model_loaded = False - model_size = None - - # Check if default model is downloaded (cached) - model_downloaded = None - try: - # Check if the default model (1.7B) is cached - from .backends import get_model_config - - default_config = get_model_config("qwen-tts-1.7B") - default_model_id = default_config.hf_repo_id if default_config else "Qwen/Qwen3-TTS-12Hz-1.7B-Base" - - # Method 1: Try scan_cache_dir if available - try: - from huggingface_hub import scan_cache_dir - - cache_info = scan_cache_dir() - for repo in cache_info.repos: - if repo.repo_id == default_model_id: - model_downloaded = True - break - except (ImportError, Exception): - # Method 2: Check cache directory (using HuggingFace's OS-specific cache location) - cache_dir = hf_constants.HF_HUB_CACHE - repo_cache = Path(cache_dir) / ("models--" + default_model_id.replace("/", "--")) - if repo_cache.exists(): - has_model_files = ( - any(repo_cache.rglob("*.bin")) - or any(repo_cache.rglob("*.safetensors")) - or any(repo_cache.rglob("*.pt")) - or any(repo_cache.rglob("*.pth")) - or any(repo_cache.rglob("*.npz")) # MLX models may use npz - ) - model_downloaded = has_model_files - except Exception: - pass - - return models.HealthResponse( - status="healthy", - model_loaded=model_loaded, - model_downloaded=model_downloaded, - model_size=model_size, - gpu_available=gpu_available, - gpu_type=gpu_type, - vram_used_mb=vram_used, - backend_type=backend_type, - backend_variant=os.environ.get("VOICEBOX_BACKEND_VARIANT", "cuda" if torch.cuda.is_available() else "cpu"), - ) - - -@app.get("/health/filesystem", response_model=models.FilesystemHealthResponse) -async def filesystem_health(): - """Check filesystem health: directory existence, write permissions, and disk space.""" - import shutil - - dirs_to_check = { - "generations": config.get_generations_dir(), - "profiles": config.get_profiles_dir(), - "data": config.get_data_dir(), - } - - checks: list[models.DirectoryCheck] = [] - all_ok = True - - for _label, dir_path in dirs_to_check.items(): - exists = dir_path.exists() - writable = False - error = None - if exists: - # Probe writability with a temp file - probe = dir_path / ".voicebox_probe" - try: - probe.write_text("ok") - probe.unlink() - writable = True - except PermissionError: - error = "Permission denied" - except OSError as e: - error = str(e) - finally: - try: - probe.unlink(missing_ok=True) - except Exception: - pass - else: - error = "Directory does not exist" - - if not exists or not writable: - all_ok = False - - checks.append( - models.DirectoryCheck( - path=str(dir_path), - exists=exists, - writable=writable, - error=error, - ) - ) - - # Disk space for the data directory - disk_free_mb = None - disk_total_mb = None - try: - usage = shutil.disk_usage(str(config.get_data_dir())) - disk_free_mb = round(usage.free / (1024 * 1024), 1) - disk_total_mb = round(usage.total / (1024 * 1024), 1) - if disk_free_mb < 500: - all_ok = False - except OSError: - all_ok = False - - return models.FilesystemHealthResponse( - healthy=all_ok, - disk_free_mb=disk_free_mb, - disk_total_mb=disk_total_mb, - directories=checks, - ) - - -@app.post("/profiles", response_model=models.VoiceProfileResponse) -async def create_profile( - data: models.VoiceProfileCreate, - db: Session = Depends(get_db), -): - """Create a new voice profile.""" - try: - return await profiles.create_profile(data, db) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - # Fallback for unexpected errors - raise HTTPException(status_code=400, detail=str(e)) - - -@app.get("/profiles", response_model=List[models.VoiceProfileResponse]) -async def list_profiles(db: Session = Depends(get_db)): - """List all voice profiles.""" - return await profiles.list_profiles(db) - - -@app.post("/profiles/import", response_model=models.VoiceProfileResponse) -async def import_profile( - file: UploadFile = File(...), - db: Session = Depends(get_db), -): - """Import a voice profile from a ZIP archive.""" - # Validate file size (max 100MB) - MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB - - # Read file content - content = await file.read() - - if len(content) > MAX_FILE_SIZE: - raise HTTPException( - status_code=400, detail=f"File too large. Maximum size is {MAX_FILE_SIZE / (1024 * 1024)}MB" - ) - - try: - profile = await export_import.import_profile_from_zip(content, db) - return profile - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.get("/profiles/{profile_id}", response_model=models.VoiceProfileResponse) -async def get_profile( - profile_id: str, - db: Session = Depends(get_db), -): - """Get a voice profile by ID.""" - profile = await profiles.get_profile(profile_id, db) - if not profile: - raise HTTPException(status_code=404, detail="Profile not found") - return profile - - -@app.put("/profiles/{profile_id}", response_model=models.VoiceProfileResponse) -async def update_profile( - profile_id: str, - data: models.VoiceProfileCreate, - db: Session = Depends(get_db), -): - """Update a voice profile.""" - try: - profile = await profiles.update_profile(profile_id, data, db) - if not profile: - raise HTTPException(status_code=404, detail="Profile not found") - return profile - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@app.delete("/profiles/{profile_id}") -async def delete_profile( - profile_id: str, - db: Session = Depends(get_db), -): - """Delete a voice profile.""" - success = await profiles.delete_profile(profile_id, db) - if not success: - raise HTTPException(status_code=404, detail="Profile not found") - return {"message": "Profile deleted successfully"} - - -@app.post("/profiles/{profile_id}/samples", response_model=models.ProfileSampleResponse) -async def add_profile_sample( - profile_id: str, - file: UploadFile = File(...), - reference_text: str = Form(...), - db: Session = Depends(get_db), -): - """Add a sample to a voice profile.""" - # Preserve the uploaded file's extension so librosa can detect format correctly. - # Defaulting to .wav was causing soundfile to reject MP3/WebM content as invalid WAV. - _allowed_audio_exts = {".wav", ".mp3", ".m4a", ".ogg", ".flac", ".aac", ".webm", ".opus"} - _uploaded_ext = Path(file.filename or "").suffix.lower() - file_suffix = _uploaded_ext if _uploaded_ext in _allowed_audio_exts else ".wav" - - with tempfile.NamedTemporaryFile(suffix=file_suffix, delete=False) as tmp: - content = await file.read() - tmp.write(content) - tmp_path = tmp.name - - try: - sample = await profiles.add_profile_sample( - profile_id, - tmp_path, - reference_text, - db, - ) - return sample - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to process audio file: {str(e)}") - finally: - # Clean up temp file - Path(tmp_path).unlink(missing_ok=True) - - -@app.get("/profiles/{profile_id}/samples", response_model=List[models.ProfileSampleResponse]) -async def get_profile_samples( - profile_id: str, - db: Session = Depends(get_db), -): - """Get all samples for a profile.""" - return await profiles.get_profile_samples(profile_id, db) - - -@app.delete("/profiles/samples/{sample_id}") -async def delete_profile_sample( - sample_id: str, - db: Session = Depends(get_db), -): - """Delete a profile sample.""" - success = await profiles.delete_profile_sample(sample_id, db) - if not success: - raise HTTPException(status_code=404, detail="Sample not found") - return {"message": "Sample deleted successfully"} - - -@app.put("/profiles/samples/{sample_id}", response_model=models.ProfileSampleResponse) -async def update_profile_sample( - sample_id: str, - data: models.ProfileSampleUpdate, - db: Session = Depends(get_db), -): - """Update a profile sample's reference text.""" - sample = await profiles.update_profile_sample(sample_id, data.reference_text, db) - if not sample: - raise HTTPException(status_code=404, detail="Sample not found") - return sample - - -@app.post("/profiles/{profile_id}/avatar", response_model=models.VoiceProfileResponse) -async def upload_profile_avatar( - profile_id: str, - file: UploadFile = File(...), - db: Session = Depends(get_db), -): - """Upload or update avatar image for a profile.""" - # Save uploaded file to temp location - with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file.filename).suffix) as tmp: - content = await file.read() - tmp.write(content) - tmp_path = tmp.name - - try: - profile = await profiles.upload_avatar(profile_id, tmp_path, db) - return profile - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - finally: - # Clean up temp file - Path(tmp_path).unlink(missing_ok=True) - - -@app.get("/profiles/{profile_id}/avatar") -async def get_profile_avatar( - profile_id: str, - db: Session = Depends(get_db), -): - """Get avatar image for a profile.""" - profile = await profiles.get_profile(profile_id, db) - if not profile: - raise HTTPException(status_code=404, detail="Profile not found") - - if not profile.avatar_path: - raise HTTPException(status_code=404, detail="No avatar found for this profile") - - avatar_path = Path(profile.avatar_path) - if not avatar_path.exists(): - raise HTTPException(status_code=404, detail="Avatar file not found") - - return FileResponse(avatar_path) - - -@app.delete("/profiles/{profile_id}/avatar") -async def delete_profile_avatar( - profile_id: str, - db: Session = Depends(get_db), -): - """Delete avatar image for a profile.""" - success = await profiles.delete_avatar(profile_id, db) - if not success: - raise HTTPException(status_code=404, detail="Profile not found or no avatar to delete") - return {"message": "Avatar deleted successfully"} - - -@app.get("/profiles/{profile_id}/export") -async def export_profile( - profile_id: str, - db: Session = Depends(get_db), -): - """Export a voice profile as a ZIP archive.""" - try: - # Get profile to get name for filename - profile = await profiles.get_profile(profile_id, db) - if not profile: - raise HTTPException(status_code=404, detail="Profile not found") - - # Export to ZIP - zip_bytes = export_import.export_profile_to_zip(profile_id, db) - - # Create safe filename - safe_name = "".join(c for c in profile.name if c.isalnum() or c in (" ", "-", "_")).strip() - if not safe_name: - safe_name = "profile" - filename = f"profile-{safe_name}.voicebox.zip" - - # Return as streaming response - return StreamingResponse( - io.BytesIO(zip_bytes), - media_type="application/zip", - headers={"Content-Disposition": _safe_content_disposition("attachment", filename)}, - ) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.get("/channels", response_model=List[models.AudioChannelResponse]) -async def list_channels(db: Session = Depends(get_db)): - """List all audio channels.""" - return await channels.list_channels(db) - - -@app.post("/channels", response_model=models.AudioChannelResponse) -async def create_channel( - data: models.AudioChannelCreate, - db: Session = Depends(get_db), -): - """Create a new audio channel.""" - try: - return await channels.create_channel(data, db) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@app.get("/channels/{channel_id}", response_model=models.AudioChannelResponse) -async def get_channel( - channel_id: str, - db: Session = Depends(get_db), -): - """Get an audio channel by ID.""" - channel = await channels.get_channel(channel_id, db) - if not channel: - raise HTTPException(status_code=404, detail="Channel not found") - return channel - - -@app.put("/channels/{channel_id}", response_model=models.AudioChannelResponse) -async def update_channel( - channel_id: str, - data: models.AudioChannelUpdate, - db: Session = Depends(get_db), -): - """Update an audio channel.""" - try: - channel = await channels.update_channel(channel_id, data, db) - if not channel: - raise HTTPException(status_code=404, detail="Channel not found") - return channel - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@app.delete("/channels/{channel_id}") -async def delete_channel( - channel_id: str, - db: Session = Depends(get_db), -): - """Delete an audio channel.""" - try: - success = await channels.delete_channel(channel_id, db) - if not success: - raise HTTPException(status_code=404, detail="Channel not found") - return {"message": "Channel deleted successfully"} - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@app.get("/channels/{channel_id}/voices") -async def get_channel_voices( - channel_id: str, - db: Session = Depends(get_db), -): - """Get list of profile IDs assigned to a channel.""" - try: - profile_ids = await channels.get_channel_voices(channel_id, db) - return {"profile_ids": profile_ids} - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@app.put("/channels/{channel_id}/voices") -async def set_channel_voices( - channel_id: str, - data: models.ChannelVoiceAssignment, - db: Session = Depends(get_db), -): - """Set which voices are assigned to a channel.""" - try: - await channels.set_channel_voices(channel_id, data, db) - return {"message": "Channel voices updated successfully"} - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@app.get("/profiles/{profile_id}/channels") -async def get_profile_channels( - profile_id: str, - db: Session = Depends(get_db), -): - """Get list of channel IDs assigned to a profile.""" - try: - channel_ids = await channels.get_profile_channels(profile_id, db) - return {"channel_ids": channel_ids} - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@app.put("/profiles/{profile_id}/channels") -async def set_profile_channels( - profile_id: str, - data: models.ProfileChannelAssignment, - db: Session = Depends(get_db), -): - """Set which channels a profile is assigned to.""" - try: - await channels.set_profile_channels(profile_id, data, db) - return {"message": "Profile channels updated successfully"} - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@app.post("/generate", response_model=models.GenerationResponse) -async def generate_speech( - data: models.GenerationRequest, - db: Session = Depends(get_db), -): - """Generate speech from text using a voice profile. - - Creates a history entry immediately with status='generating' and kicks off - TTS in the background. The frontend can poll or use SSE to detect completion. - """ - task_manager = get_task_manager() - generation_id = str(uuid.uuid4()) - - # Validate profile exists before creating the record - profile = await profiles.get_profile(data.profile_id, db) - if not profile: - raise HTTPException(status_code=404, detail="Profile not found") - - from .backends import engine_has_model_sizes - - engine = data.engine or "qwen" - model_size = data.model_size or "1.7B" - - # Create the history entry immediately with status="generating" - generation = await history.create_generation( - profile_id=data.profile_id, - text=data.text, - language=data.language, - audio_path="", - duration=0, - seed=data.seed, - db=db, - instruct=data.instruct, - generation_id=generation_id, - status="generating", - engine=engine, - model_size=model_size if engine_has_model_sizes(engine) else None, - ) - - # Track in task manager - task_manager.start_generation( - task_id=generation_id, - profile_id=data.profile_id, - text=data.text, - ) - - # Resolve effects chain: explicit request > profile default > none - effects_chain_config = None - if data.effects_chain is not None: - effects_chain_config = [e.model_dump() for e in data.effects_chain] - else: - # Check profile default - import json as _json - - profile_obj = db.query(DBVoiceProfile).filter_by(id=data.profile_id).first() - if profile_obj and profile_obj.effects_chain: - try: - effects_chain_config = _json.loads(profile_obj.effects_chain) - except Exception: - pass - - # Kick off TTS in background - enqueue_generation( - run_generation( - generation_id=generation_id, - profile_id=data.profile_id, - text=data.text, - language=data.language, - engine=engine, - model_size=model_size, - seed=data.seed, - normalize=data.normalize, - effects_chain=effects_chain_config, - instruct=data.instruct, - mode="generate", - max_chunk_chars=data.max_chunk_chars, - crossfade_ms=data.crossfade_ms, - ) - ) - - return generation - - -@app.post("/generate/{generation_id}/retry", response_model=models.GenerationResponse) -async def retry_generation(generation_id: str, db: Session = Depends(get_db)): - """Retry a failed generation using the same parameters.""" - gen = db.query(DBGeneration).filter_by(id=generation_id).first() - if not gen: - raise HTTPException(status_code=404, detail="Generation not found") - - if (gen.status or "completed") != "failed": - raise HTTPException(status_code=400, detail="Only failed generations can be retried") - - # Reset the record to generating - gen.status = "generating" - gen.error = None - gen.audio_path = "" - gen.duration = 0 - db.commit() - db.refresh(gen) - - task_manager = get_task_manager() - task_manager.start_generation( - task_id=generation_id, - profile_id=gen.profile_id, - text=gen.text, - ) - - enqueue_generation( - run_generation( - generation_id=generation_id, - profile_id=gen.profile_id, - text=gen.text, - language=gen.language, - engine=gen.engine or "qwen", - model_size=gen.model_size or "1.7B", - seed=gen.seed, - instruct=gen.instruct, - mode="retry", - ) - ) - - return models.GenerationResponse.model_validate(gen) - - -@app.post( - "/generate/{generation_id}/regenerate", - response_model=models.GenerationResponse, -) -async def regenerate_generation(generation_id: str, db: Session = Depends(get_db)): - """Re-run TTS with the same parameters and save the result as a new version.""" - gen = db.query(DBGeneration).filter_by(id=generation_id).first() - if not gen: - raise HTTPException(status_code=404, detail="Generation not found") - if (gen.status or "completed") != "completed": - raise HTTPException(status_code=400, detail="Generation must be completed to regenerate") - - # Set to generating so the UI shows the loader and SSE picks it up - gen.status = "generating" - gen.error = None - db.commit() - db.refresh(gen) - - task_manager = get_task_manager() - task_manager.start_generation( - task_id=generation_id, - profile_id=gen.profile_id, - text=gen.text, - ) - - version_id = str(uuid.uuid4()) - - enqueue_generation( - run_generation( - generation_id=generation_id, - profile_id=gen.profile_id, - text=gen.text, - language=gen.language, - engine=gen.engine or "qwen", - model_size=gen.model_size or "1.7B", - seed=gen.seed, - instruct=gen.instruct, - mode="regenerate", - version_id=version_id, - ) - ) - - return models.GenerationResponse.model_validate(gen) - - -@app.get("/generate/{generation_id}/status") -async def get_generation_status(generation_id: str, db: Session = Depends(get_db)): - """SSE endpoint that streams generation status updates. - - Polls the DB every second and yields the current status. Closes when - the generation reaches 'completed' or 'failed'. - """ - import json - - async def event_stream(): - while True: - db.expire_all() - gen = db.query(DBGeneration).filter_by(id=generation_id).first() - if not gen: - yield f"data: {json.dumps({'status': 'not_found', 'id': generation_id})}\n\n" - return - - payload = { - "id": gen.id, - "status": gen.status or "completed", - "duration": gen.duration, - "error": gen.error, - } - yield f"data: {json.dumps(payload)}\n\n" - - if (gen.status or "completed") in ("completed", "failed"): - return - - await asyncio.sleep(1) - - return StreamingResponse( - event_stream(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) - - -@app.post("/generate/stream") -async def stream_speech( - data: models.GenerationRequest, - db: Session = Depends(get_db), -): - """ - Generate speech and stream the WAV audio directly without saving to disk. - - Returns raw WAV bytes via a StreamingResponse so the client can start - playing audio before the entire file has been received. This endpoint - does NOT create a history entry — use /generate for that. - """ - from .backends import get_tts_backend_for_engine - - profile = await profiles.get_profile(data.profile_id, db) - if not profile: - raise HTTPException(status_code=404, detail="Profile not found") - - engine = data.engine or "qwen" - tts_model = get_tts_backend_for_engine(engine) - model_size = data.model_size or "1.7B" - - from .backends import ensure_model_cached_or_raise, load_engine_model, engine_needs_trim - - await ensure_model_cached_or_raise(engine, model_size) - await load_engine_model(engine, model_size) - - voice_prompt = await profiles.create_voice_prompt_for_profile( - data.profile_id, - db, - engine=engine, - ) - - from .utils.chunked_tts import generate_chunked - - trim_fn = None - if engine_needs_trim(engine): - from .utils.audio import trim_tts_output - - trim_fn = trim_tts_output - - audio, sample_rate = await generate_chunked( - tts_model, - data.text, - voice_prompt, - language=data.language, - seed=data.seed, - instruct=data.instruct, - max_chunk_chars=data.max_chunk_chars, - crossfade_ms=data.crossfade_ms, - trim_fn=trim_fn, - ) - - if data.normalize: - from .utils.audio import normalize_audio - - audio = normalize_audio(audio) - - wav_bytes = tts.audio_to_wav_bytes(audio, sample_rate) - - async def _wav_stream(): - # Yield in chunks so large responses don't block the event loop - chunk_size = 64 * 1024 # 64 KB - for i in range(0, len(wav_bytes), chunk_size): - yield wav_bytes[i : i + chunk_size] - - return StreamingResponse( - _wav_stream(), - media_type="audio/wav", - headers={"Content-Disposition": 'attachment; filename="speech.wav"'}, - ) - - -@app.get("/history", response_model=models.HistoryListResponse) -async def list_history( - profile_id: Optional[str] = None, - search: Optional[str] = None, - limit: int = 50, - offset: int = 0, - db: Session = Depends(get_db), -): - """List generation history with optional filters.""" - query = models.HistoryQuery( - profile_id=profile_id, - search=search, - limit=limit, - offset=offset, - ) - return await history.list_generations(query, db) - - -@app.get("/history/stats") -async def get_stats(db: Session = Depends(get_db)): - """Get generation statistics.""" - return await history.get_generation_stats(db) - - -@app.post("/history/import") -async def import_generation( - file: UploadFile = File(...), - db: Session = Depends(get_db), -): - """Import a generation from a ZIP archive.""" - # Validate file size (max 50MB) - MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB - - # Read file content - content = await file.read() - - if len(content) > MAX_FILE_SIZE: - raise HTTPException( - status_code=400, detail=f"File too large. Maximum size is {MAX_FILE_SIZE / (1024 * 1024)}MB" - ) - - try: - result = await export_import.import_generation_from_zip(content, db) - return result - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.get("/history/{generation_id}", response_model=models.HistoryResponse) -async def get_generation( - generation_id: str, - db: Session = Depends(get_db), -): - """Get a generation by ID.""" - # Get generation with profile name - result = ( - db.query(DBGeneration, DBVoiceProfile.name.label("profile_name")) - .join(DBVoiceProfile, DBGeneration.profile_id == DBVoiceProfile.id) - .filter(DBGeneration.id == generation_id) - .first() - ) - - if not result: - raise HTTPException(status_code=404, detail="Generation not found") - - gen, profile_name = result - return models.HistoryResponse( - id=gen.id, - profile_id=gen.profile_id, - profile_name=profile_name, - text=gen.text, - language=gen.language, - audio_path=gen.audio_path, - duration=gen.duration, - seed=gen.seed, - instruct=gen.instruct, - created_at=gen.created_at, - ) - - -@app.post("/history/{generation_id}/favorite") -async def toggle_favorite( - generation_id: str, - db: Session = Depends(get_db), -): - """Toggle the favorite status of a generation.""" - gen = db.query(DBGeneration).filter_by(id=generation_id).first() - if not gen: - raise HTTPException(status_code=404, detail="Generation not found") - gen.is_favorited = not gen.is_favorited - db.commit() - return {"is_favorited": gen.is_favorited} - - -@app.delete("/history/{generation_id}") -async def delete_generation( - generation_id: str, - db: Session = Depends(get_db), -): - """Delete a generation.""" - success = await history.delete_generation(generation_id, db) - if not success: - raise HTTPException(status_code=404, detail="Generation not found") - return {"message": "Generation deleted successfully"} - - -@app.get("/history/{generation_id}/export") -async def export_generation( - generation_id: str, - db: Session = Depends(get_db), -): - """Export a generation as a ZIP archive.""" - try: - # Get generation to create filename - generation = db.query(DBGeneration).filter_by(id=generation_id).first() - if not generation: - raise HTTPException(status_code=404, detail="Generation not found") - - # Export to ZIP - zip_bytes = export_import.export_generation_to_zip(generation_id, db) - - # Create safe filename from text - safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (" ", "-", "_")).strip() - if not safe_text: - safe_text = "generation" - filename = f"generation-{safe_text}.voicebox.zip" - - # Return as streaming response - return StreamingResponse( - io.BytesIO(zip_bytes), - media_type="application/zip", - headers={"Content-Disposition": _safe_content_disposition("attachment", filename)}, - ) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.get("/history/{generation_id}/export-audio") -async def export_generation_audio( - generation_id: str, - db: Session = Depends(get_db), -): - """Export only the audio file from a generation.""" - generation = db.query(DBGeneration).filter_by(id=generation_id).first() - if not generation: - raise HTTPException(status_code=404, detail="Generation not found") - - audio_path = Path(generation.audio_path) - if not audio_path.exists(): - raise HTTPException(status_code=404, detail="Audio file not found") - - # Create safe filename from text - safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (" ", "-", "_")).strip() - if not safe_text: - safe_text = "generation" - filename = f"{safe_text}.wav" - - return FileResponse( - audio_path, - media_type="audio/wav", - headers={"Content-Disposition": _safe_content_disposition("attachment", filename)}, - ) - - -@app.post("/transcribe", response_model=models.TranscriptionResponse) -async def transcribe_audio( - file: UploadFile = File(...), - language: Optional[str] = Form(None), -): - """Transcribe audio file to text.""" - # Save uploaded file to temporary location - with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: - content = await file.read() - tmp.write(content) - tmp_path = tmp.name - - try: - # Get audio duration - from .utils.audio import load_audio - - audio, sr = await asyncio.to_thread(load_audio, tmp_path) - duration = len(audio) / sr - - # Transcribe - whisper_model = transcribe.get_whisper_model() - - # Check if Whisper model is downloaded - model_size = whisper_model.model_size - # Map model sizes to HF repo IDs (some need special suffixes) - whisper_hf_repos = { - "large": "openai/whisper-large-v3", - "turbo": "openai/whisper-large-v3-turbo", - } - model_name = whisper_hf_repos.get(model_size, f"openai/whisper-{model_size}") - - # Check if model is cached - from huggingface_hub import constants as hf_constants - - repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_name.replace("/", "--")) - if not repo_cache.exists(): - # Start download in background - progress_model_name = f"whisper-{model_size}" - - async def download_whisper_background(): - try: - await whisper_model.load_model_async(model_size) - except Exception as e: - get_task_manager().error_download(progress_model_name, str(e)) - - get_task_manager().start_download(progress_model_name) - create_background_task(download_whisper_background()) - - # Return 202 Accepted - raise HTTPException( - status_code=202, - detail={ - "message": f"Whisper model {model_size} is being downloaded. Please wait and try again.", - "model_name": progress_model_name, - "downloading": True, - }, - ) - - text = await whisper_model.transcribe(tmp_path, language) - - return models.TranscriptionResponse( - text=text, - duration=duration, - ) - - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - finally: - # Clean up temp file - Path(tmp_path).unlink(missing_ok=True) - - -@app.get("/stories", response_model=List[models.StoryResponse]) -async def list_stories(db: Session = Depends(get_db)): - """List all stories.""" - return await stories.list_stories(db) - - -@app.post("/stories", response_model=models.StoryResponse) -async def create_story( - data: models.StoryCreate, - db: Session = Depends(get_db), -): - """Create a new story.""" - try: - return await stories.create_story(data, db) - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@app.get("/stories/{story_id}", response_model=models.StoryDetailResponse) -async def get_story( - story_id: str, - db: Session = Depends(get_db), -): - """Get a story with all its items.""" - story = await stories.get_story(story_id, db) - if not story: - raise HTTPException(status_code=404, detail="Story not found") - return story - - -@app.put("/stories/{story_id}", response_model=models.StoryResponse) -async def update_story( - story_id: str, - data: models.StoryCreate, - db: Session = Depends(get_db), -): - """Update a story.""" - story = await stories.update_story(story_id, data, db) - if not story: - raise HTTPException(status_code=404, detail="Story not found") - return story - - -@app.delete("/stories/{story_id}") -async def delete_story( - story_id: str, - db: Session = Depends(get_db), -): - """Delete a story.""" - success = await stories.delete_story(story_id, db) - if not success: - raise HTTPException(status_code=404, detail="Story not found") - return {"message": "Story deleted successfully"} - - -@app.post("/stories/{story_id}/items", response_model=models.StoryItemDetail) -async def add_story_item( - story_id: str, - data: models.StoryItemCreate, - db: Session = Depends(get_db), -): - """Add a generation to a story.""" - item = await stories.add_item_to_story(story_id, data, db) - if not item: - raise HTTPException(status_code=404, detail="Story or generation not found") - return item - - -@app.delete("/stories/{story_id}/items/{item_id}") -async def remove_story_item( - story_id: str, - item_id: str, - db: Session = Depends(get_db), -): - """Remove a story item from a story.""" - success = await stories.remove_item_from_story(story_id, item_id, db) - if not success: - raise HTTPException(status_code=404, detail="Story item not found") - return {"message": "Item removed successfully"} - - -@app.put("/stories/{story_id}/items/times") -async def update_story_item_times( - story_id: str, - data: models.StoryItemBatchUpdate, - db: Session = Depends(get_db), -): - """Update story item timecodes.""" - success = await stories.update_story_item_times(story_id, data, db) - if not success: - raise HTTPException(status_code=400, detail="Invalid timecode update request") - return {"message": "Item timecodes updated successfully"} - - -@app.put("/stories/{story_id}/items/reorder", response_model=List[models.StoryItemDetail]) -async def reorder_story_items( - story_id: str, - data: models.StoryItemReorder, - db: Session = Depends(get_db), -): - """Reorder story items and recalculate timecodes.""" - items = await stories.reorder_story_items(story_id, data.generation_ids, db) - if items is None: - raise HTTPException( - status_code=400, detail="Invalid reorder request - ensure all generation IDs belong to this story" - ) - return items - - -@app.put("/stories/{story_id}/items/{item_id}/move", response_model=models.StoryItemDetail) -async def move_story_item( - story_id: str, - item_id: str, - data: models.StoryItemMove, - db: Session = Depends(get_db), -): - """Move a story item (update position and/or track).""" - item = await stories.move_story_item(story_id, item_id, data, db) - if item is None: - raise HTTPException(status_code=404, detail="Story item not found") - return item - - -@app.put("/stories/{story_id}/items/{item_id}/trim", response_model=models.StoryItemDetail) -async def trim_story_item( - story_id: str, - item_id: str, - data: models.StoryItemTrim, - db: Session = Depends(get_db), -): - """Trim a story item (update trim_start_ms and trim_end_ms).""" - item = await stories.trim_story_item(story_id, item_id, data, db) - if item is None: - raise HTTPException(status_code=404, detail="Story item not found or invalid trim values") - return item - - -@app.post("/stories/{story_id}/items/{item_id}/split", response_model=List[models.StoryItemDetail]) -async def split_story_item( - story_id: str, - item_id: str, - data: models.StoryItemSplit, - db: Session = Depends(get_db), -): - """Split a story item at a given time, creating two clips.""" - items = await stories.split_story_item(story_id, item_id, data, db) - if items is None: - raise HTTPException(status_code=404, detail="Story item not found or invalid split point") - return items - - -@app.post("/stories/{story_id}/items/{item_id}/duplicate", response_model=models.StoryItemDetail) -async def duplicate_story_item( - story_id: str, - item_id: str, - db: Session = Depends(get_db), -): - """Duplicate a story item, creating a copy with all properties.""" - item = await stories.duplicate_story_item(story_id, item_id, db) - if item is None: - raise HTTPException(status_code=404, detail="Story item not found") - return item - - -@app.put("/stories/{story_id}/items/{item_id}/version", response_model=models.StoryItemDetail) -async def set_story_item_version( - story_id: str, - item_id: str, - data: models.StoryItemVersionUpdate, - db: Session = Depends(get_db), -): - """Pin a story item to a specific generation version.""" - item = await stories.set_story_item_version(story_id, item_id, data, db) - if item is None: - raise HTTPException(status_code=404, detail="Story item or version not found") - return item - - -@app.get("/stories/{story_id}/export-audio") -async def export_story_audio( - story_id: str, - db: Session = Depends(get_db), -): - """Export story as single mixed audio file with timecode-based mixing.""" - try: - # Get story to create filename - story = db.query(database.Story).filter_by(id=story_id).first() - if not story: - raise HTTPException(status_code=404, detail="Story not found") - - # Export audio - audio_bytes = await stories.export_story_audio(story_id, db) - if not audio_bytes: - raise HTTPException(status_code=400, detail="Story has no audio items") - - # Create safe filename - 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" - - # Return as streaming response - return StreamingResponse( - io.BytesIO(audio_bytes), - media_type="audio/wav", - headers={"Content-Disposition": _safe_content_disposition("attachment", filename)}, - ) - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.post("/effects/preview/{generation_id}") -async def preview_effects( - generation_id: str, - data: models.ApplyEffectsRequest, - db: Session = Depends(get_db), -): - """Apply effects to a generation's clean audio and stream back the result without saving. - - Used for ephemeral preview/auditioning of effects chains. - """ - gen = db.query(DBGeneration).filter_by(id=generation_id).first() - if not gen: - raise HTTPException(status_code=404, detail="Generation not found") - if (gen.status or "completed") != "completed": - raise HTTPException(status_code=400, detail="Generation is not completed") - - from . import versions as versions_mod - from .utils.effects import apply_effects, validate_effects_chain - from .utils.audio import load_audio - - # Validate chain - chain_dicts = [e.model_dump() for e in data.effects_chain] - error = validate_effects_chain(chain_dicts) - if error: - raise HTTPException(status_code=400, detail=error) - - # Find the original unprocessed version (no effects applied) - all_versions = versions_mod.list_versions(generation_id, db) - clean_version = next((v for v in all_versions if v.effects_chain is None), None) - source_path = clean_version.audio_path if clean_version else gen.audio_path - if not source_path or not Path(source_path).exists(): - raise HTTPException(status_code=404, detail="Source audio file not found") - - # Process in memory (off the event loop) - audio, sample_rate = await asyncio.to_thread(load_audio, source_path) - processed = await asyncio.to_thread(apply_effects, audio, sample_rate, chain_dicts) - - # Write to in-memory buffer - import soundfile as sf - - buf = io.BytesIO() - await asyncio.to_thread(lambda: sf.write(buf, processed, sample_rate, format="WAV")) - buf.seek(0) - - return StreamingResponse( - buf, - media_type="audio/wav", - headers={ - "Content-Disposition": f'inline; filename="preview_{generation_id}.wav"', - "Cache-Control": "no-cache, no-store", - }, - ) - - -@app.get("/effects/available", response_model=models.AvailableEffectsResponse) -async def get_available_effects(): - """List all available effect types with parameter definitions.""" - from .utils.effects import get_available_effects as _get_effects - - return models.AvailableEffectsResponse(effects=[models.AvailableEffect(**e) for e in _get_effects()]) - - -@app.get("/effects/presets", response_model=List[models.EffectPresetResponse]) -async def list_effect_presets(db: Session = Depends(get_db)): - """List all effect presets (built-in + user-created).""" - from . import effects as effects_mod - - return effects_mod.list_presets(db) - - -@app.get("/effects/presets/{preset_id}", response_model=models.EffectPresetResponse) -async def get_effect_preset(preset_id: str, db: Session = Depends(get_db)): - """Get a specific effect preset.""" - from . import effects as effects_mod - - preset = effects_mod.get_preset(preset_id, db) - if not preset: - raise HTTPException(status_code=404, detail="Preset not found") - return preset - - -@app.post("/effects/presets", response_model=models.EffectPresetResponse) -async def create_effect_preset( - data: models.EffectPresetCreate, - db: Session = Depends(get_db), -): - """Create a new effect preset.""" - from . import effects as effects_mod - - try: - return effects_mod.create_preset(data, db) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@app.put("/effects/presets/{preset_id}", response_model=models.EffectPresetResponse) -async def update_effect_preset( - preset_id: str, - data: models.EffectPresetUpdate, - db: Session = Depends(get_db), -): - """Update an effect preset.""" - from . import effects as effects_mod - - try: - result = effects_mod.update_preset(preset_id, data, db) - if not result: - raise HTTPException(status_code=404, detail="Preset not found") - return result - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@app.delete("/effects/presets/{preset_id}") -async def delete_effect_preset(preset_id: str, db: Session = Depends(get_db)): - """Delete a user effect preset.""" - from . import effects as effects_mod - - try: - if not effects_mod.delete_preset(preset_id, db): - raise HTTPException(status_code=404, detail="Preset not found") - return {"status": "deleted"} - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@app.get( - "/generations/{generation_id}/versions", - response_model=List[models.GenerationVersionResponse], -) -async def list_generation_versions( - generation_id: str, - db: Session = Depends(get_db), -): - """List all versions for a generation.""" - gen = await history.get_generation(generation_id, db) - if not gen: - raise HTTPException(status_code=404, detail="Generation not found") - - from . import versions as versions_mod - - return versions_mod.list_versions(generation_id, db) - - -@app.post( - "/generations/{generation_id}/versions/apply-effects", - response_model=models.GenerationVersionResponse, -) -async def apply_effects_to_generation( - generation_id: str, - data: models.ApplyEffectsRequest, - db: Session = Depends(get_db), -): - """Apply an effects chain to an existing generation, creating a new version.""" - gen = db.query(DBGeneration).filter_by(id=generation_id).first() - if not gen: - raise HTTPException(status_code=404, detail="Generation not found") - if (gen.status or "completed") != "completed": - raise HTTPException(status_code=400, detail="Generation is not completed") - - from . import versions as versions_mod - from .utils.effects import apply_effects, validate_effects_chain - from .utils.audio import load_audio, save_audio - - # Validate effects chain - chain_dicts = [e.model_dump() for e in data.effects_chain] - error = validate_effects_chain(chain_dicts) - if error: - raise HTTPException(status_code=400, detail=error) - - # Determine source audio: use specified version, or fall back to clean/original - all_versions = versions_mod.list_versions(generation_id, db) - source_version_id = data.source_version_id - if source_version_id: - source_version = next((v for v in all_versions if v.id == source_version_id), None) - if not source_version: - raise HTTPException(status_code=404, detail="Source version not found") - source_path = source_version.audio_path - else: - clean_version = next((v for v in all_versions if v.effects_chain is None), None) - if not clean_version: - source_path = gen.audio_path - else: - source_path = clean_version.audio_path - source_version_id = clean_version.id - - if not source_path or not Path(source_path).exists(): - raise HTTPException(status_code=404, detail="Source audio file not found") - - # Load, process, save (off the event loop) - audio, sample_rate = await asyncio.to_thread(load_audio, source_path) - processed_audio = await asyncio.to_thread(apply_effects, audio, sample_rate, chain_dicts) - - # Generate a unique filename - version_id = str(uuid.uuid4()) - processed_path = config.get_generations_dir() / f"{generation_id}_{version_id[:8]}.wav" - await asyncio.to_thread(save_audio, processed_audio, str(processed_path), sample_rate) - - # Auto-label - label = data.label or f"version-{len(all_versions) + 1}" - - version = versions_mod.create_version( - generation_id=generation_id, - label=label, - audio_path=str(processed_path), - db=db, - effects_chain=chain_dicts, - is_default=data.set_as_default, - source_version_id=source_version_id, - ) - - return version - - -@app.put( - "/generations/{generation_id}/versions/{version_id}/set-default", - response_model=models.GenerationVersionResponse, -) -async def set_default_version( - generation_id: str, - version_id: str, - db: Session = Depends(get_db), -): - """Set a specific version as the default for a generation.""" - from . import versions as versions_mod - - version = versions_mod.get_version(version_id, db) - if not version or version.generation_id != generation_id: - raise HTTPException(status_code=404, detail="Version not found") - - result = versions_mod.set_default_version(version_id, db) - if not result: - raise HTTPException(status_code=404, detail="Version not found") - return result - - -@app.delete("/generations/{generation_id}/versions/{version_id}") -async def delete_generation_version( - generation_id: str, - version_id: str, - db: Session = Depends(get_db), -): - """Delete a version. Cannot delete the last remaining version.""" - from . import versions as versions_mod - - version = versions_mod.get_version(version_id, db) - if not version or version.generation_id != generation_id: - raise HTTPException(status_code=404, detail="Version not found") - - if not versions_mod.delete_version(version_id, db): - raise HTTPException( - status_code=400, - detail="Cannot delete the last remaining version", - ) - return {"status": "deleted"} - - -@app.get("/audio/version/{version_id}") -async def get_version_audio(version_id: str, db: Session = Depends(get_db)): - """Serve audio for a specific version.""" - from . import versions as versions_mod - - version = versions_mod.get_version(version_id, db) - if not version: - raise HTTPException(status_code=404, detail="Version not found") - - audio_path = Path(version.audio_path) - if not audio_path.exists(): - raise HTTPException(status_code=404, detail="Audio file not found") - - return FileResponse( - audio_path, - media_type="audio/wav", - filename=f"generation_{version.generation_id}_{version.label}.wav", - ) - - -@app.put("/profiles/{profile_id}/effects", response_model=models.VoiceProfileResponse) -async def update_profile_effects( - profile_id: str, - data: models.ProfileEffectsUpdate, - db: Session = Depends(get_db), -): - """Set or clear the default effects chain for a voice profile.""" - import json as _json - - profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first() - if not profile: - raise HTTPException(status_code=404, detail="Profile not found") - - if data.effects_chain is not None: - from .utils.effects import validate_effects_chain - - chain_dicts = [e.model_dump() for e in data.effects_chain] - error = validate_effects_chain(chain_dicts) - if error: - raise HTTPException(status_code=400, detail=error) - profile.effects_chain = _json.dumps(chain_dicts) - else: - profile.effects_chain = None - - profile.updated_at = datetime.utcnow() - db.commit() - db.refresh(profile) - - return _profile_to_response(profile) - - -@app.get("/audio/{generation_id}") -async def get_audio(generation_id: str, db: Session = Depends(get_db)): - """Serve generated audio file (serves the default version).""" - generation = await history.get_generation(generation_id, db) - if not generation: - raise HTTPException(status_code=404, detail="Generation not found") - - audio_path = Path(generation.audio_path) - if not audio_path.exists(): - raise HTTPException(status_code=404, detail="Audio file not found") - - return FileResponse( - audio_path, - media_type="audio/wav", - filename=f"generation_{generation_id}.wav", - ) - - -@app.get("/samples/{sample_id}") -async def get_sample_audio(sample_id: str, db: Session = Depends(get_db)): - """Serve profile sample audio file.""" - from .database import ProfileSample as DBProfileSample - - sample = db.query(DBProfileSample).filter_by(id=sample_id).first() - if not sample: - raise HTTPException(status_code=404, detail="Sample not found") - - audio_path = Path(sample.audio_path) - if not audio_path.exists(): - raise HTTPException(status_code=404, detail="Audio file not found") - - return FileResponse( - audio_path, - media_type="audio/wav", - filename=f"sample_{sample_id}.wav", - ) - - -@app.post("/models/load") -async def load_model(model_size: str = "1.7B"): - """Manually load TTS model.""" - try: - tts_model = tts.get_tts_model() - await tts_model.load_model_async(model_size) - return {"message": f"Model {model_size} loaded successfully"} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.post("/models/unload") -async def unload_model(): - """Unload the default Qwen TTS model to free memory.""" - try: - tts.unload_tts_model() - return {"message": "Model unloaded successfully"} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.post("/models/{model_name}/unload") -async def unload_model_by_name(model_name: str): - """Unload a specific model from memory without deleting it from disk.""" - from .backends import get_model_config, unload_model_by_config - - config = get_model_config(model_name) - if not config: - raise HTTPException(status_code=400, detail=f"Unknown model: {model_name}") - - try: - was_loaded = unload_model_by_config(config) - if not was_loaded: - return {"message": f"Model {model_name} is not loaded"} - return {"message": f"Model {model_name} unloaded successfully"} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) from e - - -@app.get("/models/progress/{model_name}") -async def get_model_progress(model_name: str): - """Get model download progress via Server-Sent Events.""" - from fastapi.responses import StreamingResponse - - progress_manager = get_progress_manager() - - async def event_generator(): - """Generate SSE events for progress updates.""" - async for event in progress_manager.subscribe(model_name): - yield event - - return StreamingResponse( - event_generator(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) - - -@app.get("/models/cache-dir") -async def get_models_cache_dir(): - """Get the path to the HuggingFace model cache directory.""" - from huggingface_hub import constants as hf_constants - - return {"path": str(Path(hf_constants.HF_HUB_CACHE))} - - -def _get_dir_size(path: Path) -> int: - """Get total size of a directory in bytes.""" - total = 0 - for f in path.rglob("*"): - if f.is_file(): - total += f.stat().st_size - return total - - -def _copy_with_progress(src: Path, dst: Path, progress_manager, copied_so_far: int, total_bytes: int) -> int: - """Copy a directory tree with byte-level progress tracking.""" - import shutil - - dst.mkdir(parents=True, exist_ok=True) - for item in src.iterdir(): - dest_item = dst / item.name - if item.is_dir(): - copied_so_far = _copy_with_progress(item, dest_item, progress_manager, copied_so_far, total_bytes) - else: - size = item.stat().st_size - shutil.copy2(str(item), str(dest_item)) - copied_so_far += size - progress_manager.update_progress( - "migration", - copied_so_far, - total_bytes, - filename=item.name, - status="downloading", - ) - return copied_so_far - - -@app.post("/models/migrate") -async def migrate_models(request: models.ModelMigrateRequest): - """Move all downloaded models to a new directory with byte-level progress via SSE.""" - import shutil - from huggingface_hub import constants as hf_constants - - source = Path(hf_constants.HF_HUB_CACHE) - destination = Path(request.destination) - - if not source.exists(): - raise HTTPException(status_code=404, detail="Current model cache directory not found") - - model_dirs = [d for d in source.iterdir() if d.name.startswith("models--") and d.is_dir()] - if not model_dirs: - return {"moved": 0, "errors": [], "source": str(source), "destination": str(destination)} - - destination.mkdir(parents=True, exist_ok=True) - - progress_manager = get_progress_manager() - - # Check if source and destination are on the same filesystem (rename is instant) - same_fs = False - try: - same_fs = source.stat().st_dev == destination.stat().st_dev - except OSError: - pass - - async def migrate_background(): - moved = 0 - errors = [] - try: - if same_fs: - # Same filesystem: rename is instant, just track model count - total = len(model_dirs) - for i, item in enumerate(model_dirs): - dest_item = destination / item.name - try: - if dest_item.exists(): - shutil.rmtree(dest_item) - shutil.move(str(item), str(dest_item)) - moved += 1 - progress_manager.update_progress( - "migration", - i + 1, - total, - filename=item.name, - status="downloading", - ) - except Exception as e: - errors.append(f"{item.name}: {str(e)}") - else: - # Cross-filesystem: copy with byte-level progress, then delete source - total_bytes = sum(_get_dir_size(d) for d in model_dirs) - progress_manager.update_progress( - "migration", 0, total_bytes, filename="Calculating...", status="downloading" - ) - - copied = 0 - for item in model_dirs: - dest_item = destination / item.name - try: - if dest_item.exists(): - shutil.rmtree(dest_item) - copied = await asyncio.to_thread( - _copy_with_progress, item, dest_item, progress_manager, copied, total_bytes - ) - # Remove source after successful copy - await asyncio.to_thread(shutil.rmtree, str(item)) - moved += 1 - except Exception as e: - errors.append(f"{item.name}: {str(e)}") - - progress_manager.update_progress("migration", 1, 1, status="complete") - progress_manager.mark_complete("migration") - except Exception as e: - progress_manager.update_progress("migration", 0, 0, status="error") - progress_manager.mark_error("migration", str(e)) - - create_background_task(migrate_background()) - - return {"source": str(source), "destination": str(destination)} - - -@app.get("/models/migrate/progress") -async def get_migration_progress(): - """Get model migration progress via Server-Sent Events.""" - from fastapi.responses import StreamingResponse - - progress_manager = get_progress_manager() - - async def event_generator(): - async for event in progress_manager.subscribe("migration"): - yield event - - return StreamingResponse( - event_generator(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) - - -@app.get("/models/status", response_model=models.ModelStatusListResponse) -async def get_model_status(): - """Get status of all available models.""" - from huggingface_hub import constants as hf_constants - from pathlib import Path - - backend_type = get_backend_type() - task_manager = get_task_manager() - - # Get set of currently downloading model names - active_download_names = {task.model_name for task in task_manager.get_active_downloads()} - - # Try to import scan_cache_dir (might not be available in older versions) - try: - from huggingface_hub import scan_cache_dir - - use_scan_cache = True - except ImportError: - use_scan_cache = False - - from .backends import get_all_model_configs, check_model_loaded - - registry_configs = get_all_model_configs() - model_configs = [ - { - "model_name": cfg.model_name, - "display_name": cfg.display_name, - "hf_repo_id": cfg.hf_repo_id, - "model_size": cfg.model_size, - "check_loaded": lambda c=cfg: check_model_loaded(c), - } - for cfg in registry_configs - ] - - # Build a mapping of model_name -> hf_repo_id so we can check if shared repos are downloading - model_to_repo = {cfg["model_name"]: cfg["hf_repo_id"] for cfg in model_configs} - - # Get the set of hf_repo_ids that are currently being downloaded - # This handles the case where multiple models share the same repo (e.g., 0.6B and 1.7B on MLX) - active_download_repos = {model_to_repo.get(name) for name in active_download_names if name in model_to_repo} - - # Get HuggingFace cache info (if available) - cache_info = None - if use_scan_cache: - try: - cache_info = scan_cache_dir() - except Exception: - # Function failed, continue without it - pass - - statuses = [] - - for config in model_configs: - try: - downloaded = False - size_mb = None - loaded = False - - # Method 1: Try using scan_cache_dir if available - if cache_info: - repo_id = config["hf_repo_id"] - for repo in cache_info.repos: - if repo.repo_id == repo_id: - # Check if actual model weight files exist (not just config files) - # scan_cache_dir only shows completed files, so check if any are model weights - has_model_weights = False - for rev in repo.revisions: - for f in rev.files: - fname = f.file_name.lower() - if fname.endswith((".safetensors", ".bin", ".pt", ".pth", ".npz")): - has_model_weights = True - break - if has_model_weights: - break - - # Also check for .incomplete files in blobs directory (downloads in progress) - has_incomplete = False - try: - cache_dir = hf_constants.HF_HUB_CACHE - blobs_dir = Path(cache_dir) / ("models--" + repo_id.replace("/", "--")) / "blobs" - if blobs_dir.exists(): - has_incomplete = any(blobs_dir.glob("*.incomplete")) - except Exception: - pass - - # Only mark as downloaded if we have model weights AND no incomplete files - if has_model_weights and not has_incomplete: - downloaded = True - # Calculate size from cache info - try: - total_size = sum(revision.size_on_disk for revision in repo.revisions) - size_mb = total_size / (1024 * 1024) - except Exception: - pass - break - - # Method 2: Fallback to checking cache directory directly (using HuggingFace's OS-specific cache location) - if not downloaded: - try: - cache_dir = hf_constants.HF_HUB_CACHE - repo_cache = Path(cache_dir) / ("models--" + config["hf_repo_id"].replace("/", "--")) - - if repo_cache.exists(): - # Check for .incomplete files - if any exist, download is still in progress - blobs_dir = repo_cache / "blobs" - has_incomplete = blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")) - - if not has_incomplete: - # Check for actual model weight files (not just index files) - # in the snapshots directory (symlinks to completed blobs) - snapshots_dir = repo_cache / "snapshots" - has_model_files = False - if snapshots_dir.exists(): - has_model_files = ( - any(snapshots_dir.rglob("*.bin")) - or any(snapshots_dir.rglob("*.safetensors")) - or any(snapshots_dir.rglob("*.pt")) - or any(snapshots_dir.rglob("*.pth")) - or any(snapshots_dir.rglob("*.npz")) - ) - - if has_model_files: - downloaded = True - # Calculate size (exclude .incomplete files) - try: - total_size = sum( - f.stat().st_size - for f in repo_cache.rglob("*") - if f.is_file() and not f.name.endswith(".incomplete") - ) - size_mb = total_size / (1024 * 1024) - except Exception: - pass - except Exception: - pass - - # Method 3 removed - checking for config.json is too lenient - # Methods 1 and 2 properly verify that model weight files exist - - # Check if loaded in memory - try: - loaded = config["check_loaded"]() - except Exception: - loaded = False - - # Check if this model (or its shared repo) is currently being downloaded - is_downloading = config["hf_repo_id"] in active_download_repos - - # If downloading, don't report as downloaded (partial files exist) - if is_downloading: - downloaded = False - size_mb = None # Don't show partial size during download - - statuses.append( - models.ModelStatus( - model_name=config["model_name"], - display_name=config["display_name"], - hf_repo_id=config["hf_repo_id"], - downloaded=downloaded, - downloading=is_downloading, - size_mb=size_mb, - loaded=loaded, - ) - ) - except Exception as e: - # If check fails, try to at least check if loaded - try: - loaded = config["check_loaded"]() - except Exception: - loaded = False - - # Check if this model (or its shared repo) is currently being downloaded - is_downloading = config["hf_repo_id"] in active_download_repos - - statuses.append( - models.ModelStatus( - model_name=config["model_name"], - display_name=config["display_name"], - hf_repo_id=config["hf_repo_id"], - downloaded=False, # Assume not downloaded if check failed - downloading=is_downloading, - size_mb=None, - loaded=loaded, - ) - ) - - return models.ModelStatusListResponse(models=statuses) - - -@app.post("/models/download") -async def trigger_model_download(request: models.ModelDownloadRequest): - """Trigger download of a specific model.""" - import asyncio - from .backends import get_model_config, get_model_load_func - - task_manager = get_task_manager() - progress_manager = get_progress_manager() - - config = get_model_config(request.model_name) - if not config: - raise HTTPException(status_code=400, detail=f"Unknown model: {request.model_name}") - - load_func = get_model_load_func(config) - - async def download_in_background(): - """Download model in background without blocking the HTTP request.""" - try: - # Call the load function (which may be async) - result = load_func() - # If it's a coroutine, await it - if asyncio.iscoroutine(result): - await result - task_manager.complete_download(request.model_name) - except Exception as e: - task_manager.error_download(request.model_name, str(e)) - - # Start tracking download - task_manager.start_download(request.model_name) - - # Initialize progress state so SSE endpoint has initial data to send. - # This fixes a race condition where the frontend connects to SSE before - # any progress callbacks have fired (especially for large models like Qwen - # where huggingface_hub takes time to fetch metadata for all files). - progress_manager.update_progress( - model_name=request.model_name, - current=0, - total=0, # Will be updated once actual total is known - filename="Connecting to HuggingFace...", - status="downloading", - ) - - # Start download in background task (don't await) - create_background_task(download_in_background()) - - # Return immediately - frontend should poll progress endpoint - return {"message": f"Model {request.model_name} download started"} - - -@app.post("/models/download/cancel") -async def cancel_model_download(request: models.ModelDownloadRequest): - """Cancel or dismiss an errored/stale download task.""" - task_manager = get_task_manager() - progress_manager = get_progress_manager() - - removed = task_manager.cancel_download(request.model_name) - - # Also clear progress state so the model doesn't show as downloading - progress_removed = False - with progress_manager._lock: - if request.model_name in progress_manager._progress: - del progress_manager._progress[request.model_name] - progress_removed = True - - if removed or progress_removed: - return {"message": f"Download task for {request.model_name} cancelled"} - return {"message": f"No active task found for {request.model_name}"} - - -@app.post("/tasks/clear") -async def clear_all_tasks(): - """Clear all download tasks and progress state. Does not delete downloaded files.""" - task_manager = get_task_manager() - progress_manager = get_progress_manager() - - task_manager.clear_all() - - with progress_manager._lock: - progress_manager._progress.clear() - progress_manager._last_notify_time.clear() - progress_manager._last_notify_progress.clear() - - return {"message": "All task state cleared"} - - -@app.delete("/models/{model_name}") -async def delete_model(model_name: str): - """Delete a downloaded model from the HuggingFace cache.""" - import shutil - import os - from huggingface_hub import constants as hf_constants - - from .backends import get_model_config, unload_model_by_config - - config = get_model_config(model_name) - if not config: - raise HTTPException(status_code=400, detail=f"Unknown model: {model_name}") - - hf_repo_id = config.hf_repo_id - - try: - # Unload model if currently loaded - unload_model_by_config(config) - - # Find and delete the cache directory (using HuggingFace's OS-specific cache location) - cache_dir = hf_constants.HF_HUB_CACHE - repo_cache_dir = Path(cache_dir) / ("models--" + hf_repo_id.replace("/", "--")) - - # Check if the cache directory exists - if not repo_cache_dir.exists(): - raise HTTPException(status_code=404, detail=f"Model {model_name} not found in cache") - - # Delete the entire cache directory for this model - try: - shutil.rmtree(repo_cache_dir) - except OSError as e: - raise HTTPException(status_code=500, detail=f"Failed to delete model cache directory: {str(e)}") - - return {"message": f"Model {model_name} deleted successfully"} - - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to delete model: {str(e)}") - - -@app.post("/cache/clear") -async def clear_cache(): - """Clear all voice prompt caches (memory and disk).""" - try: - deleted_count = clear_voice_prompt_cache() - return { - "message": f"Voice prompt cache cleared successfully", - "files_deleted": deleted_count, - } - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to clear cache: {str(e)}") - - -@app.get("/tasks/active", response_model=models.ActiveTasksResponse) -async def get_active_tasks(): - """Return all currently active downloads and generations.""" - task_manager = get_task_manager() - progress_manager = get_progress_manager() - - # Get active downloads from both task manager and progress manager - # Task manager tracks which downloads are active - # Progress manager has the actual progress data - active_downloads = [] - task_manager_downloads = task_manager.get_active_downloads() - progress_active = progress_manager.get_all_active() - - # Combine data from both sources - download_map = {task.model_name: task for task in task_manager_downloads} - progress_map = {p["model_name"]: p for p in progress_active} - - # Create unified list - all_model_names = set(download_map.keys()) | set(progress_map.keys()) - for model_name in all_model_names: - task = download_map.get(model_name) - progress = progress_map.get(model_name) - - if task: - # Prefer task error, fall back to progress manager error - error = task.error - if not error: - with progress_manager._lock: - pm_data = progress_manager._progress.get(model_name) - if pm_data: - error = pm_data.get("error") - # Include progress data if available - prog = progress or {} - if not prog: - with progress_manager._lock: - pm_data = progress_manager._progress.get(model_name) - if pm_data: - prog = pm_data - active_downloads.append( - models.ActiveDownloadTask( - model_name=model_name, - status=task.status, - started_at=task.started_at, - error=error, - progress=prog.get("progress"), - current=prog.get("current"), - total=prog.get("total"), - filename=prog.get("filename"), - ) - ) - elif progress: - # Progress exists but no task - create from progress data - timestamp_str = progress.get("timestamp") - if timestamp_str: - try: - started_at = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00")) - except (ValueError, AttributeError): - started_at = datetime.utcnow() - else: - started_at = datetime.utcnow() - - active_downloads.append( - models.ActiveDownloadTask( - model_name=model_name, - status=progress.get("status", "downloading"), - started_at=started_at, - error=progress.get("error"), - progress=progress.get("progress"), - current=progress.get("current"), - total=progress.get("total"), - filename=progress.get("filename"), - ) - ) - - # Get active generations - active_generations = [] - for gen_task in task_manager.get_active_generations(): - active_generations.append( - models.ActiveGenerationTask( - task_id=gen_task.task_id, - profile_id=gen_task.profile_id, - text_preview=gen_task.text_preview, - started_at=gen_task.started_at, - ) - ) - - return models.ActiveTasksResponse( - downloads=active_downloads, - generations=active_generations, - ) - - -@app.get("/backend/cuda-status") -async def get_cuda_status(): - """Get CUDA backend download/availability status.""" - from . import cuda_download - - return cuda_download.get_cuda_status() - - -@app.post("/backend/download-cuda") -async def download_cuda_backend(): - """Download the CUDA backend binary. Returns immediately; track progress via SSE.""" - from . import cuda_download - - # Check if already downloaded - if cuda_download.get_cuda_binary_path() is not None: - raise HTTPException(status_code=409, detail="CUDA backend already downloaded") - - async def _download(): - try: - await cuda_download.download_cuda_binary() - except Exception as e: - import logging - - logging.getLogger(__name__).error(f"CUDA download failed: {e}") - - create_background_task(_download()) - return {"message": "CUDA backend download started", "progress_key": "cuda-backend"} - - -@app.delete("/backend/cuda") -async def delete_cuda_backend(): - """Delete the downloaded CUDA backend binary.""" - from . import cuda_download - - if cuda_download.is_cuda_active(): - raise HTTPException( - status_code=409, - detail="Cannot delete CUDA backend while it is active. Switch to CPU first.", - ) - - deleted = await cuda_download.delete_cuda_binary() - if not deleted: - raise HTTPException(status_code=404, detail="No CUDA backend found to delete") - - return {"message": "CUDA backend deleted"} - - -@app.get("/backend/cuda-progress") -async def get_cuda_download_progress(): - """Get CUDA backend download progress via Server-Sent Events.""" - progress_manager = get_progress_manager() - - async def event_generator(): - async for event in progress_manager.subscribe("cuda-backend"): - yield event - - return StreamingResponse( - event_generator(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) - - -def _get_gpu_status() -> str: - """Get GPU availability status.""" - backend_type = get_backend_type() - if torch.cuda.is_available(): - device_name = torch.cuda.get_device_name(0) - # Check if this is ROCm (AMD) or CUDA (NVIDIA) - is_rocm = hasattr(torch.version, "hip") and torch.version.hip is not None - if is_rocm: - return f"ROCm ({device_name})" - return f"CUDA ({device_name})" - elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): - return "MPS (Apple Silicon)" - elif backend_type == "mlx": - return "Metal (Apple Silicon via MLX)" - return "None (CPU only)" - - -@app.on_event("startup") -async def startup_event(): - """Run on application startup.""" - print("voicebox API starting up...") - database.init_db() - print(f"Database initialized at {database._db_path}") - - # Start the serial generation worker - init_queue() - - # Mark any stale "generating" records as failed — these are leftovers - # from a previous process that was killed mid-generation - try: - from sqlalchemy import text as sa_text - - db = next(get_db()) - result = db.execute( - sa_text( - "UPDATE generations SET status = 'failed', error = 'Server was shut down during generation' WHERE status = 'generating'" - ) - ) - if result.rowcount > 0: - print(f"Marked {result.rowcount} stale generation(s) as failed") - db.commit() - db.close() - except Exception as e: - print(f"Warning: Could not clean up stale generations: {e}") - backend_type = get_backend_type() - print(f"Backend: {backend_type.upper()}") - print(f"GPU available: {_get_gpu_status()}") - - # Auto-update CUDA binary if installed but outdated - from .cuda_download import check_and_update_cuda_binary - - create_background_task(check_and_update_cuda_binary()) - - # Initialize progress manager with main event loop for thread-safe operations - try: - progress_manager = get_progress_manager() - progress_manager._set_main_loop(asyncio.get_running_loop()) - print("Progress manager initialized with event loop") - except Exception as e: - print(f"Warning: Could not initialize progress manager event loop: {e}") - - # Ensure HuggingFace cache directory exists - try: - from huggingface_hub import constants as hf_constants - - cache_dir = Path(hf_constants.HF_HUB_CACHE) - cache_dir.mkdir(parents=True, exist_ok=True) - print(f"HuggingFace cache directory: {cache_dir}") - except Exception as e: - print(f"Warning: Could not create HuggingFace cache directory: {e}") - print("Model downloads may fail. Please ensure the directory exists and has write permissions.") - - -@app.on_event("shutdown") -async def shutdown_event(): - """Run on application shutdown.""" - print("voicebox API shutting down...") - # Unload models to free memory - tts.unload_tts_model() - transcribe.unload_whisper_model() +import uvicorn +from .app import app # noqa: F401 -- re-export for uvicorn "backend.main:app" +from . import config, database if __name__ == "__main__": parser = argparse.ArgumentParser(description="voicebox backend server") @@ -2563,16 +32,14 @@ if __name__ == "__main__": ) args = parser.parse_args() - # Set data directory if provided if args.data_dir: config.set_data_dir(args.data_dir) - # Initialize database after data directory is set database.init_db() uvicorn.run( "backend.main:app", host=args.host, port=args.port, - reload=False, # Disable reload in production + reload=False, ) diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py new file mode 100644 index 00000000..2ee2c956 --- /dev/null +++ b/backend/routes/__init__.py @@ -0,0 +1,32 @@ +"""Route registration for the voicebox API.""" + +from fastapi import FastAPI + + +def register_routers(app: FastAPI) -> None: + """Include all domain routers on the application.""" + from .health import router as health_router + from .profiles import router as profiles_router + from .channels import router as channels_router + from .generations import router as generations_router + from .history import router as history_router + from .transcription import router as transcription_router + from .stories import router as stories_router + from .effects import router as effects_router + from .audio import router as audio_router + from .models import router as models_router + from .tasks import router as tasks_router + from .cuda import router as cuda_router + + app.include_router(health_router) + app.include_router(profiles_router) + app.include_router(channels_router) + app.include_router(generations_router) + app.include_router(history_router) + app.include_router(transcription_router) + app.include_router(stories_router) + app.include_router(effects_router) + app.include_router(audio_router) + app.include_router(models_router) + app.include_router(tasks_router) + app.include_router(cuda_router) diff --git a/backend/routes/audio.py b/backend/routes/audio.py new file mode 100644 index 00000000..12e774ef --- /dev/null +++ b/backend/routes/audio.py @@ -0,0 +1,70 @@ +"""Audio file serving endpoints.""" + +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session + +from .. import history, models +from ..database import get_db + +router = APIRouter() + + +@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.""" + from .. import versions as versions_mod + + version = versions_mod.get_version(version_id, db) + if not version: + raise HTTPException(status_code=404, detail="Version not found") + + audio_path = Path(version.audio_path) + if not audio_path.exists(): + raise HTTPException(status_code=404, detail="Audio file not found") + + return FileResponse( + audio_path, + media_type="audio/wav", + filename=f"generation_{version.generation_id}_{version.label}.wav", + ) + + +@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).""" + generation = await history.get_generation(generation_id, db) + if not generation: + raise HTTPException(status_code=404, detail="Generation not found") + + audio_path = Path(generation.audio_path) + if not audio_path.exists(): + raise HTTPException(status_code=404, detail="Audio file not found") + + return FileResponse( + audio_path, + media_type="audio/wav", + filename=f"generation_{generation_id}.wav", + ) + + +@router.get("/samples/{sample_id}") +async def get_sample_audio(sample_id: str, db: Session = Depends(get_db)): + """Serve profile sample audio file.""" + from ..database import ProfileSample as DBProfileSample + + sample = db.query(DBProfileSample).filter_by(id=sample_id).first() + if not sample: + raise HTTPException(status_code=404, detail="Sample not found") + + audio_path = Path(sample.audio_path) + if not audio_path.exists(): + raise HTTPException(status_code=404, detail="Audio file not found") + + return FileResponse( + audio_path, + media_type="audio/wav", + filename=f"sample_{sample_id}.wav", + ) diff --git a/backend/routes/channels.py b/backend/routes/channels.py new file mode 100644 index 00000000..56391624 --- /dev/null +++ b/backend/routes/channels.py @@ -0,0 +1,97 @@ +"""Audio channel endpoints.""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from .. import channels, models +from ..database import get_db + +router = APIRouter() + + +@router.get("/channels", response_model=list[models.AudioChannelResponse]) +async def list_channels(db: Session = Depends(get_db)): + """List all audio channels.""" + return await channels.list_channels(db) + + +@router.post("/channels", response_model=models.AudioChannelResponse) +async def create_channel( + data: models.AudioChannelCreate, + db: Session = Depends(get_db), +): + """Create a new audio channel.""" + try: + return await channels.create_channel(data, db) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/channels/{channel_id}", response_model=models.AudioChannelResponse) +async def get_channel( + channel_id: str, + db: Session = Depends(get_db), +): + """Get an audio channel by ID.""" + channel = await channels.get_channel(channel_id, db) + if not channel: + raise HTTPException(status_code=404, detail="Channel not found") + return channel + + +@router.put("/channels/{channel_id}", response_model=models.AudioChannelResponse) +async def update_channel( + channel_id: str, + data: models.AudioChannelUpdate, + db: Session = Depends(get_db), +): + """Update an audio channel.""" + try: + channel = await channels.update_channel(channel_id, data, db) + if not channel: + raise HTTPException(status_code=404, detail="Channel not found") + return channel + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.delete("/channels/{channel_id}") +async def delete_channel( + channel_id: str, + db: Session = Depends(get_db), +): + """Delete an audio channel.""" + try: + success = await channels.delete_channel(channel_id, db) + if not success: + raise HTTPException(status_code=404, detail="Channel not found") + return {"message": "Channel deleted successfully"} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/channels/{channel_id}/voices") +async def get_channel_voices( + channel_id: str, + db: Session = Depends(get_db), +): + """Get list of profile IDs assigned to a channel.""" + try: + profile_ids = await channels.get_channel_voices(channel_id, db) + return {"profile_ids": profile_ids} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.put("/channels/{channel_id}/voices") +async def set_channel_voices( + channel_id: str, + data: models.ChannelVoiceAssignment, + db: Session = Depends(get_db), +): + """Set which voices are assigned to a channel.""" + try: + await channels.set_channel_voices(channel_id, data, db) + return {"message": "Channel voices updated successfully"} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/routes/cuda.py b/backend/routes/cuda.py new file mode 100644 index 00000000..9c6e1cd3 --- /dev/null +++ b/backend/routes/cuda.py @@ -0,0 +1,77 @@ +"""CUDA backend management endpoints.""" + +import logging + +from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse + +from ..services.task_queue import create_background_task +from ..utils.progress import get_progress_manager + +router = APIRouter() + +logger = logging.getLogger(__name__) + + +@router.get("/backend/cuda-status") +async def get_cuda_status(): + """Get CUDA backend download/availability status.""" + from ..services import cuda + + return cuda.get_cuda_status() + + +@router.post("/backend/download-cuda") +async def download_cuda_backend(): + """Download the CUDA backend binary.""" + from ..services import cuda + + if cuda.get_cuda_binary_path() is not None: + raise HTTPException(status_code=409, detail="CUDA backend already downloaded") + + async def _download(): + try: + await cuda.download_cuda_binary() + except Exception as e: + logger.error("CUDA download failed: %s", e) + + create_background_task(_download()) + return {"message": "CUDA backend download started", "progress_key": "cuda-backend"} + + +@router.delete("/backend/cuda") +async def delete_cuda_backend(): + """Delete the downloaded CUDA backend binary.""" + from ..services import cuda + + if cuda.is_cuda_active(): + raise HTTPException( + status_code=409, + detail="Cannot delete CUDA backend while it is active. Switch to CPU first.", + ) + + deleted = await cuda.delete_cuda_binary() + if not deleted: + raise HTTPException(status_code=404, detail="No CUDA backend found to delete") + + return {"message": "CUDA backend deleted"} + + +@router.get("/backend/cuda-progress") +async def get_cuda_download_progress(): + """Get CUDA backend download progress via Server-Sent Events.""" + progress_manager = get_progress_manager() + + async def event_generator(): + async for event in progress_manager.subscribe("cuda-backend"): + yield event + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/backend/routes/effects.py b/backend/routes/effects.py new file mode 100644 index 00000000..8e1f6272 --- /dev/null +++ b/backend/routes/effects.py @@ -0,0 +1,260 @@ +"""Effects presets and generation version endpoints.""" + +import asyncio +import io +import uuid +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session + +from .. import config, history, models +from ..database import Generation as DBGeneration, get_db + +router = APIRouter() + + +@router.post("/effects/preview/{generation_id}") +async def preview_effects( + generation_id: str, + data: models.ApplyEffectsRequest, + db: Session = Depends(get_db), +): + """Apply effects to a generation's clean audio and stream back without saving.""" + gen = db.query(DBGeneration).filter_by(id=generation_id).first() + if not gen: + raise HTTPException(status_code=404, detail="Generation not found") + if (gen.status or "completed") != "completed": + raise HTTPException(status_code=400, detail="Generation is not completed") + + from .. import versions as versions_mod + from ..utils.effects import apply_effects, validate_effects_chain + from ..utils.audio import load_audio + + chain_dicts = [e.model_dump() for e in data.effects_chain] + error = validate_effects_chain(chain_dicts) + if error: + raise HTTPException(status_code=400, detail=error) + + all_versions = versions_mod.list_versions(generation_id, db) + clean_version = next((v for v in all_versions if v.effects_chain is None), None) + source_path = clean_version.audio_path if clean_version else gen.audio_path + if not source_path or not Path(source_path).exists(): + raise HTTPException(status_code=404, detail="Source audio file not found") + + audio, sample_rate = await asyncio.to_thread(load_audio, source_path) + processed = await asyncio.to_thread(apply_effects, audio, sample_rate, chain_dicts) + + import soundfile as sf + + buf = io.BytesIO() + await asyncio.to_thread(lambda: sf.write(buf, processed, sample_rate, format="WAV")) + buf.seek(0) + + return StreamingResponse( + buf, + media_type="audio/wav", + headers={ + "Content-Disposition": f'inline; filename="preview_{generation_id}.wav"', + "Cache-Control": "no-cache, no-store", + }, + ) + + +@router.get("/effects/available", response_model=models.AvailableEffectsResponse) +async def get_available_effects(): + """List all available effect types with parameter definitions.""" + from ..utils.effects import get_available_effects as _get_effects + + return models.AvailableEffectsResponse(effects=[models.AvailableEffect(**e) for e in _get_effects()]) + + +@router.get("/effects/presets", response_model=list[models.EffectPresetResponse]) +async def list_effect_presets(db: Session = Depends(get_db)): + """List all effect presets (built-in + user-created).""" + from .. import effects as effects_mod + + return effects_mod.list_presets(db) + + +@router.get("/effects/presets/{preset_id}", response_model=models.EffectPresetResponse) +async def get_effect_preset(preset_id: str, db: Session = Depends(get_db)): + """Get a specific effect preset.""" + from .. import effects as effects_mod + + preset = effects_mod.get_preset(preset_id, db) + if not preset: + raise HTTPException(status_code=404, detail="Preset not found") + return preset + + +@router.post("/effects/presets", response_model=models.EffectPresetResponse) +async def create_effect_preset( + data: models.EffectPresetCreate, + db: Session = Depends(get_db), +): + """Create a new effect preset.""" + from .. import effects as effects_mod + + try: + return effects_mod.create_preset(data, db) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.put("/effects/presets/{preset_id}", response_model=models.EffectPresetResponse) +async def update_effect_preset( + preset_id: str, + data: models.EffectPresetUpdate, + db: Session = Depends(get_db), +): + """Update an effect preset.""" + from .. import effects as effects_mod + + try: + result = effects_mod.update_preset(preset_id, data, db) + if not result: + raise HTTPException(status_code=404, detail="Preset not found") + return result + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.delete("/effects/presets/{preset_id}") +async def delete_effect_preset(preset_id: str, db: Session = Depends(get_db)): + """Delete a user effect preset.""" + from .. import effects as effects_mod + + try: + if not effects_mod.delete_preset(preset_id, db): + raise HTTPException(status_code=404, detail="Preset not found") + return {"status": "deleted"} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get( + "/generations/{generation_id}/versions", + response_model=list[models.GenerationVersionResponse], +) +async def list_generation_versions( + generation_id: str, + db: Session = Depends(get_db), +): + """List all versions for a generation.""" + gen = await history.get_generation(generation_id, db) + if not gen: + raise HTTPException(status_code=404, detail="Generation not found") + + from .. import versions as versions_mod + + return versions_mod.list_versions(generation_id, db) + + +@router.post( + "/generations/{generation_id}/versions/apply-effects", + response_model=models.GenerationVersionResponse, +) +async def apply_effects_to_generation( + generation_id: str, + data: models.ApplyEffectsRequest, + db: Session = Depends(get_db), +): + """Apply an effects chain to an existing generation, creating a new version.""" + gen = db.query(DBGeneration).filter_by(id=generation_id).first() + if not gen: + raise HTTPException(status_code=404, detail="Generation not found") + if (gen.status or "completed") != "completed": + raise HTTPException(status_code=400, detail="Generation is not completed") + + from .. import versions as versions_mod + from ..utils.effects import apply_effects, validate_effects_chain + from ..utils.audio import load_audio, save_audio + + chain_dicts = [e.model_dump() for e in data.effects_chain] + error = validate_effects_chain(chain_dicts) + if error: + raise HTTPException(status_code=400, detail=error) + + all_versions = versions_mod.list_versions(generation_id, db) + source_version_id = data.source_version_id + if source_version_id: + source_version = next((v for v in all_versions if v.id == source_version_id), None) + if not source_version: + raise HTTPException(status_code=404, detail="Source version not found") + source_path = source_version.audio_path + else: + clean_version = next((v for v in all_versions if v.effects_chain is None), None) + if not clean_version: + source_path = gen.audio_path + else: + source_path = clean_version.audio_path + source_version_id = clean_version.id + + if not source_path or not Path(source_path).exists(): + raise HTTPException(status_code=404, detail="Source audio file not found") + + audio, sample_rate = await asyncio.to_thread(load_audio, source_path) + processed_audio = await asyncio.to_thread(apply_effects, audio, sample_rate, chain_dicts) + + version_id = str(uuid.uuid4()) + processed_path = config.get_generations_dir() / f"{generation_id}_{version_id[:8]}.wav" + await asyncio.to_thread(save_audio, processed_audio, str(processed_path), sample_rate) + + label = data.label or f"version-{len(all_versions) + 1}" + + version = versions_mod.create_version( + generation_id=generation_id, + label=label, + audio_path=str(processed_path), + db=db, + effects_chain=chain_dicts, + is_default=data.set_as_default, + source_version_id=source_version_id, + ) + + return version + + +@router.put( + "/generations/{generation_id}/versions/{version_id}/set-default", + response_model=models.GenerationVersionResponse, +) +async def set_default_version( + generation_id: str, + version_id: str, + db: Session = Depends(get_db), +): + """Set a specific version as the default for a generation.""" + from .. import versions as versions_mod + + version = versions_mod.get_version(version_id, db) + if not version or version.generation_id != generation_id: + raise HTTPException(status_code=404, detail="Version not found") + + result = versions_mod.set_default_version(version_id, db) + if not result: + raise HTTPException(status_code=404, detail="Version not found") + return result + + +@router.delete("/generations/{generation_id}/versions/{version_id}") +async def delete_generation_version( + generation_id: str, + version_id: str, + db: Session = Depends(get_db), +): + """Delete a version. Cannot delete the last remaining version.""" + from .. import versions as versions_mod + + version = versions_mod.get_version(version_id, db) + if not version or version.generation_id != generation_id: + raise HTTPException(status_code=404, detail="Version not found") + + if not versions_mod.delete_version(version_id, db): + raise HTTPException( + status_code=400, + detail="Cannot delete the last remaining version", + ) + return {"status": "deleted"} diff --git a/backend/routes/generations.py b/backend/routes/generations.py new file mode 100644 index 00000000..32051796 --- /dev/null +++ b/backend/routes/generations.py @@ -0,0 +1,275 @@ +"""TTS generation endpoints.""" + +import asyncio +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session + +from .. import history, models, profiles, tts +from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db +from ..services.generation import run_generation +from ..services.task_queue import enqueue_generation +from ..utils.tasks import get_task_manager + +router = APIRouter() + + +@router.post("/generate", response_model=models.GenerationResponse) +async def generate_speech( + data: models.GenerationRequest, + db: Session = Depends(get_db), +): + """Generate speech from text using a voice profile.""" + task_manager = get_task_manager() + generation_id = str(uuid.uuid4()) + + profile = await profiles.get_profile(data.profile_id, db) + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + + from ..backends import engine_has_model_sizes + + engine = data.engine or "qwen" + model_size = data.model_size or "1.7B" + + generation = await history.create_generation( + profile_id=data.profile_id, + text=data.text, + language=data.language, + audio_path="", + duration=0, + seed=data.seed, + db=db, + instruct=data.instruct, + generation_id=generation_id, + status="generating", + engine=engine, + model_size=model_size if engine_has_model_sizes(engine) else None, + ) + + task_manager.start_generation( + task_id=generation_id, + profile_id=data.profile_id, + text=data.text, + ) + + effects_chain_config = None + if data.effects_chain is not None: + effects_chain_config = [e.model_dump() for e in data.effects_chain] + else: + import json as _json + + profile_obj = db.query(DBVoiceProfile).filter_by(id=data.profile_id).first() + if profile_obj and profile_obj.effects_chain: + try: + effects_chain_config = _json.loads(profile_obj.effects_chain) + except Exception: + pass + + enqueue_generation( + run_generation( + generation_id=generation_id, + profile_id=data.profile_id, + text=data.text, + language=data.language, + engine=engine, + model_size=model_size, + seed=data.seed, + normalize=data.normalize, + effects_chain=effects_chain_config, + instruct=data.instruct, + mode="generate", + max_chunk_chars=data.max_chunk_chars, + crossfade_ms=data.crossfade_ms, + ) + ) + + return generation + + +@router.post("/generate/{generation_id}/retry", response_model=models.GenerationResponse) +async def retry_generation(generation_id: str, db: Session = Depends(get_db)): + """Retry a failed generation using the same parameters.""" + gen = db.query(DBGeneration).filter_by(id=generation_id).first() + if not gen: + raise HTTPException(status_code=404, detail="Generation not found") + + if (gen.status or "completed") != "failed": + raise HTTPException(status_code=400, detail="Only failed generations can be retried") + + gen.status = "generating" + gen.error = None + gen.audio_path = "" + gen.duration = 0 + db.commit() + db.refresh(gen) + + task_manager = get_task_manager() + task_manager.start_generation( + task_id=generation_id, + profile_id=gen.profile_id, + text=gen.text, + ) + + enqueue_generation( + run_generation( + generation_id=generation_id, + profile_id=gen.profile_id, + text=gen.text, + language=gen.language, + engine=gen.engine or "qwen", + model_size=gen.model_size or "1.7B", + seed=gen.seed, + instruct=gen.instruct, + mode="retry", + ) + ) + + return models.GenerationResponse.model_validate(gen) + + +@router.post( + "/generate/{generation_id}/regenerate", + response_model=models.GenerationResponse, +) +async def regenerate_generation(generation_id: str, db: Session = Depends(get_db)): + """Re-run TTS with the same parameters and save the result as a new version.""" + gen = db.query(DBGeneration).filter_by(id=generation_id).first() + if not gen: + raise HTTPException(status_code=404, detail="Generation not found") + if (gen.status or "completed") != "completed": + raise HTTPException(status_code=400, detail="Generation must be completed to regenerate") + + gen.status = "generating" + gen.error = None + db.commit() + db.refresh(gen) + + task_manager = get_task_manager() + task_manager.start_generation( + task_id=generation_id, + profile_id=gen.profile_id, + text=gen.text, + ) + + version_id = str(uuid.uuid4()) + + enqueue_generation( + run_generation( + generation_id=generation_id, + profile_id=gen.profile_id, + text=gen.text, + language=gen.language, + engine=gen.engine or "qwen", + model_size=gen.model_size or "1.7B", + seed=gen.seed, + instruct=gen.instruct, + mode="regenerate", + version_id=version_id, + ) + ) + + return models.GenerationResponse.model_validate(gen) + + +@router.get("/generate/{generation_id}/status") +async def get_generation_status(generation_id: str, db: Session = Depends(get_db)): + """SSE endpoint that streams generation status updates.""" + import json + + async def event_stream(): + while True: + db.expire_all() + gen = db.query(DBGeneration).filter_by(id=generation_id).first() + if not gen: + yield f"data: {json.dumps({'status': 'not_found', 'id': generation_id})}\n\n" + return + + payload = { + "id": gen.id, + "status": gen.status or "completed", + "duration": gen.duration, + "error": gen.error, + } + yield f"data: {json.dumps(payload)}\n\n" + + if (gen.status or "completed") in ("completed", "failed"): + return + + await asyncio.sleep(1) + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@router.post("/generate/stream") +async def stream_speech( + data: models.GenerationRequest, + db: Session = Depends(get_db), +): + """Generate speech and stream the WAV audio directly without saving to disk.""" + from ..backends import get_tts_backend_for_engine, ensure_model_cached_or_raise, load_engine_model, engine_needs_trim + + profile = await profiles.get_profile(data.profile_id, db) + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + + engine = data.engine or "qwen" + tts_model = get_tts_backend_for_engine(engine) + model_size = data.model_size or "1.7B" + + await ensure_model_cached_or_raise(engine, model_size) + await load_engine_model(engine, model_size) + + voice_prompt = await profiles.create_voice_prompt_for_profile( + data.profile_id, + db, + engine=engine, + ) + + from ..utils.chunked_tts import generate_chunked + + trim_fn = None + if engine_needs_trim(engine): + from ..utils.audio import trim_tts_output + + trim_fn = trim_tts_output + + audio, sample_rate = await generate_chunked( + tts_model, + data.text, + voice_prompt, + language=data.language, + seed=data.seed, + instruct=data.instruct, + max_chunk_chars=data.max_chunk_chars, + crossfade_ms=data.crossfade_ms, + trim_fn=trim_fn, + ) + + if data.normalize: + from ..utils.audio import normalize_audio + + audio = normalize_audio(audio) + + wav_bytes = tts.audio_to_wav_bytes(audio, sample_rate) + + async def _wav_stream(): + chunk_size = 64 * 1024 + for i in range(0, len(wav_bytes), chunk_size): + yield wav_bytes[i : i + chunk_size] + + return StreamingResponse( + _wav_stream(), + media_type="audio/wav", + headers={"Content-Disposition": 'attachment; filename="speech.wav"'}, + ) diff --git a/backend/routes/health.py b/backend/routes/health.py new file mode 100644 index 00000000..181aaebf --- /dev/null +++ b/backend/routes/health.py @@ -0,0 +1,224 @@ +"""Health and infrastructure endpoints.""" + +import asyncio +import os +import signal + +import torch +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from .. import config, models, tts +from ..database import get_db +from ..platform_detect import get_backend_type + +router = APIRouter() + + +@router.get("/") +async def root(): + """Root endpoint.""" + from .. import __version__ + + return {"message": "voicebox API", "version": __version__} + + +@router.post("/shutdown") +async def shutdown(): + """Gracefully shutdown the server.""" + + async def shutdown_async(): + await asyncio.sleep(0.1) + os.kill(os.getpid(), signal.SIGTERM) + + asyncio.create_task(shutdown_async()) + return {"message": "Shutting down..."} + + +@router.post("/watchdog/disable") +async def watchdog_disable(): + """Disable the parent process watchdog so the server keeps running.""" + from backend.server import disable_watchdog + + disable_watchdog() + return {"message": "Watchdog disabled"} + + +@router.get("/health", response_model=models.HealthResponse) +async def health(): + """Health check endpoint.""" + from huggingface_hub import constants as hf_constants + from pathlib import Path + + tts_model = tts.get_tts_model() + backend_type = get_backend_type() + + has_cuda = torch.cuda.is_available() + has_mps = hasattr(torch.backends, "mps") and torch.backends.mps.is_available() + + has_xpu = False + xpu_name = None + try: + import intel_extension_for_pytorch as ipex # noqa: F401 -- side-effect import enables XPU + + if hasattr(torch, "xpu") and torch.xpu.is_available(): + has_xpu = True + try: + xpu_name = torch.xpu.get_device_name(0) + except Exception: + xpu_name = "Intel GPU" + except ImportError: + pass + + has_directml = False + directml_name = None + try: + import torch_directml + + if torch_directml.device_count() > 0: + has_directml = True + try: + directml_name = torch_directml.device_name(0) + except Exception: + directml_name = "DirectML GPU" + except ImportError: + pass + + gpu_available = has_cuda or has_mps or has_xpu or has_directml or backend_type == "mlx" + + gpu_type = None + if has_cuda: + gpu_type = f"CUDA ({torch.cuda.get_device_name(0)})" + elif has_mps: + gpu_type = "MPS (Apple Silicon)" + elif backend_type == "mlx": + gpu_type = "Metal (Apple Silicon via MLX)" + elif has_xpu: + gpu_type = f"XPU ({xpu_name})" + elif has_directml: + gpu_type = f"DirectML ({directml_name})" + + vram_used = None + if has_cuda: + vram_used = torch.cuda.memory_allocated() / 1024 / 1024 + + model_loaded = False + model_size = None + try: + if tts_model.is_loaded(): + model_loaded = True + model_size = getattr(tts_model, "_current_model_size", None) + if not model_size: + model_size = getattr(tts_model, "model_size", None) + except Exception: + model_loaded = False + model_size = None + + model_downloaded = None + try: + from ..backends import get_model_config + + default_config = get_model_config("qwen-tts-1.7B") + default_model_id = default_config.hf_repo_id if default_config else "Qwen/Qwen3-TTS-12Hz-1.7B-Base" + + try: + from huggingface_hub import scan_cache_dir + + cache_info = scan_cache_dir() + for repo in cache_info.repos: + if repo.repo_id == default_model_id: + model_downloaded = True + break + except (ImportError, Exception): + cache_dir = hf_constants.HF_HUB_CACHE + repo_cache = Path(cache_dir) / ("models--" + default_model_id.replace("/", "--")) + if repo_cache.exists(): + has_model_files = ( + any(repo_cache.rglob("*.bin")) + or any(repo_cache.rglob("*.safetensors")) + or any(repo_cache.rglob("*.pt")) + or any(repo_cache.rglob("*.pth")) + or any(repo_cache.rglob("*.npz")) + ) + model_downloaded = has_model_files + except Exception: + pass + + return models.HealthResponse( + status="healthy", + model_loaded=model_loaded, + model_downloaded=model_downloaded, + model_size=model_size, + gpu_available=gpu_available, + gpu_type=gpu_type, + vram_used_mb=vram_used, + backend_type=backend_type, + backend_variant=os.environ.get("VOICEBOX_BACKEND_VARIANT", "cuda" if torch.cuda.is_available() else "cpu"), + ) + + +@router.get("/health/filesystem", response_model=models.FilesystemHealthResponse) +async def filesystem_health(): + """Check filesystem health: directory existence, write permissions, and disk space.""" + import shutil + + dirs_to_check = { + "generations": config.get_generations_dir(), + "profiles": config.get_profiles_dir(), + "data": config.get_data_dir(), + } + + checks: list[models.DirectoryCheck] = [] + all_ok = True + + for _label, dir_path in dirs_to_check.items(): + exists = dir_path.exists() + writable = False + error = None + if exists: + probe = dir_path / ".voicebox_probe" + try: + probe.write_text("ok") + probe.unlink() + writable = True + except PermissionError: + error = "Permission denied" + except OSError as e: + error = str(e) + finally: + try: + probe.unlink(missing_ok=True) + except Exception: + pass + else: + error = "Directory does not exist" + + if not exists or not writable: + all_ok = False + + checks.append( + models.DirectoryCheck( + path=str(dir_path), + exists=exists, + writable=writable, + error=error, + ) + ) + + disk_free_mb = None + disk_total_mb = None + try: + usage = shutil.disk_usage(str(config.get_data_dir())) + disk_free_mb = round(usage.free / (1024 * 1024), 1) + disk_total_mb = round(usage.total / (1024 * 1024), 1) + if disk_free_mb < 500: + all_ok = False + except OSError: + all_ok = False + + return models.FilesystemHealthResponse( + healthy=all_ok, + disk_free_mb=disk_free_mb, + disk_total_mb=disk_total_mb, + directories=checks, + ) diff --git a/backend/routes/history.py b/backend/routes/history.py new file mode 100644 index 00000000..e4c2d47c --- /dev/null +++ b/backend/routes/history.py @@ -0,0 +1,174 @@ +"""Generation history endpoints.""" + +import io +from pathlib import Path + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi.responses import FileResponse, StreamingResponse +from sqlalchemy.orm import Session + +from .. import export_import, history, models +from ..app import safe_content_disposition +from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db + +router = APIRouter() + + +@router.get("/history", response_model=models.HistoryListResponse) +async def list_history( + profile_id: str | None = None, + search: str | None = None, + limit: int = 50, + offset: int = 0, + db: Session = Depends(get_db), +): + """List generation history with optional filters.""" + query = models.HistoryQuery( + profile_id=profile_id, + search=search, + limit=limit, + offset=offset, + ) + return await history.list_generations(query, db) + + +@router.get("/history/stats") +async def get_stats(db: Session = Depends(get_db)): + """Get generation statistics.""" + return await history.get_generation_stats(db) + + +@router.post("/history/import") +async def import_generation( + file: UploadFile = File(...), + db: Session = Depends(get_db), +): + """Import a generation from a ZIP archive.""" + MAX_FILE_SIZE = 50 * 1024 * 1024 + + content = await file.read() + + if len(content) > MAX_FILE_SIZE: + raise HTTPException( + status_code=400, detail=f"File too large. Maximum size is {MAX_FILE_SIZE / (1024 * 1024)}MB" + ) + + try: + result = await export_import.import_generation_from_zip(content, db) + return result + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/history/{generation_id}", response_model=models.HistoryResponse) +async def get_generation( + generation_id: str, + db: Session = Depends(get_db), +): + """Get a generation by ID.""" + result = ( + db.query(DBGeneration, DBVoiceProfile.name.label("profile_name")) + .join(DBVoiceProfile, DBGeneration.profile_id == DBVoiceProfile.id) + .filter(DBGeneration.id == generation_id) + .first() + ) + + if not result: + raise HTTPException(status_code=404, detail="Generation not found") + + gen, profile_name = result + return models.HistoryResponse( + id=gen.id, + profile_id=gen.profile_id, + profile_name=profile_name, + text=gen.text, + language=gen.language, + audio_path=gen.audio_path, + duration=gen.duration, + seed=gen.seed, + instruct=gen.instruct, + created_at=gen.created_at, + ) + + +@router.post("/history/{generation_id}/favorite") +async def toggle_favorite( + generation_id: str, + db: Session = Depends(get_db), +): + """Toggle the favorite status of a generation.""" + gen = db.query(DBGeneration).filter_by(id=generation_id).first() + if not gen: + raise HTTPException(status_code=404, detail="Generation not found") + gen.is_favorited = not gen.is_favorited + db.commit() + return {"is_favorited": gen.is_favorited} + + +@router.delete("/history/{generation_id}") +async def delete_generation( + generation_id: str, + db: Session = Depends(get_db), +): + """Delete a generation.""" + success = await history.delete_generation(generation_id, db) + if not success: + raise HTTPException(status_code=404, detail="Generation not found") + return {"message": "Generation deleted successfully"} + + +@router.get("/history/{generation_id}/export") +async def export_generation( + generation_id: str, + db: Session = Depends(get_db), +): + """Export a generation as a ZIP archive.""" + try: + generation = db.query(DBGeneration).filter_by(id=generation_id).first() + if not generation: + raise HTTPException(status_code=404, detail="Generation not found") + + zip_bytes = export_import.export_generation_to_zip(generation_id, db) + + safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (" ", "-", "_")).strip() + if not safe_text: + safe_text = "generation" + filename = f"generation-{safe_text}.voicebox.zip" + + return StreamingResponse( + io.BytesIO(zip_bytes), + media_type="application/zip", + headers={"Content-Disposition": safe_content_disposition("attachment", filename)}, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/history/{generation_id}/export-audio") +async def export_generation_audio( + generation_id: str, + db: Session = Depends(get_db), +): + """Export only the audio file from a generation.""" + generation = db.query(DBGeneration).filter_by(id=generation_id).first() + if not generation: + raise HTTPException(status_code=404, detail="Generation not found") + + audio_path = Path(generation.audio_path) + if not audio_path.exists(): + raise HTTPException(status_code=404, detail="Audio file not found") + + safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (" ", "-", "_")).strip() + if not safe_text: + safe_text = "generation" + filename = f"{safe_text}.wav" + + return FileResponse( + audio_path, + media_type="audio/wav", + headers={"Content-Disposition": safe_content_disposition("attachment", filename)}, + ) diff --git a/backend/routes/models.py b/backend/routes/models.py new file mode 100644 index 00000000..c17d0f15 --- /dev/null +++ b/backend/routes/models.py @@ -0,0 +1,468 @@ +"""Model management endpoints.""" + +import asyncio +import shutil +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session + +from .. import models +from ..platform_detect import get_backend_type +from ..services.task_queue import create_background_task +from ..utils.progress import get_progress_manager +from ..utils.tasks import get_task_manager + +router = APIRouter() + + +def _get_dir_size(path: Path) -> int: + """Get total size of a directory in bytes.""" + total = 0 + for f in path.rglob("*"): + if f.is_file(): + total += f.stat().st_size + return total + + +def _copy_with_progress(src: Path, dst: Path, progress_manager, copied_so_far: int, total_bytes: int) -> int: + """Copy a directory tree with byte-level progress tracking.""" + dst.mkdir(parents=True, exist_ok=True) + for item in src.iterdir(): + dest_item = dst / item.name + if item.is_dir(): + copied_so_far = _copy_with_progress(item, dest_item, progress_manager, copied_so_far, total_bytes) + else: + size = item.stat().st_size + shutil.copy2(str(item), str(dest_item)) + copied_so_far += size + progress_manager.update_progress( + "migration", + copied_so_far, + total_bytes, + filename=item.name, + status="downloading", + ) + return copied_so_far + + +@router.post("/models/load") +async def load_model(model_size: str = "1.7B"): + """Manually load TTS model.""" + from .. import tts + + try: + tts_model = tts.get_tts_model() + await tts_model.load_model_async(model_size) + return {"message": f"Model {model_size} loaded successfully"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/models/unload") +async def unload_model(): + """Unload the default Qwen TTS model to free memory.""" + from .. import tts + + try: + tts.unload_tts_model() + return {"message": "Model unloaded successfully"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/models/{model_name}/unload") +async def unload_model_by_name(model_name: str): + """Unload a specific model from memory without deleting it from disk.""" + from ..backends import get_model_config, unload_model_by_config + + config = get_model_config(model_name) + if not config: + raise HTTPException(status_code=400, detail=f"Unknown model: {model_name}") + + try: + was_loaded = unload_model_by_config(config) + if not was_loaded: + return {"message": f"Model {model_name} is not loaded"} + return {"message": f"Model {model_name} unloaded successfully"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/models/progress/{model_name}") +async def get_model_progress(model_name: str): + """Get model download progress via Server-Sent Events.""" + progress_manager = get_progress_manager() + + async def event_generator(): + async for event in progress_manager.subscribe(model_name): + yield event + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@router.get("/models/cache-dir") +async def get_models_cache_dir(): + """Get the path to the HuggingFace model cache directory.""" + from huggingface_hub import constants as hf_constants + + return {"path": str(Path(hf_constants.HF_HUB_CACHE))} + + +@router.post("/models/migrate") +async def migrate_models(request: models.ModelMigrateRequest): + """Move all downloaded models to a new directory with byte-level progress via SSE.""" + from huggingface_hub import constants as hf_constants + + source = Path(hf_constants.HF_HUB_CACHE) + destination = Path(request.destination) + + if not source.exists(): + raise HTTPException(status_code=404, detail="Current model cache directory not found") + + model_dirs = [d for d in source.iterdir() if d.name.startswith("models--") and d.is_dir()] + if not model_dirs: + return {"moved": 0, "errors": [], "source": str(source), "destination": str(destination)} + + destination.mkdir(parents=True, exist_ok=True) + + progress_manager = get_progress_manager() + + same_fs = False + try: + same_fs = source.stat().st_dev == destination.stat().st_dev + except OSError: + pass + + async def migrate_background(): + moved = 0 + errors = [] + try: + if same_fs: + total = len(model_dirs) + for i, item in enumerate(model_dirs): + dest_item = destination / item.name + try: + if dest_item.exists(): + shutil.rmtree(dest_item) + shutil.move(str(item), str(dest_item)) + moved += 1 + progress_manager.update_progress( + "migration", + i + 1, + total, + filename=item.name, + status="downloading", + ) + except Exception as e: + errors.append(f"{item.name}: {str(e)}") + else: + total_bytes = sum(_get_dir_size(d) for d in model_dirs) + progress_manager.update_progress( + "migration", 0, total_bytes, filename="Calculating...", status="downloading" + ) + + copied = 0 + for item in model_dirs: + dest_item = destination / item.name + try: + if dest_item.exists(): + shutil.rmtree(dest_item) + copied = await asyncio.to_thread( + _copy_with_progress, item, dest_item, progress_manager, copied, total_bytes + ) + await asyncio.to_thread(shutil.rmtree, str(item)) + moved += 1 + except Exception as e: + errors.append(f"{item.name}: {str(e)}") + + progress_manager.update_progress("migration", 1, 1, status="complete") + progress_manager.mark_complete("migration") + except Exception as e: + progress_manager.update_progress("migration", 0, 0, status="error") + progress_manager.mark_error("migration", str(e)) + + create_background_task(migrate_background()) + + return {"source": str(source), "destination": str(destination)} + + +@router.get("/models/migrate/progress") +async def get_migration_progress(): + """Get model migration progress via Server-Sent Events.""" + progress_manager = get_progress_manager() + + async def event_generator(): + async for event in progress_manager.subscribe("migration"): + yield event + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@router.get("/models/status", response_model=models.ModelStatusListResponse) +async def get_model_status(): + """Get status of all available models.""" + from huggingface_hub import constants as hf_constants + + backend_type = get_backend_type() + task_manager = get_task_manager() + + active_download_names = {task.model_name for task in task_manager.get_active_downloads()} + + try: + from huggingface_hub import scan_cache_dir + + use_scan_cache = True + except ImportError: + use_scan_cache = False + + from ..backends import get_all_model_configs, check_model_loaded + + registry_configs = get_all_model_configs() + model_configs = [ + { + "model_name": cfg.model_name, + "display_name": cfg.display_name, + "hf_repo_id": cfg.hf_repo_id, + "model_size": cfg.model_size, + "check_loaded": lambda c=cfg: check_model_loaded(c), + } + for cfg in registry_configs + ] + + model_to_repo = {cfg["model_name"]: cfg["hf_repo_id"] for cfg in model_configs} + active_download_repos = {model_to_repo.get(name) for name in active_download_names if name in model_to_repo} + + cache_info = None + if use_scan_cache: + try: + cache_info = scan_cache_dir() + except Exception: + pass + + statuses = [] + + for config in model_configs: + try: + downloaded = False + size_mb = None + loaded = False + + if cache_info: + repo_id = config["hf_repo_id"] + for repo in cache_info.repos: + if repo.repo_id == repo_id: + has_model_weights = False + for rev in repo.revisions: + for f in rev.files: + fname = f.file_name.lower() + if fname.endswith((".safetensors", ".bin", ".pt", ".pth", ".npz")): + has_model_weights = True + break + if has_model_weights: + break + + has_incomplete = False + try: + cache_dir = hf_constants.HF_HUB_CACHE + blobs_dir = Path(cache_dir) / ("models--" + repo_id.replace("/", "--")) / "blobs" + if blobs_dir.exists(): + has_incomplete = any(blobs_dir.glob("*.incomplete")) + except Exception: + pass + + if has_model_weights and not has_incomplete: + downloaded = True + try: + total_size = sum(revision.size_on_disk for revision in repo.revisions) + size_mb = total_size / (1024 * 1024) + except Exception: + pass + break + + if not downloaded: + try: + cache_dir = hf_constants.HF_HUB_CACHE + repo_cache = Path(cache_dir) / ("models--" + config["hf_repo_id"].replace("/", "--")) + + if repo_cache.exists(): + blobs_dir = repo_cache / "blobs" + has_incomplete = blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")) + + if not has_incomplete: + snapshots_dir = repo_cache / "snapshots" + has_model_files = False + if snapshots_dir.exists(): + has_model_files = ( + any(snapshots_dir.rglob("*.bin")) + or any(snapshots_dir.rglob("*.safetensors")) + or any(snapshots_dir.rglob("*.pt")) + or any(snapshots_dir.rglob("*.pth")) + or any(snapshots_dir.rglob("*.npz")) + ) + + if has_model_files: + downloaded = True + try: + total_size = sum( + f.stat().st_size + for f in repo_cache.rglob("*") + if f.is_file() and not f.name.endswith(".incomplete") + ) + size_mb = total_size / (1024 * 1024) + except Exception: + pass + except Exception: + pass + + try: + loaded = config["check_loaded"]() + except Exception: + loaded = False + + is_downloading = config["hf_repo_id"] in active_download_repos + + if is_downloading: + downloaded = False + size_mb = None + + statuses.append( + models.ModelStatus( + model_name=config["model_name"], + display_name=config["display_name"], + hf_repo_id=config["hf_repo_id"], + downloaded=downloaded, + downloading=is_downloading, + size_mb=size_mb, + loaded=loaded, + ) + ) + except Exception: + try: + loaded = config["check_loaded"]() + except Exception: + loaded = False + + is_downloading = config["hf_repo_id"] in active_download_repos + + statuses.append( + models.ModelStatus( + model_name=config["model_name"], + display_name=config["display_name"], + hf_repo_id=config["hf_repo_id"], + downloaded=False, + downloading=is_downloading, + size_mb=None, + loaded=loaded, + ) + ) + + return models.ModelStatusListResponse(models=statuses) + + +@router.post("/models/download") +async def trigger_model_download(request: models.ModelDownloadRequest): + """Trigger download of a specific model.""" + from ..backends import get_model_config, get_model_load_func + + task_manager = get_task_manager() + progress_manager = get_progress_manager() + + config = get_model_config(request.model_name) + if not config: + raise HTTPException(status_code=400, detail=f"Unknown model: {request.model_name}") + + load_func = get_model_load_func(config) + + async def download_in_background(): + try: + result = load_func() + if asyncio.iscoroutine(result): + await result + task_manager.complete_download(request.model_name) + except Exception as e: + task_manager.error_download(request.model_name, str(e)) + + task_manager.start_download(request.model_name) + + progress_manager.update_progress( + model_name=request.model_name, + current=0, + total=0, + filename="Connecting to HuggingFace...", + status="downloading", + ) + + create_background_task(download_in_background()) + + return {"message": f"Model {request.model_name} download started"} + + +@router.post("/models/download/cancel") +async def cancel_model_download(request: models.ModelDownloadRequest): + """Cancel or dismiss an errored/stale download task.""" + task_manager = get_task_manager() + progress_manager = get_progress_manager() + + removed = task_manager.cancel_download(request.model_name) + + progress_removed = False + with progress_manager._lock: + if request.model_name in progress_manager._progress: + del progress_manager._progress[request.model_name] + progress_removed = True + + if removed or progress_removed: + return {"message": f"Download task for {request.model_name} cancelled"} + return {"message": f"No active task found for {request.model_name}"} + + +@router.delete("/models/{model_name}") +async def delete_model(model_name: str): + """Delete a downloaded model from the HuggingFace cache.""" + from huggingface_hub import constants as hf_constants + from ..backends import get_model_config, unload_model_by_config + + config = get_model_config(model_name) + if not config: + raise HTTPException(status_code=400, detail=f"Unknown model: {model_name}") + + hf_repo_id = config.hf_repo_id + + try: + unload_model_by_config(config) + + cache_dir = hf_constants.HF_HUB_CACHE + repo_cache_dir = Path(cache_dir) / ("models--" + hf_repo_id.replace("/", "--")) + + if not repo_cache_dir.exists(): + raise HTTPException(status_code=404, detail=f"Model {model_name} not found in cache") + + try: + shutil.rmtree(repo_cache_dir) + except OSError as e: + raise HTTPException(status_code=500, detail=f"Failed to delete model cache directory: {str(e)}") + + return {"message": f"Model {model_name} deleted successfully"} + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to delete model: {str(e)}") diff --git a/backend/routes/profiles.py b/backend/routes/profiles.py new file mode 100644 index 00000000..320dfc50 --- /dev/null +++ b/backend/routes/profiles.py @@ -0,0 +1,308 @@ +"""Voice profile endpoints.""" + +import io +import tempfile +from datetime import datetime +from pathlib import Path + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from fastapi.responses import FileResponse, StreamingResponse +from sqlalchemy.orm import Session + +from .. import channels, config, export_import, models, profiles +from ..app import safe_content_disposition +from ..database import VoiceProfile as DBVoiceProfile, get_db +from ..profiles import _profile_to_response + +router = APIRouter() + + +@router.post("/profiles", response_model=models.VoiceProfileResponse) +async def create_profile( + data: models.VoiceProfileCreate, + db: Session = Depends(get_db), +): + """Create a new voice profile.""" + try: + return await profiles.create_profile(data, db) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/profiles", response_model=list[models.VoiceProfileResponse]) +async def list_profiles(db: Session = Depends(get_db)): + """List all voice profiles.""" + return await profiles.list_profiles(db) + + +@router.post("/profiles/import", response_model=models.VoiceProfileResponse) +async def import_profile( + file: UploadFile = File(...), + db: Session = Depends(get_db), +): + """Import a voice profile from a ZIP archive.""" + MAX_FILE_SIZE = 100 * 1024 * 1024 + + content = await file.read() + + if len(content) > MAX_FILE_SIZE: + raise HTTPException( + status_code=400, detail=f"File too large. Maximum size is {MAX_FILE_SIZE / (1024 * 1024)}MB" + ) + + try: + profile = await export_import.import_profile_from_zip(content, db) + return profile + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/profiles/{profile_id}", response_model=models.VoiceProfileResponse) +async def get_profile( + profile_id: str, + db: Session = Depends(get_db), +): + """Get a voice profile by ID.""" + profile = await profiles.get_profile(profile_id, db) + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + return profile + + +@router.put("/profiles/{profile_id}", response_model=models.VoiceProfileResponse) +async def update_profile( + profile_id: str, + data: models.VoiceProfileCreate, + db: Session = Depends(get_db), +): + """Update a voice profile.""" + try: + profile = await profiles.update_profile(profile_id, data, db) + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + return profile + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.delete("/profiles/{profile_id}") +async def delete_profile( + profile_id: str, + db: Session = Depends(get_db), +): + """Delete a voice profile.""" + success = await profiles.delete_profile(profile_id, db) + if not success: + raise HTTPException(status_code=404, detail="Profile not found") + return {"message": "Profile deleted successfully"} + + +@router.post("/profiles/{profile_id}/samples", response_model=models.ProfileSampleResponse) +async def add_profile_sample( + profile_id: str, + file: UploadFile = File(...), + reference_text: str = Form(...), + db: Session = Depends(get_db), +): + """Add a sample to a voice profile.""" + _allowed_audio_exts = {".wav", ".mp3", ".m4a", ".ogg", ".flac", ".aac", ".webm", ".opus"} + _uploaded_ext = Path(file.filename or "").suffix.lower() + file_suffix = _uploaded_ext if _uploaded_ext in _allowed_audio_exts else ".wav" + + with tempfile.NamedTemporaryFile(suffix=file_suffix, delete=False) as tmp: + content = await file.read() + tmp.write(content) + tmp_path = tmp.name + + try: + sample = await profiles.add_profile_sample( + profile_id, + tmp_path, + reference_text, + db, + ) + return sample + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to process audio file: {str(e)}") + finally: + Path(tmp_path).unlink(missing_ok=True) + + +@router.get("/profiles/{profile_id}/samples", response_model=list[models.ProfileSampleResponse]) +async def get_profile_samples( + profile_id: str, + db: Session = Depends(get_db), +): + """Get all samples for a profile.""" + return await profiles.get_profile_samples(profile_id, db) + + +@router.delete("/profiles/samples/{sample_id}") +async def delete_profile_sample( + sample_id: str, + db: Session = Depends(get_db), +): + """Delete a profile sample.""" + success = await profiles.delete_profile_sample(sample_id, db) + if not success: + raise HTTPException(status_code=404, detail="Sample not found") + return {"message": "Sample deleted successfully"} + + +@router.put("/profiles/samples/{sample_id}", response_model=models.ProfileSampleResponse) +async def update_profile_sample( + sample_id: str, + data: models.ProfileSampleUpdate, + db: Session = Depends(get_db), +): + """Update a profile sample's reference text.""" + sample = await profiles.update_profile_sample(sample_id, data.reference_text, db) + if not sample: + raise HTTPException(status_code=404, detail="Sample not found") + return sample + + +@router.post("/profiles/{profile_id}/avatar", response_model=models.VoiceProfileResponse) +async def upload_profile_avatar( + profile_id: str, + file: UploadFile = File(...), + db: Session = Depends(get_db), +): + """Upload or update avatar image for a profile.""" + with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file.filename).suffix) as tmp: + content = await file.read() + tmp.write(content) + tmp_path = tmp.name + + try: + profile = await profiles.upload_avatar(profile_id, tmp_path, db) + return profile + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + finally: + Path(tmp_path).unlink(missing_ok=True) + + +@router.get("/profiles/{profile_id}/avatar") +async def get_profile_avatar( + profile_id: str, + db: Session = Depends(get_db), +): + """Get avatar image for a profile.""" + profile = await profiles.get_profile(profile_id, db) + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + + if not profile.avatar_path: + raise HTTPException(status_code=404, detail="No avatar found for this profile") + + avatar_path = Path(profile.avatar_path) + if not avatar_path.exists(): + raise HTTPException(status_code=404, detail="Avatar file not found") + + return FileResponse(avatar_path) + + +@router.delete("/profiles/{profile_id}/avatar") +async def delete_profile_avatar( + profile_id: str, + db: Session = Depends(get_db), +): + """Delete avatar image for a profile.""" + success = await profiles.delete_avatar(profile_id, db) + if not success: + raise HTTPException(status_code=404, detail="Profile not found or no avatar to delete") + return {"message": "Avatar deleted successfully"} + + +@router.get("/profiles/{profile_id}/export") +async def export_profile( + profile_id: str, + db: Session = Depends(get_db), +): + """Export a voice profile as a ZIP archive.""" + try: + profile = await profiles.get_profile(profile_id, db) + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + + zip_bytes = export_import.export_profile_to_zip(profile_id, db) + + safe_name = "".join(c for c in profile.name if c.isalnum() or c in (" ", "-", "_")).strip() + if not safe_name: + safe_name = "profile" + filename = f"profile-{safe_name}.voicebox.zip" + + return StreamingResponse( + io.BytesIO(zip_bytes), + media_type="application/zip", + headers={"Content-Disposition": safe_content_disposition("attachment", filename)}, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/profiles/{profile_id}/channels") +async def get_profile_channels( + profile_id: str, + db: Session = Depends(get_db), +): + """Get list of channel IDs assigned to a profile.""" + try: + channel_ids = await channels.get_profile_channels(profile_id, db) + return {"channel_ids": channel_ids} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.put("/profiles/{profile_id}/channels") +async def set_profile_channels( + profile_id: str, + data: models.ProfileChannelAssignment, + db: Session = Depends(get_db), +): + """Set which channels a profile is assigned to.""" + try: + await channels.set_profile_channels(profile_id, data, db) + return {"message": "Profile channels updated successfully"} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.put("/profiles/{profile_id}/effects", response_model=models.VoiceProfileResponse) +async def update_profile_effects( + profile_id: str, + data: models.ProfileEffectsUpdate, + db: Session = Depends(get_db), +): + """Set or clear the default effects chain for a voice profile.""" + import json as _json + + profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first() + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + + if data.effects_chain is not None: + from ..utils.effects import validate_effects_chain + + chain_dicts = [e.model_dump() for e in data.effects_chain] + error = validate_effects_chain(chain_dicts) + if error: + raise HTTPException(status_code=400, detail=error) + profile.effects_chain = _json.dumps(chain_dicts) + else: + profile.effects_chain = None + + profile.updated_at = datetime.utcnow() + db.commit() + db.refresh(profile) + + return _profile_to_response(profile) diff --git a/backend/routes/stories.py b/backend/routes/stories.py new file mode 100644 index 00000000..0c5296ba --- /dev/null +++ b/backend/routes/stories.py @@ -0,0 +1,222 @@ +"""Story endpoints.""" + +import io + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session + +from .. import database, models, stories +from ..app import safe_content_disposition +from ..database import get_db + +router = APIRouter() + + +@router.get("/stories", response_model=list[models.StoryResponse]) +async def list_stories(db: Session = Depends(get_db)): + """List all stories.""" + return await stories.list_stories(db) + + +@router.post("/stories", response_model=models.StoryResponse) +async def create_story( + data: models.StoryCreate, + db: Session = Depends(get_db), +): + """Create a new story.""" + try: + return await stories.create_story(data, db) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/stories/{story_id}", response_model=models.StoryDetailResponse) +async def get_story( + story_id: str, + db: Session = Depends(get_db), +): + """Get a story with all its items.""" + story = await stories.get_story(story_id, db) + if not story: + raise HTTPException(status_code=404, detail="Story not found") + return story + + +@router.put("/stories/{story_id}", response_model=models.StoryResponse) +async def update_story( + story_id: str, + data: models.StoryCreate, + db: Session = Depends(get_db), +): + """Update a story.""" + story = await stories.update_story(story_id, data, db) + if not story: + raise HTTPException(status_code=404, detail="Story not found") + return story + + +@router.delete("/stories/{story_id}") +async def delete_story( + story_id: str, + db: Session = Depends(get_db), +): + """Delete a story.""" + success = await stories.delete_story(story_id, db) + if not success: + raise HTTPException(status_code=404, detail="Story not found") + return {"message": "Story deleted successfully"} + + +@router.post("/stories/{story_id}/items", response_model=models.StoryItemDetail) +async def add_story_item( + story_id: str, + data: models.StoryItemCreate, + db: Session = Depends(get_db), +): + """Add a generation to a story.""" + item = await stories.add_item_to_story(story_id, data, db) + if not item: + raise HTTPException(status_code=404, detail="Story or generation not found") + return item + + +@router.delete("/stories/{story_id}/items/{item_id}") +async def remove_story_item( + story_id: str, + item_id: str, + db: Session = Depends(get_db), +): + """Remove a story item from a story.""" + success = await stories.remove_item_from_story(story_id, item_id, db) + if not success: + raise HTTPException(status_code=404, detail="Story item not found") + return {"message": "Item removed successfully"} + + +@router.put("/stories/{story_id}/items/times") +async def update_story_item_times( + story_id: str, + data: models.StoryItemBatchUpdate, + db: Session = Depends(get_db), +): + """Update story item timecodes.""" + success = await stories.update_story_item_times(story_id, data, db) + if not success: + raise HTTPException(status_code=400, detail="Invalid timecode update request") + return {"message": "Item timecodes updated successfully"} + + +@router.put("/stories/{story_id}/items/reorder", response_model=list[models.StoryItemDetail]) +async def reorder_story_items( + story_id: str, + data: models.StoryItemReorder, + db: Session = Depends(get_db), +): + """Reorder story items and recalculate timecodes.""" + items = await stories.reorder_story_items(story_id, data.generation_ids, db) + if items is None: + raise HTTPException( + status_code=400, detail="Invalid reorder request - ensure all generation IDs belong to this story" + ) + return items + + +@router.put("/stories/{story_id}/items/{item_id}/move", response_model=models.StoryItemDetail) +async def move_story_item( + story_id: str, + item_id: str, + data: models.StoryItemMove, + db: Session = Depends(get_db), +): + """Move a story item (update position and/or track).""" + item = await stories.move_story_item(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}/trim", response_model=models.StoryItemDetail) +async def trim_story_item( + story_id: str, + item_id: str, + data: models.StoryItemTrim, + db: Session = Depends(get_db), +): + """Trim a story item.""" + item = await stories.trim_story_item(story_id, item_id, data, db) + if item is None: + raise HTTPException(status_code=404, detail="Story item not found or invalid trim values") + return item + + +@router.post("/stories/{story_id}/items/{item_id}/split", response_model=list[models.StoryItemDetail]) +async def split_story_item( + story_id: str, + item_id: str, + data: models.StoryItemSplit, + db: Session = Depends(get_db), +): + """Split a story item at a given time, creating two clips.""" + items = await stories.split_story_item(story_id, item_id, data, db) + if items is None: + raise HTTPException(status_code=404, detail="Story item not found or invalid split point") + return items + + +@router.post("/stories/{story_id}/items/{item_id}/duplicate", response_model=models.StoryItemDetail) +async def duplicate_story_item( + story_id: str, + item_id: str, + db: Session = Depends(get_db), +): + """Duplicate a story item.""" + item = await stories.duplicate_story_item(story_id, item_id, 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}/version", response_model=models.StoryItemDetail) +async def set_story_item_version( + story_id: str, + item_id: str, + data: models.StoryItemVersionUpdate, + db: Session = Depends(get_db), +): + """Pin a story item to a specific generation version.""" + item = await stories.set_story_item_version(story_id, item_id, data, db) + if item is None: + raise HTTPException(status_code=404, detail="Story item or version not found") + return item + + +@router.get("/stories/{story_id}/export-audio") +async def export_story_audio( + story_id: str, + db: Session = Depends(get_db), +): + """Export story as single mixed audio file.""" + 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) + if not audio_bytes: + raise HTTPException(status_code=400, detail="Story has no audio items") + + 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" + + return StreamingResponse( + io.BytesIO(audio_bytes), + media_type="audio/wav", + headers={"Content-Disposition": safe_content_disposition("attachment", filename)}, + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/routes/tasks.py b/backend/routes/tasks.py new file mode 100644 index 00000000..c3fc5a91 --- /dev/null +++ b/backend/routes/tasks.py @@ -0,0 +1,125 @@ +"""Task and cache management endpoints.""" + +from datetime import datetime + +from fastapi import APIRouter + +from .. import models +from ..utils.cache import clear_voice_prompt_cache +from ..utils.progress import get_progress_manager +from ..utils.tasks import get_task_manager +from fastapi import HTTPException + +router = APIRouter() + + +@router.post("/tasks/clear") +async def clear_all_tasks(): + """Clear all download tasks and progress state.""" + task_manager = get_task_manager() + progress_manager = get_progress_manager() + + task_manager.clear_all() + + with progress_manager._lock: + progress_manager._progress.clear() + progress_manager._last_notify_time.clear() + progress_manager._last_notify_progress.clear() + + return {"message": "All task state cleared"} + + +@router.post("/cache/clear") +async def clear_cache(): + """Clear all voice prompt caches (memory and disk).""" + try: + deleted_count = clear_voice_prompt_cache() + return { + "message": "Voice prompt cache cleared successfully", + "files_deleted": deleted_count, + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to clear cache: {str(e)}") + + +@router.get("/tasks/active", response_model=models.ActiveTasksResponse) +async def get_active_tasks(): + """Return all currently active downloads and generations.""" + task_manager = get_task_manager() + progress_manager = get_progress_manager() + + active_downloads = [] + task_manager_downloads = task_manager.get_active_downloads() + progress_active = progress_manager.get_all_active() + + download_map = {task.model_name: task for task in task_manager_downloads} + progress_map = {p["model_name"]: p for p in progress_active} + + all_model_names = set(download_map.keys()) | set(progress_map.keys()) + for model_name in all_model_names: + task = download_map.get(model_name) + progress = progress_map.get(model_name) + + if task: + error = task.error + if not error: + with progress_manager._lock: + pm_data = progress_manager._progress.get(model_name) + if pm_data: + error = pm_data.get("error") + prog = progress or {} + if not prog: + with progress_manager._lock: + pm_data = progress_manager._progress.get(model_name) + if pm_data: + prog = pm_data + active_downloads.append( + models.ActiveDownloadTask( + model_name=model_name, + status=task.status, + started_at=task.started_at, + error=error, + progress=prog.get("progress"), + current=prog.get("current"), + total=prog.get("total"), + filename=prog.get("filename"), + ) + ) + elif progress: + timestamp_str = progress.get("timestamp") + if timestamp_str: + try: + started_at = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00")) + except (ValueError, AttributeError): + started_at = datetime.utcnow() + else: + started_at = datetime.utcnow() + + active_downloads.append( + models.ActiveDownloadTask( + model_name=model_name, + status=progress.get("status", "downloading"), + started_at=started_at, + error=progress.get("error"), + progress=progress.get("progress"), + current=progress.get("current"), + total=progress.get("total"), + filename=progress.get("filename"), + ) + ) + + active_generations = [] + for gen_task in task_manager.get_active_generations(): + active_generations.append( + models.ActiveGenerationTask( + task_id=gen_task.task_id, + profile_id=gen_task.profile_id, + text_preview=gen_task.text_preview, + started_at=gen_task.started_at, + ) + ) + + return models.ActiveTasksResponse( + downloads=active_downloads, + generations=active_generations, + ) diff --git a/backend/routes/transcription.py b/backend/routes/transcription.py new file mode 100644 index 00000000..8edafef6 --- /dev/null +++ b/backend/routes/transcription.py @@ -0,0 +1,78 @@ +"""Transcription endpoints.""" + +import asyncio +import tempfile +from pathlib import Path + +from fastapi import APIRouter, File, Form, HTTPException, UploadFile + +from .. import models, transcribe +from ..services.task_queue import create_background_task +from ..utils.tasks import get_task_manager + +router = APIRouter() + + +@router.post("/transcribe", response_model=models.TranscriptionResponse) +async def transcribe_audio( + file: UploadFile = File(...), + language: str | None = Form(None), +): + """Transcribe audio file to text.""" + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + content = await file.read() + tmp.write(content) + tmp_path = tmp.name + + try: + from ..utils.audio import load_audio + + audio, sr = await asyncio.to_thread(load_audio, tmp_path) + duration = len(audio) / sr + + whisper_model = transcribe.get_whisper_model() + + model_size = whisper_model.model_size + whisper_hf_repos = { + "large": "openai/whisper-large-v3", + "turbo": "openai/whisper-large-v3-turbo", + } + model_name = whisper_hf_repos.get(model_size, f"openai/whisper-{model_size}") + + from huggingface_hub import constants as hf_constants + + repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_name.replace("/", "--")) + if not repo_cache.exists(): + progress_model_name = f"whisper-{model_size}" + + async def download_whisper_background(): + try: + await whisper_model.load_model_async(model_size) + except Exception as e: + get_task_manager().error_download(progress_model_name, str(e)) + + get_task_manager().start_download(progress_model_name) + create_background_task(download_whisper_background()) + + raise HTTPException( + status_code=202, + detail={ + "message": f"Whisper model {model_size} is being downloaded. Please wait and try again.", + "model_name": progress_model_name, + "downloading": True, + }, + ) + + text = await whisper_model.transcribe(tmp_path, language) + + return models.TranscriptionResponse( + text=text, + duration=duration, + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + Path(tmp_path).unlink(missing_ok=True) diff --git a/backend/cuda_download.py b/backend/services/cuda.py similarity index 98% rename from backend/cuda_download.py rename to backend/services/cuda.py index bafc324d..7e4d9602 100644 --- a/backend/cuda_download.py +++ b/backend/services/cuda.py @@ -14,9 +14,9 @@ import sys from pathlib import Path from typing import Optional -from .config import get_data_dir -from .utils.progress import get_progress_manager -from . import __version__ +from ..config import get_data_dir +from ..utils.progress import get_progress_manager +from .. import __version__ logger = logging.getLogger(__name__)