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 services.model_manager import get_model, _gpu_pool, _cpu_pool, get_best_device, get_diarization_pipeline
from services.audio_dsp import apply_mastering, normalize_audio
from services.ffmpeg_utils import find_ffmpeg
from services.ffmpeg_utils import find_ffmpeg, _get_semaphore, _spawn_with_retry
from services.segmentation import (
segment_transcript,
assign_speakers_from_diarization,
@@ -254,23 +254,40 @@ async def dub_upload(video: UploadFile = File(...), job_id: Optional[str] = Form
ffmpeg = find_ffmpeg()
async def _run_proc(cmd, timeout: float = 900.0):
p = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
)
_register_proc(job_id, p)
try:
# Gate through the shared ffmpeg semaphore + EAGAIN retry so concurrent
# dub uploads can't pile up posix_spawn failures under macOS fd limits.
async with _get_semaphore():
p = await _spawn_with_retry(
cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_register_proc(job_id, p)
try:
stdout, stderr = await asyncio.wait_for(p.communicate(), timeout=timeout)
except asyncio.TimeoutError:
try:
p.kill()
except ProcessLookupError:
pass
raise HTTPException(status_code=504, detail=f"subprocess timed out after {timeout}s")
return p, stdout, stderr
finally:
_unregister_proc(job_id, p)
stdout, stderr = await asyncio.wait_for(p.communicate(), timeout=timeout)
except asyncio.TimeoutError:
try:
p.kill()
except ProcessLookupError:
pass
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:
@@ -358,6 +375,165 @@ async def dub_upload(video: UploadFile = File(...), job_id: Optional[str] = Form
_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}")
async def dub_transcribe(job_id: str):
job = _get_job(job_id)
+7 -6
View File
@@ -194,12 +194,13 @@ async def dub_download(
cmd += ["-shortest", output_path, "-y"]
try:
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise Exception(stderr.decode())
rc, _, stderr = await run_ffmpeg(cmd, timeout=1800.0)
if rc != 0:
raise Exception(stderr.decode(errors="replace") if stderr else "ffmpeg mux non-zero")
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="ffmpeg mux timed out")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"ffmpeg mux failed: {str(e)}")
+37 -21
View File
@@ -1,4 +1,5 @@
import os
import time
import asyncio
import logging
from fastapi import APIRouter
@@ -220,28 +221,43 @@ async def dub_translate(req: TranslateRequest):
# Legacy / API Deep_Translator logic
src_arg = TRANSLATE_CODES.get(src_lang, src_lang) or "auto"
def _translate_single(seg):
try:
if not seg.text or not seg.text.strip():
return {"id": seg.id, "text": seg.text}
seg_lc = TRANSLATE_CODES.get(seg.target_lang, seg.target_lang) if seg.target_lang else lang_code
if provider == "deepl":
from deep_translator import DeepL
translator = DeepL(api_key=api_key, source=src_arg, target=seg_lc)
elif provider == "mymemory":
from deep_translator import MyMemoryTranslator
translator = MyMemoryTranslator(source=src_arg, target=seg_lc)
elif provider == "microsoft":
from deep_translator import MicrosoftTranslator
translator = MicrosoftTranslator(api_key=api_key, source=src_arg, target=seg_lc)
else:
from deep_translator import GoogleTranslator
translator = GoogleTranslator(source=src_arg, target=seg_lc)
def _build_translator(src, tgt):
if provider == "deepl":
from deep_translator import DeepL
return DeepL(api_key=api_key, source=src, target=tgt)
if provider == "mymemory":
from deep_translator import MyMemoryTranslator
return MyMemoryTranslator(source=src, target=tgt)
if provider == "microsoft":
from deep_translator import MicrosoftTranslator
return MicrosoftTranslator(api_key=api_key, source=src, target=tgt)
from deep_translator import GoogleTranslator
return GoogleTranslator(source=src, target=tgt)
translated = translator.translate(seg.text)
return {"id": seg.id, "text": translated or seg.text}
except Exception as e:
return {"id": seg.id, "text": seg.text, "error": str(e)}
def _translate_single(seg):
seg_lc = (
TRANSLATE_CODES.get(seg.target_lang, seg.target_lang)
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]
translated = await asyncio.gather(*tasks)
+10 -1
View File
@@ -5,6 +5,8 @@ import time
import asyncio
import tempfile
import contextlib
import logging
import traceback
import torch
import torchaudio
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
router = APIRouter()
logger = logging.getLogger("omnivoice.generate")
def _run_inference(
model, text, language, ref_audio_path, ref_text, instruct, duration,
@@ -81,6 +84,7 @@ async def generate_speech(
ref_audio_path = None
cleanup_ref = False
used_seed = seed
resolved_profile_id = None
if profile_id:
conn = get_db()
@@ -89,6 +93,7 @@ async def generate_speech(
finally:
conn.close()
if row:
resolved_profile_id = profile_id
if row["is_locked"] and row["locked_audio_path"]:
ref_audio_path = os.path.join(VOICES_DIR, row["locked_audio_path"])
if not ref_text:
@@ -144,7 +149,7 @@ async def generate_speech(
conn.execute(
"INSERT INTO generation_history (id, text, mode, language, instruct, profile_id, audio_path, duration_seconds, generation_time, seed, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(audio_id, text[:200], "clone" if ref_audio_path else "design",
language or "Auto", instruct or "", profile_id or "",
language or "Auto", instruct or "", resolved_profile_id,
audio_filename, audio_dur, gen_time, used_seed, time.time())
)
@@ -170,7 +175,11 @@ async def generate_speech(
"Content-Length": str(len(wav_bytes)),
}
)
except HTTPException:
raise
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)}")
finally:
if cleanup_ref and ref_audio_path:
+19 -35
View File
@@ -11,7 +11,7 @@ import shutil
from core.config import OUTPUTS_DIR
from services.model_manager import get_model_status, get_best_device
from services.ffmpeg_utils import find_ffmpeg
from services.ffmpeg_utils import find_ffmpeg, run_ffmpeg
router = APIRouter()
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")
ffmpeg = find_ffmpeg()
proc = await asyncio.create_subprocess_exec(
ffmpeg, "-y", "-i", raw_path, "-ar", "24000", "-ac", "1", converted_path,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
)
try:
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=120.0)
rc, _, _ = await run_ffmpeg(
[ffmpeg, "-y", "-i", raw_path, "-ar", "24000", "-ac", "1", converted_path],
timeout=120.0,
)
except asyncio.TimeoutError:
try:
proc.kill()
except ProcessLookupError:
pass
rc = -1
if rc != 0:
converted_path = raw_path
else:
if proc.returncode != 0:
converted_path = raw_path
clean_path = converted_path
try:
proc = await asyncio.create_subprocess_exec(
sys.executable, "-m", "demucs.separate", "--two-stems", "vocals", "-n", "htdemucs",
"-d", get_best_device(), converted_path, "-o", tmp_dir,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
rc, _, _ = await run_ffmpeg(
[sys.executable, "-m", "demucs.separate", "--two-stems", "vocals", "-n", "htdemucs",
"-d", get_best_device(), converted_path, "-o", tmp_dir],
timeout=900.0,
)
try:
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=900.0)
except asyncio.TimeoutError:
try:
proc.kill()
except ProcessLookupError:
pass
raise Exception("demucs timed out")
if proc.returncode == 0:
if rc == 0:
demucs_out = os.path.join(tmp_dir, "htdemucs", "converted")
vocals_file = os.path.join(demucs_out, "vocals.wav")
if os.path.exists(vocals_file):
clean_path = vocals_file
except asyncio.TimeoutError:
logger.warning("Demucs timed out for mic audio, using raw")
except Exception as e:
logger.warning(f"Demucs failed for mic audio, using raw: {e}")
clean_filename = f"mic_{clean_id}.wav"
final_path = os.path.join(OUTPUTS_DIR, clean_filename)
proc = await asyncio.create_subprocess_exec(
ffmpeg, "-y", "-i", clean_path, "-ar", "24000", "-ac", "1", final_path,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
)
try:
await asyncio.wait_for(proc.communicate(), timeout=120.0)
await run_ffmpeg(
[ffmpeg, "-y", "-i", clean_path, "-ar", "24000", "-ac", "1", final_path],
timeout=120.0,
)
except asyncio.TimeoutError:
try:
proc.kill()
except ProcessLookupError:
pass
pass
if not os.path.exists(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",
"devDependencies": {
"concurrently": "^9.2.1",
"turbo": "^2.9.6",
"wait-on": "^9.0.5",
},
},
"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=="],
"@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/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=="],
"@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/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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"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=="],
"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=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
@@ -270,16 +322,38 @@
"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=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"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=="],
"globals": ["globals@17.4.0", "", {}, "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw=="],
"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-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-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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"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=="],
@@ -370,6 +458,8 @@
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
}
}
+156 -20
View File
@@ -2,6 +2,20 @@ import React, { useState, useRef, useEffect, useCallback } from 'react';
import './index.css';
import WaveformTimeline from './components/WaveformTimeline';
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_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 [text, setText] = useState('');
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 [instruct, setInstruct] = useState('');
const [language, setLanguage] = useState('Auto');
@@ -478,7 +506,7 @@ function App() {
if (saved.dubJobId) setDubJobId(saved.dubJobId);
if (saved.dubFilename) setDubFilename(saved.dubFilename);
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.dubLangCode) setDubLangCode(saved.dubLangCode);
if (saved.dubTracks) setDubTracks(saved.dubTracks);
@@ -866,14 +894,12 @@ function App() {
const cleanFilename = res.headers.get("X-Clean-Filename") || "recording_clean.wav";
const cleanFile = new File([cleanBlob], cleanFilename, { type: "audio/wav" });
setRefAudio(cleanFile);
setSelectedProfile(null);
await ingestRefAudio(cleanFile);
toast.success("🎙️ Recording cleaned & loaded!");
} catch (e) {
// Fallback: use raw recording without denoising
const rawFile = new File([blob], "recording.webm", { type: "audio/webm" });
setRefAudio(rawFile);
setSelectedProfile(null);
await ingestRefAudio(rawFile);
toast.success("Recording loaded (raw — denoising unavailable)");
} finally {
setIsCleaning(false);
@@ -924,11 +950,61 @@ function App() {
setDubJobId(data.job_id); setDubFilename(data.filename); setDubDuration(data.duration);
setDubStep('transcribing');
setTranscribeStart(Date.now());
const tRes = await fetch(`${API}/dub/transcribe/${data.job_id}`, { method: "POST", signal: ctrl.signal });
if (!tRes.ok) throw new Error(await tRes.text());
const tData = await tRes.json();
setDubSegments(tData.segments.map((s, i) => ({ ...s, id: i })));
setDubTranscript(tData.full_transcript || '');
setDubSegments([]);
await new Promise((resolve, reject) => {
const evt = new EventSource(`${API}/dub/transcribe-stream/${data.job_id}`);
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);
setDubStep('editing');
} catch (err) {
@@ -983,7 +1059,13 @@ function App() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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,
provider: translateProvider,
}),
@@ -991,8 +1073,27 @@ function App() {
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
const translatedMap = {};
data.translated.forEach(t => { translatedMap[t.id] = t.text; });
setDubSegments(dubSegments.map(s => ({ ...s, text: translatedMap[s.id] || s.text })));
const errors = [];
(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); }
setIsTranslating(false);
};
@@ -1277,7 +1378,7 @@ function App() {
setDubJobId(s.dubJobId || null);
setDubFilename(s.dubFilename || data.video_path || '');
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');
setDubLangCode(s.dubLangCode || 'en');
setDubInstruct(s.dubInstruct || '');
@@ -1313,7 +1414,7 @@ function App() {
setDubJobId(item.id);
setDubFilename(job.filename || '');
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 || '');
setDubLang(item.language || 'Auto');
setDubLangCode(item.language_code || 'und');
@@ -1355,6 +1456,14 @@ function App() {
return (
<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={{
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' } },
@@ -1954,6 +2063,13 @@ function App() {
{isTranslating ? <Loader className="spinner" size={9}/> : <Languages size={10}/>}
{isTranslating ? 'Translating…' : 'Translate All'}
</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}
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'}}>
@@ -2046,9 +2162,28 @@ function App() {
)}
</span>
<span style={{width:50, fontSize:'0.58rem', color:'#a89984'}}>{seg.speaker_id || ''}</span>
<input className="input-base segment-input" value={seg.text}
onChange={e => editSegments(dubSegments.map(s => s.id===seg.id?{...s,text:e.target.value}:s))}
disabled={dubStep==='generating'||dubStep==='stopping'}/>
<span style={{flex:1, display:'flex', flexDirection:'column', gap:2, minWidth:0}}>
<input className="input-base segment-input" value={seg.text}
onChange={e => editSegments(dubSegments.map(s => s.id===seg.id?{...s,text:e.target.value}:s))}
disabled={dubStep==='generating'||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'}}
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))}>
@@ -2257,7 +2392,7 @@ function App() {
{!selectedProfile && (
<>
<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}}
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=''; }}
@@ -2265,7 +2400,8 @@ function App() {
e.preventDefault();
e.currentTarget.style.borderColor=''; e.currentTarget.style.background='';
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}/>
<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,
"packageManager": "bun@1.3.11",
"scripts": {
"dev": "turbo run dev //#dev:api",
"desktop": "turbo run desktop //#dev:api",
"dev:api": "uv run uvicorn main:app --app-dir backend --host 0.0.0.0 --port 8000 --reload",
"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",
"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": [
"frontend"
],
"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)