diff --git a/Dockerfile b/Dockerfile index c2787eff..2ca2a473 100644 --- a/Dockerfile +++ b/Dockerfile @@ -51,10 +51,10 @@ COPY omnivoice/ ./omnivoice/ COPY --from=frontend-builder /app/frontend/dist ./frontend/dist # Expose the single unified API and UI port -EXPOSE 8000 +EXPOSE 3900 # Mount points for persistent data (sqlite db, user voices, huggingface cache) VOLUME ["/app/omnivoice_data"] # Bind to 0.0.0.0 for external access -ENTRYPOINT ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] +ENTRYPOINT ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "3900"] diff --git a/backend/api/routers/dub_translate.py b/backend/api/routers/dub_translate.py index 8e2e4e18..3899ce9b 100644 --- a/backend/api/routers/dub_translate.py +++ b/backend/api/routers/dub_translate.py @@ -23,12 +23,70 @@ TRANSLATE_CODES = { FLORES_CODES = { "en": "eng_Latn", "es": "spa_Latn", "fr": "fra_Latn", "de": "deu_Latn", "it": "ita_Latn", "pt": "por_Latn", "ru": "rus_Cyrl", "ja": "jpn_Jpan", - "ko": "kor_Hang", "zh": "zho_Hans", "zh-CN": "zho_Hans", "ar": "arb_Arab", + "ko": "kor_Hang", "zh": "zho_Hans", "zh-CN": "zho_Hans", "ar": "arb_Arab", "hi": "hin_Deva", "tr": "tur_Latn", "pl": "pol_Latn", "nl": "nld_Latn", "sv": "swe_Latn", "th": "tha_Thai", "vi": "vie_Latn", "id": "ind_Latn", "uk": "ukr_Cyrl", } +# Human-readable language names for LLM prompts. Empirically a tiny / 7B +# local LLM produces Devanagari Hindi reliably when told "translate into +# Hindi" but drifts to German / English / phonetic-Latin when told +# "translate into hi". The two-letter ISO codes "hi" / "de" / "fr" can +# overlap with everyday tokens ("hi" = greeting), which throws off small +# instruction-tuned models. Pass the full name in the prompt so the model +# can't misread it. +LANG_NAMES = { + "en": "English", "es": "Spanish", "fr": "French", "de": "German", + "it": "Italian", "pt": "Portuguese", "ru": "Russian", "ja": "Japanese", + "ko": "Korean", "zh": "Chinese (Simplified)", "zh-CN": "Chinese (Simplified)", + "ar": "Arabic", "hi": "Hindi", "tr": "Turkish", "pl": "Polish", + "nl": "Dutch", "sv": "Swedish", "th": "Thai", "vi": "Vietnamese", + "id": "Indonesian", "uk": "Ukrainian", +} + +# Per-language script enforcement. Maps language code → required Unicode +# block(s) the translation must contain. Used as a sanity gate after the +# LLM responds: if the output contains <50% characters from the expected +# block, we treat the translation as corrupted and retry. The block names +# here are the keys recognised by Python's `unicodedata.name()` lookup or +# regex Unicode property classes. +LANG_REQUIRED_SCRIPT = { + "hi": ("DEVANAGARI", (0x0900, 0x097F)), + "ar": ("ARABIC", (0x0600, 0x06FF)), + "zh": ("CJK", (0x4E00, 0x9FFF)), + "zh-CN": ("CJK", (0x4E00, 0x9FFF)), + "ja": ("JAPANESE", (0x3040, 0x30FF)), + "ko": ("HANGUL", (0xAC00, 0xD7AF)), + "th": ("THAI", (0x0E00, 0x0E7F)), + "ru": ("CYRILLIC", (0x0400, 0x04FF)), + "uk": ("CYRILLIC", (0x0400, 0x04FF)), +} + + +def _script_ratio(text: str, code: str) -> float: + """Fraction of letters in `text` that fall inside the script block we + expect for `code`. Punctuation/digits/whitespace are excluded from the + denominator so a Hindi sentence ending in "." still scores 1.0.""" + info = LANG_REQUIRED_SCRIPT.get(code) + if not info: + return 1.0 + _, (lo, hi) = info + letters = [c for c in text if c.isalpha()] + if not letters: + return 1.0 + inside = sum(1 for c in letters if lo <= ord(c) <= hi) + return inside / len(letters) + + +def _looks_like_target(text: str, code: str, threshold: float = 0.5) -> bool: + """Sanity gate for non-Latin targets. True if `text` is *plausibly* in + the target language by script. Only meaningful for languages with a + distinctive script (Indic, CJK, Arabic, etc.); Latin-script targets + always return True since we can't distinguish English from German by + codepoints alone.""" + return _script_ratio(text, code) >= threshold + _nllb_model = None _nllb_tokenizer = None _nllb_device = None @@ -154,22 +212,89 @@ async def dub_translate(req: TranslateRequest): from openai import OpenAI client = OpenAI(base_url=base_url, api_key=api_key or "local") - def _translate_llm(seg): - try: - if not seg.text or not seg.text.strip(): - return {"id": seg.id, "text": seg.text} - tgt = seg.target_lang if seg.target_lang else req.target_lang - res = client.chat.completions.create( - model=model_name, - messages=[ - {"role": "system", "content": f"You are a professional dubbing translator. Translate the user's text from {src_lang} into {tgt}. Reply ONLY with the translated text, do not add any quotes, notes, or explanations."}, - {"role": "user", "content": seg.text} - ] + def _build_prompt(src_code: str, tgt_code: str) -> str: + """Build a system prompt that resists hallucinations on small + local LLMs. Three things matter: + + 1. Use full language names (Hindi, German) not ISO codes — + tiny models read 'hi' as a greeting and drift. + 2. For non-Latin targets, name the required script explicitly + so the model can't fall back to phonetic Latin or another + target it knows better (Hindi → German is a common drift + we've actually observed). + 3. End with a strict format guard so the model can't prepend + 'Translation:' or quote the output. + """ + src_name = LANG_NAMES.get(src_code, src_code) + tgt_name = LANG_NAMES.get(tgt_code, tgt_code) + script_clause = "" + info = LANG_REQUIRED_SCRIPT.get(tgt_code) + if info: + script_name, _ = info + script_clause = ( + f" The output MUST be written in {script_name} script " + f"only — do not use Latin/Roman letters, do not " + f"transliterate, do not output any other language." ) - out_text = res.choices[0].message.content.strip() - return {"id": seg.id, "text": out_text} - except Exception as e: - return {"id": seg.id, "text": seg.text, "error": str(e)} + return ( + f"You are a professional dubbing translator. " + f"Translate the user's text from {src_name} into " + f"{tgt_name}.{script_clause} " + f"Reply ONLY with the translated {tgt_name} text, do not " + f"add quotes, notes, headers, explanations, or commentary." + ) + + def _translate_llm(seg): + if not seg.text or not seg.text.strip(): + return {"id": seg.id, "text": seg.text} + tgt_code = seg.target_lang if seg.target_lang else req.target_lang + system_msg = _build_prompt(src_lang, tgt_code) + last_err = None + # Up to 2 attempts: if the first response fails the + # script-ratio gate (e.g. Hindi target but mostly Latin + # output), retry once with a more emphatic instruction. + for attempt in range(2): + sys_for_attempt = system_msg + if attempt == 1: + sys_for_attempt = ( + system_msg + + " Your previous attempt produced output in the " + "wrong language or script. Output ONLY the " + f"{LANG_NAMES.get(tgt_code, tgt_code)} translation." + ) + try: + res = client.chat.completions.create( + model=model_name, + temperature=0.2, # less drift than default 1.0 + messages=[ + {"role": "system", "content": sys_for_attempt}, + {"role": "user", "content": seg.text}, + ], + ) + out_text = (res.choices[0].message.content or "").strip() + if not out_text: + last_err = "empty LLM response" + continue + if not _looks_like_target(out_text, tgt_code): + last_err = ( + f"LLM output script_ratio={_script_ratio(out_text, tgt_code):.2f} " + f"below threshold for {tgt_code}" + ) + logger.warning( + "translate %s: attempt %d wrong script (%s); retrying", + seg.id, attempt + 1, last_err, + ) + continue + return {"id": seg.id, "text": out_text} + except Exception as e: + last_err = f"{type(e).__name__}: {e}" + logger.warning( + "translate %s: LLM attempt %d failed: %s", + seg.id, attempt + 1, e, + ) + # Both attempts failed — keep source text + flag error so the + # frontend can surface "fallback to literal" warning. + return {"id": seg.id, "text": seg.text, "error": last_err or "llm-failed"} tasks = [loop.run_in_executor(_cpu_pool, _translate_llm, seg) for seg in req.segments] translated = await asyncio.gather(*tasks) diff --git a/backend/api/routers/setup.py b/backend/api/routers/setup.py index 654b74db..8d46960d 100644 --- a/backend/api/routers/setup.py +++ b/backend/api/routers/setup.py @@ -367,8 +367,50 @@ async def install_model(req: InstallModelRequest): }) try: from huggingface_hub import snapshot_download + from huggingface_hub.utils import ( + HfHubHTTPError, + LocalEntryNotFoundError, + ) logger.info("model install starting: %s", req.repo_id) - snapshot_download(repo_id=req.repo_id) + # On Windows, NTFS symlinks require Developer Mode or Admin — + # most first-run installs don't have either. The global env var + # HF_HUB_DISABLE_SYMLINKS=1 (set in main.py) covers implicit + # downloads, but we also pass the kwarg here as a belt-and-braces + # guard for older huggingface_hub versions that don't read the var. + dl_kwargs: dict = {"repo_id": req.repo_id} + if sys.platform == "win32": + dl_kwargs["local_dir_use_symlinks"] = False + + # Resume on transient network failures. snapshot_download writes + # `.incomplete` shards into the HF cache and resumes from them on + # the next call automatically — re-invoking with the same args + # picks up where it left off, so each retry only re-fetches what's + # missing. + _max_attempts = 5 + _attempt = 0 + while True: + _attempt += 1 + try: + snapshot_download(**dl_kwargs) + break + except (HfHubHTTPError, LocalEntryNotFoundError, OSError) as net_err: + if _attempt >= _max_attempts: + raise + _backoff = min(30, 2 ** _attempt) + logger.warning( + "model install %s: attempt %d/%d failed (%s); retry in %ds", + req.repo_id, _attempt, _max_attempts, net_err, _backoff, + ) + hf_progress.emit({ + "repo_id": req.repo_id, + "filename": req.repo_id, + "downloaded": 0, "total": 0, "pct": 0.0, + "phase": "install_retry", + "attempt": _attempt, + "error": str(net_err), + }) + import time as _t + _t.sleep(_backoff) logger.info("model install done: %s", req.repo_id) hf_progress.emit({ "repo_id": req.repo_id, diff --git a/backend/core/config.py b/backend/core/config.py index 341b6489..1d29814f 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -13,6 +13,36 @@ def get_app_data_dir(): else: return os.path.expanduser("~/.omnivoice") + +def _ensure_short_hf_cache_on_windows(): + """Redirect HuggingFace cache to a short path on Windows. + + The default ``~/.cache/huggingface/hub/models--org--name/snapshots//…`` + path regularly exceeds the 260-char MAX_PATH limit on NTFS, causing + ``FileNotFoundError`` or truncated downloads on first install. We shorten + it to ``%LOCALAPPDATA%\\OmniVoice\\hf_cache`` (~40 chars) so even the + deepest blob path stays well under the limit. + + Respects any explicit override the user already set via + ``OMNIVOICE_CACHE_DIR``, ``HF_HOME``, or ``HF_HUB_CACHE``. + """ + if sys.platform != "win32": + return + # Don't override if the user (or main.py's OMNIVOICE_CACHE_DIR block) + # already pointed the cache somewhere specific. + if os.environ.get("OMNIVOICE_CACHE_DIR") or os.environ.get("HF_HOME") or os.environ.get("HF_HUB_CACHE"): + return + local_app = os.environ.get("LOCALAPPDATA", "") + if not local_app: + return + short_cache = os.path.join(local_app, "OmniVoice", "hf_cache") + os.makedirs(short_cache, exist_ok=True) + os.environ["HF_HOME"] = short_cache + os.environ["HF_HUB_CACHE"] = short_cache + +_ensure_short_hf_cache_on_windows() + + DATA_DIR = get_app_data_dir() VOICES_DIR = os.path.join(DATA_DIR, "voices") # Reference audio for profiles OUTPUTS_DIR = os.path.join(DATA_DIR, "outputs") # Generated audio files diff --git a/backend/main.py b/backend/main.py index ef4f8bb0..c50a7ed7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -20,6 +20,30 @@ if _cache_dir: os.environ["HF_HUB_CACHE"] = _cache_dir os.environ["TORCH_HOME"] = _cache_dir +# ── Windows symlink fix ───────────────────────────────────────────────────── +# HuggingFace Hub creates NTFS symlinks in its cache to deduplicate blobs +# across model revisions. On Windows, symlink creation requires either +# Developer Mode enabled or an elevated (Administrator) shell. Without +# either, `snapshot_download` / `hf_hub_download` raises: +# OSError: [WinError 1314] A required privilege is not held by the client +# Setting HF_HUB_DISABLE_SYMLINKS_WARNING silences the console spam, and the +# newer HF_HUB_DISABLE_SYMLINKS (huggingface_hub ≥ 0.21) forces file copies +# instead — slightly more disk but always works on first install. +if sys.platform == "win32": + os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1") + os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS", "1") + +# ── HF Xet → legacy LFS fallback ──────────────────────────────────────────── +# huggingface_hub ≥ 1.5 routes large file downloads through the Xet content- +# addressed protocol (hf_xet runtime), which has its own internal progress +# reporting that bypasses our `tqdm` monkey-patch in `utils.hf_progress`. +# As a result the SetupWizard install rows show no byte progress while the +# download is actually running. Force the legacy LFS path until we add a +# proper hf_xet progress hook — this still streams via the standard tqdm +# wrapper that our patch intercepts. Override-able by the user. +os.environ.setdefault("HF_HUB_DISABLE_XET", "1") + + # Prevent torchaudio from lazy-importing torchcodec (broken on some installs). # Proper fix = exclude torchcodec in pyproject.toml; this is a belt-and-braces guard. os.environ.setdefault("TORCHAUDIO_USE_TORCHCODEC", "0") diff --git a/backend/services/translator.py b/backend/services/translator.py index 8254bb1d..6721dae1 100644 --- a/backend/services/translator.py +++ b/backend/services/translator.py @@ -59,8 +59,38 @@ _ADAPT_PROMPT = """\ You are a cinematic dubbing writer. Rewrite the literal translation using the editor's critique so it sounds natural, in-character, and fits the speaker's time slot. Keep meaning faithful but prefer native idiom over word-for-word -accuracy. Reply ONLY with the adapted translation — no quotes, no headers, -no code fences, no commentary.""" +accuracy. The output MUST be written in the same target language and script +as the literal translation — never switch language or transliterate. +Reply ONLY with the adapted translation — no quotes, no headers, no code +fences, no commentary.""" + +# Per-language script ranges, mirrored from dub_translate.LANG_REQUIRED_SCRIPT +# so the cinematic refine path can reject LLM outputs that drifted off the +# target script. Kept local instead of imported because the routers package +# also imports this services module — circular-import risk otherwise. +_SCRIPT_RANGES = { + "hi": (0x0900, 0x097F), + "ar": (0x0600, 0x06FF), + "zh": (0x4E00, 0x9FFF), + "zh-CN": (0x4E00, 0x9FFF), + "ja": (0x3040, 0x30FF), + "ko": (0xAC00, 0xD7AF), + "th": (0x0E00, 0x0E7F), + "ru": (0x0400, 0x04FF), + "uk": (0x0400, 0x04FF), +} + + +def _looks_like_target_script(text: str, code: str, threshold: float = 0.5) -> bool: + rng = _SCRIPT_RANGES.get(code) + if not rng: + return True + lo, hi = rng + letters = [c for c in text if c.isalpha()] + if not letters: + return True + inside = sum(1 for c in letters if lo <= ord(c) <= hi) + return (inside / len(letters)) >= threshold def _llm_client(): @@ -219,7 +249,22 @@ def cinematic_refine_sync( "error": f"adapt: {e}", } - final = adapted.strip() or literal_text + final = (adapted or "").strip() or literal_text + # Refuse adaptations that drifted off the target script (e.g. local LLM + # rewrote a Devanagari line in Latin/German). Caller still gets the + # critique so the UI can show what happened, but the live text falls + # back to the literal translation rather than corrupting the dub. + if final is not literal_text and not _looks_like_target_script(final, target_lang): + logger.warning( + "cinematic adapt produced wrong-script output for %s — falling back to literal", + target_lang, + ) + return { + "text": literal_text, + "literal": literal_text, + "critique": critique, + "error": f"adapt-wrong-script:{target_lang}", + } return { "text": final, "literal": literal_text, diff --git a/docker-compose.yml b/docker-compose.yml index 4d92239f..d6cc162d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: container_name: omnivoice-studio restart: unless-stopped ports: - - "8000:8000" + - "3900:3900" volumes: # Map the backend data directory to host for persistent SQLite, voices, and history - ./omnivoice_data:/app/omnivoice_data diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index edcec698..c6d59057 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -77,7 +77,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "app" -version = "0.2.2" +version = "0.2.3" dependencies = [ "flate2", "libc", diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index ef8808a9..c13573b6 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -1,12 +1,12 @@ use std::fs; -use std::io::{self, Read}; +use std::io::{self, BufRead, BufReader, Read}; use std::net::TcpStream; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::time::{Duration, Instant}; use serde::Serialize; -use tauri::Manager; +use tauri::{Emitter, Manager}; // Unique port range (3900-3902) chosen to avoid common conflicts: // 8000 collides with Django/Rails/Jupyter/Airflow on most dev machines. @@ -37,6 +37,9 @@ pub enum BootstrapStage { /// Running `uv sync --frozen --no-dev`. Biggest time sink on first run /// (~5-10 min to pull torch + whisperx + faster-whisper + demucs). InstallingDeps, + /// Fetching the per-platform static ffmpeg binary from the + /// ffmpeg-static GitHub release. ~30-70 MB. + DownloadingFfmpeg { percent: Option }, /// Venv ready, spawning uvicorn. Should be <5 s. StartingBackend, /// Backend is listening and healthy. Frontend can leave the splash. @@ -55,6 +58,94 @@ fn set_stage(state: &Arc>, stage: BootstrapStage) { } } +// ── Splash log + byte-progress event channel ───────────────────────────── +// +// Two Tauri events drive the splash UI's log panel + per-stage progress +// bar. The splash polls `bootstrap_status` for the coarse stage label and +// listens on these for live detail. + +#[derive(Clone, Serialize)] +struct LogPayload { + stage: String, + line: String, +} + +#[derive(Clone, Serialize)] +struct ProgressPayload { + stage: String, + bytes_done: u64, + bytes_total: u64, + percent: Option, +} + +fn emit_log(app: &tauri::AppHandle, stage: &str, line: &str) { + let _ = app.emit( + "bootstrap-log", + LogPayload { stage: stage.to_string(), line: line.to_string() }, + ); +} + +fn emit_progress( + app: &tauri::AppHandle, + stage: &str, + done: u64, + total: u64, +) { + let percent = if total > 0 { + Some(((done as f64 / total as f64) * 100.0).min(100.0) as u8) + } else { + None + }; + let _ = app.emit( + "bootstrap-progress", + ProgressPayload { + stage: stage.to_string(), + bytes_done: done, + bytes_total: total, + percent, + }, + ); +} + +/// Stream stdout+stderr of a long-running subprocess line-by-line into the +/// splash log panel. Replaces blocking `.status()` calls so the user sees +/// `uv sync` chatter during the 5–10 min pip resolve. Returns the exit +/// status once the child exits. +fn run_streaming( + app: &tauri::AppHandle, + stage: &str, + cmd: &mut Command, +) -> io::Result { + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = cmd.spawn()?; + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let app_out = app.clone(); + let app_err = app.clone(); + let stage_out = stage.to_string(); + let stage_err = stage.to_string(); + let h_out = std::thread::spawn(move || { + if let Some(s) = stdout { + for line in BufReader::new(s).lines().flatten() { + log::info!("[{}] {}", stage_out, line); + emit_log(&app_out, &stage_out, &line); + } + } + }); + let h_err = std::thread::spawn(move || { + if let Some(s) = stderr { + for line in BufReader::new(s).lines().flatten() { + log::info!("[{}] {}", stage_err, line); + emit_log(&app_err, &stage_err, &line); + } + } + }); + let status = child.wait()?; + let _ = h_out.join(); + let _ = h_err.join(); + Ok(status) +} + #[tauri::command] fn bootstrap_status(state: tauri::State<'_, BootstrapState>) -> BootstrapStage { state @@ -287,7 +378,8 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> { /// Dev mode wins: if `.venv` exists at the project root, reuse it (matches /// the behaviour of `bun run dev`). Otherwise copy the bundled pyproject.toml /// + uv.lock + backend/ from Tauri resources into `app_local_data_dir/project` -/// and run `uv venv` + `uv sync --frozen --no-dev` there. +/// and run `uv venv` + `uv sync --frozen --no-dev` there. All subprocess +/// stdout/stderr is streamed to the splash log panel via Tauri events. fn ensure_venv_ready(app: &tauri::AppHandle, progress: Option<&Arc>>) -> Option<(PathBuf, PathBuf)> { let fail = |progress: Option<&Arc>>, msg: &str| { log::error!("{}", msg); @@ -370,11 +462,10 @@ fn ensure_venv_ready(app: &tauri::AppHandle, progress: Opt if let Some(p) = progress { set_stage(p, BootstrapStage::CreatingVenv); } - let status = Command::new(&uv_path) - .args(["venv", "--python", "3.11"]) - .current_dir(&project_dir) - .status(); - if !matches!(status, Ok(s) if s.success()) { + let mut venv_cmd = Command::new(&uv_path); + venv_cmd.args(["venv", "--python", "3.11"]).current_dir(&project_dir); + let status = run_streaming(app, "creating_venv", &mut venv_cmd); + if !matches!(status, Ok(ref s) if s.success()) { fail(progress, &format!("uv venv failed: {:?}", status)); return None; } @@ -382,11 +473,12 @@ fn ensure_venv_ready(app: &tauri::AppHandle, progress: Opt if let Some(p) = progress { set_stage(p, BootstrapStage::InstallingDeps); } - let sync_status = Command::new(&uv_path) - .args(["sync", "--frozen", "--no-dev"]) - .current_dir(&project_dir) - .status(); - if !matches!(sync_status, Ok(s) if s.success()) { + let mut sync_cmd = Command::new(&uv_path); + sync_cmd + .args(["sync", "--frozen", "--no-dev", "--verbose"]) + .current_dir(&project_dir); + let sync_status = run_streaming(app, "installing_deps", &mut sync_cmd); + if !matches!(sync_status, Ok(ref s) if s.success()) { fail(progress, &format!("uv sync failed: {:?}", sync_status)); return None; } @@ -417,6 +509,143 @@ fn backend_log_path() -> PathBuf { log_dir.join("backend.log") } +// ── ffmpeg static binary fetch (cross-platform, no extraction) ─────────── +// +// We pull a single statically-linked binary per host platform from the +// long-lived `ffmpeg-static` GitHub release (MIT, ffmpeg-6.0). One binary, +// no archive — just download, chmod +x, done. URLs intentionally pinned +// to a specific tag for reproducibility; bump `FFMPEG_TAG` to upgrade. +const FFMPEG_TAG: &str = "b6.0"; + +fn ffmpeg_download_url() -> Option<&'static str> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => Some("ffmpeg-darwin-arm64"), + ("macos", "x86_64") => Some("ffmpeg-darwin-x64"), + ("linux", "x86_64") => Some("ffmpeg-linux-x64"), + ("linux", "aarch64") => Some("ffmpeg-linux-arm64"), + ("windows", "x86_64") => Some("ffmpeg-win32-x64.exe"), + _ => None, + } +} + +/// Download the static ffmpeg binary into `app_data/bin/ffmpeg[.exe]`. +/// Idempotent: if the file exists and is executable, no-ops. Streams byte +/// progress to the splash via `bootstrap-progress`. +fn install_ffmpeg( + app: &tauri::AppHandle, + dest_dir: &Path, + progress: Option<&Arc>>, +) -> io::Result { + let bin_name = if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" }; + let final_path = dest_dir.join(bin_name); + + if final_path.is_file() { + // Treat any non-zero file as good enough — the user can delete it + // to force a re-download. Avoids bullying users on flaky networks. + if let Ok(meta) = fs::metadata(&final_path) { + if meta.len() > 1_000_000 { + return Ok(final_path); + } + } + } + + let asset = ffmpeg_download_url().ok_or_else(|| { + io::Error::new(io::ErrorKind::Unsupported, "no ffmpeg binary for this platform") + })?; + let url = format!( + "https://github.com/eugeneware/ffmpeg-static/releases/download/{}/{}", + FFMPEG_TAG, asset + ); + + fs::create_dir_all(dest_dir)?; + let tmp_path = dest_dir.join(format!("{}.part", bin_name)); + let _ = fs::remove_file(&tmp_path); + + if let Some(p) = progress { + set_stage(p, BootstrapStage::DownloadingFfmpeg { percent: Some(0) }); + } + emit_log(app, "downloading_ffmpeg", &format!("GET {}", url)); + + let resp = ureq::get(&url) + .timeout(Duration::from_secs(300)) + .call() + .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("ffmpeg download: {}", e)))?; + if resp.status() != 200 { + return Err(io::Error::new( + io::ErrorKind::Other, + format!("ffmpeg HTTP {} from {}", resp.status(), url), + )); + } + let total: u64 = resp + .header("Content-Length") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + + let mut reader = resp.into_reader(); + let mut out = fs::File::create(&tmp_path)?; + let mut buf = [0u8; 64 * 1024]; + let mut done: u64 = 0; + let mut last_emit = Instant::now(); + loop { + let n = reader.read(&mut buf)?; + if n == 0 { break; } + use std::io::Write; + out.write_all(&buf[..n])?; + done += n as u64; + if last_emit.elapsed() > Duration::from_millis(150) { + emit_progress(app, "downloading_ffmpeg", done, total); + if let Some(p) = progress { + let pct = if total > 0 { + Some(((done as f64 / total as f64) * 100.0) as u8) + } else { None }; + set_stage(p, BootstrapStage::DownloadingFfmpeg { percent: pct }); + } + last_emit = Instant::now(); + } + } + drop(out); + emit_progress(app, "downloading_ffmpeg", done, total.max(done)); + + fs::rename(&tmp_path, &final_path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&final_path)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&final_path, perms)?; + } + emit_log(app, "downloading_ffmpeg", + &format!("ffmpeg ready at {} ({} bytes)", final_path.display(), done)); + Ok(final_path) +} + +/// Resolve the ffmpeg path to inject into the backend env. Order: app-data +/// download (preferred — controlled), bundled resource (legacy), system +/// PATH (None — let the backend find it). Triggers a fresh download into +/// `app_data/bin/` if nothing usable is on disk. +fn ensure_ffmpeg_ready( + app: &tauri::AppHandle, + progress: Option<&Arc>>, +) -> Option { + let app_data = app.path().app_local_data_dir().ok()?; + let bin_dir = app_data.join("bin"); + let installed = bin_dir.join(if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" }); + if installed.is_file() { + return Some(installed); + } + if let Some(bundled) = find_bundled_ffmpeg(app) { + return Some(bundled); + } + match install_ffmpeg(app, &bin_dir, progress) { + Ok(p) => Some(p), + Err(e) => { + emit_log(app, "downloading_ffmpeg", &format!("ffmpeg fetch failed: {}", e)); + log::warn!("ffmpeg fetch failed: {} — backend will fall back to system PATH", e); + None + } + } +} + /// Stage the bundled ffmpeg binary and return its absolute path. The path is /// exported via `OMNIVOICE_FFMPEG` so the Python backend uses it over a /// system install. Returns None if the bundled binary isn't present. @@ -456,6 +685,12 @@ fn spawn_backend(app: &tauri::AppHandle, progress: Option< return None; } }; + + // Fetch ffmpeg before flipping to StartingBackend so the splash shows + // the real-time download. Failure isn't fatal — we'll fall back to the + // system PATH and let the backend log the missing-ffmpeg error itself. + let ffmpeg_path = ensure_ffmpeg_ready(app, progress); + if let Some(p) = progress { set_stage(p, BootstrapStage::StartingBackend); } @@ -464,7 +699,7 @@ fn spawn_backend(app: &tauri::AppHandle, progress: Option< let stderr_file = fs::File::create(&err_path).ok(); let mut env: Vec<(String, String)> = vec![("PYTHONUNBUFFERED".into(), "1".into())]; - if let Some(ff) = find_bundled_ffmpeg(app) { + if let Some(ff) = ffmpeg_path { env.push(("OMNIVOICE_FFMPEG".into(), ff.to_string_lossy().into_owned())); let path_sep = if cfg!(windows) { ";" } else { ":" }; env.push(( @@ -581,11 +816,11 @@ pub fn run() { *guard = child; } // Poll the port until the backend actually responds, then flip - // the splash to Ready. Bounded wait — if it never comes up we - // stop after ~60 s and leave the stage on StartingBackend so - // the UI can surface an error. + // the splash to Ready. Bounded wait — first-run cold starts + // can hit 90+ s on slow disks while torch initialises, so + // we give it 3 min before declaring failure. let start = std::time::Instant::now(); - while start.elapsed() < Duration::from_secs(60) { + while start.elapsed() < Duration::from_secs(180) { if backend_healthy(BACKEND_PORT) { set_stage(&stage_handle, BootstrapStage::Ready); return; @@ -595,7 +830,7 @@ pub fn run() { set_stage( &stage_handle, BootstrapStage::Failed { - message: "Backend did not respond within 60 s".to_string(), + message: "Backend did not respond within 180 s".to_string(), }, ); }); diff --git a/frontend/src/components/BootstrapSplash.css b/frontend/src/components/BootstrapSplash.css index 28fba33b..2ec6aada 100644 --- a/frontend/src/components/BootstrapSplash.css +++ b/frontend/src/components/BootstrapSplash.css @@ -107,3 +107,66 @@ white-space: pre-wrap; word-break: break-word; } + +.bootstrap-splash__sub-progress { + margin: -0.5rem 0 1rem; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.bootstrap-splash__sub-bar { + height: 3px; + width: 100%; + border-radius: 2px; + background: color-mix(in srgb, var(--chrome-fg, #eee) 6%, transparent); + overflow: hidden; +} + +.bootstrap-splash__sub-bar-fill { + height: 100%; + background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 70%, transparent); + transition: width 0.2s ease; +} + +.bootstrap-splash__sub-label { + font-family: 'IBM Plex Mono', ui-monospace, monospace; + font-size: 0.72rem; + opacity: 0.65; +} + +.bootstrap-splash__log-toggle { + margin-top: 1.25rem; + background: none; + border: none; + color: inherit; + opacity: 0.65; + font: inherit; + font-size: 0.78rem; + padding: 0.25rem 0; + cursor: pointer; + text-align: left; +} + +.bootstrap-splash__log-toggle:hover { opacity: 1; } + +.bootstrap-splash__log-count { + opacity: 0.6; + font-variant-numeric: tabular-nums; +} + +.bootstrap-splash__logs { + margin: 0.5rem 0 0; + max-height: 220px; + overflow-y: auto; + font-family: 'IBM Plex Mono', ui-monospace, monospace; + font-size: 0.72rem; + line-height: 1.45; + background: color-mix(in srgb, var(--chrome-fg, #eee) 4%, transparent); + border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 8%, transparent); + border-radius: 8px; + padding: 0.6rem 0.75rem; + white-space: pre-wrap; + word-break: break-word; + opacity: 0.85; +} diff --git a/frontend/src/components/BootstrapSplash.jsx b/frontend/src/components/BootstrapSplash.jsx index c97d5de7..5e5bdcdc 100644 --- a/frontend/src/components/BootstrapSplash.jsx +++ b/frontend/src/components/BootstrapSplash.jsx @@ -1,31 +1,102 @@ /** * First-run bootstrap splash. * - * The Rust side spawns the venv setup (uv install + `uv sync --frozen`) in a - * background thread and publishes progress via the `bootstrap_status` Tauri - * command. This component polls that command every 1 s and renders the - * current stage until the backend is healthy (stage === 'ready'), then - * unmounts and lets the main UI take over. + * Two data sources drive this UI: + * 1. `bootstrap_status` Tauri command (polled every 1 s) — coarse stage. + * 2. `bootstrap-log` + `bootstrap-progress` Tauri events — live stdout + * from `uv sync`, ffmpeg byte counts, etc. The log panel shows the + * last N lines so users can see *something* happening during the 5–10 + * min dependency install. */ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import './BootstrapSplash.css'; const STAGE_LABEL = { - checking: 'Checking environment…', - downloading_uv: 'Downloading uv (Python package manager)…', - creating_venv: 'Creating Python virtual environment…', - installing_deps: 'Installing dependencies — this is a one-time setup (5-10 min on first run).', - starting_backend: 'Starting backend…', - ready: 'Ready', - failed: 'Setup failed', + checking: 'Checking environment…', + downloading_uv: 'Downloading uv (Python package manager)…', + creating_venv: 'Creating Python virtual environment…', + installing_deps: 'Installing dependencies — first run, 5–10 min.', + downloading_ffmpeg: 'Downloading ffmpeg…', + starting_backend: 'Starting backend…', + ready: 'Ready', + failed: 'Setup failed', }; -const STEPS = ['checking', 'downloading_uv', 'creating_venv', 'installing_deps', 'starting_backend']; +const STEPS = [ + 'checking', + 'downloading_uv', + 'creating_venv', + 'installing_deps', + 'downloading_ffmpeg', + 'starting_backend', +]; + +const MAX_LOG_LINES = 200; + +function formatBytes(n) { + if (!n || n < 0) return ''; + const units = ['B', 'KB', 'MB', 'GB']; + let i = 0; + let v = n; + while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; } + return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`; +} export function BootstrapSplash({ stage, message }) { const label = STAGE_LABEL[stage] || stage; const stepIndex = Math.max(0, STEPS.indexOf(stage)); const isFailed = stage === 'failed'; + const [logs, setLogs] = useState([]); + const [logsOpen, setLogsOpen] = useState(false); + const [progress, setProgress] = useState(null); // { stage, bytes_done, bytes_total, percent } + const logRef = useRef(null); + + // Subscribe to live log + progress events from the Rust bootstrap. + useEffect(() => { + if (typeof window === 'undefined') return; + if (!('__TAURI_INTERNALS__' in window)) return; + let unlistenLog = null; + let unlistenProgress = null; + let cancelled = false; + + (async () => { + try { + const { listen } = await import('@tauri-apps/api/event'); + if (cancelled) return; + unlistenLog = await listen('bootstrap-log', (e) => { + const { stage: s, line } = e.payload || {}; + if (!line) return; + setLogs((prev) => { + const next = prev.concat([{ stage: s, line, t: Date.now() }]); + return next.length > MAX_LOG_LINES + ? next.slice(next.length - MAX_LOG_LINES) + : next; + }); + }); + unlistenProgress = await listen('bootstrap-progress', (e) => { + setProgress(e.payload || null); + }); + } catch { + /* not in Tauri or listen unavailable — silent */ + } + })(); + return () => { + cancelled = true; + if (unlistenLog) unlistenLog(); + if (unlistenProgress) unlistenProgress(); + }; + }, []); + + // Auto-scroll the log panel to the latest line whenever it opens or + // new lines arrive. + useEffect(() => { + if (logsOpen && logRef.current) { + logRef.current.scrollTop = logRef.current.scrollHeight; + } + }, [logs, logsOpen]); + + const stageProgress = progress && progress.stage === stage ? progress : null; + const pctFromBytes = stageProgress?.percent != null ? stageProgress.percent : null; return (
@@ -42,6 +113,23 @@ export function BootstrapSplash({ stage, message }) { style={{ width: `${((stepIndex + 1) / STEPS.length) * 100}%` }} />
+ {stageProgress && ( +
+
+
+
+ + {formatBytes(stageProgress.bytes_done)} + {stageProgress.bytes_total > 0 + ? ` / ${formatBytes(stageProgress.bytes_total)}` + : ''} + {pctFromBytes != null ? ` (${pctFromBytes}%)` : ''} + +
+ )}
    {STEPS.map((s, i) => (
  1. )} + + {logsOpen && ( +
    +            {logs.length === 0
    +              ? 'Waiting for output…'
    +              : logs.map((l, i) => `[${l.stage}] ${l.line}`).join('\n')}
    +          
    + )}
); diff --git a/install.sh b/install.sh index 0ff9dfc1..83dd35c3 100755 --- a/install.sh +++ b/install.sh @@ -1,115 +1,362 @@ -#!/usr/bin/env bash -# OmniVoice Studio — one-shot installer for macOS (Apple Silicon). +#!/bin/sh +# OmniVoice Studio — universal installer. # -# Run this once, then `./run.sh` each time you want to use the app. -# Needs: macOS 12+ on M-series, internet (first run downloads ~5 GB of -# ML model weights), and access to run Homebrew/xcode-select if missing. +# Works on macOS (ARM + Intel), Linux (Debian/Ubuntu, Fedora, Arch), and WSL. +# Run once, then `./run.sh` each time you want to use the app. # # Usage: -# bash install.sh -set -euo pipefail +# curl -fsSL https://raw.githubusercontent.com/debpalash/OmniVoice-Studio/main/install.sh | sh +# # or locally: +# sh install.sh +# sh install.sh --verbose # show all subcommand output +# sh install.sh --python 3.12 # override Python version +set -e -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$SCRIPT_DIR" +# ── Output style ──────────────────────────────────────────────────────────── +RULE="" +_rule_i=0 +while [ "$_rule_i" -lt 56 ]; do + RULE="${RULE}─" + _rule_i=$((_rule_i + 1)) +done -# ── pretty logging ────────────────────────────────────────────────────────── -BOLD=$(tput bold 2>/dev/null || echo "") -DIM=$(tput dim 2>/dev/null || echo "") -GREEN=$(tput setaf 2 2>/dev/null || echo "") -YELLOW=$(tput setaf 3 2>/dev/null || echo "") -RED=$(tput setaf 1 2>/dev/null || echo "") -RESET=$(tput sgr0 2>/dev/null || echo "") +if [ -n "${NO_COLOR:-}" ]; then + C_TITLE="" C_DIM="" C_OK="" C_WARN="" C_ERR="" C_RST="" +elif [ -t 1 ] || [ -n "${FORCE_COLOR:-}" ]; then + _ESC="$(printf '\033')" + C_TITLE="${_ESC}[1;38;5;141m" # bold purple + C_DIM="${_ESC}[38;5;245m" + C_OK="${_ESC}[38;5;108m" # green + C_WARN="${_ESC}[38;5;136m" # yellow + C_ERR="${_ESC}[91m" # red + C_RST="${_ESC}[0m" +else + C_TITLE="" C_DIM="" C_OK="" C_WARN="" C_ERR="" C_RST="" +fi -step() { printf "\n${BOLD}${GREEN}▸ %s${RESET}\n" "$*"; } -note() { printf "${DIM} %s${RESET}\n" "$*"; } -warn() { printf "${YELLOW} ⚠ %s${RESET}\n" "$*"; } -die() { printf "${RED} ✗ %s${RESET}\n" "$*" >&2; exit 1; } +step() { printf " ${C_DIM}%-18.18s${C_RST}${3:-$C_OK}%s${C_RST}\n" "$1" "$2"; } +note() { printf " ${C_DIM}%-18s${2:-$C_DIM}%s${C_RST}\n" "" "$1"; } +warn() { printf " ${C_WARN}⚠ %s${C_RST}\n" "$1"; } +die() { printf " ${C_ERR}✗ %s${C_RST}\n" "$1" >&2; exit 1; } have() { command -v "$1" >/dev/null 2>&1; } -# ── sanity ─────────────────────────────────────────────────────────────────── -step "Checking platform" -if [[ "$(uname -s)" != "Darwin" ]]; then - die "This installer is macOS-only. You're on $(uname -s)." +# ── Parse flags ───────────────────────────────────────────────────────────── +_VERBOSE=false +_USER_PYTHON="" +_next_is_python=false +for arg in "$@"; do + if [ "$_next_is_python" = true ]; then + _USER_PYTHON="$arg" + _next_is_python=false + continue + fi + case "$arg" in + --verbose|-v) _VERBOSE=true ;; + --python) _next_is_python=true ;; + esac +done +if [ "$_next_is_python" = true ]; then + die "--python requires a version argument (e.g. --python 3.12)" fi -if [[ "$(uname -m)" != "arm64" ]]; then - warn "Not on Apple Silicon ($(uname -m)). MLX Whisper will fall back to CPU Whisper — slower but works." -fi -note "macOS $(sw_vers -productVersion) · $(uname -m)" -# ── Xcode Command Line Tools ───────────────────────────────────────────────── -step "Xcode Command Line Tools" -if ! xcode-select -p >/dev/null 2>&1; then - note "Not found — installing. A macOS dialog will appear; click Install." - xcode-select --install || true - until xcode-select -p >/dev/null 2>&1; do - note "Waiting for install to finish…" - sleep 10 - done -fi -note "OK ($(xcode-select -p))" +run_quiet() { + if [ "$_VERBOSE" = true ]; then + "$@" + else + "$@" > /dev/null 2>&1 + fi +} -# ── Homebrew ───────────────────────────────────────────────────────────────── -step "Homebrew" -if ! have brew; then - note "Installing Homebrew (you may be prompted for your password)…" - /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" - # Put brew on PATH for this session (Apple Silicon default prefix) - if [[ -x /opt/homebrew/bin/brew ]]; then - eval "$(/opt/homebrew/bin/brew shellenv)" - fi -fi -note "OK ($(brew --version | head -n1))" +# ── Helper: download (curl or wget) ──────────────────────────────────────── +download() { + if have curl; then + curl -LsSf "$1" -o "$2" + elif have wget; then + wget -qO "$2" "$1" + else + die "Neither curl nor wget found. Install one and re-run." + fi +} -# ── ffmpeg (audio/video mux) ───────────────────────────────────────────────── -step "ffmpeg" +# ── Helper: open browser (cross-platform) ────────────────────────────────── +open_browser() { + _url="$1" + if [ "$(uname)" = "Darwin" ] && have open; then + open "$_url" + elif grep -qi microsoft /proc/version 2>/dev/null; then + # WSL: use Windows browser + if have powershell.exe; then + powershell.exe -NoProfile -Command "Start-Process '$_url'" >/dev/null 2>&1 & + elif have cmd.exe; then + cmd.exe /c start "" "$_url" >/dev/null 2>&1 & + elif have xdg-open; then + xdg-open "$_url" >/dev/null 2>&1 & + else + echo " Open in your browser: $_url" + fi + elif have xdg-open; then + xdg-open "$_url" >/dev/null 2>&1 & + else + echo " Open in your browser: $_url" + fi +} + +# ── Detect platform ──────────────────────────────────────────────────────── +OS="linux" +if [ "$(uname)" = "Darwin" ]; then + OS="macos" +elif grep -qi microsoft /proc/version 2>/dev/null; then + OS="wsl" +fi +ARCH=$(uname -m) + +echo "" +printf " ${C_TITLE}%s${C_RST}\n" "🎙 OmniVoice Studio Installer" +printf " ${C_DIM}%s${C_RST}\n" "$RULE" +echo "" + +step "platform" "$OS ($ARCH)" + +# Detect Rosetta on macOS +if [ "$OS" = "macos" ] && [ "$ARCH" = "x86_64" ]; then + if [ "$(sysctl -in hw.optional.arm64 2>/dev/null || echo 0)" = "1" ]; then + warn "Apple Silicon detected running under Rosetta (x86_64)." + note "Re-run from a native arm64 terminal for full MLX support." + fi +fi + +# ── Resolve script directory (for local installs) ────────────────────────── +SCRIPT_DIR="" +if [ -n "${0:-}" ] && [ -f "$0" ]; then + SCRIPT_DIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd) || true +fi +if [ -z "$SCRIPT_DIR" ]; then + SCRIPT_DIR=$(pwd) +fi + +# If run via curl pipe, clone the repo first +if [ ! -f "$SCRIPT_DIR/pyproject.toml" ]; then + step "clone" "downloading OmniVoice Studio..." + INSTALL_DIR="$HOME/OmniVoice" + if [ -d "$INSTALL_DIR/.git" ]; then + note "Updating existing clone at $INSTALL_DIR" + (cd "$INSTALL_DIR" && git pull --ff-only 2>/dev/null || true) + else + if have git; then + git clone --depth 1 https://github.com/debpalash/OmniVoice-Studio.git "$INSTALL_DIR" + else + die "git is required. Install git and re-run." + fi + fi + SCRIPT_DIR="$INSTALL_DIR" +fi + +cd "$SCRIPT_DIR" + +# ── Python version ────────────────────────────────────────────────────────── +if [ -n "$_USER_PYTHON" ]; then + PYTHON_VERSION="$_USER_PYTHON" + note "Using user-specified Python $PYTHON_VERSION" +else + PYTHON_VERSION="3.11" +fi + +# ── System dependencies ──────────────────────────────────────────────────── + +# Helper: install system packages with the right package manager +_install_sys_pkgs() { + case "$OS" in + macos) + if ! have brew; then + note "Installing Homebrew..." + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" /dev/null || true + ;; + linux|wsl) + if have apt-get; then + # Try without sudo first, then escalate + apt-get update -y /dev/null 2>&1 || true + apt-get install -y "$@" /dev/null 2>&1 || { + if have sudo; then + echo "" + echo " Need elevated permissions to install: $*" + sudo apt-get update -y /dev/null || true + elif have yum; then + sudo yum install -y "$@" /dev/null || true + elif have pacman; then + sudo pacman -S --noconfirm "$@" 2>/dev/null || true + else + warn "No supported package manager found. Please install manually: $*" + fi + ;; + esac +} + +# Xcode CLT (macOS only) +if [ "$OS" = "macos" ]; then + step "xcode" "checking..." + if ! xcode-select -p >/dev/null 2>&1; then + note "Installing Xcode Command Line Tools..." + xcode-select --install /dev/null || true + until xcode-select -p >/dev/null 2>&1; do + note "Waiting for Xcode CLT install..." + sleep 10 + done + fi + step "xcode" "$(xcode-select -p)" +fi + +# ffmpeg (required for audio/video processing) +step "ffmpeg" "checking..." if ! have ffmpeg; then - brew install ffmpeg + note "Installing ffmpeg..." + case "$OS" in + macos) _install_sys_pkgs ffmpeg ;; + linux|wsl) + if have apt-get; then + _install_sys_pkgs ffmpeg + elif have dnf; then + _install_sys_pkgs ffmpeg-free # Fedora + elif have pacman; then + _install_sys_pkgs ffmpeg + else + warn "Please install ffmpeg manually." + fi + ;; + esac fi -note "OK ($(ffmpeg -version | head -n1 | cut -d' ' -f1-3))" - -# ── uv (Python package manager) ────────────────────────────────────────────── -step "uv (Python manager)" -if ! have uv; then - brew install uv || curl -LsSf https://astral.sh/uv/install.sh | sh - # Put ~/.local/bin on PATH for this session if that's where uv landed - export PATH="$HOME/.local/bin:$PATH" +if have ffmpeg; then + step "ffmpeg" "$(ffmpeg -version 2>/dev/null | head -n1 | cut -d' ' -f1-3)" +else + warn "ffmpeg not found — some features will be unavailable." fi -note "OK ($(uv --version))" -# ── bun (JS runtime for the frontend) ──────────────────────────────────────── -step "bun (JS runtime)" +# ── Install uv ────────────────────────────────────────────────────────────── +step "uv" "checking..." +UV_MIN_VERSION="0.7.0" + +_version_ge() { + # Returns 0 if $1 >= $2 (dotted version comparison) + _a=$1; _b=$2 + while [ -n "$_a" ] || [ -n "$_b" ]; do + _a_part=${_a%%.*}; _b_part=${_b%%.*} + [ "$_a" = "$_a_part" ] && _a="" || _a=${_a#*.} + [ "$_b" = "$_b_part" ] && _b="" || _b=${_b#*.} + [ -z "$_a_part" ] && _a_part=0 + [ -z "$_b_part" ] && _b_part=0 + if [ "$_a_part" -gt "$_b_part" ] 2>/dev/null; then return 0; fi + if [ "$_a_part" -lt "$_b_part" ] 2>/dev/null; then return 1; fi + done + return 0 +} + +_uv_ok() { + have uv || return 1 + _raw=$(uv --version 2>/dev/null | awk '{print $2}') || return 1 + [ -n "$_raw" ] || return 1 + _ver=${_raw%%[-+]*} + _version_ge "$_ver" "$UV_MIN_VERSION" +} + +if ! _uv_ok; then + note "Installing uv package manager..." + _uv_tmp=$(mktemp) + download "https://astral.sh/uv/install.sh" "$_uv_tmp" + run_quiet sh "$_uv_tmp" /dev/null)" + +# ── Install bun (JS runtime for frontend) ────────────────────────────────── +step "bun" "checking..." if ! have bun; then - curl -fsSL https://bun.sh/install | bash - export PATH="$HOME/.bun/bin:$PATH" + note "Installing bun..." + if have curl; then + curl -fsSL https://bun.sh/install | sh /dev/null)" -# ── Python deps via uv ─────────────────────────────────────────────────────── -step "Python dependencies" -note "This can take 5–10 min the first time (torch + torchaudio + demucs…)" +# ── GPU detection (informational) ────────────────────────────────────────── +step "gpu" "detecting..." +GPU_INFO="CPU only" +if [ "$OS" = "macos" ] && [ "$ARCH" = "arm64" ]; then + GPU_INFO="Apple Silicon (Metal/MPS)" +elif have nvidia-smi; then + _gpu_name=$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -1) + _cuda_ver=$(nvidia-smi 2>/dev/null | sed -n 's/.*CUDA Version:[[:space:]]*\([0-9]*\.[0-9]*\).*/\1/p' | head -1) + if [ -n "$_gpu_name" ]; then + GPU_INFO="NVIDIA $_gpu_name (CUDA $_cuda_ver)" + fi +elif have rocminfo; then + _amd_name=$(rocminfo 2>/dev/null | awk '/Marketing Name:/{$1=$2=""; print; exit}' | sed 's/^ *//') + if [ -n "$_amd_name" ]; then + GPU_INFO="AMD $_amd_name (ROCm)" + fi +fi +step "gpu" "$GPU_INFO" + +# ── Python dependencies via uv ────────────────────────────────────────────── +step "python" "syncing dependencies..." +note "This can take 5–10 min the first time (torch + torchaudio + demucs...)" + +# Create venv with the target Python version if it doesn't exist +if [ ! -d .venv ]; then + uv venv --python "$PYTHON_VERSION" +fi + +# Sync all deps from pyproject.toml + uv.lock uv sync -note "OK — virtualenv at .venv/" -# ── Frontend deps + build ──────────────────────────────────────────────────── -step "Frontend dependencies" +step "python" "OK — virtualenv at .venv/" + +# ── Frontend deps + build ────────────────────────────────────────────────── +step "frontend" "installing dependencies..." (cd frontend && bun install) -note "OK" +step "frontend" "OK" -step "Building frontend bundle" +step "frontend" "building bundle..." (cd frontend && bun run build) -note "OK — output at frontend/dist/" +step "frontend" "OK — output at frontend/dist/" -# ── done ──────────────────────────────────────────────────────────────────── -cat <