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:
debpalash
2026-04-18 17:20:19 +05:30
co-authored by Claude Opus 4.7
parent d2cab558c3
commit 67328d04fe
44 changed files with 5159 additions and 2355 deletions
+12
View File
@@ -37,10 +37,22 @@ bun.lockb
# Generated logs and binaries # Generated logs and binaries
*.log *.log
crash_log.txt
cloudflared cloudflared
cloudflared.tgz 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 # Data directories and sqlite db
omnivoice_data/ omnivoice_data/
*.db *.db
demo_recording.webp demo_recording.webp
# Local archives / reference clones
omnivoice.zip
/research/
View File
View File
+464
View File
@@ -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))
+513
View File
@@ -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"'},
)
+182
View File
@@ -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}
+253
View File
@@ -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)})
+115
View File
@@ -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))
+224
View File
@@ -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}
+132
View File
@@ -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}
+72
View File
@@ -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}
+132
View File
@@ -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})
View File
+37
View File
@@ -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", "")
+141
View File
@@ -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()
+103
View File
@@ -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()
+66 -2064
View File
File diff suppressed because it is too large Load Diff
View File
+53
View File
@@ -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.
View File
+32
View File
@@ -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
+90
View File
@@ -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
+118
View File
@@ -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
+506
View File
@@ -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
+51
View File
@@ -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")
-37
View File
@@ -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'
+430 -182
View File
@@ -1,6 +1,10 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'; import React, { useState, useRef, useEffect, useCallback } from 'react';
import './index.css'; import './index.css';
import WaveformTimeline from './components/WaveformTimeline'; 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 { Toaster, toast } from 'react-hot-toast';
import ALL_LANGUAGES from './languages.json'; import ALL_LANGUAGES from './languages.json';
import { import {
@@ -10,7 +14,7 @@ import {
FileText, Loader, Check, AlertCircle, Plus, User, Save, Languages, Headphones, FileText, Loader, Check, AlertCircle, Plus, User, Save, Languages, Headphones,
FolderOpen, FolderPlus, Pencil, Clock, Lock, Unlock, Mic, MicOff, Square, FolderOpen, FolderPlus, Pencil, Clock, Lock, Unlock, Mic, MicOff, Square,
CheckCircle, Circle, ChevronRight, Target, PanelLeftClose, PanelLeftOpen, Scale, CheckCircle, Circle, ChevronRight, Target, PanelLeftClose, PanelLeftOpen, Scale,
Layers, Music, Package, DownloadCloud Layers, Music, Package, DownloadCloud, RefreshCw
} from 'lucide-react'; } from 'lucide-react';
// Tauri: pre-import window API to avoid async delays in event handlers // 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. * We upload to the backend's /preview endpoint and serve via HTTP instead.
* Falls back to createObjectURL for regular browsers. * 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) => { const fileToMediaUrl = async (file, prevUrls) => {
// Revoke previous blob URLs if they exist // Revoke previous blob URLs if they exist
if (prevUrls?.videoUrl?.startsWith('blob:')) URL.revokeObjectURL(prevUrls.videoUrl); if (prevUrls?.videoUrl?.startsWith('blob:')) URL.revokeObjectURL(prevUrls.videoUrl);
@@ -60,17 +64,33 @@ const fileToMediaUrl = async (file, prevUrls) => {
const playBlobAudio = async (blob) => { const playBlobAudio = async (blob) => {
if (isTauri) { if (isTauri) {
const ctx = new (window.AudioContext || window.webkitAudioContext)(); const ctx = new (window.AudioContext || window.webkitAudioContext)();
const buf = await blob.arrayBuffer(); // WebKit suspends AudioContext by default — must resume before decoding
const decoded = await ctx.decodeAudioData(buf); if (ctx.state === 'suspended') await ctx.resume();
const src = ctx.createBufferSource(); try {
src.buffer = decoded; const buf = await blob.arrayBuffer();
src.connect(ctx.destination); const decoded = await ctx.decodeAudioData(buf);
src.start(0); const src = ctx.createBufferSource();
src.onended = () => ctx.close(); src.buffer = decoded;
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 { } else {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = new Audio(url); const a = new Audio(url);
a.play().catch(() => {}); a.play().catch((e) => console.error('playBlobAudio play error:', e));
a.onended = () => URL.revokeObjectURL(url); a.onended = () => URL.revokeObjectURL(url);
} }
}; };
@@ -172,7 +192,6 @@ function App() {
const [refText, setRefText] = useState(''); const [refText, setRefText] = useState('');
const [instruct, setInstruct] = useState(''); const [instruct, setInstruct] = useState('');
const [language, setLanguage] = useState('Auto'); const [language, setLanguage] = useState('Auto');
const [langSearch, setLangSearch] = useState('');
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const [history, setHistory] = useState([]); const [history, setHistory] = useState([]);
const [exportHistory, setExportHistory] = useState([]); const [exportHistory, setExportHistory] = useState([]);
@@ -229,8 +248,8 @@ function App() {
const [dubStep, setDubStep] = useState('idle'); const [dubStep, setDubStep] = useState('idle');
const [dubSegments, setDubSegments] = useState([]); const [dubSegments, setDubSegments] = useState([]);
const [dubLang, setDubLang] = useState('Auto'); const [dubLang, setDubLang] = useState('Auto');
const [dubLangSearch, setDubLangSearch] = useState('');
const [dubLangCode, setDubLangCode] = useState('en'); const [dubLangCode, setDubLangCode] = useState('en');
const [translateProvider, setTranslateProvider] = useState('argos');
const [dubInstruct, setDubInstruct] = useState(''); const [dubInstruct, setDubInstruct] = useState('');
const [dubProgress, setDubProgress] = useState({ current: 0, total: 0, text: '' }); const [dubProgress, setDubProgress] = useState({ current: 0, total: 0, text: '' });
const [dubFilename, setDubFilename] = useState(''); const [dubFilename, setDubFilename] = useState('');
@@ -238,6 +257,14 @@ function App() {
const [dubError, setDubError] = useState(''); const [dubError, setDubError] = useState('');
const [dubVideoFile, setDubVideoFile] = useState(null); const [dubVideoFile, setDubVideoFile] = useState(null);
const [dubLocalBlobUrl, setDubLocalBlobUrl] = 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 [dubTracks, setDubTracks] = useState([]);
const [dubTranscript, setDubTranscript] = useState(''); const [dubTranscript, setDubTranscript] = useState('');
const [showTranscript, setShowTranscript] = useState(false); const [showTranscript, setShowTranscript] = useState(false);
@@ -249,6 +276,7 @@ function App() {
const [exportTracks, setExportTracks] = useState({original: true}); // {original: true, es: true, de: false, ...} const [exportTracks, setExportTracks] = useState({original: true}); // {original: true, es: true, de: false, ...}
const [transcribeStart, setTranscribeStart] = useState(null); const [transcribeStart, setTranscribeStart] = useState(null);
const [transcribeElapsed, setTranscribeElapsed] = useState(0); const [transcribeElapsed, setTranscribeElapsed] = useState(0);
const [dubTaskId, setDubTaskId] = useState(null);
// ═══ STUDIO PROJECTS ═══ // ═══ STUDIO PROJECTS ═══
const [studioProjects, setStudioProjects] = useState([]); const [studioProjects, setStudioProjects] = useState([]);
@@ -456,6 +484,17 @@ function App() {
if (saved.dubTracks) setDubTracks(saved.dubTracks); if (saved.dubTracks) setDubTracks(saved.dubTracks);
if (saved.dubStep) setDubStep(saved.dubStep); if (saved.dubStep) setDubStep(saved.dubStep);
if (saved.dubTranscript) setDubTranscript(saved.dubTranscript); 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) {} } catch (e) {}
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, []);
@@ -465,9 +504,16 @@ function App() {
uiScale, text, mode, vdStates, language, uiScale, text, mode, vdStates, language,
isSidebarCollapsed, sidebarTab, isSidebarCollapsed, sidebarTab,
dubJobId, dubFilename, dubDuration, dubSegments, 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 ── // ── KEYBOARD SHORTCUTS ──
useEffect(() => { useEffect(() => {
@@ -710,10 +756,11 @@ function App() {
if (fin_prof) formData.append("profile_id", fin_prof); if (fin_prof) formData.append("profile_id", fin_prof);
if (fin_inst) formData.append("instruct", fin_inst); 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); // Hardcode lightweight inference steps for live preview to drastically boost timeline responsiveness
formData.append("num_step", 8);
formData.append("num_step", steps || 16);
formData.append("guidance_scale", cfg || 2.0); formData.append("guidance_scale", cfg || 2.0);
if (seg.speed && seg.speed !== 1.0) formData.append("speed", seg.speed); if (seg.speed && seg.speed !== 1.0) formData.append("speed", seg.speed);
@@ -855,26 +902,54 @@ function App() {
}; };
// ═══ DUB WORKFLOW ═══ // ═══ DUB WORKFLOW ═══
const dubAbortCtrlRef = useRef(null);
const dubClientJobIdRef = useRef(null);
const handleDubUpload = async () => { const handleDubUpload = async () => {
if (!dubVideoFile) return; if (!dubVideoFile) return;
setDubStep('uploading'); setDubError(''); setDubTracks([]); 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 { try {
const fd = new FormData(); const fd = new FormData();
fd.append("video", dubVideoFile); 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()); if (!res.ok) throw new Error(await res.text());
const data = await res.json(); const data = await res.json();
setDubJobId(data.job_id); setDubFilename(data.filename); setDubDuration(data.duration); setDubJobId(data.job_id); setDubFilename(data.filename); setDubDuration(data.duration);
setDubStep('transcribing'); setDubStep('transcribing');
setTranscribeStart(Date.now()); 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()); if (!tRes.ok) throw new Error(await tRes.text());
const tData = await tRes.json(); const tData = await tRes.json();
setDubSegments(tData.segments.map((s, i) => ({ ...s, id: i }))); setDubSegments(tData.segments.map((s, i) => ({ ...s, id: i })));
setDubTranscript(tData.full_transcript || ''); setDubTranscript(tData.full_transcript || '');
setTranscribeStart(null); setTranscribeStart(null);
setDubStep('editing'); 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 // Transcription elapsed timer
@@ -885,6 +960,21 @@ function App() {
}, [transcribeStart]); }, [transcribeStart]);
// ── AUTO-TRANSLATE ── // ── 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 () => { const handleTranslateAll = async () => {
if (!dubSegments.length || !dubLangCode) return; if (!dubSegments.length || !dubLangCode) return;
setIsTranslating(true); setIsTranslating(true);
@@ -893,8 +983,9 @@ function App() {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ 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, target_lang: dubLangCode,
provider: translateProvider,
}), }),
}); });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
@@ -929,6 +1020,7 @@ function App() {
instruct: fin_inst, profile_id: fin_prof, instruct: fin_inst, profile_id: fin_prof,
speed: s.speed || undefined, speed: s.speed || undefined,
gain: s.gain !== undefined && s.gain !== 1.0 ? s.gain : undefined, gain: s.gain !== undefined && s.gain !== 1.0 ? s.gain : undefined,
target_lang: s.target_lang || undefined,
}; };
}), }),
language: dubLang === 'Auto' ? 'Auto' : dubLang, language: dubLang === 'Auto' ? 'Auto' : dubLang,
@@ -943,11 +1035,14 @@ function App() {
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Failed to start generation"); if (!res.ok) throw new Error(data.detail || "Failed to start generation");
setDubTaskId(data.task_id);
// Connect to background task SSE stream // Connect to background task SSE stream
const streamRes = await fetch(`${API}/tasks/stream/${data.task_id}`); const streamRes = await fetch(`${API}/tasks/stream/${data.task_id}`);
const reader = streamRes.body.getReader(); const reader = streamRes.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; let buffer = '';
let wasCancelled = false;
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
@@ -965,15 +1060,34 @@ function App() {
setDubSegments(prev => prev.map((s, idx) => ({ ...s, sync_ratio: evt.sync_scores[idx] }))); 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}`); else if (evt.type === 'error') setDubError(p => p + `\nSeg ${evt.segment}: ${evt.error}`);
} catch (e) {} } catch (e) {}
} }
} }
} }
if (dubStep !== 'done') setDubStep('done'); setDubTaskId(null);
loadDubHistory(); if (!wasCancelled) {
playPing(); if (dubStep !== 'done') setDubStep('done');
} catch (err) { setDubError(err.message); setDubStep('editing'); } loadDubHistory();
playPing();
}
} 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) => { const handleNativeExport = async (e, sourceIdentifier, fallbackName, mode) => {
@@ -1012,28 +1126,73 @@ function App() {
toast.error(`Could not open folder: ${err.message}`); 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 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 { try {
toast.loading(`Processing ${fallbackName}...`, { id: fallbackName }); toast.loading(`Processing ${fallbackName}...`, { id: fallbackName });
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) throw new Error("Download failed"); 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 blob = await response.blob();
const localUrl = URL.createObjectURL(blob); const localUrl = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = localUrl; a.href = localUrl;
a.download = fallbackName || 'download'; a.download = finalName;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(localUrl); URL.revokeObjectURL(localUrl);
toast.success(`Downloaded ${fallbackName}`, { id: fallbackName }); toast.success(`Downloaded ${finalName}`, { id: fallbackName });
// Record to export history
try { 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`, { await fetch(`${API}/export/record`, {
method: 'POST', headers: {'Content-Type': 'application/json'}, 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(); loadExportHistory();
} catch (_) {} } catch (_) {}
@@ -1056,7 +1215,11 @@ function App() {
setDubDuration(0); setDubError(''); setDubVideoFile(null); setDubTracks([]); setDubDuration(0); setDubError(''); setDubVideoFile(null); setDubTracks([]);
setDubProgress({ current: 0, total: 0, text: '' }); setDubTranscript(''); setShowTranscript(false); setDubProgress({ current: 0, total: 0, text: '' }); setDubTranscript(''); setShowTranscript(false);
setPreviewAudios({}); 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(''); 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 ( return (
<div className={`app-container${isSidebarCollapsed ? ' sidebar-collapsed' : ''}`} style={{ zoom: uiScale }}> <div className={`app-container${isSidebarCollapsed ? ' sidebar-collapsed' : ''}`} style={{ zoom: uiScale }}>
@@ -1199,35 +1360,37 @@ function App() {
error: { iconTheme: { primary: '#fb4934', secondary: '#fff' } }, error: { iconTheme: { primary: '#fb4934', secondary: '#fff' } },
success: { iconTheme: { primary: '#b8bb26', 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'}}> <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'}}>
{/* Empty placeholder for traffic light buffer */} {/* Left cluster: traffic light buffer + tabs + dev panel */}
<div style={{minWidth: 80}}></div> <div style={{display:'flex', alignItems:'center', gap:'16px', justifySelf:'start', minWidth:0}}>
<div style={{minWidth: 80, flexShrink:0}}></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}}>
<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" />
<path d="M12 6v12" />
<path d="M8 9v6" />
<path d="M16 9v6" />
</svg>
<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}}> <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 === '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 === '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 === '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> <button className={`tab ${mode === 'dub' ? 'active' : ''}`} style={{whiteSpace:'nowrap'}} onClick={() => setMode('dub')}><Film size={11}/> Dub</button>
</div> </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: 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" />
<path d="M12 6v12" />
<path d="M8 9v6" />
<path d="M16 9v6" />
</svg>
<span style={{fontSize:'0.85rem', fontWeight:700, color:'#ebdbb2', letterSpacing:'-0.01em', fontFamily:'Outfit, sans-serif'}}>OmniVoice</span>
</div> </div>
{/* Right cluster: scale + stats */} {/* 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}}> <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)} 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> <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} disabled={true}
overlayContent={ overlayContent={
dubStep === 'uploading' ? ( 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"/> <Loader className="spinner" size={20} color="#d3869b"/>
<span style={{color:'#ebdbb2', fontWeight:500, fontSize:'0.85rem'}}>Extracting audio</span> <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> </div>
) : dubStep === 'transcribing' ? ( ) : dubStep === 'transcribing' ? (
<div style={{display:'flex', flexDirection:'column', alignItems:'center', gap:10, width:'100%'}}> <div style={{display:'flex', flexDirection:'column', alignItems:'center', gap:10, width:'100%'}}>
@@ -1496,6 +1664,11 @@ function App() {
}}/> }}/>
</div> </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> </div>
) : null ) : null
} }
@@ -1680,21 +1853,25 @@ function App() {
videoSrc={`${API}/dub/media/${dubJobId}`} videoSrc={`${API}/dub/media/${dubJobId}`}
segments={dubSegments} segments={dubSegments}
onSegmentsChange={setDubSegments} onSegmentsChange={setDubSegments}
disabled={dubStep === 'generating'} disabled={dubStep === 'generating' || dubStep === 'stopping'}
overlayContent={dubStep === 'generating' ? ( overlayContent={(dubStep === 'generating' || dubStep === 'stopping') ? (
<div style={{display:'flex', flexDirection:'column', alignItems:'center', gap:6, width:'100%'}}> <div style={{display:'flex', flexDirection:'column', alignItems:'center', gap:6, width:'100%'}}>
<div style={{display:'flex', alignItems:'center', gap:6}}> <div style={{display:'flex', alignItems:'center', gap:6}}>
<Sparkles className="spinner" size={14} color="#d3869b"/> {dubStep === 'stopping' ? <Loader className="spinner" size={14} color="#a89984"/> : <Sparkles className="spinner" size={14} color="#d3869b"/>}
<span style={{color:'#ebdbb2', fontWeight:500, fontSize:'0.72rem'}}> <span style={{color: dubStep === 'stopping' ? '#a89984' : '#ebdbb2', fontWeight:500, fontSize:'0.72rem'}}>
Dubbing {dubProgress.current}/{dubProgress.total} {dubStep === 'stopping' ? 'Stopping…' : `Dubbing ${dubProgress.current}/${dubProgress.total}`}
</span> </span>
</div> </div>
<div className="progress-container" style={{width:'80%', maxWidth:240}}> {dubStep === 'generating' && (
<div className="progress-fill" style={{ <>
width:`${dubProgress.total ? (dubProgress.current/dubProgress.total)*100 : 0}%` <div className="progress-container" style={{width:'80%', maxWidth:240}}>
}}/> <div className="progress-fill" style={{
</div> width:`${dubProgress.total ? (dubProgress.current/dubProgress.total)*100 : 0}%`
{dubProgress.text && <span style={{fontSize:'0.65rem', color:'#a89984'}}>{dubProgress.text}</span>} }}/>
</div>
{dubProgress.text && <span style={{fontSize:'0.65rem', color:'#a89984'}}>{dubProgress.text}</span>}
</>
)}
</div> </div>
) : null} ) : null}
/> />
@@ -1738,31 +1915,50 @@ function App() {
<div style={{display:'flex', gap:4, marginBottom:4, flexWrap:'wrap', alignItems:'flex-end'}}> <div style={{display:'flex', gap:4, marginBottom:4, flexWrap:'wrap', alignItems:'flex-end'}}>
<div style={{flex:1, minWidth:90}}> <div style={{flex:1, minWidth:90}}>
<div className="label-row"><Globe className="label-icon" size={9}/> Language</div> <div className="label-row"><Globe className="label-icon" size={9}/> Language</div>
<select className="input-base" value={dubLang} onChange={e => { <SearchableSelect
const lang = e.target.value; size="sm"
setDubLang(lang); value={dubLang}
// Auto-sync ISO code when language name changes options={ALL_LANGUAGES}
const match = LANG_CODES.find(lc => lc.label.toLowerCase() === lang.toLowerCase()); popular={POPULAR_LANGS}
if (match) setDubLangCode(match.code); recentsKey="omnivoice.recents.dubLang"
}} style={{fontSize:'0.65rem'}}> onChange={(lang) => {
{filteredDubLangs.map(l => <option key={l} value={l}>{l}</option>)} setDubLang(lang);
</select> const match = LANG_CODES.find(lc => lc.label.toLowerCase() === lang.toLowerCase());
if (match) setDubLangCode(match.code);
}}
/>
</div> </div>
<div style={{flex:1, minWidth:80}}> <div style={{flex:1, minWidth:80}}>
<div className="label-row">ISO Code</div> <div className="label-row">ISO Code</div>
<select className="input-base" value={dubLangCode} onChange={e => setDubLangCode(e.target.value)} style={{fontSize:'0.65rem'}}> <SearchableSelect
{LANG_CODES.map(lc => <option key={lc.code} value={lc.code}>{lc.code} {lc.label}</option>)} size="sm"
</select> 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>
<div style={{flex:1, minWidth:90}}> <div style={{flex:1, minWidth:90}}>
<div className="label-row"><UserSquare2 className="label-icon" size={9}/> Style</div> <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'}}/> <input className="input-base" placeholder="e.g. female" value={dubInstruct} onChange={e => setDubInstruct(e.target.value)} style={{fontSize:'0.65rem'}}/>
</div> </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} <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'}}> 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 ? <Loader className="spinner" size={9}/> : <Languages size={10}/>}
{isTranslating ? 'Translating…' : 'Translate All'} {isTranslating ? 'Translating…' : 'Translate All'}
</button> </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> </div>
{/* Full transcript toggle */} {/* Full transcript toggle */}
@@ -1817,12 +2013,13 @@ function App() {
<span style={{width:55}}>Time</span> <span style={{width:55}}>Time</span>
<span style={{width:50}}>Spkr</span> <span style={{width:50}}>Spkr</span>
<span style={{flex:1}}>Text</span> <span style={{flex:1}}>Text</span>
<span style={{width:45}}>Lang</span>
<span style={{width:90}}>Voice</span> <span style={{width:90}}>Voice</span>
<span style={{width:30}} title="Volume (0-200%)">Vol</span> <span style={{width:30}} title="Volume (0-200%)">Vol</span>
<span style={{width:40}}></span> <span style={{width:40}}></span>
</div> </div>
{dubSegments.map((seg, idx) => ( {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 className="segment-time" style={{width:55, display:'flex', flexDirection:'column'}}>
<span> <span>
{formatTime(seg.start)}{formatTime(seg.end)} {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> <span style={{width:50, fontSize:'0.58rem', color:'#a89984'}}>{seg.speaker_id || ''}</span>
<input className="input-base segment-input" value={seg.text} <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))} 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'}} <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))}> onChange={e => editSegments(dubSegments.map(s => s.id===seg.id?{...s,profile_id:e.target.value}:s))}>
<option value="">Default</option> <option value="">Default</option>
{profiles.length > 0 && ( {profiles.length > 0 && (
@@ -1868,15 +2071,15 @@ function App() {
)} )}
</select> </select>
<input type="range" min="0" max="200" value={Math.round((seg.gain ?? 1.0) * 100)} title={`${Math.round((seg.gain ?? 1.0) * 100)}%`} <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))} 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'}} 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}}> <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}/>} {segmentPreviewLoading === seg.id ? <Loader className="spinner" size={9}/> : <Headphones size={9}/>}
</button> </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> onClick={() => editSegments(dubSegments.filter(s=>s.id!==seg.id))}><Trash2 size={9}/></button>
</div> </div>
</div> </div>
@@ -1930,9 +2133,20 @@ function App() {
</div> </div>
)} )}
<div style={{display:'flex', gap:4}}> <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==='stopping' ? (
{dubStep==='generating' ? <><Sparkles className="spinner" size={11}/> Dubbing</> : <><Play size={11}/> Generate Dub</>} <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}}>
</button> <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}} <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'}> onClick={handleDubDownload} disabled={dubStep!=='done'}>
<DownloadIcon size={11}/> MP4 <DownloadIcon size={11}/> MP4
@@ -1998,14 +2212,13 @@ function App() {
<div className="grid-2"> <div className="grid-2">
<div> <div>
<div className="label-row"><Globe className="label-icon" size={14}/> Language ({ALL_LANGUAGES.length - 1})</div> <div className="label-row"><Globe className="label-icon" size={14}/> Language ({ALL_LANGUAGES.length - 1})</div>
<div style={{position:'relative'}}> <SearchableSelect
<Search size={14} color="#a89984" style={{position:'absolute', left:8, top:8, zIndex:1}}/> value={language}
<input type="text" className="input-base" style={{paddingLeft:28, fontSize:'0.8rem', marginBottom:4}} options={ALL_LANGUAGES}
placeholder="Search languages..." value={langSearch} onChange={e => setLangSearch(e.target.value)}/> popular={POPULAR_LANGS}
<select className="input-base" value={language} onChange={e => setLanguage(e.target.value)}> recentsKey="omnivoice.recents.genLang"
{filteredLangs.map(l => <option key={l} value={l}>{l}</option>)} onChange={setLanguage}
</select> />
</div>
</div> </div>
<div> <div>
<div className="label-row" style={{justifyContent:'space-between'}}> <div className="label-row" style={{justifyContent:'space-between'}}>
@@ -2242,28 +2455,32 @@ function App() {
</div> </div>
) : ( ) : (
studioProjects.map(proj => ( studioProjects.map(proj => (
<div key={proj.id} className={`history-item ${activeProjectId === proj.id ? 'project-active' : ''}`} style={{position:'relative'}}> <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"> <div className="history-header" style={{ marginBottom: 0 }}>
<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'}}> <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 <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> </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} {proj.name}
</div> </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.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>
<div style={{fontSize:'0.6rem', color:'#665c54', marginTop:2}}> <div style={{display:'flex', gap:'6px', marginTop:'2px'}}>
<Clock size={9} style={{verticalAlign:'middle', marginRight:3}}/> <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'}}
{new Date(proj.updated_at * 1000).toLocaleString()} onMouseEnter={e => { e.currentTarget.style.color='#ebdbb2'; e.currentTarget.style.borderColor='rgba(255,255,255,0.1)'; }}
</div> onMouseLeave={e => { e.currentTarget.style.color='var(--text-secondary)'; e.currentTarget.style.borderColor='rgba(255,255,255,0.05)'; }}>
<div style={{display:'flex', gap:'6px', marginTop:'8px'}}> <FolderOpen size={10}/> Load Project
<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
</button> </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}/> <Trash2 size={10}/>
</button> </button>
</div> </div>
@@ -2283,32 +2500,41 @@ function App() {
</div> </div>
) : ( ) : (
(mode === 'clone' ? profiles.filter(p => !p.instruct) : profiles.filter(p => !!p.instruct)).map(proj => ( (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 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"> <div className="history-header" style={{ marginBottom: 0 }}>
<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'}}> <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')} {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 && ( {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>
<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} {proj.name}
</div> </div>
{proj.instruct && <div style={{fontSize:'0.6rem', color:'#a89984', fontStyle:'italic', marginBottom:2}}>{proj.instruct}</div>} {proj.instruct && <div style={{fontSize:'0.6rem', color:'var(--text-secondary)', fontStyle:'italic'}}>{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"> <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}/>} {previewLoading === proj.id ? <Loader className="spinner" size={10}/> : <Play size={10}/>}
</button> </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'}}> <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'}}
<FolderOpen size={10}/> Select 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> </button>
{proj.is_locked && ( {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}/> <Unlock size={10}/>
</button> </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}/> <Trash2 size={10}/>
</button> </button>
</div> </div>
@@ -2357,30 +2583,33 @@ function App() {
<> <>
{/* Dub history */} {/* Dub history */}
{!isSidebarCollapsed && dubHistory.map(item => ( {!isSidebarCollapsed && dubHistory.map(item => (
<div key={`dub-${item.id}`} className="history-item"> <div key={`dub-${item.id}`} className="history-item" style={{ padding: '8px 10px', display: 'flex', flexDirection: 'column', gap: 6 }}>
<div className="history-header"> <div className="history-header" style={{ marginBottom: 0 }}>
<div className="history-badge" style={{background:'rgba(131,165,152,0.15)', color:'#83a598'}}> <span style={{ fontSize: '0.55rem', fontWeight: 600, color: '#83a598', letterSpacing: '0.02em', textTransform: 'uppercase', display: 'flex', alignItems: 'center', gap: 4 }}>
<Film size={10}/> DUB <Film size={10}/> DUB
</div> </span>
<div className="history-time">{item.segments_count} segs</div> <div className="history-time" style={{margin:0, opacity:0.6}}>{item.segments_count} segs</div>
</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} {item.filename}
</div> </div>
<div style={{display:'flex', gap:4, flexWrap:'wrap', marginBottom:2}}> <div style={{display:'flex', gap:4, flexWrap:'wrap'}}>
<span style={{fontSize:'0.65rem', padding:'1px 6px', background:'rgba(131,165,152,0.15)', color:'#83a598', borderRadius:4}}> <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}) {item.language} ({item.language_code})
</span> </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 {Math.round(item.duration)}s
</span> </span>
</div> </div>
<div style={{display:'flex', gap:'6px', marginTop:'2px'}}>
<div style={{display:'flex', gap:'6px', marginTop:'8px'}}> <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'}}
<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'}}> onMouseEnter={e => { e.currentTarget.style.color='#ebdbb2'; e.currentTarget.style.borderColor='rgba(255,255,255,0.1)'; }}
<FolderOpen size={10}/> Load 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>
<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}/> <Trash2 size={10}/>
</button> </button>
</div> </div>
@@ -2389,35 +2618,49 @@ function App() {
{/* Clone/Design history */} {/* Clone/Design history */}
{!isSidebarCollapsed && history.map(item => ( {!isSidebarCollapsed && history.map(item => (
<div key={item.id} className="history-item"> <div key={item.id} className="history-item" style={{ padding: '8px 10px', display: 'flex', flexDirection: 'column', gap: 6 }}>
<div className="history-header"> <div className="history-header" style={{ marginBottom: 0 }}>
<div className="history-badge"> <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||'').toUpperCase()} {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> </div>
{item.generation_time && <div className="history-time">{item.generation_time}s</div>}
</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:'var(--text-secondary)', opacity: 0.6}}>seed: {item.seed}</div>}
{item.seed && <div style={{fontSize:'0.55rem', color:'#665c54', marginBottom:2}}>seed: {item.seed}</div>} <div className="history-text" title={item.text} style={{marginTop: 2, color: 'var(--text-primary)', lineHeight: 1.3}}>{item.text}</div>
<div className="history-text" title={item.text}>{item.text}</div>
{item.audio_path && <audio controls src={`${API}/audio/${item.audio_path}`} />} {item.audio_path && <audio controls src={`${API}/audio/${item.audio_path}`} style={{height: 24, marginTop: 4, width: '100%'}} />}
{item.audio_path && ( {item.audio_path && (
<div style={{display:'flex', gap:'6px', marginTop:'8px', flexWrap:'wrap'}}> <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.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'}}> <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'}}
<Save size={10}/> Save Profile 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> </button>
{/* Lock Voice: only for items that have an associated profile */}
{item.profile_id && ( {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 <Lock size={10}/> Lock
</button> </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}/> <DownloadIcon size={10}/>
</button> </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}/> <FolderOpen size={10}/>
</button> </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}/> <Trash2 size={10}/>
</button> </button>
</div> </div>
@@ -2458,7 +2701,7 @@ function App() {
{/* ── DOWNLOADS TAB ── */} {/* ── DOWNLOADS TAB ── */}
{sidebarTab === 'downloads' && ( {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 ? ( {exportHistory.length === 0 ? (
<div style={{color:'var(--text-secondary)', textAlign:'center', padding:'24px 12px'}}> <div style={{color:'var(--text-secondary)', textAlign:'center', padding:'24px 12px'}}>
<DownloadCloud size={28} style={{opacity:0.3, marginBottom:8}} /> <DownloadCloud size={28} style={{opacity:0.3, marginBottom:8}} />
@@ -2467,39 +2710,44 @@ function App() {
</div> </div>
) : ( ) : (
<> <>
{!isSidebarCollapsed && exportHistory.map(item => ( {!isSidebarCollapsed && exportHistory.map(item => {
<div key={item.id} className="history-item"> const pathParts = item.destination_path.split(/[\\/]/);
<div className="history-header"> const parentFolder = pathParts.length > 1 ? pathParts[pathParts.length - 2] : '...';
<div className="history-badge" style={{background:'rgba(142,192,124,0.15)', color:'#8ec07c'}}> return (
<DownloadCloud 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: '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>
<div className="history-text" style={{margin:0, opacity:0.6}}>{new Date(item.created_at * 1000).toLocaleTimeString()}</div> <div style={{fontSize:'0.72rem', color:'var(--text-primary)', wordWrap:'break-word', fontWeight:500, lineHeight: 1.2}}>
</div> {item.filename}
<div style={{fontSize:'0.72rem', color:'var(--text-primary)', marginTop:6, wordWrap:'break-word', fontWeight:600}}> </div>
{item.filename} <div onClick={() => revealInFolder(item.destination_path)}
</div> style={{
<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'}}> display:'flex', alignItems:'center', gap:6, marginTop:2,
{item.destination_path} padding:'4px 6px', background:'rgba(255,255,255,0.03)',
</div> borderRadius:4, border:'1px solid rgba(255,255,255,0.05)', cursor:'pointer',
<div style={{display:'flex', gap:4, marginTop:6}}> color: 'var(--text-secondary)', transition:'all 0.15s ease'
<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)', 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'; }}
color:'#8ec07c', borderRadius:4, fontSize:'0.62rem', cursor:'pointer', display:'flex', alignItems:'center', justifyContent:'center', gap:4, fontWeight:600, transition:'all 0.15s ease' 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}}/>
onMouseEnter={e => { e.currentTarget.style.background='rgba(142,192,124,0.2)'; e.currentTarget.style.borderColor='rgba(142,192,124,0.4)'; }} <span style={{fontSize:'0.58rem', whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis'}}>
onMouseLeave={e => { e.currentTarget.style.background='rgba(142,192,124,0.1)'; e.currentTarget.style.borderColor='rgba(142,192,124,0.25)'; }} Show in {parentFolder}
> </span>
<FolderOpen size={10}/> Open Folder </div>
</button> </div>
</div> );
</div> })}
))}
{isSidebarCollapsed && exportHistory.map(item => ( {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)} onClick={() => revealInFolder(item.destination_path)}
style={{ 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' 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)'; }} 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>
);
}
+27 -13
View File
@@ -59,7 +59,15 @@ export default function WaveformTimeline({
// ── 1. Create the video element imperatively (stable, no React re-renders) ── // ── 1. Create the video element imperatively (stable, no React re-renders) ──
let videoEl = null; let videoEl = null;
if (videoSrc && videoContainerRef.current) { 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 = document.createElement('video');
videoEl.src = videoSrc; videoEl.src = videoSrc;
videoEl.muted = false; videoEl.muted = false;
@@ -117,29 +125,26 @@ export default function WaveformTimeline({
return; // Ignore React cleanup aborts return; // Ignore React cleanup aborts
} }
// If WaveSurfer failed to decode the `<video>` stream (e.g. .mov container on MacOS), // 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` (.wav), decode it, and supply raw peaks. // we manually fetch the companion `audioSrc`, decode it, and supply raw peaks.
if (audioSrc && audioSrc !== videoSrc) { if (audioSrc) {
fetch(audioSrc) fetch(audioSrc)
.then(res => res.arrayBuffer()) .then(res => {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.arrayBuffer();
})
.then(buffer => { .then(buffer => {
const actx = new (window.AudioContext || window.webkitAudioContext)(); const actx = new (window.AudioContext || window.webkitAudioContext)();
return actx.decodeAudioData(buffer); return actx.decodeAudioData(buffer);
}) })
.then(audioBuffer => { .then(audioBuffer => {
const channelData = audioBuffer.getChannelData(0); const channelData = audioBuffer.getChannelData(0);
// Load the peaks without mutating the `<video>` element's src!
ws.load(undefined, [channelData], audioBuffer.duration); ws.load(undefined, [channelData], audioBuffer.duration);
}) })
.catch((decodeErr) => { .catch((decodeErr) => {
console.error('Audio decode fallback failed:', decodeErr); console.error('Audio decode fallback failed:', decodeErr);
setLoadError(true); setLoadError(true);
}); });
} else if (audioSrc && audioSrc.startsWith('blob:')) {
fetch(audioSrc)
.then(r => r.blob())
.then(blob => ws.loadBlob(blob))
.catch(() => setLoadError(true));
} else { } else {
setLoadError(true); setLoadError(true);
} }
@@ -185,8 +190,17 @@ export default function WaveformTimeline({
try { ws.destroy(); } catch (_) {} try { ws.destroy(); } catch (_) {}
wsRef.current = null; wsRef.current = null;
regionsRef.current = null; regionsRef.current = null;
// Clear the imperatively-created video element // Clear the imperatively-created video element (release src so browser frees decoder)
if (videoContainerRef.current) videoContainerRef.current.innerHTML = ''; 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); setReady(false);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
+123 -6
View File
@@ -292,26 +292,35 @@ input[type="range"]::-webkit-slider-thumb:active {
/* ═══ BUTTON ═══ */ /* ═══ BUTTON ═══ */
.btn-primary { .btn-primary {
width: 100%; background: linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%); 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; border-radius: var(--radius); font-size: 0.78rem; font-weight: 600; cursor: pointer;
display: flex; justify-content: center; align-items: center; gap: 6px; 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; font-family: 'Inter', sans-serif;
letter-spacing: 0.01em; letter-spacing: 0.01em;
position: relative; position: relative;
overflow: hidden; 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 { .btn-primary::before {
content: ''; content: '';
position: absolute; position: absolute;
top: 0; left: -100%; width: 100%; height: 100%; 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; transition: left 0.4s ease;
} }
.btn-primary:hover:not(:disabled)::before { left: 100%; } .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:hover:not(:disabled) {
.btn-primary:active:not(:disabled) { transform: scale(0.98); filter: brightness(0.95); } filter: brightness(1.15);
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; filter: grayscale(0.3); } 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; } .spinner { animation: spin 1s linear infinite; }
/* ═══ SIDEBAR / HISTORY ═══ */ /* ═══ SIDEBAR / HISTORY ═══ */
@@ -432,6 +441,10 @@ audio::-webkit-media-controls-time-remaining-display { color: #ebdbb2; font-size
.segment-row.segment-active { .segment-row.segment-active {
background: rgba(211,134,155,0.08); background: rgba(211,134,155,0.08);
border-left: 2px solid var(--primary); 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-row.segment-done { opacity: 0.5; }
.segment-time { .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)); 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; 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
View File
@@ -8,7 +8,7 @@
"desktop": "turbo run desktop //#dev:api", "desktop": "turbo run desktop //#dev:api",
"build": "turbo run build", "build": "turbo run build",
"start": "turbo run start", "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": [ "workspaces": [
"frontend" "frontend"
+3
View File
@@ -44,6 +44,9 @@ dependencies = [
"pyannote-audio>=4.0.4", "pyannote-audio>=4.0.4",
"pyinstaller>=6.19.0", "pyinstaller>=6.19.0",
"imageio-ffmpeg>=0.6.0", "imageio-ffmpeg>=0.6.0",
"pedalboard>=0.9.14",
"mlx-whisper>=0.2.1",
"demucs>=4.0.1",
] ]
[project.optional-dependencies] [project.optional-dependencies]
-13
View File
@@ -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}")
-7
View File
@@ -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!")
-8
View File
@@ -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
-17
View File
@@ -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())
-7
View File
@@ -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!")
+7
View File
@@ -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)
+11
View File
@@ -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
View File
@@ -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]}
]
}
+140
View File
@@ -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"]
+207
View File
@@ -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]}"
+347
View File
@@ -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.01.0 (1.0s overlap with seg 02)
# SPEAKER_01 covers 1.01.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 02
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"]
Generated
+329
View File
@@ -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" }, { 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]] [[package]]
name = "colorama" name = "colorama"
version = "0.4.6" 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" }, { 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]] [[package]]
name = "editdistance" name = "editdistance"
version = "0.8.1" 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" }, { 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]] [[package]]
name = "lazy-loader" name = "lazy-loader"
version = "0.5" 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" }, { 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]] [[package]]
name = "modelscope" name = "modelscope"
version = "1.35.1" 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" }, { 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]] [[package]]
name = "mpmath" name = "mpmath"
version = "1.3.0" version = "1.3.0"
@@ -2709,10 +2876,13 @@ version = "0.2.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "accelerate" }, { name = "accelerate" },
{ name = "demucs" },
{ name = "gradio" }, { name = "gradio" },
{ name = "imageio-ffmpeg" }, { 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.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 = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "pedalboard" },
{ name = "psutil" }, { name = "psutil" },
{ name = "pyannote-audio" }, { name = "pyannote-audio" },
{ name = "pydub" }, { name = "pydub" },
@@ -2758,6 +2928,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "accelerate" }, { name = "accelerate" },
{ name = "demucs", specifier = ">=4.0.1" },
{ name = "fastapi", marker = "extra == 'api'" }, { name = "fastapi", marker = "extra == 'api'" },
{ name = "funasr", marker = "extra == 'eval'" }, { name = "funasr", marker = "extra == 'eval'" },
{ name = "gradio" }, { name = "gradio" },
@@ -2766,7 +2937,9 @@ requires-dist = [
{ name = "imageio-ffmpeg", specifier = ">=0.6.0" }, { name = "imageio-ffmpeg", specifier = ">=0.6.0" },
{ name = "jiwer", marker = "extra == 'eval'", specifier = "==3.1.0" }, { name = "jiwer", marker = "extra == 'eval'", specifier = "==3.1.0" },
{ name = "librosa", marker = "extra == 'eval'" }, { name = "librosa", marker = "extra == 'eval'" },
{ name = "mlx-whisper", specifier = ">=0.2.1" },
{ name = "numpy" }, { name = "numpy" },
{ name = "pedalboard", specifier = ">=0.9.14" },
{ name = "psutil", specifier = ">=7.2.2" }, { name = "psutil", specifier = ">=7.2.2" },
{ name = "pyannote-audio", specifier = ">=4.0.4" }, { name = "pyannote-audio", specifier = ">=4.0.4" },
{ name = "pydub" }, { 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" }, { 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]] [[package]]
name = "optuna" name = "optuna"
version = "4.8.0" 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" }, { 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]] [[package]]
name = "pefile" name = "pefile"
version = "2024.8.26" 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" }, { 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]] [[package]]
name = "rich" name = "rich"
version = "14.3.3" 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" }, { 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]] [[package]]
name = "sympy" name = "sympy"
version = "1.14.0" 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" }, { 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]] [[package]]
name = "tokenizers" name = "tokenizers"
version = "0.22.2" 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" }, { 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]] [[package]]
name = "triton" name = "triton"
version = "3.4.0" version = "3.4.0"