* fix: eliminate DB connection leaks, race conditions, and deprecated asyncio API ## DB Connection Leaks (P0) - Convert 38 raw get_db() calls to db_conn() context manager across 14 router files - Connections are now guaranteed to close even when exceptions are raised - profiles.py create_profile: clean up orphaned audio file if DB insert fails - profiles.py lock_profile: consolidate 3 separate conn.close() error paths ## Race Condition (P1) - Add _dub_jobs_lock (threading.Lock) to protect _dub_jobs dict in dub_pipeline.py - get_job/put_job now thread-safe for concurrent dub sessions ## asyncio Deprecation (P2) - Replace 23 asyncio.get_event_loop() calls with asyncio.get_running_loop() - Prevents DeprecationWarning on Python 3.12+ and future breakage on 3.14 ## Quick Fixes - gallery.py preview_voice: remove filesystem path from error response (P2) - dub_pipeline.py parse_vtt_segments: remove redundant `import re` inside loop (P3) - gallery.py _init_gallery_db: use db_conn() context manager (P2) * refactor: extract hooks, centralize isTauri, add pytest-cov ## Frontend - Extract useTTS hook (150 LOC) — TTS generation, streaming, audio ingestion - Extract useProfiles hook (219 LOC) — voice profile CRUD, lock/unlock, preview - Centralize isTauri detection: dialog.js, VoiceGallery.jsx, Settings.jsx now import from utils/media.js instead of 4 different detection patterns ## Backend - Add pytest-cov to dev dependencies - Baseline coverage: 39% across backend/ (214 tests pass) - Add .coverage to .gitignore * feat: add Vitest + checkJs, extract useDubWorkflow + useAppData hooks ## Frontend Testing (new) - Set up Vitest with jsdom environment + @testing-library/react - 11 tests: utils (isTauri, formatTime, constants) + Zustand store (mode, text, dubStep, pill) - Scripts: 'test' (vitest run), 'test:watch' (vitest), 'test:legacy' (node runner) ## App.jsx Decomposition (continued) - Extract useDubWorkflow hook (387 LOC) — upload, ingest, transcribe SSE, translate, generate SSE, abort, stop, cleanup - Extract useAppData hook (181 LOC) — data loading, localStorage persistence, WebSocket real-time updates, model-status pill management ## TypeScript checkJs - Enable checkJs: true in tsconfig.json for IDE-level type checking - 947 existing errors (informational, not blocking builds) - noImplicitAny remains false to avoid blocking * ci: add Vitest step, fix useProfiles duplicate state ## CI - Add 'Run Vitest (frontend)' step — runs 11 unit tests - Override --checkJs false in CI typecheck to avoid 947 pre-existing errors - Rename legacy test step for clarity ## Hooks - Fix useProfiles to accept loadProfiles from parent (useAppData) instead of managing its own duplicate profiles array * refactor: wire hooks into App.jsx — 2067 → 1129 LOC (-45%) App.jsx now delegates to extracted hooks instead of inline logic: - useAppData: data loading, localStorage, WebSocket, model pill - useProfiles: voice profile CRUD, lock/unlock, preview - useTTS: generation, streaming, audio ingestion - useDubWorkflow: upload, transcribe SSE, translate, generate SSE 988 lines removed. All handler logic lives in focused, independently testable hooks. Store selectors and render JSX stay in App.jsx as the shell. Verified: vite build clean, 11 frontend + 214 backend tests pass. * feat: show real-time percentage on model loading pill Backend: register hf_progress listener during _load_model_sync() so download/weight-loading tqdm events update _loading_detail with a progress percentage (0-99%). get_model_status() now includes a 'progress' field that the frontend polls. Frontend: useAppData reads msQuery.data.progress and calls setPillProgress() — the FloatingPill already renders the percentage text and progress bar width from this value. * fix: prevent FileNotFoundError in desktop bundle during model init transformers >=4.52 calls _can_set_experts_implementation() and _can_set_attn_implementation() during PreTrainedModel.__init__, which open the class source file via open(class_file). In a Tauri desktop bundle, module.__file__ points to a path that doesn't exist on disk, causing: FileNotFoundError: .../omnivoice/models/omnivoice.py Override both classmethods on OmniVoice to return static values without filesystem access. OmniVoice doesn't use MoE experts (return False), but does support flex/flash attn (return True). * fix: sync source dirs on every bootstrap, not just first run The Tauri bootstrap previously only copied omnivoice/ and backend/ to Application Support on the first run. Subsequent app updates kept using stale source files, preventing bug fixes from landing. Now ensure_venv_ready() always syncs both directories from the bundle resources before returning, even when the venv is healthy. This fixes the FileNotFoundError crash where the old omnivoice.py lacked the _can_set_experts_implementation override. * ui: premium setup wizard polish - Primary button: solid gradient fill with hover glow + lift + press - Stepper nav: connected pills with glow ring on active step - Welcome cards: glassmorphism with stagger-in animations, lucide icons, left-border accent strip, hover translate - Preflight panel: colored icon pill backgrounds, stagger-slide entrance - Step transitions: fade+slide animation via keyed wrapper - Footnote: shortened paths (~/ notation), Reveal in Finder button - Recommendation banner: gradient background with accent glow - Compact spacing throughout for denser, professional layout * fix: kill zombie backend on clean+retry bootstrap When clean_and_retry_bootstrap removes the project dir, any old uvicorn process still running from the deleted paths remains alive on port 3900. The subsequent retry_bootstrap sees the port is healthy and attaches to the zombie instead of re-bootstrapping. Now explicitly kill any process on the backend port after cleaning, before calling retry_bootstrap. * feat: integrate speaker clones into dubbing interface, sanitize system environment variables for subprocesses, and improve FFMPEG binary path resolution. * fix: restore docker compose default + drop dead setSeed call - deploy/docker-compose.yml: remove profiles: ["cpu"] from the default service so `docker compose up` matches the comment on line 5. With the profile present, no service auto-started. - frontend/src/App.jsx: drop the setSeed call in restoreHistory. The selector was never reintroduced after the App.jsx hooks split, and there is no seed state in the store — seeds are generated fresh per call in useTTS and only read from history items for display. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address CodeRabbit review — async detection, dub stream, bootstrap fail-fast - backend/services/tts_backend.py: invert async-context detection in _ensure_loaded. The previous code unconditionally caught its own diagnostic RuntimeError and then called asyncio.run() inside a running loop, masking the intended error message. - frontend/src/hooks/useDubWorkflow.js: require a terminal `done` event before reporting dub success. Without this, a dropped stream after partial progress would flip the UI to `done`, refresh history, and play the completion ping as if generation finished. - frontend/src/hooks/useDubWorkflow.js: restore the previous step when tasksCancel() fails. The UI was getting stuck in `stopping` forever on cancel errors. - frontend/src-tauri/src/bootstrap.rs: fail-fast when source sync fails after the existing directory has already been removed. The previous warn-and-continue path could leave the install with no backend/ or omnivoice/ sources and defer the failure to backend startup with a cryptic error. - backend/api/routers/generation.py: add `from e` to the ValueError → HTTPException re-raise (Ruff B904). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: preserve % suffix in TTS generation timer The 100ms timer in useTTS was rewriting generationTime to a plain elapsed-seconds string, which immediately wiped the "(xx%)" download suffix written on the next iteration of the response-body loop. The real-time percentage was flickering on/off as a result. Read the previous value inside the setter and reattach any existing percent suffix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
557 lines
18 KiB
Python
557 lines
18 KiB
Python
import os
|
|
import json
|
|
import uuid
|
|
import time
|
|
import asyncio
|
|
import logging
|
|
import subprocess
|
|
from typing import Optional, List
|
|
from pathlib import Path
|
|
from fastapi import APIRouter, File, Form, UploadFile, HTTPException, Query
|
|
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
|
from pydantic import BaseModel
|
|
|
|
from core.db import db_conn
|
|
from core.config import VOICES_DIR, OUTPUTS_DIR
|
|
from core import event_bus
|
|
|
|
logger = logging.getLogger("omnivoice.gallery")
|
|
|
|
router = APIRouter()
|
|
|
|
VOICE_GALLERY_DIR = Path(os.path.join(OUTPUTS_DIR, "voice_gallery"))
|
|
VOICE_GALLERY_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
CATEGORIES = [
|
|
{
|
|
"id": "disney",
|
|
"name": "Disney",
|
|
"icon": "🎬",
|
|
"description": "Disney characters, Pixar, and animated films",
|
|
},
|
|
{
|
|
"id": "anime",
|
|
"name": "Anime",
|
|
"icon": "🎌",
|
|
"description": "Japanese anime characters",
|
|
},
|
|
{
|
|
"id": "marvel",
|
|
"name": "Marvel/DC",
|
|
"icon": "🦸",
|
|
"description": "Superhero movies and TV shows",
|
|
},
|
|
{
|
|
"id": "celebs",
|
|
"name": "Celebrities",
|
|
"icon": "⭐",
|
|
"description": "Famous actors and personalities",
|
|
},
|
|
{
|
|
"id": "politicians",
|
|
"name": "Politicians",
|
|
"icon": "🏛️",
|
|
"description": "World leaders and politicians",
|
|
},
|
|
{
|
|
"id": "news",
|
|
"name": "News Anchors",
|
|
"icon": "📰",
|
|
"description": "News broadcasters",
|
|
},
|
|
{
|
|
"id": "gaming",
|
|
"name": "Gaming",
|
|
"icon": "🎮",
|
|
"description": "Video game characters",
|
|
},
|
|
{
|
|
"id": "books",
|
|
"name": "Books/Movies",
|
|
"icon": "📚",
|
|
"description": "Literary and film characters",
|
|
},
|
|
]
|
|
|
|
|
|
class VoiceEntry(BaseModel):
|
|
id: str
|
|
name: str
|
|
character: str
|
|
category: str
|
|
source_type: str # "youtube", "upload", "preset"
|
|
source_url: Optional[str] = None
|
|
audio_path: str
|
|
duration: float
|
|
description: Optional[str] = None
|
|
thumbnail: Optional[str] = None
|
|
tags: List[str] = []
|
|
created_at: float
|
|
|
|
|
|
def _init_gallery_db():
|
|
"""Initialize the voice gallery table."""
|
|
with db_conn() as conn:
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS voice_gallery (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
character TEXT NOT NULL,
|
|
category TEXT NOT NULL,
|
|
source_type TEXT NOT NULL,
|
|
source_url TEXT,
|
|
audio_path TEXT NOT NULL,
|
|
duration REAL NOT NULL,
|
|
description TEXT,
|
|
thumbnail TEXT,
|
|
tags TEXT,
|
|
is_favorite INTEGER NOT NULL DEFAULT 0,
|
|
created_at REAL NOT NULL
|
|
)
|
|
""")
|
|
# Migration: add is_favorite column if missing (existing DBs)
|
|
try:
|
|
conn.execute("SELECT is_favorite FROM voice_gallery LIMIT 1")
|
|
except Exception:
|
|
conn.execute("ALTER TABLE voice_gallery ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0")
|
|
|
|
|
|
@router.get("/gallery/categories")
|
|
def list_categories():
|
|
"""List all voice gallery categories."""
|
|
return CATEGORIES
|
|
|
|
|
|
@router.get("/gallery/voices")
|
|
def list_voices(
|
|
category: Optional[str] = Query(None, description="Filter by category"),
|
|
search: Optional[str] = Query(None, description="Search by name or character"),
|
|
limit: int = Query(50, ge=1, le=200),
|
|
):
|
|
"""List voices in the gallery, optionally filtered by category or search."""
|
|
query = "SELECT * FROM voice_gallery"
|
|
params = []
|
|
conditions = []
|
|
|
|
if category:
|
|
conditions.append("category = ?")
|
|
params.append(category)
|
|
if search:
|
|
conditions.append("(name LIKE ? OR character LIKE ? OR description LIKE ?)")
|
|
params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
|
|
|
|
if conditions:
|
|
query += " WHERE " + " AND ".join(conditions)
|
|
query += " ORDER BY created_at DESC LIMIT ?"
|
|
params.append(limit)
|
|
|
|
with db_conn() as conn:
|
|
rows = conn.execute(query, params).fetchall()
|
|
|
|
results = []
|
|
for row in rows:
|
|
r = dict(row)
|
|
r["tags"] = json.loads(r.get("tags", "[]") or "[]")
|
|
results.append(r)
|
|
return results
|
|
|
|
|
|
@router.get("/gallery/voices/{voice_id}")
|
|
def get_voice(voice_id: str):
|
|
"""Get a specific voice from the gallery."""
|
|
with db_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Voice not found")
|
|
r = dict(row)
|
|
r["tags"] = json.loads(r.get("tags", "[]") or "[]")
|
|
return r
|
|
|
|
|
|
@router.delete("/gallery/voices/{voice_id}")
|
|
def delete_voice(voice_id: str):
|
|
"""Delete a voice from the gallery."""
|
|
with db_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT audio_path FROM voice_gallery WHERE id = ?", (voice_id,)
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Voice not found")
|
|
|
|
audio_path = row["audio_path"]
|
|
if audio_path and os.path.exists(audio_path):
|
|
try:
|
|
os.remove(audio_path)
|
|
except Exception:
|
|
pass
|
|
|
|
conn.execute("DELETE FROM voice_gallery WHERE id = ?", (voice_id,))
|
|
return {"success": True}
|
|
|
|
|
|
@router.post("/gallery/search/youtube")
|
|
async def search_youtube(
|
|
query: str = Query(..., description="Character or celebrity name to search"),
|
|
category: str = Query(..., description="Category to associate results with"),
|
|
max_results: int = Query(5, ge=1, le=20),
|
|
):
|
|
"""Search YouTube for character/celebrity clips using yt-dlp."""
|
|
try:
|
|
result = await asyncio.create_subprocess_exec(
|
|
"yt-dlp",
|
|
"--dump-json",
|
|
"--remote-components", "ejs:github",
|
|
f"ytsearch{max_results}:{query}",
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
stdout, stderr = await result.communicate()
|
|
|
|
if result.returncode != 0:
|
|
logger.error(f"yt-dlp search failed: {stderr.decode()}")
|
|
raise HTTPException(
|
|
status_code=500, detail=f"YouTube search failed: {stderr.decode()}"
|
|
)
|
|
|
|
lines = stdout.decode().strip().split("\n")
|
|
results = []
|
|
for line in lines:
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
data = json.loads(line)
|
|
results.append(
|
|
{
|
|
"title": data.get("title", ""),
|
|
"video_id": data.get("id", ""),
|
|
"duration": str(data.get("duration")) if data.get("duration") is not None else None,
|
|
"thumbnail": data.get("thumbnail", None),
|
|
}
|
|
)
|
|
except json.JSONDecodeError:
|
|
logger.warning(f"Failed to parse yt-dlp JSON line: {line}")
|
|
|
|
return {"results": results, "query": query, "category": category}
|
|
except FileNotFoundError:
|
|
raise HTTPException(status_code=500, detail="yt-dlp not installed")
|
|
except Exception as e:
|
|
logger.error(f"YouTube search error: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/gallery/download")
|
|
async def download_youtube_clip(
|
|
video_url: str = Query(..., description="YouTube video URL"),
|
|
start_time: float = Query(0, ge=0, description="Start time in seconds"),
|
|
duration: float = Query(10, ge=1, le=30, description="Clip duration in seconds"),
|
|
character_name: str = Query(..., description="Character/celebrity name"),
|
|
category: str = Query(..., description="Category"),
|
|
description: str = Query("", description="Optional description"),
|
|
):
|
|
"""Download a clip from YouTube for voice cloning."""
|
|
voice_id = str(uuid.uuid4())[:8]
|
|
output_path = str(VOICE_GALLERY_DIR / f"{voice_id}.wav")
|
|
temp_path = str(VOICE_GALLERY_DIR / f"{voice_id}.%(ext)s")
|
|
|
|
try:
|
|
cmd = [
|
|
"yt-dlp",
|
|
"--remote-components", "ejs:github",
|
|
"-f",
|
|
"bestaudio",
|
|
"--download-sections",
|
|
f"*{start_time:.1f}-{start_time + duration:.1f}",
|
|
"-x",
|
|
"--audio-format",
|
|
"wav",
|
|
"--audio-quality",
|
|
"0",
|
|
"-o",
|
|
temp_path,
|
|
video_url,
|
|
]
|
|
|
|
result = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
stdout, stderr = await result.communicate()
|
|
|
|
if result.returncode != 0:
|
|
logger.error(f"yt-dlp download failed: {stderr.decode()}")
|
|
raise HTTPException(
|
|
status_code=500, detail=f"Download failed: {stderr.decode()}"
|
|
)
|
|
|
|
# Find the downloaded file (yt-dlp replaces %s with actual extension)
|
|
downloaded_files = list(VOICE_GALLERY_DIR.glob(f"{voice_id}.*"))
|
|
if not downloaded_files:
|
|
raise HTTPException(status_code=500, detail="Downloaded file not found")
|
|
|
|
actual_path = downloaded_files[0]
|
|
# Rename to output_path
|
|
final_path = Path(output_path)
|
|
actual_path.rename(final_path)
|
|
|
|
conn = db_conn()
|
|
with conn as c:
|
|
c.execute(
|
|
"""
|
|
INSERT INTO voice_gallery
|
|
(id, name, character, category, source_type, source_url, audio_path, duration, description, tags, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
voice_id,
|
|
character_name,
|
|
character_name,
|
|
category,
|
|
"youtube",
|
|
video_url,
|
|
output_path,
|
|
duration,
|
|
description,
|
|
json.dumps([character_name.lower(), category]),
|
|
time.time(),
|
|
),
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"voice_id": voice_id,
|
|
"audio_path": output_path,
|
|
"duration": duration,
|
|
}
|
|
except FileNotFoundError:
|
|
raise HTTPException(status_code=500, detail="yt-dlp not installed")
|
|
except Exception as e:
|
|
logger.error(f"Download error: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/gallery/upload")
|
|
async def upload_voice_clip(
|
|
name: str = Form(...),
|
|
character: str = Form(...),
|
|
category: str = Form(...),
|
|
description: str = Form(""),
|
|
audio: UploadFile = File(...),
|
|
):
|
|
"""Upload a voice clip directly to the gallery."""
|
|
voice_id = str(uuid.uuid4())[:8]
|
|
ext = os.path.splitext(audio.filename or ".wav")[1]
|
|
audio_path = str(VOICE_GALLERY_DIR / f"{voice_id}{ext}")
|
|
|
|
with open(audio_path, "wb") as f:
|
|
f.write(await audio.read())
|
|
|
|
try:
|
|
import soundfile as sf
|
|
|
|
info = sf.info(audio_path)
|
|
duration = info.frames / info.samplerate
|
|
except Exception:
|
|
duration = 10.0
|
|
|
|
with db_conn() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO voice_gallery
|
|
(id, name, character, category, source_type, source_url, audio_path, duration, description, tags, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
voice_id,
|
|
name,
|
|
character,
|
|
category,
|
|
"upload",
|
|
None,
|
|
audio_path,
|
|
duration,
|
|
description,
|
|
json.dumps([character.lower(), category]),
|
|
time.time(),
|
|
),
|
|
)
|
|
|
|
return {
|
|
"id": voice_id,
|
|
"name": name,
|
|
"audio_path": audio_path,
|
|
"duration": duration,
|
|
}
|
|
|
|
|
|
@router.post("/gallery/voices/{voice_id}/save-as-profile")
|
|
async def save_voice_as_profile(
|
|
voice_id: str,
|
|
profile_name: str = Query(..., description="Name for the voice profile"),
|
|
):
|
|
"""Save a gallery voice as a voice profile for cloning."""
|
|
with db_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)
|
|
).fetchone()
|
|
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Voice not found")
|
|
|
|
profile_id = str(uuid.uuid4())[:8]
|
|
import shutil
|
|
|
|
ext = os.path.splitext(row["audio_path"])[1]
|
|
new_audio_path = os.path.join(VOICES_DIR, f"{profile_id}{ext}")
|
|
shutil.copy(row["audio_path"], new_audio_path)
|
|
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO voice_profiles (id, name, ref_audio_path, ref_text, instruct, language, seed, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
profile_id,
|
|
profile_name,
|
|
f"{profile_id}{ext}",
|
|
row["description"] or "",
|
|
row["character"] or "",
|
|
"Auto",
|
|
None,
|
|
time.time(),
|
|
),
|
|
)
|
|
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
|
|
|
return {"profile_id": profile_id, "name": profile_name}
|
|
|
|
|
|
@router.get("/gallery/voices/{voice_id}/preview")
|
|
def preview_voice(voice_id: str):
|
|
"""Get a voice clip for preview playback."""
|
|
with db_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT audio_path FROM voice_gallery WHERE id = ?", (voice_id,)
|
|
).fetchone()
|
|
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Voice not found")
|
|
|
|
audio_path = row["audio_path"]
|
|
|
|
# Debug logging
|
|
is_absolute = os.path.isabs(audio_path)
|
|
path_exists = os.path.exists(audio_path) if audio_path else False
|
|
|
|
# If absolute path, serve directly or redirect
|
|
if is_absolute and path_exists:
|
|
# Get just the relative path from outputs dir
|
|
outputs_path = str(OUTPUTS_DIR)
|
|
if audio_path.startswith(outputs_path):
|
|
# Remove outputs_dir prefix to get relative path within outputs
|
|
rel_path = os.path.relpath(audio_path, outputs_path)
|
|
# The audio_path is like: /Users/user4/.../outputs/voice_gallery/file.wav
|
|
# rel_path becomes: voice_gallery/file.wav
|
|
# We want to serve from /audio/ so: /audio/voice_gallery/file.wav
|
|
return RedirectResponse(f"/audio/{rel_path}")
|
|
return FileResponse(audio_path, media_type="audio/wav")
|
|
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="Audio file not found. It may have been deleted or moved.",
|
|
)
|
|
|
|
|
|
# ── Library management endpoints ──────────────────────────────────────────
|
|
|
|
@router.patch("/gallery/voices/{voice_id}")
|
|
def update_voice(voice_id: str, body: dict):
|
|
"""Update voice metadata — name, tags, is_favorite."""
|
|
with db_conn() as conn:
|
|
row = conn.execute("SELECT id FROM voice_gallery WHERE id = ?", (voice_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Voice not found")
|
|
|
|
updates = []
|
|
params = []
|
|
if "name" in body:
|
|
updates.append("name = ?")
|
|
params.append(body["name"])
|
|
if "tags" in body:
|
|
updates.append("tags = ?")
|
|
params.append(json.dumps(body["tags"]) if isinstance(body["tags"], list) else body["tags"])
|
|
if "is_favorite" in body:
|
|
updates.append("is_favorite = ?")
|
|
params.append(1 if body["is_favorite"] else 0)
|
|
if "description" in body:
|
|
updates.append("description = ?")
|
|
params.append(body["description"])
|
|
|
|
if not updates:
|
|
return {"success": True, "updated": []}
|
|
|
|
params.append(voice_id)
|
|
conn.execute(f"UPDATE voice_gallery SET {', '.join(updates)} WHERE id = ?", params)
|
|
return {"success": True, "updated": list(body.keys())}
|
|
|
|
|
|
@router.post("/gallery/voices/batch-delete")
|
|
def batch_delete_voices(body: dict):
|
|
"""Delete multiple voices by ID list."""
|
|
ids = body.get("ids", [])
|
|
if not ids:
|
|
return {"deleted": 0}
|
|
|
|
deleted = 0
|
|
with db_conn() as conn:
|
|
for vid in ids:
|
|
row = conn.execute("SELECT audio_path FROM voice_gallery WHERE id = ?", (vid,)).fetchone()
|
|
if row:
|
|
audio_path = row["audio_path"]
|
|
if audio_path and os.path.exists(audio_path):
|
|
try:
|
|
os.remove(audio_path)
|
|
except Exception:
|
|
pass
|
|
conn.execute("DELETE FROM voice_gallery WHERE id = ?", (vid,))
|
|
deleted += 1
|
|
return {"deleted": deleted}
|
|
|
|
|
|
@router.post("/gallery/voices/{voice_id}/to-profile")
|
|
def voice_to_profile(voice_id: str):
|
|
"""Create a voice profile from a gallery clip."""
|
|
with db_conn() as conn:
|
|
row = conn.execute("SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Voice not found")
|
|
|
|
voice = dict(row)
|
|
audio_path = voice["audio_path"]
|
|
if not os.path.exists(audio_path):
|
|
raise HTTPException(status_code=404, detail="Audio file not found on disk")
|
|
|
|
import shutil
|
|
import uuid
|
|
|
|
profile_id = str(uuid.uuid4())[:8]
|
|
# Copy audio to voices dir
|
|
dest_filename = f"{profile_id}_gallery.wav"
|
|
dest_path = os.path.join(VOICES_DIR, dest_filename)
|
|
shutil.copy2(audio_path, dest_path)
|
|
|
|
import time
|
|
now = time.time()
|
|
conn.execute(
|
|
"""INSERT INTO voice_profiles
|
|
(id, name, ref_audio_path, ref_text, instruct, seed, is_locked, locked_audio_path, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(profile_id, voice["name"], dest_filename, "", None, None, 0, None, now, now),
|
|
)
|
|
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
|
|
|
return {"success": True, "profile_id": profile_id, "name": voice["name"]}
|
|
|