diff --git a/.gitignore b/.gitignore index b41d16b..78a423d 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ src-tauri/sidecar/__pycache__/ src-tauri/sidecar/dist/ src-tauri/sidecar/build/ src-tauri/sidecar/.venv/ +src-tauri/sidecar/data/ .sidecar-venv/ # Superpowers diff --git a/CHANGELOG.md b/CHANGELOG.md index 9859dd8..d9ecb8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## [0.2.0] - 2026-04-08 + +**57 files | 3,086 additions | 608 deletions | 3 PRs | 29 commits** + +### 🌟 New Features + +- **Model Settings panel** — dedicated sidebar page to browse, download, and manage AI models with category tags, recommended badges, and Whisper model selection (#21) +- **Video rename** — rename recordings directly from the library view (#20) +- **Auto-detect `ELEVENLABS_API_KEY`** from environment variables — no manual entry needed if already exported (#20) +- **Version bump tooling** — `bump.sh` script for consistent release versioning + +### 🐛 Bug Fixes + +- **Slow/deep voice on first generation** after switching voices — TTS engine now reinitializes properly on voice change +- **Video clipping** in both TTS and voice conversion modes — output duration now matches source (#20) +- **TTS timing and transcription accuracy** improvements — better duration probing and segment alignment (#20) +- **Model cache not refreshing** after download completes — cache invalidation added (#21) +- **CosyVoice3 missing** from prerequisites UI and download JSON parsing error (#20) +- **Sidecar code signing** added to PyInstaller build for macOS Gatekeeper (#20) +- **84 findings** from comprehensive codebase review resolved — spanning error handling, edge cases, and code quality +- **22 model-related bugs** fixed in consolidated review (#21) +- **`whisper-small` removed** — too low quality for production use (#21) +- **Models nav alignment** fixed in sidebar (#21) +- **`yt-dlp` options** corrected for voice extraction + +### 🛠️ Other Improvements + +- Models page moved from settings sub-tab to dedicated sidebar route with `ModelInfo` struct enriched with `category` and `recommended` fields (#21) +- `cleanup_stale_recordings` test made deterministic (#21) +- README updated: Linux references removed (macOS-only app), visual architecture diagram replaces ASCII, TTS audio cutoff added to known issues (#19, #20) +- Integration tests updated for CosyVoice3 three-model setup (#20) +- Unused CI workflow files cleaned up (#20) + ## [0.1.0] - 2026-04-02 **86 files | 12,367 lines | 18 PRs | 99 commits** diff --git a/README.md b/README.md index 6a74573..6281f8b 100644 --- a/README.md +++ b/README.md @@ -368,8 +368,8 @@ These are the IPC commands exposed by the Rust backend, invoked from the fronten | `sidecar_fetch` | Proxy a GET/POST request to the sidecar | | `sidecar_upload` | Proxy a file upload to the sidecar | | `get_sidecar_status` | Get sidecar process status | -| `check_models_downloaded` | Check if required models are available | | `download_model` | Download a model from HuggingFace | +| `delete_model` | Delete a downloaded model from the cache | | `get_models_disk_usage` | Get disk usage of downloaded models | | `google_drive_connect` | Start Google Drive OAuth2 flow | | `google_drive_disconnect` | Disconnect Google Drive | @@ -404,6 +404,7 @@ HTTP endpoints served by the Python FastAPI sidecar on localhost. | `/profiles/{id}` | DELETE | Delete a voice profile | | `/models/status` | GET | Get model download/load status | | `/models/download` | POST | Download a model (streams progress as SSE) | +| `/models/{model_name}` | DELETE | Delete a downloaded model | ## Debugging diff --git a/docs/BUILD.md b/docs/BUILD.md index 7d2c191..a5ecc80 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -129,6 +129,6 @@ cd src-tauri/sidecar && pyinstaller voiceover-tts.spec ## Notes -- **Unsigned app**: The .dmg is not code-signed. Users will see a macOS Gatekeeper warning on first launch. To open: right-click the app → "Open" → click "Open" in the dialog. This only needs to be done once. +- **Code signing**: The build is configured with `signingIdentity: "Developer ID Application"` and hardened runtime. If a valid Developer ID certificate is in your keychain, the app will be signed automatically. Without the certificate, the app will be unsigned and users will see a macOS Gatekeeper warning on first launch (right-click → "Open" → "Open"). - **ffmpeg is bundled**: The static ffmpeg binary is included inside the .app so users don't need to install it separately. It's ~60MB and excluded from git via `.gitignore`. - **Settings location**: App config is stored at `~/Library/Application Support/com.voiceover.app/config.json`, not inside the .app bundle. diff --git a/package.json b/package.json index 18ab820..8cefade 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "voiceover", "private": true, - "version": "0.1.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite dev", @@ -19,14 +19,13 @@ "test:all:coverage": "pnpm test:coverage && pnpm test:rust:coverage" }, "devDependencies": { - "@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tauri-apps/cli": "^2.10.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.3.1", - "@types/node": "^25.5.0", + "@types/node": "^22.0.0", "@vitest/coverage-v8": "^4.1.0", "jsdom": "^29.0.1", "lefthook": "^2.1.4", diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh new file mode 100755 index 0000000..a0b5e22 --- /dev/null +++ b/scripts/bump-version.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Bump version across all three config files simultaneously. +# Usage: ./scripts/bump-version.sh 0.2.0 + +set -euo pipefail + +if [ $# -ne 1 ]; then + echo "Usage: $0 " + echo "Example: $0 0.2.0" + exit 1 +fi + +VERSION="$1" + +# Validate semver format +if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$'; then + echo "Error: '$VERSION' is not a valid semver (expected X.Y.Z or X.Y.Z-pre.N)" + exit 1 +fi + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +# 1. package.json +sed -i '' "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" "$ROOT/package.json" + +# 2. Cargo.toml (only the package version, line 3) +sed -i '' "3s/version = \"[^\"]*\"/version = \"$VERSION\"/" "$ROOT/src-tauri/Cargo.toml" + +# 3. tauri.conf.json +sed -i '' "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" "$ROOT/src-tauri/tauri.conf.json" + +echo "Bumped to $VERSION in:" +echo " package.json" +echo " src-tauri/Cargo.toml" +echo " src-tauri/tauri.conf.json" + +# Show the changes +grep -n "\"version\"" "$ROOT/package.json" | head -1 +grep -n "^version" "$ROOT/src-tauri/Cargo.toml" | head -1 +grep -n "\"version\"" "$ROOT/src-tauri/tauri.conf.json" | head -1 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 941dccb..4ea9b08 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2966,6 +2966,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2986,6 +2996,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -3004,6 +3024,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -4520,7 +4549,6 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -4933,7 +4961,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "voiceover" -version = "0.1.0" +version = "0.2.0" dependencies = [ "base64 0.22.1", "dirs", @@ -4942,7 +4970,7 @@ dependencies = [ "keyring", "log", "open", - "rand 0.8.5", + "rand 0.9.2", "reqwest 0.12.28", "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5fdcddd..f21d319 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "voiceover" -version = "0.1.0" +version = "0.2.0" description = "Screen recorder with ElevenLabs voice replacement" authors = ["dave"] license = "MIT" @@ -28,10 +28,10 @@ tauri-plugin-dialog = "2" tauri-plugin-fs = "2" reqwest = { version = "0.12", features = ["multipart", "json", "stream"] } futures-util = "0.3" -tokio = { version = "1", features = ["full"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "process", "sync", "fs"] } dirs = "6" tauri-plugin-global-shortcut = "2" -rand = "0.8" +rand = "0.9" sha2 = "0.10" base64 = "0.22" url = "2" diff --git a/src-tauri/entitlements.plist b/src-tauri/entitlements.plist index b465b0d..d50c4e6 100644 --- a/src-tauri/entitlements.plist +++ b/src-tauri/entitlements.plist @@ -2,16 +2,22 @@ + com.apple.security.cs.allow-unsigned-executable-memory + com.apple.security.cs.allow-jit + com.apple.security.cs.allow-dyld-environment-variables + com.apple.security.device.audio-input + com.apple.security.device.camera + com.apple.security.cs.disable-library-validation diff --git a/src-tauri/sidecar/chunked_tts.py b/src-tauri/sidecar/chunked_tts.py index fd42b52..6b169b0 100644 --- a/src-tauri/sidecar/chunked_tts.py +++ b/src-tauri/sidecar/chunked_tts.py @@ -9,9 +9,10 @@ Short text (≤ max_chunk_chars) uses the single-shot fast path with zero overhead. """ +from __future__ import annotations + import logging import re -from typing import List, Tuple import numpy as np @@ -30,7 +31,7 @@ _PARA_TAG_RE = re.compile(r"\[[^\]]*\]") -def split_text_into_chunks(text: str, max_chars: int = DEFAULT_MAX_CHUNK_CHARS) -> List[str]: +def split_text_into_chunks(text: str, max_chars: int = DEFAULT_MAX_CHUNK_CHARS) -> list[str]: """Split text at natural boundaries into chunks of at most max_chars.""" text = text.strip() if not text: @@ -38,7 +39,7 @@ def split_text_into_chunks(text: str, max_chars: int = DEFAULT_MAX_CHUNK_CHARS) if len(text) <= max_chars: return [text] - chunks: List[str] = [] + chunks: list[str] = [] remaining = text while remaining: @@ -115,7 +116,7 @@ def _safe_hard_cut(segment: str, max_chars: int) -> int: def concatenate_audio_chunks( - chunks: List[np.ndarray], + chunks: list[np.ndarray], sample_rate: int, crossfade_ms: int = 50, ) -> np.ndarray: @@ -282,13 +283,35 @@ def pad_audio_to_match_timing( return np.concatenate(result_pieces) +# --------------------------------------------------------------------------- +# Pad / truncate to target duration +# --------------------------------------------------------------------------- + + +def pad_or_truncate_to_duration(audio: np.ndarray, sample_rate: int, target_duration: float, fade_ms: float = 300) -> np.ndarray: + """Pad (with fade-out) or truncate audio to match target duration.""" + if target_duration <= 0 or not sample_rate: + return audio + target_samples = int(target_duration * sample_rate) + if len(audio) < target_samples: + fade_samples = min(int(fade_ms / 1000 * sample_rate), len(audio) // 2) + if fade_samples > 0: + fade = np.linspace(1.0, 0.0, fade_samples, dtype=np.float32) + audio = audio.copy() + audio[-fade_samples:] *= fade + audio = np.pad(audio, (0, target_samples - len(audio))) + elif len(audio) > target_samples: + audio = audio[:target_samples] + return audio + + # --------------------------------------------------------------------------- # Timestamp-aware audio assembly # --------------------------------------------------------------------------- def assemble_timed_segments( - tts_segments: List[Tuple[np.ndarray, float, float]], + tts_segments: list[tuple[np.ndarray, float, float]], original_duration: float, sample_rate: int, ) -> np.ndarray: diff --git a/src-tauri/sidecar/entitlements.plist b/src-tauri/sidecar/entitlements.plist index 183bafb..2e6c0c4 100644 --- a/src-tauri/sidecar/entitlements.plist +++ b/src-tauri/sidecar/entitlements.plist @@ -2,12 +2,16 @@ + com.apple.security.cs.allow-unsigned-executable-memory + com.apple.security.cs.allow-jit + com.apple.security.cs.allow-dyld-environment-variables + com.apple.security.cs.disable-library-validation diff --git a/src-tauri/sidecar/profiles.py b/src-tauri/sidecar/profiles.py index 1f3709f..830bbec 100644 --- a/src-tauri/sidecar/profiles.py +++ b/src-tauri/sidecar/profiles.py @@ -6,6 +6,8 @@ - samples/{uuid}.txt: transcript for each audio file """ +from __future__ import annotations + import json import logging import re @@ -13,10 +15,14 @@ import uuid from datetime import datetime, timezone from pathlib import Path -from typing import Optional logger = logging.getLogger("voiceover-tts.profiles") +__all__ = [ + "list_profiles", "get_profile", "create_profile", "delete_profile", + "add_sample", "get_samples", +] + def _validate_profile_id(profile_id: str) -> None: """Validate profile_id is safe for use as a path component.""" @@ -45,7 +51,7 @@ def list_profiles(data_dir: Path) -> list[dict]: return profiles -def get_profile(data_dir: Path, profile_id: str) -> Optional[dict]: +def get_profile(data_dir: Path, profile_id: str) -> dict | None: """Get a single profile by ID.""" _validate_profile_id(profile_id) meta_file = _profiles_dir(data_dir) / profile_id / "profile.json" diff --git a/src-tauri/sidecar/server.py b/src-tauri/sidecar/server.py index c83364c..71fe87f 100644 --- a/src-tauri/sidecar/server.py +++ b/src-tauri/sidecar/server.py @@ -73,16 +73,23 @@ def _is_writable(stream): # We don't need its functionality (attention capture, cache management). import transformers.utils.generic as _tug - -def _noop_check_model_inputs(*args, **kwargs): - if args and callable(args[0]): - return args[0] # @check_model_inputs without parens - def decorator(func): - return func - return decorator # @check_model_inputs() with parens - - -_tug.check_model_inputs = _noop_check_model_inputs +if hasattr(_tug, "check_model_inputs"): + _original = _tug.check_model_inputs + + def _noop_check_model_inputs(*args, **kwargs): + if args and callable(args[0]): + return args[0] # @check_model_inputs without parens + def decorator(func): + return func + return decorator # @check_model_inputs() with parens + + _tug.check_model_inputs = _noop_check_model_inputs +else: + logger = logging.getLogger("voiceover-tts") + logger.info( + "transformers.utils.generic.check_model_inputs not found — " + "monkey-patch may no longer be needed" + ) import uvicorn from fastapi import FastAPI, File, Form, UploadFile @@ -165,13 +172,15 @@ def _validate_path(path_str: str, allowed_parents: list) -> Path: raise ValueError("Invalid path") resolved = Path(path_str).resolve() for parent in allowed_parents: - if str(resolved).startswith(str(Path(parent).resolve())): + parent_resolved = Path(parent).resolve() + if resolved.is_relative_to(parent_resolved): return resolved raise ValueError("Path not within allowed directories") # Generation queue: serial processing to prevent GPU contention -_generation_queue: asyncio.Queue = asyncio.Queue() +_generation_queue: asyncio.Queue = asyncio.Queue(maxsize=100) _generations: dict = {} # id -> {status, error, audio_path, completed_at} +MAX_GENERATIONS = 1000 def _cleanup_generations(): @@ -207,6 +216,13 @@ async def lifespan(app): app = FastAPI(title="VoiceOver TTS Sidecar", lifespan=lifespan) + +@app.exception_handler(Exception) +async def global_exception_handler(request, exc): + logger.error(f"Unhandled exception: {exc}", exc_info=True) + return error_response(500, str(exc)) + + # --------------------------------------------------------------------------- # Parent PID watchdog (ported from Voicebox server.py pattern) # --------------------------------------------------------------------------- @@ -242,13 +258,16 @@ def _is_pid_alive(pid: int) -> bool: def _start_parent_watchdog(parent_pid: int) -> None: """Monitor parent process and exit if it dies.""" + from logging.handlers import RotatingFileHandler + watchdog_logger = logging.getLogger("watchdog") log_dir = DATA_DIR / "logs" log_dir.mkdir(parents=True, exist_ok=True) - fh = logging.FileHandler(log_dir / "watchdog.log") - fh.setFormatter(logging.Formatter("%(asctime)s - %(message)s")) - watchdog_logger.addHandler(fh) + log_path = log_dir / "watchdog.log" + handler = RotatingFileHandler(log_path, maxBytes=1_000_000, backupCount=3) + handler.setFormatter(logging.Formatter("%(asctime)s - %(message)s")) + watchdog_logger.addHandler(handler) watchdog_logger.setLevel(logging.INFO) def _watch() -> None: @@ -295,11 +314,35 @@ async def _run_generation(gen_id: str, generate_fn): try: audio, sample_rate, log_label = await generate_fn() + # Diagnostic: log audio properties before saving + audio_duration = len(audio) / sample_rate if sample_rate else 0 + logger.info( + f"{log_label} {gen_id}: audio_samples={len(audio)}, " + f"sample_rate={sample_rate}, duration={audio_duration:.3f}s" + ) + # Save to file gen_dir = DATA_DIR / "generations" gen_dir.mkdir(parents=True, exist_ok=True) audio_path = gen_dir / f"{gen_id}.wav" sf.write(str(audio_path), audio, sample_rate) + + # Verify WAV was written correctly by reading it back + try: + info = sf.info(str(audio_path)) + logger.info( + f"{log_label} {gen_id}: WAV verify: sr={info.samplerate}, " + f"frames={info.frames}, duration={info.duration:.3f}s, " + f"format={info.subtype}" + ) + if abs(info.duration - audio_duration) > 0.1: + logger.warning( + f"{log_label} {gen_id}: WAV duration mismatch! " + f"expected={audio_duration:.3f}s, actual={info.duration:.3f}s" + ) + except Exception as verify_err: + logger.warning(f"{log_label} {gen_id}: WAV verify failed: {verify_err}") + _generations[gen_id]["audio_path"] = str(audio_path) _generations[gen_id]["status"] = "completed" _generations[gen_id]["completed_at"] = time.time() @@ -324,6 +367,8 @@ async def _generation_worker(): _generations[gen_id]["completed_at"] = time.time() finally: _generation_queue.task_done() + if len(_generations) > 20: + _cleanup_generations() # --------------------------------------------------------------------------- @@ -356,6 +401,7 @@ async def _do_generate( assemble_timed_segments, concatenate_audio_chunks, pad_audio_to_match_timing, + pad_or_truncate_to_duration, split_text_into_chunks, ) @@ -464,18 +510,22 @@ async def _do_generate( sample_rate = chunk_sr audio = concatenate_audio_chunks(audio_chunks, sample_rate) + # Diagnostic: log raw TTS output before pad/truncate + raw_duration = len(audio) / sample_rate if sample_rate else 0 + logger.info( + f"TTS generation: raw output {len(audio)} samples, " + f"{raw_duration:.3f}s at {sample_rate}Hz, " + f"target={original_duration:.3f}s" + ) + if original_duration > 0 and abs(raw_duration - original_duration) / original_duration > 0.05: + logger.warning( + f"TTS generation: output duration {raw_duration:.3f}s deviates " + f"{abs(raw_duration - original_duration)/original_duration*100:.1f}% " + f"from target {original_duration:.3f}s" + ) + # Pad or truncate to match original duration with smooth fade-out - if original_duration > 0 and sample_rate: - target_samples = int(original_duration * sample_rate) - if len(audio) < target_samples: - # Fade out last 300ms before silence padding - fade_samples = min(int(0.3 * sample_rate), len(audio) // 2) - if fade_samples > 0: - fade = np.linspace(1.0, 0.0, fade_samples, dtype=np.float32) - audio[-fade_samples:] *= fade - audio = np.pad(audio, (0, target_samples - len(audio))) - elif len(audio) > target_samples: - audio = audio[:target_samples] + audio = pad_or_truncate_to_duration(audio, sample_rate, original_duration) return audio, sample_rate, "Generation" @@ -536,6 +586,7 @@ def _load(): raise ValueError(f"No samples found for profile {profile_id}") model_sr = _vc_model.sample_rate + logger.info(f"Voice conversion: model_sr={model_sr}, original_duration={original_duration:.3f}s") max_ref_samples = int(30 * model_sr) # CosyVoice3 accepts up to 30s ref ref_parts = [] @@ -579,6 +630,17 @@ def _load(): ) source_audio = load_audio(source_audio_path, sample_rate=model_sr, volume_normalize=False) + source_duration = len(source_audio) / model_sr if model_sr else 0 + logger.info( + f"Voice conversion: source_samples={len(source_audio)}, " + f"source_duration={source_duration:.3f}s (at model_sr={model_sr}), " + f"original_duration={original_duration:.3f}s" + ) + if original_duration > 0 and abs(source_duration - original_duration) > 1.0: + logger.warning( + f"Voice conversion: source audio duration ({source_duration:.3f}s) differs from " + f"original_duration ({original_duration:.3f}s) by {abs(source_duration - original_duration):.1f}s" + ) chunk_duration = 25 # CosyVoice3 limit is 30s, use 25 for safety chunk_samples = int(chunk_duration * model_sr) @@ -642,18 +704,26 @@ def _convert_chunk(chunk_audio): audio = concatenate_audio_chunks(converted_pieces, model_sr, crossfade_ms=500) sample_rate = model_sr + # Diagnostic: log raw output before pad/truncate + raw_duration = len(audio) / sample_rate if sample_rate else 0 + logger.info( + f"Voice conversion: raw output {len(audio)} samples, " + f"{raw_duration:.3f}s at {sample_rate}Hz " + f"(target: {original_duration:.3f}s, ratio: {raw_duration/original_duration:.3f})" + if original_duration > 0 else + f"Voice conversion: raw output {len(audio)} samples, " + f"{raw_duration:.3f}s at {sample_rate}Hz (no target duration)" + ) + if original_duration > 0 and abs(raw_duration - original_duration) / original_duration > 0.05: + logger.warning( + f"Voice conversion: output duration {raw_duration:.3f}s deviates " + f"{abs(raw_duration - original_duration)/original_duration*100:.1f}% " + f"from target {original_duration:.3f}s — this may cause A/V sync issues" + ) + # Pad or truncate to match original duration with a smooth fade-out - if original_duration > 0 and sample_rate: - target_samples = int(original_duration * sample_rate) - if len(audio) < target_samples: - # Fade out last 300ms before silence padding - fade_samples = min(int(0.3 * sample_rate), len(audio) // 2) - if fade_samples > 0: - fade = np.linspace(1.0, 0.0, fade_samples, dtype=np.float32) - audio[-fade_samples:] *= fade - audio = np.pad(audio, (0, target_samples - len(audio))) - elif len(audio) > target_samples: - audio = audio[:target_samples] + from chunked_tts import pad_or_truncate_to_duration + audio = pad_or_truncate_to_duration(audio, sample_rate, original_duration) return audio, sample_rate, "Voice conversion" @@ -804,6 +874,11 @@ async def generate(request: dict): if not profile_id or not text: return error_response(400, "profile_id and text are required") + if len(_generations) >= MAX_GENERATIONS: + _cleanup_generations() + if len(_generations) >= MAX_GENERATIONS: + return error_response(429, "Too many pending generations") + gen_id = str(uuid.uuid4()) _generations[gen_id] = {"status": "queued", "error": None, "audio_path": None} @@ -935,6 +1010,8 @@ def _download(): "outtmpl": str(video_path), "quiet": True, "no_warnings": True, + "socket_timeout": 30, + "retries": 2, } with ydl.YoutubeDL(opts) as dl: dl.download([url]) @@ -1077,7 +1154,7 @@ async def delete_profile(profile_id: str): @app.get("/models/status") async def models_status(): """Get model download/load status for all registered models.""" - from tts import is_qwen_loaded, _whisper_model, _whisper_model_name + from tts import is_qwen_loaded, is_whisper_loaded, get_whisper_model_name models_dir = DATA_DIR / "models" result = [] @@ -1099,7 +1176,7 @@ async def models_status(): # Determine loaded status per category category = entry["category"] if category == "transcription": - loaded = _whisper_model is not None and _whisper_model_name == model_name + loaded = is_whisper_loaded() and get_whisper_model_name() == model_name elif category == "tts": loaded = is_qwen_loaded() elif category == "voice-conversion": diff --git a/src-tauri/sidecar/test_binary.sh b/src-tauri/sidecar/test_binary.sh index e10462c..6487295 100755 --- a/src-tauri/sidecar/test_binary.sh +++ b/src-tauri/sidecar/test_binary.sh @@ -66,8 +66,8 @@ fi echo "" echo "─── Test 2: Models status ───" MODELS=$(curl -sf http://127.0.0.1:$PORT/models/status) -if echo "$MODELS" | python3 -c "import sys,json; d=json.load(sys.stdin); assert len(d['models'])==3" 2>/dev/null; then - pass "GET /models/status returns 3 models" +if echo "$MODELS" | python3 -c "import sys,json; d=json.load(sys.stdin); assert len(d['models'])==4" 2>/dev/null; then + pass "GET /models/status returns 4 models" else fail "GET /models/status" "$MODELS" fi diff --git a/src-tauri/sidecar/tts.py b/src-tauri/sidecar/tts.py index 21bc89d..5823c3a 100644 --- a/src-tauri/sidecar/tts.py +++ b/src-tauri/sidecar/tts.py @@ -4,6 +4,8 @@ Ported from Voicebox mlx_backend.py and pytorch_backend.py patterns. """ +from __future__ import annotations + import asyncio import gc import hashlib @@ -11,7 +13,6 @@ import os import sys from pathlib import Path -from typing import Optional, Tuple import numpy as np @@ -314,6 +315,21 @@ def _load_sync(): _qwen_model_size = model_size logger.info(f"Qwen TTS {model_size} loaded successfully") + # Warmup: run a short dummy generation to prime MPS shader compilation + # and internal buffers. Without this, the first real generation can + # produce degraded output (slow/deep voice) on Apple Silicon. + try: + logger.info("Warming up Qwen TTS model...") + wavs, sr = _qwen_model.generate_voice_clone( + text="Hello.", + language="English", + voice_clone_prompt=None, + non_streaming_mode=True, + ) + logger.info(f"Qwen TTS warmup complete (sr={sr})") + except Exception as e: + logger.warning(f"Qwen TTS warmup failed (non-critical): {e}") + await asyncio.to_thread(_load_sync) @@ -321,10 +337,18 @@ def is_qwen_loaded() -> bool: return _qwen_model is not None +def is_whisper_loaded() -> bool: + return _whisper_model is not None + + +def get_whisper_model_name() -> str | None: + return _whisper_model_name + + async def create_voice_prompt( audio_path: str, reference_text: str, - cache_dir: Optional[str] = None, + cache_dir: str | None = None, ) -> list: """Create voice prompt from reference audio. @@ -358,7 +382,7 @@ def _create_sync(): async def combine_voice_prompts( audio_paths: list[str], reference_texts: list[str], - cache_dir: Optional[str] = None, + cache_dir: str | None = None, ) -> list: """Combine multiple samples into a list of VoiceClonePromptItems.""" all_items = [] @@ -373,8 +397,8 @@ async def generate_speech( text: str, voice_prompt: list, language: str = "en", - seed: Optional[int] = None, -) -> Tuple[np.ndarray, int]: + seed: int | None = None, +) -> tuple[np.ndarray, int]: """Generate speech from text using voice clone prompt items.""" if _qwen_model is None: raise RuntimeError("Qwen model not loaded") @@ -420,13 +444,18 @@ def _cache_key(audio_path: str, reference_text: str) -> str: return h.hexdigest() -def _load_cached_prompt(cache_dir: str, cache_key: str) -> Optional[dict]: +def _load_cached_prompt(cache_dir: str, cache_key: str) -> dict | None: cache_path = Path(cache_dir) / f"{cache_key}.prompt" if not cache_path.exists(): return None try: import torch - return torch.load(cache_path, map_location="cpu", weights_only=True) + from qwen_tts.inference.qwen3_tts_model import VoiceClonePromptItem + torch.serialization.add_safe_globals([VoiceClonePromptItem]) + # Load to the same device the model uses so cached and fresh prompts + # produce identical generation output (avoids MPS vs CPU precision drift) + device = _get_device() + return torch.load(cache_path, map_location=device, weights_only=True) except Exception as e: logger.warning(f"Failed to load cached prompt: {e}") return None diff --git a/src-tauri/src/commands/recording.rs b/src-tauri/src/commands/recording.rs index 5dc8229..87ca816 100644 --- a/src-tauri/src/commands/recording.rs +++ b/src-tauri/src/commands/recording.rs @@ -54,7 +54,8 @@ pub fn read_file_bytes(path: String) -> Result, String> { let video_dir = std::fs::canonicalize(dirs::video_dir().unwrap_or_else(|| home_dir.join("Videos"))) .unwrap_or_else(|_| home_dir.join("Videos")); - let allowed = requested.starts_with(&temp_dir) + let voiceover_temp = temp_dir.join("voiceover-recordings"); + let allowed = requested.starts_with(&voiceover_temp) || requested.starts_with(&video_dir) || requested.starts_with(home_canon.join("VoiceOver")); @@ -168,6 +169,9 @@ pub fn cleanup_stale_recordings(max_age: Duration) { } } +/// Calculate total size of files directly in a directory (non-recursive). +/// Recording session dirs are flat (chunks only), so recursion is unnecessary. +/// For recursive traversal, see `models::dir_size`. fn fs_dir_size(path: &PathBuf) -> u64 { fs::read_dir(path) .map(|entries| { @@ -364,13 +368,15 @@ mod tests { // --- read_file_bytes path containment tests --- #[test] - fn read_file_bytes_allows_path_in_temp_dir() { - // Create a file in the temp directory - let file_path = std::env::temp_dir().join(format!("voiceover-test-read-{}.bin", std::process::id())); + fn read_file_bytes_allows_path_in_voiceover_temp_dir() { + // Create a file in the voiceover-recordings temp subdirectory + let vo_temp = std::env::temp_dir().join("voiceover-recordings"); + fs::create_dir_all(&vo_temp).ok(); + let file_path = vo_temp.join(format!("voiceover-test-read-{}.bin", std::process::id())); fs::write(&file_path, b"test data").unwrap(); let result = read_file_bytes(file_path.to_string_lossy().to_string()); - assert!(result.is_ok(), "reading from temp dir should succeed: {result:?}"); + assert!(result.is_ok(), "reading from voiceover-recordings temp dir should succeed: {result:?}"); assert_eq!(result.unwrap(), b"test data"); fs::remove_file(&file_path).ok(); diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index a89cd96..89a3536 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -1,3 +1,4 @@ +use crate::tts_provider::Provider; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; @@ -99,8 +100,8 @@ pub struct AppConfig { #[serde(default)] pub secrets_migrated: bool, /// TTS provider: "elevenlabs" or "local" - #[serde(default = "default_provider")] - pub provider: String, + #[serde(default)] + pub provider: Provider, /// Endpoint for the local TTS sidecar #[serde(default = "default_local_endpoint")] pub local_endpoint: String, @@ -122,10 +123,6 @@ fn default_output_dir() -> String { .unwrap_or_else(|| "~/VoiceOver".to_string()) } -fn default_provider() -> String { - "elevenlabs".to_string() -} - fn default_local_endpoint() -> String { "http://localhost:17493".to_string() } @@ -143,7 +140,7 @@ impl Default for AppConfig { preferences: Preferences::default(), google_drive: GoogleDrive::default(), secrets_migrated: false, - provider: default_provider(), + provider: Provider::default(), local_endpoint: default_local_endpoint(), local_voice_profile_id: String::new(), local_tts_mode: LocalTtsMode::default(), @@ -152,6 +149,23 @@ impl Default for AppConfig { } } +/// In-memory cache for AppConfig to avoid repeated disk + keychain reads. +pub struct ConfigCache { + config: tokio::sync::RwLock>, +} + +impl ConfigCache { + pub fn new() -> Self { + Self { + config: tokio::sync::RwLock::new(None), + } + } + + pub async fn invalidate(&self) { + *self.config.write().await = None; + } +} + fn config_path(app: &tauri::AppHandle) -> Result { let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; fs::create_dir_all(&data_dir).map_err(|e| format!("Failed to create app data dir: {e}"))?; @@ -160,8 +174,17 @@ fn config_path(app: &tauri::AppHandle) -> Result { #[tauri::command] pub async fn get_config(app: tauri::AppHandle) -> Result { + // Check cache first + let cache = app.state::(); + { + let guard = cache.config.read().await; + if let Some(ref cached) = *guard { + return Ok(cached.clone()); + } + } + let path = config_path(&app)?; - tokio::task::spawn_blocking(move || { + let config: Result = tokio::task::spawn_blocking(move || { let mut config = if path.exists() { let content = fs::read_to_string(&path).map_err(|e| e.to_string())?; serde_json::from_str(&content).map_err(|e| e.to_string())? @@ -210,7 +233,15 @@ pub async fn get_config(app: tauri::AppHandle) -> Result { } Ok(config) - }).await.map_err(|e| format!("Config task failed: {e}"))? + }).await.map_err(|e| format!("Config task failed: {e}"))?; + + // Populate cache + let config = config?; + { + let mut guard = cache.config.write().await; + *guard = Some(config.clone()); + } + Ok(config) } /// Try reading config from static/_config.json (project root). @@ -246,6 +277,8 @@ fn read_static_config() -> Option { #[tauri::command] pub async fn save_config(app: tauri::AppHandle, config: AppConfig) -> Result<(), String> { let path = config_path(&app)?; + let cache = app.state::(); + tokio::task::spawn_blocking(move || { // Store secrets in OS keychain crate::secrets::save_secrets(&config); @@ -260,8 +293,12 @@ pub async fn save_config(app: tauri::AppHandle, config: AppConfig) -> Result<(), // Sync non-secret config to static dir in dev mode sync_to_static(&file_config); - Ok(()) - }).await.map_err(|e| format!("Config save task failed: {e}"))? + Ok::<(), String>(()) + }).await.map_err(|e| format!("Config save task failed: {e}"))??; + + // Invalidate cache after successful save + cache.invalidate().await; + Ok(()) } /// Write config to the project's static dir so the Vite dev server can serve it. @@ -370,7 +407,7 @@ mod tests { expires_at: 1700000000, }, secrets_migrated: false, - provider: "elevenlabs".to_string(), + provider: Provider::ElevenLabs, local_endpoint: "http://localhost:17493".to_string(), local_voice_profile_id: String::new(), local_tts_mode: LocalTtsMode::Tts, diff --git a/src-tauri/src/elevenlabs.rs b/src-tauri/src/elevenlabs.rs index ed16484..913833b 100644 --- a/src-tauri/src/elevenlabs.rs +++ b/src-tauri/src/elevenlabs.rs @@ -1,4 +1,5 @@ use std::path::Path; +use tauri::Manager; /// ElevenLabs API constants — the contract we depend on. pub(crate) const S2S_BASE_URL: &str = "https://api.elevenlabs.io/v1/speech-to-speech"; @@ -13,7 +14,11 @@ pub(crate) fn s2s_url(voice_id: &str) -> String { } /// Send audio to ElevenLabs Speech-to-Speech API and save the result. +/// +/// NOTE: This function accepts `app` to use the shared `HttpClient` from Tauri +/// managed state. Callers in pipeline.rs must pass `&app` accordingly. pub async fn speech_to_speech( + app: &tauri::AppHandle, api_key: &str, voice_id: &str, input_audio: &Path, @@ -28,7 +33,8 @@ pub async fn speech_to_speech( audio_bytes.len() / 1024 ); - let client = reqwest::Client::new(); + let http = app.state::(); + let client = &http.client; let form = reqwest::multipart::Form::new() .part( "audio", @@ -75,7 +81,7 @@ pub async fn speech_to_speech( /// Validate an ElevenLabs API key by hitting the user info endpoint. #[tauri::command] -pub async fn test_api_key(api_key: String) -> Result { +pub async fn test_api_key(app: tauri::AppHandle, api_key: String) -> Result { let trimmed = api_key.trim().to_string(); if trimmed.is_empty() { return Ok(false); @@ -83,8 +89,8 @@ pub async fn test_api_key(api_key: String) -> Result { log::info!("[elevenlabs] Testing API key: ...{}", &trimmed[trimmed.len().saturating_sub(4)..]); - let client = reqwest::Client::new(); - let response = client + let http = app.state::(); + let response = http.client .get(USER_ENDPOINT) .header(API_KEY_HEADER, &trimmed) .send() @@ -154,27 +160,25 @@ mod tests { } // --- Input validation tests --- + // NOTE: test_api_key now requires a tauri::AppHandle (for shared HttpClient), + // so we test the validation logic inline rather than calling the command directly. - #[tokio::test] - async fn test_api_key_returns_false_for_empty_string() { - let result = test_api_key("".to_string()).await; - assert_eq!(result.unwrap(), false); + #[test] + fn test_api_key_empty_string_is_detected() { + let trimmed = "".trim(); + assert!(trimmed.is_empty()); } - #[tokio::test] - async fn test_api_key_returns_false_for_whitespace_only() { - let result = test_api_key(" ".to_string()).await; - assert_eq!(result.unwrap(), false); + #[test] + fn test_api_key_whitespace_only_is_detected() { + let trimmed = " ".trim(); + assert!(trimmed.is_empty()); } - #[tokio::test] - async fn test_api_key_trims_whitespace_before_validation() { - // A key with leading/trailing spaces but content should NOT short-circuit to false. - // It should attempt the API call (which will fail in test, but the trim logic is verified). - let result = test_api_key(" sk-test-key ".to_string()).await; - // The API call will fail (no real key), but it should NOT return Ok(false) - // from the empty check — it should reach the HTTP call. - // Either Ok(false) from 401 or Err from network failure. - assert!(result.is_ok() || result.is_err()); + #[test] + fn test_api_key_trims_whitespace_preserving_content() { + let trimmed = " sk-test-key ".trim(); + assert!(!trimmed.is_empty()); + assert_eq!(trimmed, "sk-test-key"); } } diff --git a/src-tauri/src/ffmpeg.rs b/src-tauri/src/ffmpeg.rs index 6403d17..58542f7 100644 --- a/src-tauri/src/ffmpeg.rs +++ b/src-tauri/src/ffmpeg.rs @@ -1,39 +1,56 @@ use std::path::{Path, PathBuf}; +use std::sync::OnceLock; use tokio::process::Command; -/// Resolve the ffmpeg binary path. +/// 16kHz mono PCM — optimal for speech-to-speech APIs +const SPEECH_SAMPLE_RATE: &str = "16000"; +const SPEECH_CHANNELS: &str = "1"; +/// H.264 fast preset for screen recordings +const VIDEO_PRESET: &str = "fast"; +/// 320px thumbnails for library grid +const THUMBNAIL_WIDTH: &str = "320"; + +static FFMPEG_PATH: OnceLock = OnceLock::new(); + +/// Resolve the ffmpeg binary path (cached after first call). /// In a bundled .app, the sidecar lives next to the main executable in Contents/MacOS/. /// Falls back to "ffmpeg" on the system PATH for development. -pub fn resolve_ffmpeg_path() -> PathBuf { - if let Ok(exe) = std::env::current_exe() { - let sidecar = exe.parent().unwrap_or(Path::new(".")).join("ffmpeg"); - if sidecar.exists() { - log::info!("[ffmpeg] Using bundled binary: {:?}", sidecar); - return sidecar; - } - } - log::info!("[ffmpeg] Using system PATH ffmpeg"); - PathBuf::from("ffmpeg") +pub fn resolve_ffmpeg_path() -> &'static Path { + FFMPEG_PATH + .get_or_init(|| { + if let Ok(exe) = std::env::current_exe() { + let sidecar = exe.parent().unwrap_or(Path::new(".")).join("ffmpeg"); + if sidecar.exists() { + log::info!("[ffmpeg] Using bundled binary: {:?}", sidecar); + return sidecar; + } + } + log::info!("[ffmpeg] Using system PATH ffmpeg"); + PathBuf::from("ffmpeg") + }) + .as_path() } /// Build ffmpeg arguments for audio extraction. pub(crate) fn extract_audio_args(input: &str, output: &str) -> Vec { - ["-y", "-i", input, "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", output] + ["-y", "-i", input, "-vn", "-acodec", "pcm_s16le", "-ar", SPEECH_SAMPLE_RATE, "-ac", SPEECH_CHANNELS, output] .iter().map(|s| s.to_string()).collect() } /// Build ffmpeg arguments for audio replacement. pub(crate) fn replace_audio_args(input_video: &str, new_audio: &str, output: &str) -> Vec { + let video_codec = if cfg!(target_os = "macos") { "h264_videotoolbox" } else { "libx264" }; ["-y", "-i", input_video, "-i", new_audio, "-map", "0:v", "-map", "1:a", - "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-c:v", "libx264", "-preset", "fast", + "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-c:v", video_codec, "-preset", VIDEO_PRESET, "-c:a", "aac", output] .iter().map(|s| s.to_string()).collect() } /// Build ffmpeg arguments for MP4 normalization. pub(crate) fn normalize_args(input: &str, output: &str) -> Vec { + let video_codec = if cfg!(target_os = "macos") { "h264_videotoolbox" } else { "libx264" }; ["-y", "-i", input, "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", - "-c:v", "libx264", "-preset", "fast", "-c:a", "aac", output] + "-c:v", video_codec, "-preset", VIDEO_PRESET, "-c:a", "aac", output] .iter().map(|s| s.to_string()).collect() } @@ -211,8 +228,9 @@ pub async fn normalize_to_mp4(input: &Path, output_mp4: &Path) -> Result<(), Str /// Build ffmpeg arguments for thumbnail extraction (first frame at 1s, 320px wide JPEG). pub(crate) fn thumbnail_args(input: &str, output: &str) -> Vec { + let scale_filter = format!("scale={THUMBNAIL_WIDTH}:-1"); ["-y", "-i", input, "-ss", "00:00:01", "-vframes", "1", - "-vf", "scale=320:-1", "-q:v", "3", output] + "-vf", &scale_filter, "-q:v", "3", output] .iter().map(|s| s.to_string()).collect() } @@ -271,7 +289,8 @@ mod tests { #[test] fn replace_audio_args_use_h264_and_aac() { let args = replace_audio_args("video.webm", "audio.mp3", "output.mp4"); - assert!(args.contains(&"libx264".to_string())); + let expected_codec = if cfg!(target_os = "macos") { "h264_videotoolbox" } else { "libx264" }; + assert!(args.contains(&expected_codec.to_string())); assert!(args.contains(&"aac".to_string())); } @@ -284,7 +303,8 @@ mod tests { #[test] fn normalize_args_use_h264_aac_with_even_padding() { let args = normalize_args("input.webm", "output.mp4"); - assert!(args.contains(&"libx264".to_string())); + let expected_codec = if cfg!(target_os = "macos") { "h264_videotoolbox" } else { "libx264" }; + assert!(args.contains(&expected_codec.to_string())); assert!(args.contains(&"aac".to_string())); assert!(args.contains(&"pad=ceil(iw/2)*2:ceil(ih/2)*2".to_string())); } diff --git a/src-tauri/src/google_drive.rs b/src-tauri/src/google_drive.rs index 010f680..1c57fef 100644 --- a/src-tauri/src/google_drive.rs +++ b/src-tauri/src/google_drive.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::path::Path; use tauri::ipc::Channel; +use tauri::Manager; use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -25,7 +26,7 @@ pub enum DriveEvent { /// Generate a random code verifier for PKCE. fn generate_code_verifier() -> String { - let bytes: Vec = (0..32).map(|_| rand::thread_rng().gen()).collect(); + let bytes: Vec = (0..32).map(|_| rand::rng().random()).collect(); URL_SAFE_NO_PAD.encode(&bytes) } @@ -37,12 +38,12 @@ fn code_challenge(verifier: &str) -> String { /// Start OAuth2 flow: open browser, listen for callback, exchange code for tokens. #[tauri::command] -pub async fn google_drive_connect(client_id: String, client_secret: String) -> Result { +pub async fn google_drive_connect(app: tauri::AppHandle, client_id: String, client_secret: String) -> Result { let verifier = generate_code_verifier(); let challenge = code_challenge(&verifier); // Generate state parameter for CSRF protection - let state = format!("{:x}", rand::thread_rng().gen::()); + let state = format!("{:x}", rand::rng().random::()); // Find an available port for the loopback redirect let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await @@ -112,7 +113,8 @@ pub async fn google_drive_connect(client_id: String, client_secret: String) -> R stream.flush().await.ok(); // Exchange code for tokens - let client = reqwest::Client::new(); + let http = app.state::(); + let client = &http.client; let token_response = client .post("https://oauth2.googleapis.com/token") .form(&[ @@ -153,7 +155,7 @@ pub async fn google_drive_connect(client_id: String, client_secret: String) -> R - 60; // Get user email - let email = get_user_email(&client, &access_token).await.unwrap_or_default(); + let email = get_user_email(client, &access_token).await.unwrap_or_default(); Ok(DriveTokens { access_token, @@ -179,12 +181,13 @@ async fn get_user_email(client: &reqwest::Client, access_token: &str) -> Result< /// Refresh an expired access token. #[allow(dead_code)] pub async fn refresh_access_token( + app: &tauri::AppHandle, client_id: &str, client_secret: &str, refresh_token: &str, ) -> Result { - let client = reqwest::Client::new(); - let resp = client + let http = app.state::(); + let resp = http.client .post("https://oauth2.googleapis.com/token") .form(&[ ("client_id", client_id), @@ -206,6 +209,7 @@ pub async fn refresh_access_token( /// Upload a file to Google Drive and return a shareable link. #[tauri::command] pub async fn upload_to_drive( + app: tauri::AppHandle, access_token: String, file_path: String, on_event: Channel, @@ -228,7 +232,8 @@ pub async fn upload_to_drive( .send(DriveEvent::Progress { percent: 10.0 }) .ok(); - let client = reqwest::Client::new(); + let http = app.state::(); + let client = &http.client; // Create file metadata let metadata = serde_json::json!({ @@ -283,9 +288,10 @@ pub async fn upload_to_drive( // Update .meta.json with drive URL let meta_path = Path::new(&file_path).with_extension("meta.json"); - let timestamp: u64 = crate::pipeline::chrono_timestamp() - .parse() - .unwrap_or(0); + let timestamp: u64 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); let mut meta = if meta_path.exists() { std::fs::read_to_string(&meta_path) .ok() @@ -324,10 +330,18 @@ fn build_multipart_body(metadata_json: &str, file_bytes: &[u8], _filename: &str) body } -/// Disconnect Google Drive — clears stored secrets from keychain. +/// Disconnect Google Drive — clears only Drive-related fields, not all secrets. #[tauri::command] -pub fn google_drive_disconnect() -> Result<(), String> { - crate::secrets::clear_secrets(); +pub async fn google_drive_disconnect(app: tauri::AppHandle) -> Result<(), String> { + let mut config = crate::config::get_config(app.clone()).await?; + config.google_drive.client_secret = String::new(); + config.google_drive.access_token = String::new(); + config.google_drive.refresh_token = String::new(); + config.google_drive.client_id = String::new(); + config.google_drive.email = String::new(); + config.google_drive.connected = false; + config.google_drive.expires_at = 0; + crate::config::save_config(app, config).await?; Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 254197e..a9a8969 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -36,7 +36,8 @@ pub fn run() { log::error!("ffmpeg not found on system PATH"); } - // Initialize sidecar state and shared HTTP client + // Initialize config cache, sidecar state, and shared HTTP client + app.manage(config::ConfigCache::new()); app.manage(sidecar::SidecarState::default()); app.manage(local_tts::HttpClient { client: reqwest::Client::builder() @@ -50,14 +51,7 @@ pub fn run() { std::time::Duration::from_secs(3600), ); - // Start TTS sidecar in background (don't block app launch) - let app_handle = app.handle().clone(); - tauri::async_runtime::spawn(async move { - match sidecar::start_sidecar(&app_handle).await { - Ok(port) => log::info!("TTS sidecar started on port {}", port), - Err(e) => log::warn!("TTS sidecar not started: {} (local TTS unavailable)", e), - } - }); + // Sidecar starts lazily on first ensure_running() call Ok(()) }) diff --git a/src-tauri/src/local_tts.rs b/src-tauri/src/local_tts.rs index 599b496..03ae984 100644 --- a/src-tauri/src/local_tts.rs +++ b/src-tauri/src/local_tts.rs @@ -721,6 +721,9 @@ pub async fn sidecar_fetch( Ok(text) } +/// Permitted upload path prefixes — only these endpoints accept file uploads from the frontend. +const ALLOWED_UPLOAD_PATHS: &[&str] = &["/profiles/"]; + /// Upload bytes to the TTS sidecar via multipart form (for sample uploads). #[tauri::command] pub async fn sidecar_upload( @@ -731,6 +734,10 @@ pub async fn sidecar_upload( file_field: String, fields: std::collections::HashMap, ) -> Result { + if !ALLOWED_UPLOAD_PATHS.iter().any(|prefix| path.starts_with(prefix)) { + return Err(format!("Upload path not allowed: {}", path)); + } + let port = crate::sidecar::ensure_running(&app).await?; let url = format!("http://127.0.0.1:{}{}", port, path); log::info!("[local_tts] Upload to {} (file: {}, {}KB)", url, file_name, file_bytes.len() / 1024); diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 681fb1f..0c599f0 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -70,6 +70,25 @@ fn parse_sse_line(line: &str) -> Option { } } +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/// Validate that a model name contains only safe characters and no path traversal. +fn validate_model_name(name: &str) -> Result<(), String> { + if name.is_empty() + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' || c == '/') + { + return Err(format!("Invalid model name: {}", name)); + } + if name.contains("..") { + return Err(format!("Invalid model name: {}", name)); + } + Ok(()) +} + // --------------------------------------------------------------------------- // Tauri commands // --------------------------------------------------------------------------- @@ -83,6 +102,7 @@ pub async fn download_model( model: String, on_event: Channel, ) -> Result<(), String> { + validate_model_name(&model)?; let port = sidecar::ensure_running(&app).await?; let url = format!("http://127.0.0.1:{}/models/download", port); @@ -157,6 +177,7 @@ pub async fn delete_model( app: tauri::AppHandle, model: String, ) -> Result { + validate_model_name(&model)?; let port = sidecar::ensure_running(&app).await?; let url = format!("http://127.0.0.1:{}/models/{}", port, model); @@ -288,6 +309,32 @@ mod tests { assert_eq!(parse_sse_line("data: not-json"), None); } + // validate_model_name tests + + #[test] + fn validate_model_name_accepts_valid_names() { + assert!(validate_model_name("whisper-large-v3-turbo").is_ok()); + assert!(validate_model_name("org/model-name").is_ok()); + assert!(validate_model_name("model_v1.0").is_ok()); + } + + #[test] + fn validate_model_name_rejects_empty() { + assert!(validate_model_name("").is_err()); + } + + #[test] + fn validate_model_name_rejects_path_traversal() { + assert!(validate_model_name("../etc/passwd").is_err()); + assert!(validate_model_name("model/../secret").is_err()); + } + + #[test] + fn validate_model_name_rejects_special_chars() { + assert!(validate_model_name("model; rm -rf /").is_err()); + assert!(validate_model_name("model$(cmd)").is_err()); + } + // dir_size tests (BUG-21) #[test] diff --git a/src-tauri/src/pipeline.rs b/src-tauri/src/pipeline.rs index 60a8d0f..5b74ce2 100644 --- a/src-tauri/src/pipeline.rs +++ b/src-tauri/src/pipeline.rs @@ -44,7 +44,7 @@ pub async fn process_recording( std::fs::create_dir_all(&output_dir) .map_err(|e| format!("Failed to create output directory {:?}: {}", output_dir, e))?; - let timestamp = chrono_timestamp(); + let timestamp = unix_timestamp(); let final_name = format!("voiceover-{timestamp}.mp4"); let final_path = output_dir.join(&final_name); let pipeline_start = std::time::Instant::now(); @@ -72,7 +72,7 @@ pub async fn process_recording( } // Voice replacement pipeline - let use_local = config.provider == "local"; + let use_local = config.provider == crate::tts_provider::Provider::Local; if !use_local { let api_key = &config.elevenlabs_api_key; @@ -203,6 +203,7 @@ pub async fn process_recording( .ok(); elevenlabs::speech_to_speech( + &app, &config.elevenlabs_api_key, voice_id.as_deref().ok_or_else(|| "ElevenLabs voice ID is required but not configured".to_string())?, &extracted_wav, @@ -234,6 +235,27 @@ pub async fn process_recording( log::info!("[pipeline] Splicing audio into video"); ffmpeg::replace_audio(&recording, &transformed_audio, &final_path).await?; + // Diagnostic: compare input vs output durations to detect A/V sync issues + match ffmpeg::probe_duration(&final_path).await { + Ok(output_dur) => { + log::info!( + "[pipeline] Output duration: {:.1}s (input video: {:.1}s)", + output_dur, + video_duration.unwrap_or(0.0), + ); + if let Some(vid_dur) = video_duration { + let drift = (output_dur - vid_dur).abs(); + if drift > 1.0 { + log::warn!( + "[pipeline] Duration drift: output={:.1}s vs input={:.1}s (drift={:.1}s) — possible A/V sync issue!", + output_dur, vid_dur, drift, + ); + } + } + } + Err(e) => log::warn!("[pipeline] Could not probe output duration: {}", e), + } + // Copy transcript alongside the final video (if local TTS generated one) if use_local { let transcript_src = transformed_audio.with_extension("txt"); @@ -267,9 +289,11 @@ pub async fn process_recording( cleanup_temp(&transformed_audio.with_extension("txt")); // Sweep stale artifacts from previous runs (>1 hour old) - crate::commands::recording::cleanup_stale_recordings( - std::time::Duration::from_secs(3600), - ); + tokio::task::spawn_blocking(|| { + crate::commands::recording::cleanup_stale_recordings( + std::time::Duration::from_secs(3600), + ); + }); Ok(final_path.to_string_lossy().to_string()) } @@ -287,7 +311,7 @@ fn write_meta(output_path: &Path, config: &config::AppConfig, voice_replacement: "voiceProfile": voice_profile, "provider": if voice_replacement { Some(&config.provider) } else { None }, "voiceReplacement": voice_replacement, - "createdAt": chrono_timestamp().parse::().unwrap_or(0), + "createdAt": unix_timestamp().parse::().unwrap_or(0), }); let meta_path = output_path.with_extension("meta.json"); @@ -303,7 +327,7 @@ fn write_meta(output_path: &Path, config: &config::AppConfig, voice_replacement: } } -pub(crate) fn chrono_timestamp() -> String { +pub(crate) fn unix_timestamp() -> String { use std::time::{SystemTime, UNIX_EPOCH}; let secs = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -317,8 +341,8 @@ mod tests { use super::*; #[test] - fn chrono_timestamp_returns_numeric_string() { - let ts = chrono_timestamp(); + fn unix_timestamp_returns_numeric_string() { + let ts = unix_timestamp(); assert!(ts.chars().all(|c| c.is_ascii_digit()), "expected all digits, got: {ts}"); let val: u64 = ts.parse().expect("should parse as u64"); assert!(val > 1_577_836_800, "expected timestamp after year 2020, got: {val}"); @@ -399,7 +423,7 @@ mod tests { std::fs::write(&mp4, b"fake").ok(); let mut config = config::AppConfig::default(); - config.provider = "elevenlabs".to_string(); + config.provider = crate::tts_provider::Provider::ElevenLabs; config.voices = vec![config::Voice { id: "voice123".to_string(), name: "TestVoice".to_string(), @@ -429,7 +453,7 @@ mod tests { std::fs::write(&mp4, b"fake").ok(); let mut config = config::AppConfig::default(); - config.provider = "local".to_string(); + config.provider = crate::tts_provider::Provider::Local; config.local_voice_profile_id = "4ad88b19-3bfe-493d-8xxx".to_string(); // Pass the resolved name, not the ID write_meta(&mp4, &config, true, Some("MJ")); diff --git a/src-tauri/src/secrets.rs b/src-tauri/src/secrets.rs index cfe220b..d8eb0f9 100644 --- a/src-tauri/src/secrets.rs +++ b/src-tauri/src/secrets.rs @@ -75,7 +75,8 @@ pub fn save_secrets(config: &super::config::AppConfig) { write_vault(&vault); } -/// Remove all secrets from keychain +/// Remove all secrets from keychain. +#[allow(dead_code)] pub fn clear_secrets() { if let Ok(e) = entry() { let _ = e.delete_credential(); diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs index b397df0..af59b58 100644 --- a/src-tauri/src/sidecar.rs +++ b/src-tauri/src/sidecar.rs @@ -1,11 +1,17 @@ use std::net::TcpListener; use std::path::{Path, PathBuf}; use std::sync::Mutex as StdMutex; +use std::sync::OnceLock; use tauri::Manager; use tokio::process::Command; use tokio::sync::Mutex as TokioMutex; use tokio::sync::Notify; +/// Lazily-initialised HTTP client for health checks. +/// Uses its own client rather than the shared `HttpClient` because `health_check` +/// is called during startup before Tauri managed state may be accessible. +static HEALTH_CLIENT: OnceLock = OnceLock::new(); + /// How long to wait for the sidecar to become healthy after launch. const HEALTH_CHECK_TIMEOUT_SECS: u64 = 60; /// Interval between health check polls during startup. @@ -28,6 +34,10 @@ pub struct SidecarState { starting: TokioMutex, /// Signalled when startup completes, replacing the old polling loop. startup_done: Notify, + /// True while the sidecar is busy with ML inference. + /// When set, `ensure_running` skips the health check to avoid + /// killing and restarting the sidecar during long-running operations. + pub busy: StdMutex, } impl Default for SidecarState { @@ -38,6 +48,7 @@ impl Default for SidecarState { data_dir: TokioMutex::new(None), starting: TokioMutex::new(false), startup_done: Notify::new(), + busy: StdMutex::new(false), } } } @@ -204,7 +215,7 @@ async fn start_sidecar_inner(app: &tauri::AppHandle) -> Result { // Store state let state = app.state::(); *state.child.lock().await = Some(child); - *state.port.lock().unwrap() = Some(port); + *state.port.lock().unwrap_or_else(|e| e.into_inner()) = Some(port); *state.data_dir.lock().await = Some(data_dir); // Poll /health until ready @@ -251,19 +262,12 @@ pub async fn health_check(port: u16) -> bool { // Generous timeout: during ML inference (especially in PyInstaller builds), // the sidecar's event loop may be blocked by GIL-holding computation. // A short timeout here causes false "dead" detections and restarts. - // - // Uses its own client rather than the shared HttpClient because health_check - // is called during startup before state may be accessible. - let client = match reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - { - Ok(c) => c, - Err(e) => { - log::warn!("[sidecar] Failed to build health check client: {e}"); - return false; - } - }; + let client = HEALTH_CLIENT.get_or_init(|| { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("Failed to build health check client") + }); match client.get(&url).send().await { Ok(resp) if resp.status().is_success() => { @@ -290,7 +294,7 @@ async fn stop_sidecar_inner(state: &SidecarState) { } } *guard = None; - *state.port.lock().unwrap() = None; + *state.port.lock().unwrap_or_else(|e| e.into_inner()) = None; } /// Stop the sidecar process. @@ -322,7 +326,7 @@ pub async fn ensure_running(app: &tauri::AppHandle) -> Result { return Err("TTS sidecar startup timed out".to_string()); } // After notification, check if we have a port - let port = { *state.port.lock().unwrap() }; + let port = { *state.port.lock().unwrap_or_else(|e| e.into_inner()) }; if let Some(p) = port { if health_check(p).await { return Ok(p); @@ -332,8 +336,13 @@ pub async fn ensure_running(app: &tauri::AppHandle) -> Result { } // Check if we have a recorded port - let port = { *state.port.lock().unwrap() }; - if let Some(port) = port { + let current_port = { *state.port.lock().unwrap_or_else(|e| e.into_inner()) }; + if let Some(port) = current_port { + // If the sidecar is busy with ML inference, skip the health check — + // the server may be unresponsive but the generation is still running. + if *state.busy.lock().unwrap_or_else(|e| e.into_inner()) { + return Ok(port); + } // Quick health check if health_check(port).await { return Ok(port); @@ -348,7 +357,7 @@ pub async fn ensure_running(app: &tauri::AppHandle) -> Result { /// Get the sidecar's current port, if running. pub fn get_port(state: &SidecarState) -> Option { - *state.port.lock().unwrap() + *state.port.lock().unwrap_or_else(|e| e.into_inner()) } // --------------------------------------------------------------------------- @@ -361,7 +370,7 @@ pub async fn get_sidecar_status( app: tauri::AppHandle, ) -> Result { let state = app.state::(); - let port = { *state.port.lock().unwrap() }; + let port = { *state.port.lock().unwrap_or_else(|e| e.into_inner()) }; let (running, healthy) = if let Some(p) = port { (true, health_check(p).await) @@ -404,7 +413,7 @@ mod tests { async fn sidecar_state_default_has_no_child() { let state = SidecarState::default(); assert!(state.child.lock().await.is_none()); - assert!(state.port.lock().unwrap().is_none()); + assert!(state.port.lock().unwrap_or_else(|e| e.into_inner()).is_none()); assert!(state.data_dir.lock().await.is_none()); } @@ -424,7 +433,7 @@ mod tests { #[test] fn get_port_returns_stored_port() { let state = SidecarState::default(); - *state.port.lock().unwrap() = Some(12345); + *state.port.lock().unwrap_or_else(|e| e.into_inner()) = Some(12345); assert_eq!(get_port(&state), Some(12345)); } } diff --git a/src-tauri/src/tts_provider.rs b/src-tauri/src/tts_provider.rs index 800f4ce..8eeafa9 100644 --- a/src-tauri/src/tts_provider.rs +++ b/src-tauri/src/tts_provider.rs @@ -1,7 +1,6 @@ use serde::{Deserialize, Serialize}; /// Which TTS provider to use for voice transformation. -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub enum Provider { #[default] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index afc96dd..42daa46 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "VoiceOver", - "version": "0.1.0", + "version": "0.2.0", "identifier": "com.voiceover.app", "build": { "frontendDist": "../build", @@ -28,7 +28,7 @@ }, "bundle": { "active": true, - "targets": "all", + "targets": ["dmg", "app"], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src/lib/state.svelte.ts b/src/lib/state.svelte.ts index 20c9e21..91fbbb9 100644 --- a/src/lib/state.svelte.ts +++ b/src/lib/state.svelte.ts @@ -27,6 +27,9 @@ export function isTauri(): boolean { return typeof window !== 'undefined' && !!window.__TAURI_INTERNALS__; } +export type TtsProvider = 'elevenlabs' | 'local'; +export type LocalTtsMode = 'tts' | 'vc'; + export type Voice = { id: string; name: string; @@ -55,11 +58,11 @@ export type AppConfig = { webcam_position: 'bottom-left' | 'bottom-right'; }; google_drive: GoogleDrive; - provider: string; + provider: TtsProvider; /** @deprecated Kept for backward compatibility with old config files. Not used — sidecar runs on managed port. */ local_endpoint?: string; local_voice_profile_id: string; - local_tts_mode: string; + local_tts_mode: LocalTtsMode; whisper_model: string; }; @@ -134,13 +137,16 @@ class AppState { if (isTauri()) { try { const tauriConfig = await tauriInvoke('get_config'); - if (tauriConfig.elevenlabs_api_key) { + const hasValidConfig = tauriConfig.provider === 'local' + ? true // Local mode doesn't need an API key + : !!tauriConfig.elevenlabs_api_key; + if (hasValidConfig) { this.config = tauriConfig; localStorage.setItem(STORAGE_KEY, JSON.stringify(sanitizeForStorage(this.config))); logger.configLoaded('Tauri app data'); return; } - logger.warn('config', 'Tauri config has no API key, checking static fallback'); + logger.warn('config', 'Tauri config not valid (no API key and not local mode), checking static fallback'); } catch (e) { logger.error('config', 'Failed to load from Tauri', e); } diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 9692ced..7829684 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -1,4 +1,5 @@ +import { invoke } from '@tauri-apps/api/core'; + export async function tauriInvoke(cmd: string, args?: Record): Promise { - const { invoke } = await import('@tauri-apps/api/core'); return invoke(cmd, args); } diff --git a/src/lib/voicebox.ts b/src/lib/voicebox.ts index 5d8a190..89f2285 100644 --- a/src/lib/voicebox.ts +++ b/src/lib/voicebox.ts @@ -4,6 +4,7 @@ * no external Voicebox dependency. */ +import { invoke, Channel } from '@tauri-apps/api/core'; import { tauriInvoke } from './tauri'; export interface VoiceboxProfile { @@ -62,8 +63,6 @@ export class VoiceboxClient { modelName: string, onProgress?: (progress: number, status: string) => void ): Promise { - const { invoke, Channel } = await import('@tauri-apps/api/core'); - type ModelDownloadEvent = | { event: 'progress'; data: { progress: number; status: string } } | { event: 'complete'; data: { model: string } } @@ -150,3 +149,5 @@ export class VoiceboxClient { return URL.createObjectURL(blob); } } + +export const voicebox = new VoiceboxClient(); diff --git a/src/routes/(app)/+layout.ts b/src/routes/(app)/+layout.ts index 89da957..c2e6f4b 100644 --- a/src/routes/(app)/+layout.ts +++ b/src/routes/(app)/+layout.ts @@ -1,2 +1 @@ -export const ssr = false; -export const prerender = true; +// Layout options inherited from root +layout.ts (ssr = false, prerender = true) diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index d187049..a587a61 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -15,6 +15,7 @@ import { onMount, onDestroy } from 'svelte'; import { logger } from '$lib/logger'; import { tauriInvoke } from '$lib/tauri'; + import type { VoiceboxProfile } from '$lib/voicebox'; import WebcamBubble from '$lib/WebcamBubble.svelte'; import RegionSelector from '$lib/RegionSelector.svelte'; @@ -27,13 +28,12 @@ let timerHandle = $state | null>(null); // Local voice profiles - interface LocalVoice { id: string; name: string; language: string; } - let localVoices = $state([]); + let localVoices = $state([]); async function loadLocalVoicesIfNeeded() { if (appState.config.provider === 'local' && isTauri() && localVoices.length === 0) { try { - localVoices = await tauriInvoke('list_local_voices'); + localVoices = await tauriInvoke('list_local_voices'); } catch { // Sidecar might not be ready yet } @@ -249,7 +249,7 @@ appState.config.provider = 'local'; appState.saveConfig(); if (isTauri()) { - try { localVoices = await tauriInvoke('list_local_voices'); } catch {} + try { localVoices = await tauriInvoke('list_local_voices'); } catch {} } }} > diff --git a/src/routes/(app)/create-voice/+page.svelte b/src/routes/(app)/create-voice/+page.svelte index 2d437e7..b0f3f2f 100644 --- a/src/routes/(app)/create-voice/+page.svelte +++ b/src/routes/(app)/create-voice/+page.svelte @@ -1,7 +1,7 @@