Skip to content

Latest commit

 

History

History
172 lines (152 loc) · 41.8 KB

File metadata and controls

172 lines (152 loc) · 41.8 KB

Timbre — TODO

Living list of work still to do. Items marked [x] are complete; [ ] are pending. Add new items as we agree to them; tick them off as they ship.

Remaining for v1

  • Fold Voices Import into the Add-Voice dialog — replace the confusing pair of Voices-page header buttons (Import + Add voice) with a single Add voice entry point. Inside VoiceCreateDialog, add a third source tile (Record / File / Import) that opens a .timbrevoice picker, shows the safety warning inline (replacing the window.confirm), hides Name/Transcript, and calls voices.import_prompts. Plan at ~/.claude/plans/plan-a-change-to-mossy-salamander.md.
  • Linux x86_64 builds + ROCm (Linux & Windows) + Nix package + torch 2.6→2.8 bump — single PR adding Linux as a built target (.deb / .AppImage / .rpm), a new Backend::Rocm variant whose torch lane branches on OS (Linux uses PyTorch whl/rocm6.4; Windows uses AMD's direct repo.radeon.com/rocm/windows/rocm-rel-6.4.4 wheel URLs), cu124cu128 for the CUDA lane, FirstRun CPU/CUDA/ROCm cards on non-Mac with OS-aware ROCm subtitle, device_label + transcribe.py HIP handling, Linux CI job in qa-builds.yml, and a Linux x86_64-only packages.timbre derivation in flake.nix (nix/timbre.nix). Plan at ~/.claude/plans/plan-a-change-to-snoopy-rainbow.md. Verification span: Linux/Windows/macOS smoke tests across CPU/CUDA/ROCm and a Nix build.
  • Auto-detect & select compute backend at FirstRun — Rust gpu.rs probes for NVIDIA/AMD GPUs (Linux /sys/bus/pci scan, Windows DXGI via the windows crate; macOS skipped) behind a detect_backends IPC command. FirstRun filters the picker to detected hardware (+ CPU), pre-selects the recommendation (MPS > CUDA > ROCm > CPU, NVIDIA wins ties) with a "Show all options" escape hatch, and falls back to all options with CPU selected when nothing is detected. New docs/supported-hardware.md documents the supported hardware matrix (CUDA follows PyTorch 2.8 cu128, with Maxwell/Pascal removed; ROCm follows AMD's 6.4.4 Radeon/Ryzen Linux and Windows matrices). Plan at ~/.claude/plans/perform-a-full-review-sharded-pillow.md.
  • direnv auto-installs deps — extend .envrc so node + python deps are installed (and kept fresh) on shell entry, idempotently.
  • Run end-to-end oncepnpm tauri:dev through FirstRun → install → Studio → synthesize a short clip. Surface and fix the first wave of runtime bugs.
  • License acknowledgement gate — modal + persisted ack for F5 (CC-BY-NC) and XTTS (CPML). Manifest already flags license_acknowledgement_required.
  • Manual QA on packaged macOS/Windows builds — install the unsigned macOS Apple Silicon and Windows x64 artifacts, run first-run backend install, install a model, synthesize, play, and export audio.
  • Windows build: cmd-window popups + empty Models route — implementation landed, awaiting QA on a Windows VM. Hide subprocess consoles with CREATE_NO_WINDOW on the sidecar spawn (src-tauri/src/sidecar.rs) and the shared run_uv helper (src-tauri/src/backend_pack.rs). Force binary mode on the duplicated RPC stdout fd via msvcrt.setmode(fd, O_BINARY) in py/timbre/__main__.py so the JSON-RPC \r\n framing isn't CRLF-mangled by the CRT. Added a tracing-appender daily rolling file sink under paths::cache_dir()/logs/timbre.log (src-tauri/src/lib.rs) so the GUI-subsystem release build has any diagnostics at all, and made paths::resource_path log each candidate it tries plus the tried-list in the error so a missing manifest is debuggable from that file.

v1 stability review (2026-05-16)

Full codebase review covering Rust shell, Python sidecar, React frontend, and build/CI. Items are sorted by severity; each line names the file + the fix.

Critical — release-blockers

  • Path traversal in archive extractionsrc-tauri/src/backend_pack.rs:334-350 extract_archive calls tar.unpack() / zip.extract() without checking that each entry stays inside dst. A maliciously crafted python-build-standalone tarball or uv zip could write outside the data dir (zip-slip / tar-slip). Iterate entries manually and reject any whose normalised path escapes dst or contains ... Same hardening needed on the Python side for voices_import_prompts.
  • Unsafe pickle on imported voice promptspy/timbre/adapters/base.py:default_deserialize_payload uses pickle.loads() on .pkl files unpacked from .timbrevoice archives. Archives can be shared between users, so pickle is an RCE vector. Replace pickle with a restricted format (safetensors for tensors + JSON for scalars), or wrap pickle.Unpickler.find_class with an allow-list of torch.*, numpy.*, and the adapter's payload dataclass. Reject archives whose schema_version doesn't match the current adapter.
  • Sidecar RPC has no timeoutsrc-tauri/src/sidecar.rs:171-214 Sidecar::call() awaits a oneshot::Receiver indefinitely. A hung sidecar (model load deadlock, OOM, etc.) hangs every IPC forever and the UI cannot recover without a kill. Add a per-call timeout via tokio::time::timeout (e.g. 5 min for synth.run, 30 s for everything else) and propagate the timeout as a typed RPC error so the frontend can offer a "restart sidecar" action. Same fix on Sidecar::stop() so app quit can't hang.
  • Generate button double-firessrc/routes/Studio.tsx synth mutation flips its in-flight gate inside onSuccess/onError, so a fast double-click passes the if (running) … guard twice before the first RPC resolves. Set the in-flight flag synchronously inside onMutate (or gate the button on synth.isPending) and reject the second invocation. Same pattern on Models.tsx Install/Remove and VoicePrepareControl.tsx Prepare buttons.
  • Event listeners leak on FirstRun / Models unmountsrc/routes/FirstRun.tsx:38-42 and src/routes/Models.tsx:94-172 register Tauri listeners via listen(...).then(off => offs.push(off)). If the component unmounts before the promise resolves, off is never captured; each remount stacks another listener and stale callbacks fire setState on dead components. Pattern: useEffect(() => { let active = true; let off: UnlistenFn | undefined; (async () => { off = await listen(...); if (!active) off(); })(); return () => { active = false; off?.(); }; }, [...]). Audit every listen() call in Studio.tsx while you're there.
  • paths.rs swallows mkdir errorssrc-tauri/src/paths.rs:23,39,45,51 call std::fs::create_dir_all(&p).ok(). If the directory can't be created (locked path, permissions, full disk on first run), the function still returns the path and every downstream call fails with a cryptic "file not found". Propagate the error (or panic with a clear message at startup if the data dir is unwritable) so FirstRun can surface the real cause.
  • paths.rs panics if HOME/APPDATA unsetpaths.rs:14,30 dirs::home_dir().expect("home dir"). Won't hit on a normal user macOS install, but on headless / restricted Windows configurations the app crashes at boot with no log entry. Replace .expect() with a graceful error returned via the backend status command so FirstRun can render a message instead of crashing.
  • Voice deletion can orphan reference + embedding filespy/timbre/server.py:voices_delete + voicelib.delete_voice soft-delete the DB row, then walk the filesystem; if FS deletion errors midway (Windows file-in-use, permissions), the row is gone but voices/<id>/ remains and accumulates GB across versions. Move files to voices/.trash/<uuid>/ in the same transactional step as the DB delete, then best-effort delete the trash. Alternatively reverse order (FS first, DB second) so a partial failure leaves the user able to retry.
  • Whitespace-only synth text fails with internal errorpy/timbre/server.py:_synth_run doesn't validate text.strip() before chunk_text(). Pure-whitespace input yields zero chunks; _rebuild_final_audio then raises "cannot build final audio without ready chunks". Reject early with RpcError(ERR_INVALID_PARAMS, "text is empty") so the UI shows a meaningful message.
  • Dialog has no focus trap or restoresrc/components/Dialog.tsx renders a plain <div> overlay; keyboard tab order escapes into the page behind it and focus never returns to the trigger after close. Implement a minimal trap (focus first focusable, cycle Tab/Shift-Tab, restore to previousActiveElement on unmount) or pull in @radix-ui/react-dialog to get this for free.
  • CopyChip / SamplePrompt timeouts fire after unmountsrc/routes/Models.tsx CopyChip and SamplePrompt set setTimeout(() => setCopied(false), 1500) without clearing on unmount. Navigating away within 1.5 s calls setState on a dead component. Store the id in a useRef and clear in an effect cleanup.

High — data integrity, UX correctness, security hardening

  • Download has no resume / retrysrc-tauri/src/backend_pack.rs:download_file aborts on the first transient network error. Reattempt with exponential backoff (3 tries, 2s/8s/30s) and persist the partial file across attempts so the user doesn't restart the 2 GB Python download from scratch on a flaky connection.
  • Backend install leaves inconsistent state on partial failurebackend_pack.rs:install writes backend.state.json only at the very end but creates python/, bin/uv, and venv/ as it goes. If torch install fails after venv creation, the user retries and uv re-extracts python on top of an existing dir (tar errors out). Add a rollback path: on any error remove venv/ and state.json so the next install starts clean.
  • Adapter _move_tensors can recurse foreverpy/timbre/adapters/base.py:default_deserialize_payload walks the payload graph without cycle detection. PyTorch state-dicts have no cycles by default, but a Chatterbox Conditionals field that ever holds a self-reference (or a pickle with shared refs) hangs the sidecar. Pass a visited: set[int] of id(obj) and skip seen objects.
  • _ensure_conditionals mutates the cached payload in placepy/timbre/adapters/chatterbox.py patches emotion_adv on the loaded Conditionals. The next synthesis reuses the mutated payload, so a user's per-run exaggeration tweak silently sticks across runs. Always operate on a dataclasses.replace(payload, t3=replace(payload.t3, emotion_adv=…)) copy.
  • to_mono heuristic guesses channel axispy/timbre/audio.py:to_mono averages over the smaller axis. For a 5-second stereo clip at 24 kHz that's (120000, 2) and works; for an adapter that returns (channels, frames), it silently averages frames. Treat (frames, channels) explicitly and raise on anything unexpected; consult soundfile.info() rather than guessing.
  • Global torch.manual_seed clobbers concurrent RPCspy/timbre/adapters/qwen3.py and chatterbox.py call torch.manual_seed(seed) globally. RpcServer._dispatch already spawns a thread per request (py/timbre/rpc.py:165), so transcription that runs in parallel with chunk synthesis shares/disturbs the RNG state. Move to per-call torch.Generator(device).manual_seed(seed) and pass it to generate/sample. (If the model API doesn't expose a generator, serialise all torch.* ops behind _SYNTH_RUN_LOCK.)
  • Heartbeat thread can drop final progresspy/timbre/server.py:with_heartbeat joins the heartbeat with timeout=0.2 s; on a slow shutdown the last "phase=complete" notification can be lost and the UI stays on the spinner. Use a non-daemon thread, signal it via an Event, and join without timeout — or send the terminal notification on the main thread before the heartbeat winds down.
  • Studio per-batch counter is a plain useRefsrc/routes/Studio.tsx mutates activeBatchRemainingRef from multiple async event handlers (synth.started / synth.finished). Out-of-order events can drive it negative and the "all runs done" condition never fires. Track completion in useState so React batches updates correctly, or derive from a Set<runId> of completed runs.
  • useSidecarEvent handlers close over stale statesrc/lib/sidecar.ts re-subscribes on every handler-identity change, but most call sites pass an inline useCallback that captures values not listed in its dep array (e.g. mergeProgress captures activeModel/device). The listener fires with a callback bound to first-subscribe values. Pass the handler through a ref updated in a layout effect each render, or audit every callsite to ensure all read state is in useCallback deps.
  • Studio history re-renders on every notificationGeneratedRunGroup (per-batch card) and Waveform are not wrapped in React.memo, so the entire list re-renders on every progress event. With 50+ runs that's a perceivable freeze and the Waveform's HTMLAudioElement is recreated mid-playback. Memoise GeneratedRunGroup, GeneratedTake, GeneratedChunk and stabilise the Waveform key so it only changes when final_audio_path does (drop updated_at).
  • timbre:audio-play dispatched before play() resolvessrc/components/Waveform.tsx dispatches the "another audio just started" event before await audio.play() returns. Other Waveform instances pause on receipt, but if the calling audio rejects (autoplay policy, decode error) you've stopped the previously-playing audio for nothing. Dispatch only after play() resolves.
  • onOtherAudioStarted listener captures stale audio refWaveform.tsx adds a window listener that compares event.detail !== audio to decide whether to pause. The closure captures the audio from the render it was attached on; if the component re-mounts with a new element the old listener still pauses the old element. Include audio in the listener effect deps so it re-attaches.
  • Settings reset uninstalls backend with no confirmsrc/routes/Settings.tsx "Reset / uninstall backend" wipes ~/Library/Application Support/timbre/venv (potentially GB of weights) on one click. Add a <Dialog> with explicit copy: "this removes Python, all installed model weights, and your downloaded backend pack — your voices and history stay".
  • FirstRun auto-flips choice to MPSsrc/routes/FirstRun.tsx:58-62 useEffect(() => { if (is_apple_silicon) setChoice("mps"); }, [is_apple_silicon]). If the user picks CPU explicitly and host info refetches, their pick is overridden back to MPS. Only set when choice === null.
  • Library export-loading state never lights upsrc/routes/Library.tsx:159 checks exportPrompts.variables?.id === v.id but the mutation is invoked with the full Voice object, so variables is Voice, not { id }. The check never matches; the per-voice spinner never appears. Invoke the mutation with { id: v.id } (or read the cast variable) consistently.
  • CI runs no automated tests.github/workflows/qa-builds.yml only does tsc --noEmit and a release build. Add at minimum (a) cargo test --workspace (catches build regressions even with empty test mods), (b) python -m py_compile over every py/timbre/*.py to catch syntax errors, (c) a non-recording UI smoke test that boots Vite and checks the main route renders.
  • CI doesn't lint Rust or JS/TS — neither cargo clippy -- -D warnings nor an eslint/biome step are wired. clippy surfaces dead code, unused imports, easy-to-miss unwrap patterns. Add cargo clippy --workspace --all-targets -- -D warnings and biome for the frontend.

Medium — robustness, performance, polish

  • Refresh / progress polling not debouncedsrc/routes/Models.tsx Refresh invalidates ["models"] + ["model-statuses"] on every click; spamming fires N parallel queries. Disable while statuses.isFetching is true.
  • stderr + header reader are byte-at-a-timesrc-tauri/src/sidecar.rs:read_until and spawn_stderr_logger each read(&mut [u8;1]) in a loop. Negligible for headers but the stderr logger pays this for every log byte. Use BufReader::read_line.
  • download_file reports progress only when content-length knownbackend_pack.rs:download_file skips emit_progress when total == 0. Some CDNs omit content-length; the bar appears stuck at 0. Emit a "downloaded N bytes" tick at least every 250 ms even without a total.
  • extract_archive blocks the executor — synchronous tar.unpack / zip.extract runs inside an async function and blocks the tokio worker for ~10 s. Wrap in tokio::task::spawn_blocking so other Tauri commands stay responsive.
  • Chatterbox crossfade may corrupt PerTh watermarkpy/timbre/server.py concatenates chunks with a 10 ms crossfade. Chatterbox embeds an inaudible PerTh watermark per segment; envelope multiplication at boundaries can desynchronise it. Validate experimentally with the upstream detector or skip the fade for Chatterbox (concat with no overlap and zero-cross alignment).
  • Studio chunk list refetches on detailsOpen toggleuseSynthChunks is gated on detailsOpen with refetchOnMount: "always". Opening/closing the panel briefly shows a spinner even when fresh. Cache for 30 s via staleTime and refetch only when a chunk event arrives.
  • useQueries in VoicePrepareControl rebuilds query objects per rendersrc/components/VoicePrepareControl.tsx passes a fresh .map() to useQueries every render, churning query identity and refetching. Wrap in useMemo.
  • VoiceCreateDialog silent recording truncation — recording past 15 s is silently sliced; the user thinks all of their take was captured. Show a toast / inline message at truncation and surface the original duration.
  • VoiceCreateDialog doesn't validate uploaded file size / type — drag-and-drop of a 2 GB MOV silently attempts to process it. Reject anything outside audio/* mime or larger than 100 MB at pick time.
  • Voice.ref_transcript schema is .nullable().optional()src/lib/schema.ts allows null | undefined | string. Server uses null, UI sometimes writes "". Normalise to string | null and convert ""null at the API boundary.
  • Models size display logic is tangledModels.tsx:modelSizeDisplay() has four nested ternaries (removed → install progress → status → manifest estimate). Extract to a helper with named cases and inline-comment the precedence.
  • device_policy substring matching is too laxpy/timbre/device_policy.py matches error strings via "mps" in str(e).lower(). False positives on unrelated errors trigger pointless CPU fallback. Switch to explicit (exc_type, substring) tuples or regex word boundaries.
  • transcribe.py disables VAD unconditionally — fine for short reference clips, but a user passing a 10-minute file by mistake decodes the whole thing. Probe duration with soundfile.info(); raise if > 60 s.
  • models_state snapshot stats walks the entire HF cache_snapshot_stats rglobs every file then stat()s each one. For the Chatterbox cache that's seconds and runs on every models.list_status poll. Cache the size on first compute and invalidate on download_weights / remove_weights.
  • Missing microphone entitlementsrc-tauri/entitlements.plist lacks com.apple.security.device.microphone. With hardened runtime + signed builds macOS will refuse mic access even with NSMicrophoneUsageDescription in Info.plist. Add the entitlement and verify recording works on a notarised build before shipping.
  • Hardened-runtime exceptions are broadentitlements.plist grants allow-jit, allow-unsigned-executable-memory, disable-library-validation. Necessary for PyTorch but reduces the user's effective sandbox. Document the rationale in README / app-store notes so reviewers/users see the justification.
  • Adapter sample-rate not asserted against manifestsynthesize returns (samples, sr); audio.concat_with_crossfade uses the first chunk's sr for the whole file. If a CPU-fallback reload silently changes sr the output ends up at the wrong pitch. Assert sr == manifest.sample_rate and fail loudly.
  • No pnpm lint — no eslint/biome config; only tsc --noEmit runs. Add biome (zero-config, fast) and run it alongside the typecheck in CI.

Low — nits, hygiene, documentation

  • window.prompt / window.confirm in Studio — replace with <Dialog> instances (and add the focus-trap fix above to make them usable).
  • Seed input strips non-digits in onChangeStudio.tsx mangles 1e5 to 15. Validate on blur with a clear error instead of silently rewriting.
  • formatEta can show "5m 60s"Models.tsx:formatEta Math.ceil(seconds - minutes*60) can equal 60. Cap with Math.min(59, …) or recompute minutes from the rounded total.
  • Long license overflows option labelStudio.tsx model <select> concatenates name · license. Add title={license} so the full string is visible on hover when truncated.
  • Default model not highlighted in picker — sort is_default first or add a (default) suffix.
  • flake.nix pins nixos-unstableflake.lock pins the commit so this is OK in practice; document it (or move to nixos-25.05) to avoid surprises if someone runs nix flake update near release.
  • _prompt_archive_name uses raw model_idpy/timbre/server.py sanitises non-alphanumerics but doesn't truncate. Use hashlib.sha256(model_id).hexdigest()[:16] + .pkl so filenames stay short and predictable.
  • rpc.py silently skips malformed headers_read_message continues on header lines that don't split on :. Log at warning so framing desync is visible in support bundles.
  • audio.py doesn't validate model output rangenp.clip(samples, -1.0, 1.0) assumes the model returns floats already in that range. Warn (or raise) when abs(samples).max() > 1.0 so adapter bugs surface early instead of being silently clipped.
  • paths.rs:resource_path falls back through CWD-relative paths — drop the two PathBuf::from(relative) / PathBuf::from("..").join(…) candidates; keep only BaseDirectory and exe-relative ones.
  • expect("error while running tauri application") in lib.rs:38 — panics with no diagnostics. Log via tracing first so the failure lands in the rolling log file the user can attach to a bug report.

Structural — file-level refactors (post-fix cleanup)

  • Split src/routes/Studio.tsx (2151 LOC) into a folder:
    • Studio/Studio.tsx — main route + synth form (~800 LOC)
    • Studio/SynthProgressPanel.tsx — progress + diagnostics
    • Studio/GeneratedRunGroup.tsx — batch card
    • Studio/GeneratedTake.tsx — per-take playback
    • Studio/GeneratedChunk.tsx — chunk row with regenerate
    • Studio/ParamBadgeRow.tsx
    • Studio/studioUtils.ts — grouping/formatting helpers
  • Split py/timbre/server.py (1343 LOC) by feature surface:
    • server.py — RpcServer boot + method registration only
    • server_voices.py — voice CRUD + reference / prompt handling
    • server_synth.py — synthesis orchestration + history
    • server_models.py — model install / status RPCs
    • move module-level globals into a SynthState class so locking discipline is enforceable instead of "remember to grab the right lock".
  • Split src/components/VoiceCreateDialog.tsx (732 LOC) — extract useMediaRecording(), useTranscription(), useSaveVoice() hooks and reduce the dialog to a ~250 LOC orchestrator.
  • Split src/routes/Models.tsx (849 LOC) — extract ModelCard.tsx, GettingStartedDisclosure.tsx, InstallProgressBar.tsx, and a useModelInstall(modelId) hook so the route itself is mostly composition.

Post-v1 (deferred per the approved plan)

  • Adapter roster is frozen at the current four (Qwen3 1.7B / 0.6B, Chatterbox Turbo / English). Add a new adapter only if it clears one of: license unlock (Apache and MIT both blocked); footprint (< 1 GB RAM, < 500 MB disk, with usable quality); capability the current four lack (voice mixing, controllable timing, sing/speak hybrid — paralinguistic tags and expressive knobs are already covered); or measurably better long-form stability against a benchmark fixture (not vibes).
  • Code signing automation — Apple Developer ID + notarization on macOS, EV cert on Windows.
  • Auto-updater — wire up tauri-plugin-updater.
  • MP3 export — alongside the existing WAV path.
  • Chunk regeneration UX — shelved for now; keep the server method experimental/internal until core voice creation, model install, and synthesis flows are stable.
  • Adapter unit tests — fixture clip + assert sample rate, non-silent output for each adapter.
  • IPC golden transcripts — round-trip tests per RPC method.
  • Regenerate determinism test — synth a 10-sentence script, regen chunk 5 with new seed, assert chunks 1–4 + 6–10 are byte-identical.
  • Crash recovery — kill sidecar mid-synth, mark in-flight chunks failed, offer retry.
  • CoreML / GGUF native paths for Qwen3 on macOS so we can skip PyTorch entirely there.
  • Per-run playback speed + speed-adjusted WAV export — each generated audio card gets its own persisted playback speed control (0.5x2.0x, default 1.0x). Preview playback uses the selected speed with pitch preservation where supported, and export applies the same speed to the WAV itself via a sidecar pitch-preserving time-stretch path (1.0x remains a direct copy).

Completed

  • Model download progress now updates in real time — two-part fix. (1) _sibling_blob_id in py/timbre/models_state.py prefers the LFS sha256 over the git blob OID; HF caches LFS files under the LFS sha256, so the previous lookup never matched the on-disk <hash>.incomplete file and _file_state returned zero bytes for the whole LFS portion of every download. (2) HF_HUB_DISABLE_XET=1 set on the sidecar process in src-tauri/src/sidecar.rs: hf_xet writes assembled chunks to .incomplete in ~67 MB bursts (measured: 0→67MB→134MB→268MB→682MB across 13 s for one file), so the 0.5 s filesystem poller saw long flat stretches followed by big jumps. Plain HTTP via http_get grows the file in ~10 MB / 400 ms steps, which the existing poller renders smoothly. Net download speed parity (27.7 s HTTP vs ~30 s Xet on the test file).

  • Windows setup hardening (reliability pass) — turned scripts/setup-windows.ps1 from a thin wrapper into a self-healing bootstrap: detects non-elevated invocations and self-elevates via Start-Process -Verb RunAs (VS Build Tools / WebView2 need admin); asserts winget >= 1.6 and that winget configure is wired (older App Installer builds lack DSC and used to fail mid-stream with cryptic errors); warns on pending reboot (Windows Update flag silently breaks the VS installer); enables HKLM\…\FileSystem\LongPathsEnabled=1 so cargo target/ + node_modules don't hit MAX_PATH; resolves rustup/corepack/pnpm via explicit .cargo\bin and Program Files\nodejs fallbacks rather than trusting registry PATH propagation; forces rustup install stable-x86_64-pc-windows-msvc --profile minimal before rustup default so the toolchain is materialised; passes --disable-interactivity + --accept-source-agreements to winget configure so the run is fully non-interactive. Added scripts/bootstrap.cmd as a zero-prereq .cmd shim (auto-detects pwsh vs powershell, sets ExecutionPolicy Bypass, forwards args) so users on a fresh box don't need pnpm or Node to start the setup. Hardened scripts/verify-build-env.ps1: probes VS Build Tools via vswhere.exe and locates link.exe directly under VC\Tools\MSVC\<ver>\bin\Hostx64\x64\ (instead of trusting "rust host is msvc" as a proxy); checks all three WebView2 registry locations (HKLM 64-bit, WOW6432Node, HKCU per-user) instead of only the WOW64 path; gates winget on >= 1.6; downgraded LongPathsEnabled and pending-reboot to warnings so CI (which doesn't write that registry key) still passes while dev machines get nudged.

  • CI pipeline + Windows one-shot setup.github/workflows/qa-builds.yml was red on cargo check because the proc-macro tauri::generate_context!() validates frontendDist (../dist) at compile time and cargo doesn't run beforeBuildCommand. Dropped the standalone cargo check step; tauri build covers it. Also dropped the broken -- separators on pnpm tauri:build -- --bundles … and pnpm release:checksums -- … (pnpm 10 doesn't strip --, so they were leaking into the script args and silently corrupting both the bundle step and the checksum filename). Added Swatinem/rust-cache@v2 per platform. New scripts/windows-deps.winget declares the toolchain (VS Build Tools / MSVC v143 + Win11 SDK, Rustup, Node LTS, WebView2, Git) as the Windows analogue of flake.nix's common list; scripts/setup-windows.ps1 is the one-command entry point (parity with direnv allow on Unix — runs winget configure, refreshes PATH, sets the rustup default, activates pinned pnpm, runs pnpm install, then verifies); scripts/verify-build-env.ps1 is the non-mutating smoke check, also wired in as a Windows-only CI step before the long Rust build. pnpm setup:windows exposes the script via the same surface as the rest of the project.

  • Project scaffold: directory structure + package.json / pyproject.toml / Cargo.toml / tauri.conf.json / models.manifest.json / python-build-standalone.urls.json.

  • Python sidecar core: stdio JSON-RPC server, sqlite voice library, sentence chunking via pysbd, WAV I/O, HF cache wiring, app-data paths.

  • Qwen3 adapter — lazy qwen-tts import; one-shot synthesis (no persisted payload yet).

  • Rust Tauri shell — sidecar process management, JSON-RPC framing, Tauri commands proxying to Python.

  • First-run backend-pack installer — downloads python-build-standalone + uv + the right torch wheel for CPU/CUDA/MPS.

  • React UI — FirstRun, Studio (synth + waveform + per-chunk regenerate), Library, Settings; routing; query layer; sidecar event subscriptions.

  • Nix flake + .envrc for the dev toolchain.

  • Verified builds: python3 -m py_compile, pnpm tsc --noEmit, pnpm vite build, cargo check, cargo check --release.

  • Fix double-logger panic — removed tauri-plugin-log so tracing_subscriber::fmt() is the only global logger.

  • First-run setup UX — sidebar hidden until backend is installed; progress bar shows percentage and stage label, with an indeterminate sweep before the first event; backend selection and Install button are disabled (and pointer-events-none) during install.

  • First-run install actually works — fixed the python-build-standalone URLs (the previous tag/version pair didn't exist), bumped uv to 0.11.10, flatten the uv-{triple}/ wrapper into data_dir/bin/uv after extraction, and capture uv stderr on failure. Errors now surface as a persistent red banner in FirstRun instead of vanishing with the progress bar.

  • Models route + per-model install — sidecar models.list_status / models.status / models.download_weights (HF snapshot download with byte-poll progress) plus a Rust install_model_deps Tauri command running uv pip install -r requirements/{adapter}.txt. New Models route shows status + Install per card with a streaming progress bar; Studio gates synth on the active model being ready and links to the Models route otherwise. Default qwen-tts install path is now lazy via this flow rather than baked into base.txt.

  • Switch Qwen3 manifest entries from -CustomVoice to -Base (zero-shot voice cloning checkpoint).

  • Fix Models install hanging at 100% — snapshot_download materialises symlinks after bytes finish, so we now emit a finalizing notification when blob bytes plateau, and the UI flips to ready on Promise resolution rather than waiting for the complete event.

  • Fix model deps reading as not-installed after install — call importlib.invalidate_caches() before each find_spec so a freshly-installed qwen-tts is visible to the long-running sidecar.

  • Voice creation collects a transcript — new VoiceCreateDialog (audio file + name + transcript), used by both Library and Studio. Library shows an amber "no transcript" hint on voices without one.

  • Auto-generated transcript via faster-whisper — Generate / Regenerate button in the voice dialog, sidecar transcribe.audio RPC backed by faster-whisper base model (now in requirements/base.txt), with a graceful "reinstall backend" hint when the active venv predates the change.

  • Qwen3 adapter rewritten against the actual qwen-tts API — uses create_voice_clone_prompt(ref_audio, ref_text, x_vector_only_mode) + generate_voice_clone(text, voice_clone_prompt=…). Server.py builds the voice-clone prompt once per synthesis and reuses it across all chunks (encoding the reference is the slow step). Falls back to x_vector_only_mode when no transcript is provided.

  • Models list resilience — useModels and useModelStatuses now retry with backoff and refetchOnMount: "always"; Models route has a manual Refresh button that invalidates both queries.

  • Fix models always reading as "not installed" — _hf_repo_dir was looking at models_dir/hub/models--<repo>/ but HUGGINGFACE_HUB_CACHE=models_dir makes HF skip the hub/ subdir and lay snapshots straight under the cache root, so the check never matched. Dropped the hub/ segment; existing downloads are detected without re-fetching.

  • Fix synth crash on first run — qwen-tts (and HF lib in general) writes tqdm bars to stdout, which is the JSON-RPC channel. Sidecar now quarantines stdout at startup: dup the real fd 1 into a private RPC writer, redirect fd 1 to fd 2 so any future stdout traffic (Python prints, tqdm, native printf) goes to stderr. Also dropped the deprecated TRANSFORMERS_CACHE env, suppressed pysbd SyntaxWarning noise, and made the Rust frame reader resync on bogus bytes instead of killing the pipe.

  • MPS fallback for Qwen3 — set PYTORCH_ENABLE_MPS_FALLBACK=1 from both the Rust spawn env and the Python __main__.py so the few ops that don't have an MPS kernel (e.g. the giant output-codebook conv) drop to CPU automatically. Belt-and-suspenders so it's set before any torch import path.

  • Studio voice picker shows prompt readiness without blocking synthesis — new sidecar voices.prompt_status_all(model_id) bulk RPC, frontend useVoicePromptStatuses hook, Studio lets any voice run once the selected model is installed and labels whether the cached prompt is ready or will be prepared during synth. Library's Prepare button invalidates the bulk query so Studio updates without a manual refresh.

  • Qwen3-TTS runs on full MPS with eager attention — fp32 throughout (MPS fp16 attention produces NaN/Inf logits and the multinomial sampler errors). attn_implementation="eager" is required because MPS' SDPA kernel doesn't internally repeat K/V heads to match Q under GQA (Qwen3 has 16 Q / 8 KV heads), which otherwise raises mps_matmul: incompatible dimensions mid-generation. Eager runs an explicit repeat_kv first. CPU/CUDA paths default to SDPA. (Earlier hybrid-CPU/MPS placement workaround for the 65,536-channel embedding limit was removed in e2337f0 — full MPS works in current PyTorch + qwen-tts.)

  • Chatterbox adapter — py/timbre/adapters/chatterbox.py + requirements/chatterbox.txt. Zero-shot cloning straight from the reference clip; Conditionals payload is prepared once and reused across chunks via the cached-prompt flow.

  • Sidecar resilience on death — when stdout EOFs (process crash, MPS-graph LLVM error, manual kill, etc.), the Rust shell now clears the inner handle, fails any pending requests with a clean error, and emits a sidecar:died event. Frontend listens for it and resets the "already started" flag so the next operation triggers a fresh spawn instead of hanging on a broken pipe.

  • Sidecar resource freshness in dev — paths::resource_path now prefers <workspace>/<rel> (via compile-time CARGO_MANIFEST_DIR) when cfg!(debug_assertions) so pnpm tauri:dev always loads the latest py/ source instead of the stale target/debug/py/ copy. Release builds keep using the bundled resources.

  • Persisted voice-clone prompts — promoted prompt generation to a first-class step. New sidecar RPCs voices.prompt_status and voices.prepare_for_model (streaming), default adapter serialize_payload / deserialize_payload that pickle with tensors moved to CPU. Synth path now loads the cached prompt from disk if present, otherwise builds + saves it on the fly. Library shows a per-voice "Prepare for <model>" button that flips to "prompt ready" once cached. Voice deletion also removes its embedding files.

  • Generated audio history in Studio — sidecar now stores synthesis status, final full-run WAV path, duration, prompt text, model, date, and reference transcript; synth.list_history / synth.get expose persisted runs; Studio shows generated audio across restarts with a playable full-run waveform.

  • Move numba JIT cache out of OS temp into the app cache dir — new paths::cache_dir() (Rust) + paths.cache_dir() (Python) follow OS conventions (~/Library/Caches/timbre on macOS, %LOCALAPPDATA%\timbre on Windows, $XDG_CACHE_HOME/timbre on Linux). Sidecar now exports TIMBRE_CACHE_DIR and NUMBA_CACHE_DIR from there so Storage Sense / Disk Cleanup on Windows can't wipe the JIT cache between sessions.

  • Studio "Advanced" panel exposes per-model synthesis levers — schema-driven from models.manifest.json (params: [...]); Chatterbox English surfaces exaggeration + cfg_weight, Turbo shows a note that expressive controls are ignored, every model gets a Seed input. Values persist per model_id via the existing zustand store; only non-default values are sent to synth.run so synthesis.params_json stays clean. Server synth.run also stamps the user-supplied seed into params_json, and the Generated Audio cards render small badges for each non-default param (using the manifest schema for labels), so you can see at a glance how each historical run differed from defaults.

  • Runtime download SHA-256 verification — resources/python-build-standalone.urls.json now pins hashes for the tracked macOS and Windows Python/uv downloads, so first-run backend installs verify artifacts before extraction.

  • Unsigned macOS/Windows QA build workflow — GitHub Actions builds macOS Apple Silicon and Windows x64 bundles, validates release inputs, runs a Rust shell check, and uploads checksummed QA artifacts.

  • App icon — neon-blue waveform/signal logo on dark navy; regenerated all src-tauri/icons/ sizes (macOS .icns, Windows .ico, iOS, Android, Microsoft Square logos) from the new 1254×1254 source via npx tauri icon.

  • macOS icon squircle shape — packaged .icns was rendering as a full opaque rectangle in Dock/Finder because macOS doesn't auto-mask app icons. Added scripts/build-icons.mjs (uses sharp) that derives a squircle-masked source (~22.37% corner radius, transparent outside) for the macOS pass and the unmasked square for the Windows .ico / Square logos, runs npx tauri icon once per shape, and stitches the platform-correct outputs together. Exposed as pnpm build:icons for reproducibility.

  • Rebrand qwentts-localTimbre (tagline: "From text to timbre."). Full rename: Tauri productName + window title; bundle id com.kialo.qwentts-localcom.timbre.app; Cargo package timbre / lib timbre_lib; npm package timbre; Python module py/qwentts/py/timbre/ (74 imports rewritten); env vars QWENTTS_*TIMBRE_* (TIMBRE_DATA_DIR, TIMBRE_CACHE_DIR, TIMBRE_MANIFEST, TIMBRE_SIDECAR_DIR, TIMBRE_ARTIFACT_PLATFORM); app data dir relocates to ~/Library/Application Support/timbre and ~/Library/Caches/timbre (orphaned qwentts-local dirs can be rm -rf'd after first new-name launch); zustand persist key qwentts-ui-settingstimbre-ui-settings (resets saved UI prefs once); custom DOM event qwentts:audio-playtimbre:audio-play; log/thread prefix [qwentts][timbre]; QA-build artifact names + Info.plist mic prompt + flake.nix banner all updated.

  • Reference clip ingestion — selected/recorded voice clips are copied into app-managed voices/<voice-id>/reference.wav files, normalized to mono WAV via the backend audio stack, validated to 3-15 seconds, and persisted in the DB by managed path instead of arbitrary source path.

  • Export final WAV — Studio generated audio cards have an explicit Export button for playable full-run WAVs.

  • Sidebar branding refresh — replaced the placeholder Wand2 glyph with Lucide AudioWaveform and reworded the tagline from "local voice cloning" to "on-device voice cloning" in src/App.tsx.

  • Advanced params surfaced on each generated card — extracted a ParamBadgeRow component in src/routes/Studio.tsx that renders every schema param (with its current value, falling back to the schema default) plus the seed if recorded. Rendered at the bottom of each GeneratedRunGroup card under its own border-t divider, and reused inside GeneratedTake so expanded sub-takes share the same layout. Gated on !simpleMode.

  • Per-model "Getting started" guidance on the Models route — rewrote the four description strings in resources/models.manifest.json to lead with each model's distinguishing capability and add a "pick this when…" hook; added a getting_started block per model carrying reference-clip tips, text/chunk guidance, audio-length cap, sample prompts, paralinguistic tag list (Turbo only, authoritative 19 tokens from upstream added_tokens.json, grouped Non-verbal / Emotion / Register), slider value-by-value guidance (English only), and gotchas. Extended src/lib/schema.ts ModelInfo with the optional getting_started Zod object. Added a collapsible "Getting started" disclosure under each card in src/routes/Models.tsx with copy-to-clipboard tag chips and per-prompt copy buttons. Froze the deferred adapter roster at the current four with explicit re-add criteria.