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.
- Fold Voices Import into the Add-Voice dialog — replace the confusing pair of Voices-page header buttons (
Import+Add voice) with a singleAdd voiceentry point. InsideVoiceCreateDialog, add a third source tile (Record/File/Import) that opens a.timbrevoicepicker, shows the safety warning inline (replacing thewindow.confirm), hides Name/Transcript, and callsvoices.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 newBackend::Rocmvariant whose torch lane branches on OS (Linux uses PyTorchwhl/rocm6.4; Windows uses AMD's directrepo.radeon.com/rocm/windows/rocm-rel-6.4.4wheel URLs),cu124→cu128for the CUDA lane, FirstRun CPU/CUDA/ROCm cards on non-Mac with OS-aware ROCm subtitle,device_label+transcribe.pyHIP handling, Linux CI job inqa-builds.yml, and a Linux x86_64-onlypackages.timbrederivation inflake.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.rsprobes for NVIDIA/AMD GPUs (Linux/sys/bus/pciscan, Windows DXGI via thewindowscrate; macOS skipped) behind adetect_backendsIPC 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. Newdocs/supported-hardware.mddocuments the supported hardware matrix (CUDA follows PyTorch 2.8cu128, 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
.envrcso node + python deps are installed (and kept fresh) on shell entry, idempotently. - Run end-to-end once —
pnpm tauri:devthrough 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_WINDOWon the sidecar spawn (src-tauri/src/sidecar.rs) and the sharedrun_uvhelper (src-tauri/src/backend_pack.rs). Force binary mode on the duplicated RPC stdout fd viamsvcrt.setmode(fd, O_BINARY)inpy/timbre/__main__.pyso the JSON-RPC\r\nframing isn't CRLF-mangled by the CRT. Added atracing-appenderdaily rolling file sink underpaths::cache_dir()/logs/timbre.log(src-tauri/src/lib.rs) so the GUI-subsystem release build has any diagnostics at all, and madepaths::resource_pathlog each candidate it tries plus the tried-list in the error so a missing manifest is debuggable from that file.
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.
- Path traversal in archive extraction —
src-tauri/src/backend_pack.rs:334-350extract_archivecallstar.unpack()/zip.extract()without checking that each entry stays insidedst. 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 escapesdstor contains... Same hardening needed on the Python side forvoices_import_prompts. - Unsafe pickle on imported voice prompts —
py/timbre/adapters/base.py:default_deserialize_payloadusespickle.loads()on.pklfiles unpacked from.timbrevoicearchives. 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 wrappickle.Unpickler.find_classwith an allow-list oftorch.*,numpy.*, and the adapter's payload dataclass. Reject archives whose schema_version doesn't match the current adapter. - Sidecar RPC has no timeout —
src-tauri/src/sidecar.rs:171-214Sidecar::call()awaits aoneshot::Receiverindefinitely. 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 viatokio::time::timeout(e.g. 5 min forsynth.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 onSidecar::stop()so app quit can't hang. - Generate button double-fires —
src/routes/Studio.tsxsynth mutation flips its in-flight gate insideonSuccess/onError, so a fast double-click passes theif (running) …guard twice before the first RPC resolves. Set the in-flight flag synchronously insideonMutate(or gate the button onsynth.isPending) and reject the second invocation. Same pattern onModels.tsxInstall/Remove andVoicePrepareControl.tsxPrepare buttons. - Event listeners leak on FirstRun / Models unmount —
src/routes/FirstRun.tsx:38-42andsrc/routes/Models.tsx:94-172register Tauri listeners vialisten(...).then(off => offs.push(off)). If the component unmounts before the promise resolves,offis never captured; each remount stacks another listener and stale callbacks firesetStateon dead components. Pattern:useEffect(() => { let active = true; let off: UnlistenFn | undefined; (async () => { off = await listen(...); if (!active) off(); })(); return () => { active = false; off?.(); }; }, [...]). Audit everylisten()call inStudio.tsxwhile you're there. -
paths.rsswallows mkdir errors —src-tauri/src/paths.rs:23,39,45,51callstd::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.rspanics ifHOME/APPDATAunset —paths.rs:14,30dirs::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 files —
py/timbre/server.py:voices_delete+voicelib.delete_voicesoft-delete the DB row, then walk the filesystem; if FS deletion errors midway (Windows file-in-use, permissions), the row is gone butvoices/<id>/remains and accumulates GB across versions. Move files tovoices/.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 error —
py/timbre/server.py:_synth_rundoesn't validatetext.strip()beforechunk_text(). Pure-whitespace input yields zero chunks;_rebuild_final_audiothen raises "cannot build final audio without ready chunks". Reject early withRpcError(ERR_INVALID_PARAMS, "text is empty")so the UI shows a meaningful message. - Dialog has no focus trap or restore —
src/components/Dialog.tsxrenders 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 topreviousActiveElementon unmount) or pull in@radix-ui/react-dialogto get this for free. - CopyChip / SamplePrompt timeouts fire after unmount —
src/routes/Models.tsxCopyChip and SamplePrompt setsetTimeout(() => setCopied(false), 1500)without clearing on unmount. Navigating away within 1.5 s callssetStateon a dead component. Store the id in auseRefand clear in an effect cleanup.
- Download has no resume / retry —
src-tauri/src/backend_pack.rs:download_fileaborts 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 failure —
backend_pack.rs:installwritesbackend.state.jsononly at the very end but createspython/,bin/uv, andvenv/as it goes. If torch install fails after venv creation, the user retries anduvre-extracts python on top of an existing dir (tar errors out). Add a rollback path: on any error removevenv/andstate.jsonso the next install starts clean. - Adapter
_move_tensorscan recurse forever —py/timbre/adapters/base.py:default_deserialize_payloadwalks the payload graph without cycle detection. PyTorch state-dicts have no cycles by default, but a ChatterboxConditionalsfield that ever holds a self-reference (or a pickle with shared refs) hangs the sidecar. Pass avisited: set[int]ofid(obj)and skip seen objects. -
_ensure_conditionalsmutates the cached payload in place —py/timbre/adapters/chatterbox.pypatchesemotion_advon the loadedConditionals. The next synthesis reuses the mutated payload, so a user's per-run exaggeration tweak silently sticks across runs. Always operate on adataclasses.replace(payload, t3=replace(payload.t3, emotion_adv=…))copy. -
to_monoheuristic guesses channel axis —py/timbre/audio.py:to_monoaverages 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; consultsoundfile.info()rather than guessing. - Global
torch.manual_seedclobbers concurrent RPCs —py/timbre/adapters/qwen3.pyandchatterbox.pycalltorch.manual_seed(seed)globally.RpcServer._dispatchalready 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-calltorch.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 progress —
py/timbre/server.py:with_heartbeatjoins the heartbeat withtimeout=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 anEvent, 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
useRef—src/routes/Studio.tsxmutatesactiveBatchRemainingReffrom 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 inuseStateso React batches updates correctly, or derive from aSet<runId>of completed runs. -
useSidecarEventhandlers close over stale state —src/lib/sidecar.tsre-subscribes on every handler-identity change, but most call sites pass an inlineuseCallbackthat captures values not listed in its dep array (e.g.mergeProgresscapturesactiveModel/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 inuseCallbackdeps. - Studio history re-renders on every notification —
GeneratedRunGroup(per-batch card) andWaveformare not wrapped inReact.memo, so the entire list re-renders on every progress event. With 50+ runs that's a perceivable freeze and the Waveform'sHTMLAudioElementis recreated mid-playback. MemoiseGeneratedRunGroup,GeneratedTake,GeneratedChunkand stabilise the Waveformkeyso it only changes whenfinal_audio_pathdoes (dropupdated_at). -
timbre:audio-playdispatched beforeplay()resolves —src/components/Waveform.tsxdispatches the "another audio just started" event beforeawait 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 afterplay()resolves. -
onOtherAudioStartedlistener captures stale audio ref —Waveform.tsxadds a window listener that comparesevent.detail !== audioto 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. Includeaudioin the listener effect deps so it re-attaches. - Settings reset uninstalls backend with no confirm —
src/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 MPS —
src/routes/FirstRun.tsx:58-62useEffect(() => { 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 whenchoice === null. - Library export-loading state never lights up —
src/routes/Library.tsx:159checksexportPrompts.variables?.id === v.idbut the mutation is invoked with the fullVoiceobject, sovariablesisVoice, 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.ymlonly doestsc --noEmitand a release build. Add at minimum (a)cargo test --workspace(catches build regressions even with empty test mods), (b)python -m py_compileover everypy/timbre/*.pyto 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 warningsnor an eslint/biome step are wired.clippysurfaces dead code, unused imports, easy-to-missunwrappatterns. Addcargo clippy --workspace --all-targets -- -D warningsand biome for the frontend.
- Refresh / progress polling not debounced —
src/routes/Models.tsxRefresh invalidates["models"]+["model-statuses"]on every click; spamming fires N parallel queries. Disable whilestatuses.isFetchingis true. -
stderr+ header reader are byte-at-a-time —src-tauri/src/sidecar.rs:read_untilandspawn_stderr_loggereachread(&mut [u8;1])in a loop. Negligible for headers but the stderr logger pays this for every log byte. UseBufReader::read_line. -
download_filereports progress only whencontent-lengthknown —backend_pack.rs:download_fileskipsemit_progresswhentotal == 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_archiveblocks the executor — synchronoustar.unpack/zip.extractruns inside an async function and blocks the tokio worker for ~10 s. Wrap intokio::task::spawn_blockingso other Tauri commands stay responsive. - Chatterbox crossfade may corrupt PerTh watermark —
py/timbre/server.pyconcatenates 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 toggle —
useSynthChunksis gated ondetailsOpenwithrefetchOnMount: "always". Opening/closing the panel briefly shows a spinner even when fresh. Cache for 30 s viastaleTimeand refetch only when a chunk event arrives. -
useQueriesin VoicePrepareControl rebuilds query objects per render —src/components/VoicePrepareControl.tsxpasses a fresh.map()touseQueriesevery render, churning query identity and refetching. Wrap inuseMemo. -
VoiceCreateDialogsilent 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. -
VoiceCreateDialogdoesn't validate uploaded file size / type — drag-and-drop of a 2 GB MOV silently attempts to process it. Reject anything outsideaudio/*mime or larger than 100 MB at pick time. -
Voice.ref_transcriptschema is.nullable().optional()—src/lib/schema.tsallowsnull | undefined | string. Server usesnull, UI sometimes writes"". Normalise tostring | nulland convert""→nullat the API boundary. - Models size display logic is tangled —
Models.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_policysubstring matching is too lax —py/timbre/device_policy.pymatches 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.pydisables VAD unconditionally — fine for short reference clips, but a user passing a 10-minute file by mistake decodes the whole thing. Probe duration withsoundfile.info(); raise if > 60 s. -
models_statesnapshot stats walks the entire HF cache —_snapshot_statsrglobs every file thenstat()s each one. For the Chatterbox cache that's seconds and runs on everymodels.list_statuspoll. Cache the size on first compute and invalidate ondownload_weights/remove_weights. - Missing microphone entitlement —
src-tauri/entitlements.plistlackscom.apple.security.device.microphone. With hardened runtime + signed builds macOS will refuse mic access even withNSMicrophoneUsageDescriptionin Info.plist. Add the entitlement and verify recording works on a notarised build before shipping. - Hardened-runtime exceptions are broad —
entitlements.plistgrantsallow-jit,allow-unsigned-executable-memory,disable-library-validation. Necessary for PyTorch but reduces the user's effective sandbox. Document the rationale inREADME/ app-store notes so reviewers/users see the justification. - Adapter sample-rate not asserted against manifest —
synthesizereturns(samples, sr);audio.concat_with_crossfadeuses the first chunk'ssrfor the whole file. If a CPU-fallback reload silently changessrthe output ends up at the wrong pitch. Assertsr == manifest.sample_rateand fail loudly. - No
pnpm lint— no eslint/biome config; onlytsc --noEmitruns. Add biome (zero-config, fast) and run it alongside the typecheck in CI.
-
window.prompt/window.confirmin Studio — replace with<Dialog>instances (and add the focus-trap fix above to make them usable). - Seed input strips non-digits in onChange —
Studio.tsxmangles1e5to15. Validate on blur with a clear error instead of silently rewriting. -
formatEtacan show "5m 60s" —Models.tsx:formatEtaMath.ceil(seconds - minutes*60)can equal 60. Cap withMath.min(59, …)or recomputeminutesfrom the rounded total. - Long license overflows option label —
Studio.tsxmodel<select>concatenatesname · license. Addtitle={license}so the full string is visible on hover when truncated. - Default model not highlighted in picker — sort
is_defaultfirst or add a(default)suffix. -
flake.nixpinsnixos-unstable—flake.lockpins the commit so this is OK in practice; document it (or move tonixos-25.05) to avoid surprises if someone runsnix flake updatenear release. -
_prompt_archive_nameuses rawmodel_id—py/timbre/server.pysanitises non-alphanumerics but doesn't truncate. Usehashlib.sha256(model_id).hexdigest()[:16]+.pklso filenames stay short and predictable. -
rpc.pysilently skips malformed headers —_read_messagecontinues on header lines that don't split on:. Log atwarningso framing desync is visible in support bundles. -
audio.pydoesn't validate model output range —np.clip(samples, -1.0, 1.0)assumes the model returns floats already in that range. Warn (or raise) whenabs(samples).max() > 1.0so adapter bugs surface early instead of being silently clipped. -
paths.rs:resource_pathfalls back through CWD-relative paths — drop the twoPathBuf::from(relative)/PathBuf::from("..").join(…)candidates; keep only BaseDirectory and exe-relative ones. -
expect("error while running tauri application")inlib.rs:38— panics with no diagnostics. Log viatracingfirst so the failure lands in the rolling log file the user can attach to a bug report.
- Split
src/routes/Studio.tsx(2151 LOC) into a folder:Studio/Studio.tsx— main route + synth form (~800 LOC)Studio/SynthProgressPanel.tsx— progress + diagnosticsStudio/GeneratedRunGroup.tsx— batch cardStudio/GeneratedTake.tsx— per-take playbackStudio/GeneratedChunk.tsx— chunk row with regenerateStudio/ParamBadgeRow.tsxStudio/studioUtils.ts— grouping/formatting helpers
- Split
py/timbre/server.py(1343 LOC) by feature surface:server.py— RpcServer boot + method registration onlyserver_voices.py— voice CRUD + reference / prompt handlingserver_synth.py— synthesis orchestration + historyserver_models.py— model install / status RPCs- move module-level globals into a
SynthStateclass so locking discipline is enforceable instead of "remember to grab the right lock".
- Split
src/components/VoiceCreateDialog.tsx(732 LOC) — extractuseMediaRecording(),useTranscription(),useSaveVoice()hooks and reduce the dialog to a ~250 LOC orchestrator. - Split
src/routes/Models.tsx(849 LOC) — extractModelCard.tsx,GettingStartedDisclosure.tsx,InstallProgressBar.tsx, and auseModelInstall(modelId)hook so the route itself is mostly composition.
- 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.5x–2.0x, default1.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.0xremains a direct copy).
-
Model download progress now updates in real time — two-part fix. (1)
_sibling_blob_idinpy/timbre/models_state.pyprefers 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>.incompletefile and_file_statereturned zero bytes for the whole LFS portion of every download. (2)HF_HUB_DISABLE_XET=1set on the sidecar process insrc-tauri/src/sidecar.rs: hf_xet writes assembled chunks to.incompletein ~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 viahttp_getgrows 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.ps1from a thin wrapper into a self-healing bootstrap: detects non-elevated invocations and self-elevates viaStart-Process -Verb RunAs(VS Build Tools / WebView2 need admin); asserts winget >= 1.6 and thatwinget configureis 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); enablesHKLM\…\FileSystem\LongPathsEnabled=1so cargotarget/+node_modulesdon't hit MAX_PATH; resolvesrustup/corepack/pnpmvia explicit.cargo\binandProgram Files\nodejsfallbacks rather than trusting registry PATH propagation; forcesrustup install stable-x86_64-pc-windows-msvc --profile minimalbeforerustup defaultso the toolchain is materialised; passes--disable-interactivity+--accept-source-agreementstowinget configureso the run is fully non-interactive. Addedscripts/bootstrap.cmdas a zero-prereq.cmdshim (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. Hardenedscripts/verify-build-env.ps1: probes VS Build Tools viavswhere.exeand locateslink.exedirectly underVC\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.ymlwas red oncargo checkbecause the proc-macrotauri::generate_context!()validatesfrontendDist(../dist) at compile time and cargo doesn't runbeforeBuildCommand. Dropped the standalonecargo checkstep;tauri buildcovers it. Also dropped the broken--separators onpnpm tauri:build -- --bundles …andpnpm 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). AddedSwatinem/rust-cache@v2per platform. Newscripts/windows-deps.wingetdeclares the toolchain (VS Build Tools / MSVC v143 + Win11 SDK, Rustup, Node LTS, WebView2, Git) as the Windows analogue offlake.nix'scommonlist;scripts/setup-windows.ps1is the one-command entry point (parity withdirenv allowon Unix — runswinget configure, refreshes PATH, sets the rustup default, activates pinned pnpm, runspnpm install, then verifies);scripts/verify-build-env.ps1is the non-mutating smoke check, also wired in as a Windows-only CI step before the long Rust build.pnpm setup:windowsexposes 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-ttsimport; 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 +
.envrcfor 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-logsotracing_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 intodata_dir/bin/uvafter 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 Rustinstall_model_depsTauri command runninguv pip install -r requirements/{adapter}.txt. NewModelsroute 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. Defaultqwen-ttsinstall path is now lazy via this flow rather than baked intobase.txt. -
Switch Qwen3 manifest entries from
-CustomVoiceto-Base(zero-shot voice cloning checkpoint). -
Fix Models install hanging at 100% —
snapshot_downloadmaterialises symlinks after bytes finish, so we now emit afinalizingnotification when blob bytes plateau, and the UI flips toreadyon Promise resolution rather than waiting for thecompleteevent. -
Fix model deps reading as not-installed after install — call
importlib.invalidate_caches()before eachfind_specso a freshly-installedqwen-ttsis 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/Regeneratebutton in the voice dialog, sidecartranscribe.audioRPC backed byfaster-whisperbase model (now inrequirements/base.txt), with a graceful "reinstall backend" hint when the active venv predates the change. -
Qwen3 adapter rewritten against the actual
qwen-ttsAPI — usescreate_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 tox_vector_only_modewhen no transcript is provided. -
Models list resilience —
useModelsanduseModelStatusesnow retry with backoff andrefetchOnMount: "always"; Models route has a manual Refresh button that invalidates both queries. -
Fix models always reading as "not installed" —
_hf_repo_dirwas looking atmodels_dir/hub/models--<repo>/butHUGGINGFACE_HUB_CACHE=models_dirmakes HF skip thehub/subdir and lay snapshots straight under the cache root, so the check never matched. Dropped thehub/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_CACHEenv, suppressed pysbdSyntaxWarningnoise, and made the Rust frame reader resync on bogus bytes instead of killing the pipe. -
MPS fallback for Qwen3 — set
PYTORCH_ENABLE_MPS_FALLBACK=1from both the Rust spawn env and the Python__main__.pyso 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, frontenduseVoicePromptStatuseshook, 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 raisesmps_matmul: incompatible dimensionsmid-generation. Eager runs an explicitrepeat_kvfirst. CPU/CUDA paths default to SDPA. (Earlier hybrid-CPU/MPS placement workaround for the 65,536-channel embedding limit was removed ine2337f0— 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;Conditionalspayload 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
innerhandle, fails any pending requests with a clean error, and emits asidecar:diedevent. 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_pathnow prefers<workspace>/<rel>(via compile-timeCARGO_MANIFEST_DIR) whencfg!(debug_assertions)sopnpm tauri:devalways loads the latestpy/source instead of the staletarget/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_statusandvoices.prepare_for_model(streaming), default adapterserialize_payload/deserialize_payloadthat 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.getexpose 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/timbreon macOS,%LOCALAPPDATA%\timbreon Windows,$XDG_CACHE_HOME/timbreon Linux). Sidecar now exportsTIMBRE_CACHE_DIRandNUMBA_CACHE_DIRfrom 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 surfacesexaggeration+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 tosynth.runsosynthesis.params_jsonstays clean. Serversynth.runalso stamps the user-suppliedseedintoparams_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.jsonnow 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 vianpx tauri icon. -
macOS icon squircle shape — packaged
.icnswas rendering as a full opaque rectangle in Dock/Finder because macOS doesn't auto-mask app icons. Addedscripts/build-icons.mjs(usessharp) 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, runsnpx tauri icononce per shape, and stitches the platform-correct outputs together. Exposed aspnpm build:iconsfor reproducibility. -
Rebrand
qwentts-local→ Timbre (tagline: "From text to timbre."). Full rename: TauriproductName+ window title; bundle idcom.kialo.qwentts-local→com.timbre.app; Cargo packagetimbre/ libtimbre_lib; npm packagetimbre; Python modulepy/qwentts/→py/timbre/(74 imports rewritten); env varsQWENTTS_*→TIMBRE_*(TIMBRE_DATA_DIR,TIMBRE_CACHE_DIR,TIMBRE_MANIFEST,TIMBRE_SIDECAR_DIR,TIMBRE_ARTIFACT_PLATFORM); app data dir relocates to~/Library/Application Support/timbreand~/Library/Caches/timbre(orphanedqwentts-localdirs can berm -rf'd after first new-name launch); zustand persist keyqwentts-ui-settings→timbre-ui-settings(resets saved UI prefs once); custom DOM eventqwentts:audio-play→timbre: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.wavfiles, 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
Wand2glyph with LucideAudioWaveformand reworded the tagline from "local voice cloning" to "on-device voice cloning" insrc/App.tsx. -
Advanced params surfaced on each generated card — extracted a
ParamBadgeRowcomponent insrc/routes/Studio.tsxthat renders every schema param (with its current value, falling back to the schema default) plus the seed if recorded. Rendered at the bottom of eachGeneratedRunGroupcard under its ownborder-tdivider, and reused insideGeneratedTakeso expanded sub-takes share the same layout. Gated on!simpleMode. -
Per-model "Getting started" guidance on the Models route — rewrote the four
descriptionstrings inresources/models.manifest.jsonto lead with each model's distinguishing capability and add a "pick this when…" hook; added agetting_startedblock per model carrying reference-clip tips, text/chunk guidance, audio-length cap, sample prompts, paralinguistic tag list (Turbo only, authoritative 19 tokens from upstreamadded_tokens.json, grouped Non-verbal / Emotion / Register), slider value-by-value guidance (English only), and gotchas. Extendedsrc/lib/schema.tsModelInfowith the optionalgetting_startedZod object. Added a collapsible "Getting started" disclosure under each card insrc/routes/Models.tsxwith copy-to-clipboard tag chips and per-prompt copy buttons. Froze the deferred adapter roster at the current four with explicit re-add criteria.