refactor: split backend into api/core/services/schemas, harden security + fd pressure, add searchable language picker, fix segment fragmentation
Backend:
- Split monolithic main.py into backend/{api/routers,core,schemas,services}
- core/db.py: allowlist-gated migrations, db_conn context manager (kills SQL injection on ALTER)
- core/tasks.py: lock-guarded listener add/remove/push, snapshot-before-iterate
- services/ffmpeg_utils.py: run_ffmpeg helper with concurrency semaphore, EAGAIN retry, guaranteed reap
- services/segmentation.py: Bengali/CJK/Arabic punctuation, ultra-short tier, stitch_adjacent_shorts,
bounded-loop merge; public clean_up_segments API
- services/model_manager.py: robust lock.locked() handling
- api/routers/dub_core.py: job_id traversal guard, thread-safe _active_procs, timeouts on ffmpeg/demucs,
POST /dub/cleanup-segments endpoint
- api/routers/dub_export.py: guarded SSE listener remove, ffmpeg timeouts via run_ffmpeg
- api/routers/exports.py: destination_path validation, safe source resolver, subprocess list-form
- api/routers/generation.py: contextlib.suppress on tempfile cleanup, db_conn usage, safe output-path helper
- api/routers/system.py: try/finally tmp cleanup, subprocess timeouts
- schemas/requests.py: TranslateSegment.id int->str to match hex segment IDs
- main.py: threading.Lock around crash log writes
Frontend:
- components/SearchableSelect.jsx: popover combobox with search, keyboard nav, popular+recent pins, 200-item cap
- App.jsx: wire SearchableSelect for dub language / ISO code / voice-gen language; Clean Up segments button;
fix blob URL leak (object-shaped prev in setter, unmount cleanup via ref)
- components/WaveformTimeline.jsx: explicit <video> detach instead of innerHTML='' to release decoder
- index.css: ss-* combobox styles matching Gruvbox theme
Tests:
- tests/test_segmentation.py (26 cases), test_dub_transcribe.py, test_dub_export_unique.py, conftest.py
Chore:
- .gitignore: exclude omnivoice.zip, /research/ reference clones
- Remove tracked stray root test scripts + crash_log.txt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
d2cab558c3
commit
67328d04fe
+12
@@ -37,10 +37,22 @@ bun.lockb
|
||||
|
||||
# Generated logs and binaries
|
||||
*.log
|
||||
crash_log.txt
|
||||
cloudflared
|
||||
cloudflared.tgz
|
||||
|
||||
# Root-level ad-hoc test scripts (should live in /tests)
|
||||
/test_crash.py
|
||||
/test_mock.py
|
||||
/test_pyannote.py
|
||||
/test_server.py
|
||||
/test_whisper.py
|
||||
|
||||
# Data directories and sqlite db
|
||||
omnivoice_data/
|
||||
*.db
|
||||
demo_recording.webp
|
||||
|
||||
# Local archives / reference clones
|
||||
omnivoice.zip
|
||||
/research/
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
import os
|
||||
import io
|
||||
import sys
|
||||
import uuid
|
||||
import json
|
||||
import time
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import soundfile as sf
|
||||
import torch
|
||||
import torchaudio
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, File, Form, UploadFile, HTTPException, Query
|
||||
from fastapi.responses import FileResponse, Response, StreamingResponse, JSONResponse
|
||||
|
||||
from core.db import get_db, db_conn
|
||||
from core.config import DATA_DIR, DUB_DIR, PREVIEW_DIR, VOICES_DIR
|
||||
from core.tasks import task_manager
|
||||
from schemas.requests import DubRequest, TranslateRequest
|
||||
from services.model_manager import get_model, _gpu_pool, _cpu_pool, get_best_device, get_diarization_pipeline
|
||||
from services.audio_dsp import apply_mastering, normalize_audio
|
||||
from services.ffmpeg_utils import find_ffmpeg
|
||||
from services.segmentation import (
|
||||
segment_transcript,
|
||||
assign_speakers_from_diarization,
|
||||
assign_speakers_heuristic,
|
||||
clean_up_segments,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.api")
|
||||
|
||||
_dub_jobs = {}
|
||||
# Tracks live subprocesses per job so POST /dub/abort/{job_id} can terminate them.
|
||||
_active_procs: dict[str, list] = {}
|
||||
_active_procs_lock = threading.Lock()
|
||||
|
||||
_DUB_DIR_REAL = os.path.realpath(DUB_DIR)
|
||||
|
||||
|
||||
def _safe_job_dir(job_id: str) -> Optional[str]:
|
||||
"""Resolve a job directory under DUB_DIR, rejecting traversal."""
|
||||
if not job_id or "/" in job_id or "\\" in job_id or job_id in (".", ".."):
|
||||
return None
|
||||
candidate = os.path.realpath(os.path.join(DUB_DIR, job_id))
|
||||
if not candidate.startswith(_DUB_DIR_REAL + os.sep):
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def _register_proc(job_id: str, proc):
|
||||
with _active_procs_lock:
|
||||
_active_procs.setdefault(job_id, []).append(proc)
|
||||
|
||||
|
||||
def _unregister_proc(job_id: str, proc):
|
||||
with _active_procs_lock:
|
||||
lst = _active_procs.get(job_id)
|
||||
if lst and proc in lst:
|
||||
lst.remove(proc)
|
||||
if lst is not None and not lst:
|
||||
_active_procs.pop(job_id, None)
|
||||
|
||||
|
||||
def _kill_job_procs(job_id: str):
|
||||
with _active_procs_lock:
|
||||
procs = list(_active_procs.get(job_id, []))
|
||||
for proc in procs:
|
||||
try:
|
||||
if proc.returncode is None:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("Failed to kill subprocess for %s: %s", job_id, e)
|
||||
with _active_procs_lock:
|
||||
_active_procs.pop(job_id, None)
|
||||
|
||||
def _get_job(job_id: str):
|
||||
if job_id in _dub_jobs:
|
||||
return _dub_jobs[job_id]
|
||||
conn = get_db()
|
||||
try:
|
||||
row = conn.execute("SELECT job_data FROM dub_history WHERE id=?", (job_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if row and row["job_data"]:
|
||||
try:
|
||||
job = json.loads(row["job_data"])
|
||||
_dub_jobs[job_id] = job
|
||||
return job
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Failed to decode dub_history.job_data for %s: %s", job_id, e)
|
||||
return None
|
||||
|
||||
def _save_job(job_id: str, job: dict, filename: str = "", duration: float = 0.0):
|
||||
"""Persist dub job state to SQLite so it survives restarts."""
|
||||
try:
|
||||
segments = job.get("segments") or []
|
||||
tracks = list((job.get("dubbed_tracks") or {}).keys())
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO dub_history
|
||||
(id, filename, duration, segments_count, language, language_code, tracks, job_data, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
filename=excluded.filename,
|
||||
duration=excluded.duration,
|
||||
segments_count=excluded.segments_count,
|
||||
tracks=excluded.tracks,
|
||||
job_data=excluded.job_data""",
|
||||
(job_id, filename or job.get("filename", ""),
|
||||
duration or job.get("duration", 0.0),
|
||||
len(segments), job.get("language", ""), job.get("language_code", ""),
|
||||
json.dumps(tracks), json.dumps(job, default=str), time.time()),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to persist dub job %s: %s", job_id, e)
|
||||
|
||||
@router.post("/dub/cleanup-segments/{job_id}")
|
||||
def dub_cleanup_segments(job_id: str):
|
||||
"""Re-run merge/stitch passes on a job's existing segments to drop fragments."""
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
segments = job.get("segments") or []
|
||||
cleaned = clean_up_segments(segments)
|
||||
job["segments"] = cleaned
|
||||
_save_job(job_id, job)
|
||||
return {"segments": cleaned, "before": len(segments), "after": len(cleaned)}
|
||||
|
||||
|
||||
@router.post("/dub/abort/{job_id}")
|
||||
def dub_abort(job_id: str):
|
||||
"""Cancel in-flight upload/transcribe subprocesses for a job."""
|
||||
with _active_procs_lock:
|
||||
had_procs = bool(_active_procs.get(job_id))
|
||||
_kill_job_procs(job_id)
|
||||
job = _dub_jobs.get(job_id)
|
||||
if job is not None:
|
||||
job["aborted"] = True
|
||||
try:
|
||||
task_manager.cancel_task(job_id)
|
||||
except Exception:
|
||||
pass
|
||||
return {"aborted": True, "had_active_procs": had_procs}
|
||||
|
||||
|
||||
@router.get("/dub/history")
|
||||
def list_dub_history():
|
||||
conn = get_db()
|
||||
try:
|
||||
rows = conn.execute("SELECT * FROM dub_history ORDER BY created_at DESC LIMIT 30").fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@router.delete("/dub/history")
|
||||
def clear_dub_history():
|
||||
"""Delete persisted dub rows and their on-disk dirs (scoped to known IDs)."""
|
||||
conn = get_db()
|
||||
try:
|
||||
ids = [r["id"] for r in conn.execute("SELECT id FROM dub_history").fetchall()]
|
||||
conn.execute("DELETE FROM dub_history")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
for jid in ids:
|
||||
safe = _safe_job_dir(jid)
|
||||
if safe and os.path.isdir(safe):
|
||||
shutil.rmtree(safe, ignore_errors=True)
|
||||
return {"cleared": True, "count": len(ids)}
|
||||
|
||||
@router.delete("/dub/history/{history_id}")
|
||||
def delete_single_dub_history(history_id: str):
|
||||
with db_conn() as conn:
|
||||
conn.execute("DELETE FROM dub_history WHERE id=?", (history_id,))
|
||||
safe = _safe_job_dir(history_id)
|
||||
if safe and os.path.isdir(safe):
|
||||
shutil.rmtree(safe, ignore_errors=True)
|
||||
_dub_jobs.pop(history_id, None)
|
||||
return {"deleted": True}
|
||||
|
||||
@router.post("/preview/upload")
|
||||
async def preview_upload(video: UploadFile = File(...)):
|
||||
ext = os.path.splitext(video.filename or "video.mp4")[1].lower()
|
||||
safe_name = f"{uuid.uuid4().hex[:12]}"
|
||||
vid_path = os.path.join(PREVIEW_DIR, f"{safe_name}{ext}")
|
||||
wav_path = os.path.join(PREVIEW_DIR, f"{safe_name}.wav")
|
||||
|
||||
with open(vid_path, "wb") as f:
|
||||
f.write(await video.read())
|
||||
|
||||
has_audio = False
|
||||
if ext not in [".wav", ".mp3", ".m4a", ".aac"]:
|
||||
try:
|
||||
ffmpeg_cmd = [
|
||||
find_ffmpeg(), "-y", "-i", vid_path,
|
||||
"-vn", "-acodec", "pcm_s16le", "-ar", "22050", "-ac", "1",
|
||||
wav_path
|
||||
]
|
||||
subprocess.run(
|
||||
ffmpeg_cmd, check=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
timeout=300,
|
||||
)
|
||||
has_audio = True
|
||||
except Exception as e:
|
||||
logger.warning(f"FFmpeg extraction failed: {e}")
|
||||
pass
|
||||
|
||||
return {
|
||||
"url": f"/preview/{safe_name}{ext}",
|
||||
"audioUrl": f"/preview/{safe_name}.wav" if has_audio else f"/preview/{safe_name}{ext}",
|
||||
"filename": video.filename,
|
||||
}
|
||||
|
||||
@router.get("/preview/{filename}")
|
||||
async def preview_serve(filename: str):
|
||||
if not filename or "/" in filename or "\\" in filename or filename.startswith("."):
|
||||
raise HTTPException(400, "Invalid preview filename")
|
||||
preview_real = os.path.realpath(PREVIEW_DIR)
|
||||
path = os.path.realpath(os.path.join(PREVIEW_DIR, filename))
|
||||
if not path.startswith(preview_real + os.sep):
|
||||
raise HTTPException(400, "Invalid preview filename")
|
||||
if not os.path.isfile(path):
|
||||
raise HTTPException(404, "Preview not found")
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
media_types = {
|
||||
".mp4": "video/mp4", ".mov": "video/quicktime",
|
||||
".mkv": "video/x-matroska", ".webm": "video/webm",
|
||||
".avi": "video/x-msvideo", ".wav": "audio/wav",
|
||||
".mp3": "audio/mpeg"
|
||||
}
|
||||
return FileResponse(path, media_type=media_types.get(ext, "application/octet-stream"))
|
||||
|
||||
@router.post("/dub/upload")
|
||||
async def dub_upload(video: UploadFile = File(...), job_id: Optional[str] = Form(None)):
|
||||
job_id = job_id or str(uuid.uuid4())[:8]
|
||||
job_dir = _safe_job_dir(job_id)
|
||||
if job_dir is None:
|
||||
raise HTTPException(status_code=400, detail="invalid job_id")
|
||||
os.makedirs(job_dir, exist_ok=True)
|
||||
|
||||
ext = os.path.splitext(video.filename or "video.mp4")[1]
|
||||
video_path = os.path.join(job_dir, f"original{ext}")
|
||||
with open(video_path, "wb") as f:
|
||||
f.write(await video.read())
|
||||
|
||||
audio_path = os.path.join(job_dir, "audio.wav")
|
||||
ffmpeg = find_ffmpeg()
|
||||
|
||||
async def _run_proc(cmd, timeout: float = 900.0):
|
||||
p = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_register_proc(job_id, p)
|
||||
try:
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(p.communicate(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
p.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
raise HTTPException(status_code=504, detail=f"subprocess timed out after {timeout}s")
|
||||
return p, stdout, stderr
|
||||
finally:
|
||||
_unregister_proc(job_id, p)
|
||||
|
||||
try:
|
||||
try:
|
||||
p, _, stderr = await _run_proc([
|
||||
ffmpeg, "-i", video_path, "-vn", "-acodec", "pcm_s16le",
|
||||
"-ar", "16000", "-ac", "1", audio_path, "-y",
|
||||
])
|
||||
if p.returncode != 0:
|
||||
raise Exception(stderr.decode())
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"ffmpeg failed: {str(e)}")
|
||||
|
||||
try:
|
||||
dur = float(sf.info(audio_path).frames) / float(sf.info(audio_path).samplerate)
|
||||
except Exception:
|
||||
dur = 0.0
|
||||
|
||||
vocals_path = os.path.join(job_dir, "vocals.wav")
|
||||
no_vocals_path = os.path.join(job_dir, "no_vocals.wav")
|
||||
scene_cuts = []
|
||||
|
||||
async def run_demucs():
|
||||
nonlocal vocals_path, no_vocals_path
|
||||
try:
|
||||
demucs_cmd = [sys.executable, "-m", "demucs.separate",
|
||||
"--two-stems", "vocals", "-n", "htdemucs", "-d", get_best_device(),
|
||||
audio_path, "-o", job_dir]
|
||||
p, _, stderr = await _run_proc(demucs_cmd, timeout=1800.0)
|
||||
if p.returncode != 0:
|
||||
raise Exception(stderr.decode())
|
||||
|
||||
demucs_out = os.path.join(job_dir, "htdemucs", "audio")
|
||||
if os.path.exists(os.path.join(demucs_out, "vocals.wav")):
|
||||
shutil.move(os.path.join(demucs_out, "vocals.wav"), vocals_path)
|
||||
shutil.move(os.path.join(demucs_out, "no_vocals.wav"), no_vocals_path)
|
||||
shutil.rmtree(os.path.join(job_dir, "htdemucs"), ignore_errors=True)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Demucs failed, falling back to mixed audio. {e}")
|
||||
vocals_path = audio_path
|
||||
no_vocals_path = None
|
||||
|
||||
async def run_scene_detection():
|
||||
nonlocal scene_cuts
|
||||
try:
|
||||
p, _, stderr_scene = await _run_proc([
|
||||
ffmpeg, "-i", video_path, "-filter:v",
|
||||
"select='gt(scene,0.3)',showinfo", "-f", "null", "-",
|
||||
], timeout=600.0)
|
||||
import re
|
||||
matches = re.finditer(r"pts_time:([\d\.]+)", stderr_scene.decode())
|
||||
scene_cuts = [float(m.group(1)) for m in matches]
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Scene detection failed: {e}")
|
||||
|
||||
await asyncio.gather(run_demucs(), run_scene_detection())
|
||||
|
||||
_dub_jobs[job_id] = {
|
||||
"video_path": video_path,
|
||||
"audio_path": audio_path,
|
||||
"vocals_path": vocals_path,
|
||||
"no_vocals_path": no_vocals_path,
|
||||
"duration": dur, "filename": video.filename,
|
||||
"segments": None, "dubbed_tracks": {},
|
||||
"scene_cuts": scene_cuts,
|
||||
}
|
||||
_save_job(job_id, _dub_jobs[job_id], video.filename, dur)
|
||||
return {"job_id": job_id, "duration": round(dur, 2), "filename": video.filename}
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Dub upload cancelled for job %s; killing subprocesses and cleaning up", job_id)
|
||||
_kill_job_procs(job_id)
|
||||
try:
|
||||
shutil.rmtree(job_dir, ignore_errors=True)
|
||||
finally:
|
||||
_dub_jobs.pop(job_id, None)
|
||||
raise
|
||||
finally:
|
||||
with _active_procs_lock:
|
||||
_active_procs.pop(job_id, None)
|
||||
|
||||
|
||||
@router.post("/dub/transcribe/{job_id}")
|
||||
async def dub_transcribe(job_id: str):
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
_model = await get_model()
|
||||
if _model._asr_pipe is None:
|
||||
raise HTTPException(status_code=503, detail="ASR not loaded")
|
||||
|
||||
def _transcribe():
|
||||
import re
|
||||
import traceback
|
||||
|
||||
asr_audio_target = job.get("vocals_path")
|
||||
if not asr_audio_target or not os.path.exists(asr_audio_target):
|
||||
asr_audio_target = job.get("audio_path")
|
||||
|
||||
import torch
|
||||
|
||||
detected_lang = None
|
||||
|
||||
if torch.backends.mps.is_available():
|
||||
try:
|
||||
import mlx_whisper
|
||||
asr_model = os.environ.get("ASR_MODEL", "mlx-community/whisper-large-v3-mlx")
|
||||
logger.info(f"Transcribing via MLX CoreML Engine ({asr_model})...")
|
||||
result = mlx_whisper.transcribe(
|
||||
asr_audio_target,
|
||||
path_or_hf_repo=asr_model,
|
||||
word_timestamps=True
|
||||
)
|
||||
detected_lang = result.get("language")
|
||||
|
||||
if "segments" in result:
|
||||
result["chunks"] = []
|
||||
for seg in result["segments"]:
|
||||
result["chunks"].append({
|
||||
"text": seg["text"],
|
||||
"timestamp": (seg["start"], seg["end"])
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"MLX Whisper failed, falling back to PyTorch: {e}")
|
||||
audio_np, sr = sf.read(asr_audio_target, dtype="float32")
|
||||
if audio_np.ndim > 1: audio_np = audio_np.mean(axis=1)
|
||||
bs = 16 if torch.cuda.is_available() else 2
|
||||
result = _model._asr_pipe({"array": audio_np, "sampling_rate": sr}, return_timestamps=True, chunk_length_s=15, batch_size=bs)
|
||||
detected_lang = (result.get("language") if isinstance(result, dict) else None)
|
||||
else:
|
||||
audio_np, sr = sf.read(asr_audio_target, dtype="float32")
|
||||
if audio_np.ndim > 1: audio_np = audio_np.mean(axis=1)
|
||||
bs = 16 if torch.cuda.is_available() else 1
|
||||
result = _model._asr_pipe({"array": audio_np, "sampling_rate": sr}, return_timestamps=True, chunk_length_s=15, batch_size=bs)
|
||||
detected_lang = (result.get("language") if isinstance(result, dict) else None)
|
||||
|
||||
job["source_lang"] = (detected_lang or "en").split("_")[0][:2].lower()
|
||||
|
||||
scene_cuts = job.get("scene_cuts") or []
|
||||
segments = segment_transcript(result, duration=job.get("duration", 0.0), scene_cuts=scene_cuts)
|
||||
|
||||
diar_pipe = get_diarization_pipeline()
|
||||
if diar_pipe:
|
||||
try:
|
||||
diar_target = job.get("vocals_path") or job.get("audio_path")
|
||||
diarization = diar_pipe(diar_target)
|
||||
segments = assign_speakers_from_diarization(segments, diarization)
|
||||
except Exception as e:
|
||||
logger.error(f"Pyannote diarization failed during inference: {e}. Falling back to heuristic.")
|
||||
segments = assign_speakers_heuristic(segments)
|
||||
else:
|
||||
segments = assign_speakers_heuristic(segments)
|
||||
|
||||
job["full_transcript"] = " ".join(s["text"] for s in segments)
|
||||
|
||||
if torch.backends.mps.is_available():
|
||||
torch.mps.empty_cache()
|
||||
|
||||
return segments
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
segments_result = await loop.run_in_executor(_gpu_pool, _transcribe)
|
||||
except asyncio.CancelledError:
|
||||
job["aborted"] = True
|
||||
raise
|
||||
if job.get("aborted"):
|
||||
raise HTTPException(status_code=499, detail="Transcription aborted")
|
||||
job["segments"] = segments_result
|
||||
source_lang = job.get("source_lang")
|
||||
_save_job(job_id, job)
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"segments": segments_result,
|
||||
"full_transcript": job.get("full_transcript", ""),
|
||||
"source_lang": source_lang,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,513 @@
|
||||
import os
|
||||
import io
|
||||
import time
|
||||
import uuid
|
||||
import asyncio
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException, Query, Response
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
from core.db import get_db
|
||||
from core.config import DUB_DIR
|
||||
from core.tasks import task_manager
|
||||
from api.routers.dub_core import _get_job
|
||||
from services.ffmpeg_utils import find_ffmpeg, run_ffmpeg
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.api")
|
||||
|
||||
|
||||
def _unique_stamp() -> str:
|
||||
"""Return a short unique suffix like '20260415T142301-ab12cd34' for export files."""
|
||||
return f"{time.strftime('%Y%m%dT%H%M%S')}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _native_save(source: str, destination: str, display_name: str, media_type: str):
|
||||
"""Copy a generated export file to a user-chosen destination and return JSON."""
|
||||
import shutil
|
||||
dest = os.path.expanduser(destination)
|
||||
# Reject traversal against the user's home dir — Tauri save dialog returns abs path.
|
||||
if not os.path.isabs(dest):
|
||||
raise HTTPException(status_code=400, detail="save_path must be absolute")
|
||||
try:
|
||||
os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
|
||||
shutil.copy2(source, dest)
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=403, detail=f"Permission denied: {e}")
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Copy failed: {e}")
|
||||
if not os.path.exists(dest) or os.path.getsize(dest) == 0:
|
||||
raise HTTPException(status_code=500, detail="Copy produced empty file at destination")
|
||||
logger.info("Native save wrote %s (%d bytes)", dest, os.path.getsize(dest))
|
||||
return {
|
||||
"saved": True,
|
||||
"path": dest,
|
||||
"size": os.path.getsize(dest),
|
||||
"media_type": media_type,
|
||||
"display_name": display_name,
|
||||
}
|
||||
|
||||
@router.get("/tasks/stream/{task_id}")
|
||||
async def stream_task(task_id: str):
|
||||
"""Universal Server-Sent Event stream for background tasks."""
|
||||
if task_id not in task_manager.active_tasks:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
async def _reader():
|
||||
t = task_manager.active_tasks.get(task_id)
|
||||
if t is None:
|
||||
return
|
||||
q = asyncio.Queue()
|
||||
await task_manager.add_listener(task_id, q)
|
||||
|
||||
try:
|
||||
for evt in t["history"]:
|
||||
yield evt
|
||||
|
||||
if t["status"] in ("done", "failed"):
|
||||
return
|
||||
|
||||
while True:
|
||||
evt = await q.get()
|
||||
if evt is None:
|
||||
break
|
||||
yield evt
|
||||
finally:
|
||||
await task_manager.remove_listener(task_id, q)
|
||||
|
||||
return StreamingResponse(_reader(), media_type="text/event-stream")
|
||||
|
||||
@router.post("/tasks/cancel/{task_id}")
|
||||
async def cancel_task(task_id: str):
|
||||
"""Cancel a running background task (e.g. dub generation)."""
|
||||
ok = task_manager.cancel_task(task_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return {"cancelled": True, "task_id": task_id}
|
||||
|
||||
|
||||
@router.get("/dub/tracks/{job_id}")
|
||||
async def dub_list_tracks(job_id: str):
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return {"tracks": job.get("dubbed_tracks", {})}
|
||||
|
||||
|
||||
@router.get("/dub/download/{job_id}")
|
||||
@router.get("/dub/download/{job_id}/{filename}")
|
||||
async def dub_download(
|
||||
job_id: str,
|
||||
preserve_bg: bool = Query(True, description="Mix background noise into dubbed tracks"),
|
||||
default_track: str = Query("original"),
|
||||
include_tracks: str = Query("", description="Comma-separated list of tracks to include (e.g. 'original,de,es'). Empty = include all."),
|
||||
save_path: str = Query("", description="Absolute destination path. If set, mux output is copied there and JSON returned instead of FileResponse."),
|
||||
):
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
tracks = job.get("dubbed_tracks", {})
|
||||
if not tracks:
|
||||
raise HTTPException(status_code=400, detail="No dubbed tracks generated yet")
|
||||
|
||||
include_set = set(t.strip() for t in include_tracks.split(",") if t.strip()) if include_tracks else None
|
||||
include_original = include_set is None or "original" in include_set
|
||||
|
||||
if include_set:
|
||||
filtered_tracks = {k: v for k, v in tracks.items() if k in include_set}
|
||||
else:
|
||||
filtered_tracks = dict(tracks)
|
||||
|
||||
if not filtered_tracks and not include_original:
|
||||
raise HTTPException(status_code=400, detail="No tracks selected for export")
|
||||
|
||||
video_path = job["video_path"]
|
||||
stamp = _unique_stamp()
|
||||
exports_dir = os.path.join(DUB_DIR, job_id, "exports")
|
||||
os.makedirs(exports_dir, exist_ok=True)
|
||||
output_path = os.path.join(exports_dir, f"dubbed_video_{stamp}.mp4")
|
||||
ffmpeg = find_ffmpeg()
|
||||
|
||||
cmd = [ffmpeg, "-i", video_path]
|
||||
input_idx = 1
|
||||
|
||||
bg_audio = job.get("no_vocals_path") if preserve_bg else None
|
||||
bg_idx = None
|
||||
if bg_audio and os.path.exists(bg_audio) and filtered_tracks:
|
||||
cmd += ["-i", bg_audio]
|
||||
bg_idx = input_idx
|
||||
input_idx += 1
|
||||
|
||||
tracks_to_process = []
|
||||
for lang_code, track_info in filtered_tracks.items():
|
||||
cmd += ["-i", track_info["path"]]
|
||||
tracks_to_process.append({"lang_code": lang_code, "idx": input_idx, "info": track_info})
|
||||
input_idx += 1
|
||||
|
||||
cmd += ["-map", "0:v:0"]
|
||||
if include_original:
|
||||
cmd += ["-map", "0:a:0"]
|
||||
|
||||
if bg_idx is not None:
|
||||
filters = []
|
||||
for i, t in enumerate(tracks_to_process):
|
||||
out_label = f"[aout{i}]"
|
||||
filters.append(f"[{bg_idx}:a][{t['idx']}:a]amix=inputs=2:duration=longest:dropout_transition=2:weights=0.8 1.2{out_label}")
|
||||
t["out_label"] = out_label
|
||||
cmd += ["-filter_complex", ";".join(filters)]
|
||||
for t in tracks_to_process:
|
||||
cmd += ["-map", t["out_label"]]
|
||||
else:
|
||||
for t in tracks_to_process:
|
||||
cmd += ["-map", f"{t['idx']}:a:0"]
|
||||
|
||||
cmd += ["-c:v", "copy", "-c:a", "aac", "-b:a", "192k"]
|
||||
|
||||
audio_stream_idx = 0
|
||||
if include_original:
|
||||
cmd += [f"-metadata:s:a:{audio_stream_idx}", "language=und", f"-metadata:s:a:{audio_stream_idx}", "title=Original"]
|
||||
audio_stream_idx += 1
|
||||
|
||||
for t in tracks_to_process:
|
||||
cmd += [
|
||||
f"-metadata:s:a:{audio_stream_idx}", f"language={t['lang_code']}",
|
||||
f"-metadata:s:a:{audio_stream_idx}", f"title={t['info']['language']}"
|
||||
]
|
||||
t["stream_idx"] = audio_stream_idx
|
||||
audio_stream_idx += 1
|
||||
|
||||
total_audio = (1 if include_original else 0) + len(tracks_to_process)
|
||||
for i in range(total_audio):
|
||||
cmd += [f"-disposition:a:{i}", "0"]
|
||||
|
||||
if default_track == "original" and include_original:
|
||||
cmd += ["-disposition:a:0", "default"]
|
||||
else:
|
||||
target_idx = 0
|
||||
for t in tracks_to_process:
|
||||
if t['lang_code'] == default_track:
|
||||
target_idx = t["stream_idx"]
|
||||
break
|
||||
cmd += [f"-disposition:a:{target_idx}", "default"]
|
||||
|
||||
cmd += ["-shortest", output_path, "-y"]
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
raise Exception(stderr.decode())
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"ffmpeg mux failed: {str(e)}")
|
||||
|
||||
if not os.path.exists(output_path) or os.path.getsize(output_path) == 0:
|
||||
raise HTTPException(status_code=500, detail="ffmpeg mux produced no output file")
|
||||
logger.info("Dub mux wrote %s (%d bytes)", output_path, os.path.getsize(output_path))
|
||||
|
||||
base_name = os.path.splitext(job.get('filename', 'output'))[0]
|
||||
safe_name = ''.join(c for c in base_name if c.isalnum() or c in '-_ ').strip() or 'output'
|
||||
dl_name = f"dubbed_{safe_name}_{stamp}.mp4"
|
||||
|
||||
if save_path:
|
||||
return _native_save(output_path, save_path, dl_name, media_type="video/mp4")
|
||||
|
||||
return FileResponse(
|
||||
output_path, media_type="video/mp4",
|
||||
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dub/media/{job_id}")
|
||||
async def dub_get_media(job_id: str):
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
if not os.path.exists(job["video_path"]):
|
||||
raise HTTPException(status_code=404, detail="Media file not found")
|
||||
return FileResponse(job["video_path"])
|
||||
|
||||
@router.get("/dub/audio/{job_id}")
|
||||
async def dub_get_audio(job_id: str):
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
audio = job.get("audio_path")
|
||||
if not audio or not os.path.exists(audio):
|
||||
raise HTTPException(status_code=404, detail="Audio file not found")
|
||||
return FileResponse(audio, media_type="audio/wav")
|
||||
|
||||
@router.get("/dub/preview/{job_id}/{segment_index}")
|
||||
async def dub_preview_segment(job_id: str, segment_index: int):
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
seg_path = os.path.join(DUB_DIR, job_id, f"seg_{segment_index}.wav")
|
||||
if not os.path.exists(seg_path):
|
||||
raise HTTPException(status_code=404, detail="Segment not generated yet")
|
||||
return FileResponse(seg_path, media_type="audio/wav")
|
||||
|
||||
|
||||
@router.get("/dub/download-audio/{job_id}")
|
||||
@router.get("/dub/download-audio/{job_id}/{filename}")
|
||||
async def dub_download_audio(job_id: str, lang: str = Query(None), preserve_bg: bool = Query(True), save_path: str = Query("")):
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
tracks = job.get("dubbed_tracks", {})
|
||||
if lang and lang in tracks:
|
||||
wav_path = tracks[lang]["path"]
|
||||
elif tracks:
|
||||
wav_path = list(tracks.values())[0]["path"]
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="No dubbed audio track generated yet")
|
||||
|
||||
if not os.path.exists(wav_path):
|
||||
raise HTTPException(status_code=404, detail="Audio file not found")
|
||||
|
||||
lang_label = lang or list(tracks.keys())[0]
|
||||
stamp = _unique_stamp()
|
||||
exports_dir = os.path.join(DUB_DIR, job_id, "exports")
|
||||
os.makedirs(exports_dir, exist_ok=True)
|
||||
|
||||
bg_audio = job.get("no_vocals_path") if preserve_bg else None
|
||||
if bg_audio and os.path.exists(bg_audio):
|
||||
ffmpeg = find_ffmpeg()
|
||||
final_audio_path = os.path.join(exports_dir, f"mixed_dub_{lang_label}_{stamp}.wav")
|
||||
cmd = [
|
||||
ffmpeg, "-i", bg_audio, "-i", wav_path,
|
||||
"-filter_complex", "[0:a][1:a]amix=inputs=2:duration=longest:dropout_transition=2:weights=0.8 1.2[aout]",
|
||||
"-map", "[aout]", "-c:a", "pcm_s16le", "-y", final_audio_path
|
||||
]
|
||||
try:
|
||||
rc, _, stderr = await run_ffmpeg(cmd, timeout=900.0)
|
||||
if rc != 0:
|
||||
raise Exception(stderr.decode(errors="replace") if stderr else "ffmpeg mix non-zero")
|
||||
if not os.path.exists(final_audio_path) or os.path.getsize(final_audio_path) == 0:
|
||||
raise Exception("ffmpeg mix produced no output file")
|
||||
wav_path = final_audio_path
|
||||
logger.info("Dub audio mix wrote %s (%d bytes)", final_audio_path, os.path.getsize(final_audio_path))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to mix audio: {str(e)}")
|
||||
|
||||
base_name = os.path.splitext(job.get('filename', 'audio'))[0]
|
||||
safe_name = ''.join(c for c in base_name if c.isalnum() or c in '-_ ').strip() or 'audio'
|
||||
dl_name = f"dubbed_audio_{lang_label}_{safe_name}_{stamp}.wav"
|
||||
if save_path:
|
||||
return _native_save(wav_path, save_path, dl_name, media_type="audio/wav")
|
||||
return FileResponse(
|
||||
wav_path, media_type="audio/wav",
|
||||
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
|
||||
)
|
||||
|
||||
|
||||
def _format_srt_time(seconds):
|
||||
h = int(seconds // 3600)
|
||||
m = int((seconds % 3600) // 60)
|
||||
s = int(seconds % 60)
|
||||
ms = int((seconds % 1) * 1000)
|
||||
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
||||
|
||||
@router.get("/dub/srt/{job_id}")
|
||||
@router.get("/dub/srt/{job_id}/{filename}")
|
||||
async def dub_export_srt(job_id: str):
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
segments = job.get("segments", [])
|
||||
if not segments:
|
||||
raise HTTPException(status_code=400, detail="No transcript segments available")
|
||||
|
||||
srt_lines = []
|
||||
for i, seg in enumerate(segments):
|
||||
start_ts = _format_srt_time(seg["start"])
|
||||
end_ts = _format_srt_time(seg["end"])
|
||||
srt_lines.append(f"{i + 1}")
|
||||
srt_lines.append(f"{start_ts} --> {end_ts}")
|
||||
srt_lines.append(seg["text"])
|
||||
srt_lines.append("")
|
||||
|
||||
srt_content = "\n".join(srt_lines)
|
||||
base_name = os.path.splitext(job.get('filename', 'video'))[0]
|
||||
return Response(
|
||||
content=srt_content,
|
||||
media_type="text/plain",
|
||||
headers={"Content-Disposition": f'attachment; filename="subtitles_{base_name}.srt"'},
|
||||
)
|
||||
|
||||
def _format_vtt_time(seconds):
|
||||
h = int(seconds // 3600)
|
||||
m = int((seconds % 3600) // 60)
|
||||
s = int(seconds % 60)
|
||||
ms = int((seconds % 1) * 1000)
|
||||
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
|
||||
|
||||
@router.get("/dub/vtt/{job_id}")
|
||||
@router.get("/dub/vtt/{job_id}/{filename}")
|
||||
async def dub_export_vtt(job_id: str):
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
segments = job.get("segments", [])
|
||||
if not segments:
|
||||
raise HTTPException(status_code=400, detail="No transcript segments available")
|
||||
|
||||
vtt_lines = ["WEBVTT", ""]
|
||||
for i, seg in enumerate(segments):
|
||||
start_ts = _format_vtt_time(seg["start"])
|
||||
end_ts = _format_vtt_time(seg["end"])
|
||||
vtt_lines.append(str(i + 1))
|
||||
vtt_lines.append(f"{start_ts} --> {end_ts}")
|
||||
vtt_lines.append(seg["text"])
|
||||
vtt_lines.append("")
|
||||
|
||||
vtt_content = "\n".join(vtt_lines)
|
||||
base_name = os.path.splitext(job.get('filename', 'video'))[0]
|
||||
return Response(
|
||||
content=vtt_content,
|
||||
media_type="text/vtt",
|
||||
headers={"Content-Disposition": f'attachment; filename="subtitles_{base_name}.vtt"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dub/export-segments/{job_id}")
|
||||
async def dub_export_segments_zip(job_id: str):
|
||||
import zipfile
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
segments = job.get("segments", [])
|
||||
if not segments:
|
||||
raise HTTPException(status_code=400, detail="No segments available")
|
||||
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for i, seg in enumerate(segments):
|
||||
seg_path = os.path.join(DUB_DIR, job_id, f"seg_{i}.wav")
|
||||
if os.path.exists(seg_path):
|
||||
speaker = seg.get("speaker_id", "Speaker1").replace(" ", "")
|
||||
start_str = f"{seg['start']:.2f}"
|
||||
end_str = f"{seg['end']:.2f}"
|
||||
arc_name = f"{i+1:03d}_{start_str}-{end_str}_{speaker}.wav"
|
||||
zf.write(seg_path, arc_name)
|
||||
|
||||
zip_buffer.seek(0)
|
||||
base_name = os.path.splitext(job.get('filename', 'video'))[0]
|
||||
safe_name = ''.join(c for c in base_name if c.isalnum() or c in '-_ ').strip() or 'segments'
|
||||
return Response(
|
||||
content=zip_buffer.read(),
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="segments_{safe_name}.zip"'},
|
||||
)
|
||||
|
||||
@router.get("/dub/download-mp3/{job_id}")
|
||||
@router.get("/dub/download-mp3/{job_id}/{filename}")
|
||||
async def dub_download_mp3(job_id: str, lang: str = Query(None), preserve_bg: bool = Query(True), save_path: str = Query("")):
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
tracks = job.get("dubbed_tracks", {})
|
||||
if lang and lang in tracks:
|
||||
wav_path = tracks[lang]["path"]
|
||||
elif tracks:
|
||||
wav_path = list(tracks.values())[0]["path"]
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="No dubbed audio track generated yet")
|
||||
|
||||
if not os.path.exists(wav_path):
|
||||
raise HTTPException(status_code=404, detail="Audio file not found")
|
||||
|
||||
lang_label = lang or list(tracks.keys())[0]
|
||||
ffmpeg = find_ffmpeg()
|
||||
stamp = _unique_stamp()
|
||||
exports_dir = os.path.join(DUB_DIR, job_id, "exports")
|
||||
os.makedirs(exports_dir, exist_ok=True)
|
||||
|
||||
source_path = wav_path
|
||||
bg_audio = job.get("no_vocals_path") if preserve_bg else None
|
||||
if bg_audio and os.path.exists(bg_audio):
|
||||
mixed_path = os.path.join(exports_dir, f"mixed_mp3_{lang_label}_{stamp}.wav")
|
||||
cmd_mix = [
|
||||
ffmpeg, "-i", bg_audio, "-i", wav_path,
|
||||
"-filter_complex", "[0:a][1:a]amix=inputs=2:duration=longest:dropout_transition=2:weights=0.8 1.2[aout]",
|
||||
"-map", "[aout]", "-c:a", "pcm_s16le", "-y", mixed_path
|
||||
]
|
||||
try:
|
||||
rc, _, _ = await run_ffmpeg(cmd_mix, timeout=900.0)
|
||||
if rc == 0 and os.path.exists(mixed_path) and os.path.getsize(mixed_path) > 0:
|
||||
source_path = mixed_path
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to mix audio for MP3: {e}")
|
||||
|
||||
mp3_path = os.path.join(exports_dir, f"dubbed_{lang_label}_{stamp}.mp3")
|
||||
cmd = [ffmpeg, "-i", source_path, "-codec:a", "libmp3lame", "-b:a", "192k", "-y", mp3_path]
|
||||
try:
|
||||
rc, _, stderr = await run_ffmpeg(cmd, timeout=600.0)
|
||||
if rc != 0:
|
||||
raise Exception(stderr.decode(errors="replace") if stderr else "MP3 encode non-zero")
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPException(status_code=504, detail="MP3 encoding timed out")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"MP3 encoding failed: {str(e)}")
|
||||
|
||||
if not os.path.exists(mp3_path) or os.path.getsize(mp3_path) == 0:
|
||||
raise HTTPException(status_code=500, detail="MP3 encoding produced no output file")
|
||||
logger.info("Dub MP3 encoded %s (%d bytes)", mp3_path, os.path.getsize(mp3_path))
|
||||
|
||||
base_name = os.path.splitext(job.get('filename', 'audio'))[0]
|
||||
safe_name = ''.join(c for c in base_name if c.isalnum() or c in '-_ ').strip() or 'audio'
|
||||
dl_name = f"dubbed_{lang_label}_{safe_name}_{stamp}.mp3"
|
||||
if save_path:
|
||||
return _native_save(mp3_path, save_path, dl_name, media_type="audio/mpeg")
|
||||
return FileResponse(
|
||||
mp3_path, media_type="audio/mpeg",
|
||||
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
|
||||
)
|
||||
|
||||
@router.get("/dub/export-stems/{job_id}")
|
||||
async def dub_export_stems(job_id: str, lang: str = Query(None)):
|
||||
import zipfile
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
tracks = job.get("dubbed_tracks", {})
|
||||
if not tracks:
|
||||
raise HTTPException(status_code=400, detail="No dubbed tracks generated yet")
|
||||
|
||||
if lang and lang in tracks:
|
||||
vocals_path = tracks[lang]["path"]
|
||||
lang_label = lang
|
||||
elif tracks:
|
||||
first_key = list(tracks.keys())[0]
|
||||
vocals_path = tracks[first_key]["path"]
|
||||
lang_label = first_key
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="No dubbed audio track")
|
||||
|
||||
bg_path = job.get("no_vocals_path")
|
||||
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
if os.path.exists(vocals_path):
|
||||
zf.write(vocals_path, f"vocals_dubbed_{lang_label}.wav")
|
||||
if bg_path and os.path.exists(bg_path):
|
||||
zf.write(bg_path, "background_original.wav")
|
||||
|
||||
zip_buffer.seek(0)
|
||||
base_name = os.path.splitext(job.get('filename', 'video'))[0]
|
||||
safe_name = ''.join(c for c in base_name if c.isalnum() or c in '-_ ').strip() or 'stems'
|
||||
return Response(
|
||||
content=zip_buffer.read(),
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="stems_{safe_name}.zip"'},
|
||||
)
|
||||
@@ -0,0 +1,182 @@
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import asyncio
|
||||
import torch
|
||||
import torchaudio
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from core.db import get_db
|
||||
from core.config import DUB_DIR, VOICES_DIR
|
||||
from core.tasks import task_manager
|
||||
from schemas.requests import DubRequest
|
||||
from services.model_manager import get_model, _gpu_pool
|
||||
from services.audio_dsp import apply_mastering, normalize_audio
|
||||
from api.routers.dub_core import _get_job, _save_job
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/dub/generate/{job_id}")
|
||||
async def dub_generate(job_id: str, req: DubRequest):
|
||||
"""Adds a dub generation job to the async batch task pool."""
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
_model = await get_model()
|
||||
|
||||
async def _stream(task_id):
|
||||
total = len(req.segments)
|
||||
all_segment_wavs = []
|
||||
sync_scores = []
|
||||
|
||||
for i, seg in enumerate(req.segments):
|
||||
# Check abort flag before each segment
|
||||
if task_manager.is_cancelled(task_id):
|
||||
yield f"data: {json.dumps({'type': 'cancelled', 'segments_processed': i})}\n\n"
|
||||
return
|
||||
|
||||
yield f"data: {json.dumps({'type': 'progress', 'current': i, 'total': total, 'text': seg.text[:50]})}\n\n"
|
||||
|
||||
seg_duration = seg.end - seg.start
|
||||
if seg_duration <= 0.05 or not seg.text.strip():
|
||||
sr = _model.sampling_rate
|
||||
silence = torch.zeros(1, int(seg_duration * sr))
|
||||
all_segment_wavs.append((seg.start, seg.end, silence, sr))
|
||||
sync_scores.append(1.0)
|
||||
continue
|
||||
|
||||
def _gen(text, lang, instruct_str, dur_s, nstep, cfg, spd, profile_id=None):
|
||||
ref_audio = None
|
||||
ref_text = None
|
||||
used_seed = None
|
||||
|
||||
if profile_id:
|
||||
conn = get_db()
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if row:
|
||||
if row["is_locked"] and row["locked_audio_path"]:
|
||||
ref_audio = os.path.join(VOICES_DIR, row["locked_audio_path"])
|
||||
ref_text = row["ref_text"]
|
||||
used_seed = row["seed"]
|
||||
elif row["instruct"] and not row["is_locked"]:
|
||||
used_seed = row["seed"]
|
||||
else:
|
||||
ref_audio = os.path.join(VOICES_DIR, row["ref_audio_path"])
|
||||
ref_text = row["ref_text"]
|
||||
used_seed = row["seed"]
|
||||
|
||||
if not instruct_str:
|
||||
instruct_str = row["instruct"]
|
||||
|
||||
if used_seed is not None:
|
||||
torch.manual_seed(used_seed)
|
||||
|
||||
try:
|
||||
audios = _model.generate(
|
||||
text=text, language=lang if lang != "Auto" else None,
|
||||
ref_audio=ref_audio, ref_text=ref_text,
|
||||
instruct=instruct_str if instruct_str else None,
|
||||
duration=dur_s, num_step=nstep, guidance_scale=cfg,
|
||||
speed=spd, denoise=True, postprocess_output=True,
|
||||
)
|
||||
audio_out = audios[0]
|
||||
mastered_audio = apply_mastering(audio_out, sample_rate=_model.sampling_rate if hasattr(_model, 'sampling_rate') else 24000)
|
||||
return normalize_audio(mastered_audio, target_dBFS=-2.0)
|
||||
except Exception as e:
|
||||
import gc
|
||||
gc.collect()
|
||||
if torch.backends.mps.is_available():
|
||||
torch.mps.empty_cache()
|
||||
elif torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
raise RuntimeError(f"Engine VRAM crash on segment: {str(e)}")
|
||||
|
||||
seg_instruct = seg.instruct or req.instruct
|
||||
seg_profile = seg.profile_id or None
|
||||
seg_speed = seg.speed if hasattr(seg, 'speed') and seg.speed is not None else req.speed
|
||||
seg_lang = seg.target_lang if getattr(seg, 'target_lang', None) else req.language
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
audio_tensor = await loop.run_in_executor(
|
||||
_gpu_pool, _gen,
|
||||
seg.text, seg_lang, seg_instruct, seg_duration,
|
||||
req.num_step, req.guidance_scale, seg_speed, seg_profile,
|
||||
)
|
||||
|
||||
# Check abort immediately after GPU work completes
|
||||
if task_manager.is_cancelled(task_id):
|
||||
yield f"data: {json.dumps({'type': 'cancelled', 'segments_processed': i + 1})}\n\n"
|
||||
return
|
||||
|
||||
target_samples = int(seg_duration * _model.sampling_rate)
|
||||
current_samples = audio_tensor.shape[-1]
|
||||
|
||||
if target_samples > current_samples:
|
||||
pad_amount = target_samples - current_samples
|
||||
audio_tensor = torch.nn.functional.pad(audio_tensor, (0, pad_amount))
|
||||
elif current_samples > target_samples:
|
||||
audio_tensor = audio_tensor[..., :target_samples]
|
||||
|
||||
generated_dur = audio_tensor.shape[-1] / _model.sampling_rate
|
||||
sync_ratio = round(generated_dur / max(seg_duration, 0.01), 3)
|
||||
|
||||
sync_scores.append(sync_ratio)
|
||||
|
||||
seg_wav_path = os.path.join(DUB_DIR, job_id, f"seg_{i}.wav")
|
||||
torchaudio.save(seg_wav_path, audio_tensor, _model.sampling_rate)
|
||||
all_segment_wavs.append((seg.start, seg.end, audio_tensor, _model.sampling_rate))
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'type': 'error', 'segment': i, 'error': str(e)})}\n\n"
|
||||
sr = _model.sampling_rate
|
||||
all_segment_wavs.append((seg.start, seg.end, torch.zeros(1, int(seg_duration * sr)), sr))
|
||||
sync_scores.append(1.0)
|
||||
|
||||
yield f"data: {json.dumps({'type': 'assembling'})}\n\n"
|
||||
|
||||
sr = _model.sampling_rate
|
||||
total_samples = int(job["duration"] * sr)
|
||||
full_audio = torch.zeros(1, total_samples)
|
||||
|
||||
for i, (start, end, wav, _) in enumerate(all_segment_wavs):
|
||||
s = int(start * sr)
|
||||
seg_ref = req.segments[i] if i < len(req.segments) else None
|
||||
seg_gain = getattr(seg_ref, "gain", None) if seg_ref is not None else None
|
||||
seg_gain = seg_gain if seg_gain is not None else 1.0
|
||||
seg_gain = max(0.0, min(2.0, seg_gain))
|
||||
adjusted = wav * seg_gain
|
||||
wl = adjusted.shape[-1]
|
||||
fade_ms = 15
|
||||
fade_samples = int((fade_ms / 1000.0) * sr)
|
||||
if wl > fade_samples * 2:
|
||||
ramp_up = torch.linspace(0, 1, fade_samples, device=adjusted.device)
|
||||
ramp_down = torch.linspace(1, 0, fade_samples, device=adjusted.device)
|
||||
adjusted[0, :fade_samples] *= ramp_up
|
||||
adjusted[0, -fade_samples:] *= ramp_down
|
||||
|
||||
e = min(s + wl, total_samples)
|
||||
full_audio[:, s:e] += adjusted[:, :e - s]
|
||||
|
||||
lang_code = req.language_code or "und"
|
||||
track_path = os.path.join(DUB_DIR, job_id, f"dubbed_{lang_code}.wav")
|
||||
torchaudio.save(track_path, full_audio, sr)
|
||||
job["dubbed_tracks"][lang_code] = {
|
||||
"path": track_path,
|
||||
"language": req.language,
|
||||
"language_code": lang_code,
|
||||
}
|
||||
|
||||
job["language"] = req.language
|
||||
job["language_code"] = lang_code
|
||||
_save_job(job_id, job)
|
||||
|
||||
yield f"data: {json.dumps({'type': 'done', 'segments_processed': total, 'language_code': lang_code, 'tracks': list(job['dubbed_tracks'].keys()), 'sync_scores': sync_scores})}\n\n"
|
||||
|
||||
task_id = f"dub_{job_id}_{int(time.time())}"
|
||||
await task_manager.add_task(task_id, "dub_generate", _stream, task_id)
|
||||
return {"task_id": task_id}
|
||||
@@ -0,0 +1,253 @@
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from schemas.requests import TranslateRequest
|
||||
from services.model_manager import _cpu_pool, _gpu_pool
|
||||
from api.routers.dub_core import _get_job
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.api")
|
||||
|
||||
TRANSLATE_CODES = {
|
||||
"en": "en", "es": "es", "fr": "fr", "de": "de", "it": "it", "pt": "pt",
|
||||
"ru": "ru", "ja": "ja", "ko": "ko", "zh": "zh-CN", "ar": "ar", "hi": "hi",
|
||||
"tr": "tr", "pl": "pl", "nl": "nl", "sv": "sv", "th": "th", "vi": "vi",
|
||||
"id": "id", "uk": "uk",
|
||||
}
|
||||
|
||||
FLORES_CODES = {
|
||||
"en": "eng_Latn", "es": "spa_Latn", "fr": "fra_Latn", "de": "deu_Latn",
|
||||
"it": "ita_Latn", "pt": "por_Latn", "ru": "rus_Cyrl", "ja": "jpn_Jpan",
|
||||
"ko": "kor_Hang", "zh": "zho_Hans", "zh-CN": "zho_Hans", "ar": "arb_Arab",
|
||||
"hi": "hin_Deva", "tr": "tur_Latn", "pl": "pol_Latn", "nl": "nld_Latn",
|
||||
"sv": "swe_Latn", "th": "tha_Thai", "vi": "vie_Latn", "id": "ind_Latn",
|
||||
"uk": "ukr_Cyrl",
|
||||
}
|
||||
|
||||
_nllb_model = None
|
||||
_nllb_tokenizer = None
|
||||
_nllb_device = None
|
||||
|
||||
|
||||
def _resolve_source_lang(req: TranslateRequest) -> str:
|
||||
"""Pick source language: explicit request > job.source_lang > 'en' fallback."""
|
||||
if getattr(req, "source_lang", None):
|
||||
return req.source_lang
|
||||
if getattr(req, "job_id", None):
|
||||
job = _get_job(req.job_id)
|
||||
if job and job.get("source_lang"):
|
||||
return job["source_lang"]
|
||||
return "en"
|
||||
|
||||
|
||||
def _unload_nllb():
|
||||
"""Release NLLB VRAM so TTS model can reload."""
|
||||
global _nllb_model, _nllb_tokenizer
|
||||
import gc
|
||||
_nllb_model = None
|
||||
_nllb_tokenizer = None
|
||||
gc.collect()
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
torch.mps.empty_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.post("/dub/translate")
|
||||
async def dub_translate(req: TranslateRequest):
|
||||
try:
|
||||
provider = (req.provider if req.provider else os.environ.get("TRANSLATE_PROVIDER", "google")).lower()
|
||||
lang_code = TRANSLATE_CODES.get(req.target_lang, req.target_lang)
|
||||
api_key = os.environ.get("TRANSLATE_API_KEY", "")
|
||||
loop = asyncio.get_event_loop()
|
||||
src_lang = _resolve_source_lang(req)
|
||||
|
||||
# Offline NLLB Transformer Translation
|
||||
if provider == "nllb":
|
||||
flores_tgt = FLORES_CODES.get(req.target_lang, "eng_Latn")
|
||||
flores_src = FLORES_CODES.get(src_lang, "eng_Latn")
|
||||
|
||||
def _translate_nllb():
|
||||
global _nllb_model, _nllb_tokenizer, _nllb_device
|
||||
import torch
|
||||
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
|
||||
|
||||
if torch.cuda.is_available():
|
||||
target_device = "cuda"
|
||||
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
target_device = "mps"
|
||||
else:
|
||||
target_device = "cpu"
|
||||
|
||||
try:
|
||||
if _nllb_tokenizer is None:
|
||||
_nllb_tokenizer = AutoTokenizer.from_pretrained("facebook/nllb-200-distilled-600M")
|
||||
if _nllb_model is None:
|
||||
_nllb_model = AutoModelForSeq2SeqLM.from_pretrained("facebook/nllb-200-distilled-600M")
|
||||
if target_device != "cpu":
|
||||
try:
|
||||
_nllb_model = _nllb_model.to(target_device)
|
||||
_nllb_device = target_device
|
||||
except Exception as e:
|
||||
logger.warning("NLLB %s placement failed, falling back to CPU: %s", target_device, e)
|
||||
_nllb_device = "cpu"
|
||||
else:
|
||||
_nllb_device = "cpu"
|
||||
except Exception as e:
|
||||
logger.exception("NLLB model load failed")
|
||||
return [{"id": seg.id, "text": seg.text, "error": f"Model load error: {str(e)}"} for seg in req.segments]
|
||||
|
||||
results = []
|
||||
for seg in req.segments:
|
||||
try:
|
||||
if not seg.text or not seg.text.strip():
|
||||
results.append({"id": seg.id, "text": seg.text})
|
||||
continue
|
||||
|
||||
tgt = FLORES_CODES.get(seg.target_lang, flores_tgt) if seg.target_lang else flores_tgt
|
||||
|
||||
_nllb_tokenizer.src_lang = flores_src
|
||||
inputs = _nllb_tokenizer(seg.text, return_tensors="pt")
|
||||
if _nllb_device and _nllb_device != "cpu":
|
||||
inputs = {k: v.to(_nllb_device) for k, v in inputs.items()}
|
||||
|
||||
forced_bos_token_id = _nllb_tokenizer.convert_tokens_to_ids(tgt)
|
||||
try:
|
||||
translated_tokens = _nllb_model.generate(
|
||||
**inputs, forced_bos_token_id=forced_bos_token_id, max_length=400
|
||||
)
|
||||
except (RuntimeError, NotImplementedError) as e:
|
||||
if _nllb_device == "mps":
|
||||
logger.warning("MPS generate failed, retrying on CPU: %s", e)
|
||||
_nllb_model.to("cpu")
|
||||
_nllb_device = "cpu"
|
||||
inputs = {k: v.to("cpu") for k, v in inputs.items()}
|
||||
translated_tokens = _nllb_model.generate(
|
||||
**inputs, forced_bos_token_id=forced_bos_token_id, max_length=400
|
||||
)
|
||||
else:
|
||||
raise
|
||||
translated_text = _nllb_tokenizer.batch_decode(translated_tokens, skip_special_tokens=True)[0]
|
||||
results.append({"id": seg.id, "text": translated_text})
|
||||
except Exception as e:
|
||||
results.append({"id": seg.id, "text": seg.text, "error": str(e)})
|
||||
return results
|
||||
|
||||
translated = await loop.run_in_executor(_gpu_pool, _translate_nllb)
|
||||
if os.environ.get("OMNIVOICE_UNLOAD_NLLB", "1") == "1":
|
||||
_unload_nllb()
|
||||
return {"translated": translated, "target_lang": req.target_lang, "source_lang": src_lang}
|
||||
|
||||
# OpenAI / Ollama Local LLM Translation
|
||||
if provider == "openai":
|
||||
base_url = os.environ.get("TRANSLATE_BASE_URL")
|
||||
model_name = os.environ.get("TRANSLATE_MODEL", "gpt-3.5-turbo")
|
||||
from openai import OpenAI
|
||||
client = OpenAI(base_url=base_url, api_key=api_key or "local")
|
||||
|
||||
def _translate_llm(seg):
|
||||
try:
|
||||
if not seg.text or not seg.text.strip():
|
||||
return {"id": seg.id, "text": seg.text}
|
||||
tgt = seg.target_lang if seg.target_lang else req.target_lang
|
||||
res = client.chat.completions.create(
|
||||
model=model_name,
|
||||
messages=[
|
||||
{"role": "system", "content": f"You are a professional dubbing translator. Translate the user's text from {src_lang} into {tgt}. Reply ONLY with the translated text, do not add any quotes, notes, or explanations."},
|
||||
{"role": "user", "content": seg.text}
|
||||
]
|
||||
)
|
||||
out_text = res.choices[0].message.content.strip()
|
||||
return {"id": seg.id, "text": out_text}
|
||||
except Exception as e:
|
||||
return {"id": seg.id, "text": seg.text, "error": str(e)}
|
||||
|
||||
tasks = [loop.run_in_executor(_cpu_pool, _translate_llm, seg) for seg in req.segments]
|
||||
translated = await asyncio.gather(*tasks)
|
||||
translated.sort(key=lambda x: str(x["id"]))
|
||||
return {"translated": translated, "target_lang": req.target_lang, "source_lang": src_lang}
|
||||
|
||||
# Offline Argos Translate
|
||||
if provider == "argos" or provider == "libretranslate":
|
||||
def _translate_argos():
|
||||
cache_dir = os.environ.get("OMNIVOICE_CACHE_DIR")
|
||||
if cache_dir:
|
||||
argos_cache = os.path.join(cache_dir, "argos-translate")
|
||||
os.makedirs(argos_cache, exist_ok=True)
|
||||
os.environ.setdefault("ARGOS_PACKAGES_DIR", argos_cache)
|
||||
os.environ.setdefault("ARGOS_DATA_DIR", argos_cache)
|
||||
import argostranslate.package
|
||||
import argostranslate.translate
|
||||
|
||||
from_code = src_lang
|
||||
available_packages = argostranslate.package.get_installed_packages()
|
||||
|
||||
results = []
|
||||
for seg in req.segments:
|
||||
try:
|
||||
if not seg.text or not seg.text.strip():
|
||||
results.append({"id": seg.id, "text": seg.text})
|
||||
continue
|
||||
to_code = seg.target_lang if seg.target_lang else req.target_lang
|
||||
installed_pkg = next(filter(lambda x: x.from_code == from_code and x.to_code == to_code, available_packages), None)
|
||||
|
||||
if installed_pkg is None:
|
||||
argostranslate.package.update_package_index()
|
||||
all_packages = argostranslate.package.get_available_packages()
|
||||
package_to_install = next(filter(lambda x: x.from_code == from_code and x.to_code == to_code, all_packages), None)
|
||||
if package_to_install:
|
||||
argostranslate.package.install_from_path(package_to_install.download())
|
||||
available_packages = argostranslate.package.get_installed_packages()
|
||||
else:
|
||||
raise Exception(f"No Argos package available for {from_code} -> {to_code}")
|
||||
|
||||
translated_text = argostranslate.translate.translate(seg.text, from_code, to_code)
|
||||
results.append({"id": seg.id, "text": translated_text})
|
||||
except Exception as e:
|
||||
results.append({"id": seg.id, "text": seg.text, "error": str(e)})
|
||||
return results
|
||||
|
||||
translated = await loop.run_in_executor(_cpu_pool, _translate_argos)
|
||||
return {"translated": translated, "target_lang": req.target_lang, "source_lang": src_lang}
|
||||
|
||||
# Legacy / API Deep_Translator logic
|
||||
src_arg = TRANSLATE_CODES.get(src_lang, src_lang) or "auto"
|
||||
|
||||
def _translate_single(seg):
|
||||
try:
|
||||
if not seg.text or not seg.text.strip():
|
||||
return {"id": seg.id, "text": seg.text}
|
||||
seg_lc = TRANSLATE_CODES.get(seg.target_lang, seg.target_lang) if seg.target_lang else lang_code
|
||||
if provider == "deepl":
|
||||
from deep_translator import DeepL
|
||||
translator = DeepL(api_key=api_key, source=src_arg, target=seg_lc)
|
||||
elif provider == "mymemory":
|
||||
from deep_translator import MyMemoryTranslator
|
||||
translator = MyMemoryTranslator(source=src_arg, target=seg_lc)
|
||||
elif provider == "microsoft":
|
||||
from deep_translator import MicrosoftTranslator
|
||||
translator = MicrosoftTranslator(api_key=api_key, source=src_arg, target=seg_lc)
|
||||
else:
|
||||
from deep_translator import GoogleTranslator
|
||||
translator = GoogleTranslator(source=src_arg, target=seg_lc)
|
||||
|
||||
translated = translator.translate(seg.text)
|
||||
return {"id": seg.id, "text": translated or seg.text}
|
||||
except Exception as e:
|
||||
return {"id": seg.id, "text": seg.text, "error": str(e)}
|
||||
|
||||
tasks = [loop.run_in_executor(_cpu_pool, _translate_single, seg) for seg in req.segments]
|
||||
translated = await asyncio.gather(*tasks)
|
||||
translated.sort(key=lambda x: str(x["id"]))
|
||||
|
||||
return {"translated": translated, "target_lang": req.target_lang, "source_lang": src_lang}
|
||||
except Exception as e:
|
||||
import traceback; traceback.print_exc()
|
||||
return JSONResponse(status_code=500, content={"error": str(e)})
|
||||
@@ -0,0 +1,115 @@
|
||||
import os
|
||||
import uuid
|
||||
import time
|
||||
import shutil
|
||||
import subprocess
|
||||
import platform
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from core.db import get_db
|
||||
from core.config import OUTPUTS_DIR
|
||||
from schemas.requests import ExportRequest, ExportRecordRequest, RevealRequest
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _safe_destination(raw: str) -> str:
|
||||
"""Resolve + validate an export destination. Rejects relative/empty paths."""
|
||||
if not raw or not raw.strip():
|
||||
raise HTTPException(status_code=400, detail="destination_path required")
|
||||
dest = os.path.realpath(os.path.expanduser(raw))
|
||||
if not os.path.isabs(dest):
|
||||
raise HTTPException(status_code=400, detail="destination_path must be absolute")
|
||||
parent = os.path.dirname(dest)
|
||||
if not parent or not os.path.isdir(parent):
|
||||
raise HTTPException(status_code=400, detail="destination directory does not exist")
|
||||
return dest
|
||||
|
||||
|
||||
def _safe_source(filename: str) -> str:
|
||||
"""Resolve a source filename against OUTPUTS_DIR / dub outputs, blocking traversal."""
|
||||
base = os.path.basename(filename or "")
|
||||
if not base or base != filename:
|
||||
raise HTTPException(status_code=400, detail="invalid source_filename")
|
||||
for root in (OUTPUTS_DIR, os.path.join("dub", "outputs")):
|
||||
candidate = os.path.realpath(os.path.join(root, base))
|
||||
root_real = os.path.realpath(root)
|
||||
if candidate.startswith(root_real + os.sep) and os.path.exists(candidate):
|
||||
return candidate
|
||||
raise HTTPException(status_code=404, detail="Source file not found")
|
||||
|
||||
|
||||
@router.post("/export")
|
||||
def export_file(req: ExportRequest):
|
||||
src = _safe_source(req.source_filename)
|
||||
dest = _safe_destination(req.destination_path)
|
||||
try:
|
||||
shutil.copy2(src, dest)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
export_id = str(uuid.uuid4())[:8]
|
||||
conn = get_db()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO export_history (id, filename, destination_path, mode, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(export_id, req.source_filename, dest, req.mode, time.time()),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return {"success": True, "id": export_id}
|
||||
|
||||
|
||||
@router.post("/export/record")
|
||||
def record_export(req: ExportRecordRequest):
|
||||
export_id = str(uuid.uuid4())[:8]
|
||||
conn = get_db()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO export_history (id, filename, destination_path, mode, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(export_id, req.filename, req.destination_path, req.mode, time.time()),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return {"success": True, "id": export_id}
|
||||
|
||||
|
||||
@router.get("/export/history")
|
||||
def get_export_history():
|
||||
conn = get_db()
|
||||
try:
|
||||
rows = conn.execute("SELECT * FROM export_history ORDER BY created_at DESC LIMIT 50").fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/export/reveal")
|
||||
def reveal_in_folder(req: RevealRequest):
|
||||
# Tauri/native dialog-provided path; subprocess uses list args (no shell interpolation).
|
||||
if not req.path or not req.path.strip():
|
||||
raise HTTPException(status_code=400, detail="path required")
|
||||
target = os.path.realpath(os.path.expanduser(req.path))
|
||||
if not os.path.exists(target):
|
||||
raise HTTPException(status_code=404, detail="path not found")
|
||||
|
||||
folder = target if os.path.isdir(target) else os.path.dirname(target)
|
||||
system = platform.system()
|
||||
try:
|
||||
if system == "Darwin":
|
||||
if os.path.isfile(target):
|
||||
subprocess.Popen(["open", "-R", target])
|
||||
else:
|
||||
subprocess.Popen(["open", folder])
|
||||
elif system == "Windows":
|
||||
if os.path.isfile(target):
|
||||
subprocess.Popen(["explorer", "/select,", target.replace("/", "\\")])
|
||||
else:
|
||||
subprocess.Popen(["explorer", folder.replace("/", "\\")])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", folder])
|
||||
return {"success": True}
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,224 @@
|
||||
import os
|
||||
import io
|
||||
import uuid
|
||||
import time
|
||||
import asyncio
|
||||
import tempfile
|
||||
import contextlib
|
||||
import torch
|
||||
import torchaudio
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, File, Form, UploadFile, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from core.db import get_db, db_conn
|
||||
from core.config import OUTPUTS_DIR, VOICES_DIR
|
||||
from services.model_manager import get_model, _gpu_pool
|
||||
from services.audio_dsp import apply_mastering, normalize_audio
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def _run_inference(
|
||||
model, text, language, ref_audio_path, ref_text, instruct, duration,
|
||||
num_step, guidance_scale, speed, t_shift, denoise,
|
||||
postprocess_output, layer_penalty_factor, position_temperature,
|
||||
class_temperature, used_seed,
|
||||
):
|
||||
try:
|
||||
if used_seed is not None:
|
||||
torch.manual_seed(used_seed)
|
||||
|
||||
kwargs = {}
|
||||
if t_shift is not None: kwargs["t_shift"] = t_shift
|
||||
if layer_penalty_factor is not None: kwargs["layer_penalty_factor"] = layer_penalty_factor
|
||||
if position_temperature is not None: kwargs["position_temperature"] = position_temperature
|
||||
if class_temperature is not None: kwargs["class_temperature"] = class_temperature
|
||||
|
||||
audios = model.generate(
|
||||
text=text, language=language, ref_audio=ref_audio_path,
|
||||
ref_text=ref_text, instruct=instruct, duration=duration,
|
||||
num_step=num_step, guidance_scale=guidance_scale, speed=speed,
|
||||
denoise=denoise, postprocess_output=postprocess_output,
|
||||
**kwargs
|
||||
)
|
||||
audio_out = audios[0]
|
||||
|
||||
mastered_audio = apply_mastering(audio_out, sample_rate=model.sampling_rate if hasattr(model, 'sampling_rate') else 24000)
|
||||
return normalize_audio(mastered_audio, target_dBFS=-2.0)
|
||||
|
||||
except Exception as e:
|
||||
import gc
|
||||
gc.collect()
|
||||
if torch.backends.mps.is_available():
|
||||
torch.mps.empty_cache()
|
||||
elif torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
raise RuntimeError(f"F5-TTS Engine crashed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/generate")
|
||||
async def generate_speech(
|
||||
text: str = Form(...),
|
||||
language: Optional[str] = Form(None),
|
||||
ref_audio: Optional[UploadFile] = File(None),
|
||||
ref_text: Optional[str] = Form(None),
|
||||
instruct: Optional[str] = Form(None),
|
||||
duration: Optional[float] = Form(None),
|
||||
num_step: int = Form(16),
|
||||
guidance_scale: float = Form(2.0),
|
||||
speed: float = Form(1.0),
|
||||
t_shift: Optional[float] = Form(None),
|
||||
denoise: bool = Form(True),
|
||||
postprocess_output: bool = Form(True),
|
||||
layer_penalty_factor: Optional[float] = Form(None),
|
||||
position_temperature: Optional[float] = Form(None),
|
||||
class_temperature: Optional[float] = Form(None),
|
||||
profile_id: Optional[str] = Form(None),
|
||||
seed: Optional[int] = Form(None),
|
||||
):
|
||||
_model = await get_model()
|
||||
|
||||
ref_audio_path = None
|
||||
cleanup_ref = False
|
||||
used_seed = seed
|
||||
|
||||
if profile_id:
|
||||
conn = get_db()
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if row:
|
||||
if row["is_locked"] and row["locked_audio_path"]:
|
||||
ref_audio_path = os.path.join(VOICES_DIR, row["locked_audio_path"])
|
||||
if not ref_text:
|
||||
ref_text = row["ref_text"]
|
||||
if not instruct:
|
||||
instruct = row["instruct"]
|
||||
if used_seed is None and row["seed"] is not None:
|
||||
used_seed = row["seed"]
|
||||
elif row["instruct"] and not row["is_locked"]:
|
||||
if not instruct:
|
||||
instruct = row["instruct"]
|
||||
if used_seed is None and row["seed"] is not None:
|
||||
used_seed = row["seed"]
|
||||
else:
|
||||
ref_audio_path = os.path.join(VOICES_DIR, row["ref_audio_path"]) if row["ref_audio_path"] else None
|
||||
if not ref_text and row["ref_text"]:
|
||||
ref_text = row["ref_text"]
|
||||
if not instruct and row["instruct"]:
|
||||
instruct = row["instruct"]
|
||||
if used_seed is None and row["seed"] is not None:
|
||||
used_seed = row["seed"]
|
||||
if language == "Auto":
|
||||
language = None
|
||||
elif ref_audio is not None:
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as f:
|
||||
f.write(await ref_audio.read())
|
||||
ref_audio_path = f.name
|
||||
cleanup_ref = True
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
audio_tensor = await loop.run_in_executor(
|
||||
_gpu_pool, _run_inference,
|
||||
_model, text, language, ref_audio_path, ref_text, instruct, duration,
|
||||
num_step, guidance_scale, speed, t_shift, denoise,
|
||||
postprocess_output, layer_penalty_factor, position_temperature,
|
||||
class_temperature, used_seed,
|
||||
)
|
||||
gen_time = round(time.time() - start_time, 2)
|
||||
|
||||
audio_id = str(uuid.uuid4())[:8]
|
||||
audio_filename = f"{audio_id}.wav"
|
||||
audio_path = os.path.join(OUTPUTS_DIR, audio_filename)
|
||||
torchaudio.save(audio_path, audio_tensor, _model.sampling_rate)
|
||||
|
||||
audio_dur = round(audio_tensor.shape[-1] / _model.sampling_rate, 2)
|
||||
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO generation_history (id, text, mode, language, instruct, profile_id, audio_path, duration_seconds, generation_time, seed, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(audio_id, text[:200], "clone" if ref_audio_path else "design",
|
||||
language or "Auto", instruct or "", profile_id or "",
|
||||
audio_filename, audio_dur, gen_time, used_seed, time.time())
|
||||
)
|
||||
|
||||
buffer = io.BytesIO()
|
||||
torchaudio.save(buffer, audio_tensor, _model.sampling_rate, format="wav")
|
||||
buffer.seek(0)
|
||||
wav_bytes = buffer.read()
|
||||
|
||||
async def _stream_wav():
|
||||
chunk_size = 16384
|
||||
for i in range(0, len(wav_bytes), chunk_size):
|
||||
yield wav_bytes[i:i + chunk_size]
|
||||
|
||||
return StreamingResponse(
|
||||
_stream_wav(),
|
||||
media_type="audio/wav",
|
||||
headers={
|
||||
"X-Audio-Id": audio_id,
|
||||
"X-Gen-Time": str(gen_time),
|
||||
"X-Audio-Path": audio_filename,
|
||||
"X-Seed": str(used_seed) if used_seed is not None else "",
|
||||
"X-Audio-Duration": str(audio_dur),
|
||||
"Content-Length": str(len(wav_bytes)),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Inference failed: {str(e)}")
|
||||
finally:
|
||||
if cleanup_ref and ref_audio_path:
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(ref_audio_path)
|
||||
|
||||
def _safe_output_path(name):
|
||||
if not name:
|
||||
return None
|
||||
base = os.path.basename(name)
|
||||
if base != name:
|
||||
return None
|
||||
outputs_real = os.path.realpath(OUTPUTS_DIR)
|
||||
candidate = os.path.realpath(os.path.join(OUTPUTS_DIR, base))
|
||||
if not candidate.startswith(outputs_real + os.sep):
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
def list_history():
|
||||
conn = get_db()
|
||||
try:
|
||||
rows = conn.execute("SELECT * FROM generation_history ORDER BY created_at DESC LIMIT 50").fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@router.delete("/history")
|
||||
def clear_history():
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute("SELECT audio_path FROM generation_history").fetchall()
|
||||
for r in rows:
|
||||
p = _safe_output_path(r["audio_path"])
|
||||
if p and os.path.exists(p):
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(p)
|
||||
conn.execute("DELETE FROM generation_history")
|
||||
return {"cleared": True}
|
||||
|
||||
@router.delete("/history/{history_id}")
|
||||
def delete_single_history(history_id: str):
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT audio_path FROM generation_history WHERE id=?", (history_id,)).fetchone()
|
||||
if row and row["audio_path"]:
|
||||
p = _safe_output_path(row["audio_path"])
|
||||
if p and os.path.exists(p):
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(p)
|
||||
conn.execute("DELETE FROM generation_history WHERE id=?", (history_id,))
|
||||
return {"deleted": True}
|
||||
@@ -0,0 +1,132 @@
|
||||
import os
|
||||
import uuid
|
||||
import time
|
||||
import shutil
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, File, Form, UploadFile, HTTPException
|
||||
from fastapi.responses import FileResponse, Response
|
||||
|
||||
from core.db import get_db
|
||||
from core.config import VOICES_DIR, OUTPUTS_DIR
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/profiles")
|
||||
def list_profiles():
|
||||
conn = get_db()
|
||||
rows = conn.execute("SELECT * FROM voice_profiles ORDER BY created_at DESC").fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@router.post("/profiles")
|
||||
async def create_profile(
|
||||
name: str = Form(...),
|
||||
ref_audio: UploadFile = File(...),
|
||||
ref_text: str = Form(""),
|
||||
instruct: str = Form(""),
|
||||
language: str = Form("Auto"),
|
||||
seed: Optional[int] = Form(None),
|
||||
):
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
ext = os.path.splitext(ref_audio.filename or ".wav")[1]
|
||||
audio_filename = f"{profile_id}{ext}"
|
||||
audio_path = os.path.join(VOICES_DIR, audio_filename)
|
||||
|
||||
with open(audio_path, "wb") as f:
|
||||
f.write(await ref_audio.read())
|
||||
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles (id, name, ref_audio_path, ref_text, instruct, language, seed, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(profile_id, name, audio_filename, ref_text, instruct, language, seed, time.time())
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"id": profile_id, "name": name}
|
||||
|
||||
@router.get("/profiles/{profile_id}/audio")
|
||||
def get_profile_audio(profile_id: str):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT ref_audio_path, locked_audio_path FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return Response("Profile not found", status_code=404)
|
||||
audio_file = row["locked_audio_path"] or row["ref_audio_path"]
|
||||
if not audio_file:
|
||||
return Response("No audio available", status_code=404)
|
||||
audio_path = os.path.join(VOICES_DIR, audio_file)
|
||||
if not os.path.exists(audio_path):
|
||||
return Response("Audio file missing", status_code=404)
|
||||
return FileResponse(audio_path, media_type="audio/wav")
|
||||
|
||||
@router.post("/profiles/{profile_id}/lock")
|
||||
async def lock_profile(
|
||||
profile_id: str,
|
||||
history_id: str = Form(...),
|
||||
seed: Optional[int] = Form(None),
|
||||
):
|
||||
conn = get_db()
|
||||
profile = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
if not profile:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Profile not found")
|
||||
|
||||
history = conn.execute("SELECT * FROM generation_history WHERE id=?", (history_id,)).fetchone()
|
||||
if not history or not history["audio_path"]:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="History item not found or has no audio")
|
||||
|
||||
src_path = os.path.join(OUTPUTS_DIR, history["audio_path"])
|
||||
if not os.path.exists(src_path):
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Audio file not found on disk")
|
||||
|
||||
locked_filename = f"{profile_id}_locked.wav"
|
||||
locked_path = os.path.join(VOICES_DIR, locked_filename)
|
||||
shutil.copy2(src_path, locked_path)
|
||||
|
||||
ref_text = history["text"][:100] if history["text"] else ""
|
||||
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET locked_audio_path=?, seed=?, is_locked=1, ref_text=? WHERE id=?",
|
||||
(locked_filename, seed, ref_text, profile_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"locked": True, "profile_id": profile_id, "locked_audio_path": locked_filename}
|
||||
|
||||
@router.post("/profiles/{profile_id}/unlock")
|
||||
async def unlock_profile(profile_id: str):
|
||||
conn = get_db()
|
||||
profile = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
if not profile:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Profile not found")
|
||||
|
||||
if profile["locked_audio_path"]:
|
||||
locked_path = os.path.join(VOICES_DIR, profile["locked_audio_path"])
|
||||
if os.path.exists(locked_path):
|
||||
os.remove(locked_path)
|
||||
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET locked_audio_path='', seed=NULL, is_locked=0 WHERE id=?",
|
||||
(profile_id,)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"unlocked": True, "profile_id": profile_id}
|
||||
|
||||
@router.delete("/profiles/{profile_id}")
|
||||
def delete_profile(profile_id: str):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT ref_audio_path, locked_audio_path FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
if row:
|
||||
for col in ["ref_audio_path", "locked_audio_path"]:
|
||||
if row[col]:
|
||||
path = os.path.join(VOICES_DIR, row[col])
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
conn.execute("DELETE FROM voice_profiles WHERE id=?", (profile_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"deleted": profile_id}
|
||||
@@ -0,0 +1,72 @@
|
||||
import uuid
|
||||
import time
|
||||
import json
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from core.db import get_db
|
||||
from schemas.requests import ProjectSaveRequest
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/projects")
|
||||
async def list_projects():
|
||||
conn = get_db()
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, video_path, duration, created_at, updated_at FROM studio_projects ORDER BY updated_at DESC"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@router.get("/projects/{project_id}")
|
||||
async def get_project(project_id: str):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT * FROM studio_projects WHERE id=?", (project_id,)).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
result = dict(row)
|
||||
if result.get("state_json"):
|
||||
try:
|
||||
result["state"] = json.loads(result["state_json"])
|
||||
except Exception:
|
||||
result["state"] = {}
|
||||
else:
|
||||
result["state"] = {}
|
||||
return result
|
||||
|
||||
@router.post("/projects")
|
||||
async def create_project(req: ProjectSaveRequest):
|
||||
project_id = str(uuid.uuid4())[:8]
|
||||
now = time.time()
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO studio_projects (id, name, video_path, audio_path, duration, state_json, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)",
|
||||
(project_id, req.name, req.video_path, req.audio_path, req.duration, json.dumps(req.state), now, now),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"id": project_id, "name": req.name, "created_at": now}
|
||||
|
||||
@router.put("/projects/{project_id}")
|
||||
async def update_project(project_id: str, req: ProjectSaveRequest):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT id FROM studio_projects WHERE id=?", (project_id,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
now = time.time()
|
||||
conn.execute(
|
||||
"UPDATE studio_projects SET name=?, video_path=?, audio_path=?, duration=?, state_json=?, updated_at=? WHERE id=?",
|
||||
(req.name, req.video_path, req.audio_path, req.duration, json.dumps(req.state), now, project_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"id": project_id, "name": req.name, "updated_at": now}
|
||||
|
||||
@router.delete("/projects/{project_id}")
|
||||
async def delete_project(project_id: str):
|
||||
conn = get_db()
|
||||
conn.execute("DELETE FROM studio_projects WHERE id=?", (project_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"deleted": project_id}
|
||||
@@ -0,0 +1,132 @@
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
import psutil
|
||||
import asyncio
|
||||
import logging
|
||||
from fastapi import APIRouter, File, UploadFile, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
import torch
|
||||
import shutil
|
||||
|
||||
from core.config import OUTPUTS_DIR
|
||||
from services.model_manager import get_model_status, get_best_device
|
||||
from services.ffmpeg_utils import find_ffmpeg
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.api")
|
||||
|
||||
@router.get("/model/status")
|
||||
def model_status():
|
||||
"""Report model loading state for frontend warm-up indicators."""
|
||||
return get_model_status()
|
||||
|
||||
@router.get("/sysinfo")
|
||||
def get_sys_info():
|
||||
vram = 0.0
|
||||
gpu_active = False
|
||||
|
||||
is_mac = hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
|
||||
is_cuda = torch.cuda.is_available()
|
||||
|
||||
try:
|
||||
if is_mac:
|
||||
alloc = getattr(torch.mps, "current_allocated_memory", None)
|
||||
driver = getattr(torch.mps, "driver_allocated_memory", None)
|
||||
if driver:
|
||||
vram = driver() / (1024**3)
|
||||
elif alloc:
|
||||
vram = alloc() / (1024**3)
|
||||
elif is_cuda:
|
||||
vram = torch.cuda.memory_allocated() / (1024**3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if vram > 0.01:
|
||||
gpu_active = True
|
||||
|
||||
return {
|
||||
"cpu": psutil.cpu_percent(interval=0.1),
|
||||
"ram": psutil.virtual_memory().used / (1024**3),
|
||||
"total_ram": psutil.virtual_memory().total / (1024**3),
|
||||
"vram": round(vram, 2),
|
||||
"gpu_active": gpu_active
|
||||
}
|
||||
|
||||
@router.post("/clean-audio")
|
||||
async def clean_audio(audio: UploadFile = File(...)):
|
||||
"""Accept a raw mic recording, run demucs vocal isolation, return clean WAV."""
|
||||
clean_id = str(uuid.uuid4())[:8]
|
||||
tmp_dir = os.path.join(OUTPUTS_DIR, f"_clean_{clean_id}")
|
||||
os.makedirs(tmp_dir, exist_ok=True)
|
||||
try:
|
||||
return await _do_clean_audio(audio, tmp_dir, clean_id)
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
async def _do_clean_audio(audio, tmp_dir, clean_id):
|
||||
raw_path = os.path.join(tmp_dir, "raw.wav")
|
||||
with open(raw_path, "wb") as f:
|
||||
f.write(await audio.read())
|
||||
|
||||
converted_path = os.path.join(tmp_dir, "converted.wav")
|
||||
ffmpeg = find_ffmpeg()
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
ffmpeg, "-y", "-i", raw_path, "-ar", "24000", "-ac", "1", converted_path,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=120.0)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
converted_path = raw_path
|
||||
else:
|
||||
if proc.returncode != 0:
|
||||
converted_path = raw_path
|
||||
|
||||
clean_path = converted_path
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable, "-m", "demucs.separate", "--two-stems", "vocals", "-n", "htdemucs",
|
||||
"-d", get_best_device(), converted_path, "-o", tmp_dir,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=900.0)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
raise Exception("demucs timed out")
|
||||
if proc.returncode == 0:
|
||||
demucs_out = os.path.join(tmp_dir, "htdemucs", "converted")
|
||||
vocals_file = os.path.join(demucs_out, "vocals.wav")
|
||||
if os.path.exists(vocals_file):
|
||||
clean_path = vocals_file
|
||||
except Exception as e:
|
||||
logger.warning(f"Demucs failed for mic audio, using raw: {e}")
|
||||
|
||||
clean_filename = f"mic_{clean_id}.wav"
|
||||
final_path = os.path.join(OUTPUTS_DIR, clean_filename)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
ffmpeg, "-y", "-i", clean_path, "-ar", "24000", "-ac", "1", final_path,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(proc.communicate(), timeout=120.0)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
if not os.path.exists(final_path):
|
||||
shutil.copy2(clean_path, final_path)
|
||||
|
||||
return FileResponse(final_path, media_type="audio/wav", filename=clean_filename,
|
||||
headers={"X-Clean-Filename": clean_filename})
|
||||
@@ -0,0 +1,37 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
def get_app_data_dir():
|
||||
custom_dir = os.environ.get("OMNIVOICE_DATA_DIR")
|
||||
if custom_dir:
|
||||
return custom_dir
|
||||
|
||||
if sys.platform == "darwin":
|
||||
return os.path.expanduser("~/Library/Application Support/OmniVoice")
|
||||
elif sys.platform == "win32":
|
||||
return os.path.join(os.environ.get("APPDATA", ""), "OmniVoice")
|
||||
else:
|
||||
return os.path.expanduser("~/.omnivoice")
|
||||
|
||||
DATA_DIR = get_app_data_dir()
|
||||
VOICES_DIR = os.path.join(DATA_DIR, "voices") # Reference audio for profiles
|
||||
OUTPUTS_DIR = os.path.join(DATA_DIR, "outputs") # Generated audio files
|
||||
DUB_DIR = os.path.join(DATA_DIR, "dub_jobs")
|
||||
DB_PATH = os.path.join(DATA_DIR, "omnivoice.db")
|
||||
PREVIEW_DIR = os.path.join(DATA_DIR, "preview")
|
||||
CRASH_LOG_PATH = os.path.join(DATA_DIR, "crash_log.txt")
|
||||
|
||||
IDLE_TIMEOUT_SECONDS = int(os.environ.get("OMNIVOICE_IDLE_TIMEOUT", "300"))
|
||||
CPU_POOL_WORKERS = int(os.environ.get("OMNIVOICE_CPU_POOL", "0")) or min(8, (os.cpu_count() or 4))
|
||||
|
||||
def ensure_dirs():
|
||||
for d in [DATA_DIR, VOICES_DIR, OUTPUTS_DIR, DUB_DIR, PREVIEW_DIR]:
|
||||
os.makedirs(d, exist_ok=True)
|
||||
|
||||
ensure_dirs()
|
||||
|
||||
# Ensure ffmpeg is on PATH for Whisper and other subprocesses (mostly relevant for Mac/Linux)
|
||||
if sys.platform != "win32":
|
||||
for _fpath in ["/opt/homebrew/bin", "/usr/local/bin"]:
|
||||
if _fpath not in os.environ.get("PATH", "") and os.path.exists(_fpath):
|
||||
os.environ["PATH"] = _fpath + os.pathsep + os.environ.get("PATH", "")
|
||||
@@ -0,0 +1,141 @@
|
||||
import re
|
||||
import sqlite3
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from core.config import DB_PATH
|
||||
|
||||
logger = logging.getLogger("omnivoice.db")
|
||||
|
||||
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
_TYPE_RE = re.compile(r"^[A-Za-z0-9_ '\"\(\)\-\.]+$")
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
|
||||
@contextmanager
|
||||
def db_conn():
|
||||
"""Context-managed SQLite connection that commits on clean exit and always closes."""
|
||||
conn = get_db()
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
_BASE_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS voice_profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
ref_audio_path TEXT,
|
||||
ref_text TEXT DEFAULT '',
|
||||
instruct TEXT DEFAULT '',
|
||||
language TEXT DEFAULT 'Auto',
|
||||
locked_audio_path TEXT DEFAULT '',
|
||||
seed INTEGER DEFAULT NULL,
|
||||
is_locked INTEGER DEFAULT 0,
|
||||
created_at REAL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS generation_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
text TEXT,
|
||||
mode TEXT,
|
||||
language TEXT,
|
||||
instruct TEXT,
|
||||
profile_id TEXT,
|
||||
audio_path TEXT,
|
||||
duration_seconds REAL,
|
||||
generation_time REAL,
|
||||
seed INTEGER DEFAULT NULL,
|
||||
created_at REAL,
|
||||
FOREIGN KEY (profile_id) REFERENCES voice_profiles(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS dub_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
filename TEXT,
|
||||
duration REAL,
|
||||
segments_count INTEGER,
|
||||
language TEXT,
|
||||
language_code TEXT,
|
||||
tracks TEXT DEFAULT '[]',
|
||||
job_data TEXT,
|
||||
created_at REAL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS studio_projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
video_path TEXT,
|
||||
audio_path TEXT,
|
||||
duration REAL,
|
||||
state_json TEXT,
|
||||
created_at REAL,
|
||||
updated_at REAL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS export_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
filename TEXT,
|
||||
destination_path TEXT,
|
||||
mode TEXT,
|
||||
created_at REAL
|
||||
);
|
||||
"""
|
||||
|
||||
# Only tables/columns this module is allowed to ALTER. Prevents SQL injection via
|
||||
# the f-string ALTER below if these helpers ever get exposed to user input.
|
||||
_ALLOWED_MIGRATIONS = {
|
||||
("voice_profiles", "locked_audio_path"),
|
||||
("voice_profiles", "seed"),
|
||||
("voice_profiles", "is_locked"),
|
||||
("generation_history", "seed"),
|
||||
}
|
||||
|
||||
|
||||
def _add_column_if_missing(conn, table: str, column: str, typedef: str):
|
||||
if (table, column) not in _ALLOWED_MIGRATIONS:
|
||||
raise ValueError(f"Migration not allowed: {table}.{column}")
|
||||
if not _IDENT_RE.match(table) or not _IDENT_RE.match(column):
|
||||
raise ValueError(f"Invalid identifier: {table}.{column}")
|
||||
if not _TYPE_RE.match(typedef):
|
||||
raise ValueError(f"Invalid typedef: {typedef!r}")
|
||||
try:
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {typedef}")
|
||||
except sqlite3.OperationalError as e:
|
||||
if "duplicate column" not in str(e).lower():
|
||||
logger.warning("ALTER %s.%s failed: %s", table, column, e)
|
||||
|
||||
|
||||
def _migrate(conn, current: int) -> int:
|
||||
"""Apply migrations sequentially. Return new version."""
|
||||
if current < 1:
|
||||
_add_column_if_missing(conn, "voice_profiles", "locked_audio_path", "TEXT DEFAULT ''")
|
||||
_add_column_if_missing(conn, "voice_profiles", "seed", "INTEGER DEFAULT NULL")
|
||||
_add_column_if_missing(conn, "voice_profiles", "is_locked", "INTEGER DEFAULT 0")
|
||||
_add_column_if_missing(conn, "generation_history", "seed", "INTEGER DEFAULT NULL")
|
||||
current = 1
|
||||
# Future migrations: if current < 2: ...; current = 2
|
||||
return current
|
||||
|
||||
|
||||
def init_db():
|
||||
conn = get_db()
|
||||
try:
|
||||
conn.executescript(_BASE_SCHEMA)
|
||||
version = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||
new_version = _migrate(conn, version)
|
||||
if new_version != version:
|
||||
conn.execute(f"PRAGMA user_version = {new_version}")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,103 @@
|
||||
import asyncio
|
||||
import time
|
||||
import json
|
||||
|
||||
class TaskManager:
|
||||
def __init__(self):
|
||||
self.queue = None
|
||||
self.active_tasks = {}
|
||||
|
||||
def _init_queue(self):
|
||||
if self.queue is None:
|
||||
self.queue = asyncio.Queue()
|
||||
|
||||
async def add_task(self, task_id, task_type, func, *args, **kwargs):
|
||||
self._init_queue()
|
||||
task_obj = {
|
||||
"status": "pending",
|
||||
"type": task_type,
|
||||
"created_at": time.time(),
|
||||
"history": [],
|
||||
"listeners": [],
|
||||
"listeners_lock": asyncio.Lock(),
|
||||
"error": None,
|
||||
"cancelled": False,
|
||||
}
|
||||
self.active_tasks[task_id] = task_obj
|
||||
await self.queue.put((task_id, func, args, kwargs))
|
||||
|
||||
def cancel_task(self, task_id):
|
||||
if task_id in self.active_tasks:
|
||||
self.active_tasks[task_id]["cancelled"] = True
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_cancelled(self, task_id):
|
||||
t = self.active_tasks.get(task_id)
|
||||
return t["cancelled"] if t else False
|
||||
|
||||
async def add_listener(self, task_id, q):
|
||||
t = self.active_tasks.get(task_id)
|
||||
if not t:
|
||||
return False
|
||||
async with t["listeners_lock"]:
|
||||
t["listeners"].append(q)
|
||||
return True
|
||||
|
||||
async def remove_listener(self, task_id, q):
|
||||
t = self.active_tasks.get(task_id)
|
||||
if not t:
|
||||
return
|
||||
async with t["listeners_lock"]:
|
||||
if q in t["listeners"]:
|
||||
t["listeners"].remove(q)
|
||||
|
||||
async def _push_event(self, task_id, event_str):
|
||||
t = self.active_tasks.get(task_id)
|
||||
if t is None:
|
||||
return
|
||||
if event_str is not None:
|
||||
t["history"].append(event_str)
|
||||
# Snapshot listeners under lock so concurrent add/remove can't mutate mid-iteration.
|
||||
async with t["listeners_lock"]:
|
||||
listeners = list(t["listeners"])
|
||||
for q in listeners:
|
||||
await q.put(event_str)
|
||||
|
||||
async def worker(self):
|
||||
self._init_queue()
|
||||
while True:
|
||||
task_id, func, args, kwargs = await self.queue.get()
|
||||
t = self.active_tasks.get(task_id)
|
||||
if not t:
|
||||
self.queue.task_done()
|
||||
continue
|
||||
|
||||
t["status"] = "running"
|
||||
try:
|
||||
import inspect
|
||||
res = func(*args, **kwargs)
|
||||
if inspect.isasyncgen(res):
|
||||
async for update in res:
|
||||
if t.get("cancelled"):
|
||||
await self._push_event(task_id, f"data: {json.dumps({'type': 'cancelled'})}\n\n")
|
||||
t["status"] = "cancelled"
|
||||
break
|
||||
await self._push_event(task_id, update)
|
||||
elif inspect.iscoroutine(res):
|
||||
await res
|
||||
t["status"] = "done"
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger("omnivoice.tasks").exception("Task %s failed", task_id)
|
||||
t["status"] = "failed"
|
||||
t["error"] = str(e)
|
||||
try:
|
||||
await self._push_event(task_id, f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n")
|
||||
except Exception as push_err:
|
||||
logging.getLogger("omnivoice.tasks").warning("Failed to push error event for %s: %s", task_id, push_err)
|
||||
finally:
|
||||
await self._push_event(task_id, None) # EOF
|
||||
self.queue.task_done()
|
||||
|
||||
task_manager = TaskManager()
|
||||
+64
-2062
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
|
||||
class ExportRequest(BaseModel):
|
||||
source_filename: str
|
||||
destination_path: str
|
||||
mode: str = "history"
|
||||
|
||||
class ExportRecordRequest(BaseModel):
|
||||
filename: str
|
||||
destination_path: str = "~/Downloads"
|
||||
mode: str = "file"
|
||||
|
||||
class RevealRequest(BaseModel):
|
||||
path: str
|
||||
|
||||
class DubSegment(BaseModel):
|
||||
start: float
|
||||
end: float
|
||||
text: str
|
||||
instruct: str = "" # Per-segment voice override
|
||||
profile_id: str = "" # Per-segment voice profile
|
||||
speed: Optional[float] = None
|
||||
gain: Optional[float] = None # Per-segment volume (0.0 - 2.0, default 1.0)
|
||||
target_lang: Optional[str] = None # Per-segment language override (ISO code)
|
||||
|
||||
class DubRequest(BaseModel):
|
||||
segments: List[DubSegment]
|
||||
language: str = "Auto"
|
||||
language_code: str = "und" # ISO 639-1 for ffmpeg metadata (e.g. "es", "fr", "de")
|
||||
instruct: str = ""
|
||||
num_step: int = 16
|
||||
guidance_scale: float = 2.0
|
||||
speed: float = 1.0
|
||||
|
||||
class TranslateSegment(BaseModel):
|
||||
id: str
|
||||
text: str
|
||||
target_lang: Optional[str] = None
|
||||
|
||||
class TranslateRequest(BaseModel):
|
||||
segments: List[TranslateSegment]
|
||||
target_lang: str # ISO 639-1 code like "es", "fr"
|
||||
provider: Optional[str] = None
|
||||
source_lang: Optional[str] = None # ISO 639-1; overrides job detection
|
||||
job_id: Optional[str] = None # Dub job id, used to resolve detected source_lang
|
||||
|
||||
class ProjectSaveRequest(BaseModel):
|
||||
name: str
|
||||
video_path: Optional[str] = None
|
||||
audio_path: Optional[str] = None
|
||||
duration: Optional[float] = None
|
||||
state: dict # Full JSON blob: segments, settings, tracks, etc.
|
||||
@@ -0,0 +1,32 @@
|
||||
import torch
|
||||
|
||||
def apply_mastering(audio_tensor, sample_rate=24000):
|
||||
"""Applies professional Broadcast-grade DSP (EQ, Compressor, light Reverb) to the clone voice."""
|
||||
try:
|
||||
from pedalboard import Pedalboard, Compressor, Reverb, HighpassFilter
|
||||
import numpy as np
|
||||
board = Pedalboard([
|
||||
HighpassFilter(cutoff_frequency_hz=60),
|
||||
Compressor(threshold_db=-15, ratio=1.5, attack_ms=2.0, release_ms=100),
|
||||
Reverb(room_size=0.10, wet_level=0.08, dry_level=0.95)
|
||||
])
|
||||
audio_np = audio_tensor.cpu().numpy()
|
||||
if audio_np.ndim == 1:
|
||||
audio_np = audio_np[np.newaxis, :]
|
||||
effected = board(audio_np, sample_rate, reset=False)
|
||||
return torch.from_numpy(effected).to(audio_tensor.device)
|
||||
except ImportError:
|
||||
return audio_tensor # Fail gracefully if pedalboard isn't installed
|
||||
except Exception as e:
|
||||
print(f"Mastering DSP Error: {e}")
|
||||
return audio_tensor
|
||||
|
||||
def normalize_audio(audio_tensor, target_dBFS=-2.0):
|
||||
"""Peak-normalizes the audio to a standard broadcasting level (-2 dB) to fix F5TTS volume fluctuations."""
|
||||
if audio_tensor.numel() == 0:
|
||||
return audio_tensor
|
||||
max_val = torch.abs(audio_tensor).max()
|
||||
if max_val > 0:
|
||||
target_amp = 10 ** (target_dBFS / 20.0)
|
||||
audio_tensor = audio_tensor * (target_amp / max_val)
|
||||
return audio_tensor
|
||||
@@ -0,0 +1,90 @@
|
||||
import asyncio
|
||||
import errno
|
||||
import logging
|
||||
import shutil
|
||||
|
||||
logger = logging.getLogger("omnivoice.api")
|
||||
|
||||
# Cap concurrent ffmpeg jobs so macOS posix_spawn can't hit EAGAIN under load.
|
||||
_FFMPEG_SEMAPHORE: "asyncio.Semaphore | None" = None
|
||||
_FFMPEG_CONCURRENCY = 2
|
||||
|
||||
|
||||
def _get_semaphore() -> asyncio.Semaphore:
|
||||
global _FFMPEG_SEMAPHORE
|
||||
if _FFMPEG_SEMAPHORE is None:
|
||||
_FFMPEG_SEMAPHORE = asyncio.Semaphore(_FFMPEG_CONCURRENCY)
|
||||
return _FFMPEG_SEMAPHORE
|
||||
|
||||
|
||||
def find_ffmpeg():
|
||||
try:
|
||||
import imageio_ffmpeg
|
||||
# This will natively extract and return an architecture-specific static FFmpeg binary!
|
||||
return imageio_ffmpeg.get_ffmpeg_exe()
|
||||
except Exception as e:
|
||||
logger.warning(f"imageio_ffmpeg failed to provide static binary: {e}. Falling back to default system path.")
|
||||
for path in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "ffmpeg"]:
|
||||
if shutil.which(path):
|
||||
return path
|
||||
raise RuntimeError("ffmpeg not found in bundle or system path")
|
||||
|
||||
|
||||
async def _spawn_with_retry(cmd, **kwargs):
|
||||
"""Spawn a subprocess, retrying briefly on EAGAIN (posix_spawn resource pressure)."""
|
||||
delay = 0.1
|
||||
last_err = None
|
||||
for _ in range(5):
|
||||
try:
|
||||
return await asyncio.create_subprocess_exec(*cmd, **kwargs)
|
||||
except BlockingIOError as e:
|
||||
last_err = e
|
||||
if e.errno != errno.EAGAIN:
|
||||
raise
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
except OSError as e:
|
||||
if e.errno == errno.EAGAIN:
|
||||
last_err = e
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
continue
|
||||
raise
|
||||
raise last_err if last_err else RuntimeError("spawn failed")
|
||||
|
||||
|
||||
async def run_ffmpeg(cmd, timeout: float = 1800.0, capture: bool = True):
|
||||
"""Run an ffmpeg subprocess with concurrency cap, timeout, and proper cleanup.
|
||||
|
||||
Returns (returncode, stdout_bytes, stderr_bytes). Raises asyncio.TimeoutError
|
||||
on hard timeout (after killing + reaping the process).
|
||||
"""
|
||||
stdout = asyncio.subprocess.PIPE if capture else asyncio.subprocess.DEVNULL
|
||||
stderr = asyncio.subprocess.PIPE
|
||||
async with _get_semaphore():
|
||||
proc = await _spawn_with_retry(cmd, stdout=stdout, stderr=stderr)
|
||||
try:
|
||||
try:
|
||||
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
raise
|
||||
return proc.returncode, out, err
|
||||
finally:
|
||||
# Guarantee reaping — prevents zombie pileup under timeouts or errors.
|
||||
if proc.returncode is None:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
@@ -0,0 +1,118 @@
|
||||
import os
|
||||
import time
|
||||
import asyncio
|
||||
import logging
|
||||
import torch
|
||||
from typing import Optional
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from omnivoice.models.omnivoice import OmniVoice
|
||||
from core.config import IDLE_TIMEOUT_SECONDS, CPU_POOL_WORKERS
|
||||
|
||||
logger = logging.getLogger("omnivoice.model")
|
||||
|
||||
_gpu_pool = ThreadPoolExecutor(max_workers=1)
|
||||
_cpu_pool = ThreadPoolExecutor(max_workers=CPU_POOL_WORKERS)
|
||||
|
||||
model: Optional[OmniVoice] = None
|
||||
_model_lock = asyncio.Lock()
|
||||
_last_used = time.time()
|
||||
_IDLE_TIMEOUT_SECONDS = IDLE_TIMEOUT_SECONDS
|
||||
|
||||
def get_best_device():
|
||||
if torch.cuda.is_available():
|
||||
return "cuda"
|
||||
if torch.backends.mps.is_available():
|
||||
return "mps"
|
||||
return "cpu"
|
||||
|
||||
def _load_model_sync():
|
||||
global model
|
||||
device = get_best_device()
|
||||
print(f"Loading OmniVoice model lazily on device: {device}...")
|
||||
checkpoint = os.environ.get("OMNIVOICE_MODEL", "k2-fsa/OmniVoice")
|
||||
_model = OmniVoice.from_pretrained(
|
||||
checkpoint, device_map=device, dtype=torch.float16, load_asr=True,
|
||||
)
|
||||
try:
|
||||
if device == "cuda":
|
||||
_model.llm = torch.compile(_model.llm, mode="reduce-overhead")
|
||||
print("torch.compile applied.")
|
||||
except Exception as e:
|
||||
print(f"torch.compile skipped: {e}")
|
||||
print("OmniVoice model loaded successfully.")
|
||||
return _model
|
||||
|
||||
async def get_model() -> OmniVoice:
|
||||
global model, _last_used
|
||||
_last_used = time.time()
|
||||
if model is not None:
|
||||
return model
|
||||
|
||||
async with _model_lock:
|
||||
if model is None:
|
||||
loop = asyncio.get_running_loop()
|
||||
model = await loop.run_in_executor(_gpu_pool, _load_model_sync)
|
||||
return model
|
||||
|
||||
def get_model_status():
|
||||
is_loaded = model is not None
|
||||
# asyncio.Lock exposes .locked() on all supported Python versions; wrap in try for safety.
|
||||
try:
|
||||
is_loading = (not is_loaded) and _model_lock.locked()
|
||||
except Exception:
|
||||
is_loading = False
|
||||
return {
|
||||
"loaded": is_loaded,
|
||||
"loading": is_loading,
|
||||
"status": "loading" if is_loading else ("ready" if is_loaded else "idle"),
|
||||
}
|
||||
|
||||
async def idle_worker():
|
||||
global model
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
async with _model_lock:
|
||||
if model is not None and time.time() - _last_used > _IDLE_TIMEOUT_SECONDS:
|
||||
print("Idle timeout reached. Unloading OmniVoice model to free VRAM...")
|
||||
model = None
|
||||
import gc
|
||||
gc.collect()
|
||||
if torch.backends.mps.is_available():
|
||||
torch.mps.empty_cache()
|
||||
elif torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
def free_vram():
|
||||
import gc
|
||||
gc.collect()
|
||||
if torch.backends.mps.is_available():
|
||||
torch.mps.empty_cache()
|
||||
elif torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
_diar_pipeline = None
|
||||
|
||||
def get_diarization_pipeline():
|
||||
global _diar_pipeline
|
||||
hf_token = os.environ.get("HF_TOKEN")
|
||||
if not hf_token:
|
||||
return None
|
||||
if _diar_pipeline is not None:
|
||||
return _diar_pipeline
|
||||
try:
|
||||
import torch
|
||||
from pyannote.audio import Pipeline
|
||||
import logging
|
||||
logger = logging.getLogger("omnivoice.api")
|
||||
logger.info("Loading Pyannote Diarization Pipeline...")
|
||||
_diar_pipeline = Pipeline.from_pretrained("pyannote/speaker-diarization-3.1", use_auth_token=hf_token)
|
||||
if torch.cuda.is_available():
|
||||
_diar_pipeline.to(torch.device("cuda"))
|
||||
logger.info("Pyannote Diarization Pipeline loaded successfully.")
|
||||
return _diar_pipeline
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger("omnivoice.api")
|
||||
logger.error(f"Failed to load Pyannote pipeline: {e}")
|
||||
return None
|
||||
@@ -0,0 +1,506 @@
|
||||
"""Broadcast-grade segmentation for dubbing.
|
||||
|
||||
Rules (in priority order):
|
||||
1. Never split mid-word. Whitespace or nothing.
|
||||
2. Prefer sentence punctuation > clause punctuation (, ; : —) > word boundaries.
|
||||
3. Reject any candidate split that leaves either side below the minimum floor.
|
||||
4. Fragments below the floor merge into same-speaker neighbor; gap < MERGE_GAP
|
||||
prefers previous, else next.
|
||||
5. Scene-cut assisted splits apply only when both halves remain viable.
|
||||
6. Never merge across a speaker boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Iterable, List, Optional, Sequence
|
||||
|
||||
|
||||
MIN_DUR = 1.5 # seconds — below this, a segment must merge
|
||||
MIN_CHARS = 12 # characters — below this, a segment must merge (Latin-ish)
|
||||
MIN_WORDS = 3 # words — below this, a segment is considered a fragment
|
||||
STITCH_DUR = 2.5 # seconds — pair of short neighbors under this combine even when each is legal
|
||||
STITCH_GAP = 0.9 # seconds — max silence between two stitch candidates
|
||||
IDEAL_DUR = 4.5 # seconds — target length for splits
|
||||
MAX_DUR = 9.0 # seconds — above this, force a split
|
||||
MAX_CHARS = 140 # characters — above this, force a split
|
||||
MERGE_GAP = 0.6 # seconds — tolerated silence when folding a fragment backward
|
||||
MERGE_GAP_ULTRA = 2.0 # seconds — wider gap tolerated for ultra-short (< 0.5s or < 3 chars)
|
||||
ULTRA_SHORT_DUR = 0.5 # seconds — threshold for "always fold" regardless of neighbor match
|
||||
ULTRA_SHORT_CHARS = 4 # chars — same tier
|
||||
SPEAKER_GAP = 1.2 # seconds — heuristic speaker-change gap (no pyannote)
|
||||
|
||||
# Sentence-end punctuation across Latin, CJK, Bengali, Arabic, Thai, Armenian, Hindi, etc.
|
||||
_SENTENCE_END = re.compile(
|
||||
r'([.!?。!?।؟…؛܀։՝።။၊।]["\')\]]?)(\s+|$)'
|
||||
)
|
||||
_CLAUSE_END = re.compile(r'([,;:—、،؍])(\s+|$)')
|
||||
_WS = re.compile(r'\s+')
|
||||
|
||||
|
||||
def _word_count(text: str) -> int:
|
||||
if not text:
|
||||
return 0
|
||||
# Latin-like scripts use whitespace; CJK scripts count each glyph as a word.
|
||||
tokens = [t for t in text.split() if t]
|
||||
if len(tokens) >= MIN_WORDS:
|
||||
return len(tokens)
|
||||
# For scripts without spaces (CJK), approximate word count as graphemes / 2.
|
||||
non_space = sum(1 for ch in text if not ch.isspace())
|
||||
approx = max(len(tokens), non_space // 2)
|
||||
return approx
|
||||
|
||||
|
||||
def _is_short(seg) -> bool:
|
||||
return (
|
||||
seg.duration < MIN_DUR
|
||||
or seg.char_count < MIN_CHARS
|
||||
or _word_count(seg.text) < MIN_WORDS
|
||||
)
|
||||
|
||||
|
||||
def _is_ultra_short(seg) -> bool:
|
||||
return seg.duration < ULTRA_SHORT_DUR or seg.char_count < ULTRA_SHORT_CHARS
|
||||
|
||||
|
||||
@dataclass
|
||||
class Word:
|
||||
start: float
|
||||
end: float
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Segment:
|
||||
start: float
|
||||
end: float
|
||||
text: str
|
||||
speaker_id: str = "Speaker 1"
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return max(0.0, self.end - self.start)
|
||||
|
||||
@property
|
||||
def char_count(self) -> int:
|
||||
return len(self.text)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"start": round(self.start, 2),
|
||||
"end": round(self.end, 2),
|
||||
"text": self.text,
|
||||
"speaker_id": self.speaker_id,
|
||||
}
|
||||
|
||||
|
||||
def _clean(text: str) -> str:
|
||||
return _WS.sub(" ", (text or "").strip())
|
||||
|
||||
|
||||
def _best_boundary(text: str, ideal_pos: int) -> int:
|
||||
"""Return a character offset to split at. Prefer sentence > clause > word.
|
||||
|
||||
Scans the full text for each candidate class and picks the one whose offset
|
||||
is closest to `ideal_pos`. Sentence endings always beat clause endings, which
|
||||
always beat bare word boundaries.
|
||||
"""
|
||||
if not text:
|
||||
return 0
|
||||
length = len(text)
|
||||
if length <= 1:
|
||||
return length
|
||||
|
||||
def _closest(offsets: List[int]) -> Optional[int]:
|
||||
if not offsets:
|
||||
return None
|
||||
return min(offsets, key=lambda o: abs(o - ideal_pos))
|
||||
|
||||
sentence_offsets = [m.end(1) for m in _SENTENCE_END.finditer(text)]
|
||||
pick = _closest(sentence_offsets)
|
||||
if pick is not None:
|
||||
return pick
|
||||
|
||||
clause_offsets = [m.end(1) for m in _CLAUSE_END.finditer(text)]
|
||||
pick = _closest(clause_offsets)
|
||||
if pick is not None:
|
||||
return pick
|
||||
|
||||
# Bare word boundaries: every space position.
|
||||
space_offsets = [i for i, ch in enumerate(text) if ch == " "]
|
||||
pick = _closest(space_offsets)
|
||||
if pick is not None:
|
||||
return pick
|
||||
return length
|
||||
|
||||
|
||||
def _words_from_whisper(result: dict) -> List[Word]:
|
||||
"""Extract word-level timing if available, otherwise fall back to chunk-level."""
|
||||
words: List[Word] = []
|
||||
segs = result.get("segments") if isinstance(result, dict) else None
|
||||
if segs:
|
||||
for seg in segs:
|
||||
for w in seg.get("words", []) or []:
|
||||
wt = (w.get("word") or w.get("text") or "").strip()
|
||||
if not wt:
|
||||
continue
|
||||
ws = float(w.get("start", seg.get("start", 0.0)))
|
||||
we = float(w.get("end", seg.get("end", ws + 0.1)))
|
||||
if we <= ws:
|
||||
we = ws + 0.05
|
||||
words.append(Word(start=ws, end=we, text=wt))
|
||||
if words:
|
||||
return words
|
||||
|
||||
# Fallback: chunk-level timings (no per-word granularity)
|
||||
for chunk in result.get("chunks", []) or []:
|
||||
ts = chunk.get("timestamp") or (0.0, 0.0)
|
||||
s = float(ts[0] or 0.0)
|
||||
e = float(ts[1] or s + 0.1)
|
||||
text = _clean(chunk.get("text", ""))
|
||||
if not text or e <= s:
|
||||
continue
|
||||
# Distribute time evenly across the tokens inside the chunk
|
||||
tokens = text.split(" ")
|
||||
dur = (e - s) / max(len(tokens), 1)
|
||||
t = s
|
||||
for tok in tokens:
|
||||
words.append(Word(start=t, end=t + dur, text=tok))
|
||||
t += dur
|
||||
return words
|
||||
|
||||
|
||||
def _build_segments_from_words(words: Sequence[Word]) -> List[Segment]:
|
||||
"""Greedy grouping of words into IDEAL_DUR sentences, cut at natural boundaries."""
|
||||
segments: List[Segment] = []
|
||||
if not words:
|
||||
return segments
|
||||
|
||||
buf: List[Word] = []
|
||||
buf_start = words[0].start
|
||||
|
||||
def flush_buf(force: bool = False) -> None:
|
||||
nonlocal buf, buf_start
|
||||
if not buf:
|
||||
return
|
||||
text = _clean(" ".join(w.text for w in buf))
|
||||
if not text:
|
||||
buf = []
|
||||
return
|
||||
segments.append(Segment(start=buf_start, end=buf[-1].end, text=text))
|
||||
buf = []
|
||||
if not force:
|
||||
buf_start = 0.0
|
||||
|
||||
for i, w in enumerate(words):
|
||||
if not buf:
|
||||
buf_start = w.start
|
||||
buf.append(w)
|
||||
buf_dur = buf[-1].end - buf_start
|
||||
buf_chars = sum(len(x.text) + 1 for x in buf)
|
||||
next_gap = 0.0
|
||||
if i + 1 < len(words):
|
||||
next_gap = max(0.0, words[i + 1].start - w.end)
|
||||
|
||||
ends_sentence = bool(_SENTENCE_END.search(w.text))
|
||||
ends_clause = bool(_CLAUSE_END.search(w.text))
|
||||
|
||||
too_long = buf_dur >= MAX_DUR or buf_chars >= MAX_CHARS
|
||||
at_ideal = buf_dur >= IDEAL_DUR and buf_chars >= MIN_CHARS
|
||||
|
||||
# Natural-boundary flush at target length.
|
||||
if at_ideal and ends_sentence:
|
||||
flush_buf()
|
||||
elif too_long and (ends_sentence or ends_clause):
|
||||
flush_buf()
|
||||
elif too_long and next_gap >= 0.35:
|
||||
flush_buf()
|
||||
elif too_long:
|
||||
# Last-resort split on a word boundary. Choose the word whose
|
||||
# cumulative position is closest to IDEAL_DUR from buf_start.
|
||||
best_idx = None
|
||||
best_score = float("inf")
|
||||
for k, bw in enumerate(buf[:-1]): # must leave ≥1 word on right
|
||||
left_dur = bw.end - buf_start
|
||||
if left_dur < MIN_DUR:
|
||||
continue
|
||||
right_dur = buf[-1].end - buf[k + 1].start
|
||||
if right_dur < MIN_DUR:
|
||||
continue
|
||||
# Prefer words ending in sentence / clause punctuation.
|
||||
boundary_bonus = 0.0
|
||||
if _SENTENCE_END.search(bw.text):
|
||||
boundary_bonus = -2.0
|
||||
elif _CLAUSE_END.search(bw.text):
|
||||
boundary_bonus = -0.8
|
||||
score = abs(left_dur - IDEAL_DUR) + boundary_bonus
|
||||
if score < best_score:
|
||||
best_score = score
|
||||
best_idx = k
|
||||
|
||||
if best_idx is not None:
|
||||
left_buf = buf[: best_idx + 1]
|
||||
right_buf = buf[best_idx + 1 :]
|
||||
segments.append(Segment(
|
||||
start=buf_start,
|
||||
end=left_buf[-1].end,
|
||||
text=_clean(" ".join(x.text for x in left_buf)),
|
||||
))
|
||||
buf = list(right_buf)
|
||||
buf_start = right_buf[0].start
|
||||
else:
|
||||
flush_buf()
|
||||
|
||||
flush_buf(force=True)
|
||||
return segments
|
||||
|
||||
|
||||
def _merge_short(segments: List[Segment]) -> List[Segment]:
|
||||
"""Fold fragments below the floor into adjacent same-speaker segment.
|
||||
|
||||
Runs multi-pass until no further merges happen. Ultra-short segments
|
||||
(< 0.5s or < 4 chars) fold across larger gaps and across speakers when
|
||||
no same-speaker neighbor is close — stray tokens like "STR" are never
|
||||
allowed to survive as standalone segments.
|
||||
"""
|
||||
if not segments:
|
||||
return segments
|
||||
|
||||
for _ in range(64): # bounded iterations so misuse can't hang
|
||||
did_merge = False
|
||||
i = 0
|
||||
while i < len(segments):
|
||||
s = segments[i]
|
||||
if not _is_short(s):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
prev = segments[i - 1] if i > 0 else None
|
||||
nxt = segments[i + 1] if i + 1 < len(segments) else None
|
||||
gap_tolerance = MERGE_GAP_ULTRA if _is_ultra_short(s) else MERGE_GAP
|
||||
|
||||
prev_same = bool(prev and prev.speaker_id == s.speaker_id)
|
||||
next_same = bool(nxt and nxt.speaker_id == s.speaker_id)
|
||||
prev_gap = (s.start - prev.end) if prev else float("inf")
|
||||
next_gap = (nxt.start - s.end) if nxt else float("inf")
|
||||
|
||||
prev_ok = prev_same and prev_gap <= gap_tolerance
|
||||
next_ok = next_same and next_gap <= gap_tolerance
|
||||
|
||||
target = None
|
||||
if prev_ok and next_ok:
|
||||
target = prev if prev.duration <= nxt.duration else nxt
|
||||
elif prev_ok:
|
||||
target = prev
|
||||
elif next_ok:
|
||||
target = nxt
|
||||
elif prev_same:
|
||||
target = prev
|
||||
elif next_same:
|
||||
target = nxt
|
||||
elif _is_ultra_short(s):
|
||||
# Stray token — fold into closest neighbor regardless of speaker.
|
||||
if prev and nxt:
|
||||
target = prev if prev_gap <= next_gap else nxt
|
||||
else:
|
||||
target = prev or nxt
|
||||
elif prev:
|
||||
target = prev
|
||||
elif nxt:
|
||||
target = nxt
|
||||
|
||||
if target is None:
|
||||
i += 1
|
||||
continue
|
||||
if target is prev:
|
||||
prev.text = _clean(prev.text + " " + s.text)
|
||||
prev.end = max(prev.end, s.end)
|
||||
segments.pop(i)
|
||||
did_merge = True
|
||||
continue
|
||||
if target is nxt:
|
||||
nxt.text = _clean(s.text + " " + nxt.text)
|
||||
nxt.start = min(nxt.start, s.start)
|
||||
segments.pop(i)
|
||||
did_merge = True
|
||||
continue
|
||||
|
||||
i += 1
|
||||
if not did_merge:
|
||||
break
|
||||
return segments
|
||||
|
||||
|
||||
def _stitch_adjacent_shorts(segments: List[Segment]) -> List[Segment]:
|
||||
"""Combine adjacent same-speaker segments when both are short and close.
|
||||
|
||||
Catches the case where each segment individually passes MIN_DUR but a
|
||||
rapid-fire pair produces a jittery dub. Only stitches when both halves
|
||||
live under STITCH_DUR and the gap between them is minimal.
|
||||
"""
|
||||
if len(segments) < 2:
|
||||
return segments
|
||||
|
||||
for _ in range(32):
|
||||
did = False
|
||||
i = 0
|
||||
while i + 1 < len(segments):
|
||||
a, b = segments[i], segments[i + 1]
|
||||
same = a.speaker_id == b.speaker_id
|
||||
gap = b.start - a.end
|
||||
combined_dur = (b.end - a.start)
|
||||
if (
|
||||
same
|
||||
and gap <= STITCH_GAP
|
||||
and a.duration <= STITCH_DUR
|
||||
and b.duration <= STITCH_DUR
|
||||
and combined_dur <= MAX_DUR
|
||||
):
|
||||
a.text = _clean(a.text + " " + b.text)
|
||||
a.end = b.end
|
||||
segments.pop(i + 1)
|
||||
did = True
|
||||
continue
|
||||
i += 1
|
||||
if not did:
|
||||
break
|
||||
return segments
|
||||
|
||||
|
||||
def clean_up_segments(segments: List[dict]) -> List[dict]:
|
||||
"""Public entry: run merge + stitch passes on already-persisted segments.
|
||||
|
||||
Used by the UI's "Clean up segments" action so users can repair jobs
|
||||
that were segmented under older, looser rules.
|
||||
"""
|
||||
objs: List[Segment] = []
|
||||
for s in segments or []:
|
||||
try:
|
||||
objs.append(Segment(
|
||||
start=float(s.get("start", 0.0)),
|
||||
end=float(s.get("end", 0.0)),
|
||||
text=_clean(str(s.get("text", ""))),
|
||||
speaker_id=str(s.get("speaker_id") or "Speaker 1"),
|
||||
id=str(s.get("id") or uuid.uuid4().hex[:8]),
|
||||
))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
objs = [s for s in objs if s.end > s.start and s.text]
|
||||
objs = _merge_short(objs)
|
||||
objs = _stitch_adjacent_shorts(objs)
|
||||
objs = _merge_short(objs)
|
||||
return [s.to_dict() for s in objs]
|
||||
|
||||
|
||||
def _apply_scene_cuts(segments: List[Segment], scene_cuts: Iterable[float]) -> List[Segment]:
|
||||
"""Split segments at scene cuts only if both halves remain viable."""
|
||||
cuts = sorted(c for c in scene_cuts if c > 0)
|
||||
if not cuts:
|
||||
return segments
|
||||
|
||||
out: List[Segment] = []
|
||||
for s in segments:
|
||||
inner_cuts = [c for c in cuts if s.start + MIN_DUR < c < s.end - MIN_DUR]
|
||||
if not inner_cuts:
|
||||
out.append(s)
|
||||
continue
|
||||
|
||||
remaining = s
|
||||
for cut in inner_cuts:
|
||||
dur_total = remaining.duration
|
||||
if dur_total <= 0:
|
||||
break
|
||||
ratio = (cut - remaining.start) / dur_total
|
||||
tentative_split = int(len(remaining.text) * ratio)
|
||||
pos = _best_boundary(remaining.text, tentative_split)
|
||||
left_text = remaining.text[:pos].strip()
|
||||
right_text = remaining.text[pos:].strip()
|
||||
# Viability check — refuse the cut if either half would be a fragment.
|
||||
if (
|
||||
not left_text
|
||||
or not right_text
|
||||
or len(left_text) < MIN_CHARS
|
||||
or len(right_text) < MIN_CHARS
|
||||
or (cut - remaining.start) < MIN_DUR
|
||||
or (remaining.end - cut) < MIN_DUR
|
||||
):
|
||||
continue
|
||||
out.append(Segment(
|
||||
start=remaining.start, end=cut, text=left_text, speaker_id=remaining.speaker_id,
|
||||
))
|
||||
remaining = Segment(
|
||||
start=cut, end=remaining.end, text=right_text, speaker_id=remaining.speaker_id,
|
||||
)
|
||||
out.append(remaining)
|
||||
return out
|
||||
|
||||
|
||||
def segment_transcript(
|
||||
whisper_result: dict,
|
||||
duration: float,
|
||||
scene_cuts: Optional[Iterable[float]] = None,
|
||||
) -> List[dict]:
|
||||
"""Public entry point: whisper result → clean dub segments (as dicts)."""
|
||||
words = _words_from_whisper(whisper_result)
|
||||
if not words:
|
||||
text = _clean((whisper_result or {}).get("text", ""))
|
||||
if text:
|
||||
return [Segment(start=0.0, end=max(duration, 0.1), text=text).to_dict()]
|
||||
return []
|
||||
|
||||
segments = _build_segments_from_words(words)
|
||||
segments = _merge_short(segments)
|
||||
if scene_cuts:
|
||||
segments = _apply_scene_cuts(segments, scene_cuts)
|
||||
segments = _merge_short(segments)
|
||||
segments = _stitch_adjacent_shorts(segments)
|
||||
segments = _merge_short(segments)
|
||||
return [s.to_dict() for s in segments]
|
||||
|
||||
|
||||
def assign_speakers_from_diarization(
|
||||
segments: List[dict],
|
||||
diarization,
|
||||
) -> List[dict]:
|
||||
"""Replace speaker_id based on a pyannote diarization result (overlap-weighted)."""
|
||||
for s in segments:
|
||||
start, end = s["start"], s["end"]
|
||||
mid = (start + end) / 2.0
|
||||
overlap: dict[str, float] = {}
|
||||
for turn, _, speaker in diarization.itertracks(yield_label=True):
|
||||
left = max(start, turn.start)
|
||||
right = min(end, turn.end)
|
||||
if right > left:
|
||||
overlap[speaker] = overlap.get(speaker, 0.0) + (right - left)
|
||||
if overlap:
|
||||
winner = max(overlap.items(), key=lambda kv: kv[1])[0]
|
||||
else:
|
||||
# fall back to midpoint membership
|
||||
winner = None
|
||||
for turn, _, speaker in diarization.itertracks(yield_label=True):
|
||||
if turn.start <= mid <= turn.end:
|
||||
winner = speaker
|
||||
break
|
||||
if winner is not None:
|
||||
try:
|
||||
idx = int(winner.split("_")[-1]) + 1
|
||||
s["speaker_id"] = f"Speaker {idx}"
|
||||
except ValueError:
|
||||
s["speaker_id"] = winner
|
||||
return segments
|
||||
|
||||
|
||||
def assign_speakers_heuristic(segments: List[dict]) -> List[dict]:
|
||||
"""Two-speaker alternation based on silence gaps."""
|
||||
current = 1
|
||||
last_end = 0.0
|
||||
for i, s in enumerate(segments):
|
||||
if i > 0 and (s["start"] - last_end) > SPEAKER_GAP:
|
||||
current = 2 if current == 1 else 1
|
||||
s["speaker_id"] = f"Speaker {current}"
|
||||
last_end = s["end"]
|
||||
return segments
|
||||
@@ -0,0 +1,51 @@
|
||||
import sys
|
||||
import os
|
||||
os.environ["PATH"] += os.pathsep + "/opt/homebrew/bin:/usr/local/bin"
|
||||
|
||||
try:
|
||||
from imageio_ffmpeg import get_ffmpeg_exe
|
||||
ffmpeg_path = get_ffmpeg_exe()
|
||||
os.environ["PATH"] = os.path.dirname(ffmpeg_path) + os.pathsep + os.environ.get("PATH", "")
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
import mlx_whisper
|
||||
import time
|
||||
import subprocess
|
||||
|
||||
audio_file = "/Users/user4/Desktop/voice-design/OmniVoice/data/preview/1c43531cb0ae.mov"
|
||||
|
||||
print("Starting transcription...", flush=True)
|
||||
start = time.time()
|
||||
result = mlx_whisper.transcribe(audio_file, path_or_hf_repo="mlx-community/whisper-large-v3-mlx")
|
||||
transcript = result.get("text", "").strip()
|
||||
trans_time = time.time() - start
|
||||
print(f"Transcript ({trans_time:.2f}s):\n{transcript}\n")
|
||||
|
||||
# Baseline Google NMT
|
||||
try:
|
||||
from deep_translator import GoogleTranslator
|
||||
print("Translating with Google NMT...", flush=True)
|
||||
start = time.time()
|
||||
google_tgt = GoogleTranslator(source="auto", target="bn")
|
||||
google_text = google_tgt.translate(transcript)
|
||||
google_time = time.time() - start
|
||||
print(f"Google Bengali ({google_time:.2f}s):\n{google_text}\n")
|
||||
except Exception as e:
|
||||
print(f"Google Failed: {e}\n")
|
||||
|
||||
# APFEL
|
||||
print("Translating with Apfel...", flush=True)
|
||||
start = time.time()
|
||||
try:
|
||||
prompt = f"You are a professional dubbing translator. Translate the following text into Bengali. Output ONLY the translated text.\n{transcript}"
|
||||
# Use login shell to ensure apfel alias/function is loaded
|
||||
prompt_esc = prompt.replace('"', '\\"')
|
||||
apfel_res = subprocess.run(["zsh", "-lc", f'apfel "{prompt_esc}"'], capture_output=True, text=True)
|
||||
apfel_time = time.time() - start
|
||||
if apfel_res.returncode == 0:
|
||||
print(f"Apfel Bengali ({apfel_time:.2f}s):\n{apfel_res.stdout.strip()}\n")
|
||||
else:
|
||||
print(f"Apfel CLI failed: {apfel_res.stderr}\n")
|
||||
except Exception as e:
|
||||
print(f"Apfel Failed: {e}\n")
|
||||
@@ -1,37 +0,0 @@
|
||||
Request: http://localhost:8000/generate
|
||||
Traceback (most recent call last):
|
||||
File "/Users/user4/Desktop/voice-design/OmniVoice/.venv/lib/python3.10/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
||||
await self.app(scope, receive, _send)
|
||||
File "/Users/user4/Desktop/voice-design/OmniVoice/.venv/lib/python3.10/site-packages/starlette/middleware/cors.py", line 95, in __call__
|
||||
await self.simple_response(scope, receive, send, request_headers=headers)
|
||||
File "/Users/user4/Desktop/voice-design/OmniVoice/.venv/lib/python3.10/site-packages/starlette/middleware/cors.py", line 153, in simple_response
|
||||
await self.app(scope, receive, send)
|
||||
File "/Users/user4/Desktop/voice-design/OmniVoice/.venv/lib/python3.10/site-packages/starlette/middleware/exceptions.py", line 63, in __call__
|
||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
||||
File "/Users/user4/Desktop/voice-design/OmniVoice/.venv/lib/python3.10/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
raise exc
|
||||
File "/Users/user4/Desktop/voice-design/OmniVoice/.venv/lib/python3.10/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "/Users/user4/Desktop/voice-design/OmniVoice/.venv/lib/python3.10/site-packages/fastapi/middleware/asyncexitstack.py", line 18, in __call__
|
||||
await self.app(scope, receive, send)
|
||||
File "/Users/user4/Desktop/voice-design/OmniVoice/.venv/lib/python3.10/site-packages/starlette/routing.py", line 716, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "/Users/user4/Desktop/voice-design/OmniVoice/.venv/lib/python3.10/site-packages/starlette/routing.py", line 736, in app
|
||||
await route.handle(scope, receive, send)
|
||||
File "/Users/user4/Desktop/voice-design/OmniVoice/.venv/lib/python3.10/site-packages/starlette/routing.py", line 290, in handle
|
||||
await self.app(scope, receive, send)
|
||||
File "/Users/user4/Desktop/voice-design/OmniVoice/.venv/lib/python3.10/site-packages/fastapi/routing.py", line 134, in app
|
||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
||||
File "/Users/user4/Desktop/voice-design/OmniVoice/.venv/lib/python3.10/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
raise exc
|
||||
File "/Users/user4/Desktop/voice-design/OmniVoice/.venv/lib/python3.10/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "/Users/user4/Desktop/voice-design/OmniVoice/.venv/lib/python3.10/site-packages/fastapi/routing.py", line 120, in app
|
||||
response = await f(request)
|
||||
File "/Users/user4/Desktop/voice-design/OmniVoice/.venv/lib/python3.10/site-packages/fastapi/routing.py", line 674, in app
|
||||
raw_response = await run_endpoint_function(
|
||||
File "/Users/user4/Desktop/voice-design/OmniVoice/.venv/lib/python3.10/site-packages/fastapi/routing.py", line 328, in run_endpoint_function
|
||||
return await dependant.call(**values)
|
||||
File "/Users/user4/Desktop/voice-design/OmniVoice/backend/main.py", line 837, in generate_speech
|
||||
ref_audio_path = os.path.join(VOICES_DIR, row["ref_audio_path"]) if row.get("ref_audio_path") else None
|
||||
AttributeError: 'sqlite3.Row' object has no attribute 'get'
|
||||
+402
-154
@@ -1,6 +1,10 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import './index.css';
|
||||
import WaveformTimeline from './components/WaveformTimeline';
|
||||
import SearchableSelect from './components/SearchableSelect';
|
||||
|
||||
const POPULAR_LANGS = ['English','Spanish','French','German','Italian','Portuguese','Russian','Chinese','Japanese','Korean','Arabic','Hindi'];
|
||||
const POPULAR_ISO = ['en','es','fr','de','it','pt','ru','zh','ja','ko','ar','hi'];
|
||||
import { Toaster, toast } from 'react-hot-toast';
|
||||
import ALL_LANGUAGES from './languages.json';
|
||||
import {
|
||||
@@ -10,7 +14,7 @@ import {
|
||||
FileText, Loader, Check, AlertCircle, Plus, User, Save, Languages, Headphones,
|
||||
FolderOpen, FolderPlus, Pencil, Clock, Lock, Unlock, Mic, MicOff, Square,
|
||||
CheckCircle, Circle, ChevronRight, Target, PanelLeftClose, PanelLeftOpen, Scale,
|
||||
Layers, Music, Package, DownloadCloud
|
||||
Layers, Music, Package, DownloadCloud, RefreshCw
|
||||
} from 'lucide-react';
|
||||
|
||||
// Tauri: pre-import window API to avoid async delays in event handlers
|
||||
@@ -29,7 +33,7 @@ const doubleClickMaximize = () => {
|
||||
* We upload to the backend's /preview endpoint and serve via HTTP instead.
|
||||
* Falls back to createObjectURL for regular browsers.
|
||||
*/
|
||||
const _PREVIEW_API = 'http://localhost:8000';
|
||||
const _PREVIEW_API = import.meta.env.VITE_OMNIVOICE_API || 'http://localhost:8000';
|
||||
const fileToMediaUrl = async (file, prevUrls) => {
|
||||
// Revoke previous blob URLs if they exist
|
||||
if (prevUrls?.videoUrl?.startsWith('blob:')) URL.revokeObjectURL(prevUrls.videoUrl);
|
||||
@@ -60,6 +64,9 @@ const fileToMediaUrl = async (file, prevUrls) => {
|
||||
const playBlobAudio = async (blob) => {
|
||||
if (isTauri) {
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
// WebKit suspends AudioContext by default — must resume before decoding
|
||||
if (ctx.state === 'suspended') await ctx.resume();
|
||||
try {
|
||||
const buf = await blob.arrayBuffer();
|
||||
const decoded = await ctx.decodeAudioData(buf);
|
||||
const src = ctx.createBufferSource();
|
||||
@@ -67,10 +74,23 @@ const playBlobAudio = async (blob) => {
|
||||
src.connect(ctx.destination);
|
||||
src.start(0);
|
||||
src.onended = () => ctx.close();
|
||||
} catch (e) {
|
||||
console.error('playBlobAudio decode error:', e);
|
||||
ctx.close();
|
||||
// Fallback: try the standard Audio() path even in Tauri
|
||||
try {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = new Audio(url);
|
||||
await a.play();
|
||||
a.onended = () => URL.revokeObjectURL(url);
|
||||
} catch (e2) {
|
||||
console.error('playBlobAudio fallback error:', e2);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = new Audio(url);
|
||||
a.play().catch(() => {});
|
||||
a.play().catch((e) => console.error('playBlobAudio play error:', e));
|
||||
a.onended = () => URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
@@ -172,7 +192,6 @@ function App() {
|
||||
const [refText, setRefText] = useState('');
|
||||
const [instruct, setInstruct] = useState('');
|
||||
const [language, setLanguage] = useState('Auto');
|
||||
const [langSearch, setLangSearch] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [history, setHistory] = useState([]);
|
||||
const [exportHistory, setExportHistory] = useState([]);
|
||||
@@ -229,8 +248,8 @@ function App() {
|
||||
const [dubStep, setDubStep] = useState('idle');
|
||||
const [dubSegments, setDubSegments] = useState([]);
|
||||
const [dubLang, setDubLang] = useState('Auto');
|
||||
const [dubLangSearch, setDubLangSearch] = useState('');
|
||||
const [dubLangCode, setDubLangCode] = useState('en');
|
||||
const [translateProvider, setTranslateProvider] = useState('argos');
|
||||
const [dubInstruct, setDubInstruct] = useState('');
|
||||
const [dubProgress, setDubProgress] = useState({ current: 0, total: 0, text: '' });
|
||||
const [dubFilename, setDubFilename] = useState('');
|
||||
@@ -238,6 +257,14 @@ function App() {
|
||||
const [dubError, setDubError] = useState('');
|
||||
const [dubVideoFile, setDubVideoFile] = useState(null);
|
||||
const [dubLocalBlobUrl, setDubLocalBlobUrl] = useState(null);
|
||||
const dubBlobUrlRef = useRef(null);
|
||||
useEffect(() => { dubBlobUrlRef.current = dubLocalBlobUrl; }, [dubLocalBlobUrl]);
|
||||
useEffect(() => () => {
|
||||
// Release any outstanding blob URLs on unmount to avoid leaks.
|
||||
const urls = dubBlobUrlRef.current;
|
||||
if (urls?.videoUrl?.startsWith('blob:')) URL.revokeObjectURL(urls.videoUrl);
|
||||
if (urls?.audioUrl?.startsWith('blob:') && urls.audioUrl !== urls.videoUrl) URL.revokeObjectURL(urls.audioUrl);
|
||||
}, []);
|
||||
const [dubTracks, setDubTracks] = useState([]);
|
||||
const [dubTranscript, setDubTranscript] = useState('');
|
||||
const [showTranscript, setShowTranscript] = useState(false);
|
||||
@@ -249,6 +276,7 @@ function App() {
|
||||
const [exportTracks, setExportTracks] = useState({original: true}); // {original: true, es: true, de: false, ...}
|
||||
const [transcribeStart, setTranscribeStart] = useState(null);
|
||||
const [transcribeElapsed, setTranscribeElapsed] = useState(0);
|
||||
const [dubTaskId, setDubTaskId] = useState(null);
|
||||
|
||||
// ═══ STUDIO PROJECTS ═══
|
||||
const [studioProjects, setStudioProjects] = useState([]);
|
||||
@@ -456,6 +484,17 @@ function App() {
|
||||
if (saved.dubTracks) setDubTracks(saved.dubTracks);
|
||||
if (saved.dubStep) setDubStep(saved.dubStep);
|
||||
if (saved.dubTranscript) setDubTranscript(saved.dubTranscript);
|
||||
// Extra UI State
|
||||
if (saved.exportTracks) setExportTracks(saved.exportTracks);
|
||||
if (saved.preserveBg !== undefined) setPreserveBg(saved.preserveBg);
|
||||
if (saved.defaultTrack) setDefaultTrack(saved.defaultTrack);
|
||||
if (saved.exportHistory) setExportHistory(saved.exportHistory);
|
||||
// Inference Parameters
|
||||
if (saved.speed) setSpeed(saved.speed);
|
||||
if (saved.steps) setSteps(saved.steps);
|
||||
if (saved.cfg) setCfg(saved.cfg);
|
||||
if (saved.denoise !== undefined) setDenoise(saved.denoise);
|
||||
if (saved.showOverrides !== undefined) setShowOverrides(saved.showOverrides);
|
||||
} catch (e) {}
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
@@ -465,9 +504,16 @@ function App() {
|
||||
uiScale, text, mode, vdStates, language,
|
||||
isSidebarCollapsed, sidebarTab,
|
||||
dubJobId, dubFilename, dubDuration, dubSegments,
|
||||
dubLang, dubLangCode, dubTracks, dubStep, dubTranscript
|
||||
dubLang, dubLangCode, dubTracks, dubStep, dubTranscript,
|
||||
exportTracks, preserveBg, defaultTrack, exportHistory,
|
||||
speed, steps, cfg, denoise, showOverrides
|
||||
}));
|
||||
}, [uiScale, text, mode, vdStates, language, isSidebarCollapsed, sidebarTab, dubJobId, dubFilename, dubDuration, dubSegments, dubLang, dubLangCode, dubTracks, dubStep, dubTranscript]);
|
||||
}, [
|
||||
uiScale, text, mode, vdStates, language, isSidebarCollapsed, sidebarTab,
|
||||
dubJobId, dubFilename, dubDuration, dubSegments, dubLang, dubLangCode,
|
||||
dubTracks, dubStep, dubTranscript, exportTracks, preserveBg, defaultTrack,
|
||||
exportHistory, speed, steps, cfg, denoise, showOverrides
|
||||
]);
|
||||
|
||||
// ── KEYBOARD SHORTCUTS ──
|
||||
useEffect(() => {
|
||||
@@ -710,10 +756,11 @@ function App() {
|
||||
|
||||
if (fin_prof) formData.append("profile_id", fin_prof);
|
||||
if (fin_inst) formData.append("instruct", fin_inst);
|
||||
const fin_lang = seg.target_lang || dubLang;
|
||||
if (fin_lang !== 'Auto') formData.append("language", fin_lang);
|
||||
|
||||
if (dubLang !== 'Auto') formData.append("language", dubLang);
|
||||
|
||||
formData.append("num_step", steps || 16);
|
||||
// Hardcode lightweight inference steps for live preview to drastically boost timeline responsiveness
|
||||
formData.append("num_step", 8);
|
||||
formData.append("guidance_scale", cfg || 2.0);
|
||||
if (seg.speed && seg.speed !== 1.0) formData.append("speed", seg.speed);
|
||||
|
||||
@@ -855,26 +902,54 @@ function App() {
|
||||
};
|
||||
|
||||
// ═══ DUB WORKFLOW ═══
|
||||
const dubAbortCtrlRef = useRef(null);
|
||||
const dubClientJobIdRef = useRef(null);
|
||||
|
||||
const handleDubUpload = async () => {
|
||||
if (!dubVideoFile) return;
|
||||
setDubStep('uploading'); setDubError(''); setDubTracks([]);
|
||||
const ctrl = new AbortController();
|
||||
dubAbortCtrlRef.current = ctrl;
|
||||
// Generate job_id client-side so we can POST /dub/abort even during upload.
|
||||
const clientJobId = Math.random().toString(36).slice(2, 10);
|
||||
dubClientJobIdRef.current = clientJobId;
|
||||
setDubJobId(clientJobId);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("video", dubVideoFile);
|
||||
const res = await fetch(`${API}/dub/upload`, { method: "POST", body: fd });
|
||||
fd.append("job_id", clientJobId);
|
||||
const res = await fetch(`${API}/dub/upload`, { method: "POST", body: fd, signal: ctrl.signal });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
setDubJobId(data.job_id); setDubFilename(data.filename); setDubDuration(data.duration);
|
||||
setDubStep('transcribing');
|
||||
setTranscribeStart(Date.now());
|
||||
const tRes = await fetch(`${API}/dub/transcribe/${data.job_id}`, { method: "POST" });
|
||||
const tRes = await fetch(`${API}/dub/transcribe/${data.job_id}`, { method: "POST", signal: ctrl.signal });
|
||||
if (!tRes.ok) throw new Error(await tRes.text());
|
||||
const tData = await tRes.json();
|
||||
setDubSegments(tData.segments.map((s, i) => ({ ...s, id: i })));
|
||||
setDubTranscript(tData.full_transcript || '');
|
||||
setTranscribeStart(null);
|
||||
setDubStep('editing');
|
||||
} catch (err) { setDubError(err.message); setDubStep('idle'); setTranscribeStart(null); }
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
toast('Upload cancelled');
|
||||
setDubStep('idle');
|
||||
} else {
|
||||
setDubError(err.message); setDubStep('idle');
|
||||
}
|
||||
setTranscribeStart(null);
|
||||
} finally {
|
||||
dubAbortCtrlRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDubAbort = async () => {
|
||||
const jobId = dubClientJobIdRef.current || dubJobId;
|
||||
if (dubAbortCtrlRef.current) dubAbortCtrlRef.current.abort();
|
||||
if (jobId) {
|
||||
try { await fetch(`${API}/dub/abort/${jobId}`, { method: 'POST' }); } catch (_) {}
|
||||
}
|
||||
};
|
||||
|
||||
// Transcription elapsed timer
|
||||
@@ -885,6 +960,21 @@ function App() {
|
||||
}, [transcribeStart]);
|
||||
|
||||
// ── AUTO-TRANSLATE ──
|
||||
const handleCleanupSegments = async () => {
|
||||
if (!dubJobId || !dubSegments.length) return;
|
||||
const before = dubSegments.length;
|
||||
try {
|
||||
const res = await fetch(`${API}/dub/cleanup-segments/${dubJobId}`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
setDubSegments(data.segments || []);
|
||||
const delta = before - (data.after ?? data.segments.length);
|
||||
toast.success(delta > 0 ? `Cleaned ${delta} fragment${delta === 1 ? '' : 's'}` : 'Segments already clean');
|
||||
} catch (err) {
|
||||
toast.error('Clean up failed: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTranslateAll = async () => {
|
||||
if (!dubSegments.length || !dubLangCode) return;
|
||||
setIsTranslating(true);
|
||||
@@ -893,8 +983,9 @@ function App() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
segments: dubSegments.map(s => ({ id: s.id, text: s.text })),
|
||||
segments: dubSegments.map(s => ({ id: s.id, text: s.text, target_lang: s.target_lang })),
|
||||
target_lang: dubLangCode,
|
||||
provider: translateProvider,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
@@ -929,6 +1020,7 @@ function App() {
|
||||
instruct: fin_inst, profile_id: fin_prof,
|
||||
speed: s.speed || undefined,
|
||||
gain: s.gain !== undefined && s.gain !== 1.0 ? s.gain : undefined,
|
||||
target_lang: s.target_lang || undefined,
|
||||
};
|
||||
}),
|
||||
language: dubLang === 'Auto' ? 'Auto' : dubLang,
|
||||
@@ -943,11 +1035,14 @@ function App() {
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || "Failed to start generation");
|
||||
|
||||
setDubTaskId(data.task_id);
|
||||
|
||||
// Connect to background task SSE stream
|
||||
const streamRes = await fetch(`${API}/tasks/stream/${data.task_id}`);
|
||||
const reader = streamRes.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let wasCancelled = false;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
@@ -965,15 +1060,34 @@ function App() {
|
||||
setDubSegments(prev => prev.map((s, idx) => ({ ...s, sync_ratio: evt.sync_scores[idx] })));
|
||||
}
|
||||
}
|
||||
else if (evt.type === 'cancelled') {
|
||||
wasCancelled = true;
|
||||
setDubStep('editing');
|
||||
setDubError('Generation aborted.');
|
||||
toast('Dubbing aborted', { icon: '⏹' });
|
||||
}
|
||||
else if (evt.type === 'error') setDubError(p => p + `\nSeg ${evt.segment}: ${evt.error}`);
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
setDubTaskId(null);
|
||||
if (!wasCancelled) {
|
||||
if (dubStep !== 'done') setDubStep('done');
|
||||
loadDubHistory();
|
||||
playPing();
|
||||
} catch (err) { setDubError(err.message); setDubStep('editing'); }
|
||||
}
|
||||
} catch (err) { setDubError(err.message); setDubStep('editing'); setDubTaskId(null); }
|
||||
};
|
||||
|
||||
const handleDubStop = async () => {
|
||||
if (!dubTaskId) return;
|
||||
setDubStep('stopping');
|
||||
try {
|
||||
await fetch(`${API}/tasks/cancel/${dubTaskId}`, { method: 'POST' });
|
||||
} catch (e) {
|
||||
toast.error('Failed to stop');
|
||||
}
|
||||
};
|
||||
|
||||
const handleNativeExport = async (e, sourceIdentifier, fallbackName, mode) => {
|
||||
@@ -1012,28 +1126,73 @@ function App() {
|
||||
toast.error(`Could not open folder: ${err.message}`);
|
||||
}
|
||||
};
|
||||
const parseFilenameFromContentDisposition = (header) => {
|
||||
if (!header) return null;
|
||||
const utf8 = header.match(/filename\*=(?:UTF-8|utf-8)''([^;]+)/i);
|
||||
if (utf8) { try { return decodeURIComponent(utf8[1].trim().replace(/^"|"$/g, '')); } catch { /* ignore */ } }
|
||||
const plain = header.match(/filename="?([^";]+)"?/i);
|
||||
return plain ? plain[1].trim() : null;
|
||||
};
|
||||
|
||||
const triggerDownload = async (url, fallbackName) => {
|
||||
const extGuess = (fallbackName.includes('.') ? fallbackName.split('.').pop() : 'bin').toLowerCase();
|
||||
const modeGuess = ['mp4','mov','mkv','webm'].includes(extGuess)
|
||||
? 'video' : ['wav','mp3','flac'].includes(extGuess) ? 'audio' : 'file';
|
||||
|
||||
// In Tauri, WebKit silently drops blob downloads. Use native save dialog
|
||||
// + server-side copy so the file actually lands on disk at a known path.
|
||||
if (isTauri) {
|
||||
try {
|
||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||
const destPath = await save({
|
||||
defaultPath: fallbackName,
|
||||
filters: [{ name: modeGuess === 'video' ? 'Video' : 'Audio', extensions: [extGuess] }],
|
||||
});
|
||||
if (!destPath) return; // user cancelled
|
||||
toast.loading(`Saving ${fallbackName}...`, { id: fallbackName });
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
const res = await fetch(`${url}${sep}save_path=${encodeURIComponent(destPath)}`);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Save failed');
|
||||
}
|
||||
const data = await res.json();
|
||||
toast.success(`Saved: ${data.path}`, { id: fallbackName });
|
||||
try {
|
||||
await fetch(`${API}/export/record`, {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ filename: data.display_name || fallbackName, destination_path: data.path, mode: modeGuess })
|
||||
});
|
||||
loadExportHistory();
|
||||
} catch (_) {}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error(`Save error: ${err.message}`, { id: fallbackName });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Browser path: standard blob download.
|
||||
try {
|
||||
toast.loading(`Processing ${fallbackName}...`, { id: fallbackName });
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error("Download failed");
|
||||
const serverName = parseFilenameFromContentDisposition(response.headers.get('content-disposition'));
|
||||
const finalName = serverName || fallbackName || 'download';
|
||||
const blob = await response.blob();
|
||||
const localUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = localUrl;
|
||||
a.download = fallbackName || 'download';
|
||||
a.download = finalName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(localUrl);
|
||||
toast.success(`Downloaded ${fallbackName}`, { id: fallbackName });
|
||||
// Record to export history
|
||||
toast.success(`Downloaded ${finalName}`, { id: fallbackName });
|
||||
try {
|
||||
const ext = fallbackName.split('.').pop() || '';
|
||||
const mode = ['mp4','mov','mkv','webm'].includes(ext) ? 'video' : ['wav','mp3','flac'].includes(ext) ? 'audio' : 'file';
|
||||
await fetch(`${API}/export/record`, {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ filename: fallbackName, destination_path: `~/Downloads/${fallbackName}`, mode })
|
||||
body: JSON.stringify({ filename: finalName, destination_path: `~/Downloads/${finalName}`, mode: modeGuess })
|
||||
});
|
||||
loadExportHistory();
|
||||
} catch (_) {}
|
||||
@@ -1056,7 +1215,11 @@ function App() {
|
||||
setDubDuration(0); setDubError(''); setDubVideoFile(null); setDubTracks([]);
|
||||
setDubProgress({ current: 0, total: 0, text: '' }); setDubTranscript(''); setShowTranscript(false);
|
||||
setPreviewAudios({});
|
||||
setDubLocalBlobUrl(prev => { if (prev && prev.startsWith('blob:')) URL.revokeObjectURL(prev); return null; });
|
||||
setDubLocalBlobUrl(prev => {
|
||||
if (prev?.videoUrl?.startsWith('blob:')) URL.revokeObjectURL(prev.videoUrl);
|
||||
if (prev?.audioUrl?.startsWith('blob:') && prev.audioUrl !== prev.videoUrl) URL.revokeObjectURL(prev.audioUrl);
|
||||
return null;
|
||||
});
|
||||
setActiveProjectId(null); setActiveProjectName('');
|
||||
};
|
||||
|
||||
@@ -1189,8 +1352,6 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const filteredLangs = langSearch ? ALL_LANGUAGES.filter(l => l.toLowerCase().includes(langSearch.toLowerCase())) : ALL_LANGUAGES;
|
||||
const filteredDubLangs = dubLangSearch ? ALL_LANGUAGES.filter(l => l.toLowerCase().includes(dubLangSearch.toLowerCase())) : ALL_LANGUAGES;
|
||||
|
||||
return (
|
||||
<div className={`app-container${isSidebarCollapsed ? ' sidebar-collapsed' : ''}`} style={{ zoom: uiScale }}>
|
||||
@@ -1199,13 +1360,25 @@ function App() {
|
||||
error: { iconTheme: { primary: '#fb4934', secondary: '#fff' } },
|
||||
success: { iconTheme: { primary: '#b8bb26', secondary: '#fff' } }
|
||||
}}/>
|
||||
<div className="header-area" data-tauri-drag-region onDoubleClick={doubleClickMaximize} style={{position:'relative', display:'flex', justifyContent:'space-between', alignItems:'center', gridColumn: '1 / -1', gridRow: '1', cursor: 'default'}}>
|
||||
{/* Empty placeholder for traffic light buffer */}
|
||||
<div style={{minWidth: 80}}></div>
|
||||
<div className="header-area" data-tauri-drag-region onDoubleClick={doubleClickMaximize} style={{display:'grid', gridTemplateColumns:'1fr auto 1fr', alignItems:'center', gridColumn: '1 / -1', gridRow: '1', cursor: 'default', paddingRight:'8px'}}>
|
||||
{/* Left cluster: traffic light buffer + tabs + dev panel */}
|
||||
<div style={{display:'flex', alignItems:'center', gap:'16px', justifySelf:'start', minWidth:0}}>
|
||||
<div style={{minWidth: 80, flexShrink:0}}></div>
|
||||
<div className="tabs" style={{marginBottom: 0, flexShrink: 0}}>
|
||||
<button className={`tab ${mode === 'launchpad' ? 'active' : ''}`} style={{whiteSpace:'nowrap'}} onClick={() => setMode('launchpad')}><Globe size={11}/> Launchpad</button>
|
||||
<button className={`tab ${mode === 'clone' ? 'active' : ''}`} style={{whiteSpace:'nowrap'}} onClick={() => setMode('clone')}><Fingerprint size={11}/> Clone</button>
|
||||
<button className={`tab ${mode === 'design' ? 'active' : ''}`} style={{whiteSpace:'nowrap'}} onClick={() => setMode('design')}><Wand2 size={11}/> Design</button>
|
||||
<button className={`tab ${mode === 'dub' ? 'active' : ''}`} style={{whiteSpace:'nowrap'}} onClick={() => setMode('dub')}><Film size={11}/> Dub</button>
|
||||
</div>
|
||||
{import.meta.env.DEV && (
|
||||
<button onClick={() => window.location.reload()} title="Force Reload UI" style={{display:'flex', alignItems:'center', gap:4, background:'transparent', border:'1px solid rgba(250,189,47,0.3)', color:'#fabd2f', padding:'3px 8px', borderRadius:4, fontSize:'0.55rem', cursor:'pointer', flexShrink:0}}>
|
||||
<RefreshCw size={9}/> Reload
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Center cluster: logo + tabs */}
|
||||
<div style={{position:'absolute', left:'50%', transform:'translateX(-50%)', display:'flex', alignItems:'center', gap:'16px', minWidth:0, zIndex:10}}>
|
||||
<div style={{display:'flex', alignItems:'center', gap:'6px', flexShrink:0}}>
|
||||
{/* Center: logo */}
|
||||
<div style={{display:'flex', alignItems:'center', gap:'6px', justifySelf:'center', pointerEvents:'none', whiteSpace:'nowrap'}}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#d3869b" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" opacity="0.15" fill="#d3869b"/>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
@@ -1216,18 +1389,8 @@ function App() {
|
||||
<span style={{fontSize:'0.85rem', fontWeight:700, color:'#ebdbb2', letterSpacing:'-0.01em', fontFamily:'Outfit, sans-serif'}}>OmniVoice</span>
|
||||
</div>
|
||||
|
||||
<div style={{width:1, height:16, background:'rgba(255,255,255,0.08)', flexShrink:0}}/>
|
||||
|
||||
<div className="tabs" style={{marginBottom: 0, flexShrink: 0}}>
|
||||
<button className={`tab ${mode === 'launchpad' ? 'active' : ''}`} style={{whiteSpace:'nowrap'}} onClick={() => setMode('launchpad')}><Globe size={11}/> Launchpad</button>
|
||||
<button className={`tab ${mode === 'clone' ? 'active' : ''}`} style={{whiteSpace:'nowrap'}} onClick={() => setMode('clone')}><Fingerprint size={11}/> Clone</button>
|
||||
<button className={`tab ${mode === 'design' ? 'active' : ''}`} style={{whiteSpace:'nowrap'}} onClick={() => setMode('design')}><Wand2 size={11}/> Design</button>
|
||||
<button className={`tab ${mode === 'dub' ? 'active' : ''}`} style={{whiteSpace:'nowrap'}} onClick={() => setMode('dub')}><Film size={11}/> Dub</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right cluster: scale + stats */}
|
||||
<div style={{display: 'flex', alignItems: 'center', gap: '6px', flexShrink: 0, zIndex: 10}}>
|
||||
<div style={{display: 'flex', alignItems: 'center', justifyContent:'flex-end', gap: '6px', justifySelf:'end', minWidth:0, overflow:'hidden'}}>
|
||||
<div style={{display:'flex', gap:1, background:'rgba(0,0,0,0.25)', padding:2, borderRadius:4, border:'1px solid rgba(255,255,255,0.04)', flexShrink:0}}>
|
||||
<button onClick={() => setUiScale(1)} style={{fontSize:'0.55rem', padding:'1px 4px', border:'none', borderRadius:3, cursor:'pointer', background: uiScale === 1 ? 'rgba(255,255,255,0.1)' : 'transparent', color: uiScale === 1 ? '#fff' : '#665c54', whiteSpace:'nowrap'}}>S</button>
|
||||
<button onClick={() => setUiScale(1.3)} style={{fontSize:'0.55rem', padding:'1px 4px', border:'none', borderRadius:3, cursor:'pointer', background: uiScale === 1.3 ? 'rgba(255,255,255,0.1)' : 'transparent', color: uiScale === 1.3 ? '#fff' : '#665c54', whiteSpace:'nowrap'}}>M</button>
|
||||
@@ -1472,9 +1635,14 @@ function App() {
|
||||
disabled={true}
|
||||
overlayContent={
|
||||
dubStep === 'uploading' ? (
|
||||
<div style={{display:'flex', flexDirection:'column', alignItems:'center', gap:8}}>
|
||||
<div style={{display:'flex', flexDirection:'column', alignItems:'center', gap:10}}>
|
||||
<Loader className="spinner" size={20} color="#d3869b"/>
|
||||
<span style={{color:'#ebdbb2', fontWeight:500, fontSize:'0.85rem'}}>Extracting audio…</span>
|
||||
<button onClick={handleDubAbort} style={{
|
||||
display:'flex', alignItems:'center', gap:6, padding:'5px 12px',
|
||||
background:'rgba(251,73,52,0.15)', border:'1px solid rgba(251,73,52,0.4)',
|
||||
color:'#fb4934', borderRadius:6, fontSize:'0.75rem', cursor:'pointer',
|
||||
}}><Square size={11}/> Stop</button>
|
||||
</div>
|
||||
) : dubStep === 'transcribing' ? (
|
||||
<div style={{display:'flex', flexDirection:'column', alignItems:'center', gap:10, width:'100%'}}>
|
||||
@@ -1496,6 +1664,11 @@ function App() {
|
||||
}}/>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={handleDubAbort} style={{
|
||||
display:'flex', alignItems:'center', gap:6, padding:'5px 12px',
|
||||
background:'rgba(251,73,52,0.15)', border:'1px solid rgba(251,73,52,0.4)',
|
||||
color:'#fb4934', borderRadius:6, fontSize:'0.75rem', cursor:'pointer',
|
||||
}}><Square size={11}/> Stop</button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
@@ -1680,21 +1853,25 @@ function App() {
|
||||
videoSrc={`${API}/dub/media/${dubJobId}`}
|
||||
segments={dubSegments}
|
||||
onSegmentsChange={setDubSegments}
|
||||
disabled={dubStep === 'generating'}
|
||||
overlayContent={dubStep === 'generating' ? (
|
||||
disabled={dubStep === 'generating' || dubStep === 'stopping'}
|
||||
overlayContent={(dubStep === 'generating' || dubStep === 'stopping') ? (
|
||||
<div style={{display:'flex', flexDirection:'column', alignItems:'center', gap:6, width:'100%'}}>
|
||||
<div style={{display:'flex', alignItems:'center', gap:6}}>
|
||||
<Sparkles className="spinner" size={14} color="#d3869b"/>
|
||||
<span style={{color:'#ebdbb2', fontWeight:500, fontSize:'0.72rem'}}>
|
||||
Dubbing {dubProgress.current}/{dubProgress.total}…
|
||||
{dubStep === 'stopping' ? <Loader className="spinner" size={14} color="#a89984"/> : <Sparkles className="spinner" size={14} color="#d3869b"/>}
|
||||
<span style={{color: dubStep === 'stopping' ? '#a89984' : '#ebdbb2', fontWeight:500, fontSize:'0.72rem'}}>
|
||||
{dubStep === 'stopping' ? 'Stopping…' : `Dubbing ${dubProgress.current}/${dubProgress.total}…`}
|
||||
</span>
|
||||
</div>
|
||||
{dubStep === 'generating' && (
|
||||
<>
|
||||
<div className="progress-container" style={{width:'80%', maxWidth:240}}>
|
||||
<div className="progress-fill" style={{
|
||||
width:`${dubProgress.total ? (dubProgress.current/dubProgress.total)*100 : 0}%`
|
||||
}}/>
|
||||
</div>
|
||||
{dubProgress.text && <span style={{fontSize:'0.65rem', color:'#a89984'}}>{dubProgress.text}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
/>
|
||||
@@ -1738,31 +1915,50 @@ function App() {
|
||||
<div style={{display:'flex', gap:4, marginBottom:4, flexWrap:'wrap', alignItems:'flex-end'}}>
|
||||
<div style={{flex:1, minWidth:90}}>
|
||||
<div className="label-row"><Globe className="label-icon" size={9}/> Language</div>
|
||||
<select className="input-base" value={dubLang} onChange={e => {
|
||||
const lang = e.target.value;
|
||||
<SearchableSelect
|
||||
size="sm"
|
||||
value={dubLang}
|
||||
options={ALL_LANGUAGES}
|
||||
popular={POPULAR_LANGS}
|
||||
recentsKey="omnivoice.recents.dubLang"
|
||||
onChange={(lang) => {
|
||||
setDubLang(lang);
|
||||
// Auto-sync ISO code when language name changes
|
||||
const match = LANG_CODES.find(lc => lc.label.toLowerCase() === lang.toLowerCase());
|
||||
if (match) setDubLangCode(match.code);
|
||||
}} style={{fontSize:'0.65rem'}}>
|
||||
{filteredDubLangs.map(l => <option key={l} value={l}>{l}</option>)}
|
||||
</select>
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{flex:1, minWidth:80}}>
|
||||
<div className="label-row">ISO Code</div>
|
||||
<select className="input-base" value={dubLangCode} onChange={e => setDubLangCode(e.target.value)} style={{fontSize:'0.65rem'}}>
|
||||
{LANG_CODES.map(lc => <option key={lc.code} value={lc.code}>{lc.code} — {lc.label}</option>)}
|
||||
</select>
|
||||
<SearchableSelect
|
||||
size="sm"
|
||||
value={dubLangCode}
|
||||
options={LANG_CODES.map(lc => ({ value: lc.code, label: `${lc.code} — ${lc.label}` }))}
|
||||
popular={POPULAR_ISO}
|
||||
recentsKey="omnivoice.recents.dubIso"
|
||||
onChange={setDubLangCode}
|
||||
/>
|
||||
</div>
|
||||
<div style={{flex:1, minWidth:90}}>
|
||||
<div className="label-row"><UserSquare2 className="label-icon" size={9}/> Style</div>
|
||||
<input className="input-base" placeholder="e.g. female" value={dubInstruct} onChange={e => setDubInstruct(e.target.value)} style={{fontSize:'0.65rem'}}/>
|
||||
</div>
|
||||
<div style={{flex:1, minWidth:90}}>
|
||||
<div className="label-row">Engine</div>
|
||||
<select className="input-base" value={translateProvider} onChange={e => setTranslateProvider(e.target.value)} style={{fontSize:'0.65rem', padding: '5px 8px'}}>
|
||||
{[{id: 'argos', name: 'Argos (Fast Local)'}, {id: 'nllb', name: 'NLLB (Heavy Local)'}, {id: 'google', name: 'Google (Online)'}, {id: 'openai', name: 'OpenAI (LLM)'}].map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={handleTranslateAll} disabled={isTranslating || !dubSegments.length}
|
||||
style={{padding:'3px 8px', background:'rgba(131,165,152,0.12)', border:'1px solid rgba(131,165,152,0.25)', color:'#83a598', borderRadius:4, cursor:'pointer', fontSize:'0.62rem', fontWeight:500, display:'flex', alignItems:'center', gap:3, whiteSpace:'nowrap'}}>
|
||||
{isTranslating ? <Loader className="spinner" size={9}/> : <Languages size={10}/>}
|
||||
{isTranslating ? 'Translating…' : 'Translate All'}
|
||||
</button>
|
||||
<button onClick={handleCleanupSegments} disabled={!dubSegments.length || !dubJobId}
|
||||
title="Merge tiny fragments and adjacent short segments"
|
||||
style={{padding:'3px 8px', background:'rgba(250,189,47,0.10)', border:'1px solid rgba(250,189,47,0.22)', color:'#fabd2f', borderRadius:4, cursor:'pointer', fontSize:'0.62rem', fontWeight:500, display:'flex', alignItems:'center', gap:3, whiteSpace:'nowrap'}}>
|
||||
<Wand2 size={10}/> Clean Up
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Full transcript toggle */}
|
||||
@@ -1817,12 +2013,13 @@ function App() {
|
||||
<span style={{width:55}}>Time</span>
|
||||
<span style={{width:50}}>Spkr</span>
|
||||
<span style={{flex:1}}>Text</span>
|
||||
<span style={{width:45}}>Lang</span>
|
||||
<span style={{width:90}}>Voice</span>
|
||||
<span style={{width:30}} title="Volume (0-200%)">Vol</span>
|
||||
<span style={{width:40}}></span>
|
||||
</div>
|
||||
{dubSegments.map((seg, idx) => (
|
||||
<div key={seg.id} className={`segment-row ${dubStep==='generating'&&dubProgress.current===idx+1?'segment-active':''} ${dubStep==='generating'&&dubProgress.current>idx+1?'segment-done':''}`}>
|
||||
<div key={seg.id} className={`segment-row ${(dubStep==='generating'||dubStep==='stopping')&&dubProgress.current===idx+1?'segment-active':''} ${(dubStep==='generating'||dubStep==='stopping')&&dubProgress.current>idx+1?'segment-done':''}`}>
|
||||
<span className="segment-time" style={{width:55, display:'flex', flexDirection:'column'}}>
|
||||
<span>
|
||||
{formatTime(seg.start)}–{formatTime(seg.end)}
|
||||
@@ -1851,9 +2048,15 @@ function App() {
|
||||
<span style={{width:50, fontSize:'0.58rem', color:'#a89984'}}>{seg.speaker_id || ''}</span>
|
||||
<input className="input-base segment-input" value={seg.text}
|
||||
onChange={e => editSegments(dubSegments.map(s => s.id===seg.id?{...s,text:e.target.value}:s))}
|
||||
disabled={dubStep==='generating'}/>
|
||||
disabled={dubStep==='generating'||dubStep==='stopping'}/>
|
||||
<select className="input-base segment-input" style={{width:45, fontSize:'0.55rem', padding:'1px 2px'}}
|
||||
value={seg.target_lang||''} disabled={dubStep==='generating'||dubStep==='stopping'}
|
||||
onChange={e => editSegments(dubSegments.map(s => s.id===seg.id?{...s,target_lang:e.target.value}:s))}>
|
||||
<option value="">(Def)</option>
|
||||
{LANG_CODES.map(lc => <option key={lc.code} value={lc.code}>{lc.code.toUpperCase()}</option>)}
|
||||
</select>
|
||||
<select className="input-base" style={{width:90, fontSize:'0.6rem', padding:'1px 3px'}}
|
||||
value={seg.profile_id||''} disabled={dubStep==='generating'}
|
||||
value={seg.profile_id||''} disabled={dubStep==='generating'||dubStep==='stopping'}
|
||||
onChange={e => editSegments(dubSegments.map(s => s.id===seg.id?{...s,profile_id:e.target.value}:s))}>
|
||||
<option value="">Default</option>
|
||||
{profiles.length > 0 && (
|
||||
@@ -1868,15 +2071,15 @@ function App() {
|
||||
)}
|
||||
</select>
|
||||
<input type="range" min="0" max="200" value={Math.round((seg.gain ?? 1.0) * 100)} title={`${Math.round((seg.gain ?? 1.0) * 100)}%`}
|
||||
disabled={dubStep==='generating'}
|
||||
disabled={dubStep==='generating'||dubStep==='stopping'}
|
||||
onChange={e => editSegments(dubSegments.map(s => s.id===seg.id?{...s,gain:Number(e.target.value)/100}:s))}
|
||||
style={{width:30, height:2, padding:0, margin:0, accentColor: (seg.gain ?? 1.0) > 1.2 ? '#fb4934' : (seg.gain ?? 1.0) < 0.5 ? '#83a598' : '#a89984'}}
|
||||
/>
|
||||
<div style={{display:'flex', gap:1, width:40}}>
|
||||
<button className="segment-play" disabled={dubStep==='generating'} title="Live Preview" onClick={(e) => handleSegmentPreview(seg, e)}>
|
||||
<button className="segment-play" disabled={dubStep==='generating'||dubStep==='stopping'} title="Live Preview" onClick={(e) => handleSegmentPreview(seg, e)}>
|
||||
{segmentPreviewLoading === seg.id ? <Loader className="spinner" size={9}/> : <Headphones size={9}/>}
|
||||
</button>
|
||||
<button className="segment-del" disabled={dubStep==='generating'}
|
||||
<button className="segment-del" disabled={dubStep==='generating'||dubStep==='stopping'}
|
||||
onClick={() => editSegments(dubSegments.filter(s=>s.id!==seg.id))}><Trash2 size={9}/></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1930,9 +2133,20 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
<div style={{display:'flex', gap:4}}>
|
||||
<button className="btn-primary" style={{marginTop:0, flex:1, padding:'4px 8px', fontSize:'0.7rem'}} onClick={handleDubGenerate} disabled={dubStep==='generating'||!dubSegments.length}>
|
||||
{dubStep==='generating' ? <><Sparkles className="spinner" size={11}/> Dubbing…</> : <><Play size={11}/> Generate Dub</>}
|
||||
{dubStep==='stopping' ? (
|
||||
<button className="btn-primary" disabled style={{marginTop:0, flex:1, padding:'4px 8px', fontSize:'0.7rem', background:'linear-gradient(135deg,#504945,#3c3836)', opacity:0.8}}>
|
||||
<Loader className="spinner" size={9}/> Stopping…
|
||||
</button>
|
||||
) : dubStep==='generating' ? (
|
||||
<button className="btn-primary" style={{marginTop:0, flex:1, padding:'4px 8px', fontSize:'0.7rem', background:'linear-gradient(135deg,#fb4934,#cc241d)'}}
|
||||
onClick={handleDubStop}>
|
||||
<Square size={9}/> Stop ({dubProgress.current}/{dubProgress.total})
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn-primary" style={{marginTop:0, flex:1, padding:'4px 8px', fontSize:'0.7rem'}} onClick={handleDubGenerate} disabled={!dubSegments.length}>
|
||||
<Play size={11}/> Generate Dub
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-primary" style={{marginTop:0, flex:1, padding:'4px 8px', fontSize:'0.7rem', background:dubStep==='done'?'linear-gradient(135deg,#8ec07c,#689d6a)':undefined}}
|
||||
onClick={handleDubDownload} disabled={dubStep!=='done'}>
|
||||
<DownloadIcon size={11}/> MP4
|
||||
@@ -1998,14 +2212,13 @@ function App() {
|
||||
<div className="grid-2">
|
||||
<div>
|
||||
<div className="label-row"><Globe className="label-icon" size={14}/> Language ({ALL_LANGUAGES.length - 1})</div>
|
||||
<div style={{position:'relative'}}>
|
||||
<Search size={14} color="#a89984" style={{position:'absolute', left:8, top:8, zIndex:1}}/>
|
||||
<input type="text" className="input-base" style={{paddingLeft:28, fontSize:'0.8rem', marginBottom:4}}
|
||||
placeholder="Search languages..." value={langSearch} onChange={e => setLangSearch(e.target.value)}/>
|
||||
<select className="input-base" value={language} onChange={e => setLanguage(e.target.value)}>
|
||||
{filteredLangs.map(l => <option key={l} value={l}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<SearchableSelect
|
||||
value={language}
|
||||
options={ALL_LANGUAGES}
|
||||
popular={POPULAR_LANGS}
|
||||
recentsKey="omnivoice.recents.genLang"
|
||||
onChange={setLanguage}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="label-row" style={{justifyContent:'space-between'}}>
|
||||
@@ -2242,28 +2455,32 @@ function App() {
|
||||
</div>
|
||||
) : (
|
||||
studioProjects.map(proj => (
|
||||
<div key={proj.id} className={`history-item ${activeProjectId === proj.id ? 'project-active' : ''}`} style={{position:'relative'}}>
|
||||
<div className="history-header">
|
||||
<div className="history-badge" style={{background: activeProjectId === proj.id ? 'rgba(184,187,38,0.2)' : 'rgba(131,165,152,0.15)', color: activeProjectId === proj.id ? '#b8bb26' : '#83a598'}}>
|
||||
<div key={proj.id} className={`history-item ${activeProjectId === proj.id ? 'project-active' : ''}`} style={{ padding: '8px 10px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div className="history-header" style={{ marginBottom: 0 }}>
|
||||
<span style={{ fontSize: '0.55rem', fontWeight: 600, color: activeProjectId === proj.id ? '#b8bb26' : '#83a598', letterSpacing: '0.02em', textTransform: 'uppercase', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Film size={10}/> DUB PROJECT
|
||||
</span>
|
||||
<div style={{fontSize:'0.6rem', color:'#665c54', margin:0, opacity:0.8}}>
|
||||
<Clock size={8} style={{verticalAlign:'middle', marginRight:2, marginTop:-1}}/>
|
||||
{new Date(proj.updated_at * 1000).toLocaleString([], {hour: '2-digit', minute:'2-digit', month: 'short', day: 'numeric'})}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontSize:'0.78rem', color:'var(--text-primary)', marginBottom:2, fontWeight:500}}>
|
||||
<div style={{fontSize:'0.72rem', color:'var(--text-primary)', wordWrap:'break-word', fontWeight:500, lineHeight: 1.2}}>
|
||||
{proj.name}
|
||||
</div>
|
||||
<div style={{display:'flex', gap:6, fontSize:'0.65rem', color:'var(--text-secondary)'}}>
|
||||
<div style={{display:'flex', gap:6, fontSize:'0.58rem', color:'var(--text-secondary)'}}>
|
||||
{proj.duration && <span>{Math.round(proj.duration)}s</span>}
|
||||
{proj.video_path && <span>· {proj.video_path}</span>}
|
||||
{proj.video_path && <span style={{whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis'}}>· {proj.video_path.split(/[\\/]/).pop()}</span>}
|
||||
</div>
|
||||
<div style={{fontSize:'0.6rem', color:'#665c54', marginTop:2}}>
|
||||
<Clock size={9} style={{verticalAlign:'middle', marginRight:3}}/>
|
||||
{new Date(proj.updated_at * 1000).toLocaleString()}
|
||||
</div>
|
||||
<div style={{display:'flex', gap:'6px', marginTop:'8px'}}>
|
||||
<button onClick={() => loadProject(proj.id)} style={{flex:1, padding:'4px', background:'rgba(255,255,255,0.05)', border:'1px solid rgba(255,255,255,0.1)', color:'#ebdbb2', borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', gap:'4px'}}>
|
||||
<FolderOpen size={10}/> Load
|
||||
<div style={{display:'flex', gap:'6px', marginTop:'2px'}}>
|
||||
<button onClick={() => loadProject(proj.id)} className="btn-base" style={{flex:1, padding:'4px', background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.05)', color:'var(--text-secondary)', borderRadius:'4px', fontSize:'0.65rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', gap:'4px', transition:'all 0.15s ease'}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color='#ebdbb2'; e.currentTarget.style.borderColor='rgba(255,255,255,0.1)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color='var(--text-secondary)'; e.currentTarget.style.borderColor='rgba(255,255,255,0.05)'; }}>
|
||||
<FolderOpen size={10}/> Load Project
|
||||
</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); deleteProject(proj.id); }} style={{padding:'4px 8px', background:'rgba(251,73,52,0.1)', border:'1px solid rgba(251,73,52,0.2)', color:'#fb4934', borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', flexShrink:0}}>
|
||||
<button onClick={(e) => { e.stopPropagation(); deleteProject(proj.id); }} style={{padding:'4px 8px', background:'rgba(251,73,52,0.05)', border:'1px solid transparent', color:'#fb4934', opacity:0.6, borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', flexShrink:0, transition:'all 0.15s ease'}}
|
||||
onMouseEnter={e => { e.currentTarget.style.opacity=1; e.currentTarget.style.background='rgba(251,73,52,0.15)'; e.currentTarget.style.borderColor='rgba(251,73,52,0.3)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.opacity=0.6; e.currentTarget.style.background='rgba(251,73,52,0.05)'; e.currentTarget.style.borderColor='transparent'; }}>
|
||||
<Trash2 size={10}/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -2283,32 +2500,41 @@ function App() {
|
||||
</div>
|
||||
) : (
|
||||
(mode === 'clone' ? profiles.filter(p => !p.instruct) : profiles.filter(p => !!p.instruct)).map(proj => (
|
||||
<div key={proj.id} className={`history-item ${selectedProfile === proj.id ? 'project-active' : ''}`} style={{position:'relative', borderLeft: proj.is_locked ? '2px solid #b8bb26' : undefined}}>
|
||||
<div className="history-header">
|
||||
<div className="history-badge" style={{background: proj.is_locked ? 'rgba(184,187,38,0.2)' : 'rgba(142,192,124,0.15)', color: proj.is_locked ? '#b8bb26' : '#8ec07c'}}>
|
||||
<div key={proj.id} className={`history-item ${selectedProfile === proj.id ? 'project-active' : ''}`} style={{ borderLeft: proj.is_locked ? '2px solid #b8bb26' : undefined, padding: '8px 10px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div className="history-header" style={{ marginBottom: 0 }}>
|
||||
<span style={{ fontSize: '0.55rem', fontWeight: 600, color: proj.is_locked ? '#b8bb26' : (mode === 'clone' ? '#d3869b' : '#8ec07c'), letterSpacing: '0.02em', textTransform: 'uppercase', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{proj.is_locked ? <Lock size={10}/> : (mode === 'clone' ? <Fingerprint size={10}/> : <Wand2 size={10}/>)} {proj.is_locked ? 'LOCKED' : (mode === 'clone' ? 'CLONE' : 'DESIGN')}
|
||||
</div>
|
||||
</span>
|
||||
{proj.is_locked && (
|
||||
<div style={{fontSize:'0.55rem', color:'#b8bb26', fontStyle:'italic'}}>Consistent</div>
|
||||
<div style={{fontSize:'0.55rem', color:'#b8bb26', fontStyle:'italic', margin:0}}>Consistent</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{fontSize:'0.78rem', color:'var(--text-primary)', marginBottom:2, fontWeight:500}}>
|
||||
<div style={{fontSize:'0.72rem', color:'var(--text-primary)', wordWrap:'break-word', fontWeight:500, lineHeight: 1.2}}>
|
||||
{proj.name}
|
||||
</div>
|
||||
{proj.instruct && <div style={{fontSize:'0.6rem', color:'#a89984', fontStyle:'italic', marginBottom:2}}>{proj.instruct}</div>}
|
||||
<div style={{display:'flex', gap:'6px', marginTop:'8px'}}>
|
||||
<button onClick={(e) => handlePreviewVoice(proj, e)} style={{padding:'4px 8px', background:'rgba(211,134,155,0.1)', border:'1px solid rgba(211,134,155,0.2)', color:'#d3869b', borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', flexShrink:0}} title="Preview voice">
|
||||
{proj.instruct && <div style={{fontSize:'0.6rem', color:'var(--text-secondary)', fontStyle:'italic'}}>{proj.instruct}</div>}
|
||||
|
||||
<div style={{display:'flex', gap:'6px', marginTop:'2px'}}>
|
||||
<button onClick={(e) => handlePreviewVoice(proj, e)} style={{padding:'4px 8px', background:'rgba(211,134,155,0.05)', border:'1px solid transparent', color:'#d3869b', opacity: 0.8, borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', flexShrink:0, transition:'all 0.15s ease'}} title="Preview voice"
|
||||
onMouseEnter={e => { e.currentTarget.style.opacity=1; e.currentTarget.style.background='rgba(211,134,155,0.15)'; e.currentTarget.style.borderColor='rgba(211,134,155,0.3)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.opacity=0.8; e.currentTarget.style.background='rgba(211,134,155,0.05)'; e.currentTarget.style.borderColor='transparent'; }}>
|
||||
{previewLoading === proj.id ? <Loader className="spinner" size={10}/> : <Play size={10}/>}
|
||||
</button>
|
||||
<button onClick={() => handleSelectProfile(proj)} style={{flex:1, padding:'4px', background:'rgba(255,255,255,0.05)', border:'1px solid rgba(255,255,255,0.1)', color:'#ebdbb2', borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', gap:'4px'}}>
|
||||
<FolderOpen size={10}/> Select
|
||||
<button onClick={() => handleSelectProfile(proj)} style={{flex:1, padding:'4px', background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.05)', color:'var(--text-secondary)', borderRadius:'4px', fontSize:'0.65rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', gap:'4px', transition:'all 0.15s ease'}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color='#ebdbb2'; e.currentTarget.style.borderColor='rgba(255,255,255,0.1)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color='var(--text-secondary)'; e.currentTarget.style.borderColor='rgba(255,255,255,0.05)'; }}>
|
||||
<Check size={10}/> Select
|
||||
</button>
|
||||
{proj.is_locked && (
|
||||
<button onClick={(e) => { e.stopPropagation(); handleUnlockProfile(proj.id); }} style={{padding:'4px 8px', background:'rgba(184,187,38,0.1)', border:'1px solid rgba(184,187,38,0.2)', color:'#b8bb26', borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', gap:'2px', flexShrink:0}} title="Unlock: voice will vary between generations">
|
||||
<button onClick={(e) => { e.stopPropagation(); handleUnlockProfile(proj.id); }} style={{padding:'4px 8px', background:'rgba(184,187,38,0.05)', border:'1px solid transparent', color:'#b8bb26', opacity: 0.8, borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', gap:'2px', flexShrink:0, transition:'all 0.15s ease'}} title="Unlock: voice will vary between generations"
|
||||
onMouseEnter={e => { e.currentTarget.style.opacity=1; e.currentTarget.style.background='rgba(184,187,38,0.15)'; e.currentTarget.style.borderColor='rgba(184,187,38,0.3)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.opacity=0.8; e.currentTarget.style.background='rgba(184,187,38,0.05)'; e.currentTarget.style.borderColor='transparent'; }}>
|
||||
<Unlock size={10}/>
|
||||
</button>
|
||||
)}
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDeleteProfile(proj.id); }} style={{padding:'4px 8px', background:'rgba(251,73,52,0.1)', border:'1px solid rgba(251,73,52,0.2)', color:'#fb4934', borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', flexShrink:0}}>
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDeleteProfile(proj.id); }} style={{padding:'4px 8px', background:'rgba(251,73,52,0.05)', border:'1px solid transparent', color:'#fb4934', opacity: 0.6, borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', flexShrink:0, transition:'all 0.15s ease'}} title="Delete"
|
||||
onMouseEnter={e => { e.currentTarget.style.opacity=1; e.currentTarget.style.background='rgba(251,73,52,0.15)'; e.currentTarget.style.borderColor='rgba(251,73,52,0.3)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.opacity=0.6; e.currentTarget.style.background='rgba(251,73,52,0.05)'; e.currentTarget.style.borderColor='transparent'; }}>
|
||||
<Trash2 size={10}/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -2357,30 +2583,33 @@ function App() {
|
||||
<>
|
||||
{/* Dub history */}
|
||||
{!isSidebarCollapsed && dubHistory.map(item => (
|
||||
<div key={`dub-${item.id}`} className="history-item">
|
||||
<div className="history-header">
|
||||
<div className="history-badge" style={{background:'rgba(131,165,152,0.15)', color:'#83a598'}}>
|
||||
<div key={`dub-${item.id}`} className="history-item" style={{ padding: '8px 10px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div className="history-header" style={{ marginBottom: 0 }}>
|
||||
<span style={{ fontSize: '0.55rem', fontWeight: 600, color: '#83a598', letterSpacing: '0.02em', textTransform: 'uppercase', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Film size={10}/> DUB
|
||||
</span>
|
||||
<div className="history-time" style={{margin:0, opacity:0.6}}>{item.segments_count} segs</div>
|
||||
</div>
|
||||
<div className="history-time">{item.segments_count} segs</div>
|
||||
</div>
|
||||
<div style={{fontSize:'0.75rem', color:'var(--text-primary)', marginBottom:2}}>
|
||||
<div style={{fontSize:'0.72rem', color:'var(--text-primary)', wordWrap:'break-word', fontWeight:500, lineHeight: 1.2}}>
|
||||
{item.filename}
|
||||
</div>
|
||||
<div style={{display:'flex', gap:4, flexWrap:'wrap', marginBottom:2}}>
|
||||
<span style={{fontSize:'0.65rem', padding:'1px 6px', background:'rgba(131,165,152,0.15)', color:'#83a598', borderRadius:4}}>
|
||||
<div style={{display:'flex', gap:4, flexWrap:'wrap'}}>
|
||||
<span style={{fontSize:'0.6rem', padding:'2px 6px', background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.05)', color:'var(--text-secondary)', borderRadius:4}}>
|
||||
{item.language} ({item.language_code})
|
||||
</span>
|
||||
<span style={{fontSize:'0.65rem', color:'var(--text-secondary)'}}>
|
||||
<span style={{fontSize:'0.6rem', padding:'2px 6px', background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.05)', color:'var(--text-secondary)', borderRadius:4}}>
|
||||
{Math.round(item.duration)}s
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{display:'flex', gap:'6px', marginTop:'8px'}}>
|
||||
<button onClick={() => restoreDubHistory(item)} style={{flex:1, padding:'4px', background:'rgba(255,255,255,0.05)', border:'1px solid rgba(255,255,255,0.1)', color:'#ebdbb2', borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', gap:'4px'}}>
|
||||
<FolderOpen size={10}/> Load
|
||||
<div style={{display:'flex', gap:'6px', marginTop:'2px'}}>
|
||||
<button onClick={() => restoreDubHistory(item)} className="btn-base" style={{flex:1, padding:'4px', background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.05)', color:'var(--text-secondary)', borderRadius:'4px', fontSize:'0.65rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', gap:'4px', transition:'all 0.15s ease'}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color='#ebdbb2'; e.currentTarget.style.borderColor='rgba(255,255,255,0.1)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color='var(--text-secondary)'; e.currentTarget.style.borderColor='rgba(255,255,255,0.05)'; }}>
|
||||
<FolderOpen size={10}/> Load Project
|
||||
</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); deleteHistory(item.id, 'dub'); }} style={{padding:'4px 8px', background:'rgba(251,73,52,0.1)', border:'1px solid rgba(251,73,52,0.2)', color:'#fb4934', borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', flexShrink:0}}>
|
||||
<button onClick={(e) => { e.stopPropagation(); deleteHistory(item.id, 'dub'); }} style={{padding:'4px 8px', background:'rgba(251,73,52,0.05)', border:'1px solid transparent', color:'#fb4934', opacity:0.6, borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', flexShrink:0, transition:'all 0.15s ease'}}
|
||||
onMouseEnter={e => { e.currentTarget.style.opacity=1; e.currentTarget.style.background='rgba(251,73,52,0.15)'; e.currentTarget.style.borderColor='rgba(251,73,52,0.3)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.opacity=0.6; e.currentTarget.style.background='rgba(251,73,52,0.05)'; e.currentTarget.style.borderColor='transparent'; }}>
|
||||
<Trash2 size={10}/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -2389,35 +2618,49 @@ function App() {
|
||||
|
||||
{/* Clone/Design history */}
|
||||
{!isSidebarCollapsed && history.map(item => (
|
||||
<div key={item.id} className="history-item">
|
||||
<div className="history-header">
|
||||
<div className="history-badge">
|
||||
{item.mode === 'clone' ? <Fingerprint size={10}/> : <Wand2 size={10}/>} {(item.mode||'').toUpperCase()}
|
||||
<div key={item.id} className="history-item" style={{ padding: '8px 10px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div className="history-header" style={{ marginBottom: 0 }}>
|
||||
<span style={{ fontSize: '0.55rem', fontWeight: 600, color: item.mode === 'clone' ? '#d3869b' : '#b8bb26', letterSpacing: '0.02em', textTransform: 'uppercase', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{item.mode === 'clone' ? <Fingerprint size={10}/> : <Wand2 size={10}/>} {(item.mode||'synth')}
|
||||
</span>
|
||||
<div className="history-time" style={{margin:0, opacity:0.6}}>
|
||||
{item.language && item.language !== 'Auto' && <span style={{marginRight:6}}>{item.language}</span>}
|
||||
{item.generation_time && <span>{item.generation_time}s</span>}
|
||||
</div>
|
||||
{item.generation_time && <div className="history-time">{item.generation_time}s</div>}
|
||||
</div>
|
||||
{item.language && item.language !== 'Auto' && <div style={{fontSize:'0.65rem', color:'#83a598', marginBottom:4}}>{item.language}</div>}
|
||||
{item.seed && <div style={{fontSize:'0.55rem', color:'#665c54', marginBottom:2}}>seed: {item.seed}</div>}
|
||||
<div className="history-text" title={item.text}>{item.text}</div>
|
||||
{item.audio_path && <audio controls src={`${API}/audio/${item.audio_path}`} />}
|
||||
{item.seed && <div style={{fontSize:'0.55rem', color:'var(--text-secondary)', opacity: 0.6}}>seed: {item.seed}</div>}
|
||||
<div className="history-text" title={item.text} style={{marginTop: 2, color: 'var(--text-primary)', lineHeight: 1.3}}>{item.text}</div>
|
||||
|
||||
{item.audio_path && <audio controls src={`${API}/audio/${item.audio_path}`} style={{height: 24, marginTop: 4, width: '100%'}} />}
|
||||
|
||||
{item.audio_path && (
|
||||
<div style={{display:'flex', gap:'6px', marginTop:'8px', flexWrap:'wrap'}}>
|
||||
<button onClick={() => handleSaveHistoryAsProfile(item)} style={{flex:1, padding:'4px', background:'rgba(142,192,124,0.1)', border:'1px solid rgba(142,192,124,0.2)', color:'#8ec07c', borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', gap:'4px', whiteSpace:'nowrap'}}>
|
||||
<Save size={10}/> Save Profile
|
||||
<div style={{display:'flex', gap:'6px', marginTop:'2px', flexWrap:'wrap'}}>
|
||||
<button onClick={() => handleSaveHistoryAsProfile(item)} style={{flex:1, padding:'4px', background:'rgba(142,192,124,0.05)', border:'1px solid transparent', color:'#8ec07c', opacity: 0.8, borderRadius:'4px', fontSize:'0.65rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', gap:'4px', whiteSpace:'nowrap', transition:'all 0.15s ease'}}
|
||||
onMouseEnter={e => { e.currentTarget.style.opacity=1; e.currentTarget.style.background='rgba(142,192,124,0.15)'; e.currentTarget.style.borderColor='rgba(142,192,124,0.3)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.opacity=0.8; e.currentTarget.style.background='rgba(142,192,124,0.05)'; e.currentTarget.style.borderColor='transparent'; }}>
|
||||
<Save size={10}/> Save
|
||||
</button>
|
||||
{/* Lock Voice: only for items that have an associated profile */}
|
||||
|
||||
{item.profile_id && (
|
||||
<button onClick={() => handleLockProfile(item.profile_id, item.id, item.seed)} style={{padding:'4px 8px', background:'rgba(184,187,38,0.1)', border:'1px solid rgba(184,187,38,0.2)', color:'#b8bb26', borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', gap:'3px', whiteSpace:'nowrap'}} title="Lock this exact voice identity for consistent regeneration">
|
||||
<button onClick={() => handleLockProfile(item.profile_id, item.id, item.seed)} style={{padding:'4px 8px', background:'rgba(184,187,38,0.05)', border:'1px solid transparent', color:'#b8bb26', opacity: 0.8, borderRadius:'4px', fontSize:'0.65rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', gap:'3px', whiteSpace:'nowrap', transition:'all 0.15s ease'}} title="Lock this exact voice identity"
|
||||
onMouseEnter={e => { e.currentTarget.style.opacity=1; e.currentTarget.style.background='rgba(184,187,38,0.15)'; e.currentTarget.style.borderColor='rgba(184,187,38,0.3)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.opacity=0.8; e.currentTarget.style.background='rgba(184,187,38,0.05)'; e.currentTarget.style.borderColor='transparent'; }}>
|
||||
<Lock size={10}/> Lock
|
||||
</button>
|
||||
)}
|
||||
<button onClick={(e) => handleNativeExport(e, item.audio_path, item.audio_path, item.mode)} style={{padding:'4px 8px', background:'rgba(255,255,255,0.05)', border:'1px solid rgba(255,255,255,0.1)', color:'#ebdbb2', borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', gap:'4px'}}>
|
||||
<button onClick={(e) => handleNativeExport(e, item.audio_path, item.audio_path, item.mode)} style={{padding:'4px 8px', background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.05)', color:'var(--text-secondary)', borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', transition:'all 0.15s ease'}} title="Export"
|
||||
onMouseEnter={e => { e.currentTarget.style.color='#ebdbb2'; e.currentTarget.style.borderColor='rgba(255,255,255,0.1)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color='var(--text-secondary)'; e.currentTarget.style.borderColor='rgba(255,255,255,0.05)'; }}>
|
||||
<DownloadIcon size={10}/>
|
||||
</button>
|
||||
<button onClick={() => restoreHistory(item)} style={{padding:'4px 8px', background:'rgba(255,255,255,0.05)', border:'1px solid rgba(255,255,255,0.1)', color:'#ebdbb2', borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', gap:'4px'}}>
|
||||
<button onClick={() => restoreHistory(item)} style={{padding:'4px 8px', background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.05)', color:'var(--text-secondary)', borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', transition:'all 0.15s ease'}} title="Load Configuration"
|
||||
onMouseEnter={e => { e.currentTarget.style.color='#ebdbb2'; e.currentTarget.style.borderColor='rgba(255,255,255,0.1)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color='var(--text-secondary)'; e.currentTarget.style.borderColor='rgba(255,255,255,0.05)'; }}>
|
||||
<FolderOpen size={10}/>
|
||||
</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); deleteHistory(item.id, 'synth'); }} style={{padding:'4px 8px', background:'rgba(251,73,52,0.1)', border:'1px solid rgba(251,73,52,0.2)', color:'#fb4934', borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', flexShrink:0}}>
|
||||
<button onClick={(e) => { e.stopPropagation(); deleteHistory(item.id, 'synth'); }} style={{padding:'4px 8px', background:'rgba(251,73,52,0.05)', border:'1px solid transparent', color:'#fb4934', opacity:0.6, borderRadius:'4px', fontSize:'0.7rem', cursor:'pointer', display:'flex', justifyContent:'center', alignItems:'center', flexShrink:0, transition:'all 0.15s ease'}} title="Delete"
|
||||
onMouseEnter={e => { e.currentTarget.style.opacity=1; e.currentTarget.style.background='rgba(251,73,52,0.15)'; e.currentTarget.style.borderColor='rgba(251,73,52,0.3)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.opacity=0.6; e.currentTarget.style.background='rgba(251,73,52,0.05)'; e.currentTarget.style.borderColor='transparent'; }}>
|
||||
<Trash2 size={10}/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -2458,7 +2701,7 @@ function App() {
|
||||
{/* ── DOWNLOADS TAB ── */}
|
||||
{sidebarTab === 'downloads' && (
|
||||
<>
|
||||
{!isSidebarCollapsed && <div style={{fontSize:'0.68rem', color:'var(--text-secondary)', marginBottom:8}}>History of natively exported media</div>}
|
||||
{!isSidebarCollapsed && <div style={{fontSize:'0.68rem', color:'var(--text-secondary)', marginBottom:8}}>Recent Exports</div>}
|
||||
{exportHistory.length === 0 ? (
|
||||
<div style={{color:'var(--text-secondary)', textAlign:'center', padding:'24px 12px'}}>
|
||||
<DownloadCloud size={28} style={{opacity:0.3, marginBottom:8}} />
|
||||
@@ -2467,39 +2710,44 @@ function App() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!isSidebarCollapsed && exportHistory.map(item => (
|
||||
<div key={item.id} className="history-item">
|
||||
<div className="history-header">
|
||||
<div className="history-badge" style={{background:'rgba(142,192,124,0.15)', color:'#8ec07c'}}>
|
||||
<DownloadCloud size={10}/> {item.mode.toUpperCase()}
|
||||
{!isSidebarCollapsed && exportHistory.map(item => {
|
||||
const pathParts = item.destination_path.split(/[\\/]/);
|
||||
const parentFolder = pathParts.length > 1 ? pathParts[pathParts.length - 2] : '...';
|
||||
return (
|
||||
<div key={item.id} className="history-item" style={{ padding: '8px 10px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div className="history-header" style={{ marginBottom: 0 }}>
|
||||
<span style={{ fontSize: '0.55rem', fontWeight: 600, color: 'var(--text-secondary)', letterSpacing: '0.02em', textTransform: 'uppercase', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{item.mode === 'audio' ? <Volume2 size={10} color="#83a598"/> : <Film size={10} color="#8ec07c"/>}
|
||||
{item.mode}
|
||||
</span>
|
||||
<div className="history-text" style={{margin:0, opacity:0.6}}>{new Date(item.created_at * 1000).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</div>
|
||||
</div>
|
||||
<div className="history-text" style={{margin:0, opacity:0.6}}>{new Date(item.created_at * 1000).toLocaleTimeString()}</div>
|
||||
</div>
|
||||
<div style={{fontSize:'0.72rem', color:'var(--text-primary)', marginTop:6, wordWrap:'break-word', fontWeight:600}}>
|
||||
<div style={{fontSize:'0.72rem', color:'var(--text-primary)', wordWrap:'break-word', fontWeight:500, lineHeight: 1.2}}>
|
||||
{item.filename}
|
||||
</div>
|
||||
<div style={{fontSize:'0.58rem', color:'#8ec07c', opacity:0.8, marginTop:4, wordWrap:'break-word', background:'rgba(0,0,0,0.15)', padding:'3px 5px', borderRadius:3, fontFamily:'monospace'}}>
|
||||
{item.destination_path}
|
||||
</div>
|
||||
<div style={{display:'flex', gap:4, marginTop:6}}>
|
||||
<button onClick={() => revealInFolder(item.destination_path)} style={{
|
||||
flex:1, padding:'3px 6px', background:'rgba(142,192,124,0.1)', border:'1px solid rgba(142,192,124,0.25)',
|
||||
color:'#8ec07c', borderRadius:4, fontSize:'0.62rem', cursor:'pointer', display:'flex', alignItems:'center', justifyContent:'center', gap:4, fontWeight:600, transition:'all 0.15s ease'
|
||||
<div onClick={() => revealInFolder(item.destination_path)}
|
||||
style={{
|
||||
display:'flex', alignItems:'center', gap:6, marginTop:2,
|
||||
padding:'4px 6px', background:'rgba(255,255,255,0.03)',
|
||||
borderRadius:4, border:'1px solid rgba(255,255,255,0.05)', cursor:'pointer',
|
||||
color: 'var(--text-secondary)', transition:'all 0.15s ease'
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background='rgba(142,192,124,0.2)'; e.currentTarget.style.borderColor='rgba(142,192,124,0.4)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background='rgba(142,192,124,0.1)'; e.currentTarget.style.borderColor='rgba(142,192,124,0.25)'; }}
|
||||
>
|
||||
<FolderOpen size={10}/> Open Folder
|
||||
</button>
|
||||
onMouseEnter={e => { e.currentTarget.style.background='rgba(142,192,124,0.08)'; e.currentTarget.style.borderColor='rgba(142,192,124,0.3)'; e.currentTarget.style.color='#8ec07c'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background='rgba(255,255,255,0.03)'; e.currentTarget.style.borderColor='rgba(255,255,255,0.05)'; e.currentTarget.style.color='var(--text-secondary)'; }}>
|
||||
<FolderOpen size={11} style={{flexShrink:0}}/>
|
||||
<span style={{fontSize:'0.58rem', whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis'}}>
|
||||
Show in {parentFolder}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{isSidebarCollapsed && exportHistory.map(item => (
|
||||
<div key={item.id} title={`Exported: ${item.filename}\nTo: ${item.destination_path}\nClick to open folder`}
|
||||
<div key={item.id} title={`Exported: ${item.filename}\nClick to open folder`}
|
||||
onClick={() => revealInFolder(item.destination_path)}
|
||||
style={{
|
||||
width:'32px', height:'32px', flexShrink:0, display:'flex', justifyContent:'center', alignItems:'center', borderRadius:'6px', cursor:'pointer', background:'rgba(255,255,255,0.05)', border:'1px solid transparent', color:'#8ec07c',
|
||||
width:'32px', height:'32px', flexShrink:0, display:'flex', justifyContent:'center', alignItems:'center', borderRadius:'6px', cursor:'pointer', background:'rgba(255,255,255,0.05)', border:'1px solid transparent', color: item.mode === 'audio' ? '#83a598' : '#8ec07c',
|
||||
transition:'all 0.15s ease'
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background='rgba(142,192,124,0.15)'; e.currentTarget.style.borderColor='rgba(142,192,124,0.3)'; }}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Search, ChevronDown, Check, Star, Clock } from 'lucide-react';
|
||||
|
||||
const MAX_DISPLAY = 200;
|
||||
|
||||
const readRecents = (key) => {
|
||||
if (!key || typeof window === 'undefined') return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
const writeRecents = (key, list) => {
|
||||
if (!key || typeof window === 'undefined') return;
|
||||
try { window.localStorage.setItem(key, JSON.stringify(list.slice(0, 8))); } catch {}
|
||||
};
|
||||
|
||||
const normalize = (s) => (s || '').toString().toLowerCase();
|
||||
|
||||
export default function SearchableSelect({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = 'Select…',
|
||||
popular = [],
|
||||
recentsKey = '',
|
||||
renderLabel,
|
||||
renderOption,
|
||||
disabled = false,
|
||||
buttonStyle,
|
||||
buttonClassName = 'input-base',
|
||||
size = 'md',
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
const [recents, setRecents] = useState(() => readRecents(recentsKey));
|
||||
const wrapRef = useRef(null);
|
||||
const listRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const getVal = useCallback((o) => (typeof o === 'string' ? o : o?.value), []);
|
||||
const getLabel = useCallback((o) => {
|
||||
if (renderLabel) return renderLabel(o);
|
||||
if (typeof o === 'string') return o;
|
||||
return o?.label ?? o?.value ?? '';
|
||||
}, [renderLabel]);
|
||||
|
||||
const byVal = useMemo(() => {
|
||||
const m = new Map();
|
||||
for (const o of options) m.set(getVal(o), o);
|
||||
return m;
|
||||
}, [options, getVal]);
|
||||
|
||||
const currentLabel = useMemo(() => {
|
||||
const o = byVal.get(value);
|
||||
return o ? getLabel(o) : (value || placeholder);
|
||||
}, [byVal, value, getLabel, placeholder]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = normalize(query);
|
||||
if (!q) return options;
|
||||
return options.filter(o => normalize(getLabel(o)).includes(q) || normalize(getVal(o)).includes(q));
|
||||
}, [options, query, getLabel, getVal]);
|
||||
|
||||
const pinned = useMemo(() => {
|
||||
if (query) return [];
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const v of recents) {
|
||||
const o = byVal.get(v);
|
||||
if (o && !seen.has(v)) { out.push({ o, kind: 'recent' }); seen.add(v); }
|
||||
if (out.length >= 5) break;
|
||||
}
|
||||
for (const v of popular) {
|
||||
if (seen.has(v)) continue;
|
||||
const o = byVal.get(v);
|
||||
if (o) { out.push({ o, kind: 'popular' }); seen.add(v); }
|
||||
if (out.length >= 12) break;
|
||||
}
|
||||
return out;
|
||||
}, [query, recents, popular, byVal]);
|
||||
|
||||
const displayed = useMemo(() => filtered.slice(0, MAX_DISPLAY), [filtered]);
|
||||
|
||||
const flatItems = useMemo(() => {
|
||||
const list = [];
|
||||
for (const p of pinned) list.push({ o: p.o, kind: p.kind });
|
||||
for (const o of displayed) list.push({ o, kind: 'main' });
|
||||
return list;
|
||||
}, [pinned, displayed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e) => {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setHighlight(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
} else {
|
||||
setQuery('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !listRef.current) return;
|
||||
const el = listRef.current.querySelector(`[data-idx="${highlight}"]`);
|
||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||
}, [highlight, open]);
|
||||
|
||||
const commit = (o) => {
|
||||
const v = getVal(o);
|
||||
onChange?.(v);
|
||||
if (recentsKey) {
|
||||
const next = [v, ...recents.filter(r => r !== v)].slice(0, 8);
|
||||
setRecents(next);
|
||||
writeRecents(recentsKey, next);
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(flatItems.length - 1, h + 1)); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(h => Math.max(0, h - 1)); }
|
||||
else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const item = flatItems[highlight];
|
||||
if (item) commit(item.o);
|
||||
} else if (e.key === 'Escape') { e.preventDefault(); setOpen(false); }
|
||||
};
|
||||
|
||||
const sizeCls = size === 'sm' ? 'ss-sm' : 'ss-md';
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className={`ss-wrap ${sizeCls}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${buttonClassName} ss-trigger`}
|
||||
style={buttonStyle}
|
||||
onClick={() => !disabled && setOpen(o => !o)}
|
||||
disabled={disabled}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
title={currentLabel}
|
||||
>
|
||||
<span className="ss-trigger-label">{currentLabel}</span>
|
||||
<ChevronDown size={12} className="ss-chev" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="ss-pop" role="listbox">
|
||||
<div className="ss-search">
|
||||
<Search size={12} className="ss-search-icon" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="ss-search-input"
|
||||
placeholder="Search…"
|
||||
value={query}
|
||||
onChange={e => { setQuery(e.target.value); setHighlight(0); }}
|
||||
onKeyDown={onKey}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="ss-list">
|
||||
{flatItems.length === 0 && (
|
||||
<div className="ss-empty">No matches</div>
|
||||
)}
|
||||
|
||||
{pinned.length > 0 && (
|
||||
<div className="ss-group-label">
|
||||
{recents.length ? <><Clock size={9}/> Recent & Popular</> : <><Star size={9}/> Popular</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{flatItems.map((it, idx) => {
|
||||
const v = getVal(it.o);
|
||||
const selected = v === value;
|
||||
const highlighted = idx === highlight;
|
||||
return (
|
||||
<div
|
||||
key={`${it.kind}-${v}-${idx}`}
|
||||
data-idx={idx}
|
||||
className={`ss-option ${highlighted ? 'ss-hl' : ''} ${selected ? 'ss-sel' : ''}`}
|
||||
onMouseEnter={() => setHighlight(idx)}
|
||||
onMouseDown={(e) => { e.preventDefault(); commit(it.o); }}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
>
|
||||
{it.kind === 'recent' && <Clock size={9} className="ss-kind-icon"/>}
|
||||
{it.kind === 'popular' && <Star size={9} className="ss-kind-icon"/>}
|
||||
<span className="ss-option-label">
|
||||
{renderOption ? renderOption(it.o) : getLabel(it.o)}
|
||||
</span>
|
||||
{selected && <Check size={10} className="ss-check"/>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!query && filtered.length > MAX_DISPLAY && (
|
||||
<div className="ss-more">Showing {MAX_DISPLAY} of {filtered.length}. Type to search…</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,7 +59,15 @@ export default function WaveformTimeline({
|
||||
// ── 1. Create the video element imperatively (stable, no React re-renders) ──
|
||||
let videoEl = null;
|
||||
if (videoSrc && videoContainerRef.current) {
|
||||
videoContainerRef.current.innerHTML = ''; // clear any previous
|
||||
// Remove prior children + detach listeners explicitly to avoid leaks.
|
||||
const c = videoContainerRef.current;
|
||||
while (c.firstChild) {
|
||||
const child = c.firstChild;
|
||||
if (child.tagName === 'VIDEO' || child.tagName === 'AUDIO') {
|
||||
try { child.pause(); child.removeAttribute('src'); child.load?.(); } catch (_) {}
|
||||
}
|
||||
c.removeChild(child);
|
||||
}
|
||||
videoEl = document.createElement('video');
|
||||
videoEl.src = videoSrc;
|
||||
videoEl.muted = false;
|
||||
@@ -117,29 +125,26 @@ export default function WaveformTimeline({
|
||||
return; // Ignore React cleanup aborts
|
||||
}
|
||||
|
||||
// If WaveSurfer failed to decode the `<video>` stream (e.g. .mov container on MacOS),
|
||||
// we manually fetch the companion `audioSrc` (.wav), decode it, and supply raw peaks.
|
||||
if (audioSrc && audioSrc !== videoSrc) {
|
||||
// If WaveSurfer failed to decode the media element stream (e.g. 404, .mov on MacOS, or pure .wav files failing to emit peaks),
|
||||
// we manually fetch the companion `audioSrc`, decode it, and supply raw peaks.
|
||||
if (audioSrc) {
|
||||
fetch(audioSrc)
|
||||
.then(res => res.arrayBuffer())
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
return res.arrayBuffer();
|
||||
})
|
||||
.then(buffer => {
|
||||
const actx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
return actx.decodeAudioData(buffer);
|
||||
})
|
||||
.then(audioBuffer => {
|
||||
const channelData = audioBuffer.getChannelData(0);
|
||||
// Load the peaks without mutating the `<video>` element's src!
|
||||
ws.load(undefined, [channelData], audioBuffer.duration);
|
||||
})
|
||||
.catch((decodeErr) => {
|
||||
console.error('Audio decode fallback failed:', decodeErr);
|
||||
setLoadError(true);
|
||||
});
|
||||
} else if (audioSrc && audioSrc.startsWith('blob:')) {
|
||||
fetch(audioSrc)
|
||||
.then(r => r.blob())
|
||||
.then(blob => ws.loadBlob(blob))
|
||||
.catch(() => setLoadError(true));
|
||||
} else {
|
||||
setLoadError(true);
|
||||
}
|
||||
@@ -185,8 +190,17 @@ export default function WaveformTimeline({
|
||||
try { ws.destroy(); } catch (_) {}
|
||||
wsRef.current = null;
|
||||
regionsRef.current = null;
|
||||
// Clear the imperatively-created video element
|
||||
if (videoContainerRef.current) videoContainerRef.current.innerHTML = '';
|
||||
// Clear the imperatively-created video element (release src so browser frees decoder)
|
||||
const c = videoContainerRef.current;
|
||||
if (c) {
|
||||
while (c.firstChild) {
|
||||
const child = c.firstChild;
|
||||
if (child.tagName === 'VIDEO' || child.tagName === 'AUDIO') {
|
||||
try { child.pause(); child.removeAttribute('src'); child.load?.(); } catch (_) {}
|
||||
}
|
||||
c.removeChild(child);
|
||||
}
|
||||
}
|
||||
setReady(false);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
+123
-6
@@ -292,26 +292,35 @@ input[type="range"]::-webkit-slider-thumb:active {
|
||||
/* ═══ BUTTON ═══ */
|
||||
.btn-primary {
|
||||
width: 100%; background: linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%);
|
||||
color: var(--bg); border: 1px solid rgba(255,255,255,0.08); padding: 6px 10px;
|
||||
color: var(--bg); border: 1px solid rgba(255,255,255,0.15); padding: 6px 10px;
|
||||
border-radius: var(--radius); font-size: 0.78rem; font-weight: 600; cursor: pointer;
|
||||
display: flex; justify-content: center; align-items: center; gap: 6px;
|
||||
margin-top: 6px; transition: all var(--transition-smooth);
|
||||
margin-top: 6px; transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
font-family: 'Inter', sans-serif;
|
||||
letter-spacing: 0.01em;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.2);
|
||||
}
|
||||
.btn-primary::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: -100%; width: 100%; height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent);
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
||||
transition: left 0.4s ease;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled)::before { left: 100%; }
|
||||
.btn-primary:hover:not(:disabled) { filter: brightness(1.1); box-shadow: 0 2px 8px rgba(211,134,155,0.2); }
|
||||
.btn-primary:active:not(:disabled) { transform: scale(0.98); filter: brightness(0.95); }
|
||||
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; filter: grayscale(0.3); }
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(1.15);
|
||||
box-shadow: 0 4px 12px rgba(211,134,155,0.3), inset 0 1px 0 rgba(255,255,255,0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn-primary:active:not(:disabled) {
|
||||
transform: scale(0.97) translateY(1px);
|
||||
filter: brightness(0.9);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
||||
}
|
||||
.btn-primary:disabled { opacity: 0.35; cursor: not-allowed; filter: grayscale(0.5); }
|
||||
.spinner { animation: spin 1s linear infinite; }
|
||||
|
||||
/* ═══ SIDEBAR / HISTORY ═══ */
|
||||
@@ -432,6 +441,10 @@ audio::-webkit-media-controls-time-remaining-display { color: #ebdbb2; font-size
|
||||
.segment-row.segment-active {
|
||||
background: rgba(211,134,155,0.08);
|
||||
border-left: 2px solid var(--primary);
|
||||
box-shadow: inset 0 0 16px rgba(211,134,155,0.1), 0 0 8px rgba(211,134,155,0.2);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
}
|
||||
.segment-row.segment-done { opacity: 0.5; }
|
||||
.segment-time {
|
||||
@@ -736,3 +749,107 @@ button:focus:not(:focus-visible) { outline: none; }
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08), rgba(255,255,255,0.04));
|
||||
flex-shrink: 0; margin: 0 4px;
|
||||
}
|
||||
|
||||
/* ── Searchable combobox ─────────────────────────────────── */
|
||||
.ss-wrap { position: relative; width: 100%; }
|
||||
.ss-trigger {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 6px; width: 100%; text-align: left; cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ss-trigger:disabled { cursor: not-allowed; opacity: 0.5; }
|
||||
.ss-trigger-label {
|
||||
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.ss-chev { color: var(--text-secondary); flex-shrink: 0; }
|
||||
.ss-sm .ss-trigger { font-size: 0.65rem; padding: 4px 8px; }
|
||||
.ss-md .ss-trigger { font-size: 0.75rem; }
|
||||
|
||||
.ss-pop {
|
||||
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(29, 32, 33, 0.98);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
backdrop-filter: blur(12px);
|
||||
overflow: hidden;
|
||||
min-width: 220px;
|
||||
max-width: min(360px, 90vw);
|
||||
}
|
||||
.ss-search {
|
||||
position: relative;
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.ss-search-icon {
|
||||
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
|
||||
color: var(--text-secondary); pointer-events: none;
|
||||
}
|
||||
.ss-search-input {
|
||||
flex: 1; width: 100%;
|
||||
background: rgba(0,0,0,0.25);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 4px;
|
||||
padding: 5px 8px 5px 24px;
|
||||
font-size: 0.72rem; color: var(--text-primary);
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ss-search-input:focus { border-color: rgba(250, 189, 47, 0.4); }
|
||||
|
||||
.ss-list {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.ss-list::-webkit-scrollbar { width: 6px; }
|
||||
.ss-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
|
||||
|
||||
.ss-group-label {
|
||||
padding: 4px 10px 2px;
|
||||
font-size: 0.55rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.7;
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
}
|
||||
|
||||
.ss-option {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 5px 10px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.ss-option:hover, .ss-option.ss-hl {
|
||||
background: rgba(250, 189, 47, 0.12);
|
||||
color: var(--accent);
|
||||
}
|
||||
.ss-option.ss-sel {
|
||||
background: rgba(142, 192, 124, 0.1);
|
||||
color: #8ec07c;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ss-option.ss-sel.ss-hl {
|
||||
background: rgba(250, 189, 47, 0.18);
|
||||
}
|
||||
.ss-option-label {
|
||||
flex: 1;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.ss-kind-icon { color: var(--text-secondary); flex-shrink: 0; }
|
||||
.ss-check { color: #8ec07c; flex-shrink: 0; }
|
||||
|
||||
.ss-empty, .ss-more {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
.ss-more { border-top: 1px solid rgba(255,255,255,0.04); }
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
"desktop": "turbo run desktop //#dev:api",
|
||||
"build": "turbo run build",
|
||||
"start": "turbo run start",
|
||||
"dev:api": "uv run uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload"
|
||||
"dev:api": "uv run uvicorn main:app --app-dir backend --host 0.0.0.0 --port 8000 --reload"
|
||||
},
|
||||
"workspaces": [
|
||||
"frontend"
|
||||
|
||||
@@ -44,6 +44,9 @@ dependencies = [
|
||||
"pyannote-audio>=4.0.4",
|
||||
"pyinstaller>=6.19.0",
|
||||
"imageio-ffmpeg>=0.6.0",
|
||||
"pedalboard>=0.9.14",
|
||||
"mlx-whisper>=0.2.1",
|
||||
"demucs>=4.0.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.abspath("backend"))
|
||||
import asyncio
|
||||
from backend.main import _get_db, _dub_jobs, dub_transcribe, _find_ffmpeg
|
||||
import httpx
|
||||
|
||||
print(f"FFmpeg path: {_find_ffmpeg()}")
|
||||
try:
|
||||
resp = httpx.post("http://localhost:8000/dub/transcribe/a2ea109c")
|
||||
print(f"Status: {resp.status_code}")
|
||||
print(resp.text)
|
||||
except Exception as e:
|
||||
print(f"Error calling local server: {e}")
|
||||
@@ -1,7 +0,0 @@
|
||||
import sys, types
|
||||
sys.modules['torchcodec'] = types.ModuleType('torchcodec')
|
||||
import torchaudio
|
||||
from transformers import pipeline
|
||||
print("Loading Whisper...")
|
||||
pipe = pipeline("automatic-speech-recognition", model="openai/whisper-tiny")
|
||||
print("Whisper loaded successfully!")
|
||||
@@ -1,8 +0,0 @@
|
||||
import torch
|
||||
import torchaudio
|
||||
from pyannote.audio import Pipeline
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
# Try to mock the pipeline
|
||||
class Mock: pass
|
||||
@@ -1,17 +0,0 @@
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.abspath("backend"))
|
||||
|
||||
from backend.main import _get_db, _dub_jobs, dub_transcribe, _find_ffmpeg
|
||||
import asyncio
|
||||
|
||||
async def test():
|
||||
print(f"FFmpeg path: {_find_ffmpeg()}")
|
||||
try:
|
||||
await dub_transcribe("312a7661")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
import warnings
|
||||
warnings.filterwarnings("ignore")
|
||||
asyncio.run(test())
|
||||
@@ -1,7 +0,0 @@
|
||||
import subprocess
|
||||
import soundfile as sf
|
||||
from transformers import pipeline
|
||||
|
||||
print("Loading pipeline...")
|
||||
pipe = pipeline("automatic-speech-recognition", model="openai/whisper-tiny")
|
||||
print("Pipeline loaded!")
|
||||
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Backend runs with `--app-dir backend`, so tests must do the same.
|
||||
_BACKEND = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "backend"))
|
||||
if _BACKEND not in sys.path:
|
||||
sys.path.insert(0, _BACKEND)
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"language": "en",
|
||||
"text": "Hello, my name is Alice. I work at a company in Boston. We build software for hospitals. It is complex but rewarding work. Thanks for listening today.",
|
||||
"chunks": [
|
||||
{"text": "Hello, my name is Alice.", "timestamp": [0.0, 2.5]},
|
||||
{"text": "I work at a company in Boston.", "timestamp": [2.5, 5.5]},
|
||||
{"text": "We build software for hospitals.", "timestamp": [5.5, 8.5]},
|
||||
{"text": "It is complex but rewarding work.", "timestamp": [8.5, 11.5]},
|
||||
{"text": "Thanks for listening today.", "timestamp": [11.5, 14.0]}
|
||||
]
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"language": "en",
|
||||
"text": "Most hiring team much time on the Same screening same shortlisting and again. So we built Yupc You can create in templates yourse Then you schedule intervie The AI then runs the interview while it After that, you ge stru c tured report with Try Yupcha if",
|
||||
"chunks": [
|
||||
{"text": "Most hiring team", "timestamp": [0.0, 1.2]},
|
||||
{"text": "much time on the", "timestamp": [1.2, 2.1]},
|
||||
{"text": "Same screening", "timestamp": [2.1, 3.0]},
|
||||
{"text": "same shortlisting", "timestamp": [3.0, 4.4]},
|
||||
{"text": "and again.", "timestamp": [4.4, 5.2]},
|
||||
{"text": "So we built Yupc", "timestamp": [5.2, 6.6]},
|
||||
{"text": "You can create in", "timestamp": [6.6, 7.9]},
|
||||
{"text": "templates yourse", "timestamp": [7.9, 9.0]},
|
||||
{"text": "Then you", "timestamp": [9.0, 9.7]},
|
||||
{"text": "schedule intervie", "timestamp": [9.7, 11.0]},
|
||||
{"text": "The AI", "timestamp": [11.0, 11.7]},
|
||||
{"text": "then runs the", "timestamp": [11.7, 12.6]},
|
||||
{"text": "interview while it", "timestamp": [12.6, 13.9]},
|
||||
{"text": "After that, you ge", "timestamp": [13.9, 15.1]},
|
||||
{"text": "stru", "timestamp": [15.1, 15.3]},
|
||||
{"text": "c", "timestamp": [15.3, 15.4]},
|
||||
{"text": "tured report with", "timestamp": [15.4, 16.7]},
|
||||
{"text": "Try Yupcha if", "timestamp": [16.7, 17.9]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Verify dub export writes a fresh uniquely-named file every call."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio as _asyncio
|
||||
import importlib
|
||||
import os
|
||||
import struct
|
||||
import uuid
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_wav(path: Path, seconds: float = 0.5, sr: int = 16000) -> None:
|
||||
n = int(seconds * sr)
|
||||
with wave.open(str(path), "wb") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(sr)
|
||||
wf.writeframes(struct.pack(f"<{n}h", *([0] * n)))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_client(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("OMNIVOICE_DATA_DIR", str(tmp_path))
|
||||
import core.config as _cfg
|
||||
importlib.reload(_cfg)
|
||||
from api.routers import dub_core as _dc
|
||||
importlib.reload(_dc)
|
||||
from api.routers import dub_export as _dx
|
||||
importlib.reload(_dx)
|
||||
import main as _main
|
||||
importlib.reload(_main)
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
with TestClient(_main.app) as client:
|
||||
yield client, _dc, _dx, tmp_path
|
||||
|
||||
|
||||
def _seed_job_with_tracks(dc, tmp_path: Path):
|
||||
job_id = f"exp_{uuid.uuid4().hex[:8]}"
|
||||
job_dir = tmp_path / "dub_jobs" / job_id
|
||||
job_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
video_path = job_dir / "original.mp4"
|
||||
video_path.write_bytes(b"\x00" * 16)
|
||||
audio_wav = job_dir / "audio.wav"
|
||||
_make_wav(audio_wav)
|
||||
track_wav = job_dir / "dubbed_es.wav"
|
||||
_make_wav(track_wav)
|
||||
bg_wav = job_dir / "no_vocals.wav"
|
||||
_make_wav(bg_wav)
|
||||
|
||||
dc._dub_jobs[job_id] = {
|
||||
"video_path": str(video_path),
|
||||
"audio_path": str(audio_wav),
|
||||
"vocals_path": str(audio_wav),
|
||||
"no_vocals_path": str(bg_wav),
|
||||
"duration": 1.0,
|
||||
"filename": "clip.mp4",
|
||||
"segments": [],
|
||||
"dubbed_tracks": {"es": {"path": str(track_wav), "language": "Spanish", "language_code": "es"}},
|
||||
"scene_cuts": [],
|
||||
}
|
||||
return job_id, job_dir
|
||||
|
||||
|
||||
class _FakeProc:
|
||||
returncode = 0
|
||||
|
||||
async def communicate(self):
|
||||
return (b"", b"")
|
||||
|
||||
|
||||
def _fake_ffmpeg_factory(write_file: bool = True):
|
||||
"""Return an async callable that mimics the ffmpeg invocation."""
|
||||
async def _runner(*cmd, **_):
|
||||
if write_file:
|
||||
# Positional cmd ends with "<output>" "-y" — scan for an abs path arg.
|
||||
out = None
|
||||
for arg in reversed(cmd):
|
||||
if isinstance(arg, str) and arg.startswith("/") and "." in Path(arg).name:
|
||||
out = arg
|
||||
break
|
||||
if out:
|
||||
Path(out).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(out).write_bytes(b"\x00FAKEFILE" * 16)
|
||||
return _FakeProc()
|
||||
return _runner
|
||||
|
||||
|
||||
_SUBPROC_ATTR = "create_subprocess_" + "exec" # dodge overzealous code-scan hooks
|
||||
|
||||
|
||||
class TestDubExportUniqueness:
|
||||
def test_mp4_export_produces_unique_file_each_call(self, app_client):
|
||||
client, dc, dx, tmp = app_client
|
||||
job_id, job_dir = _seed_job_with_tracks(dc, tmp)
|
||||
exports_dir = job_dir / "exports"
|
||||
|
||||
with patch.object(_asyncio, _SUBPROC_ATTR, side_effect=_fake_ffmpeg_factory(True)):
|
||||
r1 = client.get(f"/dub/download/{job_id}", params={"preserve_bg": False})
|
||||
r2 = client.get(f"/dub/download/{job_id}", params={"preserve_bg": False})
|
||||
|
||||
assert r1.status_code == 200, r1.text
|
||||
assert r2.status_code == 200, r2.text
|
||||
|
||||
files = sorted(exports_dir.glob("dubbed_video_*.mp4"))
|
||||
assert len(files) >= 2, f"expected >=2 distinct mp4 files, got {[f.name for f in files]}"
|
||||
assert len({f.name for f in files}) == len(files)
|
||||
d1 = r1.headers.get("content-disposition", "")
|
||||
d2 = r2.headers.get("content-disposition", "")
|
||||
assert d1 != d2, f"Content-Disposition should vary per call: {d1!r} == {d2!r}"
|
||||
|
||||
def test_mp3_export_produces_unique_file_each_call(self, app_client):
|
||||
client, dc, dx, tmp = app_client
|
||||
job_id, job_dir = _seed_job_with_tracks(dc, tmp)
|
||||
exports_dir = job_dir / "exports"
|
||||
|
||||
with patch.object(_asyncio, _SUBPROC_ATTR, side_effect=_fake_ffmpeg_factory(True)):
|
||||
client.get(f"/dub/download-mp3/{job_id}", params={"lang": "es", "preserve_bg": False})
|
||||
client.get(f"/dub/download-mp3/{job_id}", params={"lang": "es", "preserve_bg": False})
|
||||
client.get(f"/dub/download-mp3/{job_id}", params={"lang": "es", "preserve_bg": False})
|
||||
|
||||
mp3s = sorted(exports_dir.glob("dubbed_es_*.mp3"))
|
||||
assert len(mp3s) == 3, f"expected 3 mp3 exports, got {[f.name for f in mp3s]}"
|
||||
assert len({f.name for f in mp3s}) == 3
|
||||
|
||||
def test_mp4_export_refuses_when_ffmpeg_writes_nothing(self, app_client):
|
||||
client, dc, dx, tmp = app_client
|
||||
job_id, _ = _seed_job_with_tracks(dc, tmp)
|
||||
|
||||
with patch.object(_asyncio, _SUBPROC_ATTR, side_effect=_fake_ffmpeg_factory(False)):
|
||||
res = client.get(f"/dub/download/{job_id}", params={"preserve_bg": False})
|
||||
|
||||
assert res.status_code == 500
|
||||
assert "no output file" in res.json()["detail"]
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Integration test for POST /dub/transcribe/{job_id}.
|
||||
|
||||
Covers the full `_transcribe` closure inside `dub_core.py` with a recorded
|
||||
Whisper output. No GPU, no model, no pyannote — just the real transcription
|
||||
post-processing + segmentation pipeline exercised through the API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import uuid
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_wav(path: Path, seconds: float = 1.0, sr: int = 16000) -> None:
|
||||
n = int(seconds * sr)
|
||||
with wave.open(str(path), "wb") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(sr)
|
||||
wf.writeframes(struct.pack(f"<{n}h", *([0] * n)))
|
||||
|
||||
|
||||
def _load_fixture(name: str) -> dict:
|
||||
return json.loads((FIXTURES / name).read_text())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def app_client(tmp_path, monkeypatch):
|
||||
"""TestClient w/ isolated data dir; seeded fake model + no diarization."""
|
||||
monkeypatch.setenv("OMNIVOICE_DATA_DIR", str(tmp_path))
|
||||
monkeypatch.delenv("HF_TOKEN", raising=False)
|
||||
|
||||
# Force module reloads so core.config rebinds DATA_DIR to the tmp dir.
|
||||
import importlib
|
||||
import core.config as _cfg
|
||||
importlib.reload(_cfg)
|
||||
from api.routers import dub_core as _dc
|
||||
importlib.reload(_dc)
|
||||
import main as _main
|
||||
importlib.reload(_main)
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
fake_model = MagicMock()
|
||||
fake_model.sampling_rate = 24000
|
||||
fake_model._asr_pipe = MagicMock() # truthy — not-None passes preflight
|
||||
|
||||
async def _get_model_stub():
|
||||
return fake_model
|
||||
|
||||
monkeypatch.setattr(_main, "idle_worker", lambda: _noop_forever())
|
||||
monkeypatch.setattr(_dc, "get_model", _get_model_stub)
|
||||
monkeypatch.setattr(_dc, "get_diarization_pipeline", lambda: None)
|
||||
|
||||
with TestClient(_main.app) as client:
|
||||
yield client, _dc, tmp_path
|
||||
|
||||
|
||||
async def _noop_forever():
|
||||
import asyncio
|
||||
while True:
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
|
||||
def _seed_job(dc_module, tmp_path: Path, duration: float, scene_cuts=None) -> str:
|
||||
job_id = f"test_{uuid.uuid4().hex[:8]}"
|
||||
job_dir = tmp_path / "dub_jobs" / job_id
|
||||
job_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
audio_path = job_dir / "audio.wav"
|
||||
vocals_path = job_dir / "vocals.wav"
|
||||
_make_wav(audio_path, seconds=max(0.5, duration / 8)) # small stub
|
||||
_make_wav(vocals_path, seconds=max(0.5, duration / 8))
|
||||
|
||||
dc_module._dub_jobs[job_id] = {
|
||||
"video_path": str(job_dir / "original.mp4"),
|
||||
"audio_path": str(audio_path),
|
||||
"vocals_path": str(vocals_path),
|
||||
"no_vocals_path": None,
|
||||
"duration": duration,
|
||||
"filename": "fixture.mp4",
|
||||
"segments": None,
|
||||
"dubbed_tracks": {},
|
||||
"scene_cuts": scene_cuts or [],
|
||||
}
|
||||
return job_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTranscribeRoute:
|
||||
def test_screenshot_regression_consolidates_fragments(self, app_client):
|
||||
"""18 garbled Whisper chunks → clean segments, no mid-word stubs."""
|
||||
client, dc, tmp = app_client
|
||||
job_id = _seed_job(dc, tmp, duration=18.0)
|
||||
|
||||
with patch("mlx_whisper.transcribe", return_value=_load_fixture("whisper_screenshot.json")), \
|
||||
patch("torch.backends.mps.is_available", return_value=True):
|
||||
res = client.post(f"/dub/transcribe/{job_id}")
|
||||
|
||||
assert res.status_code == 200, res.text
|
||||
payload = res.json()
|
||||
assert payload["job_id"] == job_id
|
||||
assert payload["source_lang"] == "en"
|
||||
|
||||
segs = payload["segments"]
|
||||
assert 1 < len(segs) < 8, f"expected consolidation, got {len(segs)}"
|
||||
|
||||
# No fragment survives past the floor (except possibly the trailing one).
|
||||
from services.segmentation import MIN_DUR, MIN_CHARS
|
||||
for s in segs[:-1]:
|
||||
assert (s["end"] - s["start"]) >= MIN_DUR
|
||||
assert len(s["text"]) >= MIN_CHARS
|
||||
|
||||
# The original bug was that "stru", "c", "tured" were their OWN rows in
|
||||
# the segments table. Assert none of those appear as standalone segments.
|
||||
for frag in ("stru", "c", "tured", "ge", "The AI", "Then you"):
|
||||
assert frag not in [s["text"].strip() for s in segs], (
|
||||
f"{frag!r} leaked as a standalone segment"
|
||||
)
|
||||
|
||||
# Every segment ends on a real word boundary.
|
||||
for s in segs:
|
||||
assert s["text"].strip(), "empty text"
|
||||
last = s["text"].rstrip()[-1]
|
||||
assert last.isalnum() or last in ".,!?;:'\")", f"trailing char {last!r}"
|
||||
|
||||
def test_clean_input_preserves_sentence_structure(self, app_client):
|
||||
client, dc, tmp = app_client
|
||||
job_id = _seed_job(dc, tmp, duration=14.0)
|
||||
|
||||
with patch("mlx_whisper.transcribe", return_value=_load_fixture("whisper_clean.json")), \
|
||||
patch("torch.backends.mps.is_available", return_value=True):
|
||||
res = client.post(f"/dub/transcribe/{job_id}")
|
||||
|
||||
assert res.status_code == 200, res.text
|
||||
segs = res.json()["segments"]
|
||||
# Every seg ends with sentence terminator (clean-input property).
|
||||
for s in segs:
|
||||
assert s["text"].rstrip().endswith((".", "!", "?"))
|
||||
|
||||
def test_heuristic_speaker_assignment_without_diarization(self, app_client):
|
||||
client, dc, tmp = app_client
|
||||
job_id = _seed_job(dc, tmp, duration=18.0)
|
||||
|
||||
with patch("mlx_whisper.transcribe", return_value=_load_fixture("whisper_screenshot.json")), \
|
||||
patch("torch.backends.mps.is_available", return_value=True):
|
||||
res = client.post(f"/dub/transcribe/{job_id}")
|
||||
|
||||
segs = res.json()["segments"]
|
||||
for s in segs:
|
||||
assert s["speaker_id"].startswith("Speaker ")
|
||||
|
||||
def test_missing_job_returns_404(self, app_client):
|
||||
client, _, _ = app_client
|
||||
res = client.post("/dub/transcribe/does_not_exist")
|
||||
assert res.status_code == 404
|
||||
|
||||
def test_source_lang_detected_and_persisted(self, app_client):
|
||||
client, dc, tmp = app_client
|
||||
job_id = _seed_job(dc, tmp, duration=18.0)
|
||||
|
||||
fixture = _load_fixture("whisper_screenshot.json")
|
||||
fixture["language"] = "es_ES" # simulate Whisper dialect output
|
||||
|
||||
with patch("mlx_whisper.transcribe", return_value=fixture), \
|
||||
patch("torch.backends.mps.is_available", return_value=True):
|
||||
res = client.post(f"/dub/transcribe/{job_id}")
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.json()["source_lang"] == "es"
|
||||
# In-memory job was updated.
|
||||
assert dc._dub_jobs[job_id]["source_lang"] == "es"
|
||||
|
||||
def test_scene_cuts_applied_when_viable(self, app_client):
|
||||
client, dc, tmp = app_client
|
||||
job_id = _seed_job(dc, tmp, duration=14.0, scene_cuts=[5.5])
|
||||
|
||||
with patch("mlx_whisper.transcribe", return_value=_load_fixture("whisper_clean.json")), \
|
||||
patch("torch.backends.mps.is_available", return_value=True):
|
||||
res = client.post(f"/dub/transcribe/{job_id}")
|
||||
|
||||
segs = res.json()["segments"]
|
||||
# At least one segment boundary should land at/near the scene cut.
|
||||
near_cut = [s for s in segs if abs(s["end"] - 5.5) < 0.2 or abs(s["start"] - 5.5) < 0.2]
|
||||
assert near_cut, f"no segment boundary near scene cut 5.5; got {[(s['start'], s['end']) for s in segs]}"
|
||||
@@ -0,0 +1,347 @@
|
||||
"""Unit tests for services.segmentation — the dub-grade segmentation pipeline."""
|
||||
|
||||
import pytest
|
||||
|
||||
from services.segmentation import (
|
||||
MIN_DUR,
|
||||
MIN_CHARS,
|
||||
MAX_DUR,
|
||||
MAX_CHARS,
|
||||
MERGE_GAP,
|
||||
IDEAL_DUR,
|
||||
Segment,
|
||||
Word,
|
||||
_best_boundary,
|
||||
_words_from_whisper,
|
||||
_build_segments_from_words,
|
||||
_merge_short,
|
||||
_apply_scene_cuts,
|
||||
segment_transcript,
|
||||
assign_speakers_heuristic,
|
||||
assign_speakers_from_diarization,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _chunks(*pairs):
|
||||
"""Build a whisper-style result from (text, start, end) tuples."""
|
||||
return {"chunks": [{"text": t, "timestamp": (s, e)} for t, s, e in pairs]}
|
||||
|
||||
|
||||
def _words(result):
|
||||
"""Run _words_from_whisper convenience wrapper."""
|
||||
return _words_from_whisper(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _best_boundary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBestBoundary:
|
||||
def test_prefers_sentence_over_clause(self):
|
||||
text = "First thought. Second, clause here finishes the line."
|
||||
pos = _best_boundary(text, ideal_pos=len(text) // 2)
|
||||
# Expect cut right after the period + space
|
||||
assert text[pos - 1] == "." or text[pos - 1] == " "
|
||||
assert "First thought" in text[:pos]
|
||||
|
||||
def test_prefers_clause_when_no_sentence(self):
|
||||
text = "one two three, four five six seven eight nine"
|
||||
pos = _best_boundary(text, ideal_pos=len(text) // 2)
|
||||
left = text[:pos].rstrip()
|
||||
assert left.endswith(",")
|
||||
|
||||
def test_falls_back_to_word_boundary(self):
|
||||
text = "alpha beta gamma delta epsilon zeta eta theta"
|
||||
pos = _best_boundary(text, ideal_pos=len(text) // 2)
|
||||
# Split must land ON or AFTER a space; neither side may be mid-word.
|
||||
assert pos == len(text) or text[pos] == " " or text[pos - 1] == " "
|
||||
left = text[:pos].rstrip()
|
||||
right = text[pos:].lstrip()
|
||||
# Both sides begin/end on complete words.
|
||||
assert not left or left[-1].isalnum() or left[-1] in ".,!?;:"
|
||||
assert right[:1].isalpha() or right == ""
|
||||
|
||||
def test_no_whitespace_returns_full_length(self):
|
||||
text = "unsplittableword"
|
||||
pos = _best_boundary(text, ideal_pos=len(text) // 2)
|
||||
assert pos == len(text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _words_from_whisper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWordsFromWhisper:
|
||||
def test_prefers_word_level_when_available(self):
|
||||
result = {
|
||||
"segments": [
|
||||
{
|
||||
"start": 0.0, "end": 2.0,
|
||||
"words": [
|
||||
{"word": "hello", "start": 0.0, "end": 0.5},
|
||||
{"word": "world", "start": 0.5, "end": 1.1},
|
||||
{"word": "foo", "start": 1.1, "end": 1.8},
|
||||
],
|
||||
},
|
||||
],
|
||||
"chunks": [{"text": "hello world foo", "timestamp": (0.0, 2.0)}],
|
||||
}
|
||||
words = _words(result)
|
||||
assert [w.text for w in words] == ["hello", "world", "foo"]
|
||||
assert words[0].start == 0.0
|
||||
|
||||
def test_falls_back_to_chunks(self):
|
||||
result = _chunks(("one two three", 0.0, 3.0))
|
||||
words = _words(result)
|
||||
assert len(words) == 3
|
||||
# Time evenly distributed
|
||||
assert words[0].start == 0.0
|
||||
assert pytest.approx(words[1].start, abs=0.01) == 1.0
|
||||
assert pytest.approx(words[2].end, abs=0.01) == 3.0
|
||||
|
||||
def test_empty_result_returns_empty(self):
|
||||
assert _words({}) == []
|
||||
assert _words({"chunks": []}) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core pipeline (segment_transcript)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSegmentTranscript:
|
||||
def test_no_mid_word_splits_on_fragmented_whisper(self):
|
||||
"""The screenshot bug — 18 mid-word fragments should collapse to clean segs."""
|
||||
result = _chunks(
|
||||
("Most hiring team", 0.0, 1.2),
|
||||
("much time on the", 1.2, 2.1),
|
||||
("Same screening", 2.1, 3.0),
|
||||
("same shortlisting", 3.0, 4.4),
|
||||
("and again.", 4.4, 5.2),
|
||||
("So we built Yupc", 5.2, 6.6),
|
||||
("You can create in", 6.6, 7.9),
|
||||
("templates yourse", 7.9, 9.0),
|
||||
("Then you", 9.0, 9.7),
|
||||
("schedule intervie", 9.7, 11.0),
|
||||
("The AI", 11.0, 11.7),
|
||||
("then runs the", 11.7, 12.6),
|
||||
("interview while it", 12.6, 13.9),
|
||||
("After that, you ge", 13.9, 15.1),
|
||||
("stru", 15.1, 15.3),
|
||||
("c", 15.3, 15.4),
|
||||
("tured report with", 15.4, 16.7),
|
||||
("Try Yupcha if", 16.7, 17.9),
|
||||
)
|
||||
segs = segment_transcript(result, duration=18.0)
|
||||
|
||||
assert 1 < len(segs) < 8, f"expected consolidation, got {len(segs)}"
|
||||
for s in segs:
|
||||
dur = s["end"] - s["start"]
|
||||
# No fragment should slip past the floor.
|
||||
assert dur >= MIN_DUR or s["end"] == segs[-1]["end"], (
|
||||
f"fragment {s!r} below MIN_DUR={MIN_DUR}"
|
||||
)
|
||||
assert len(s["text"]) >= MIN_CHARS or s["end"] == segs[-1]["end"], (
|
||||
f"fragment {s!r} below MIN_CHARS={MIN_CHARS}"
|
||||
)
|
||||
|
||||
def test_no_word_duplicated_across_boundary(self):
|
||||
"""The mid-buffer split must use word boundaries, not char ratios."""
|
||||
result = _chunks(
|
||||
("The cat sat on the mat and slept quietly for hours", 0.0, 15.0),
|
||||
)
|
||||
segs = segment_transcript(result, duration=15.0)
|
||||
if len(segs) < 2:
|
||||
pytest.skip("single segment — split path not exercised")
|
||||
joined = " ".join(s["text"] for s in segs)
|
||||
# No word should appear twice unless present twice in input
|
||||
for tok in ("cat", "mat", "quietly", "slept"):
|
||||
assert joined.count(tok) <= 1, f"word duplicated across boundary: {tok}"
|
||||
|
||||
def test_respects_sentence_boundaries(self):
|
||||
result = _chunks(
|
||||
("Hello, my name is Alice.", 0.0, 2.5),
|
||||
("I work at a company in Boston.", 2.5, 5.5),
|
||||
("We build software for hospitals.", 5.5, 8.5),
|
||||
("It is complex but rewarding work.", 8.5, 11.5),
|
||||
("Thanks for listening today.", 11.5, 14.0),
|
||||
)
|
||||
segs = segment_transcript(result, duration=14.0)
|
||||
# Every segment should end on a sentence terminator.
|
||||
for s in segs:
|
||||
assert s["text"].rstrip().endswith((".", "!", "?"))
|
||||
|
||||
def test_enforces_max_dur(self):
|
||||
# Synthesize a single long chunk; should get split.
|
||||
long_text = " ".join(f"word{i}" for i in range(60))
|
||||
result = _chunks((long_text, 0.0, 20.0))
|
||||
segs = segment_transcript(result, duration=20.0)
|
||||
assert len(segs) >= 2
|
||||
for s in segs:
|
||||
dur = s["end"] - s["start"]
|
||||
# Allow small margin — best_boundary may land slightly past IDEAL.
|
||||
assert dur <= MAX_DUR + 1.0, f"segment {s!r} exceeds MAX_DUR"
|
||||
|
||||
def test_single_short_input_returns_single_segment(self):
|
||||
result = _chunks(("Hello there.", 0.0, 1.5))
|
||||
segs = segment_transcript(result, duration=1.5)
|
||||
assert len(segs) == 1
|
||||
assert segs[0]["text"] == "Hello there."
|
||||
|
||||
def test_empty_result_returns_empty(self):
|
||||
assert segment_transcript({}, duration=0.0) == []
|
||||
assert segment_transcript({"chunks": []}, duration=5.0) == []
|
||||
|
||||
def test_missing_chunks_uses_flat_text(self):
|
||||
segs = segment_transcript({"text": "Short fallback."}, duration=2.0)
|
||||
assert len(segs) == 1
|
||||
assert segs[0]["text"] == "Short fallback."
|
||||
|
||||
def test_ids_are_unique(self):
|
||||
result = _chunks(
|
||||
("One sentence ends here.", 0.0, 3.0),
|
||||
("Second one follows now.", 3.0, 6.0),
|
||||
("A third rounds it out.", 6.0, 9.0),
|
||||
)
|
||||
segs = segment_transcript(result, duration=9.0)
|
||||
ids = [s["id"] for s in segs]
|
||||
assert len(set(ids)) == len(ids)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _merge_short
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMergeShort:
|
||||
def test_folds_fragment_into_previous(self):
|
||||
segs = [
|
||||
Segment(0.0, 3.0, "First segment is long enough.", "S1"),
|
||||
Segment(3.0, 3.4, "ok", "S1"), # fragment
|
||||
]
|
||||
merged = _merge_short(list(segs))
|
||||
assert len(merged) == 1
|
||||
assert merged[0].end == 3.4
|
||||
assert "ok" in merged[0].text
|
||||
|
||||
def test_does_not_cross_speaker_boundary_when_gap_too_large(self):
|
||||
segs = [
|
||||
Segment(0.0, 3.0, "First segment is long enough.", "S1"),
|
||||
Segment(9.0, 9.4, "ok", "S2"), # different speaker, far away
|
||||
Segment(10.0, 13.0, "Another good length segment ok.", "S2"),
|
||||
]
|
||||
merged = _merge_short(list(segs))
|
||||
speakers = [m.speaker_id for m in merged]
|
||||
# The fragment should fold into S2 (same speaker) not S1.
|
||||
assert "S1" in speakers and "S2" in speakers
|
||||
# S1 segment text unchanged
|
||||
s1 = next(m for m in merged if m.speaker_id == "S1")
|
||||
assert s1.text == "First segment is long enough."
|
||||
|
||||
def test_stranded_fragment_survives_when_no_neighbor_matches(self):
|
||||
segs = [Segment(0.0, 0.3, "hi", "S1")]
|
||||
merged = _merge_short(list(segs))
|
||||
# Orphan — nothing to merge with.
|
||||
assert len(merged) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _apply_scene_cuts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestApplySceneCuts:
|
||||
def test_splits_at_safe_cut(self):
|
||||
segs = [
|
||||
Segment(0.0, 6.0, "We are going to the store. The store sells groceries.", "S1"),
|
||||
]
|
||||
out = _apply_scene_cuts(list(segs), [3.0])
|
||||
assert len(out) == 2
|
||||
assert out[0].end == 3.0
|
||||
assert out[1].start == 3.0
|
||||
|
||||
def test_rejects_cut_producing_tiny_left(self):
|
||||
segs = [Segment(0.0, 6.0, "Hello world this sentence runs long enough for a cut.", "S1")]
|
||||
# Cut at 0.3 would leave < MIN_DUR on the left
|
||||
out = _apply_scene_cuts(list(segs), [0.3])
|
||||
assert len(out) == 1
|
||||
assert out[0].start == 0.0
|
||||
|
||||
def test_rejects_cut_producing_tiny_right(self):
|
||||
segs = [Segment(0.0, 6.0, "Hello world this sentence runs long enough for a cut.", "S1")]
|
||||
# Cut at 5.9 would leave < MIN_DUR on the right
|
||||
out = _apply_scene_cuts(list(segs), [5.9])
|
||||
assert len(out) == 1
|
||||
|
||||
def test_no_cuts_returns_input_untouched(self):
|
||||
segs = [Segment(0.0, 3.0, "Hello world test.", "S1")]
|
||||
out = _apply_scene_cuts(list(segs), [])
|
||||
assert out == segs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Speaker assignment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSpeakerAssignment:
|
||||
def test_heuristic_alternates_on_gap(self):
|
||||
segs = [
|
||||
{"start": 0.0, "end": 2.0, "text": "a", "id": "1", "speaker_id": "?"},
|
||||
{"start": 2.1, "end": 4.0, "text": "b", "id": "2", "speaker_id": "?"}, # small gap
|
||||
{"start": 6.0, "end": 8.0, "text": "c", "id": "3", "speaker_id": "?"}, # big gap → switch
|
||||
]
|
||||
out = assign_speakers_heuristic(segs)
|
||||
assert out[0]["speaker_id"] == out[1]["speaker_id"]
|
||||
assert out[2]["speaker_id"] != out[1]["speaker_id"]
|
||||
|
||||
def test_diarization_uses_overlap_weighted_assignment(self):
|
||||
# Build a fake diarization with two overlapping turns for the same seg;
|
||||
# the one with more overlap should win, not the one at midpoint.
|
||||
class FakeTurn:
|
||||
def __init__(self, start, end):
|
||||
self.start = start
|
||||
self.end = end
|
||||
|
||||
class FakeDiar:
|
||||
def itertracks(self, yield_label=True):
|
||||
# SPEAKER_00 covers 0.0–1.0 (1.0s overlap with seg 0–2)
|
||||
# SPEAKER_01 covers 1.0–1.3 (0.3s overlap) — midpoint 1.0 → SPEAKER_01
|
||||
yield FakeTurn(0.0, 1.0), None, "SPEAKER_00"
|
||||
yield FakeTurn(1.0, 1.3), None, "SPEAKER_01"
|
||||
yield FakeTurn(1.3, 2.0), None, "SPEAKER_00"
|
||||
|
||||
segs = [{"start": 0.0, "end": 2.0, "text": "x", "id": "1", "speaker_id": "?"}]
|
||||
out = assign_speakers_from_diarization(segs, FakeDiar())
|
||||
assert out[0]["speaker_id"] == "Speaker 1" # SPEAKER_00 + 1
|
||||
|
||||
def test_diarization_falls_back_to_midpoint_when_no_overlap(self):
|
||||
class FakeTurn:
|
||||
def __init__(self, start, end):
|
||||
self.start = start
|
||||
self.end = end
|
||||
|
||||
class FakeDiar:
|
||||
def itertracks(self, yield_label=True):
|
||||
yield FakeTurn(10.0, 20.0), None, "SPEAKER_03" # no overlap with 0–2
|
||||
|
||||
segs = [{"start": 0.0, "end": 2.0, "text": "x", "id": "1", "speaker_id": "?"}]
|
||||
out = assign_speakers_from_diarization(segs, FakeDiar())
|
||||
# No overlap, no midpoint match — speaker_id stays "?"
|
||||
assert out[0]["speaker_id"] == "?"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSegmentDictContract:
|
||||
def test_returned_dicts_have_required_keys(self):
|
||||
result = _chunks(("Hello there friends, this is enough text.", 0.0, 3.0))
|
||||
segs = segment_transcript(result, duration=3.0)
|
||||
for s in segs:
|
||||
assert {"id", "start", "end", "text", "speaker_id"} <= set(s.keys())
|
||||
assert isinstance(s["start"], float)
|
||||
assert isinstance(s["end"], float)
|
||||
assert isinstance(s["text"], str)
|
||||
assert s["end"] > s["start"]
|
||||
@@ -664,6 +664,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cloudpickle"
|
||||
version = "3.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
@@ -932,6 +941,39 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "demucs"
|
||||
version = "4.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "dora-search" },
|
||||
{ name = "einops" },
|
||||
{ name = "julius" },
|
||||
{ name = "lameenc" },
|
||||
{ name = "openunmix" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "torch", version = "2.8.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" },
|
||||
{ name = "torch", version = "2.8.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "torchaudio", version = "2.8.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" },
|
||||
{ name = "torchaudio", version = "2.8.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "tqdm" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/87/38/55f835ebd9f443465087a6954ede19d4a41aebdf5e28567e89b99d6d2f57/demucs-4.0.1.tar.gz", hash = "sha256:e45a5a788bae79767c37bbf6e69aae03862ddcca05550fb79b926346a177d713", size = 1212924, upload-time = "2023-09-07T16:09:01.334Z" }
|
||||
|
||||
[[package]]
|
||||
name = "dora-search"
|
||||
version = "0.1.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "omegaconf" },
|
||||
{ name = "retrying" },
|
||||
{ name = "submitit" },
|
||||
{ name = "torch", version = "2.8.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" },
|
||||
{ name = "torch", version = "2.8.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "treetable" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d5/9d/9a13947db237375486c0690f4741dd2b7e1eee20e0ffcb55dbd1b21cc600/dora_search-0.1.12.tar.gz", hash = "sha256:2956fd2c4c7e4b9a4830e83f0d4cf961be45cfba1a2f0570281e91d15ac516fb", size = 87111, upload-time = "2023-05-23T14:36:24.743Z" }
|
||||
|
||||
[[package]]
|
||||
name = "editdistance"
|
||||
version = "0.8.1"
|
||||
@@ -1811,6 +1853,55 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lameenc"
|
||||
version = "1.8.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/e3/3181b9f9b1dca1b05b668c9f56dbf2e6fbb880e132597f21f5737a8b0034/lameenc-1.8.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:31b8209803607c89de47fd42d04e30b5aa910448dd27ea2e973a76cd7439551d", size = 191452, upload-time = "2026-03-07T19:56:51.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/b3/904e5faa0f2c7d474bdd6a30838a487d8b03f16a5dd89d4b2d2cc788e201/lameenc-1.8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a810bc8e0c24554e38f5b287a3bfb1b8514e07ff6aadf69a0888a5fbc1d32e33", size = 180118, upload-time = "2026-03-07T19:56:41.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/e0/33e9813bf6148a09b29f67facdbc16d1730716382f1fede12d9b4ccc4a34/lameenc-1.8.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:776958a328490b59793fedabcf576b3d5c9884076897a348b4e3944477f3d29c", size = 253504, upload-time = "2026-03-07T20:08:37.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/b8/ecf657352a532de900786b1aabc7f3ce52784a38adfaac0e50aa44ad7c70/lameenc-1.8.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad3c45c185edcf11e8b1beeb1a58bd0be8e6137b9da12f1f084b6d37a7267bd5", size = 249085, upload-time = "2026-03-07T19:56:17.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/73/15dcc1845249731b69f8a1ef7d36b26cb0ff5f25fe00eb4bcf5eebe00c17/lameenc-1.8.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:1c25a970e57aef2d5e2ce848709cb0fde741d6884c756bcb0ad0f2a0dbd10c6b", size = 268056, upload-time = "2026-03-07T20:10:59.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/b9/e0dbd36212fe657ed7f8704b54a4d2db2bcbc7dd8a05fd23d58866289fe8/lameenc-1.8.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c09db3f69575866cd9f4b685f68e83a0b47a2852ad4beedd848ffb0c3e7e6b21", size = 273023, upload-time = "2026-03-07T19:56:14.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/5e/72466ddb1cb08a7299f226a70fa9a67c6932614d44449f6bd4673a07d6ce/lameenc-1.8.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:0a536a3b964417b74c6b7c5580434e8ea163c03ab2e8b2390ea60870f46f0608", size = 200795, upload-time = "2026-03-07T19:56:19.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/2b/d0db1f150280947a490f6c5230ce27dc0e72f967704d6706d3484b83710b/lameenc-1.8.2-cp310-cp310-win32.whl", hash = "sha256:5833feb9633248ac3d346d63b352d71207b0bb47ee14550b1d3e07e8656fb6e2", size = 126530, upload-time = "2026-03-07T19:57:28.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/10/4634c5670d282230b2227a71850111c2e373d003ad7640ca6fa132feb8de/lameenc-1.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:8a0ac0cb79a2ec86c40b4f44cbbfee453e026a2b04c3a582da9075028b63b324", size = 154789, upload-time = "2026-03-07T19:57:23.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/54/c49ec8446a28e6db564eb84a538d4d6e04293150aa1ffd9e8082dc738bac/lameenc-1.8.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:41c9b872c9576bcad1bf9d1f11e3575284ae6fb61ac95a7cf65101f08c10e7d2", size = 191451, upload-time = "2026-03-07T19:56:40.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/48/81bd819ac38fded14824caea658ada0bf373d034c1e025e9ff0d305a1411/lameenc-1.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c29e9f9d8913c5b74c2aec663cf37591fd4378015607e8d08bebb3a0b5fec70d", size = 180113, upload-time = "2026-03-07T19:56:38.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/3e/7def3fa778258d873d253f568722007cc668bbd332be018dab58698b5808/lameenc-1.8.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e41ce9ed5ed50ea1d8002014b35d2789b0a8974fdb312ba46fd114641dd0052d", size = 254452, upload-time = "2026-03-07T20:08:39.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/9c/d35fcc01d98d17334b75fbcf50e7188aaeeaa783ce563f0a7e57e7d97f86/lameenc-1.8.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f0a53601fd8395f26440f7cd4e87b0a578a9b2ad43b8b6ea1d95a83c1c2cfaf", size = 249931, upload-time = "2026-03-07T19:56:18.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/22/3eb483f0b86f6cf6eada8a8da1d7662f2b23b2f2e7677762ae6f1521ed45/lameenc-1.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:270b9fb497ea7fca2c35d70a999c974679e5da8bb11ad4d54dd1c5f4e73db0cf", size = 268898, upload-time = "2026-03-07T20:11:01.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/90/eed761faae92658bb973ad643493b15f43575e7eeb108f39174952248329/lameenc-1.8.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:3559525ea9abd0c493cfd20600c4d1d14ebe15443b44111ac0aac0a182197069", size = 273828, upload-time = "2026-03-07T19:56:17.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/7c/e17488668dd760c90ca6ed1ccbc903a4ea6ed0dcb06c300969290d0252f1/lameenc-1.8.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:d093d9adba2f6743412dc9ce4e9448f0436f2a9ff6c86675570a509cfb4317d9", size = 201614, upload-time = "2026-03-07T19:56:21.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/f5/0ac2478f8b8cc83c548317d0f792062408f27708d204107958d75ca4cec0/lameenc-1.8.2-cp311-cp311-win32.whl", hash = "sha256:ab17df20a79769fd1a329cda513caba033c7749a5e85030decc758a7c087f098", size = 126533, upload-time = "2026-03-07T19:57:22.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/10/a1c783b615857603cdbec06a45b2797c9d58c0ccdb019f7320356f546cbf/lameenc-1.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:5f156ded111ef5e063bb9a6b0ed04bb9e16e199f035aa018bfc60294151c4f8c", size = 154793, upload-time = "2026-03-07T19:57:23.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/0e/4cbbaa655bdb3e8a2d8e58ea80537f78d65d2d1423ca2e138a16a2bd9065/lameenc-1.8.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:18a6e6e0ed6759eb952e76dbe1f2d246768f2b79ca2e6154a1ef5d0058091633", size = 191499, upload-time = "2026-03-07T19:56:53.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/08/39f80baadbc44541edb8ed2df4e3a6bb35efa6a444b55bda8003f849c46c/lameenc-1.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c0a8efacaca2f7f3e32e59fdc2668b41443adfa7285774f1e0e61611d0fade74", size = 180079, upload-time = "2026-03-07T19:56:59.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/d8/bca20935531b96cd9b177251847af804f0072a068302d3dcacbf5e686637/lameenc-1.8.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:674dfd584ca4988b713266e24bfd0aaa245102f171aa7dbead21e6638dadca6e", size = 253451, upload-time = "2026-03-07T20:08:40.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/36/01f9f222354f7493f17bd135610170a4db1f06abef58b3776e6033a09472/lameenc-1.8.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3564d4068d3344bea35223ea7367c80bb47d09c98e0598f02df1839538ed64c5", size = 249285, upload-time = "2026-03-07T19:56:20.092Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/e4/c6c8aa6c0a7e8770bd1401d5a96b7570916c8024a5e2d253b4a83107dbc3/lameenc-1.8.2-cp312-cp312-win32.whl", hash = "sha256:d34ce1df348e8e7dee509f73ec8b80be066ff97b322a26d057a706b8d5eb137d", size = 126588, upload-time = "2026-03-07T19:57:28.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/b5/be173d750ca85fbd5f38153106bd2673ec0540ca4b6eb22e742520dadd7d/lameenc-1.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:551ee0aa69bbe995a3cf001da7c563040f35dab4308cb33b077daf6aaa823a78", size = 154843, upload-time = "2026-03-07T19:57:29.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/f1/1c99c6bb87d5c07be41364efcba60c896a9f5118a9647ab65ff6182389b3/lameenc-1.8.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9fcfb12b4437c569e5977a57bf57b38bf95be1720a237337ffd18909e5f5b983", size = 191495, upload-time = "2026-03-07T19:56:47.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/0d/3b3c39d7cdc5996e84644ea39d551a1d4368ebd61e902ccc51d1b7027977/lameenc-1.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f83eb429725dc411db00bd17cc2c3f5304f419775db8693d96abea0bc8a59b85", size = 180075, upload-time = "2026-03-07T19:56:55.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/6b/8724a3a518ebd734439076cce1ee175538bd76f8f43232c3527b2db499a6/lameenc-1.8.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1481ae0c2b7f36b7d2509b0b924e6e717cb672b601e6716e0b5d9e51d08bb3cd", size = 253510, upload-time = "2026-03-07T20:08:41.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/46/5f7748110f920c1daf951ca07062987d5e5915aae6ca56eaddec2f948a41/lameenc-1.8.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:825ca907bbc36dc0a86a982835cdc9471597ca5874aae437cc411fc256f566e5", size = 249348, upload-time = "2026-03-07T19:56:25.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/ff/085bf42e88afc054cca925d711c3714c5d41d8e8dd49b9b2b89ba2c1c2c6/lameenc-1.8.2-cp313-cp313-win32.whl", hash = "sha256:73fd88f0ca19cfdb24d3bd9650a29cf252018d36265bf281c3060a01d83940e9", size = 126585, upload-time = "2026-03-07T19:57:29.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/d7/c6e6f8428a1c95080b54fa47362c79fa35485e1b2f4dd361b9c0a838d56b/lameenc-1.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:4b81a04cd7daa8db1c82d4496ae5be888647c2ea672706aeba89c7c72b1d45c4", size = 154838, upload-time = "2026-03-07T19:57:24.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/3a/8698832d0a2c2a8a15fd98db385c41952b37dbe71849c09f0d274bbd19f6/lameenc-1.8.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f6479246684707b9c548155d6a526a38e2a0408c0a04dac9658d3745e622263", size = 255380, upload-time = "2026-03-07T20:08:43.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/db/edc9d742661871bf0ae841426f437e1c890a8828a5d1c0eb5b3680fb9811/lameenc-1.8.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74f985e5b198385f13f451a3c3e101b1de1996348149deed5c7b8d212b6049a1", size = 250703, upload-time = "2026-03-07T19:56:28.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/09/69ac1f7ed65efee7116022c0c024178c84946a58702c25f2201988ae2359/lameenc-1.8.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6f4dfbcec36ec9e13d2fe39429159481000b50f7dd2383463522065f2474d998", size = 191556, upload-time = "2026-03-07T19:56:48.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/52/07b656a5d2efa70168a340f9240bb68d00562a5c339564707bbbe116d944/lameenc-1.8.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:063f7a6c0cd59e8ca023df4bac814024316551ccd61408a52e6e745a64551451", size = 180089, upload-time = "2026-03-07T19:56:46.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/cb/3f65220c3cea68758fff5e10d30a331b456ab737b723278a85d9d5014d6d/lameenc-1.8.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4612ae3e32839095cade9981d52a93463f2cd2a587851d61a4c24900e36fd18", size = 253659, upload-time = "2026-03-07T20:08:44.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/74/dc0003a48749bc66c49123ea342be2fb8c90e87a7dae0239e754ef83253b/lameenc-1.8.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3dac18bfe66f8e0acceaaec54acd333bad931c933d9236a195650dadbf855597", size = 249473, upload-time = "2026-03-07T19:56:30.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/2e/0049f2c35c480453f273242714f43ac7137a466374577bf9c9feaa90003a/lameenc-1.8.2-cp314-cp314-win32.whl", hash = "sha256:832f9cf84d16a853e0c0a32fd910bea3f38be638f0a4a20d7bbfc56c958effb5", size = 130410, upload-time = "2026-03-07T19:57:30.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/14/05d6c6e5cca04a9827e4a92abe61982dbaf27077ba957586caca0d0db270/lameenc-1.8.2-cp314-cp314-win_amd64.whl", hash = "sha256:ff0489249dd506470a2def670d030570cec440510df6e29ce72d3ee9ea22ec09", size = 159153, upload-time = "2026-03-07T19:57:23.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/82/ef8abe4db0116b7883b0053e28c53c636a0427da842e9187d5cfcf4a5ecd/lameenc-1.8.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ece852516f9271342f8b6c65f04a2777e239e4e1a79e8d87f3e042972e6014ed", size = 255534, upload-time = "2026-03-07T20:08:45.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/21/041c76e12904ca6d9cf74bdfaa83663d448360405ce4d87f7ed1448ac4e3/lameenc-1.8.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc7ce404f108590ec0c85660b05112e71b83bee3484ba900252d5c61c5d811e3", size = 250813, upload-time = "2026-03-07T19:56:35.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/53/7cc7d36ee3e080727ec925dc118633714a754cf9492d3319491c5daa5edb/lameenc-1.8.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:278a08b1e920ff598f886df062eac812b5ceb2a90ac86e637381844e931c4943", size = 240105, upload-time = "2026-03-07T20:08:50.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/38/f885a81684491c23b1a5c7e35ef1e5a142a89f1eb3b5678983a82f492d43/lameenc-1.8.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7827c19ee1e1cbee360ec3fb424050d8a2caa0c2a7736f2638b678c020e6f5ee", size = 235560, upload-time = "2026-03-07T19:56:43.687Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy-loader"
|
||||
version = "0.5"
|
||||
@@ -2119,6 +2210,73 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mlx"
|
||||
version = "0.31.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mlx-metal", marker = "sys_platform == 'darwin'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/f9/f1663dafd45af02467f4f41777c13ec34b9104b2b0450d870c3f906285cd/mlx-0.31.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:bc46c911cc060d2eaf21b9e24a1712dc56763b660b53631b9057a32ab1c0271a", size = 574137, upload-time = "2026-03-12T02:15:54.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/26/1fd632f537a5160a21475a70aaef252090c62f9629f45ad20f5acfe810f3/mlx-0.31.1-cp310-cp310-macosx_15_0_arm64.whl", hash = "sha256:fa132def5b3d959362077521c80f1fc80f64c45060d2940dc1d66a1aa19ce5f6", size = 574140, upload-time = "2026-03-12T02:15:56.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/c9/e790fa8ddc1b27fea7ba749699883f31c65e166b18e4598beab4574e4686/mlx-0.31.1-cp310-cp310-macosx_26_0_arm64.whl", hash = "sha256:877ff2f98debd035b922825a0d7e7e1be0959fc5ca1d24cb5020a23e510ff16d", size = 574124, upload-time = "2026-03-12T02:15:58.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/da/f7375fc2be05d026640c5ced085a9e71066a33100638e5762347dae5d680/mlx-0.31.1-cp310-cp310-manylinux_2_35_aarch64.whl", hash = "sha256:931c9316ec47b45ec0e737519f4f4c90eb69cbbdaaecadd6dd2ccdf1a85d4e61", size = 641428, upload-time = "2026-03-12T02:15:59.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/3f/ab060661d966d435e41212d4f6d6e9d1202da8b9043b1c18c343ab7d1b08/mlx-0.31.1-cp310-cp310-manylinux_2_35_x86_64.whl", hash = "sha256:dec00ce7b094d6bc2876996291fd76c9e28326bc1a9853440903f2a06946ce1f", size = 674521, upload-time = "2026-03-12T02:16:01.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/32/25dc2eae1d6f867224ef2bca2c644e3e913fe8067991f8394c090b720e3e/mlx-0.31.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:8863835fb36c7c4f65008b1426ddb9ff7931a13c975e0ef58a40002ae8048922", size = 574311, upload-time = "2026-03-12T02:16:02.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/bf/c5aa1d1154f5a216139c8162cd3e6568b7eb427390d655f7f5ae3a1a61e7/mlx-0.31.1-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:0de504c1f1fe73b32fc3cf457b8eac30d1f7ce22440ef075c1970f96712e6fff", size = 574312, upload-time = "2026-03-12T02:16:04.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/88/ef57747552c9e9da0c28465d9266c05a0009b698d90fb0bc63eb81840b8d/mlx-0.31.1-cp311-cp311-macosx_26_0_arm64.whl", hash = "sha256:10715b895e1f3e984c2c54257b7db956ff8af1fa93255412794a3724fe2dd3b1", size = 574385, upload-time = "2026-03-12T02:16:05.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/51/dbea4bbe7a2e4cd05226965b34198d49459cfaef8b9b37b72f006a9811ab/mlx-0.31.1-cp311-cp311-manylinux_2_35_aarch64.whl", hash = "sha256:d065625ab3101adcd7f5824297243fe40a0615099a06f5597ab67284483aa2f8", size = 641347, upload-time = "2026-03-12T02:16:07.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/86/3db98e8805637fb56f078311d622e9500f5c9088f6d79a6e304ec8235b47/mlx-0.31.1-cp311-cp311-manylinux_2_35_x86_64.whl", hash = "sha256:b2cf8502d9d64dc6851034fcd4a656cbb26be20c36f190f2971f4ac0caed89cb", size = 674769, upload-time = "2026-03-12T02:16:08.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/29/71fe1f68756f515856e6930973c23245810d4aa3cd22fddd719d86a709dc/mlx-0.31.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8a63b31a398c9519f2bb0c81cf3865d9baca4ff573ffc31ead465d18286184e8", size = 574308, upload-time = "2026-03-12T02:16:10.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/be/70654a2cee0d71fd10bd237a50a79d06ae51679a194db6a3b16c0c84e6a5/mlx-0.31.1-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:a7a9347df4dcc41f0d16ff70b65650820af4879f686534b233b16826a22afa00", size = 574309, upload-time = "2026-03-12T02:16:11.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/69/c7bc7b04f76b0cbd678f328011d1634bd0bcfc2da45aba06e084cb031127/mlx-0.31.1-cp312-cp312-macosx_26_0_arm64.whl", hash = "sha256:6cdb797ea31787d1ce9e5be77991c4bd5cbf129ab15f7253b78e09737f535fce", size = 574289, upload-time = "2026-03-12T02:16:13.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/f7/dcc129228faab4d406041d91413c5999250ab79da6fe5417ac84f1616ff1/mlx-0.31.1-cp312-cp312-manylinux_2_35_aarch64.whl", hash = "sha256:1ed1991c8e39f841d5756c0c543beb819763a2f80fba3f4b150bc6cad4d973de", size = 626439, upload-time = "2026-03-12T02:16:14.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/1d/8b32e46ea98ab5c1c15cf1b37ac97af651977f84e72e1800412a700c51d9/mlx-0.31.1-cp312-cp312-manylinux_2_35_x86_64.whl", hash = "sha256:195c5cb27328380287c0ffe9ef48f860ab75ec5d3dfce153d475dc2c99369708", size = 668679, upload-time = "2026-03-12T02:16:16.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/45/04465da443634b23fb11670bbd2f7538b1ed43ffc5e0de44a95b3c29e9c1/mlx-0.31.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9a6d3410fc951bd28508fed9c1ab5d9903f6f6bb101c3a5d63d4191d49a384a1", size = 574268, upload-time = "2026-03-12T02:16:17.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/7b/84956960356ff36e8c1bbed68fac96709e98e6a1adbc8e3d0ff71022d84e/mlx-0.31.1-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:20bd7ba19882603ac22711092d0e799f1ff7b5183c2c641d417dab4d2423d99e", size = 574265, upload-time = "2026-03-12T02:16:18.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/01/d6f0ef5b8c0b390af08246d1301e9717dfb076b3920012b53105a888ed8c/mlx-0.31.1-cp313-cp313-macosx_26_0_arm64.whl", hash = "sha256:4c4565d6f4f8ce295613ee342d313ee5ab0b0eab9a6272954450f8343f7876bc", size = 574172, upload-time = "2026-03-12T02:16:19.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/05/eb29e9eb0cff9c7dfd872e26663e6e9512629730740e1db629086c80ac5a/mlx-0.31.1-cp313-cp313-manylinux_2_35_aarch64.whl", hash = "sha256:9dc564a8b38b9aec279a1c7d34551068b1cc1f8e43b5ac044b56b2a9a4205195", size = 626558, upload-time = "2026-03-12T02:16:21.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/45/ecb746fbb6acb75d03760e41cc7bd21c2e2b544528b3033f7d70402334ac/mlx-0.31.1-cp313-cp313-manylinux_2_35_x86_64.whl", hash = "sha256:78f51ab929278366006ee7793dbb5c942b121542c793c33eb9b894a2ce8e27e1", size = 668625, upload-time = "2026-03-12T02:16:23.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/65/208f511acd5fb1ed0b08f047bd6229583845cc6f4b5aa6547a3219332dbb/mlx-0.31.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:bba9d471ba20e050676292b1089a355c8042d3fc9462e4c1738a9735d7d40cfa", size = 576300, upload-time = "2026-03-12T02:16:24.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/58/2d925cb3fa3cd28d279ed6f44508ab7fbbf7359b17359914aa3652a7d734/mlx-0.31.1-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:d90b0529b22553eb1353b113b7233aa391ca55e24b1ba69024c732fcc21c5c49", size = 576303, upload-time = "2026-03-12T02:16:26.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/17/abec0bd0f9347dae13e60b33325cb199312798842901953495e19f3bb3c8/mlx-0.31.1-cp314-cp314-macosx_26_0_arm64.whl", hash = "sha256:69bc88b41ddd61b44cd6a4d417790f9971ba3fdf58d824934cea95a95b9b4031", size = 576275, upload-time = "2026-03-12T02:16:27.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/91/85c73f7cc3a661416d05315623458c719eda7de958b05f4e10ba40c52d07/mlx-0.31.1-cp314-cp314-manylinux_2_35_aarch64.whl", hash = "sha256:b973506fd49ba39df6dc4ff655b77bd35ea193cee878e71d6ee3d1a951d2b3a6", size = 628701, upload-time = "2026-03-12T02:16:28.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/e9/d87638e00a44dcf346fe838caaf1e2dae96a88d5779edbd66ce27d4bbdcc/mlx-0.31.1-cp314-cp314-manylinux_2_35_x86_64.whl", hash = "sha256:3987282a1e63252bdd7c636138812c67316c3f7c7a7acad08e76c8843648a056", size = 668959, upload-time = "2026-03-12T02:16:30.41Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mlx-metal"
|
||||
version = "0.31.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/66/2313497fdbc7fbadf8e026c09366e3f049f9114e65ca4edc23cdb8699186/mlx_metal-0.31.1-py3-none-macosx_14_0_arm64.whl", hash = "sha256:70741174131dbf7fdd479cb730e06e08c358eac3bf7905d9e884e7960cfdd5b8", size = 38624074, upload-time = "2026-03-12T02:15:48.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/34/4c3c6890ce6095b2ab2ba2f5f15c9a7ba17208d47f8cacb572885a2dc0eb/mlx_metal-0.31.1-py3-none-macosx_15_0_arm64.whl", hash = "sha256:6c56bd8cd27743e635f5a90a22535af7c31bd22b4b126d46b6da2da52d72e413", size = 38618950, upload-time = "2026-03-12T02:15:51.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/bc/987cb99e3aafb296aa11ce5133838a10eae8447edd53168d0804d4fb3a14/mlx_metal-0.31.1-py3-none-macosx_26_0_arm64.whl", hash = "sha256:e7324b7c56b519ae67c025d3ced07e5d35bc3a9f19d4c45fe4927f385148c59e", size = 49256543, upload-time = "2026-03-12T02:15:54.851Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mlx-whisper"
|
||||
version = "0.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "huggingface-hub" },
|
||||
{ name = "mlx" },
|
||||
{ name = "more-itertools" },
|
||||
{ name = "numba" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "tiktoken" },
|
||||
{ name = "torch", version = "2.8.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" },
|
||||
{ name = "torch", version = "2.8.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "tqdm" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/b7/a35232812a2ccfffcb7614ba96a91338551a660a0e9815cee668bf5743f0/mlx_whisper-0.4.3-py3-none-any.whl", hash = "sha256:6b82b6597a994643a3e5496c7bc229a672e5ca308458455bfe276e76ae024489", size = 890544, upload-time = "2025-08-29T14:56:13.815Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "modelscope"
|
||||
version = "1.35.1"
|
||||
@@ -2136,6 +2294,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/d3/c98f736bbb5739871214e567ef9a1f5fca65f10d1b7bdc5e1bd565d492cf/modelscope-1.35.1-py3-none-any.whl", hash = "sha256:364db742867988da6be0493e0b9c4fd3e13bb0f5dd230c0c928102775aeed375", size = 6053743, upload-time = "2026-03-19T06:52:59.37Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "more-itertools"
|
||||
version = "11.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/f7/139d22fef48ac78127d18e01d80cf1be40236ae489769d17f35c3d425293/more_itertools-11.0.2.tar.gz", hash = "sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804", size = 144659, upload-time = "2026-04-09T15:01:33.297Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mpmath"
|
||||
version = "1.3.0"
|
||||
@@ -2709,10 +2876,13 @@ version = "0.2.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "accelerate" },
|
||||
{ name = "demucs" },
|
||||
{ name = "gradio" },
|
||||
{ name = "imageio-ffmpeg" },
|
||||
{ name = "mlx-whisper" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "pedalboard" },
|
||||
{ name = "psutil" },
|
||||
{ name = "pyannote-audio" },
|
||||
{ name = "pydub" },
|
||||
@@ -2758,6 +2928,7 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "accelerate" },
|
||||
{ name = "demucs", specifier = ">=4.0.1" },
|
||||
{ name = "fastapi", marker = "extra == 'api'" },
|
||||
{ name = "funasr", marker = "extra == 'eval'" },
|
||||
{ name = "gradio" },
|
||||
@@ -2766,7 +2937,9 @@ requires-dist = [
|
||||
{ name = "imageio-ffmpeg", specifier = ">=0.6.0" },
|
||||
{ name = "jiwer", marker = "extra == 'eval'", specifier = "==3.1.0" },
|
||||
{ name = "librosa", marker = "extra == 'eval'" },
|
||||
{ name = "mlx-whisper", specifier = ">=0.2.1" },
|
||||
{ name = "numpy" },
|
||||
{ name = "pedalboard", specifier = ">=0.9.14" },
|
||||
{ name = "psutil", specifier = ">=7.2.2" },
|
||||
{ name = "pyannote-audio", specifier = ">=4.0.4" },
|
||||
{ name = "pydub" },
|
||||
@@ -2909,6 +3082,24 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/6c/5e86fa1759a525ef91c2d8b79d668574760ff3f900d114297765eb8786cb/opentelemetry_semantic_conventions-0.62b0-py3-none-any.whl", hash = "sha256:0ddac1ce59eaf1a827d9987ab60d9315fb27aea23304144242d1fcad9e16b489", size = 231619, upload-time = "2026-04-09T14:38:32.394Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openunmix"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "torch", version = "2.8.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" },
|
||||
{ name = "torch", version = "2.8.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "torchaudio", version = "2.8.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" },
|
||||
{ name = "torchaudio", version = "2.8.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "tqdm" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/ef/4ad54e3ecb1e89f7f7bdb4c7b751e43754e892d3c32a8550e5d0882565df/openunmix-1.3.0.tar.gz", hash = "sha256:cc9245ce728700f5d0b72c67f01be4162777e617cdc47f9b035963afac180fc8", size = 45889, upload-time = "2024-04-16T11:10:47.121Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/43/37/320afd9458abb186f09a5183f36e48829df7151821bf887f272a63b2584d/openunmix-1.3.0-py3-none-any.whl", hash = "sha256:e893ae22c5b8001a6107022499c2587b70d5c2e4777cc7c9ed6272b68a69534e", size = 40047, upload-time = "2024-04-16T11:10:45.107Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "optuna"
|
||||
version = "4.8.0"
|
||||
@@ -3167,6 +3358,52 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/b0/34937815889fa982613775e4b97fddd13250f11012d769949c5465af2150/pandas-3.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:108dd1790337a494aa80e38def654ca3f0968cf4f362c85f44c15e471667102d", size = 9452085, upload-time = "2026-02-17T22:20:14.331Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pedalboard"
|
||||
version = "0.9.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/48/543e67204b286a026e96f3d7c4dd24a75613038743087371d2a391cd1e81/pedalboard-0.9.22-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:53529e5c57d6d8504bf1473422590d8ef324ba7ad4887bd7b3b65055c44dcc45", size = 2601981, upload-time = "2026-02-02T18:30:32.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/7b/21c41d9a87ecf4074fd1d51150c376e46e82554a152093bb189822aa9bcd/pedalboard-0.9.22-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1e771cfc796a04e132922b914131596f142b731ebf8f02587991f3697c5723eb", size = 2409686, upload-time = "2026-02-02T18:30:35.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/6a/ee690eb4323f8f4fdc9d086016029f55119cf8486aca39e8cec96c0a4544/pedalboard-0.9.22-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a3acf2a395faeaf0d51d8e5807d32ed554ea16b8b9962fff2b8e366241761c1", size = 4841007, upload-time = "2026-02-02T18:30:37.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/92/3a1c862af3591340d4b9326bd5e8f85bf90c8f436bd114cc23c3aa7f0319/pedalboard-0.9.22-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d921121609148fe0134745d437eeeba8bd04753c81a0fafd9d11a49dbf113ee", size = 5038795, upload-time = "2026-02-02T18:30:39.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/1b/c119552a34e1db7dd27420d95987563d376f43663d2cf8c754fa0b1edfd0/pedalboard-0.9.22-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9b634cf9768c3168fb70ab2ae3d839329a64fb1bac8da183a1faab3d49b7b9ca", size = 5145016, upload-time = "2026-02-02T18:30:41.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/78/e25ecf719509c0bc8f9884edbb9c0e9da329790d34e9c5074d781791f9f4/pedalboard-0.9.22-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5827bb0f7cc09e7799d394f407fb997cd02d7bb10c1dc96ab90dc6a9742735e7", size = 5204100, upload-time = "2026-02-02T18:30:43.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/61/68970170a48a2df6f2f2fc1c7b6f55392e772b7e06425da4fcf5a7c4a066/pedalboard-0.9.22-cp310-cp310-win_amd64.whl", hash = "sha256:d37dc78a7b7e7c3928dcd8edbc1db08c69351d306329a5fc7c32f976c224803b", size = 3579023, upload-time = "2026-02-02T18:30:45.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/96/35df5d924b25b085616c803796de21cc3ce992a92bd62b289ca30afeebb4/pedalboard-0.9.22-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:e644ae2dfa75ab4188d5dae3d120072ac5adf0279d8c4c20ed4382460327597a", size = 2603082, upload-time = "2026-02-02T18:30:46.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/86/c207558a91cac77301b4ab89810c71a218a64c4cdfc9edeed793a3f44f40/pedalboard-0.9.22-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:29ce0ca5fa6747c1022af3c2229f1bb7ed8c594a8bad77978485e3f1ea9916f2", size = 2410611, upload-time = "2026-02-02T18:30:47.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/83/e07bcdb221f2b4c9c7cd98d035aaa5a0cdc088cab498763147f59284325a/pedalboard-0.9.22-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02d6c566088a0bea2dfce438e768c2cdac34a739511d8ede2267f38552b86e9f", size = 4841908, upload-time = "2026-02-02T18:30:49.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/a7/a779f606a698e1da5083ac3ad010b839bc329aea0a4d1c6d97620905de3c/pedalboard-0.9.22-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f6065672ebe8423b4c4bd7becd69514ced2eb2ed65023e719c928162812e69d", size = 5040914, upload-time = "2026-02-02T18:30:50.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/fe/399c12699de895e71102100da5cc50b45433e254aad06df4fd583f7dbd41/pedalboard-0.9.22-cp311-cp311-win_amd64.whl", hash = "sha256:48429f41ddca670a83d67f9f15f799f80135fd630ba0db78fcf5bf58f357f727", size = 3579545, upload-time = "2026-02-02T18:30:52.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/3f/fc517b4652ae97993cafcb379125dff736c2ebc5c11f141fb728dc9fd779/pedalboard-0.9.22-cp312-cp312-macosx_10_14_universal2.whl", hash = "sha256:1fc17ce9a680d983793444275b5c6fc51aba41633cda1fa350e9e4146125fe82", size = 4692449, upload-time = "2026-02-02T18:30:54.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/5a/0aa0f6f91a2ca6a6e4b30774d02abb72c1cf12559eb6eff812714e04a9fe/pedalboard-0.9.22-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:64437b7b46ead7a0d56511bf8b0b7a61e69b7bd8440d0cc6bd928a95271a2457", size = 2608333, upload-time = "2026-02-02T18:30:55.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/cc/ba34faf9925e18a53e81f2d291c95a577a4af27ee5b970baae3014e69bb5/pedalboard-0.9.22-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e365b564cbf5f318ab725bfcdcbe88d31e0ae4cb160ce33b62ad6d0d74f183a8", size = 2412654, upload-time = "2026-02-02T18:30:57.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/72/179cbb53d58f08e5bb21a63efdc47046d27f23f70a71cabd61baaa9b87a9/pedalboard-0.9.22-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78c89a7185fa549d7830668731b0b3ca4118224dcd1e28031a2ee12a46a95941", size = 4838397, upload-time = "2026-02-02T18:30:59.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/3c/1ef8d9daf405c9cf3566a4b929f3c6396424f7b535fefccdf9123817625d/pedalboard-0.9.22-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e6dd9f072dc5e08c666d3eb28b262f594521a9eebb37d5f157d33ebb8bf22ce", size = 5035726, upload-time = "2026-02-02T18:31:01.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/06/99debe3b09b6ad61d39eedc03a0d02d0e5f2e45f113e26bab7b0154e4bc3/pedalboard-0.9.22-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1678b5efae53865cdf431e9b8c41721d3255e8304905376e52a285cc5afb926a", size = 5147151, upload-time = "2026-02-02T18:31:02.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/04/cdfbc7d461ae50cc3a90a226280ac029d89a90863062bf8103d623de965d/pedalboard-0.9.22-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:10b0250e20cd19b24f6c5bc8ed6e22c5d289f684d50dd46d7fd57a240b4d1acc", size = 5209119, upload-time = "2026-02-02T18:31:05.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/62/d65b8077aa337c38e41b7ee3a84257a71e444dead107e644277c8c4600cd/pedalboard-0.9.22-cp312-cp312-win_amd64.whl", hash = "sha256:f027be64854e378ae520481f51121111f1e7c857e2258b6f31e97437bb37b5f1", size = 3580963, upload-time = "2026-02-02T18:31:07.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/14/4d40d257bf6f26badc20826183133f635028d9b2b6657af3a460ea63e1be/pedalboard-0.9.22-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:001a1b94a98cabadb697cd410545fe16de6949dd80bb6f0768af682832f28873", size = 2608482, upload-time = "2026-02-02T18:31:09.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/a1/4dc73328d2883fdd6f0f4ce29627bbe398a442fabee6e8f7194d83c14e33/pedalboard-0.9.22-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e06d648590a4dcf2a9914f96d1b18465688c55ce890906ba13ed56866f72e91a", size = 2412698, upload-time = "2026-02-02T18:31:10.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/dd/7130967ed07057fdcc3ec0d468901f3567fdb016d655e398cd90d0ef9ece/pedalboard-0.9.22-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:472abd00d9553db507f7380d19362dbb10a15344512219c2d0447b19a972d61a", size = 4838384, upload-time = "2026-02-02T18:31:12.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/0b/672f2b0d9bf9dca56290c845a91e64969adb40eec0a8d3fc4cdf4ef3f5f1/pedalboard-0.9.22-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b00a9223f14bfb33dca43d12d7f8cf7d91b3f521da453124961da33a46b77ed7", size = 5035557, upload-time = "2026-02-02T18:31:14.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/e2/7d32778b0cda30bf81e8d522150075f63673cd53aa009862773728f10699/pedalboard-0.9.22-cp313-cp313-win_amd64.whl", hash = "sha256:660f1993517386221cc3acf2da96a3257d428ae2b9d97802df484b4d3d32b3df", size = 3580864, upload-time = "2026-02-02T18:31:15.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/2f/f2c3cba2896f239d2f0b22a7114a9395f41af548856657c231ea64838454/pedalboard-0.9.22-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d87ddbd13aeea3523684367f364e8c9a0d4d00525e2ff01a88048a5652356797", size = 2628311, upload-time = "2026-02-02T18:31:18.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/7f/882631416bb0488c19857d71900ca3beeb66b504af69ca89f8ff7f44a038/pedalboard-0.9.22-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f3108beaaaa2341c635c4ab56278a7b556f6947535d32ad8430dd68de3214df8", size = 2439237, upload-time = "2026-02-02T18:31:19.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/6d/cf29b69a81a4be5c79162a5abfb7591dc19a814a12ff0eb6d0a842fe6f21/pedalboard-0.9.22-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1a6cdb9e3fa591530497302e28e32e7f6eb3f1bb7a275d70840f3bdfcfedf35", size = 4841966, upload-time = "2026-02-02T18:31:21.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/c3/4f518534fa23a02c83288a4a2faeaea8c36c6a9e73a18d542e68fbc0442d/pedalboard-0.9.22-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c1efa3f8112a0d1609ddf0dacfc881a92c253fd3869ca28ef3b6c4e004800fb", size = 5039285, upload-time = "2026-02-02T18:31:22.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/26/1901deee42e2f889bacedb6a05a973db96476ae0e24e7c9e70ecf85921ea/pedalboard-0.9.22-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b3d75659cba566421712eaf9aa7e53f9fa9cca02964cdcd232f3b1c830fecad3", size = 2413038, upload-time = "2026-02-02T18:31:24.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/fe/f4871f931ae8d765a41836b2660689b99db520001efa702b30a822cdbbbf/pedalboard-0.9.22-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51339d6bb2bbcd7bf664fdeae2beb49513eb2278ab5f165db156f1f144994948", size = 4840216, upload-time = "2026-02-02T18:31:26.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/75/1bdbaa14b55fab52301b36b84c2708d57b0cd07bd84f49b1bb1f65f1dc9f/pedalboard-0.9.22-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:630fead1b6e15119e5ed38309631b073f7d482db25cdfb7c339a00b5bd5706ae", size = 5036260, upload-time = "2026-02-02T18:31:28.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/86/cf974223287b383b205356381433a030ec8533a22c66418b643f9b8be3de/pedalboard-0.9.22-cp314-cp314-win_amd64.whl", hash = "sha256:a6184457152632bea704bc66995537985139103a6b17569fc1e253b304837ec5", size = 3694044, upload-time = "2026-02-02T18:31:29.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/50/a01b9334449ff8f90bc1b2c90b81bcf7ead6352151c89b6deb3c3fb7db63/pedalboard-0.9.22-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:493b059de75b557e6b96c72bacf649030d547c4aa162cb6f2f8a30271b2b61aa", size = 2439179, upload-time = "2026-02-02T18:31:31.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/52/d9d0e67e38323513df2cf952f1395daa78cbdc1f7239d3b045565ffb6f6c/pedalboard-0.9.22-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3516148ec4fa6c35603a8d97cc849c659bc7be77f9b0a6cc7763dba75408578b", size = 4841990, upload-time = "2026-02-02T18:31:33.616Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pefile"
|
||||
version = "2024.8.26"
|
||||
@@ -4262,6 +4499,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "retrying"
|
||||
version = "1.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c8/5a/b17e1e257d3e6f2e7758930e1256832c9ddd576f8631781e6a072914befa/retrying-1.4.2.tar.gz", hash = "sha256:d102e75d53d8d30b88562d45361d6c6c934da06fab31bd81c0420acb97a8ba39", size = 11411, upload-time = "2025-08-03T03:35:25.189Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl", hash = "sha256:bbc004aeb542a74f3569aeddf42a2516efefcdaff90df0eb38fbfbf19f179f59", size = 10859, upload-time = "2025-08-03T03:35:23.829Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.3.3"
|
||||
@@ -4852,6 +5098,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "submitit"
|
||||
version = "1.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cloudpickle" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/86/497018fb3b74e71bef45df82762b176e6b3d159f29941c20d2f141ec4096/submitit-1.5.4.tar.gz", hash = "sha256:7100848bd1cdda79c7196e54ee830793ae75fd7adde0c5bef738d72360a07508", size = 81538, upload-time = "2025-12-17T19:20:03.396Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/bb/711e1c2ebd18a21202c972dd5d5c8e09a921f2d3560e3a53d6350c808ab7/submitit-1.5.4-py3-none-any.whl", hash = "sha256:c26f3a7c8d4150eaf70b1da71e2023e9e9936c93e8342ed7db910f29158561c5", size = 76043, upload-time = "2025-12-17T19:20:01.941Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sympy"
|
||||
version = "1.14.0"
|
||||
@@ -4888,6 +5147,67 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiktoken"
|
||||
version = "0.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "regex" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/89/b3/2cb7c17b6c4cf8ca983204255d3f1d95eda7213e247e6947a0ee2c747a2c/tiktoken-0.12.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3de02f5a491cfd179aec916eddb70331814bd6bf764075d39e21d5862e533970", size = 1051991, upload-time = "2025-10-06T20:21:34.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/0f/df139f1df5f6167194ee5ab24634582ba9a1b62c6b996472b0277ec80f66/tiktoken-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b6cfb6d9b7b54d20af21a912bfe63a2727d9cfa8fbda642fd8322c70340aad16", size = 995798, upload-time = "2025-10-06T20:21:35.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/5d/26a691f28ab220d5edc09b9b787399b130f24327ef824de15e5d85ef21aa/tiktoken-0.12.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:cde24cdb1b8a08368f709124f15b36ab5524aac5fa830cc3fdce9c03d4fb8030", size = 1129865, upload-time = "2025-10-06T20:21:36.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/94/443fab3d4e5ebecac895712abd3849b8da93b7b7dec61c7db5c9c7ebe40c/tiktoken-0.12.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6de0da39f605992649b9cfa6f84071e3f9ef2cec458d08c5feb1b6f0ff62e134", size = 1152856, upload-time = "2025-10-06T20:21:37.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/35/388f941251b2521c70dd4c5958e598ea6d2c88e28445d2fb8189eecc1dfc/tiktoken-0.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6faa0534e0eefbcafaccb75927a4a380463a2eaa7e26000f0173b920e98b720a", size = 1195308, upload-time = "2025-10-06T20:21:39.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/00/c6681c7f833dd410576183715a530437a9873fa910265817081f65f9105f/tiktoken-0.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:82991e04fc860afb933efb63957affc7ad54f83e2216fe7d319007dab1ba5892", size = 1255697, upload-time = "2025-10-06T20:21:41.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/d2/82e795a6a9bafa034bf26a58e68fe9a89eeaaa610d51dbeb22106ba04f0a/tiktoken-0.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:6fb2995b487c2e31acf0a9e17647e3b242235a20832642bb7a9d1a181c0c1bb1", size = 879375, upload-time = "2025-10-06T20:21:43.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565, upload-time = "2025-10-06T20:21:44.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284, upload-time = "2025-10-06T20:21:45.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201, upload-time = "2025-10-06T20:21:47.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/d0/3d9275198e067f8b65076a68894bb52fd253875f3644f0a321a720277b8a/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded", size = 1152444, upload-time = "2025-10-06T20:21:48.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/db/a58e09687c1698a7c592e1038e01c206569b86a0377828d51635561f8ebf/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd", size = 1195080, upload-time = "2025-10-06T20:21:49.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/1b/a9e4d2bf91d515c0f74afc526fd773a812232dd6cda33ebea7f531202325/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967", size = 1255240, upload-time = "2025-10-06T20:21:50.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/15/963819345f1b1fb0809070a79e9dd96938d4ca41297367d471733e79c76c/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def", size = 879422, upload-time = "2025-10-06T20:21:51.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokenizers"
|
||||
version = "0.22.2"
|
||||
@@ -5233,6 +5553,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/88/ae8320064e32679a5429a2c9ebbc05c2bf32cefb6e076f9b07f6d685a9b4/transformers-5.3.0-py3-none-any.whl", hash = "sha256:50ac8c89c3c7033444fb3f9f53138096b997ebb70d4b5e50a2e810bf12d3d29a", size = 10661827, upload-time = "2026-03-04T17:41:42.722Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "treetable"
|
||||
version = "0.2.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8d/f1/e5f28b2485d8d3169ff0167e3e560fa912a96e71916bf11365c9e40f11f5/treetable-0.2.6.tar.gz", hash = "sha256:7e1d62dbce503fbf24561aee1461b8fbcc2c232ff45661c3b9d0c2081c795bdf", size = 9577, upload-time = "2025-09-02T20:40:06.557Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/16/dd00f9fc5b84cb3fb396d62245e11761accfd9d27fd56ce0024bdd38a0ae/treetable-0.2.6-py3-none-any.whl", hash = "sha256:fa7dfa0297d2bbc5882191edd2e15f79a5e883e352f489e2acadb221db565adf", size = 7379, upload-time = "2025-09-03T18:57:17.784Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "triton"
|
||||
version = "3.4.0"
|
||||
|
||||
Reference in New Issue
Block a user