feat: add audio trimming for reference clips, implement streaming transcription, and refactor ffmpeg utility handling

This commit is contained in:
debpalash
2026-04-18 22:37:20 +05:30
parent 67328d04fe
commit 508d4ba116
14 changed files with 2309 additions and 103 deletions
+192 -16
View File
@@ -22,7 +22,7 @@ from core.tasks import task_manager
from schemas.requests import DubRequest, TranslateRequest 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.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.audio_dsp import apply_mastering, normalize_audio
from services.ffmpeg_utils import find_ffmpeg from services.ffmpeg_utils import find_ffmpeg, _get_semaphore, _spawn_with_retry
from services.segmentation import ( from services.segmentation import (
segment_transcript, segment_transcript,
assign_speakers_from_diarization, assign_speakers_from_diarization,
@@ -254,23 +254,40 @@ async def dub_upload(video: UploadFile = File(...), job_id: Optional[str] = Form
ffmpeg = find_ffmpeg() ffmpeg = find_ffmpeg()
async def _run_proc(cmd, timeout: float = 900.0): async def _run_proc(cmd, timeout: float = 900.0):
p = await asyncio.create_subprocess_exec( # Gate through the shared ffmpeg semaphore + EAGAIN retry so concurrent
*cmd, # dub uploads can't pile up posix_spawn failures under macOS fd limits.
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, async with _get_semaphore():
) p = await _spawn_with_retry(
_register_proc(job_id, p) cmd,
try: 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: try:
p.kill() stdout, stderr = await asyncio.wait_for(p.communicate(), timeout=timeout)
except ProcessLookupError: except asyncio.TimeoutError:
pass try:
raise HTTPException(status_code=504, detail=f"subprocess timed out after {timeout}s") p.kill()
return p, stdout, stderr except ProcessLookupError:
finally: pass
_unregister_proc(job_id, p) try:
await asyncio.wait_for(p.wait(), timeout=5.0)
except asyncio.TimeoutError:
pass
raise HTTPException(status_code=504, detail=f"subprocess timed out after {timeout}s")
return p, stdout, stderr
finally:
_unregister_proc(job_id, p)
if p.returncode is None:
try:
p.kill()
except ProcessLookupError:
pass
try:
await asyncio.wait_for(p.wait(), timeout=5.0)
except asyncio.TimeoutError:
pass
try: try:
try: try:
@@ -358,6 +375,165 @@ async def dub_upload(video: UploadFile = File(...), job_id: Optional[str] = Form
_active_procs.pop(job_id, None) _active_procs.pop(job_id, None)
TRANSCRIBE_CHUNK_S = float(os.environ.get("OMNIVOICE_TRANSCRIBE_CHUNK_S", "30.0"))
def _sse_event(event: str, payload) -> bytes:
return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n".encode("utf-8")
@router.get("/dub/transcribe-stream/{job_id}")
async def dub_transcribe_stream(job_id: str):
"""Stream per-chunk segments via SSE, then emit diarized final pass."""
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")
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")
if not asr_audio_target or not os.path.exists(asr_audio_target):
raise HTTPException(status_code=404, detail="No audio available for transcription")
use_mlx = torch.backends.mps.is_available()
asr_model = os.environ.get("ASR_MODEL", "mlx-community/whisper-large-v3-mlx")
scene_cuts = job.get("scene_cuts") or []
async def gen():
import math
import tempfile
loop = asyncio.get_event_loop()
def _load():
audio_np, sr = sf.read(asr_audio_target, dtype="float32")
if audio_np.ndim > 1:
audio_np = audio_np.mean(axis=1)
return audio_np, sr
try:
audio_np, sr = await loop.run_in_executor(_cpu_pool, _load)
except Exception as e:
yield _sse_event("error", {"detail": f"audio load failed: {e}"})
return
total = float(len(audio_np)) / float(sr) if sr else 0.0
chunks_n = max(1, int(math.ceil(total / TRANSCRIBE_CHUNK_S))) if total > 0 else 1
yield _sse_event("start", {"duration": total, "chunks": chunks_n, "chunk_s": TRANSCRIBE_CHUNK_S})
all_segments: list[dict] = []
detected_lang = None
next_seg_id = 0
for i in range(chunks_n):
if job.get("aborted"):
yield _sse_event("aborted", {})
return
t0 = i * TRANSCRIBE_CHUNK_S
t1 = min(total, t0 + TRANSCRIBE_CHUNK_S)
s_from = int(t0 * sr)
s_to = int(t1 * sr)
chunk_arr = audio_np[s_from:s_to]
if len(chunk_arr) == 0:
continue
def _transcribe_chunk(arr=chunk_arr, offset=t0, local_sr=sr):
try:
if use_mlx:
import mlx_whisper
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
tmp.close()
try:
sf.write(tmp.name, arr, local_sr)
r = mlx_whisper.transcribe(
tmp.name, path_or_hf_repo=asr_model, word_timestamps=True,
)
finally:
try: os.remove(tmp.name)
except OSError: pass
shifted = []
for seg in r.get("segments", []) or []:
shifted.append({
"text": seg.get("text", ""),
"timestamp": (float(seg.get("start", 0.0)) + offset,
float(seg.get("end", 0.0)) + offset),
})
return {"chunks": shifted, "language": r.get("language")}
else:
r = _model._asr_pipe(
{"array": arr, "sampling_rate": local_sr},
return_timestamps=True, chunk_length_s=15, batch_size=1,
)
shifted = []
for c in (r.get("chunks", []) if isinstance(r, dict) else []):
ts = c.get("timestamp", (0.0, 0.0)) or (0.0, 0.0)
a0 = (ts[0] if ts[0] is not None else 0.0) + offset
a1 = (ts[1] if ts[1] is not None else 0.0) + offset
shifted.append({"text": c.get("text", ""), "timestamp": (a0, a1)})
return {"chunks": shifted, "language": r.get("language") if isinstance(r, dict) else None}
except Exception as e:
logger.exception("chunk transcribe failed")
return {"chunks": [], "language": None, "error": str(e)}
part = await loop.run_in_executor(_gpu_pool, _transcribe_chunk)
if detected_lang is None and part.get("language"):
detected_lang = part["language"]
chunk_segs = segment_transcript(part, duration=t1, scene_cuts=scene_cuts)
chunk_segs = assign_speakers_heuristic(chunk_segs)
for s in chunk_segs:
s["id"] = f"s{next_seg_id:05x}"
next_seg_id += 1
all_segments.extend(chunk_segs)
yield _sse_event("segments", {
"chunk": i, "total_chunks": chunks_n,
"segments": chunk_segs,
"progress": (i + 1) / chunks_n,
"error": part.get("error"),
})
if job.get("aborted"):
yield _sse_event("aborted", {})
return
def _diarize():
diar_pipe = get_diarization_pipeline()
try:
if diar_pipe:
diar = diar_pipe(asr_audio_target)
return assign_speakers_from_diarization(all_segments, diar)
except Exception as e:
logger.error(f"Diarization failed: {e}")
return assign_speakers_heuristic(all_segments)
final_segs = await loop.run_in_executor(_gpu_pool, _diarize)
job["segments"] = final_segs
job["source_lang"] = ((detected_lang or "en").split("_")[0][:2] or "en").lower()
job["full_transcript"] = " ".join(s.get("text", "") for s in final_segs)
_save_job(job_id, job)
if torch.backends.mps.is_available():
try: torch.mps.empty_cache()
except Exception: pass
yield _sse_event("final", {
"segments": final_segs,
"source_lang": job["source_lang"],
"full_transcript": job["full_transcript"],
})
yield _sse_event("done", {})
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
},
)
@router.post("/dub/transcribe/{job_id}") @router.post("/dub/transcribe/{job_id}")
async def dub_transcribe(job_id: str): async def dub_transcribe(job_id: str):
job = _get_job(job_id) job = _get_job(job_id)
+7 -6
View File
@@ -194,12 +194,13 @@ async def dub_download(
cmd += ["-shortest", output_path, "-y"] cmd += ["-shortest", output_path, "-y"]
try: try:
proc = await asyncio.create_subprocess_exec( rc, _, stderr = await run_ffmpeg(cmd, timeout=1800.0)
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE if rc != 0:
) raise Exception(stderr.decode(errors="replace") if stderr else "ffmpeg mux non-zero")
_, stderr = await proc.communicate() except asyncio.TimeoutError:
if proc.returncode != 0: raise HTTPException(status_code=504, detail="ffmpeg mux timed out")
raise Exception(stderr.decode()) except HTTPException:
raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"ffmpeg mux failed: {str(e)}") raise HTTPException(status_code=500, detail=f"ffmpeg mux failed: {str(e)}")
+37 -21
View File
@@ -1,4 +1,5 @@
import os import os
import time
import asyncio import asyncio
import logging import logging
from fastapi import APIRouter from fastapi import APIRouter
@@ -220,28 +221,43 @@ async def dub_translate(req: TranslateRequest):
# Legacy / API Deep_Translator logic # Legacy / API Deep_Translator logic
src_arg = TRANSLATE_CODES.get(src_lang, src_lang) or "auto" src_arg = TRANSLATE_CODES.get(src_lang, src_lang) or "auto"
def _translate_single(seg): def _build_translator(src, tgt):
try: if provider == "deepl":
if not seg.text or not seg.text.strip(): from deep_translator import DeepL
return {"id": seg.id, "text": seg.text} return DeepL(api_key=api_key, source=src, target=tgt)
seg_lc = TRANSLATE_CODES.get(seg.target_lang, seg.target_lang) if seg.target_lang else lang_code if provider == "mymemory":
if provider == "deepl": from deep_translator import MyMemoryTranslator
from deep_translator import DeepL return MyMemoryTranslator(source=src, target=tgt)
translator = DeepL(api_key=api_key, source=src_arg, target=seg_lc) if provider == "microsoft":
elif provider == "mymemory": from deep_translator import MicrosoftTranslator
from deep_translator import MyMemoryTranslator return MicrosoftTranslator(api_key=api_key, source=src, target=tgt)
translator = MyMemoryTranslator(source=src_arg, target=seg_lc) from deep_translator import GoogleTranslator
elif provider == "microsoft": return GoogleTranslator(source=src, target=tgt)
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) def _translate_single(seg):
return {"id": seg.id, "text": translated or seg.text} seg_lc = (
except Exception as e: TRANSLATE_CODES.get(seg.target_lang, seg.target_lang)
return {"id": seg.id, "text": seg.text, "error": str(e)} if seg.target_lang else lang_code
)
if not seg.text or not seg.text.strip():
return {"id": seg.id, "text": seg.text}
last_err = None
# Try: (src_arg, tgt) → retry once → fall back to (auto, tgt).
for attempt, src in enumerate([src_arg, src_arg, "auto"]):
try:
out = _build_translator(src, seg_lc).translate(seg.text)
if out and out.strip():
return {"id": seg.id, "text": out}
last_err = "empty translation"
except Exception as e:
last_err = f"{type(e).__name__}: {e}"
logger.warning(
"translate attempt %d %s->%s (provider=%s) failed: %s",
attempt + 1, src, seg_lc, provider, e,
)
time.sleep(0.25 * (attempt + 1))
logger.error("translate %s -> %s gave up (provider=%s): %s", src_arg, seg_lc, provider, last_err)
return {"id": seg.id, "text": seg.text, "error": last_err or "unknown"}
tasks = [loop.run_in_executor(_cpu_pool, _translate_single, seg) for seg in req.segments] tasks = [loop.run_in_executor(_cpu_pool, _translate_single, seg) for seg in req.segments]
translated = await asyncio.gather(*tasks) translated = await asyncio.gather(*tasks)
+10 -1
View File
@@ -5,6 +5,8 @@ import time
import asyncio import asyncio
import tempfile import tempfile
import contextlib import contextlib
import logging
import traceback
import torch import torch
import torchaudio import torchaudio
from typing import Optional from typing import Optional
@@ -17,6 +19,7 @@ from services.model_manager import get_model, _gpu_pool
from services.audio_dsp import apply_mastering, normalize_audio from services.audio_dsp import apply_mastering, normalize_audio
router = APIRouter() router = APIRouter()
logger = logging.getLogger("omnivoice.generate")
def _run_inference( def _run_inference(
model, text, language, ref_audio_path, ref_text, instruct, duration, model, text, language, ref_audio_path, ref_text, instruct, duration,
@@ -81,6 +84,7 @@ async def generate_speech(
ref_audio_path = None ref_audio_path = None
cleanup_ref = False cleanup_ref = False
used_seed = seed used_seed = seed
resolved_profile_id = None
if profile_id: if profile_id:
conn = get_db() conn = get_db()
@@ -89,6 +93,7 @@ async def generate_speech(
finally: finally:
conn.close() conn.close()
if row: if row:
resolved_profile_id = profile_id
if row["is_locked"] and row["locked_audio_path"]: if row["is_locked"] and row["locked_audio_path"]:
ref_audio_path = os.path.join(VOICES_DIR, row["locked_audio_path"]) ref_audio_path = os.path.join(VOICES_DIR, row["locked_audio_path"])
if not ref_text: if not ref_text:
@@ -144,7 +149,7 @@ async def generate_speech(
conn.execute( conn.execute(
"INSERT INTO generation_history (id, text, mode, language, instruct, profile_id, audio_path, duration_seconds, generation_time, seed, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)", "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", (audio_id, text[:200], "clone" if ref_audio_path else "design",
language or "Auto", instruct or "", profile_id or "", language or "Auto", instruct or "", resolved_profile_id,
audio_filename, audio_dur, gen_time, used_seed, time.time()) audio_filename, audio_dur, gen_time, used_seed, time.time())
) )
@@ -170,7 +175,11 @@ async def generate_speech(
"Content-Length": str(len(wav_bytes)), "Content-Length": str(len(wav_bytes)),
} }
) )
except HTTPException:
raise
except Exception as e: except Exception as e:
tb = traceback.format_exc()
logger.error("Inference failed: %s\n%s", e, tb)
raise HTTPException(status_code=500, detail=f"Inference failed: {str(e)}") raise HTTPException(status_code=500, detail=f"Inference failed: {str(e)}")
finally: finally:
if cleanup_ref and ref_audio_path: if cleanup_ref and ref_audio_path:
+19 -35
View File
@@ -11,7 +11,7 @@ import shutil
from core.config import OUTPUTS_DIR from core.config import OUTPUTS_DIR
from services.model_manager import get_model_status, get_best_device from services.model_manager import get_model_status, get_best_device
from services.ffmpeg_utils import find_ffmpeg from services.ffmpeg_utils import find_ffmpeg, run_ffmpeg
router = APIRouter() router = APIRouter()
logger = logging.getLogger("omnivoice.api") logger = logging.getLogger("omnivoice.api")
@@ -72,59 +72,43 @@ async def _do_clean_audio(audio, tmp_dir, clean_id):
converted_path = os.path.join(tmp_dir, "converted.wav") converted_path = os.path.join(tmp_dir, "converted.wav")
ffmpeg = find_ffmpeg() 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: try:
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=120.0) rc, _, _ = await run_ffmpeg(
[ffmpeg, "-y", "-i", raw_path, "-ar", "24000", "-ac", "1", converted_path],
timeout=120.0,
)
except asyncio.TimeoutError: except asyncio.TimeoutError:
try: rc = -1
proc.kill() if rc != 0:
except ProcessLookupError:
pass
converted_path = raw_path converted_path = raw_path
else:
if proc.returncode != 0:
converted_path = raw_path
clean_path = converted_path clean_path = converted_path
try: try:
proc = await asyncio.create_subprocess_exec( rc, _, _ = await run_ffmpeg(
sys.executable, "-m", "demucs.separate", "--two-stems", "vocals", "-n", "htdemucs", [sys.executable, "-m", "demucs.separate", "--two-stems", "vocals", "-n", "htdemucs",
"-d", get_best_device(), converted_path, "-o", tmp_dir, "-d", get_best_device(), converted_path, "-o", tmp_dir],
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, timeout=900.0,
) )
try: if rc == 0:
_, 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") demucs_out = os.path.join(tmp_dir, "htdemucs", "converted")
vocals_file = os.path.join(demucs_out, "vocals.wav") vocals_file = os.path.join(demucs_out, "vocals.wav")
if os.path.exists(vocals_file): if os.path.exists(vocals_file):
clean_path = vocals_file clean_path = vocals_file
except asyncio.TimeoutError:
logger.warning("Demucs timed out for mic audio, using raw")
except Exception as e: except Exception as e:
logger.warning(f"Demucs failed for mic audio, using raw: {e}") logger.warning(f"Demucs failed for mic audio, using raw: {e}")
clean_filename = f"mic_{clean_id}.wav" clean_filename = f"mic_{clean_id}.wav"
final_path = os.path.join(OUTPUTS_DIR, clean_filename) 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: try:
await asyncio.wait_for(proc.communicate(), timeout=120.0) await run_ffmpeg(
[ffmpeg, "-y", "-i", clean_path, "-ar", "24000", "-ac", "1", final_path],
timeout=120.0,
)
except asyncio.TimeoutError: except asyncio.TimeoutError:
try: pass
proc.kill()
except ProcessLookupError:
pass
if not os.path.exists(final_path): if not os.path.exists(final_path):
shutil.copy2(clean_path, final_path) shutil.copy2(clean_path, final_path)
+208
View File
@@ -0,0 +1,208 @@
"""Serial async job queue for GPU-bound work.
Why: TTS inference, diarization, demucs, Whisper, ffmpeg muxing all contend
for the same GPU/CPU/fd budget. Without a gate, a second dub request can
OOM VRAM or trigger posix_spawn EAGAIN on macOS. This module provides a
single serialized worker with cancel semantics and queue-position
introspection so the UI can tell users "2 jobs ahead of you".
Complements `core.tasks.task_manager`:
* `task_manager` — SSE event streaming (per-task history + listeners).
* `job_queue` — resource gating, cancellation, position tracking.
"""
from __future__ import annotations
import asyncio
import enum
import logging
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, Dict, List, Optional
logger = logging.getLogger("omnivoice.jobs")
class JobState(str, enum.Enum):
QUEUED = "queued"
RUNNING = "running"
DONE = "done"
FAILED = "failed"
CANCELLED = "cancelled"
@dataclass
class Job:
id: str
label: str
fn: Callable[["Job"], Awaitable[Any]]
state: JobState = JobState.QUEUED
created_at: float = field(default_factory=time.time)
started_at: Optional[float] = None
finished_at: Optional[float] = None
error: Optional[str] = None
result: Any = None
cancel_event: asyncio.Event = field(default_factory=asyncio.Event)
done_event: asyncio.Event = field(default_factory=asyncio.Event)
@property
def is_cancelled(self) -> bool:
return self.cancel_event.is_set()
def to_dict(self) -> dict:
return {
"id": self.id,
"label": self.label,
"state": self.state.value,
"created_at": self.created_at,
"started_at": self.started_at,
"finished_at": self.finished_at,
"error": self.error,
}
class JobQueue:
"""Single-lane serial async worker with cancellation and introspection."""
def __init__(self, name: str = "jobs", concurrency: int = 1) -> None:
self.name = name
self._concurrency = max(1, concurrency)
self._queue: asyncio.Queue[str] = asyncio.Queue()
self._jobs: Dict[str, Job] = {}
self._order: List[str] = []
self._workers: List[asyncio.Task] = []
self._lock = asyncio.Lock()
async def start(self) -> None:
if self._workers:
return
self._workers = [
asyncio.create_task(self._run_worker(i), name=f"{self.name}-worker-{i}")
for i in range(self._concurrency)
]
async def stop(self) -> None:
for w in self._workers:
w.cancel()
if self._workers:
await asyncio.gather(*self._workers, return_exceptions=True)
self._workers = []
async def submit(
self,
label: str,
fn: Callable[[Job], Awaitable[Any]],
job_id: Optional[str] = None,
) -> Job:
"""Enqueue a coroutine-returning callable. Idempotent on job_id."""
jid = job_id or uuid.uuid4().hex[:12]
async with self._lock:
existing = self._jobs.get(jid)
if existing is not None:
return existing
job = Job(id=jid, label=label, fn=fn)
self._jobs[jid] = job
self._order.append(jid)
await self._queue.put(jid)
return job
def get(self, job_id: str) -> Optional[Job]:
return self._jobs.get(job_id)
def position(self, job_id: str) -> int:
"""Return 0-indexed position among queued jobs, or -1 if not queued."""
job = self._jobs.get(job_id)
if not job or job.state != JobState.QUEUED:
return -1
idx = 0
for jid in self._order:
other = self._jobs.get(jid)
if not other or other.state != JobState.QUEUED:
continue
if jid == job_id:
return idx
idx += 1
return -1
def list_jobs(self, include_finished: bool = False) -> List[Job]:
if include_finished:
return [self._jobs[j] for j in self._order if j in self._jobs]
live = {JobState.QUEUED, JobState.RUNNING}
return [self._jobs[j] for j in self._order if j in self._jobs and self._jobs[j].state in live]
async def cancel(self, job_id: str) -> bool:
job = self._jobs.get(job_id)
if not job:
return False
if job.state == JobState.QUEUED:
job.state = JobState.CANCELLED
job.finished_at = time.time()
job.cancel_event.set()
job.done_event.set()
return True
if job.state == JobState.RUNNING:
# Cooperative: worker polls `job.is_cancelled` between steps.
job.cancel_event.set()
return True
return False
async def wait(self, job_id: str, timeout: Optional[float] = None) -> Job:
job = self._jobs.get(job_id)
if not job:
raise KeyError(job_id)
if timeout is None:
await job.done_event.wait()
else:
await asyncio.wait_for(job.done_event.wait(), timeout=timeout)
return job
def purge_finished(self, older_than_seconds: float = 3600.0) -> int:
"""Drop rows for jobs finished more than `older_than_seconds` ago."""
cutoff = time.time() - older_than_seconds
drop = [
jid for jid in self._order
if (j := self._jobs.get(jid))
and j.state in (JobState.DONE, JobState.FAILED, JobState.CANCELLED)
and (j.finished_at or 0.0) < cutoff
]
for jid in drop:
self._jobs.pop(jid, None)
self._order = [j for j in self._order if j in self._jobs]
return len(drop)
async def _run_worker(self, idx: int) -> None:
while True:
jid = await self._queue.get()
try:
job = self._jobs.get(jid)
if job is None:
continue
if job.state == JobState.CANCELLED:
# Cancelled while queued — skip execution.
continue
job.state = JobState.RUNNING
job.started_at = time.time()
try:
result = await job.fn(job)
if job.is_cancelled:
job.state = JobState.CANCELLED
else:
job.state = JobState.DONE
job.result = result
except asyncio.CancelledError:
job.state = JobState.CANCELLED
raise
except Exception as e:
logger.exception("Job %s (%s) failed", job.id, job.label)
job.state = JobState.FAILED
job.error = str(e)
finally:
job.finished_at = time.time()
job.done_event.set()
finally:
self._queue.task_done()
# Shared GPU-bound queue — single lane, protects TTS/ASR/demucs from VRAM contention.
gpu_queue = JobQueue(name="gpu", concurrency=1)
+116
View File
@@ -5,7 +5,9 @@
"": { "": {
"name": "omnivoice-studio-monorepo", "name": "omnivoice-studio-monorepo",
"devDependencies": { "devDependencies": {
"concurrently": "^9.2.1",
"turbo": "^2.9.6", "turbo": "^2.9.6",
"wait-on": "^9.0.5",
}, },
}, },
"frontend": { "frontend": {
@@ -90,6 +92,18 @@
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
"@hapi/address": ["@hapi/address@5.1.1", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA=="],
"@hapi/formula": ["@hapi/formula@3.0.2", "", {}, "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw=="],
"@hapi/hoek": ["@hapi/hoek@11.0.7", "", {}, "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ=="],
"@hapi/pinpoint": ["@hapi/pinpoint@2.0.1", "", {}, "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q=="],
"@hapi/tlds": ["@hapi/tlds@1.1.6", "", {}, "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw=="],
"@hapi/topo": ["@hapi/topo@6.0.2", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
@@ -144,6 +158,8 @@
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="], "@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
"@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="], "@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="],
@@ -206,6 +222,14 @@
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"axios": ["axios@1.15.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA=="],
@@ -214,8 +238,22 @@
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="], "caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
@@ -226,10 +264,24 @@
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.334", "", {}, "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog=="], "electron-to-chromium": ["electron-to-chromium@1.5.334", "", {}, "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
@@ -270,16 +322,38 @@
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@17.4.0", "", {}, "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw=="], "globals": ["globals@17.4.0", "", {}, "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw=="],
"goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="], "goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
@@ -290,10 +364,14 @@
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"joi": ["joi@18.1.2", "", { "dependencies": { "@hapi/address": "^5.1.1", "@hapi/formula": "^3.0.2", "@hapi/hoek": "^11.0.7", "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", "@standard-schema/spec": "^1.1.0" } }, "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
@@ -336,12 +414,22 @@
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@1.8.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="], "lucide-react": ["lucide-react@1.8.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
@@ -370,6 +458,8 @@
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
@@ -378,8 +468,12 @@
"react-hot-toast": ["react-hot-toast@2.6.0", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg=="], "react-hot-toast": ["react-hot-toast@2.6.0", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"rolldown": ["rolldown@1.0.0-rc.15", "", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="], "rolldown": ["rolldown@1.0.0-rc.15", "", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="],
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -388,10 +482,20 @@
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"turbo": ["turbo@2.9.6", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.6", "@turbo/darwin-arm64": "2.9.6", "@turbo/linux-64": "2.9.6", "@turbo/linux-arm64": "2.9.6", "@turbo/windows-64": "2.9.6", "@turbo/windows-arm64": "2.9.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg=="], "turbo": ["turbo@2.9.6", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.6", "@turbo/darwin-arm64": "2.9.6", "@turbo/linux-64": "2.9.6", "@turbo/linux-arm64": "2.9.6", "@turbo/windows-64": "2.9.6", "@turbo/windows-arm64": "2.9.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg=="],
@@ -404,14 +508,24 @@
"vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="], "vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="],
"wait-on": ["wait-on@9.0.5", "", { "dependencies": { "axios": "^1.15.0", "joi": "^18.1.2", "lodash": "^4.18.1", "minimist": "^1.2.8", "rxjs": "^7.8.2" }, "bin": { "wait-on": "bin/wait-on" } }, "sha512-qgnbHDfDTRIp73ANEJNRW/7kn8CrDUcvZz18xotJQku/P4saTGkbIzvnMZebPmVvVNUiRq1qWAPyqCH+W4H8KA=="],
"wavesurfer.js": ["wavesurfer.js@7.12.6", "", {}, "sha512-zSxPgOFprtyJ31ppHQF0+E9jAmjAhi1rR36yIW6h1GOYdpRxDe6mbkYtlChqLK0Iz8ROBweiEFw2zus7tDFibA=="], "wavesurfer.js": ["wavesurfer.js@7.12.6", "", {}, "sha512-zSxPgOFprtyJ31ppHQF0+E9jAmjAhi1rR36yIW6h1GOYdpRxDe6mbkYtlChqLK0Iz8ROBweiEFw2zus7tDFibA=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
@@ -420,6 +534,8 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="], "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
} }
} }
+156 -20
View File
@@ -2,6 +2,20 @@ 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'; import SearchableSelect from './components/SearchableSelect';
import AudioTrimmer from './components/AudioTrimmer';
const CLONE_MAX_SECONDS = 15;
async function probeAudioDuration(file) {
return new Promise((resolve) => {
const url = URL.createObjectURL(file);
const a = new Audio();
const done = (v) => { URL.revokeObjectURL(url); resolve(v); };
a.addEventListener('loadedmetadata', () => done(isFinite(a.duration) ? a.duration : null));
a.addEventListener('error', () => done(null));
a.src = url;
});
}
const POPULAR_LANGS = ['English','Spanish','French','German','Italian','Portuguese','Russian','Chinese','Japanese','Korean','Arabic','Hindi']; 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']; const POPULAR_ISO = ['en','es','fr','de','it','pt','ru','zh','ja','ko','ar','hi'];
@@ -189,6 +203,20 @@ function App() {
const [mode, setMode] = useState('launchpad'); const [mode, setMode] = useState('launchpad');
const [text, setText] = useState(''); const [text, setText] = useState('');
const [refAudio, setRefAudio] = useState(null); const [refAudio, setRefAudio] = useState(null);
const [pendingTrimFile, setPendingTrimFile] = useState(null);
const ingestRefAudio = useCallback(async (file) => {
if (!file) { setRefAudio(null); return; }
const dur = await probeAudioDuration(file);
if (dur && dur > CLONE_MAX_SECONDS) {
setPendingTrimFile(file);
setSelectedProfile(null);
toast(`Audio is ${dur.toFixed(1)}s — trim to ≤${CLONE_MAX_SECONDS}s for best cloning`);
return;
}
setRefAudio(file);
setSelectedProfile(null);
}, []);
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');
@@ -478,7 +506,7 @@ function App() {
if (saved.dubJobId) setDubJobId(saved.dubJobId); if (saved.dubJobId) setDubJobId(saved.dubJobId);
if (saved.dubFilename) setDubFilename(saved.dubFilename); if (saved.dubFilename) setDubFilename(saved.dubFilename);
if (saved.dubDuration !== undefined) setDubDuration(saved.dubDuration); if (saved.dubDuration !== undefined) setDubDuration(saved.dubDuration);
if (saved.dubSegments) setDubSegments(saved.dubSegments); if (saved.dubSegments) setDubSegments(saved.dubSegments.map(s => ({ ...s, text_original: s.text_original || s.text || '' })));
if (saved.dubLang) setDubLang(saved.dubLang); if (saved.dubLang) setDubLang(saved.dubLang);
if (saved.dubLangCode) setDubLangCode(saved.dubLangCode); if (saved.dubLangCode) setDubLangCode(saved.dubLangCode);
if (saved.dubTracks) setDubTracks(saved.dubTracks); if (saved.dubTracks) setDubTracks(saved.dubTracks);
@@ -866,14 +894,12 @@ function App() {
const cleanFilename = res.headers.get("X-Clean-Filename") || "recording_clean.wav"; const cleanFilename = res.headers.get("X-Clean-Filename") || "recording_clean.wav";
const cleanFile = new File([cleanBlob], cleanFilename, { type: "audio/wav" }); const cleanFile = new File([cleanBlob], cleanFilename, { type: "audio/wav" });
setRefAudio(cleanFile); await ingestRefAudio(cleanFile);
setSelectedProfile(null);
toast.success("🎙️ Recording cleaned & loaded!"); toast.success("🎙️ Recording cleaned & loaded!");
} catch (e) { } catch (e) {
// Fallback: use raw recording without denoising // Fallback: use raw recording without denoising
const rawFile = new File([blob], "recording.webm", { type: "audio/webm" }); const rawFile = new File([blob], "recording.webm", { type: "audio/webm" });
setRefAudio(rawFile); await ingestRefAudio(rawFile);
setSelectedProfile(null);
toast.success("Recording loaded (raw — denoising unavailable)"); toast.success("Recording loaded (raw — denoising unavailable)");
} finally { } finally {
setIsCleaning(false); setIsCleaning(false);
@@ -924,11 +950,61 @@ function App() {
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", signal: ctrl.signal }); setDubSegments([]);
if (!tRes.ok) throw new Error(await tRes.text());
const tData = await tRes.json(); await new Promise((resolve, reject) => {
setDubSegments(tData.segments.map((s, i) => ({ ...s, id: i }))); const evt = new EventSource(`${API}/dub/transcribe-stream/${data.job_id}`);
setDubTranscript(tData.full_transcript || ''); let gotFinal = false;
const close = () => { try { evt.close(); } catch {} };
const onAbortSignal = () => { close(); reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); };
ctrl.signal.addEventListener('abort', onAbortSignal, { once: true });
evt.addEventListener('start', () => {});
evt.addEventListener('segments', (e) => {
try {
const m = JSON.parse(e.data);
const incoming = (m.segments || []).map((s, i) => ({
...s,
id: s.id != null ? String(s.id) : `c${m.chunk}-${i}`,
text_original: s.text_original || s.text || '',
}));
setDubSegments(prev => [...prev, ...incoming]);
} catch (err) { /* ignore parse errors */ }
});
evt.addEventListener('final', (e) => {
try {
const m = JSON.parse(e.data);
gotFinal = true;
setDubSegments((m.segments || []).map((s, i) => ({
...s,
id: s.id != null ? String(s.id) : String(i),
text_original: s.text_original || s.text || '',
})));
setDubTranscript(m.full_transcript || '');
} catch {}
});
evt.addEventListener('done', () => {
close();
ctrl.signal.removeEventListener('abort', onAbortSignal);
resolve();
});
evt.addEventListener('aborted', () => {
close();
ctrl.signal.removeEventListener('abort', onAbortSignal);
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
});
evt.addEventListener('error', (e) => {
try {
const m = e.data ? JSON.parse(e.data) : null;
if (m && m.detail) { close(); reject(new Error(m.detail)); return; }
} catch {}
// EventSource auto-retries on network errors; treat as fatal after final is in.
if (gotFinal) { close(); resolve(); }
else if (evt.readyState === EventSource.CLOSED) { reject(new Error('transcribe stream closed')); }
});
});
setTranscribeStart(null); setTranscribeStart(null);
setDubStep('editing'); setDubStep('editing');
} catch (err) { } catch (err) {
@@ -983,7 +1059,13 @@ 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, target_lang: s.target_lang })), // Always translate from the preserved ORIGINAL text so switching target
// languages doesn't compound errors (de → fr translating already-German).
segments: dubSegments.map(s => ({
id: String(s.id),
text: (s.text_original && s.text_original.trim()) ? s.text_original : s.text,
target_lang: s.target_lang,
})),
target_lang: dubLangCode, target_lang: dubLangCode,
provider: translateProvider, provider: translateProvider,
}), }),
@@ -991,8 +1073,27 @@ function App() {
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();
const translatedMap = {}; const translatedMap = {};
data.translated.forEach(t => { translatedMap[t.id] = t.text; }); const errors = [];
setDubSegments(dubSegments.map(s => ({ ...s, text: translatedMap[s.id] || s.text }))); (data.translated || []).forEach(t => {
translatedMap[t.id] = t;
if (t.error) errors.push({ id: t.id, error: t.error });
});
setDubSegments(dubSegments.map(s => {
const hit = translatedMap[s.id];
if (!hit) return s;
const newText = (hit.text && hit.text.trim()) ? hit.text : s.text;
return { ...s, text: newText, translate_error: hit.error || undefined };
}));
if (errors.length) {
const unique = [...new Set(errors.map(e => e.error))];
toast.error(
`${errors.length}/${data.translated.length} segment${errors.length === 1 ? '' : 's'} failed: ${unique[0].slice(0, 120)}`,
{ duration: 6000 }
);
console.warn('Translation errors:', errors);
} else {
toast.success(`Translated ${data.translated.length} segment${data.translated.length === 1 ? '' : 's'}${data.target_lang}`);
}
} catch (err) { setDubError('Translation failed: ' + err.message); } } catch (err) { setDubError('Translation failed: ' + err.message); }
setIsTranslating(false); setIsTranslating(false);
}; };
@@ -1277,7 +1378,7 @@ function App() {
setDubJobId(s.dubJobId || null); setDubJobId(s.dubJobId || null);
setDubFilename(s.dubFilename || data.video_path || ''); setDubFilename(s.dubFilename || data.video_path || '');
setDubDuration(s.dubDuration || data.duration || 0); setDubDuration(s.dubDuration || data.duration || 0);
setDubSegments(s.dubSegments || []); setDubSegments((s.dubSegments || []).map(x => ({ ...x, text_original: x.text_original || x.text || '' })));
setDubLang(s.dubLang || 'Auto'); setDubLang(s.dubLang || 'Auto');
setDubLangCode(s.dubLangCode || 'en'); setDubLangCode(s.dubLangCode || 'en');
setDubInstruct(s.dubInstruct || ''); setDubInstruct(s.dubInstruct || '');
@@ -1313,7 +1414,7 @@ function App() {
setDubJobId(item.id); setDubJobId(item.id);
setDubFilename(job.filename || ''); setDubFilename(job.filename || '');
setDubDuration(job.duration || 0); setDubDuration(job.duration || 0);
setDubSegments((job.segments || []).map((s, i) => ({ ...s, id: s.id !== undefined ? s.id : i }))); setDubSegments((job.segments || []).map((s, i) => ({ ...s, id: s.id != null ? String(s.id) : String(i), text_original: s.text_original || s.text || '' })));
setDubTranscript(job.full_transcript || ''); setDubTranscript(job.full_transcript || '');
setDubLang(item.language || 'Auto'); setDubLang(item.language || 'Auto');
setDubLangCode(item.language_code || 'und'); setDubLangCode(item.language_code || 'und');
@@ -1355,6 +1456,14 @@ function App() {
return ( return (
<div className={`app-container${isSidebarCollapsed ? ' sidebar-collapsed' : ''}`} style={{ zoom: uiScale }}> <div className={`app-container${isSidebarCollapsed ? ' sidebar-collapsed' : ''}`} style={{ zoom: uiScale }}>
{pendingTrimFile && (
<AudioTrimmer
file={pendingTrimFile}
maxSeconds={CLONE_MAX_SECONDS}
onCancel={() => setPendingTrimFile(null)}
onConfirm={(trimmed) => { setPendingTrimFile(null); setRefAudio(trimmed); setSelectedProfile(null); toast.success('Trimmed audio loaded'); }}
/>
)}
<Toaster position="top-center" toastOptions={{ <Toaster position="top-center" toastOptions={{
style: { background: 'rgba(40,40,40,0.9)', backdropFilter: 'blur(10px)', color: '#ebdbb2', border: '1px solid rgba(255,255,255,0.08)', fontSize: '0.72rem', padding: '4px 8px' }, style: { background: 'rgba(40,40,40,0.9)', backdropFilter: 'blur(10px)', color: '#ebdbb2', border: '1px solid rgba(255,255,255,0.08)', fontSize: '0.72rem', padding: '4px 8px' },
error: { iconTheme: { primary: '#fb4934', secondary: '#fff' } }, error: { iconTheme: { primary: '#fb4934', secondary: '#fff' } },
@@ -1954,6 +2063,13 @@ function App() {
{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={() => editSegments(dubSegments.map(s => ({ ...s, text: s.text_original || s.text, translate_error: undefined })))}
disabled={!dubSegments.some(s => s.text_original && s.text_original !== s.text)}
title="Restore all segments to the original transcribed text"
style={{padding:'3px 8px', background:'rgba(131,165,152,0.08)', border:'1px solid rgba(131,165,152,0.2)', color:'#83a598', borderRadius:4, cursor:'pointer', fontSize:'0.62rem', fontWeight:500, display:'flex', alignItems:'center', gap:3, whiteSpace:'nowrap'}}>
Restore
</button>
<button onClick={handleCleanupSegments} disabled={!dubSegments.length || !dubJobId} <button onClick={handleCleanupSegments} disabled={!dubSegments.length || !dubJobId}
title="Merge tiny fragments and adjacent short segments" 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'}}> 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'}}>
@@ -2046,9 +2162,28 @@ function App() {
)} )}
</span> </span>
<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} <span style={{flex:1, display:'flex', flexDirection:'column', gap:2, minWidth:0}}>
onChange={e => editSegments(dubSegments.map(s => s.id===seg.id?{...s,text:e.target.value}:s))} <input className="input-base segment-input" value={seg.text}
disabled={dubStep==='generating'||dubStep==='stopping'}/> onChange={e => editSegments(dubSegments.map(s => s.id===seg.id?{...s,text:e.target.value}:s))}
disabled={dubStep==='generating'||dubStep==='stopping'}
title={seg.translate_error ? `Translation error: ${seg.translate_error}` : undefined}
style={seg.translate_error ? {borderColor:'rgba(251,73,52,0.5)'} : undefined}/>
{seg.text_original && seg.text_original !== seg.text && (
<span style={{fontSize:'0.55rem', color:'#6b6657', display:'flex', alignItems:'center', gap:4, padding:'0 4px', overflow:'hidden'}}>
<span style={{opacity:0.8, textTransform:'uppercase', fontWeight:600, fontSize:'0.5rem', color:'#7c6f64'}}>orig</span>
<span style={{flex:1, whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis'}} title={seg.text_original}>
{seg.text_original}
</span>
<button
onClick={() => editSegments(dubSegments.map(s => s.id===seg.id?{...s,text:seg.text_original,translate_error:undefined}:s))}
disabled={dubStep==='generating'||dubStep==='stopping'}
title="Restore original text"
style={{background:'none', border:'none', color:'#83a598', cursor:'pointer', padding:0, fontSize:'0.55rem'}}>
</button>
</span>
)}
</span>
<select className="input-base segment-input" style={{width:45, fontSize:'0.55rem', padding:'1px 2px'}} <select className="input-base segment-input" style={{width:45, fontSize:'0.55rem', padding:'1px 2px'}}
value={seg.target_lang||''} disabled={dubStep==='generating'||dubStep==='stopping'} 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))}> onChange={e => editSegments(dubSegments.map(s => s.id===seg.id?{...s,target_lang:e.target.value}:s))}>
@@ -2257,7 +2392,7 @@ function App() {
{!selectedProfile && ( {!selectedProfile && (
<> <>
<div style={{display:'flex', gap:'8px', alignItems:'stretch'}}> <div style={{display:'flex', gap:'8px', alignItems:'stretch'}}>
<input type="file" accept="audio/*" onChange={e => { setRefAudio(e.target.files[0]); setSelectedProfile(null); }} style={{display:'none'}} id="audio-upload" /> <input type="file" accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg" onChange={e => { const f = e.target.files[0]; ingestRefAudio(f); e.target.value=''; }} style={{display:'none'}} id="audio-upload" />
<label htmlFor="audio-upload" className="file-drag" style={{padding: '6px', flex:1}} <label htmlFor="audio-upload" className="file-drag" style={{padding: '6px', flex:1}}
onDragOver={e => { e.preventDefault(); e.currentTarget.style.borderColor='#d3869b'; e.currentTarget.style.background='rgba(211,134,155,0.05)'; }} onDragOver={e => { e.preventDefault(); e.currentTarget.style.borderColor='#d3869b'; e.currentTarget.style.background='rgba(211,134,155,0.05)'; }}
onDragLeave={e => { e.currentTarget.style.borderColor=''; e.currentTarget.style.background=''; }} onDragLeave={e => { e.currentTarget.style.borderColor=''; e.currentTarget.style.background=''; }}
@@ -2265,7 +2400,8 @@ function App() {
e.preventDefault(); e.preventDefault();
e.currentTarget.style.borderColor=''; e.currentTarget.style.background=''; e.currentTarget.style.borderColor=''; e.currentTarget.style.background='';
const file = e.dataTransfer.files[0]; const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('audio/')) { setRefAudio(file); setSelectedProfile(null); } const okType = file && (file.type.startsWith('audio/') || /\.(mp3|wav|m4a|flac|ogg|aac|webm)$/i.test(file.name));
if (okType) ingestRefAudio(file);
}}> }}>
<UploadCloud color="#a89984" size={18}/> <UploadCloud color="#a89984" size={18}/>
<p>{refAudio ? <span style={{color:'#ebdbb2'}}>{refAudio.name}</span> : "Drop audio or click · WAV / MP3"}</p> <p>{refAudio ? <span style={{color:'#ebdbb2'}}>{refAudio.name}</span> : "Drop audio or click · WAV / MP3"}</p>
+719
View File
@@ -0,0 +1,719 @@
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { Scissors, X, Play, Pause, Check, ZoomIn, ZoomOut, Maximize2, Repeat } from 'lucide-react';
import {
clamp, encodeWav, computePeaksFromChannel, computePeaksAsync, pickTickInterval,
xToTime as xToTimeUtil, pickHandle as pickHandleUtil,
applyDrag as applyDragUtil, zoomAtCursor, zoomCenter, sliceToMono,
decodeToMonoLowRate, DEFAULT_PEAK_BUCKETS,
} from '../utils/audioTrim.js';
const EDGE_GRAB_PX = 10;
const computePeaks = (buffer, buckets = DEFAULT_PEAK_BUCKETS) =>
computePeaksFromChannel(buffer.getChannelData(0), buckets);
function fmtSec(t, precision = 2) {
if (!isFinite(t)) return '0.00s';
return `${t.toFixed(precision)}s`;
}
function fmtHMS(t) {
if (!isFinite(t)) return '0:00.00';
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const s = t % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${s.toFixed(2).padStart(5, '0')}`;
return `${m}:${s.toFixed(2).padStart(5, '0')}`;
}
export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCancel }) {
const waveRef = useRef(null);
const rulerRef = useRef(null);
const audioRef = useRef(null);
const containerRef = useRef(null);
const bufferRef = useRef(null);
const peaksRef = useRef(null);
const drawRafRef = useRef(0);
const dragRafRef = useRef(0);
const dragStateRef = useRef(null);
const pointerRef = useRef(null);
const stateRef = useRef({ start: 0, end: 0, cursor: 0, viewStart: 0, viewEnd: 0, duration: 0 });
const [ready, setReady] = useState(false);
const [decoding, setDecoding] = useState(true);
const [peakProgress, setPeakProgress] = useState(0);
const [error, setError] = useState('');
const [start, setStart] = useState(0);
const [end, setEnd] = useState(0);
const [cursor, setCursor] = useState(0);
const [viewStart, setViewStart] = useState(0);
const [viewEnd, setViewEnd] = useState(0);
const [playing, setPlaying] = useState(false);
const [loop, setLoop] = useState(true);
const [startInput, setStartInput] = useState('0.00');
const [endInput, setEndInput] = useState('0.00');
useEffect(() => {
stateRef.current = {
start, end, cursor, viewStart, viewEnd,
duration: bufferRef.current ? bufferRef.current.duration : 0,
};
}, [start, end, cursor, viewStart, viewEnd]);
useEffect(() => { setStartInput(start.toFixed(2)); }, [start]);
useEffect(() => { setEndInput(end.toFixed(2)); }, [end]);
// Decode (low rate mono) + async peaks keeps UI responsive for long files.
useEffect(() => {
if (!file) return;
let cancelled = false;
setDecoding(true);
setReady(false);
setPeakProgress(0);
(async () => {
try {
const buf = await decodeToMonoLowRate(file, 22050);
if (cancelled) return;
bufferRef.current = buf;
// Prime with a coarse synchronous pass so waveform shows something instantly.
peaksRef.current = computePeaksFromChannel(buf.getChannelData(0), 1024);
const initEnd = Math.min(buf.duration, maxSeconds);
setStart(0);
setEnd(initEnd);
setCursor(0);
setViewStart(0);
setViewEnd(buf.duration);
setReady(true);
setDecoding(false);
// Refine peaks asynchronously without blocking UI.
const refined = await computePeaksAsync(
buf.getChannelData(0),
DEFAULT_PEAK_BUCKETS,
(p) => { if (!cancelled) setPeakProgress(p); },
);
if (cancelled) return;
peaksRef.current = refined;
setPeakProgress(1);
} catch (e) {
if (!cancelled) setError('Decode failed: ' + (e.message || e));
setDecoding(false);
}
})();
return () => { cancelled = true; };
}, [file, maxSeconds]);
// Bind audio src
useEffect(() => {
if (!file) return;
const url = URL.createObjectURL(file);
const a = audioRef.current;
if (a) { a.src = url; a.load(); }
return () => {
if (a) { try { a.pause(); a.removeAttribute('src'); a.load(); } catch {} }
URL.revokeObjectURL(url);
};
}, [file]);
const sizeCanvas = useCallback((canvas) => {
if (!canvas) return null;
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
const w = Math.max(1, Math.floor(rect.width * dpr));
const h = Math.max(1, Math.floor(rect.height * dpr));
if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h;
return { w, h, dpr };
}, []);
const drawWave = useCallback(() => {
const buffer = bufferRef.current;
const peaks = peaksRef.current;
const canvas = waveRef.current;
if (!buffer || !peaks || !canvas) return;
const sized = sizeCanvas(canvas);
if (!sized) return;
const { w, h, dpr } = sized;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, w, h);
const { viewStart: vs, viewEnd: ve, start: s, end: e, cursor: c, duration: dur } = stateRef.current;
const viewDur = Math.max(1e-6, ve - vs);
const totalBuckets = peaks.length / 2;
const secPerBucket = dur / totalBuckets;
const secPerPixel = viewDur / w;
const useRaw = secPerPixel < secPerBucket;
const ch = buffer.getChannelData(0);
const sr = buffer.sampleRate;
// Center baseline
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, h / 2);
ctx.lineTo(w, h / 2);
ctx.stroke();
// Waveform
ctx.fillStyle = '#7a6f5d';
for (let x = 0; x < w; x++) {
const t0 = vs + (x / w) * viewDur;
const t1 = vs + ((x + 1) / w) * viewDur;
let mn = 1, mx = -1;
if (useRaw) {
const i0 = Math.max(0, Math.floor(t0 * sr));
const i1 = Math.min(ch.length, Math.ceil(t1 * sr));
for (let i = i0; i < i1; i++) {
const v = ch[i];
if (v < mn) mn = v;
if (v > mx) mx = v;
}
} else {
const b0 = Math.max(0, Math.floor(t0 / secPerBucket));
const b1 = Math.min(totalBuckets, Math.ceil(t1 / secPerBucket));
for (let b = b0; b < b1; b++) {
const pmn = peaks[b * 2];
const pmx = peaks[b * 2 + 1];
if (pmn < mn) mn = pmn;
if (pmx > mx) mx = pmx;
}
}
if (mx < mn) { mn = 0; mx = 0; }
const y1 = (1 - mx) * 0.5 * h;
const y2 = (1 - mn) * 0.5 * h;
ctx.fillRect(x, y1, 1, Math.max(1, y2 - y1));
}
// Selection
const tToX = (t) => ((t - vs) / viewDur) * w;
const sx = tToX(s);
const ex = tToX(e);
const selW = ex - sx;
ctx.fillStyle = 'rgba(211,134,155,0.18)';
ctx.fillRect(sx, 0, selW, h);
// Redraw selection waveform in accent
ctx.save();
ctx.beginPath();
ctx.rect(Math.max(0, sx), 0, Math.max(0, Math.min(w, ex) - Math.max(0, sx)), h);
ctx.clip();
ctx.fillStyle = '#d3869b';
for (let x = Math.max(0, Math.floor(sx)); x < Math.min(w, Math.ceil(ex)); x++) {
const t0 = vs + (x / w) * viewDur;
const t1 = vs + ((x + 1) / w) * viewDur;
let mn = 1, mx = -1;
if (useRaw) {
const i0 = Math.max(0, Math.floor(t0 * sr));
const i1 = Math.min(ch.length, Math.ceil(t1 * sr));
for (let i = i0; i < i1; i++) {
const v = ch[i];
if (v < mn) mn = v;
if (v > mx) mx = v;
}
} else {
const b0 = Math.max(0, Math.floor(t0 / secPerBucket));
const b1 = Math.min(totalBuckets, Math.ceil(t1 / secPerBucket));
for (let b = b0; b < b1; b++) {
const pmn = peaks[b * 2];
const pmx = peaks[b * 2 + 1];
if (pmn < mn) mn = pmn;
if (pmx > mx) mx = pmx;
}
}
if (mx < mn) { mn = 0; mx = 0; }
const y1 = (1 - mx) * 0.5 * h;
const y2 = (1 - mn) * 0.5 * h;
ctx.fillRect(x, y1, 1, Math.max(1, y2 - y1));
}
ctx.restore();
// Selection border
ctx.strokeStyle = '#d3869b';
ctx.lineWidth = 1 * dpr;
ctx.strokeRect(sx + 0.5, 0.5, selW, h - 1);
// Handles (flags)
const handleW = 6 * dpr;
const handleH = Math.min(h, 22 * dpr);
ctx.fillStyle = '#d3869b';
ctx.fillRect(sx - handleW / 2, 0, handleW, handleH);
ctx.fillRect(ex - handleW / 2, 0, handleW, handleH);
ctx.fillRect(sx - handleW / 2, h - handleH, handleW, handleH);
ctx.fillRect(ex - handleW / 2, h - handleH, handleW, handleH);
// Playhead
if (c >= vs && c <= ve) {
const cx = tToX(c);
ctx.fillStyle = '#fabd2f';
ctx.fillRect(cx, 0, 1.5 * dpr, h);
}
}, [sizeCanvas]);
const drawRuler = useCallback(() => {
const canvas = rulerRef.current;
const buffer = bufferRef.current;
if (!canvas || !buffer) return;
const sized = sizeCanvas(canvas);
if (!sized) return;
const { w, h, dpr } = sized;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, w, h);
const { viewStart: vs, viewEnd: ve } = stateRef.current;
const viewDur = Math.max(1e-6, ve - vs);
const tick = pickTickInterval(viewDur);
const firstTick = Math.ceil(vs / tick) * tick;
ctx.font = `${10 * dpr}px -apple-system, system-ui, sans-serif`;
ctx.fillStyle = '#a89984';
ctx.strokeStyle = 'rgba(168,153,132,0.3)';
ctx.lineWidth = 1;
for (let t = firstTick; t <= ve + 1e-6; t += tick) {
const x = ((t - vs) / viewDur) * w;
ctx.beginPath();
ctx.moveTo(x, h - 6 * dpr);
ctx.lineTo(x, h);
ctx.stroke();
const label = tick >= 1 ? fmtHMS(t) : `${t.toFixed(2)}s`;
ctx.fillText(label, x + 3 * dpr, h - 8 * dpr);
}
}, [sizeCanvas]);
const scheduleDraw = useCallback(() => {
if (drawRafRef.current) return;
drawRafRef.current = requestAnimationFrame(() => {
drawRafRef.current = 0;
drawWave();
drawRuler();
});
}, [drawWave, drawRuler]);
useEffect(() => { scheduleDraw(); }, [start, end, cursor, viewStart, viewEnd, ready, scheduleDraw]);
useEffect(() => {
const onResize = () => scheduleDraw();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [scheduleDraw]);
const canvasRect = () => {
const c = waveRef.current;
return c ? c.getBoundingClientRect() : null;
};
const xToTime = (clientX) => {
const rect = canvasRect();
if (!rect) return 0;
return xToTimeUtil(clientX - rect.left, rect.width,
stateRef.current.viewStart, stateRef.current.viewEnd);
};
const pickHandleAt = (clientX) => {
const rect = canvasRect();
if (!rect) return null;
return pickHandleUtil(clientX, rect.left, rect.width, stateRef.current, EDGE_GRAB_PX);
};
const applyPointer = useCallback(() => {
const pos = pointerRef.current;
if (!pos) return;
pointerRef.current = null;
const buffer = bufferRef.current;
const rect = canvasRect();
if (!buffer || !rect) return;
const drag = dragStateRef.current;
if (!drag) {
const t = xToTimeUtil(pos.clientX - rect.left, rect.width,
stateRef.current.viewStart, stateRef.current.viewEnd);
setCursor(clamp(t, 0, stateRef.current.duration));
return;
}
const out = applyDragUtil(stateRef.current, pos.clientX, rect.left, rect.width, drag, 0.02);
if (drag.mode === 'start') setStart(out.start);
else if (drag.mode === 'end') setEnd(out.end);
else if (drag.mode === 'region' || drag.mode === 'new') { setStart(out.start); setEnd(out.end); }
else if (drag.mode === 'pan') { setViewStart(out.viewStart); setViewEnd(out.viewEnd); }
}, []);
const schedulePointer = useCallback(() => {
if (dragRafRef.current) return;
dragRafRef.current = requestAnimationFrame(() => {
dragRafRef.current = 0;
applyPointer();
});
}, [applyPointer]);
useEffect(() => {
const move = (e) => {
pointerRef.current = { clientX: e.clientX };
schedulePointer();
};
const up = () => { dragStateRef.current = null; pointerRef.current = null; };
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
return () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
}, [schedulePointer]);
const onCanvasDown = (e) => {
if (!ready) return;
e.preventDefault();
const buffer = bufferRef.current;
if (!buffer) return;
if (e.button === 1 || e.altKey || e.metaKey) {
dragStateRef.current = {
mode: 'pan',
startClientX: e.clientX,
viewStart: stateRef.current.viewStart,
viewDur: stateRef.current.viewEnd - stateRef.current.viewStart,
};
pointerRef.current = { clientX: e.clientX };
schedulePointer();
return;
}
const handle = pickHandleAt(e.clientX);
if (handle === 'region') {
const t = xToTime(e.clientX);
dragStateRef.current = {
mode: 'region',
regionLen: stateRef.current.end - stateRef.current.start,
offset: t - stateRef.current.start,
};
} else if (handle === 'start' || handle === 'end') {
dragStateRef.current = { mode: handle };
} else {
const t = clamp(xToTime(e.clientX), 0, buffer.duration);
// Start a fresh selection anchored at click point. Drag extends it.
setStart(t);
setEnd(t);
setCursor(t);
const a = audioRef.current;
if (a) { try { a.currentTime = t; } catch {} }
dragStateRef.current = { mode: 'new', anchorT: t };
}
pointerRef.current = { clientX: e.clientX };
schedulePointer();
};
const onWheel = useCallback((e) => {
const buffer = bufferRef.current;
if (!buffer) return;
e.preventDefault();
const rect = waveRef.current.getBoundingClientRect();
const { viewStart: vs, viewEnd: ve } = stateRef.current;
const viewDur = ve - vs;
if (e.shiftKey) {
const dir = Math.sign(e.deltaY || e.deltaX);
const pan = dir * viewDur * 0.15;
const newVs = clamp(vs + pan, 0, buffer.duration - viewDur);
setViewStart(newVs);
setViewEnd(newVs + viewDur);
} else {
const xFrac = clamp((e.clientX - rect.left) / rect.width, 0, 1);
const factor = e.deltaY < 0 ? 0.8 : 1.25;
const minDur = Math.max(0.01, buffer.sampleRate ? 200 / buffer.sampleRate : 0.01);
const out = zoomAtCursor(vs, ve, buffer.duration, factor, xFrac, minDur);
setViewStart(out.viewStart);
setViewEnd(out.viewEnd);
}
}, []);
useEffect(() => {
const canvas = waveRef.current;
if (!canvas) return;
const handler = (e) => { if (ready) onWheel(e); };
canvas.addEventListener('wheel', handler, { passive: false });
return () => canvas.removeEventListener('wheel', handler);
}, [onWheel, ready]);
const zoomIn = () => {
const buffer = bufferRef.current; if (!buffer) return;
const { viewStart: vs, viewEnd: ve } = stateRef.current;
const out = zoomCenter(vs, ve, buffer.duration, 0.5);
setViewStart(out.viewStart); setViewEnd(out.viewEnd);
};
const zoomOut = () => {
const buffer = bufferRef.current; if (!buffer) return;
const { viewStart: vs, viewEnd: ve } = stateRef.current;
const out = zoomCenter(vs, ve, buffer.duration, 2);
setViewStart(out.viewStart); setViewEnd(out.viewEnd);
};
const fitAll = () => {
const buffer = bufferRef.current; if (!buffer) return;
setViewStart(0);
setViewEnd(buffer.duration);
};
const fitSelection = () => {
const buffer = bufferRef.current; if (!buffer) return;
const { start: s, end: e } = stateRef.current;
const pad = Math.max(0.1, (e - s) * 0.2);
const nvs = clamp(s - pad, 0, buffer.duration);
const nve = clamp(e + pad, nvs + 0.02, buffer.duration);
setViewStart(nvs);
setViewEnd(nve);
};
const togglePlay = () => {
const a = audioRef.current;
if (!a) return;
if (playing) { a.pause(); setPlaying(false); return; }
const s = stateRef.current.start;
const doPlay = () => {
try { a.currentTime = s; } catch (err) { console.warn('currentTime set failed', err); }
a.play().then(() => setPlaying(true)).catch((err) => {
setError('Playback failed: ' + (err.message || err));
});
};
// HAVE_METADATA = 1 is enough to set currentTime on most browsers.
if (a.readyState >= 1) {
doPlay();
} else {
a.addEventListener('loadedmetadata', doPlay, { once: true });
a.addEventListener('error', () => setError('Audio load failed'), { once: true });
}
};
useEffect(() => {
const a = audioRef.current;
if (!a) return;
let raf;
const tick = () => {
if (!a.paused) {
setCursor(a.currentTime);
const { start: s, end: e } = stateRef.current;
if (a.currentTime >= e) {
if (loop) {
try { a.currentTime = s; } catch {}
} else {
a.pause();
setPlaying(false);
}
}
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [loop]);
const duration = end - start;
const tooLong = duration > maxSeconds;
const tooShort = duration < 0.1;
const commitStartInput = () => {
const v = parseFloat(startInput);
if (isFinite(v)) setStart(clamp(v, 0, Math.max(0, end - 0.02)));
else setStartInput(start.toFixed(2));
};
const commitEndInput = () => {
const buffer = bufferRef.current;
const v = parseFloat(endInput);
if (isFinite(v) && buffer) setEnd(clamp(v, start + 0.02, buffer.duration));
else setEndInput(end.toFixed(2));
};
const onKeyDown = (e) => {
if (!ready) return;
const buffer = bufferRef.current;
if (!buffer) return;
const tgt = e.target;
if (tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA')) return;
const fine = e.shiftKey ? 0.01 : e.altKey ? 1.0 : 0.1;
if (e.key === ' ' || e.code === 'Space') { e.preventDefault(); togglePlay(); return; }
if (e.key === 'ArrowLeft') {
e.preventDefault();
if (e.ctrlKey || e.metaKey) setEnd(clamp(end - fine, start + 0.02, buffer.duration));
else setStart(clamp(start - fine, 0, Math.max(0, end - 0.02)));
} else if (e.key === 'ArrowRight') {
e.preventDefault();
if (e.ctrlKey || e.metaKey) setEnd(clamp(end + fine, start + 0.02, buffer.duration));
else setStart(clamp(start + fine, 0, Math.max(0, end - 0.02)));
} else if (e.key === '+' || e.key === '=') { e.preventDefault(); zoomIn(); }
else if (e.key === '-' || e.key === '_') { e.preventDefault(); zoomOut(); }
else if (e.key === 'Home') { e.preventDefault(); fitAll(); }
else if (e.key === 'End') { e.preventDefault(); fitSelection(); }
else if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
else if (e.key === 'Enter') { if (!tooLong && !tooShort) handleConfirm(); }
};
const keyHandlerRef = useRef(onKeyDown);
keyHandlerRef.current = onKeyDown;
useEffect(() => {
const node = containerRef.current;
if (!node) return;
const handler = (e) => keyHandlerRef.current(e);
node.addEventListener('keydown', handler);
node.focus();
return () => node.removeEventListener('keydown', handler);
}, []);
const handleConfirm = () => {
const buffer = bufferRef.current;
if (!buffer || tooLong || tooShort) return;
const mono = sliceToMono(buffer, start, end);
const wav = encodeWav(mono, buffer.sampleRate);
const base = (file.name || 'trimmed').replace(/\.[^.]+$/, '');
const trimmed = new File([wav], `${base}_trim.wav`, { type: 'audio/wav' });
onConfirm(trimmed);
};
const duration_ms = Math.max(0, (end - start) * 1000);
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.8)', zIndex: 9999,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
}} onClick={onCancel}>
<div
ref={containerRef}
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
style={{
background: '#1d2021', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 12,
padding: 16, width: 'min(920px, 100%)', display: 'flex', flexDirection: 'column', gap: 10,
outline: 'none', boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#ebdbb2', fontWeight: 600, fontSize: '0.9rem' }}>
<Scissors size={15} color="#d3869b" /> Trim reference audio
</div>
<button onClick={onCancel} style={{ background: 'none', border: 'none', color: '#a89984', cursor: 'pointer' }}>
<X size={18} />
</button>
</div>
<div style={{ fontSize: '0.72rem', color: '#a89984', display: 'flex', gap: 14, alignItems: 'center' }}>
<span>{decoding
? 'Decoding audio…'
: (bufferRef.current
? `Length ${fmtHMS(bufferRef.current.duration)} · ${bufferRef.current.sampleRate} Hz${peakProgress > 0 && peakProgress < 1 ? ` · rendering waveform ${Math.round(peakProgress * 100)}%` : ''}`
: '…')
}</span>
<span style={{ marginLeft: 'auto', color: '#6b6657', fontSize: '0.66rem' }}>
scroll = zoom · shift+scroll = pan · alt+drag = pan · keys adjust handles
</span>
</div>
{error && <div style={{ color: '#fb4934', fontSize: '0.8rem' }}>{error}</div>}
{/* Zoom controls */}
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<button onClick={zoomIn} disabled={!ready} title="Zoom in (+)" style={toolBtn}><ZoomIn size={12}/></button>
<button onClick={zoomOut} disabled={!ready} title="Zoom out (-)" style={toolBtn}><ZoomOut size={12}/></button>
<button onClick={fitAll} disabled={!ready} title="Fit all (Home)" style={toolBtn}><Maximize2 size={12}/></button>
<button onClick={fitSelection} disabled={!ready} title="Fit selection (End)" style={toolBtn}>
<span style={{ fontSize: '0.62rem', color: '#d3869b', fontWeight: 600 }}>FIT SEL</span>
</button>
<div style={{ marginLeft: 'auto', fontSize: '0.68rem', color: '#6b6657' }}>
View {fmtHMS(viewStart)} {fmtHMS(viewEnd)} ({fmtSec(viewEnd - viewStart, viewEnd - viewStart < 10 ? 2 : 0)})
</div>
</div>
{/* Ruler */}
<canvas
ref={rulerRef}
style={{ width: '100%', height: 18, background: '#141414', borderRadius: 4, borderBottom: '1px solid rgba(255,255,255,0.05)' }}
/>
{/* Waveform */}
<canvas
ref={waveRef}
onMouseDown={onCanvasDown}
style={{
width: '100%', height: 160, background: '#0f1112', borderRadius: 6,
border: '1px solid rgba(255,255,255,0.05)', cursor: 'crosshair', touchAction: 'none',
}}
/>
{/* Numeric fields */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr auto', gap: 8, alignItems: 'center' }}>
<label style={fieldWrap}>
<span style={fieldLabel}>Start</span>
<input
type="text" inputMode="decimal" value={startInput}
onChange={(e) => setStartInput(e.target.value)}
onBlur={commitStartInput}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitStartInput(); } }}
style={fieldInput}
/>
<span style={fieldUnit}>s</span>
</label>
<label style={fieldWrap}>
<span style={fieldLabel}>End</span>
<input
type="text" inputMode="decimal" value={endInput}
onChange={(e) => setEndInput(e.target.value)}
onBlur={commitEndInput}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitEndInput(); } }}
style={fieldInput}
/>
<span style={fieldUnit}>s</span>
</label>
<div style={{ ...fieldWrap, cursor: 'default' }}>
<span style={fieldLabel}>Length</span>
<span style={{ ...fieldInput, background: 'transparent', border: 'none', color: tooLong ? '#fb4934' : '#b8bb26', padding: '4px 6px' }}>
{(duration_ms / 1000).toFixed(2)}s
</span>
<span style={fieldUnit}>{tooLong ? `>${maxSeconds}s` : tooShort ? 'too short' : 'ok'}</span>
</div>
<button onClick={() => setLoop((v) => !v)} title="Loop preview"
style={{ ...toolBtn, borderColor: loop ? 'rgba(142,192,124,0.5)' : 'rgba(255,255,255,0.12)', color: loop ? '#8ec07c' : '#a89984' }}>
<Repeat size={12}/>
</button>
</div>
{/* Play / Action row */}
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button onClick={togglePlay} disabled={!ready} style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px',
background: 'rgba(142,192,124,0.08)', border: '1px solid rgba(142,192,124,0.3)',
borderRadius: 6, color: '#8ec07c', cursor: ready ? 'pointer' : 'not-allowed', fontSize: '0.78rem',
}}>
{playing ? <Pause size={12} /> : <Play size={12} />} {playing ? 'Pause' : 'Preview selection'}
</button>
<span style={{ fontSize: '0.7rem', color: '#6b6657' }}>Space to play · Enter to confirm · Esc to cancel</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
<button onClick={onCancel} style={{
padding: '6px 12px', background: 'transparent', border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 6, color: '#a89984', cursor: 'pointer', fontSize: '0.78rem',
}}>Cancel</button>
<button onClick={handleConfirm} disabled={!ready || tooLong || tooShort} style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px',
background: (tooLong || tooShort) ? 'rgba(251,73,52,0.08)' : 'rgba(211,134,155,0.18)',
border: `1px solid ${(tooLong || tooShort) ? 'rgba(251,73,52,0.4)' : 'rgba(211,134,155,0.5)'}`,
borderRadius: 6, color: (tooLong || tooShort) ? '#fb4934' : '#d3869b',
cursor: (!ready || tooLong || tooShort) ? 'not-allowed' : 'pointer', fontSize: '0.78rem', fontWeight: 600,
}}>
<Check size={12} /> Use trimmed
</button>
</div>
</div>
<audio ref={audioRef} preload="auto" onEnded={() => setPlaying(false)} style={{ display: 'none' }} />
</div>
</div>
);
}
const toolBtn = {
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
padding: '5px 8px', background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.08)', borderRadius: 5,
color: '#a89984', cursor: 'pointer', fontSize: '0.7rem',
};
const fieldWrap = {
display: 'flex', alignItems: 'center', gap: 4,
background: '#0f1112', border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 5, padding: '2px 6px',
};
const fieldLabel = { fontSize: '0.62rem', color: '#6b6657', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600 };
const fieldInput = {
background: 'transparent', border: 'none', outline: 'none', color: '#ebdbb2',
fontSize: '0.78rem', padding: '4px 6px', width: '100%', minWidth: 60,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
};
const fieldUnit = { fontSize: '0.64rem', color: '#6b6657' };
+192
View File
@@ -0,0 +1,192 @@
export const DEFAULT_PEAK_BUCKETS = 8192;
export function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
export function encodeWav(samples, sampleRate) {
const buf = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buf);
const writeStr = (off, s) => { for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i)); };
writeStr(0, 'RIFF');
view.setUint32(4, 36 + samples.length * 2, true);
writeStr(8, 'WAVE');
writeStr(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
writeStr(36, 'data');
view.setUint32(40, samples.length * 2, true);
for (let i = 0; i < samples.length; i++) {
const s = clamp(samples[i], -1, 1);
view.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true);
}
return buf;
}
export function computePeaksFromChannel(channel, buckets = DEFAULT_PEAK_BUCKETS) {
const n = channel.length;
const eff = Math.max(1, Math.min(buckets, n));
const peaks = new Float32Array(eff * 2);
const step = n / eff;
for (let b = 0; b < eff; b++) {
const s = Math.floor(b * step);
const e = Math.min(n, Math.floor((b + 1) * step));
let mn = 1, mx = -1;
for (let i = s; i < e; i++) {
const v = channel[i];
if (v < mn) mn = v;
if (v > mx) mx = v;
}
if (mx < mn) { mn = 0; mx = 0; }
peaks[b * 2] = mn;
peaks[b * 2 + 1] = mx;
}
return peaks;
}
export function pickTickInterval(viewDur) {
const candidates = [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 30, 60, 120, 300, 600];
const target = viewDur / 8;
for (const c of candidates) if (c >= target) return c;
return 1200;
}
export function xToTime(x, width, viewStart, viewEnd) {
const clampedX = clamp(x, 0, width);
return viewStart + (clampedX / Math.max(1, width)) * (viewEnd - viewStart);
}
export function pickHandle(clientX, canvasLeft, canvasWidth, state, edgeGrabPx = 10) {
const x = clientX - canvasLeft;
const { viewStart, viewEnd, start, end } = state;
const viewDur = viewEnd - viewStart;
if (viewDur <= 0) return null;
const sx = ((start - viewStart) / viewDur) * canvasWidth;
const ex = ((end - viewStart) / viewDur) * canvasWidth;
if (Math.abs(x - sx) < edgeGrabPx) return 'start';
if (Math.abs(x - ex) < edgeGrabPx) return 'end';
if (x > sx && x < ex) return 'region';
return null;
}
export function applyDrag(state, pointerClientX, canvasLeft, canvasWidth, drag, minGap = 0.02) {
const { start, end, viewStart, viewEnd, duration } = state;
const t = xToTime(pointerClientX - canvasLeft, canvasWidth, viewStart, viewEnd);
const out = { start, end, viewStart, viewEnd };
if (drag.mode === 'start') {
out.start = clamp(t, 0, Math.max(0, end - minGap));
} else if (drag.mode === 'end') {
out.end = clamp(t, start + minGap, duration);
} else if (drag.mode === 'region') {
const len = drag.regionLen;
const anchor = clamp(t - drag.offset, 0, Math.max(0, duration - len));
out.start = anchor;
out.end = anchor + len;
} else if (drag.mode === 'new') {
const a = clamp(drag.anchorT, 0, duration);
const p = clamp(t, 0, duration);
if (p >= a) {
out.start = a;
out.end = Math.max(p, a + minGap);
} else {
out.end = a;
out.start = Math.min(p, a - minGap);
}
out.start = Math.max(0, out.start);
out.end = Math.min(duration, out.end);
} else if (drag.mode === 'pan') {
const delta = pointerClientX - drag.startClientX;
const viewDur = drag.viewDur;
const timeDelta = -(delta / Math.max(1, canvasWidth)) * viewDur;
const newVs = clamp(drag.viewStart + timeDelta, 0, Math.max(0, duration - viewDur));
out.viewStart = newVs;
out.viewEnd = newVs + viewDur;
}
return out;
}
export function zoomCenter(viewStart, viewEnd, duration, factor) {
const viewDur = viewEnd - viewStart;
const center = (viewStart + viewEnd) / 2;
const newDur = clamp(viewDur * factor, 0.01, duration);
const newVs = clamp(center - newDur / 2, 0, Math.max(0, duration - newDur));
return { viewStart: newVs, viewEnd: newVs + newDur };
}
export function zoomAtCursor(viewStart, viewEnd, duration, factor, xFrac, minDur = 0.01) {
const viewDur = viewEnd - viewStart;
const anchor = viewStart + xFrac * viewDur;
const newDur = clamp(viewDur * factor, minDur, duration);
let newVs = anchor - xFrac * newDur;
newVs = clamp(newVs, 0, Math.max(0, duration - newDur));
return { viewStart: newVs, viewEnd: newVs + newDur };
}
export async function computePeaksAsync(channel, buckets = DEFAULT_PEAK_BUCKETS, onProgress) {
const n = channel.length;
const eff = Math.max(1, Math.min(buckets, n));
const peaks = new Float32Array(eff * 2);
const step = n / eff;
const YIELD_EVERY = 64; // buckets per chunk
for (let b = 0; b < eff; b++) {
const s = Math.floor(b * step);
const e = Math.min(n, Math.floor((b + 1) * step));
let mn = 1, mx = -1;
for (let i = s; i < e; i++) {
const v = channel[i];
if (v < mn) mn = v;
if (v > mx) mx = v;
}
if (mx < mn) { mn = 0; mx = 0; }
peaks[b * 2] = mn;
peaks[b * 2 + 1] = mx;
if ((b & (YIELD_EVERY - 1)) === 0) {
if (onProgress) onProgress(b / eff);
// Yield to event loop so UI stays responsive.
await new Promise((r) => setTimeout(r, 0));
}
}
if (onProgress) onProgress(1);
return peaks;
}
export async function probeDuration(file) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const a = new Audio();
a.preload = 'metadata';
const cleanup = () => { try { URL.revokeObjectURL(url); } catch {} };
a.addEventListener('loadedmetadata', () => { const d = a.duration; cleanup(); resolve(isFinite(d) ? d : 0); }, { once: true });
a.addEventListener('error', () => { cleanup(); reject(new Error('metadata failed')); }, { once: true });
a.src = url;
});
}
export async function decodeToMonoLowRate(file, targetSR = 22050) {
const duration = await probeDuration(file);
const arr = await file.arrayBuffer();
const len = Math.max(1, Math.ceil(Math.max(0.001, duration) * targetSR));
const Offline = window.OfflineAudioContext || window.webkitOfflineAudioContext;
const offline = new Offline(1, len, targetSR);
const buf = await offline.decodeAudioData(arr);
return buf;
}
export function sliceToMono(buffer, startSec, endSec) {
const sr = buffer.sampleRate;
const s0 = Math.floor(startSec * sr);
const s1 = Math.floor(endSec * sr);
const len = Math.max(0, s1 - s0);
const chCount = buffer.numberOfChannels;
const out = new Float32Array(len);
for (let c = 0; c < chCount; c++) {
const d = buffer.getChannelData(c);
for (let i = 0; i < len; i++) out[i] += d[s0 + i] / chCount;
}
return out;
}
+11 -4
View File
@@ -4,16 +4,23 @@
"private": true, "private": true,
"packageManager": "bun@1.3.11", "packageManager": "bun@1.3.11",
"scripts": { "scripts": {
"dev": "turbo run dev //#dev:api", "dev:api": "uv run uvicorn main:app --app-dir backend --host 0.0.0.0 --port 8000 --reload",
"desktop": "turbo run desktop //#dev:api", "dev:frontend": "bun run --cwd frontend dev",
"dev:desktop": "bun run --cwd frontend desktop",
"wait:api": "wait-on -t 120000 http-get://localhost:8000/model/status",
"wait:frontend": "wait-on -t 120000 http://localhost:5173",
"dev": "concurrently -n api,fe -c green,cyan --kill-others-on-fail \"bun run dev:api\" \"bun run wait:api && bun run dev:frontend\"",
"desktop": "concurrently -n api,app -c green,magenta --kill-others-on-fail \"bun run dev:api\" \"bun run wait:api && bun run dev:desktop\"",
"build": "turbo run build", "build": "turbo run build",
"start": "turbo run start", "start": "turbo run start",
"dev:api": "uv run uvicorn main:app --app-dir backend --host 0.0.0.0 --port 8000 --reload" "test:frontend": "node --test tests/frontend/*.test.mjs"
}, },
"workspaces": [ "workspaces": [
"frontend" "frontend"
], ],
"devDependencies": { "devDependencies": {
"turbo": "^2.9.6" "concurrently": "^9.2.1",
"turbo": "^2.9.6",
"wait-on": "^9.0.5"
} }
} }
+223
View File
@@ -0,0 +1,223 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
clamp, encodeWav, computePeaksFromChannel, pickTickInterval,
xToTime, pickHandle, applyDrag, zoomAtCursor, zoomCenter, sliceToMono,
} from '../../frontend/src/utils/audioTrim.js';
test('clamp', () => {
assert.equal(clamp(5, 0, 10), 5);
assert.equal(clamp(-1, 0, 10), 0);
assert.equal(clamp(11, 0, 10), 10);
});
test('encodeWav writes valid RIFF/WAVE header', () => {
const samples = new Float32Array([0, 0.5, -0.5, 1, -1]);
const buf = encodeWav(samples, 48000);
const view = new DataView(buf);
const tag = (off) => String.fromCharCode(view.getUint8(off), view.getUint8(off+1), view.getUint8(off+2), view.getUint8(off+3));
assert.equal(tag(0), 'RIFF');
assert.equal(tag(8), 'WAVE');
assert.equal(tag(12), 'fmt ');
assert.equal(view.getUint32(16, true), 16, 'fmt chunk size');
assert.equal(view.getUint16(20, true), 1, 'PCM');
assert.equal(view.getUint16(22, true), 1, 'mono');
assert.equal(view.getUint32(24, true), 48000);
assert.equal(view.getUint16(34, true), 16, 'bit depth');
assert.equal(tag(36), 'data');
assert.equal(view.getUint32(40, true), samples.length * 2);
assert.equal(buf.byteLength, 44 + samples.length * 2);
// Peak samples
assert.equal(view.getInt16(44 + 3 * 2, true), 0x7fff, 'peak positive sample');
assert.equal(view.getInt16(44 + 4 * 2, true), -0x8000, 'peak negative sample');
});
test('encodeWav clamps out-of-range samples', () => {
const samples = new Float32Array([2, -2]);
const buf = encodeWav(samples, 44100);
const view = new DataView(buf);
assert.equal(view.getInt16(44, true), 0x7fff);
assert.equal(view.getInt16(46, true), -0x8000);
});
test('computePeaksFromChannel produces min/max per bucket', () => {
const ch = new Float32Array(1000);
for (let i = 0; i < 1000; i++) ch[i] = Math.sin(i * 0.1);
const peaks = computePeaksFromChannel(ch, 10);
assert.equal(peaks.length, 20);
for (let b = 0; b < 10; b++) {
const mn = peaks[b * 2];
const mx = peaks[b * 2 + 1];
assert.ok(mn <= mx, `bucket ${b} min<=max`);
assert.ok(mn >= -1 && mx <= 1);
}
});
test('computePeaksFromChannel with empty bucket range stays 0/0', () => {
const ch = new Float32Array(5); // shorter than buckets
ch.set([0.2, -0.3, 0.9, -0.8, 0.1]);
const peaks = computePeaksFromChannel(ch, 100);
// implementation caps to ch.length buckets = 5
assert.equal(peaks.length, 5 * 2);
});
test('pickTickInterval picks sane steps', () => {
assert.equal(pickTickInterval(1), 0.25); // target ~0.125 → 0.25
assert.equal(pickTickInterval(10), 2); // target 1.25 → 2
assert.equal(pickTickInterval(1700), 300); // target 212.5 → 300
assert.equal(pickTickInterval(0.1), 0.05); // target 0.0125 → 0.05
assert.equal(pickTickInterval(20000), 1200); // fallback
});
test('xToTime maps canvas x to time linearly', () => {
assert.equal(xToTime(0, 100, 10, 20), 10);
assert.equal(xToTime(100, 100, 10, 20), 20);
assert.equal(xToTime(50, 100, 10, 20), 15);
assert.equal(xToTime(-5, 100, 10, 20), 10, 'clamps x<0');
assert.equal(xToTime(999, 100, 10, 20), 20, 'clamps x>width');
});
test('pickHandle classifies near-edge vs inside vs outside', () => {
const state = { viewStart: 0, viewEnd: 100, start: 20, end: 40 };
// canvas 1000px wide, state→ sx=200, ex=400
assert.equal(pickHandle(205, 0, 1000, state, 10), 'start');
assert.equal(pickHandle(395, 0, 1000, state, 10), 'end');
assert.equal(pickHandle(300, 0, 1000, state, 10), 'region');
assert.equal(pickHandle(50, 0, 1000, state, 10), null);
assert.equal(pickHandle(600, 0, 1000, state, 10), null);
});
test('applyDrag start handle respects minGap', () => {
const state = { start: 1, end: 5, viewStart: 0, viewEnd: 10, duration: 10 };
const out = applyDrag(state, 800, 0, 1000, { mode: 'start' }, 0.1);
// pointerX 800 → t = 8
// clamp(8, 0, end-0.1=4.9) = 4.9
assert.equal(out.start.toFixed(2), '4.90');
assert.equal(out.end, 5);
});
test('applyDrag end handle respects minGap and duration', () => {
const state = { start: 1, end: 5, viewStart: 0, viewEnd: 10, duration: 10 };
const out = applyDrag(state, 100, 0, 1000, { mode: 'end' }, 0.1);
// t=1, clamp(1, start+0.1=1.1, 10)=1.1
assert.equal(out.end.toFixed(2), '1.10');
const out2 = applyDrag(state, 2000, 0, 1000, { mode: 'end' }, 0.1);
assert.equal(out2.end, 10);
});
test('applyDrag region preserves length and clamps', () => {
const state = { start: 2, end: 4, viewStart: 0, viewEnd: 10, duration: 10 };
// Region length 2, offset at t=3 (middle), user drags to pointer x=900 → t=9
const drag = { mode: 'region', regionLen: 2, offset: 1 };
const out = applyDrag(state, 900, 0, 1000, drag);
// anchor = 9-1 = 8; 8+2 = 10 ok
assert.equal(out.start.toFixed(2), '8.00');
assert.equal(out.end.toFixed(2), '10.00');
// drag past end — clamps to duration-len
const out2 = applyDrag(state, 1100, 0, 1000, drag);
assert.equal(out2.start, 8);
assert.equal(out2.end, 10);
// drag before 0
const out3 = applyDrag(state, -500, 0, 1000, drag);
assert.equal(out3.start, 0);
assert.equal(out3.end, 2);
});
test('applyDrag pan clamps within duration', () => {
const state = { start: 0, end: 1, viewStart: 10, viewEnd: 20, duration: 100 };
const drag = { mode: 'pan', startClientX: 500, viewStart: 10, viewDur: 10 };
// Pointer moves left by 100 px: delta = -100, timeDelta = +1 (view shifts right)
const out = applyDrag(state, 400, 0, 1000, drag);
assert.equal(out.viewStart, 11);
assert.equal(out.viewEnd, 21);
// Pan to very far right — clamp
const out2 = applyDrag(state, -10000, 0, 1000, drag);
assert.equal(out2.viewStart, 90, 'pan clamped at duration-viewDur');
assert.equal(out2.viewEnd, 100);
});
test('zoomAtCursor keeps anchor time under xFrac', () => {
// view 0..100, cursor at 20% → anchor t=20
const out = zoomAtCursor(0, 100, 1000, 0.5, 0.2);
// newDur=50, newVs = 20 - 0.2*50 = 10
assert.equal(out.viewStart, 10);
assert.equal(out.viewEnd, 60);
});
test('zoomAtCursor clamps to duration', () => {
const out = zoomAtCursor(0, 100, 1000, 0.0001, 0.5);
assert.ok(out.viewEnd - out.viewStart >= 0.01 - 1e-9);
});
test('zoomCenter halves duration', () => {
const out = zoomCenter(0, 100, 1000, 0.5);
assert.equal(out.viewStart, 25);
assert.equal(out.viewEnd, 75);
});
test('sliceToMono mixes channels and slices sample window', () => {
const sr = 100;
const L = new Float32Array(sr * 3); // 3 s
const R = new Float32Array(sr * 3);
for (let i = 0; i < L.length; i++) { L[i] = 0.4; R[i] = 0.6; }
const buffer = {
sampleRate: sr, numberOfChannels: 2,
getChannelData: (c) => (c === 0 ? L : R),
};
const slice = sliceToMono(buffer, 1, 2); // 1 second
assert.equal(slice.length, sr);
// mean per sample = (0.4 + 0.6) / 2 = 0.5
for (let i = 0; i < slice.length; i++) {
assert.ok(Math.abs(slice[i] - 0.5) < 1e-6);
}
});
test('sliceToMono handles mono buffer', () => {
const sr = 48000;
const L = new Float32Array(sr);
L.fill(0.1);
const buffer = { sampleRate: sr, numberOfChannels: 1, getChannelData: () => L };
const slice = sliceToMono(buffer, 0.25, 0.5);
assert.equal(slice.length, Math.floor(0.5 * sr) - Math.floor(0.25 * sr));
assert.ok(Math.abs(slice[0] - 0.1) < 1e-6);
});
test('applyDrag start cannot go below 0', () => {
const state = { start: 0.5, end: 2, viewStart: 0, viewEnd: 10, duration: 10 };
const out = applyDrag(state, -100, 0, 1000, { mode: 'start' }, 0.1);
assert.equal(out.start, 0);
});
test('applyDrag new-selection drags rightward', () => {
const state = { start: 2, end: 4, viewStart: 0, viewEnd: 10, duration: 10 };
const drag = { mode: 'new', anchorT: 3 };
// pointer at x=500 → t=5 → forward drag
const out = applyDrag(state, 500, 0, 1000, drag, 0.1);
assert.equal(out.start, 3);
assert.equal(out.end, 5);
});
test('applyDrag new-selection drags leftward and swaps', () => {
const state = { start: 2, end: 4, viewStart: 0, viewEnd: 10, duration: 10 };
const drag = { mode: 'new', anchorT: 5 };
// pointer at x=200 → t=2 (< anchor) → reversed
const out = applyDrag(state, 200, 0, 1000, drag, 0.1);
assert.equal(out.start, 2);
assert.equal(out.end, 5);
});
test('applyDrag new-selection collapsed respects minGap', () => {
const state = { start: 0, end: 0, viewStart: 0, viewEnd: 10, duration: 10 };
const drag = { mode: 'new', anchorT: 3 };
// same spot as anchor — pointer at x=300 → t=3
const out = applyDrag(state, 300, 0, 1000, drag, 0.1);
assert.ok(out.end - out.start >= 0.1 - 1e-9, 'minGap enforced');
});
test('applyDrag region length 0 (edge case)', () => {
const state = { start: 5, end: 5, viewStart: 0, viewEnd: 10, duration: 10 };
const drag = { mode: 'region', regionLen: 0, offset: 0 };
const out = applyDrag(state, 500, 0, 1000, drag);
assert.equal(out.start, 5);
assert.equal(out.end, 5);
});
+228
View File
@@ -0,0 +1,228 @@
"""Unit tests for dub_translate — no network, pure helpers + monkeypatched translator."""
import asyncio
import pytest
def test_translate_codes_cover_popular_iso():
from api.routers.dub_translate import TRANSLATE_CODES
popular = ['en', 'es', 'fr', 'de', 'it', 'pt', 'ru', 'zh', 'ja', 'ko', 'ar', 'hi']
for code in popular:
assert code in TRANSLATE_CODES, f"{code} missing from TRANSLATE_CODES"
def test_flores_codes_cover_core_languages():
from api.routers.dub_translate import FLORES_CODES
for code in ('en', 'de', 'es', 'fr', 'hi', 'ja'):
assert code in FLORES_CODES
def test_resolve_source_lang_priority(monkeypatch):
from api.routers import dub_translate
class Req:
def __init__(self, src=None, jid=None):
self.source_lang = src
self.job_id = jid
# Explicit source_lang wins
assert dub_translate._resolve_source_lang(Req(src='fr')) == 'fr'
# Fall through to job-detected source_lang
monkeypatch.setattr(
dub_translate, '_get_job',
lambda jid: {'source_lang': 'de'} if jid == 'j1' else None,
)
assert dub_translate._resolve_source_lang(Req(jid='j1')) == 'de'
# No job, no explicit → default en
assert dub_translate._resolve_source_lang(Req()) == 'en'
assert dub_translate._resolve_source_lang(Req(jid='missing')) == 'en'
class _FakeSeg:
def __init__(self, sid, text, target_lang=None):
self.id = sid
self.text = text
self.target_lang = target_lang
class _FakeReq:
def __init__(self, segments, target_lang, provider='google', source_lang=None):
self.segments = segments
self.target_lang = target_lang
self.provider = provider
self.source_lang = source_lang
self.job_id = None
@pytest.mark.asyncio
async def test_google_path_passes_correct_target_code(monkeypatch):
"""GoogleTranslator constructed with the expected src/tgt codes for German."""
from api.routers import dub_translate
calls = []
class FakeTranslator:
def __init__(self, source=None, target=None, **kwargs):
calls.append({'source': source, 'target': target})
def translate(self, text):
return f"[{calls[-1]['target']}]{text}"
class FakeModule:
GoogleTranslator = FakeTranslator
DeepL = FakeTranslator
MyMemoryTranslator = FakeTranslator
MicrosoftTranslator = FakeTranslator
monkeypatch.setitem(__import__('sys').modules, 'deep_translator', FakeModule)
req = _FakeReq(
segments=[_FakeSeg('s1', 'Hello'), _FakeSeg('s2', 'World')],
target_lang='de',
provider='google',
source_lang='en',
)
resp = await dub_translate.dub_translate(req)
assert resp['target_lang'] == 'de'
assert resp['source_lang'] == 'en'
texts = {t['id']: t['text'] for t in resp['translated']}
assert texts['s1'] == '[de]Hello'
assert texts['s2'] == '[de]World'
# Each segment built a translator with de as target
assert all(c['target'] == 'de' for c in calls)
# Source came through as "en"
assert any(c['source'] == 'en' for c in calls)
@pytest.mark.asyncio
async def test_google_path_uses_seg_target_lang_override(monkeypatch):
from api.routers import dub_translate
class FakeTranslator:
def __init__(self, source=None, target=None, **kwargs):
self.target = target
def translate(self, text):
return f"[{self.target}]{text}"
class FakeModule:
GoogleTranslator = FakeTranslator
monkeypatch.setitem(__import__('sys').modules, 'deep_translator', FakeModule)
req = _FakeReq(
segments=[
_FakeSeg('s1', 'Hi', target_lang='bn'), # per-segment override
_FakeSeg('s2', 'Ok'),
],
target_lang='de',
provider='google',
source_lang='en',
)
resp = await dub_translate.dub_translate(req)
texts = {t['id']: t['text'] for t in resp['translated']}
assert texts['s1'] == '[bn]Hi'
assert texts['s2'] == '[de]Ok'
@pytest.mark.asyncio
async def test_google_retries_then_falls_back_to_auto(monkeypatch):
"""Transient failure → retry → still fails → fall back to auto source."""
from api.routers import dub_translate
attempts = []
class FakeTranslator:
def __init__(self, source=None, target=None, **kwargs):
self.source = source
self.target = target
def translate(self, text):
attempts.append(self.source)
if self.source != 'auto':
raise RuntimeError('transient google error')
return f"[auto:{self.target}]{text}"
class FakeModule:
GoogleTranslator = FakeTranslator
monkeypatch.setitem(__import__('sys').modules, 'deep_translator', FakeModule)
req = _FakeReq(
segments=[_FakeSeg('s1', 'Hello')],
target_lang='de', provider='google', source_lang='en',
)
resp = await dub_translate.dub_translate(req)
assert resp['translated'][0]['text'] == '[auto:de]Hello'
assert 'error' not in resp['translated'][0]
# explicit src tried at least once before auto
assert attempts[0] == 'en'
assert attempts[-1] == 'auto'
@pytest.mark.asyncio
async def test_google_reports_error_when_all_attempts_fail(monkeypatch):
from api.routers import dub_translate
class FakeTranslator:
def __init__(self, **kwargs): pass
def translate(self, text): raise RuntimeError('total failure')
class FakeModule:
GoogleTranslator = FakeTranslator
monkeypatch.setitem(__import__('sys').modules, 'deep_translator', FakeModule)
req = _FakeReq(
segments=[_FakeSeg('s1', 'Hello')],
target_lang='de', provider='google', source_lang='en',
)
resp = await dub_translate.dub_translate(req)
seg = resp['translated'][0]
assert seg['text'] == 'Hello', 'original text preserved on failure'
assert 'error' in seg
assert 'total failure' in seg['error']
@pytest.mark.asyncio
async def test_empty_text_skipped(monkeypatch):
from api.routers import dub_translate
class FakeTranslator:
def __init__(self, **kwargs): pass
def translate(self, text): return '[xx]' + text
class FakeModule:
GoogleTranslator = FakeTranslator
monkeypatch.setitem(__import__('sys').modules, 'deep_translator', FakeModule)
req = _FakeReq(
segments=[_FakeSeg('s1', ' '), _FakeSeg('s2', 'hi')],
target_lang='de', provider='google', source_lang='en',
)
resp = await dub_translate.dub_translate(req)
texts = {t['id']: t['text'] for t in resp['translated']}
assert texts['s1'].strip() == '' # untouched
assert texts['s2'] == '[xx]hi'
@pytest.mark.asyncio
async def test_empty_translation_preserves_original(monkeypatch):
from api.routers import dub_translate
class FakeTranslator:
def __init__(self, **kwargs): pass
def translate(self, text): return '' # always empty
class FakeModule:
GoogleTranslator = FakeTranslator
monkeypatch.setitem(__import__('sys').modules, 'deep_translator', FakeModule)
req = _FakeReq(
segments=[_FakeSeg('s1', 'hi')],
target_lang='de', provider='google', source_lang='en',
)
resp = await dub_translate.dub_translate(req)
seg = resp['translated'][0]
assert seg['text'] == 'hi'
assert 'error' in seg
+191
View File
@@ -0,0 +1,191 @@
"""Tests for core.job_queue — serial execution, cancel, position, failure isolation."""
import asyncio
import pytest
import pytest_asyncio
from core.job_queue import Job, JobQueue, JobState
@pytest_asyncio.fixture
async def queue():
q = JobQueue(name="test", concurrency=1)
await q.start()
try:
yield q
finally:
await q.stop()
@pytest.mark.asyncio
async def test_single_job_runs_and_completes(queue):
async def work(job: Job):
return 42
j = await queue.submit("one", work)
done = await queue.wait(j.id, timeout=2.0)
assert done.state == JobState.DONE
assert done.result == 42
@pytest.mark.asyncio
async def test_jobs_run_serially_not_in_parallel(queue):
"""Two submits must not overlap — concurrency=1."""
events = []
start_a = asyncio.Event()
release_a = asyncio.Event()
async def slow_a(job):
events.append("a-start")
start_a.set()
await release_a.wait()
events.append("a-end")
async def quick_b(job):
events.append("b-start")
events.append("b-end")
ja = await queue.submit("a", slow_a)
jb = await queue.submit("b", quick_b)
await start_a.wait()
# While A runs, B must still be queued.
assert queue.get(jb.id).state == JobState.QUEUED
release_a.set()
await queue.wait(jb.id, timeout=2.0)
assert events == ["a-start", "a-end", "b-start", "b-end"]
@pytest.mark.asyncio
async def test_cancel_while_queued_skips_execution(queue):
ran = asyncio.Event()
block = asyncio.Event()
async def blocker(job):
await block.wait()
async def never(job):
ran.set()
ja = await queue.submit("blocker", blocker)
jb = await queue.submit("skipped", never)
cancelled = await queue.cancel(jb.id)
assert cancelled is True
assert queue.get(jb.id).state == JobState.CANCELLED
block.set()
await queue.wait(ja.id, timeout=2.0)
# Give worker a tick to process the cancelled entry.
await asyncio.sleep(0.05)
assert not ran.is_set()
@pytest.mark.asyncio
async def test_cancel_while_running_sets_flag_for_cooperative_exit(queue):
async def cooperative(job):
for _ in range(20):
if job.is_cancelled:
return "stopped-early"
await asyncio.sleep(0.02)
return "finished-normally"
j = await queue.submit("coop", cooperative)
await asyncio.sleep(0.05) # let it start
await queue.cancel(j.id)
done = await queue.wait(j.id, timeout=2.0)
# Worker marks cancelled when is_cancelled was true at return.
assert done.state == JobState.CANCELLED
assert done.result == "stopped-early" or done.result is None
@pytest.mark.asyncio
async def test_failure_isolated_next_job_still_runs(queue):
async def boom(job):
raise RuntimeError("kaboom")
async def ok(job):
return "ok"
ja = await queue.submit("fail", boom)
jb = await queue.submit("ok", ok)
await queue.wait(ja.id, timeout=2.0)
await queue.wait(jb.id, timeout=2.0)
assert queue.get(ja.id).state == JobState.FAILED
assert queue.get(ja.id).error == "kaboom"
assert queue.get(jb.id).state == JobState.DONE
@pytest.mark.asyncio
async def test_position_reports_zero_for_next_up(queue):
block = asyncio.Event()
async def wait_a(job):
await block.wait()
async def wait_b(job):
await block.wait()
ja = await queue.submit("a", wait_a)
jb = await queue.submit("b", wait_b)
jc = await queue.submit("c", wait_b)
await asyncio.sleep(0.02) # let worker pick a
assert queue.get(ja.id).state == JobState.RUNNING
# positions among QUEUED jobs
assert queue.position(jb.id) == 0
assert queue.position(jc.id) == 1
assert queue.position(ja.id) == -1
block.set()
await queue.wait(jc.id, timeout=2.0)
@pytest.mark.asyncio
async def test_submit_idempotent_on_job_id(queue):
async def work(job):
return 1
j1 = await queue.submit("x", work, job_id="fixed")
j2 = await queue.submit("x", work, job_id="fixed")
assert j1 is j2
await queue.wait("fixed", timeout=2.0)
@pytest.mark.asyncio
async def test_cancel_unknown_returns_false(queue):
assert await queue.cancel("does-not-exist") is False
@pytest.mark.asyncio
async def test_purge_finished_drops_old_rows(queue):
async def work(job):
return 1
j = await queue.submit("x", work)
await queue.wait(j.id, timeout=2.0)
# Force finished_at into the past.
queue.get(j.id).finished_at = 0.0
dropped = queue.purge_finished(older_than_seconds=1.0)
assert dropped == 1
assert queue.get(j.id) is None
@pytest.mark.asyncio
async def test_list_jobs_live_only_by_default(queue):
finished_evt = asyncio.Event()
block = asyncio.Event()
async def finish_fast(job):
finished_evt.set()
async def wait_forever(job):
await block.wait()
j1 = await queue.submit("done", finish_fast)
await finished_evt.wait()
await queue.wait(j1.id, timeout=1.0)
j2 = await queue.submit("live", wait_forever)
await asyncio.sleep(0.02)
live = queue.list_jobs()
all_jobs = queue.list_jobs(include_finished=True)
assert j2.id in [j.id for j in live]
assert j1.id not in [j.id for j in live]
assert j1.id in [j.id for j in all_jobs]
block.set()
await queue.wait(j2.id, timeout=1.0)