Compare commits

..
Author SHA1 Message Date
James Pine 2bcb98d1a8 windows keybind note 2026-04-25 15:09:49 -07:00
Jamie Pine 95b0c4123c better naming for sponsors 2026-04-25 12:15:10 -07:00
Jamie Pine 2c499fe2a6 Merge remote-tracking branch 'origin/main' into feat/capture 2026-04-25 11:18:43 -07:00
Jamie Pine 0dbdec4531 changelog 2026-04-25 11:17:07 -07:00
Vincent SchäfferandGitHub 627d40b42d Fix web API URL for remote access (#550)
Default production web builds to the page origin so Docker and LAN users do not fetch from browser-local localhost. Preserve the local/Tauri fallback and repair stale persisted loopback URLs.
2026-04-25 10:59:58 -07:00
Jamie Pine a6a5717eb2 style(landing): drop pill chrome from /download maintainer kicker 2026-04-25 10:52:29 -07:00
Jamie Pine 4427af918e feat(sponsors): add /sponsors page, homepage promo, and in-app strip 2026-04-25 10:49:29 -07:00
Jamie Pine 2c3df3873d fix(captures): hide unwired storage settings 2026-04-25 10:32:44 -07:00
Jamie Pine 3f2c22b793 fix(mcp): preload speak pill window 2026-04-25 10:29:50 -07:00
Jamie Pine 2a1bb6f936 fix(captures): use platform hotkey defaults 2026-04-25 10:28:34 -07:00
Jamie Pine 29f99a8622 fix(mcp): preserve speak engine defaults 2026-04-25 10:24:51 -07:00
Jamie Pine 166250856f fix(captures): allow dictation without paste permission 2026-04-25 10:23:25 -07:00
Jamie Pine 2e5b8d2d67 fix(mcp): bundle stdio shim sidecar 2026-04-25 10:21:52 -07:00
Jamie Pine c7f50d5668 feat(stories): add empty tracks above/below the timeline
Tiny + strips sit at the top of the topmost label cell and the bottom of the bottommost one, sticky-positioned in the label column so they follow horizontal scroll. Clicking either extends the visible track stack in that direction by one — above adds max(existing)+1, below adds min(existing)-1. Both compute against the full set (defaults + item-derived + previously-added) so successive clicks keep extending instead of fighting over the same number.

Empty extras live in component state because a track only earns its keep once a clip lands on it. Once one does, item.track carries the number forward and the row keeps deriving from items naturally; if nothing lands there before reload, the empty row simply isn't there next time, which matches what the user expects of an unused affordance.
2026-04-25 04:27:23 -07:00
Jamie Pine 9b3fa177e2 fix(stories): hard-cut the audio graph on stop so long imports actually halt
source.stop() was the only thing happening when a clip was halted, and on long imported buffers (multi-minute MP3s scheduled via source.start with a duration argument) it was silently failing to halt the buffer in some browsers — pause left the music playing and seek stacked another source on top of the original. The mute-the-WaveSurfer-element fix was a different bug along the same path; this is the one that actually addresses the duplicated audio.

ActiveSource now carries the per-clip GainNode alongside the source, and stopSource detaches the onended handler before calling stop() (so the natural-end callback can't race with explicit teardown and re-delete a freshly rescheduled entry at the same id), then disconnects both nodes inside their own try/catch blocks. Even when stop() doesn't actually halt the buffer the graph is severed — no path from source to destination, no audio.
2026-04-25 04:27:15 -07:00
Jamie Pine e7846eaf77 fix(stories): mute the clip waveform's media element so it can't bleed audio
The clip waveforms drawn inside each timeline track use WaveSurfer with the default MediaElement backend, which creates an internal <audio> element to drive playback timing. Web Audio in useStoryPlayback is what actually produces sound, but WaveSurfer's element was happily preloading and — after the first user gesture unlocked browser autoplay — playing the source URL through the page output too.

For TTS clips it was masked: they're short, both sources start at the same time, and stopping the BufferSourceNode at pause coincides with the natural end of the audio element. For long imports (a four-minute MP3) the BufferSourceNode stops on pause but WaveSurfer's element keeps going on its own track — which is exactly the "music keeps playing when I pause" symptom.

Hand WaveSurfer a muted <audio> element via the `media` option so the visual still loads peaks but the element itself can never produce sound. preload="metadata" keeps the load lightweight.
2026-04-25 02:25:37 -07:00
Jamie Pine 9f183e5832 feat(stories): per-clip volume control on the timeline
Each story item now carries a volume column (linear gain, default 1.0,
clamped 0.0–2.0 server-side). New PUT /stories/{}/items/{}/volume route
+ useUpdateStoryItemVolume hook + a Volume2 icon in the clip-edit
toolbar that opens a popover with a 0–200% slider. Local slider state
drives the visual during a drag; the persist fires once on
onValueCommit, mirroring the generation-page slider pattern.

Web Audio playback inserts a per-clip GainNode between source and
master so volume changes apply live without re-decoding the buffer
(source -> clipGain -> masterGain -> destination). Server-side
mixdown in export multiplies the trimmed clip by its volume before
summing into the timeline. Split + duplicate carry the volume forward
to the new clips so trimming a faded section keeps the level you set.

Migration adds the volume column with default 1.0 so existing rows
read as full volume.
2026-04-25 02:18:56 -07:00
Jamie Pine 935efedae0 fix(stories): round split_time_ms before posting
handleSplit was sending currentTimeMs - item.start_time_ms straight to the backend, which rejects it because StoryItemSplit.split_time_ms is typed as int and the playhead's currentTimeMs is a float (it's driven from HTMLAudioElement.currentTime, which carries sub-millisecond precision). Pydantic surfaced the mismatch as "Input should be a valid integer, got a number with a fractional part" and the toast read "Failed to split clip". Math.round at the call site, matching what the trim and move handlers already do.
2026-04-25 02:12:00 -07:00
Jamie Pine d526a9e337 fix(stories): show the source filename on imported clips
Imports were rendering as "Imported Audio" everywhere because every
import points at the singleton voice profile. The filename was already
being stored on the generation row (in the `text` field), so the chat
item title and the timeline clip label now read from `text` when
`engine === 'import'` and fall back to the profile name otherwise. The
chat item also drops the language pill (always "en" on imports — not
informative) and skips the transcript textarea since imports have no
spoken text to show.
2026-04-25 02:09:45 -07:00
Jamie Pine 4e7f8f9bda feat(stories): zoom bar bounds tracked to project length, default 60s scope
The track editor's zoom was clamped to a hardcoded [10, 200] pixels-per-second range, which had no relationship to the project — on a 4-minute story a "max zoom out" of 200 px/s still required scrolling, and on a 5-second story you could zoom all the way in to where every clip was a tiny sliver. Reframed the bounds in the unit the user actually thinks in: how many seconds of timeline are visible at once. Min scope is 10 s (most zoomed in), max scope is the entire project, and the default lands on a 60 s scope (or the full project, whichever is shorter) once the editor measures its visible track width on first mount.

The pixels-per-second value still lives in component state (because every downstream calculation already uses it) but minPps/maxPps are computed from `containerWidth − LABEL_COL_WIDTH` and the project's effective duration, so the +/- buttons and the edge-drag handles on the scrollbar all clamp to bounds that move with the project. Re-clamping fires whenever those bounds shift — adding a long clip or resizing the window pulls the current zoom inside the new range instead of leaving the user parked outside it.
2026-04-25 02:08:03 -07:00
Jamie Pine 3d4d0a9335 fix(audio): serve real Content-Type so imports decode in WaveSurfer
/audio/{id} and /audio/version/{id} hardcoded media_type="audio/wav" on
the FileResponse. That was a no-op when every generation came out of
TTS (everything on disk was a .wav anyway), but imported audio keeps
its source format — .mp3 / .m4a / .ogg — and the WaveSurfer MediaElement
backend uses an <audio> tag that checks Content-Type before letting the
clip play, so an MP3 announced as audio/wav silently failed to load.

Both endpoints now derive the type via mimetypes.guess_type and fall
back to audio/wav for unknown suffixes. Download filenames also keep
the real extension instead of always saying ".wav".
2026-04-25 02:00:54 -07:00
Jamie Pine c43f2d45cc feat(stories): import external audio into the timeline (drag-drop + picker)
You can now drop a music file onto the story content area or pick one through the new "Import audio" button in the add-clip popover. Both call POST /generate/import which writes the file to data/generations/<id>.<ext>, probes duration via librosa, and inserts a Generation row pointing at a singleton "Imported Audio" profile (created lazily on first import). The existing addStoryItem flow takes over from there — the timeline doesn't care that the row didn't come out of TTS.

Engine field on the row is "import"; it's surfaced on StoryItemDetail so the chat list shows a music icon instead of the (missing) profile avatar and both the dropdown and the track-editor toolbar hide the Regenerate action — there's nothing to regenerate. Accepted formats: wav/mp3/flac/ogg/m4a/aac/webm, capped at 200 MB. Translation keys added across en/ja/zh-CN/zh-TW.
2026-04-25 01:55:49 -07:00
Jamie Pine 2a937983c5 feat(stories): regenerate action on clips and the chat list dropdown
The track editor's clip toolbar now has a regenerate icon next to Delete; clicking it kicks a fresh take of the selected clip's underlying generation through the same /generate/{id}/regenerate path the History table uses, and pushes the id into the global pending set so the SSE watcher picks it up. The chat list's per-item dropdown gets the same action between Play-from-here and Remove. Translation keys added under storyContent.itemActions / storyContent.toast across all four locales.
2026-04-25 01:48:00 -07:00
Jamie Pine fe14df5fda chore(backend): Ruff lint pass — deprecated APIs, exception leaks, dead patterns
Mechanical sweep of items called out in the PR review:

- qwen_llm_backend: AutoModelForCausalLM.from_pretrained(torch_dtype=…) is deprecated in transformers ≥4.41 in favor of dtype=. Renamed.
- routes/llm: try/except around backend.generate() raised HTTPException(500, detail=str(e)) which leaks stack traces / paths to clients and trips Ruff B904. Now logs the original exception server-side and hands the client a generic message; chained via `from e` to preserve traceback context.
- mcp_bindings + mcp_server/context: datetime.utcnow() is deprecated since 3.12. Switched the two assignment sites to datetime.now(timezone.utc). The schema-level `default=datetime.utcnow` defaults in database/models.py are left for a later schema-aware pass.
- routes/generations: `logger = …` sat between two import blocks (Ruff E402). Moved below imports.
- mcp_server/server + tests/test_refinement_samples: typing.Callable / typing.Iterable have been preferred-via collections.abc since 3.9 (Ruff UP035).
- routes/events: `except asyncio.TimeoutError` aliases plain `TimeoutError` since 3.11 (UP041).
- services/captures: hoisted WHISPER_NATIVE_FORMATS to module scope (was a function-local UPPER_SNAKE that tripped N806) and replaced the raw_path.unlink try/except OSError-pass with contextlib.suppress (SIM105). Semantic equivalence preserved — written_files.remove(raw_path) still only runs when unlink succeeds because it sits inside the suppressed block after the unlink call.
- database/migrations: hoisted the duplicate `import sqlite3` from inside two helper bodies to a single module-level import.
2026-04-25 01:40:34 -07:00
Jamie Pine 6cf8da2698 perf(settings): persist generation sliders on release, not per pointer-move
Both sliders on the generation settings page were calling update() —
which is a React Query mutation that PATCHes /settings/generation —
inside onValueChange. Dragging the chunk-limit slider from 800 to 3000
fired a request per pointer-move pixel, and a mid-drag failure plus
optimistic rollback would leave persisted state visibly out of sync
with the thumb position.

Local state now mirrors each slider during a drag and the persist
happens once on Radix's onValueCommit (pointer-up / keyboard-release).
useEffects keep the local state in sync if the persisted value changes
out-of-band — another window editing the same setting still updates the
slider position cleanly.
2026-04-25 01:36:09 -07:00
Jamie Pine 70ec8d995b fix: i18n cleanup + readiness checklist effect cadence + ChordPicker shadow
- DictationReadinessChecklist was constructing downloadByModel as a fresh Map every render and listing it in the cleanup effect's deps. With the 1 s polling cadence and arbitrary parent rerenders the effect ran more often than it needed to. Memoised the Map on activeTasks; the effect now keys off the memo's identity.
- zh-CN persona tooltipActive/ariaLabelActive matched their inactive twins byte-for-byte ("以人物设定朗读"). The other locales differentiate the active state with a -ing / -中 suffix; zh-CN now reads "正以人物设定朗读" when active.
- personalityPlaceholder was a ~290-character paragraph that doubled as both the example text and the explanation, repeating most of what personalityHint already said. Trimmed to the example only and folded the explanation + leave-blank consequence into the hint, across all four locales.
- Refinement model size keys were size06 / size17 / size4. Renamed the 4B variant to size40 so the decimal padding is consistent.
- ChordPicker's open-effect bound a window.setTimeout id to a local `t`, shadowing the i18n `t` from useTranslation. Renamed to timeoutId.
2026-04-25 01:27:06 -07:00
Jamie Pine c113faf131 fix(dictate): force-dismiss the speaking pill when SSE never comes back
The pill subscribed to /generation/{id}/status to know when to start
playback, but EventSource.onerror was a no-op — auto-reconnect was the
intended recovery for transient drops. The gap: if the backend deletes
the gen row mid-flight or the connection silently dies in a way the
browser keeps retrying without ever getting a status event, the pill
sits in 'speaking' forever and the user has no way to clear it.

Added a 60-second hard cap that arms when the SSE opens and clears the
moment any real status event lands. If it fires while the pill is still
on the same id and audio never started, it force-dismisses. Same idea
as the existing post-speak-end 15s grace, but covers the case where the
backend never says anything at all.
2026-04-25 01:23:37 -07:00
Jamie Pine 51c46cd89e perf(mcp): move last_seen_at stamp off the request path
ClientIdMiddleware was running the SQLAlchemy SELECT/INSERT/UPDATE/COMMIT
inline on the event loop after every /mcp/* and /speak request. SQLite
serialises writes, so concurrent MCP traffic queued behind the stamp
write — the response sat waiting on a side-effect that the client never
needs in band, and SSE streams would stall briefly per request.

The middleware now hands the stamp to asyncio.to_thread via a fire-and-
forget create_task so the response returns immediately and the write
runs on the default executor. A module-level set keeps strong refs to
in-flight tasks (per asyncio docs) so the GC can't collect them mid-
write. The fallback path runs the stamp inline if no loop is available
(tests/oddball callers) rather than silently dropping it.
2026-04-25 01:22:07 -07:00
Jamie Pine 0aa3a8d6b4 fix: PR review nits — response shape, landing copy, form reset
- /llm/generate's "model is downloading" branch was raising HTTPException(202, detail={...}), which wraps the payload in {"detail": ...} and forces clients to parse a success status as if it were an error. Switched to JSONResponse so the payload sits at the top level.
- The landing page's "Language Models" card advertised "Qwen 3.5" with sizes 4B/2B/0.8B; we ship Qwen3 at 0.6B/1.7B/4B. Aligned to what's actually in the binary.
- ProfileForm's discard-draft button reset the form without touching `personality` or `avatarFile`, so stale persona text and an attached avatar would survive the discard. The other three resets in the file already include both fields — this brings the discard path in line.
2026-04-25 01:21:21 -07:00
Jamie Pine c5b7760a8c fix(mcp): restrict voicebox.transcribe(audio_path=...) to loopback
audio_path mode took any absolute filesystem path and returned its
decoded contents as transcribed text with no caller verification beyond
the existence/size checks. The X-Voicebox-Client-Id middleware records
the header but never rejects an absent or fake one, so a Voicebox bound
to 0.0.0.0 (the documented "remote access" mode) was effectively an
unauthenticated arbitrary-local-file read primitive.

The middleware now stashes the request's remote address in a ContextVar
alongside the existing client_id, and audio_path mode refuses anything
that doesn't parse as a loopback address (IPv4 127.0.0.0/8, IPv6 ::1).
audio_base64 mode is unchanged — that path was always bounded to bytes
the caller already has.

Loopback callers (the Tauri webview, local CLI scripts, MCP clients on
the same machine) keep working. Remote callers now have to send the
audio over the wire if they want it transcribed.
2026-04-24 20:39:51 -07:00
Jamie Pine 9525bff28a fix(captures): clean up audio files when create_capture fails
The create flow wrote raw audio (and a transcoded .wav for non-wav
sources) to data/captures before the DB row was committed, so any
failure between the write and the commit — a webm that decoded to a
0-length array, a whisper model that errored mid-transcribe, a SQLite
contention on the commit — left the audio on disk with nothing pointing
at it. Over enough flaky uploads the directory grows without bound.

Now every path written before the commit is tracked in a list, and the
whole stretch from the first write to db.commit() runs inside a
try/except that unlinks each tracked file on raise and re-raises. The
transcode branch removes the raw file from the cleanup list only when
the unlink actually succeeds, so an OSError on the raw-path delete
still hands cleanup the original blob to retry.
2026-04-24 20:37:56 -07:00
Jamie Pine 800e390108 fix(settings): honor explicit null on nullable fields, ignore on the rest
Routes were calling model_dump(exclude_none=True), which drops every
client-sent null before it reaches the service. The service then layered
on its own `if value is not None` guard. Net effect: setting a nullable
column back to null was a no-op — the MCPPage default-voice picker sends
null when the user picks "no default" and the row was silently keeping
whatever was there before.

Switched the routes to exclude_unset=True so absent fields stay absent
but explicit nulls survive the dump, and centralised the per-field
nullability check in the service. The check inspects the SQLAlchemy
column metadata so non-nullable columns (stt_model, llm_model, the chord
key lists) still drop nulls instead of crashing the request, while
default_playback_voice_id can finally be cleared.
2026-04-24 20:36:48 -07:00
Jamie Pine 5f62a0ed1b fix(captures+chord): Stop button stops, ChordPicker accepts shorter chords
Two unrelated correctness bugs caught in PR review:

- The Play As "Stop" button was wired to handlePlayAs() unconditionally, so clicking it during playback kicked a fresh generation instead of halting. Now pauses the player when the click came from the main button while playbackState is 'playing'. Picking a different voice from the dropdown still kicks a new generation as before.
- ChordPicker tracked the peak set of held keys but seeded the peak from initialKeys, so a user who opened the picker with a 3-key chord saved couldn't replace it with a 2-key chord — the candidate length never beat the seed. The peak now resets on the first press of a fresh sequence (when no keys were held immediately prior), then grows monotonically within that hold.
2026-04-24 20:35:17 -07:00
Jamie Pine 7ad91f5767 fix(captures): Play As autoplay + default voice + orphan recovery
- Hand /generate ids to the global SSE watcher so playback fires on completion. The mutation onSuccess was checking audio_path on a queued row, which is always empty — autoplay never ran.
- Bind the Play As voice selection to capture_settings.default_playback_voice_id, kept in sync with the Settings → Captures and Settings → MCP pickers. Picking from the split-button dropdown writes back to settings.
- Extract AudioBars from HistoryTable into a shared component; use it for the Play As generating state in place of Loader2.
- Stop the active-state hover from flashing white text when the button is in its lighter accent/10 fill.
- Drop the gradient avatar swatches from the Settings → Captures voice dropdown.
- Backend: when the gen worker exits without writing a terminal status (e.g. SQLite lock racing the failed-status write inside its own exception handler), the cancel endpoint now flips the row to failed instead of 409-ing. Worker also force-fails on its way out as a belt-and-suspenders.
2026-04-24 19:49:36 -07:00
Jamie Pine b97c565a45 feat(ui): shared ListPane primitive + misc polish
ListPane is a compound component (Header / TitleRow / Title / Actions /
Search / Scroll) that owns the relative wrapper, faded right divider
(50px top fade), top scroll mask, and absolute-positioned header used by
every list-detail tab. Wires up CapturesTab, StoryList, and EffectsList.
EffectsTab gets -mx-8 / pr-8 to match the edge-to-edge layout used
elsewhere.

Other changes:
- MCPPage: native <select> → shadcn <Select> for default voice and
  per-binding voice pickers
- Button outline variant: add hover:border-accent
- Drop hover:text-destructive from trailing delete buttons
  (HistoryTable, GpuAcceleration, GpuPage, EffectsChainEditor,
  EffectsDetail)
- HistoryTable empty state moved behind t('history.empty')
- StoryContent scroll padding pt-14 → pt-16
- backend health reports the captures dir
- landing CapturesMockup: "Send to" → "Export" with Download icon
- CHANGELOG: drop [Unreleased] personality section
2026-04-24 17:23:14 -07:00
Jamie Pine 736a661059 fix mlx llm bundling 2026-04-24 14:41:30 -07:00
Jamie Pine 24833242b5 feat(ui): theme settings, stories polish, track editor restructure
- add dark/light/system theme with persisted choice + OS change listener
- restyle stories sidebar (search, item layout, border) to match captures
- move floating generate box to right column of stories, add top fade mask
- story track editor: sticky track labels aligned via flex rows, custom scrollbar with left/right zoom handles
- capture pill light mode pass, fix inline waveform progress color
- pull mcp_server hidden imports into the pyinstaller spec
- notarization doc draft
2026-04-24 04:16:36 -07:00
James Pine 271ecd924b perf(captures): stop polling readiness once both models are green
useQuery was firing GET /capture/readiness every 5s forever, and also
on every window focus. Once stt.ready and llm.ready are both true the
answer can only change when the user swaps a model in settings, and
useSettings already invalidates the query on that path — the polling
was pure noise.

Gate both refetchInterval and refetchOnWindowFocus on "not fully ready"
so we fall silent once the checklist is green.
2026-04-23 21:20:50 -07:00
James Pine c7cd7fd0fd chore(deps): bump keytap 0.2 → 0.4 for macOS modifier-events fix
0.2 read CGEventFlags via CGEventGetIntegerValueField(event, 0x81),
which is not a valid CGEventField id — macOS silently returned 0, so
FlagsChanged events produced no KeyDown / KeyUp for any modifier key
and the PTT / toggle chords never armed on macOS. 0.4 uses the
documented CGEventGetFlags(event) API.

0.3 (tracing / serde / Fn / IntlBackslash) is picked up as a free
consequence; no API surface we depend on changed.
2026-04-23 20:45:53 -07:00
James PineandClaude Opus 4.7 c6114b69bc feat(capture): swap the rdev fork for keytap 0.2, delete local chord state machine
Dep swap:
- Drop the git-pinned jamiepine/rdev fork we were carrying since the
  upstream crate is abandoned.
- Depend on keytap 0.2 from crates.io — our own cross-platform global
  keyboard tap crate. Clean shutdown via Drop, Sonoma-safe by design
  (no TSMGetInputSourceProperty calls off the main thread, so
  `set_is_main_thread(false)` is gone), and properly versioned.

Chord engine rewrite:
- Delete hotkey_monitor.rs's internal Chord state machine (Match enum,
  KeyEvent enum, step()/classify() methods, associated unit tests).
  keytap's ChordMatcher subsumes it: Momentary chord for PTT,
  add_toggle() for Toggle-to-talk, longest-match resolution, sticky-end
  for Toggle. Net: -80 LOC in hotkey_monitor.rs; the remaining module
  is the dispatcher loop + Effect→Tauri translation.
- Preserve the PTT→Toggle "RestartRecording" upgrade signal. keytap
  emits End(PTT)+Start(Toggle) atomically (same Instant) when the held
  set upgrades from a shorter chord to a longer superset. The
  dispatcher peeks at the matcher with a 5 ms recv_timeout after any
  End and coalesces the pair into Effect::RestartRecording so the
  frontend still gets the "discard the transition-moment audio" signal
  instead of an unrelated Stop+Start pair.
- HotkeyMonitor::update_bindings now actually tears down the tap on
  empty bindings instead of leaving an idle CGEventTap around. New
  bindings rebuild the matcher and the dispatcher thread from scratch.

key_codes.rs:
- Rewrite the browser-code → Key table against keytap's cleaner Key
  variant names (`A`..`Z` not `KeyA`..`KeyZ`, `Digit0`..`Digit9` not
  `Num0`..`Num9`, `ArrowUp` not `UpArrow`, `AltLeft`/`AltRight` instead
  of `Alt`/`AltGr`, `Period` not `Dot`, …). On-disk chord string
  format (W3C `KeyboardEvent.code` identifiers) is unchanged, so
  capture_settings rows written before the swap round-trip identically.
  Legacy aliases (`Alt`, `AltGr`, `Num0`, `UpArrow`, `Dot`, …) kept for
  forward-compat on old rows.

main.rs / input_monitoring.rs:
- Update the few doc comments that referenced `rdev::listen` to
  describe keytap's Tap; no behavioural change.
- build_chord_bindings now imports from keytap::Key.
- enable_hotkey / disable_hotkey / update_chord_bindings reach into
  HotkeyMonitor via &mut since apply()/update_bindings() now mutate.

Tests live in keytap now (22 chord-related tests in keytap 0.2,
including the PTT→Toggle upgrade scenario that used to be tested in
hotkey_monitor.rs). Voicebox's hotkey_monitor.rs is thin enough that
local testing would be trivia.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-23 20:13:23 -07:00
James PineandClaude Opus 4.7 1ca7895ffb fix(captures): hide macOS-only copy when running on Windows / Linux
Two surfaces leaked macOS-specific copy onto other platforms:

1. The Input Monitoring + Accessibility rows in the readiness
   checklist rendered everywhere. On Windows/Linux the Rust permission
   stubs return true, so the rows showed as permanent green checkmarks
   with copy like "macOS allows Voicebox to detect your global
   shortcut." — nonsense when you're on Windows. Gate both rows on a
   userAgent-based isMacOS check so they only render where the
   underlying TCC permission actually exists.

2. The global-shortcut setting description ended with "macOS will ask
   for Input Monitoring permission the first time you turn this on."
   That sentence rendered on every platform. The readiness checklist
   already surfaces the TCC requirement at the right moment on macOS,
   so the description doesn't need the platform note — drop it from
   en / ja / zh-CN / zh-TW.

Other macOS strings (AccessibilityNotice, InputMonitoringNotice, their
"stillMissing" hints) are already gated behind the Rust permission
booleans returning false, which never happens on Windows/Linux, so they
stay inert without further changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-23 19:50:55 -07:00
James PineandClaude Opus 4.7 ef63faf33a fix(captures): refetch readiness immediately after STT/LLM model swap
useCaptureSettings updated its own cache optimistically but never
invalidated ['capture-readiness'], so for up to 5 s (the poll interval)
after switching stt_model or llm_model the checklist kept showing the
previous model's ready/missing state. The backend endpoint resolves
the model live on each call — it was just the frontend cache that
lagged. Invalidate in onSettled only when the patch touched a model
field, so unrelated updates (chord keys, toggles) don't pay for a
refetch.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-23 19:41:10 -07:00
James PineandClaude Opus 4.7 66ca56bb0b feat(captures): move sidebar checklist below differences + hide when all green
Two small follow-ups to the sidebar checklist placement. Move it below
the What's different section so the sticky top of the sidebar stays the
page's narrative context (About → differences) and the checklist reads
as a status panel rather than preamble. Gate the whole block on
!readiness.allReady so once every gate is green the sidebar drops back
to just About + What's different — no value in real estate full of
checkmarks.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-23 19:41:10 -07:00
James PineandClaude Opus 4.7 f21778fcf3 feat(captures): mirror readiness checklist into the settings sidebar
The six-gate checklist only rendered in the CapturesTab empty state, so
a user already on the settings page had no single surface showing which
gate was red — the inline InputMonitoringNotice covered one, the model
pickers covered another, and Accessibility was only hinted at by the
auto-paste toggle. Mirror the same component into the right sidebar of
the settings page so every gate (STT model, LLM model, Input Monitoring,
Accessibility, plus the hotkey toggle in the main column) is always
visible while the user configures dictation.

New compact prop on DictationReadinessChecklist drops the centered
header and empty-state max-width so it fits the 280 px sidebar next to
the existing About / Differences blocks. Callers in compact mode own
the heading — CapturesPage reuses the existing captures.readiness.title
key (present in en / ja / zh-CN / zh-TW already) as an h3 matching the
sibling sidebar sections.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-23 19:33:14 -07:00
James PineandClaude Opus 4.7 687ab2aa16 feat(ui): persist selectedProfileId across sessions
Wrap useUIStore in zustand/middleware's persist under the key
voicebox-ui. partialize only selectedProfileId so volatile UI state
(dialog open flags, form drafts, engine/voice pickers, sidebar) stays
in-memory as before — but reopening the app no longer loses whichever
profile the user was last working with.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-23 19:20:42 -07:00
James PineandClaude Opus 4.7 67a9e308a9 feat(captures): scrubbable WaveSurfer player for capture detail view
Replace the placeholder fake-waveform + play button in CapturesTab's
audio card with a real CaptureInlinePlayer (wavesurfer.js). The player
renders the actual waveform, lets users scrub through the clip, and
shows a proper current/total timestamp pair in place of the
duration-only label.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-23 19:20:42 -07:00
James PineandClaude Opus 4.7 c9103a24da fix(mcp): stamp last_seen_at on /speak too + tighten path predicate
POST /speak is a REST wrapper around voicebox.speak for agents that
don't talk MCP (shell scripts, ACP, A2A). It reads X-Voicebox-Client-Id
and uses it for the same per-client profile resolution + default
personality lookup the MCP tool does (speak.py:39-64), so its callers
are first-class clients — but the ClientIdMiddleware only stamped
last_seen_at on /mcp* paths. REST speak callers showed up as "never
seen" in Settings → MCP despite actively acting on their bindings.

Widen the stamp predicate to an explicit ("/mcp", "/speak") prefix
list, and require a path boundary on match so future routes named
/mcpfoo or /speakers don't silently inherit the stamp via the prefix.
New test_client_id_middleware.py pins the scope with 17 parametrised
cases (both the allowed set and the overlap cases that must not match).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-23 19:18:41 -07:00
James PineandClaude Opus 4.7 30c2cf1a2c fix(mcp): correct lifespan shutdown order — drain MCP before unloading models
The inline lifespan ran _run_shutdown inside the MCP context, so the
TTS / Whisper / LLM models were unloaded *before* FastMCP's __aexit__
got a chance to cancel its in-flight session tasks. Any MCP request
mid-generate at shutdown time would crash on "model unloaded" instead
of receiving a clean session-cancelled error.

Rewire via compose_lifespan (which was already defined in
mcp_server.server for exactly this purpose but never used):
AsyncExitStack enters factories in order and exits in LIFO, so
MCP teardown fires first — cancelling sessions — and _run_shutdown
runs after nothing is holding the models. Smoke test shows the
log order flipped as expected:

  Ready
  StreamableHTTP session manager started
  ... running ...
  StreamableHTTP session manager shutting down   ← was last, now first
  Voicebox server shutting down...               ← was first, now last

As a side benefit, _run_shutdown is now paired with _run_startup via
try/finally inside voicebox_lifespan, so a partial startup (models
half-loaded, MCP __aenter__ fails) still unloads whatever was loaded
instead of leaking it to process exit.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-23 19:18:41 -07:00
James PineandClaude Opus 4.7 c0eba9c628 fix(db): graceful fallback when SQLite < 3.35 on MCP bindings migration
SQLite gained ALTER TABLE … DROP COLUMN in 3.35 (Mar 2021). Production
PyInstaller builds bundle Python 3.12 which links to SQLite 3.40+ so
that path is always safe, but a dev running the backend directly on
Ubuntu 20.04 (3.31) or Debian 11 (3.34) would crash on first startup
trying to drop the legacy default_intent column.

Add _supports_drop_column(engine) — returns True on non-SQLite
dialects (Postgres / MySQL have supported DROP COLUMN for decades) and
gates on the runtime sqlite_version for SQLite. When unsupported, log a
warning and leave the unused column in place: SQLAlchemy only maps
declared columns, so a stray default_intent column does no reads or
writes and can't interfere with runtime behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-23 19:18:41 -07:00
James PineandClaude Opus 4.7 0081e97ad7 fix(refinement): character-level loop collapse + pytest coverage
The word-level pass catches single-word Whisper loops ("URL URL URL…")
but misses two common hallucination patterns the PR had to claim as
"edge cases":

1. Multi-word English loops — "thanks for watching thanks for watching…"
   × 6 sails through because no two consecutive tokens are identical
   after text.split().
2. CJK loops — "謝謝觀看" × 7 sails through because text.split() returns
   a single unsplit token for the whole loop (no whitespace between
   characters).

Add a character-level second pass: a non-greedy regex finds any 2–60
char substring that repeats min_run+ times immediately after itself and
strips the run. The 2-char floor keeps emphasised single-letter runs
("wooooooow") intact. The 60-char ceiling covers every observed
Whisper tail hallucination ("Please like and subscribe to my
channel.", "Subtitles by the Amara.org community") while staying short
enough that coincidental long-phrase repetition in legitimate speech
doesn't hit the threshold. Whitespace normalisation only runs when the
pass actually stripped something, so untouched transcripts keep their
original spacing.

New test_refinement_collapse.py gives the pre-processor its first
deterministic unit-test coverage: 17 tests pinning the word-level
legacy behaviour plus the new multi-word English / CJK / Japanese /
emphasis-preservation cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-23 19:18:41 -07:00
James PineandClaude Opus 4.7 239523d797 fix(mcp): idle timeout + escalating backoff for speak-SSE monitor
Two reliability gaps in the /events/speak subscriber:

1. resp.chunk().await had no idle timeout. A backend that accepts the
   TCP connection but stops producing frames (deadlocked SSE endpoint,
   zombie process) would block the task forever without reconnecting.
   The pill window would never surface for agent-initiated speech and
   there would be nothing to log. Backend emits a `:ping` heartbeat
   every 15 s, so 45 s without any data is now treated as a dead
   stream — the task errors out and the reconnect loop takes over.

2. Flat 2 s backoff escalates nowhere. Logs fill with reconnect lines
   when the backend is down for minutes, and a backend that accepts +
   immediately closes connections (no data) spins the loop tightly.
   Backoff now escalates 500 ms → 30 s on unproductive rounds and
   resets only when at least one frame arrives (the connection was
   genuinely productive, not just accepted).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-23 19:18:41 -07:00
James PineandClaude Opus 4.7 27798dd46f chore(deps): pin rdev to jamiepine/rdev fork
Upstream Narsil/rdev has shipped no release since 2023-06 (crates.io
still serves 0.5.3), so the Sonoma main-thread fix we depend on — PR
#147, applied at hotkey_monitor.rs:184 — is only reachable via a git
pin. A pin to a third-party repo breaks the build whenever the remote
force-pushes, renames, or is taken down, and Cargo does not durably
cache git-dep archives the way it does crates.io tarballs.

Forking to jamiepine/rdev at the same SHA removes that failure mode
without changing crate behavior and gives us a place to cherry-pick
future OS-compatibility fixes on our own timeline. The SHA was verified
to exist on the fork before re-pinning.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-23 19:18:41 -07:00
James PineandClaude Opus 4.7 ea7a4e9f6d fix(capture): conditional clipboard restore + always-attempt on paste failure
Two bugs in paste_final_text' clipboard handling:

1. Restore was unconditional. If the user ⌘C'd in the target app during
   the 400 ms paste-consume window — or a clipboard history tool (Paste,
   Pastebot, Maccy) or Universal Clipboard sync snapshotted our staged
   text — the blind restore overwrote their newer content with the
   pre-paste snapshot, silently losing user data.

2. send_paste' errors were propagated with ? before the restore, so a
   CGEventPost / SendInput failure left the user's clipboard stuck on
   the transcript.

Fix folds both into one pattern: capture the post-write change count,
re-read it after paste-consume, restore only when they match (plus treat
a change-count read failure as "unknown, don't overwrite"). Isolate
send_paste's error so the restore runs regardless of paste success, then
propagate the paste error after.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-23 19:18:41 -07:00
James PineandClaude Opus 4.7 c65531bed1 fix(capture): cooperative app activation for synthetic paste on macOS 14+
macOS 14 deprecated NSRunningApplication.activateWithOptions: in favour
of a cooperative-activation pattern: the caller first yields activation
rights to the target, then the target activate()s against the tightened
Sonoma foreground rules. Without the yield, activate() on 14+ sometimes
silently fails or only bounces the dock icon — the exact "paste lands in
the wrong app" symptom we were previously one API break away from.

activate_pid now discovers the 14+ selector via respondsToSelector: and
branches: on 14+ it yieldActivationToApplication:'s from
NSRunningApplication.current then calls -activate on the target; on
11–13 it stays on -activateWithOptions: (still the only option). Both
branches propagate the BOOL return — if activation is refused we error
out before clobbering the clipboard instead of silently proceeding.

The respondsToSelector: result is cached in a OnceLock so the probe
isn't repeated on every paste.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-23 19:18:41 -07:00
James PineandClaude Opus 4.7 53a7693101 fix(capture): layout-aware V keycode for synthetic paste on macOS
macOS apps match Cmd+V against the layout-translated character via NSMenu
key equivalents, so posting kVK_ANSI_V (= 9, the QWERTY V position) on
Dvorak produces Cmd+. and never triggers Paste. New keyboard_layout
module resolves the active layout's V keycode via
TISCopyCurrentKeyboardLayoutInputSource + UCKeyTranslate, caches it in an
AtomicU16, and refreshes on kTISNotifySelectedKeyboardInputSourceChanged.
All TIS calls run on the main thread (init from Tauri setup; observer
callback delivered to the main runloop); synthetic_keys::send_paste
reads the cached value once per paste. Falls back to kVK_ANSI_V when
resolution fails or the active input source carries no Unicode key
layout data.

Windows is intentionally left on hardcoded VK_V — SendInput delivers
WM_KEYDOWN with wParam = VK_V to the target regardless of the active
layout, which is why `Send "^v"` works for AutoHotkey on Dvorak Windows.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-23 19:18:41 -07:00
Jamie PineandClaude Opus 4.6 0b2a3cdc78 fix: BOOL import for windows crate 0.62
BOOL moved from Win32::Foundation to windows::core in 0.62.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-23 19:10:43 -07:00
Jamie Pine 3f1ab75d49 i18n: GenerationPage sidebar copy 2026-04-23 17:41:22 -07:00
Jamie Pine abf5dfda8c personality: bool API, i18n across the app
- Collapse intent tri-state (respond/rewrite/compose) to `personality: bool` on /generate, /speak, and voicebox.speak. Drop respond entirely; keep compose as a standalone button via /profiles/{id}/compose. Remove /rewrite, /respond, and /speak profile endpoints.
- FloatingGenerateBox: Wand2 persona toggle + Dices compose button appear when the selected profile has a personality. ProfileCard badges Wand2 alongside the effects Sparkles.
- MCP bindings: default_intent column → default_personality: bool. Migration drops the legacy column.
- i18n: en / ja / zh-CN / zh-TW translation files filled out and wired through the capture, server, and profile UI.

```ts
voicebox.speak({
  text: "Deploy complete.",
  profile: "Morgan",
  personality: true, // rewrite through the profile's personality LLM
});
```
2026-04-23 17:31:23 -07:00
Jamie Pine 7c50e189cb progress 2026-04-23 04:08:51 -07:00
James Pine be73a33ee1 model download status 2026-04-23 03:26:30 -07:00
James Pine ef00570145 color 2026-04-23 03:18:32 -07:00
James PineandClaude Opus 4.7 85a3e1363f feat(capture): gate global hotkey on dictation readiness checklist
Stops the "stuck pill" failure where pressing the chord with missing
STT/LLM models triggers a recording that has nowhere to land. The
hotkey now stays disarmed until every gate (models downloaded, Input
Monitoring + Accessibility granted) is green; the empty-state checklist
in CapturesTab surfaces each unmet gate with a one-click action and
auto-arms the chord once everything turns green.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-23 03:09:44 -07:00
Jamie Pine 868a40fb7e readme and dev script 2026-04-23 02:00:42 -07:00
James PineandClaude Opus 4.7 6b75e097e1 feat(mcp): Rust-owned speaking pill with self-contained audio playback
The pill window now surfaces for agent-initiated speech without main-window
involvement. Rust subscribes to /events/speak via a tokio task + reqwest
streaming body (speak_monitor.rs), shows the pill, and forwards events to
the dictate webview over Tauri's event bus. The pill plays audio via a
plain HTMLAudioElement and emits dictate:hide when playback ends. The
pill stays hidden through the ~1 s generation wait and only surfaces when
audio actually starts, with the counter armed at that moment.

Fixes a shared-dict mutation in mcp_server/events.publish() that caused
the second subscriber (Rust speak_monitor) to receive `event: message`
instead of named speak-start/speak-end frames. Also teaches the speak_monitor
parser to handle CRLF framing (sse-starlette default). Main-window
AudioPlayer now skips autoplay for source in {mcp, rest} to avoid
double-play when both windows are alive.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-23 01:46:17 -07:00
James PineandClaude Opus 4.7 0cef2c9fe1 feat(mcp): local MCP server exposes voicebox.* tools to AI agents
Mounts FastMCP at /mcp (Streamable HTTP) so Claude Code, Cursor,
Windsurf, and the VS Code MCP extensions can call voicebox.speak,
voicebox.transcribe, voicebox.list_captures, and voicebox.list_profiles
against the running Voicebox server.

Backend
- new backend/mcp_server package (tools, middleware, profile resolve,
  pub/sub events); named mcp_server to avoid shadowing the installed mcp
  PyPI package FastMCP imports internally
- app.py migrated from @app.on_event to lifespan= so FastMCP's session
  manager cohabits with Voicebox's startup/shutdown
- new MCPClientBinding table + /mcp/bindings CRUD; ClientIdMiddleware
  reads X-Voicebox-Client-Id into a ContextVar and stamps last_seen_at
- profile resolution precedence: explicit -> per-client binding ->
  capture_settings.default_playback_voice_id
- POST /speak REST wrapper for non-MCP callers (shell, ACP, A2A)
- GET /events/speak SSE broadcasts speak-start / speak-end so the pill
  surfaces agent-initiated speech
- backend/mcp_shim proxy (plain httpx) for stdio-only MCP clients
- PyInstaller spec updates + new --shim build target (~18 MB)

Frontend
- Settings -> MCP page with HTTP / stdio / claude-mcp-add copy snippets,
  default voice picker, per-client bindings table, connection status
- useMCPBindings, useSpeakEvents hooks
- CapturePill gains 'speaking' state; DictateWindow subscribes to SSE
  and emits dictate:show so the Rust side surfaces the pill window

Native
- tauri.conf.json externalBin now includes voicebox-mcp
- show_dictate_window helper + dictate:show listener in main.rs
- (also in this commit: InputMonitoringGate UX, hotkey_monitor tweaks,
  landing footer/navbar updates, new overview docs for captures /
  dictation / mcp-server / voice-personalities)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-22 22:05:30 -07:00
James PineandClaude Opus 4.7 87c582ad54 feat(capture): dictation, personalities, 0.5.0
Ships the Capture release end to end. Global-hotkey dictation with
synthetic paste into the focused app on macOS and Windows, an on-screen
pill across recording / transcribing / refining, customizable push-to-
talk and toggle chords, and an accessibility-permission prompt scoped to
Settings → Captures with inline re-check feedback.

Voice profiles gain optional personalities that power compose / rewrite /
respond actions via a local Qwen3 LLM — shared with refinement, so there
is one local LLM in the app, not two.

Refinement hardened with deterministic Whisper-loop collapse before the
LLM sees the transcript, per-capture flag snapshots for re-runs, and a
ten-transcript evaluation harness across every bundled refinement size.

Version bump 0.4.5 → 0.5.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-22 18:49:16 -07:00
Jamie Pine ed2eec591a Bump version: 0.4.4 → 0.4.5 2026-04-21 22:06:19 -07:00
Jamie PineandGitHub d61e884104 fix(offline): patch transformers mistral-regex check to survive HF failures (#530)
* fix(offline): patch transformers mistral-regex check to survive HF failures

transformers 4.57.x's `PreTrainedTokenizerBase._patch_mistral_regex` calls
`huggingface_hub.model_info(repo_id)` unconditionally during any non-local
tokenizer load to probe for Mistral-family models. The call raises on
`HF_HUB_OFFLINE=1`, on network outages, and on slow/blocked HF endpoints,
and transformers doesn't catch any of it — the exception bubbles out of
`from_pretrained` and kills the load for unrelated engines (Qwen TTS,
Qwen CustomVoice, TADA, etc.).

0.4.2's load-time `force_offline_if_cached` guard walked straight into
this trap: on cached online users it flipped `HF_HUB_OFFLINE=1` and
converted a healthy load into a hard crash. 0.4.3's inference-path guard
masked it; #524 removed the inference guard in 0.4.4, and users updating
to 0.4.4 started hitting the same error on the load path instead
(#526).

Fix:
- Wrap `_patch_mistral_regex` so any exception from the inner HF
  metadata check is swallowed and the tokenizer is returned unchanged.
  Voicebox never loads Mistral models, so the regex rewrite this check
  gates is a no-op for us; matches the success-path behavior for
  non-Mistral repos (tokenization_utils_base.py:2503).
- Drop the `force_offline_if_cached` wraps from every load path
  (pytorch_backend Qwen + Whisper, qwen_custom_voice_backend,
  mlx_backend Qwen + Whisper). With the mistral patch in place they
  provide zero value and only risk re-introducing the same class of
  bug. Helper and its unit tests stay — still correct for targeted
  future use.
- Add `backend/tests/test_offline_patch.py` covering
  OfflineModeIsEnabled / ConnectionError suppression, success
  pass-through, idempotence, and the missing-method no-op path.

Fixes #526.

* fix(offline): install mistral-regex patch for non-MLX backends

The previous commit left the patch wired only through ``mlx_backend.py``'s
existing import of ``hf_offline_patch``. On Windows/Linux/CUDA users who
never load the MLX backend (everyone who hit #526), the patch module was
never imported, so ``patch_transformers_mistral_regex`` never ran and the
crash persisted.

Hoist the import into ``backends/__init__.py``. Every backend imports from
this package, so the module-level patch install runs before any
``from_pretrained`` call regardless of which engine the user picks.

Caught by CodeRabbit and Cursor Bugbot on #530.
2026-04-21 22:01:29 -07:00
Jamie Pine 74e004400f Bump version: 0.4.3 → 0.4.4 2026-04-21 04:28:09 -07:00
Jamie PineandGitHub 0047352df1 fix(offline): remove inference-path HF_HUB_OFFLINE guards (#524)
0.4.3 wrapped every inference body (`generate`, `transcribe`,
`create_voice_clone_prompt`) with `force_offline_if_cached(True, …)` to
prevent lazy HF lookups from hanging when the network drops
mid-inference (#462). That trade broke online users: the guard flips
`huggingface_hub.constants.HF_HUB_OFFLINE` globally, so any legitimate
metadata call the library makes during generation (e.g. revision
resolution via `HfApi().model_info`) now raises:

    Cannot reach https://huggingface.co/api/models/Qwen/Qwen3-TTS-…:
    offline mode is enabled.

Hit by multiple users on 0.4.3 within hours of release. The offline
blast radius is much larger than the original hang it fixed.

This reverts the inference-path guards. Load-path guards stay — those
worked fine in 0.4.2 and aren't the source of the regression. The
`force_offline_if_cached` helper itself is unchanged; tests still pass.

The #462 hang (network dropping mid-inference) remains unaddressed by
this commit and will need a targeted fix that doesn't flip a global
flag — most likely per-call timeouts or library-specific
`local_files_only` arguments, not a process-wide env mutation.
2026-04-21 04:25:28 -07:00
Jamie Pine 7e7feeac54 chore(landing): swap 4th tutorial for Tech指南 Voicebox review 2026-04-21 01:43:22 -07:00
Jamie Pine 328bdca61c Bump version: 0.4.2 → 0.4.3 2026-04-20 23:34:06 -07:00
Jamie PineandGitHub abb752d623 fix(release): notarize and staple macOS DMGs (#523)
* fix(release): notarize and staple macOS DMGs

Tauri's bundler signs the .app and notarizes it, but ships the .dmg
wrapper unnotarized. Gatekeeper rejects that on macOS 15 Sequoia
(caught by Homebrew Cask CI) and causes the 'app isn't signed'
dialog on older Intel Macs when Apple's notarization servers are
slow (issue #509).

New step submits each built DMG to notarytool, staples the ticket,
verifies with spctl, then overwrites the release asset tauri-action
already uploaded to the draft release.

Adds ~5-10 min per macOS job (notarytool round-trip).

* fix(release): fail loudly when no DMG is found to notarize

Empty glob + nullglob was silently skipping the loop body, so if
Tauri's bundler output path changed we'd re-publish the unnotarized
DMGs with a green CI. Assert the glob matched at least one file.

* fix(release): resolve release tag from tauri.conf.json, not GITHUB_REF_NAME

GITHUB_REF_NAME is the branch name when the workflow runs via
workflow_dispatch, so the gh release upload targeted the wrong thing
on manual runs. tauri-action derives its tag from tauri.conf.json's
version field via the v__VERSION__ template; use the same source so
the two always agree.
2026-04-20 23:30:35 -07:00
Jamie PineandGitHub f0924d19d3 fix(backend): bundle unidic-lite for misaki Japanese G2P (#514) (#521)
fugashi (pulled in by misaki[ja]) needs a MeCab dictionary at runtime.
The `unidic` package that ships today contains no data — it relies on
`python -m unidic download` (~526MB), which isn't run by `just setup`
and won't survive PyInstaller freezing.

Switch to `unidic-lite`, which bundles a MeCab-compatible dict inside
the wheel (~50MB). Collect its data files in build_binary.py so frozen
builds also pick up the dicdir. Same failure mode and same fix shape as
the existing en_core_web_sm pre-install.
2026-04-20 23:00:55 -07:00
Jamie Pine 0f97300b4d chore(release): add 0.4.2 changelog, sync bumpversion
The 0.4.1 → 0.4.2 version bump (a756295) was done manually and missed
.bumpversion.cfg; catching it up so the next bumpversion run picks up
the right base. Also writes the 0.4.2 release story into CHANGELOG.md
so the release workflow can extract it as the GitHub Release body.
2026-04-20 17:05:45 -07:00
Jamie Pine 6787f65701 fix(ci): disable Linux release builds
Bundler still hangs on linuxdeploy download mid-rpm even with
createUpdaterArtifacts: false. Drop the ubuntu-22.04 matrix entry
until we figure out a reliable path; the ubuntu-specific setup steps
stay so we can re-enable by adding the matrix row back.
2026-04-20 17:01:48 -07:00
a72ef81dc1 feat(i18n): add i18next foundation with English + zh-CN locales (#508)
* feat(i18n): add i18next foundation with English + zh-CN locales

Installs i18next + react-i18next + language detector and wires up a
language selector in the General settings page. Extracts strings from
the highest-visibility surfaces: all settings tabs, model management,
sidebar nav, main editor, and the floating generate box. Remaining
strings (profile forms, history, stories/effects/voices/audio tabs)
can land in follow-up PRs.

Closes #411.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(i18n): ensure language switch actually re-renders the tree

- `nonExplicitSupportedLngs: true` was normalizing `zh-CN` → `zh` in
  some code paths; since we have explicit `zh-CN` resources, swap it
  for `load: 'currentOnly'` which keeps the code as-is.
- `react: { useSuspense: false }` — react-i18next v17 defaults Suspense
  on, which can silently suspend components mid-switch and look like
  "nothing happens" to the user.
- Use `i18n.language` (the raw current code) instead of
  `resolvedLanguage` in the selector so the dropdown always mirrors
  what we just set.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* feat(i18n): localize ProfileCard, HistoryTable, and relative dates

- ProfileCard: "No description", "designed" badge, aria-labels, and
  delete dialog.
- HistoryTable: delete / clear-failed / import / effects dialogs.
- formatDate: switch date-fns `formatDistance` locale based on
  `i18n.language` so "5 minutes ago" becomes "5 分钟前" under zh-CN.
  HistoryTable now subscribes via useTranslation so the table
  re-renders when language flips.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* feat(i18n): localize Stories tab (list, content, dialogs, toasts)

Covers the title + "New Story" button, empty states, story row
metadata (item count, updated time), the create/edit/delete dialogs,
and all toast notifications. Also handles StoryContent: "Select a
story" placeholder, search popover, "Export Audio" button, and the
"Generating N audios" pending indicator.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* feat(i18n): localize history item and story item dropdown menus

Covers the "..." action menu on both the History table (Play, Export
Audio, Export Package, Apply Effects, Regenerate, Delete) and on
individual story chat items (Play from here, Remove from Story).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* feat(i18n): localize Effects tab (list, detail, dialogs, toasts)

Covers EffectsList (title, "New Preset", section headers, preset
cards) and EffectsDetail (header buttons for Save / Save as Custom /
Delete, name/description fields, preview section, Save as Custom
dialog, all toasts).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* feat(i18n): localize EffectsChainEditor and built-in preset names

- Effect type labels (Chorus/Flanger, Reverb, Delay, Compressor, Gain,
  High-Pass, Low-Pass, Pitch Shift) and every param label (LFO speed,
  Modulation depth, Threshold, Ratio, etc.) go through
  `effects.types.<type>.{label,params.<param>}` with the backend string
  as defaultValue fallback.
- Chain-level controls: "Load preset…", "Add effect…", "Clear",
  Power/Remove button titles.
- Built-in preset names + descriptions (Robotic, Radio, Echo Chamber,
  Deep Voice) are translated client-side; user-created presets keep
  their original names.

Backend keeps returning English — frontend intercepts and translates
via key lookup, defaulting to the backend string so unknown
effects/params don't break.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* feat(i18n): localize Create/Edit Voice modal and audio sample panels

ProfileForm now routes its title, description, voice-source toggle
(Clone from audio / Built-in voice), field labels (Name, Description,
Language, Engine, Voice, Reference Text, Default Engine, Default
Effects), sample tabs (Upload / Record / System Audio), action
buttons, and every toast + Zod validation message through i18n.

Also covers the three AudioSample panels (Upload/Record/System) — the
choose-file / start-recording / start-capture call-to-actions, the
"N remaining" countdown, "Recording complete" / "Capture complete"
states, and the Play / Transcribe / Remove / Record Again buttons.

SampleList too — the "No samples yet" empty state, per-sample edit
mode, mini-player aria labels, Delete Sample dialog, and toasts.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* feat(i18n): localize Audio Channels tab (list, dialogs, device picker)

Covers the "Audio Channels" title and "New Channel" button, the empty
state, per-channel section labels (Output Devices / Assigned Voices),
the Available Devices right pane with its three contextual hints, the
"No voices assigned" fallback, and both Create/Edit dialogs (titles,
descriptions, field labels, Select placeholders, and the "(default)"
badge).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* feat(i18n): localize Voices tab (table header, search, inspector)

VoicesTab now translates the "Voices" title, search placeholder,
"New Voice" button, all six table column headers (Name, Language,
Generations, Samples, Effects, Channels), the avatar alt text, and
the per-row channel MultiSelect (placeholder + "(Default)" suffix).

VoiceInspector routes its form labels through the existing
`profileForm.fields.*` keys, has its own "Default Effects" hint and
avatar/save toasts, and reuses the ProfileForm Zod validation.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* feat(i18n): localize ProfileList unsupported-model note and empty state

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* feat(i18n): add Traditional Chinese (zh-TW) locale

Adds a zh-TW translation with Taiwan vocabulary conventions
(e.g. 預設 / 儲存 / 載入 / 匯入 / 匯出 / 設定 / 檔案 / 伺服器 / 裝置 / 網路).
Registers it alongside en and zh-CN; the language dropdown picks it up
automatically from SUPPORTED_LANGUAGES.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* feat(i18n): add Japanese (ja) locale

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(i18n): localize relative dates for ja and zh-TW

formatDate only mapped zh-CN, so history timestamps stayed in English for
ja and zh-TW users even after the rest of the UI translated. Extend the
switch to ja and zhTW from date-fns/locale.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(i18n): address PR review feedback

Bugs:
- GeneralPage: network access toast used the keep-server-running title key
  (wrong semantic scope). Add networkAccess.updatedTitle and use it.
- GeneralPage: fallback "Unknown" version was stored as a translated string
  in state, so it stayed stale across language switches. Store null, resolve
  the label at render time.
- GeneralPage: memoize the zod resolver on t and retrigger validation when
  the locale changes so existing error messages retranslate.
- GpuPage: adding t to the CUDA progress EventSource effect deps caused the
  SSE connection to be torn down and reopened on every language change,
  potentially dropping in-flight download events. Capture t in a ref.
- HistoryTable: Effects dialog still rendered English "Source" / "Select
  source version" / "Cancel" / "Apply" / "Applying..." — localize them.
- Locales: zh-CN / zh-TW / ja devSuffix was missing the leading space before
  "(开发版)"/"(開發版)"/"(開発版)", so dev builds rendered "v0.4.2(开发版)"
  instead of "v0.4.2 (开发版)".

Nits:
- ModelManagement: rename .find((t) => ...) callback param to avoid
  shadowing useTranslation().t.
- GenerationPage: rename chunkLimit.value interpolation key from count →
  chars so i18next doesn't silently activate pluralization if a translator
  later adds _one/_other forms.
- LanguageSelect: narrow onValueChange handler param to LanguageCode.

Key count now 559 across en/zh-CN/zh-TW/ja (added 4 effectsDialog keys
plus networkAccess.updatedTitle).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-20 16:49:35 -07:00
21dd3b8315 fix(backend): pin miniaudio in requirements-mlx.txt (#505) (#506)
mlx-audio's STT path imports miniaudio, but we install mlx-audio
--no-deps to dodge its transformers>=5.x pin. Nothing else pulls
miniaudio transitively, so fresh Apple Silicon installs fail to
transcribe with ModuleNotFoundError: miniaudio. Listed explicitly
and updated the stale comments in requirements-mlx.txt and
release.yml that claimed it came from other engines.

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-20 04:20:40 -07:00
5aa1677a25 fix(offline): guard inference paths with HF_HUB_OFFLINE (#503)
* fix(offline): guard inference paths with HF_HUB_OFFLINE (#462)

PR #443 wrapped the model *load* path with `force_offline_if_cached` so
cached models don't phone home at startup. The context manager restores
`HF_HUB_OFFLINE` on exit, which left inference paths (generate,
transcribe, voice-prompt creation) unguarded — and `qwen_tts`,
`mlx_audio`, and `transformers` perform lazy tokenizer/processor/config
lookups during inference. With internet on, those lookups are
near-instant and invisible; with internet off, `requests` hangs on DNS
or connect until the network returns. This is exactly what users in
#462 describe: model shows "Loaded", internet drops, generation
"thinks" forever, internet comes back, generation completes.

Chatterbox and LuxTTS don't exhibit this because their engine libs
resolve everything through already-cached paths at load time.

Fix: wrap each inference-sync body with `force_offline_if_cached(True,
...)`. Since inference only runs after a successful load, weights are
known to be on disk, so `is_cached=True` is unconditional.

Also adds the load-time guard that was missing from
`qwen_custom_voice_backend.py` — CustomVoice previously had no offline
protection at all.

Paths patched:
  - PyTorchTTSBackend.create_voice_prompt (create_voice_clone_prompt)
  - PyTorchTTSBackend.generate (generate_voice_clone)
  - PyTorchSTTBackend.transcribe (Whisper generate + decoder-prompt-ids)
  - MLXTTSBackend.generate (mlx_audio generate, all branches)
  - MLXSTTBackend.transcribe (mlx_audio whisper generate)
  - QwenCustomVoiceBackend._load_model_sync + generate

Does not address the secondary `check_model_inputs() missing 'func'`
error reported in the same issue — that's a `transformers` 5.x
version-skew bug on the install path, separate concern.

Fixes #462.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(offline): mutate cached HF constants + threadsafe refcount

Review feedback on the initial fix surfaced two real issues:

1. ``os.environ`` toggles alone don't flip offline mode.
   ``huggingface_hub.constants.HF_HUB_OFFLINE`` is read once at import
   time into a module-level bool; ``transformers.utils.hub._is_offline_mode``
   mirrors that bool at its own import time. The hot paths
   (``_http._default_backend_factory`` in huggingface_hub,
   ``is_offline_mode`` in transformers) read the cached bools — not the
   env — so mutating only ``os.environ`` was a no-op.

2. Race condition on concurrent inference. Two threads running inside
   ``force_offline_if_cached`` via ``asyncio.to_thread`` could have
   thread A's ``finally`` strip thread B's offline protection mid-run.

Rewrite the helper to:
  - mutate ``huggingface_hub.constants.HF_HUB_OFFLINE`` and
    ``transformers.utils.hub._is_offline_mode`` directly
  - refcount concurrent users under a single ``threading.RLock`` so a
    shared offline window is restored only when the last caller exits
  - still write ``os.environ`` for anything that reads it dynamically

Also addresses the unused-variable ruff flag on the Whisper transcribe
path (``audio, sr`` → ``audio, _sr``).

New unit tests cover the cached-constant mutation, env propagation,
no-op on ``is_cached=False``, nested contexts, and a threaded race
where a slow thread must retain offline mode after a peer exits.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(offline): atomic entry rollback + tidy test assertions

Review follow-up:

- Wrap the `_offline_refcount == 0` setup in a try/except so any failure
  during the cached-constant mutation (including unexpected non-ImportError
  like RuntimeError or AttributeError from a half-initialized module)
  rolls back *all* partial state before re-raising. Without this, a
  mid-setup crash could leave `huggingface_hub.constants.HF_HUB_OFFLINE`
  mutated but the refcount at 0 — a persistent offline flag outliving
  the process.
- Swap ruff-flagged Yoda comparisons in the new test file (SIM300) and
  add a module-level note warning that these tests mutate global state
  and are not safe under cross-process parallelism.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* test(offline): make concurrency test deterministic and bounded

Replace the `sleep(0.15)` ordering hack with an explicit `threading.Event`
the fast thread sets in `finally`. The slow thread waits on that event
(bounded), then observes the flag — so we deterministically verify the
slow thread still sees offline mode after the fast thread has exited.

Also add timeouts to `barrier.wait()` and assert `not thread.is_alive()`
after the joins so the test can't hang on an unexpected failure path.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-19 19:27:42 -07:00
5964af5dea feat(ci): re-enable Linux release builds (CPU, deb+rpm) (#488)
* feat(ci): re-enable Linux release builds (CPU, deb+rpm)

Linux shipped briefly in March 2026 (b580189..103e98b) then was removed
with the message "github runners suck." The post-mortem: standard
ubuntu-22.04 hit disk-pressure during pip+PyInstaller, and a namespace
custom-runner attempt proved flaky. Nothing in the app itself was the
problem — the NVIDIA-package exclusions in build_binary.py (~3 GB
shaved from CPU builds), the CPU-only torch install order, the
PulseAudio/PipeWire audio capture code, and the Tauri deb/rpm bundle
targets all still work.

This restores ubuntu-22.04 to the release matrix as a CPU-only Linux
build with a disk-space cleanup step (jlumbroso/free-disk-space) to
address the root cause of the March failures. Ships .deb + .rpm only
— AppImage was explicitly dropped in e18757b due to glibc portability
issues, keeping that decision.

CUDA-for-Linux is intentionally deferred to a follow-up PR: the
GpuAcceleration.tsx frontend hard-codes CUDA download as Tauri-only
without a Linux branch, and AMD users already get ROCm acceleration
for free on a stock CPU-torch install (backend/app.py:148-160). The
NVIDIA-on-Linux case is the only remaining gap and is non-blocking
for a v1 Linux ship.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* chore(release): bump version 0.4.1 → 0.4.2

tauri-action uses tauri.conf.json's version to name the release, so
pushing v0.4.2 as a tag alone was insufficient — the workflow was still
trying to publish to v0.4.1 (immutable) and failing. Bumps all workspace
package.json files, Cargo.toml, Cargo.lock, and tauri.conf.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* ci(release): add Linux bundle-step watchdog + verbose cargo logging

The first v0.4.2 attempt hung inside tauri-action at the bundling stage
on ubuntu-22.04 for ~28 min with no output. Same failure mode that drove
the March 2026 removal (commit 103e98b). Without visibility we're
guessing — probable causes include linuxdeploy/AppImage download stalls
(despite --bundles deb,rpm), cargo link on a cold cache, or disk
pressure during the final link step.

- timeout-minutes: 30 on tauri-action for Ubuntu (45 min elsewhere) —
  fail fast with logs instead of waiting out the 6hr job timeout.
- --verbose added to the Linux build args so cargo streams progress.
- CARGO_TERM_VERBOSE + RUST_BACKTRACE=1 exported on tauri-action.
- New 'Disk / environment snapshot' step dumps df/free/tool versions
  right before the tauri step so we can correlate with any later
  OOM/ENOSPC failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* ci(release): disable Tauri updater artifacts on Linux (stops linuxdeploy hang)

Round-2 diagnosis from the v0.4.2 attempt: cargo finished in 3m 55s,
.deb and .rpm were bundled within 22s, then the step hung silently for
25 min until our 30-min watchdog killed it — no further log lines.

tauri.conf.json has `createUpdaterArtifacts: "v1Compatible"`. On Linux
the v1-compatible updater path wants a .AppImage.tar.gz, which means
downloading linuxdeploy-x86_64.AppImage from GitHub at build time.
That download is the silent blocker — same signature as the March 2026
"github runners suck" removal (commit 103e98b).

Fix: pass `--config {"bundle":{"createUpdaterArtifacts":false}}` on the
Linux build only. Mac/Windows continue to produce signed updater
artifacts as before. Linux users update via apt/dnf; Tauri in-app
auto-update for Linux can come later (and would require shipping
AppImage alongside deb/rpm).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* ci(release): pin free-disk-space action + stop removing LLVM

Review feedback on the Linux release workflow:

1. `jlumbroso/free-disk-space@main` was an unpinned ref in a job that
   runs with `contents: write` and handles signing secrets. Pin to the
   v1.3.1 commit SHA so a force-push or repo compromise can't inject
   arbitrary code into our release flow.

2. `large-packages: true` runs `apt-get remove '^llvm-.*'`, wiping LLVM
   just before the next step installs `llvm-dev`. That wastes CI time
   and risks cascade-removal of reverse deps that won't be pulled back
   in by `llvm-dev` alone. The remaining toggles (android, dotnet,
   haskell, swap-storage) already clear ~20 GB, which is enough
   headroom for the Python + torch + PyInstaller build.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-19 19:22:21 -07:00
115de231d0 fix(audio): preprocess reference samples instead of rejecting them (#502)
* fix(audio): preprocess reference samples instead of rejecting them

Uploaded/recorded voice samples were rejected outright whenever the peak
exceeded 0.99 ("Audio is clipping (reduce input gain)"). That wasn't
actionable: a recording in the app has no pre-gain control, and an
already-captured file can't be re-taken by the user. The Settings
"Normalize audio" toggle only affects generated TTS output, so users who
enabled it expecting it to help with sample uploads were still blocked.

Replace the hard reject with a small, always-on preprocess step that
runs right after load:
  - DC-offset removal
  - Conservative edge-silence trim (top_db=30) with 100 ms padding kept
  - Peak cap at 0.95 if the input peak exceeds that

Duration and RMS checks now run on the preprocessed waveform, so
samples that were previously rejected for being "hot" are accepted and
stored with safe headroom. True in-waveform clipping artifacts still
can't be repaired — peak scaling only prevents downstream re-clipping
during multi-sample combination and TTS inference.

Adds a unit-test file (previously none existed for audio.py).

Fixes #456.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(audio): raise trim threshold, cap pad at net-neutral

Review feedback on the preprocessor:

1. ``trim_top_db=30`` was labelled "conservative" in the docstring but is
   actually *more* aggressive than librosa's default of 60. Normal
   speech dynamic range sits around 30 dB, so 30 dB would eat quiet
   trailing syllables and soft consonants. Raise the default to 40 dB —
   below normal speech dynamic range but still catching obvious edge
   silence — and fix the docstring.

2. Unconditional 100 ms edge padding ran even when ``librosa.effects.trim``
   removed nothing. For a well-recorded 29.9 s upload that path would
   push the waveform past the 30 s ceiling and trigger a spurious "too
   long" rejection. Only pad when trimming actually shortened the
   audio, and cap the pad so the output never exceeds the input length.

Adds a regression test for the net-neutral length behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-19 19:22:18 -07:00
8929947c7a fix(mlx): point Qwen 0.6B at the published mlx-community repo (#501)
The 0.6B slot was aliased to the 1.7B repo as a temporary fallback
because `mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16` wasn't published
when MLX support shipped. That conversion is live now, so use it —
Apple Silicon users picking 0.6B get the actual 0.6B model (1.2 GB
instead of 3.5 GB).

Also drops the now-obsolete troubleshooting entry and updates the
triage notes in PROJECT_STATUS.md.

Fixes #485.

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-19 19:22:15 -07:00
Shekhar KumarandGitHub e3f7cd9d00 fix(landing): use public origin for download redirects behind proxies (#498)
Prefer x-forwarded host/proto for redirect URL construction so users are not sent to internal localhost origins.

Fixes #496
2026-04-19 15:57:58 -07:00
27a5a62581 fix(landing): API example + new /download page (no more dumping users on GitHub) (#487)
* fix(landing): use qwen_custom_voice in API example (instruct is CustomVoice-only)

The curl snippet showed engine: "qwen" alongside an instruct field, but base
Qwen3-TTS has no instruct path — that's a Qwen CustomVoice feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(landing): use a realistic UUID for profile_id in API example

Profile IDs are str(uuid.uuid4()), not slugs (see backend/services/profiles.py:175).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* feat(landing): add polished /download page — no more dumping users on GitHub

Users were clicking download, landing on the GitHub releases page, and filing
confused comments along the lines of "I ended up on some blog site called
GitHub." We now route every download CTA through a dedicated /download page
that auto-triggers the platform-specific download and gives users a polished
post-click experience with donate + docs + AI help prompts.

- New /download page:
  - Big app logo + "Your download has started" messaging.
  - Auto-detects platform from ?platform=X or navigator.userAgent.
  - Programmatically clicks a hidden anchor to trigger the file download
    without leaving the page.
  - Platform-specific buttons as a visible fallback for "download not
    working" / manual-pick.
  - Personal donate spiel + Buy Me a Coffee button.
  - Resources grid: docs, DeepWiki ("got questions? ask AI"), GitHub.
- Landing page download section cards now link to /download?platform=X
  instead of the asset URL directly.
- /download/[platform] (used by README/docs links) now redirects to the
  /download page rather than straight to the asset or to GitHub on error.
- Drops unused downloadLinks state from the landing page.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(landing): use official platform brand icons via simple-icons

The hand-rolled Linux SVG path wasn't actually Tux — it was a symmetric
placeholder shape. Apple/Windows were close but not canonical either.

- Apple + Linux: pulled from @icons-pack/react-simple-icons (SiApple, SiLinux).
- Windows: simple-icons drops the Microsoft mark over trademark policy, so
  the Windows 11 flag is inlined from Microsoft's public brand guidance.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(landing): route Download CTAs to /download page, not the section anchor

Hero CTA, navbar link, and footer link were all scrolling to #download
(the section at the bottom of the page) instead of going to the new
/download page that triggers the actual download.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* chore(landing): run dev server on Node instead of Bun runtime

Bun runtime + Next 16 Turbopack dev server intermittently trips a
JavaScriptCore allocator panic ('pas panic: deallocation did fail ...
Alloc bit not set') after a few requests. Dropping --bun keeps Bun as
the package manager but runs next dev on Node, which is stable.

Build + start keep --bun since one-shot invocations don't exhibit the
allocator drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(landing): route Linux users to /linux-install instead of attempting download

No prebuilt Linux binary exists yet (see /linux-install for build-from-source
instructions). The /download page previously treated Linux like the other
platforms — auto-triggering a non-existent AppImage and offering a dead
manual button.

- /download page: if platform resolves to 'linux' via ?platform or UA detect,
  window.location.replace('/linux-install') — never try to auto-download.
- Manual Linux card: label changed to "Build from source" and links to
  /linux-install (no download attribute, no asset URL).
- /download/linux pretty URL: 307s straight to /linux-install.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* docs: consolidate troubleshooting into the MDX docs site + status updates

- Delete docs/TROUBLESHOOTING.md; the canonical troubleshooting guide now
  lives under docs/content/docs/overview/troubleshooting.mdx so it's served
  from docs.voicebox.sh alongside the rest of the docs.
- CONTRIBUTING.md + README.md: repoint "Troubleshooting" references to the
  new MDX path. README gets a top-level callout so users hit the guide
  before filing an issue.
- PROJECT_STATUS.md: refresh issue/PR counts, document the flash-attn
  warning (cosmetic on all platforms; CUDA-only, fallback is PyTorch SDPA
  which is near-FA2 on Ampere+) with per-platform context + community
  Windows wheels + SageAttention/xformers alternatives, add WebAudio
  audio-session bug note (tracked separately in PR #486), and expand the
  Qwen 0.6B→1.7B MLX fallback explanation for triage.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(landing): address PR #487 review feedback

- Preserve canonical camelCase platform aliases (macArm, macIntel) in the
  /download/[platform] redirect so those URLs don't lose their platform param.
- Add accessible title + role="img" to the inline Windows SVG so it passes
  Biome's a11y rule and announces to screen readers.
- On /api/releases fetch failure, show an explicit error state with a single
  intentional link to GitHub releases — no more silent GitHub fallback or
  disabled-button UX lie. Keeps normies off GitHub unless they opt in.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-18 23:32:32 -07:00
d3a44338a2 fix(audio): prevent WKWebView audio session teardown after backgrounding (#41) (#486)
Keep a silent looping <audio> element mounted at the app root so macOS
never tears down the CoreAudio session. Without this, backgrounding the
app long enough leaves WaveSurfer's AudioContext in a state where play()
resolves and timeupdate fires, but no audio reaches the output — and not
even cmd+R (full JS reload) restores it, only a full app relaunch.

Uses a zero-PCM WAV blob at full volume rather than a muted element,
since WebKit can optimize muted media away and defeat the purpose.

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-18 22:43:59 -07:00
James Pine 28aa963b09 readme update 2026-04-18 21:19:51 -07:00
ae91aa9a88 docs: audit mdx docs against multi-engine backend (#484)
* docs: audit mdx docs against multi-engine backend and refresh stale content

Rewrote developer-facing docs that predated the TTSBackend Protocol /
ModelConfig registry refactor (architecture, tts-generation,
model-management, transcription). Updated user-facing docs to reflect all
seven shipped engines (Qwen, Qwen CustomVoice, LuxTTS, Chatterbox,
Chatterbox Turbo, TADA, Kokoro) instead of the outdated "5 engines" claim.

Also fixes:
- Stale app identifier (com.voicebox.app → sh.voicebox.app)
- CUDA backend update flow (now two-archive split, not N-way chunks)
- Whisper model list (removed tiny, added turbo)
- Broken /development/ and /guides/ route links
- Stale just commands and install steps (missing --no-deps chatterbox/tada)
- Removed ASCII art diagrams from README and stories.mdx
- History Generation schema sync with DB model

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* docs: add DeepWiki badge to README

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* docs: address PR review feedback

- architecture.mdx: fix backends/ file list (remove nonexistent qwen_backend.py, rename tada_backend.py → hume_backend.py)
- model-management.mdx: Kokoro language count 9 → 8 (matches ModelConfig)
- model-management.mdx: ProgressManager path services/ → utils/
- tts-generation.mdx: ModelConfig example uses field(default_factory=...) — mutable default would raise at runtime
- tts-generation.mdx: "1080p samples" → "on CUDA" (1080p is video, not audio)
- PROJECT_STATUS.md: replace ASCII architecture diagram with prose (matches no-ASCII-art rule)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(app): guard against undefined engine in FloatingGenerateBox preset check

form.getValues('engine') returns string | undefined; Set<string>.has()
rejects undefined under strict mode. Added a truthy guard before the
preset lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-18 21:06:06 -07:00
da6070155e landing: three more tutorials, mobile navbar + hero CTA fixes (#483)
- Add three tutorial cards (Danish Sofi, StinkyScrublet, mikbes)
- Navbar: switch parent to flex/justify-between on mobile (grid on sm+),
  unhide Donate button so both CTAs sit on the right, matching desktop
- Hero CTAs: keep Download and GitHub side-by-side on mobile instead of
  stacking vertically

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-18 19:21:46 -07:00
3c1e8512b9 fix(build): install mlx-audio/mlx-lm with --no-deps to bypass transformers 5.x conflict (#482)
The previous fix (#481) capped transformers at 4.57.6 in requirements-mlx.txt,
but pip's clean resolver in CI can't satisfy that alongside mlx-audio>=0.3.1
(declares `transformers==5.0.0rc3` or `>=5.0.0`) — it backtracks through every
transformers and tokenizers version and exits with `ResolutionImpossible`.

The dev install worked only because mlx-audio 0.4.1 was already present, so
pip never tried to re-resolve.

mlx-audio 0.4.1 + mlx-lm 0.31.1 both declare transformers>=5.x but the API
surface we actually use works fine on 4.57.x in practice (verified across all
engines in dev). Install both --no-deps to bypass the resolver; transitive
runtime deps (huggingface_hub, librosa, numpy, numba, pyloudnorm, etc.) are
already pulled in by requirements.txt.

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-18 17:43:31 -07:00
bf58750447 fix(build): pin transformers in MLX requirements to prevent 5.x upgrade (#481)
mlx-audio depends on `transformers` with no upper bound. Installing
requirements-mlx.txt after requirements.txt lets pip upgrade transformers
past the 4.57.x cap to 5.x, which breaks three engines in the frozen MLX
bundle:

- qwen-custom-voice: `check_model_inputs` was rewritten to take `func` as
  positional, so `@check_model_inputs()` factory calls fail with
  `TypeError: missing 1 required positional argument: 'func'`
- tada-1b: `PretrainedConfig.__init_subclass__` now applies `@dataclass`,
  which rejects tada's `strides: list = []` mutable default
- luxtts: Whisper init hits `AssertionError` in `torch._refs.normal_`

Restating the same constraint here keeps mlx-audio's transformers
dependency from quietly winning the resolver.

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-18 17:05:36 -07:00
Jamie PineandGitHub 2d56309bdd Change 'About' link text to 'Models' 2026-04-18 16:46:23 -07:00
James Pine 0445be295c tests and better website 2026-04-18 16:19:12 -07:00
James Pine 8d550a5f7c Bump version: 0.4.0 → 0.4.1 2026-04-18 15:15:58 -07:00
Esteban FraccasciaandGitHub 795bd54381 fix(linux): use pactl to detect PipeWire/PulseAudio monitor for system audio capture (#457)
cpal 0.15 uses ALSA as its Linux backend, which does not expose
PulseAudio/PipeWire monitor sources. The previous approach searched
for 'monitor' in cpal device names, which never matched on most
Linux systems, silently falling back to the microphone input.

This fix:
- Detects the correct monitor source via 'pactl get-default-sink'
  and 'pactl list short sources'
- Sets PULSE_SOURCE env var before cpal initialization so PulseAudio's
  ALSA plugin routes the default input through the monitor
- Preserves the original name-based search as fallback when pactl is
  unavailable
- No new dependencies added

Tested on PipeWire 1.0.5 with Realtek ALC897 (HD-Audio Generic).
2026-04-18 03:15:05 -07:00
a6ab5f3858 Add initial frontend quality gates and TS hardening (#418)
Co-authored-by: Erion De Andrade <[email protected]>
2026-04-18 03:14:43 -07:00
9d7e4a417e fix(api-client): declare moved + errors on migrateModels response type (#470)
ModelManagement.tsx reads migrationResult.moved (added in #433) but
apiClient.migrateModels() was typed as returning only { source, destination }.
The backend actually returns { moved: int, errors: list[str], source, destination }
(backend/routes/models.py:140, 168). Widen the TS return type so the check
typechecks under the new CI gate from #418.

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-18 03:14:08 -07:00
882cabc7d2 fix: warn user when no models to migrate during storage change (#433)
When user attempts to change model storage location with no models
downloaded, the migration API returns moved=0 early. Previously the UI
would still call setCustomModelsDir() and restart the server, causing
unexpected behavior (hang/connection lost).

This change checks migrationResult.moved === 0 and shows a helpful
toast message instead of proceeding with the storage change.

Fixes: #426

Co-authored-by: fuleinist <[email protected]>
2026-04-18 03:13:06 -07:00
9c76b5de2c docs: clarify paralinguistic tag support in quick start (#450)
Co-authored-by: txhno <[email protected]>
2026-04-18 03:12:46 -07:00
Cocoon-BreakandGitHub 4560b7378a fix: delete version rows and files in delete_generations_by_profile (Closes #446) (#447)
Signed-off-by: Cocoon-Break <[email protected]>
2026-04-18 03:12:39 -07:00
高巨龙andGitHub abd9943430 Fix migration dialog hanging when no models are present (#439)
When migrating model path with an empty cache, backend returned early without emitting migration completion SSE, causing frontend overlay to hang. This patch emits complete status for empty migrations.
2026-04-18 03:12:32 -07:00
c8cb12f1bc fix(build): repair frozen-binary imports for kokoro, chatterbox-multilingual, scipy, transformers (#438)
* fix(build): bundle kokoro source files for transformers runtime introspection

transformers opens .py source files at runtime to check attention/MoE
implementation via regex (e.g. _can_set_attn_implementation). PyInstaller's
--hidden-import only bundles .pyc bytecode, so kokoro/modules.py was missing
from the bundle causing a FileNotFoundError on Kokoro model load.

Switch from individual --hidden-import entries to --collect-all kokoro in both
build_binary.py and voicebox-server.spec. The kokoro package is 172K so no
meaningful bundle size impact.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(build): use SPECPATH for runtime hook instead of hardcoded absolute path

The linter expanded runtime_hooks=[] to an absolute /Users/... path which
would break CI and other dev machines. Use os.path.join(SPECPATH, ...) to
mirror the relative approach in build_binary.py.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(build): runtime hook to work around PyInstaller + Python 3.12 import breakages

Four distinct bundling-specific crashes blocked Kokoro and Qwen CustomVoice
from loading in the frozen binary:

1. torch._dynamo import triggered via class-body decorators
   (@torch._dynamo.allow_in_graph on PreTrainedModel,
   @torch.compiler.disable in flex_attention) pulls in torch._numpy._ufuncs
   which crashes on module load with NameError: name 'name' is not defined.

2. AlbertModel (Kokoro) triggers @auto_docstring -> modeling_auto ->
   GenerationMixin -> candidate_generator -> sklearn -> scipy, which hits
   the same class of bug in scipy.stats._distn_infrastructure (NameError:
   name 'obj' is not defined).

3. AutoModel (Qwen) pulls the same sklearn -> scipy chain directly.

4. librosa (required by most TTS engines) -> scipy.signal -> scipy.stats
   hits the _distn_infrastructure crash regardless of the transformers
   stubs above.

The root cause of (1) and (4) is that PyInstaller's frozen importer runs
module-level `for X in [<list-comp using dir()>]:` loops with an empty
iterable, leaving the loop variable unbound. Trailing `del obj` / unrelated
references then crash.

Fix: a single runtime hook (pyi_rth_torch_compiler_disable.py) installs:

- sys.modules stubs for torch._dynamo and torch._dynamo.config, plus a
  meta-path finder for torch._dynamo.* submodules — voicebox never uses
  torch.compile/dynamo for inference, so a permissive no-op stub (callable
  as decorator, falsey as predicate, context-manager-safe for
  TransformGetItemToIndex) is drop-in safe.
- meta-path finder stubs for transformers.utils.auto_docstring and
  transformers.generation.candidate_generator — both import-chain
  short-circuits; docstrings and speculative decoding aren't used for TTS.
- meta-path finder for scipy.stats._distn_infrastructure that reads the
  real .py source via the wrapped loader's get_source(), replaces the
  bundling-broken `del obj` with `globals().pop('obj', None)`, and
  compile+exec's the patched source. This keeps the real scipy module
  intact so librosa and everything downstream works normally.

Supporting changes:

- backend/pyi_hooks/hook-scipy.stats._distn_infrastructure.py sets
  module_collection_mode = "pyz+py" so the .py source is actually in the
  bundle for the runtime patcher to read.
- build_binary.py and voicebox-server.spec register the runtime hook and
  the new hooks dir.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(build): force transformers torch<2.6 mask path and bundle spacy_pkuseg

- patch transformers.masking_utils to set _is_torch_greater_or_equal_than_2_6
  = False, forcing sdpa_mask_older_torch and avoiding the vmap .item() crash
  that breaks Qwen CustomVoice generation (our torch._dynamo stub can't
  reproduce TransformGetItemToIndex's graph transform).
- add PyInstaller hook to bundle transformers.masking_utils .py source so the
  runtime finder can source-patch it.
- --collect-all spacy_pkuseg so Chatterbox Multilingual can load its Chinese
  segmenter (dicts/default.pkl + native .so extensions).
- add per-finder install diagnostics + _HOOK_VERSION marker to make future
  bundle-only regressions easier to triage.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(build): pass PyInstaller hook paths relative so .spec is portable

Absolute paths ended up in the auto-regenerated voicebox-server.spec
because build_binary.py prefixed every --runtime-hook and
--additional-hooks-dir with str(backend_dir / ...). That broke builds
on any machine whose checkout wasn't at /Users/jamie/... and anyone
invoking pyinstaller voicebox-server.spec directly.

os.chdir(backend_dir) already runs before PyInstaller (same reason
server.py works as a bare filename), so the backend_dir prefix is
unnecessary. Drop it so the generated spec references pyi_hooks/,
pyi_rth_numpy_compat.py, pyi_rth_torch_compiler_disable.py as repo-
relative paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-18 03:12:13 -07:00
Andrew BarnesandGitHub 54a3bf322e fix: add generation cancellation flow (#444) 2026-04-18 02:51:39 -07:00
476abe07fc fix(paths): strip legacy "data/" prefix when resolving stored paths (#440)
0.3.0 sometimes stored relative media paths with the data-dir name baked in
(e.g. "data/profiles/<uuid>/sample.wav"). resolve_storage_path joined those
directly with _data_dir, producing "<data_dir>/data/profiles/..." — a
spurious double nest that breaks file reads after upgrading to 0.4.0.

The 0.4.0 startup migration didn't catch it because resolve_storage_path
produced the buggy double-nested path, to_storage_path saw "data" at the
first (legitimate) index, and the normalized value matched the stored value
so the row was skipped.

Strip any leading "data/" component before joining. This unblocks runtime
reads and lets _normalize_storage_paths rewrite the affected rows on next
startup — no manual migration needed.

Fixes "No such file or directory: '<data_dir>/data/profiles/...'" and
associated 404s on GET /audio/<id> after upgrading from 0.3.0 to 0.4.0.

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-17 17:53:11 -07:00
James PineandClaude Opus 4.6 67bf8e906a docs/landing: update for 0.4.0 — new engines, GPU docs, donate button, voice docs restructure
Docs:
- Add gpu-acceleration.mdx (all 9 platform/GPU combos, CUDA backend swap, Blackwell, XPU, troubleshooting)
- Add preset-voices.mdx (Kokoro 50 voices, Qwen CustomVoice 9 voices, instruct mode docs)
- Restructure voice-cloning.mdx to cover all 5 cloning engines with comparison table
- Restructure creating-voice-profiles.mdx around cloned vs preset workflows
- Update voice-profiles.mdx schema with voice_type discriminator, preset/design columns

Landing:
- Add 3 new engine cards (Qwen CustomVoice, HumeAI TADA, Kokoro) to Multi-Engine section
- Add Donate button (Buy Me a Coffee) to navbar and footer
- Add DONATE_URL constant

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-16 19:51:43 -07:00
James Pine 625e1ba549 Bump version: 0.3.1 → 0.4.0 2026-04-16 03:16:06 -07:00
James PineandClaude Opus 4.6 cfe6770639 style: apply biome format to drifted authored source
Catches format drift that accumulated across 13 authored files in
app/src and docs/. Auto-generated artifacts (tauri/src-tauri/gen,
docs/openapi.json, docs/cli.json, app/src/lib/api) were left alone
since the build regenerates them on each run — baking their formatted
state into git just causes churn next build.

No behavioral changes. Trailing commas, line wrapping, and indentation
only.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-16 03:13:45 -07:00
James PineandClaude Opus 4.6 00452b51a8 style(changelog): make entry version headings bigger in settings UI
The in-app changelog viewer rendered each entry's version number at the
same size as body text (text-sm font-medium), so visually there was no
clear anchor for where one release's notes ended and the next began.

Bump the version heading to text-xl font-semibold tracking-tight and
widen the bottom margin so each release reads as a proper section
header.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-16 03:12:09 -07:00
James PineandClaude Opus 4.6 106aec46a8 feat(generate): restore instruct toggle in floating generate box (Qwen CustomVoice)
Before 0.4 every engine was a cloning model, so the instruct UI in the
floating generate box applied the same way everywhere. Commit 3187344
hid the instruct toggle because the mix of new engines landing in 0.4
made it unclear which ones honored the kwarg. With Qwen CustomVoice
now shipping as the only engine actually tuned for instruct-style
control, bring the button back — conditionally, and only for that
engine.

Changes:
  • FloatingGenerateBox: SlidersHorizontal toggle button appears left
    of Generate when the box is expanded AND engine is
    qwen_custom_voice. Clicking it reveals an additive instruct
    textarea below the main text field (not a modal swap like the old
    version). State persists across engine switches so the toggle
    remembers its last position.
  • GenerationForm: narrow the instruct FormField's conditional from
    `qwen || qwen_custom_voice` to just `qwen_custom_voice`.
  • useGenerationForm: narrow supportsInstruct for the same reason.
    Base Qwen3-TTS accepts the kwarg but the model itself doesn't honor
    it — only CustomVoice was trained for instruction-based style
    control.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-16 03:09:55 -07:00
James PineandClaude Opus 4.6 c9e5c5d9a7 fix: clean up scroll effect timers and fix disabled+selected card toggle
- Add cleanup for requestAnimationFrame and setTimeout in scroll effect
  to prevent stale DOM writes on unmount or rapid selection changes
- Fix disabled+selected card click: bounce the selection to re-trigger
  the engine auto-switch instead of deselecting

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-16 02:56:45 -07:00
James PineandClaude Opus 4.6 48cd1f369a feat: gray out unsupported profiles instead of filtering, auto-switch engine on selection
- Show all voice profiles with unsupported ones grayed out (opacity) instead of hidden
- Clicking a grayed-out profile selects it and auto-switches the engine to a compatible one
- Sort supported profiles first, with info tip about compatibility at the bottom
- Scroll to selected profile after engine/sort changes with safe margin
- Fix engine desync on tab navigation by initializing form engine from store

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-16 02:56:45 -07:00
James PineandClaude Opus 4.6 2bfe400457 feat(skills): add triage-prs skill for pre-release PR speedruns
Immortalizes the workflow used to clear the open-PR backlog before
0.4.0: classify every open PR into merge / candidate / supersede /
defer tiers, write a working triage doc, then run the merge loop —
rebasing where needed, merging in batches, applying post-merge
follow-ups, and closing superseded PRs with credit.

Captures the gotchas that matter most:
  • Never review a stale branch via `git diff main..HEAD` — it shows
    every intermediate main commit as a deletion and makes a 3-line
    PR look like a 700-line revert
  • Always rebase before squash-merging; GitHub's squash computes
    diff(PR-head, merge-base), so a stale branch will revert
    in-between work
  • Route-ordering, weak-framework linking, dependency floors, !Send
    audio types, and why PyTorch nightly isn't shippable

Paired with draft-release-notes and release-bump: triage → draft →
bump.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-16 02:22:57 -07:00
0aa19a9994 feat(history): add "Clear failed" button to wipe failed generations (#412)
When the model wasn't loaded, the app was closed mid-run, or a
generation otherwise errored out, the resulting "Failed" rows
accumulate in history and there was no way to remove them in bulk —
individual delete was the only option.

Adds a header row above the history list (only rendered when at
least one failed generation is present) with a "Clear failed" button
that opens a confirmation dialog, then calls a new
DELETE /history/failed endpoint which sweeps all status='failed'
rows (plus their version files / audio files on disk).

Closes jamiepine/voicebox#410

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-04-16 02:12:30 -07:00
73170d0e92 feat(health): warn when GPU arch isn't supported by PyTorch build
Applies the compatibility-checker portion of #367. Adds a
check_cuda_compatibility() helper that compares the current device's
compute capability against torch.cuda._get_arch_list() and returns a
human-readable warning if the PyTorch build doesn't support it.

Wired into three places:
  • HealthResponse gains a gpu_compatibility_warning field so clients
    can surface the issue in the UI
  • Startup logs the warning as WARN level
  • _get_gpu_status() appends "[UNSUPPORTED - see logs]" to the GPU
    label shown in settings

Skipped #367's other half — the switch from stable to nightly cu128
wheels across release.yml, build_binary.py, and justfile. That's
redundant with #401's TORCH_CUDA_ARCH_LIST=...12.0+PTX approach and
would introduce non-deterministic builds from shifting nightly
releases.

Co-Authored-By: nyzxor <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-16 02:11:23 -07:00
James PineandClaude Opus 4.6 0317626677 fix(qwen): unify HF cache dir to avoid split cache on Windows
Applies the cache_dir portion of #218. On Windows local setups, model
assets can split between .hf-cache/hub and .hf-cache/transformers when
Qwen3TTSModel.from_pretrained doesn't explicitly pin the cache root —
speech_tokenizer and preprocessor_config.json then fail to resolve
during load, causing 500s at generation time.

Routes both HF Hub and Transformers through hf_constants.HF_HUB_CACHE.

Skipped the torch_dtype= → dtype= rename from #218: transformers 4.36
(our minimum) doesn't accept the dtype alias, only 4.46+. Once we bump
the minimum we can make that change.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-16 02:08:59 -07:00
a5d5c780c2 fix: avoid ScreenCaptureKit launch crash on macOS 11 (#424)
Co-authored-by: txhno <[email protected]>
2026-04-16 01:58:29 -07:00
James PineandClaude Opus 4.6 7184a25e44 fix(watchdog): clear stale .keep-running sentinel on startup
Follow-up to #402. The sentinel is only removed inside the grace-period
"sentinel found" branch. When the HTTP /watchdog/disable request wins
the race (normal case on macOS/Linux, occasional on Windows), the
_watchdog_disabled=True check returns first and the sentinel is left on
disk indefinitely.

If a later session spawns a fresh server and the user exits without
"keep running", the new watchdog would find that stale sentinel during
its grace period and keep the server alive against user intent.

Wipe any pre-existing sentinel when the watchdog starts so only signals
written during this session's lifetime can influence grace-period
decisions.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-16 01:57:05 -07:00
479bc7fc5e fix: reliably keep server alive after GUI close on Windows (#402)
The HTTP /watchdog/disable request races with process exit on Windows,
causing the watchdog to kill the server before the request arrives.

Added a .keep-running sentinel file as a reliable fallback:
- Tauri writes the file to data_dir before sending the HTTP request
- The watchdog checks for it during the grace period after detecting
  parent death
- The file is removed after being read to avoid stale state

This approach works regardless of HTTP timing because file writes
complete synchronously before the Tauri process exits.

Fixes #372

Co-authored-by: Matt Van Horn <[email protected]>
2026-04-16 01:56:30 -07:00
Cocoon-BreakandGitHub 3e7727d1d2 fix: keep cpal Stream alive until playback completes (Closes #404) (#405)
The cpal Stream was created and play() called but then immediately
dropped when play_to_device() returned. When a cpal Stream is dropped,
audio output stops immediately. This caused silent playback.

Fix: add a spin-wait loop that holds the Stream in scope until all
samples have been consumed (or stop_flag is set).
2026-04-16 01:54:31 -07:00
JunghwanandGitHub be7c0cec12 fix: add asyncio.Lock to prevent concurrent CUDA downloads (#428)
* fix: add asyncio.Lock to prevent concurrent CUDA downloads

The startup auto-update task and the manual download endpoint can both
invoke download_cuda_binary() concurrently. Without mutual exclusion,
both coroutines write to the same temp file path, corrupting the
download. The progress-manager status check is a TOCTOU race because
the status is not set until after several synchronous checks complete.

Add a module-level asyncio.Lock acquired at the top of
download_cuda_binary() so only one download can proceed at a time.

* fix: fast-reject duplicate CUDA download when lock is held

Address CodeRabbit review feedback: check _download_lock.locked()
before awaiting the lock so concurrent callers return immediately
instead of queueing behind the first download. This prevents the
route handler from returning "started" to multiple callers when only
one download actually proceeds.
2026-04-16 01:51:21 -07:00
c9d8142a78 feat: add Blackwell GPU (sm_120) CUDA support (#401)
Set TORCH_CUDA_ARCH_LIST in the CUDA build step to include 12.0+PTX
for forward compatibility with Blackwell GPUs (RTX 5070 Ti, 5080, etc).

Pre-built PyTorch cu128 wheels only ship native kernels for sm_80/86/89/90.
Without this, Blackwell GPU users get "no kernel image is available for
execution on the device" at runtime.

Fixes #386
Related: #395, #396, #399, #400

Co-authored-by: Matt Van Horn <[email protected]>
2026-04-16 01:51:18 -07:00
13ba5f1aa6 fix: prevent intermittent clip splitting failures (#403)
Two changes to address the race condition causing "Failed to split clip":

Backend (stories.py): Added with_for_update() to the item query in
split_story_item so concurrent requests for the same clip are
serialized via a row lock instead of racing.

Frontend (StoryTrackEditor.tsx): Guard handleSplit with
splitItem.isPending to prevent rapid double-clicks from firing
multiple mutations before the first completes.

Fixes #366

Co-authored-by: Matt Van Horn <[email protected]>
2026-04-16 01:51:15 -07:00
9a3c307c75 fix(history): populate status/error/engine fields from DB row (#394)
* fix(history): populate status/error/engine/model_size/is_favorited from DB

GET /history/{generation_id} was constructing HistoryResponse without
passing status, error, engine, model_size, or is_favorited from the
DB row. Since HistoryResponse.status defaults to "completed" in the
Pydantic model (models.py:141), this endpoint returned
status="completed" for every generation regardless of the actual DB
state — including jobs still in "loading_model" or "generating", and
even "failed" jobs.

This breaks any client polling /history/{id} for job completion:
the API lies about the status, so the only trustworthy success
signal becomes `audio_path` being non-empty. All other fields left
at their model defaults were similarly masked.

Fix: pass all fields through from the DB row, matching the pattern
used elsewhere in the codebase. The DB model (Generation in
database/models.py) already has all these columns.

* fix(history): apply NULL fallbacks to match list endpoint

Align the defensive mappings with services/history.py:206-223 so
both the single-item and list history endpoints handle legacy rows
with NULL status/engine/is_favorited identically. Without this,
HistoryResponse's non-Optional str/bool fields would raise a
pydantic ValidationError (500) on any row where these columns are
NULL — possible from direct SQL updates or past migrations.

Addresses review feedback on PR #394.

---------

Co-authored-by: malletfils <[email protected]>
2026-04-16 01:51:12 -07:00
JunghwanandGitHub 1da16cfc57 fix: harden voice prompt cache loading and SPA path guard (#429)
Two small safety improvements:

1. Voice prompt cache (cache.py): add weights_only=True to torch.load()
   so cached .prompt files are loaded using the safe unpickler instead of
   the unrestricted pickle deserializer. This follows the PyTorch 2.6+
   best practice of opting in to safe loading for all torch.load() calls.

2. SPA catch-all (app.py): replace str.startswith() path guard with
   Path.is_relative_to(). The string prefix check passes for sibling
   paths like /app/frontend_evil/ that share the /app/frontend prefix.
   is_relative_to() correctly tests directory containment.
2026-04-16 01:49:19 -07:00
Luis SambranoandGitHub a1807be04d fix(deps): relax torch requirement for macOS x86_64 compatibility (#416) 2026-04-16 01:49:16 -07:00
Khaled SolimanandGitHub fdba18e9ee fix: resolve ModuleNotFoundError by using relative import for utils (#384) 2026-04-16 01:49:13 -07:00
MaxandGitHub 07a845cece Add NUMBA_CACHE_DIR environment variable (#425) 2026-04-16 01:49:10 -07:00
James PineandClaude Opus 4.6 615d604ceb fix(numpy-compat): raise on unknown dtype + add fp16/complex
Follow-up to #361. The original fallback silently mapped unknown numpy
dtypes to torch.float32, which would reinterpret the memcpy'd bytes in
the wrong dtype and corrupt data (e.g. fp16 tensors from some TTS
engines) rather than erroring loudly.

- Hoist dtype_map out of the inner function so it's built once
- Add float16, complex64, complex128 mappings
- Raise TypeError on unknown dtype instead of silent float32 fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-16 01:47:22 -07:00
a383ff6863 fix: torch.from_numpy crash with numpy 2.x in frozen binary (#361)
torch is compiled against numpy 1.x. numpy 2.x changed the ABI version
returned by PyArray_GetNDArrayCVersion() (0x01000009 → 0x02000000), so
torch's is_numpy_available() always returns False and torch.from_numpy()
raises RuntimeError. This causes TTS generation to fail with:

  ValueError: Unable to create tensor, you should probably activate
  padding with 'padding=True'

Two fixes:

1. Pin numpy<2.0 in requirements.txt so new builds bundle a compatible
   numpy version. (The existing comment already flagged this intention
   but the upper bound was never added.)

2. Add a PyInstaller runtime hook (pyi_rth_numpy_compat.py) that installs
   a ctypes memmove fallback for torch.from_numpy() at startup. Runtime
   hooks run after FrozenImporter is registered so frozen torch is
   importable. The fallback catches RuntimeError from the C-level ABI
   check and copies the numpy array into a new tensor via raw memory copy,
   bypassing the check entirely. This is a belt-and-suspenders fix that
   works regardless of the bundled numpy version.

Co-authored-by: aimaaaimaa <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-04-16 01:46:40 -07:00
Jamie PineandGitHub 75abbb02c3 Merge pull request #344 from pandego/fix/308-docker-compose-startup
fix: include changelog in docker web build
2026-03-26 23:06:47 -07:00
Jamie PineandGitHub b49f14a814 Merge pull request #319 from jamiepine/fix/startup-and-server-switch
fix: GUI startup with external server + data refresh on server switch
2026-03-26 23:06:29 -07:00
Jamie PineandGitHub 05686efbfd Merge pull request #345 from ArfianID/fix/backend-import-error
Fix: "Failed to Save" preset error by resolving backend import path resolution
2026-03-22 09:45:25 -07:00
Arfian e2c03fef9a Fix: move lazy imports to top-level and use absolute paths to resolve ModuleNotFoundError in production 2026-03-22 22:29:25 +07:00
pandego 4347eaed4c fix: include changelog in docker web build 2026-03-22 09:32:32 +01:00
James Pine 60aac279ce fix: address PR #319 review feedback — health validation, error handling, queryClient decoupling
- Rust: Replace fragile body.contains("status") with proper JSON
  deserialization validating status=="healthy", model_loaded (bool),
  and gpu_available (bool) to prevent misidentifying non-Voicebox services

- Frontend: Validate health response has Voicebox-specific fields before
  marking server as ready during fallback polling

- Frontend: Discriminate port-in-use errors (poll for external server) from
  real startup failures (missing sidecar, signing issues) — surface errors
  immediately with a startupError state and Retry button in the UI

- Frontend: Set explicit startup-error state when 2-minute polling timeout
  expires so the loading screen shows actionable feedback instead of hanging

- Architecture: Extract QueryClient to standalone side-effect-free module
  (lib/queryClient.ts) to decouple serverStore from React bootstrap entrypoint
2026-03-21 10:24:15 -07:00
James Pine 8b1c7552be Merge remote-tracking branch 'origin/fix/startup-and-server-switch' into pr-319 2026-03-21 10:18:45 -07:00
Jamie PineandGitHub 9a955a77d2 Merge pull request #320 from jamiepine/feat/intel-xpu-support
feat: Intel Arc (XPU) GPU support
2026-03-21 08:39:51 -07:00
Jamie PineandGitHub c18591c0c3 Merge pull request #318 from jamiepine/fix/offline-model-loading
fix: force offline mode when loading cached models (Qwen TTS & Whisper)
2026-03-21 08:38:08 -07:00
Jamie PineandGitHub ea3469f2dc Merge pull request #332 from nicoschtein/patch-1
Fix links in Get Started section of index.mdx
2026-03-21 08:37:00 -07:00
James Pine b108bb1cb1 fix: store media paths relative to data dir 2026-03-20 15:06:07 -07:00
Nicolas SchteinschraberandGitHub 8b796bc6b4 Fix links in Get Started section of index.mdx
Updated links in the Get Started section for correct paths.
2026-03-20 13:58:53 -03:00
James Pine e6f419cd70 fix: show all engines in floating generator 2026-03-19 19:52:08 -07:00
James Pine 72c13fd3fc fix: enforce preset profile engine compatibility 2026-03-19 19:51:53 -07:00
James Pine 4e0c731db8 feat: add Qwen CustomVoice preset engine 2026-03-19 19:48:50 -07:00
James Pine d70b878b71 fix: tighten kokoro profile handling 2026-03-19 19:32:49 -07:00
Jamie PineandGitHub a71011741d Merge pull request #325 from jamiepine/feat/kokoro-engine
feat: Kokoro 82M TTS engine + voice profile type system
2026-03-19 19:21:15 -07:00
James Pine d6f48ace3e Mirror the regular /generate endpoint behavior more closely 2026-03-19 16:11:38 -07:00
James Pine 0fc2192204 fix: resolve relative paths using configured data dir, not CWD 2026-03-19 10:37:11 -07:00
James Pine 9e726ad048 fix: remove engine dropdown filtering — profile grid handles it 2026-03-19 10:14:33 -07:00
James Pine 3584283d84 feat: Kokoro 82M TTS engine + voice profile type system
Add Kokoro-82M as a new TTS engine — 82M params, CPU realtime, 8 languages,
Apache 2.0. Unlike cloning engines, Kokoro uses pre-built voice styles, which
required a new profile type system to support non-cloning engines cleanly.

Kokoro engine:
- New kokoro_backend.py implementing TTSBackend protocol
- 50 built-in voices across en/es/fr/hi/it/pt/ja/zh
- KPipeline API with language-aware G2P routing via misaki
- PyInstaller bundling for misaki, language_tags, espeakng_loader, en_core_web_sm

Voice profile type system:
- New voice_type column: 'cloned' | 'preset' | 'designed' (future)
- Preset profiles store engine + voice ID instead of audio samples
- default_engine field on profiles — auto-selects engine on profile pick
- Create Voice dialog: toggle between 'Clone from audio' and 'Built-in voice'
- Edit dialog shows preset voice info instead of sample list for preset profiles
- Engine selector locks to preset engine when preset profile is selected
- Profile grid filters by engine — shows Kokoro voices when Kokoro selected
- Custom empty state when no preset profiles exist for selected engine

Bug fixes:
- Fix relative audio paths in DB causing 404s in production builds
- config.set_data_dir() now resolves to absolute paths
- Startup migration converts existing relative paths to absolute

Also updates PROJECT_STATUS.md and tts-engines.mdx developer guide.
2026-03-19 10:09:48 -07:00
Jamie PineandGitHub e4def9365f Merge pull request #321 from liorshahverdi/fix/delete-failed-generations-292
fix/allows deletion of failed generations 292
2026-03-19 09:36:55 -07:00
James Pine 707046237c fix: complete Intel XPU support — device-aware seeding, GPU status reporting, and setup detection
Address CodeRabbit review feedback and user-reported GPU acceleration failure:

- Use shared manual_seed() in chatterbox, chatterbox_turbo, and luxtts
  backends so XPU (and future accelerators) get proper device seeding
- Add XPU branch to _get_gpu_status() so startup log reports Intel Arc
  GPUs instead of 'None (CPU only)'
- Add XPU VRAM reporting and correct backend_variant fallback in the
  /health endpoint
- Switch justfile GPU detection from Get-WmiObject to Get-CimInstance,
  simplify the Arc regex to match 'Arc' (not 'Intel.*Arc'), log
  detected GPUs, and print manual install instructions on miss

Resolves the root cause where IPEX was silently not installed due to
WMI detection failure, causing CPU-only fallback on Intel Arc systems.
2026-03-18 17:01:12 -07:00
Lior Shahverdi 12ed2d51ce Adds a trash icon button alongside the existing retry button for
failed generations, giving users a way to clean up failed entries
  without having to retry them first.
2026-03-18 15:44:37 -04:00
James Pine 83ebababe7 feat: add Intel Arc (XPU) GPU support across all backends
Auto-detect Intel Arc GPUs during Windows setup and install PyTorch
with XPU support + intel-extension-for-pytorch. Enable allow_xpu=True
on all TTS backends (Chatterbox, Chatterbox Turbo, Hume TADA, LuxTTS)
that previously only supported CUDA. Add shared empty_device_cache()
and manual_seed() helpers in base.py to handle XPU memory management
and reproducible seeding alongside CUDA.
2026-03-18 11:24:51 -07:00
James Pine eb5869e59f fix: GUI startup with external server + data refresh on server switch
Two fixes for issue #312:

1. GUI stuck on loading screen when backend is already running externally
   (e.g. via python/uvicorn/Docker):

   - Rust: add HTTP health check fallback when the process on the port
     doesn't have 'voicebox' in its name. If /health responds with a
     valid Voicebox response, reuse the server instead of erroring.
   - Frontend: when startServer() fails, fall back to polling the
     health endpoint every 2s instead of permanently blocking.

2. No data refresh when switching server URLs in settings:

   - serverStore.setServerUrl() now invalidates all React Query caches
     when the URL actually changes, so profiles/history/models/stories
     are re-fetched from the new server.
   - Export queryClient from main.tsx for store-level cache invalidation.

Fixes #312
2026-03-18 10:59:59 -07:00
James Pine 2e95b7c5d8 fix: force offline mode when loading cached models (Qwen TTS & Whisper)
Qwen TTS and Whisper Base make network calls to HuggingFace even when
model weights are fully cached locally, because from_pretrained()
defaults to local_files_only=False. This causes failures for offline
users.

Add a reusable force_offline_if_cached() context manager that sets
HF_HUB_OFFLINE=1 during model loading when is_model_cached() is True.
Applied to all four affected load paths:

- PyTorchTTSBackend (Qwen TTS)
- PyTorchSTTBackend (Whisper)
- MLXTTSBackend (refactored from inline implementation)
- MLXSTTBackend (previously unprotected)

Closes #82
2026-03-18 10:31:30 -07:00
Jamie PineandGitHub ffc1b54812 Merge pull request #316 from jamiepine/fix/cuda-cu128-upgrade
Upgrade CUDA backend from cu126 to cu128, fix GPU settings UI
2026-03-18 07:58:12 -07:00
James Pine fc5ed1ff40 upgrade CUDA backend from cu126 to cu128 and fix GPU settings UI
Upgrade CUDA toolkit from 12.6 (cu126) to 12.8 (cu128) for proper
RTX 50-series (Blackwell) GPU support. Users with RTX 5070/5080/5090
were reporting CUDA detection failures with cu126.

Also fix the GPU Acceleration settings panel where the 'Switch to CPU
Backend' button was unreachable — it was inside a conditional block
that required !isCurrentlyCuda, making it impossible to switch back
to CPU once running on CUDA.

Closes #315
2026-03-18 07:47:39 -07:00
Jamie PineandGitHub c9f38dd496 Merge pull request #305 from jamiepine/fix/qwen-tts-pyinstaller-source-files
fix: bundle qwen_tts source files in PyInstaller build
2026-03-17 09:24:42 -07:00
James Pine 58b19e4e9f fix: bundle qwen_tts source files in PyInstaller build
Replace --collect-submodules + --collect-data with --collect-all for
qwen_tts. The qwen_tts runtime expects physical .py source files
(e.g. modeling_qwen3_tts.py) under _MEIPASS, which only --collect-all
provides. This is the same pattern used for inflect/typeguard.

Fixes #212
2026-03-17 09:23:30 -07:00
Jamie PineandGitHub 0245c31dba Merge pull request #298 from jamiepine/feat/cuda-libs-addon
feat: split CUDA backend into independently versioned server + libs archives
2026-03-17 09:17:31 -07:00
Jamie Pine 81864e831a fix: always clean up temp archive on failure and fix justfile data dir path
- Wrap download/verify/extract in try/finally so .download-*.tmp is
  always deleted, even on mid-download or extraction failures
- Fix justfile build-server-cuda to use sh.voicebox.app (production path)
2026-03-17 09:15:23 -07:00
Jamie Pine 7bd72ea9f7 Bump version: 0.3.0 → 0.3.1 2026-03-17 07:50:12 -07:00
Jamie Pine f96eae2567 fix: address PR review feedback from CodeRabbit
- Upgrade softprops/action-gh-release@v1 to @v2 (Node 16 EOL)
- Fail-fast on checksum fetch failure instead of extracting unverified archives
- Abort packaging if no NVIDIA files found (prevents empty cuda-libs archive)
- Fix nvidia/ path detection bug (list membership vs substring check)
- Fix justfile Copy-Item nesting (copy contents, not the directory itself)
2026-03-17 06:53:54 -07:00
Jamie Pine 7d53699c96 fix: update build-server-cuda to copy onedir folder instead of single exe 2026-03-17 06:12:30 -07:00
Jamie Pine 28e91ce2c1 chore: add .spec and nul to .gitignore 2026-03-17 04:58:13 -07:00
Jamie Pine 88be097b62 fix: update package_cuda.py for PyInstaller 6.18 layout and remove split_binary.py
- Fix is_nvidia_file() to match NVIDIA DLLs in _internal/torch/lib/
  (PyInstaller 6.18 + torch 2.10 no longer uses nvidia/ subdirectories)
- Remove deprecated split_binary.py (both archives are under 2GB)
- Update torch_compat range to >=2.6.0,<2.11.0
- Update build docs for new dual-archive packaging flow
2026-03-17 04:57:05 -07:00
James Pine 564d787927 feat: split CUDA backend into independently versioned server + libs archives
Switch CUDA builds from PyInstaller --onefile to --onedir and split the
output into two separately versioned archives:

1. Server core (~200-400MB) — versioned with the app, redownloaded on
   every app update
2. CUDA libs (~2GB) — versioned independently (cu126-v1), only
   redownloaded when the CUDA toolkit or torch version changes

This eliminates the ~2.4GB full redownload on every version bump.
After initial setup, most app updates only need ~200-400MB.

Closes #297
2026-03-17 04:04:17 -07:00
James Pine 2c1ee94891 docs: add TADA learnings to TTS engine guide and CUDA libs addon plan
Enrich tts-engines.mdx with patterns discovered during TADA integration:
- Phase 0.2: new greps for @torch.jit.script, torchaudio.load, gated repos
- Phase 3.4: model naming inconsistency warning
- Phase 5.2: TADA shim failure added to lessons table
- Phase 6: four new workaround sections (gated repos, torchcodec,
  torch.jit.script, toxic dependency shim pattern)
- Checklist: four new items matching the new scan patterns
- Remove TADA from upcoming engines (now shipped)

Add CUDA_LIBS_ADDON.md exploring --onedir split to avoid 2.4GB
redownloads on every version bump.
2026-03-17 03:53:40 -07:00
Jamie PineandGitHub e789c937ad Merge pull request #296 from jamiepine/feat/add-tada-tts-engine
Add HumeAI TADA TTS engine (1B English + 3B Multilingual)
2026-03-17 03:47:47 -07:00
James Pine 273483ffcf fix TorchScript error in frozen builds and update docs for TADA
Remove @torch.jit.script from the DAC shim's snake() function —
TorchScript calls inspect.getsource() which fails in PyInstaller
binaries (no .py source files).

Update all user-facing docs: 4 → 5 TTS engines, add TADA row to
every engine comparison table, mark TADA as Shipped in the upcoming
engines list, update architecture diagrams and tech stack tables.
2026-03-17 03:28:58 -07:00
James Pine 5774a168a9 fix TADA 3B model name: tada-3b -> tada-3b-ml 2026-03-17 03:17:53 -07:00
James Pine 6bf40bd2d0 fix tokenizer patch corrupting AutoTokenizer for other engines
Replace the monkey-patch on AutoTokenizer.from_pretrained (which broke
the classmethod descriptor and caused 'Tokenizer not loaded' errors
when loading Qwen after TADA) with two targeted config patches:
- Set AlignerConfig.tokenizer_name to the local ungated tokenizer path
- Pre-load TadaConfig, inject tokenizer_name, pass config= to from_pretrained

No global state is modified; other engines are unaffected.
2026-03-17 03:15:57 -07:00
James Pine 12cda2e090 fix torchcodec error by using soundfile instead of torchaudio.load
torchaudio 2.10+ switched its default audio loading backend to
torchcodec, which isn't installed. Replace torchaudio.load() with
soundfile.read() in create_voice_prompt(). TADA's internal use of
torchaudio.functional.resample() is unaffected (pure PyTorch math,
no torchcodec dependency).
2026-03-17 02:25:05 -07:00
James Pine 7a90290a76 fix gated Llama tokenizer error by redirecting to ungated mirror
TADA hardcodes 'meta-llama/Llama-3.2-1B' as its tokenizer source in
both the Aligner and TadaForCausalLM.from_pretrained(). That repo is
gated and requires accepting Meta's license on HuggingFace.

Monkey-patch AutoTokenizer.from_pretrained during model loading to
redirect Llama tokenizer requests to 'unsloth/Llama-3.2-1B', an
ungated mirror with identical tokenizer files. The patch is scoped
to model loading only and restored immediately after.
2026-03-17 02:22:26 -07:00
James Pine b02ce8e2f3 replace descript-audio-codec with lightweight DAC shim
The real descript-audio-codec package pulls in descript-audiotools,
which transitively requires onnx, tensorboard, protobuf, matplotlib,
pystoi, and other heavy dependencies. onnx fails to build from source
on macOS due to CMake version incompatibility.

TADA only uses Snake1d (a 7-line PyTorch module) from DAC. This commit
adds a shim in backend/utils/dac_shim.py that registers fake dac.*
modules in sys.modules with just the Snake1d class, completely
eliminating the DAC/audiotools dependency chain.
2026-03-17 02:16:33 -07:00
James Pine 4e7772a21d add HumeAI TADA TTS engine (1B English + 3B Multilingual)
Integrates HumeAI's TADA (Text-Acoustic Dual Alignment) speech-language
model as a new TTS engine. TADA uses a novel 1:1 token-audio alignment
that produces coherent speech over long sequences (700s+).

Two model variants:
- tada-1b: English-only, ~4GB, built on Llama 3.2 1B
- tada-3b-ml: 10 languages, ~8GB, built on Llama 3.2 3B

Backend uses the Encoder for voice prompt encoding with caching, and
TadaForCausalLM with flow-matching diffusion for generation. Supports
bf16 inference on CUDA, forces CPU on macOS (MPS compatibility).

Installed with --no-deps due to torch>=2.7 pin conflict; descript-audio-codec
and torchaudio added as explicit sub-dependencies.
2026-03-17 01:55:15 -07:00
James Pine 51fb320b8c readme 2026-03-17 01:24:48 -07:00
James Pine 8ac202aa58 docs for adding new engines 2026-03-17 01:13:30 -07:00
Jamie Pine ac68052945 create stub resource files when actool output is missing
Older Xcode versions don't produce Assets.car from .icon assets.
Fall back to empty stubs for all platforms so the bundler succeeds.
2026-03-17 01:07:54 -07:00
Jamie Pine e601fd2ca4 generate icon assets at build time instead of tracking them
build.rs now generates voicebox.icns via sips + iconutil alongside the
existing actool Assets.car compilation. On non-macOS, empty stub files
are created so Tauri's resource bundler doesn't fail on missing paths.
2026-03-17 00:51:12 -07:00
Jamie Pine 7b25e0ba0b Bump version: 0.2.3 → 0.3.0 2026-03-17 00:25:56 -07:00
Jamie PineandGitHub a6817cd082 Merge pull request #295 from jamiepine/fix/misc-bugs
fix: batch of bug fixes from issue tracker
2026-03-17 00:08:17 -07:00
Jamie Pine df50b8a925 add --force-reinstall --no-deps to torchaudio CUDA install 2026-03-17 00:07:45 -07:00
Jamie Pine a672ac5279 remove voicebox.icns from tracking and add to gitignore 2026-03-17 00:07:19 -07:00
Jamie Pine d35e6f0cc5 fix sample upload blocking the event loop and causing server timeouts
Move audio validation and saving to thread pool so librosa/ffmpeg decoding
doesn't block the async event loop. Combine validate + load into a single
pass to avoid decoding the file twice. Add 50 MB upload limit and chunked
reads to prevent unbounded memory allocation.

Closes #278
2026-03-16 23:29:18 -07:00
Jamie Pine b1069b4521 upgrade CUDA backend build from cu121 to cu126
cu121 only ships kernels up to SM 9.0 (Ada Lovelace). RTX 50-series
(Blackwell, SM 12.0) and RTX 6000 Pro need cu126 which includes SM 12.0
support while remaining backward compatible with older GPUs.

Closes #289
2026-03-16 23:23:55 -07:00
Jamie Pine f9e1aa153d handle client disconnects in SSE and streaming endpoints
Wrap SSE generators with BrokenPipeError/ConnectionResetError handling
so client disconnects during generation status polling, download progress,
or audio streaming don't produce unhandled Errno 32 errors.

Closes #248
2026-03-16 23:17:09 -07:00
Jamie Pine 01800f196f upgrade pip before installing deps in Docker build
Fixes hash mismatch when pip resolves Qwen3-TTS transitive deps.

Closes #286
2026-03-16 23:13:50 -07:00
Jamie Pine 606da1c894 fix generation list not updating on completion
Use refetchQueries instead of invalidateQueries for more reliable history
refresh. Add history refetch to SSE onerror handler so dropped connections
don't leave the list stale. Reset page to 0 in HistoryTable when a pending
generation completes.

Closes #231
2026-03-16 22:54:11 -07:00
Jamie Pine 664178f0cf fix error detail serialization producing [object Object] in error messages
Closes #290
2026-03-16 22:47:01 -07:00
Jamie Pine f1541701fb add model selection and expanded language support to /transcribe endpoint
Closes #233
2026-03-16 22:44:28 -07:00
Jamie PineandGitHub a2adc3b506 Merge pull request #293 from jamiepine/fix/audio-player-freeze
Fix audio player freezing and improve UX
2026-03-16 22:28:39 -07:00
Jamie PineandGitHub 15ba824472 Merge pull request #294 from jamiepine/feat/settings-overhaul
Settings overhaul: routed sub-tabs, server logs, changelog, about page
2026-03-16 13:07:39 -07:00
Jamie Pine 2ad4776a76 fix review feedback: restart race, listener cleanup, stable keys, accessibility 2026-03-16 13:06:57 -07:00
Jamie Pine a8469b39f1 fix about license 2026-03-16 12:31:33 -07:00
Jamie Pine 7dd70a52e4 fix audio player freezing and improve UX
Switch WaveSurfer from MediaElement to WebAudio backend to prevent
WKWebView deadlocks that were freezing the entire Tauri app during
audio playback.

Reuse a single WaveSurfer instance across track changes instead of
destroying and recreating on every URL change, which was exhausting
the browser's AudioContext pool.

Other improvements:
- spacebar play/pause with capture phase to prevent history item activation
- drag-to-seek on waveform with silent scrub to avoid WebAudio popping
- slider always mounted to prevent layout shift during track transitions
- play button fills icon, accent bg when playing, loop button accent bg when active
- fix AudioBars animation getting stuck by keying on mode
- remove focus ring on history items
- sync slider position on pause and during seek
- remove title text from player bar
- thicker cursor (3px)
2026-03-16 12:29:10 -07:00
James Pine 1526f2de26 about page + generation folder open and display 2026-03-16 10:14:47 -07:00
James Pine 2c63dfff25 overhaul settings: split into routed sub-tabs, add server logs, changelog, reusable setting components
- Rename Server tab to Settings with horizontal sub-tab navigation (General, Generation, GPU, Logs, Changelog)
- All sub-tabs are proper routes under /settings/* with /server redirect for backwards compat
- General: connection settings, link cards (docs + discord), API reference card, app updates
- Generation: auto-chunking, crossfade, normalize, autoplay as SettingRow components
- GPU: info card with platform-aware icons (Apple logo for MPS), CUDA management, explainer text
- Logs: real-time server log viewer piped from Tauri sidecar via event system (Tauri-only)
- Changelog: parsed from CHANGELOG.md at build time via Vite virtual module plugin
- New reusable SettingRow/SettingSection components for consistent settings layout
- New Toggle (switch) UI component replacing checkboxes in settings
- Toast viewport now offsets when audio player is open
- Sidebar stays active on settings sub-routes (fuzzy matching)
2026-03-16 09:31:14 -07:00
James Pine e0a798dc0d add release skills, backfill changelog, wire CI to use changelog for GitHub releases
- Backfill CHANGELOG.md from all 17 GitHub releases (was stale at v0.1.0)
- Add draft-release-notes and release-bump agent skills
- Extract release notes from CHANGELOG.md in release CI instead of hardcoded placeholder
- Remove stale PATCH_NOTES.md, mlx-test/, move PROJECT_STATUS to docs/notes
- Reorganize API reference docs from unknown/ to named groups
- Update openapi.json
2026-03-16 06:18:46 -07:00
James Pine 5933cba8e9 add release skills and backfill changelog from GitHub releases
- Backfill CHANGELOG.md from all 17 GitHub releases (was stale at v0.1.0)
- Add draft-release-notes and release-bump agent skills
- Remove stale PATCH_NOTES.md, mlx-test/, move PROJECT_STATUS to docs/notes
- Minor voicebox-server.spec cleanup
2026-03-16 06:15:04 -07:00
James Pine 1b2d492398 add /og preview page and OG image metadata 2026-03-16 05:51:04 -07:00
James Pine faa825290f docs links 2026-03-16 05:33:33 -07:00
James Pine 4a8a9eac14 fix: docker frontend + docs cleanup 2026-03-16 05:28:05 -07:00
Jamie PineandGitHub 3e4d9ff641 Merge pull request #288 from jamiepine/better-docs
Better docs
2026-03-16 05:12:09 -07:00
James Pine 192979a762 docs 2026-03-16 05:11:21 -07:00
James Pine e16cc42d53 enable Edit on GitHub and last updated on all doc pages 2026-03-16 04:45:40 -07:00
James Pine f10e965003 rewrite docs root page, add screenshot 2026-03-16 04:12:11 -07:00
James Pine a8968d4081 rewrite docs introduction based on README content 2026-03-16 04:09:50 -07:00
James Pine 7c4afbe4df expand sidebar groups by default, remove stale plans reference 2026-03-16 04:08:46 -07:00
James Pine a180fcc56f redirect root to /docs 2026-03-16 04:06:38 -07:00
James Pine 1860b8dc92 remove plans/ from docs site 2026-03-16 04:05:14 -07:00
James Pine 1597937535 Merge branch 'main' into better-docs
# Conflicts:
#	backend/main.py
#	docs/content/docs/plans/ADDING_TTS_ENGINES.md
#	docs/content/docs/plans/CUDA_BACKEND_SWAP.md
#	docs/content/docs/plans/CUDA_BACKEND_SWAP_FINAL.md
#	docs/content/docs/plans/EXTERNAL_PROVIDERS.md
#	docs/content/docs/plans/MLX_AUDIO.md
#	docs/content/docs/plans/PROJECT_STATUS.md
2026-03-16 04:01:08 -07:00
Jamie PineandGitHub ac41a89359 Merge pull request #285 from jamiepine/backend-refactor
Backend refactor: modular architecture, style guide, tooling
2026-03-16 03:51:10 -07:00
James Pine 60c0fe3b92 isolate shutdown unload calls so one failure doesn't block the other 2026-03-16 03:50:28 -07:00
James Pine c99828cf76 fix startup db session leak on error (rollback + close in finally) 2026-03-16 03:49:21 -07:00
James Pine 5c4b979480 suppress E402 for app.py (AMD env vars must precede torch import) 2026-03-16 03:47:56 -07:00
James Pine 2d1b0ae820 remove unused _get_cuda_dll_excludes function 2026-03-16 03:46:58 -07:00
James Pine 69486c2a77 handle null duration in story_items migration 2026-03-16 03:44:33 -07:00
James Pine e9f63d6c57 reject model migration to subdirectory of source cache 2026-03-16 03:43:37 -07:00
James Pine 8906bee23e fix docstring for find_voicebox_pid_on_port 2026-03-16 03:43:06 -07:00
James Pine 0dabb121c9 improve startup logging: version, platform, data dir, db stats
Replace verbose startup messages with a clean summary:
- App version, Python version, OS/arch
- Database path (fix None display), data directory
- Profile and generation counts
- Backend, GPU, model cache path
- Clean up stale loading_model status on startup
- Remove noisy progress manager log line
2026-03-16 03:42:39 -07:00
James Pine 944ba227ca soften select focus indicator opacity 2026-03-16 03:22:36 -07:00
James Pine 473bb3e9fb fix take-label race in regeneration, add accessible focus to select
- Use DB COUNT query instead of list length for take-N label to avoid
  TOCTOU race between list_versions and create_version
- Add focus:bg-muted to SelectTrigger for keyboard focus visibility
2026-03-16 03:22:05 -07:00
James Pine 0d0b62ea93 address CodeRabbit review: fix 4 critical + 12 major issues
Critical:
- Remove dead backend.utils.validation PyInstaller hidden import
- Fix story_items table rebuild to preserve track/trim/version columns
- Guard cache migration against same source/destination path
- Fix regeneration audio overwrite (use random uuid suffix per take)

Major:
- Engine selector: validate language on Qwen switch, clear stale modelSize
- Sync language validation regex between profile create and generate (22 langs)
- Guard CUDA download against duplicate concurrent requests
- Only set model_size for engines that support multiple sizes
- Fix 404 swallowed by generic except in history export
- Validate audio_path before FileResponse in export-audio
- Transcription: stream uploads in 1MB chunks, use robust cache check,
  call complete_download() on Whisper download success
- Set clean version as default when effects chain validation fails
- Return explicit error when Windows port occupied by non-voicebox process
2026-03-16 03:12:01 -07:00
James Pine 798cd40f05 delete stale planning docs 2026-03-16 02:59:24 -07:00
James Pine 3187344f01 add model loading status, effects preset dropdown, clean up UI
Backend:
- Generation service reports 'loading_model' status only when model
  is not yet in memory, then 'generating' once inference starts
- Migrate hf_offline_patch.py from print() to logging module
- Update ADDING_TTS_ENGINES.md for post-refactor file paths

Frontend:
- HistoryTable shows 'Loading model...' vs 'Generating...' based on step
- FloatingGenerateBox: replace instruct toggle + inline effects editor
  with an effects preset dropdown (third dropdown after language and engine)
- Instruct UI removed for now (form field preserved for future models)
- Remove focus ring from Select component globally
2026-03-16 02:58:41 -07:00
James Pine 8efcc95606 update Cargo.lock 2026-03-16 02:20:19 -07:00
James Pine 87cab9473d gitignore: stop tracking tauri/src-tauri/gen/Assets.car
Compiled Xcode asset catalog gets regenerated every build. No reason
to track it.
2026-03-16 02:20:02 -07:00
James Pine 7b0fbfb567 rewrite backend README, remove completed refactor plan, update style guide
Replace the outdated backend README (473 lines of stale API docs and
pre-refactor file tree) with a concise architecture document covering
module structure, request flow, backend selection, API domain overview,
and development commands.

Delete REFACTOR_PLAN.md -- all phases are complete.

Update STYLE_GUIDE.md to remove refactor plan references and replace
the verbose target layout with the current actual structure.
2026-03-16 02:18:34 -07:00
James Pine 7c1ea0a1e1 fix: replace netstat with TcpStream + PowerShell for port detection (#277)
On Windows, Voicebox shelled out to netstat.exe on startup to check for
existing server processes. On systems with corrupted DLLs, netstat fails
with 0xc0000142, causing an infinite loading loop.

Replace with:
- TcpStream::connect_timeout() for port-in-use checks (pure Rust)
- PowerShell Get-NetTCPConnection for port-to-PID lookup (built-in cmdlet)
- tasklist for process name verification (unchanged)

Closes #277
2026-03-16 02:15:26 -07:00
James Pine b3012ed10c move CRUD and service modules into services/, platform_detect into utils/
Move 9 business-logic modules from the backend root into services/:
channels, effects, history, profiles, stories, versions, export_import,
transcribe, tts. Move platform_detect.py into utils/.

Backend root now contains only infrastructure (app, main, config, server,
models, build_binary) and docs. All 94 routes verified.
2026-03-16 02:15:20 -07:00
James Pine 88536d27f7 extract routes from main.py into domain routers (Phase 4)
Split the 2,578-line main.py (90 routes) into 12 domain-specific router
modules under routes/. main.py is now a 45-line entry point.

New structure:
- app.py: FastAPI instance, CORS, startup/shutdown, safe_content_disposition
- routes/: health, profiles, channels, generations, history, transcription,
  stories, effects, audio, models, tasks, cuda
- services/cuda.py: moved from cuda_download.py

Also includes Phase 5 database/ package (from parallel agent):
- database/__init__.py re-exports all symbols for backward compat
- database/models.py, session.py, migrations.py, seed.py

All 90 routes verified registered and app imports cleanly.
2026-03-16 02:03:15 -07:00
James Pine 89d6e364d4 move pyproject 2026-03-16 01:48:26 -07:00
James Pine b7781951df comment cleanup 2026-03-16 01:46:19 -07:00
James Pine fe19a9ca47 add style guide, ruff config, generation service extraction, remove Makefile
- Add backend/STYLE_GUIDE.md covering formatting, imports, types, docstrings,
  comments, error handling, async, logging, and naming conventions
- Add pyproject.toml with ruff linter/formatter config (ERA, FIX, isort, pyupgrade)
- Extract generation service (Phase 3): unified run_generation() replaces three
  duplicated closures, serial queue moved to services/task_queue.py
- Delete Makefile in favor of justfile; update all references
- Add Python lint/format/test commands to justfile (check-python, fix-python, test)
- Install ruff, pytest, pytest-asyncio as dev tools in setup-python
- Update REFACTOR_PLAN.md with Phase 3 and Phase 7 completion
2026-03-16 01:35:59 -07:00
Jamie Pine 439fedcbf2 update refactor plan with phase 1+2 progress 2026-03-16 01:10:59 -07:00
Jamie Pine 0813a3d9d6 refactor: remove dead code, deduplicate backends
Phase 1 - delete dead code:
- studio.py, migrate_add_instruct.py, utils/validation.py
- duplicate _profile_to_response in main.py, duplicate asyncio import
- pointless _get_profiles_dir/_get_generations_dir wrappers
- duplicate LANGUAGE_CODE_TO_NAME and WHISPER_HF_REPOS constants

Phase 2 - extract backends/base.py with shared utilities:
- is_model_cached() replaces 7 copy-pasted HF cache checks
- get_torch_device() replaces 5 device detection methods
- combine_voice_prompts() replaces 5 identical implementations
- model_load_progress() ctx manager replaces progress boilerplate in all backends
- patch_chatterbox_f32() replaces identical monkey-patches in both chatterbox backends

net -1078 lines across the backend
2026-03-16 01:10:02 -07:00
Jamie Pine 9514c6596c migrations 2026-03-16 00:54:13 -07:00
Jamie Pine 4e84415da7 refactor start 2026-03-16 00:52:23 -07:00
Jamie Pine 82cd4bf2ef Add dynamic download redirect routes and update README links 2026-03-15 23:22:03 -07:00
Jamie Pine 3c30c5bec1 Update README for v0.2.x: multi-engine, effects, 23 languages, fix download links 2026-03-15 17:12:47 -07:00
Jamie Pine c9d7bc4f27 Fix macOS download links to use .dmg instead of .app.tar.gz 2026-03-15 17:03:17 -07:00
James Pine 34e17bd469 Fix LuxTTS + Chatterbox in prod: bundle espeak/perth data, fix multiprocessing
- collect-all piper_phonemize to bundle espeak-ng-data for LuxTTS phonemization
- Set ESPEAK_DATA_PATH in frozen builds so the C library finds bundled data
- collect-all perth to bundle pretrained watermark model for Chatterbox
- Add multiprocessing.freeze_support() to fix resource_tracker subprocess crash
2026-03-15 16:02:09 -07:00
James Pine aada13a5c9 Collect all inflect files for PyInstaller (fixes typeguard inspect.getsource) 2026-03-15 14:32:10 -07:00
James Pine de8558d197 Fix prod build: download progress, robust stderr, full tracebacks
- Force tqdm disable=False in TrackedTqdm so byte progress works in prod
  (huggingface_hub disables tqdm based on logger level, which prevents
  self.n from updating — our progress tracking needs the counter even
  though we don't render to terminal)
- Harden devnull redirect to test writability, not just None check
- Add full traceback logging to all backend error handlers
- Add chatterbox/luxtts/zipvoice hidden imports and metadata to spec
2026-03-15 14:23:11 -07:00
Jamie Pine 9d79ea367a Only use --noconsole on Windows, macOS/Linux need stdout for Tauri logs 2026-03-15 12:07:35 -07:00
Jamie Pine 04316f7adc Copy metadata for requests/transformers/huggingface-hub to fix PyInstaller metadata lookup 2026-03-15 11:35:05 -07:00
Jamie Pine 4e4361d350 Fix noconsole crash: redirect None stdout/stderr to devnull on Windows 2026-03-15 11:27:35 -07:00
Jamie Pine e9a249587c Collect all linacodec files for PyInstaller (fixes inspect.getsource in Vocos) 2026-03-15 11:06:13 -07:00
Jamie Pine d8a9ed7d15 Enable updater artifacts with v1Compatible for tauri-action sig generation 2026-03-15 10:54:12 -07:00
Jamie Pine 3dbf1c200e Revert "Bump version: 0.2.3 → 0.2.4"
This reverts commit 40fcb8d917.
2026-03-15 10:20:31 -07:00
Jamie Pine 40fcb8d917 Bump version: 0.2.3 → 0.2.4 2026-03-15 10:18:51 -07:00
Jamie Pine ad64d1c3d9 Collect all zipvoice files for PyInstaller (fixes source code error) 2026-03-15 10:18:40 -07:00
Jamie Pine f826e45250 Install chatterbox-tts in CI release workflow 2026-03-15 10:17:23 -07:00
Jamie Pine 3d53c06c5b Bump version: 0.2.2 → 0.2.3 2026-03-15 10:08:56 -07:00
James Pine 9835b9f6d4 fix: prevent stale release data by removing Next.js fetch cache
Replace next: { revalidate: 600 } with cache: 'no-store' on GitHub
API fetches so new releases show up within 5 minutes (in-memory cache
only, no Next.js/Vercel cache layer on top).
2026-03-15 10:07:50 -07:00
Jamie Pine a15dd30b1e Update tauri-action to v0.6 to fix updater JSON and signature generation 2026-03-15 10:05:36 -07:00
Jamie Pine 1d343ac071 Treat missing/draft releases as up-to-date instead of showing error 2026-03-15 09:52:17 -07:00
James Pine ca602de0ae fix: don't reset audio player when unmuting during playback 2026-03-15 09:29:44 -07:00
James Pine cdc0293ca8 feat: add /linux-install page with build-from-source instructions
Linux download card now links to /linux-install instead of a direct
binary download. The page explains the CI situation and gives
clone + setup + build commands.
2026-03-15 09:17:30 -07:00
Jamie Pine e7f749f082 Add luxtts/zipvoice hidden imports to PyInstaller build 2026-03-15 09:13:59 -07:00
Jamie Pine d42e926e5c Bump version: 0.2.1 → 0.2.2 2026-03-15 09:02:10 -07:00
Jamie Pine 32768ea874 Add chatterbox hidden imports to PyInstaller build 2026-03-15 09:00:13 -07:00
James Pine b585e18ccf fix: fade in hero background glow to avoid Safari rendering flash 2026-03-15 08:53:08 -07:00
Jamie Pine 655910457f Auto-update CUDA binary on app update: check version on startup, download if stale 2026-03-15 08:46:17 -07:00
James Pine d6984f1057 fix: remove mix-blend-lighten and drop-shadow causing boxes in Safari 2026-03-15 08:45:40 -07:00
James Pine a637aebe69 feat: show version and total download count on landing page
Fetches download counts across all GitHub releases (paginated) and
displays version, total downloads, and platform list below the CTA.
2026-03-15 08:37:23 -07:00
James Pine a5269d23db Fix keep-server-running on macOS: ignore SIGHUP, watchdog grace period, build script fixes 2026-03-15 08:22:35 -07:00
Jamie Pine fc450e5024 Hide console window for server binary on Windows 2026-03-15 07:57:58 -07:00
Jamie Pine a99c2b572d Show download progress bar for CUDA backend download 2026-03-15 07:50:23 -07:00
Jamie Pine 96289e95f1 Bump version: 0.2.0 → 0.2.1 2026-03-15 06:36:38 -07:00
Jamie PineandGitHub e316b0b4bb Merge pull request #274 from jamiepine/feat/landing-page-redesign
Landing page v0.2.0 redesign
2026-03-15 06:22:04 -07:00
Jamie PineandGitHub 732270b571 Merge pull request #272 from jamiepine/windows-support
Windows support: CUDA detection, cross-platform justfile, clean server shutdown
2026-03-15 06:20:46 -07:00
James Pine 0c6aa15746 Responsive polish: pointer-events-none on animations, sticky header with scroll fade, desktop scroll-to-active fix, iOS audio unlock, player and UI tweaks
- Add pointer-events-none/select-none to feature cards, voice creator, and ControlUI mock
- Sticky header with gradient fade overlay (matching real app 3-layer technique)
- Fix desktop scroll-to-active: separate mobile/desktop card refs to prevent mobile refs overwriting desktop
- Scroll selected card to 2nd row when outside safe zone above generate box
- iOS Safari audio unlock via WaveSurfer's actual media element
- Player: accent fill play/pause button, padding on volume slider, remove close button
- Profile cards: fixed 143px height, mobile edge fades with scroll-aware left fade
- Generate box: accent effect pill when active, white fill sparkle icon, edge-aligned on desktop
- Voice creator: animated waveform background with height-based bars
- 12 profiles (added Attenborough, Zendaya, Obama) for 4-row grid with scroll
2026-03-15 06:17:54 -07:00
Jamie Pine 410413dc57 Watchdog respects keep-server-running setting via /watchdog/disable endpoint 2026-03-15 06:05:17 -07:00
Jamie Pine e239be5bbb Review fixes: CUDA restore in finally, os._exit on Windows, taskkill /T for process tree, build-server-cuda error handling, db-init path 2026-03-15 05:43:26 -07:00
James Pine f80782a90a Landing page v0.2.0 updates: multi-engine copy, star count, model cards, voice creator section, responsive ControlUI, iOS audio fix
- Replace Qwen-specific copy with multi-engine messaging across hero, meta, and features
- Add GitHub star count fetched server-side via /api/stars with Spacedrive-style navbar badge
- Replace 'Why Voicebox exists' section with model cards for all 4 TTS engines
- Enable Linux download card (was 'Coming soon')
- Update GPU support copy to include ROCm, Intel Arc, DirectML
- Add Voice Creator section with animated 3-tab UI (upload, mic, system audio) and waveform background
- Make ControlUI responsive: horizontal scroll cards on mobile, stacked layout, scroll-to-active profile
- Fix iOS Safari audio autoplay (unlock AudioContext on user gesture)
- Fix hero logo square background with mix-blend-lighten
- Remove generation length green coloring, use gray with accent highlights
- Comment out grain overlay (visible tile seams)
- Remove player close button, stack waveform above controls on mobile
- Fixed-height profile cards (143px) with space between badges and buttons
2026-03-15 04:53:28 -07:00
Jamie Pine f1ba73a386 Address review: validate parent-pid, ensure binaries dir exists, fix Xcode typo 2026-03-15 04:09:49 -07:00
Jamie Pine f1963740b4 Fix server binary build, watchdog logging, pedalboard import, window close loop 2026-03-15 04:04:56 -07:00
Jamie Pine 4d6c976ad9 Windows support: CUDA detection, justfile cross-platform, clean server shutdown 2026-03-15 00:02:13 -07:00
Jamie Pine 8377152d86 Redesign landing page with animated ControlUI hero
New Spacedrive-inspired landing page with dark warm color system, glassmorphic navbar, feature cards with animated illustrations, and an interactive ControlUI mockup that cycles through voice generations with real audio playback via WaveSurfer.

The ControlUI demo script is fully data-driven - profiles, generation text, audio samples, and effects are all configurable from a single DEMO_SCRIPT array.

Includes 6 real voice samples (Jarvis, Morgan Freeman, Sam Altman, Samuel L. Jackson, Linus Tech Tips, Fireship) converted to webm opus.
2026-03-14 23:08:29 -07:00
Jamie PineandGitHub 7a511e3756 Merge pull request #271 from jamiepine/feat/post-processing-effects
Add post-processing audio effects system
2026-03-14 12:14:39 -07:00
Jamie PineandGitHub 6d261c44a1 Merge branch 'main' into feat/post-processing-effects 2026-03-14 12:14:26 -07:00
Jamie Pine 103e98b38f github runners suck 2026-03-14 12:13:45 -07:00
Jamie Pine 1c61b47a64 Glassmorphic active state for sidebar buttons with accent border shine 2026-03-14 12:11:07 -07:00
Jamie Pine 626e3740e1 Auto-select first story when navigating to Stories tab 2026-03-14 11:14:46 -07:00
Jamie Pine 310a4acb02 Add source version selection when applying effects, voices tab overhaul with inline inspector 2026-03-14 11:07:32 -07:00
Jamie Pine 899b90202b Add version control to track editor, restyle story list
- Story items can be pinned to a specific generation version via
  toolbar dropdown (shows when clip is selected and has >1 version)
- version_id column on story_items with migration, validated against
  the generation's versions before saving
- Split/duplicate preserve the source clip's pinned version
- Export and playback resolve version-specific audio paths
- Extracted _build_item_detail helper in stories.py (DRY cleanup)
- Story list restyled from rounded cards to flat rows with rounded
  hover/active states, gradient header fade, and dynamic bottom
  padding that accounts for track editor + generate box
2026-03-14 09:56:27 -07:00
Jamie Pine e8d54d52d3 Add favorites, effects badge on profiles, UI polish
- Add is_favorited column with toggle endpoint and star button on history
- Show sparkles icon on profile cards that have effects configured
- Gold ring on selected profile cards
- Smaller, gray action buttons with brighter hover
- Clamp player time to duration to prevent runaway playback
- Align profile card icon to top for wrapped names
- Flush bottom corners on history card when versions expanded
- Simplify .gitignore data/ rule
2026-03-14 09:10:56 -07:00
Jamie Pine 00c5b75ffb Regenerate as new version, UI polish, and bugfixes
- Add /generate/{id}/regenerate endpoint that creates a new take version
- Wire regenerate into history dropdown with SSE progress + autoplay
- Add normalize_audio to regenerate path
- Remove duplicate Regenerate menu item
- Show disabled ellipsis menu during generation instead of hiding it
- Fix sf.write format kwarg broken by asyncio.to_thread migration
- Rename clean version label to 'original', effects to 'version-N'
- Show effects chain names in version list instead of 'N fx'
- Include all versions in export package
- Clean up version panel padding
2026-03-14 08:34:58 -07:00
Jamie Pine 25134b4ba9 Fix player not loading new version after applying effects
Reload the player with the version-specific audio URL when effects are
applied to the currently playing generation. Also consolidate the
instruct/effects buttons into a single button with the effects editor
shown inline when instruct mode is open.
2026-03-14 08:01:45 -07:00
Jamie Pine 3d922ec846 Fix review findings: toggle logic, preset saving, version lookup, async audio ops
- Fix inverted effects toggle in FloatingGenerateBox
- Add Save button + API method for editing custom effect presets
- Return early on effects save failure in ProfileForm
- Fix no-op ternary in effectsStore
- Handle duplicate preset names with proper 400 response
- Use effects_chain is None instead of label for clean version lookup
- Move blocking audio ops to asyncio.to_thread in async endpoints
- Log warnings instead of silently swallowing parse errors
2026-03-14 07:47:06 -07:00
James Pine 638820c839 Add post-processing audio effects system
Adds a full effects pipeline powered by Spotify's pedalboard library,
enabling users to apply professional DSP effects (flanger, reverb, delay,
compressor, pitch shift, filters, gain) to generated audio.

Key features:
- Effects chain editor with drag-and-drop reordering (dnd-kit)
- Generation versions: clean copy always saved, processed versions created on top
- Built-in presets (Robotic, Radio, Echo Chamber, Deep Voice) + custom user presets
- Per-profile default effects chain (auto-applied to new generations)
- Per-generation effects override from the generation form
- Apply effects to existing generations from history (creates new version)
- Ephemeral preview endpoint for auditioning effects without persisting
- Dedicated Effects sidebar tab with preset management and live preview
- Version switcher in history cards with expandable panel
- Backward-compatible: existing generations backfilled as clean versions
2026-03-14 07:11:56 -07:00
Jamie Pine 7cbf5a1ded Use Namespace runner for Linux release build 2026-03-14 05:38:25 -07:00
Jamie Pine e89a7eb7e7 Windows dev experience: CUDA detection, GPU display cleanup, hide drag region, dev-mode update status 2026-03-14 03:24:39 -07:00
Jamie Pine e18757bab3 Skip AppImage on Linux, build deb/rpm only 2026-03-14 03:04:59 -07:00
Jamie Pine 0e6c678fc3 Add Windows support to justfile 2026-03-14 02:41:59 -07:00
Jamie Pine 942dabbcac Install CPU-only PyTorch before requirements.txt on Linux
The previous ordering let requirements.txt pull CUDA-enabled torch
with all nvidia deps first, then the CPU override only swapped the
torch wheel while leaving ~3GB of nvidia packages installed.
2026-03-14 00:55:43 -07:00
Jamie Pine b915825165 Add libasound2-dev to Linux release deps 2026-03-13 21:36:46 -07:00
Jamie Pine f3fc63942f Fix Linux release build exceeding 4GB PyInstaller limit
Install CPU-only PyTorch on Linux and exclude nvidia modules from
non-CUDA PyInstaller builds.
2026-03-13 21:19:24 -07:00
Jamie Pine 9044b986f3 Move tauri imports behind platform abstraction, merge CUDA build into release workflow
- Add openPath and pickDirectory to PlatformFilesystem interface
- Remove direct @tauri-apps/plugin-shell and plugin-dialog imports from app
- Merge build-cuda.yml into release.yml as a parallel job
2026-03-13 11:33:32 -07:00
Jamie Pine b01076b6b3 Bump version: 0.1.13 → 0.2.0 2026-03-13 11:06:40 -07:00
Jamie PineandGitHub 5121c76e39 Merge pull request #269 from jamiepine/feat/async-generation-queue
feat: async generation queue
2026-03-13 10:58:18 -07:00
Jamie Pine 49ebf6222e fix SSE cleanup, filter add popover to completed only, derive isGenerating from pending set, handle not_found status 2026-03-13 10:57:28 -07:00
Jamie Pine 509b0e71cc responsive layout fixes, version in sidebar, fixed voice card height, hide player title at small widths 2026-03-13 10:44:53 -07:00
Jamie Pine 81f8be1a94 defer story add until TTS completes, add generating pill to story editor, fix item placement per-track 2026-03-13 10:28:20 -07:00
Jamie Pine 655a60ca81 feat: async generation queue with serial execution
Generations now return immediately with a 'generating' status and appear
in history right away. TTS runs in a serial background queue to avoid
GPU contention. Users can kick off multiple generations without blocking.

- Async POST /generate creates DB record immediately, queues TTS work
- Serial generation queue prevents concurrent GPU access (Metal/CUDA/CPU)
- SSE endpoint GET /generate/{id}/status for real-time completion tracking
- Retry endpoint POST /generate/{id}/retry for failed generations
- Store engine and model_size on generation records for retry support
- History cards show animated loader (react-loaders) for generating/playing
- Failed generations show retry button instead of actions menu
- Model downloads happen inline in the queue instead of rejecting with 202
- Stale 'generating' records marked as failed on server startup
- Autoplay on generate setting (default: on)
- Show engine name on generation cards
- Remove sidebar generation spinner
- Checkbox alignment fix in settings
2026-03-13 10:02:41 -07:00
Jamie PineandGitHub 52285362ce Merge pull request #268 from jamiepine/feat/model-management-improvements
feat: model management improvements and folder migration
2026-03-13 09:16:43 -07:00
Jamie Pine 3ea587797f feat: model management improvements and folder migration
- Add model folder migration with byte-level progress tracking (backend + UI)
- Custom models directory support via VOICEBOX_MODELS_DIR env var passed to sidecar
- Hardcoded model descriptions displayed in model detail cards
- Open model folder button in storage location row
- Remove 'not downloaded' badge from model cards
- Fix server settings scroll offset for audio player
- Fix shell open permission to allow file paths
- Add normalize toggle to generation settings
2026-03-13 08:38:20 -07:00
Jamie PineandGitHub 325714bb83 Merge pull request #266 from jamiepine/feat/chunked-tts
feat: chunked TTS generation for long text (engine-agnostic)
2026-03-13 08:23:53 -07:00
James Pine 9aa7080c51 refactor: restructure server settings and models UI
- Split chunking/crossfade sliders into dedicated GenerationSettings card
- Merge connection status badges into ConnectionForm (remove ServerStatus card)
- 2-column grid layout for the entire settings page
- GPU Acceleration: remove icon, badge, and MLX info card
- Models: merge 'Other Voice Models' into single 'Voice Generation' list
- Model detail: remove 'Downloaded' badge, border above actions, swap
  badges above stats row, match disk size font to stats
2026-03-13 07:26:46 -07:00
James Pine 97292ecef7 feat: add chunk crossfade slider (0ms = hard cut)
Persisted setting (default 50ms) controls how audio chunks are blended
together.  Set to 0 for a clean hard cut with no overlap.
2026-03-13 06:48:06 -07:00
James Pine 837f8525d8 feat: add auto-chunking limit slider to settings
Persisted setting (default 800 chars) controls how long text is split
before generation.  Lower values improve quality for long outputs by
keeping each chunk well within the model's context window.

- Slider in Server Connection settings (100–2000 chars, step 50)
- Stored in localStorage via Zustand persist
- Passed as max_chunk_chars on every generation request
- Frontend text limit raised to 50,000 to match backend
2026-03-13 06:35:39 -07:00
James Pine 70ca7f66cb feat: chunked TTS generation for long text (engine-agnostic)
Text exceeding max_chunk_chars (default 800) is automatically split at
sentence boundaries, generated per-chunk, and concatenated with a 50ms
crossfade.  Works with all engines (Qwen, LuxTTS, Chatterbox, Turbo).

- Abbreviation-aware sentence splitter (Dr., Mr., e.g., decimals)
- CJK sentence-ending punctuation support
- Paralinguistic tag preservation ([laugh], [cough], etc.)
- Per-chunk seed variation to avoid correlated RNG artefacts
- Per-chunk Chatterbox trim (catches hallucination at each boundary)
- max_chunk_chars exposed as per-request param on GenerationRequest
- Text max_length raised to 50,000 characters

Closes #99
2026-03-13 06:21:34 -07:00
Jamie PineandGitHub c12b5d6f0a Merge pull request #265 from jamiepine/feat/paralinguistic-tags
feat: paralinguistic tag autocomplete for Chatterbox Turbo
2026-03-13 05:55:06 -07:00
James Pine 139fa38e3f fix: address review feedback for ParalinguisticInput
- Initialize lastSerializedRef to empty string so first-mount hydration
  always runs (fixes initial value not rendering)
- Guard arrow-key menu nav against empty filteredTags (avoids NaN index)
- Disable ARIA role/multiline and detach event handlers when disabled
- Add onBlur to close autocomplete dropdown when editor loses focus
- Chain exception with 'from e' in unload endpoint for better tracebacks
2026-03-13 05:52:06 -07:00
Jamie PineandGitHub 0e9f5db40f Merge pull request #264 from jamiepine/fix/chatterbox-float64-dtype
fix: Chatterbox float64 dtype mismatch + model unload button
2026-03-13 05:40:46 -07:00
James Pine 2f535a772f fix: load model into local var before patching to avoid half-initialised state
Apply local-var-then-assign pattern to chatterbox_backend.py (multilingual)
to match the turbo backend. Also use _current_model_size fallback in
unload, delete, and status endpoints for consistent Qwen model size checks.
2026-03-13 05:40:18 -07:00
James Pine b420637957 feat: paralinguistic tag autocomplete for Chatterbox Turbo
Type / in the text input when using Chatterbox Turbo to open an
autocomplete dropdown with 9 supported paralinguistic tags ([laugh],
[chuckle], [gasp], [cough], [sigh], [groan], [sniff], [shush],
[clear throat]).

- contentEditable div replaces textarea for Turbo engine only
- Tags render as inline styled badges
- Pasting text with [tag] patterns auto-converts to badges
- Badges serialize back to plain [tag] text for the API
- Dropdown portalled to body, opens above caret to avoid overflow
2026-03-13 05:19:23 -07:00
James Pine bfd7b815a5 fix: patch S3Tokenizer.log_mel_spectrogram for float64→float32 cast
The actual dtype mismatch was in S3Tokenizer.log_mel_spectrogram, not
VoiceEncoder.forward. librosa.load returns float64 numpy, which
torch.from_numpy preserves as double. The STFT output (double) then
hits _mel_filters (float32) in a matmul at s3tokenizer.py:163.

Now patching both entry points after model load:
1. S3Tokenizer.log_mel_spectrogram — cast audio to float32 before STFT
2. VoiceEncoder.forward — cast mels to float32 before LSTM

Remove debug traceback logging (no longer needed).
2026-03-13 05:04:29 -07:00
James Pine cac80f6af0 feat: add per-model unload endpoint and UI button
- POST /models/{model_name}/unload — unloads a specific model from
  memory without deleting from disk, supports all engine types
- Frontend: Unload button in model detail dialog when model is loaded
- Delete button remains disabled while loaded (unload first)
2026-03-13 04:50:56 -07:00
James Pine 47ce4cafdf fix: patch VoiceEncoder.forward to cast float64 mels to float32
The previous approach of patching librosa.load didn't work because
melspectrogram itself performs float64 math (numpy dot, signal.lfilter)
regardless of input dtype. The actual mismatch happens when pack()
creates a float64 tensor from the mel arrays and passes it into the
float32 LSTM weights in VoiceEncoder.forward().

Fix by monkey-patching VoiceEncoder.forward() to call mels.float()
before the LSTM, ensuring the input always matches the model dtype.
2026-03-13 04:41:43 -07:00
James Pine bfe912e41a fix: specify WAV format for atomic save temp file
soundfile cannot infer format from .tmp extension, causing all
generations to fail with 'No format specified and unable to get
format from file extension'
2026-03-13 04:34:26 -07:00
James Pine 5ccf79a8f7 Revert "fix: cast librosa float64 audio to float32 for Chatterbox voice encoder"
This reverts commit 1d32170c2e.
2026-03-13 04:28:00 -07:00
James Pine 1d32170c2e fix: cast librosa float64 audio to float32 for Chatterbox voice encoder
The upstream VoiceEncoder's melspectrogram only casts to float32 when
hp.normalized_mels is True (it defaults to False), so librosa's float64
output flows through as double tensors into float32 model weights,
causing 'expected m1 and m2 to have the same dtype, but got: float !=
double'. Fix by monkey-patching prepare_conditionals in both Chatterbox
and Chatterbox Turbo backends to ensure librosa.load returns float32.
2026-03-13 04:15:20 -07:00
James Pine ca74c155e2 fix: pass language parameter to Qwen TTS models and sync form with profile language
Both PyTorch and MLX backends silently dropped the language parameter —
it was accepted by generate() but never forwarded to the underlying
Qwen3-TTS model, causing it to default to auto-detection which
frequently confuses similar languages (e.g. Portuguese for Spanish).

- Add LANGUAGE_CODE_TO_NAME mapping (ISO 639-1 to full name) to both backends
- PyTorch: pass language= to generate_voice_clone()
- MLX: pass lang_code= to all 4 model.generate() call sites
- Frontend: auto-sync generation form language with selected voice profile

Closes #97
2026-03-13 04:04:04 -07:00
James Pine 1f770a157d fix: mismatched JSX closing tag in ModelManagement 2026-03-13 03:59:30 -07:00
Jamie PineandGitHub d64e24d422 Merge pull request #230 from haosenwang1018/docs/readme-grammar-profile-management
docs: fix minor README grammar in feature bullets
2026-03-13 03:56:55 -07:00
Jamie PineandGitHub 77d86ba835 Merge pull request #88 from Balneario-de-Cofrentes/fix/restrict-cors-origins
security: restrict CORS to known local origins
2026-03-13 03:56:15 -07:00
Jamie PineandGitHub 986a748420 Merge pull request #161 from ageofalgo/feat/docker-web-deployment
feat: add Docker + web deployment support
2026-03-13 03:55:04 -07:00
James Pine 50e01d17f8 fix: remove unused TTS_MODE env var from docker-compose
TTS_MODE is not read by any code in the backend — it only exists in
unimplemented planning docs. Remove it to avoid confusing users.
2026-03-13 03:53:15 -07:00
Jamie PineandGitHub 084c51b983 Merge pull request #215 from mikeswann/main
Update prerequisites in markdown with Tauri deps
2026-03-13 03:52:34 -07:00
Jamie PineandGitHub efbbbc7ec1 Merge branch 'main' into main 2026-03-13 03:52:22 -07:00
Jamie PineandGitHub 8e7f0cb9ad Merge pull request #133 from rayl15/feat/network-access-toggle
feat: add network access toggle to server settings
2026-03-13 03:47:35 -07:00
Jamie PineandGitHub 3357a06cba Merge pull request #263 from jamiepine/fix/atomic-save-error-handling
fix: atomic audio save with error handling and filesystem health endpoint
2026-03-13 03:45:26 -07:00
Jamie PineandGitHub f58c7c1cf3 Merge pull request #262 from jamiepine/feat/linux-rocm-whisper-turbo
feat: Linux support, AMD ROCm, Whisper Turbo, and spawn fix
2026-03-13 03:44:36 -07:00
James Pine ea41213123 fix: atomic audio save with errno-specific error handling and filesystem health endpoint
- save_audio() now writes to .tmp then os.replace() for atomic writes
- /generate endpoint catches OSError with specific messages for ENOENT, EACCES, ENOSPC, and BrokenPipeError
- New /health/filesystem endpoint checks directory existence, write permissions, and disk space
- New DirectoryCheck and FilesystemHealthResponse models

Cherry-picked and expanded from #178 (@Vaibhavee89)
2026-03-13 03:43:42 -07:00
James Pine b5801891b8 feat: Linux support, AMD ROCm, Whisper Turbo, and spawn fix
Cherry-picked and adapted from PR #89 and #214:

- Linux audio capture via PulseAudio/PipeWire monitor sources (cpal)
- AMD ROCm GPU support: HSA_OVERRIDE_GFX_VERSION env var, ROCm detection
- Whisper Turbo model (openai/whisper-large-v3-turbo) in all endpoints
- Cleaner Whisper language handling via generate_kwargs
- tauri::async_runtime::spawn fix to prevent panic on app shutdown
- Enable Linux (ubuntu-22.04) in release CI matrix
2026-03-13 03:35:18 -07:00
Jamie PineandGitHub 8f77c041f5 Merge pull request #152 from mpecanha/fix-offline-mode-crash
Fix: Prevent crashes when HuggingFace is unreachable
2026-03-13 03:31:23 -07:00
James Pine 5a3f3ba030 Merge remote-tracking branch 'origin/main' into feat/docker-web-deployment 2026-03-13 03:21:39 -07:00
Jamie PineandGitHub 3c25ee6e2c Merge pull request #243 from ways2read/a11y/screen-reader-and-keyboard-improvements
a11y: screen reader and keyboard improvements
2026-03-13 03:18:42 -07:00
James Pine b92b0dd508 merge: resolve conflicts with latest main 2026-03-13 03:16:56 -07:00
Jamie PineandGitHub 670900bf5a Merge pull request #258 from jamiepine/feat/chatterbox-turbo
feat: Chatterbox Turbo engine + per-engine language lists
2026-03-13 03:14:44 -07:00
James Pine 219cfb1605 docs: update PROJECT_STATUS.md to reflect multi-engine architecture
- Reflects merged PRs: #254 (LuxTTS/multi-engine), #257 (Chatterbox), #252 (CUDA swap), #238 (download UI)
- Updated architecture diagram to show all 4 TTS engines
- Added TTS engine comparison table and multi-engine architecture section
- Marked resolved bottlenecks (singleton backend, frontend Qwen assumptions)
- Updated PR triage: marked #194 and #33 as superseded
- Added 'Adding a New Engine' guide (now ~1 day effort)
- Updated recommended priorities to reflect current state
- Added new API endpoints (CUDA, cancel, active tasks)
2026-03-13 02:39:10 -07:00
James Pine bf728a780c feat: add Chatterbox Turbo engine and per-engine language lists
- New ChatterboxTurboTTSBackend wrapping ChatterboxTurboTTS (ResembleAI/chatterbox-turbo)
- English-only 350M model with paralinguistic tag support ([laugh], [cough], [chuckle])
- Bypasses upstream token=True bug by calling snapshot_download(token=None) + from_local()
- Same CPU-on-macOS forcing and torch.load monkey-patching as multilingual backend
- Full engine integration: generate, stream, model status/download/delete endpoints
- Language dropdown now shows only languages supported by the selected engine
- Per-engine language maps: Qwen (10), LuxTTS (en), Chatterbox (23), Turbo (en)
- Auto-switches to English when selecting English-only engines
- Backend language regex expanded to accept all 23 Chatterbox languages
2026-03-13 02:35:10 -07:00
Jamie PineandGitHub 3e6513c0fb Merge pull request #257 from jamiepine/feat/chatterbox
feat: Chatterbox TTS engine with multilingual voice cloning
2026-03-13 02:12:56 -07:00
James Pine c54ee14173 fix: model loaded icon uses accent-colored CircleCheck, show size for loaded models, fix generate box overlapping player on stories route 2026-03-13 02:09:32 -07:00
James Pine cc07d4d3c9 fix: download progress tracking for all engines and inline progress UI
- Add HFProgressTracker to LuxTTS and Chatterbox backends so tqdm-based
  file-level download progress reaches the frontend (previously only Qwen
  had this, LuxTTS/Chatterbox showed a static spinner)
- Add progress/current/total/filename fields to ActiveDownloadTask so the
  /tasks/active polling endpoint carries progress data
- Show inline progress bar + bytes in the model list and detail modal,
  poll at 1s during active downloads (5s otherwise)
- Fix GpuAcceleration crash: cudaStatusLoading was referenced before
  initialization in its own useQuery declaration
2026-03-13 02:09:32 -07:00
James Pine 9beb9d7fec fix: install chatterbox-tts with --no-deps to avoid numpy pin conflict
chatterbox-tts 0.1.6 pins numpy<1.26 and torch==2.6 which are
incompatible with Python 3.12+. Install with --no-deps and list
its sub-dependencies explicitly in requirements.txt.

Also removes HFProgressTracker from chatterbox backend to avoid
'generator didn't stop after throw()' errors from tqdm patching.
2026-03-13 02:09:32 -07:00
James Pine 76bb207b2b feat: add Chatterbox TTS engine for multilingual voice cloning
- New ChatterboxTTSBackend wrapping ChatterboxMultilingualTTS (ResembleAI/chatterbox)
- Supports 23 languages including Hebrew, forces CPU on macOS (MPS issue)
- Monkey-patches torch.load for CPU loading, forces eager attention for compatibility
- trim_tts_output utility cuts trailing silence/hallucination from Chatterbox output
- Full engine integration: /generate, /generate/stream, model status/download/delete
- Hebrew (he) added to supported languages in frontend and backend validation
- Single flat model dropdown extended with Chatterbox option in both generation UIs
- ModelManagement UI groups LuxTTS and Chatterbox under 'Other Voice Models' section
2026-03-13 02:09:32 -07:00
Jamie PineandGitHub 3576521d62 Merge pull request #254 from jamiepine/feat/luxtts
feat: LuxTTS integration — multi-engine TTS support
2026-03-13 02:04:46 -07:00
Jamie PineandGitHub 2df4ece388 Merge pull request #210 from ieguiguren/fix/linux-nvidia-gbm-buffer
fix: Linux NVIDIA GBM buffer crash + WebKitGTK microphone access
2026-03-13 01:55:58 -07:00
Jamie PineandGitHub cbb4979ed6 Merge pull request #175 from Vaibhavee89/fix/profile-duplicate-name-validation
Fix #134: Add validation for duplicate profile names
2026-03-13 01:55:30 -07:00
James Pine 753158c1c9 fix: address review feedback — race condition, GPU safety, task GC
- Add threading lock to get_tts_backend_for_engine() to prevent race
  condition where concurrent requests could create duplicate backend
  instances (double-checked locking pattern)
- Fix LuxTTS generate: call .detach().cpu() before .numpy() so it
  works on GPU/MPS devices, not just CPU
- Store background download tasks in a module-level set to prevent
  garbage collection before completion (asyncio.create_task fire-and-
  forget pattern)
- Deduplicate cache_key computation in LuxTTS create_voice_prompt
- Prefix unused sr variable with underscore
2026-03-13 01:54:09 -07:00
Jamie PineandGitHub 573f82a7e6 Merge pull request #250 from pandego/fix/docs-align-local-port-17493
docs: align local API port examples with current dev flow
2026-03-13 01:53:28 -07:00
James Pine 1e5afc2bef fix: LuxTTS generation and preserve model selection after generate
- Fix silent Zod validation failure when LuxTTS selected (modelSize was
  set to 'default' which failed enum validation, preventing form submit)
- Preserve engine, model size, and language after successful generation
  instead of resetting to defaults
2026-03-13 00:21:43 -07:00
James Pine 163528bf69 fix: single flat model dropdown, linacodec dep, quiet sidecar script
- Combine engine + model size into one flat dropdown (Qwen3-TTS 1.7B,
  Qwen3-TTS 0.6B, LuxTTS) in both FloatingGenerateBox and GenerationForm
- Add linacodec git dep to requirements.txt (uv-only source, pip can't
  resolve it from Zipvoice's pyproject.toml)
- Remove redundant transitive deps from requirements.txt
- Quiet the sidecar setup script (was printing misleading instructions)
2026-03-13 00:21:43 -07:00
James Pine e1ad7a6e73 fix: add piper-phonemize find-links for LuxTTS install
piper-phonemize has no PyPI wheels — needs custom find-links URL
from k2-fsa.github.io. Removed redundant transitive deps that
Zipvoice already declares.
2026-03-13 00:21:43 -07:00
James Pine 411e91bb19 docs: add just commands to README dev quick start 2026-03-13 00:21:43 -07:00
James Pine 05cf163744 chore: add justfile for streamlined dev setup and workflow
Adds 'just' as the recommended dev tool: 'just setup' for one-time
install, 'just dev' to run backend + frontend in one terminal.
Updates CONTRIBUTING.md to document just as the primary setup method.
2026-03-13 00:21:43 -07:00
James Pine d46eb5bcc6 feat: add LuxTTS as second TTS engine with multi-engine support
Introduce LuxTTS (ZipVoice) alongside Qwen TTS, enabling users to choose
between engines at generation time. LuxTTS offers fast, English-focused
voice cloning at 48kHz with ~1GB VRAM.

Backend:
- Add LuxTTSBackend with encode_prompt/generate_speech integration
- Multi-engine registry (get_tts_backend_for_engine) replacing singleton
- Engine-prefixed voice prompt cache keys to avoid collisions
- Engine field on GenerationRequest (default 'qwen' for backward compat)
- Engine dispatch in /generate and /generate/stream endpoints
- LuxTTS in model status, download, and delete maps

Frontend:
- TTS Engine selector dropdown in GenerationForm (Qwen TTS / LuxTTS)
- Conditionally hide Model Size and Delivery Instructions for LuxTTS
- Engine field added to TypeScript types and Zod schema
- LuxTTS section in Model Management page
2026-03-13 00:21:43 -07:00
Jamie PineandGitHub 6359dee406 Merge pull request #252 from jamiepine/feat/cuda-backend-swap
feat: CUDA backend swap via binary download and restart
2026-03-13 00:20:40 -07:00
James Pine a69c216794 fix: address review feedback on CUDA backend swap
- Use YAML block scalar for inline run with colons (build-cuda.yml)
- Explicitly set VOICEBOX_BACKEND_VARIANT=cpu instead of setdefault (server.py)
- Use Path.replace() for atomic move on all platforms (cuda_download.py)
- Log actual exception in checksum fetch warning (cuda_download.py)
2026-03-13 00:20:05 -07:00
James Pine 2867421550 feat: CUDA backend swap via binary download and restart
Add the ability to download a CUDA-enabled backend binary (~2.4 GB) and
swap it in via a backend-only restart, solving the #1 user pain point
(19 open 'GPU not detected' issues caused by GitHub's 2 GB asset limit).

Backend:
- cuda_download.py: download from R2 (primary) or GitHub split-parts
  (fallback), SHA-256 verification, atomic writes, progress via SSE
- 4 new endpoints: GET/POST/DELETE /backend/cuda-*, GET cuda-progress
- server.py: --version flag, auto-detect variant from binary name
- build_binary.py: --cuda flag for CUDA PyInstaller builds
- split_binary.py: split large binaries into <2GB GitHub Release assets
- CI workflow for building CUDA binary

Tauri:
- restart_server command (stop -> wait -> start)
- start_server prefers CUDA binary from {data_dir}/backends/ if present
- Version mismatch check: runs --version before launching CUDA binary

Frontend:
- GpuAcceleration component: download, progress, restart, switch, delete
- API client + types for CUDA status and management
- Platform lifecycle: restartServer() on Tauri/Web
- Aggressive 1s health polling during restart for fast reconnection
2026-03-13 00:04:12 -07:00
Jamie PineandGitHub 758577fd4b Merge pull request #238 from luminest-llc/feat/download-cancel-and-error-ui
Added download cancel/clear UI, fixed model downloading
2026-03-13 00:03:37 -07:00
pandego 3d2506767d docs: address review nits for API generator 2026-03-13 05:08:25 +01:00
pandego cdef2163c1 docs: align local API port examples with current dev flow 2026-03-12 12:17:50 +01:00
Richard Orme 9955e1dcb7 a11y: address PR feedback and polish docs
- HistoryTable: skip row key handler when focus is on Actions button (Enter/Space)
- StoryList: expose selected story (aria-pressed, 'Selected' in label)
- ProfileCard: skip card key handler when focus is on Export/Edit/Delete
- VoicesTab: keep table semantics; edit button in first cell instead of role=button on row
- PR-ACCESSIBILITY.md: 'Fine-tune' wording, 'focus on the text area' phrasing

Made-with: Cursor
2026-03-07 12:36:02 -08:00
Richard Orme 19a28bf6c5 a11y: screen reader and keyboard improvements
- Audio player: aria-labels for Play/Pause, Loop, Mute, Close; labelled playback and volume sliders
- Generation: aria-labels for Generate speech and Fine tune instructions buttons
- Voice cards: focusable, labelled, Enter/Space to select
- History rows: focusable, labelled, Enter/Space to play; transcript textarea labelled
- Voices tab: focusable rows, labelled, Enter/Space to edit; Actions button labelled
- Model management: focusable model rows and labelled Download/Delete buttons
- Server tab: regions with aria-label and tabIndex for Connection, Status, App Updates
- Stories: focusable story rows, labelled, Enter/Space to select; Actions and track editor buttons labelled
- Voice profile samples: Play/Pause/Stop and mini-player slider labelled

Tested with NVDA and Narrator on Windows. See docs/PR-ACCESSIBILITY.md for full description.

Made-with: Cursor
2026-03-07 12:02:33 -08:00
Daddy Raegen a8ecf3f31d refactor: encapsulate task clearing behind TaskManager.clear_all() 2026-03-06 20:33:21 -05:00
Daddy Raegen d744e634a8 fix: address PR review feedback for download cancel/error UI
- Fix transcribe_audio to use whisper-large-v3 mapping (not openai/whisper-large)
- Propagate error field in progress-only fallback path for get_active_tasks
- Use removed return value in cancel endpoint to vary response message
- Add error rollback to handleCancel with toast on failure
- Make isCancelling per-model instead of global
- Fix inverted chevron icons in Problems panel
- Move all clears under lock in clear_all_tasks
- Simplify cancel_download to use dict.pop()
2026-03-06 10:52:57 -05:00
Daddy Raegen a362d7de2a feat: add download cancel/clear UI, fix whisper-large and error reporting
- Add cancel (X) button on downloading and errored model items
- Add collapsible Problems panel (VS Code-style) showing error details
- Add "Clear All" button to reset all stale download/error state
- Add POST /models/download/cancel endpoint to dismiss individual downloads
- Add POST /tasks/clear endpoint to reset all task and progress state
- Include error messages in /tasks/active response for visibility
- Capture SSE error messages client-side for immediate display
- Fix whisper-large using wrong HF repo (openai/whisper-large → openai/whisper-large-v3)
- Fix Whisper HF repo mapping in both PyTorch and MLX backends
- Shorten error toast to point users to Problems panel instead of wall of text
2026-03-06 00:56:14 -05:00
OpenClaw Bot 3f10a70d4c docs: fix minor grammar in feature bullets 2026-03-04 04:39:28 +00:00
mikeswannGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
d0dfe78701 Update CONTRIBUTING.md
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-28 10:36:34 +01:00
mikeswannandGitHub 172addd918 Update README.md 2026-02-28 00:29:23 +01:00
mikeswannandGitHub ada309cfb9 Update CONTRIBUTING.md 2026-02-28 00:28:01 +01:00
IvanandClaude Opus 4.6 30ee07c2e3 fix: scope DMABUF workaround to Linux+NVIDIA, add origin validation
Address CodeRabbit review feedback:
- Makefile: only set WEBKIT_DISABLE_DMABUF_RENDERER=1 when running on
  Linux with an NVIDIA GPU detected via lspci
- main.rs: validate webview origin before auto-granting microphone
  permission — only allow for trusted local origins (tauri://, localhost,
  127.0.0.1)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-27 07:01:32 +01:00
IvanandClaude Opus 4.6 d21c63b52c fix: enable microphone access on Linux via WebKitGTK
WebKitGTK denies getUserMedia by default. This adds webkit2gtk as a
Linux dependency and configures the webview to enable media streams
and auto-grant UserMediaPermissionRequest for microphone access.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-27 06:49:08 +01:00
IvanandClaude Opus 4.6 5ad67d7ecb fix: disable DMABUF renderer for NVIDIA GPUs on Linux
WebKitGTK fails to create GBM buffers with NVIDIA proprietary drivers,
resulting in an empty/blank Tauri window. Set WEBKIT_DISABLE_DMABUF_RENDERER=1
in the dev target to work around this.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-27 06:32:02 +01:00
Vaibhavee Singh 6cc96c2614 Fix #134: Add validation for duplicate profile names
- Add validation in create_profile() to check for existing names before insert
- Add validation in update_profile() to prevent renaming to duplicate names
- Improve error handling in API endpoints with user-friendly messages
- Add comprehensive test suite for duplicate name validation
- Update CHANGELOG.md with fix details

This fix prevents database constraint violations and provides clear
error messages when users attempt to create or update profiles with
names that already exist in the database.
2026-02-24 10:17:39 +05:30
Jamie Pine 38bf96ff20 fix: pin numba for release CI wheel compatibility 2026-02-23 12:18:34 -08:00
Jamie Pine 0b14cb1b2c Bump version: 0.1.12 → 0.1.13 2026-02-23 11:24:46 -08:00
Jamie Pine 4d24e69012 docs: point download links to latest release 2026-02-23 11:23:30 -08:00
Jamie PineandGitHub 90436e428d Merge pull request #77 from ManuLG/fix/broken-confirmation-modals
fix: await for confirmation before deleting voices and channels
2026-02-23 11:13:12 -08:00
Jamie PineandGitHub e4bb288904 Merge pull request #93 from iJaack/fix/mlx-apple-silicon-binary
fix(mlx): bundle native libs and broaden error handling for Apple Silicon
2026-02-23 11:12:34 -08:00
Jamie PineandGitHub 6f8bc7f23b Merge pull request #95 from CelebrityPunks/fix/model-size-selection-ignored
Fix: selecting 0.6B model still downloads and uses 1.7B
2026-02-23 11:12:12 -08:00
Jamie PineandGitHub baca111d50 Merge branch 'main' into fix/model-size-selection-ignored 2026-02-23 11:12:05 -08:00
Jamie PineandGitHub cc298fe6d8 Merge pull request #79 from martyniukyurii/fix/unicode-content-disposition
fix: handle non-ASCII filenames in Content-Disposition headers
2026-02-23 11:09:36 -08:00
Jamie PineandGitHub 46b8f6b882 Merge pull request #78 from tomasmach/fix/getUserMedia-undefined-check
fix: guard getUserMedia call against undefined mediaDevices in non-secure contexts
2026-02-23 11:09:23 -08:00
Claudio Casale edfc6e99fe feat: add Docker + web deployment support 2026-02-23 12:52:03 +01:00
Makinde d00e28ffda Fix: Prevent crashes when HuggingFace is unreachable
Implements offline mode patch for API stability issues:

- Add hf_offline_patch.py to monkey-patch huggingface_hub
- Force cache-only lookups before mlx_audio imports
- Create symlink from original Qwen repo to MLX community version
  when only MLX version is cached

This fixes:
- Issue #150: Internet required even with cached models
- Issue #151: API crashes when HF network fails

The patch ensures that if models are locally cached, no network
requests are made to HuggingFace during speech generation.
2026-02-22 01:57:02 -08:00
Jamie PineandGitHub 162cf4fb84 Merge pull request #122 from white1107/fix/web-tailwind-plugin
fix(web): add @tailwindcss/vite plugin to web config
2026-02-21 13:46:30 -08:00
Jamie PineandGitHub 68558243d9 Merge pull request #126 from lemassykoi/main
Create requirements.txt
2026-02-21 13:46:07 -08:00
Jamie PineandGitHub 8d5ad926f9 Merge pull request #128 from mrigankad/fix/voicebox-bugs
fix: resolve multiple issues (#96, #119, #111, #108, #121, #125, #127)
2026-02-21 13:45:19 -08:00
Jamie PineandGitHub 334f037dce Merge pull request #146 from xPolar/landing/spacebot-banner
Add Spacebot banner to landing page
2026-02-21 13:41:31 -08:00
xPolar f6522eea80 Add Spacebot banner to landing page
Adds a persistent top-of-page banner linking to spacebot.sh,
another project by the creator of Voicebox. Uses existing design
tokens for a consistent look.
2026-02-21 13:37:44 -08:00
lemassykoiandAmp 7615a08f81 ci: add Windows-only build workflow without signing
Amp-Thread-ID: https://ampcode.com/threads/T-019c7c3e-072f-7109-86a7-072a6b309891
Co-authored-by: Amp <[email protected]>
2026-02-20 23:06:45 +01:00
Rahul Sharma 28a4fd4824 feat: add network access toggle to server settings
Exposes the existing remote server mode through a checkbox in Server
Connection settings. When enabled, the server binds to 0.0.0.0 instead
of 127.0.0.1, making it accessible from other devices on the network.

The plumbing already existed (Rust sidecar passes --host 0.0.0.0 when
remote=true, serverStore has mode state, Python backend accepts --host),
but the UI hardcoded startServer(false). This wires it up.

Closes #104
2026-02-21 00:39:39 +05:30
lemassykoiandAmp 31ea3c68a5 fix: remove silent browser fallback that bypasses save dialog path
Amp-Thread-ID: https://ampcode.com/threads/T-019c7c3e-072f-7109-86a7-072a6b309891
Co-authored-by: Amp <[email protected]>
2026-02-20 19:27:41 +01:00
Mriganka 54d72ddfd0 fix: resolve multiple issues (#96, #119, #111, #108, #121, #125, #127) 2026-02-20 23:23:38 +05:30
Clément PAPPALARDOandGitHub d4794f78e1 Create requirements.txt 2026-02-20 17:14:14 +01:00
white1107 aa7c9a9a8d fix(web): add @tailwindcss/vite plugin to web config
The web version was missing the Tailwind CSS Vite plugin, causing
CSS to not load at all. This adds the same plugin configuration
that exists in the tauri version.

Fixes #121
2026-02-20 20:11:27 +09:00
AbrahamandClaude Opus 4.6 ca6ed0998a Fix model size selection ignored when generating speech
The /generate endpoint created the voice prompt before loading the
user's requested model size. Since create_voice_prompt() internally
calls load_model_async(None), it fell back to the hardcoded default
of "1.7B", causing the 1.7B model to be downloaded even when the
user explicitly selected 0.6B.

This reorders the operations so the requested model is loaded first,
ensuring create_voice_prompt() and generate() use the correct model.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-18 09:41:44 -08:00
Eva 829d4d6d5b fix(mlx): bundle native libs and broaden error handling for Apple Silicon
The distributed macOS aarch64 binary shipped without MLX acceleration despite
the model and backend code supporting it. Two root causes:

1. **OSError not caught in platform_detect.py**
   PyInstaller bundles isolate the filesystem, so when MLX tries to load its
   Metal shader libraries (.metallib) it raises OSError, not ImportError.
   platform_detect.get_backend_type() only caught ImportError, causing a
   silent fallback to PyTorch even on Apple Silicon hardware.
   Fix: broaden the except clause to (ImportError, OSError, RuntimeError)
   and import mlx.core instead of mlx (forces native lib loading eagerly).

2. **collect_data_files used instead of collect_all for MLX**
   build_binary.py and voicebox-server.spec used --collect-data /
   collect_data_files for mlx and mlx_audio. This copies Python source and
   pure-Python data, but NOT native shared libraries (.dylib, .metallib).
   Fix: switch to --collect-all / collect_all which captures binaries too,
   then pass them to Analysis(binaries=...) in the spec.

Result: macOS Apple Silicon users now get MLX inference (~4-5x faster than
PyTorch CPU), matching the performance documented in the README.
2026-02-18 16:51:48 +01:00
David Gil 80c87c8e2c test: add CORS origin restriction tests
20 tests covering:
- All 6 default local origins are allowed
- Arbitrary external origins are blocked
- Preflight (OPTIONS) requests respect the allowlist
- VOICEBOX_CORS_ORIGINS env var extends the allowlist
- Edge cases: empty env, whitespace trimming, trailing commas

Tests use a minimal FastAPI app mirroring the real CORS config,
so they run without ML dependencies (torch, numpy, etc.).
2026-02-17 22:04:25 +01:00
David Gil 427d811954 security: restrict CORS to known local origins instead of wildcard
The wildcard `allow_origins=["*"]` allows any website the user visits to
make requests to the local voicebox backend, potentially triggering TTS
generation or reading voice profiles without consent.

Restrict to the known Tauri webview and Vite dev server origins by
default. Users running in remote server mode can set
VOICEBOX_CORS_ORIGINS to allow additional origins.
2026-02-17 21:58:08 +01:00
YuriiandCursor 0be7975db5 fix: handle non-ASCII filenames in Content-Disposition headers
The export endpoints (export-audio, export generation, export profile,
export story) crash with `'latin-1' codec can't encode characters` when
the generated text or profile/story name contains non-ASCII characters
(e.g. Cyrillic, Chinese, Arabic).

Root cause: Python's `str.isalnum()` passes Unicode letters through to
the filename, but HTTP headers are encoded as latin-1 by the ASGI server,
which cannot represent characters outside the 0-255 range.

Fix: introduce `_safe_content_disposition()` helper that builds a
standards-compliant header with an ASCII-only `filename` fallback and a
RFC 5987 `filename*=UTF-8''...` parameter for Unicode-capable clients.

Fixes #68

Co-authored-by: Cursor <[email protected]>
2026-02-17 12:58:42 +04:00
tomasmach 40e4af828a fix: guard getUserMedia call against undefined mediaDevices in non-secure contexts 2026-02-17 09:28:23 +01:00
Manuel Lorenzo 0e57826ea5 fix: await for confirmation before deleting voices and channels 2026-02-17 00:29:52 +01:00
Spacedrive Mac Mini 2 eb2cd861b1 chore: update Cargo.lock version to 0.1.12 2026-02-10 06:59:41 -08:00
Jamie PineandGitHub 701cc647a7 Merge pull request #57 from selop/chore/readme
chore: updates repo URL in README
2026-02-06 05:08:24 -08:00
Sergej Lopatkin be6ccaf044 chore: updates repo URL in README
Updates the repository URL in the README to point to the correct fork.

Adds a prerequisite for XCode on macOS for development.
2026-02-06 13:22:59 +01:00
Jamie Pine 2e6efa00a2 Refactor documentation structure and dependencies for migration to Fumadocs
- Updated `.gitignore` to include new build and generated content directories.
- Removed outdated Mintlify configuration files and documentation.
- Introduced new `MIGRATION.md` to outline the transition from Mintlify to Fumadocs.
- Added `mdx-components.tsx` for MDX component configuration and compatibility.
- Updated `package.json` and `next.config.mjs` for new dependencies and Next.js configuration.
- Created `source.config.ts` for content source configuration.
- Added OpenAPI specification in `openapi.json` for API documentation.
- Removed legacy files and adjusted project structure to align with Fumadocs conventions.
2026-02-02 23:29:35 -08:00
Jamie Pine 788a04f265 Merge branch 'main' into better-docs 2026-02-02 23:18:06 -08:00
Jamie PineandGitHub 1040625a88 Merge pull request #44 from selop/feature/delivery-instructions
Enhances floating generate box UX
2026-02-02 17:58:19 -08:00
Sergej Lopatkin 6f4503b521 Enhances floating generate box UX
- Adds tooltips on hover for buttons of the generate box
- Replaces the message square icon with a sliders icon for the instruction mode toggle.
- Adds a tooltip to the instruction mode toggle button.
- Updates the placeholder text for the input field.
2026-02-02 22:19:54 +01:00
Sergej LopatkinandGitHub f5b6edc2e7 Merge pull request #1 from jamiepine/main
update fork
2026-02-02 22:19:30 +01:00
Jamie PineandGitHub 8197f0724c Merge pull request #40 from Spyabo/fix/audio-export-path-resolution
Fix: audio export path resolution
2026-02-02 06:54:39 -08:00
Reese Wright d40f7d2676 refactor: improve path resolution readability 2026-02-02 14:54:05 +00:00
Reese Wright 99fbcca7f4 update CHANGELOG for audio export fix 2026-02-02 14:34:39 +00:00
Reese Wright 04f9880c9a fix audio export path resolution 2026-02-02 14:26:34 +00:00
Jamie Pine b9c858295d Update Voicebox description as an alternative to ElevenLabs, rather than Ollama 2026-02-01 00:45:47 -08:00
Jamie Pine 610f64c762 fix linux compile 2026-01-31 07:44:28 -08:00
Jamie Pine 220333b3bb corrections 2026-01-31 02:15:45 -08:00
Jamie Pine e194e95512 corrections 2026-01-31 02:14:37 -08:00
Jamie Pine e796412c2c corrections 2026-01-31 02:13:42 -08:00
Jamie Pine cb541521d2 Update TTS Provider Architecture status to v0.1.13 2026-01-31 02:11:41 -08:00
Jamie Pine 2bc243f93e Add TTS Provider Architecture plan
Solves GitHub 2GB limit + frequent update UX issues by splitting app into:
- Main app (~150MB): UI + backend logic + Whisper
- TTS Providers (plugins): Separate downloadable binaries
  - pytorch-cpu (~300MB)
  - pytorch-cuda (~2.4GB)
  - mlx (~800MB, macOS)
  - remote (connect to external server)
  - openai (API wrapper)

Benefits:
- Main app under GitHub 2GB limit
- Updates don't require re-downloading providers
- User choice of compute backend
- External provider support for teams/cloud
- Future-proof extensibility
2026-01-31 02:09:42 -08:00
Jamie Pine 0209008d73 disable cuda for 0.1.12 2026-01-31 01:46:14 -08:00
Jamie Pine 5cb54ee03c Update API documentation and enhance server configuration
- Added server configurations for local and production environments in `main.py`.
- Removed outdated authentication and generation API documentation files.
- Updated documentation structure to reflect the removal of deprecated API endpoints.
- Adjusted links in the quick start and developer setup documentation to point to the new API reference.
- Enhanced global CSS styles for improved theming support.
2026-01-31 01:45:42 -08:00
Jamie Pine 0922845101 disable cuda for 0.1.12 2026-01-31 01:44:34 -08:00
Jamie Pine 64dd29d35a Add initial setup for Fumadocs documentation migration
- Created new directory structure for documentation under `/docs2`.
- Added `.gitignore` to exclude build artifacts and dependencies.
- Introduced `package.json`, `next.config.mjs`, and `postcss.config.mjs` for project configuration.
- Implemented MDX components in `mdx-components.tsx` for rendering documentation.
- Migrated existing documentation content and created new files for auto-updater and other features.
- Established compatibility layer for Mintlify components in `mintlify-compat.tsx`.
- Set up OpenAPI documentation in `openapi.json`.
- Updated README and migration guide to reflect new structure and usage instructions.
- Ensured all components and pages are ready for development and deployment with Fumadocs.
2026-01-30 23:32:45 -08:00
Jamie Pine 9bde534860 Bump version: 0.1.11 → 0.1.12 2026-01-30 21:23:07 -08:00
Jamie PineandGitHub 97eb570b28 Merge pull request #25 from jamiepine/fix-dl-notification-when-generating-from-already-cached-model
Fix dl notification when generating from already cached model
2026-01-30 21:20:25 -08:00
Jamie PineandGitHub 7d0557a099 Merge pull request #27 from jamiepine/model-dl-fix
Enhance model caching checks and progress tracking for downloads
2026-01-30 21:19:52 -08:00
Jamie Pine 60a03c56a9 Enhance model caching checks and progress tracking for downloads
- Updated caching methods in MLX, PyTorch, and backend to ensure models are fully downloaded before being marked as cached.
- Improved progress tracking to filter out non-download progress and provide accurate feedback during model downloads.
- Enhanced HFProgressTracker to skip non-byte progress bars and ensure meaningful progress reporting.
- Refactored progress initialization to provide immediate feedback while fetching metadata from HuggingFace.
- Added error handling and logging for better debugging during cache checks and download processes.
2026-01-30 21:17:01 -08:00
Jamie Pine d3393fb940 Refactor model download progress tracking and enhance SSE handling
- Rearranged imports for consistency in useModelDownloadToast hook.
- Improved logging in useModelDownloadToast for better debugging during download events.
- Updated progress calculation to handle cases where progress exceeds 100%.
- Enhanced toast notifications to reflect download completion and error states.
- Introduced throttling in ProgressManager to optimize SSE updates and prevent overwhelming clients.
- Added new test scripts for monitoring SSE events during model downloads, ensuring accurate progress reporting.
2026-01-30 20:18:53 -08:00
Jamie Pine 07c0aba883 Refactor model download handling and improve progress tracking
- Rearranged imports for consistency across components.
- Enhanced the ModelManagement component to include detailed logging for download actions and errors.
- Updated the ModelProgress component to connect to SSE only when actively downloading, preventing connection exhaustion.
- Added a downloading state to the model status to indicate ongoing downloads.
- Improved toast notifications for model downloads with completion and error callbacks.
- Refactored the useModelDownloadToast hook to support new callbacks for download completion and error handling.
- Updated backend model status to reflect downloading state during active downloads.
2026-01-30 19:53:20 -08:00
Jamie Pine 77418a52ae Update release workflow and model references
- Added a step to install PyTorch with CUDA for Windows in the release workflow.
- Updated model references in backend/main.py to use openai/whisper models instead of mlx-community for the MLX backend.
2026-01-30 18:10:17 -08:00
Jamie Pine 46f6806e14 Update versions and implement auto-update feature
- Bumped version numbers for @voicebox/app, @voicebox/landing, @voicebox/tauri, and @voicebox/web to 0.1.11.
- Added a new `useAutoUpdater` hook to check for app updates on startup and notify users with toast messages.
- Enhanced `UpdateStatus` component to handle version retrieval errors more gracefully.
- Updated dependencies in `package.json` for Tauri plugins to support new update functionalities.
2026-01-30 18:02:28 -08:00
Jamie PineandGitHub 20851ccc2b Merge pull request #24 from jamiepine/fix-multi-sample
Fix multi sample
2026-01-30 17:07:53 -08:00
Jamie Pine e5f4606a6c Update CircleButton component to include default button type
- Added a default `type` prop set to 'button' in the CircleButton component to ensure proper button behavior.
- Enhanced the component's flexibility by allowing the type to be overridden through props.
2026-01-30 17:07:35 -08:00
Jamie Pine 146ef5aaeb Add delete confirmation dialogs in HistoryTable and SampleList components
- Implemented delete confirmation dialogs for both HistoryTable and SampleList components to enhance user experience and prevent accidental deletions.
- Added state management for handling the selected item to be deleted and the visibility of the delete dialog.
- Refactored delete handling functions to utilize the new dialog confirmation flow, improving code clarity and maintainability.
2026-01-30 17:06:12 -08:00
Jamie Pine 971604d14f Implement profile cache management in audio processing
- Added `clear_profile_cache` function to manage cache files for specific profiles.
- Integrated cache clearing in `add_profile_sample`, `delete_profile`, and `delete_profile_sample` functions to ensure stale audio caches are invalidated after modifications.
- Enhanced `clear_voice_prompt_cache` to also delete combined audio files, improving overall cache management.
2026-01-30 16:50:24 -08:00
Jamie Pine 0b17073345 Add test suite for Voicebox backend
- Introduced a new directory for manual test scripts aimed at debugging and validating backend functionality.
- Added README.md detailing the purpose and usage of various test scripts, including tests for TTS generation, model downloads, and progress tracking.
- Included an __init__.py file to define the test suite structure and provide context for the tests.
2026-01-30 16:48:14 -08:00
Jamie Pine 17106b1e40 Add progress tracking and caching checks for model downloads
- Introduced methods to check if models are cached locally in MLX and PyTorch backends.
- Enhanced progress tracking during model loading to filter out non-download progress when models are cached.
- Updated HFProgressTracker to conditionally report progress based on download status.
- Added test scripts for monitoring SSE events during model downloads and verifying progress tracking functionality.
- Improved overall error handling and logging for better debugging during model download processes.
2026-01-30 16:47:54 -08:00
Jamie Pine 953e6ec7d8 Refactor import order and fix typo in SampleList component
- Rearranged import statements for consistency and clarity.
- Corrected the spelling of "interchangeable" in the note about sample quality.
2026-01-30 16:16:17 -08:00
Jamie Pine d3c65fc6c2 Enhance HistoryTable Component with Infinite Scroll and Cache Management
- Updated HistoryTable to implement infinite scrolling for loading history items dynamically.
- Introduced state management for accumulated history and total item count.
- Added Intersection Observer for triggering additional data fetches when scrolling.
- Implemented cache clearing functionality in the backend to manage voice prompt caches effectively.
- Improved loading indicators and user feedback for data fetching states.
- Refactored code for better readability and maintainability.
2026-01-30 16:16:05 -08:00
Jamie PineandGitHub 7fcca09f24 Merge pull request #23 from jamiepine/audio-export-entitlement-fix
Audio export entitlement fix
2026-01-30 15:08:50 -08:00
Jamie Pine b6e772c6ac formatting 2026-01-30 15:08:34 -08:00
Jamie Pine a6b070201b Refactor Tauri Integration to Use Platform Context
- Replaced direct Tauri API calls with a unified platform context across multiple components, enhancing code maintainability and readability.
- Removed the deprecated tauri.ts file, consolidating platform-related logic into the new PlatformContext.
- Updated components such as App, AudioPlayer, and ServerSettings to utilize the new platform context for lifecycle management and server interactions.
- Improved platform detection and handling for audio playback and system audio capture functionalities.
- Ensured consistent error handling and user feedback across the application when interacting with platform-specific features.
2026-01-30 15:04:38 -08:00
Jamie Pine 30352e2419 formatting 2026-01-30 14:39:41 -08:00
Jamie Pine bfa38b36b7 Update file filters for export generation and profile export
- Modified the file extension filters in useHistory.ts and useProfiles.ts to only allow 'zip' files, removing 'voicebox.zip' for a more streamlined export process.
- Added user-selected read-write permission in Entitlements.plist to enhance file handling capabilities.
2026-01-30 14:39:29 -08:00
Jamie Pine 1b66a528d1 Enhance README and UI Components for Performance and Features
- Updated README.md to highlight MLX backend performance improvements on Mac with Metal acceleration.
- Refined ProfileCard and ProfileForm components by optimizing imports and improving error handling for avatar uploads.
- Adjusted landing page content to better describe features, including a new multi-voice narrative editor and performance optimizations for different platforms.
- Bumped version to 0.1.11 in Cargo.lock to reflect recent changes.
2026-01-30 02:53:15 -08:00
Jamie Pine bef4092e6e Bump version: 0.1.10 → 0.1.11 2026-01-30 02:28:08 -08:00
Jamie Pine 9654f7b642 Refactor MLX and PyTorch Backend Model Loading
- Updated hidden imports in build_binary.py to replace 'mlx_audio.asr' with 'mlx_audio.stt'.
- Enhanced model loading logic in MLX and PyTorch backends to ensure proper progress tracking during model downloads.
- Improved error handling and context management for progress tracking in both backends.
- Bumped version to 0.1.10 in Cargo.lock to reflect recent changes.
2026-01-30 02:26:50 -08:00
Jamie Pine eba1244add Bump version: 0.1.9 → 0.1.10 2026-01-29 23:12:16 -08:00
Jamie Pine 94487f32a5 Enhance MLX and PyTorch Backend Integration
- Added support for MLX backend on Apple Silicon, enabling optimized performance for TTS and STT tasks.
- Implemented platform detection to dynamically select between MLX and PyTorch based on the runtime environment.
- Updated build process to include MLX-specific dependencies and configurations for macOS.
- Refactored backend code to improve model loading and inference logic, accommodating backend-specific requirements.
- Enhanced documentation to clarify backend selection and performance benefits for different platforms.
- Streamlined installation instructions and troubleshooting guidance for MLX-related issues.
2026-01-29 23:11:48 -08:00
Jamie Pine 081f45e680 ADDED MLX FOR SUPER FAST GENERATIONS ON APPLE SILICON
- Added support for MLX backend on Apple Silicon, enabling optimized performance for TTS and STT tasks.
- Updated release workflow to include MLX-specific dependencies and configurations for macOS platforms.
- Refactored backend code to dynamically select between MLX and PyTorch based on the runtime environment.
- Enhanced model loading and inference logic to accommodate backend-specific requirements, including updated model IDs and hidden imports.
- Improved health check and model status reporting to reflect the active backend type.
- Streamlined caching mechanisms to support both backend types, ensuring compatibility and performance.
2026-01-29 21:50:46 -08:00
Jamie Pine 86768288ce Enhance MLX Audio Documentation and Testing Framework
- Updated MLX_AUDIO.md to reflect validated status and included detailed validation results, model mapping, and API usage examples.
- Added a demo script (demo.py) for testing audio generation speed and functionality.
- Introduced a test script (test_tts.py) to validate MLX audio model loading and generation, ensuring robust testing for future developments.
- Created a .gitignore file in the mlx-test directory to exclude unnecessary files from version control.
2026-01-29 21:28:22 -08:00
Jamie Pine 0fd063442a Remove risks and mitigations section from MLX_AUDIO.md and update open questions with responses for clarity. This streamlines the documentation and provides clearer guidance on future considerations. 2026-01-29 21:08:28 -08:00
Jamie Pine a0c2493e98 Add MLX Audio Integration for Apple Silicon Support
- Introduced a new backend for MLX audio to enable GPU acceleration on macOS Apple Silicon, improving performance and user experience.
- Implemented platform detection to switch between MLX and PyTorch backends based on the runtime environment.
- Added new streaming capabilities for TTS and STT, enhancing real-time audio generation.
- Updated API endpoints and frontend components to support new features while maintaining backward compatibility.
- Created documentation for backend integration and performance comparisons.
2026-01-29 20:57:52 -08:00
Jamie Pine b39f48cc81 Refactor useGenerationForm to streamline model download handling
- Removed unnecessary isDownloading variable and related logic.
- Consolidated model download state reset to the finally block for improved clarity and reliability.
- Enhanced error handling by ensuring model download state is reset in case of failure.
2026-01-29 20:17:55 -08:00
Jamie Pine 4ff775bc98 Update packageManager version in package.json to [email protected] 2026-01-29 19:53:38 -08:00
Jamie Pine 6351aa75e9 Refactor StoryList component for improved readability and organization
- Reorganized import statements for clarity and consistency.
- Adjusted formatting of state declarations for better readability.
- Streamlined JSX structure for improved visual hierarchy.
- Updated dialog descriptions for consistency in presentation.
- Made minor adjustments to spacing and layout for enhanced UI consistency.
2026-01-29 19:46:27 -08:00
Jamie Pine 43873a883b Update StoryList component styles for improved UI consistency
- Changed border radius of the "No stories yet" message to rounded-2xl for a softer appearance.
- Updated story item borders to rounded-2xl to enhance visual cohesion across the component.
2026-01-29 19:34:28 -08:00
Jamie Pine 60012b81c0 Refactor ProfileForm and SampleList components for improved UI and functionality
- Updated button styles in ProfileForm for better visual consistency and user experience.
- Replaced Pencil icon with Edit in SampleList for clearer action representation.
- Introduced CircleButton component for action buttons in SampleList, enhancing UI responsiveness and clarity.
- Improved layout and hover effects for action buttons in SampleList to streamline user interactions.
2026-01-29 19:32:05 -08:00
Jamie Pine 89f3127c37 Implement avatar upload and management for voice profiles
- Added functionality to upload, delete, and retrieve avatar images for voice profiles.
- Introduced new API endpoints for avatar management, including upload and delete operations.
- Enhanced profile forms and components to support avatar image handling, including previews and error handling.
- Updated database schema to include avatar_path for profiles and added necessary migrations.
- Implemented image validation and processing utilities to ensure proper avatar uploads.
2026-01-29 19:28:42 -08:00
Jamie Pine ef3c3a7f8c Refactor ProfileForm for improved readability and maintainability
- Reorganized import statements for clarity.
- Enhanced conditional checks for restoring saved files with improved formatting.
- Streamlined draft saving logic by consolidating variable declarations.
- Updated UI components for better structure and readability in the form layout.
2026-01-29 18:56:22 -08:00
Jamie Pine 7b5e73cfa8 Add .npmrc for bun usage and update dependencies
- Created a new .npmrc file to enforce bun usage.
- Bumped version numbers for multiple packages to 0.1.9 in bun.lock.
- Added react-sound-visualizer dependency to enhance audio visualization features.
- Introduced convert:assets script in package.json for asset optimization.
- Updated CONTRIBUTING.md with instructions for converting assets to web formats.
- Added documentation files for API endpoints and developer guidelines in the docs directory.
2026-01-29 18:56:10 -08:00
Jamie Pine 462f104494 Enhance ProgressManager for thread safety and event loop integration
- Added thread-safe mechanisms to the ProgressManager for handling model download progress updates.
- Introduced a main event loop setter to ensure safe operations from background threads.
- Improved listener notification to handle updates in a thread-safe manner.
- Updated methods to ensure thread safety when accessing progress data.
2026-01-29 16:23:46 -08:00
Jamie Pine fadb57164e Update README to reflect API endpoint changes and enhance profile creation example
- Updated API endpoints from `/api/...` to `/...` for consistency.
- Modified the speech generation example to include a language parameter.
- Revised the profile creation example to use JSON format instead of form data.
2026-01-29 16:14:19 -08:00
Jamie Pine e870d65136 Add badges to README for downloads, releases, stars, and license 2026-01-29 16:09:10 -08:00
Jamie PineandGitHub 3df40278cc Merge pull request #5 from Snowy7/fix/dev-mode-sidecar
Fix dev mode sidecar and cross-platform HuggingFace cache paths
2026-01-29 16:05:00 -08:00
Jamie PineandGitHub deeef5a474 Merge pull request #12 from tomasmach/feat/makefile
feat: add Makefile for streamlined development workflow
2026-01-29 16:04:48 -08:00
Jamie Pine 236e464525 Bump version: 0.1.8 → 0.1.9 2026-01-29 15:58:31 -08:00
Jamie Pine cf3cf3f002 Enhance model download handling in useGenerationForm and ProgressManager
- Introduced a flag to track download status in useGenerationForm, ensuring proper UI updates during model downloads.
- Updated ProgressManager to only send initial progress updates if the model is actively downloading or extracting, preventing outdated status messages from being sent.
- Improved error handling and logging for better visibility into model download processes.
2026-01-29 15:57:53 -08:00
tomasmach 9d98e1e768 fix: improve Makefile robustness and update CONTRIBUTING docs
- Add exit 1 to test-backend when pytest not installed
- Add exit 1 to test-frontend when no test script configured
- Add venv dependency to db-init target
- Document Makefile usage in CONTRIBUTING.md
2026-01-30 00:51:41 +01:00
Jamie Pine 3be8980f48 Refactor ProfileForm to support draft state management and improve file handling
- Introduced functionality to save and restore form state as a draft when creating a new voice profile.
- Added helper functions for converting files to and from base64 format to facilitate file handling.
- Updated the API types to use a more flexible LanguageCode type for language parameters.
- Enhanced the UI store to manage profile form drafts, improving user experience during profile creation.
2026-01-29 15:45:14 -08:00
tomasmach 76bc070f5b docs: update CHANGELOG with Makefile feature 2026-01-30 00:44:10 +01:00
tomasmach 01838f4773 docs: add Makefile reference and setup instructions to README 2026-01-30 00:35:20 +01:00
tomasmach 39e4f9d08c fix: correct backend server port to match frontend expectations (17493) 2026-01-30 00:33:50 +01:00
Jamie Pine 341d71470c Implement auto-scroll feature in StoryTrackEditor to keep playhead centered during playback
- Added a useEffect hook to automatically scroll the timeline when the playhead moves past the halfway point of the visible area, enhancing user experience during playback.
2026-01-29 15:32:34 -08:00
Jamie Pine bb6cea24ba Refactor StoryTrackEditor to account for time ruler height during drag operations
- Introduced a constant for TIME_RULER_HEIGHT to improve code readability.
- Updated drag position calculations to subtract the time ruler height, ensuring accurate positioning of clips relative to the tracks area.
2026-01-29 15:31:23 -08:00
Jamie Pine fa7ac88abc Reset playback timing anchors in story store for fresh initialization by playback hook 2026-01-29 15:27:27 -08:00
Jamie Pine c68ddc45b1 Enhance contribution guidelines and improve FloatingGenerateBox component
- Updated CONTRIBUTING.md to include instructions for building with a local Qwen3-TTS development version, facilitating easier testing and development.
- Refactored FloatingGenerateBox component to streamline the rendering of text and instruct fields, improving code readability and maintainability.
- Added functionality to handle auto-resizing of text areas based on content changes, enhancing user experience.
- Improved event handling for keyboard interactions in StoryTrackEditor, allowing for play/pause functionality with the spacebar.
- Introduced a MiniSamplePlayer component in SampleList for better audio playback control, including play, pause, and seek features.
- Implemented sample update functionality in the backend, allowing users to edit reference text for audio samples, with appropriate error handling and user feedback.
2026-01-29 15:25:40 -08:00
tomasmach f89dc66d0c feat: add Python version fallback (3.12 > 3.13 > python3) and compatibility warning 2026-01-30 00:10:30 +01:00
tomasmach cba7d7bc23 feat: add Makefile for streamlined development workflow 2026-01-30 00:05:36 +01:00
Jamie PineandGitHub 3c89b068f3 Merge pull request #6 from jamiepine/windows-server-shutdown
Windows server shutdown
2026-01-29 03:12:40 -08:00
Jamie Pine 229841e05e Add GPU type information to health check response
- Updated the health check endpoint to include the type of GPU available (CUDA or MPS).
- Modified the HealthResponse model to accommodate the new gpu_type field, enhancing the response with detailed GPU information.
- This change improves the clarity of system capabilities for users and developers.
2026-01-29 03:12:11 -08:00
Jamie Pine 123e8215e4 Merge branch 'main' into windows-server-shutdown 2026-01-29 03:00:08 -08:00
Jamie Pine 2a3afec2ca Implement graceful shutdown for the server and enhance process management on Windows
- Added a new `/shutdown` endpoint to allow graceful server shutdown via HTTP.
- Implemented process tree management functions to handle child processes during shutdown on Windows.
- Updated the `stop_server` function to attempt graceful shutdown before forcefully terminating processes.
- Enhanced error handling and logging for shutdown operations.
2026-01-29 02:58:21 -08:00
Jamie Pine 99ddd5a0b4 Add asynchronous model download handling for TTS and Whisper models
- Implemented background tasks for downloading TTS and Whisper models to prevent blocking HTTP responses.
- Enhanced error handling during model downloads, providing users with real-time feedback on download status.
- Updated HTTP responses to indicate when models are being downloaded, improving user experience during model initialization.
2026-01-29 02:55:17 -08:00
Jamie Pine 8d730621bc Refactor model download handling to use background tasks
- Moved model download logic into a separate asynchronous function to allow non-blocking HTTP responses.
- Improved error handling by tracking download status and reporting errors without interrupting the main request flow.
- The frontend is now expected to poll the progress endpoint for download status updates.
2026-01-29 02:42:02 -08:00
Jamie Pine e23118f610 Bump version: 0.1.7 → 0.1.8 2026-01-29 02:21:01 -08:00
Jamie Pine d4bfdc0d68 Update version handling in backend and improve HuggingFace cache management
- Added __version__ variable in backend/__init__.py to centralize versioning.
- Updated main.py to use __version__ for API versioning in the FastAPI app.
- Enhanced cache directory handling by utilizing HuggingFace's constants for improved compatibility across platforms.
2026-01-29 02:20:33 -08:00
Jamie Pine 116c108906 Update screenshot asset in landing page for consistency with current design 2026-01-29 00:06:06 -08:00
Jamie Pine 2d23c8e06a Swap screenshot assets in landing page for improved visual representation
- Replaced app screenshot paths to ensure correct images are displayed.
- Adjusted alt text for screenshots to accurately reflect their content.
2026-01-29 00:06:00 -08:00
Jamie Pine 3973a59ba3 Revise README to clarify Voicebox features and benefits
- Changed section title from "Why Voicebox?" to "What is Voicebox?" for better clarity.
- Expanded description to emphasize local-first voice cloning capabilities and professional tools.
- Highlighted privacy, model flexibility, and native performance as key advantages over cloud services.
2026-01-28 23:59:47 -08:00
Jamie Pine d9aa75253a Enhance README with new features and multi-track editor details
- Added multi-sample support for higher quality cloning.
- Introduced a new Stories Editor section with features for multi-track composition, inline audio editing, auto-playback, and voice mixing.
- Updated recording section to include system audio capture for macOS and Windows.
2026-01-28 23:55:44 -08:00
Jamie Pine b22bf36565 Update README and landing page with new screenshots; bump version to 0.1.7
- Replaced existing screenshot paths in README and landing page with new assets.
- Added additional screenshots to the landing page for enhanced visual representation.
- Updated version in Cargo.lock from 0.1.6 to 0.1.7.
2026-01-28 23:51:43 -08:00
Jamie Pine 33f4ed9b44 Bump version: 0.1.6 → 0.1.7 2026-01-28 22:28:18 -08:00
Jamie Pine cc37e04221 Refactor HistoryTable and SampleList components for improved code consistency
- Cleaned up formatting in HistoryTable for better readability.
- Adjusted import statements in SampleList to maintain consistent structure.
2026-01-28 22:28:01 -08:00
Jamie Pine 2b4fbe5173 Refactor AudioPlayer and related components to support conditional auto-play functionality
- Updated AudioPlayer to auto-play only if the shouldAutoPlay flag is set, enhancing user control over playback.
- Refactored HistoryTable, SampleList, and useGenerationForm to utilize setAudioWithAutoPlay for consistent audio loading and playback behavior.
- Improved user experience by ensuring audio is only played when explicitly intended, reducing unexpected playback.
2026-01-28 22:27:37 -08:00
Snowy 423d69b7cc Use HuggingFace's built-in cache detection for cross-platform support
Replace hardcoded ~/.cache/huggingface/hub paths with
huggingface_hub.constants.HF_HUB_CACHE which correctly handles
OS-specific cache locations (Windows uses AppData, etc.)
2026-01-29 09:25:41 +03:00
Jamie Pine ea943876dc formatting 2026-01-28 22:23:27 -08:00
Jamie Pine b55d8cc567 Implement auto-activation of stories in StoryTrackEditor and improve playback state management
- Added useEffect to automatically activate the story when the editor is shown, ensuring the playhead is visible.
- Introduced setActiveStory function in storyStore to manage story activation without playback.
- Updated playback state checks to reflect the current playing status accurately.
- Enhanced UI to always display the playhead for better user experience during playback.
2026-01-28 22:22:47 -08:00
Jamie Pine 036d90dc8e Enhance story item management with trimming, splitting, and duplication features
- Updated StoryTrackEditor and StoryContent components to support trimming and splitting of story items.
- Introduced new API endpoints for trimming, splitting, and duplicating story items, enhancing item management capabilities.
- Refactored related hooks and state management to accommodate new functionalities.
- Improved data models to include trim start and end times for better audio playback control.
- Enhanced UI interactions for selecting and managing story items within the track editor.
2026-01-28 22:16:53 -08:00
Snowy c513451277 Fix dev mode to work without pre-built server binary
Previously, running `bun run dev` would fail because Tauri requires
the sidecar binary to exist at compile time, even in development mode.
This forced developers to build the full PyInstaller binary before
they could start development.

This change introduces a streamlined dev workflow:

1. Add `scripts/setup-dev-sidecar.js` - Creates minimal placeholder
   binaries that satisfy Tauri's compile-time check. Works cross-platform
   (Windows PE stub, Unix shell script).

2. Update Rust code to gracefully handle dev mode - When the sidecar
   fails to start, it checks if a manually-started server is already
   running on the expected port and connects to it instead.

3. Update npm scripts - `bun run dev` now auto-runs the setup script,
   and `dev:server` uses the correct port (17493).

4. Update CONTRIBUTING.md with clearer dev workflow documentation.

New development workflow:
  Terminal 1: bun run dev:server
  Terminal 2: bun run dev

The bundled binary is only required for production builds.
2026-01-29 09:11:41 +03:00
Jamie PineandGitHub 27ae6dfbab Merge pull request #3 from jamiepine/stories
Stories
2026-01-28 21:18:19 -08:00
Jamie Pine 51b9e2fd3d Bump version: 0.1.5 → 0.1.6 2026-01-28 20:48:48 -08:00
Jamie Pine be25ddbe0e Refactor FloatingGenerateBox for improved code organization and readability
- Cleaned up import statements for better structure and consistency.
- Adjusted formatting and spacing in the FloatingGenerateBox component for enhanced readability.
- Streamlined the use of hooks and state management within the component.
- Ensured consistent styling and layout adjustments for better user experience.
2026-01-28 20:48:45 -08:00
Jamie Pine 9cd4921291 Enhance FloatingGenerateBox with auto-resizing textarea and default voice selection
- Added auto-resizing functionality to the textarea in FloatingGenerateBox, improving user experience when inputting text.
- Implemented logic to set the first voice profile as default if none is selected, ensuring a smoother workflow.
- Updated StoryContent to remove hardcoded height for the generate box, simplifying layout calculations.
- Refactored StoryTrackEditor to improve background styling for better visual consistency.
2026-01-28 20:48:13 -08:00
Jamie Pine 2349bd24ba Enhance FloatingGenerateBox and StoryContent with new features and improved UI
- Refactored FloatingGenerateBox to improve layout and ensure consistent styling for the voice selector.
- Added a popover component to StoryContent for adding generations, including search functionality for better user experience.
- Implemented story item editing and deletion capabilities in StoryList, enhancing story management features.
- Updated import statements and added new hooks for better organization and functionality across components.
2026-01-28 20:39:09 -08:00
Jamie Pine cd82ed0664 Refactor FloatingGenerateBox and StoriesTab for improved layout and interaction
- Adjusted FloatingGenerateBox positioning to align with the story list, ensuring consistent UI across different routes.
- Modified StoriesTab layout to enhance responsiveness, including setting a maximum width for the story list and adjusting the right column for better content display.
- Streamlined StoryChatItem interaction by simplifying the play functionality, allowing double-click to trigger playback directly from the text area.
- Enhanced StoryContent component by cleaning up unused playback controls and improving overall structure for better readability.
2026-01-28 20:31:17 -08:00
Jamie Pine c4884a0443 Enhance StoryContent and StoryTrackEditor for improved playback and UI dynamics
- Added auto-scrolling functionality to StoryContent for the currently playing item, enhancing user experience during playback.
- Refactored StoryTrackEditor to dynamically calculate container width, ensuring proper layout for varying story lengths.
- Updated audio playback management to improve timing anchor handling and playback scheduling.
- Cleaned up import statements for better organization and readability across components.
2026-01-28 20:15:59 -08:00
Jamie Pine 232d231788 Refactor story management components and enhance track editor integration
- Updated AppFrame to conditionally render StoryTrackEditor based on the selected story and route.
- Modified FloatingGenerateBox to adjust its position based on the visibility of the track editor.
- Improved StoriesTab by removing direct track editor rendering and relying on the new store state for height management.
- Enhanced StoryContent to dynamically calculate bottom padding based on the track editor's height.
- Introduced trackEditorHeight state in storyStore for better UI management of the track editor's visibility and size.
2026-01-28 19:50:46 -08:00
Jamie Pine 1cf90c81dd Enhance story item management with track editing functionality
- Introduced StoryTrackEditor component for managing story item positions and tracks.
- Updated StoriesTab to conditionally render the track editor based on selected story.
- Implemented moveStoryItem API endpoint to handle item repositioning and track changes.
- Enhanced story item data model to include track information.
- Improved audio playback management to support multiple tracks using Web Audio API.
- Added hooks for moving story items and managing playback timing.
2026-01-28 19:35:53 -08:00
Jamie Pine 3204e193fa Implement story management features and update dependencies
- Introduced story management functionality, including creating, listing, and managing story items.
- Added new components for story display and interaction, including StoriesTab, StoryList, and StoryContent.
- Integrated drag-and-drop functionality for reordering story items using @dnd-kit.
- Updated dependencies for @dnd-kit packages to enhance drag-and-drop capabilities.
- Bumped version for @voicebox/app, @voicebox/landing, @voicebox/tauri, and @voicebox/web to 0.1.5.
- Enhanced audio playback features to support story mode with auto-play functionality.
- Improved error handling and user feedback through toast notifications in story-related actions.
2026-01-28 19:10:58 -08:00
Jamie Pine 9d5d6cb56a Update UpdateStatus component logic and bump voicebox version to 0.1.5
- Modified conditional rendering in UpdateStatus to display content when the status is ready to install, improving user feedback.
- Bumped voicebox package version from 0.1.4 to 0.1.5 for dependency updates.
2026-01-28 15:14:23 -08:00
Jamie Pine 153eaba5f3 Refactor UpdateStatus component for improved UI and code organization
- Adjusted the order of import statements for better readability.
- Updated the conditional rendering logic to display a different message when an update is not ready to install, enhancing user feedback.
2026-01-28 15:14:05 -08:00
Jamie Pine 3370e3b419 Bump version: 0.1.4 → 0.1.5 2026-01-28 14:38:43 -08:00
Jamie Pine 615bd188a0 Refactor import statements in SampleUpload component for improved readability
- Adjusted the order of imports in SampleUpload.tsx to follow a more conventional structure, enhancing code organization.
2026-01-28 14:38:31 -08:00
Jamie Pine 7208f51eee Refactor audio generation components and improve debugging capabilities
- Introduced useGenerationForm hook to streamline audio generation form handling, including validation and model download management.
- Updated FloatingGenerateBox and GenerationForm components to utilize the new hook, enhancing code organization and reducing duplication.
- Replaced console logging with a debug utility for better logging control during audio playback and generation processes.
- Improved error handling in HistoryTable and MainEditor components by integrating toast notifications for user feedback.
- Adjusted audio recording duration limits across various components for consistency.
2026-01-28 14:30:33 -08:00
Jamie Pine 07a91a2381 Update app version to 0.1.4 and integrate @tanstack/react-router for improved routing functionality
- Bumped version for @voicebox/app, @voicebox/landing, @voicebox/tauri, and @voicebox/web to 0.1.4.
- Added @tanstack/react-router dependency for enhanced routing capabilities.
- Refactored App component to utilize RouterProvider for routing management.
- Created a new router configuration in router.tsx to define application routes and layout.
- Updated Sidebar component to use Link from @tanstack/react-router for navigation.
2026-01-28 00:00:18 -08:00
Jamie Pine 70cc36857d Bump version: 0.1.3 → 0.1.4 2026-01-27 17:25:15 -08:00
Jamie PineandGitHub 7f66d02591 Merge pull request #2 from jamiepine/channels
Channels
2026-01-27 17:23:58 -08:00
Jamie Pine 9f7a5a492e Refactor ConnectionForm and Checkbox component for improved functionality and UI
- Updated ConnectionForm to utilize a Checkbox component for managing the "keep server running" setting, enhancing user interaction.
- Refactored Checkbox component to use a button element for better accessibility and visual feedback.
- Streamlined import statements and improved code organization across multiple components for better readability.
2026-01-27 17:23:13 -08:00
Jamie Pine cb44377b09 Remove CheckCircle2 icon from various components for a cleaner UI
- Eliminated CheckCircle2 icon from ModelManagement, ModelProgress, ServerStatus, and UpdateStatus components to streamline the visual presentation.
- Updated import statements accordingly to reflect the removal of unused icons.
2026-01-27 17:14:17 -08:00
Jamie Pine a42a946586 Refactor UI components for improved layout consistency and responsiveness
- Updated App and AudioTab components to utilize TOP_SAFE_AREA_PADDING and BOTTOM_SAFE_AREA_PADDING constants for better layout adjustments.
- Enhanced HistoryTable and ModelManagement components for improved visual consistency.
- Streamlined import statements and component structure across various files for better organization and readability.
2026-01-27 17:12:16 -08:00
Jamie Pine a3cbe7f2b6 Refactor UI layout and introduce safe area constants for improved responsiveness
- Updated App, AppFrame, and AudioTab components to utilize new TOP_SAFE_AREA_PADDING and BOTTOM_SAFE_AREA_PADDING constants for consistent layout adjustments.
- Enhanced Sidebar and MainEditor components for better organization and user experience.
- Improved VoicesTab and ProfileList components by integrating new layout features and removing redundant import functionality.
- Streamlined HistoryTable and ModelManagement components for better visual consistency and interaction.
2026-01-27 17:11:38 -08:00
Jamie Pine d8d9eeaa6a Refactor App layout and introduce new components for improved organization
- Replaced the main layout in App component with AppFrame for better structure.
- Introduced MainEditor component to encapsulate the main editing interface, including ProfileList and HistoryTable.
- Added ModelsTab component to manage model-related functionalities.
- Updated Sidebar to include a new Models tab for navigation.
- Removed unused components and streamlined the layout for enhanced user experience.
2026-01-27 16:38:37 -08:00
Jamie Pine f7cb219f6d Refactor AudioPlayer for improved native playback handling and debugging
- Introduced a stop flag mechanism to manage audio playback more effectively.
- Enhanced native playback logic to ensure proper stopping of existing streams before starting new playback.
- Updated error handling and logging for better visibility during playback operations.
- Refactored audio output handling in Rust to support stopping playback and outputting silence when required.
- Improved the integration of native playback with WaveSurfer for seamless audio visualization.
2026-01-27 16:25:38 -08:00
Jamie Pine 7f18c09628 Enhance AudioPlayer component for native playback and debugging
- Improved the useNativePlayback logic to include detailed console logging for better debugging.
- Updated auto-play functionality to fetch runtime profile channels and channels, ensuring accurate playback decisions.
- Refactored audio playback handling to support native audio routing with enhanced error handling and logging.
- Introduced a new MultiSelect component for improved channel selection in VoicesTab.
- Updated FloatingGenerateBox to include selectedProfileId in audio setting.
- Added new dependencies for audio processing in Cargo.toml and Cargo.lock.
2026-01-27 16:15:16 -08:00
Jamie Pine d9c7121c5b Merge branch 'main' into channels 2026-01-27 15:24:18 -08:00
Jamie Pine 008b58f91c put back gen files for now 2026-01-27 14:08:30 -08:00
Jamie Pine c7404411d5 Bump version: 0.1.2 → 0.1.3 2026-01-27 13:46:21 -08:00
Jamie Pine ac08c4fcf4 Remove unused .gitkeep files and adjust ProfileList component padding
- Deleted .gitkeep files from Generation, ServerSettings, VoiceProfiles, and hooks directories as they are no longer necessary.
- Updated the ProfileList component to increase bottom padding for improved layout consistency.
2026-01-27 13:46:08 -08:00
Jamie Pine 3ce7498495 Remove unused schema files and update .gitignore
- Deleted several JSON schema files related to capabilities and desktop configurations that are no longer needed.
- Updated .gitignore to exclude the entire generated assets directory instead of a specific file, improving version control management.
2026-01-27 13:42:35 -08:00
Jamie Pine ce2f09d29e Refactor useAutoUpdater hook for improved readability and update handling
- Rearranged import statements for better organization.
- Enhanced download progress handling logic for clarity and consistency.
- Ensured proper state updates during the download process, including setting download progress to 100% upon completion.
2026-01-27 13:18:33 -08:00
Jamie Pine f1be633dca Implement FloatingGenerateBox component and update App layout
- Introduced the FloatingGenerateBox component for audio generation, enhancing user interaction with voice profiles.
- Updated App component to integrate FloatingGenerateBox and removed the GenerationForm component.
- Enhanced the layout for better responsiveness and added functionality to manage audio playback state.
- Updated UpdateStatus component to include new update handling logic and improved UI feedback for update readiness.
2026-01-27 13:18:12 -08:00
Jamie Pine 5f58c4dc3d Refactor VoiceProfiles components and remove ProfileDetail
- Removed the ProfileDetail component to streamline the ProfileCard functionality.
- Updated ProfileCard to eliminate the detail view and associated state management.
- Enhanced ProfileForm to manage audio samples more effectively, including improved UI for sample management.
- Adjusted SampleList to ensure proper button types for better accessibility.
2026-01-26 22:28:29 -08:00
Jamie Pine 892f363e3a Update development server port and enhance GPU status reporting
- Changed the development server port in package.json from 17493 to 8000 for dev.
- Improved GPU availability checks in main.py to include support for MPS on Apple Silicon.
- Removed the Assets.car file from version control as it is no longer needed.
- Updated .gitignore to reflect the removal of Assets.car.
2026-01-26 22:18:59 -08:00
Jamie Pine 535cf362de Bump version: 0.1.1 → 0.1.2 2026-01-26 21:04:11 -08:00
Jamie Pine f1116e05a6 Merge branch 'main' of https://github.com/jamiepine/voicebox 2026-01-26 21:01:55 -08:00
Jamie Pine f9aca9d418 Update development server port in package.json
- Changed the port for the development server from 8000 to 17493 to avoid conflicts and improve accessibility during local development.
2026-01-26 21:01:27 -08:00
Jamie Pine d913a9ae2a Implement audio format conversion and enhance recording completion handling
- Added a new utility function to convert audio blobs to WAV format, ensuring compatibility without requiring ffmpeg on the backend.
- Updated the useAudioRecording hook to convert recorded audio from WebM to WAV upon completion, with error handling for conversion failures.
- Improved the organization of imports in useAudioRecording for better readability.
2026-01-26 20:41:18 -08:00
Jamie Pine 36031a0df5 Enhance audio capture functionality and update dependencies
- Added support for capturing system audio on Windows using WASAPI with improved error handling and thread safety.
- Introduced the 'scopeguard' crate for better resource management during audio capture.
- Updated Cargo.toml to include 'scopeguard' and modified Windows-specific dependencies for enhanced functionality.
- Added a new test for validating audio capture output, ensuring the captured audio data is valid and non-empty.
2026-01-26 20:40:10 -08:00
Jamie Pine e59f86aa63 Refactor audio capture error handling and cleanup logic
- Removed console logging from the useSystemAudioCapture hook to streamline the code.
- Introduced error handling in the audio capture state to capture and report errors more effectively.
- Updated the cleanup logic to ensure proper handling of errors during audio capture on unmount.
- Enhanced error messages for better clarity when audio capture fails.
2026-01-26 20:12:37 -08:00
Jamie Pine b59c0f44e5 Enhance audio capture functionality in useSystemAudioCapture hook
- Added isRecordingRef to track recording state more reliably.
- Implemented console logging for key actions in startRecording and cancelRecording functions to aid in debugging.
- Updated cleanup logic on component unmount to ensure proper cancellation of recording if still active.
- Refactored condition checks to utilize isRecordingRef for improved performance and clarity.
2026-01-26 20:02:39 -08:00
Jamie Pine f8c5e54962 Add audio input entitlement and enhance audio sample extraction logic
- Added the `com.apple.security.device.audio-input` entitlement to the Entitlements.plist for improved audio capture capabilities.
- Refactored the audio sample extraction logic in macOS to handle both interleaved and planar audio formats, improving sample processing and interleaving of channels.
- Updated the Assets.car file to reflect changes in the audio capture implementation.
2026-01-26 19:39:09 -08:00
Jamie Pine 8b67faf96d Enhance update status display and audio sample components
- Improved the UpdateStatus component to show download progress and total bytes downloaded during updates.
- Refactored AudioSampleRecording and AudioSampleSystem components for cleaner button rendering and consistent layout.
- Updated import order in SampleUpload component for better organization.
2026-01-26 19:22:43 -08:00
Jamie Pine 30ea627ae8 Implement audio channel management features
- Added new components for managing audio channels, including creation, updating, and deletion of channels.
- Introduced a new AudioTab for channel management and integrated it into the main application layout.
- Updated the API client to support audio channel operations and added corresponding backend endpoints.
- Enhanced the player store to handle audio playback routing through assigned channels.
- Refactored existing components to accommodate the new audio channel functionality, including updates to the HistoryTable and GenerationForm for profile-channel associations.
- Improved sidebar navigation to include new tabs for Voices and Audio management.
2026-01-26 19:20:20 -08:00
Jamie Pine cd77b80b4f Refactor Windows audio capture implementation
- Introduced AtomicBool for stop signal handling to improve thread safety.
- Updated audio capture logic to utilize WASAPI more effectively, including error handling and buffer management.
- Enhanced the spawn mechanism for audio capture tasks to ensure compatibility with non-Send types.
- Added a new dependency on the 'windows' crate in Cargo.lock for improved functionality.
2026-01-26 18:31:08 -08:00
Jamie Pine 12174010f9 Fix Windows audio capture API compatibility with wasapi 2026-01-26 17:56:18 -08:00
Jamie Pine 57c68040bc Remove bumpversion dependency from backend requirements 2026-01-26 17:41:25 -08:00
Jamie Pine d65704aa68 Enhance icon generation and update dependencies
- Added support for generating a multi-size Windows icon (icon.ico) in the update-icons.sh script.
- Updated Cargo.toml to include the 'windows' crate with specific features for Windows support.
- Bumped version of the 'voicebox' package from 0.1.0 to 0.1.1 in Cargo.lock.
- Updated .gitignore to exclude the generated Assets.car file.
2026-01-26 17:39:02 -08:00
Jamie Pine 83a6aca1ff Bump version: 0.1.0 → 0.1.1 2026-01-26 17:30:05 -08:00
Jamie Pine f18abc0da6 Add .bumpversion.cfg for version management
- Introduced .bumpversion.cfg to automate versioning across multiple files.
- Configured version updates for tauri.conf.json, Cargo.toml, and various package.json files.
- Set up commit and tag generation for new releases, streamlining the release process.
2026-01-26 17:29:06 -08:00
Jamie Pine 0f616ff1c1 Update CONTRIBUTING.md to include bumpversion instructions and enhance release process clarity; add bumpversion to backend requirements 2026-01-26 17:26:50 -08:00
Jamie Pine 7c4b1d4dd2 Remove UpdateNotification component and its usage in App.tsx
- Deleted the UpdateNotification component to streamline the application.
- Removed its invocation from the App component, enhancing the overall code clarity.
- Updated Assets.car file to reflect changes in the application structure.
2026-01-26 17:23:18 -08:00
Jamie Pine 48acc10422 Refactor Tauri server management and improve logging
- Updated the server management logic in main.rs to include the PID of the existing voicebox server when reusing it, enhancing clarity in logging.
- Improved the formatting of imports in App.tsx for better readability.
2026-01-26 17:20:46 -08:00
Jamie Pine 1fdf61ca2e Implement server reuse logic and improve build script formatting
- Added logic in main.rs to check for an existing voicebox server running on the designated port, allowing reuse of the server if found.
- Enhanced the build.rs script by improving formatting and readability of the Swift library path definitions.
- Updated logging to provide clearer warnings when the icon source is not found during the build process.
2026-01-26 17:17:57 -08:00
Jamie Pine 6a0601bd6c Update server URL handling and improve logging
- Changed the server URL from 'http://localhost:8000' to 'http://127.0.0.1:17493' in the server store and connection form.
- Enhanced server startup logging to display the dynamically assigned server URL.
- Updated server management logic in main.rs to reflect the new port configuration and improve orphaned process handling.
2026-01-26 17:13:13 -08:00
Jamie Pine 88d41f342b Implement server running preference and cleanup on exit
- Added `setKeepServerRunning` function to manage server persistence on app close.
- Integrated server running preference in `ConnectionForm` and synced settings on app startup.
- Enhanced server management in `main.rs` to handle orphaned processes based on user preference.
- Improved cleanup logic to ensure proper termination of server processes when not set to keep running.
2026-01-26 17:02:00 -08:00
Jamie Pine b1cf7926c7 Add audio sample components for recording, system capture, and upload
- Introduced `AudioSampleRecording`, `AudioSampleSystem`, and `AudioSampleUpload` components to handle audio recording, system audio capture, and file uploads respectively.
- Implemented play/pause functionality for audio playback across all components, enhancing user interaction.
- Refactored `ProfileForm` and `SampleUpload` to utilize new audio components, improving code organization and maintainability.
- Added hooks for audio playback management, ensuring consistent audio handling and cleanup across the application.
2026-01-26 16:44:49 -08:00
Jamie Pine 834323068d Enhance audio sample handling in ProfileForm and SampleUpload components
- Introduced play/pause functionality for audio samples, allowing users to preview uploaded audio files.
- Added drag-and-drop support for file uploads, improving user experience when selecting audio files.
- Refactored audio validation and error handling to ensure proper feedback for audio file requirements.
- Updated UI elements for better clarity and consistency in audio file management.
2026-01-26 16:38:52 -08:00
Jamie Pine 8cd868d33f Enhance audio recording functionality to improve duration handling
- Updated getAudioDuration function to utilize recordedDuration property for files, addressing metadata issues on Windows.
- Modified onRecordingComplete callbacks in audio recording hooks to pass the actual recorded duration.
- Adjusted error handling in ProfileForm and SampleUpload components to clear validation errors for recorded files.
- Ensured consistent handling of audio file duration across components.
2026-01-26 16:22:31 -08:00
Jamie Pine 446182e16c macos audio capture for sample creation 2026-01-26 16:05:42 -08:00
Jamie Pine c7c401b98c Refactor getLatestRelease function to improve file filtering for downloads
- Added logic to skip non-downloadable files such as signature, JSON, and text files.
- Updated conditions for identifying downloadable files for macOS, Windows, and Linux platforms to use `endsWith` for better accuracy.
2026-01-26 01:58:06 -08:00
Jamie Pine 595d735143 Update landing page to remove Linux support and adjust platform descriptions
- Modified metadata and various components to reflect the removal of Linux support, focusing on macOS and Windows.
- Updated descriptions in the layout, page, and footer components for consistency.
- Adjusted download links and icons to align with the new platform availability.
2026-01-26 01:56:44 -08:00
Jamie Pine 85935c1bbb Update HistoryTable component to improve user feedback for empty history state
- Modified the message displayed when there are no voice generations to be more concise and user-friendly.
- Adjusted the styling of the empty state message for better visual appeal.
2026-01-26 01:17:59 -08:00
Jamie Pine 8058360744 Update README.md to enhance demo video visibility
- Wrapped the app screenshot in a link to the demo video on voicebox.sh for better accessibility.
- Added a descriptive text below the image to encourage users to click and watch the demo video.
2026-01-26 01:11:59 -08:00
Jamie Pine 47e4da7ce2 Update date formatting logic and replace binary assets
- Enhanced the formatDate function to handle date strings without timezone information by treating them as UTC.
- Updated binary assets including screenshots and application images for improved visual representation.
2026-01-26 01:10:22 -08:00
Jamie Pine b7ab4410a6 Update README.md to reflect new download links and remove Linux support
- Updated download links for macOS and Windows, including new file formats and naming conventions.
- Removed Linux download option, with a note indicating that Linux builds are coming soon due to GitHub runner disk space limitations.
2026-01-26 00:58:14 -08:00
Jamie Pine afd0381243 Update landing page metadata and reintroduce Header component
- Changed the title in metadata to reflect the open-source nature of the app.
- Reorganized the import statements to include the Header component for better structure.
- Streamlined the apple touch icon configuration in metadata.
2026-01-26 00:52:03 -08:00
Jamie Pine 2ceccaec51 Refactor global styles to utilize Tailwind CSS directives
- Replaced direct imports of Tailwind CSS with @tailwind directives for base, components, and utilities.
- Improved organization of global styles for better maintainability and adherence to Tailwind CSS conventions.
2026-01-26 00:45:03 -08:00
Jamie Pine 551abc9856 Enhance history management with export and import functionalities
- Added endpoints for exporting generations as ZIP archives and audio files.
- Implemented import functionality for generations from ZIP archives with validation.
- Updated HistoryTable component to support new export and import features.
- Improved error handling and user notifications for export/import processes.
- Refactored related hooks and API client methods to accommodate new functionalities.
2026-01-26 00:30:37 -08:00
Jamie Pine 333cb262e0 Add voice profile import functionality with file size validation
- Implemented a new endpoint to import voice profiles from ZIP archives.
- Added file size validation to ensure uploads do not exceed 100MB.
- Enhanced error handling for various exceptions during the import process.
- Cleaned up the import function by removing the previous implementation.
2026-01-26 00:08:27 -08:00
Jamie Pine 04bc1aded4 Implement active task management for downloads and generations, enhancing user experience with toast notifications for ongoing tasks. Refactor language handling in forms to support multiple languages. Update audio player to manage restart functionality and improve sidebar icon representation. Adjust progress tracking for model downloads in the backend. 2026-01-26 00:00:00 -08:00
Jamie Pine b2659e6a6d Refactor App and Sidebar components to support macOS, add TitleBarDragRegion for improved window dragging, and enhance model management with delete functionality and download progress tracking. Update audio player to handle audio resets more effectively and improve generation form with model download notifications. 2026-01-25 23:25:21 -08:00
Jamie Pine 090b1f6dde Add granular import logging and increase timeout to 120s 2026-01-25 22:25:27 -08:00
Jamie Pine d943e1d6d4 Remove all module exclusions 2026-01-25 22:01:57 -08:00
Jamie Pine d1273c3d33 Remove aggressive module exclusions - torch and stdlib need them 2026-01-25 22:01:38 -08:00
Jamie Pine 1ce62e8b15 Enhance contribution guidelines and remove outdated setup documentation
- Updated CONTRIBUTING.md with detailed setup instructions for Bun, Python, and Rust.
- Removed SETUP.md as its content is now integrated into CONTRIBUTING.md.
- Adjusted README.md to reference the new contribution guidelines.
- Improved the HistoryTable component for better accessibility and user interaction.
- Updated global styles and landing page elements for improved aesthetics and functionality.
2026-01-25 21:50:54 -08:00
Jamie Pine 240b9b71a7 Remove torch.testing exclusion - needed by torch internals 2026-01-25 21:44:12 -08:00
Jamie Pine 9396c6c86d better docs 2026-01-25 21:42:38 -08:00
Jamie Pine 82431dc5f5 Update demo video link in README.md to direct users to the official website for better accessibility 2026-01-25 21:33:36 -08:00
Jamie Pine f6e5111f68 Replace demo video section in README.md with a clickable image link for improved user experience 2026-01-25 21:30:40 -08:00
Jamie Pine 658e967558 Update demo video source in README.md to use the correct GitHub raw URL for improved accessibility 2026-01-25 21:29:35 -08:00
Jamie Pine 777e73f195 Update demo video source in README.md to use the raw GitHub URL for improved accessibility 2026-01-25 21:28:26 -08:00
Jamie Pine c7004d5776 Add demo video section to README and landing page
- Introduced a new section in README.md showcasing a demo video for Voicebox.
- Added a demo video section in the landing page with responsive design and improved aesthetics.
- Updated video source paths for consistency across the application.
2026-01-25 21:27:15 -08:00
Jamie Pine 5d731d900b Simplify jaraco imports - use collect-submodules only 2026-01-25 21:23:31 -08:00
Jamie Pine fd89831d83 Fix PyInstaller missing jaraco dependencies 2026-01-25 21:14:03 -08:00
Jamie Pine b4b3762ef0 Update global styles and enhance landing page layout
- Added overflow-x hidden to prevent horizontal scrolling on html and body elements.
- Centered the heading and paragraph text on the landing page for improved aesthetics.
- Introduced a mobile-friendly centered screenshot above download buttons.
- Updated comments for clarity regarding screenshot positioning on different devices.
2026-01-25 21:06:41 -08:00
Jamie Pine af2f37ccb1 Enhance server startup logging and error handling
- Implemented detailed logging for server startup in server.py, including Python version, executable path, and parsed arguments.
- Added error handling for module imports and server initialization to improve robustness.
- Introduced an Entitlements.plist file for macOS to manage security settings.
- Updated tauri.conf.json to reference the new Entitlements.plist.
- Enhanced error reporting in main.rs for better debugging during server process management.
2026-01-25 20:55:13 -08:00
Jamie Pine 5feda1519c Add Apple API key installation and codesigning certificate setup in release workflow
- Introduced steps to install the Apple API key and codesigning certificate for macOS platforms.
- Enhanced the release workflow to support secure handling of Apple credentials for code signing.
- Updated environment variables to include necessary Apple signing information for Tauri builds.
2026-01-25 20:09:15 -08:00
Jamie Pine e56bfdc694 Update download links in constants.ts to use versioned filenames for consistency with release structure 2026-01-25 20:07:02 -08:00
Jamie Pine caff18dabe Update download links and repository URL in constants.ts to reflect the correct GitHub username 2026-01-25 19:47:38 -08:00
Jamie Pine 36bd2d2656 Update README.md to change link text for consistency 2026-01-25 19:37:25 -08:00
Jamie Pine 9ca0f43afb Update README.md 2026-01-25 19:34:36 -08:00
Jamie Pine c14fb937ef Revamp README.md for improved clarity and presentation
- Updated the README to feature a new layout with centered headings and images for better visual appeal.
- Enhanced the introduction to clearly define Voicebox as an open-source voice synthesis studio.
- Added sections for API usage, tech stack, and roadmap to provide comprehensive information about the project.
- Removed outdated content and streamlined the structure for easier navigation and understanding.
2026-01-25 19:32:30 -08:00
Jamie Pine 7d43938b49 Update application assets and modify screenshot references
- Replaced the old AppScreenshot.webp with a new VoiceBoxAppScreenshot.webp.
- Removed the obsolete AppScreenshot.webp file.
- Updated the landing page to reference the new VoiceBoxAppScreenshot.webp.
- Commented out the ReactQueryDevtools import in main.tsx for cleaner code.
2026-01-25 19:30:11 -08:00
Jamie Pine a7463968e4 Add creator attribution to settings tab in App component 2026-01-25 19:15:33 -08:00
Jamie Pine d9de8f04f2 Refactor components and implement generation state management
- Updated import order in App component for consistency.
- Enhanced Sidebar component with loading indicator for audio generation state.
- Integrated generation state management using Zustand in GenerationForm and Sidebar.
- Improved ProfileList component formatting for better readability.
- Added new generationStore for managing audio generation state across components.
2026-01-25 19:13:23 -08:00
Jamie Pine 1f075c1c15 Enhance App component with loading messages and UI improvements
- Added a loading message feature that cycles through various messages while the server is starting in Tauri.
- Improved the loading screen UI with a new layout and animations for the voicebox logo and loading text.
- Refactored the App component to include necessary imports and state management for loading messages.
- Updated styles for better visual appeal and user experience during the loading phase.
2026-01-25 19:07:21 -08:00
Jamie Pine 9125a4abe0 Add profile export and import functionality
- Implemented API endpoints for exporting and importing voice profiles as ZIP archives.
- Enhanced the frontend with new hooks and components for profile export and import, including file handling and user dialogs.
- Integrated Tauri plugins for file system access and dialog interactions to facilitate seamless user experience.
- Updated ProfileCard and ProfileList components to support new export and import features, improving overall functionality.
- Added necessary error handling and validation for file operations to ensure robustness.
2026-01-25 19:00:16 -08:00
Jamie Pine c62f615162 Implement sample audio retrieval and update playback functionality
- Added a new API endpoint to serve profile sample audio files.
- Introduced a method in the apiClient to generate sample audio URLs.
- Refactored the SampleList component to utilize the new API for audio playback, enhancing the user experience.
- Cleaned up unused imports and optimized the handlePlay function for better performance.
2026-01-25 18:44:25 -08:00
Jamie Pine 6acad47335 Enhance GenerationForm to autoplay generated audio
- Integrated audio playback functionality by utilizing the apiClient to fetch audio URLs.
- Updated the GenerationForm to set audio state with the generated audio details after successful generation.
- Improved user experience by automatically playing the generated audio upon completion.
2026-01-25 18:33:41 -08:00
Jamie Pine 75520e0c29 Refactor landing page layout and update assets
- Replaced the Hero component with a new section layout for improved structure and responsiveness.
- Updated download buttons for macOS, Windows, and Linux with enhanced styling.
- Added a new application screenshot in WebP format and removed the old App.webp asset.
- Adjusted Header component to capitalize the application name for consistency.
- Minor formatting improvements in HistoryTable for better readability.
2026-01-25 18:14:36 -08:00
Jamie Pine e6a05f7208 Update DownloadSection to link to macArm download for improved compatibility 2026-01-25 17:54:00 -08:00
Jamie Pine 57880fc2c7 Update Tauri configuration for autoupdater with new signing key
- Replaced the existing public key in the updater configuration with a new key for enhanced security.
- Maintained the endpoint for fetching update information from the GitHub releases.
2026-01-25 17:47:18 -08:00
Jamie Pine dc44a128de Enhance App UI with logo and animations
- Added a voicebox logo to the loading screen in the App component for improved branding.
- Introduced fade-in animations for the logo and loading text to enhance user experience.
- Updated Sidebar component styles for better visual consistency.
- Refactored HistoryTable to implement a new fixed-height row layout, improving readability and interaction.
- Removed unused Badge component from GenerationForm for cleaner code.
2026-01-25 17:46:39 -08:00
Jamie PineandGitHub 2617936d39 Merge pull request #1 from jamiepine/improvements
Improvements
2026-01-25 17:20:52 -08:00
Jamie Pine 05adc4e013 Remove additional CUDA and torch compiler module exclusions from PyInstaller build to streamline the process 2026-01-25 13:19:22 -08:00
Jamie Pine b479178e91 Exclude CUDA libraries and torch compiler modules to reduce bundle size 2026-01-25 13:13:24 -08:00
Jamie Pine a57e7dbc54 Exclude sklearn, pandas, and torchaudio to reduce bundle size 2026-01-25 12:40:51 -08:00
Jamie Pine 530ee407ee Enhance PyInstaller build by excluding additional unnecessary modules
- Added exclusions for 'torch.utils.tensorboard', 'scipy', 'PIL', 'tkinter', 'unittest', and 'test' to reduce bundle size and improve build efficiency.
2026-01-25 12:17:24 -08:00
Jamie PineandClaude Sonnet 4.5 bc21b4c422 Pin LLVM to version 20 for llvmlite compatibility
llvmlite only supports LLVM up to version 20, but brew install llvm
installs version 21. Update macOS runners to install llvm@20 specifically.

Co-Authored-By: Claude Sonnet 4.5 (1M context) <[email protected]>
2026-01-25 11:44:34 -08:00
524 changed files with 96611 additions and 11677 deletions
+120
View File
@@ -0,0 +1,120 @@
---
name: add-tts-engine
description: Use this skill to add a new TTS engine to Voicebox. It walks through dependency research, backend implementation, frontend wiring, PyInstaller bundling, and frozen-build testing. Always start with Phase 0 (dependency audit) before writing any code.
---
# Add TTS Engine
## Goal
Integrate a new text-to-speech engine into Voicebox end-to-end: dependency research, backend protocol implementation, frontend UI wiring, PyInstaller bundling, and frozen-build verification. The user should only need to test the final build locally.
## Reference Doc
The full phased guide lives at `docs/content/docs/developer/tts-engines.mdx`. **Read this file in its entirety before starting.** It contains:
- Phase 0: Dependency research (mandatory before writing code)
- Phase 1: Backend implementation (`TTSBackend` protocol)
- Phase 2: Route and service integration (usually zero changes)
- Phase 3: Frontend integration (5 files)
- Phase 4: Dependencies (`requirements.txt`, justfile, CI, Docker)
- Phase 5: PyInstaller bundling (`build_binary.py` + `server.py`)
- Phase 6: Common upstream workarounds
- Implementation checklist (gate between phases)
## Workflow
### 1. Read the guide
```bash
# Read the full TTS engines doc
cat docs/content/docs/developer/tts-engines.mdx
```
Internalize all phases, especially Phase 0 and Phase 5. The v0.2.3 release was three patch releases because Phase 0 was skipped.
### 2. Dependency research (Phase 0)
Clone the model library into a temporary directory and audit it. Do NOT skip this.
```bash
mkdir /tmp/engine-research && cd /tmp/engine-research
git clone <model-library-url>
```
Run the grep searches from Phase 0.2 in the guide against the cloned source and its transitive dependencies. Produce a written dependency audit covering:
1. PyPI vs non-PyPI packages
2. PyInstaller directives needed (`--collect-all`, `--copy-metadata`, `--hidden-import`)
3. Runtime data files that must be bundled
4. Native library paths that need env var overrides in frozen builds
5. Monkey-patches needed (`torch.load`, float64, MPS, HF token)
6. Sample rate
7. Model download method (`from_pretrained` vs `snapshot_download` + `from_local`)
Test model loading and generation on CPU in the throwaway venv before proceeding.
### 3. Implement (Phases 1–4)
Follow the guide's phases in order. Key files to modify:
**Backend (Phase 1):**
- Create `backend/backends/<engine>_backend.py`
- Register in `backend/backends/__init__.py` (ModelConfig + TTS_ENGINES + factory)
- Update regex in `backend/models.py`
**Frontend (Phase 3):**
- `app/src/lib/api/types.ts` — engine union type
- `app/src/lib/constants/languages.ts` — ENGINE_LANGUAGES
- `app/src/components/Generation/EngineModelSelector.tsx` — ENGINE_OPTIONS, ENGINE_DESCRIPTIONS
- `app/src/lib/hooks/useGenerationForm.ts` — Zod schema, model-name mapping
- `app/src/components/ServerSettings/ModelManagement.tsx` — MODEL_DESCRIPTIONS
**Dependencies (Phase 4):**
- `backend/requirements.txt`
- `justfile` (setup-python, setup-python-release targets)
- `.github/workflows/release.yml`
- `Dockerfile` (if applicable)
### 4. PyInstaller bundling (Phase 5)
Register the engine in `backend/build_binary.py`:
- `--hidden-import` for the backend module and model package
- `--collect-all` for packages using `inspect.getsource`, shipping data files, or native libraries
- `--copy-metadata` for packages using `importlib.metadata`
If the engine has native data paths, add `os.environ.setdefault()` in `backend/server.py` inside the `if getattr(sys, 'frozen', False):` block.
### 5. Verify in dev mode
```bash
just dev
```
Test the full chain: model download → load → generate → voice cloning.
### 6. Use the checklist
Walk through the Implementation Checklist at the bottom of `tts-engines.mdx`. Every item must be checked before handing the build to the user.
## Key Lessons (from v0.2.3)
These are the most common failure modes. Phase 0 research catches all of them:
| Pattern | Symptom in Frozen Build | Fix |
|---------|------------------------|-----|
| `@typechecked` / `inspect.getsource()` | "could not get source code" | `--collect-all <package>` |
| Package ships pretrained model files | `FileNotFoundError` for `.pth.tar`, `.yaml` | `--collect-all <package>` |
| C library with hardcoded system paths | `FileNotFoundError` for `/usr/share/...` | `--collect-all` + env var in `server.py` |
| `importlib.metadata.version()` | "No package metadata found" | `--copy-metadata <package>` |
| `torch.load` without `map_location` | CUDA device not available on CPU build | Monkey-patch `torch.load` |
| `torch.from_numpy` on float64 data | dtype mismatch RuntimeError | Cast to `.float()` |
| `token=True` in HF download calls | Auth failure without stored HF token | Use `snapshot_download(token=None)` + `from_local()` |
## Notes
- The route and service layers have zero per-engine dispatch points. `main.py` requires zero changes.
- The model config registry in `backends/__init__.py` handles all dispatch automatically.
- Use `get_torch_device()` and `model_load_progress()` from `backends/base.py` — don't reimplement device detection or progress tracking.
- Always test with a **clean HuggingFace cache** (no pre-downloaded models from dev).
- Do NOT push or create a release. Hand the build to the user for local testing.
@@ -0,0 +1,94 @@
---
name: draft-release-notes
description: Use this skill to draft or update the [Unreleased] section of CHANGELOG.md from the actual changes since the last tag. Run this at any point during development to keep a working copy of the release narrative. Does NOT bump versions or create tags.
---
# Draft Release Notes
## Goal
Update the `[Unreleased]` section at the top of `CHANGELOG.md` with a narrative release story based on the real changes since the last tag. This is a **non-destructive working copy** — run it as many times as you want during development.
## Workflow
1. **Identify the last release tag and gather changes.**
```bash
LAST_TAG=$(git tag --list "v*" --sort=-v:refname | head -n 1)
echo "Last tag: $LAST_TAG"
```
Then collect raw material from three sources:
a. **Commit log since last tag:**
```bash
git log --oneline "$LAST_TAG"..HEAD
```
b. **GitHub-generated release notes preview** (PR titles, new contributors):
```bash
gh api repos/:owner/:repo/releases/generate-notes \
-f tag_name="vNEXT" \
-f target_commitish="$(git rev-parse HEAD)" \
-f previous_tag_name="$LAST_TAG" \
--jq '.body'
```
c. **Diff stat for theme analysis:**
```bash
git diff --stat "$LAST_TAG"..HEAD
```
2. **Draft the release narrative.**
Write markdown for the `[Unreleased]` section following the format below. Do not include the `## [Unreleased]` heading itself — just the body content.
3. **Update CHANGELOG.md.**
Replace everything between `## [Unreleased]` and the next `## [` heading with the new draft. Preserve the HTML comment header and all existing release sections below.
The `[Unreleased]` section must always exist and always be the first section after the header comments.
4. **Do NOT commit, tag, or bump versions.** Just leave the file modified in the working tree.
## Release Story Format
Structure the `[Unreleased]` section like this:
```markdown
## [Unreleased]
<One strong opening paragraph: what this release is about and why it matters.
Tie it to concrete shipped changes. No vague hype.>
<One paragraph on major technical shifts, if applicable.>
### <Feature/Theme Group>
- Bullet points with specifics
- Reference PRs where available: ([#123](https://github.com/jamiepine/voicebox/pull/123))
### <Another Group>
- ...
### Bug Fixes
- ...
```
### Style Guidelines
- **Factual and specific.** Every claim should trace to a real commit or PR.
- **Narrative over list.** Lead with paragraphs that tell the story, then support with bullets.
- **Group by theme, not by commit.** Cluster related changes under descriptive headings.
- **Reference PRs** where they exist, but don't fabricate them.
- **Skip trivial chores** (typo fixes, CI tweaks) unless they're the bulk of the release.
- **Match the voice of existing releases** — look at the v0.2.1 and v0.2.3 entries in CHANGELOG.md for tone reference.
## When There Are No Changes
If `git log "$LAST_TAG"..HEAD` is empty, leave the `[Unreleased]` section empty (just the heading) and tell the user there's nothing to draft.
## Notes
- This skill only touches the `[Unreleased]` section. It never modifies stamped release sections.
- The agent can be asked to run this skill at any point — mid-feature, before a PR, or right before cutting a release.
- The `release-bump` skill depends on this draft being up to date before it finalizes.
+124
View File
@@ -0,0 +1,124 @@
---
name: release-bump
description: Use this skill to finalize a release. It stamps the [Unreleased] changelog section with a version and date, runs bumpversion to update all version files, and creates the release commit and tag. Only run this when you're ready to ship.
---
# Release Bump
## Goal
Finalize the changelog draft, bump the version across all tracked files, and create a tagged release commit. After this skill runs, the repo has a clean release commit and tag ready to push.
## Prerequisites
- `gh` CLI installed and authenticated (`gh auth status`).
- `bumpversion` installed (`pip install bumpversion` or available in the project venv).
- The `[Unreleased]` section of `CHANGELOG.md` should already contain the release narrative. If it's empty or stale, run the `draft-release-notes` skill first.
## Workflow
1. **Verify the working tree is clean** (except `CHANGELOG.md` which may have the draft).
```bash
git status --porcelain
```
Only `CHANGELOG.md` (and optionally `.agents/` files) should be modified. If there are other uncommitted changes, stop and ask the user to commit or stash them first.
2. **Determine the bump level.**
Ask the user if not specified: `patch`, `minor`, or `major`. Check the current version:
```bash
grep '^current_version' .bumpversion.cfg
```
3. **Stamp the changelog.**
Read the current `[Unreleased]` content from `CHANGELOG.md`. Compute the new version (based on bump level and current version). Then:
a. Replace the `## [Unreleased]` section body with an empty placeholder.
b. Insert a new stamped section immediately after `## [Unreleased]`:
```markdown
## [Unreleased]
## [X.Y.Z] - YYYY-MM-DD
<the content that was in [Unreleased]>
```
c. Update the reference links at the bottom of the file:
- Change the `[Unreleased]` link to compare against the new tag
- Add a new link for the new version
```markdown
[Unreleased]: https://github.com/jamiepine/voicebox/compare/vX.Y.Z...HEAD
[X.Y.Z]: https://github.com/jamiepine/voicebox/compare/vPREVIOUS...vX.Y.Z
```
4. **Stage the changelog.**
```bash
git add CHANGELOG.md
```
5. **Run bumpversion.**
```bash
bumpversion --allow-dirty <patch|minor|major>
```
The `--allow-dirty` flag is needed because `CHANGELOG.md` is already staged. bumpversion will:
- Update version strings in all tracked files (see `.bumpversion.cfg`)
- Create a commit with message `Bump version: X.Y.Z -> A.B.C`
- Create a tag `vA.B.C`
The staged `CHANGELOG.md` will be included in this commit automatically.
6. **Verify results.**
```bash
git show --name-only --stat HEAD
git tag --list "v*" --sort=-v:refname | head -n 5
```
Confirm the commit contains:
- `CHANGELOG.md`
- `.bumpversion.cfg`
- `tauri/src-tauri/tauri.conf.json`
- `tauri/src-tauri/Cargo.toml`
- `package.json`
- `app/package.json`
- `tauri/package.json`
- `landing/package.json`
- `web/package.json`
- `backend/__init__.py`
Confirm the new tag exists.
7. **Do NOT push** unless the user explicitly asks. Report the tag name and suggest:
```
Ready to push. When you're ready:
git push origin main --follow-tags
```
## Version Calculation Reference
Given current version `X.Y.Z`:
- `patch` -> `X.Y.(Z+1)`
- `minor` -> `X.(Y+1).0`
- `major` -> `(X+1).0.0`
## Error Recovery
- If bumpversion fails, the tag won't exist. Fix the issue and re-run — bumpversion is idempotent as long as the tag doesn't already exist.
- If you need to undo a release commit (before pushing): `git tag -d vX.Y.Z && git reset --soft HEAD~1`
- Never amend a release commit that has been pushed.
## Notes
- When the tag is pushed, the release CI (`.github/workflows/release.yml`) automatically extracts the matching version section from `CHANGELOG.md` and uses it as the GitHub Release body. No manual copy-paste needed.
- The release commit message is controlled by `.bumpversion.cfg` (`Bump version: X.Y.Z -> A.B.C`). Do not override it.
- If you need to manually update the GitHub Release body after the fact: `gh release edit vX.Y.Z --notes-file <(sed -n '/## \[X.Y.Z\]/,/## \[/p' CHANGELOG.md | head -n -1)`
+299
View File
@@ -0,0 +1,299 @@
---
name: triage-prs
description: Use this skill to triage the open PR queue before a release. Classifies every open PR into must-merge, candidate, superseded, or deferred; writes a working triage doc; and runs the merge loop end-to-end. Designed for the pre-release "PR speedrun" pass where a solo maintainer wants to clear the inbound backlog in a single session.
---
# Triage PRs
## Goal
Turn a backlog of open PRs into a shipped set of merges in a single focused session. Produce a tracked, resumable plan (`<VERSION>_PR_TRIAGE.md`), then work it — rebasing where needed, merging in isolation-safe batches, applying post-merge follow-ups, and closing superseded or partially-applicable PRs with credit to their authors.
This skill pairs with `draft-release-notes` and `release-bump`: triage first, then draft notes against the new main, then cut the release.
## When to use
- Before a minor or major release when 10+ open PRs have accumulated
- When you want to unblock merging without losing the narrative of what's landing
- When you know you can't personally review every PR deeply, but need to land the critical subset fast
## Prerequisites
- `gh` CLI authenticated against the repo
- A dedicated worktree for PR review (avoid contaminating `main` with checkouts of contributor branches)
- Clarity on the target version — the triage doc is named after it (e.g. `0.4.0_PR_TRIAGE.md`)
## Workflow
### 1. Set up an isolated PR-review worktree
```bash
git worktree list # check for stale ones first
git worktree prune
git worktree add ../voicebox-pr-review -b pr-review-<VERSION> main
```
Keep the main worktree for release-prep work (changelog drafts, direct-to-main follow-ups). Keep the review worktree for `gh pr checkout` — each checkout moves HEAD to a contributor branch, which you don't want to do in the main worktree.
### 2. Gather metadata for every open PR
```bash
gh pr list --state open --limit 50 --json \
number,title,author,isDraft,mergeable,mergeStateStatus,files,additions,deletions,reviewDecision,statusCheckRollup,maintainerCanModify \
--jq '.[] | {num: .number, title, author: .author.login, mergeable, state: .mergeStateStatus, canModify: .maintainerCanModify, changes: "+\(.additions)/-\(.deletions)", files: [.files[].path]}'
```
You want, for each PR:
- Size (`+additions/-deletions`)
- Mergeable state (`CLEAN`, `UNSTABLE`, `DIRTY` = conflicts, `UNKNOWN` = GitHub still computing)
- Whether maintainer edits are allowed on the branch (needed later if you rebase for the author)
- File paths touched (helps spot overlaps between PRs)
`UNKNOWN` is common right after a push to main — just try the merge and see.
### 3. Classify into tiers
Sort each PR into exactly one bucket:
**Tier 1 — Merge:** small, mergeable, fixes a real bug, clean CI, low review cost. One-liners, dependency relaxations, targeted safety hardening. These are the easy wins.
**Tier 2 — Candidate, review:** medium size (50-200 lines), touches more surface area, looks sound but needs a closer read. New user-facing features that fit the product direction.
**Supersede:** the fix or feature is already covered by something merged. Close with a comment pointing to the superseding PR. Check carefully — "similar title" isn't proof; compare the actual diffs.
**Defer to next release:** big features, dirty conflicts, draft PRs, anything touching the release pipeline in ways that would introduce risk. Don't merge these in a speedrun — they need dedicated focus.
### 4. Write the triage doc
Create `<VERSION>_PR_TRIAGE.md` in the PR-review worktree root. Structure:
```markdown
# <Repo> <VERSION> — PR Triage
Working doc for tracking which open PRs land in <VERSION>. Delete after release cut.
Last updated: <DATE>
## Progress
**Tier 1: 0 / N merged**
**Tier 2: 0 / M handled**
**Supersede triage: pending**
---
## Merge for <VERSION> — critical bug fixes
| PR | Status | Size | What it fixes | Why must-have |
|---|---|---|---|---|
| [#123](url) | [ ] | +5/-0 | ... | ... |
## Strong candidate — needs a quick review
| PR | Status | Size | Summary |
|---|---|---|---|
## Close as superseded
| PR | Status | Reason |
|---|---|---|
## Defer to <NEXT_VERSION>
- [#xxx](url) ... — reason
---
## Order of attack
1. Close superseded PRs (one-liner comments)
2. Merge tier-1 in dependency-free batches — check file paths don't overlap
3. Review tier-2 individually
4. Rerun `draft-release-notes` to pick up everything
5. Run `release-bump`
```
The **Progress** header is the most important part — it's your scoreboard and lets you resume cleanly if the session gets interrupted.
### 5. Work the loop — per PR
For each PR in the tier-1 / tier-2 list:
**a. Checkout in the review worktree:**
```bash
cd ../voicebox-pr-review
git checkout pr-review-<VERSION> # reset to neutral base
gh pr checkout <N>
```
**b. Read the *actual* commit, not `main..HEAD`:**
```bash
git show HEAD # the PR's actual changes
git show --stat HEAD # files touched + line counts
```
**Do NOT review via `git diff main..HEAD`** if the PR branch is older than main. That diff includes *every commit that landed on main after the PR was forked* as `-` (deletion) lines. A 3-line PR can look like a 700-line revert. This is the single easiest way to misjudge a PR.
**c. Evaluate concerns:** correctness, scope, interaction with already-merged work, version compatibility (e.g. can't use an API that requires a dependency version we don't yet pin).
**d. Rebase if the branch is behind main:**
```bash
git fetch origin main
git rebase origin/main
```
This is **essential** before squash-merging. GitHub's squash computes `diff(PR-head, merge-base)` — on a stale branch, that diff includes reverting every in-between commit. Rebasing moves the merge-base forward so the squash is clean.
**e. If maintainer edits are allowed, push the rebase back to the contributor's fork:**
```bash
git remote add <author> https://github.com/<author>/<repo>.git
git fetch <author> <branch> # get their ref first
git push <author> HEAD:<branch> --force-with-lease
```
This keeps GitHub's PR UI in sync with the rebased state and makes the merge clean from the GitHub side.
**f. Merge:**
```bash
gh pr merge <N> --squash
```
**g. Update the triage doc** — flip the checkbox to `✅ merged <sha>` (use the short SHA from `gh pr view <N> --json mergeCommit --jq '.mergeCommit.oid[0:7]'`). Update the Progress header.
### 6. Batch tiny fixes
PRs with ≤5 line changes, clean CI, non-overlapping file paths, and obviously-correct intent (e.g. one-line dependency relax, env var add, import path fix) can be merged in a single loop without the review-per-PR ceremony:
```bash
for pr in 425 384 416 429; do
echo "=== Merging PR $pr ==="
gh pr merge $pr --squash
done
```
Verify afterward that each landed cleanly:
```bash
for pr in 425 384 416 429; do
gh pr view $pr --json state,mergeCommit --jq "{pr: $pr, state, sha: .mergeCommit.oid[0:7]}"
done
```
### 7. Post-merge follow-ups
Sometimes a PR is worth merging despite a known minor issue (e.g. incomplete dtype map, stale sentinel cleanup). Don't block the merge; apply the follow-up as a normal branch + PR right after:
```bash
cd <main-worktree>
git pull --ff-only origin main
git checkout -b fix/<short-name>
# edit...
git commit -m "fix(<area>): <one-liner>"
git push -u origin fix/<short-name>
gh pr create --title "..." --body "Follow-up to #<N>. ..."
```
Record both SHAs in the triage doc (`✅ merged <pr-sha> + follow-up <pr>`).
**Direct-to-main exception:** only under an explicit, scoped policy (e.g. "release speedrun"). Don't default to it.
### 8. Supersede: close with a credit-pointing comment
```bash
gh pr close <N> --comment "Closing — superseded by merged #<M> which landed <brief description>. Thanks!"
```
Check the diffs first — "similar title" is not enough. If the PR is *partially* superseded (the diagnosis is right but only half the changes are still needed), do a partial-apply instead.
### 9. Partial-apply pattern
When a PR has both valuable and questionable changes bundled:
```bash
cd <main-worktree>
git pull --ff-only origin main
# Cherry-pick specific files from the PR branch
git checkout <pr-commit-sha> -- <file1> <file2>
# Review the staged changes, adjust as needed
git diff --cached
# Apply any surgical edits to files you don't want to bulk-replace
# (e.g. the PR's file predates a recent main commit you need to preserve)
# Commit with a trailer crediting the original author
git commit -m "$(cat <<'EOF'
<subject>
<body explaining what was kept vs dropped>
Co-Authored-By: <author> <[email protected]>
EOF
)"
git push ... # branch + PR, unless under the direct-to-main exception
```
Then close the PR with a comment explaining what was applied and what was dropped, referencing the commit SHA.
### 10. Keep the doc current
Every merge, every close, every follow-up → update `<VERSION>_PR_TRIAGE.md`. The doc is your session log. If you're interrupted and resume tomorrow, the doc is the only source of truth for "where am I."
### 11. When triage is done
- Every PR in the doc has a terminal status (✅ merged / ✅ closed / deferred)
- Progress header shows N/N for each tier
- Next skill to run is `draft-release-notes` (to regenerate `[Unreleased]` against the new main), then `release-bump`
You can delete the triage doc after the release ships, or keep it in version history as a record.
## Gotchas
- **`main..HEAD` on a stale branch lies.** It shows everything main gained since the branch split as deletions. Always review via `git show HEAD` for the PR's actual commit.
- **Squash-merging an unrebased branch reverts in-between work.** The squash computes `diff(PR-head, merge-base)`. Rebase moves the merge-base forward.
- **`mergeable=UNKNOWN`** is transient — GitHub is recomputing after a push. Just try the merge.
- **Route ordering matters (FastAPI and similar):** `DELETE /history/failed` must be registered *before* `DELETE /history/{id}`, or the parameterized path will consume `"failed"` as an ID.
- **Apple's `-weak_framework` overrides `-framework`** for the same framework, regardless of order — use it via `cargo:rustc-link-arg=-Wl,-weak_framework,Name` when a dependency hard-links something optional.
- **Dependency version floors constrain what you can apply.** Before accepting a kwarg rename like `torch_dtype=` → `dtype=`, check the min-version pin supports it. Sometimes the right move is to cherry-pick half the PR.
- **`cpal::Stream` and similar `!Send` audio types** can't cross `await` points or `spawn_blocking`. Sometimes a "not-ideal but correct" sync wait is the best available fix; flag but don't block.
- **PyTorch nightly builds are not shippable for releases** — non-deterministic, can regress between runs. If a PR suggests switching to nightly to fix a GPU issue, prefer `TORCH_CUDA_ARCH_LIST=...+PTX` or wait for stable support instead.
## Canonical commands reference
```bash
# Bulk PR metadata
gh pr list --state open --limit 50 --json number,title,author,mergeable,mergeStateStatus,additions,deletions,maintainerCanModify,files
# Detailed single-PR view
gh pr view <N> --json body,author,headRefName,baseRefName,mergeable,maintainerCanModify,files,statusCheckRollup
# The actual commit, not the branch-vs-main diff
git show HEAD
git show --stat HEAD
gh pr diff <N>
# Rebase contributor branch onto current main
git fetch origin main && git rebase origin/main
# Push rebase back to contributor fork (maintainerCanModify=true required)
git remote add <author> https://github.com/<author>/<repo>.git
git fetch <author> <branch>
git push <author> HEAD:<branch> --force-with-lease
# Merge
gh pr merge <N> --squash
# Confirm merge SHA for triage doc
gh pr view <N> --json state,mergeCommit --jq '{state, sha: .mergeCommit.oid[0:7]}'
# Close superseded
gh pr close <N> --comment "Closing — superseded by merged #<M>. Thanks!"
```
## Notes
- **Never review a stale branch via `main..HEAD`.** This is the single most important line in this skill.
- **The triage doc is the session state.** Lose the doc, lose the session. Update it after every action.
- **Credit contributors even on partial-applies.** Use `Co-Authored-By:` trailers and close comments that link to the applied commit.
- **Don't let perfect be the enemy of shipped.** A fix that goes from "broken" to "works with a minor known issue" is a strict improvement. Flag the issue, file a follow-up, merge the fix.
+39
View File
@@ -0,0 +1,39 @@
[bumpversion]
current_version = 0.5.0
commit = True
tag = True
tag_name = v{new_version}
tag_message = Release v{new_version}
message = Bump version: {current_version} → {new_version}
[bumpversion:file:tauri/src-tauri/tauri.conf.json]
search = "version": "{current_version}"
replace = "version": "{new_version}"
[bumpversion:file:tauri/src-tauri/Cargo.toml]
search = version = "{current_version}"
replace = version = "{new_version}"
[bumpversion:file:package.json]
search = "version": "{current_version}"
replace = "version": "{new_version}"
[bumpversion:file:app/package.json]
search = "version": "{current_version}"
replace = "version": "{new_version}"
[bumpversion:file:tauri/package.json]
search = "version": "{current_version}"
replace = "version": "{new_version}"
[bumpversion:file:landing/package.json]
search = "version": "{current_version}"
replace = "version": "{new_version}"
[bumpversion:file:web/package.json]
search = "version": "{current_version}"
replace = "version": "{new_version}"
[bumpversion:file:backend/__init__.py]
search = __version__ = "{current_version}"
replace = __version__ = "{new_version}"
+45
View File
@@ -0,0 +1,45 @@
# Version control
.git
.github
.gitignore
# Desktop-only (not needed in web container)
tauri/
landing/
docs/
mlx-test/
scripts/
# Dependencies & build artifacts (rebuilt in Docker)
node_modules/
__pycache__/
*.pyc
*.pyo
*.egg-info/
dist/
build/
*.spec
# Data (will be bind-mounted)
data/
backend/data/
# IDE & OS
.vscode/
.idea/
*.swp
*.swo
.DS_Store
Thumbs.db
# Config files not needed in container
biome.json
.biomeignore
.bumpversion.cfg
.npmrc
Makefile
CONTRIBUTING.md
SECURITY.md
LICENSE
README.md
backend/README.md
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

+66
View File
@@ -0,0 +1,66 @@
name: Build Windows
on:
workflow_dispatch:
jobs:
build-windows:
permissions:
contents: write
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
pip install -r backend/requirements.txt
- name: Build Python server
shell: bash
run: |
cd backend
python build_binary.py
python build_binary.py --shim
PLATFORM=$(rustc --print host-tuple)
mkdir -p ../tauri/src-tauri/binaries
cp dist/voicebox-server.exe ../tauri/src-tauri/binaries/voicebox-server-${PLATFORM}.exe
cp dist/voicebox-mcp.exe ../tauri/src-tauri/binaries/voicebox-mcp-${PLATFORM}.exe
echo "Built voicebox-server-${PLATFORM}.exe"
echo "Built voicebox-mcp-${PLATFORM}.exe"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: "./tauri/src-tauri -> target"
- name: Install dependencies
run: bun install
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: tauri
tagName: v__VERSION__
releaseName: "voicebox v__VERSION__ (test build)"
releaseBody: "Test build for audio export fix"
releaseDraft: true
prerelease: true
args: ""
includeUpdaterJson: false
+26
View File
@@ -0,0 +1,26 @@
name: CI
on:
pull_request:
push:
branches:
- main
jobs:
frontend-quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Typecheck app + web
run: bun run typecheck
- name: Build web smoke test
run: bun run build:web
+253 -32
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
push:
tags:
- 'v*'
- "v*"
jobs:
release:
@@ -14,48 +14,93 @@ jobs:
fail-fast: false
matrix:
include:
- platform: 'macos-latest'
args: '--target aarch64-apple-darwin'
python-version: '3.12'
- platform: 'macos-15-intel'
args: '--target x86_64-apple-darwin'
python-version: '3.12'
- platform: 'ubuntu-22.04'
args: ''
python-version: '3.12'
- platform: 'windows-latest'
args: ''
python-version: '3.12'
- platform: "macos-latest"
args: "--target aarch64-apple-darwin"
python-version: "3.12"
backend: "mlx"
- platform: "macos-15-intel"
args: "--target x86_64-apple-darwin"
python-version: "3.12"
backend: "pytorch"
- platform: "windows-latest"
args: ""
python-version: "3.12"
backend: "pytorch"
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
# Ubuntu runners ship with ~14 GB free; pip + PyInstaller + torch can
# peak well above that during the build. Reclaim ~25 GB by pruning
# preinstalled toolchains we don't use. This is what likely tripped
# the March 2026 Linux release attempts (see commit 103e98b
# "github runners suck") — not a code issue, a disk-pressure one.
- name: Free up disk space (ubuntu)
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
# Pinned to v1.3.1 (SHA) — this job runs with contents: write and
# handles signing secrets later, so we don't want a floating ref.
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
with:
tool-cache: false
android: true
dotnet: true
haskell: true
# large-packages: true would `apt-get remove '^llvm-.*'`, which
# cascade-removes reverse deps that won't be pulled back in by the
# `llvm-dev` install below. The other flags already free ~20 GB,
# enough for the Python + torch + PyInstaller build.
large-packages: false
swap-storage: true
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf llvm-dev
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf llvm-dev libasound2-dev
- name: Install LLVM (macOS)
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
run: |
brew install llvm
echo "$(brew --prefix llvm)/bin" >> $GITHUB_PATH
echo "LLVM_CONFIG=$(brew --prefix llvm)/bin/llvm-config" >> $GITHUB_ENV
brew install llvm@20
echo "$(brew --prefix llvm@20)/bin" >> $GITHUB_PATH
echo "LLVM_CONFIG=$(brew --prefix llvm@20)/bin/llvm-config" >> $GITHUB_ENV
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
cache: "pip"
- name: Install CPU-only PyTorch (Linux)
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
run: |
pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
pip install -r backend/requirements.txt
pip install --no-deps chatterbox-tts
pip install --no-deps hume-tada
- name: Install MLX dependencies (Apple Silicon only)
if: matrix.backend == 'mlx'
run: |
pip install -r backend/requirements-mlx.txt
# mlx-audio>=0.3.1 and mlx-lm>=0.31.1 both declare transformers>=5.x,
# which conflicts with our 4.57.x cap. The runtime APIs we use work
# fine on transformers 4.57.x in practice (verified in dev), so install
# them --no-deps. mlx-audio's other runtime deps (huggingface_hub,
# librosa, numpy, numba, pyloudnorm) are already in requirements.txt;
# miniaudio is in requirements-mlx.txt (needed by mlx_audio.stt,
# not transitively pulled by anything else — see issue #505); the
# rest (sounddevice, protobuf, sentencepiece, pyyaml, jinja2) are
# pulled in by other engines.
pip install --no-deps mlx-lm==0.31.1
pip install --no-deps mlx-audio==0.4.1
- name: Build Python server (Linux/macOS)
if: matrix.platform != 'windows-latest'
@@ -69,6 +114,7 @@ jobs:
run: |
cd backend
python build_binary.py
python build_binary.py --shim
# Get platform tuple
PLATFORM=$(rustc --print host-tuple)
@@ -78,7 +124,9 @@ jobs:
# Copy with platform suffix
cp dist/voicebox-server.exe ../tauri/src-tauri/binaries/voicebox-server-${PLATFORM}.exe
cp dist/voicebox-mcp.exe ../tauri/src-tauri/binaries/voicebox-mcp-${PLATFORM}.exe
echo "Built voicebox-server-${PLATFORM}.exe"
echo "Built voicebox-mcp-${PLATFORM}.exe"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
@@ -91,31 +139,204 @@ jobs:
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: './tauri/src-tauri -> target'
workspaces: "./tauri/src-tauri -> target"
- name: Install dependencies
run: bun install
- uses: tauri-apps/tauri-action@v0
- name: Install Apple API key
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
run: |
mkdir -p ~/.appstoreconnect/private_keys/
cd ~/.appstoreconnect/private_keys/
echo ${{ secrets.APPLE_API_KEY_BASE64 }} >> AuthKey_${{ secrets.APPLE_API_KEY }}.p8.base64
base64 --decode -i AuthKey_${{ secrets.APPLE_API_KEY }}.p8.base64 -o AuthKey_${{ secrets.APPLE_API_KEY }}.p8
rm AuthKey_${{ secrets.APPLE_API_KEY }}.p8.base64
- name: Install Codesigning Certificate
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Disk / environment snapshot (pre-bundle debug)
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
run: |
echo "=== df -h ==="
df -h
echo "=== free -h ==="
free -h
echo "=== Rust / Cargo ==="
rustc --version
cargo --version
echo "=== Bun ==="
bun --version
echo "=== Tauri CLI ==="
cd tauri && bun run tauri --version
- name: Extract release notes from CHANGELOG.md
id: changelog
shell: bash
run: |
# Get the version from the tag (strip leading 'v')
VERSION="${GITHUB_REF_NAME#v}"
# Extract the section for this version from CHANGELOG.md
# Matches from "## [X.Y.Z]" until the next "## [" heading
NOTES=$(sed -n "/^## \[${VERSION}\]/,/^## \[/{/^## \[${VERSION}\]/d;/^## \[/d;p;}" CHANGELOG.md)
# Fall back to a placeholder if the version isn't in the changelog
if [ -z "$(echo "$NOTES" | tr -d '[:space:]')" ]; then
NOTES="See the assets below to download and install this version."
fi
# Use multiline output syntax
{
echo "notes<<CHANGELOG_EOF"
echo "$NOTES"
echo "CHANGELOG_EOF"
} >> "$GITHUB_OUTPUT"
# Linux hang watchdog: previous releases silently wedged inside tauri
# bundling (possibly linuxdeploy/AppImage download, possibly cargo link).
# Cap the step at 30 min so we get logs instead of waiting out the 6hr
# job timeout. Other platforms historically complete in ~25 min, so 45
# is comfortable.
- uses: tauri-apps/[email protected]
timeout-minutes: ${{ (contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')) && 30 || 45 }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_PROVIDER_SHORT_NAME: ${{ secrets.APPLE_PROVIDER_SHORT_NAME }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
# Stream subprocess stdout/stderr so the hang is visible in logs.
CARGO_TERM_VERBOSE: "true"
RUST_BACKTRACE: "1"
with:
projectPath: tauri
tagName: v__VERSION__
releaseName: 'voicebox v__VERSION__'
releaseBody: |
## What's Changed
See the assets below to download and install this version.
### Installation
- **macOS**: Download the `.dmg` file
- **Windows**: Download the `.msi` installer
- **Linux**: Download the `.AppImage` or `.deb` package
The app includes automatic updates - future updates will be installed automatically.
releaseName: "voicebox v__VERSION__"
releaseBody: ${{ steps.changelog.outputs.notes }}
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
includeUpdaterJson: true
# Tauri's bundler signs the .app and notarizes it, but the .dmg wrapper
# ships unnotarized. Gatekeeper rejects that on macOS 15 Sequoia (caught
# by Homebrew Cask CI) and causes "app isn't signed" dialogs on older
# Intel Macs when Apple's notarization servers are slow (see issue #509).
# Submit the .dmg to notarytool, staple the ticket, and overwrite the
# release asset uploaded by tauri-action.
- name: Notarize and staple DMG (macOS)
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
KEY_PATH="$HOME/.appstoreconnect/private_keys/AuthKey_${APPLE_API_KEY_ID}.p8"
TARGET=$(echo "${{ matrix.args }}" | sed -n 's/.*--target \([a-z0-9_-]*\).*/\1/p')
DMG_DIR="tauri/src-tauri/target/${TARGET}/release/bundle/dmg"
# Match the release tag tauri-action resolved from tauri.conf.json's
# version field; GITHUB_REF_NAME is a branch name under workflow_dispatch.
RELEASE_TAG="v$(jq -r '.version' tauri/src-tauri/tauri.conf.json)"
shopt -s nullglob
dmgs=("${DMG_DIR}"/*.dmg)
if [ ${#dmgs[@]} -eq 0 ]; then
echo "::error::No DMGs found in ${DMG_DIR} — tauri bundler output path may have changed"
exit 1
fi
for dmg in "${dmgs[@]}"; do
echo "::group::Notarize $(basename "$dmg")"
xcrun notarytool submit "$dmg" \
--key "$KEY_PATH" \
--key-id "$APPLE_API_KEY_ID" \
--issuer "$APPLE_API_ISSUER" \
--wait --timeout 20m
xcrun stapler staple "$dmg"
spctl -a -t open --context context:primary-signature -vv "$dmg"
gh release upload "${RELEASE_TAG}" "$dmg" --clobber \
--repo "${GITHUB_REPOSITORY}"
echo "::endgroup::"
done
build-cuda-windows:
runs-on: windows-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
pip install -r backend/requirements.txt
pip install --no-deps chatterbox-tts
pip install --no-deps hume-tada
- name: Install PyTorch with CUDA 12.8
run: |
pip install torch --index-url https://download.pytorch.org/whl/cu128 --force-reinstall --no-deps
pip install torchaudio --index-url https://download.pytorch.org/whl/cu128 --force-reinstall --no-deps
- name: Verify CUDA support in torch
run: |
python -c "import torch; print(f'CUDA available in build: {torch.cuda.is_available()}'); print(f'CUDA version: {torch.version.cuda}')"
- name: Build CUDA server binary (onedir)
shell: bash
working-directory: backend
env:
# Include Blackwell (sm_120) via PTX forward compatibility.
# Pre-built PyTorch cu128 wheels ship native kernels for sm_80/86/89/90
# but not sm_120. Setting this env var causes torch.utils.cpp_extension
# (and any JIT-compiled kernels) to target Blackwell GPUs as well.
TORCH_CUDA_ARCH_LIST: "8.0;8.6;8.9;9.0;12.0+PTX"
run: python build_binary.py --cuda
- name: Package into server core + CUDA libs archives
shell: bash
run: |
python scripts/package_cuda.py \
backend/dist/voicebox-server-cuda/ \
--output release-assets/ \
--cuda-libs-version cu128-v1 \
--torch-compat ">=2.7.0,<2.11.0"
- name: Upload archives to GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
files: |
release-assets/voicebox-server-cuda.tar.gz
release-assets/voicebox-server-cuda.tar.gz.sha256
release-assets/cuda-libs-cu128-v1.tar.gz
release-assets/cuda-libs-cu128-v1.tar.gz.sha256
release-assets/cuda-libs.json
draft: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload onedir as workflow artifact
uses: actions/upload-artifact@v4
with:
name: voicebox-server-cuda-windows
path: backend/dist/voicebox-server-cuda/
retention-days: 7
+15 -4
View File
@@ -35,10 +35,7 @@ target/
Thumbs.db
# Data (user-generated)
data/profiles/*
data/generations/*
data/projects/*
data/voicebox.db
data/
!data/.gitkeep
# Logs
@@ -52,8 +49,22 @@ logs/
# Generated files
app/openapi.json
tauri/src-tauri/binaries/*
tauri/src-tauri/gen/Assets.car
tauri/src-tauri/gen/voicebox.icns
tauri/src-tauri/gen/partial.plist
# PyInstaller
*.spec
# Windows artifacts
nul
# Temporary
tmp/
temp/
*.tmp
# E2E test artifacts
backend/tests/results/
backend/tests/fixtures/reference_voice.wav
backend/tests/fixtures/reference_voice.txt
+11
View File
@@ -0,0 +1,11 @@
{
"mcpServers": {
"voicebox": {
"type": "http",
"url": "http://127.0.0.1:17493/mcp",
"headers": {
"X-Voicebox-Client-Id": "claude-code"
}
}
}
}
+2
View File
@@ -0,0 +1,2 @@
# Force bun usage
engine-strict=true
+767
View File
@@ -0,0 +1,767 @@
<!-- This file is compiled automatically during the release workflow. -->
<!-- Do not edit manually — your changes will be overwritten. -->
<!-- To update the draft: ask the agent to use the draft-release-notes skill. -->
<!-- To finalize a release: ask the agent to use the release-bump skill. -->
# Changelog
## [0.5.0] - 2026-04-22
**The Capture release.** Voicebox stops being just a voice-cloning studio and becomes a full AI voice studio. Hold a key anywhere on your machine, speak, release — the transcript lands in the focused text field. Flip the primitive around and any MCP-aware agent — Claude Code, Cursor, Spacebot — speaks back through an on-screen pill in one of your cloned voices. A local LLM sits between the two, so transcripts come out clean and voice profiles can carry a personality that reshapes what the agent says before it gets spoken.
### Dictation — speak anywhere, paste anywhere
- **Global hotkey capture.** Hold a customizable chord anywhere on your machine (defaults: right-Cmd + right-Option on macOS, right-Ctrl + right-Shift on Windows), speak, release. A floating on-screen pill walks through recording → transcribing → refining → done with a live elapsed timer. The transcript lands as clean text.
- **Push-to-talk and toggle modes, each with its own chord.** The default toggle chord adds Space to the push-to-talk chord. Holding PTT and tapping Space mid-hold upgrades a hold into a hands-free session without a gap in the recording.
- **Auto-paste into the focused app.** Once transcription finishes, Voicebox synthesizes a paste into whatever text field had focus when you started the chord — not wherever focus drifted while you were talking. Works across Dvorak / AZERTY layouts. Your clipboard is saved before and restored after.
- **Chord picker UI.** Customize either chord from Settings → Captures by holding the keys you want. Left/right modifier badges show whether a key is the left or right variant.
- **Defaults stay out of your way.** macOS defaults avoid left-hand Cmd+Option chords so the system shortcuts they collide with stay yours. Windows defaults route around AltGr collisions on German / French / Spanish layouts.
- **Accessibility permission is scoped.** If macOS Accessibility isn't granted, dictation still runs and transcripts still land in the Captures tab — only synthetic paste is disabled. The permission prompt lives inline next to the auto-paste toggle, not as a global banner.
### Personality — voice profiles that speak for themselves
Voice profiles now carry an optional **personality** — a free-form description of who this voice is, up to 2000 characters. When set, two new controls appear next to the generate button, each powered by a new Qwen3 LLM running entirely locally:
- **Compose** — the shuffle button drops a fresh in-character line into the textarea. Click again for variety, edit before speaking.
- **Speak in character** — the wand toggle runs your input through the personality LLM before TTS, preserving every idea but delivering it in the character's voice.
The same LLM doubles as the refinement model, so there's one local LLM in the app, not two.
**API surface.** `POST /generate`, `POST /speak`, and the MCP `voicebox.speak` tool accept `personality: bool`. `POST /profiles/{id}/compose` powers the shuffle button. MCP client bindings carry a `default_personality: bool` that applies when `personality` isn't passed explicitly.
### Agents — any MCP-aware agent gets a voice
Voicebox ships a built-in **Model Context Protocol** server at `http://127.0.0.1:17493/mcp` so Claude Code, Cursor, Windsurf, Cline, VS Code MCP extensions — any MCP-aware agent — can call into your local Voicebox install. Four tools ship with dotted names:
- **`voicebox.speak`** — speak text in any voice profile, with optional `personality: true` to run through the profile's personality LLM first
- **`voicebox.transcribe`** — Whisper transcription of a base64 blob or an absolute local path. Path mode is restricted to loopback callers so a Voicebox bound on `0.0.0.0` doesn't double as an unauthenticated arbitrary-local-file read primitive.
- **`voicebox.list_captures`** — recent captures with their transcripts
- **`voicebox.list_profiles`** — available voice profiles (cloned + preset)
- **Streamable HTTP as primary transport.** Cursor / Windsurf / VS Code / Claude Code all support it out of the box — drop a `mcpServers` block with the URL and an `X-Voicebox-Client-Id` header.
- **Stdio shim for clients that don't speak HTTP MCP.** A `voicebox-mcp` binary ships inside the app bundle as a Tauri sidecar. The Settings page renders the install snippet with the right absolute path pre-filled.
- **Per-client voice binding.** Pin Claude Code to Morgan, Cursor to Scarlett, Cline to its own voice — the `X-Voicebox-Client-Id` header resolves to a bound voice whenever `speak` is called without an explicit `profile`. Managed in **Settings → MCP**.
- **Profile resolution precedence.** Explicit `profile` arg (name or id, case-insensitive) → per-client binding → global default from `capture_settings.default_playback_voice_id` → error with a pointer to Settings.
- **Speaking pill.** Agent-initiated speech surfaces the same on-screen pill as dictation, in a `speaking` state with the profile name and an elapsed timer. Silent background TTS is a trust hazard — the pill always shows what's coming out of your machine.
- **`POST /speak` REST wrapper.** Same code path and voice resolution for shell scripts, ACP, A2A, GitHub Actions, or anything else that isn't MCP-native.
**Claude Code one-liner:**
```
claude mcp add voicebox --transport http --url http://127.0.0.1:17493/mcp --header "X-Voicebox-Client-Id: claude-code"
```
### Refinement
A clean transcript needs more than Whisper. Each capture flows through a small Qwen3 LLM that strips fillers, fixes punctuation, and optionally rewrites self-corrections — all on-device.
- **Loop-stripping before the LLM sees the transcript.** Whisper's "thanks for watching thanks for watching thanks for watching…" hallucination loops are collapsed at a six-identical-tokens threshold (case-insensitive) so a small refinement model can't echo them back. Coverage spans single-word runs, multi-word phrases, CJK character runs, and Japanese emphasis patterns; legitimate repetition ("no, no, no, no, no") doesn't cross the threshold.
- **Per-capture flag snapshot.** `smart_cleanup`, `self_correction`, and `preserve_technical` are stored on each capture, so refinement can be re-run later with different flags without losing the raw transcript.
- **Model picker** — Qwen3 0.6B (400 MB, very fast), 1.7B (1.1 GB, fast), 4B (2.5 GB, full quality). 0.6B is the default; 1.7B is the sweet spot for transcripts with code identifiers.
### Captures tab + settings
Settings → Captures is now the home for the whole dictation flow:
- **Dictation**: global shortcut toggle, push-to-talk chord picker, toggle chord picker, live pill preview, auto-paste into focused field (with inline accessibility prompt).
- **Transcription**: model picker (Whisper Base / Small / Medium / Large / Turbo), language lock.
- **Refinement**: auto-refine toggle, model picker, smart cleanup, remove self-corrections, preserve technical terms.
- **Playback**: default voice for the Captures tab's "Play as" action — picking a voice from the split-button persists the choice across tab switches and restarts.
- **Storage**: captures folder quick-open.
### Stories — timeline editor
The Stories tab graduates from a TTS sequencer into a real timeline editor. Same generation-row backing, but clips now compose with imported audio, per-clip levels, and a flexible track stack.
- **Import external audio.** Drag a music file onto the story content area or pick one from the new "Import audio" entry in the add-clip popover. Accepted formats: wav / mp3 / flac / ogg / m4a / aac / webm, capped at 200 MB. Imported clips show their filename instead of a profile name and skip the regenerate / version-picker controls — there's nothing to regenerate.
- **Per-clip volume.** A `Volume2` icon in the clip-edit toolbar opens a 0–200% slider. Adjustments apply live and to exports. Split and duplicate carry the volume forward into the new clips.
- **Regenerate** from both the clip's chat-list dropdown and the track-editor toolbar. Re-runs the underlying generation through the same path the History tab uses, with completion tracked in the global pending set.
- **Add empty tracks above or below the timeline** via tiny `+` strips at the top of the topmost label cell and the bottom of the bottommost. Sticky in the label column so they follow horizontal scroll.
- **Zoom bar tracks the project.** Min scope is 10 seconds visible (zoomed in cap), max is the entire project (zoomed out cap), default lands on 60 s. Both the +/− buttons and the scrollbar edge-drag handles clamp to those dynamic bounds.
### Interface
- **Theme selector.** Light / dark / system in **Settings → General**, persisted across sessions. System mode listens for OS-level appearance changes and flips live without a restart.
- **Scrubbable waveform player on captures.** The capture detail card now embeds a WaveSurfer waveform with click-to-seek and a current / total timestamp pair, replacing the static duration label.
- **Capture pill light mode.** The on-screen pill gets a dedicated light palette so it stays legible against bright windows.
- **Readiness checklist in the Captures settings sidebar.** The same six-gate checklist the Captures empty state uses mirrors into Settings → Captures so a red gate can't hide behind a green toggle. Hidden once every gate is green. macOS-only rows (Input Monitoring, Accessibility) hide entirely on Windows and Linux.
### Windows parity
Same dictation flow on Windows. Right-hand default chord (Ctrl+Shift) avoids AltGr collisions on layouts where Ctrl+Alt is the compose key. Focus is captured at chord-start so paste lands in the original field even if focus drifts during transcribe/refine.
## [0.4.5] - 2026-04-22
Second hotfix for the "offline mode is enabled" crash on model load. 0.4.4 reverted the inference-path offline guards but kept the same trap on the load path, so users who updated to 0.4.4 kept hitting the exact error the release was supposed to fix ([#526](https://github.com/jamiepine/voicebox/issues/526)). This release removes the load-path guards and patches the transformers tokenizer load to be robust to HuggingFace metadata failures at the source, so the class of bug can't recur.
### Reliability
- **Load no longer fails with "offline mode is enabled"** ([#530](https://github.com/jamiepine/voicebox/pull/530), fixes [#526](https://github.com/jamiepine/voicebox/issues/526)). transformers 4.57.x added an unconditional `huggingface_hub.model_info()` call inside `AutoTokenizer.from_pretrained` (via `_patch_mistral_regex`) that runs for every non-local repo load, regardless of cache state or whether the target model is actually a Mistral variant. The load-time `HF_HUB_OFFLINE` guard from 0.4.2 turned that into a hard crash for cached online users the moment 0.4.4 removed the inference-path guard that had been masking the problem. Fix wraps `_patch_mistral_regex` so any exception from the HF metadata check is caught and the tokenizer is returned unchanged — matching the success-path behavior for non-Mistral repos. The wrapper installs at `backend.backends` import time so it covers Qwen Base, Qwen CustomVoice, TADA, and every other transformers-backed engine on Windows, Linux, and CUDA alike. The load-time `force_offline_if_cached` guards were removed — with the wrapper in place they provide zero value and only risk re-introducing the same failure mode.
- **No more 30s pause when generating without a network.** The HuggingFace metadata timeout called out as a known caveat in 0.4.4 is covered by the same patch; offline users no longer wait for the check to time out before load completes.
## [0.4.4] - 2026-04-21
Hotfix for a regression in 0.4.3 where generation and transcription could fail outright with "offline mode is enabled" even when the user was online.
### Reliability
- **Inference no longer fails with "offline mode is enabled" while online** ([#524](https://github.com/jamiepine/voicebox/pull/524), reverts the inference-path guards from [#503](https://github.com/jamiepine/voicebox/pull/503)). 0.4.3 wrapped every inference body (`generate`, `transcribe`, `create_voice_clone_prompt`) with a process-wide `HF_HUB_OFFLINE` flip to stop lazy HuggingFace lookups from hanging when the network drops mid-inference ([#462](https://github.com/jamiepine/voicebox/issues/462)). That flag also blocks legitimate metadata calls (e.g. `HfApi().model_info` for revision resolution) so online users started seeing generation fail outright. Inference now runs with the process's default HF state. Load-time offline guards — which weren't the source of the regression — stay in place.
**Known caveat**: users generating without an internet connection may see brief pauses during inference while HuggingFace metadata lookups time out (typically ~30s, after which the library recovers). A proper offline-mode toggle is planned for 0.4.5.
## [0.4.3] - 2026-04-20
A patch focused on two user-impacting reliability fixes: macOS DMG notarization (unblocks `brew install voicebox` on macOS 15 Sequoia and fixes spurious "app isn't signed" Gatekeeper dialogs on older Intel Macs) and Kokoro Japanese voice initialization on fresh installs.
### macOS
- **DMGs are now notarized and stapled** ([#523](https://github.com/jamiepine/voicebox/pull/523)). Tauri's bundler notarizes the `.app` inside the DMG but ships the DMG wrapper itself unnotarized. Gatekeeper rejects that on macOS 15 Sequoia (confirmed by Homebrew Cask CI failing on both arm and intel Sequoia runners) and causes the "the app is not signed" dialog on older Intel Macs when Apple's notarization servers are slow or unreachable ([#509](https://github.com/jamiepine/voicebox/issues/509)). The release workflow now submits each DMG to `notarytool`, staples the ticket, verifies with `spctl`, and overwrites the draft-release asset `tauri-action` uploaded. Adds ~5-10 min per macOS job.
### Backend
- **Kokoro Japanese voices no longer crash on fresh installs** ([#521](https://github.com/jamiepine/voicebox/pull/521), fixes [#514](https://github.com/jamiepine/voicebox/issues/514)). `misaki[ja]` pulls in `fugashi`, which needs a MeCab dictionary on disk. The `unidic` package that was being installed ships no data and expects a ~526MB runtime download that `just setup` doesn't run (and which wouldn't survive PyInstaller anyway). Swapped to `unidic-lite`, which bundles a MeCab-compatible dict inside the wheel (~50MB). Collected in `build_binary.py` so frozen builds pick up `unidic_lite/dicdir/`.
## [0.4.2] - 2026-04-20
This release localizes the entire app. English, Simplified Chinese (zh-CN), Traditional Chinese (zh-TW), and Japanese (ja) are wired up end-to-end across every tab, modal, dialog, and toast — 559 translation keys per locale, parity verified. Plus a batch of reliability fixes: offline-mode now actually stays offline, Chatterbox accepts reference samples it used to reject, MLX Qwen 0.6B points at the right repo, and macOS system audio survives backgrounding.
### Internationalization ([#508](https://github.com/jamiepine/voicebox/pull/508))
- **i18next foundation** with an in-app language switcher that re-renders the tree on change — lazy-loaded components were holding stale strings without an explicit key-bump on the React root.
- **Four locales** at full coverage: English, Simplified Chinese, Traditional Chinese, Japanese. No partial/English-fallback surfaces.
- **Every user-visible surface translated**: Stories (list, content editor, dialogs, toasts), Effects (list, detail, chain editor, built-in preset names), Voices (table, search, inspector, Create/Edit modal, audio sample panels), Audio Channels (list, dialogs, device picker), history + story dropdown menus, ProfileCard / ProfileList / HistoryTable, and the unsupported-model note.
- **Relative dates** localize via `date-fns` locale objects (`3 days ago` → `3 天前` / `3 日前`) — `Intl.RelativeTimeFormat` doesn't produce the phrasing we use in the history table.
- **Dev-build version suffix** (`v0.4.2 (dev)` / `(开发版)` / `(開發版)` / `(開発版)`) is now locale-aware.
- **559 translation keys** across all four locales.
### Reliability
- **`HF_HUB_OFFLINE` now guards every inference path** ([#503](https://github.com/jamiepine/voicebox/pull/503)) — some engines were still attempting a HuggingFace metadata roundtrip on first load when offline mode was enabled, causing hangs on airgapped or flaky networks.
- **Chatterbox reference samples are preprocessed instead of rejected** ([#502](https://github.com/jamiepine/voicebox/pull/502)) — samples outside the expected sample rate or channel layout are resampled to match, rather than failing with an opaque error.
- **MLX Qwen 0.6B repo path fixed** ([#501](https://github.com/jamiepine/voicebox/pull/501)) — now points at the published `mlx-community` repo so the model actually downloads on Apple Silicon.
- **macOS system audio survives backgrounding** ([#486](https://github.com/jamiepine/voicebox/pull/486), closes [#41](https://github.com/jamiepine/voicebox/issues/41)) — WKWebView was tearing down the audio session when the app lost focus, silently killing system-audio capture.
- **MLX backend `miniaudio` dependency pinned** ([#506](https://github.com/jamiepine/voicebox/pull/506)) — `mlx_audio.stt` needs it at runtime and nothing else transitively pulled it in, so `--no-deps` installs were breaking on first use.
### Landing / Docs
- **New `/download` page** ([#487](https://github.com/jamiepine/voicebox/pull/487)) — no more dumping first-time visitors onto the GitHub releases list. The API example snippet on the landing page also got an accuracy pass.
- **Download redirects work behind reverse proxies** ([#498](https://github.com/jamiepine/voicebox/pull/498)) — uses the public origin instead of `localhost` when resolving platform-specific installer URLs.
- **MDX docs audited against the multi-engine backend** ([#484](https://github.com/jamiepine/voicebox/pull/484)) — stale single-engine assumptions removed.
- **Three more tutorials + mobile navbar / hero CTA fixes** ([#483](https://github.com/jamiepine/voicebox/pull/483)).
### Linux
- **Still not shipping.** The re-enable attempt ([#488](https://github.com/jamiepine/voicebox/pull/488)) landed on `main` but CI still hangs in the `tauri-action` bundler step on `ubuntu-22.04` — no output for 25+ minutes after `rpm` bundling, even with `createUpdaterArtifacts: false` and `--bundles deb,rpm`. The matrix entry is disabled again for 0.4.2; the ubuntu-specific setup steps stay in the workflow so re-enabling is a one-line change once we identify the hang. Next release will take another pass.
### New Contributors
- [@shekharyv](https://github.com/shekharyv) — download redirects behind reverse proxies ([#498](https://github.com/jamiepine/voicebox/pull/498))
## [0.4.1] - 2026-04-18
A fast follow-up to 0.4.0 focused on making the new engines actually load in the production binary — plus generation cancellation, Linux system-audio capture, and the repo's first PR-time type check. Five first-time contributors shipped in this release.
0.4.0 introduced three new TTS engines, but the frozen PyInstaller binary tripped over several Python-ecosystem quirks that don't show up in the dev venv: `transformers` opening `.py` sources at runtime, `scipy.stats._distn_infrastructure` hitting a frozen-importer `NameError`, and `chatterbox-multilingual` failing to find its Chinese segmenter dictionary. This release patches all of those in one sweep.
### Frozen-Binary Reliability ([#438](https://github.com/jamiepine/voicebox/pull/438))
- **Kokoro** now bundles `.py` sources alongside `.pyc` via `--collect-all kokoro` so `transformers`' `_can_set_attn_implementation` regex scan can read them — previously `FileNotFoundError: kokoro/modules.py` killed Kokoro loading in production builds
- **Chatterbox Multilingual** now bundles `spacy_pkuseg/dicts/default.pkl` and the package's native `.so` extensions via `--collect-all spacy_pkuseg` — previously the Chinese word segmenter crashed with `FileNotFoundError` on first load
- **scipy.stats._distn_infrastructure** — new runtime hook source-patches the trailing `del obj` (which raises `NameError` under PyInstaller's frozen importer because the preceding list comprehension evaluates empty) to `globals().pop('obj', None)`, unblocking `librosa` → `scipy.signal` → `scipy.stats` for every TTS engine that depends on librosa
- **transformers.masking_utils** — same runtime hook forces `_is_torch_greater_or_equal_than_2_6 = False` so the older `sdpa_mask_older_torch` path is selected; the 2.6+ path uses `TransformGetItemToIndex()`, a real `torch._dynamo` graph transform our permissive stub can't reproduce
- **torch._dynamo** — no-op stub replaces the real module before `transformers` imports it, preventing the `torch._numpy._ufuncs` import crash (`NameError: name 'name' is not defined`) that blocked Kokoro and every engine pulling in `flex_attention`
- `.spec` paths are now repo-relative instead of absolute, so the generated spec is portable across machines and CI
### Generation
- **Cancel queued or running generations** ([#444](https://github.com/jamiepine/voicebox/pull/444)) — new `/generate/{id}/cancel` endpoint and a Stop button on the history row while generating. The serial queue now tracks per-ID state (queued / running / cancelled) so queued jobs are skipped before the worker picks them up and running jobs are `.cancel()`-ed mid-flight; `run_generation` catches `CancelledError` and marks the row `failed` with a "cancelled" error.
- **Legacy `data/` path prefix resolution** ([#440](https://github.com/jamiepine/voicebox/pull/440)) — generations stored with the old `data/` prefix under pre-0.4 installs now resolve correctly after the storage root moved, fixing 404s for historical audio.
### Model Migration
- Migration dialog no longer hangs when the cache is empty ([#439](https://github.com/jamiepine/voicebox/pull/439)) — the backend now emits a completion SSE event even when zero models are moved.
- Storage-change flow surfaces a toast when there's nothing to migrate ([#433](https://github.com/jamiepine/voicebox/pull/433)) instead of proceeding with a no-op move and restarting the server.
- Deleting all generations from a voice profile now deletes the associated version files and DB rows too ([#447](https://github.com/jamiepine/voicebox/pull/447)) — previously orphaned versions accumulated in storage.
### Platform
- **Linux system audio capture** ([#457](https://github.com/jamiepine/voicebox/pull/457)) — `cpal`'s ALSA backend doesn't expose PulseAudio/PipeWire monitor sources by name, so the previous device-name search never matched and silently fell back to the microphone. Detection now uses `pactl get-default-sink` + `pactl list short sources` and routes via `PULSE_SOURCE`, with the name-based search retained as a fallback when `pactl` is absent.
### Frontend CI
- First PR-time quality gate ([#418](https://github.com/jamiepine/voicebox/pull/418)) — new `.github/workflows/ci.yml` runs `bun run typecheck` + `bun run build:web` on every PR. Fixed pre-existing type issues that were being suppressed with `@ts-expect-error`, cleaned up a dep-array typo (`[platform.metadata.isTauricheckOnMountcheckForUpdates]`) in `useAutoUpdater`, and removed 100+ lines of dead `ModelItem` code from `ModelManagement.tsx`.
- Follow-up: widened `apiClient.migrateModels()` return type to include `moved` and `errors` so the storage-change handler typechecks against the real backend response ([#470](https://github.com/jamiepine/voicebox/pull/470)).
### Docs
- Clarified in the Quick Start + README that paralinguistic tags (`[laugh]`, `[sigh]`) only work with Chatterbox Turbo; other engines read them as literal text ([#450](https://github.com/jamiepine/voicebox/pull/450)).
### New Contributors
- [@Bortlesboat](https://github.com/Bortlesboat) — generation cancellation (#444)
- [@gaojulong](https://github.com/gaojulong) — migration dialog hang fix (#439)
- [@fuleinist](https://github.com/fuleinist) — migration no-op toast (#433)
- [@erionjuniordeandrade-a11y](https://github.com/erionjuniordeandrade-a11y) — frontend CI + type hardening (#418)
- [@estefrac](https://github.com/estefrac) — Linux pactl system-audio capture (#457)
## [0.4.0] - 2026-04-16
The biggest Voicebox release yet. Three new TTS engines bring the lineup to **seven** — HumeAI TADA, Kokoro 82M, and Qwen CustomVoice join Qwen3-TTS, LuxTTS, Chatterbox Multilingual, and Chatterbox Turbo. GPU support broadens to Intel Arc (XPU) and NVIDIA Blackwell (RTX 50-series), with runtime diagnostics that warn when your PyTorch build doesn't match your GPU. The CUDA backend is now split into independently versioned server and library archives, so upgrading no longer redownloads 4 GB of PyTorch/CUDA DLLs.
This release also marks a big community moment: **13 new contributors** shipped fixes and features in 0.4.0. Thirty-plus bug fixes target the most-reported issues in the tracker — numpy 2.x TTS crashes, Windows background-server reliability, macOS 11 launch failures, audio playback silence, Stories clip-splitting races, history status staleness, and more.
### New TTS Engines
#### HumeAI TADA — Expressive English & Multilingual ([#296](https://github.com/jamiepine/voicebox/pull/296))
- Added `tada-1b` (English) and `tada-3b-ml` (multilingual) backends
- Replaced `descript-audio-codec` with a lightweight DAC shim to cut dependencies
- Switched audio decoding to `soundfile` to sidestep `torchcodec` bundling issues
- Redirected gated Llama tokenizer lookups to an ungated mirror so model loading works out of the box
- Fixed tokenizer patch that was corrupting `AutoTokenizer` for other engines
- Fixed TorchScript error in frozen builds
#### Kokoro 82M — Fast Lightweight TTS ([#325](https://github.com/jamiepine/voicebox/pull/325))
- Added Kokoro 82M engine with a new voice profile type system that distinguishes preset voices from cloned profiles
- Profile grid now handles engine compatibility directly — removed redundant dropdown filtering
- Tightened Kokoro profile handling so preset voices can't be edited like cloned profiles
#### Qwen CustomVoice ([#328](https://github.com/jamiepine/voicebox/pull/328))
- Added `qwen-custom-voice` preset engine backed by Qwen3-TTS
- Enforced preset/profile engine compatibility across the generation flow
- Floating generator now shows all engines instead of silently filtering
### Voice Profile UX
Until 0.4, every engine in Voicebox was a cloning model, so every voice profile was usable with every engine and the profile grid just showed them all. Introducing Kokoro and Qwen CustomVoice — which work from preset voices rather than cloned samples — broke that assumption for the first time. An early cut on `main` filtered the grid by the selected engine, which left users running pre-release builds thinking their cloned voices had vanished whenever they switched to a preset-only engine.
This release ships the resolution before it ever reaches a tagged version:
- **Grey-out instead of filter** — all profiles are always visible; unsupported ones render dimmed with a compatibility hint at the bottom of the grid
- **Auto-switch on selection** — clicking a greyed-out profile selects it AND switches the engine to a compatible one, instead of silently doing nothing
- **Instruct toggle restored for Qwen CustomVoice** — the floating generate box now reveals a delivery-instructions input (tone, emotion, pace) when CustomVoice is selected. Hidden across the board while the new multi-engine lineup was stabilizing because most engines don't honor the kwarg; now conditionally exposed only for the one engine that was actually trained for instruction-based style control
- Supported profiles sort first; the grid scrolls the selected profile into view after engine/sort changes
- Fixed engine desync on tab navigation — the form now initializes its engine from the store
- Fixed the disabled-and-selected card click edge case by bouncing selection to re-trigger the auto-switch
- Cleaned up scroll effect timers (requestAnimationFrame + setTimeout) to prevent stale DOM writes on unmount or rapid selection changes
### GPU & Platform
#### Intel Arc (XPU) Support ([#320](https://github.com/jamiepine/voicebox/pull/320))
- First-class Intel Arc support across all PyTorch-based backends
- Device-aware seeding, XPU detection in the GPU status panel, and setup flow detection
- Reports correct device name and VRAM in settings
#### Blackwell / RTX 50-series Support ([#316](https://github.com/jamiepine/voicebox/pull/316), [#401](https://github.com/jamiepine/voicebox/pull/401))
- Upgraded the CUDA backend from cu126 → cu128 for RTX 50-series support
- Added `sm_120+PTX` to the CUDA build via `TORCH_CUDA_ARCH_LIST` for forward-compatibility with Blackwell architectures (closes 5 open reports: #386, #395, #396, #399, #400)
- GPU settings UI fixes around install/uninstall state
#### GPU Compatibility Diagnostics ([#367](https://github.com/jamiepine/voicebox/pull/367), adapted)
- New `check_cuda_compatibility()` compares the current device's compute capability against the bundled PyTorch's architecture list
- Health endpoint exposes a `gpu_compatibility_warning` field so the UI can surface mismatches
- Startup logs a `WARN` when the installed PyTorch build doesn't support the detected GPU
- GPU status label shows `[UNSUPPORTED - see logs]` — no more silent "no kernel image" failures
#### Split CUDA Backend ([#298](https://github.com/jamiepine/voicebox/pull/298))
- CUDA backend now ships as two independently versioned archives: a small server binary and a large libs archive (the ~4 GB of PyTorch/CUDA DLLs)
- Upgrading Voicebox no longer redownloads the libs archive when only the server binary changed
- Added `asyncio.Lock` around `download_cuda_binary()` so auto-update and manual download can't race on the same temp file ([#428](https://github.com/jamiepine/voicebox/pull/428))
- Updated `package_cuda.py` for PyInstaller 6.18 onedir layout
- Temp archives are always cleaned up on failure, even when the install aborts mid-extract
### Bug Fixes
#### Critical: TTS Generation
- **numpy 2.x `torch.from_numpy` crash** ([#361](https://github.com/jamiepine/voicebox/pull/361)) — torch compiled against numpy 1.x ABI fails silently when paired with numpy 2.x, causing `RuntimeError: Numpy is not available` / `Unable to create tensor` on every TTS request in bundled macOS Intel / Rosetta builds. Pinned `numpy<2.0` in requirements and added a PyInstaller runtime hook with a `ctypes.memmove` fallback as belt-and-suspenders. Hardened afterward to raise on unknown dtypes instead of silently reinterpreting bytes as float32.
#### Platform Reliability
- **Windows background server** ([#402](https://github.com/jamiepine/voicebox/pull/402)) — "keep server running after close" now actually keeps the server running. The HTTP `/watchdog/disable` request could lose the race against process exit on Windows; added a `.keep-running` sentinel file as a synchronous fallback, with stale-sentinel cleanup on startup to avoid orphan server processes
- **macOS 11 launch crash** ([#424](https://github.com/jamiepine/voicebox/pull/424)) — weak-linked ScreenCaptureKit so the app can launch on macOS < 12.3 instead of crashing at dyld resolution. Gated system audio capture behind a real `sw_vers` version check so unsupported systems cleanly advertise "not available" rather than crashing at runtime
- **macOS Intel (x86_64) setup** ([#416](https://github.com/jamiepine/voicebox/pull/416)) — relaxed `torch>=2.7.0` → `torch>=2.2.0`. PyTorch dropped pre-built x86_64 wheels after 2.2.2, so Intel Mac devs could no longer `pip install`. Now resolves to the latest compatible torch per platform
- **Offline model loading** ([#318](https://github.com/jamiepine/voicebox/pull/318)) — Qwen TTS and Whisper force offline mode when loading cached models, so startup works without network access
- **GUI startup with external server** ([#319](https://github.com/jamiepine/voicebox/pull/319)) — fixed GUI launch when pointed at a remote/external server, and added data refresh on server switch; hardened health validation and error handling
- **Qwen3-TTS cache split on Windows** (adapted from [#218](https://github.com/jamiepine/voicebox/pull/218)) — route `Qwen3TTSModel.from_pretrained` through `hf_constants.HF_HUB_CACHE` so the speech tokenizer and `preprocessor_config.json` resolve from a single cache root
- **Qwen3-TTS bundling** ([#305](https://github.com/jamiepine/voicebox/pull/305)) — bundle `qwen_tts` source files in the PyInstaller build to fix `inspect.getsource` errors in frozen builds
- **Backend import paths** ([#345](https://github.com/jamiepine/voicebox/pull/345)) — moved lazy imports to top-level with absolute paths to resolve the "Failed to Save" preset error caused by `ModuleNotFoundError` in production builds
- **Effects service import** ([#384](https://github.com/jamiepine/voicebox/pull/384)) — fixed `ModuleNotFoundError` on preset create/update by switching to relative imports (#349)
#### Audio & Playback
- **cpal stream silent playback** ([#405](https://github.com/jamiepine/voicebox/pull/405)) — `cpal::Stream` was dropped on function return immediately after `play()`, causing every playback to fall silent. Now holds the stream until either the buffer drains or the stop flag fires (#404)
#### Stories & History
- **Clip-splitting race** ([#403](https://github.com/jamiepine/voicebox/pull/403)) — rapid double-clicks on split could race through `split_story_item` with inconsistent state. Added `with_for_update()` row locking on the backend and an `isPending` guard on the frontend (#366)
- **History `status` staleness** ([#394](https://github.com/jamiepine/voicebox/pull/394)) — `GET /history/{id}` was hardcoding `status="completed"` regardless of the DB row, breaking any client polling for job completion. Now returns `status`, `error`, `engine`, `model_size`, and `is_favorited` from the actual row
- **"Clear failed" bulk button** ([#412](https://github.com/jamiepine/voicebox/pull/412)) — new `DELETE /history/failed` endpoint and a header strip showing `"N failed generations"` with a Clear button, complementing the per-row trash icon added in #321 (#410)
- **Delete failed generations** ([#321](https://github.com/jamiepine/voicebox/pull/321)) — added a trash icon next to the retry button so failed entries can be cleaned up without having to retry first
#### Security & Safety
- **Voice prompt cache hardening** ([#429](https://github.com/jamiepine/voicebox/pull/429)) — `torch.load(weights_only=True)` on cached voice prompts per PyTorch 2.6 recommendation; replaced string-based SPA path guard with `Path.is_relative_to()` for more robust path-traversal protection
#### Infrastructure & Docker
- **Docker web build** ([#344](https://github.com/jamiepine/voicebox/pull/344)) — include `CHANGELOG.md` in the Docker web build so the in-app changelog page works in Docker deployments
- **Docker numba cache** ([#425](https://github.com/jamiepine/voicebox/pull/425)) — set `NUMBA_CACHE_DIR` in docker-compose so numba can write its JIT cache in container runtime (#308)
- **Relative media paths** ([#332](https://github.com/jamiepine/voicebox/pull/332)) — media paths now stored relative to the configured data dir rather than resolved against CWD, so the data directory is portable between installs
### Developer Tooling
- New `triage-prs` agent skill — encodes the end-to-end PR-speedrun workflow (classification → triage doc → rebase → squash-merge → follow-ups) so future release cycles can reproduce it
- Rewrote the TTS engine guide with the patterns learned from adding TADA and Kokoro
- Added the API refactor plan and CUDA libs addon design doc
- Fixed broken links in the Get Started section ([#332](https://github.com/jamiepine/voicebox/pull/332))
### New Contributors
Huge thank you to everyone who contributed their first PR to Voicebox in this release:
[@liorshahverdi](https://github.com/liorshahverdi), [@nicoschtein](https://github.com/nicoschtein), [@ArfianID](https://github.com/ArfianID), [@aimaaaimaa](https://github.com/aimaaaimaa), [@maxmcoding](https://github.com/maxmcoding), [@Khalodddd](https://github.com/Khalodddd), [@LuisSambrano](https://github.com/LuisSambrano), [@shaun0927](https://github.com/shaun0927), [@malletfils](https://github.com/malletfils), [@mvanhorn](https://github.com/mvanhorn), [@kuishou68](https://github.com/kuishou68), [@txhno](https://github.com/txhno), [@MukundaKatta](https://github.com/MukundaKatta)
## [0.3.0] - 2026-03-17
This release rewrites the backend into a modular architecture, overhauls the settings UI into routed sub-pages, fixes audio player freezing, migrates documentation to Fumadocs, and ships a batch of bug fixes targeting the most-reported issues from the tracker.
The backend's 3,000-line monolith `main.py` has been decomposed into domain routers, a services layer, and a proper database package. A style guide and ruff configuration now enforce consistency. On the frontend, settings have been split into dedicated routed pages with server logs, a changelog viewer, and an about page. The audio player no longer freezes mid-playback, and model loading status is now visible in the UI. Seven user-reported bugs have been fixed, including server crashes during sample uploads, generation list staleness, cryptic error messages, and CUDA support for RTX 50-series GPUs.
### Settings Overhaul ([#294](https://github.com/jamiepine/voicebox/pull/294))
- Split settings into routed sub-tabs: General, Generation, GPU, Logs, Changelog, About
- Added live server log viewer with auto-scroll
- Added in-app changelog page that parses `CHANGELOG.md` at build time
- Added About page with version info, license, and generation folder quick-open
- Extracted reusable `SettingRow` component for consistent setting layouts
### Audio Player Fix ([#293](https://github.com/jamiepine/voicebox/pull/293))
- Fixed audio player freezing during playback
- Improved playback UX with better state management and listener cleanup
- Fixed restart race condition during regeneration
- Added stable keys for audio element re-rendering
- Improved accessibility across player controls
### Backend Refactor ([#285](https://github.com/jamiepine/voicebox/pull/285))
- Extracted all routes from `main.py` into 13 domain routers under `backend/routes/` — `main.py` dropped from ~3,100 lines to ~10
- Moved CRUD and service modules into `backend/services/`, platform detection into `backend/utils/`
- Split monolithic `database.py` into a `database/` package with separate `models`, `session`, `migrations`, and `seed` modules
- Added `backend/STYLE_GUIDE.md` and `pyproject.toml` with ruff linting config
- Removed dead code: unused `_get_cuda_dll_excludes`, stale `studio.py`, `example_usage.py`, old `Makefile`
- Deduplicated shared logic across TTS backends into `backends/base.py`
- Improved startup logging with version, platform, data directory, and database stats
- Fixed startup database session leak — sessions now rollback and close in `finally` block
- Isolated shutdown unload calls so one backend failure doesn't block the others
- Handled null duration in `story_items` migration
- Reject model migration when target is a subdirectory of source cache
### Documentation Rewrite ([#288](https://github.com/jamiepine/voicebox/pull/288))
- Migrated docs site from Mintlify to Fumadocs (Next.js-based)
- Rewrote introduction and root page with content from README
- Added "Edit on GitHub" links and last-updated timestamps on all pages
- Generated OpenAPI spec and auto-generated API reference pages
- Removed stale planning docs (`CUDA_BACKEND_SWAP`, `EXTERNAL_PROVIDERS`, `MLX_AUDIO`, `TTS_PROVIDER_ARCHITECTURE`, etc.)
- Sidebar groups now expand by default; root redirects to `/docs`
- Added OG image metadata and `/og` preview page
### UI & Frontend
- Added model loading status indicator and effects preset dropdown ([3187344](https://github.com/jamiepine/voicebox/commit/3187344))
- Fixed take-label race condition during regeneration
- Added accessible focus styling to select component
- Softened select focus indicator opacity
- Addressed 4 critical and 12 major issues from CodeRabbit review
### Bug Fixes ([#295](https://github.com/jamiepine/voicebox/pull/295))
- Fixed sample uploads crashing the server — audio decoding now runs in a thread pool instead of blocking the async event loop ([#278](https://github.com/jamiepine/voicebox/issues/278))
- Fixed generation list not updating when a generation completes — switched to `refetchQueries` for reliable cache busting, added SSE error fallback, and page reset on completion ([#231](https://github.com/jamiepine/voicebox/issues/231))
- Fixed error toasts showing `[object Object]` instead of the actual error message ([#290](https://github.com/jamiepine/voicebox/issues/290))
- Added Whisper model selection (`base`, `small`, `medium`, `large`, `turbo`) and expanded language support to the `/transcribe` endpoint ([#233](https://github.com/jamiepine/voicebox/issues/233))
- Upgraded CUDA backend build from cu121 to cu126 for RTX 50-series (Blackwell) GPU support ([#289](https://github.com/jamiepine/voicebox/issues/289))
- Handled client disconnects in SSE and streaming endpoints to suppress `[Errno 32] Broken Pipe` errors ([#248](https://github.com/jamiepine/voicebox/issues/248))
- Fixed Docker build failure from pip hash mismatch on Qwen3-TTS dependencies ([#286](https://github.com/jamiepine/voicebox/issues/286))
- Added 50 MB upload size limit with chunked reads to prevent unbounded memory allocation on sample uploads
- Eliminated redundant double audio decode in sample processing pipeline
### Platform Fixes
- Replaced `netstat` with `TcpStream` + PowerShell for Windows port detection ([#277](https://github.com/jamiepine/voicebox/pull/277))
- Fixed Docker frontend build and cleaned up Docker docs
- Fixed macOS download links to use `.dmg` instead of `.app.tar.gz`
- Added dynamic download redirect routes to landing site
### Release Tooling
- Added `draft-release-notes` and `release-bump` agent skills
- Wired CI release workflow to extract notes from `CHANGELOG.md` for GitHub Releases
- Backfilled changelog with all historical releases
## [0.2.3] - 2026-03-15
The "it works in dev but not in prod" release. This version fixes a series of PyInstaller bundling issues that prevented model downloading, loading, generation, and progress tracking from working in production builds.
### Model Downloads Now Actually Work
The v0.2.1/v0.2.2 builds could not download or load models that weren't already cached from a dev install. This release fixes the entire chain:
- **Chatterbox, Chatterbox Turbo, and LuxTTS** all download, load, and generate correctly in bundled builds
- **Real-time download progress** — byte-level progress bars now work in production. The root cause: `huggingface_hub` silently disables tqdm progress bars based on logger level, which prevented our progress tracker from receiving byte updates. We now force-enable the internal counter regardless.
- **Fixed Python 3.12.0 `code.replace()` bug** — the macOS build was on Python 3.12.0, which has a [known CPython bug](https://github.com/pyinstaller/pyinstaller/issues/7992) that corrupts bytecode when PyInstaller rewrites code objects. This caused `NameError: name 'obj' is not defined` crashes during scipy/torch imports. Upgraded to Python 3.12.13.
### PyInstaller Fixes
- Collect all `inflect` files — `typeguard`'s `@typechecked` decorator calls `inspect.getsource()` at import time, which needs `.py` source files, not just bytecode. Fixes LuxTTS "could not get source code" error.
- Collect all `perth` files — bundles the pretrained watermark model (`hparams.yaml`, `.pth.tar`) needed by Chatterbox at runtime
- Collect all `piper_phonemize` files — bundles `espeak-ng-data/` (phoneme tables, language dicts) needed by LuxTTS for text-to-phoneme conversion
- Set `ESPEAK_DATA_PATH` in frozen builds so the espeak-ng C library finds the bundled data instead of looking at `/usr/share/espeak-ng-data/`
- Collect all `linacodec` files — fixes `inspect.getsource` error in Vocos codec
- Collect all `zipvoice` files — fixes source code lookup in LuxTTS voice cloning
- Copy metadata for `requests`, `transformers`, `huggingface-hub`, `tokenizers`, `safetensors`, `tqdm` — fixes `importlib.metadata` lookups in frozen binary
- Add hidden imports for `chatterbox`, `chatterbox_turbo`, `luxtts`, `zipvoice` backends
- Add `multiprocessing.freeze_support()` to fix resource_tracker subprocess crash in frozen binary
- `--noconsole` now only applied on Windows — macOS/Linux need stdout/stderr for Tauri sidecar log capture
- Hardened `sys.stdout`/`sys.stderr` devnull redirect to test writability, not just `None` check
### Updater
- Fixed updater artifact generation with `v1Compatible` for `tauri-action` signature files
- Updated `tauri-action` to v0.6 to fix updater JSON and `.sig` generation
### Other Fixes
- Full traceback logging on all backend model loading errors (was just `str(e)` before)
## [0.2.2] - 2026-03-15
- Fix Chatterbox model support in bundled builds
- Fix LuxTTS/ZipVoice support in bundled builds
- Auto-update CUDA binary when app version changes
- CUDA download progress bar
- Fix server process staying alive on macOS (SIGHUP handling, watchdog grace period)
- Hide console window when running CUDA binary on Windows
## [0.2.1] - 2026-03-15
Voicebox v0.1.x was a single-engine voice cloning app built around Qwen3-TTS. v0.2.0 is a ground-up rethink: four TTS engines, 23 languages, paralinguistic emotion controls, a post-processing effects pipeline, unlimited generation length, an async generation queue, and support for every major GPU vendor. Plus Docker.
### New TTS Engines
#### Multi-Engine Architecture
Voicebox now runs **four independent TTS engines** behind a thread-safe per-engine backend registry. Switch engines per-generation from a single dropdown — no restart required.
| Engine | Languages | Size | Key Strengths |
| --------------------------- | --------- | ------- | --------------------------------------------- |
| **Qwen3-TTS 1.7B** | 10 | ~3.5 GB | Highest quality, delivery instructions |
| **Qwen3-TTS 0.6B** | 10 | ~1.2 GB | Lighter, faster variant |
| **LuxTTS** | English | ~300 MB | CPU-friendly, 48 kHz output, 150x realtime |
| **Chatterbox Multilingual** | 23 | ~3.2 GB | Broadest language coverage, zero-shot cloning |
| **Chatterbox Turbo** | English | ~1.5 GB | 350M params, low latency, paralinguistic tags |
#### Chatterbox Multilingual — 23 Languages ([#257](https://github.com/jamiepine/voicebox/pull/257))
Zero-shot voice cloning in Arabic, Chinese, Danish, Dutch, English, Finnish, French, German, Greek, Hebrew, Hindi, Italian, Japanese, Korean, Malay, Norwegian, Polish, Portuguese, Russian, Spanish, Swahili, Swedish, and Turkish.
#### LuxTTS — Lightweight English TTS ([#254](https://github.com/jamiepine/voicebox/pull/254))
A fast, CPU-friendly English engine. ~300 MB download, 48 kHz output, runs at 150x realtime on CPU.
#### Chatterbox Turbo — Expressive English ([#258](https://github.com/jamiepine/voicebox/pull/258))
A fast 350M-parameter English model with inline paralinguistic tags.
#### Paralinguistic Tags Autocomplete ([#265](https://github.com/jamiepine/voicebox/pull/265))
Type `/` in the text input with Chatterbox Turbo selected to open an autocomplete for **9 expressive tags**: `[laugh]` `[chuckle]` `[gasp]` `[cough]` `[sigh]` `[groan]` `[sniff]` `[shush]` `[clear throat]`
### Generation
#### Unlimited Generation Length — Auto-Chunking ([#266](https://github.com/jamiepine/voicebox/pull/266))
Long text is now automatically split at sentence boundaries, generated per-chunk, and crossfaded back together. Engine-agnostic.
- Auto-chunking limit slider — 100–5,000 chars (default 800)
- Crossfade slider — 0–200ms (default 50ms)
- Max text length raised to 50,000 characters
- Smart splitting respects abbreviations, CJK punctuation, and `[tags]`
#### Asynchronous Generation Queue ([#269](https://github.com/jamiepine/voicebox/pull/269))
Generation is now fully non-blocking. Serial execution queue prevents GPU contention. Real-time SSE status streaming.
#### Generation Versions
Every generation now supports multiple versions with provenance tracking — original, effects versions, takes, source tracking, version pinning in stories, and favorites.
### Post-Processing Effects ([#271](https://github.com/jamiepine/voicebox/pull/271))
A full audio effects system powered by Spotify's `pedalboard` library: Pitch Shift, Reverb, Delay, Chorus/Flanger, Compressor, Gain, High-Pass Filter, Low-Pass Filter. 4 built-in presets, custom presets, per-profile default effects, and live preview.
### Platform Support
- **Windows Support** ([#272](https://github.com/jamiepine/voicebox/pull/272)) — Full Windows support with CUDA GPU detection
- **Linux** ([#262](https://github.com/jamiepine/voicebox/pull/262)) — AMD ROCm, NVIDIA GBM fix, WebKitGTK mic access (build from source)
- **NVIDIA CUDA Backend Swap** ([#252](https://github.com/jamiepine/voicebox/pull/252)) — Download and swap in CUDA backend from within the app
- **Intel Arc (XPU) and DirectML** — PyTorch backend supports Intel Arc and DirectML
- **Docker + Web Deployment** ([#161](https://github.com/jamiepine/voicebox/pull/161)) — 3-stage build, non-root runtime, health checks
- **Whisper Turbo** — Added `openai/whisper-large-v3-turbo` as a transcription model option
### Model Management ([#268](https://github.com/jamiepine/voicebox/pull/268))
Per-model unload, custom models directory, model folder migration, download cancel/clear UI ([#238](https://github.com/jamiepine/voicebox/pull/238)), restructured settings UI.
### Security & Reliability
- CORS hardening ([#88](https://github.com/jamiepine/voicebox/pull/88))
- Network access toggle ([#133](https://github.com/jamiepine/voicebox/pull/133))
- Offline crash fix ([#152](https://github.com/jamiepine/voicebox/pull/152))
- Atomic audio saves ([#263](https://github.com/jamiepine/voicebox/pull/263))
- Filesystem health endpoint
- Chatterbox float64 dtype fix ([#264](https://github.com/jamiepine/voicebox/pull/264))
### Accessibility ([#243](https://github.com/jamiepine/voicebox/pull/243))
Screen reader support, keyboard navigation, state-aware `aria-label` attributes on all interactive controls.
### UI Polish
- Redesigned landing page ([#274](https://github.com/jamiepine/voicebox/pull/274))
- Voices tab overhaul with inline inspector
- Responsive layout improvements
- Duplicate profile name validation ([#175](https://github.com/jamiepine/voicebox/pull/175))
### Community Contributors
[@haosenwang1018](https://github.com/haosenwang1018), [@Balneario-de-Cofrentes](https://github.com/Balneario-de-Cofrentes), [@ageofalgo](https://github.com/ageofalgo), [@mikeswann](https://github.com/mikeswann), [@rayl15](https://github.com/rayl15), [@mpecanha](https://github.com/mpecanha), [@ways2read](https://github.com/ways2read), [@ieguiguren](https://github.com/ieguiguren), [@Vaibhavee89](https://github.com/Vaibhavee89), [@pandego](https://github.com/pandego), [@luminest-llc](https://github.com/luminest-llc)
## [0.1.13] - 2026-02-23
### Stability and reliability
- [#95](https://github.com/jamiepine/voicebox/pull/95) Fix: selecting 0.6B model still downloads and uses 1.7B
- [#93](https://github.com/jamiepine/voicebox/pull/93) fix(mlx): bundle native libs and broaden error handling for Apple Silicon
- [#79](https://github.com/jamiepine/voicebox/pull/79) fix: handle non-ASCII filenames in Content-Disposition headers
- [#78](https://github.com/jamiepine/voicebox/pull/78) fix: guard getUserMedia call against undefined mediaDevices in non-secure contexts
- [#77](https://github.com/jamiepine/voicebox/pull/77) fix: await for confirmation before deleting voices and channels
- [#128](https://github.com/jamiepine/voicebox/pull/128) fix: resolve multiple issues (#96, #119, #111, #108, #121, #125, #127)
- [#40](https://github.com/jamiepine/voicebox/pull/40) Fix: audio export path resolution
### Build and packaging
- [#122](https://github.com/jamiepine/voicebox/pull/122) fix(web): add @tailwindcss/vite plugin to web config
- [#126](https://github.com/jamiepine/voicebox/pull/126) Create requirements.txt
### UX and docs
- [#44](https://github.com/jamiepine/voicebox/pull/44) Enhances floating generate box UX
- [#57](https://github.com/jamiepine/voicebox/pull/57) chore: updates repo URL in README
- [#146](https://github.com/jamiepine/voicebox/pull/146) Add Spacebot banner to landing page
- [#1](https://github.com/jamiepine/voicebox/pull/1) Improvements
## [0.1.12] - 2026-01-31
### Model Download UX Overhaul
- Real-time download progress tracking with accurate percentage and speed info
- No more downloading notifications during generation even when its not downloading
- Better error handling and status reporting throughout the download process
### Other Improvements
- Enhanced health check endpoint with GPU type information
- Improved model caching verification
- More reliable SSE progress updates
- Actual update notifications — no need to manually check in settings anymore
## [0.1.11] - 2026-01-30
- Fixed transcriptions on MLX
- Fixed model download progress (finally)
## [0.1.10] - 2026-01-30
### Faster generation on Apple Silicon
Massive speed gains, from around 20s per generation to 2-3s. Added native MLX backend support for Apple Silicon, providing significantly faster TTS and STT generation on M-series macOS machines.
- **MLX Backend** — New backend implementation optimized for Apple Silicon using MLX framework
- **Dynamic Backend Selection** — Automatically detects platform and selects between MLX (macOS) and PyTorch (other platforms)
- Refactored TTS and STT logic into modular backend implementations
- Updated build process to include MLX-specific dependencies for macOS builds
## [0.1.9] - 2026-01-30
### Improved voice profile creation flow
- Voice create drafts: No longer lose work if you close the modal
- Fixed whisper only transcribing English or Chinese, now has support for all languages
### Improved Stories editor
- Added spacebar for play/pause
- Timeline now auto-scrolls to follow playhead during playback
- Fixed misalignment of the items with mouse when picking up
- Fixed hitbox for selecting an item
- Fixed playhead jumping forward when pressing play
### Generation box improvements
- Instruct mode no longer wipes prompt text
- Improved UI cleanliness
### Misc
- Fixed "Model downloading" toast during generation when model is already downloaded
## [0.1.8] - 2026-01-29
### Model Download Timeout Issues
Fixed critical issue where model downloads would fail with "Failed to fetch" errors on Windows. Refactored download endpoints to return immediately and continue downloads in background.
### Cross-Platform Cache Path Issues
Fixed hardcoded `~/.cache/huggingface/hub` paths that don't work on Windows. All cache paths now use `hf_constants.HF_HUB_CACHE` for proper cross-platform support.
### Windows Process Management
- Added `/shutdown` endpoint for graceful server shutdown on Windows
- Added `gpu_type` field to health check response
## [0.1.7] - 2026-01-29
- Trim and split audio clips in Story Editor
- Auto-activation of stories in Story Editor with visible playhead
- Conditional auto-play support in AudioPlayer for better user control
- Refactored audio loading across HistoryTable, SampleList, and generation forms
- Audio now only auto-plays when explicitly intended, preventing unexpected playback
## [0.1.6] - 2026-01-29
### Introducing Stories
A full voice editor for composing podcasts and generated conversations.
- **Stories Editor** — Create multi-voice narratives, podcasts, or conversations with a timeline-based editor
- Compose tracks with different voices
- Edit and arrange audio segments inline
- Build generated conversations with multiple participants
- **Improved Voice Generation UI** — Auto-resizing input, default voice selection, better layout
- **Track Editor Integration** — Inline track editing within story items
## [0.1.5] - 2026-01-28
Fixed recording length limit at 0:29 to auto stop instead of passing the limit and getting an error, which would cause users to lose their recording.
## [0.1.4] - 2026-01-28
- Audio channel management system
- Native audio playback handling in AudioPlayer component
- Refactored ConnectionForm and Checkbox components
- Improved layout consistency and responsiveness
- Added safe area constants for better responsive design
## [0.1.3] - 2026-01-27
- Improved the generate textbox
- Maybe fixed Windows autoupdate restarting entire computer
## [0.1.2] - 2026-01-27
### Audio Capture & Format Conversion
- Added audio format conversion util
- Enhanced system audio capture on macOS and Windows
- Improved audio recording hooks
- Added audio input entitlement for macOS
- Added audio capture tests
### Update System
- Enhanced auto-updater functionality and update status display
## [0.1.1] - 2026-01-27
### Platform Support
- **macOS Audio Capture** — Native audio capture support for sample creation
- **Windows Audio Capture** — WASAPI implementation with improved thread safety
- **Linux Support** — Temporarily removed builds due to runner disk space constraints
### Audio Features
- Play/pause for audio samples across all components
- Three new sample components: Recording, System capture, Upload with drag-and-drop
- Audio validation, error handling, and consistent cleanup
### Voice Profile Management
- Profile import with file size validation (100MB limit)
- Enhanced profile form with new audio sample components
- Drag-and-drop support for audio file uploads
### Server Management
- Changed default URL from `localhost:8000` to `127.0.0.1:17493`
- Server reuse logic, "keep server running" preference, orphaned process handling
### Build & Release
- Added `.bumpversion.cfg` for automated version management
- Enhanced icon generation script for multi-size Windows icons
### Bug Fixes
- Fixed date formatting for timezone-less date strings
- Fixed getLatestRelease file filtering
- Improved audio duration metadata on Windows
## [0.1.0] - 2026-01-27
The first public release of Voicebox — an open-source voice synthesis studio powered by Qwen3-TTS.
### Voice Cloning with Qwen3-TTS
- Automatic model download from HuggingFace
- Multiple model sizes (1.7B and 0.6B)
- Voice prompt caching for instant regeneration
- English and Chinese support
### Voice Profile Management
- Create profiles from audio files or record directly in the app
- Multiple samples per profile for higher quality cloning
- Import/Export profiles
- Automatic transcription via Whisper
### Speech Generation
- Simple text-to-speech with profile selection
- Seed control for reproducible generations
- Long-form support up to 5,000 characters
### Generation History
- Full history with metadata
- Search by text content
- Inline playback and download
### Flexible Deployment
- Local mode with bundled backend
- Remote mode for GPU servers on your network
- One-click server setup
### Desktop Experience
- Built with Tauri v2 (Rust) — native performance, not Electron
- Cross-platform: macOS and Windows
- No Python installation required
### Tech Stack
Tauri v2, React, TypeScript, Tailwind CSS, FastAPI, Qwen3-TTS, Whisper, SQLite
[0.5.0]: https://github.com/jamiepine/voicebox/compare/v0.4.5...v0.5.0
[0.4.5]: https://github.com/jamiepine/voicebox/compare/v0.4.4...v0.4.5
[0.4.4]: https://github.com/jamiepine/voicebox/compare/v0.4.3...v0.4.4
[0.4.3]: https://github.com/jamiepine/voicebox/compare/v0.4.2...v0.4.3
[0.4.2]: https://github.com/jamiepine/voicebox/compare/v0.4.1...v0.4.2
[0.4.1]: https://github.com/jamiepine/voicebox/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/jamiepine/voicebox/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/jamiepine/voicebox/compare/v0.2.3...v0.3.0
[0.2.3]: https://github.com/jamiepine/voicebox/compare/v0.2.2...v0.2.3
[0.2.2]: https://github.com/jamiepine/voicebox/compare/v0.2.1...v0.2.2
[0.2.1]: https://github.com/jamiepine/voicebox/compare/v0.1.13...v0.2.1
[0.1.13]: https://github.com/jamiepine/voicebox/compare/v0.1.12...v0.1.13
[0.1.12]: https://github.com/jamiepine/voicebox/compare/v0.1.11...v0.1.12
[0.1.11]: https://github.com/jamiepine/voicebox/compare/v0.1.10...v0.1.11
[0.1.10]: https://github.com/jamiepine/voicebox/compare/v0.1.9...v0.1.10
[0.1.9]: https://github.com/jamiepine/voicebox/compare/v0.1.8...v0.1.9
[0.1.8]: https://github.com/jamiepine/voicebox/compare/v0.1.7...v0.1.8
[0.1.7]: https://github.com/jamiepine/voicebox/compare/v0.1.6...v0.1.7
[0.1.6]: https://github.com/jamiepine/voicebox/compare/v0.1.5...v0.1.6
[0.1.5]: https://github.com/jamiepine/voicebox/compare/v0.1.4...v0.1.5
[0.1.4]: https://github.com/jamiepine/voicebox/compare/v0.1.3...v0.1.4
[0.1.3]: https://github.com/jamiepine/voicebox/compare/v0.1.2...v0.1.3
[0.1.2]: https://github.com/jamiepine/voicebox/compare/v0.1.1...v0.1.2
[0.1.1]: https://github.com/jamiepine/voicebox/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/jamiepine/voicebox/releases/tag/v0.1.0
+392
View File
@@ -0,0 +1,392 @@
# Contributing to Voicebox
Thank you for your interest in contributing to Voicebox! This document provides guidelines and instructions for contributing.
## Code of Conduct
- Be respectful and inclusive
- Welcome newcomers and help them learn
- Focus on constructive feedback
- Respect different viewpoints and experiences
## Getting Started
### Prerequisites
- **[Bun](https://bun.sh)** - Fast JavaScript runtime and package manager
```bash
curl -fsSL https://bun.sh/install | bash
```
- **[Python 3.11+](https://python.org)** - For backend development
```bash
python --version # Should be 3.11 or higher
```
- **[Rust](https://rustup.rs)** - For Tauri desktop app (installed automatically by Tauri CLI)
```bash
rustc --version # Check if installed
```
- **[Tauri Prerequisites](https://v2.tauri.app/start/prerequisites)** - Tauri-specific system dependencies (varies by OS).
- **Git** - Version control
### Development Setup
Install [just](https://github.com/casey/just) (`brew install just`, `cargo install just`, or `winget install Casey.Just`), then:
```bash
git clone https://github.com/YOUR_USERNAME/voicebox.git
cd voicebox
just setup # creates venv, installs Python + JS deps
just dev # starts backend + desktop app
```
`just setup` handles everything automatically, including:
- Creating a Python virtual environment
- Installing Python dependencies (with CUDA PyTorch on Windows if an NVIDIA GPU is detected)
- Installing MLX dependencies on Apple Silicon
- Installing JavaScript dependencies
`just dev` starts the backend and desktop app together. If a backend is already running (e.g. from `just dev-backend` in another terminal), it detects it and only starts the frontend.
Other useful commands:
```bash
just dev-web # backend + web app (no Tauri/Rust build)
just dev-backend # backend only
just dev-frontend # Tauri app only (backend must be running)
just kill # stop all dev processes
just clean-all # nuke everything and start fresh
just --list # see all available commands
```
> **Note:** In dev mode, the app connects to a manually-started Python server.
> The bundled server binary is only used in production builds.
#### Windows Notes
The justfile works natively on Windows via PowerShell. No WSL or Git Bash required. On Windows with an NVIDIA GPU, `just setup` automatically installs CUDA-enabled PyTorch for GPU acceleration.
### Model Downloads
Models are automatically downloaded from HuggingFace Hub on first use:
- **Whisper** (transcription): Auto-downloads on first transcription
- **Qwen3-TTS** (voice cloning): Auto-downloads on first generation (~2-4GB)
First-time usage will be slower due to model downloads, but subsequent runs will use cached models.
### Building
**Build production app:**
```bash
just build # Build CPU server binary + Tauri installer
```
On Windows, to build with CUDA support for local testing:
```bash
just build-local # Build CPU + CUDA server binaries + Tauri installer
```
This builds the CPU sidecar (bundled with the app), the CUDA binary (placed in `%APPDATA%/com.voicebox.app/backends/` for runtime GPU switching), and the installable Tauri app.
Creates platform-specific installers (`.dmg`, `.msi`, `.AppImage`) in `tauri/src-tauri/target/release/bundle/`.
**Individual build targets:**
```bash
just build-server # CPU server binary only
just build-server-cuda # CUDA server binary only (Windows)
just build-tauri # Tauri desktop app only
just build-web # Web app only
```
**Building with local Qwen3-TTS development version:**
If you're actively developing or modifying the Qwen3-TTS library, set the `QWEN_TTS_PATH` environment variable to point to your local clone:
```bash
export QWEN_TTS_PATH=~/path/to/your/Qwen3-TTS
just build-server
```
This makes PyInstaller use your local qwen-tts version instead of the pip-installed package.
### Generate OpenAPI Client
After starting the backend server:
```bash
./scripts/generate-api.sh
```
This downloads the OpenAPI schema and generates the TypeScript client in `app/src/lib/api/`
### Convert Assets to Web Formats
To optimize images and videos for the web, run:
```bash
bun run convert:assets
```
This script:
- Converts PNG → WebP (better compression, same quality)
- Converts MOV → WebM (VP9 codec, smaller file size)
- Processes files in `landing/public/` and `docs/public/`
- **Deletes original files** after successful conversion
**Requirements:** Install `webp` and `ffmpeg`:
```bash
brew install webp ffmpeg
```
> **Note:** Run this before committing new images or videos to keep the repository size small.
## Development Workflow
### 1. Create a Branch
```bash
git checkout -b feature/your-feature-name
# or
git checkout -b fix/your-bug-fix
```
### 2. Make Your Changes
- Write clean, readable code
- Follow existing code style
- Add comments for complex logic
- Update documentation as needed
### 3. Test Your Changes
- Test manually in the app
- Ensure backend API endpoints work
- Check for TypeScript/Python errors
- Verify UI components render correctly
### 4. Commit Your Changes
Write clear, descriptive commit messages:
```bash
git commit -m "Add feature: voice profile export"
git commit -m "Fix: audio playback stops after 30 seconds"
```
### 5. Push and Create Pull Request
```bash
git push origin feature/your-feature-name
```
Then create a pull request on GitHub with:
- Clear description of changes
- Screenshots (for UI changes)
- Reference to related issues
## Code Style
### TypeScript/React
- Use TypeScript strict mode
- Follow React best practices
- Use functional components with hooks
- Prefer named exports
- Format with Biome (runs automatically)
```typescript
// Good
export function ProfileCard({ profile }: { profile: Profile }) {
return <div>{profile.name}</div>;
}
// Avoid
export const ProfileCard = (props) => { ... }
```
### Python
- Follow PEP 8 style guide
- Use type hints
- Use async/await for I/O operations
- Format with Black (if configured)
```python
# Good
async def create_profile(name: str, language: str) -> Profile:
"""Create a new voice profile."""
...
# Avoid
def create_profile(name, language):
...
```
### Rust
- Follow Rust conventions
- Use meaningful variable names
- Handle errors explicitly
- Format with `rustfmt`
## Project Structure
```
voicebox/
├── app/ # Shared React frontend
│ └── src/
│ ├── components/ # UI components
│ ├── lib/ # Utilities and API client
│ └── hooks/ # React hooks
├── backend/ # Python FastAPI server
│ ├── main.py # API routes
│ ├── tts.py # Voice synthesis
│ └── ...
├── tauri/ # Desktop app wrapper
│ └── src-tauri/ # Rust backend
└── scripts/ # Build scripts
```
## Areas for Contribution
### 🐛 Bug Fixes
- Check existing issues for bugs to fix
- Test your fix thoroughly
- Add tests if possible
### ✨ New Features
- Check the roadmap in README.md and the engineering status in [`docs/PROJECT_STATUS.md`](docs/PROJECT_STATUS.md) before proposing work — it lists prioritized tasks (Tier 1 → 3), known architectural bottlenecks, and candidate TTS engines already under evaluation (including why some have been backlogged)
- Discuss major features in an issue first
- Keep features focused and well-scoped
### 📚 Documentation
- Improve README clarity
- Add code comments
- Write API documentation
- Create tutorials or guides
### 🎨 UI/UX Improvements
- Improve accessibility
- Enhance visual design
- Optimize performance
- Add animations/transitions
### 🔧 Infrastructure
- Improve build process
- Add CI/CD improvements
- Optimize bundle size
- Add testing infrastructure
## API Development
When adding new API endpoints:
1. **Add route in `backend/main.py`**
2. **Create Pydantic models in `backend/models.py`**
3. **Implement business logic in appropriate module**
4. **Update OpenAPI schema** (automatic with FastAPI)
5. **Regenerate TypeScript client:**
```bash
bun run generate:api
```
6. **Update `backend/README.md`** with endpoint documentation
## Testing
Currently, testing is primarily manual. When adding tests:
- **Backend**: Use pytest for Python tests
- **Frontend**: Use Vitest for React component tests
- **E2E**: Use Playwright for end-to-end tests (future)
## Pull Request Process
1. **Update documentation** if needed
2. **Ensure code follows style guidelines**
3. **Test your changes thoroughly**
4. **Update CHANGELOG.md** with your changes
5. **Request review** from maintainers
### PR Checklist
- [ ] Code follows style guidelines
- [ ] Documentation updated
- [ ] Changes tested
- [ ] No breaking changes (or documented)
- [ ] CHANGELOG.md updated
## Release Process
Releases are managed by maintainers:
1. **Bump version using bumpversion:**
```bash
# Install bumpversion (if not already installed)
pip install bumpversion
# Bump patch version (0.1.0 -> 0.1.1)
bumpversion patch
# Or bump minor version (0.1.0 -> 0.2.0)
bumpversion minor
# Or bump major version (0.1.0 -> 1.0.0)
bumpversion major
```
This automatically:
- Updates version numbers in all files (`tauri.conf.json`, `Cargo.toml`, all `package.json` files, `backend/main.py`)
- Creates a git commit with the version bump
- Creates a git tag (e.g., `v0.1.1`, `v0.2.0`)
2. **Update CHANGELOG.md** with release notes
3. **Push commits and tags:**
```bash
git push
git push --tags
```
4. **GitHub Actions builds and releases** automatically when tags are pushed
## Troubleshooting
See [docs/content/docs/overview/troubleshooting.mdx](docs/content/docs/overview/troubleshooting.mdx) for common issues and solutions.
**Quick fixes:**
- **Backend won't start:** Check Python version (3.11+), ensure venv is activated, install dependencies
- **Tauri build fails:** Ensure Rust is installed, clean build with `cd tauri/src-tauri && cargo clean`
- **OpenAPI client generation fails:** Ensure backend is running, check `curl http://localhost:17493/openapi.json`
## Questions?
- Open an issue for bugs or feature requests
- Check existing issues and discussions
- Review the codebase to understand patterns
- See [docs/content/docs/overview/troubleshooting.mdx](docs/content/docs/overview/troubleshooting.mdx) for common issues
## Additional Resources
- [README.md](README.md) - Project overview
- [backend/README.md](backend/README.md) - API documentation
- [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) - Living engineering roadmap: architecture, shipped vs in-flight work, prioritized open issues, candidate TTS engines under evaluation, architectural bottlenecks. Keep this updated when you ship significant features, close or backlog a model integration, or identify new bottlenecks.
- [docs/AUTOUPDATER_QUICKSTART.md](docs/AUTOUPDATER_QUICKSTART.md) - Auto-updater setup
- [SECURITY.md](SECURITY.md) - Security policy
- [CHANGELOG.md](CHANGELOG.md) - Version history
## License
By contributing, you agree that your contributions will be licensed under the MIT License.
---
Thank you for contributing to Voicebox! 🎉
-447
View File
@@ -1,447 +0,0 @@
# voicebox - Current State Overview
**Last Updated:** January 25, 2026
**Status:** ✅ MVP Core Features Working - Voice generation from Tauri app successful!
---
## 🎯 What We Have
### ✅ **Fully Implemented & Working**
#### **Backend (Python FastAPI)**
- **Voice Profile Management**
- Create, read, update, delete profiles
- Add multiple audio samples per profile
- Multi-reference voice combination (combines multiple samples)
- Profile storage in SQLite + file system (`data/profiles/`)
- **Voice Generation**
- Qwen3-TTS model integration (1.7B and 0.6B support)
- Automatic model downloading from HuggingFace Hub
- Voice prompt caching for instant re-generation
- Support for English and Chinese
- Seed-based reproducibility
- GPU/CPU/MPS device detection
- **Generation History**
- Full CRUD operations
- Search by text content
- Filter by profile
- Pagination support
- Statistics endpoint
- Audio file storage (`data/generations/`)
- **Audio Transcription**
- Whisper integration for speech-to-text
- Language detection/selection
- Used for reference text extraction from samples
- **Database**
- SQLite with SQLAlchemy ORM
- Tables: `profiles`, `profile_samples`, `generations`, `projects` (ready for future)
- Automatic schema initialization
- **API Endpoints**
- RESTful API with FastAPI
- OpenAPI schema generation
- CORS enabled
- Health check endpoint
- File serving for audio files
#### **Frontend (React + TypeScript + Tauri)**
- **Voice Profile UI**
- Profile list with cards
- Create/edit profile dialog
- Upload audio samples with transcription
- Sample management (view/delete)
- Profile detail view
- **Generation UI**
- Form with profile selection
- Text input (up to 5000 chars)
- Language selection (en/zh)
- Optional seed input
- Loading states and error handling
- **History UI**
- Table view with pagination
- Search functionality
- Play audio inline
- Download audio files
- Delete generations
- **Server Settings**
- Connection form (local/remote mode)
- Server status display
- Health check integration
- **State Management**
- React Query for server state
- Zustand for client state (server URL, connection status)
- Type-safe API client
- **UI Components**
- shadcn/ui component library
- Tailwind CSS styling
- Responsive design
- Toast notifications
- Form validation with Zod
#### **Tauri Desktop App**
- **Rust Backend**
- Sidecar management for Python server
- Start/stop server commands
- Remote mode support (0.0.0.0 binding)
- Process lifecycle management
- **Build System**
- Tauri v2 configuration
- Platform-specific builds
- Dev tools in debug mode
---
## 🏗️ Architecture
### **Project Structure**
```
voicebox/
├── app/ # Shared React frontend
│ ├── src/
│ │ ├── components/ # React components
│ │ │ ├── VoiceProfiles/ ✅ Complete
│ │ │ ├── Generation/ ✅ Complete
│ │ │ ├── History/ ✅ Complete
│ │ │ ├── ServerSettings/ ✅ Complete
│ │ │ └── AudioStudio/ 📦 Placeholder (future)
│ │ ├── lib/
│ │ │ ├── api/ # Type-safe API client ✅
│ │ │ ├── hooks/ # React Query hooks ✅
│ │ │ └── utils/ # Utilities ✅
│ │ └── stores/ # Zustand stores ✅
│
├── backend/ # Python FastAPI server
│ ├── main.py # FastAPI app + routes ✅
│ ├── models.py # Pydantic models ✅
│ ├── database.py # SQLAlchemy ORM ✅
│ ├── profiles.py # Profile management ✅
│ ├── history.py # History management ✅
│ ├── tts.py # Qwen3-TTS integration ✅
│ ├── transcribe.py # Whisper integration ✅
│ ├── studio.py # Audio studio (future)
│ └── utils/
│ ├── audio.py # Audio processing ✅
│ ├── cache.py # Voice prompt caching ✅
│ └── validation.py # Validation helpers ✅
│
├── tauri/ # Tauri desktop wrapper
│ ├── src/ # React entry point ✅
│ └── src-tauri/ # Rust backend ✅
│ └── src/main.rs # Sidecar management ✅
│
├── data/ # User data directory
│ ├── profiles/ # Profile audio samples
│ ├── generations/ # Generated audio files
│ ├── cache/ # Cached voice prompts
│ └── voicebox.db # SQLite database
│
└── scripts/ # Build & generation scripts
├── generate-api.sh # OpenAPI client generation
└── build-server.sh # Python binary build
```
### **Data Flow**
```
User Action (Tauri App)
↓
React Component (Form Submit)
↓
React Query Hook (useGeneration)
↓
API Client (apiClient.generateSpeech)
↓
HTTP Request → FastAPI Backend
↓
Backend Route Handler (/generate)
↓
Business Logic:
1. Get profile from DB
2. Create voice prompt (with caching)
3. Generate audio with Qwen3-TTS
4. Save audio file
5. Create history entry
↓
Response (GenerationResponse)
↓
React Query Cache Update
↓
UI Refresh (History table updates)
```
### **Key Technologies**
| Layer | Technology | Purpose |
|-------|-----------|---------|
| **Desktop Framework** | Tauri v2 | Native desktop app wrapper |
| **Frontend Framework** | React 18 | UI components |
| **Language** | TypeScript | Type safety |
| **Styling** | Tailwind CSS | Utility-first CSS |
| **UI Components** | shadcn/ui | Component library |
| **State Management** | React Query + Zustand | Server & client state |
| **Form Handling** | React Hook Form + Zod | Form validation |
| **Backend Framework** | FastAPI | Async REST API |
| **Database** | SQLite + SQLAlchemy | Data persistence |
| **ML Models** | Qwen3-TTS + Whisper | Voice cloning + transcription |
| **Audio Processing** | librosa + soundfile | Audio I/O and processing |
| **Package Manager** | Bun | Fast JS/TS package management |
| **Build Tool** | Vite | Frontend bundling |
---
## 🔑 Key Features & Capabilities
### **1. Voice Profile System**
- **Multi-sample support**: Add multiple audio samples per profile
- **Automatic combination**: Multiple samples are combined for better quality
- **Voice prompt caching**: Re-use voice prompts for instant re-generation
- **Audio validation**: Ensures samples meet quality requirements
### **2. Generation Pipeline**
- **Lazy model loading**: Model loads on first use
- **Device detection**: Automatically uses GPU if available
- **Caching layer**: Voice prompts cached by audio hash + text
- **Error handling**: Graceful degradation and clear error messages
### **3. History & Search**
- **Full-text search**: Search generations by text content
- **Pagination**: Efficient loading of large histories
- **Audio playback**: Inline audio player
- **File management**: Download and delete operations
### **4. Server/Client Architecture**
- **Local mode**: Backend runs alongside Tauri app
- **Remote mode**: Connect to remote GPU machine
- **One-click server**: Start server from UI
- **Connection management**: Persistent server URL storage
---
## 📊 Database Schema
### **Tables**
```sql
-- Voice Profiles
profiles
- id (PK, UUID)
- name (unique)
- description
- language (en/zh)
- created_at
- updated_at
-- Profile Samples
profile_samples
- id (PK, UUID)
- profile_id (FK → profiles.id)
- audio_path
- reference_text
-- Generations
generations
- id (PK, UUID)
- profile_id (FK → profiles.id)
- text
- language
- audio_path
- duration (seconds)
- seed (optional)
- created_at
-- Projects (ready for future)
projects
- id (PK, UUID)
- name
- data (JSON)
- created_at
- updated_at
```
---
## 🎨 UI Components Status
| Component | Status | Features |
|-----------|--------|----------|
| **ProfileList** | ✅ Complete | List, create, empty state |
| **ProfileCard** | ✅ Complete | Display profile info |
| **ProfileForm** | ✅ Complete | Create/edit dialog |
| **ProfileDetail** | ✅ Complete | View samples, add samples |
| **SampleUpload** | ✅ Complete | File upload + transcription |
| **GenerationForm** | ✅ Complete | Full generation form |
| **HistoryTable** | ✅ Complete | Table, search, pagination, play/download |
| **ConnectionForm** | ✅ Complete | Server URL input |
| **ServerStatus** | ✅ Complete | Health check display |
| **AudioStudio** | 📦 Placeholder | Timeline editor (future) |
---
## 🔌 API Endpoints
### **Profiles**
- `POST /profiles` - Create profile
- `GET /profiles` - List all profiles
- `GET /profiles/{id}` - Get profile
- `PUT /profiles/{id}` - Update profile
- `DELETE /profiles/{id}` - Delete profile
- `POST /profiles/{id}/samples` - Add sample
- `GET /profiles/{id}/samples` - List samples
- `DELETE /profiles/samples/{id}` - Delete sample
### **Generation**
- `POST /generate` - Generate speech
### **History**
- `GET /history` - List generations (with filters)
- `GET /history/{id}` - Get generation
- `DELETE /history/{id}` - Delete generation
- `GET /history/stats` - Get statistics
### **Transcription**
- `POST /transcribe` - Transcribe audio
### **Audio**
- `GET /audio/{id}` - Serve audio file
### **Health**
- `GET /health` - Health check with model status
### **Model Management**
- `POST /models/load` - Load TTS model
- `POST /models/unload` - Unload TTS model
---
## 🚀 What's Next (Planned Features)
### **Phase 2: Advanced Features**
- [ ] Multi-reference voice combination UI
- [ ] Batch generation (multiple variations)
- [ ] Advanced audio normalization
- [ ] Export options (MP3, OGG, etc.)
- [ ] M3GAN voice effect
### **Phase 3: Audio Studio**
- [ ] Timeline-based audio editor
- [ ] Word-level timestamps
- [ ] Project system (save/load sessions)
- [ ] Audio effects and filters
- [ ] Multi-track editing
### **Phase 4: Voice Design**
- [ ] Text-to-voice (no reference needed)
- [ ] Preset voices with style control
- [ ] Conversation mode (multi-speaker)
- [ ] Custom audio effects library
---
## 📝 Code Quality Standards
- ✅ **Type safety**: TypeScript strict mode, Pydantic models
- ✅ **Modular architecture**: No files over 500 lines
- ✅ **Error handling**: Comprehensive error messages
- ✅ **Caching**: Voice prompt caching for performance
- ✅ **Database**: SQLAlchemy ORM with proper relationships
- ✅ **API design**: RESTful with OpenAPI schema
- ✅ **UI/UX**: Responsive, accessible, loading states
---
## 🧪 Testing Status
- ✅ **Manual testing**: Voice generation working end-to-end
- 📦 **Unit tests**: Not yet implemented
- 📦 **Integration tests**: Not yet implemented
- 📦 **E2E tests**: Not yet implemented
---
## 📦 Dependencies
### **Backend**
- FastAPI - Web framework
- SQLAlchemy - ORM
- Pydantic - Validation
- Qwen3-TTS - Voice cloning model
- Whisper - Speech recognition
- librosa - Audio processing
- soundfile - Audio I/O
- PyTorch - ML framework
### **Frontend**
- React 18 - UI framework
- TypeScript - Type safety
- React Query - Server state
- Zustand - Client state
- React Hook Form - Forms
- Zod - Schema validation
- Tailwind CSS - Styling
- shadcn/ui - Components
- Lucide React - Icons
### **Desktop**
- Tauri v2 - Desktop framework
- Rust - System backend
---
## 🎯 Current Capabilities Summary
✅ **Working End-to-End:**
1. Create voice profiles with audio samples
2. Generate speech from text using cloned voices
3. View and manage generation history
4. Play and download generated audio
5. Search and filter history
6. Connect to local or remote backend
7. Automatic model downloading
8. Voice prompt caching for speed
🎉 **You just successfully generated voice from the Tauri app!**
---
## 🔍 Key Files Reference
### **Backend Core**
- `backend/main.py` - FastAPI app and routes
- `backend/tts.py` - Qwen3-TTS model wrapper
- `backend/profiles.py` - Profile business logic
- `backend/history.py` - History business logic
- `backend/database.py` - Database models
### **Frontend Core**
- `app/src/App.tsx` - Main app component
- `app/src/lib/api/client.ts` - API client
- `app/src/lib/hooks/` - React Query hooks
- `app/src/stores/` - Zustand stores
### **Tauri**
- `tauri/src-tauri/src/main.rs` - Rust backend
- `tauri/src/main.tsx` - React entry point
---
## 💡 Development Workflow
1. **Start backend**: `bun run dev:server` (or via Tauri)
2. **Start frontend**: `bun run dev` (Tauri) or `bun run dev:web` (web)
3. **Generate API client**: `bun run generate:api` (after backend changes)
4. **Build server binary**: `bun run build:server` (for Tauri bundling)
---
**Ready to build more features! 🚀**
+83
View File
@@ -0,0 +1,83 @@
# ============================================================
# Voicebox — Local TTS Server with Web UI (CPU)
# 3-stage build: Frontend → Python deps → Runtime
# ============================================================
# === Stage 1: Build frontend ===
FROM oven/bun:1 AS frontend
WORKDIR /build
# Copy workspace config and frontend source
COPY package.json bun.lock CHANGELOG.md ./
COPY app/ ./app/
COPY web/ ./web/
# Strip workspaces not needed for web build, and fix trailing comma
RUN sed -i '/"tauri"/d; /"landing"/d' package.json && \
sed -i -z 's/,\n ]/\n ]/' package.json
RUN bun install --no-save
# Build frontend (skip tsc — upstream has pre-existing type errors)
RUN cd web && bunx --bun vite build
# === Stage 2: Build Python dependencies ===
FROM python:3.11-slim AS backend-builder
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
build-essential \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir --upgrade pip
COPY backend/requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
RUN pip install --no-cache-dir --prefix=/install --no-deps chatterbox-tts
RUN pip install --no-cache-dir --prefix=/install --no-deps hume-tada
RUN pip install --no-cache-dir --prefix=/install \
git+https://github.com/QwenLM/Qwen3-TTS.git
# === Stage 3: Runtime ===
FROM python:3.11-slim
# Create non-root user for security
RUN groupadd -r voicebox && \
useradd -r -g voicebox -m -s /bin/bash voicebox
WORKDIR /app
# Install only runtime system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy installed Python packages from builder stage
COPY --from=backend-builder /install /usr/local
# Copy backend application code
COPY --chown=voicebox:voicebox backend/ /app/backend/
# Copy built frontend from frontend stage
COPY --from=frontend --chown=voicebox:voicebox /build/web/dist /app/frontend/
# Create data directories owned by non-root user
RUN mkdir -p /app/data/generations /app/data/profiles /app/data/cache \
&& chown -R voicebox:voicebox /app/data
# Switch to non-root user
USER voicebox
# Expose the API port
EXPOSE 17493
# Health check — auto-restart if the server hangs
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s \
CMD curl -f http://localhost:17493/health || exit 1
# Start the FastAPI server
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "17493"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Voicebox Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+399 -339
View File
@@ -1,409 +1,469 @@
# voicebox
<p align="center">
<img src=".github/assets/icon-dark.webp" alt="Voicebox" width="120" height="120" />
</p>
A production-quality desktop app for Qwen3-TTS voice cloning and generation.
<h1 align="center">Voicebox</h1>
**Domain:** voicebox.sh
<p align="center">
<strong>The open-source AI voice studio.</strong><br/>
Clone any voice. Generate speech. Dictate into any app. Talk to agents in voices you own.<br/>
The full voice I/O stack, running locally on your machine.
</p>
<p align="center">
<a href="https://github.com/jamiepine/voicebox/releases">
<img src="https://img.shields.io/github/downloads/jamiepine/voicebox/total?style=flat&color=blue" alt="Downloads" />
</a>
<a href="https://github.com/jamiepine/voicebox/releases/latest">
<img src="https://img.shields.io/github/v/release/jamiepine/voicebox?style=flat" alt="Release" />
</a>
<a href="https://github.com/jamiepine/voicebox/stargazers">
<img src="https://img.shields.io/github/stars/jamiepine/voicebox?style=flat" alt="Stars" />
</a>
<a href="https://github.com/jamiepine/voicebox/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/jamiepine/voicebox?style=flat" alt="License" />
</a>
<a href="https://deepwiki.com/jamiepine/voicebox">
<img src="https://img.shields.io/static/v1?label=Ask&message=DeepWiki&color=5B6EF7" alt="Ask DeepWiki" />
</a>
</p>
<p align="center">
<a href="https://voicebox.sh">voicebox.sh</a> •
<a href="https://docs.voicebox.sh">Docs</a> •
<a href="#download">Download</a> •
<a href="#features">Features</a> •
<a href="#api">API</a> •
<a href="docs/content/docs/overview/troubleshooting.mdx">Troubleshooting</a>
</p>
<br/>
<p align="center">
<a href="https://voicebox.sh">
<img src="landing/public/assets/app-screenshot-1.webp" alt="Voicebox App Screenshot" width="800" />
</a>
</p>
<p align="center">
<em>Click the image above to watch the demo video on <a href="https://voicebox.sh">voicebox.sh</a></em>
</p>
<br/>
<p align="center">
<img src="landing/public/assets/app-screenshot-2.webp" alt="Voicebox Screenshot 2" width="800" />
</p>
<p align="center">
<img src="landing/public/assets/app-screenshot-3.webp" alt="Voicebox Screenshot 3" width="800" />
</p>
<br/>
## What is Voicebox?
Voicebox is a **local-first AI voice studio** — a free and open-source alternative to **ElevenLabs** and **WisprFlow** in one app. Clone voices from a few seconds of audio, generate speech in 23 languages across 7 TTS engines, dictate into any text field with a global hotkey, and give any MCP-aware AI agent a voice of your choosing.
The two cloud incumbents sit on opposite halves of the voice I/O loop — ElevenLabs on output, WisprFlow on input. Voicebox does both, bridges them with a bundled local LLM for refinement and per-profile personas, and runs the whole thing on your machine.
- **Complete privacy** — models, voice data, and captures never leave your machine
- **7 TTS engines** — Qwen3-TTS, Qwen CustomVoice, LuxTTS, Chatterbox Multilingual, Chatterbox Turbo, HumeAI TADA, and Kokoro
- **Voice cloning and preset voices** — zero-shot cloning from a reference sample, or 50+ curated preset voices via Kokoro and Qwen CustomVoice
- **23 languages** — from English to Arabic, Japanese, Hindi, Swahili, and more
- **Post-processing effects** — pitch shift, reverb, delay, chorus, compression, and filters
- **Expressive speech** — paralinguistic tags like `[laugh]`, `[sigh]`, `[gasp]` via Chatterbox Turbo; natural-language delivery control via Qwen CustomVoice
- **Unlimited length** — auto-chunking with crossfade for scripts, articles, and chapters
- **Stories editor** — multi-track timeline for conversations, podcasts, and narratives
- **Voice input** — global dictation hotkey with push-to-talk and toggle modes, accessibility-verified auto-paste on macOS, in-app mic on every text field, Whisper-based STT
- **Agent voice output** — one tool call (`voicebox.speak`) and any MCP-aware agent (Claude Code, Cursor, Cline) speaks to you in a voice you've cloned
- **Voice personalities** — attach a free-form persona to any voice profile, then Compose, Rewrite, or Respond via a bundled local LLM — agents can invoke the same modes over MCP
- **API-first** — REST API plus a built-in MCP server for integrating voice I/O into your own apps and agents
- **Native performance** — built with Tauri (Rust), not Electron
- **Runs everywhere** — macOS (MLX/Metal), Windows (CUDA), Linux, AMD ROCm, Intel Arc, Docker
---
## Vision
## Download
Qwen3-TTS is a breakthrough model from Alibaba that achieves near-perfect voice cloning. The existing implementations (Voice-Clone-Studio, mimic, etc.) are either feature-rich but architecturally messy, or well-structured but limited in scope.
| Platform | Download |
| --------------------- | ------------------------------------------------------ |
| macOS (Apple Silicon) | [Download DMG](https://voicebox.sh/download/mac-arm) |
| macOS (Intel) | [Download DMG](https://voicebox.sh/download/mac-intel) |
| Windows | [Download MSI](https://voicebox.sh/download/windows) |
| Docker | `docker compose up` |
voicebox aims to build the definitive Qwen3-TTS application by combining the best patterns from existing projects while avoiding their architectural mistakes.
> **[View all binaries →](https://github.com/jamiepine/voicebox/releases/latest)**
## Design Principles
> **Linux** — Pre-built binaries are not yet available. See [voicebox.sh/linux-install](https://voicebox.sh/linux-install) for build-from-source instructions.
1. **Clean architecture from day one** - No monolithic files, proper separation of concerns
2. **Desktop-first experience** - Native feel via Tauri, not a web app in disguise
3. **Production code quality** - Type safety, modularity, maintainability
4. **Performance and UX** - Smart caching, async operations, responsive UI
5. **Extensible design** - Easy to add new models, effects, and features
6. **Flexible deployment** - Run backend locally or connect to remote GPU machine with one click
> **Having trouble?** See the [Troubleshooting Guide](docs/content/docs/overview/troubleshooting.mdx) for common install, generation, model-download, and GPU issues.
## Technology Stack
---
### Backend (Python)
- **FastAPI** - Async REST API
- **SQLAlchemy** - Database ORM with migrations
- **Pydantic** - Request/response validation
- **Qwen3-TTS** - Voice cloning model
- **Whisper** - Speech-to-text transcription
- **librosa + soundfile** - Audio processing
## Features
### Frontend (Tauri + TypeScript)
- **Tauri** - Native desktop framework
- **React** - UI framework
- **TypeScript** - Type safety throughout
- **Bun** - Fast package manager and JavaScript runtime
- **React Query** - Server state management and API calls
- **OpenAPI (generated)** - Type-safe API client from FastAPI schema
- **Tailwind CSS** - Styling
- **Zustand** - Client-side state management
- **WaveSurfer.js** - Audio visualization
### Multi-Engine Voice Cloning
### Database
- **SQLite** - Local storage
- **Alembic** - Schema migrations
Seven TTS engines with different strengths, switchable per-generation:
## Server/Client Mode
| Engine | Languages | Strengths |
| --------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| **Qwen3-TTS** (0.6B / 1.7B) | 10 | High-quality multilingual cloning, delivery instructions ("speak slowly", "whisper") |
| **Qwen CustomVoice** | 10 | 9 curated preset voices with natural-language delivery control — no reference audio required |
| **LuxTTS** | English | Lightweight (~1GB VRAM), 48kHz output, 150x realtime on CPU |
| **Chatterbox Multilingual** | 23 | Broadest language coverage — Arabic, Danish, Finnish, Greek, Hebrew, Hindi, Malay, Norwegian, Polish, Swahili, Swedish, Turkish and more |
| **Chatterbox Turbo** | English | Fast 350M model with paralinguistic emotion/sound tags |
| **TADA** (1B / 3B) | 10 | HumeAI speech-language model — 700s+ coherent audio, text-acoustic dual alignment |
| **Kokoro** | 8 | 50 curated preset voices, tiny 82M model, fast CPU inference |
voicebox supports flexible deployment for users with multiple machines:
### Emotions & Paralinguistic Tags
### Local Mode (Default)
- Backend runs locally alongside the Tauri app
- Best for users with GPU on their primary machine
Only **Chatterbox Turbo** interprets paralinguistic tags like `[laugh]` and
`[sigh]`. Qwen3-TTS, LuxTTS, Chatterbox Multilingual, and HumeAI TADA read them
literally as text.
### Remote Mode (One-Click Setup)
- **Use case:** Your laptop doesn't have a GPU, but your desktop does
- **Server:** Run voicebox on GPU machine, click "Start Server"
- Starts FastAPI backend on local network
- Shows connection URL (e.g., `http://192.168.1.100:8000`)
- **Client:** Run voicebox on laptop, enter server URL
- Connects to remote backend
- Full UI functionality, inference happens on GPU machine
- **Security:** Local network only for now (no internet exposure)
With **Chatterbox Turbo** selected, type `/` in the text input to open the tag
inserter and add expressive tags inline with speech:
### How It Works
```
┌─────────────────┐ ┌─────────────────┐
│ Laptop │ │ Desktop │
│ (Client) │ │ (Server) │
│ │ │ │
│ Tauri App ────────────────▶ FastAPI │
│ React UI │ HTTP │ Qwen3-TTS │
│ │ │ SQLite │
│ │ │ CUDA/GPU │
└─────────────────┘ └─────────────────┘
```
`[laugh]` `[chuckle]` `[gasp]` `[cough]` `[sigh]` `[groan]` `[sniff]` `[shush]` `[clear throat]`
**Benefits:**
- Use powerful GPU machine from lightweight laptop
- No complex setup - just click "Start Server"
- All data (history, profiles) lives on server
- Client is just a UI - no local storage needed in remote mode
### Post-Processing Effects
## Core Features
8 audio effects powered by Spotify's `pedalboard` library. Apply after generation, preview in real time, build reusable presets.
### Phase 1 (MVP)
- Voice profile management
- Single-reference voice cloning
- Generation history with search
- Basic audio playback and preview
- Server/client mode (local network)
- One-click server startup
| Effect | Description |
| ---------------- | --------------------------------------------- |
| Pitch Shift | Up or down by up to 12 semitones |
| Reverb | Configurable room size, damping, wet/dry mix |
| Delay | Echo with adjustable time, feedback, and mix |
| Chorus / Flanger | Modulated delay for metallic or lush textures |
| Compressor | Dynamic range compression |
| Gain | Volume adjustment (-40 to +40 dB) |
| High-Pass Filter | Remove low frequencies |
| Low-Pass Filter | Remove high frequencies |
### Phase 2
- Multi-reference voice combination
- Batch variation generation
- Advanced audio normalization
- Export options and formats
Ships with 4 built-in presets (Robotic, Radio, Echo Chamber, Deep Voice) and supports custom presets. Effects can be assigned per-profile as defaults.
### Phase 3
- Audio studio with timeline editing
- Word-level timestamps
- Project system (save/load sessions)
- Export options
### Unlimited Generation Length
### Phase 4
- Voice design (text-to-voice)
- Preset voices with style control
- Conversation mode (multi-speaker)
- Custom audio effects
Text is automatically split at sentence boundaries and each chunk is generated independently, then crossfaded together. Works with all engines.
## Key Differentiators
- Configurable auto-chunking limit (100–5,000 chars)
- Crossfade slider (0–200ms) for smooth transitions
- Max text length: 50,000 characters
- Smart splitting respects abbreviations, CJK punctuation, and `[tags]`
What makes voicebox better than existing implementations:
### Generation Versions
1. **Clean codebase** - Modular architecture, no 2,000+ line files
2. **Type safety end-to-end** - OpenAPI-generated TypeScript client, Pydantic backend, React Query
3. **Smart caching** - Voice prompt caching for instant re-generation
4. **Desktop UX** - Native performance, keyboard shortcuts, native dialogs
5. **Server/client mode** - One-click remote GPU access from any device
6. **Multi-reference** - Combine voice samples for higher quality
7. **Audio studio** - Timeline-based editing with word-level precision
8. **Production patterns** - Cross-platform, graceful degradation, error recovery
9. **Database-backed** - Searchable history, project persistence
10. **Extensible** - Clean plugin system for models and features
Every generation supports multiple versions with provenance tracking:
## Architecture Overview
- **Original** — clean TTS output, always preserved
- **Effects versions** — apply different effects chains from any source version
- **Takes** — regenerate with a new seed for variation
- **Source tracking** — each version records its lineage
- **Favorites** — star generations for quick access
```
voicebox/
├── app/ # Shared React frontend (used by web & desktop)
│ ├── src/
│ │ ├── components/ # React components
│ │ │ ├── VoiceProfiles/
│ │ │ ├── Generation/
│ │ │ ├── AudioStudio/
│ │ │ ├── History/
│ │ │ └── ServerSettings/
│ │ ├── lib/
│ │ │ ├── api/ # Generated OpenAPI client
│ │ │ ├── hooks/ # React Query hooks
│ │ │ └── utils/
│ │ ├── types/
│ │ └── App.tsx
│ ├── package.json
│ └── vite.config.ts
│
├── tauri/ # Tauri desktop app (thin wrapper)
│ ├── src/
│ │ └── main.tsx # Entry point, imports from ../app
│ ├── src-tauri/ # Rust backend
│ │ ├── src/
│ │ │ └── main.rs # Sidecar management, IPC
│ │ ├── binaries/ # Bundled Python server
│ │ │ └── voicebox-server-{platform}
│ │ ├── Cargo.toml
│ │ └── tauri.conf.json
│ └── package.json
│
├── web/ # Web deployment (thin wrapper)
│ ├── src/
│ │ └── main.tsx # Entry point, imports from ../app
│ ├── package.json
│ └── vite.config.ts
│
├── backend/ # Python FastAPI server
│ ├── main.py # FastAPI app + server mode
│ ├── models.py # Pydantic models
│ ├── tts.py # TTS inference
│ ├── transcribe.py # Whisper ASR
│ ├── profiles.py # Voice profiles
│ ├── history.py # Generation history
│ ├── studio.py # Audio editing
│ ├── database.py # SQLite ORM
│ ├── utils/
│ │ ├── audio.py # Audio processing
│ │ ├── cache.py # Prompt caching
│ │ └── validation.py
│ ├── requirements.txt
│ └── build_binary.py # PyInstaller build script
│
├── scripts/
│ ├── build-server.sh # Build Python binary for all platforms
│ └── generate-api.sh # Generate OpenAPI client
│
├── data/ # User data
│ ├── profiles/
│ ├── generations/
│ ├── projects/
│ └── voicebox.db
│
├── package.json # Root workspace config
└── docs/
├── ANALYSIS.md # Analysis of existing projects
├── TAURI_PLAN.md # Tauri app structure and bundling strategy
└── ARCHITECTURE.md # Detailed architecture docs
```
### Async Generation Queue
**Key architectural decisions:**
- **Shared frontend** - `app/` contains all React code, used by both desktop and web
- **Thin wrappers** - `tauri/` and `web/` just configure build tools and entry points
- **Bundled backend** - Python server packaged as sidecar binary with PyInstaller
- **Type-safe API** - OpenAPI schema generated from FastAPI, TypeScript client auto-generated
Generation is non-blocking. Submit and immediately start typing the next one.
See [TAURI_PLAN.md](./docs/TAURI_PLAN.md) for detailed bundling strategy.
- Serial execution queue prevents GPU contention
- Real-time SSE status streaming
- Failed generations can be retried
- Stale generations from crashes auto-recover on startup
## Lessons from Existing Projects
### Voice Profile Management
voicebox learns from five existing Qwen3-TTS implementations:
- Create profiles from audio files or record directly in-app
- Import/export profiles to share or back up
- Multi-sample support for higher quality cloning
- Per-profile default effects chains
- Organize with descriptions and language tags
### voice (Rust CLI)
- ✅ Clean Rust/Python IPC pattern
- ✅ M3GAN voice effect
- ✅ Voice profile abstraction
- ❌ No concurrent requests
- ❌ No generation history
### Stories Editor
### Voice-Clone-Studio
- ✅ Brilliant voice prompt caching
- ✅ Feature-rich (voice design, presets, conversations)
- ✅ VRAM-efficient model management
- ❌ 2,815-line single file
- ❌ Global state everywhere
Multi-voice timeline editor for conversations, podcasts, and narratives.
### Qwen3-TTS_server
- ✅ Clean modular structure
- ✅ FastAPI REST API design
- ✅ Health endpoint for monitoring
- ❌ No authentication or rate limiting
- ❌ No caching or streaming
- ❌ No OpenAPI client generation
- Multi-track composition with drag-and-drop
- Inline audio trimming and splitting
- Auto-playback with synchronized playhead
- Version pinning per track clip
### mimic
- ✅ Excellent backend architecture (async, modular)
- ✅ Audio studio with timeline
- ✅ Database-backed history
- ✅ Multi-sample voice profiles
- ❌ 2,794-line app.js frontend
- ❌ Global state in UI
### Global Dictation & Voice Input
### qwen3-tts-enhanced
- ✅ Multi-reference combination
- ✅ Cross-platform graceful degradation
- ✅ Audio validation
- ✅ Production error handling
- ❌ Still monolithic (1,892 lines)
- ❌ No API layer
The other half of the voice I/O loop. Hold a hotkey anywhere on your system, speak, release — on macOS the transcript pastes straight into the focused text field. Or hit the mic on any Voicebox text input and dictate directly into the app.
See [ANALYSIS.md](./docs/ANALYSIS.md) for detailed breakdown of each project.
- **Configurable chord bindings** — hold-to-speak and tap-to-toggle chords, each rebindable in the in-app chord picker. Holding push-to-talk and tapping `Space` mid-hold upgrades into a toggle session without a gap in audio
- **Target-aware paste (macOS)** — accessibility-verified injection into the focused text field, with atomic clipboard save/restore so your clipboard isn't clobbered
- **First-run permissions UX** — in-app gates walk you through the macOS Accessibility and Input Monitoring grants with deep-links to System Settings
- **In-app mic button** on every Voicebox text field — generation form, profile descriptions, story titles, anywhere you'd type
- **LLM refinement** — optional cleanup of ums, stutters, and false starts before paste
- **On-screen pill** — floating overlay surfacing `recording`, `transcribing`, `refining`, and `speaking` states. Same pill agents use when they speak to you, so there's one mental model for both directions of the loop
## Development Roadmap
### Speech-to-Text
### Week 1: Foundation
- Project structure setup
- Backend skeleton (FastAPI + SQLite)
- OpenAPI schema generation
- Frontend skeleton (Tauri + React)
- TypeScript client generation from OpenAPI
- React Query setup
- Basic voice profile CRUD
- Server mode implementation
- Client connection UI
Voicebox runs OpenAI Whisper for transcription — the same model that backs dictation, the Captures tab, and the `/transcribe` API. Running on MLX (Apple Silicon) or PyTorch (CUDA / ROCm / DirectML / CPU) depending on your platform.
### Week 2: Core Features
- TTS integration
- Voice cloning pipeline
- Voice prompt caching
- Generation history
| Size | Notes |
| ----------------------------- | -------------------------------------------------- |
| Base / Small / Medium / Large | Standard Whisper quality ladder |
| Turbo | ~8x faster than Whisper Large, minimal quality loss |
### Week 3: UX Polish
- Audio playback and preview
- Profile management UI
- History search and filters
- Error handling and validation
More engines (Parakeet v3, Qwen3-ASR) are planned — see [Roadmap](#roadmap).
### Week 4: Advanced Features
- Multi-reference combination
- Batch generation
- Audio normalization
- M3GAN effect
### Captures
### Week 5+: Studio Features
- Timeline editor
- Word-level timestamps
- Project system
- Export pipeline
Every dictation, in-app recording, and uploaded audio file lands in the Captures tab — original audio paired with transcript, always preserved.
## Technical Decisions
- **Replay, re-transcribe, refine** — rerun STT with any Whisper size, or re-run the raw transcript through the local LLM with different flags (filler cleanup, self-correction removal, technical-term preservation)
- **Edit inline** — tweak the transcript and save on blur
- **Play as voice profile** — turn any capture into speech with a cloned voice, one click
- **Promote to voice sample** — use a capture's audio + transcript as a reference sample on any voice profile
- **Local capture storage** — original audio and transcript stay in your Voicebox data directory, with a folder shortcut in Settings
### Why Tauri over Electron?
- Smaller bundle size (Rust vs. Node.js)
- Better performance (native vs. V8)
- Lower memory usage
- Rust for system-level operations
### Agent Voice Output
### Why FastAPI over Flask?
- Native async/await support
- Automatic OpenAPI schema generation
- Pydantic validation built-in
- Better performance
Every agent gets a voice. One tool call and any MCP-aware agent can speak to you in a voice you've cloned — task completions, questions, notifications. The same pill that surfaces during dictation surfaces during agent speech, so you always see what's coming out of your machine.
### Why OpenAPI + React Query?
- **Type safety end-to-end** - FastAPI generates OpenAPI schema, we generate TypeScript client
- **No manual API code** - Client generated from `openapi.json` using openapi-typescript-codegen
- **Automatic caching** - React Query handles request deduplication and background refetching
- **Optimistic updates** - Update UI immediately, rollback on error
- **DevX** - Full autocomplete and type checking for all API calls
**Example workflow:**
```bash
# Backend generates OpenAPI schema
python backend/main.py --openapi > openapi.json
# Frontend generates TypeScript client
bun run generate-client
# Use type-safe hooks in React
import { useQuery } from '@tanstack/react-query';
import { ProfilesService } from '@/lib/api';
const { data: profiles } = useQuery({
queryKey: ['profiles'],
queryFn: () => ProfilesService.listProfiles()
```ts
// In any MCP-aware agent:
await voicebox.speak({
text: "Deploy complete.",
profile: "Morgan",
});
```
### Why Bun over npm/yarn/pnpm?
- **Speed** - 20-30x faster than npm for install operations
- **Drop-in replacement** - Compatible with npm ecosystem, no migration needed
- **Built-in tooling** - Bundler, test runner, and package manager in one
- **Performance** - Faster script execution than Node.js
- **Developer experience** - Better error messages, workspaces support
Also exposed as `POST /speak` for anything that doesn't speak MCP — ACP, A2A, shell scripts, custom harnesses.
### Why SQLite over file-based storage?
- Full-text search
- Transactions and integrity
- Migrations via Alembic
- Easy to backup/restore
- **Bidirectional pill** — `recording`, `transcribing`, `refining`, and `speaking` are all states of the same OS-level overlay, so dictation and agent speech share one surface
- **Per-agent voice binding** — in **Settings → MCP**, pin Claude Code to Morgan and Cursor to Scarlett so you can tell which agent is talking without looking. Each client's `last_seen_at` timestamp confirms the install actually took
- **Always visible** — no silent background TTS; every agent-initiated speak surfaces the pill with the voice profile name for the full duration
- **HTTP + stdio transports** — install as a URL in Claude Code / Cursor / Windsurf / VS Code MCP, or point stdio-only clients at the bundled `voicebox-mcp` binary
### Why React over Vue/Svelte?
- Larger ecosystem
- Better TypeScript support
- Familiar to most developers
- Mature tooling
### Voice Personalities
### Why bundle Python server with PyInstaller?
- **No Python installation required** - Users don't need Python on their system
- **Consistent environment** - Exact dependencies bundled, no version conflicts
- **Single-click install** - One installer includes everything
- **Tauri sidecar pattern** - Rust spawns/manages Python process lifecycle
- **Platform-specific binaries** - PyInstaller creates native executables for each platform
Attach a free-form personality to any voice profile — who this voice is, how they speak, what they care about. Two actions appear on the generate box when a personality is set, powered by a bundled Qwen3 LLM running entirely locally.
**Tradeoffs:**
- Larger bundle size (~500MB with models vs ~50MB without backend)
- Need separate build for each platform (macOS Intel/ARM, Windows, Linux)
- First launch slower (model loading time)
- **Compose** — a shuffle button that drops a fresh in-character line into the textarea; edit and speak, or click again for a different take
- **Speak in character** — a toggle that routes your input text through the personality LLM to be rewritten in their voice before TTS
**Alternative considered:** Require users to install Python and run `pip install` - rejected for poor UX
Agents can reach the same rewrite path over MCP by passing `personality: true` to `voicebox.speak`, turning the tool into a text-in → personality-LLM → TTS pipeline. The same LLM backs dictation's refinement step — one LLM in the app, one model cache, one GPU-memory footprint.
### Why no Docker initially?
- Desktop app, not server deployment
- Users install locally
- Can add later for server mode
**Local LLM options:** Qwen3 0.6B / 1.7B / 4B, sharing the TTS runtime (MLX on Apple Silicon, PyTorch elsewhere).
## Performance Targets
Use cases: agent dev loops (dictate a question, hear the answer in a cloned voice), interactive characters for games and narrative tools, speech assistance for people who can't speak in their original voice.
- **First generation:** < 10 seconds (cold start)
- **Cached generation:** < 2 seconds (warm start)
- **UI responsiveness:** 60 FPS at all times
- **Memory usage:** < 4GB VRAM for small models
- **Startup time:** < 3 seconds to UI
- **Database queries:** < 100ms for history search
### Model Management
## Quality Standards
- Per-model unload to free GPU memory without deleting downloads
- Custom models directory via `VOICEBOX_MODELS_DIR`
- Model folder migration with progress tracking
- Download cancel/clear UI
- **No files over 500 lines** (except auto-generated)
- **Type hints on all Python functions**
- **TypeScript strict mode enabled**
- **OpenAPI client auto-generated from schema**
- **ESLint + Prettier for frontend**
- **Black + isort for backend**
- **All user-facing errors have context**
- **No global mutable state**
- **React Query for all server state**
### GPU Support
## Project Status
| Platform | Backend | Notes |
| ------------------------ | -------------- | ---------------------------------------------- |
| macOS (Apple Silicon) | MLX (Metal) | 4-5x faster via Neural Engine |
| Windows / Linux (NVIDIA) | PyTorch (CUDA) | Auto-downloads CUDA binary from within the app |
| Linux (AMD) | PyTorch (ROCm) | Auto-configures HSA_OVERRIDE_GFX_VERSION |
| Windows (any GPU) | DirectML | Universal Windows GPU support |
| Intel Arc | IPEX/XPU | Intel discrete GPU acceleration |
| Any | CPU | Works everywhere, just slower |
**Current phase:** Planning and analysis
---
**Documentation:**
- [ANALYSIS.md](./docs/ANALYSIS.md) - Comprehensive analysis of existing implementations
- [TAURI_PLAN.md](./docs/TAURI_PLAN.md) - Tauri app architecture and Python server bundling strategy
## API
Voicebox exposes a REST API for integrating voice I/O into your own apps and agents.
```bash
# Generate speech
curl -X POST http://127.0.0.1:17493/generate \
-H "Content-Type: application/json" \
-d '{"text": "Hello world", "profile_id": "abc123", "language": "en"}'
# Agent voice output — any app or script can speak in a cloned voice
curl -X POST http://127.0.0.1:17493/speak \
-H "Content-Type: application/json" \
-H "X-Voicebox-Client-Id: my-script" \
-d '{"text": "Deploy complete.", "profile": "Morgan"}'
# Transcribe an audio file
curl -X POST http://127.0.0.1:17493/transcribe \
-F "[email protected]" \
-F "model=whisper-turbo"
# List voice profiles
curl http://127.0.0.1:17493/profiles
```
`POST /speak` accepts `profile` as a name (case-insensitive) or id, and resolves via the same precedence as the MCP tool: explicit arg → per-client binding → `capture_settings.default_playback_voice_id`.
### MCP server
Voicebox ships a built-in **Model Context Protocol** server so any MCP-aware agent (Claude Code, Cursor, Windsurf, Cline, VS Code MCP extensions) can speak, transcribe, and browse captures and profiles.
**Claude Code one-liner:**
```
claude mcp add voicebox \
--transport http \
--url http://127.0.0.1:17493/mcp \
--header "X-Voicebox-Client-Id: claude-code"
```
**Any HTTP MCP client** (Cursor, Windsurf, VS Code, etc.):
```json
{
"mcpServers": {
"voicebox": {
"url": "http://127.0.0.1:17493/mcp",
"headers": { "X-Voicebox-Client-Id": "cursor" }
}
}
}
```
**Stdio fallback** for clients that don't speak HTTP MCP — point at the bundled `voicebox-mcp` binary inside the app:
```json
{
"mcpServers": {
"voicebox": {
"command": "/Applications/Voicebox.app/Contents/MacOS/voicebox-mcp",
"env": { "VOICEBOX_CLIENT_ID": "claude-desktop" }
}
}
}
```
Four tools ship: `voicebox.speak`, `voicebox.transcribe`, `voicebox.list_captures`, `voicebox.list_profiles`. Per-client voice bindings are managed in **Voicebox → Settings → MCP**. See the [full MCP guide](docs/content/docs/overview/mcp-server.mdx) for tool signatures, resolution precedence, the speaking-pill contract, and security notes.
```ts
// In any MCP-aware agent:
await voicebox.speak({
text: "Tests passing. Ready to merge.",
profile: "Morgan", // optional — falls back to the per-client binding
personality: true, // optional — rewrites text through the profile's personality LLM first
});
```
**Use cases:** agent dev loops (voice in, voice out), game dialogue, podcast production, accessibility tools, voice assistants, content automation.
Full API documentation available at `http://127.0.0.1:17493/docs`.
---
## Tech Stack
| Layer | Technology |
| ------------- | ------------------------------------------------------------------------------- |
| Desktop App | Tauri (Rust) |
| Frontend | React, TypeScript, Tailwind CSS |
| State | Zustand, React Query |
| Backend | FastAPI (Python) |
| TTS Engines | Qwen3-TTS, Qwen CustomVoice, LuxTTS, Chatterbox, Chatterbox Turbo, TADA, Kokoro |
| STT | Whisper / Whisper Turbo (PyTorch or MLX) |
| Local LLM | Qwen3 (0.6B / 1.7B / 4B), shared runtime with TTS / STT |
| MCP Server | FastMCP mounted at `/mcp` (Streamable HTTP) + bundled stdio shim binary |
| Native Shim | Rust (inside Tauri) for global hotkey, paste injection, focus introspection |
| Effects | Pedalboard (Spotify) |
| Inference | MLX (Apple Silicon) / PyTorch (CUDA/ROCm/XPU/CPU) |
| Database | SQLite |
| Audio | WaveSurfer.js, librosa |
---
## Roadmap
| Feature | Description |
| ---------------------------------- | ------------------------------------------------------------------------ |
| **Windows / Linux auto-paste** | Dictation paste parity — `SendInput` on Windows, `uinput` / AT-SPI on Linux |
| **STT engine expansion** | Parakeet v3 and Qwen3-ASR joining Whisper — 50+ languages, better non-English quality |
| **Pipeline routing** | Configurable source → transform → sink chains with webhook + MCP sinks and a preset editor |
| **Streaming transcription** | WebSocket `/transcribe/stream` for partial transcripts as you speak |
| **End-to-end speech LLMs** | Moshi, GLM-4-Voice, Qwen2.5 Omni — real voice-to-voice, no text between |
| **Voice Design** | Create new voices from text descriptions |
| **Long-form capture** | Dual-stream recorder (mic + system audio) with summary LLM transform |
| **Platform sinks** | Apple Notes, Obsidian, and other opt-in integrations |
| **Plugin architecture** | Extend with custom models, transforms, and sinks |
| **Mobile companion** | Control Voicebox from your phone |
For the **full engineering status, open-issue triage, and prioritized work queue**, see [`docs/PROJECT_STATUS.md`](docs/PROJECT_STATUS.md) — a living document that tracks what's shipped, what's in-flight, candidate TTS engines under evaluation, and why we've accepted or backlogged specific integrations.
---
## Development
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed setup and contribution guidelines.
### Quick Start
```bash
git clone https://github.com/jamiepine/voicebox.git
cd voicebox
just setup # creates Python venv, installs all deps
just dev # starts backend + desktop app
```
Install [just](https://github.com/casey/just): `brew install just` or `cargo install just`. Run `just --list` to see all commands.
**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org), [Tauri Prerequisites](https://v2.tauri.app/start/prerequisites/), and [Xcode](https://developer.apple.com/xcode/) on macOS.
The repo ships a pre-wired `.mcp.json` at the root — running Claude Code inside this checkout picks up the Voicebox MCP tools automatically once the dev app is running.
### Building Locally
```bash
just build # Build CPU server binary + Tauri app
just build-local # (Windows) Build CPU + CUDA server binaries + Tauri app
```
### Adding New Voice Models
The multi-engine architecture makes adding new TTS engines straightforward. A [step-by-step guide](docs/content/docs/developer/tts-engines.mdx) covers the full process: dependency research, backend protocol implementation, frontend wiring, and PyInstaller bundling.
The guide is optimized for AI coding agents. An [agent skill](.agents/skills/add-tts-engine/SKILL.md) can pick up a model name and handle the entire integration autonomously — you just test the build locally.
### Project Structure
```
voicebox/
├── app/ # Shared React frontend
├── tauri/ # Desktop app (Tauri + Rust)
├── web/ # Web deployment
├── backend/ # Python FastAPI server
├── landing/ # Marketing website
└── scripts/ # Build & release scripts
```
---
## Contributing
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
1. Fork the repo
2. Create a feature branch
3. Make your changes
4. Submit a PR
## Security
Found a security vulnerability? Please report it responsibly. See [SECURITY.md](SECURITY.md) for details.
---
## License
TBD
MIT License — see [LICENSE](LICENSE) for details.
## Credits
---
Built by analyzing and learning from:
- voice (Rust CLI)
- Voice-Clone-Studio
- Qwen3-TTS_server
- mimic
- qwen3-tts-enhanced
Powered by Alibaba's Qwen3-TTS model.
<p align="center">
<a href="https://voicebox.sh">voicebox.sh</a>
</p>
+92
View File
@@ -0,0 +1,92 @@
# Security Policy
## Supported Versions
We release patches for security vulnerabilities. Which versions are eligible for receiving such patches depends on the CVSS v3.0 Rating:
| Version | Supported |
| ------- | ------------------ |
| 0.3.x | :white_check_mark: |
| < 0.3 | :x: |
## Reporting a Vulnerability
If you discover a security vulnerability, please report it responsibly:
1. **Do not** open a public GitHub issue
2. Email security details to: [[email protected]](mailto:[email protected])
3. Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
We will:
- Acknowledge receipt within 48 hours
- Provide a timeline for addressing the issue
- Keep you informed of progress
- Credit you in the security advisory (if desired)
## Security Best Practices
### For Users
- **Keep Voicebox updated** - Updates include security patches
- **Verify downloads** - Only download from official releases
- **Local processing** - Voice data stays on your machine
- **Network security** - Use HTTPS when connecting to remote servers
### For Developers
- **Dependencies** - Keep all dependencies up to date
- **Code review** - All PRs require review before merging
- **Secrets** - Never commit API keys or signing keys
- **Signing** - All releases are cryptographically signed
## Known Security Considerations
### Local Processing
Voicebox processes all audio locally by default. Your voice data never leaves your machine unless you explicitly enable remote server mode.
### Remote Server Mode
When connecting to a remote server:
- Ensure the server is on a trusted network
- Use HTTPS for remote connections
- Verify server identity before connecting
### Auto-Updates
- Updates are cryptographically signed
- Signature verification happens before installation
- Only HTTPS endpoints are allowed
### Python Server
The embedded Python server:
- Runs locally by default (localhost only)
- Can be configured for remote access
- Uses standard FastAPI security practices
## Disclosure Timeline
- **Day 0**: Vulnerability reported
- **Day 1-2**: Initial assessment and acknowledgment
- **Day 3-7**: Investigation and fix development
- **Day 8-14**: Testing and release preparation
- **Day 15+**: Public disclosure (if applicable)
Timeline may vary based on severity and complexity.
## Security Updates
Security updates will be:
- Released as patch versions (e.g., 0.3.2)
- Documented in CHANGELOG.md
- Announced via GitHub releases
- Automatically delivered via auto-updater
---
Thank you for helping keep Voicebox secure! 🔒
-207
View File
@@ -1,207 +0,0 @@
# voicebox Setup Guide
Quick start guide for setting up the voicebox development environment.
## Prerequisites
- **Bun** - Fast JavaScript runtime and package manager
```bash
curl -fsSL https://bun.sh/install | bash
```
- **Python 3.11+** - For backend development
```bash
python --version # Should be 3.11 or higher
```
- **Rust** - For Tauri desktop app (installed automatically by Tauri CLI)
```bash
rustc --version # Check if installed
```
- **Node.js 18+** (optional) - Fallback if Bun is not available
## Initial Setup
### 1. Install Dependencies
```bash
# Install all workspace dependencies
bun install
```
This will install dependencies for:
- `app/` - Shared React frontend
- `tauri/` - Tauri desktop wrapper
- `web/` - Web deployment wrapper
### 2. Setup Backend
```bash
cd backend
# Create virtual environment
python -m venv venv
# Activate virtual environment
source venv/bin/activate # On macOS/Linux
# or
venv\Scripts\activate # On Windows
# Install Python dependencies
pip install -r requirements.txt
```
### 3. Initialize Database
```bash
cd backend
python -c "from database import init_db; init_db()"
```
This creates the SQLite database at `data/voicebox.db`.
### 4. Install Qwen3-TTS (Optional)
The Qwen3-TTS models are automatically downloaded from HuggingFace Hub on first use. However, you need to install the `qwen_tts` package:
```bash
pip install git+https://github.com/QwenLM/Qwen3-TTS.git
```
**Note:** Models (~2-4GB) will be automatically downloaded on first generation. This may take a few minutes depending on your internet connection.
## Development
### Start Backend Server
```bash
cd backend
source venv/bin/activate # Activate venv if not already active
uvicorn main:app --reload --port 8000
```
Backend will be available at `http://localhost:8000`
### Start Tauri Desktop App
```bash
# From project root
bun run dev
```
Or manually:
```bash
cd tauri
bun run tauri dev
```
This will:
1. Start Vite dev server on port 5173
2. Launch Tauri window pointing to localhost:5173
3. Enable hot reload
### Start Web App
```bash
# From project root
bun run dev:web
```
Or manually:
```bash
cd web
bun run dev
```
Web app will be available at `http://localhost:5174` (or next available port)
## Building
### Build Python Server Binary
```bash
./scripts/build-server.sh
```
This creates a platform-specific binary in `tauri/src-tauri/binaries/`
### Build Tauri Desktop App
```bash
cd tauri
bun run tauri build
```
Creates platform-specific installers:
- macOS: `.app`, `.dmg`
- Windows: `.exe`, `.msi`
- Linux: `.deb`, `.AppImage`
### Build Web App
```bash
cd web
bun run build
```
Output in `web/dist/`
## Generate OpenAPI Client
After starting the backend server:
```bash
./scripts/generate-api.sh
```
This will:
1. Download OpenAPI schema from backend
2. Generate TypeScript client in `app/src/lib/api/`
## Project Structure
```
voicebox/
├── app/ # Shared React frontend
├── tauri/ # Tauri desktop wrapper
├── web/ # Web deployment wrapper
├── backend/ # Python FastAPI server
├── scripts/ # Build and utility scripts
├── data/ # User data (gitignored)
└── docs/ # Documentation
```
## Troubleshooting
### Backend won't start
- Check Python version: `python --version` (needs 3.11+)
- Ensure virtual environment is activated
- Install dependencies: `pip install -r requirements.txt`
### Tauri build fails
- Ensure Rust is installed: `rustc --version`
- Install Tauri CLI: `bunx @tauri-apps/cli install`
- Check `tauri/src-tauri/Cargo.toml` for correct dependencies
### OpenAPI client generation fails
- Ensure backend is running on port 8000
- Check `curl http://localhost:8000/openapi.json` returns valid JSON
- Install openapi-typescript-codegen: `bun add -d openapi-typescript-codegen`
## Model Downloads
Models are automatically downloaded from HuggingFace Hub on first use:
- **Whisper** (transcription): Auto-downloads on first transcription
- **Qwen3-TTS** (voice cloning): Auto-downloads on first generation
First-time usage will be slower due to model downloads, but subsequent runs will use cached models.
## Next Steps
1. ✅ TTS model loading implemented in `backend/tts.py`
2. ✅ API routes implemented in `backend/main.py`
3. Build React components in `app/src/components/`
4. Connect frontend to backend via generated API client
See [README.md](./README.md) for architecture details and [docs/](./docs/) for detailed documentation.
+17 -1
View File
@@ -1,10 +1,26 @@
<!doctype html>
<html lang="en" class="dark">
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>voicebox</title>
<script>
(function () {
try {
var theme = 'system';
var raw = localStorage.getItem('voicebox-ui');
if (raw) {
var parsed = JSON.parse(raw);
if (parsed && parsed.state && parsed.state.theme) theme = parsed.state.theme;
}
var resolved = theme === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
if (resolved === 'dark') document.documentElement.classList.add('dark');
} catch (_) {}
})();
</script>
</head>
<body>
<div id="root"></div>
+13 -1
View File
@@ -1,11 +1,12 @@
{
"name": "@voicebox/app",
"version": "0.1.0",
"version": "0.5.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc -p tsconfig.json --noEmit",
"preview": "vite preview",
"lint": "biome lint src",
"lint:fix": "biome lint --write src",
@@ -13,6 +14,9 @@
"check": "biome check --write src"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-avatar": "^1.1.0",
@@ -30,17 +34,25 @@
"@radix-ui/react-toast": "^1.2.1",
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-query-devtools": "^5.0.0",
"@tanstack/react-router": "^1.157.16",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.9.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"framer-motion": "^12.29.0",
"i18next": "^26.0.6",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.454.0",
"motion": "^12.29.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-hook-form": "^7.53.0",
"react-i18next": "^17.0.4",
"react-sound-visualizer": "^1.4.0",
"tailwind-merge": "^2.5.4",
"wavesurfer.js": "^7.0.0",
"zod": "^3.23.8",
+23
View File
@@ -0,0 +1,23 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import type { Plugin } from 'vite';
/** Vite plugin that exposes CHANGELOG.md as `virtual:changelog`. */
export function changelogPlugin(repoRoot: string): Plugin {
const virtualId = 'virtual:changelog';
const resolvedId = '\0' + virtualId;
const changelogPath = path.resolve(repoRoot, 'CHANGELOG.md');
return {
name: 'changelog',
resolveId(id) {
if (id === virtualId) return resolvedId;
},
load(id) {
if (id === resolvedId) {
const raw = readFileSync(changelogPath, 'utf-8');
return `export default ${JSON.stringify(raw)};`;
}
},
};
}
+253 -85
View File
@@ -1,33 +1,151 @@
import { useState, useEffect } from 'react';
import { GenerationForm } from '@/components/Generation/GenerationForm';
import { HistoryTable } from '@/components/History/HistoryTable';
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
import { Toaster } from '@/components/ui/toaster';
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
import { Sidebar } from '@/components/Sidebar';
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
import { UpdateNotification } from '@/components/UpdateNotification';
import { isTauri, startServer, setupWindowCloseHandler } from '@/lib/tauri';
import { RouterProvider } from '@tanstack/react-router';
import { useEffect, useRef, useState } from 'react';
import voiceboxLogo from '@/assets/voicebox-logo.png';
import { DictateWindow } from '@/components/DictateWindow/DictateWindow';
import ShinyText from '@/components/ShinyText';
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
import { useThemeSync } from '@/hooks/useThemeSync';
import { apiClient } from '@/lib/api/client';
import type { HealthResponse } from '@/lib/api/types';
import { useChordSync } from '@/lib/hooks/useChordSync';
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn';
import { usePlatform } from '@/platform/PlatformContext';
import { router } from '@/router';
import { useLogStore } from '@/stores/logStore';
import {
getDefaultServerUrl,
isLoopbackVoiceboxServerUrl,
useServerStore,
} from '@/stores/serverStore';
// Track if server is starting to prevent duplicate starts
let serverStarting = false;
function isDictateView(): boolean {
if (typeof window === 'undefined') return false;
return new URLSearchParams(window.location.search).get('view') === 'dictate';
}
/**
* Validate that a health response has the expected Voicebox-specific shape.
* Prevents misidentifying an unrelated service on the same port.
*/
function isVoiceboxHealthResponse(health: HealthResponse): boolean {
return (
health?.status === 'healthy' &&
typeof health.model_loaded === 'boolean' &&
typeof health.gpu_available === 'boolean'
);
}
/**
* Check whether a startup error indicates the port is occupied by an external
* server (which we should try to reuse via health-check polling) vs. a real
* failure (missing sidecar, signing issue, etc.) that should surface immediately.
*/
function isPortInUseError(error: unknown): boolean {
const msg = error instanceof Error ? error.message : String(error);
return (
msg.includes('already in use') ||
msg.includes('port') ||
msg.includes('EADDRINUSE') ||
msg.includes('address already in use')
);
}
const LOADING_MESSAGES = [
'Warming up tensors...',
'Calibrating synthesizer engine...',
'Initializing voice models...',
'Loading neural networks...',
'Preparing audio pipelines...',
'Optimizing waveform generators...',
'Tuning frequency analyzers...',
'Building voice embeddings...',
'Configuring text-to-speech cores...',
'Syncing audio buffers...',
'Establishing model connections...',
'Preprocessing training data...',
'Validating voice samples...',
'Compiling inference engines...',
'Mapping phoneme sequences...',
'Aligning prosody parameters...',
'Activating speech synthesis...',
'Fine-tuning acoustic models...',
'Preparing voice cloning matrices...',
'Initializing Qwen TTS framework...',
];
function App() {
const [activeTab, setActiveTab] = useState('main');
useThemeSync();
// The dictate window runs in a separate Tauri webview that must skip
// server bootstrap (the main window owns that lifecycle) and render only
// the floating recording surface. Split into a sibling component so the
// main app's hooks are not called on the dictate path.
if (isDictateView()) {
return <DictateWindow />;
}
return <MainApp />;
}
function MainApp() {
const platform = usePlatform();
const [serverReady, setServerReady] = useState(false);
const [startupError, setStartupError] = useState<string | null>(null);
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
const serverStartingRef = useRef(false);
// Automatically check for app updates on startup and show toast notifications
useAutoUpdater({ checkOnMount: true, showToast: true });
// Replay the saved chord into the Rust hotkey listener every time
// capture_settings resolves or the user edits the chord.
useChordSync();
// Sync stored setting to Rust on startup
useEffect(() => {
if (platform.metadata.isTauri) {
const keepRunning = useServerStore.getState().keepServerRunningOnClose;
platform.lifecycle.setKeepServerRunning(keepRunning).catch((error) => {
console.error('Failed to sync initial setting to Rust:', error);
});
}
// Empty dependency array - platform is stable from context, only run once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platform.metadata.isTauri, platform.lifecycle]);
// Setup lifecycle callbacks
useEffect(() => {
platform.lifecycle.onServerReady = () => {
setServerReady(true);
};
// Empty dependency array - platform is stable from context, only run once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platform.lifecycle]);
// Subscribe to server logs
useEffect(() => {
const unsubscribe = platform.lifecycle.subscribeToServerLogs((entry) => {
useLogStore.getState().addEntry(entry);
});
return unsubscribe;
}, [platform.lifecycle]);
// Setup window close handler and auto-start server when running in Tauri (production only)
useEffect(() => {
if (!isTauri()) {
if (!platform.metadata.isTauri) {
const serverUrl = getDefaultServerUrl();
const currentServerUrl = useServerStore.getState().serverUrl;
if (currentServerUrl !== serverUrl && isLoopbackVoiceboxServerUrl(currentServerUrl)) {
useServerStore.getState().setServerUrl(serverUrl);
}
setServerReady(true); // Web assumes server is running
return;
}
// Setup window close handler to check setting and stop server if needed
// This works in both dev and prod, but will only stop server if it was started by the app
setupWindowCloseHandler().catch((error) => {
platform.lifecycle.setupWindowCloseHandler().catch((error) => {
console.error('Failed to setup window close handler:', error);
});
@@ -37,104 +155,154 @@ function App() {
console.log('Dev mode: Skipping auto-start of server (run it separately)');
setServerReady(true); // Mark as ready so UI doesn't show loading screen
// Mark that server was not started by app (so we don't try to stop it on close)
// @ts-expect-error - adding property to window
window.__voiceboxServerStartedByApp = false;
return;
}
// Auto-start server in production
if (serverStarting) {
if (serverStartingRef.current) {
return;
}
serverStarting = true;
console.log('Production mode: Starting bundled server...');
serverStartingRef.current = true;
const isRemote = useServerStore.getState().mode === 'remote';
const customModelsDir = useServerStore.getState().customModelsDir;
console.log(`Production mode: Starting bundled server... (remote: ${isRemote})`);
startServer(false)
.then(() => {
console.log('Server is ready');
platform.lifecycle
.startServer(isRemote, customModelsDir)
.then((serverUrl) => {
console.log('Server is ready at:', serverUrl);
// Update the server URL in the store with the dynamically assigned port
useServerStore.getState().setServerUrl(serverUrl);
setServerReady(true);
// Mark that we started the server (so we know to stop it on close)
// @ts-expect-error - adding property to window
window.__voiceboxServerStartedByApp = true;
})
.catch((error) => {
console.error('Failed to auto-start server:', error);
serverStarting = false;
// @ts-expect-error - adding property to window
serverStartingRef.current = false;
window.__voiceboxServerStartedByApp = false;
// Only fall back to health-check polling when the error indicates the
// port is occupied (likely an external server). For real failures
// (missing sidecar, signing issues, etc.) surface the error immediately.
if (!isPortInUseError(error)) {
const msg = error instanceof Error ? error.message : String(error);
console.error('Real startup failure — not polling:', msg);
setStartupError(msg);
return;
}
// Fall back to polling: the server may already be running externally
// (e.g. started via python/uvicorn/Docker). Poll the health endpoint
// until it responds with a valid Voicebox payload, then transition to
// the main UI.
console.log('Falling back to health-check polling...');
const pollInterval = setInterval(async () => {
try {
const health = await apiClient.getHealth();
if (!isVoiceboxHealthResponse(health)) {
console.log('Health response is not from a Voicebox server, keep polling...');
return;
}
console.log('External Voicebox server detected via health check');
clearInterval(pollInterval);
setServerReady(true);
} catch {
// Server not ready yet, keep polling
}
}, 2000);
// Stop polling after 2 minutes and surface the failure
setTimeout(() => {
clearInterval(pollInterval);
serverStartingRef.current = false;
setStartupError(
'Could not connect to a Voicebox server within 2 minutes. ' +
'Please check that the server is running and try again.',
);
}, 120_000);
});
// Cleanup: stop server on actual unmount (not StrictMode remount)
// Note: Window close is handled separately in Tauri Rust code
return () => {
// Window close event handles server shutdown based on setting
serverStarting = false;
serverStartingRef.current = false;
};
}, []);
// Empty dependency array - platform is stable from context, only run once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platform.metadata.isTauri, platform.lifecycle]);
// Cycle through loading messages every 3 seconds
useEffect(() => {
if (!platform.metadata.isTauri || serverReady) {
return;
}
const interval = setInterval(() => {
setLoadingMessageIndex((prev) => (prev + 1) % LOADING_MESSAGES.length);
}, 3000);
return () => clearInterval(interval);
}, [serverReady, platform.metadata.isTauri]);
// Show loading screen while server is starting in Tauri
if (isTauri() && !serverReady) {
if (platform.metadata.isTauri && !serverReady) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center space-y-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground">Starting server...</p>
<div
className={cn(
'min-h-screen bg-background flex items-center justify-center',
TOP_SAFE_AREA_PADDING,
)}
>
<TitleBarDragRegion />
<div className="text-center space-y-6">
<div className="flex justify-center relative">
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-48 h-48 rounded-full bg-accent/20 blur-3xl" />
</div>
<img
src={voiceboxLogo}
alt="Voicebox"
className="w-48 h-48 object-contain animate-fade-in-scale relative z-10"
/>
</div>
{startupError ? (
<div className="animate-fade-in-delayed max-w-md mx-auto space-y-3">
<p className="text-lg font-medium text-destructive">Server startup failed</p>
<p className="text-sm text-muted-foreground">{startupError}</p>
<button
type="button"
className="mt-2 px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
onClick={() => {
setStartupError(null);
serverStartingRef.current = false;
// Trigger a re-mount of the effect by toggling state
window.location.reload();
}}
>
Retry
</button>
</div>
) : (
<div className="animate-fade-in-delayed">
<ShinyText
text={LOADING_MESSAGES[loadingMessageIndex]}
className="text-lg font-medium text-muted-foreground"
speed={2}
color="hsl(var(--muted-foreground))"
shineColor="hsl(var(--foreground))"
/>
</div>
)}
</div>
</div>
);
}
return (
<div className="h-screen bg-background flex flex-col overflow-hidden">
<div className="flex flex-1 min-h-0 overflow-hidden">
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} />
<main className="flex-1 ml-20 overflow-hidden flex flex-col">
<div className="container mx-auto px-8 py-8 max-w-[1800px] h-full overflow-hidden flex flex-col">
<UpdateNotification />
{activeTab === 'settings' ? (
<div className="space-y-4 overflow-y-auto">
<div className="grid gap-4 md:grid-cols-2">
<ConnectionForm />
<ServerStatus />
</div>
{isTauri() && <UpdateStatus />}
<ModelManagement />
</div>
) : (
// Main view: Profiles top left, Generator bottom left, History right
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0 overflow-hidden">
{/* Left Column */}
<div className="flex flex-col gap-6 min-h-0 overflow-y-auto pb-32">
{/* Profiles - Top Left */}
<div className="shrink-0 flex flex-col">
<ProfileList />
</div>
{/* Generator - Bottom Left */}
<div className="shrink-0">
<GenerationForm />
</div>
</div>
{/* Right Column - History */}
<div className="flex flex-col min-h-0 overflow-hidden">
<HistoryTable />
</div>
</div>
)}
</div>
</main>
</div>
{/* Audio Player - always visible except on settings */}
{activeTab !== 'settings' && <AudioPlayer />}
<Toaster />
</div>
);
return <RouterProvider router={router} />;
}
export default App;
+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 1180 320" xmlns="http://www.w3.org/2000/svg"><path d="m367.44 153.84c0 52.32 33.6 88.8 80.16 88.8s80.16-36.48 80.16-88.8-33.6-88.8-80.16-88.8-80.16 36.48-80.16 88.8zm129.6 0c0 37.44-20.4 61.68-49.44 61.68s-49.44-24.24-49.44-61.68 20.4-61.68 49.44-61.68 49.44 24.24 49.44 61.68z"/><path d="m614.27 242.64c35.28 0 55.44-29.76 55.44-65.52s-20.16-65.52-55.44-65.52c-16.32 0-28.32 6.48-36.24 15.84v-13.44h-28.8v169.2h28.8v-56.4c7.92 9.36 19.92 15.84 36.24 15.84zm-36.96-69.12c0-23.76 13.44-36.72 31.2-36.72 20.88 0 32.16 16.32 32.16 40.32s-11.28 40.32-32.16 40.32c-17.76 0-31.2-13.2-31.2-36.48z"/><path d="m747.65 242.64c25.2 0 45.12-13.2 54-35.28l-24.72-9.36c-3.84 12.96-15.12 20.16-29.28 20.16-18.48 0-31.44-13.2-33.6-34.8h88.32v-9.6c0-34.56-19.44-62.16-55.92-62.16s-60 28.56-60 65.52c0 38.88 25.2 65.52 61.2 65.52zm-1.44-106.8c18.24 0 26.88 12 27.12 25.92h-57.84c4.32-17.04 15.84-25.92 30.72-25.92z"/><path d="m823.98 240h28.8v-73.92c0-18 13.2-27.6 26.16-27.6 15.84 0 22.08 11.28 22.08 26.88v74.64h28.8v-83.04c0-27.12-15.84-45.36-42.24-45.36-16.32 0-27.6 7.44-34.8 15.84v-13.44h-28.8z"/><path d="m1014.17 67.68-65.28 172.32h30.48l14.64-39.36h74.4l14.88 39.36h30.96l-65.28-172.32zm16.8 34.08 27.36 72h-54.24z"/><path d="m1163.69 68.18h-30.72v172.32h30.72z"/><path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z"/></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1,126 @@
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { AlertTriangle, ExternalLink } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { usePlatform } from '@/platform/PlatformContext';
/**
* Tracks macOS Accessibility permission state. Without this permission the
* global chord can still record, but the synthetic-⌘V paste silently drops —
* so callers can surface an inline prompt instead of relying on the
* system-level permission dialog (which only fires once, the first time the
* app tries to post a keystroke).
*
* Triggered on three signals:
* - app mount in Tauri
* - `system:accessibility-missing` event from the dictate window's paste
* failure handler
* - window focus (cheap way to re-check after the user flips the toggle in
* System Settings and alt-tabs back)
*/
export function useAccessibilityPermission() {
const platform = usePlatform();
const [needsPermission, setNeedsPermission] = useState(false);
const [checking, setChecking] = useState(false);
const recheck = useCallback(async (): Promise<boolean> => {
if (!platform.metadata.isTauri) return true;
setChecking(true);
try {
const trusted = await invoke<boolean>('check_accessibility_permission');
setNeedsPermission(!trusted);
return trusted;
} catch (err) {
console.warn('[accessibility] check failed:', err);
return false;
} finally {
setChecking(false);
}
}, [platform.metadata.isTauri]);
useEffect(() => {
if (!platform.metadata.isTauri) return;
recheck();
const onFocus = () => {
recheck();
};
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);
}, [platform.metadata.isTauri, recheck]);
useEffect(() => {
if (!platform.metadata.isTauri) return;
let unlisten: UnlistenFn | null = null;
listen('system:accessibility-missing', () => {
setNeedsPermission(true);
})
.then((fn) => {
unlisten = fn;
})
.catch(() => {});
return () => {
if (unlisten) unlisten();
};
}, [platform.metadata.isTauri]);
const openSettings = useCallback(async () => {
try {
await invoke('open_accessibility_settings');
} catch (err) {
console.warn('[accessibility] open settings failed:', err);
}
}, []);
return { needsPermission, checking, recheck, openSettings };
}
/**
* Inline notice rendered next to the auto-paste setting when macOS
* Accessibility permission is missing. Returns null when the permission is
* already granted.
*/
export function AccessibilityNotice() {
const { t } = useTranslation();
const { needsPermission, checking, recheck, openSettings } = useAccessibilityPermission();
const [stillMissing, setStillMissing] = useState(false);
const handleRecheck = useCallback(async () => {
setStillMissing(false);
const trusted = await recheck();
if (!trusted) setStillMissing(true);
}, [recheck]);
if (!needsPermission) return null;
return (
<div className="mt-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3.5 py-3">
<div className="flex items-start gap-3">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-amber-500" />
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm font-medium text-foreground">
{t('captures.permissions.accessibility.title')}
</p>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans i18nKey="captures.permissions.accessibility.body" components={{ path: <span /> }} />
</p>
<div className="flex items-center gap-2 pt-1.5">
<Button size="sm" onClick={openSettings} className="gap-1.5">
<ExternalLink className="h-3.5 w-3.5" />
{t('captures.permissions.accessibility.openSettings')}
</Button>
<Button variant="outline" size="sm" onClick={handleRecheck} disabled={checking}>
{checking ? t('captures.permissions.accessibility.rechecking') : t('captures.permissions.accessibility.recheck')}
</Button>
</div>
{stillMissing && !checking && (
<p className="text-xs text-amber-600 dark:text-amber-400 pt-1">
{t('captures.permissions.accessibility.stillMissing')}
</p>
)}
</div>
</div>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { useRouterState } from '@tanstack/react-router';
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
import { AudioKeepAlive } from '@/components/AudioPlayer/AudioKeepAlive';
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
import { StoryTrackEditor } from '@/components/StoriesTab/StoryTrackEditor';
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn';
import { useStoryStore } from '@/stores/storyStore';
import { useStory } from '@/lib/hooks/useStories';
interface AppFrameProps {
children: React.ReactNode;
}
export function AppFrame({ children }: AppFrameProps) {
const routerState = useRouterState();
const isStoriesRoute = routerState.location.pathname === '/stories';
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
const { data: story } = useStory(selectedStoryId);
// Show track editor when on stories route with a selected story that has items
const showTrackEditor = isStoriesRoute && selectedStoryId && story && story.items.length > 0;
return (
<div
className={cn('h-screen bg-background flex flex-col overflow-hidden', TOP_SAFE_AREA_PADDING)}
>
<TitleBarDragRegion />
<AudioKeepAlive />
{children}
{showTrackEditor ? (
<StoryTrackEditor storyId={story.id} items={story.items} />
) : (
<AudioPlayer />
)}
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils/cn';
export type AudioBarsMode = 'idle' | 'generating' | 'playing';
interface AudioBarsProps {
mode: AudioBarsMode;
className?: string;
barClassName?: string;
}
export function AudioBars({ mode, className, barClassName }: AudioBarsProps) {
const activeColor = mode !== 'idle' ? 'bg-accent' : 'bg-muted-foreground/40';
return (
<div className={cn('flex items-center gap-[2px] h-5', className)}>
{[0, 1, 2, 3, 4].map((i) => (
<motion.div
key={`${mode}-${i}`}
className={cn('w-[3px] rounded-full', activeColor, barClassName)}
animate={
mode === 'generating'
? { height: ['6px', '16px', '6px'] }
: mode === 'playing'
? { height: ['8px', '14px', '4px', '12px', '8px'] }
: { height: '8px' }
}
transition={
mode === 'generating'
? { duration: 0.6, repeat: Infinity, delay: i * 0.08, ease: 'easeInOut' }
: mode === 'playing'
? { duration: 1.2, repeat: Infinity, delay: i * 0.15, ease: 'easeInOut' }
: { duration: 0.4, ease: 'easeOut' }
}
/>
))}
</div>
);
}
@@ -0,0 +1,85 @@
import { useEffect, useRef } from 'react';
import { debug } from '@/lib/utils/debug';
// WKWebView tears down the app's CoreAudio output when idle for long enough,
// and a JS-level reload (cmd+R) does NOT restore it — only relaunching the
// Tauri app does. Keeping a silent <audio> element looping forever prevents
// the OS audio session from ever going dormant.
//
// Real silence (zero PCM samples) at full volume is preferred over a muted
// element: browsers/WebKit can optimize muted media away, which defeats the
// purpose of holding the session open.
function buildSilentWavUrl(seconds = 1, sampleRate = 8000): string {
const numSamples = seconds * sampleRate;
const bytes = 44 + numSamples * 2;
const buffer = new ArrayBuffer(bytes);
const view = new DataView(buffer);
const write = (offset: number, str: string) => {
for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
};
write(0, 'RIFF');
view.setUint32(4, bytes - 8, true);
write(8, 'WAVE');
write(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
write(36, 'data');
view.setUint32(40, numSamples * 2, true);
return URL.createObjectURL(new Blob([buffer], { type: 'audio/wav' }));
}
export function AudioKeepAlive() {
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
const url = buildSilentWavUrl(1, 8000);
const el = new Audio(url);
el.loop = true;
el.volume = 1;
el.preload = 'auto';
audioRef.current = el;
const tryPlay = () => {
if (!audioRef.current) return;
if (!audioRef.current.paused) return;
audioRef.current.play().catch((err) => {
debug.log('[AudioKeepAlive] play blocked (will retry on next gesture):', err);
});
};
tryPlay();
// Autoplay may be blocked until first user interaction — re-attempt then.
const onGesture = () => tryPlay();
window.addEventListener('pointerdown', onGesture, { once: false });
window.addEventListener('keydown', onGesture, { once: false });
// If the webview ever pauses the element on background, resume on return.
const onWake = () => {
if (!document.hidden) tryPlay();
};
document.addEventListener('visibilitychange', onWake);
window.addEventListener('focus', onWake);
window.addEventListener('pageshow', onWake);
return () => {
window.removeEventListener('pointerdown', onGesture);
window.removeEventListener('keydown', onGesture);
document.removeEventListener('visibilitychange', onWake);
window.removeEventListener('focus', onWake);
window.removeEventListener('pageshow', onWake);
el.pause();
el.src = '';
URL.revokeObjectURL(url);
audioRef.current = null;
};
}, []);
return null;
}
+398 -249
View File
@@ -1,57 +1,92 @@
import { Pause, Play, Repeat, Volume2, VolumeX } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { apiClient } from '@/lib/api/client';
import { formatAudioDuration } from '@/lib/utils/audio';
import { debug } from '@/lib/utils/debug';
import { usePlatform } from '@/platform/PlatformContext';
import { usePlayerStore } from '@/stores/playerStore';
export function AudioPlayer() {
const platform = usePlatform();
const volumeLabelId = useId();
const {
audioUrl,
title,
audioId,
profileId,
isPlaying,
currentTime,
duration,
volume,
isLooping,
shouldRestart,
setIsPlaying,
setCurrentTime,
setDuration,
setVolume,
toggleLoop,
clearRestartFlag,
reset,
} = usePlayerStore();
// Check if profile has assigned channels (for native audio routing)
const { data: profileChannels } = useQuery({
queryKey: ['profile-channels', profileId],
queryFn: () => {
if (!profileId) return { channel_ids: [] };
return apiClient.getProfileChannels(profileId);
},
enabled: !!profileId && platform.metadata.isTauri,
});
const { data: channels } = useQuery({
queryKey: ['channels'],
queryFn: () => apiClient.listChannels(),
enabled: !!profileChannels && profileChannels.channel_ids.length > 0,
});
// Determine if we should use native playback
const useNativePlayback = useMemo(() => {
if (!platform.metadata.isTauri || !profileChannels || !channels) {
return false;
}
const assignedChannels = channels.filter((ch) => profileChannels.channel_ids.includes(ch.id));
// Use native playback if any assigned channel has non-default devices
const shouldUseNative = assignedChannels.some(
(ch) => ch.device_ids.length > 0 && !ch.is_default,
);
return shouldUseNative;
}, [profileChannels, channels, platform.metadata.isTauri]);
const waveformRef = useRef<HTMLDivElement>(null);
const wavesurferRef = useRef<WaveSurfer | null>(null);
const loadingRef = useRef(false);
const previousAudioIdRef = useRef<string | null>(null);
const hasInitializedRef = useRef(false);
const isUsingNativePlaybackRef = useRef(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [wsReady, setWsReady] = useState(false);
// Initialize WaveSurfer (only when audioUrl exists and container is ready)
// Create WaveSurfer once when the player becomes visible (audioUrl is set).
// This instance is reused for all subsequent audio loads - never destroyed until unmount.
useEffect(() => {
// Don't initialize if no audioUrl or already initialized
if (!audioUrl) {
return;
}
if (!audioUrl) return;
if (wavesurferRef.current) return; // already created
if (wavesurferRef.current) {
console.log('WaveSurfer already initialized, skipping');
return;
}
console.log('Creating NEW WaveSurfer instance');
// Wait for container to be properly rendered
const initWaveSurfer = () => {
const container = waveformRef.current;
if (!container) {
// Container not ready yet, retry
setTimeout(initWaveSurfer, 50);
return;
}
// Check if container has dimensions and is visible
const rect = container.getBoundingClientRect();
const style = window.getComputedStyle(container);
const isVisible =
@@ -61,267 +96,221 @@ export function AudioPlayer() {
style.visibility !== 'hidden';
if (!isVisible) {
// Retry after a short delay
setTimeout(initWaveSurfer, 50);
return;
}
console.log('Initializing WaveSurfer...', {
container,
debug.log('Creating WaveSurfer instance', {
width: rect.width,
height: rect.height,
});
try {
// Get computed CSS variable values
const root = document.documentElement;
const getCSSVar = (varName: string) => {
const value = getComputedStyle(root).getPropertyValue(varName).trim();
return value ? `hsl(${value})` : '';
};
const waveColor = getCSSVar('--muted');
const progressColor = getCSSVar('--accent');
const cursorColor = getCSSVar('--accent');
const wavesurfer = WaveSurfer.create({
container: container,
waveColor: waveColor,
progressColor: progressColor,
cursorColor: cursorColor,
container,
waveColor: getCSSVar('--muted'),
progressColor: getCSSVar('--accent'),
cursorColor: getCSSVar('--accent'),
cursorWidth: 3,
barWidth: 2,
barRadius: 2,
height: 80,
normalize: true,
interact: true,
dragToSeek: { debounceTime: 0 },
mediaControls: false,
backend: 'WebAudio',
interact: true, // Enable interaction (click to seek)
mediaControls: false, // Don't show native controls
});
// Wire up event handlers (these persist for the lifetime of the instance)
wavesurfer.on('timeupdate', (time) => {
const dur = usePlayerStore.getState().duration;
if (dur > 0 && time >= dur) {
setCurrentTime(dur);
const loop = usePlayerStore.getState().isLooping;
if (loop) {
wavesurfer.seekTo(0);
wavesurfer.play().catch((err) => debug.error('Loop play failed:', err));
} else {
wavesurfer.pause();
setIsPlaying(false);
}
return;
}
setCurrentTime(time);
});
wavesurfer.on('ready', () => {
const dur = wavesurfer.getDuration();
setDuration(dur);
loadingRef.current = false;
setIsLoading(false);
setError(null);
debug.log('Audio ready, duration:', dur);
wavesurfer.setVolume(usePlayerStore.getState().volume);
wavesurfer.setMuted(false);
// Auto-play if the flag is set (story mode advance or explicit play)
const shouldAutoPlayNow = usePlayerStore.getState().shouldAutoPlay;
if (shouldAutoPlayNow) {
usePlayerStore.getState().clearAutoPlayFlag();
wavesurfer.play().catch((err) => {
debug.error('Failed to autoplay:', err);
});
} else {
debug.log('Skipping auto-play - shouldAutoPlay is false');
}
});
wavesurfer.on('play', () => setIsPlaying(true));
wavesurfer.on('pause', () => {
setIsPlaying(false);
setCurrentTime(wavesurfer.getCurrentTime());
});
wavesurfer.on('seeking', (time) => setCurrentTime(time));
// Mute audio during drag-to-seek to prevent popping from the WebAudio
// backend's hard stop/start cycle on each seek. Unmute with a short
// fade-in when the drag ends.
const seekMedia = wavesurfer.getMediaElement() as any;
const seekGain: GainNode | null = seekMedia?.getGainNode?.() ?? null;
if (seekGain) {
const ctx = seekGain.context as AudioContext;
wavesurfer.on('dragstart', () => {
seekGain.gain.cancelScheduledValues(ctx.currentTime);
seekGain.gain.setTargetAtTime(0, ctx.currentTime, 0.002);
});
wavesurfer.on('dragend', () => {
seekGain.gain.cancelScheduledValues(ctx.currentTime);
seekGain.gain.setTargetAtTime(1, ctx.currentTime, 0.01);
});
}
wavesurfer.on('finish', () => {
const loop = usePlayerStore.getState().isLooping;
if (loop) {
wavesurfer.seekTo(0);
wavesurfer.play().catch((err) => debug.error('Loop play failed:', err));
} else {
setIsPlaying(false);
const onFinish = usePlayerStore.getState().onFinish;
if (onFinish) onFinish();
}
});
wavesurfer.on('error', (err) => {
debug.error('WaveSurfer error:', err);
setIsLoading(false);
setError(`Audio error: ${err instanceof Error ? err.message : String(err)}`);
});
wavesurfer.on('loading', (percent) => {
setIsLoading(true);
if (percent === 100) setIsLoading(false);
});
wavesurferRef.current = wavesurfer;
console.log('WaveSurfer created successfully');
} catch (error) {
console.error('Failed to create WaveSurfer:', error);
setWsReady(true);
debug.log('WaveSurfer created successfully');
} catch (err) {
debug.error('Failed to create WaveSurfer:', err);
setError(
`Failed to initialize waveform: ${error instanceof Error ? error.message : String(error)}`,
`Failed to initialize waveform: ${err instanceof Error ? err.message : String(err)}`,
);
return;
}
const wavesurfer = wavesurferRef.current;
if (!wavesurfer) return;
// Update store when time changes
wavesurfer.on('timeupdate', (time) => {
setCurrentTime(time);
});
// Update store when duration is loaded
wavesurfer.on('ready', () => {
const dur = wavesurfer.getDuration();
setDuration(dur);
loadingRef.current = false;
setIsLoading(false);
setError(null);
console.log('Audio ready, duration:', dur);
console.log('Waveform should be visible now');
// Ensure volume is set
const currentVolume = usePlayerStore.getState().volume;
wavesurfer.setVolume(currentVolume);
// Get the underlying audio element and ensure it's not muted
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
mediaElement.volume = currentVolume;
mediaElement.muted = false;
console.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted);
}
// Auto-play when ready
// Use a small delay to ensure audio element is fully ready
setTimeout(() => {
wavesurfer.play().catch((error) => {
console.error('Failed to autoplay:', error);
// Don't show error for autoplay failures (browser restrictions)
});
}, 100);
});
// Handle play/pause
wavesurfer.on('play', () => {
setIsPlaying(true);
// Ensure audio element is not muted when playing
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
mediaElement.muted = false;
const currentVolume = usePlayerStore.getState().volume;
mediaElement.volume = currentVolume;
console.log('Playing - volume:', mediaElement.volume, 'muted:', mediaElement.muted);
}
});
wavesurfer.on('pause', () => setIsPlaying(false));
wavesurfer.on('finish', () => {
// Check loop state from store
const loop = usePlayerStore.getState().isLooping;
if (loop) {
wavesurfer.seekTo(0);
wavesurfer.play();
} else {
setIsPlaying(false);
}
});
// Handle errors
wavesurfer.on('error', (error) => {
console.error('WaveSurfer error:', error);
setIsLoading(false);
setError(`Audio error: ${error instanceof Error ? error.message : String(error)}`);
});
// Handle loading
wavesurfer.on('loading', (percent) => {
setIsLoading(true);
if (percent === 100) {
setIsLoading(false);
}
});
// Load audio immediately if audioUrl is already set
if (audioUrl) {
console.log('WaveSurfer ready, loading audio:', audioUrl);
loadingRef.current = true;
setIsLoading(true);
// Stop any current playback before loading new audio
if (wavesurfer.isPlaying()) {
wavesurfer.pause();
}
wavesurfer
.load(audioUrl)
.then(() => {
console.log('Audio loaded into WaveSurfer');
loadingRef.current = false;
})
.catch((error) => {
console.error('Failed to load audio into WaveSurfer:', error);
loadingRef.current = false;
setIsLoading(false);
setError(
`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`,
);
});
}
};
// Use double requestAnimationFrame to ensure DOM is fully rendered
let rafId1: number;
let rafId2: number;
let timeoutId: number | null = null;
rafId1 = requestAnimationFrame(() => {
rafId2 = requestAnimationFrame(() => {
// Add a small delay to ensure container is fully laid out
timeoutId = setTimeout(() => {
initWaveSurfer();
}, 10);
});
let rafId: number;
rafId = requestAnimationFrame(() => {
initWaveSurfer();
});
return () => {
console.log('Cleaning up WaveSurfer initialization effect');
if (rafId1) cancelAnimationFrame(rafId1);
if (rafId2) cancelAnimationFrame(rafId2);
if (timeoutId) clearTimeout(timeoutId);
cancelAnimationFrame(rafId);
};
// Only run on mount-like conditions. audioUrl is here so we create the instance
// when the player first appears, but we guard against re-creation above.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [audioUrl, setIsPlaying, setDuration, setCurrentTime]);
// Destroy WaveSurfer only on unmount
useEffect(() => {
return () => {
if (wavesurferRef.current) {
console.log('Destroying WaveSurfer instance');
debug.log('Destroying WaveSurfer instance (unmount)');
try {
const mediaElement = wavesurferRef.current.getMediaElement();
if (mediaElement) {
mediaElement.pause();
mediaElement.src = '';
}
wavesurferRef.current.destroy();
} catch (error) {
console.error('Error destroying WaveSurfer:', error);
} catch (err) {
debug.error('Error destroying WaveSurfer:', err);
}
wavesurferRef.current = null;
setWsReady(false);
}
};
}, [audioUrl, setIsPlaying, setCurrentTime, setDuration]);
}, []);
// Load audio when URL changes (only if WaveSurfer is already initialized)
// Load audio when URL changes (reuses the existing WaveSurfer instance)
useEffect(() => {
const wavesurfer = wavesurferRef.current;
if (!wavesurfer || !wsReady) return;
if (!audioUrl || !wavesurfer) {
// Reset state when no audio or WaveSurfer not ready
if (!audioUrl && wavesurfer) {
wavesurfer.pause();
wavesurfer.seekTo(0);
loadingRef.current = false;
setIsLoading(false);
setDuration(0);
setCurrentTime(0);
setError(null);
}
if (!audioUrl) {
// No audio - pause and reset
wavesurfer.pause();
wavesurfer.seekTo(0);
loadingRef.current = false;
setIsLoading(false);
setDuration(0);
setCurrentTime(0);
setError(null);
isUsingNativePlaybackRef.current = false;
return;
}
// CRITICAL: Force stop any current playback and cancel any pending loads
// This must happen BEFORE any early returns
console.log('Audio URL changed to:', audioUrl);
// Reset native playback state
isUsingNativePlaybackRef.current = false;
wavesurfer.setMuted(false);
wavesurfer.setVolume(usePlayerStore.getState().volume);
// COMPLETELY stop and destroy the current audio
// Stop current playback and reset position before loading new audio.
// With the WebAudio backend, pause() accumulates playedDuration internally.
// seekTo(0) resets it so the new track starts from the beginning.
debug.log('Loading new audio URL:', audioUrl);
try {
// First pause if playing
if (wavesurfer.isPlaying()) {
console.log('Pausing current playback');
wavesurfer.pause();
}
// Stop the media element explicitly
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
console.log('Stopping media element');
mediaElement.pause();
mediaElement.currentTime = 0;
mediaElement.src = '';
}
// Use empty() to completely destroy the waveform and media element
console.log('Calling wavesurfer.empty() to destroy audio');
wavesurfer.empty();
} catch (error) {
console.error('Error stopping previous audio:', error);
// Continue anyway to load new audio
wavesurfer.seekTo(0);
} catch (err) {
debug.error('Error resetting before load:', err);
}
// Reset loading state to allow new load (cancel any pending loads)
loadingRef.current = false;
// Now start the new load
loadingRef.current = true;
setIsLoading(true);
setError(null);
setCurrentTime(0);
setDuration(0);
// Load new audio
console.log('Starting new audio load for:', audioUrl);
wavesurfer
.load(audioUrl)
.then(() => {
console.log('Audio load promise resolved');
// Don't set loading to false here - wait for 'ready' event
debug.log('Audio loaded into WaveSurfer');
loadingRef.current = false;
})
.catch((error) => {
console.error('Failed to load audio:', error);
console.error('Audio URL:', audioUrl);
.catch((err) => {
debug.error('Failed to load audio:', err);
loadingRef.current = false;
setIsLoading(false);
setError(`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`);
setError(`Failed to load audio: ${err instanceof Error ? err.message : String(err)}`);
});
}, [audioUrl, setCurrentTime, setDuration]);
}, [audioUrl, wsReady, setCurrentTime, setDuration]);
// Sync play/pause state (only when user clicks play/pause button, not auto-sync)
// This effect is kept for external state changes but should be minimal
@@ -329,9 +318,8 @@ export function AudioPlayer() {
if (!wavesurferRef.current || duration === 0) return;
if (isPlaying && wavesurferRef.current.isPlaying() === false) {
// Only auto-play if audio is ready
wavesurferRef.current.play().catch((error) => {
console.error('Failed to play:', error);
debug.error('Failed to play:', error);
setIsPlaying(false);
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
});
@@ -344,36 +332,156 @@ export function AudioPlayer() {
useEffect(() => {
if (wavesurferRef.current) {
wavesurferRef.current.setVolume(volume);
// Also ensure the underlying audio element volume is set
const mediaElement = wavesurferRef.current.getMediaElement();
if (mediaElement) {
mediaElement.volume = volume;
mediaElement.muted = volume === 0;
console.log('Volume synced:', volume, 'muted:', mediaElement.muted);
}
}
}, [volume]);
// Handle loop - WaveSurfer handles this via the 'finish' event
// Mark as initialized when audio is ready, reset when audioId changes
useEffect(() => {
if (duration > 0 && audioId) {
hasInitializedRef.current = true;
}
// Reset initialization flag when audioId changes to a new audio
if (audioId !== previousAudioIdRef.current && previousAudioIdRef.current !== null) {
hasInitializedRef.current = false;
}
if (audioId !== null) {
previousAudioIdRef.current = audioId;
}
}, [duration, audioId]);
const handlePlayPause = () => {
// Handle restart flag - when history item is clicked again, restart from beginning
useEffect(() => {
const wavesurfer = wavesurferRef.current;
if (!wavesurfer || !shouldRestart || duration === 0) {
return;
}
debug.log('Restarting current audio from beginning');
wavesurfer.seekTo(0);
wavesurfer.play().catch((error) => {
debug.error('Failed to play after restart:', error);
setIsPlaying(false);
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
});
clearRestartFlag();
}, [shouldRestart, duration, setIsPlaying, clearRestartFlag]);
// Auto-play is handled exclusively in the WaveSurfer 'ready' event handler.
// A separate effect here would race with the ready event since the WebAudio
// backend needs to fully decode the audio before play() works correctly.
// Spacebar to play/pause (capture phase so it fires before focused elements)
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.code !== 'Space') return;
// Ignore if user is typing in an input/textarea
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) {
return;
}
if (audioUrl && duration > 0 && wavesurferRef.current) {
e.preventDefault();
e.stopPropagation();
if (wavesurferRef.current.isPlaying()) {
wavesurferRef.current.pause();
} else {
wavesurferRef.current.play().catch((err) => debug.error('Spacebar play failed:', err));
}
}
};
document.addEventListener('keydown', onKeyDown, true);
return () => document.removeEventListener('keydown', onKeyDown, true);
}, [audioUrl, duration]);
const handlePlayPause = async () => {
// Standard WaveSurfer playback (works for both normal and native playback modes)
// When using native playback, WaveSurfer is muted but still controls visualization
if (!wavesurferRef.current) {
console.error('WaveSurfer not initialized');
debug.error('WaveSurfer not initialized');
return;
}
// Check if audio is loaded
if (duration === 0 && !isLoading) {
console.error('Audio not loaded yet');
debug.error('Audio not loaded yet');
setError('Audio not loaded. Please wait...');
return;
}
// If using native playback
if (useNativePlayback && audioUrl && profileChannels && channels) {
if (isPlaying) {
// Pause: stop native playback and pause WaveSurfer visualization
try {
platform.audio.stopPlayback();
debug.log('Stopped native audio playback');
} catch (error) {
debug.error('Failed to stop native playback:', error);
}
wavesurferRef.current.pause();
return;
}
// Play: trigger native playback
try {
// Stop any existing native playback first
try {
platform.audio.stopPlayback();
} catch (_error) {
// Ignore errors when stopping (might not be playing)
debug.log('No existing playback to stop');
}
// Collect all device IDs from assigned channels
const assignedChannels = channels.filter((ch) =>
profileChannels.channel_ids.includes(ch.id),
);
const deviceIds = assignedChannels.flatMap((ch) => ch.device_ids);
if (deviceIds.length > 0) {
// Fetch audio data
const response = await fetch(audioUrl);
const audioData = new Uint8Array(await response.arrayBuffer());
// Play via native audio
await platform.audio.playToDevices(audioData, deviceIds);
// Mark that we're using native playback
isUsingNativePlaybackRef.current = true;
// Mute WaveSurfer and start it for visualization
wavesurferRef.current.setVolume(0);
wavesurferRef.current.setMuted(true);
// Start WaveSurfer for visualization (muted)
wavesurferRef.current.play().catch((error) => {
debug.error('Failed to start WaveSurfer visualization:', error);
setIsPlaying(false);
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
});
return;
}
} catch (error) {
debug.error('Native playback failed, falling back to WaveSurfer:', error);
// Fall through to WaveSurfer playback
isUsingNativePlaybackRef.current = false;
}
}
// Standard WaveSurfer playback (or fallback from native playback failure)
if (wavesurferRef.current.isPlaying()) {
wavesurferRef.current.pause();
} else {
// Ensure WaveSurfer is not muted if not using native playback
if (!isUsingNativePlaybackRef.current) {
wavesurferRef.current.setMuted(false);
wavesurferRef.current.setVolume(volume);
}
wavesurferRef.current.play().catch((error) => {
console.error('Failed to play:', error);
debug.error('Failed to play:', error);
setIsPlaying(false);
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
});
@@ -390,6 +498,24 @@ export function AudioPlayer() {
setVolume(value[0] / 100);
};
const handleClose = () => {
// Stop any native playback
if (isUsingNativePlaybackRef.current && platform.metadata.isTauri) {
try {
platform.audio.stopPlayback();
} catch (error) {
debug.error('Failed to stop native playback:', error);
}
}
// Stop WaveSurfer
if (wavesurferRef.current) {
wavesurferRef.current.pause();
wavesurferRef.current.seekTo(0);
}
// Reset player state
reset();
};
// Don't render if no audio
if (!audioUrl) {
return null;
@@ -405,27 +531,32 @@ export function AudioPlayer() {
size="icon"
onClick={handlePlayPause}
disabled={isLoading || duration === 0}
className="shrink-0"
className={`shrink-0 -mt-2 ${isPlaying ? 'bg-accent text-accent-foreground' : ''}`}
title={duration === 0 && !isLoading ? 'Audio not loaded' : ''}
aria-label={
duration === 0 && !isLoading ? 'Audio not loaded' : isPlaying ? 'Pause' : 'Play'
}
>
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
{isPlaying ? (
<Pause className="h-5 w-5 fill-current" />
) : (
<Play className="h-5 w-5 fill-current" />
)}
</Button>
{/* Waveform */}
<div className="flex-1 min-w-0 flex flex-col gap-1">
<div ref={waveformRef} className="w-full min-h-[80px]" />
{duration > 0 && (
<Slider
value={duration > 0 ? [(currentTime / duration) * 100] : [0]}
onValueChange={handleSeek}
max={100}
step={0.1}
className="w-full"
/>
)}
{isLoading && (
<div className="text-xs text-muted-foreground text-center py-2">Loading audio...</div>
)}
<div ref={waveformRef} className="w-full min-h-[80px] select-none" />
<Slider
value={duration > 0 ? [(currentTime / duration) * 100] : [0]}
onValueChange={handleSeek}
max={100}
step={0.1}
className="w-full"
aria-label="Playback position"
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
/>
{error && <div className="text-xs text-destructive text-center py-2">{error}</div>}
</div>
@@ -436,40 +567,58 @@ export function AudioPlayer() {
<span className="font-mono">{formatAudioDuration(duration)}</span>
</div>
{/* Title */}
{title && (
<div className="text-sm font-medium truncate max-w-[200px] shrink-0">{title}</div>
)}
{/* Loop Button */}
<Button
variant="ghost"
size="icon"
onClick={toggleLoop}
className={isLooping ? 'text-primary' : ''}
className={isLooping ? 'bg-accent text-accent-foreground' : ''}
title="Toggle loop"
aria-label={isLooping ? 'Stop looping' : 'Loop'}
>
<Repeat className="h-4 w-4" />
</Button>
{/* Volume Control */}
<div className="flex items-center gap-2 shrink-0 w-[120px]">
<div
className="flex items-center gap-2 shrink-0 w-[120px]"
role="group"
aria-label="Volume"
>
<Button
variant="ghost"
size="icon"
onClick={() => setVolume(volume > 0 ? 0 : 1)}
className="h-8 w-8"
aria-label={volume > 0 ? 'Mute' : 'Unmute'}
>
{volume > 0 ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
</Button>
<span id={volumeLabelId} className="sr-only">
Volume level, {Math.round(volume * 100)}%
</span>
<Slider
value={[volume * 100]}
onValueChange={handleVolumeChange}
max={100}
step={1}
className="flex-1"
aria-labelledby={volumeLabelId}
aria-valuetext={`${Math.round(volume * 100)}%`}
/>
</div>
{/* Close Button */}
<Button
variant="ghost"
size="icon"
onClick={handleClose}
className="shrink-0"
title="Close player"
aria-label="Close player"
>
<X className="h-5 w-5" />
</Button>
</div>
</div>
</div>
+675
View File
@@ -0,0 +1,675 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiClient } from '@/lib/api/client';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn';
import { usePlatform } from '@/platform/PlatformContext';
import { usePlayerStore } from '@/stores/playerStore';
interface AudioDevice {
id: string;
name: string;
is_default: boolean;
}
export function AudioTab() {
const { t } = useTranslation();
const platform = usePlatform();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editingChannel, setEditingChannel] = useState<string | null>(null);
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
const queryClient = useQueryClient();
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
const { data: channels, isLoading: channelsLoading } = useQuery({
queryKey: ['channels'],
queryFn: () => apiClient.listChannels(),
});
const { data: devices, isLoading: devicesLoading } = useQuery({
queryKey: ['audio-devices'],
queryFn: async () => {
if (!platform.metadata.isTauri) {
return [];
}
try {
return await platform.audio.listOutputDevices();
} catch (error) {
console.error('Failed to list audio devices:', error);
return [];
}
},
enabled: platform.metadata.isTauri,
});
const { data: profiles } = useQuery({
queryKey: ['profiles'],
queryFn: () => apiClient.listProfiles(),
});
const createChannel = useMutation({
mutationFn: (data: { name: string; device_ids: string[] }) => apiClient.createChannel(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] });
setCreateDialogOpen(false);
},
});
const updateChannel = useMutation({
mutationFn: ({
channelId,
data,
}: {
channelId: string;
data: { name?: string; device_ids?: string[] };
}) => apiClient.updateChannel(channelId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] });
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
setEditingChannel(null);
},
});
const deleteChannel = useMutation({
mutationFn: (channelId: string) => apiClient.deleteChannel(channelId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] });
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
},
});
const { data: channelVoices } = useQuery({
queryKey: ['channel-voices', editingChannel],
queryFn: async () => {
if (!editingChannel) return { profile_ids: [] };
return apiClient.getChannelVoices(editingChannel);
},
enabled: !!editingChannel,
});
const setChannelVoices = useMutation({
mutationFn: ({ channelId, profileIds }: { channelId: string; profileIds: string[] }) =>
apiClient.setChannelVoices(channelId, profileIds),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channel-voices'] });
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
},
});
if (channelsLoading || devicesLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-muted-foreground">{t('audioChannels.loading')}</div>
</div>
);
}
const handleChannelDelete = async (e: React.MouseEvent, channelId: string) => {
e.stopPropagation();
if (await confirm(t('audioChannels.confirmDelete'))) {
deleteChannel.mutate(channelId);
}
};
const allChannels = channels || [];
const allDevices = devices || [];
const selectedChannel = selectedChannelId
? allChannels.find((c) => c.id === selectedChannelId)
: null;
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between mb-6 shrink-0">
<h2 className="text-2xl font-bold">{t('audioChannels.title')}</h2>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
{t('audioChannels.newChannel')}
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0">
{/* Left Column - Channels */}
<div
className={cn(
'flex flex-col min-h-0 overflow-y-auto',
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
)}
>
{allChannels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
<Speaker className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-4">{t('audioChannels.empty.message')}</p>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
{t('audioChannels.empty.action')}
</Button>
</div>
) : (
<div className="space-y-3">
{allChannels.map((channel) => {
const isSelected = selectedChannelId === channel.id;
return (
<button
key={channel.id}
type="button"
className={cn(
'group border rounded-lg p-4 transition-colors cursor-pointer text-left w-full',
isSelected && 'ring-2 ring-primary bg-primary/5 border-primary',
)}
onClick={() => setSelectedChannelId(isSelected ? null : channel.id)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-3">
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Speaker className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex items-center gap-2 min-w-0">
<h3 className="font-semibold text-base truncate">{channel.name}</h3>
</div>
</div>
<div className="space-y-2.5 ml-10">
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">
{t('audioChannels.labels.outputDevices')}
</div>
<div className="flex flex-wrap gap-1.5">
{channel.device_ids.length > 0
? channel.device_ids.map((deviceId) => {
const device = allDevices.find((d) => d.id === deviceId);
return (
<Badge
key={deviceId}
variant="outline"
className="text-xs font-normal"
>
{device?.name || deviceId}
</Badge>
);
})
: (() => {
const defaultDevice = allDevices.find((d) => d.is_default);
return defaultDevice ? (
<Badge variant="outline" className="text-xs font-normal">
{defaultDevice.name}
</Badge>
) : null;
})()}
</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">
{t('audioChannels.labels.assignedVoices')}
</div>
<ChannelVoicesList channelId={channel.id} />
</div>
</div>
</div>
{!channel.is_default && (
<div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
setEditingChannel(channel.id);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => handleChannelDelete(e, channel.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
</button>
);
})}
</div>
)}
</div>
{/* Right Column - Available Devices */}
<div
className={cn(
'flex flex-col min-h-0 overflow-y-auto',
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
)}
>
<div className="shrink-0 mb-4">
<h3 className="text-lg font-semibold">{t('audioChannels.devices.title')}</h3>
<p className="text-sm text-muted-foreground mt-1">
{selectedChannelId
? selectedChannel?.is_default
? t('audioChannels.devices.defaultNote')
: t('audioChannels.devices.toggleHint')
: t('audioChannels.devices.selectHint')}
</p>
</div>
{allDevices.length > 0 ? (
<div className="space-y-2">
{allDevices.map((device) => {
const isConnected =
selectedChannelId &&
selectedChannel &&
(selectedChannel.device_ids.length === 0
? device.is_default
: selectedChannel.device_ids.includes(device.id));
const canToggle =
selectedChannelId && selectedChannel && !selectedChannel.is_default;
const handleDeviceClick = () => {
if (!canToggle || !selectedChannel) return;
const currentDeviceIds = selectedChannel.device_ids;
const newDeviceIds = isConnected
? currentDeviceIds.filter((id) => id !== device.id)
: [...currentDeviceIds, device.id];
updateChannel.mutate({
channelId: selectedChannelId,
data: { device_ids: newDeviceIds },
});
};
return (
<button
key={device.id}
type="button"
onClick={handleDeviceClick}
disabled={!canToggle}
className={cn(
'flex items-center gap-2 text-sm p-3 rounded-lg border transition-colors text-left w-full',
isConnected
? 'bg-primary/10 border-primary ring-1 ring-primary/20'
: 'hover:bg-muted/50',
!canToggle && 'cursor-default opacity-60',
canToggle && 'cursor-pointer',
)}
>
{canToggle ? (
<div
className={cn(
'h-4 w-4 rounded border-2 flex items-center justify-center shrink-0',
isConnected ? 'bg-accent border-accent' : 'border-muted-foreground/30',
)}
>
{isConnected && <Check className="h-3 w-3 text-accent-foreground" />}
</div>
) : device.is_default ? (
<CheckCircle2 className="h-4 w-4 text-primary shrink-0" />
) : null}
<span className={cn('truncate flex-1', device.is_default && 'font-medium')}>
{device.name}
</span>
</button>
);
})}
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground text-center">
{platform.metadata.isTauri
? t('audioChannels.devices.empty')
: t('audioChannels.devices.requiresTauri')}
</p>
</div>
)}
</div>
</div>
{/* Create Channel Dialog */}
<CreateChannelDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
devices={devices || []}
onCreate={(name, deviceIds) => {
createChannel.mutate({ name, device_ids: deviceIds });
}}
/>
{/* Edit Channel Dialog */}
{editingChannel &&
(() => {
const channel = channels?.find((c) => c.id === editingChannel);
return channel ? (
<EditChannelDialog
open={!!editingChannel}
onOpenChange={(open) => !open && setEditingChannel(null)}
channel={channel}
devices={devices || []}
profiles={profiles || []}
channelVoices={channelVoices?.profile_ids || []}
onUpdate={(name, deviceIds) => {
updateChannel.mutate({
channelId: editingChannel,
data: { name, device_ids: deviceIds },
});
}}
onSetVoices={(profileIds) => {
setChannelVoices.mutate({
channelId: editingChannel,
profileIds,
});
}}
/>
) : null;
})()}
</div>
);
}
function ChannelVoicesList({ channelId }: { channelId: string }) {
const { t } = useTranslation();
const { data: voices } = useQuery({
queryKey: ['channel-voices', channelId],
queryFn: () => apiClient.getChannelVoices(channelId),
});
const { data: profiles } = useQuery({
queryKey: ['profiles'],
queryFn: () => apiClient.listProfiles(),
});
const voiceNames =
voices?.profile_ids.map((id) => profiles?.find((p) => p.id === id)?.name).filter(Boolean) || [];
return (
<div className="flex flex-wrap gap-1.5">
{voiceNames.length > 0 ? (
voiceNames.map((name) => (
<Badge key={name} variant="outline" className="text-xs font-normal">
{name}
</Badge>
))
) : (
<span className="text-sm text-muted-foreground">{t('audioChannels.noVoicesAssigned')}</span>
)}
</div>
);
}
interface CreateChannelDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
devices: AudioDevice[];
onCreate: (name: string, deviceIds: string[]) => void;
}
function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateChannelDialogProps) {
const { t } = useTranslation();
const [name, setName] = useState('');
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
const handleSubmit = () => {
if (name.trim()) {
onCreate(name.trim(), selectedDevices);
setName('');
setSelectedDevices([]);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('audioChannels.createDialog.title')}</DialogTitle>
<DialogDescription>{t('audioChannels.createDialog.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="channel-name">{t('audioChannels.fields.name')}</Label>
<Input
id="channel-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('audioChannels.fields.namePlaceholder')}
/>
</div>
<div>
<Label>{t('audioChannels.labels.outputDevices')}</Label>
<Select
value={selectedDevices[0] || ''}
onValueChange={(value) => {
if (value && !selectedDevices.includes(value)) {
setSelectedDevices([...selectedDevices, value]);
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t('audioChannels.selectDevice')} />
</SelectTrigger>
<SelectContent>
{devices.map((device) => (
<SelectItem key={device.id} value={device.id}>
{device.name} {device.is_default && `(${t('audioChannels.defaultSuffix')})`}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedDevices.length > 0 && (
<div className="mt-2 space-y-1">
{selectedDevices.map((deviceId) => {
const device = devices.find((d) => d.id === deviceId);
return (
<div
key={deviceId}
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
>
<span>{device?.name || deviceId}</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleSubmit} disabled={!name.trim()}>
{t('audioChannels.createDialog.action')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
interface EditChannelDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
channel: {
id: string;
name: string;
device_ids: string[];
};
devices: AudioDevice[];
profiles: Array<{ id: string; name: string }>;
channelVoices: string[];
onUpdate: (name: string, deviceIds: string[]) => void;
onSetVoices: (profileIds: string[]) => void;
}
function EditChannelDialog({
open,
onOpenChange,
channel,
devices,
profiles,
channelVoices,
onUpdate,
onSetVoices,
}: EditChannelDialogProps) {
const { t } = useTranslation();
const [name, setName] = useState(channel.name);
const [selectedDevices, setSelectedDevices] = useState<string[]>(channel.device_ids);
const [selectedVoices, setSelectedVoices] = useState<string[]>(channelVoices);
const handleSubmit = () => {
if (name.trim()) {
onUpdate(name.trim(), selectedDevices);
onSetVoices(selectedVoices);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('audioChannels.editDialog.title')}</DialogTitle>
<DialogDescription>{t('audioChannels.editDialog.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="edit-channel-name">{t('audioChannels.fields.name')}</Label>
<Input id="edit-channel-name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<Label>{t('audioChannels.labels.outputDevices')}</Label>
<Select
value=""
onValueChange={(value) => {
if (value && !selectedDevices.includes(value)) {
setSelectedDevices([...selectedDevices, value]);
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t('audioChannels.addDevice')} />
</SelectTrigger>
<SelectContent>
{devices.map((device) => (
<SelectItem key={device.id} value={device.id}>
{device.name} {device.is_default && `(${t('audioChannels.defaultSuffix')})`}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedDevices.length > 0 && (
<div className="mt-2 space-y-1">
{selectedDevices.map((deviceId) => {
const device = devices.find((d) => d.id === deviceId);
return (
<div
key={deviceId}
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
>
<span>{device?.name || deviceId}</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
)}
</div>
<div>
<Label>{t('audioChannels.labels.assignedVoices')}</Label>
<Select
value=""
onValueChange={(value) => {
if (value && !selectedVoices.includes(value)) {
setSelectedVoices([...selectedVoices, value]);
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t('audioChannels.addVoice')} />
</SelectTrigger>
<SelectContent>
{profiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedVoices.length > 0 && (
<div className="mt-2 space-y-1">
{selectedVoices.map((profileId) => {
const profile = profiles.find((p) => p.id === profileId);
return (
<div
key={profileId}
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
>
<span>{profile?.name || profileId}</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
setSelectedVoices(selectedVoices.filter((id) => id !== profileId))
}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleSubmit} disabled={!name.trim()}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,198 @@
import { motion } from 'framer-motion';
import { AlertCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils/cn';
/**
* Pill state machine shared between the settings preview and the live
* recording pill in the Captures tab.
*/
export type PillState =
| 'recording'
| 'transcribing'
| 'refining'
| 'speaking'
| 'completed'
| 'rest'
| 'error';
const PILL_LABEL_KEYS: Record<Exclude<PillState, 'rest' | 'error'>, string> = {
recording: 'captures.pill.recording',
transcribing: 'captures.pill.transcribing',
refining: 'captures.pill.refining',
speaking: 'captures.pill.speaking',
completed: 'captures.pill.completed',
};
function barModeFor(
state: Exclude<PillState, 'error'>,
): 'generating' | 'playing' | 'idle' {
if (state === 'recording' || state === 'speaking') return 'playing';
if (state === 'completed' || state === 'rest') return 'idle';
return 'generating';
}
export function PillAudioBars({ mode }: { mode: 'generating' | 'playing' | 'idle' }) {
return (
<div className="flex items-center gap-[2px] h-5 shrink-0">
{[0, 1, 2, 3, 4].map((i) => (
<motion.div
key={`${mode}-${i}`}
className={cn('w-[3px] rounded-full', mode === 'idle' ? 'bg-accent/30' : 'bg-accent')}
animate={
mode === 'generating'
? { height: ['6px', '16px', '6px'] }
: mode === 'playing'
? { height: ['8px', '14px', '4px', '12px', '8px'] }
: { height: '8px' }
}
transition={
mode === 'generating'
? { duration: 0.6, repeat: Infinity, delay: i * 0.08, ease: 'easeInOut' }
: mode === 'playing'
? { duration: 1.2, repeat: Infinity, delay: i * 0.15, ease: 'easeInOut' }
: { duration: 0.4, ease: 'easeOut' }
}
/>
))}
</div>
);
}
function formatElapsed(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
/**
* Floating pill shown during capture. `state` drives the label, dot animation,
* and bar motion; `elapsedMs` freezes at whatever the caller last passed in
* (recording advances the timer, transcribing/refining hold the final value).
* The ``error`` state renders a destructive variant — a clickable pill that
* copies its message to the clipboard on press and calls ``onDismiss``.
*/
export function CapturePill({
state,
elapsedMs,
onStop,
errorMessage,
onDismiss,
className,
}: {
state: PillState;
elapsedMs: number;
onStop?: () => void;
errorMessage?: string | null;
onDismiss?: () => void;
className?: string;
}) {
const { t } = useTranslation();
if (state === 'error') {
return (
<ErrorPill
message={errorMessage ?? t('captures.pill.errorFallback')}
onDismiss={onDismiss}
className={className}
/>
);
}
const visible = state !== 'rest';
const labelText = t(state === 'rest' ? PILL_LABEL_KEYS.recording : PILL_LABEL_KEYS[state]);
const barMode = barModeFor(state);
const dot = (
<span className="relative flex h-2 w-2 shrink-0">
{state === 'recording' && (
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-70" />
)}
<span className="relative rounded-full h-2 w-2 bg-accent" />
</span>
);
const stopButton = onStop && state === 'recording' ? (
<button
type="button"
onClick={onStop}
aria-label={t('captures.pill.stopAria')}
className="relative flex h-2 w-2 shrink-0 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-accent/50"
>
{dot}
</button>
) : dot;
// Completed gets an inset accent stroke (via box-shadow, not Tailwind's
// ring — ring utility doesn't compose with arbitrary shadow-[…]) to mark
// the success moment without changing the pill's dimensions.
const completedStroke =
state === 'completed'
? 'shadow-[inset_0_0_0_2px_hsl(var(--accent)/0.6)]'
: null;
return (
<div
className={cn(
'inline-flex items-center gap-3 px-4 h-10 rounded-full text-accent',
'bg-white/80 ring-1 ring-black/5 shadow-lg backdrop-blur-xl',
'dark:bg-black/55 dark:ring-0 dark:shadow-none dark:backdrop-blur-md',
completedStroke,
'transition-opacity duration-300 ease-out',
visible ? 'opacity-100' : 'opacity-0 pointer-events-none',
className,
)}
>
{stopButton}
<span className="text-sm font-medium shrink-0" style={{ minWidth: '104px' }}>
{labelText}
</span>
<PillAudioBars mode={barMode} />
<span className="text-xs tabular-nums text-accent/70 font-medium shrink-0 -ml-1">
{formatElapsed(elapsedMs)}
</span>
</div>
);
}
function ErrorPill({
message,
onDismiss,
className,
}: {
message: string;
onDismiss?: () => void;
className?: string;
}) {
const { t } = useTranslation();
const handleClick = async () => {
try {
await navigator.clipboard.writeText(message);
} catch {
// Clipboard access can be denied in rare webview configs — ignore,
// we still want the dismiss to land.
}
onDismiss?.();
};
return (
<button
type="button"
onClick={handleClick}
title={t('captures.pill.errorCopyTooltip')}
className={cn(
'inline-flex items-center gap-2.5 px-4 h-10 rounded-full',
'bg-white/85 ring-1 ring-destructive/25 shadow-lg backdrop-blur-xl text-red-600 hover:bg-white',
'dark:bg-black/65 dark:ring-0 dark:shadow-none dark:backdrop-blur-md dark:text-red-300 dark:hover:bg-black/80',
'max-w-[380px] transition-colors',
'focus:outline-none focus:ring-2 focus:ring-red-400/50',
className,
)}
>
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
<span className="text-sm font-medium truncate">{message}</span>
</button>
);
}
@@ -0,0 +1,156 @@
import { Loader2, Pause, Play } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils/cn';
import { debug } from '@/lib/utils/debug';
function formatDuration(ms?: number | null): string {
if (!ms || ms < 0) return '0:00';
const total = Math.round(ms / 1000);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
export function CaptureInlinePlayer({
audioUrl,
fallbackDurationMs,
className,
}: {
audioUrl: string;
fallbackDurationMs?: number | null;
className?: string;
}) {
const waveformRef = useRef<HTMLDivElement>(null);
const wavesurferRef = useRef<WaveSurfer | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const container = waveformRef.current;
if (!container) return;
const root = document.documentElement;
const cssHsla = (varName: string, alpha: number) => {
const value = getComputedStyle(root).getPropertyValue(varName).trim();
if (!value) return '';
const [h, s, l] = value.split(/\s+/);
if (!h || !s || !l) return '';
return `hsla(${h}, ${s}, ${l}, ${alpha})`;
};
const ws = WaveSurfer.create({
container,
waveColor: cssHsla('--muted-foreground', 1),
progressColor: cssHsla('--accent', 1),
cursorColor: 'transparent',
barWidth: 2,
barRadius: 2,
barGap: 2,
height: 40,
normalize: true,
interact: true,
dragToSeek: { debounceTime: 0 },
mediaControls: false,
backend: 'WebAudio',
});
ws.on('ready', () => {
setDuration(ws.getDuration());
setIsLoading(false);
setError(null);
});
ws.on('play', () => setIsPlaying(true));
ws.on('pause', () => setIsPlaying(false));
ws.on('finish', () => {
setIsPlaying(false);
setCurrentTime(ws.getDuration());
});
ws.on('timeupdate', (t) => setCurrentTime(t));
ws.on('seeking', (t) => setCurrentTime(t));
ws.on('error', (err) => {
debug.error('Inline waveform error', err);
setError(err instanceof Error ? err.message : String(err));
setIsLoading(false);
});
wavesurferRef.current = ws;
return () => {
try {
ws.destroy();
} catch (err) {
debug.error('Failed to destroy inline waveform', err);
}
wavesurferRef.current = null;
};
}, []);
useEffect(() => {
const ws = wavesurferRef.current;
if (!ws) return;
setIsLoading(true);
setError(null);
setCurrentTime(0);
setDuration(0);
setIsPlaying(false);
try {
if (ws.isPlaying()) ws.pause();
ws.seekTo(0);
} catch (err) {
debug.error('Failed to reset inline waveform before load', err);
}
ws.load(audioUrl).catch((err) => {
debug.error('Inline waveform load failed', err);
setError(err instanceof Error ? err.message : String(err));
setIsLoading(false);
});
}, [audioUrl]);
const handlePlayPause = () => {
const ws = wavesurferRef.current;
if (!ws || isLoading) return;
if (ws.isPlaying()) {
ws.pause();
} else {
ws.play().catch((err) => {
debug.error('Inline play failed', err);
setError(err instanceof Error ? err.message : String(err));
});
}
};
const displayMs =
duration > 0
? Math.round((isPlaying || currentTime > 0 ? currentTime : duration) * 1000)
: (fallbackDurationMs ?? 0);
return (
<div className={cn('flex items-center gap-4', className)}>
<Button
size="icon"
variant="outline"
className="h-10 w-10 rounded-full shrink-0"
onClick={handlePlayPause}
disabled={isLoading || !!error}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isPlaying ? (
<Pause className="h-4 w-4 fill-current" />
) : (
<Play className="h-4 w-4 ml-0.5 fill-current" />
)}
</Button>
<div ref={waveformRef} className="flex-1 min-w-0 h-10 select-none" />
<span className="text-xs tabular-nums text-muted-foreground font-medium shrink-0">
{error ? '—' : formatDuration(displayMs)}
</span>
</div>
);
}
@@ -0,0 +1,909 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { save } from '@tauri-apps/plugin-dialog';
import { writeFile, writeTextFile } from '@tauri-apps/plugin-fs';
import {
Captions,
Check,
ChevronDown,
CircleDot,
Copy,
Download,
FileAudio,
FileText,
Loader2,
Mic,
Settings2,
Sparkles,
Square,
Trash2,
Upload,
Volume2,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AudioBars } from '@/components/AudioBars';
import { CapturePill } from '@/components/CapturePill/CapturePill';
import { CaptureInlinePlayer } from '@/components/CapturesTab/CaptureInlinePlayer';
import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Textarea } from '@/components/ui/textarea';
import {
ListPane,
ListPaneHeader,
ListPaneScroll,
ListPaneSearch,
ListPaneTitle,
ListPaneTitleRow,
} from '@/components/ListPane';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type {
CaptureListResponse,
CaptureResponse,
CaptureSource,
VoiceProfileResponse,
} from '@/lib/api/types';
import type { LanguageCode } from '@/lib/constants/languages';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSession';
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { cn } from '@/lib/utils/cn';
import { formatAbsoluteDate, formatDate } from '@/lib/utils/format';
import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore';
const CAPTURE_AUDIO_MIME = 'audio/*,.wav,.mp3,.m4a,.flac,.ogg,.webm';
function formatDuration(ms?: number | null): string {
if (!ms || ms < 0) return '0:00';
const total = Math.round(ms / 1000);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
function ChordKeys({ keys }: { keys: string[] }) {
if (keys.length === 0) return null;
return (
<div className="flex items-center gap-1">
{keys.map((k) => {
const side = modifierSideHint(k);
return (
<span
key={k}
className="relative inline-flex items-center justify-center h-6 min-w-[1.5rem] px-1.5 rounded-md border border-border bg-muted/60 font-mono text-[11px] font-medium shadow-sm text-foreground"
>
{displayLabelForKey(k)}
{side ? (
<span className="absolute -top-1 -right-1 h-3 min-w-[0.75rem] px-0.5 rounded-sm bg-accent text-[7px] font-bold leading-none flex items-center justify-center text-accent-foreground">
{side}
</span>
) : null}
</span>
);
})}
</div>
);
}
function SourceBadge({ source }: { source: CaptureSource }) {
const { t } = useTranslation();
const Icon = source === 'dictation' ? Mic : source === 'recording' ? CircleDot : FileAudio;
const label =
source === 'dictation'
? t('captures.source.dictation')
: source === 'recording'
? t('captures.source.recording')
: t('captures.source.file');
return (
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-muted/60 text-muted-foreground"
>
<Icon className="h-2.5 w-2.5" />
{label}
</Badge>
);
}
type PlaybackState = 'idle' | 'generating' | 'playing';
export function CapturesTab() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
const snippetOf = (capture: CaptureResponse): string => {
const source = capture.transcript_refined || capture.transcript_raw || '';
return source.trim() || t('captures.snippetEmpty');
};
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [showRefined, setShowRefined] = useState(true);
const [launchedPlayAsId, setLaunchedPlayAsId] = useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const audioUrl = usePlayerStore((s) => s.audioUrl);
const playerAudioId = usePlayerStore((s) => s.audioId);
const playerIsPlaying = usePlayerStore((s) => s.isPlaying);
const isPlayerVisible = !!audioUrl;
const setIsPlaying = usePlayerStore((s) => s.setIsPlaying);
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
const pendingGenerationIds = useGenerationStore((s) => s.pendingGenerationIds);
const { settings: captureSettings, update: updateCaptureSettings } = useCaptureSettings();
const sttModel = captureSettings?.stt_model ?? 'turbo';
const llmModel = captureSettings?.llm_model ?? '0.6B';
const hotkeyEnabled = captureSettings?.hotkey_enabled ?? false;
const pushToTalkKeys = captureSettings?.chord_push_to_talk_keys ?? [];
const toggleToTalkKeys = captureSettings?.chord_toggle_to_talk_keys ?? [];
const readiness = useDictationReadiness();
const session = useCaptureRecordingSession({
onCaptureCreated: (capture) => setSelectedId(capture.id),
});
const { data: capturesData, isLoading: capturesLoading } = useQuery({
queryKey: ['captures'],
queryFn: () => apiClient.listCaptures(200, 0),
});
const { data: profiles } = useQuery({
queryKey: ['profiles'],
queryFn: () => apiClient.listProfiles(),
});
const captures = capturesData?.items ?? [];
// Keep a selection. If the current selection disappears (e.g. deletion),
// fall through to the first capture, then to null.
useEffect(() => {
if (!captures.length) {
if (selectedId !== null) setSelectedId(null);
return;
}
if (!selectedId || !captures.find((c) => c.id === selectedId)) {
setSelectedId(captures[0].id);
}
}, [captures, selectedId]);
// Live sync from sibling Tauri webviews (the floating dictate window).
// ``capture:created`` carries the full row so we can seed the cache before
// the refetch lands and focus the new capture in one shot — without the
// seed, the selection-guard effect would snap back to ``captures[0]`` in
// the race window between ``setSelectedId(new)`` and the refetched list
// actually containing the new row.
useEffect(() => {
const unlistens: Promise<UnlistenFn>[] = [];
unlistens.push(
listen<{ capture: CaptureResponse }>('capture:created', (event) => {
const capture = event.payload?.capture;
if (capture) {
queryClient.setQueryData<CaptureListResponse>(['captures'], (prev) => {
if (!prev) return prev;
if (prev.items.some((c) => c.id === capture.id)) return prev;
return { ...prev, items: [capture, ...prev.items], total: prev.total + 1 };
});
setSelectedId(capture.id);
}
queryClient.invalidateQueries({ queryKey: ['captures'] });
}),
);
unlistens.push(
listen('capture:updated', () => {
queryClient.invalidateQueries({ queryKey: ['captures'] });
}),
);
return () => {
for (const p of unlistens) p.then((fn) => fn()).catch(() => {});
};
}, [queryClient]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return captures;
return captures.filter((c) => {
const raw = (c.transcript_raw || '').toLowerCase();
const refined = (c.transcript_refined || '').toLowerCase();
return raw.includes(q) || refined.includes(q);
});
}, [search, captures]);
const selected = captures.find((c) => c.id === selectedId) ?? null;
// Source of truth is capture_settings.default_playback_voice_id, shared
// with Settings → Captures and the MCP global default. Stale ids (e.g.
// referenced profile was deleted) fall through to the first profile.
const storedVoiceId = captureSettings?.default_playback_voice_id ?? null;
const playAsVoice =
(storedVoiceId && profiles?.find((p) => p.id === storedVoiceId)) ||
profiles?.[0] ||
null;
const playAsVoiceId = playAsVoice?.id ?? null;
const deleteMutation = useMutation({
mutationFn: async (captureId: string) => apiClient.deleteCapture(captureId),
onSuccess: () => {
setDeleteDialogOpen(false);
queryClient.invalidateQueries({ queryKey: ['captures'] });
},
onError: (err: Error) => {
toast({ title: t('captures.toast.deleteFailed'), description: err.message, variant: 'destructive' });
},
});
const playAsMutation = useMutation({
mutationFn: async ({ capture, voice }: { capture: CaptureResponse; voice: VoiceProfileResponse }) => {
const text = capture.transcript_refined || capture.transcript_raw;
if (!text.trim()) throw new Error(t('captures.noTranscriptError'));
const language = (capture.language || voice.language) as LanguageCode;
// Preset profiles (Kokoro etc.) reject the qwen default — honor the
// profile's stored engine preference. Cloned profiles without an
// override fall through to whatever the backend picks.
const engine = voice.default_engine as
| 'qwen' | 'qwen_custom_voice' | 'luxtts' | 'chatterbox'
| 'chatterbox_turbo' | 'tada' | 'kokoro'
| undefined;
return apiClient.generateSpeech({
profile_id: voice.id,
text,
language,
engine,
});
},
onSuccess: (result) => {
// /generate is queue-based — it returns a generating row with an empty
// audio_path. Hand the id to the global SSE handler which polls
// /generation/{id}/status and triggers autoplay on completion.
setLaunchedPlayAsId(result.id);
addPendingGeneration(result.id);
},
onError: (err: Error) => {
toast({ title: t('captures.toast.playAsFailed'), description: err.message, variant: 'destructive' });
},
});
const playbackState: PlaybackState = playAsMutation.isPending
? 'generating'
: launchedPlayAsId && pendingGenerationIds.has(launchedPlayAsId)
? 'generating'
: launchedPlayAsId && playerAudioId === launchedPlayAsId && playerIsPlaying
? 'playing'
: 'idle';
const handleUploadClick = () => uploadInputRef.current?.click();
const handleUploadFile = (e: React.ChangeEvent<HTMLInputElement>, source: CaptureSource) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
session.uploadFile(file, source);
};
const handleCopy = async () => {
if (!selected) return;
const text = showRefined
? selected.transcript_refined || selected.transcript_raw
: selected.transcript_raw;
try {
await navigator.clipboard.writeText(text || '');
toast({ title: t('captures.toast.transcriptCopied') });
} catch {
toast({ title: t('captures.toast.copyFailed'), variant: 'destructive' });
}
};
const exportToastSuccess = (path: string) => {
const name = path.split(/[\\/]/).pop() ?? path;
toast({ title: t('captures.toast.exportSuccess', { path: name }) });
};
const exportToastError = (err: unknown) => {
toast({
title: t('captures.toast.exportFailed'),
description: err instanceof Error ? err.message : String(err),
variant: 'destructive',
});
};
const handleExportAudio = async () => {
if (!selected) return;
try {
const dest = await save({
defaultPath: `capture_${selected.id.slice(0, 8)}.wav`,
filters: [{ name: 'Audio', extensions: ['wav'] }],
});
if (!dest) return;
const res = await fetch(apiClient.getCaptureAudioUrl(selected.id));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = new Uint8Array(await res.arrayBuffer());
await writeFile(dest, buf);
exportToastSuccess(dest);
} catch (err) {
exportToastError(err);
}
};
const handleExportTranscript = async () => {
if (!selected) return;
const text = (selected.transcript_refined || selected.transcript_raw || '').trim();
if (!text) {
toast({ title: t('captures.toast.exportEmpty'), variant: 'destructive' });
return;
}
try {
const dest = await save({
defaultPath: `capture_${selected.id.slice(0, 8)}.txt`,
filters: [{ name: 'Text', extensions: ['txt'] }],
});
if (!dest) return;
await writeTextFile(dest, text);
exportToastSuccess(dest);
} catch (err) {
exportToastError(err);
}
};
const buildCaptureMarkdown = (capture: CaptureResponse): string => {
const lines: string[] = [];
lines.push(`# Capture ${capture.id}`, '');
lines.push(`- **Source:** ${capture.source}`);
lines.push(`- **Created:** ${capture.created_at}`);
if (capture.duration_ms != null) lines.push(`- **Duration:** ${formatDuration(capture.duration_ms)}`);
if (capture.language) lines.push(`- **Language:** ${capture.language}`);
if (capture.stt_model) lines.push(`- **STT model:** ${capture.stt_model}`);
if (capture.llm_model) lines.push(`- **LLM model:** ${capture.llm_model}`);
lines.push('');
if (capture.transcript_refined?.trim()) {
lines.push('## Refined transcript', '', capture.transcript_refined.trim(), '');
}
if (capture.transcript_raw?.trim()) {
lines.push('## Raw transcript', '', capture.transcript_raw.trim(), '');
}
return lines.join('\n');
};
const handleExportMarkdown = async () => {
if (!selected) return;
const hasContent = (selected.transcript_refined || selected.transcript_raw || '').trim();
if (!hasContent) {
toast({ title: t('captures.toast.exportEmpty'), variant: 'destructive' });
return;
}
try {
const dest = await save({
defaultPath: `capture_${selected.id.slice(0, 8)}.md`,
filters: [{ name: 'Markdown', extensions: ['md'] }],
});
if (!dest) return;
await writeTextFile(dest, buildCaptureMarkdown(selected));
exportToastSuccess(dest);
} catch (err) {
exportToastError(err);
}
};
const handlePlayAs = (voice?: VoiceProfileResponse) => {
if (!selected) return;
// Stop the current playback when the button is in its 'playing' state
// and the user clicked the main button without picking a new voice.
if (!voice && playbackState === 'playing') {
setIsPlaying(false);
return;
}
const target = voice ?? playAsVoice;
if (!target) {
toast({
title: t('captures.toast.noVoice'),
description: t('captures.toast.noVoiceDescription'),
variant: 'destructive',
});
return;
}
if (voice && voice.id !== playAsVoiceId) {
updateCaptureSettings({ default_playback_voice_id: voice.id });
}
playAsMutation.mutate({ capture: selected, voice: target });
};
return (
<div className="h-full flex gap-0 overflow-hidden -mx-8">
<input
ref={uploadInputRef}
type="file"
accept={CAPTURE_AUDIO_MIME}
onChange={(e) => handleUploadFile(e, 'file')}
className="hidden"
/>
<input
ref={fileInputRef}
type="file"
accept={CAPTURE_AUDIO_MIME}
onChange={(e) => handleUploadFile(e, 'file')}
className="hidden"
/>
{/* Left: capture list */}
<div className="w-[340px] shrink-0">
<ListPane>
<ListPaneHeader>
<ListPaneTitleRow>
<ListPaneTitle>{t('captures.title')}</ListPaneTitle>
<Badge
variant="secondary"
className="h-5 px-1.5 -ml-2 text-[10px] font-medium text-accent bg-accent/10 border border-accent/20"
>
{t('captures.beta')}
</Badge>
</ListPaneTitleRow>
<ListPaneSearch
value={search}
onChange={setSearch}
placeholder={t('captures.searchPlaceholder')}
/>
</ListPaneHeader>
<ListPaneScroll className={cn(isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}>
<div className="px-4 pb-6 space-y-1">
{capturesLoading ? (
<div className="px-4 py-12 flex items-center justify-center text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
{search ? (
<p>{t('captures.empty.noMatches', { query: search })}</p>
) : (
<p>{t('captures.empty.none')}</p>
)}
</div>
) : (
filtered.map((capture) => {
const isActive = selectedId === capture.id;
const refined = !!capture.transcript_refined;
return (
<button
type="button"
key={capture.id}
onClick={() => setSelectedId(capture.id)}
className={cn(
'w-full text-left p-3 rounded-lg transition-colors block',
isActive
? 'bg-muted/70 border border-border'
: 'border border-transparent hover:bg-muted/30',
)}
>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[11px] text-muted-foreground font-medium">
{formatDate(capture.created_at)}
</span>
<div className="flex-1" />
<span className="text-[10px] text-muted-foreground/70 tabular-nums">
{formatDuration(capture.duration_ms)}
</span>
</div>
<div className="text-[13px] text-foreground/90 line-clamp-2 leading-snug mb-2">
{snippetOf(capture)}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<SourceBadge source={capture.source} />
{refined && (
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-accent/10 text-accent border border-accent/20"
>
<Sparkles className="h-2.5 w-2.5" />
{t('captures.transcript.refined')}
</Badge>
)}
</div>
</button>
);
})
)}
</div>
</ListPaneScroll>
</ListPane>
</div>
{/* Right: capture detail */}
<div className="flex-1 flex flex-col relative overflow-hidden min-w-0">
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
{/* Top action bar */}
<div className="absolute top-0 left-0 right-0 z-20 px-8">
<div className="flex items-center gap-3 py-4">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
<span>
{t('captures.header.modelSummary', {
stt: sttModel.charAt(0).toUpperCase() + sttModel.slice(1),
llm: llmModel,
})}
</span>
</div>
<div className="flex-1" />
{session.pillState !== 'hidden' && (
<CapturePill
state={session.pillState}
elapsedMs={session.pillElapsedMs}
errorMessage={session.errorMessage}
onDismiss={session.dismissError}
onStop={session.isRecording ? session.stopRecording : undefined}
/>
)}
{session.pillState === 'hidden' && (
<>
<Button variant="outline" asChild>
<Link to="/settings/captures">
<Settings2 className="mr-2 h-4 w-4" />
{t('captures.actions.configure')}
</Link>
</Button>
{readiness.canRecord && (
<Button
variant="outline"
onClick={handleUploadClick}
disabled={session.isUploading}
>
{session.isUploading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Upload className="h-4 w-4 mr-2" />
)}
{session.isUploading ? t('captures.actions.importing') : t('captures.actions.import')}
</Button>
)}
</>
)}
{/* Hide Dictate when recording readiness fails so the user can't kick off
a capture that has nowhere to land. Stop stays visible if a
recording is somehow already in flight (e.g. a model was
uninstalled mid-record) so the user can always cancel. */}
{(readiness.canRecord || session.isRecording) && (
<Button
onClick={session.toggleRecording}
disabled={session.isUploading && !session.isRecording}
className="relative overflow-hidden transition-all bg-accent text-accent-foreground hover:bg-accent/90"
>
{session.isRecording ? (
<>
<Square className="h-4 w-4 mr-2 fill-current" />
{t('captures.actions.stop')}
</>
) : (
<>
<Mic className="h-4 w-4 mr-2" />
{t('captures.actions.dictate')}
</>
)}
</Button>
)}
</div>
</div>
{selected ? (
<div
className={cn(
'flex-1 overflow-y-auto pt-20 px-8 pb-8',
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
)}
>
{/* Meta row */}
<div className="flex items-center gap-3 mb-4 text-xs text-muted-foreground">
<span>{formatAbsoluteDate(selected.created_at)}</span>
{selected.language && (
<>
<span className="text-muted-foreground/40">·</span>
<span>{selected.language.toUpperCase()}</span>
</>
)}
<span className="text-muted-foreground/40">·</span>
<SourceBadge source={selected.source} />
</div>
{/* Audio player card */}
<div className="rounded-xl border border-border bg-muted/20 p-4 mb-6">
<CaptureInlinePlayer
audioUrl={apiClient.getCaptureAudioUrl(selected.id)}
fallbackDurationMs={selected.duration_ms}
/>
</div>
{/* Transcript header */}
<div className="flex items-center gap-3 mb-3">
<div className="inline-flex rounded-md bg-muted/40 p-0.5 border border-border">
<button
type="button"
onClick={() => setShowRefined(true)}
disabled={!selected.transcript_refined}
className={cn(
'px-3 py-1 text-xs font-medium rounded transition-colors',
showRefined && selected.transcript_refined
? 'bg-background shadow-sm text-foreground'
: 'text-muted-foreground hover:text-foreground disabled:opacity-40',
)}
>
<Sparkles className="h-3 w-3 inline-block mr-1 -translate-y-px" />
{t('captures.transcript.refined')}
</button>
<button
type="button"
onClick={() => setShowRefined(false)}
className={cn(
'px-3 py-1 text-xs font-medium rounded transition-colors',
!showRefined || !selected.transcript_refined
? 'bg-background shadow-sm text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
<Captions className="h-3 w-3 inline-block mr-1 -translate-y-px" />
{t('captures.transcript.raw')}
</button>
</div>
<div className="flex-1" />
<span className="text-xs text-muted-foreground">
{showRefined && selected.transcript_refined
? t('captures.transcript.refinedHint', { model: selected.llm_model ?? llmModel })
: selected.stt_model
? t('captures.transcript.rawHint', { model: selected.stt_model })
: null}
</span>
</div>
{/* Transcript body */}
<div className="rounded-xl border border-border bg-muted/10">
<Textarea
key={`${selected.id}-${showRefined}`}
defaultValue={
showRefined && selected.transcript_refined
? selected.transcript_refined
: selected.transcript_raw
}
readOnly
className="text-[15px] leading-relaxed min-h-[260px] border-0 bg-transparent resize-none focus-visible:ring-0 focus-visible:ring-offset-0 p-6"
/>
</div>
{/* Bottom actions */}
<div className="flex items-center gap-2 mt-4 flex-wrap">
<div className="inline-flex">
<Button
variant="outline"
size="sm"
onClick={() => handlePlayAs()}
disabled={!playAsVoice || playAsMutation.isPending}
className={cn(
'gap-2 rounded-r-none border-r-0 pr-3 pl-2 transition-colors',
playbackState !== 'idle' &&
'border-accent/50 text-foreground bg-accent/10 hover:bg-accent/15 hover:text-foreground hover:border-accent/50',
)}
>
{playbackState === 'generating' ? (
<>
<AudioBars mode="generating" className="h-3.5" />
{t('captures.actions.playAsGenerating')}
</>
) : playbackState === 'playing' ? (
<>
<Square className="h-3 w-3 fill-current" />
{playAsVoice
? t('captures.actions.playAsStop', { name: playAsVoice.name })
: t('captures.actions.playAsStopFallback')}
</>
) : (
<>
<Volume2 className="h-3.5 w-3.5" />
{playAsVoice
? t('captures.actions.playAs', { name: playAsVoice.name })
: t('captures.actions.playAsFallback')}
</>
)}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
'rounded-l-none px-2 transition-colors',
playbackState !== 'idle' &&
'border-accent/50 bg-accent/10 hover:bg-accent/15 hover:text-foreground hover:border-accent/50',
)}
disabled={!profiles || !profiles.length}
>
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
{t('captures.actions.playAsDropdownLabel')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{profiles?.map((v) => (
<DropdownMenuItem
key={v.id}
onClick={() => handlePlayAs(v)}
className="py-2"
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{v.name}</div>
<div className="text-[11px] text-muted-foreground truncate">
{v.description || v.language.toUpperCase()}
</div>
</div>
{v.id === playAsVoiceId && (
<Check className="h-3.5 w-3.5 text-accent shrink-0" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<Button variant="outline" size="sm" onClick={handleCopy}>
<Copy className="h-3.5 w-3.5 mr-1.5" />
{t('captures.actions.copy')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => session.refine(selected.id)}
disabled={session.isRefining}
>
{session.isRefining ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5 mr-1.5" />
)}
{selected.transcript_refined
? t('captures.actions.reRefine')
: t('captures.actions.refine')}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Download className="h-3.5 w-3.5 mr-1.5" />
{t('captures.actions.export')}
<ChevronDown className="h-3.5 w-3.5 ml-1 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
{t('captures.actions.exportDropdownLabel')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleExportAudio}>
<FileAudio className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
{t('captures.actions.exportAudio')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportTranscript}>
<Captions className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
{t('captures.actions.exportTranscript')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportMarkdown}>
<FileText className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
{t('captures.actions.exportMarkdown')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex-1" />
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteDialogOpen(true)}
disabled={deleteMutation.isPending}
className="text-muted-foreground "
>
{deleteMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
)}
{t('captures.actions.delete')}
</Button>
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground pt-20">
{capturesLoading ? (
<div className="text-center space-y-3">
<Captions className="h-10 w-10 mx-auto opacity-40" />
<p className="text-sm">{t('captures.empty.loading')}</p>
</div>
) : captures.length ? (
<div className="text-center space-y-3">
<Captions className="h-10 w-10 mx-auto opacity-40" />
<p className="text-sm">{t('captures.empty.pickOne')}</p>
</div>
) : hotkeyEnabled && !readiness.canRecord ? (
<DictationReadinessChecklist readiness={readiness} />
) : hotkeyEnabled && (pushToTalkKeys.length || toggleToTalkKeys.length) ? (
<div className="max-w-sm mx-auto text-center space-y-5">
<div className="space-y-2">
{pushToTalkKeys.length ? (
<div className="flex items-center justify-center gap-3">
<ChordKeys keys={pushToTalkKeys} />
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
{t('captures.empty.holdToRecord')}
</span>
</div>
) : null}
{toggleToTalkKeys.length ? (
<div className="flex items-center justify-center gap-3">
<ChordKeys keys={toggleToTalkKeys} />
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
{t('captures.empty.toggleHandsFree')}
</span>
</div>
) : null}
</div>
<p className="text-sm">
{t('captures.empty.pressShortcut')}
</p>
</div>
) : (
<div className="max-w-sm mx-auto text-center space-y-3">
<Captions className="h-10 w-10 mx-auto opacity-40" />
<p className="text-sm">{t('captures.empty.none')}</p>
<p className="text-xs text-muted-foreground leading-relaxed">
{t('captures.empty.turnOnShortcut')}
</p>
<Button asChild variant="outline" size="sm">
<Link to="/settings/captures">{t('captures.empty.openSettings')}</Link>
</Button>
</div>
)}
</div>
)}
</div>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('captures.deleteDialog.title')}</AlertDialogTitle>
<AlertDialogDescription>{t('captures.deleteDialog.description')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={() => selected && deleteMutation.mutate(selected.id)}
disabled={deleteMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteMutation.isPending ? t('captures.deleteDialog.deleting') : t('common.delete')}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,287 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
Accessibility,
CheckCircle2,
Circle,
Cpu,
Download,
ExternalLink,
Keyboard,
Loader2,
} from 'lucide-react';
import { useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type { ActiveDownloadTask } from '@/lib/api/types';
import type { DictationReadiness, ReadinessGate } from '@/lib/hooks/useDictationReadiness';
import { cn } from '@/lib/utils/cn';
interface RowProps {
icon: React.ReactNode;
title: string;
description: string;
ready: boolean;
action?: React.ReactNode;
}
function ChecklistRow({ icon, title, description, ready, action }: RowProps) {
return (
<div
className={cn(
'flex items-start gap-3 rounded-lg border p-3.5 transition-colors',
ready ? 'border-accent/20 bg-accent/5' : 'border-border bg-muted/20',
)}
>
<div className="mt-0.5 shrink-0">
{ready ? (
<CheckCircle2 className="h-5 w-5 text-accent" />
) : (
<Circle className="h-5 w-5 text-muted-foreground/50" />
)}
</div>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{icon}</span>
<p className="text-sm font-medium text-foreground">{title}</p>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">{description}</p>
{!ready && action ? <div className="pt-1.5">{action}</div> : null}
</div>
</div>
);
}
function progressPercent(task: ActiveDownloadTask | undefined): number | null {
if (!task) return null;
if (typeof task.progress === 'number')
return Math.round(Math.max(0, Math.min(100, task.progress)));
if (task.current && task.total) return Math.round((task.current / task.total) * 100);
return null;
}
/**
* Renders one row per dictation-readiness gate. Each unmet gate gets an
* inline action — Download for missing models, Open Settings for missing
* TCC permissions — so the user can resolve everything without leaving
* Captures.
*
* Download-in-progress state is sourced from ``/tasks/active`` (same query
* the Models page uses) so it survives unmount: navigating away and back
* still shows "Downloading…" instead of resetting to "Download".
*
* The chord stays disarmed until every row is green; this is what stops the
* "stuck pill" failure mode of pressing the chord with a missing model.
*
* ``compact`` drops the centered title/subheading block and the
* empty-state max-width so the checklist can be embedded in a narrow
* sidebar alongside other settings. Callers own their own heading in
* that mode (typically an ``<h3>`` that matches the surrounding sidebar
* section style).
*/
export function DictationReadinessChecklist({
readiness,
compact = false,
}: {
readiness: DictationReadiness;
compact?: boolean;
}) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { toast } = useToast();
const { data: activeTasks } = useQuery({
queryKey: ['activeTasks'],
queryFn: () => apiClient.getActiveTasks(),
// Mirror ModelManagement's cadence: 1s while a download is in flight,
// 5s otherwise. Keeps progress feeling live without hammering when idle.
refetchInterval: (query) => {
const data = query.state.data;
const hasActive = data?.downloads.some((d) => d.status === 'downloading');
return hasActive ? 1000 : 5000;
},
});
// Memo so the Map identity is stable across renders that don't change
// activeTasks — otherwise the cleanup effect below saw a fresh Map every
// render and re-fired on every 1 s poll tick.
const downloadByModel = useMemo(() => {
const m = new Map<string, ActiveDownloadTask>();
for (const dl of activeTasks?.downloads ?? []) {
if (dl.status === 'downloading') m.set(dl.model_name, dl);
}
return m;
}, [activeTasks]);
// When a download disappears from activeTasks, it just finished — refetch
// readiness immediately so the row flips to ✓ instead of waiting up to 5s
// for the next readiness poll.
const prevActive = useRef<Set<string>>(new Set());
useEffect(() => {
const current = new Set(downloadByModel.keys());
for (const name of prevActive.current) {
if (!current.has(name)) {
queryClient.invalidateQueries({ queryKey: ['capture-readiness'] });
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
break;
}
}
prevActive.current = current;
}, [downloadByModel, queryClient]);
const downloadMutation = useMutation({
mutationFn: async ({ modelName }: { gate: ReadinessGate; modelName: string }) =>
apiClient.triggerModelDownload(modelName),
onSuccess: (_data, vars) => {
// Bump activeTasks so the row immediately shows "Downloading…" without
// waiting for the next 5s poll. modelStatus + readiness invalidations
// keep adjacent UI in sync.
queryClient.invalidateQueries({ queryKey: ['activeTasks'] });
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
queryClient.invalidateQueries({ queryKey: ['capture-readiness'] });
const displayName =
vars.gate === 'stt' ? readiness.stt?.display_name : readiness.llm?.display_name;
toast({
title: t('captures.readiness.downloadStarted'),
description: t('captures.readiness.downloadStartedDescription', { name: displayName }),
});
},
onError: (err: Error) => {
toast({
title: t('captures.readiness.downloadFailed'),
description: err.message,
variant: 'destructive',
});
},
});
const sttSize =
readiness.stt?.size_mb != null ? `${(readiness.stt.size_mb / 1000).toFixed(1)} GB` : null;
const llmSize =
readiness.llm?.size_mb != null ? `${(readiness.llm.size_mb / 1000).toFixed(1)} GB` : null;
function modelDownloadButton(
gate: 'stt' | 'llm',
modelName: string,
ready: boolean,
): React.ReactNode {
const task = downloadByModel.get(modelName);
const downloading = !ready && !!task;
const pct = progressPercent(task);
return (
<Button
size="sm"
onClick={() => downloadMutation.mutate({ gate, modelName })}
disabled={downloading || downloadMutation.isPending}
className="gap-1.5"
>
{downloading ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{pct != null
? t('captures.readiness.downloadingPercent', { pct })
: t('captures.readiness.downloading')}
</>
) : (
<>
<Download className="h-3.5 w-3.5" />
{t('captures.readiness.downloadButton')}
</>
)}
</Button>
);
}
return (
<div className={cn('w-full space-y-2.5', !compact && 'max-w-md mx-auto')}>
{!compact && (
<div className="text-center mb-5 space-y-1">
<h2 className="text-base font-semibold text-foreground">
{t('captures.readiness.title')}
</h2>
<p className="text-xs text-muted-foreground">
{t('captures.readiness.subheading')}
</p>
</div>
)}
{readiness.stt && (
<ChecklistRow
icon={<Cpu className="h-3.5 w-3.5" />}
title={t('captures.readiness.stt.label', { name: readiness.stt.display_name })}
description={
readiness.stt.ready
? t('captures.readiness.stt.ready')
: sttSize
? t('captures.readiness.stt.missingWithSize', { size: sttSize })
: t('captures.readiness.stt.missing')
}
ready={readiness.stt.ready}
action={modelDownloadButton('stt', readiness.stt.model_name, readiness.stt.ready)}
/>
)}
{readiness.llm && (
<ChecklistRow
icon={<Cpu className="h-3.5 w-3.5" />}
title={t('captures.readiness.llm.label', { name: readiness.llm.display_name })}
description={
readiness.llm.ready
? t('captures.readiness.llm.ready')
: llmSize
? t('captures.readiness.llm.missingWithSize', { size: llmSize })
: t('captures.readiness.llm.missing')
}
ready={readiness.llm.ready}
action={modelDownloadButton('llm', readiness.llm.model_name, readiness.llm.ready)}
/>
)}
{/* Input Monitoring + Accessibility are macOS-only TCC permissions.
The Rust stubs return true on Windows/Linux, so rendering these
rows there would show permanent green checkmarks with copy
that talks about macOS — noise. Hide on non-mac. */}
{isMacOS && (
<ChecklistRow
icon={<Keyboard className="h-3.5 w-3.5" />}
title={t('captures.readiness.inputMonitoring.label')}
description={
readiness.inputMonitoring
? t('captures.readiness.inputMonitoring.ready')
: t('captures.readiness.inputMonitoring.missing')
}
ready={readiness.inputMonitoring}
action={
<Button size="sm" onClick={readiness.openInputMonitoringSettings} className="gap-1.5">
<ExternalLink className="h-3.5 w-3.5" />
{t('captures.readiness.inputMonitoring.openSettings')}
</Button>
}
/>
)}
{isMacOS && (
<ChecklistRow
icon={<Accessibility className="h-3.5 w-3.5" />}
title={t('captures.readiness.accessibility.label')}
description={
readiness.accessibility
? t('captures.readiness.accessibility.ready')
: t('captures.readiness.accessibility.missing')
}
ready={readiness.accessibility}
action={
<Button size="sm" onClick={readiness.openAccessibilitySettings} className="gap-1.5">
<ExternalLink className="h-3.5 w-3.5" />
{t('captures.readiness.accessibility.openSettings')}
</Button>
}
/>
)}
</div>
);
}
const isMacOS =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent);
@@ -0,0 +1,209 @@
import { Keyboard } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
canonicalKeyFromEvent,
displayLabelForKey,
modifierSideHint,
sortChordKeys,
} from '@/lib/utils/keyCodes';
import { cn } from '@/lib/utils/cn';
interface ChordPickerProps {
open: boolean;
/** Title shown in the modal — caller picks "push-to-talk" vs "toggle". */
title: string;
description?: string;
/** The chord currently saved, shown as the starting state. */
initialKeys: string[];
onSave: (keys: string[]) => void;
onCancel: () => void;
}
/**
* Modal that captures a key chord from the browser keyboard. Tracks the
* peak set of keys held during the session so the user can release
* before clicking Save (otherwise they'd be saving while still holding
* the shortcut, which is awkward).
*
* Browser limitation: we can only capture keys while Voicebox has key
* focus, so the picker pulls focus to a hidden capture surface inside
* the dialog. The actual chord runs through the Rust global hook —
* this picker only writes the configuration the hook reads.
*/
export function ChordPicker({
open,
title,
description,
initialKeys,
onSave,
onCancel,
}: ChordPickerProps) {
const { t } = useTranslation();
// Currently held set, peak set captured this session, and "is the user
// mid-chord?". We freeze the peak when they release everything so the
// Save button can read a stable value.
const [pressed, setPressed] = useState<Set<string>>(new Set());
const [captured, setCaptured] = useState<string[]>(initialKeys);
const [unsupportedAttempt, setUnsupportedAttempt] = useState<string | null>(null);
const captureRef = useRef<HTMLDivElement>(null);
// Reset every time the modal re-opens — otherwise the previous picker
// session's peak set leaks into the next open and confuses the user.
useEffect(() => {
if (open) {
setPressed(new Set());
setCaptured(initialKeys);
setUnsupportedAttempt(null);
// Defer focus to the next paint so the dialog is mounted.
const timeoutId = window.setTimeout(() => captureRef.current?.focus(), 50);
return () => window.clearTimeout(timeoutId);
}
return;
}, [open, initialKeys]);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
// Esc reaches the dialog's onOpenChange and closes the modal — let
// it pass through unmodified.
if (event.key === 'Escape') return;
// Tab cycles focus inside the dialog; capturing it would trap the
// user. Same for the dialog's own keyboard interactions.
if (event.key === 'Tab') return;
const canonical = canonicalKeyFromEvent(event);
if (!canonical) {
setUnsupportedAttempt(event.code || event.key || 'unknown');
event.preventDefault();
return;
}
event.preventDefault();
event.stopPropagation();
setUnsupportedAttempt(null);
setPressed((prev) => {
if (prev.has(canonical)) return prev;
const next = new Set(prev);
next.add(canonical);
setCaptured((prevCaptured) => {
const candidate = sortChordKeys(Array.from(next));
// First key in a fresh sequence replaces the peak — otherwise a
// user trying to swap a longer saved chord for a shorter one is
// stuck because their candidate never beats the seed length.
if (prev.size === 0) return candidate;
return candidate.length >= prevCaptured.length ? candidate : prevCaptured;
});
return next;
});
},
[],
);
const handleKeyUp = useCallback((event: KeyboardEvent) => {
if (event.key === 'Escape' || event.key === 'Tab') return;
const canonical = canonicalKeyFromEvent(event);
if (!canonical) return;
event.preventDefault();
setPressed((prev) => {
if (!prev.has(canonical)) return prev;
const next = new Set(prev);
next.delete(canonical);
return next;
});
}, []);
// Wire global listeners only while open. Capture phase so Voicebox's
// own command palette / global shortcuts don't swallow the chord first.
useEffect(() => {
if (!open) return;
window.addEventListener('keydown', handleKeyDown, true);
window.addEventListener('keyup', handleKeyUp, true);
return () => {
window.removeEventListener('keydown', handleKeyDown, true);
window.removeEventListener('keyup', handleKeyUp, true);
};
}, [open, handleKeyDown, handleKeyUp]);
const displayKeys = pressed.size > 0
? sortChordKeys(Array.from(pressed))
: captured;
const canSave = captured.length > 0;
return (
<Dialog open={open} onOpenChange={(next) => { if (!next) onCancel(); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
<div
ref={captureRef}
tabIndex={-1}
className="rounded-lg border border-border bg-muted/30 p-6 outline-none focus:ring-2 focus:ring-accent"
>
<div className="flex flex-col items-center gap-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Keyboard className="h-3.5 w-3.5" />
{pressed.size > 0 ? t('captures.chord.capturing') : t('captures.chord.pressShortcut')}
</div>
<div className="flex flex-wrap items-center justify-center gap-1.5 min-h-[2.5rem]">
{displayKeys.length === 0 ? (
<span className="text-sm text-muted-foreground italic">
{t('captures.chord.noKeys')}
</span>
) : (
displayKeys.map((k) => <ChordKey key={k} name={k} />)
)}
</div>
{unsupportedAttempt ? (
<p className="text-xs text-destructive">
{t('captures.chord.unsupported', { key: unsupportedAttempt })}
</p>
) : null}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
{t('common.cancel')}
</Button>
<Button onClick={() => onSave(captured)} disabled={!canSave}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ChordKey({ name }: { name: string }) {
const side = modifierSideHint(name);
return (
<span
className={cn(
'relative inline-flex items-center justify-center h-8 min-w-[2rem] px-2',
'rounded-md border border-border bg-background font-mono text-sm font-medium',
'shadow-sm text-foreground',
)}
>
{displayLabelForKey(name)}
{side ? (
<span className="absolute -top-1 -right-1 h-3.5 min-w-[0.875rem] px-0.5 rounded-sm bg-accent text-[8px] font-bold leading-none flex items-center justify-center text-accent-foreground">
{side}
</span>
) : null}
</span>
);
}
@@ -0,0 +1,298 @@
import { invoke } from '@tauri-apps/api/core';
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event';
import { useEffect, useRef, useState } from 'react';
import { CapturePill } from '@/components/CapturePill/CapturePill';
import { apiClient } from '@/lib/api/client';
import type { FocusSnapshot } from '@/lib/api/types';
import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSession';
/**
* Floating dictate surface shown in a separate transparent Tauri window.
* Mounted when the URL contains ``?view=dictate``. The main window bypasses
* this branch and renders the full app shell.
*
* The pill surfaces for two independent cycles:
* 1. User dictation — driven by ``dictate:start`` / ``dictate:stop``
* from the Rust hotkey monitor.
* 2. Agent speech — driven by ``dictate:speak-start`` / ``dictate:speak-end``
* from the Rust ``speak_monitor`` (which owns the backend SSE stream).
* On speak-start we subscribe to this single generation's status SSE,
* then play ``/audio/{id}`` via a plain ``HTMLAudioElement`` when it
* lands. When the audio element's ``ended`` fires, we emit
* ``dictate:hide`` so Rust tucks the window away.
*/
export function DictateWindow() {
// Force the host document chrome to be transparent so the Tauri window
// takes on the pill's own shape.
useEffect(() => {
const prevHtml = document.documentElement.style.background;
const prevBody = document.body.style.background;
document.documentElement.style.background = 'transparent';
document.body.style.background = 'transparent';
return () => {
document.documentElement.style.background = prevHtml;
document.body.style.background = prevBody;
};
}, []);
// Snapshot of the focused UI element at chord-start, shipped over from
// Rust on the ``dictate:start`` payload. Held in a ref so it survives
// the 1–2 s transcribe + refine window — the paste only fires once the
// final text comes back.
const focusRef = useRef<FocusSnapshot | null>(null);
const session = useCaptureRecordingSession({
onFinalText: async (text, _capture, allowAutoPaste) => {
const focus = focusRef.current;
// Consume-once: a second chord before this fires would overwrite
// focusRef, but nulling it here guards against the late-arriving
// refine-result firing a paste after the user has moved on.
focusRef.current = null;
if (!allowAutoPaste) return;
if (!focus || !text.trim()) return;
try {
await invoke('paste_final_text', { text, focus });
} catch (err) {
// Surface accessibility failures to the main window so it can prompt
// the user to grant permission. Other errors stay swallowed —
// the transcription still landed in the captures list.
const msg = err instanceof Error ? err.message : String(err);
if (/accessibility/i.test(msg)) {
emit('system:accessibility-missing').catch(() => {});
}
console.warn('[dictate] paste_final_text failed:', err);
}
},
});
// Route the chord events emitted from Rust into the session hook. Using a
// ref so the `listen` effect only subscribes once — rebinding every render
// would thrash the Tauri event bridge.
const sessionRef = useRef(session);
sessionRef.current = session;
useEffect(() => {
const unlistens: Promise<UnlistenFn>[] = [];
unlistens.push(
listen<{ focus: FocusSnapshot | null }>('dictate:start', (event) => {
focusRef.current = event.payload?.focus ?? null;
sessionRef.current.startRecording();
}),
);
unlistens.push(
listen('dictate:stop', () => {
if (sessionRef.current.isRecording) sessionRef.current.stopRecording();
}),
);
return () => {
for (const p of unlistens) p.then((fn) => fn()).catch(() => {});
};
}, []);
// --- Agent-speak cycle ---------------------------------------------------
const [speaking, setSpeaking] = useState<{
generationId: string;
// Null while the backend is still generating audio; set to the
// wall-clock timestamp when audio playback actually begins, so the
// pill's elapsed counter only ticks while sound is coming out.
startedAt: number | null;
} | null>(null);
const [speakElapsed, setSpeakElapsed] = useState(0);
// Refs so handlers inside long-lived `listen()` callbacks can read the
// latest state without re-subscribing on every render.
const speakingRef = useRef<typeof speaking>(null);
speakingRef.current = speaking;
const statusSourceRef = useRef<EventSource | null>(null);
const statusTimeoutRef = useRef<number | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const clearStatusTimeout = () => {
if (statusTimeoutRef.current !== null) {
window.clearTimeout(statusTimeoutRef.current);
statusTimeoutRef.current = null;
}
};
const dismissSpeak = (id?: string) => {
// Guard against a late dismiss targeting a stale cycle (a new speak
// already started by the time audio.ended from the previous one fired).
if (id && speakingRef.current && speakingRef.current.generationId !== id) return;
statusSourceRef.current?.close();
statusSourceRef.current = null;
clearStatusTimeout();
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
audioRef.current = null;
}
setSpeaking(null);
};
const startSpeakPlayback = (generationId: string) => {
const audio = new Audio(apiClient.getAudioUrl(generationId));
audio.onended = () => dismissSpeak(generationId);
audio.onerror = () => dismissSpeak(generationId);
// The pill window stays hidden through the ~1 s generation wait so the
// user doesn't see a silent pill. We surface it the moment audio
// actually starts playing, and that's also when the elapsed counter
// arms.
audio.onplaying = () => {
emit('dictate:show').catch(() => {});
setSpeaking((prev) =>
prev && prev.generationId === generationId
? { ...prev, startedAt: Date.now() }
: prev,
);
setSpeakElapsed(0);
};
audioRef.current = audio;
audio.play().catch((err) => {
console.warn('[dictate] audio.play failed:', err);
dismissSpeak(generationId);
});
};
useEffect(() => {
const unlistens: Promise<UnlistenFn>[] = [];
// Rust emits the SSE payload as a JSON *string* (not a parsed object);
// the payload shape for speak-start is
// {generation_id, profile_name, source, client_id}.
unlistens.push(
listen<string>('dictate:speak-start', (event) => {
let parsed: { generation_id?: string } = {};
try {
parsed = typeof event.payload === 'string' ? JSON.parse(event.payload) : {};
} catch {
return;
}
const id = parsed.generation_id;
if (!id) return;
// Tear down any previous cycle — last speak wins.
dismissSpeak();
setSpeaking({ generationId: id, startedAt: null });
setSpeakElapsed(0);
// Subscribe to this one generation's status. When it completes, the
// `/audio/{id}` endpoint will serve the WAV we need to play.
const source = new EventSource(apiClient.getGenerationStatusUrl(id));
statusSourceRef.current = source;
// Hard cap on how long the pill can sit in the 'speaking' state
// without ever hearing back from the backend. Covers the case where
// the gen row is deleted mid-flight (SSE 404s and EventSource silently
// retries) or the backend goes away while a request is in flight.
// Clears as soon as a real status event lands.
clearStatusTimeout();
statusTimeoutRef.current = window.setTimeout(() => {
statusTimeoutRef.current = null;
if (speakingRef.current?.generationId === id && !audioRef.current) {
dismissSpeak(id);
}
}, 60_000);
source.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data) as { status?: string };
if (data.status === 'completed') {
clearStatusTimeout();
source.close();
if (statusSourceRef.current === source) statusSourceRef.current = null;
startSpeakPlayback(id);
} else if (data.status === 'failed' || data.status === 'not_found') {
clearStatusTimeout();
source.close();
dismissSpeak(id);
}
} catch {
// heartbeats / junk — ignore.
}
};
source.onerror = () => {
// EventSource auto-reconnects on transient drops; the timeout above
// is the backstop for the case where it never recovers.
};
}),
);
// Speak-end from the backend is advisory: the authoritative dismiss is
// `audio.ended`. But if generation failed or nothing ever triggered
// playback, a short grace window followed by forced dismiss avoids a
// stuck-visible pill.
unlistens.push(
listen<string>('dictate:speak-end', (event) => {
let parsed: { generation_id?: string; status?: string } = {};
try {
parsed = typeof event.payload === 'string' ? JSON.parse(event.payload) : {};
} catch {
return;
}
if (parsed.status && parsed.status !== 'completed') {
// Failed / cancelled — dismiss immediately.
if (parsed.generation_id) dismissSpeak(parsed.generation_id);
return;
}
// Completed: if audio never started (shouldn't happen, but guard),
// auto-dismiss after 15 s so the pill never stays forever.
const id = parsed.generation_id;
window.setTimeout(() => {
if (speakingRef.current?.generationId === id && !audioRef.current) {
dismissSpeak(id);
}
}, 15_000);
}),
);
return () => {
for (const p of unlistens) p.then((fn) => fn()).catch(() => {});
dismissSpeak();
};
}, []);
// Advance the pill's elapsed-time label while audio is playing. Paused
// during the pre-playback generation window (startedAt is null) so the
// counter stays at 0:00 until sound actually starts.
useEffect(() => {
if (!speaking?.startedAt) return;
const anchor = speaking.startedAt;
const iv = window.setInterval(() => {
setSpeakElapsed(Date.now() - anchor);
}, 250);
return () => window.clearInterval(iv);
}, [speaking?.generationId, speaking?.startedAt]);
// --- Effective pill state -----------------------------------------------
const isSpeaking = Boolean(speaking);
const effectiveState = isSpeaking ? 'speaking' : session.pillState;
const effectiveElapsed = isSpeaking ? speakElapsed : session.pillElapsedMs;
// When the pill cycle ends (no capture AND no speak), tell Rust to tuck
// the window away. Rust owns the hide + park-off-screen + click-through
// combo because calling hide() directly from JS has been unreliable for
// transparent always-on-top windows on macOS.
useEffect(() => {
if (effectiveState === 'hidden') {
emit('dictate:hide').catch(() => {});
}
}, [effectiveState]);
return (
<div
className="h-screen w-screen flex items-center justify-center px-3"
style={{ background: 'transparent' }}
>
{effectiveState !== 'hidden' ? (
<CapturePill
state={effectiveState}
elapsedMs={effectiveElapsed}
errorMessage={session.errorMessage}
onDismiss={session.dismissError}
onStop={session.isRecording ? session.stopRecording : undefined}
/>
) : null}
</div>
);
}
@@ -0,0 +1,394 @@
import {
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useQuery } from '@tanstack/react-query';
import { ChevronDown, ChevronRight, GripVertical, Plus, Power, Trash2 } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Slider } from '@/components/ui/slider';
import { apiClient } from '@/lib/api/client';
import type { AvailableEffect, EffectConfig, EffectPresetResponse } from '@/lib/api/types';
import { cn } from '@/lib/utils/cn';
// Each effect in the chain gets a stable ID for dnd-kit
interface EffectWithId extends EffectConfig {
_id: string;
}
let nextId = 0;
function makeId() {
return `fx-${++nextId}`;
}
interface EffectsChainEditorProps {
value: EffectConfig[];
onChange: (chain: EffectConfig[]) => void;
compact?: boolean;
showPresets?: boolean;
}
export function EffectsChainEditor({
value,
onChange,
compact = false,
showPresets = true,
}: EffectsChainEditorProps) {
const { t } = useTranslation();
const [expandedId, setExpandedId] = useState<string | null>(null);
// Maintain stable IDs for each effect across renders.
// We use a ref to map value items to IDs, rebuilding when length changes.
const idsRef = useRef<string[]>([]);
const items: EffectWithId[] = useMemo(() => {
// Grow ID array if effects were added
while (idsRef.current.length < value.length) {
idsRef.current.push(makeId());
}
// Shrink if effects were removed
if (idsRef.current.length > value.length) {
idsRef.current = idsRef.current.slice(0, value.length);
}
return value.map((e, i) => ({ ...e, _id: idsRef.current[i] }));
}, [value]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const { data: availableEffects } = useQuery({
queryKey: ['available-effects'],
queryFn: () => apiClient.getAvailableEffects(),
staleTime: Infinity,
});
const { data: presets } = useQuery({
queryKey: ['effect-presets'],
queryFn: () => apiClient.listEffectPresets(),
staleTime: 30_000,
});
const effectsMap = useMemo(() => {
const m = new Map<string, AvailableEffect>();
if (availableEffects) {
for (const e of availableEffects.effects) {
m.set(e.type, e);
}
}
return m;
}, [availableEffects]);
function addEffect(type: string) {
const def = effectsMap.get(type);
if (!def) return;
const params: Record<string, number> = {};
for (const [key, p] of Object.entries(def.params)) {
params[key] = p.default;
}
const newEffect: EffectConfig = { type, enabled: true, params };
const newId = makeId();
idsRef.current = [...idsRef.current, newId];
onChange([...value, newEffect]);
setExpandedId(newId);
}
const removeEffect = useCallback(
(index: number) => {
const removedId = idsRef.current[index];
idsRef.current = idsRef.current.filter((_, i) => i !== index);
onChange(value.filter((_, i) => i !== index));
if (expandedId === removedId) setExpandedId(null);
},
[value, onChange, expandedId],
);
const toggleEnabled = useCallback(
(index: number) => {
onChange(value.map((e, i) => (i === index ? { ...e, enabled: !e.enabled } : e)));
},
[value, onChange],
);
const updateParam = useCallback(
(index: number, paramName: string, paramValue: number) => {
onChange(
value.map((e, i) =>
i === index ? { ...e, params: { ...e.params, [paramName]: paramValue } } : e,
),
);
},
[value, onChange],
);
function loadPreset(preset: EffectPresetResponse) {
idsRef.current = preset.effects_chain.map(() => makeId());
onChange(preset.effects_chain);
setExpandedId(null);
}
function clearAll() {
idsRef.current = [];
onChange([]);
setExpandedId(null);
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = idsRef.current.indexOf(active.id as string);
const newIndex = idsRef.current.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
idsRef.current = arrayMove(idsRef.current, oldIndex, newIndex);
onChange(arrayMove([...value], oldIndex, newIndex));
}
return (
<div className={cn('space-y-2', compact && 'text-sm')}>
{/* Preset selector row */}
{showPresets && (
<div className="flex items-center gap-2">
<Select
onValueChange={(id) => {
const preset = presets?.find((p) => p.id === id);
if (preset) loadPreset(preset);
}}
>
<SelectTrigger className="h-8 flex-1 text-xs focus:ring-0 focus:ring-offset-0">
<SelectValue placeholder={t('effects.chain.loadPreset')} />
</SelectTrigger>
<SelectContent>
{presets?.map((p) => {
const name = p.is_builtin
? t(`effects.builtinPresets.${p.name}.name`, { defaultValue: p.name })
: p.name;
const description = p.is_builtin
? t(`effects.builtinPresets.${p.name}.description`, {
defaultValue: p.description ?? '',
})
: p.description;
return (
<SelectItem key={p.id} value={p.id}>
{name}
{description && (
<span className="ml-1 text-muted-foreground">- {description}</span>
)}
</SelectItem>
);
})}
</SelectContent>
</Select>
{value.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-xs text-muted-foreground"
onClick={clearAll}
>
{t('effects.chain.clear')}
</Button>
)}
</div>
)}
{/* Sortable effects chain */}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items.map((i) => i._id)} strategy={verticalListSortingStrategy}>
{items.map((effect, index) => (
<SortableEffectItem
key={effect._id}
id={effect._id}
effect={effect}
index={index}
effectDef={effectsMap.get(effect.type)}
isExpanded={expandedId === effect._id}
onToggleExpand={() => setExpandedId(expandedId === effect._id ? null : effect._id)}
onRemove={() => removeEffect(index)}
onToggleEnabled={() => toggleEnabled(index)}
onUpdateParam={(paramName, paramValue) => updateParam(index, paramName, paramValue)}
/>
))}
</SortableContext>
</DndContext>
{/* Add effect */}
{availableEffects && (
<Select onValueChange={addEffect}>
<SelectTrigger className="h-8 border-dashed text-xs text-muted-foreground focus:ring-0 focus:ring-offset-0">
<Plus className="mr-1 h-3.5 w-3.5" />
<SelectValue placeholder={t('effects.chain.addEffect')} />
</SelectTrigger>
<SelectContent>
{availableEffects.effects.map((e) => (
<SelectItem key={e.type} value={e.type}>
{t(`effects.types.${e.type}.label`, { defaultValue: e.label })}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Sortable effect item
// ---------------------------------------------------------------------------
interface SortableEffectItemProps {
id: string;
effect: EffectConfig;
index: number;
effectDef?: AvailableEffect;
isExpanded: boolean;
onToggleExpand: () => void;
onRemove: () => void;
onToggleEnabled: () => void;
onUpdateParam: (paramName: string, paramValue: number) => void;
}
function SortableEffectItem({
id,
effect,
effectDef,
isExpanded,
onToggleExpand,
onRemove,
onToggleEnabled,
onUpdateParam,
}: SortableEffectItemProps) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : undefined,
};
const label = t(`effects.types.${effect.type}.label`, {
defaultValue: effectDef?.label ?? effect.type,
});
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'rounded-md border',
effect.enabled ? 'border-border bg-card' : 'border-border/50 bg-muted/30',
isDragging && 'opacity-80 shadow-lg',
)}
>
{/* Header */}
<div className="flex items-center gap-1 px-2 py-1.5">
<button
type="button"
className="p-0.5 text-muted-foreground hover:text-foreground"
onClick={onToggleExpand}
>
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
className="p-0.5 text-muted-foreground/50 hover:text-muted-foreground cursor-grab active:cursor-grabbing touch-none"
{...attributes}
{...listeners}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
<span
className={cn('flex-1 text-xs font-medium', !effect.enabled && 'text-muted-foreground')}
>
{label}
</span>
<button
type="button"
className={cn(
'p-0.5 transition-colors',
effect.enabled ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
)}
onClick={onToggleEnabled}
title={effect.enabled ? t('effects.chain.disable') : t('effects.chain.enable')}
>
<Power className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="p-0.5 text-muted-foreground "
onClick={onRemove}
title={t('effects.chain.remove')}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
{/* Params */}
{isExpanded && effectDef && (
<div className="space-y-3 border-t px-3 py-2.5">
{Object.entries(effectDef.params).map(([paramName, paramDef]) => {
const currentValue = effect.params[paramName] ?? paramDef.default;
return (
<div key={paramName} className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-[11px] text-muted-foreground">
{t(`effects.types.${effect.type}.params.${paramName}`, {
defaultValue: paramDef.description,
})}
</Label>
<span className="text-[11px] font-mono tabular-nums text-foreground">
{currentValue.toFixed(
paramDef.step < 1 ? Math.max(1, -Math.floor(Math.log10(paramDef.step))) : 0,
)}
</span>
</div>
<Slider
min={paramDef.min}
max={paramDef.max}
step={paramDef.step}
value={[currentValue]}
onValueChange={([v]) => onUpdateParam(paramName, v)}
/>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,103 @@
import { ChevronDown, Search } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { HistoryResponse } from '@/lib/api/types';
import { useHistory } from '@/lib/hooks/useHistory';
import { cn } from '@/lib/utils/cn';
interface GenerationPickerProps {
selectedId: string | null;
onSelect: (generation: HistoryResponse) => void;
className?: string;
}
export function GenerationPicker({ selectedId, onSelect, className }: GenerationPickerProps) {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const { data: historyData } = useHistory({ limit: 50 });
const completedGenerations = useMemo(() => {
if (!historyData?.items) return [];
return historyData.items.filter((gen) => gen.status === 'completed');
}, [historyData]);
const filtered = useMemo(() => {
if (!searchQuery) return completedGenerations;
const q = searchQuery.toLowerCase();
return completedGenerations.filter(
(gen) => gen.text.toLowerCase().includes(q) || gen.profile_name.toLowerCase().includes(q),
);
}, [completedGenerations, searchQuery]);
const selectedGeneration = completedGenerations.find((g) => g.id === selectedId);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn('h-8 justify-between gap-2 text-xs font-normal', className)}
>
{selectedGeneration ? (
<span className="truncate">
<span className="font-medium">{selectedGeneration.profile_name}</span>
<span className="text-muted-foreground ml-1.5">
{selectedGeneration.text.length > 30
? `${selectedGeneration.text.substring(0, 30)}...`
: selectedGeneration.text}
</span>
</span>
) : (
<span className="text-muted-foreground">Select a generation...</span>
)}
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="start">
<div className="p-2 border-b">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search by voice or text..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-7 text-xs"
/>
</div>
</div>
<div className="max-h-60 overflow-y-auto">
{filtered.length === 0 ? (
<div className="p-4 text-center text-xs text-muted-foreground">
No generations found
</div>
) : (
filtered.map((gen) => (
<button
key={gen.id}
type="button"
className={cn(
'w-full text-left px-3 py-2 hover:bg-muted/50 transition-colors border-b border-border/30 last:border-0',
gen.id === selectedId && 'bg-accent/10',
)}
onClick={() => {
onSelect(gen);
setOpen(false);
setSearchQuery('');
}}
>
<div className="font-medium text-sm">{gen.profile_name}</div>
<div className="text-xs text-muted-foreground truncate">
{gen.text.length > 60 ? `${gen.text.substring(0, 60)}...` : gen.text}
</div>
</button>
))
)}
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,435 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Play, Save, Trash2, Wand2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { GenerationPicker } from '@/components/Effects/GenerationPicker';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type { HistoryResponse } from '@/lib/api/types';
import { useHistory } from '@/lib/hooks/useHistory';
import { useEffectsStore } from '@/stores/effectsStore';
import { usePlayerStore } from '@/stores/playerStore';
export function EffectsDetail() {
const { t } = useTranslation();
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
const isCreatingNew = useEffectsStore((s) => s.isCreatingNew);
const workingChain = useEffectsStore((s) => s.workingChain);
const setWorkingChain = useEffectsStore((s) => s.setWorkingChain);
const setSelectedPresetId = useEffectsStore((s) => s.setSelectedPresetId);
const setIsCreatingNew = useEffectsStore((s) => s.setIsCreatingNew);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
// "Save as Custom" dialog state
const [saveAsDialogOpen, setSaveAsDialogOpen] = useState(false);
const [saveAsName, setSaveAsName] = useState('');
const [saveAsDescription, setSaveAsDescription] = useState('');
// Preview state
const [previewGenId, setPreviewGenId] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const blobUrlRef = useRef<string | null>(null);
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
const { toast } = useToast();
const queryClient = useQueryClient();
// Auto-select the most recent generation as preview source
const { data: historyData } = useHistory({ limit: 1 });
useEffect(() => {
if (!previewGenId && historyData?.items?.length) {
const first = historyData.items.find((g) => g.status === 'completed');
if (first) setPreviewGenId(first.id);
}
}, [historyData, previewGenId]);
const { data: preset } = useQuery({
queryKey: ['effect-preset', selectedPresetId],
queryFn: () =>
selectedPresetId
? apiClient
.listEffectPresets()
.then((all) => all.find((p) => p.id === selectedPresetId) ?? null)
: null,
enabled: !!selectedPresetId,
staleTime: 30_000,
});
// Sync name/description when selecting a preset
useEffect(() => {
if (preset) {
setName(preset.name);
setDescription(preset.description ?? '');
} else if (isCreatingNew) {
setName('');
setDescription('');
}
}, [preset, isCreatingNew]);
// Cleanup blob URL on unmount
useEffect(() => {
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, []);
const isEditing = !!selectedPresetId || isCreatingNew;
const isBuiltIn = preset?.is_builtin ?? false;
const presetName = preset
? preset.is_builtin
? t(`effects.builtinPresets.${preset.name}.name`, { defaultValue: preset.name })
: preset.name
: '';
const presetDescription = preset
? preset.is_builtin
? t(`effects.builtinPresets.${preset.name}.description`, {
defaultValue: preset.description ?? '',
})
: preset.description
: '';
async function handlePreview() {
if (!previewGenId || workingChain.length === 0) return;
setPreviewLoading(true);
try {
const blob = await apiClient.previewEffects(previewGenId, workingChain);
// Revoke old blob URL
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
}
const url = URL.createObjectURL(blob);
blobUrlRef.current = url;
// Play through the main audio player
setAudioWithAutoPlay(url, `preview-${Date.now()}`, null, 'Effects Preview');
} catch (error) {
toast({
title: t('effects.toast.previewFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
variant: 'destructive',
});
} finally {
setPreviewLoading(false);
}
}
function handleSelectGeneration(gen: HistoryResponse) {
setPreviewGenId(gen.id);
}
async function handleSaveNew() {
if (!name.trim()) {
toast({ title: t('effects.toast.nameRequired'), variant: 'destructive' });
return;
}
setSaving(true);
try {
const created = await apiClient.createEffectPreset({
name: name.trim(),
description: description.trim() || undefined,
effects_chain: workingChain,
});
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
setIsCreatingNew(false);
setSelectedPresetId(created.id);
toast({
title: t('effects.toast.saved'),
description: t('effects.toast.createdDescription', { name: created.name }),
});
} catch (error) {
toast({
title: t('effects.toast.saveFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
variant: 'destructive',
});
} finally {
setSaving(false);
}
}
async function handleSaveExisting() {
if (!selectedPresetId || !name.trim()) return;
setSaving(true);
try {
await apiClient.updateEffectPreset(selectedPresetId, {
name: name.trim(),
description: description.trim() || undefined,
effects_chain: workingChain,
});
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
queryClient.invalidateQueries({ queryKey: ['effect-preset', selectedPresetId] });
toast({ title: t('effects.toast.updated') });
} catch (error) {
toast({
title: t('effects.toast.saveFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
variant: 'destructive',
});
} finally {
setSaving(false);
}
}
function handleSaveAsNew() {
const sourceName = isBuiltIn ? presetName : name;
setSaveAsName(t('effects.saveAs.suggestedName', { name: sourceName }));
setSaveAsDescription(description);
setSaveAsDialogOpen(true);
}
async function handleSaveAsConfirm() {
if (!saveAsName.trim()) {
toast({ title: t('effects.toast.nameRequired'), variant: 'destructive' });
return;
}
setSaving(true);
try {
const created = await apiClient.createEffectPreset({
name: saveAsName.trim(),
description: saveAsDescription.trim() || undefined,
effects_chain: workingChain,
});
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
setSaveAsDialogOpen(false);
setSelectedPresetId(created.id);
toast({
title: t('effects.toast.saved'),
description: t('effects.toast.createdDescription', { name: created.name }),
});
} catch (error) {
toast({
title: t('effects.toast.saveFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
variant: 'destructive',
});
} finally {
setSaving(false);
}
}
async function handleDelete() {
if (!selectedPresetId) return;
setDeleting(true);
try {
await apiClient.deleteEffectPreset(selectedPresetId);
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
setSelectedPresetId(null);
setWorkingChain([]);
toast({ title: t('effects.toast.deleted') });
} catch (error) {
toast({
title: t('effects.toast.deleteFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
variant: 'destructive',
});
} finally {
setDeleting(false);
}
}
if (!isEditing) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center space-y-2">
<Wand2 className="h-10 w-10 mx-auto opacity-30" />
<p className="text-sm">{t('effects.placeholder')}</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">
{isCreatingNew
? t('effects.detail.newTitle')
: isBuiltIn
? presetName
: t('effects.detail.editTitle')}
</h2>
<div className="flex items-center gap-2">
{!isBuiltIn && !isCreatingNew && (
<>
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive gap-1.5"
onClick={handleDelete}
disabled={deleting}
>
<Trash2 className="h-3.5 w-3.5" />
{deleting ? t('effects.detail.deleting') : t('common.delete')}
</Button>
<Button
size="sm"
className="h-8 gap-1.5"
onClick={handleSaveExisting}
disabled={saving || workingChain.length === 0}
>
<Save className="h-3.5 w-3.5" />
{saving ? t('effects.detail.saving') : t('common.save')}
</Button>
</>
)}
{isCreatingNew && (
<Button
size="sm"
className="h-8 gap-1.5"
onClick={handleSaveNew}
disabled={saving || workingChain.length === 0}
>
<Save className="h-3.5 w-3.5" />
{saving ? t('effects.detail.saving') : t('effects.detail.savePreset')}
</Button>
)}
{isBuiltIn && (
<Button
size="sm"
variant="outline"
className="h-8 gap-1.5"
onClick={handleSaveAsNew}
disabled={saving}
>
<Save className="h-3.5 w-3.5" />
{saving ? t('effects.detail.saving') : t('effects.detail.saveAsCustom')}
</Button>
)}
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto space-y-5 pr-1">
{(isCreatingNew || !isBuiltIn) && (
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-xs">{t('effects.fields.name')}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('effects.fields.namePlaceholder')}
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t('effects.fields.description')}</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('effects.fields.descriptionPlaceholder')}
className="min-h-[60px] resize-none"
/>
</div>
</div>
)}
{isBuiltIn && presetDescription && (
<p className="text-sm text-muted-foreground">{presetDescription}</p>
)}
<EffectsChainEditor value={workingChain} onChange={setWorkingChain} showPresets={false} />
<Separator />
<div className="space-y-3">
<Label className="text-xs">{t('effects.preview.label')}</Label>
<div className="flex items-center gap-2">
<GenerationPicker
selectedId={previewGenId}
onSelect={handleSelectGeneration}
className="flex-1"
/>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 shrink-0"
onClick={handlePreview}
disabled={!previewGenId || workingChain.length === 0 || previewLoading}
>
{previewLoading ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t('effects.preview.processing')}
</>
) : (
<>
<Play className="h-3.5 w-3.5" />
{t('effects.preview.button')}
</>
)}
</Button>
</div>
<p className="text-[11px] text-muted-foreground">{t('effects.preview.hint')}</p>
</div>
</div>
<Dialog open={saveAsDialogOpen} onOpenChange={setSaveAsDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('effects.saveAs.title')}</DialogTitle>
<DialogDescription>{t('effects.saveAs.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-1.5">
<Label className="text-xs">{t('effects.fields.name')}</Label>
<Input
value={saveAsName}
onChange={(e) => setSaveAsName(e.target.value)}
placeholder={t('effects.fields.namePlaceholder')}
className="h-9"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && saveAsName.trim()) {
handleSaveAsConfirm();
}
}}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t('effects.fields.description')}</Label>
<Textarea
value={saveAsDescription}
onChange={(e) => setSaveAsDescription(e.target.value)}
placeholder={t('effects.fields.descriptionPlaceholder')}
className="min-h-[60px] resize-none"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSaveAsDialogOpen(false)} disabled={saving}>
{t('common.cancel')}
</Button>
<Button onClick={handleSaveAsConfirm} disabled={saving || !saveAsName.trim()}>
<Save className="h-3.5 w-3.5 mr-1.5" />
{saving ? t('effects.detail.saving') : t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,183 @@
import { useQuery } from '@tanstack/react-query';
import { Loader2, Plus, Sparkles, Wand2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
ListPane,
ListPaneActions,
ListPaneHeader,
ListPaneScroll,
ListPaneTitle,
ListPaneTitleRow,
} from '@/components/ListPane';
import { Button } from '@/components/ui/button';
import { apiClient } from '@/lib/api/client';
import type { EffectPresetResponse } from '@/lib/api/types';
import { cn } from '@/lib/utils/cn';
import { useEffectsStore } from '@/stores/effectsStore';
export function EffectsList() {
const { t } = useTranslation();
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
const setSelectedPresetId = useEffectsStore((s) => s.setSelectedPresetId);
const setWorkingChain = useEffectsStore((s) => s.setWorkingChain);
const setIsCreatingNew = useEffectsStore((s) => s.setIsCreatingNew);
const isCreatingNew = useEffectsStore((s) => s.isCreatingNew);
const { data: presets, isLoading } = useQuery({
queryKey: ['effect-presets'],
queryFn: () => apiClient.listEffectPresets(),
staleTime: 30_000,
});
const builtIn = presets?.filter((p) => p.is_builtin) ?? [];
const userPresets = presets?.filter((p) => !p.is_builtin) ?? [];
function handleSelect(preset: EffectPresetResponse) {
setSelectedPresetId(preset.id);
setWorkingChain(preset.effects_chain);
}
function handleCreateNew() {
setIsCreatingNew(true);
setWorkingChain([]);
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<ListPane>
<ListPaneHeader>
<ListPaneTitleRow>
<ListPaneTitle>{t('effects.title')}</ListPaneTitle>
<ListPaneActions>
<Button onClick={handleCreateNew} size="sm">
<Plus className="mr-2 h-4 w-4" />
{t('effects.newPreset')}
</Button>
</ListPaneActions>
</ListPaneTitleRow>
</ListPaneHeader>
<ListPaneScroll className="pt-16">
<div className="px-4 pb-6 space-y-4">
{builtIn.length > 0 && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
{t('effects.sections.builtin')}
</div>
<div className="space-y-1.5">
{builtIn.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
isSelected={selectedPresetId === preset.id && !isCreatingNew}
onSelect={() => handleSelect(preset)}
/>
))}
</div>
</div>
)}
{userPresets.length > 0 && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
{t('effects.sections.custom')}
</div>
<div className="space-y-1.5">
{userPresets.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
isSelected={selectedPresetId === preset.id && !isCreatingNew}
onSelect={() => handleSelect(preset)}
/>
))}
</div>
</div>
)}
{isCreatingNew && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
{t('effects.sections.new')}
</div>
<div className="rounded-xl border-2 border-accent/40 bg-accent/5 p-3">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-accent" />
<span className="text-sm font-medium">{t('effects.unsaved.title')}</span>
</div>
<p className="text-xs text-muted-foreground mt-1">{t('effects.unsaved.hint')}</p>
</div>
</div>
)}
</div>
</ListPaneScroll>
</ListPane>
);
}
function PresetCard({
preset,
isSelected,
onSelect,
}: {
preset: EffectPresetResponse;
isSelected: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation();
const effectCount = preset.effects_chain.length;
const name = preset.is_builtin
? t(`effects.builtinPresets.${preset.name}.name`, { defaultValue: preset.name })
: preset.name;
const description = preset.is_builtin
? t(`effects.builtinPresets.${preset.name}.description`, {
defaultValue: preset.description ?? '',
})
: preset.description;
return (
<button
type="button"
className={cn(
'w-full text-left rounded-xl border p-3 h-[88px] transition-all duration-150',
isSelected
? 'border-accent/50 bg-accent/10'
: 'border-border bg-card hover:bg-muted/50 hover:border-border',
)}
onClick={onSelect}
>
<div className="flex items-center gap-2">
<Wand2
className={cn('h-4 w-4 shrink-0', isSelected ? 'text-accent' : 'text-muted-foreground')}
/>
<span className="text-sm font-medium truncate">{name}</span>
{preset.is_builtin && (
<span className="text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded-full shrink-0">
{t('effects.badge.builtin')}
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-1 pl-6">
{description || t('effects.noDescription')}
</p>
<div className="flex items-center gap-2 mt-1.5 pl-6">
<span className="text-[10px] text-muted-foreground">
{t('effects.effectCount', { count: effectCount })}
</span>
<span className="text-[10px] text-muted-foreground/50">
{preset.effects_chain
.filter((e) => e.enabled)
.map((e) => e.type)
.join(' → ')}
</span>
</div>
</button>
);
}
@@ -0,0 +1,20 @@
import { EffectsDetail } from './EffectsDetail';
import { EffectsList } from './EffectsList';
export function EffectsTab() {
return (
<div className="flex flex-col h-full min-h-0 overflow-hidden -mx-8">
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden">
{/* Left - Presets list */}
<div className="w-full max-w-[360px] shrink-0 flex flex-col min-h-0">
<EffectsList />
</div>
{/* Right - Detail / editor */}
<div className="flex-1 min-h-0 flex flex-col pr-8">
<EffectsDetail />
</div>
</div>
</div>
);
}
-1
View File
@@ -1 +0,0 @@
# Voice generation components
@@ -0,0 +1,170 @@
import { useEffect } from 'react';
import type { UseFormReturn } from 'react-hook-form';
import { FormControl } from '@/components/ui/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { VoiceProfileResponse } from '@/lib/api/types';
import { getLanguageOptionsForEngine } from '@/lib/constants/languages';
import type { GenerationFormValues } from '@/lib/hooks/useGenerationForm';
/**
* Engine/model options and their display metadata.
* Adding a new engine means adding one entry here.
*/
const ENGINE_OPTIONS = [
{ value: 'qwen:1.7B', label: 'Qwen3-TTS 1.7B', engine: 'qwen' },
{ value: 'qwen:0.6B', label: 'Qwen3-TTS 0.6B', engine: 'qwen' },
{ value: 'qwen_custom_voice:1.7B', label: 'Qwen CustomVoice 1.7B', engine: 'qwen_custom_voice' },
{ value: 'qwen_custom_voice:0.6B', label: 'Qwen CustomVoice 0.6B', engine: 'qwen_custom_voice' },
{ value: 'luxtts', label: 'LuxTTS', engine: 'luxtts' },
{ value: 'chatterbox', label: 'Chatterbox', engine: 'chatterbox' },
{ value: 'chatterbox_turbo', label: 'Chatterbox Turbo', engine: 'chatterbox_turbo' },
{ value: 'tada:1B', label: 'TADA 1B', engine: 'tada' },
{ value: 'tada:3B', label: 'TADA 3B Multilingual', engine: 'tada' },
{ value: 'kokoro', label: 'Kokoro 82M', engine: 'kokoro' },
] as const;
const ENGINE_DESCRIPTIONS: Record<string, string> = {
qwen: 'Multi-language, two sizes',
qwen_custom_voice: '9 preset voices, instruct control',
luxtts: 'Fast, English-focused',
chatterbox: '23 languages, incl. Hebrew',
chatterbox_turbo: 'English, [laugh] [cough] tags',
tada: 'HumeAI, 700s+ coherent audio',
kokoro: '82M params, CPU realtime, 8 langs',
};
/** Engines that only support English and should force language to 'en' on select. */
const ENGLISH_ONLY_ENGINES = new Set(['luxtts', 'chatterbox_turbo']);
/** Engines that support cloned (reference audio) profiles. */
const CLONING_ENGINES = new Set(['qwen', 'luxtts', 'chatterbox', 'chatterbox_turbo', 'tada']);
function getAvailableOptions(selectedProfile?: VoiceProfileResponse | null) {
if (!selectedProfile) return ENGINE_OPTIONS;
return ENGINE_OPTIONS.filter((opt) => isProfileCompatibleWithEngine(selectedProfile, opt.engine));
}
function getSelectValue(engine: string, modelSize?: string): string {
if (engine === 'qwen') return `qwen:${modelSize || '1.7B'}`;
if (engine === 'qwen_custom_voice') return `qwen_custom_voice:${modelSize || '1.7B'}`;
if (engine === 'tada') return `tada:${modelSize || '1B'}`;
return engine;
}
export function applyEngineSelection(form: UseFormReturn<GenerationFormValues>, value: string) {
if (value.startsWith('qwen_custom_voice:')) {
const [, modelSize] = value.split(':');
form.setValue('engine', 'qwen_custom_voice');
form.setValue('modelSize', modelSize as '1.7B' | '0.6B');
const currentLang = form.getValues('language');
const available = getLanguageOptionsForEngine('qwen_custom_voice');
if (!available.some((l) => l.value === currentLang)) {
form.setValue('language', available[0]?.value ?? 'en');
}
} else if (value.startsWith('qwen:')) {
const [, modelSize] = value.split(':');
form.setValue('engine', 'qwen');
form.setValue('modelSize', modelSize as '1.7B' | '0.6B');
// Validate language is supported by Qwen
const currentLang = form.getValues('language');
const available = getLanguageOptionsForEngine('qwen');
if (!available.some((l) => l.value === currentLang)) {
form.setValue('language', available[0]?.value ?? 'en');
}
} else if (value.startsWith('tada:')) {
const [, modelSize] = value.split(':');
form.setValue('engine', 'tada');
form.setValue('modelSize', modelSize as '1B' | '3B');
// TADA 1B is English-only; 3B is multilingual
if (modelSize === '1B') {
form.setValue('language', 'en');
} else {
const currentLang = form.getValues('language');
const available = getLanguageOptionsForEngine('tada');
if (!available.some((l) => l.value === currentLang)) {
form.setValue('language', available[0]?.value ?? 'en');
}
}
} else {
form.setValue('engine', value as GenerationFormValues['engine']);
form.setValue('modelSize', undefined as unknown as '1.7B' | '0.6B');
if (ENGLISH_ONLY_ENGINES.has(value)) {
form.setValue('language', 'en');
} else {
// If current language isn't supported by the new engine, reset to first available
const currentLang = form.getValues('language');
const available = getLanguageOptionsForEngine(value);
if (!available.some((l) => l.value === currentLang)) {
form.setValue('language', available[0]?.value ?? 'en');
}
}
}
}
interface EngineModelSelectorProps {
form: UseFormReturn<GenerationFormValues>;
compact?: boolean;
selectedProfile?: VoiceProfileResponse | null;
}
export function EngineModelSelector({ form, compact, selectedProfile }: EngineModelSelectorProps) {
const engine = form.watch('engine') || 'qwen';
const modelSize = form.watch('modelSize');
const selectValue = getSelectValue(engine, modelSize);
const availableOptions = getAvailableOptions(selectedProfile);
const currentEngineAvailable = availableOptions.some((opt) => opt.value === selectValue);
useEffect(() => {
if (!currentEngineAvailable && availableOptions.length > 0) {
applyEngineSelection(form, availableOptions[0].value);
}
}, [availableOptions, currentEngineAvailable, form]);
const itemClass = compact ? 'text-xs text-muted-foreground' : undefined;
const triggerClass = compact
? 'h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all'
: undefined;
return (
<Select value={selectValue} onValueChange={(v) => applyEngineSelection(form, v)}>
<FormControl>
<SelectTrigger className={triggerClass}>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{availableOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className={itemClass}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
/** Returns a human-readable description for the currently selected engine. */
export function getEngineDescription(engine: string): string {
return ENGINE_DESCRIPTIONS[engine] ?? '';
}
/**
* Check if a profile is compatible with the currently selected engine.
* Useful for UI hints.
*/
export function isProfileCompatibleWithEngine(
profile: VoiceProfileResponse,
engine: string,
): boolean {
const voiceType = profile.voice_type || 'cloned';
if (voiceType === 'preset') return profile.preset_engine === engine;
if (voiceType === 'cloned') return CLONING_ENGINES.has(engine);
return true; // designed — future
}
@@ -0,0 +1,639 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMatchRoute } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
import { Dices, Loader2, SlidersHorizontal, Sparkles, Wand2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
import { useStory } from '@/lib/hooks/useStories';
import { cn } from '@/lib/utils/cn';
import { useGenerationStore } from '@/stores/generationStore';
import { useStoryStore } from '@/stores/storyStore';
import { useUIStore } from '@/stores/uiStore';
import { EngineModelSelector } from './EngineModelSelector';
import { ParalinguisticInput } from './ParalinguisticInput';
interface FloatingGenerateBoxProps {
isPlayerOpen?: boolean;
showVoiceSelector?: boolean;
}
export function FloatingGenerateBox({
isPlayerOpen = false,
showVoiceSelector = false,
}: FloatingGenerateBoxProps) {
const { t } = useTranslation();
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
const setSelectedEngine = useUIStore((state) => state.setSelectedEngine);
const { data: selectedProfile } = useProfile(selectedProfileId || '');
const { data: profiles } = useProfiles();
const [isExpanded, setIsExpanded] = useState(false);
const [isInstructExpanded, setIsInstructExpanded] = useState(false);
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const matchRoute = useMatchRoute();
const isStoriesRoute = matchRoute({ to: '/stories' });
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
const { data: currentStory } = useStory(selectedStoryId);
const addPendingStoryAdd = useGenerationStore((s) => s.addPendingStoryAdd);
const { toast } = useToast();
const composeMutation = useMutation({
mutationFn: async () => {
if (!selectedProfileId) throw new Error('No profile selected');
return apiClient.composeWithPersonality(selectedProfileId);
},
onError: (err: Error) => {
toast({
title: t('generation.compose.failedTitle'),
description: err.message || t('generation.compose.failedDescription'),
variant: 'destructive',
});
},
});
// Fetch effect presets for the dropdown
const { data: effectPresets } = useQuery({
queryKey: ['effectPresets'],
queryFn: () => apiClient.listEffectPresets(),
});
// Calculate if track editor is visible (on stories route with items)
const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0;
const { form, handleSubmit, isPending } = useGenerationForm({
onSuccess: async (generationId) => {
setIsExpanded(false);
// Defer the story add until TTS completes -- useGenerationProgress handles it
if (isStoriesRoute && selectedStoryId && generationId) {
addPendingStoryAdd(generationId, selectedStoryId);
}
},
getEffectsChain: () => {
if (!selectedPresetId) return undefined;
// Profile's own effects chain (no matching preset)
if (selectedPresetId === '_profile') {
return selectedProfile?.effects_chain ?? undefined;
}
if (!effectPresets) return undefined;
const preset = effectPresets.find((p) => p.id === selectedPresetId);
return preset?.effects_chain;
},
});
// Click away handler to collapse the box
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
// Don't collapse if clicking inside the container
if (containerRef.current?.contains(target)) {
return;
}
// Don't collapse if clicking on a Select dropdown (which renders in a portal)
if (
target.closest('[role="listbox"]') ||
target.closest('[data-radix-popper-content-wrapper]')
) {
return;
}
setIsExpanded(false);
}
if (isExpanded) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isExpanded]);
// Set first voice as default if none selected
useEffect(() => {
if (!selectedProfileId && profiles && profiles.length > 0) {
setSelectedProfileId(profiles[0].id);
}
}, [selectedProfileId, profiles, setSelectedProfileId]);
// Sync engine selection to global store so ProfileList can filter
const watchedEngine = form.watch('engine');
useEffect(() => {
if (watchedEngine) {
setSelectedEngine(watchedEngine);
}
}, [watchedEngine, setSelectedEngine]);
// Sync generation form language, engine, and effects with selected profile
type EngineValue =
| 'qwen'
| 'luxtts'
| 'chatterbox'
| 'chatterbox_turbo'
| 'tada'
| 'kokoro'
| 'qwen_custom_voice';
useEffect(() => {
if (selectedProfile?.language) {
form.setValue('language', selectedProfile.language as LanguageCode);
}
// Auto-switch engine to match the profile
const engine = selectedProfile?.default_engine ?? selectedProfile?.preset_engine;
if (engine) {
form.setValue('engine', engine as EngineValue);
} else if (selectedProfile && selectedProfile.voice_type !== 'preset') {
// Cloned/designed profile with no default — ensure a compatible (non-preset) engine
const currentEngine = form.getValues('engine');
const presetEngines = new Set(['kokoro', 'qwen_custom_voice']);
if (currentEngine && presetEngines.has(currentEngine)) {
form.setValue('engine', 'qwen');
}
}
// Pre-fill effects from profile defaults
if (
selectedProfile?.effects_chain &&
selectedProfile.effects_chain.length > 0 &&
effectPresets
) {
// Try to match against a known preset
const profileChainJson = JSON.stringify(selectedProfile.effects_chain);
const matchingPreset = effectPresets.find(
(p) => JSON.stringify(p.effects_chain) === profileChainJson,
);
if (matchingPreset) {
setSelectedPresetId(matchingPreset.id);
} else {
// No matching preset — use special value to pass profile chain directly
setSelectedPresetId('_profile');
}
} else if (
selectedProfile &&
(!selectedProfile.effects_chain || selectedProfile.effects_chain.length === 0)
) {
setSelectedPresetId(null);
}
// Persona toggle only applies when the profile has a personality prompt.
if (selectedProfile && !selectedProfile.personality?.trim()) {
form.setValue('personality', false);
}
}, [selectedProfile, effectPresets, form]);
// Auto-resize textarea based on content (only when expanded)
useEffect(() => {
if (!isExpanded) {
// Reset textarea height after collapse animation completes
const timeoutId = setTimeout(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = '32px';
textarea.style.overflowY = 'hidden';
}
}, 200); // Wait for animation to complete
return () => clearTimeout(timeoutId);
}
const textarea = textareaRef.current;
if (!textarea) return;
const adjustHeight = () => {
textarea.style.height = 'auto';
const scrollHeight = textarea.scrollHeight;
const minHeight = 100; // Expanded minimum
const maxHeight = 300; // Max height in pixels
const targetHeight = Math.max(minHeight, Math.min(scrollHeight, maxHeight));
textarea.style.height = `${targetHeight}px`;
// Show scrollbar if content exceeds max height
if (scrollHeight > maxHeight) {
textarea.style.overflowY = 'auto';
} else {
textarea.style.overflowY = 'hidden';
}
};
// Small delay to let framer animation complete
const timeoutId = setTimeout(() => {
adjustHeight();
}, 200);
// Adjust on mount and when value changes
adjustHeight();
// Watch for input changes
textarea.addEventListener('input', adjustHeight);
return () => {
clearTimeout(timeoutId);
textarea.removeEventListener('input', adjustHeight);
};
}, [isExpanded]);
async function onSubmit(data: Parameters<typeof handleSubmit>[0]) {
await handleSubmit(data, selectedProfileId);
}
return (
<motion.div
ref={containerRef}
className={cn(
'fixed',
isStoriesRoute
? // Aligned with StoryContent: sidebar + list width + gap (tab bleeds with -mx-8)
'left-[calc(5rem+360px+1.5rem)] right-8'
: 'left-[calc(5rem+2rem)] right-8 lg:right-auto lg:w-[calc((100%-5rem-4rem)/2-1rem)]',
)}
style={{
// On stories route: offset by track editor height when visible
// On other routes: offset by audio player height when visible
bottom: hasTrackEditor
? `${trackEditorHeight + 24}px`
: isPlayerOpen
? 'calc(7rem + 1.5rem)'
: '1.5rem',
}}
>
<motion.div
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[2rem] shadow-2xl hover:bg-background/40 hover:border-accent/20 transition-all duration-300 p-3"
transition={{ duration: 0.6, ease: 'easeInOut' }}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex gap-2">
<motion.div className="flex-1" transition={{ duration: 0.3, ease: 'easeOut' }}>
<FormField
control={form.control}
name="text"
render={({ field }) => (
<FormItem>
<FormControl>
<motion.div
animate={{
height: isExpanded ? 'auto' : '32px',
}}
transition={{ duration: 0.15, ease: 'easeOut' }}
style={{ overflow: 'hidden' }}
>
{form.watch('engine') === 'chatterbox_turbo' ? (
<ParalinguisticInput
value={field.value}
onChange={field.onChange}
placeholder={
isStoriesRoute && currentStory
? t('generation.placeholder.storyWithEffects', {
name: currentStory.name,
})
: selectedProfile
? t('generation.placeholder.effectsHint')
: t('generation.placeholder.selectVoice')
}
className="px-3 py-2 resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm w-full"
style={{
minHeight: isExpanded ? '100px' : '32px',
maxHeight: '300px',
overflowY: 'auto',
}}
disabled={!selectedProfileId}
onClick={() => setIsExpanded(true)}
onFocus={() => setIsExpanded(true)}
/>
) : (
<Textarea
{...field}
ref={(node: HTMLTextAreaElement | null) => {
textareaRef.current = node;
if (typeof field.ref === 'function') {
field.ref(node);
}
}}
placeholder={
isStoriesRoute && currentStory
? t('generation.placeholder.story', { name: currentStory.name })
: selectedProfile
? t('generation.placeholder.profile', {
name: selectedProfile.name,
})
: t('generation.placeholder.selectVoice')
}
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full"
style={{
minHeight: isExpanded ? '100px' : '32px',
maxHeight: '300px',
}}
disabled={!selectedProfileId}
onClick={() => setIsExpanded(true)}
onFocus={() => setIsExpanded(true)}
/>
)}
</motion.div>
</FormControl>
<FormMessage className="text-xs" />
</FormItem>
)}
/>
</motion.div>
<div className="flex items-start gap-2 shrink-0">
{/* Compose — fills the textarea with a fresh in-character line. */}
<AnimatePresence>
{selectedProfile?.personality?.trim() && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
>
<div className="group relative">
<Button
type="button"
variant="ghost"
size="icon"
disabled={composeMutation.isPending || !selectedProfileId}
onClick={async () => {
const result = await composeMutation.mutateAsync();
form.setValue('text', result.text, { shouldDirty: true });
setIsExpanded(true);
}}
className="h-10 w-10 rounded-full bg-card border border-border hover:bg-background/50 transition-all duration-200"
aria-label={t('generation.compose.ariaLabel')}
>
{composeMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Dices className="h-4 w-4" />
)}
</Button>
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
{t('generation.compose.tooltip')}
</span>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Persona — rewrite input through the profile's personality LLM before TTS. */}
<AnimatePresence>
{selectedProfile?.personality?.trim() && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
>
<FormField
control={form.control}
name="personality"
render={({ field }) => {
const active = !!field.value;
return (
<FormItem className="space-y-0">
<FormControl>
<div className="group relative">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => field.onChange(!active)}
className={cn(
'h-10 w-10 rounded-full transition-all duration-200',
active
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
: 'bg-card border border-border hover:bg-background/50',
)}
aria-label={active ? t('generation.persona.ariaLabelActive') : t('generation.persona.ariaLabelInactive')}
aria-pressed={active}
>
<Wand2 className="h-4 w-4" />
</Button>
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
{active ? t('generation.persona.tooltipActive') : t('generation.persona.tooltipInactive')}
</span>
</div>
</FormControl>
</FormItem>
);
}}
/>
</motion.div>
)}
</AnimatePresence>
{/* Instruct toggle — only for Qwen CustomVoice, which actually honors the kwarg */}
<AnimatePresence>
{isExpanded && form.watch('engine') === 'qwen_custom_voice' && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
>
<div className="group relative">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setIsInstructExpanded((prev) => !prev)}
className={cn(
'h-10 w-10 rounded-full transition-all duration-200',
isInstructExpanded
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
: 'bg-card border border-border hover:bg-background/50',
)}
aria-label={
isInstructExpanded
? t('generation.instruct.hide')
: t('generation.instruct.show')
}
aria-pressed={isInstructExpanded}
>
<SlidersHorizontal className="h-4 w-4" />
</Button>
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
{t('generation.instruct.tooltip')}
</span>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="group relative">
<Button
type="submit"
disabled={isPending || !selectedProfileId}
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200"
size="icon"
aria-label={
isPending
? t('generation.button.generating')
: !selectedProfileId
? t('generation.button.selectFirst')
: t('generation.button.generate')
}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
</Button>
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
{isPending
? t('generation.button.generating')
: !selectedProfileId
? t('generation.button.selectFirst')
: t('generation.button.generate')}
</span>
</div>
</div>
</div>
{/* Additive instruct textarea — shown below main text when toggle is on and engine supports it */}
<AnimatePresence>
{isInstructExpanded && form.watch('engine') === 'qwen_custom_voice' && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="overflow-hidden"
>
<FormField
control={form.control}
name="instruct"
render={({ field }) => (
<FormItem className="mt-2">
<FormControl>
<Textarea
{...field}
placeholder={t('generation.instruct.placeholder')}
className="resize-none bg-transparent border border-accent/20 focus-visible:ring-1 focus-visible:ring-accent/40 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full px-3 py-2"
style={{ minHeight: '60px', maxHeight: '160px' }}
maxLength={500}
/>
</FormControl>
<FormMessage className="text-xs" />
</FormItem>
)}
/>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
className=" mt-3"
>
<div className="flex items-center gap-2">
{showVoiceSelector && (
<div className="flex-1">
<Select
value={selectedProfileId || ''}
onValueChange={(value) => setSelectedProfileId(value || null)}
>
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all w-full">
<SelectValue placeholder={t('generation.voiceSelector.placeholder')} />
</SelectTrigger>
<SelectContent>
{profiles?.map((profile) => (
<SelectItem key={profile.id} value={profile.id} className="text-xs">
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<FormField
control={form.control}
name="language"
render={({ field }) => {
const engineLangs = getLanguageOptionsForEngine(
form.watch('engine') || 'qwen',
);
return (
<FormItem className="flex-1 space-y-0">
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{engineLangs.map((lang) => (
<SelectItem key={lang.value} value={lang.value} className="text-xs">
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage className="text-xs" />
</FormItem>
);
}}
/>
<FormItem className="flex-1 space-y-0">
<EngineModelSelector form={form} compact />
</FormItem>
<FormItem className="flex-1 space-y-0">
<Select
value={selectedPresetId || 'none'}
onValueChange={(value) =>
setSelectedPresetId(value === 'none' ? null : value)
}
>
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
<SelectValue placeholder={t('generation.effects.none')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="text-xs">
{t('generation.effects.none')}
</SelectItem>
{selectedProfile?.effects_chain &&
selectedProfile.effects_chain.length > 0 && (
<SelectItem value="_profile" className="text-xs">
{t('generation.effects.profileDefault')}
</SelectItem>
)}
{effectPresets?.map((preset) => (
<SelectItem key={preset.id} value={preset.id} className="text-xs">
{preset.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
</div>
</motion.div>
</AnimatePresence>
</form>
</Form>
</motion.div>
</motion.div>
);
}
@@ -1,245 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2, Mic } from 'lucide-react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { useGeneration } from '@/lib/hooks/useGeneration';
import { useProfile } from '@/lib/hooks/useProfiles';
import { useUIStore } from '@/stores/uiStore';
const generationSchema = z.object({
text: z.string().min(1, 'Text is required').max(5000),
language: z.enum(['en', 'zh']),
seed: z.number().int().optional(),
modelSize: z.enum(['1.7B', '0.6B']).optional(),
instruct: z.string().max(500).optional(),
});
type GenerationFormValues = z.infer<typeof generationSchema>;
export function GenerationForm() {
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const { data: selectedProfile } = useProfile(selectedProfileId || '');
const generation = useGeneration();
const { toast } = useToast();
const form = useForm<GenerationFormValues>({
resolver: zodResolver(generationSchema),
defaultValues: {
text: '',
language: 'en',
seed: undefined,
modelSize: '1.7B',
instruct: '',
},
});
async function onSubmit(data: GenerationFormValues) {
if (!selectedProfileId) {
toast({
title: 'No profile selected',
description: 'Please select a voice profile from the cards above.',
variant: 'destructive',
});
return;
}
try {
const result = await generation.mutateAsync({
profile_id: selectedProfileId,
text: data.text,
language: data.language,
seed: data.seed,
model_size: data.modelSize,
instruct: data.instruct || undefined,
});
toast({
title: 'Generation complete!',
description: `Audio generated (${result.duration.toFixed(2)}s)`,
});
form.reset();
} catch (error) {
toast({
title: 'Generation failed',
description: error instanceof Error ? error.message : 'Failed to generate audio',
variant: 'destructive',
});
}
}
return (
<Card>
<CardHeader>
<CardTitle>Generate Speech</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div>
<FormLabel>Voice Profile</FormLabel>
{selectedProfile ? (
<div className="mt-2 p-3 border rounded-md bg-muted/50 flex items-center gap-2">
<Mic className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{selectedProfile.name}</span>
<Badge variant="outline">{selectedProfile.language}</Badge>
</div>
) : (
<div className="mt-2 p-3 border border-dashed rounded-md text-sm text-muted-foreground">
Click on a profile card above to select a voice profile
</div>
)}
</div>
<FormField
control={form.control}
name="text"
render={({ field }) => (
<FormItem>
<FormLabel>Text to Speak</FormLabel>
<FormControl>
<Textarea
placeholder="Enter the text you want to generate..."
className="min-h-[150px]"
{...field}
/>
</FormControl>
<FormDescription>Max 5000 characters</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="instruct"
render={({ field }) => (
<FormItem>
<FormLabel>Delivery Instructions (optional)</FormLabel>
<FormControl>
<Textarea
placeholder="e.g. Speak slowly with emphasis, Warm and friendly tone, Professional and authoritative..."
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormDescription>
Natural language instructions to control speech delivery (tone, emotion, pace).
Max 500 characters
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-4 md:grid-cols-3">
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>Language</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="zh">Chinese</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="modelSize"
render={({ field }) => (
<FormItem>
<FormLabel>Model Size</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="1.7B">Qwen TTS 1.7B (Higher Quality)</SelectItem>
<SelectItem value="0.6B">Qwen TTS 0.6B (Faster)</SelectItem>
</SelectContent>
</Select>
<FormDescription>Larger models produce better quality</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="seed"
render={({ field }) => (
<FormItem>
<FormLabel>Seed (optional)</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Random"
{...field}
onChange={(e) =>
field.onChange(e.target.value ? parseInt(e.target.value, 10) : undefined)
}
/>
</FormControl>
<FormDescription>For reproducible results</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button
type="submit"
className="w-full"
disabled={generation.isPending || !selectedProfileId}
>
{generation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating...
</>
) : (
'Generate Speech'
)}
</Button>
</form>
</Form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,422 @@
/**
* ParalinguisticInput — a contentEditable rich text input that renders
* Chatterbox Turbo paralinguistic tags (e.g. [laugh]) as inline badges.
*
* Trigger: typing "/" opens an autocomplete dropdown.
* Paste: pasting text with [tag] patterns auto-converts to badges.
* Output: serializes badges back to plain [tag] text for the API.
*/
import { AnimatePresence, motion } from 'framer-motion';
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils/cn';
// ── Tag definitions ─────────────────────────────────────────────────
const PARALINGUISTIC_TAGS = [
{ tag: '[laugh]', label: 'laugh', emoji: '\u{1F602}' },
{ tag: '[chuckle]', label: 'chuckle', emoji: '\u{1F60F}' },
{ tag: '[gasp]', label: 'gasp', emoji: '\u{1F62E}' },
{ tag: '[cough]', label: 'cough', emoji: '\u{1F637}' },
{ tag: '[sigh]', label: 'sigh', emoji: '\u{1F614}' },
{ tag: '[groan]', label: 'groan', emoji: '\u{1F629}' },
{ tag: '[sniff]', label: 'sniff', emoji: '\u{1F443}' },
{ tag: '[shush]', label: 'shush', emoji: '\u{1F92B}' },
{ tag: '[clear throat]', label: 'clear throat', emoji: '\u{1F64A}' },
] as const;
const TAG_REGEX = /\[(laugh|chuckle|gasp|cough|sigh|groan|sniff|shush|clear throat)\]/gi;
// Data attribute used to identify badge spans in the DOM
const BADGE_ATTR = 'data-ptag';
// ── Helpers ─────────────────────────────────────────────────────────
/** Build an inline badge <span> for a tag. */
function makeBadgeHTML(tag: string): string {
const entry = PARALINGUISTIC_TAGS.find((t) => t.tag.toLowerCase() === tag.toLowerCase());
const label = entry?.label ?? tag.replace(/[[\]]/g, '');
const emoji = entry?.emoji ?? '';
// Non-editable inline badge. Zero-width spaces around it let the
// caret sit on either side so the user can type before/after.
return `\u200B<span ${BADGE_ATTR}="${tag}" contenteditable="false" class="ptag-badge">${emoji ? `${emoji}\u00A0` : ''}${label}</span>\u200B`;
}
/** Convert plain text with [tag] patterns into HTML with badge spans. */
function textToHTML(text: string): string {
// Escape HTML entities first
const escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Replace tag patterns with badge HTML
return escaped.replace(TAG_REGEX, (match) => makeBadgeHTML(match));
}
/** Serialize the contentEditable innerHTML back to plain text with [tag] syntax. */
function htmlToText(container: HTMLElement): string {
let result = '';
for (const node of container.childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
// Strip zero-width spaces we added around badges
result += (node.textContent ?? '').replace(/\u200B/g, '');
} else if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement;
if (el.hasAttribute(BADGE_ATTR)) {
result += el.getAttribute(BADGE_ATTR) ?? '';
} else if (el.tagName === 'BR') {
result += '\n';
} else {
// Recurse for nested elements (e.g. spans from paste)
result += htmlToText(el);
}
}
}
return result;
}
/** Get the text content from the current caret position back to the last
* whitespace or start of container, to detect the "/" trigger. */
function getWordBeforeCaret(_container: HTMLElement): { word: string; range: Range | null } {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return { word: '', range: null };
const range = sel.getRangeAt(0).cloneRange();
range.collapse(true);
// Walk backwards from caret through the text node
const textNode = range.startContainer;
if (textNode.nodeType !== Node.TEXT_NODE) return { word: '', range: null };
const text = textNode.textContent ?? '';
const offset = range.startOffset;
let start = offset;
while (
start > 0 &&
text[start - 1] !== ' ' &&
text[start - 1] !== '\n' &&
text[start - 1] !== '\u00A0'
) {
start--;
}
const word = text.slice(start, offset);
const wordRange = document.createRange();
wordRange.setStart(textNode, start);
wordRange.setEnd(textNode, offset);
return { word, range: wordRange };
}
// ── Component ───────────────────────────────────────────────────────
export interface ParalinguisticInputProps {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
onClick?: () => void;
onFocus?: () => void;
}
export interface ParalinguisticInputRef {
focus: () => void;
element: HTMLDivElement | null;
}
export const ParalinguisticInput = forwardRef<ParalinguisticInputRef, ParalinguisticInputProps>(
function ParalinguisticInput(
{ value, onChange, placeholder, disabled, className, style, onClick, onFocus },
ref,
) {
const editorRef = useRef<HTMLDivElement>(null);
const [showMenu, setShowMenu] = useState(false);
const [menuFilter, setMenuFilter] = useState('');
const [menuIndex, setMenuIndex] = useState(0);
const [menuPosition, setMenuPosition] = useState<{ bottom: number; left: number }>({
bottom: 0,
left: 0,
});
const triggerRangeRef = useRef<Range | null>(null);
const lastSerializedRef = useRef<string>('');
const isComposingRef = useRef(false);
useImperativeHandle(ref, () => ({
focus: () => editorRef.current?.focus(),
element: editorRef.current,
}));
// Filtered tag list for the autocomplete menu
const filteredTags = PARALINGUISTIC_TAGS.filter((t) =>
t.label.toLowerCase().includes(menuFilter.toLowerCase()),
);
// ── Sync external value → editor ──────────────────────────────
useEffect(() => {
const el = editorRef.current;
if (!el) return;
// Only update DOM if the external value differs from what we last emitted
if (value !== undefined && value !== lastSerializedRef.current) {
lastSerializedRef.current = value;
el.innerHTML = value ? textToHTML(value) : '';
}
}, [value]);
// ── Emit plain-text value on input ────────────────────────────
const emitChange = useCallback(() => {
const el = editorRef.current;
if (!el || !onChange) return;
const text = htmlToText(el);
lastSerializedRef.current = text;
onChange(text);
}, [onChange]);
// ── Insert a tag badge at the caret ───────────────────────────
const insertTag = useCallback(
(tag: string) => {
const el = editorRef.current;
if (!el) return;
// Delete the /filter text
const wordRange = triggerRangeRef.current;
if (wordRange) {
wordRange.deleteContents();
}
// Insert badge HTML
const temp = document.createElement('span');
temp.innerHTML = makeBadgeHTML(tag);
const frag = document.createDocumentFragment();
let lastNode: Node | null = null;
while (temp.firstChild) {
lastNode = frag.appendChild(temp.firstChild);
}
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
range.deleteContents();
range.insertNode(frag);
// Move caret after the badge
if (lastNode) {
const newRange = document.createRange();
newRange.setStartAfter(lastNode);
newRange.collapse(true);
sel.removeAllRanges();
sel.addRange(newRange);
}
}
setShowMenu(false);
setMenuFilter('');
emitChange();
el.focus();
},
[emitChange],
);
// ── Handle keydown for autocomplete navigation ────────────────
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (showMenu) {
if (filteredTags.length === 0) {
if (e.key === 'Escape') {
e.preventDefault();
setShowMenu(false);
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setMenuIndex((i) => (i + 1) % filteredTags.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setMenuIndex((i) => (i - 1 + filteredTags.length) % filteredTags.length);
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
if (filteredTags[menuIndex]) {
insertTag(filteredTags[menuIndex].tag);
}
} else if (e.key === 'Escape') {
e.preventDefault();
setShowMenu(false);
}
} else {
// Prevent Enter from creating <div> blocks in contentEditable
if (e.key === 'Enter' && !e.shiftKey) {
// Let the form handle submit
}
}
},
[showMenu, filteredTags, menuIndex, insertTag],
);
// ── Handle input (check for / trigger) ────────────────────────
const handleInput = useCallback(() => {
if (isComposingRef.current) return;
const el = editorRef.current;
if (!el) return;
const { word, range } = getWordBeforeCaret(el);
if (word.startsWith('/')) {
const filter = word.slice(1); // strip the /
setMenuFilter(filter);
setMenuIndex(0);
triggerRangeRef.current = range;
// Position the menu above the caret using viewport coords (portalled)
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const rect = sel.getRangeAt(0).getBoundingClientRect();
setMenuPosition({
bottom: window.innerHeight - rect.top + 4,
left: rect.left,
});
}
setShowMenu(true);
} else {
setShowMenu(false);
}
emitChange();
}, [emitChange]);
// ── Handle paste — convert [tag] patterns to badges ───────────
const handlePaste = useCallback(
(e: React.ClipboardEvent) => {
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
if (!text) return;
const el = editorRef.current;
if (!el) return;
const html = textToHTML(text);
// Insert at caret
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
range.deleteContents();
const temp = document.createElement('div');
temp.innerHTML = html;
const frag = document.createDocumentFragment();
let lastNode: Node | null = null;
while (temp.firstChild) {
lastNode = frag.appendChild(temp.firstChild);
}
range.insertNode(frag);
if (lastNode) {
const newRange = document.createRange();
newRange.setStartAfter(lastNode);
newRange.collapse(true);
sel.removeAllRanges();
sel.addRange(newRange);
}
}
emitChange();
},
[emitChange],
);
// ── Show placeholder ──────────────────────────────────────────
const isEmpty = !value || value.trim() === '';
return (
<div className="relative">
{/* Placeholder */}
{isEmpty && placeholder && (
<div
className="pointer-events-none absolute inset-0 text-sm text-muted-foreground/60 px-3 py-2 select-none"
aria-hidden
>
{placeholder}
</div>
)}
{/* Editable area */}
<div
ref={editorRef}
contentEditable={!disabled}
suppressContentEditableWarning
role={disabled ? undefined : 'textbox'}
aria-multiline={disabled ? undefined : true}
aria-placeholder={placeholder}
aria-disabled={disabled}
tabIndex={disabled ? -1 : 0}
className={cn(
'min-h-[32px] text-sm whitespace-pre-wrap break-words outline-none',
'[&_.ptag-badge]:inline-flex [&_.ptag-badge]:items-center [&_.ptag-badge]:rounded-full',
'[&_.ptag-badge]:bg-accent/20 [&_.ptag-badge]:text-accent [&_.ptag-badge]:border [&_.ptag-badge]:border-accent/30',
'[&_.ptag-badge]:px-2 [&_.ptag-badge]:py-0 [&_.ptag-badge]:text-xs [&_.ptag-badge]:font-medium',
'[&_.ptag-badge]:mx-0.5 [&_.ptag-badge]:select-none [&_.ptag-badge]:cursor-default',
'[&_.ptag-badge]:align-baseline',
disabled && 'opacity-50 cursor-not-allowed',
className,
)}
style={style}
onInput={!disabled ? handleInput : undefined}
onKeyDown={!disabled ? handleKeyDown : undefined}
onPaste={!disabled ? handlePaste : undefined}
onClick={!disabled ? onClick : undefined}
onFocus={!disabled ? onFocus : undefined}
onBlur={() => {
setShowMenu(false);
triggerRangeRef.current = null;
}}
onCompositionStart={() => {
isComposingRef.current = true;
}}
onCompositionEnd={() => {
isComposingRef.current = false;
handleInput();
}}
/>
{/* Autocomplete dropdown — portalled to body, positioned above the caret */}
{showMenu &&
filteredTags.length > 0 &&
createPortal(
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.12 }}
className="fixed z-[9999] min-w-[200px] max-h-[280px] overflow-y-auto rounded-lg border border-border bg-popover shadow-lg"
style={{
bottom: menuPosition.bottom,
left: menuPosition.left,
}}
>
{filteredTags.map((t, i) => (
<button
key={t.tag}
type="button"
className={cn(
'flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left transition-colors',
i === menuIndex
? 'bg-accent/20 text-accent-foreground'
: 'text-popover-foreground hover:bg-muted/50',
)}
onMouseDown={(e) => {
e.preventDefault(); // Keep focus in editor
insertTag(t.tag);
}}
onMouseEnter={() => setMenuIndex(i)}
>
<span className="text-base leading-none">{t.emoji}</span>
<span>{t.label}</span>
<span className="ml-auto text-xs text-muted-foreground font-mono">{t.tag}</span>
</button>
))}
</motion.div>
</AnimatePresence>,
document.body,
)}
</div>
);
},
);
-1
View File
@@ -1 +0,0 @@
# Generation history components
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,108 @@
import { invoke } from '@tauri-apps/api/core';
import { AlertTriangle, ExternalLink } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { usePlatform } from '@/platform/PlatformContext';
/**
* Tracks macOS Input Monitoring permission state. Without it, `rdev::listen`
* sees no key events and the chord engine never fires — but neither does
* anything error-out visibly, so we surface an inline prompt next to the
* hotkey toggle instead of leaving the user wondering why the shortcut is
* dead.
*
* Re-checked on mount and on window focus (cheap way to pick up the user
* flipping the toggle in System Settings and alt-tabbing back).
*/
export function useInputMonitoringPermission() {
const platform = usePlatform();
const [needsPermission, setNeedsPermission] = useState(false);
const [checking, setChecking] = useState(false);
const recheck = useCallback(async (): Promise<boolean> => {
if (!platform.metadata.isTauri) return true;
setChecking(true);
try {
const trusted = await invoke<boolean>('check_input_monitoring_permission');
setNeedsPermission(!trusted);
return trusted;
} catch (err) {
console.warn('[input-monitoring] check failed:', err);
return false;
} finally {
setChecking(false);
}
}, [platform.metadata.isTauri]);
useEffect(() => {
if (!platform.metadata.isTauri) return;
recheck();
const onFocus = () => {
recheck();
};
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);
}, [platform.metadata.isTauri, recheck]);
const openSettings = useCallback(async () => {
try {
await invoke('open_input_monitoring_settings');
} catch (err) {
console.warn('[input-monitoring] open settings failed:', err);
}
}, []);
return { needsPermission, checking, recheck, openSettings };
}
/**
* Inline notice rendered under the global-shortcut toggle when the user has
* opted in but macOS Input Monitoring is not granted. Returns null when the
* permission is present (or when the toggle is off and the notice would just
* be noise).
*/
export function InputMonitoringNotice({ enabled }: { enabled: boolean }) {
const { t } = useTranslation();
const { needsPermission, checking, recheck, openSettings } =
useInputMonitoringPermission();
const [stillMissing, setStillMissing] = useState(false);
const handleRecheck = useCallback(async () => {
setStillMissing(false);
const trusted = await recheck();
if (!trusted) setStillMissing(true);
}, [recheck]);
if (!enabled || !needsPermission) return null;
return (
<div className="mt-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3.5 py-3">
<div className="flex items-start gap-3">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-amber-500" />
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm font-medium text-foreground">
{t('captures.permissions.inputMonitoring.title')}
</p>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans i18nKey="captures.permissions.inputMonitoring.body" components={{ path: <span /> }} />
</p>
<div className="flex items-center gap-2 pt-1.5">
<Button size="sm" onClick={openSettings} className="gap-1.5">
<ExternalLink className="h-3.5 w-3.5" />
{t('captures.permissions.inputMonitoring.openSettings')}
</Button>
<Button variant="outline" size="sm" onClick={handleRecheck} disabled={checking}>
{checking ? t('captures.permissions.inputMonitoring.rechecking') : t('captures.permissions.inputMonitoring.recheck')}
</Button>
</div>
{stillMissing && !checking && (
<p className="text-xs text-amber-600 dark:text-amber-400 pt-1">
{t('captures.permissions.inputMonitoring.stillMissing')}
</p>
)}
</div>
</div>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import type { CSSProperties, ReactNode } from 'react';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils/cn';
interface ListPaneProps {
className?: string;
children: ReactNode;
}
export function ListPane({ className, children }: ListPaneProps) {
return (
<div className={cn('h-full flex flex-col relative overflow-hidden', className)}>
<div
className="absolute top-0 right-0 bottom-0 w-px bg-border pointer-events-none z-30"
style={{
maskImage: 'linear-gradient(to bottom, transparent 0, black 50px)',
WebkitMaskImage: 'linear-gradient(to bottom, transparent 0, black 50px)',
}}
/>
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
{children}
</div>
);
}
interface ListPaneHeaderProps {
className?: string;
children: ReactNode;
}
export function ListPaneHeader({ className, children }: ListPaneHeaderProps) {
return (
<div className={cn('absolute top-0 left-0 right-0 z-20 px-4', className)}>{children}</div>
);
}
interface ListPaneTitleRowProps {
className?: string;
children: ReactNode;
}
export function ListPaneTitleRow({ className, children }: ListPaneTitleRowProps) {
return <div className={cn('flex items-center mb-2', className)}>{children}</div>;
}
interface ListPaneTitleProps {
className?: string;
children: ReactNode;
}
export function ListPaneTitle({ className, children }: ListPaneTitleProps) {
return <h2 className={cn('text-2xl px-4 font-bold truncate', className)}>{children}</h2>;
}
interface ListPaneActionsProps {
className?: string;
children: ReactNode;
}
export function ListPaneActions({ className, children }: ListPaneActionsProps) {
return <div className={cn('ml-auto flex items-center gap-2', className)}>{children}</div>;
}
interface ListPaneSearchProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}
export function ListPaneSearch({ value, onChange, placeholder, className }: ListPaneSearchProps) {
return (
<div className={cn('relative', className)}>
<Input
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
);
}
interface ListPaneScrollProps {
className?: string;
style?: CSSProperties;
children: ReactNode;
}
export function ListPaneScroll({ className, style, children }: ListPaneScrollProps) {
return (
<div
className={cn('flex-1 overflow-y-auto overflow-x-hidden pt-24', className)}
style={style}
>
{children}
</div>
);
}
@@ -0,0 +1,158 @@
import { Sparkles, Upload } from 'lucide-react';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
import { HistoryTable } from '@/components/History/HistoryTable';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useToast } from '@/components/ui/use-toast';
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
import { useImportProfile } from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore';
import { useUIStore } from '@/stores/uiStore';
export function MainEditor() {
const { t } = useTranslation();
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
const scrollRef = useRef<HTMLDivElement>(null);
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
const importProfile = useImportProfile();
const fileInputRef = useRef<HTMLInputElement>(null);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const { toast } = useToast();
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (!file.name.endsWith('.voicebox.zip')) {
toast({
title: t('main.import.invalidTitle'),
description: t('main.import.invalidDescription'),
variant: 'destructive',
});
return;
}
setSelectedFile(file);
setImportDialogOpen(true);
}
};
const handleImportConfirm = () => {
if (selectedFile) {
importProfile.mutate(selectedFile, {
onSuccess: () => {
setImportDialogOpen(false);
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
toast({
title: t('main.import.successTitle'),
description: t('main.import.successDescription'),
});
},
onError: (error) => {
toast({
title: t('main.import.failedTitle'),
description: error.message,
variant: 'destructive',
});
},
});
}
};
return (
<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-6 h-full min-h-0 overflow-hidden relative">
<div className="flex flex-col min-h-0 overflow-hidden relative lg:overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" />
<div className="absolute top-0 left-0 right-0 z-10">
<div className="flex items-center justify-between mb-4 px-1">
<h2 className="text-2xl font-bold">Voicebox</h2>
<div className="flex gap-2">
<Button variant="outline" onClick={handleImportClick}>
<Upload className="mr-2 h-4 w-4" />
{t('main.importVoice')}
</Button>
<input
ref={fileInputRef}
type="file"
accept=".voicebox.zip"
onChange={handleFileChange}
className="hidden"
/>
<Button onClick={() => setDialogOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
{t('main.createVoice')}
</Button>
</div>
</div>
</div>
<div
ref={scrollRef}
className={cn('flex-1 min-h-0 overflow-y-auto pt-14 pb-4', isPlayerVisible && 'lg:pb-32')}
>
<div className="flex flex-col gap-6">
<div className="shrink-0 flex flex-col">
<ProfileList />
</div>
</div>
</div>
</div>
<div className="flex flex-col min-h-0 overflow-hidden">
<HistoryTable />
</div>
<FloatingGenerateBox isPlayerOpen={!!audioUrl} />
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('main.import.dialogTitle')}</DialogTitle>
<DialogDescription>
{t('main.import.dialogDescription', { name: selectedFile?.name })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setImportDialogOpen(false);
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}}
>
{t('common.cancel')}
</Button>
<Button
onClick={handleImportConfirm}
disabled={importProfile.isPending || !selectedFile}
>
{importProfile.isPending ? t('main.import.importing') : t('main.import.action')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,9 @@
import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
export function ModelsTab() {
return (
<div className="h-full flex flex-col">
<ModelManagement />
</div>
);
}
@@ -1 +0,0 @@
# Server settings and connection components
@@ -1,9 +1,12 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Loader2, XCircle } from 'lucide-react';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
Form,
FormControl,
@@ -14,8 +17,9 @@ import {
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { useToast } from '@/components/ui/use-toast';
import { useServerHealth } from '@/lib/hooks/useServer';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
const connectionSchema = z.object({
@@ -25,11 +29,15 @@ const connectionSchema = z.object({
type ConnectionFormValues = z.infer<typeof connectionSchema>;
export function ConnectionForm() {
const platform = usePlatform();
const serverUrl = useServerStore((state) => state.serverUrl);
const setServerUrl = useServerStore((state) => state.setServerUrl);
const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose);
const setKeepServerRunningOnClose = useServerStore((state) => state.setKeepServerRunningOnClose);
const mode = useServerStore((state) => state.mode);
const setMode = useServerStore((state) => state.setMode);
const { toast } = useToast();
const { data: health, isLoading, error: healthError } = useServerHealth();
const form = useForm<ConnectionFormValues>({
resolver: zodResolver(connectionSchema),
@@ -47,7 +55,7 @@ export function ConnectionForm() {
function onSubmit(data: ConnectionFormValues) {
setServerUrl(data.serverUrl);
form.reset(data); // Reset form state after successful submission
form.reset(data);
toast({
title: 'Server URL updated',
description: `Connected to ${data.serverUrl}`,
@@ -55,7 +63,7 @@ export function ConnectionForm() {
}
return (
<Card>
<Card role="region" aria-label="Server Connection" tabIndex={0}>
<CardHeader>
<CardTitle>Server Connection</CardTitle>
</CardHeader>
@@ -69,7 +77,7 @@ export function ConnectionForm() {
<FormItem>
<FormLabel>Server URL</FormLabel>
<FormControl>
<Input placeholder="http://localhost:8000" {...field} />
<Input placeholder="http://127.0.0.1:17493" {...field} />
</FormControl>
<FormDescription>Enter the URL of your voicebox backend server</FormDescription>
<FormMessage />
@@ -81,13 +89,48 @@ export function ConnectionForm() {
</form>
</Form>
{/* Connection status */}
<div className="mt-4">
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm text-muted-foreground">Checking connection...</span>
</div>
) : healthError ? (
<div className="flex items-center gap-2">
<XCircle className="h-4 w-4 text-destructive" />
<span className="text-sm text-destructive">
Connection failed: {healthError.message}
</span>
</div>
) : health ? (
<div className="flex flex-wrap gap-2">
<Badge
variant={health.model_loaded || health.model_downloaded ? 'default' : 'secondary'}
>
{health.model_loaded || health.model_downloaded ? 'Model Ready' : 'No Model'}
</Badge>
<Badge variant={health.gpu_available ? 'default' : 'secondary'}>
GPU: {health.gpu_available ? 'Available' : 'Not Available'}
</Badge>
{health.vram_used_mb != null && health.vram_used_mb > 0 && (
<Badge variant="outline">VRAM: {health.vram_used_mb.toFixed(0)} MB</Badge>
)}
</div>
) : null}
</div>
<div className="mt-6 pt-6 border-t">
<div className="flex items-start space-x-3">
<Checkbox
id="keepServerRunning"
className="mt-[6px]"
checked={keepServerRunningOnClose}
onCheckedChange={(checked: boolean) => {
setKeepServerRunningOnClose(checked);
platform.lifecycle.setKeepServerRunning(checked).catch((error) => {
console.error('Failed to sync setting to Rust:', error);
});
toast({
title: 'Setting updated',
description: checked
@@ -110,6 +153,39 @@ export function ConnectionForm() {
</div>
</div>
</div>
{platform.metadata.isTauri && (
<div className="mt-6 pt-6 border-t">
<div className="flex items-start space-x-3">
<Checkbox
id="allowNetworkAccess"
className="mt-[6px]"
checked={mode === 'remote'}
onCheckedChange={(checked: boolean) => {
setMode(checked ? 'remote' : 'local');
toast({
title: 'Setting updated',
description: checked
? 'Network access enabled. Restart the app to apply.'
: 'Network access disabled. Restart the app to apply.',
});
}}
/>
<div className="space-y-1">
<label
htmlFor="allowNetworkAccess"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Allow network access
</label>
<p className="text-sm text-muted-foreground">
Makes the server accessible from other devices on your network. Restart the app
after changing this setting.
</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
);
@@ -0,0 +1,383 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertCircle, Download, Loader2, RotateCw, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { apiClient } from '@/lib/api/client';
import type { CudaDownloadProgress } from '@/lib/api/types';
import { useServerHealth } from '@/lib/hooks/useServer';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
type RestartPhase = 'idle' | 'stopping' | 'waiting' | 'ready';
export function GpuAcceleration() {
const platform = usePlatform();
const queryClient = useQueryClient();
const serverUrl = useServerStore((state) => state.serverUrl);
const { data: health } = useServerHealth();
const [restartPhase, setRestartPhase] = useState<RestartPhase>('idle');
const [error, setError] = useState<string | null>(null);
const [downloadProgress, setDownloadProgress] = useState<CudaDownloadProgress | null>(null);
const healthPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Query CUDA backend status
const {
data: cudaStatus,
isLoading: _cudaStatusLoading,
refetch: refetchCudaStatus,
} = useQuery({
queryKey: ['cuda-status', serverUrl],
queryFn: () => apiClient.getCudaStatus(),
refetchInterval: (query) => (query.state.status === 'pending' ? false : 10000),
retry: 1,
enabled: !!health, // Only fetch when backend is reachable
});
// Derived state
const isCurrentlyCuda = health?.backend_variant === 'cuda';
const cudaAvailable = cudaStatus?.available ?? false;
const cudaDownloading = cudaStatus?.downloading ?? false;
// Clean up health poll on unmount
useEffect(() => {
return () => {
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
}
};
}, []);
// SSE progress tracking during download
useEffect(() => {
if (!cudaDownloading || !serverUrl) {
return;
}
const eventSource = new EventSource(`${serverUrl}/backend/cuda-progress`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as CudaDownloadProgress;
setDownloadProgress(data);
if (data.status === 'complete') {
eventSource.close();
setDownloadProgress(null);
refetchCudaStatus();
} else if (data.status === 'error') {
eventSource.close();
setError(data.error || 'Download failed');
setDownloadProgress(null);
refetchCudaStatus();
}
} catch (e) {
console.error('Error parsing CUDA progress event:', e);
}
};
eventSource.onerror = () => {
eventSource.close();
};
return () => {
eventSource.close();
};
}, [cudaDownloading, serverUrl, refetchCudaStatus]);
// Start aggressive health polling during restart
const startHealthPolling = useCallback(() => {
if (healthPollRef.current) return;
healthPollRef.current = setInterval(async () => {
try {
const result = await apiClient.getHealth();
if (result.status === 'healthy') {
// Server is back up
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
}
setRestartPhase('ready');
// Invalidate all queries to refresh UI
queryClient.invalidateQueries();
// Reset after a moment
setTimeout(() => setRestartPhase('idle'), 2000);
}
} catch {
// Server still down, keep polling
}
}, 1000);
}, [queryClient]);
const handleDownload = async () => {
setError(null);
try {
await apiClient.downloadCudaBackend();
refetchCudaStatus();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Failed to start download';
if (msg.includes('already downloaded')) {
refetchCudaStatus();
} else {
setError(msg);
}
}
};
const handleRestart = async () => {
setError(null);
setRestartPhase('stopping');
try {
setRestartPhase('waiting');
startHealthPolling();
await platform.lifecycle.restartServer();
// Invoke resolved — server is likely ready. Stop polling and refresh.
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
}
setRestartPhase('ready');
queryClient.invalidateQueries();
setTimeout(() => setRestartPhase('idle'), 2000);
} catch (e: unknown) {
setRestartPhase('idle');
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
}
setError(e instanceof Error ? e.message : 'Restart failed');
}
};
const handleSwitchToCpu = async () => {
// To switch to CPU: delete the CUDA binary, then restart.
// start_server always prefers CUDA if present, so we must remove it first.
setError(null);
setRestartPhase('stopping');
try {
await apiClient.deleteCudaBackend();
setRestartPhase('waiting');
startHealthPolling();
await platform.lifecycle.restartServer();
// Invoke resolved — server is likely ready
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
}
setRestartPhase('ready');
queryClient.invalidateQueries();
setTimeout(() => setRestartPhase('idle'), 2000);
} catch (e: unknown) {
setRestartPhase('idle');
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
}
setError(e instanceof Error ? e.message : 'Failed to switch to CPU');
refetchCudaStatus();
}
};
const handleDelete = async () => {
setError(null);
try {
await apiClient.deleteCudaBackend();
refetchCudaStatus();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to delete CUDA backend');
}
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
};
// Don't render until health data is available
if (!health) return null;
// If the system already has native GPU (MPS, etc.), only show info - no CUDA needed
const hasNativeGpu =
health.gpu_available &&
!isCurrentlyCuda &&
health.gpu_type &&
!health.gpu_type.includes('CUDA');
return (
<Card>
<CardHeader>
<CardTitle>GPU Acceleration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* GPU status */}
<div className="space-y-1">
{health.gpu_available && health.gpu_type ? (
<>
<div className="text-sm font-medium">
{health.gpu_type.replace(/^(CUDA|ROCm|MPS|Metal|XPU|DirectML)\s*\((.+)\)$/, '$2') ||
health.gpu_type}
</div>
<div className="text-sm text-muted-foreground">
{health.gpu_type.replace(/\s*\(.+\)$/, '')}
{health.vram_used_mb != null && health.vram_used_mb > 0
? ` \u00b7 ${health.vram_used_mb.toFixed(0)} MB VRAM used`
: ''}
</div>
</>
) : (
<>
<div className="text-sm font-medium">CPU</div>
<div className="text-sm text-muted-foreground">No GPU acceleration available</div>
</>
)}
</div>
{/* Native GPU detected - no CUDA download needed */}
{/* Currently running CUDA - show switch back to CPU */}
{isCurrentlyCuda && platform.metadata.isTauri && (
<>
{restartPhase !== 'idle' ? (
<div className="flex items-center gap-2 p-3 rounded-lg bg-primary/5 border">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">
{restartPhase === 'stopping' && 'Stopping server...'}
{restartPhase === 'waiting' && 'Restarting server...'}
{restartPhase === 'ready' && 'Server restarted successfully!'}
</span>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Running with CUDA GPU acceleration. Switch back to CPU if needed (you can
re-download later).
</p>
<Button onClick={handleSwitchToCpu} variant="outline" className="w-full" size="sm">
<RotateCw className="h-4 w-4 mr-2" />
Switch to CPU Backend
</Button>
</div>
)}
{error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
</>
)}
{/* CUDA download/manage section - show when no native GPU and not currently running CUDA */}
{!hasNativeGpu && !isCurrentlyCuda && (
<>
{/* Download progress (manual download or auto-update) */}
{cudaDownloading && downloadProgress && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>
{downloadProgress.filename ||
(cudaAvailable
? 'Updating CUDA backend...'
: 'Downloading CUDA backend...')}
</span>
</div>
{downloadProgress.total > 0 && (
<span className="text-muted-foreground">
{downloadProgress.progress.toFixed(1)}%
</span>
)}
</div>
{downloadProgress.total > 0 && (
<>
<Progress value={downloadProgress.progress} className="h-2" />
<div className="text-xs text-muted-foreground">
{formatBytes(downloadProgress.current)} /{' '}
{formatBytes(downloadProgress.total)}
</div>
</>
)}
</div>
)}
{/* Restart in progress */}
{restartPhase !== 'idle' && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-primary/5 border">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">
{restartPhase === 'stopping' && 'Stopping server...'}
{restartPhase === 'waiting' && 'Restarting server...'}
{restartPhase === 'ready' && 'Server restarted successfully!'}
</span>
</div>
)}
{/* Error display */}
{error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
{/* Actions */}
{restartPhase === 'idle' && !cudaDownloading && (
<div className="space-y-2">
{/* Not downloaded yet - show download button */}
{!cudaAvailable && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Download the CUDA backend (~2.4 GB) for NVIDIA GPU acceleration. Requires an
NVIDIA GPU with CUDA support.
</p>
<Button onClick={handleDownload} className="w-full" size="sm">
<Download className="h-4 w-4 mr-2" />
Download CUDA Backend
</Button>
</div>
)}
{/* Downloaded but not active - show switch button */}
{cudaAvailable && platform.metadata.isTauri && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
CUDA backend is downloaded and ready. Restart the server to enable GPU
acceleration.
</p>
<Button onClick={handleRestart} className="w-full" size="sm">
<RotateCw className="h-4 w-4 mr-2" />
Switch to CUDA Backend
</Button>
</div>
)}
{/* Delete option when downloaded (and not active) */}
{cudaAvailable && (
<Button
onClick={handleDelete}
variant="ghost"
className="w-full text-muted-foreground "
size="sm"
>
<Trash2 className="h-4 w-4 mr-2" />
Remove CUDA Backend
</Button>
)}
</div>
)}
</>
)}
</CardContent>
</Card>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,22 +1,34 @@
import { Loader2, XCircle } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useServerStore } from '@/stores/serverStore';
import { Progress } from '@/components/ui/progress';
import type { ModelProgress as ModelProgressType } from '@/lib/api/types';
import { Loader2, CheckCircle2, XCircle } from 'lucide-react';
import { useServerStore } from '@/stores/serverStore';
interface ModelProgressProps {
modelName: string;
displayName: string;
/** Only connect to SSE when actively downloading - prevents connection exhaustion */
isDownloading?: boolean;
}
export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
export function ModelProgress({
modelName,
displayName,
isDownloading = false,
}: ModelProgressProps) {
const [progress, setProgress] = useState<ModelProgressType | null>(null);
const [isSubscribed, setIsSubscribed] = useState(false);
const serverUrl = useServerStore((state) => state.serverUrl);
useEffect(() => {
if (!serverUrl || isSubscribed) return;
// IMPORTANT: Only connect to SSE when this specific model is downloading
// Opening SSE connections for all models exhausts HTTP/1.1 connection limits (6 per origin)
// which causes other fetches (like the download trigger) to be queued/blocked
if (!serverUrl || !isDownloading) {
return;
}
console.log(`[ModelProgress] Connecting SSE for ${modelName}`);
// Subscribe to progress updates via Server-Sent Events
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
@@ -28,8 +40,8 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
// Close connection if complete or error
if (data.status === 'complete' || data.status === 'error') {
console.log(`[ModelProgress] Download ${data.status} for ${modelName}, closing SSE`);
eventSource.close();
setIsSubscribed(false);
}
} catch (error) {
console.error('Error parsing progress event:', error);
@@ -37,18 +49,15 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
};
eventSource.onerror = (error) => {
console.error('SSE error:', error);
console.error(`[ModelProgress] SSE error for ${modelName}:`, error);
eventSource.close();
setIsSubscribed(false);
};
setIsSubscribed(true);
return () => {
console.log(`[ModelProgress] Cleanup - closing SSE for ${modelName}`);
eventSource.close();
setIsSubscribed(false);
};
}, [serverUrl, modelName, isSubscribed]);
}, [serverUrl, modelName, isDownloading]);
// Don't render if no progress or if complete/error and some time has passed
if (
@@ -63,13 +72,11 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
};
const getStatusIcon = () => {
switch (progress.status) {
case 'complete':
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case 'error':
return <XCircle className="h-4 w-4 text-destructive" />;
case 'downloading':
@@ -1,16 +1,15 @@
import { CheckCircle2, Loader2, XCircle } from 'lucide-react';
import { Loader2, XCircle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useServerHealth } from '@/lib/hooks/useServer';
import { useServerStore } from '@/stores/serverStore';
import { ModelProgress } from './ModelProgress';
export function ServerStatus() {
const { data: health, isLoading, error } = useServerHealth();
const serverUrl = useServerStore((state) => state.serverUrl);
return (
<Card>
<Card role="region" aria-label="Server Status" tabIndex={0}>
<CardHeader>
<CardTitle>Server Status</CardTitle>
</CardHeader>
@@ -20,16 +19,6 @@ export function ServerStatus() {
<div className="font-mono text-sm">{serverUrl}</div>
</div>
{/* Model download progress */}
<div className="space-y-2">
<ModelProgress modelName="qwen-tts-1.7B" displayName="Qwen TTS 1.7B" />
<ModelProgress modelName="qwen-tts-0.6B" displayName="Qwen TTS 0.6B" />
<ModelProgress modelName="whisper-base" displayName="Whisper Base" />
<ModelProgress modelName="whisper-small" displayName="Whisper Small" />
<ModelProgress modelName="whisper-medium" displayName="Whisper Medium" />
<ModelProgress modelName="whisper-large" displayName="Whisper Large" />
</div>
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
@@ -43,7 +32,6 @@ export function ServerStatus() {
) : health ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span className="text-sm">Connected</span>
</div>
<div className="flex flex-wrap gap-2">
@@ -1,24 +1,27 @@
import { useState, useEffect } from 'react';
import { RefreshCw, Download, CheckCircle2, AlertCircle } from 'lucide-react';
import { AlertCircle, Download, RefreshCw } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
import { getVersion } from '@tauri-apps/api/app';
import { usePlatform } from '@/platform/PlatformContext';
export function UpdateStatus() {
const { status, checkForUpdates, downloadAndInstall } = useAutoUpdater(false);
const platform = usePlatform();
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
const [currentVersion, setCurrentVersion] = useState<string>('');
const isDev = !import.meta.env?.PROD;
useEffect(() => {
getVersion()
platform.metadata
.getVersion()
.then(setCurrentVersion)
.catch(() => setCurrentVersion('0.1.0'));
}, []);
.catch(() => setCurrentVersion('Unknown'));
}, [platform]);
return (
<Card>
<Card role="region" aria-label="App Updates" tabIndex={0}>
<CardHeader>
<CardTitle>App Updates</CardTitle>
</CardHeader>
@@ -26,74 +29,110 @@ export function UpdateStatus() {
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">Current Version</div>
<div className="text-sm text-muted-foreground">v{currentVersion}</div>
<div className="text-sm text-muted-foreground">
v{currentVersion}
{isDev ? ' (dev)' : ''}
</div>
</div>
<Button
onClick={checkForUpdates}
disabled={status.checking || status.downloading || status.installing}
variant="outline"
size="sm"
>
<RefreshCw className={`h-4 w-4 mr-2 ${status.checking ? 'animate-spin' : ''}`} />
Check for Updates
</Button>
{!isDev && (
<Button
onClick={checkForUpdates}
disabled={status.checking || status.downloading || status.readyToInstall}
variant="outline"
size="sm"
>
<RefreshCw className={`h-4 w-4 mr-2 ${status.checking ? 'animate-spin' : ''}`} />
Check for Updates
</Button>
)}
</div>
{status.checking && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<RefreshCw className="h-4 w-4 animate-spin" />
Checking for updates...
{isDev ? (
<div className="text-sm text-muted-foreground">
Auto-updates are disabled in development mode.
</div>
)}
{status.error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{status.error}
</div>
)}
{status.available && !status.downloading && !status.installing && (
<div className="space-y-3 p-4 border rounded-lg bg-primary/5">
<div className="flex items-center justify-between">
<div>
<div className="font-semibold">Update Available</div>
<div className="text-sm text-muted-foreground">Version {status.version}</div>
) : (
<>
{status.checking && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<RefreshCw className="h-4 w-4 animate-spin" />
Checking for updates...
</div>
<Badge>New</Badge>
</div>
<Button onClick={downloadAndInstall} className="w-full" size="sm">
<Download className="h-4 w-4 mr-2" />
Install Update
</Button>
</div>
)}
)}
{status.downloading && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Download className="h-4 w-4" />
Downloading update...
</div>
<Progress />
</div>
)}
{status.error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{status.error}
</div>
)}
{status.installing && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<RefreshCw className="h-4 w-4 animate-spin" />
Installing update...
</div>
<div className="text-xs text-muted-foreground">App will restart automatically</div>
</div>
)}
{status.available && !status.downloading && !status.readyToInstall && (
<div className="space-y-3 p-4 border rounded-lg bg-primary/5">
<div className="flex items-center justify-between">
<div>
<div className="font-semibold">Update Available</div>
<div className="text-sm text-muted-foreground">Version {status.version}</div>
</div>
<Badge>New</Badge>
</div>
<Button onClick={downloadAndInstall} className="w-full" size="sm">
<Download className="h-4 w-4 mr-2" />
Download Update
</Button>
</div>
)}
{!status.available && !status.checking && !status.error && status.checking === false && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-green-500" />
You're up to date
</div>
{status.downloading && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Download className="h-4 w-4" />
Downloading update...
</div>
{status.downloadProgress !== undefined && (
<span className="text-muted-foreground">{status.downloadProgress}%</span>
)}
</div>
<Progress value={status.downloadProgress} />
{status.downloadedBytes !== undefined &&
status.totalBytes !== undefined &&
status.totalBytes > 0 && (
<div className="text-xs text-muted-foreground">
{(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB /{' '}
{(status.totalBytes / 1024 / 1024).toFixed(1)} MB
</div>
)}
</div>
)}
{status.readyToInstall && (
<div className="space-y-3 p-4 border rounded-lg bg-accent/30 border-accent/50">
<div className="flex items-center gap-2">
<div>
<div className="font-semibold">Update Ready to Install</div>
<div className="text-sm text-muted-foreground">
Version {status.version} has been downloaded
</div>
</div>
</div>
<div className="text-sm text-muted-foreground">
The app needs to restart to complete the installation. You can do this now or
later at your convenience.
</div>
<Button onClick={restartAndInstall} className="w-full" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Restart Now
</Button>
</div>
)}
{!status.available && !status.checking && !status.error && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
You're up to date
</div>
)}
</>
)}
</CardContent>
</Card>
+172
View File
@@ -0,0 +1,172 @@
import { ArrowUpRight } from 'lucide-react';
import type { CSSProperties, ReactNode } from 'react';
import { useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import voiceboxLogo from '@/assets/voicebox-logo.png';
import { SPONSORS } from '@/lib/sponsors';
import { usePlatform } from '@/platform/PlatformContext';
function FadeIn({ delay = 0, children }: { delay?: number; children: ReactNode }) {
return (
<div
className="animate-[fadeInUp_0.5s_ease_both]"
style={{ animationDelay: `${delay}ms` } as CSSProperties}
>
{children}
</div>
);
}
export function AboutPage() {
const { t } = useTranslation();
const platform = usePlatform();
const [version, setVersion] = useState('');
useEffect(() => {
platform.metadata
.getVersion()
.then(setVersion)
.catch(() => setVersion(''));
}, [platform]);
return (
<>
<style>{`
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`}</style>
<div className="max-w-md mx-auto h-full flex items-center">
<div className="flex flex-col items-center text-center space-y-5">
<FadeIn delay={0}>
<img src={voiceboxLogo} alt="Voicebox" className="w-20 h-20 object-contain" />
</FadeIn>
<FadeIn delay={80}>
<div className="space-y-1.5">
<h1 className="text-lg font-semibold">Voicebox</h1>
<p className="text-xs text-muted-foreground/60 h-4">
{version ? `v${version}` : '\u00A0'}
</p>
</div>
</FadeIn>
<FadeIn delay={160}>
<p className="text-sm text-muted-foreground leading-relaxed max-w-sm">
{t('settings.about.tagline')}
</p>
</FadeIn>
<FadeIn delay={240}>
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<span>{t('settings.about.createdBy')}</span>
<a
href="https://github.com/jamiepine"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
Jamie Pine
</a>
</div>
</FadeIn>
<FadeIn delay={320}>
<div className="flex flex-wrap justify-center gap-3 pt-2">
<a
href="https://buymeacoffee.com/jamiepine"
target="_blank"
rel="noopener noreferrer"
className="group inline-flex items-center gap-2 rounded-lg border border-border/60 px-4 py-2 text-sm transition-colors hover:bg-muted/50"
>
<svg
className="h-4 w-4 text-[#FFDD00]"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="m20.216 6.415-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 0 0-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 0 0-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 0 1-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 0 1 3.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 0 1-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 0 1-4.743.295 37.059 37.059 0 0 1-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0 0 11.343.376.483.483 0 0 1 .535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 0 1 .39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 0 1-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 0 1-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 0 0-1.322-.238c-.826 0-1.491.284-2.26.613z" />
</svg>
{t('settings.about.buyCoffee')}
<ArrowUpRight className="h-3.5 w-3.5 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
</a>
<a
href="https://github.com/jamiepine/voicebox"
target="_blank"
rel="noopener noreferrer"
className="group inline-flex items-center gap-2 rounded-lg border border-border/60 px-4 py-2 text-sm transition-colors hover:bg-muted/50"
>
<svg
className="h-4 w-4 text-muted-foreground"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg>
GitHub
<ArrowUpRight className="h-3.5 w-3.5 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
</a>
</div>
</FadeIn>
{SPONSORS.length > 0 && (
<FadeIn delay={400}>
<div className="pt-4 flex flex-col items-center gap-3">
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-muted-foreground/60">
Sponsored by
</p>
<div className="flex flex-wrap items-center justify-center gap-3">
{SPONSORS.map((sponsor) => (
<a
key={sponsor.name}
href={sponsor.url}
target="_blank"
rel="noopener noreferrer"
aria-label={sponsor.name}
className="group flex h-12 min-w-[120px] items-center justify-center rounded-lg border border-border/60 bg-card/50 px-4 transition-colors hover:bg-muted/50"
>
<img
src={sponsor.logoSrc}
alt={sponsor.logoAlt ?? sponsor.name}
className={`h-5 w-auto max-w-[100px] object-contain opacity-80 transition-opacity group-hover:opacity-100 ${
sponsor.invertOnDark ? 'dark:brightness-0 dark:invert' : ''
}`}
/>
</a>
))}
</div>
</div>
</FadeIn>
)}
<FadeIn delay={480}>
<p className="text-xs text-muted-foreground/40 pt-4">
<Trans
i18nKey="settings.about.license"
components={{
link: (
// biome-ignore lint/a11y/useAnchorContent: Trans fills content at runtime
<a
href="https://github.com/jamiepine/voicebox/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
className="hover:text-muted-foreground/60 transition-colors"
/>
),
}}
/>
</p>
</FadeIn>
</div>
</div>
</>
);
}
@@ -0,0 +1,606 @@
import { Check, ChevronDown, FolderOpen, Info, Keyboard, Laptop, Lock, Volume2 } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AccessibilityNotice } from '@/components/AccessibilityGate/AccessibilityGate';
import { InputMonitoringNotice } from '@/components/InputMonitoringGate/InputMonitoringGate';
import { CapturePill, type PillState } from '@/components/CapturePill/CapturePill';
import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist';
import { ChordPicker } from '@/components/ChordPicker/ChordPicker';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Toggle } from '@/components/ui/toggle';
import { useToast } from '@/components/ui/use-toast';
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { useProfiles } from '@/lib/hooks/useProfiles';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
import { cn } from '@/lib/utils/cn';
import { defaultChordKeys, displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
import type { Qwen3ModelSize, VoiceProfileResponse, WhisperModelSize } from '@/lib/api/types';
import { SettingRow, SettingSection } from './SettingRow';
function ChordPreview({ keys }: { keys: string[] }) {
const { t } = useTranslation();
if (keys.length === 0) {
return <span className="text-xs text-muted-foreground italic">{t('captures.chord.notSet')}</span>;
}
return (
<div className="flex items-center gap-1">
{keys.map((k) => {
const side = modifierSideHint(k);
return (
<span
key={k}
className="relative inline-flex items-center justify-center h-6 min-w-[1.5rem] px-1.5 rounded-md border border-border bg-muted/60 font-mono text-[11px] font-medium shadow-sm text-foreground"
>
{displayLabelForKey(k)}
{side ? (
<span className="absolute -top-1 -right-1 h-3 min-w-[0.75rem] px-0.5 rounded-sm bg-accent text-[7px] font-bold leading-none flex items-center justify-center text-accent-foreground">
{side}
</span>
) : null}
</span>
);
})}
</div>
);
}
const isWindows =
typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows');
const PILL_SEQUENCE: PillState[] = ['recording', 'transcribing', 'refining', 'rest'];
const PILL_DURATIONS: Partial<Record<PillState, number>> = {
recording: 2600,
transcribing: 1500,
refining: 1500,
rest: 900,
};
function HotkeyPillPreview({ enabled }: { enabled: boolean }) {
const [state, setState] = useState<PillState>('recording');
const [tick, setTick] = useState(0);
// Cycle recording → transcribing → refining → rest → …
useEffect(() => {
const t = window.setTimeout(() => {
const next = PILL_SEQUENCE[(PILL_SEQUENCE.indexOf(state) + 1) % PILL_SEQUENCE.length];
setState(next);
}, PILL_DURATIONS[state] ?? 1000);
return () => window.clearTimeout(t);
}, [state]);
// Timer only advances while recording; holds its final value through
// transcribing and refining so users see the duration of the clip being
// processed.
useEffect(() => {
if (state !== 'recording') return;
setTick(0);
const iv = window.setInterval(() => setTick((n) => n + 1), 90);
return () => window.clearInterval(iv);
}, [state]);
const elapsedMs = tick * 90;
return (
<div
className={cn(
'relative rounded-xl border overflow-hidden transition-opacity',
'bg-muted/30',
'aspect-[6/1]',
enabled ? 'border-border' : 'border-border/50 opacity-50',
)}
style={{
backgroundImage: `
linear-gradient(to right, hsl(var(--foreground) / 0.06) 1px, transparent 1px),
linear-gradient(to bottom, hsl(var(--foreground) / 0.06) 1px, transparent 1px)
`,
backgroundSize: '22px 22px',
}}
>
<div className="absolute inset-0 flex items-center justify-center">
<CapturePill state={state} elapsedMs={elapsedMs} />
</div>
</div>
);
}
export function CapturesPage() {
const { t } = useTranslation();
const platform = usePlatform();
const serverUrl = useServerStore((state) => state.serverUrl);
const { settings, update } = useCaptureSettings();
const { data: profiles } = useProfiles();
const { toast } = useToast();
const readiness = useDictationReadiness();
const sttModel = settings?.stt_model ?? 'turbo';
const language = settings?.language ?? 'auto';
const autoRefine = settings?.auto_refine ?? true;
const llmModel = settings?.llm_model ?? '0.6B';
const smartCleanup = settings?.smart_cleanup ?? true;
const selfCorrection = settings?.self_correction ?? true;
const preserveTechnical = settings?.preserve_technical ?? true;
const allowAutoPaste = settings?.allow_auto_paste ?? true;
const defaultVoiceId = settings?.default_playback_voice_id ?? null;
const hotkeyEnabled = settings?.hotkey_enabled ?? false;
const pushToTalkKeys = settings?.chord_push_to_talk_keys ?? defaultChordKeys('push');
const toggleToTalkKeys = settings?.chord_toggle_to_talk_keys ?? defaultChordKeys('toggle');
const [chordEditor, setChordEditor] = useState<'push' | 'toggle' | null>(null);
const [opening, setOpening] = useState(false);
const [capturesPath, setCapturesPath] = useState<string | null>(null);
useEffect(() => {
fetch(`${serverUrl}/health/filesystem`)
.then((res) => res.json())
.then((data) => {
const dir = data.directories?.find((d: { path: string }) =>
d.path.includes('captures'),
);
if (dir?.path) setCapturesPath(dir.path);
})
.catch(() => {});
}, [serverUrl]);
const openCapturesFolder = useCallback(async () => {
if (!capturesPath) return;
setOpening(true);
try {
await platform.filesystem.openPath(capturesPath);
} catch (e) {
console.error('Failed to open captures folder:', e);
} finally {
setOpening(false);
}
}, [platform, capturesPath]);
const voices: VoiceProfileResponse[] = profiles ?? [];
const defaultVoice =
voices.find((v) => v.id === defaultVoiceId) ?? null;
return (
<div className="flex gap-8 items-start max-w-5xl">
<div className="flex-1 min-w-0 max-w-2xl space-y-10">
<SettingSection
title={t('settings.captures.dictation.title')}
description={t('settings.captures.dictation.description')}
>
<div>
<SettingRow
title={t('settings.captures.dictation.globalShortcut.title')}
description={t('settings.captures.dictation.globalShortcut.description')}
htmlFor="hotkeyEnabled"
action={
<Toggle
id="hotkeyEnabled"
checked={hotkeyEnabled}
onCheckedChange={(v) => {
update({ hotkey_enabled: v });
// Surface model-readiness blocks at the toggle. The
// InputMonitoringNotice below already covers TCC, but
// missing models would otherwise be invisible from this
// page — the user toggles on, presses the chord, and
// nothing happens because useChordSync gates on readiness.
if (!v) return;
const missingModels = readiness.missing.filter(
(g) => g === 'stt' || g === 'llm',
);
if (missingModels.length === 0) return;
const names = [
missingModels.includes('stt') ? readiness.stt?.display_name : null,
missingModels.includes('llm') ? readiness.llm?.display_name : null,
]
.filter(Boolean)
.join(' and ');
toast({
title: t('captures.toast.shortcutNotArmed'),
description: t('captures.toast.shortcutNotArmedDescription', {
names,
count: missingModels.length,
}),
});
}}
/>
}
/>
<InputMonitoringNotice enabled={hotkeyEnabled} />
</div>
<SettingRow
title={t('settings.captures.dictation.pushToTalk.title')}
description={t('settings.captures.dictation.pushToTalk.description')}
action={
<div className="flex items-center gap-2">
<ChordPreview keys={pushToTalkKeys} />
<Button
variant="outline"
size="sm"
disabled={!hotkeyEnabled}
onClick={() => setChordEditor('push')}
>
<Keyboard className="h-3.5 w-3.5 mr-1.5" />
{t('settings.captures.dictation.pushToTalk.change')}
</Button>
</div>
}
/>
<SettingRow
title={t('settings.captures.dictation.toggle.title')}
description={t('settings.captures.dictation.toggle.description')}
action={
<div className="flex items-center gap-2">
<ChordPreview keys={toggleToTalkKeys} />
<Button
variant="outline"
size="sm"
disabled={!hotkeyEnabled}
onClick={() => setChordEditor('toggle')}
>
<Keyboard className="h-3.5 w-3.5 mr-1.5" />
{t('settings.captures.dictation.toggle.change')}
</Button>
</div>
}
/>
<ChordPicker
open={chordEditor === 'push'}
title={t('settings.captures.dictation.chordPicker.pttTitle')}
description={t('settings.captures.dictation.chordPicker.pttDescription')}
initialKeys={pushToTalkKeys}
onCancel={() => setChordEditor(null)}
onSave={(keys) => {
update({ chord_push_to_talk_keys: keys });
setChordEditor(null);
}}
/>
<ChordPicker
open={chordEditor === 'toggle'}
title={t('settings.captures.dictation.chordPicker.toggleTitle')}
description={t('settings.captures.dictation.chordPicker.toggleDescription')}
initialKeys={toggleToTalkKeys}
onCancel={() => setChordEditor(null)}
onSave={(keys) => {
update({ chord_toggle_to_talk_keys: keys });
setChordEditor(null);
}}
/>
<SettingRow
title={t('settings.captures.dictation.preview.title')}
description={t('settings.captures.dictation.preview.description')}
>
<HotkeyPillPreview enabled={hotkeyEnabled} />
</SettingRow>
<div>
<SettingRow
title={t('settings.captures.dictation.autoPaste.title')}
description={t('settings.captures.dictation.autoPaste.description')}
htmlFor="autoPaste"
action={
<Toggle
id="autoPaste"
checked={allowAutoPaste}
onCheckedChange={(v) => update({ allow_auto_paste: v })}
disabled={!hotkeyEnabled}
/>
}
/>
<AccessibilityNotice />
</div>
</SettingSection>
<SettingSection
title={t('settings.captures.transcription.title')}
description={t('settings.captures.transcription.description')}
>
<SettingRow
title={t('settings.captures.transcription.model.title')}
description={t('settings.captures.transcription.model.description')}
action={
<Select
value={sttModel}
onValueChange={(v) => update({ stt_model: v as WhisperModelSize })}
>
<SelectTrigger className="w-[300px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="base">
{t('settings.captures.transcription.model.base', { tail: t('settings.captures.transcription.model.tail.fast') })}
</SelectItem>
<SelectItem value="small">
{t('settings.captures.transcription.model.small', { tail: t('settings.captures.transcription.model.tail.balanced') })}
</SelectItem>
<SelectItem value="medium">
{t('settings.captures.transcription.model.medium', { tail: t('settings.captures.transcription.model.tail.higher') })}
</SelectItem>
<SelectItem value="large">
{t('settings.captures.transcription.model.large', { tail: t('settings.captures.transcription.model.tail.best') })}
</SelectItem>
<SelectItem value="turbo">
{t('settings.captures.transcription.model.turbo', { tail: t('settings.captures.transcription.model.tail.nearBest') })}
</SelectItem>
</SelectContent>
</Select>
}
/>
<SettingRow
title={t('settings.captures.transcription.language.title')}
description={t('settings.captures.transcription.language.description')}
action={
<Select value={language} onValueChange={(v) => update({ language: v })}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t('settings.captures.transcription.language.auto')}</SelectItem>
<SelectItem value="en">{t('settings.captures.transcription.language.en')}</SelectItem>
<SelectItem value="es">{t('settings.captures.transcription.language.es')}</SelectItem>
<SelectItem value="fr">{t('settings.captures.transcription.language.fr')}</SelectItem>
<SelectItem value="de">{t('settings.captures.transcription.language.de')}</SelectItem>
<SelectItem value="ja">{t('settings.captures.transcription.language.ja')}</SelectItem>
<SelectItem value="zh">{t('settings.captures.transcription.language.zh')}</SelectItem>
<SelectItem value="hi">{t('settings.captures.transcription.language.hi')}</SelectItem>
</SelectContent>
</Select>
}
/>
</SettingSection>
<SettingSection
title={t('settings.captures.refinement.title')}
description={t('settings.captures.refinement.description')}
>
<SettingRow
title={t('settings.captures.refinement.auto.title')}
description={t('settings.captures.refinement.auto.description')}
htmlFor="autoRefine"
action={
<Toggle
id="autoRefine"
checked={autoRefine}
onCheckedChange={(v) => update({ auto_refine: v })}
/>
}
/>
<SettingRow
title={t('settings.captures.refinement.model.title')}
description={t('settings.captures.refinement.model.description')}
action={
<Select
value={llmModel}
onValueChange={(v) => update({ llm_model: v as Qwen3ModelSize })}
disabled={!autoRefine}
>
<SelectTrigger className="w-[260px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.6B">
{t('settings.captures.refinement.model.size06', { tail: t('settings.captures.refinement.model.tail.veryFast') })}
</SelectItem>
<SelectItem value="1.7B">
{t('settings.captures.refinement.model.size17', { tail: t('settings.captures.refinement.model.tail.fast') })}
</SelectItem>
<SelectItem value="4B">
{t('settings.captures.refinement.model.size40', { tail: t('settings.captures.refinement.model.tail.fullQuality') })}
</SelectItem>
</SelectContent>
</Select>
}
/>
<SettingRow
title={t('settings.captures.refinement.smartCleanup.title')}
description={t('settings.captures.refinement.smartCleanup.description')}
htmlFor="smartCleanup"
action={
<Toggle
id="smartCleanup"
checked={smartCleanup}
onCheckedChange={(v) => update({ smart_cleanup: v })}
disabled={!autoRefine}
/>
}
/>
<SettingRow
title={t('settings.captures.refinement.selfCorrection.title')}
description={t('settings.captures.refinement.selfCorrection.description')}
htmlFor="selfCorrection"
action={
<Toggle
id="selfCorrection"
checked={selfCorrection}
onCheckedChange={(v) => update({ self_correction: v })}
disabled={!autoRefine}
/>
}
/>
<SettingRow
title={t('settings.captures.refinement.preserveTechnical.title')}
description={t('settings.captures.refinement.preserveTechnical.description')}
htmlFor="preserveTechnical"
action={
<Toggle
id="preserveTechnical"
checked={preserveTechnical}
onCheckedChange={(v) => update({ preserve_technical: v })}
disabled={!autoRefine}
/>
}
/>
</SettingSection>
<SettingSection
title={t('settings.captures.playback.title')}
description={t('settings.captures.playback.description')}
>
<SettingRow
title={t('settings.captures.playback.defaultVoice.title')}
description={t('settings.captures.playback.defaultVoice.description')}
action={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 min-w-[220px] justify-between"
disabled={voices.length === 0}
>
<div className="flex items-center gap-2 min-w-0">
{defaultVoice ? (
<span className="truncate">{defaultVoice.name}</span>
) : (
<span className="truncate text-muted-foreground">
{voices.length === 0
? t('settings.captures.playback.defaultVoice.noClonedVoices')
: t('settings.captures.playback.defaultVoice.noneSelected')}
</span>
)}
</div>
<ChevronDown className="h-3.5 w-3.5 opacity-60 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
{t('settings.captures.playback.defaultVoice.clonedVoices')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{voices.map((v) => (
<DropdownMenuItem
key={v.id}
onClick={() => update({ default_playback_voice_id: v.id })}
className="gap-2.5 py-2"
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{v.name}</div>
{v.description ? (
<div className="text-[11px] text-muted-foreground truncate">
{v.description}
</div>
) : null}
</div>
{v.id === defaultVoiceId && <Check className="h-3.5 w-3.5 text-accent shrink-0" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
}
/>
</SettingSection>
<SettingSection
title={t('settings.captures.storage.title')}
description={t('settings.captures.storage.description')}
>
<SettingRow
title={t('settings.captures.storage.folder.title')}
description={capturesPath ?? t('settings.captures.storage.folder.description')}
action={
<Button
variant="outline"
size="sm"
onClick={openCapturesFolder}
disabled={opening || !capturesPath}
>
<FolderOpen className="h-3.5 w-3.5 mr-1.5" />
{t('settings.captures.storage.folder.open')}
</Button>
}
/>
</SettingSection>
</div>
<aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0">
<div className="space-y-2">
<h3 className="text-sm font-semibold">{t('settings.captures.sidebar.aboutTitle')}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
{t('settings.captures.sidebar.aboutBody')}
</p>
</div>
<div className="space-y-3">
<h3 className="text-sm font-semibold">{t('settings.captures.sidebar.differencesTitle')}</h3>
<ul className="space-y-3 text-sm text-muted-foreground">
<li className="flex gap-2.5">
<Lock className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">{t('settings.captures.sidebar.local.title')}</span>{' '}
{t('settings.captures.sidebar.local.body')}
</span>
</li>
<li className="flex gap-2.5">
<Volume2 className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">
{t('settings.captures.sidebar.playAs.title')}
</span>{' '}
{t('settings.captures.sidebar.playAs.body')}
</span>
</li>
<li className="flex gap-2.5">
<Laptop className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">
{t('settings.captures.sidebar.crossPlatform.title')}
</span>{' '}
{t('settings.captures.sidebar.crossPlatform.body')}
</span>
</li>
</ul>
{isWindows && (
<div className="rounded-lg border border-accent/20 bg-accent/5 px-3 py-2.5">
<div className="flex items-start gap-2.5">
<Info className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<div className="flex-1 min-w-0 space-y-0.5">
<p className="text-sm font-medium text-foreground">
{t('settings.captures.sidebar.windowsCaveat.title')}
</p>
<p className="text-sm text-muted-foreground leading-relaxed">
{t('settings.captures.sidebar.windowsCaveat.body')}
</p>
</div>
</div>
</div>
)}
</div>
{/* Same six-gate checklist the CapturesTab empty state uses.
Surfaces missing models / permissions persistently while
users configure this page, so a red gate can't hide behind
a green toggle. Hidden once every gate is green — no value
in real estate full of checkmarks. */}
{!readiness.allReady && (
<div className="space-y-2">
<h3 className="text-sm font-semibold">{t('captures.readiness.title')}</h3>
<DictationReadinessChecklist readiness={readiness} compact />
</div>
)}
</aside>
</div>
);
}
@@ -0,0 +1,224 @@
import changelogRaw from 'virtual:changelog';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { type ChangelogEntry, parseChangelog } from '@/lib/utils/parseChangelog';
function renderMarkdown(md: string): React.ReactNode[] {
const lines = md.split('\n');
const elements: React.ReactNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Skip empty lines
if (line.trim() === '') {
i++;
continue;
}
// Tables — collect all lines starting with |
if (line.trim().startsWith('|')) {
const tableLines: string[] = [];
while (i < lines.length && lines[i].trim().startsWith('|')) {
tableLines.push(lines[i]);
i++;
}
elements.push(renderTable(tableLines, elements.length));
continue;
}
// Headings
if (line.startsWith('#### ')) {
elements.push(
<h5 key={elements.length} className="text-sm font-medium mt-5 mb-1">
{inlineMarkdown(line.slice(5))}
</h5>,
);
i++;
continue;
}
if (line.startsWith('### ')) {
elements.push(
<h4 key={elements.length} className="text-sm font-medium mt-6 mb-2">
{inlineMarkdown(line.slice(4))}
</h4>,
);
i++;
continue;
}
// List items — collect consecutive
if (line.startsWith('- ')) {
const items: string[] = [];
while (i < lines.length && lines[i].startsWith('- ')) {
items.push(lines[i].slice(2));
i++;
}
elements.push(
<ul key={elements.length} className="space-y-1 my-2">
{items.map((item, idx) => (
<li key={idx} className="text-sm text-muted-foreground flex gap-2">
<span className="text-muted-foreground/50 select-none shrink-0">&bull;</span>
<span>{inlineMarkdown(item)}</span>
</li>
))}
</ul>,
);
continue;
}
// Paragraph
elements.push(
<p key={elements.length} className="text-sm text-muted-foreground my-2">
{inlineMarkdown(line)}
</p>,
);
i++;
}
return elements;
}
function renderTable(tableLines: string[], keyBase: number): React.ReactNode {
const parseRow = (line: string) =>
line
.split('|')
.slice(1, -1)
.map((c) => c.trim());
const headers = parseRow(tableLines[0]);
// Skip separator line (index 1)
const rows = tableLines.slice(2).map(parseRow);
return (
<div key={keyBase} className="overflow-x-auto my-3">
<table className="text-sm w-full">
<thead>
<tr className="border-b">
{headers.map((h, hIdx) => (
<th
key={hIdx}
className="text-left py-1.5 pr-4 text-muted-foreground font-medium text-xs"
>
{inlineMarkdown(h)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, rowIdx) => (
<tr key={rowIdx} className="border-b border-border/50">
{row.map((cell, cellIdx) => (
<td key={cellIdx} className="py-1.5 pr-4 text-muted-foreground">
{inlineMarkdown(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
function inlineMarkdown(text: string): React.ReactNode {
// Process inline markdown: bold, code, links
const parts: React.ReactNode[] = [];
// Regex matches: **bold**, `code`, [text](url)
const inlineRe = /\*\*(.+?)\*\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\)/g;
let lastIndex = 0;
let match: RegExpExecArray | null = inlineRe.exec(text);
while (match !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
if (match[1] !== undefined) {
// Bold
parts.push(
<strong key={parts.length} className="font-medium text-foreground">
{match[1]}
</strong>,
);
} else if (match[2] !== undefined) {
// Code
parts.push(
<code key={parts.length} className="px-1 py-0.5 rounded bg-muted text-xs font-mono">
{match[2]}
</code>,
);
} else if (match[3] !== undefined && match[4] !== undefined) {
// Link
parts.push(
<a
key={parts.length}
href={match[4]}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
{match[3]}
</a>,
);
}
lastIndex = match.index + match[0].length;
match = inlineRe.exec(text);
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length === 1 ? parts[0] : parts;
}
function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const content = useMemo(() => renderMarkdown(entry.body), [entry.body]);
const isLong = entry.body.split('\n').length > 12;
return (
<div className="border-b border-border/50 pb-6">
<div className="flex items-baseline gap-3 mb-3">
<h3 className="text-xl font-semibold tracking-tight">{entry.version}</h3>
{entry.date && <span className="text-xs text-muted-foreground">{entry.date}</span>}
{entry.version === 'Unreleased' && (
<Badge variant="outline">{t('settings.changelog.devBadge')}</Badge>
)}
</div>
<div className={isLong && !expanded ? 'max-h-48 overflow-hidden relative' : ''}>
{content}
{isLong && !expanded && (
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-background to-transparent" />
)}
</div>
{isLong && (
<button
onClick={() => setExpanded(!expanded)}
className="text-xs text-accent hover:underline mt-2"
>
{expanded ? t('settings.changelog.showLess') : t('settings.changelog.showMore')}
</button>
)}
</div>
);
}
export function ChangelogPage() {
const entries = useMemo(() => parseChangelog(changelogRaw), []);
return (
<div className="space-y-6 max-w-2xl">
{entries.map((entry) => (
<ChangelogEntryCard key={entry.version} entry={entry} />
))}
</div>
);
}
@@ -0,0 +1,429 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { AlertCircle, ArrowUpRight, Book, Download, Loader2, RefreshCw } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Progress } from '@/components/ui/progress';
import { Toggle } from '@/components/ui/toggle';
import { useToast } from '@/components/ui/use-toast';
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
import { useServerHealth } from '@/lib/hooks/useServer';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
import { LanguageSelect } from './LanguageSelect';
import { SettingRow, SettingSection } from './SettingRow';
import { ThemeSelect } from './ThemeSelect';
function makeConnectionSchema(invalidUrl: string) {
return z.object({
serverUrl: z.string().url(invalidUrl),
});
}
type ConnectionFormValues = { serverUrl: string };
export function GeneralPage() {
const { t } = useTranslation();
const platform = usePlatform();
const serverUrl = useServerStore((state) => state.serverUrl);
const setServerUrl = useServerStore((state) => state.setServerUrl);
const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose);
const setKeepServerRunningOnClose = useServerStore((state) => state.setKeepServerRunningOnClose);
const mode = useServerStore((state) => state.mode);
const setMode = useServerStore((state) => state.setMode);
const { toast } = useToast();
const { data: health, isLoading, error: healthError } = useServerHealth();
const resolver = useMemo(
() => zodResolver(makeConnectionSchema(t('settings.general.serverUrl.invalidUrl'))),
[t],
);
const form = useForm<ConnectionFormValues>({
resolver,
defaultValues: { serverUrl },
});
useEffect(() => {
form.reset({ serverUrl });
}, [serverUrl, form]);
// Re-run validation when the locale changes so existing error messages retranslate.
useEffect(() => {
if (form.formState.errors.serverUrl) {
form.trigger('serverUrl');
}
}, [t, form]);
const { isDirty } = form.formState;
function onSubmit(data: ConnectionFormValues) {
setServerUrl(data.serverUrl);
form.reset(data);
toast({
title: t('settings.general.serverUrl.updatedTitle'),
description: t('settings.general.serverUrl.updatedDescription', { url: data.serverUrl }),
});
}
return (
<div className="space-y-8 max-w-2xl">
<div className="grid grid-cols-2 gap-3">
<a
href="https://docs.voicebox.sh"
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-3 rounded-lg border border-border/60 p-4 transition-colors hover:bg-muted/50"
>
<Book className="h-5 w-5 shrink-0 text-accent" strokeWidth={2.5} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{t('settings.general.docs.title')}</div>
<div className="text-xs text-muted-foreground">docs.voicebox.sh</div>
</div>
<ArrowUpRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
</a>
<a
href="https://discord.gg/StkzQasqPS"
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-3 rounded-lg border border-border/60 p-4 transition-colors hover:bg-muted/50"
>
<svg
className="h-5 w-5 shrink-0 text-accent"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{t('settings.general.discord.title')}</div>
<div className="text-xs text-muted-foreground">
{t('settings.general.discord.subtitle')}
</div>
</div>
<ArrowUpRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
</a>
</div>
<SettingSection>
<SettingRow
title={t('settings.general.serverUrl.title')}
description={t('settings.general.serverUrl.description')}
action={
<ConnectionStatus health={health} isLoading={isLoading} healthError={healthError} />
}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex gap-2">
<FormField
control={form.control}
name="serverUrl"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input placeholder="http://127.0.0.1:17493" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isDirty && (
<Button type="submit" size="sm">
{t('common.save')}
</Button>
)}
</form>
</Form>
</SettingRow>
<SettingRow
title={t('settings.general.keepServerRunning.title')}
description={t('settings.general.keepServerRunning.description')}
htmlFor="keepServerRunning"
action={
<Toggle
id="keepServerRunning"
checked={keepServerRunningOnClose}
onCheckedChange={(checked: boolean) => {
setKeepServerRunningOnClose(checked);
platform.lifecycle.setKeepServerRunning(checked).catch((error) => {
console.error('Failed to sync setting to Rust:', error);
setKeepServerRunningOnClose(!checked);
toast({
title: t('settings.general.keepServerRunning.failedTitle'),
description: t('settings.general.keepServerRunning.failedDescription'),
variant: 'destructive',
});
return;
});
toast({
title: t('settings.general.keepServerRunning.updatedTitle'),
description: checked
? t('settings.general.keepServerRunning.runningDescription')
: t('settings.general.keepServerRunning.stoppedDescription'),
});
}}
/>
}
/>
{platform.metadata.isTauri && (
<SettingRow
title={t('settings.general.networkAccess.title')}
description={t('settings.general.networkAccess.description')}
htmlFor="allowNetworkAccess"
action={
<Toggle
id="allowNetworkAccess"
checked={mode === 'remote'}
onCheckedChange={(checked: boolean) => {
setMode(checked ? 'remote' : 'local');
toast({
title: t('settings.general.networkAccess.updatedTitle'),
description: checked
? t('settings.general.networkAccess.enabled')
: t('settings.general.networkAccess.disabled'),
});
}}
/>
}
/>
)}
<SettingRow
title={t('settings.language.label')}
description={t('settings.language.description')}
action={<LanguageSelect />}
/>
<SettingRow
title={t('settings.theme.label')}
description={t('settings.theme.description')}
action={<ThemeSelect />}
/>
</SettingSection>
<ApiReferenceCard serverUrl={serverUrl} />
{platform.metadata.isTauri && <UpdatesSection />}
</div>
);
}
function ConnectionStatus({
health,
isLoading,
healthError,
}: {
health: ReturnType<typeof useServerHealth>['data'];
isLoading: boolean;
healthError: ReturnType<typeof useServerHealth>['error'];
}) {
const { t } = useTranslation();
if (isLoading) {
return (
<div className="flex items-center gap-2 rounded-full border border-border/60 px-3 py-1">
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{t('settings.general.connection.connecting')}
</span>
</div>
);
}
if (healthError) {
return (
<div className="flex items-center gap-2 rounded-full border border-destructive/30 px-3 py-1">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full rounded-full bg-destructive/40" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-destructive" />
</span>
<span className="text-xs text-destructive">{t('settings.general.connection.offline')}</span>
</div>
);
}
if (health) {
return (
<div className="flex items-center gap-2 rounded-full border border-accent/30 px-3 py-1">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent/60" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-accent shadow-[0_0_6px_1px_hsl(var(--accent)/0.5)]" />
</span>
<span className="text-xs text-muted-foreground">
{t('settings.general.connection.online')}
</span>
</div>
);
}
return null;
}
function UpdatesSection() {
const { t } = useTranslation();
const platform = usePlatform();
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
const [currentVersion, setCurrentVersion] = useState<string | null>('');
const isDev = !import.meta.env?.PROD;
useEffect(() => {
platform.metadata
.getVersion()
.then(setCurrentVersion)
.catch(() => setCurrentVersion(null));
}, [platform]);
const versionLabel = currentVersion ?? t('common.unknown');
return (
<SettingSection
title={t('settings.general.updates.title')}
description={`v${versionLabel}${isDev ? t('settings.general.updates.devSuffix') : ''}`}
>
{isDev ? (
<SettingRow
title={t('settings.general.updates.devMode.title')}
description={t('settings.general.updates.devMode.description')}
/>
) : (
<>
<SettingRow
title={t('settings.general.updates.check.title')}
description={
status.available
? t('settings.general.updates.check.available', { version: status.version })
: status.checking
? t('settings.general.updates.check.checking')
: t('settings.general.updates.check.upToDate')
}
action={
<Button
onClick={checkForUpdates}
disabled={status.checking || status.downloading || status.readyToInstall}
variant="outline"
size="sm"
>
<RefreshCw
className={`h-3.5 w-3.5 mr-1.5 ${status.checking ? 'animate-spin' : ''}`}
/>
{t('settings.general.updates.check.button')}
</Button>
}
/>
{status.error && (
<SettingRow title={t('settings.general.updates.error')}>
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{status.error}
</div>
</SettingRow>
)}
{status.available && !status.downloading && !status.readyToInstall && (
<SettingRow
title={t('settings.general.updates.download.title', { version: status.version })}
description={t('settings.general.updates.download.description')}
action={
<Button onClick={downloadAndInstall} size="sm">
<Download className="h-3.5 w-3.5 mr-1.5" />
{t('settings.general.updates.download.button')}
</Button>
}
/>
)}
{status.downloading && (
<SettingRow title={t('settings.general.updates.downloading')}>
<div className="space-y-1.5">
<Progress value={status.downloadProgress} />
<div className="flex items-center justify-between text-xs text-muted-foreground">
{status.downloadedBytes !== undefined &&
status.totalBytes !== undefined &&
status.totalBytes > 0 ? (
<span>
{(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB /{' '}
{(status.totalBytes / 1024 / 1024).toFixed(1)} MB
</span>
) : (
<span />
)}
{status.downloadProgress !== undefined && <span>{status.downloadProgress}%</span>}
</div>
</div>
</SettingRow>
)}
{status.readyToInstall && (
<SettingRow
title={t('settings.general.updates.ready.title')}
description={t('settings.general.updates.ready.description', {
version: status.version,
})}
action={
<Button onClick={restartAndInstall} size="sm">
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
{t('settings.general.updates.ready.button')}
</Button>
}
/>
)}
</>
)}
</SettingSection>
);
}
function ApiReferenceCard({ serverUrl }: { serverUrl: string }) {
const { t } = useTranslation();
const endpoints = [
{ method: 'POST', path: '/generate', label: t('settings.general.api.endpoints.generate') },
{ method: 'GET', path: '/health', label: t('settings.general.api.endpoints.health') },
{ method: 'GET', path: '/profiles', label: t('settings.general.api.endpoints.profiles') },
{ method: 'GET', path: '/history', label: t('settings.general.api.endpoints.history') },
];
return (
<div className="rounded-lg border border-border/60 p-4 space-y-3">
<div>
<h3 className="text-sm font-medium">{t('settings.general.api.title')}</h3>
<p className="text-sm text-muted-foreground">
<Trans
i18nKey="settings.general.api.description"
values={{ url: serverUrl }}
components={{
code: <code className="text-xs bg-muted px-1 py-0.5 rounded font-mono" />,
}}
/>
</p>
</div>
<div className="space-y-1">
{endpoints.map((ep) => (
<div key={ep.path} className="flex items-center gap-2.5 py-1">
<span
className={`text-[10px] font-mono font-semibold w-9 text-center rounded px-1 py-px ${
ep.method === 'POST' ? 'bg-accent/10 text-accent' : 'bg-muted text-muted-foreground'
}`}
>
{ep.method}
</span>
<code className="text-xs font-mono text-muted-foreground">{ep.path}</code>
<span className="text-xs text-muted-foreground/50 ml-auto">{ep.label}</span>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
<a
href={`${serverUrl}/docs`}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
{t('settings.general.api.viewReference')}
</a>
</p>
</div>
);
}
@@ -0,0 +1,191 @@
import { FolderOpen, Languages, Mic, Zap } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Toggle } from '@/components/ui/toggle';
import { useGenerationSettings } from '@/lib/hooks/useSettings';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
import { SettingRow, SettingSection } from './SettingRow';
export function GenerationPage() {
const { t } = useTranslation();
const platform = usePlatform();
const serverUrl = useServerStore((state) => state.serverUrl);
const { settings, update } = useGenerationSettings();
const persistedMaxChunkChars = settings?.max_chunk_chars ?? 800;
const persistedCrossfadeMs = settings?.crossfade_ms ?? 50;
const normalizeAudio = settings?.normalize_audio ?? true;
const autoplayOnGenerate = settings?.autoplay_on_generate ?? true;
// Slider mirrors persist on commit (pointer-up / keyboard-release) only —
// onValueChange would fire a PATCH for every pointer-move pixel and round-
// trip mid-drag failures could leave persisted state out of sync with UI.
const [maxChunkChars, setMaxChunkChars] = useState(persistedMaxChunkChars);
const [crossfadeMs, setCrossfadeMs] = useState(persistedCrossfadeMs);
useEffect(() => setMaxChunkChars(persistedMaxChunkChars), [persistedMaxChunkChars]);
useEffect(() => setCrossfadeMs(persistedCrossfadeMs), [persistedCrossfadeMs]);
const [opening, setOpening] = useState(false);
const [generationsPath, setGenerationsPath] = useState<string | null>(null);
useEffect(() => {
fetch(`${serverUrl}/health/filesystem`)
.then((res) => res.json())
.then((data) => {
const genDir = data.directories?.find((d: { path: string }) =>
d.path.includes('generations'),
);
if (genDir?.path) setGenerationsPath(genDir.path);
})
.catch(() => {});
}, [serverUrl]);
const openGenerationsFolder = useCallback(async () => {
if (!generationsPath) return;
setOpening(true);
try {
await platform.filesystem.openPath(generationsPath);
} catch (e) {
console.error('Failed to open generations folder:', e);
} finally {
setOpening(false);
}
}, [platform, generationsPath]);
return (
<div className="flex gap-8 items-start max-w-5xl">
<div className="flex-1 min-w-0 max-w-2xl space-y-8">
<SettingSection
title={t('settings.generation.title')}
description={t('settings.generation.description')}
>
<SettingRow
title={t('settings.generation.chunkLimit.title')}
description={t('settings.generation.chunkLimit.description')}
action={
<span className="text-sm tabular-nums text-muted-foreground">
{t('settings.generation.chunkLimit.value', { chars: maxChunkChars })}
</span>
}
>
<Slider
id="maxChunkChars"
value={[maxChunkChars]}
onValueChange={([value]) => setMaxChunkChars(value)}
onValueCommit={([value]) => update({ max_chunk_chars: value })}
min={100}
max={5000}
step={50}
aria-label={t('settings.generation.chunkLimit.title')}
/>
</SettingRow>
<SettingRow
title={t('settings.generation.crossfade.title')}
description={t('settings.generation.crossfade.description')}
action={
<span className="text-sm tabular-nums text-muted-foreground">
{crossfadeMs === 0
? t('settings.generation.crossfade.cut')
: t('settings.generation.crossfade.ms', { ms: crossfadeMs })}
</span>
}
>
<Slider
id="crossfadeMs"
value={[crossfadeMs]}
onValueChange={([value]) => setCrossfadeMs(value)}
onValueCommit={([value]) => update({ crossfade_ms: value })}
min={0}
max={200}
step={10}
aria-label={t('settings.generation.crossfade.title')}
/>
</SettingRow>
<SettingRow
title={t('settings.generation.normalize.title')}
description={t('settings.generation.normalize.description')}
htmlFor="normalizeAudio"
action={
<Toggle
id="normalizeAudio"
checked={normalizeAudio}
onCheckedChange={(v) => update({ normalize_audio: v })}
/>
}
/>
<SettingRow
title={t('settings.generation.autoplay.title')}
description={t('settings.generation.autoplay.description')}
htmlFor="autoplayOnGenerate"
action={
<Toggle
id="autoplayOnGenerate"
checked={autoplayOnGenerate}
onCheckedChange={(v) => update({ autoplay_on_generate: v })}
/>
}
/>
<SettingRow
title={t('settings.generation.folder.title')}
description={generationsPath ?? t('settings.generation.folder.description')}
action={
<Button
variant="outline"
size="sm"
onClick={openGenerationsFolder}
disabled={opening || !generationsPath}
>
<FolderOpen className="h-3.5 w-3.5 mr-1.5" />
{t('settings.generation.folder.open')}
</Button>
}
/>
</SettingSection>
</div>
<aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0">
<div className="space-y-2">
<h3 className="text-sm font-semibold">{t('settings.generation.sidebar.aboutTitle')}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
{t('settings.generation.sidebar.aboutBody')}
</p>
</div>
<div className="space-y-3">
<h3 className="text-sm font-semibold">{t('settings.generation.sidebar.differencesTitle')}</h3>
<ul className="space-y-3 text-sm text-muted-foreground">
<li className="flex gap-2.5">
<Mic className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">
{t('settings.generation.sidebar.clone.title')}
</span>{' '}
{t('settings.generation.sidebar.clone.body')}
</span>
</li>
<li className="flex gap-2.5">
<Languages className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">
{t('settings.generation.sidebar.engines.title')}
</span>{' '}
{t('settings.generation.sidebar.engines.body')}
</span>
</li>
<li className="flex gap-2.5">
<Zap className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">{t('settings.generation.sidebar.agentReady.title')}</span>{' '}
{t('settings.generation.sidebar.agentReady.body')}
</span>
</li>
</ul>
</div>
</aside>
</div>
);
}
+407
View File
@@ -0,0 +1,407 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertCircle, Cpu, Download, Loader2, RotateCw, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { apiClient } from '@/lib/api/client';
import type { CudaDownloadProgress, HealthResponse } from '@/lib/api/types';
import { useServerHealth } from '@/lib/hooks/useServer';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
import { SettingRow, SettingSection } from './SettingRow';
type RestartPhase = 'idle' | 'stopping' | 'waiting' | 'ready';
function AppleLogo({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
);
}
function GpuIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="4" y="6" width="16" height="12" rx="2" />
<path d="M2 10h2M2 14h2M20 10h2M20 14h2" />
<path d="M9 10h6M9 14h4" />
</svg>
);
}
function GpuInfoCard({ health }: { health: HealthResponse }) {
const { t } = useTranslation();
const hasGpu = health.gpu_available && health.gpu_type;
const gpuName = hasGpu
? health.gpu_type!.replace(/^(CUDA|ROCm|MPS|Metal|XPU|DirectML)\s*\((.+)\)$/, '$2') ||
health.gpu_type!
: null;
const gpuBackend = hasGpu ? health.gpu_type!.replace(/\s*\(.+\)$/, '') : null;
const isApple = gpuBackend === 'MPS' || gpuBackend === 'Metal';
const showBackendVariant = health.backend_variant && health.backend_variant !== 'cpu';
return (
<div className="rounded-lg border border-border/60 p-4">
<div className="flex items-center gap-3">
{hasGpu ? (
isApple ? (
<AppleLogo className="h-5 w-5 shrink-0 text-muted-foreground" />
) : (
<GpuIcon className="h-5 w-5 shrink-0 text-accent" />
)
) : (
<Cpu className="h-5 w-5 shrink-0 text-muted-foreground" />
)}
<div className="flex-1 min-w-0 space-y-0.5">
<div className="text-sm font-medium">{hasGpu ? gpuName : t('settings.gpu.cpuOnly')}</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{hasGpu ? (
<>
<span>{gpuBackend}</span>
{showBackendVariant && (
<>
<span className="text-border">|</span>
<span className="uppercase">{health.backend_variant}</span>
</>
)}
{health.vram_used_mb != null && health.vram_used_mb > 0 && (
<>
<span className="text-border">|</span>
<span>
{t('settings.gpu.vramUsed', { mb: health.vram_used_mb.toFixed(0) })}
</span>
</>
)}
</>
) : (
<span>{t('settings.gpu.noAcceleration')}</span>
)}
</div>
</div>
{hasGpu && (
<div className="flex items-center gap-2 rounded-full border border-accent/30 px-2.5 py-0.5">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent/60" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-accent shadow-[0_0_4px_1px_hsl(var(--accent)/0.4)]" />
</span>
<span className="text-[10px] font-medium text-muted-foreground">
{t('settings.gpu.active')}
</span>
</div>
)}
</div>
</div>
);
}
export function GpuPage() {
const { t } = useTranslation();
const platform = usePlatform();
const queryClient = useQueryClient();
const serverUrl = useServerStore((state) => state.serverUrl);
const { data: health } = useServerHealth();
const [restartPhase, setRestartPhase] = useState<RestartPhase>('idle');
const [error, setError] = useState<string | null>(null);
const [downloadProgress, setDownloadProgress] = useState<CudaDownloadProgress | null>(null);
const healthPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Hold the latest `t` in a ref so the CUDA progress SSE effect below doesn't
// tear down and reconnect the EventSource every time the language changes.
const tRef = useRef(t);
useEffect(() => {
tRef.current = t;
}, [t]);
const {
data: cudaStatus,
isLoading: _cudaStatusLoading,
refetch: refetchCudaStatus,
} = useQuery({
queryKey: ['cuda-status', serverUrl],
queryFn: () => apiClient.getCudaStatus(),
refetchInterval: (query) => (query.state.status === 'pending' ? false : 10000),
retry: 1,
enabled: !!health,
});
const isCurrentlyCuda = health?.backend_variant === 'cuda';
const cudaAvailable = cudaStatus?.available ?? false;
const cudaDownloading = cudaStatus?.downloading ?? false;
useEffect(() => {
return () => {
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
}
};
}, []);
useEffect(() => {
if (!cudaDownloading || !serverUrl) return;
const eventSource = new EventSource(`${serverUrl}/backend/cuda-progress`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as CudaDownloadProgress;
setDownloadProgress(data);
if (data.status === 'complete') {
eventSource.close();
setDownloadProgress(null);
refetchCudaStatus();
} else if (data.status === 'error') {
eventSource.close();
setError(data.error || tRef.current('settings.gpu.errors.downloadFailed'));
setDownloadProgress(null);
refetchCudaStatus();
}
} catch (e) {
console.error('Error parsing CUDA progress event:', e);
}
};
eventSource.onerror = () => {
eventSource.close();
};
return () => {
eventSource.close();
};
}, [cudaDownloading, serverUrl, refetchCudaStatus]);
const clearHealthPolling = useCallback(() => {
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
}
}, []);
const startHealthPolling = useCallback(() => {
clearHealthPolling();
healthPollRef.current = setInterval(async () => {
try {
const result = await apiClient.getHealth();
if (result.status === 'healthy') {
clearHealthPolling();
setRestartPhase('ready');
queryClient.invalidateQueries();
setTimeout(() => setRestartPhase('idle'), 2000);
}
} catch {
// Server still down, keep polling
}
}, 1000);
}, [queryClient, clearHealthPolling]);
const restartServerWithPolling = useCallback(
async (errorMessage: string) => {
setRestartPhase('stopping');
try {
await platform.lifecycle.restartServer();
setRestartPhase('waiting');
startHealthPolling();
} catch (e: unknown) {
clearHealthPolling();
setRestartPhase('idle');
throw new Error(e instanceof Error ? e.message : errorMessage);
}
},
[platform, startHealthPolling, clearHealthPolling],
);
const handleDownload = async () => {
setError(null);
try {
await apiClient.downloadCudaBackend();
refetchCudaStatus();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : t('settings.gpu.errors.downloadStart');
if (msg.includes('already downloaded')) {
refetchCudaStatus();
} else {
setError(msg);
}
}
};
const handleRestart = async () => {
setError(null);
try {
await restartServerWithPolling(t('settings.gpu.errors.restartFailed'));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : t('settings.gpu.errors.restartFailed'));
}
};
const handleSwitchToCpu = async () => {
setError(null);
setRestartPhase('stopping');
try {
await apiClient.deleteCudaBackend();
await restartServerWithPolling(t('settings.gpu.errors.switchCpu'));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : t('settings.gpu.errors.switchCpu'));
refetchCudaStatus();
}
};
const handleDelete = async () => {
setError(null);
try {
await apiClient.deleteCudaBackend();
refetchCudaStatus();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : t('settings.gpu.errors.deleteCuda'));
}
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
};
if (!health) return null;
const hasNativeGpu =
health.gpu_available &&
!isCurrentlyCuda &&
health.gpu_type &&
!health.gpu_type.includes('CUDA');
return (
<div className="space-y-8 max-w-2xl">
<GpuInfoCard health={health} />
{!hasNativeGpu && !isCurrentlyCuda && (
<SettingSection
title={t('settings.gpu.cuda.title')}
description={t('settings.gpu.cuda.description')}
>
{cudaDownloading && downloadProgress && (
<SettingRow title={t('settings.gpu.cuda.downloading')}>
<div className="space-y-1.5">
<Progress value={downloadProgress.progress} className="h-2" />
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{downloadProgress.filename ||
(cudaAvailable
? t('settings.gpu.cuda.updating')
: t('settings.gpu.cuda.downloadingShort'))}
</span>
<span>
{downloadProgress.total > 0
? `${formatBytes(downloadProgress.current)} / ${formatBytes(downloadProgress.total)}`
: `${downloadProgress.progress.toFixed(1)}%`}
</span>
</div>
</div>
</SettingRow>
)}
{restartPhase !== 'idle' && (
<SettingRow
title={
restartPhase === 'ready'
? t('settings.gpu.restart.ready')
: restartPhase === 'waiting'
? t('settings.gpu.restart.waiting')
: t('settings.gpu.restart.stopping')
}
action={<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
/>
)}
{error && (
<SettingRow title={t('common.error')}>
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
</SettingRow>
)}
{restartPhase === 'idle' && !cudaDownloading && (
<>
{!cudaAvailable && !isCurrentlyCuda && (
<SettingRow
title={t('settings.gpu.download.title')}
description={t('settings.gpu.download.description')}
action={
<Button onClick={handleDownload} size="sm">
<Download className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.download.button')}
</Button>
}
/>
)}
{cudaAvailable && !isCurrentlyCuda && platform.metadata.isTauri && (
<SettingRow
title={t('settings.gpu.switchToCuda.title')}
description={t('settings.gpu.switchToCuda.description')}
action={
<Button onClick={handleRestart} size="sm">
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.switchToCuda.button')}
</Button>
}
/>
)}
{isCurrentlyCuda && platform.metadata.isTauri && (
<SettingRow
title={t('settings.gpu.switchToCpu.title')}
description={t('settings.gpu.switchToCpu.description')}
action={
<Button onClick={handleSwitchToCpu} variant="outline" size="sm">
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.switchToCpu.button')}
</Button>
}
/>
)}
{cudaAvailable && !isCurrentlyCuda && (
<SettingRow
title={t('settings.gpu.remove.title')}
description={t('settings.gpu.remove.description')}
action={
<Button
onClick={handleDelete}
variant="ghost"
size="sm"
className="text-muted-foreground "
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.remove.button')}
</Button>
}
/>
)}
</>
)}
</SettingSection>
)}
<p className="text-xs text-muted-foreground/60 leading-relaxed">{t('settings.gpu.footer')}</p>
</div>
);
}
@@ -0,0 +1,34 @@
import { useTranslation } from 'react-i18next';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { type LanguageCode, SUPPORTED_LANGUAGES } from '@/i18n';
export function LanguageSelect() {
const { i18n } = useTranslation();
const current = SUPPORTED_LANGUAGES.find((l) => l.code === i18n.language)?.code ?? 'en';
return (
<Select
value={current}
onValueChange={(value) => {
void i18n.changeLanguage(value as LanguageCode);
}}
>
<SelectTrigger className="h-9 w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUPPORTED_LANGUAGES.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils/cn';
import { type LogEntry, useLogStore } from '@/stores/logStore';
function formatTime(timestamp: number): string {
const d = new Date(timestamp);
return d.toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
}
function LogLine({ entry }: { entry: LogEntry }) {
return (
<div className="flex gap-3 font-mono text-xs leading-5 hover:bg-muted/30">
<span className="text-muted-foreground/50 select-none shrink-0">
{formatTime(entry.timestamp)}
</span>
<span
className={cn(
'whitespace-pre-wrap break-all',
entry.stream === 'stderr' ? 'text-orange-400/80' : 'text-muted-foreground',
)}
>
{entry.line}
</span>
</div>
);
}
export function LogsPage() {
const { t } = useTranslation();
const entries = useLogStore((s) => s.entries);
const clear = useLogStore((s) => s.clear);
const containerRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
// Auto-scroll to bottom when new entries arrive
useEffect(() => {
if (autoScroll && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [entries.length, autoScroll]);
// Detect manual scroll to disable auto-scroll
const handleScroll = () => {
const el = containerRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
setAutoScroll(atBottom);
};
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium">{t('settings.logs.title')}</h3>
<p className="text-sm text-muted-foreground">
{t('settings.logs.lineCount', { count: entries.length })}
</p>
</div>
<div className="flex items-center gap-2">
{!autoScroll && (
<Button
variant="outline"
size="sm"
onClick={() => {
setAutoScroll(true);
containerRef.current?.scrollTo({ top: containerRef.current.scrollHeight });
}}
>
{t('settings.logs.scrollToBottom')}
</Button>
)}
<Button variant="outline" size="sm" onClick={clear}>
{t('settings.logs.clear')}
</Button>
</div>
</div>
<div
ref={containerRef}
onScroll={handleScroll}
className="flex-1 min-h-0 overflow-y-auto rounded-md border bg-black/20 p-3"
>
{entries.length === 0 ? (
<div className="text-sm text-muted-foreground/50 font-mono space-y-1">
<p>{t('settings.logs.empty')}</p>
{!import.meta.env?.PROD && <p>{t('settings.logs.devHint')}</p>}
</div>
) : (
entries.map((entry) => <LogLine key={entry.id} entry={entry} />)
)}
</div>
</div>
);
}
+354
View File
@@ -0,0 +1,354 @@
import { Check, Copy, Plug, Trash2, Waypoints } from 'lucide-react';
import { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useMCPBindings } from '@/lib/hooks/useMCPBindings';
import { useProfiles } from '@/lib/hooks/useProfiles';
import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { useServerStore } from '@/stores/serverStore';
import { formatDate } from '@/lib/utils/format';
import { SettingRow, SettingSection } from './SettingRow';
function getStdioShimCommand(): string {
if (typeof navigator === 'undefined') {
return '/Applications/Voicebox.app/Contents/MacOS/voicebox-mcp';
}
const platform = `${navigator.platform} ${navigator.userAgent}`.toLowerCase();
if (platform.includes('win')) {
return 'C:\\Program Files\\Voicebox\\voicebox-mcp.exe';
}
if (platform.includes('linux')) {
return '/opt/voicebox/voicebox-mcp';
}
return '/Applications/Voicebox.app/Contents/MacOS/voicebox-mcp';
}
/**
* Settings → MCP — configure per-agent voice binding and show copy-paste
* install snippets for major MCP clients. Backend runs at /mcp on the
* existing Voicebox server; this page is the agent-onboarding surface.
*/
export function MCPPage() {
const { t } = useTranslation();
const serverUrl = useServerStore((s) => s.serverUrl);
const { bindings, upsertAsync, remove } = useMCPBindings();
const { data: profiles } = useProfiles();
const { settings: captureSettings, update: updateCapture } = useCaptureSettings();
const defaultProfileId = captureSettings?.default_playback_voice_id ?? '';
const mcpUrl = `${serverUrl}/mcp`;
const stdioShimCommand = getStdioShimCommand();
const [newClientId, setNewClientId] = useState('');
const [newLabel, setNewLabel] = useState('');
const [newProfileId, setNewProfileId] = useState('');
const [adding, setAdding] = useState(false);
const handleAdd = async () => {
if (!newClientId.trim()) return;
setAdding(true);
try {
await upsertAsync({
client_id: newClientId.trim(),
label: newLabel.trim() || null,
profile_id: newProfileId || null,
});
setNewClientId('');
setNewLabel('');
setNewProfileId('');
} finally {
setAdding(false);
}
};
return (
<div className="flex gap-8 items-start max-w-5xl">
<div className="flex-1 min-w-0 max-w-2xl space-y-8">
<SettingSection
title={t('settings.mcp.install.title')}
description={t('settings.mcp.install.description')}
>
<SnippetRow
title={t('settings.mcp.install.http.title')}
description={t('settings.mcp.install.http.description')}
snippet={JSON.stringify(
{
mcpServers: {
voicebox: {
url: mcpUrl,
headers: { 'X-Voicebox-Client-Id': 'claude-code' },
},
},
},
null,
2,
)}
/>
<SnippetRow
title={t('settings.mcp.install.claudeCode.title')}
description={t('settings.mcp.install.claudeCode.description')}
snippet={`claude mcp add voicebox --transport http --url ${mcpUrl} --header "X-Voicebox-Client-Id: claude-code"`}
/>
<SnippetRow
title={t('settings.mcp.install.stdio.title')}
description={t('settings.mcp.install.stdio.description')}
snippet={JSON.stringify(
{
mcpServers: {
voicebox: {
command: stdioShimCommand,
env: { VOICEBOX_CLIENT_ID: 'claude-code' },
},
},
},
null,
2,
)}
/>
</SettingSection>
<SettingSection
title={t('settings.mcp.defaultVoice.title')}
description={t('settings.mcp.defaultVoice.description')}
>
<SettingRow
title={t('settings.mcp.defaultVoice.label')}
description={t('settings.mcp.defaultVoice.labelHint')}
action={
<Select
value={defaultProfileId || '__default__'}
onValueChange={(v) =>
updateCapture({
default_playback_voice_id: v === '__default__' ? null : v,
})
}
>
<SelectTrigger className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">
{t('settings.mcp.defaultVoice.none')}
</SelectItem>
{(profiles ?? []).map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
}
/>
</SettingSection>
<SettingSection
title={t('settings.mcp.bindings.title')}
description={t('settings.mcp.bindings.description')}
>
{bindings.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 italic">
<Trans i18nKey="settings.mcp.bindings.empty" components={{ code: <code /> }} />
</p>
) : (
<div className="divide-y divide-border/60">
{bindings.map((b) => (
<div
key={b.client_id}
className="py-3 grid grid-cols-[1fr_auto_auto] gap-4 items-center"
>
<div className="min-w-0">
<div className="font-medium text-sm truncate">
{b.label || b.client_id}
</div>
<div className="text-xs text-muted-foreground truncate">
<code className="text-[11px]">{b.client_id}</code>
{' · '}
{b.last_seen_at ? (
<span title={t('settings.mcp.bindings.lastSeenTitle', { when: b.last_seen_at })}>
<Plug className="inline h-3 w-3 text-emerald-500" />{' '}
{t('settings.mcp.bindings.lastSeen', { when: formatDate(b.last_seen_at) })}
</span>
) : (
<span>{t('settings.mcp.bindings.neverConnected')}</span>
)}
</div>
</div>
<Select
value={b.profile_id ?? '__default__'}
onValueChange={(v) =>
upsertAsync({
client_id: b.client_id,
label: b.label,
profile_id: v === '__default__' ? null : v,
})
}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">
{t('settings.mcp.bindings.defaultOption')}
</SelectItem>
{(profiles ?? []).map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="icon"
variant="ghost"
onClick={() => remove(b.client_id)}
aria-label={t('settings.mcp.bindings.removeAria', { client: b.client_id })}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
<div className="pt-4 space-y-2">
<div className="text-sm font-medium">{t('settings.mcp.bindings.add.title')}</div>
<div className="grid grid-cols-[1fr_1fr_auto] gap-2">
<input
type="text"
placeholder={t('settings.mcp.bindings.add.clientIdPlaceholder')}
value={newClientId}
onChange={(e) => setNewClientId(e.target.value)}
className="h-9 px-3 rounded-md border bg-background text-sm"
/>
<input
type="text"
placeholder={t('settings.mcp.bindings.add.labelPlaceholder')}
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
className="h-9 px-3 rounded-md border bg-background text-sm"
/>
<Select
value={newProfileId || '__default__'}
onValueChange={(v) => setNewProfileId(v === '__default__' ? '' : v)}
>
<SelectTrigger className="h-9 min-w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">
{t('settings.mcp.bindings.defaultOption')}
</SelectItem>
{(profiles ?? []).map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
size="sm"
onClick={handleAdd}
disabled={!newClientId.trim() || adding}
>
{t('settings.mcp.bindings.add.action')}
</Button>
</div>
</SettingSection>
</div>
<aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0">
<div className="space-y-2">
<h3 className="text-sm font-semibold">{t('settings.mcp.sidebar.aboutTitle')}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
{t('settings.mcp.sidebar.aboutBody')}
</p>
</div>
<div className="space-y-2">
<h3 className="text-sm font-semibold">{t('settings.mcp.sidebar.toolsTitle')}</h3>
<ul className="text-sm text-muted-foreground space-y-1.5 leading-relaxed">
<li>
<code className="text-accent">voicebox.speak</code>
<div>{t('settings.mcp.sidebar.tools.speak')}</div>
</li>
<li>
<code className="text-accent">voicebox.transcribe</code>
<div>{t('settings.mcp.sidebar.tools.transcribe')}</div>
</li>
<li>
<code className="text-accent">voicebox.list_captures</code>
<div>{t('settings.mcp.sidebar.tools.listCaptures')}</div>
</li>
<li>
<code className="text-accent">voicebox.list_profiles</code>
<div>{t('settings.mcp.sidebar.tools.listProfiles')}</div>
</li>
</ul>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Waypoints className="h-3.5 w-3.5 text-accent" />
<span>
<Trans i18nKey="settings.mcp.sidebar.postSpeak" components={{ code: <code /> }} />
</span>
</div>
</aside>
</div>
);
}
function SnippetRow({
title,
description,
snippet,
}: {
title: string;
description: string;
snippet: string;
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(snippet);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// ignore; user can still select-and-copy the pre content
}
};
return (
<div className="py-3 space-y-2">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">{title}</div>
<div className="text-xs text-muted-foreground">{description}</div>
</div>
<Button size="sm" variant="outline" onClick={copy}>
{copied ? (
<>
<Check className="h-3.5 w-3.5 mr-1.5" />
{t('settings.mcp.install.copied')}
</>
) : (
<>
<Copy className="h-3.5 w-3.5 mr-1.5" />
{t('settings.mcp.install.copy')}
</>
)}
</Button>
</div>
<pre className="text-[11px] font-mono p-3 rounded-md bg-muted/50 overflow-x-auto whitespace-pre-wrap break-all">
{snippet}
</pre>
</div>
);
}
@@ -0,0 +1,78 @@
import { Link, Outlet, useMatchRoute } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn';
import { usePlatform } from '@/platform/PlatformContext';
import { usePlayerStore } from '@/stores/playerStore';
interface SettingsTab {
labelKey?: string;
label?: string;
path:
| '/settings'
| '/settings/generation'
| '/settings/captures'
| '/settings/mcp'
| '/settings/gpu'
| '/settings/logs'
| '/settings/changelog'
| '/settings/about';
tauriOnly?: boolean;
}
const tabs: SettingsTab[] = [
{ labelKey: 'settings.tabs.general', path: '/settings' },
{ labelKey: 'settings.tabs.generation', path: '/settings/generation' },
{ labelKey: 'settings.tabs.captures', path: '/settings/captures' },
{ labelKey: 'settings.tabs.mcp', path: '/settings/mcp' },
{ labelKey: 'settings.tabs.gpu', path: '/settings/gpu', tauriOnly: true },
{ labelKey: 'settings.tabs.logs', path: '/settings/logs', tauriOnly: true },
{ labelKey: 'settings.tabs.changelog', path: '/settings/changelog' },
{ labelKey: 'settings.tabs.about', path: '/settings/about' },
];
export function SettingsLayout() {
const { t } = useTranslation();
const platform = usePlatform();
const isPlayerVisible = !!usePlayerStore((state) => state.audioUrl);
const matchRoute = useMatchRoute();
return (
<div className="flex flex-col h-full min-h-0">
<nav className="flex gap-1 border-b shrink-0">
{tabs.map((tab) => {
if (tab.tauriOnly && !platform.metadata.isTauri) return null;
const isActive =
tab.path === '/settings'
? matchRoute({ to: tab.path, fuzzy: false })
: matchRoute({ to: tab.path });
return (
<Link
key={tab.path}
to={tab.path}
className={cn(
'px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px',
isActive
? 'border-accent text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30',
)}
>
{tab.label ?? (tab.labelKey ? t(tab.labelKey) : '')}
</Link>
);
})}
</nav>
<div
className={cn(
'flex-1 overflow-y-auto pt-6 pb-6 px-2 -mx-2',
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
)}
>
<Outlet />
</div>
</div>
);
}
@@ -0,0 +1,62 @@
import type { ReactNode } from 'react';
/**
* A section header with title and optional description, separated by a border.
*/
export function SettingSection({
title,
description,
children,
}: {
title?: string;
description?: string;
children: ReactNode;
}) {
return (
<div className="space-y-1">
{title && <h3 className="text-lg font-semibold">{title}</h3>}
{description && <p className="text-sm text-muted-foreground">{description}</p>}
<div className={`${title || description ? 'pt-3' : ''} space-y-0 divide-y divide-border/60`}>
{children}
</div>
</div>
);
}
/**
* A single settings row: label+description on the left, action on the right.
* Use for toggles, inputs, buttons, badges — any control type.
*/
export function SettingRow({
title,
description,
htmlFor,
action,
children,
}: {
title: string;
description?: string;
htmlFor?: string;
/** Right-aligned control (checkbox, button, badge, etc.) */
action?: ReactNode;
/** Full-width content rendered below the label row (for sliders, inputs, etc.) */
children?: ReactNode;
}) {
return (
<div className="py-3">
<div className="flex items-center justify-between gap-8">
<div className="min-w-0">
<label
htmlFor={htmlFor}
className={`text-sm font-medium leading-none select-none ${htmlFor ? 'cursor-pointer' : ''}`}
>
{title}
</label>
{description && <p className="text-sm text-muted-foreground mt-0.5">{description}</p>}
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
{children && <div className="mt-3">{children}</div>}
</div>
);
}
@@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { type Theme, useUIStore } from '@/stores/uiStore';
export function ThemeSelect() {
const { t } = useTranslation();
const theme = useUIStore((s) => s.theme);
const setTheme = useUIStore((s) => s.setTheme);
return (
<Select value={theme} onValueChange={(value) => setTheme(value as Theme)}>
<SelectTrigger className="h-9 w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">{t('settings.theme.options.system')}</SelectItem>
<SelectItem value="light">{t('settings.theme.options.light')}</SelectItem>
<SelectItem value="dark">{t('settings.theme.options.dark')}</SelectItem>
</SelectContent>
</Select>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { motion, useAnimationFrame, useMotionValue, useTransform } from 'motion/react';
import type React from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
interface ShinyTextProps {
text: string;
disabled?: boolean;
speed?: number;
className?: string;
color?: string;
shineColor?: string;
spread?: number;
yoyo?: boolean;
pauseOnHover?: boolean;
direction?: 'left' | 'right';
delay?: number;
}
const ShinyText: React.FC<ShinyTextProps> = ({
text,
disabled = false,
speed = 2,
className = '',
color = '#b5b5b5',
shineColor = '#ffffff',
spread = 120,
yoyo = false,
pauseOnHover = false,
direction = 'left',
delay = 0,
}) => {
const [isPaused, setIsPaused] = useState(false);
const progress = useMotionValue(0);
const elapsedRef = useRef(0);
const lastTimeRef = useRef<number | null>(null);
const directionRef = useRef(direction === 'left' ? 1 : -1);
const animationDuration = speed * 1000;
const delayDuration = delay * 1000;
useAnimationFrame((time) => {
if (disabled || isPaused) {
lastTimeRef.current = null;
return;
}
if (lastTimeRef.current === null) {
lastTimeRef.current = time;
return;
}
const deltaTime = time - lastTimeRef.current;
lastTimeRef.current = time;
elapsedRef.current += deltaTime;
// Animation goes from 0 to 100
if (yoyo) {
const cycleDuration = animationDuration + delayDuration;
const fullCycle = cycleDuration * 2;
const cycleTime = elapsedRef.current % fullCycle;
if (cycleTime < animationDuration) {
// Forward animation: 0 -> 100
const p = (cycleTime / animationDuration) * 100;
progress.set(directionRef.current === 1 ? p : 100 - p);
} else if (cycleTime < cycleDuration) {
// Delay at end
progress.set(directionRef.current === 1 ? 100 : 0);
} else if (cycleTime < cycleDuration + animationDuration) {
// Reverse animation: 100 -> 0
const reverseTime = cycleTime - cycleDuration;
const p = 100 - (reverseTime / animationDuration) * 100;
progress.set(directionRef.current === 1 ? p : 100 - p);
} else {
// Delay at start
progress.set(directionRef.current === 1 ? 0 : 100);
}
} else {
const cycleDuration = animationDuration + delayDuration;
const cycleTime = elapsedRef.current % cycleDuration;
if (cycleTime < animationDuration) {
// Animation phase: 0 -> 100
const p = (cycleTime / animationDuration) * 100;
progress.set(directionRef.current === 1 ? p : 100 - p);
} else {
// Delay phase - hold at end (shine off-screen)
progress.set(directionRef.current === 1 ? 100 : 0);
}
}
});
useEffect(() => {
directionRef.current = direction === 'left' ? 1 : -1;
elapsedRef.current = 0;
progress.set(0);
// eslint-d, progress.setisable-next-line react-hooks/exhaustive-deps
}, [direction]);
// Transform: p=0 -> 150% (shine off right), p=100 -> -50% (shine off left)
const backgroundPosition = useTransform(progress, (p) => `${150 - p * 2}% center`);
const handleMouseEnter = useCallback(() => {
if (pauseOnHover) setIsPaused(true);
}, [pauseOnHover]);
const handleMouseLeave = useCallback(() => {
if (pauseOnHover) setIsPaused(false);
}, [pauseOnHover]);
const gradientStyle: React.CSSProperties = {
backgroundImage: `linear-gradient(${spread}deg, ${color} 0%, ${color} 35%, ${shineColor} 50%, ${color} 65%, ${color} 100%)`,
backgroundSize: '200% auto',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
WebkitTextFillColor: 'transparent',
};
return (
<motion.span
className={`inline-block ${className}`}
style={{ ...gradientStyle, backgroundPosition }}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{text}
</motion.span>
);
};
export default ShinyText;
// plugins: [],
// };
+84 -26
View File
@@ -1,53 +1,111 @@
import { Home, Settings } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { Link, useMatchRoute } from '@tanstack/react-router';
import { AudioLines, Box, Captions, type LucideIcon, Mic, Settings, Volume2, Wand2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import voiceboxLogo from '@/assets/voicebox-logo.png';
import { cn } from '@/lib/utils/cn';
import { usePlatform } from '@/platform/PlatformContext';
import type { UpdateStatus } from '@/platform/types';
import { usePlayerStore } from '@/stores/playerStore';
import { version } from '../../package.json';
interface SidebarProps {
activeTab: string;
onTabChange: (tab: string) => void;
isMacOS?: boolean;
}
const tabs = [
{ id: 'main', icon: Home, label: 'Main' },
{ id: 'settings', icon: Settings, label: 'Settings' },
const tabs: Array<{
id: string;
path: string;
icon: LucideIcon;
labelKey?: string;
label?: string;
}> = [
{ id: 'main', path: '/', icon: Volume2, labelKey: 'nav.generate' },
{ id: 'stories', path: '/stories', icon: AudioLines, labelKey: 'nav.stories' },
{ id: 'captures', path: '/captures', icon: Captions, labelKey: 'nav.captures' },
{ id: 'voices', path: '/voices', icon: Mic, labelKey: 'nav.voices' },
{ id: 'effects', path: '/effects', icon: Wand2, labelKey: 'nav.effects' },
{ id: 'models', path: '/models', icon: Box, labelKey: 'nav.models' },
{ id: 'settings', path: '/settings', icon: Settings, labelKey: 'nav.settings' },
];
export function Sidebar({ activeTab, onTabChange }: SidebarProps) {
export function Sidebar({ isMacOS }: SidebarProps) {
const { t } = useTranslation();
const matchRoute = useMatchRoute();
const isPlayerOpen = !!usePlayerStore((s) => s.audioUrl);
const platform = usePlatform();
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>(platform.updater.getStatus());
useEffect(() => platform.updater.subscribe(setUpdateStatus), [platform.updater]);
return (
<div className="fixed left-0 top-0 h-full w-20 bg-sidebar border-r border-border flex flex-col items-center py-6 gap-6">
<div
className={cn(
'fixed left-0 top-0 h-full w-20 bg-sidebar border-r border-border flex flex-col items-center py-6 gap-6',
isMacOS && 'pt-14',
)}
>
{/* Logo */}
<div className="mb-2">
<img
src={voiceboxLogo}
alt="Voicebox"
className="w-12 h-12 object-contain"
/>
<img src={voiceboxLogo} alt="Voicebox" className="sidebar-logo w-12 h-12 object-contain" />
</div>
{/* Navigation Buttons */}
<div className="flex flex-col gap-3">
{tabs.map((tab) => {
{tabs.map((tab, index) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
const isActive =
tab.path === '/'
? matchRoute({ to: '/', fuzzy: false })
: matchRoute({ to: tab.path, fuzzy: true });
// Accent fades as buttons get further from the logo
const accentOpacity = Math.max(0.08, 0.5 - index * 0.07);
return (
<button
<Link
key={tab.id}
type="button"
onClick={() => onTabChange(tab.id)}
to={tab.path}
className={cn(
'w-12 h-12 rounded-full flex items-center justify-center transition-all duration-200',
'hover:bg-accent hover:text-accent-foreground',
isActive ? 'bg-accent text-accent-foreground shadow-lg' : 'text-muted-foreground',
'relative w-12 h-12 rounded-full flex items-center justify-center transition-all duration-200 overflow-hidden',
isActive
? 'bg-white/[0.07] text-foreground shadow-lg backdrop-blur-sm border border-white/[0.08]'
: 'text-muted-foreground hover:bg-muted/50',
)}
title={tab.label}
aria-label={tab.label}
title={tab.label ?? (tab.labelKey ? t(tab.labelKey) : tab.id)}
aria-label={tab.label ?? (tab.labelKey ? t(tab.labelKey) : tab.id)}
>
<Icon className="h-5 w-5" />
</button>
{isActive && (
<div
className="absolute inset-0 rounded-full pointer-events-none"
style={{
maskImage: 'linear-gradient(to bottom, black, transparent 60%)',
WebkitMaskImage: 'linear-gradient(to bottom, black, transparent 60%)',
border: `1px solid hsl(var(--accent) / ${accentOpacity})`,
}}
/>
)}
<Icon className="h-5 w-5 relative z-10" />
</Link>
);
})}
</div>
{/* Version */}
<div
className="mt-auto flex flex-col items-center gap-1.5 transition-all duration-300"
style={{ paddingBottom: isPlayerOpen ? '7rem' : undefined }}
>
<span className="text-[10px] text-muted-foreground/50">v{version}</span>
{updateStatus.available && (
<Link
to="/settings"
className="text-[9px] font-semibold tracking-wide uppercase px-2 py-0.5 rounded-full bg-accent/15 text-accent hover:bg-accent/25 transition-colors"
>
{t('nav.updateBadge')}
</Link>
)}
</div>
</div>
);
}
@@ -0,0 +1,28 @@
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
import { usePlayerStore } from '@/stores/playerStore';
import { StoryContent } from './StoryContent';
import { StoryList } from './StoryList';
export function StoriesTab() {
const audioUrl = usePlayerStore((state) => state.audioUrl);
return (
<div className="flex flex-col h-full min-h-0 overflow-hidden -mx-8">
{/* Main content area */}
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden relative">
{/* Left Column - Story List */}
<div className="flex flex-col min-h-0 overflow-hidden w-full max-w-[360px] shrink-0">
<StoryList />
</div>
{/* Right Column - Story Content */}
<div className="flex flex-col min-h-0 overflow-hidden flex-1 pr-8">
<StoryContent />
</div>
{/* Floating Generate Box - position is managed via storyStore.trackEditorHeight */}
<FloatingGenerateBox showVoiceSelector isPlayerOpen={!!audioUrl} />
</div>
</div>
);
}
@@ -0,0 +1,185 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Mic, MoreHorizontal, Music, Play, RotateCcw, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Textarea } from '@/components/ui/textarea';
import type { StoryItemDetail } from '@/lib/api/types';
import { cn } from '@/lib/utils/cn';
import { useStoryStore } from '@/stores/storyStore';
import { useServerStore } from '@/stores/serverStore';
interface StoryChatItemProps {
item: StoryItemDetail;
storyId: string;
index: number;
onRemove: () => void;
onRegenerate?: () => void;
currentTimeMs: number;
isPlaying: boolean;
dragHandleProps?: React.HTMLAttributes<HTMLButtonElement>;
isDragging?: boolean;
}
export function StoryChatItem({
item,
onRemove,
onRegenerate,
currentTimeMs,
isPlaying,
dragHandleProps,
isDragging,
}: StoryChatItemProps) {
const { t } = useTranslation();
const seek = useStoryStore((state) => state.seek);
const serverUrl = useServerStore((state) => state.serverUrl);
const [avatarError, setAvatarError] = useState(false);
const avatarUrl = `${serverUrl}/profiles/${item.profile_id}/avatar`;
// Check if this item is currently playing based on timecode
const itemStartMs = item.start_time_ms;
const itemEndMs = item.start_time_ms + item.duration * 1000;
const isCurrentlyPlaying = isPlaying && currentTimeMs >= itemStartMs && currentTimeMs < itemEndMs;
const handlePlay = () => {
// Seek to the start of this item
seek(itemStartMs);
};
const formatTime = (ms: number): string => {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const milliseconds = Math.floor((ms % 1000) / 100);
return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds}`;
};
return (
<div
className={cn(
'flex items-start gap-3 p-4 rounded-lg border transition-colors',
isCurrentlyPlaying && 'bg-muted/70 border-primary',
!isCurrentlyPlaying && 'hover:bg-muted/50',
isDragging && 'opacity-50 shadow-lg',
)}
>
{/* Drag Handle */}
{dragHandleProps && (
<button
type="button"
className="shrink-0 cursor-grab active:cursor-grabbing touch-none text-muted-foreground hover:text-foreground transition-colors"
{...dragHandleProps}
>
<GripVertical className="h-5 w-5" />
</button>
)}
{/* Voice Avatar */}
<div className="shrink-0">
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center overflow-hidden">
{item.engine === 'import' ? (
<Music className="h-5 w-5 text-muted-foreground" />
) : !avatarError ? (
<img
src={avatarUrl}
alt={`${item.profile_name} avatar`}
className={cn(
'h-full w-full object-cover transition-all duration-200',
!isCurrentlyPlaying && 'grayscale',
)}
onError={() => setAvatarError(true)}
/>
) : (
<Mic className="h-5 w-5 text-muted-foreground" />
)}
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<span className="font-medium text-sm truncate">
{item.engine === 'import' ? item.text : item.profile_name}
</span>
{item.engine !== 'import' && (
<span className="text-xs text-muted-foreground">{item.language}</span>
)}
<span className="text-xs text-muted-foreground tabular-nums ml-auto">
{formatTime(itemStartMs)}
</span>
</div>
{item.engine === 'import' ? null : (
<Textarea
value={item.text}
className="flex-1 resize-none text-sm text-muted-foreground select-text bg-card cursor-text"
readOnly
onDoubleClick={handlePlay}
/>
)}
</div>
{/* Actions */}
<div className="shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label={t('history.actions.menu')}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handlePlay}>
<Play className="mr-2 h-4 w-4" />
{t('storyContent.itemActions.playFromHere')}
</DropdownMenuItem>
{onRegenerate && (
<DropdownMenuItem onClick={onRegenerate}>
<RotateCcw className="mr-2 h-4 w-4" />
{t('storyContent.itemActions.regenerate')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={onRemove}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{t('storyContent.itemActions.removeFromStory')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
// Sortable wrapper component
export function SortableStoryChatItem(
props: Omit<StoryChatItemProps, 'dragHandleProps' | 'isDragging'>,
) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.item.generation_id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
<StoryChatItem {...props} dragHandleProps={listeners} isDragging={isDragging} />
</div>
);
}
@@ -0,0 +1,517 @@
import {
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { Link } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
import { Download, Music, Plus, Upload } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Loader from 'react-loaders';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { useHistory } from '@/lib/hooks/useHistory';
import {
useAddStoryItem,
useExportStoryAudio,
useRemoveStoryItem,
useReorderStoryItems,
useStory,
} from '@/lib/hooks/useStories';
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
import { useGenerationStore } from '@/stores/generationStore';
import { useStoryStore } from '@/stores/storyStore';
import { SortableStoryChatItem } from './StoryChatItem';
export function StoryContent() {
const { t } = useTranslation();
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
const { data: story, isLoading } = useStory(selectedStoryId);
const removeItem = useRemoveStoryItem();
const reorderItems = useReorderStoryItems();
const exportAudio = useExportStoryAudio();
const addStoryItem = useAddStoryItem();
const { toast } = useToast();
const scrollRef = useRef<HTMLDivElement>(null);
const importInputRef = useRef<HTMLInputElement>(null);
const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size);
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
const [isDraggingFile, setIsDraggingFile] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const dragDepthRef = useRef(0);
// Add generation popover state
const [searchQuery, setSearchQuery] = useState('');
const [isAddOpen, setIsAddOpen] = useState(false);
const { data: historyData } = useHistory();
// Filter generations not in story and matching search
const availableGenerations = useMemo(() => {
if (!historyData?.items || !story) return [];
const storyGenerationIds = new Set(story.items.map((i) => i.generation_id));
const query = searchQuery.toLowerCase();
return historyData.items.filter(
(gen) =>
gen.status === 'completed' &&
!storyGenerationIds.has(gen.id) &&
(gen.text.toLowerCase().includes(query) || gen.profile_name.toLowerCase().includes(query)),
);
}, [historyData, story, searchQuery]);
// Get track editor height from store for dynamic padding
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
// Track editor is shown when story has items
const hasBottomBar = story && story.items.length > 0;
// Clear the floating generate box (always visible on this route) and the
// track editor bar when it's showing.
const FLOATING_BOX_CLEARANCE = 140;
const bottomPadding = hasBottomBar
? trackEditorHeight + FLOATING_BOX_CLEARANCE
: FLOATING_BOX_CLEARANCE;
// Drag and drop sensors
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// Playback state (for auto-scroll and item highlighting)
const isPlaying = useStoryStore((state) => state.isPlaying);
const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
const playbackStoryId = useStoryStore((state) => state.playbackStoryId);
// Refs for auto-scrolling to playing item
const itemRefsMap = useRef<Map<string, HTMLDivElement>>(new Map());
const lastScrolledItemRef = useRef<string | null>(null);
// Use playback hook
useStoryPlayback(story?.items);
// Sort items by start_time_ms
const sortedItems = useMemo(() => {
if (!story?.items) return [];
return [...story.items].sort((a, b) => a.start_time_ms - b.start_time_ms);
}, [story?.items]);
// Find the currently playing item based on timecode
const currentlyPlayingItemId = useMemo(() => {
if (!isPlaying || playbackStoryId !== story?.id || !sortedItems.length) {
return null;
}
const playingItem = sortedItems.find((item) => {
const itemStart = item.start_time_ms;
const itemEnd = item.start_time_ms + item.duration * 1000;
return currentTimeMs >= itemStart && currentTimeMs < itemEnd;
});
return playingItem?.generation_id ?? null;
}, [isPlaying, playbackStoryId, story?.id, sortedItems, currentTimeMs]);
// Auto-scroll to the currently playing item
useEffect(() => {
if (!currentlyPlayingItemId || currentlyPlayingItemId === lastScrolledItemRef.current) {
return;
}
const element = itemRefsMap.current.get(currentlyPlayingItemId);
if (element && scrollRef.current) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
lastScrolledItemRef.current = currentlyPlayingItemId;
}
}, [currentlyPlayingItemId]);
// Reset last scrolled item when playback stops
useEffect(() => {
if (!isPlaying) {
lastScrolledItemRef.current = null;
}
}, [isPlaying]);
const handleRegenerate = async (generationId: string) => {
try {
await apiClient.regenerateGeneration(generationId);
addPendingGeneration(generationId);
} catch (error) {
toast({
title: t('storyContent.toast.regenerateFailed'),
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
}
};
const handleRemoveItem = (itemId: string) => {
if (!story) return;
removeItem.mutate(
{
storyId: story.id,
itemId,
},
{
onError: (error) => {
toast({
title: t('storyContent.toast.removeFailed'),
description: error.message,
variant: 'destructive',
});
},
},
);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!story || !over || active.id === over.id) return;
const oldIndex = sortedItems.findIndex((item) => item.generation_id === active.id);
const newIndex = sortedItems.findIndex((item) => item.generation_id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
// Calculate the new order
const newOrder = arrayMove(sortedItems, oldIndex, newIndex);
const generationIds = newOrder.map((item) => item.generation_id);
// Send reorder request to backend
reorderItems.mutate(
{
storyId: story.id,
data: { generation_ids: generationIds },
},
{
onError: (error) => {
toast({
title: t('storyContent.toast.reorderFailed'),
description: error.message,
variant: 'destructive',
});
},
},
);
};
const handleExportAudio = () => {
if (!story) return;
exportAudio.mutate(
{
storyId: story.id,
storyName: story.name,
},
{
onError: (error) => {
toast({
title: t('storyContent.toast.exportFailed'),
description: error.message,
variant: 'destructive',
});
},
},
);
};
const handleImportAudio = async (file: File) => {
if (!story) return;
setIsImporting(true);
try {
const generation = await apiClient.importAudio(file);
await addStoryItem.mutateAsync({
storyId: story.id,
data: { generation_id: generation.id },
});
setIsAddOpen(false);
} catch (error) {
toast({
title: t('storyContent.toast.importFailed'),
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
} finally {
setIsImporting(false);
}
};
const handleImportFiles = async (files: FileList | File[]) => {
for (const file of Array.from(files)) {
await handleImportAudio(file);
}
};
const handleAddGeneration = (generationId: string) => {
if (!story) return;
addStoryItem.mutate(
{
storyId: story.id,
data: { generation_id: generationId },
},
{
onSuccess: () => {
setIsAddOpen(false);
setSearchQuery('');
},
onError: (error) => {
toast({
title: t('storyContent.toast.addFailed'),
description: error.message,
variant: 'destructive',
});
},
},
);
};
if (!selectedStoryId) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center">
<p className="text-lg font-medium mb-2">{t('storyContent.selectStory.title')}</p>
<p className="text-sm">{t('storyContent.selectStory.hint')}</p>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-muted-foreground">{t('storyContent.loading')}</div>
</div>
);
}
if (!story) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center">
<p className="text-lg font-medium mb-2">{t('storyContent.notFound.title')}</p>
<p className="text-sm">{t('storyContent.notFound.hint')}</p>
</div>
</div>
);
}
return (
<div
className="flex flex-col h-full min-h-0 relative overflow-hidden"
onDragEnter={(e) => {
if (!e.dataTransfer?.types.includes('Files')) return;
e.preventDefault();
dragDepthRef.current += 1;
setIsDraggingFile(true);
}}
onDragOver={(e) => {
if (e.dataTransfer?.types.includes('Files')) e.preventDefault();
}}
onDragLeave={(e) => {
if (!e.dataTransfer?.types.includes('Files')) return;
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setIsDraggingFile(false);
}}
onDrop={(e) => {
if (!e.dataTransfer?.files?.length) return;
e.preventDefault();
dragDepthRef.current = 0;
setIsDraggingFile(false);
handleImportFiles(e.dataTransfer.files);
}}
>
<input
ref={importInputRef}
type="file"
accept="audio/*,.wav,.mp3,.flac,.ogg,.m4a,.aac,.webm"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files?.length) handleImportFiles(e.target.files);
e.target.value = '';
}}
/>
{isDraggingFile && (
<div className="absolute inset-0 z-30 pointer-events-none flex items-center justify-center bg-accent/10 border-2 border-dashed border-accent rounded-lg m-4">
<div className="flex flex-col items-center gap-2 text-accent">
<Music className="h-8 w-8" />
<span className="text-sm font-medium">{t('storyContent.dropToImport')}</span>
</div>
</div>
)}
{/* Scroll Mask */}
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
{/* Header */}
<div className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between px-1">
<div>
<h2 className="text-2xl font-bold">{story.name}</h2>
{story.description && (
<p className="text-sm text-muted-foreground mt-1">{story.description}</p>
)}
</div>
<div className="flex gap-2 items-center">
<AnimatePresence>
{pendingCount > 0 && (
<motion.div
initial={{ opacity: 0, scale: 0.9, width: 0 }}
animate={{ opacity: 1, scale: 1, width: 'auto' }}
exit={{ opacity: 0, scale: 0.9, width: 0 }}
transition={{ duration: 0.2 }}
>
<Link
to="/"
className="flex items-center gap-2 h-8 pl-1.5 pr-3 rounded-full bg-card border border-border hover:bg-muted/50 transition-all duration-200 cursor-pointer"
>
<div className="shrink-0 w-10 h-5 overflow-hidden flex items-center justify-center">
<div className="scale-[0.45]">
<Loader type="line-scale" active />
</div>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{t('storyContent.generatingCount', { count: pendingCount })}
</span>
</Link>
</motion.div>
)}
</AnimatePresence>
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
{t('storyContent.add')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="p-2 border-b space-y-2">
<Input
placeholder={t('storyContent.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoFocus
/>
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={() => importInputRef.current?.click()}
disabled={isImporting}
>
<Upload className="mr-2 h-4 w-4" />
{isImporting ? t('storyContent.importing') : t('storyContent.importAudio')}
</Button>
</div>
<div className="max-h-60 overflow-y-auto">
{availableGenerations.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{searchQuery
? t('storyContent.searchNoMatches')
: t('storyContent.searchNoAvailable')}
</div>
) : (
availableGenerations.map((gen) => (
<button
key={gen.id}
type="button"
className="w-full text-left px-3 py-2 hover:bg-muted transition-colors border-b last:border-b-0"
onClick={() => handleAddGeneration(gen.id)}
>
<div className="font-medium text-sm">{gen.profile_name}</div>
<div className="text-xs text-muted-foreground truncate">
{gen.text.length > 50 ? `${gen.text.substring(0, 50)}...` : gen.text}
</div>
</button>
))
)}
</div>
</PopoverContent>
</Popover>
{story.items.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleExportAudio}
disabled={exportAudio.isPending}
>
<Download className="mr-2 h-4 w-4" />
{t('storyContent.exportAudio')}
</Button>
)}
</div>
</div>
{/* Content */}
<div
ref={scrollRef}
className="flex-1 min-h-0 overflow-y-auto space-y-3 pt-16 scroll-pt-16 relative z-0"
style={{ paddingBottom: bottomPadding > 0 ? `${bottomPadding}px` : undefined }}
>
{sortedItems.length === 0 ? (
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-md text-muted-foreground">
<p className="text-sm">{t('storyContent.empty.title')}</p>
<p className="text-xs mt-2">{t('storyContent.empty.hint')}</p>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedItems.map((item) => item.generation_id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{sortedItems.map((item, index) => (
<div
key={item.id}
ref={(el) => {
if (el) {
itemRefsMap.current.set(item.generation_id, el);
} else {
itemRefsMap.current.delete(item.generation_id);
}
}}
>
<SortableStoryChatItem
item={item}
storyId={story.id}
index={index}
onRemove={() => handleRemoveItem(item.id)}
onRegenerate={
item.engine === 'import'
? undefined
: () => handleRegenerate(item.generation_id)
}
currentTimeMs={currentTimeMs}
isPlaying={isPlaying && playbackStoryId === story.id}
/>
</div>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
</div>
);
}
+435
View File
@@ -0,0 +1,435 @@
import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
ListPane,
ListPaneActions,
ListPaneHeader,
ListPaneScroll,
ListPaneSearch,
ListPaneTitle,
ListPaneTitleRow,
} from '@/components/ListPane';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import {
useCreateStory,
useDeleteStory,
useStories,
useStory,
useUpdateStory,
} from '@/lib/hooks/useStories';
import { cn } from '@/lib/utils/cn';
import { formatDate } from '@/lib/utils/format';
import { useStoryStore } from '@/stores/storyStore';
export function StoryList() {
const { t } = useTranslation();
const { data: stories, isLoading } = useStories();
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
const { data: selectedStory } = useStory(selectedStoryId);
const createStory = useCreateStory();
const updateStory = useUpdateStory();
const deleteStory = useDeleteStory();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [editingStory, setEditingStory] = useState<{
id: string;
name: string;
description?: string;
} | null>(null);
const [deletingStoryId, setDeletingStoryId] = useState<string | null>(null);
const [newStoryName, setNewStoryName] = useState('');
const [newStoryDescription, setNewStoryDescription] = useState('');
const [search, setSearch] = useState('');
const { toast } = useToast();
// Auto-select the first story when the list loads with no selection
useEffect(() => {
if (!selectedStoryId && stories && stories.length > 0) {
setSelectedStoryId(stories[0].id);
}
}, [selectedStoryId, stories, setSelectedStoryId]);
const handleCreateStory = () => {
if (!newStoryName.trim()) {
toast({
title: t('stories.toast.nameRequired'),
description: t('stories.toast.nameRequiredDescription'),
variant: 'destructive',
});
return;
}
createStory.mutate(
{
name: newStoryName.trim(),
description: newStoryDescription.trim() || undefined,
},
{
onSuccess: (story) => {
setSelectedStoryId(story.id);
setCreateDialogOpen(false);
setNewStoryName('');
setNewStoryDescription('');
toast({
title: t('stories.toast.created'),
description: t('stories.toast.createdDescription', { name: story.name }),
});
},
onError: (error) => {
toast({
title: t('stories.toast.createFailed'),
description: error.message,
variant: 'destructive',
});
},
},
);
};
const handleEditClick = (story: { id: string; name: string; description?: string }) => {
setEditingStory(story);
setNewStoryName(story.name);
setNewStoryDescription(story.description || '');
setEditDialogOpen(true);
};
const handleUpdateStory = () => {
if (!editingStory || !newStoryName.trim()) {
toast({
title: t('stories.toast.nameRequired'),
description: t('stories.toast.nameRequiredDescription'),
variant: 'destructive',
});
return;
}
updateStory.mutate(
{
storyId: editingStory.id,
data: {
name: newStoryName.trim(),
description: newStoryDescription.trim() || undefined,
},
},
{
onSuccess: () => {
setEditDialogOpen(false);
setEditingStory(null);
setNewStoryName('');
setNewStoryDescription('');
},
onError: (error) => {
toast({
title: t('stories.toast.updateFailed'),
description: error.message,
variant: 'destructive',
});
},
},
);
};
const handleDeleteClick = (storyId: string) => {
setDeletingStoryId(storyId);
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = () => {
if (!deletingStoryId) return;
deleteStory.mutate(deletingStoryId, {
onSuccess: () => {
// Clear selection if deleting the currently selected story
if (selectedStoryId === deletingStoryId) {
setSelectedStoryId(null);
}
setDeleteDialogOpen(false);
setDeletingStoryId(null);
},
onError: (error) => {
toast({
title: t('stories.toast.deleteFailed'),
description: error.message,
variant: 'destructive',
});
},
});
};
const storyList = stories || [];
const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0;
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return storyList;
return storyList.filter((s) => {
const name = (s.name || '').toLowerCase();
const description = (s.description || '').toLowerCase();
return name.includes(q) || description.includes(q);
});
}, [search, storyList]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-muted-foreground">{t('stories.loading')}</div>
</div>
);
}
return (
<ListPane>
<ListPaneHeader>
<ListPaneTitleRow>
<ListPaneTitle>{t('stories.title')}</ListPaneTitle>
<ListPaneActions>
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
<Plus className="mr-2 h-4 w-4" />
{t('stories.newStory')}
</Button>
</ListPaneActions>
</ListPaneTitleRow>
<ListPaneSearch
value={search}
onChange={setSearch}
placeholder={t('stories.searchPlaceholder')}
/>
</ListPaneHeader>
<ListPaneScroll
style={{ paddingBottom: hasTrackEditor ? `${trackEditorHeight + 140}px` : '170px' }}
>
{storyList.length === 0 ? (
<div className="mx-4 text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-sm">{t('stories.empty.title')}</p>
<p className="text-xs mt-2">{t('stories.empty.hint')}</p>
</div>
) : filtered.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
<p>{t('stories.empty.noMatches', { query: search })}</p>
</div>
) : (
<div className="px-4 pb-6 space-y-1">
{filtered.map((story) => {
const isActive = selectedStoryId === story.id;
return (
<div key={story.id} className="relative group">
<button
type="button"
onClick={() => setSelectedStoryId(story.id)}
aria-label={t('stories.row.ariaLabel', {
name: story.name,
count: story.item_count,
updated: formatDate(story.updated_at),
})}
aria-pressed={isActive}
className={cn(
'w-full text-left p-3 rounded-lg transition-colors block',
isActive
? 'bg-muted/70 border border-border'
: 'border border-transparent hover:bg-muted/30',
)}
>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[11px] text-muted-foreground font-medium">
{formatDate(story.updated_at)}
</span>
<div className="flex-1" />
</div>
<div className="text-[13px] line-clamp-2 leading-snug mb-2">
<span className="text-foreground font-medium">{story.name}</span>
{story.description ? (
<>
<span className="mx-1.5 text-muted-foreground/50">·</span>
<span className="text-muted-foreground">{story.description}</span>
</>
) : null}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-muted/60 text-muted-foreground"
>
{t('stories.row.itemCount', { count: story.item_count })}
</Badge>
</div>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
aria-label={t('stories.row.actionsLabel', { name: story.name })}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEditClick(story)}>
<Pencil className="mr-2 h-4 w-4" />
{t('common.edit')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteClick(story.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{t('common.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
)}
</ListPaneScroll>
{/* Create Story Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('stories.createDialog.title')}</DialogTitle>
<DialogDescription>{t('stories.createDialog.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="story-name">{t('stories.fields.name')}</Label>
<Input
id="story-name"
placeholder={t('stories.fields.namePlaceholder')}
value={newStoryName}
onChange={(e) => setNewStoryName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCreateStory();
}
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="story-description">{t('stories.fields.descriptionLabel')}</Label>
<Textarea
id="story-description"
placeholder={t('stories.fields.descriptionPlaceholder')}
value={newStoryDescription}
onChange={(e) => setNewStoryDescription(e.target.value)}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleCreateStory} disabled={createStory.isPending}>
{createStory.isPending
? t('stories.createDialog.creating')
: t('stories.createDialog.action')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('stories.editDialog.title')}</DialogTitle>
<DialogDescription>{t('stories.editDialog.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="edit-story-name">{t('stories.fields.name')}</Label>
<Input
id="edit-story-name"
placeholder={t('stories.fields.namePlaceholder')}
value={newStoryName}
onChange={(e) => setNewStoryName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleUpdateStory();
}
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-story-description">{t('stories.fields.descriptionLabel')}</Label>
<Textarea
id="edit-story-description"
placeholder={t('stories.fields.descriptionPlaceholder')}
value={newStoryDescription}
onChange={(e) => setNewStoryDescription(e.target.value)}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleUpdateStory} disabled={updateStory.isPending}>
{updateStory.isPending ? t('stories.editDialog.saving') : t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('stories.deleteDialog.title')}</AlertDialogTitle>
<AlertDialogDescription>{t('stories.deleteDialog.description')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={handleDeleteConfirm}
disabled={deleteStory.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteStory.isPending ? t('stories.deleteDialog.deleting') : t('common.delete')}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ListPane>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
const isWindows = navigator.userAgent.includes('Windows');
export function TitleBarDragRegion() {
if (isWindows) return null;
return <div data-tauri-drag-region className="fixed top-0 left-0 right-0 h-12 z-[9999]" />;
}
-69
View File
@@ -1,69 +0,0 @@
import { useAutoUpdater } from '../hooks/useAutoUpdater';
import { Button } from './ui/button';
import { Card } from './ui/card';
import { Progress } from './ui/progress';
export function UpdateNotification() {
const { status, checkForUpdates, downloadAndInstall } = useAutoUpdater(true);
if (status.error) {
return null;
}
if (!status.available && !status.checking) {
return null;
}
if (status.checking) {
return (
<Card className="p-4 mb-4">
<div className="flex items-center gap-3">
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full" />
<span className="text-sm">Checking for updates...</span>
</div>
</Card>
);
}
if (status.available) {
return (
<Card className="p-4 mb-4 border-primary">
<div className="space-y-3">
<div>
<h3 className="font-semibold">Update Available</h3>
<p className="text-sm text-muted-foreground">
Version {status.version} is ready to install
</p>
</div>
{status.downloading && (
<div className="space-y-2">
<p className="text-sm">Downloading update...</p>
<Progress />
</div>
)}
{status.installing && (
<div className="space-y-2">
<p className="text-sm">Installing update...</p>
<p className="text-xs text-muted-foreground">App will restart automatically</p>
</div>
)}
{!status.downloading && !status.installing && (
<div className="flex gap-2">
<Button onClick={downloadAndInstall} size="sm">
Install Now
</Button>
<Button onClick={() => window.location.reload()} variant="outline" size="sm">
Later
</Button>
</div>
)}
</div>
</Card>
);
}
return null;
}
@@ -1 +0,0 @@
# Voice profile management components
@@ -0,0 +1,173 @@
import { Mic, Pause, Play, Square } from 'lucide-react';
import { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Visualizer } from 'react-sound-visualizer';
import { Button } from '@/components/ui/button';
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
import { formatAudioDuration } from '@/lib/utils/audio';
const MemoizedWaveform = memo(function MemoizedWaveform({
audioStream,
}: {
audioStream: MediaStream;
}) {
return (
<div className="absolute inset-0 pointer-events-none flex items-center justify-center opacity-30">
<Visualizer audio={audioStream} autoStart strokeColor="#b39a3d">
{({ canvasRef }) => (
<canvas ref={canvasRef} width={500} height={150} className="w-full h-full" />
)}
</Visualizer>
</div>
);
});
interface AudioSampleRecordingProps {
file: File | null | undefined;
isRecording: boolean;
duration: number;
onStart: () => void;
onStop: () => void;
onCancel: () => void;
onTranscribe: () => void;
onPlayPause: () => void;
isPlaying: boolean;
isTranscribing?: boolean;
showWaveform?: boolean;
}
export function AudioSampleRecording({
file,
isRecording,
duration,
onStart,
onStop,
onCancel,
onTranscribe,
onPlayPause,
isPlaying,
isTranscribing = false,
showWaveform = true,
}: AudioSampleRecordingProps) {
const { t } = useTranslation();
const [audioStream, setAudioStream] = useState<MediaStream | null>(null);
// Request microphone access when component mounts
useEffect(() => {
if (!showWaveform) return;
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return;
let stream: MediaStream | null = null;
navigator.mediaDevices
.getUserMedia({ audio: true, video: false })
.then((s) => {
stream = s;
setAudioStream(s);
})
.catch((err) => {
console.warn('Could not access microphone for visualization:', err);
});
return () => {
if (stream) {
stream.getTracks().forEach((track) => {
track.stop();
});
}
};
}, [showWaveform]);
return (
<FormItem>
<FormControl>
<div className="space-y-4">
{!isRecording && !file && (
<div className="relative flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px] overflow-hidden">
{showWaveform && audioStream && <MemoizedWaveform audioStream={audioStream} />}
<Button
type="button"
onClick={onStart}
size="lg"
className="relative z-10 flex items-center gap-2"
>
<Mic className="h-5 w-5" />
{t('audioSample.startRecording')}
</Button>
<p className="relative z-10 text-sm text-muted-foreground text-center">
{t('audioSample.recordHint')}
</p>
</div>
)}
{isRecording && (
<div className="relative flex flex-col items-center justify-center gap-4 p-4 border-2 border-accent rounded-lg bg-accent/5 min-h-[180px] overflow-hidden">
{showWaveform && audioStream && <MemoizedWaveform audioStream={audioStream} />}
<div className="relative z-10 flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-accent animate-pulse" />
<span className="text-lg font-mono font-semibold">
{formatAudioDuration(duration)}
</span>
</div>
</div>
<Button
type="button"
onClick={onStop}
className="relative z-10 flex items-center gap-2 bg-accent text-accent-foreground hover:bg-accent/90"
>
<Square className="h-4 w-4" />
{t('audioSample.stopRecording')}
</Button>
<p className="relative z-10 text-sm text-muted-foreground text-center">
{t('audioSample.remaining', { time: formatAudioDuration(30 - duration) })}
</p>
</div>
)}
{file && !isRecording && (
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
<div className="flex items-center gap-2">
<Mic className="h-5 w-5 text-primary" />
<span className="font-medium">{t('audioSample.recordingComplete')}</span>
</div>
<p className="text-sm text-muted-foreground text-center">
{t('audioSample.fileLabel', { name: file.name })}
</p>
<div className="flex gap-2">
<Button
type="button"
size="icon"
variant="outline"
onClick={onPlayPause}
aria-label={isPlaying ? t('audioSample.pause') : t('audioSample.play')}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button
type="button"
variant="outline"
onClick={onTranscribe}
disabled={isTranscribing}
className="flex items-center gap-2"
>
<Mic className="h-4 w-4" />
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
</Button>
<Button
type="button"
variant="outline"
onClick={onCancel}
className="flex items-center gap-2"
>
{t('audioSample.recordAgain')}
</Button>
</div>
</div>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
);
}
@@ -0,0 +1,119 @@
import { Mic, Monitor, Pause, Play, Square } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
import { formatAudioDuration } from '@/lib/utils/audio';
interface AudioSampleSystemProps {
file: File | null | undefined;
isRecording: boolean;
duration: number;
onStart: () => void;
onStop: () => void;
onCancel: () => void;
onTranscribe: () => void;
onPlayPause: () => void;
isPlaying: boolean;
isTranscribing?: boolean;
}
export function AudioSampleSystem({
file,
isRecording,
duration,
onStart,
onStop,
onCancel,
onTranscribe,
onPlayPause,
isPlaying,
isTranscribing = false,
}: AudioSampleSystemProps) {
const { t } = useTranslation();
return (
<FormItem>
<FormControl>
<div className="space-y-4">
{!isRecording && !file && (
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
<Button type="button" onClick={onStart} size="lg" className="flex items-center gap-2">
<Monitor className="h-5 w-5" />
{t('audioSample.startCapture')}
</Button>
<p className="text-sm text-muted-foreground text-center">
{t('audioSample.systemHint')}
</p>
</div>
)}
{isRecording && (
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-destructive rounded-lg bg-destructive/5 min-h-[180px]">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
<span className="text-lg font-mono font-semibold">
{formatAudioDuration(duration)}
</span>
</div>
</div>
<Button
type="button"
onClick={onStop}
variant="destructive"
className="flex items-center gap-2"
>
<Square className="h-4 w-4" />
{t('audioSample.stopCapture')}
</Button>
<p className="text-sm text-muted-foreground text-center">
{t('audioSample.remaining', { time: formatAudioDuration(30 - duration) })}
</p>
</div>
)}
{file && !isRecording && (
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
<div className="flex items-center gap-2">
<Monitor className="h-5 w-5 text-primary" />
<span className="font-medium">{t('audioSample.captureComplete')}</span>
</div>
<p className="text-sm text-muted-foreground text-center">
{t('audioSample.fileLabel', { name: file.name })}
</p>
<div className="flex gap-2">
<Button
type="button"
size="icon"
variant="outline"
onClick={onPlayPause}
aria-label={isPlaying ? t('audioSample.pause') : t('audioSample.play')}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button
type="button"
variant="outline"
onClick={onTranscribe}
disabled={isTranscribing}
className="flex items-center gap-2"
>
<Mic className="h-4 w-4" />
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
</Button>
<Button
type="button"
variant="outline"
onClick={onCancel}
className="flex items-center gap-2"
>
{t('audioSample.captureAgain')}
</Button>
</div>
</div>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
);
}
@@ -0,0 +1,152 @@
import { Mic, Pause, Play, Upload } from 'lucide-react';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
interface AudioSampleUploadProps {
file: File | null | undefined;
onFileChange: (file: File | undefined) => void;
onTranscribe: () => void;
onPlayPause: () => void;
isPlaying: boolean;
isValidating?: boolean;
isTranscribing?: boolean;
isDisabled?: boolean;
fieldName: string;
}
export function AudioSampleUpload({
file,
onFileChange,
onTranscribe,
onPlayPause,
isPlaying,
isValidating = false,
isTranscribing = false,
isDisabled = false,
fieldName,
}: AudioSampleUploadProps) {
const { t } = useTranslation();
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
return (
<FormItem>
<FormControl>
<div className="flex flex-col gap-2">
<input
type="file"
accept="audio/*"
name={fieldName}
ref={fileInputRef}
onChange={(e) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
onFileChange(selectedFile);
} else {
onFileChange(undefined);
}
}}
className="hidden"
/>
<div
role="button"
tabIndex={0}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={(e) => {
e.preventDefault();
setIsDragging(false);
}}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
const droppedFile = e.dataTransfer.files?.[0];
if (droppedFile?.type.startsWith('audio/')) {
onFileChange(droppedFile);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
fileInputRef.current?.click();
}
}}
className={`flex flex-col items-center justify-center gap-4 p-4 border-2 rounded-lg transition-colors min-h-[180px] ${
file
? 'border-primary bg-primary/5'
: isDragging
? 'border-primary bg-primary/5'
: 'border-dashed border-muted-foreground/25 hover:border-muted-foreground/50'
}`}
>
{!file ? (
<>
<Button
type="button"
size="lg"
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2"
>
<Upload className="h-5 w-5" />
{t('audioSample.chooseFile')}
</Button>
<p className="text-sm text-muted-foreground text-center">
{t('audioSample.uploadHint')}
</p>
</>
) : (
<>
<div className="flex items-center gap-2">
<Upload className="h-5 w-5 text-primary" />
<span className="font-medium">{t('audioSample.fileUploaded')}</span>
</div>
<p className="text-sm text-muted-foreground text-center">
{t('audioSample.fileLabel', { name: file.name })}
</p>
<div className="flex gap-2">
<Button
type="button"
size="icon"
variant="outline"
onClick={onPlayPause}
disabled={isValidating}
aria-label={isPlaying ? t('audioSample.pause') : t('audioSample.play')}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button
type="button"
variant="outline"
onClick={onTranscribe}
disabled={isTranscribing || isValidating || isDisabled}
className="flex items-center gap-2"
>
<Mic className="h-4 w-4" />
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
onFileChange(undefined);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}}
>
{t('audioSample.remove')}
</Button>
</div>
</>
)}
</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
);
}
@@ -1,5 +1,6 @@
import { Edit, Eye, Mic, Trash2 } from 'lucide-react';
import { Download, Edit, Sparkles, Trash2, Wand2 } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -13,19 +14,27 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import type { VoiceProfileResponse } from '@/lib/api/types';
import { useDeleteProfile } from '@/lib/hooks/useProfiles';
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn';
import { useUIStore } from '@/stores/uiStore';
import { ProfileDetail } from './ProfileDetail';
/** Human-readable display names for preset engine badges. */
const ENGINE_DISPLAY_NAMES: Record<string, string> = {
kokoro: 'Kokoro',
qwen_custom_voice: 'CustomVoice',
};
interface ProfileCardProps {
profile: VoiceProfileResponse;
disabled?: boolean;
}
export function ProfileCard({ profile }: ProfileCardProps) {
const [detailOpen, setDetailOpen] = useState(false);
export function ProfileCard({ profile, disabled }: ProfileCardProps) {
const { t } = useTranslation();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const deleteProfile = useDeleteProfile();
const exportProfile = useExportProfile();
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
@@ -34,6 +43,11 @@ export function ProfileCard({ profile }: ProfileCardProps) {
const isSelected = selectedProfileId === profile.id;
const handleSelect = () => {
if (disabled && isSelected) {
setSelectedProfileId(null);
setTimeout(() => setSelectedProfileId(profile.id), 0);
return;
}
setSelectedProfileId(isSelected ? null : profile.id);
};
@@ -52,40 +66,76 @@ export function ProfileCard({ profile }: ProfileCardProps) {
setDeleteDialogOpen(false);
};
const handleExport = (e: React.MouseEvent) => {
e.stopPropagation();
exportProfile.mutate(profile.id);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.closest('button')) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelect();
}
};
const selectLabel = t(
isSelected ? 'profiles.card.selectLabelSelected' : 'profiles.card.selectLabel',
{ name: profile.name, language: profile.language },
);
return (
<>
<Card
className={cn(
'cursor-pointer hover:shadow-md transition-all flex flex-col',
isSelected && 'ring-2 ring-primary shadow-md',
'cursor-pointer transition-all flex flex-col h-[162px]',
disabled ? 'opacity-40 hover:opacity-60' : 'hover:shadow-md',
isSelected && !disabled && 'ring-2 border-transparent ring-accent shadow-md',
)}
onClick={handleSelect}
tabIndex={0}
role="button"
aria-label={selectLabel}
aria-pressed={isSelected}
onKeyDown={handleKeyDown}
>
<CardHeader className="p-3 pb-2">
<CardTitle className="flex items-center gap-1.5 text-base font-medium">
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center shrink-0">
<Mic className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<CardTitle className="text-base font-medium">
<span className="break-words">{profile.name}</span>
</CardTitle>
</CardHeader>
<CardContent className="p-3 pt-0 flex flex-col flex-1">
<p className="text-xs text-muted-foreground mb-1.5 line-clamp-2 leading-relaxed">
{profile.description || 'No description'}
{profile.description || t('profiles.card.noDescription')}
</p>
<div className="mb-2">
<div className="mb-2 flex items-center gap-1.5">
<Badge variant="outline" className="text-xs h-5 px-1.5 text-muted-foreground">
{profile.language}
</Badge>
{profile.voice_type === 'preset' && (
<Badge variant="secondary" className="text-xs h-5 px-1.5">
{ENGINE_DISPLAY_NAMES[profile.preset_engine ?? ''] ?? profile.preset_engine}
</Badge>
)}
{profile.voice_type === 'designed' && (
<Badge variant="secondary" className="text-xs h-5 px-1.5">
{t('profiles.card.designed')}
</Badge>
)}
{profile.effects_chain && profile.effects_chain.length > 0 && (
<Sparkles className="h-3.5 w-3.5 text-accent fill-accent" />
)}
{profile.personality?.trim() && (
<Wand2 className="h-3.5 w-3.5 text-accent" />
)}
</div>
<div className="flex gap-0.5 justify-end items-end mt-auto">
<CircleButton
icon={Eye}
onClick={(e) => {
e.stopPropagation();
setDetailOpen(true);
}}
aria-label="View details"
icon={Download}
onClick={handleExport}
disabled={exportProfile.isPending}
aria-label={t('profiles.card.export')}
/>
<CircleButton
icon={Edit}
@@ -93,38 +143,36 @@ export function ProfileCard({ profile }: ProfileCardProps) {
e.stopPropagation();
handleEdit();
}}
aria-label="Edit profile"
aria-label={t('profiles.card.edit')}
/>
<CircleButton
icon={Trash2}
onClick={handleDeleteClick}
disabled={deleteProfile.isPending}
aria-label="Delete profile"
aria-label={t('profiles.card.delete')}
/>
</div>
</CardContent>
</Card>
<ProfileDetail profileId={profile.id} open={detailOpen} onOpenChange={setDetailOpen} />
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Profile</DialogTitle>
<DialogTitle>{t('profiles.deleteDialog.title')}</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{profile.name}"? This action cannot be undone.
{t('profiles.deleteDialog.body', { name: profile.name })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={deleteProfile.isPending}
>
{deleteProfile.isPending ? 'Deleting...' : 'Delete'}
{deleteProfile.isPending ? t('profiles.deleteDialog.deleting') : t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,66 +0,0 @@
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useProfile } from '@/lib/hooks/useProfiles';
import { formatDate } from '@/lib/utils/format';
import { SampleList } from './SampleList';
interface ProfileDetailProps {
profileId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ProfileDetail({ profileId, open, onOpenChange }: ProfileDetailProps) {
const { data: profile, isLoading } = useProfile(profileId);
if (isLoading) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<div className="text-muted-foreground">Loading profile...</div>
</DialogContent>
</Dialog>
);
}
if (!profile) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{profile.name}</DialogTitle>
<DialogDescription>Manage samples and view profile details</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{profile.description && (
<div>
<h3 className="text-sm font-medium mb-1">Description</h3>
<p className="text-sm text-muted-foreground">{profile.description}</p>
</div>
)}
<div className="flex gap-2">
<Badge variant="outline">{profile.language}</Badge>
<span className="text-xs text-muted-foreground">
Created {formatDate(profile.created_at)}
</span>
</div>
<div className="border-t pt-4">
<SampleList profileId={profileId} />
</div>
</div>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,6 @@
import { Mic, Sparkles } from 'lucide-react';
import { Info, Mic, Sparkles } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useProfiles } from '@/lib/hooks/useProfiles';
@@ -6,57 +8,102 @@ import { useUIStore } from '@/stores/uiStore';
import { ProfileCard } from './ProfileCard';
import { ProfileForm } from './ProfileForm';
/** Engines that use preset (built-in) voices instead of cloned profiles. */
const PRESET_ENGINES = new Set(['kokoro', 'qwen_custom_voice']);
export function ProfileList() {
const { t } = useTranslation();
const { data: profiles, isLoading, error } = useProfiles();
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
const selectedEngine = useUIStore((state) => state.selectedEngine);
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Scroll to the selected profile after engine/sort changes
useEffect(() => {
if (!selectedProfileId) return;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const rafId = requestAnimationFrame(() => {
const el = cardRefs.current.get(selectedProfileId);
if (!el) return;
// Temporarily apply scroll-margin so it doesn't land flush at the top
el.style.scrollMarginTop = '180px';
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
timeoutId = setTimeout(() => {
el.style.scrollMarginTop = '';
}, 500);
});
return () => {
cancelAnimationFrame(rafId);
if (timeoutId) clearTimeout(timeoutId);
};
}, [selectedProfileId, selectedEngine]);
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
<div className="text-muted-foreground">Loading profiles...</div>
</div>
);
return null;
}
if (error) {
return (
<div className="flex items-center justify-center p-8">
<div className="text-destructive">Error loading profiles: {error.message}</div>
<div className="text-destructive">
{t('profiles.list.errorLoading', { message: error.message })}
</div>
</div>
);
}
const allProfiles = profiles || [];
const isPresetEngine = PRESET_ENGINES.has(selectedEngine);
/** Whether a profile is supported by the currently selected engine. */
const isSupported = (p: (typeof allProfiles)[number]) =>
isPresetEngine
? p.voice_type === 'preset' && p.preset_engine === selectedEngine
: p.voice_type !== 'preset';
// Sort so supported profiles come first
const sortedProfiles = [...allProfiles].sort(
(a, b) => (isSupported(a) ? 0 : 1) - (isSupported(b) ? 0 : 1),
);
const hasUnsupported = sortedProfiles.some((p) => !isSupported(p));
return (
<div className="flex flex-col">
<div className="flex items-center justify-between mb-4 shrink-0">
<h2 className="text-2xl font-bold">Voicebox</h2>
<Button onClick={() => setDialogOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
New Profile
</Button>
</div>
<div className="shrink-0">
{allProfiles.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Mic className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-4">
No voice profiles yet. Create your first profile to get started.
</p>
<p className="text-muted-foreground mb-4">{t('profiles.list.empty')}</p>
<Button onClick={() => setDialogOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
Create Profile
{t('profiles.list.createVoice')}
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-4 grid-cols-3 auto-rows-auto p-1">
{allProfiles.map((profile) => (
<ProfileCard key={profile.id} profile={profile} />
<div className="flex gap-4 overflow-x-auto p-1 pb-1 lg:grid lg:grid-cols-3 lg:auto-rows-auto lg:overflow-x-visible lg:pb-[150px]">
{sortedProfiles.map((profile) => (
<div
key={profile.id}
className="shrink-0 w-[200px] lg:w-auto lg:shrink"
ref={(el) => {
if (el) cardRefs.current.set(profile.id, el);
else cardRefs.current.delete(profile.id);
}}
>
<ProfileCard profile={profile} disabled={!isSupported(profile)} />
</div>
))}
{hasUnsupported && (
<div className="col-span-full flex items-center gap-2 text-xs text-muted-foreground py-2">
<Info className="h-3.5 w-3.5 shrink-0" />
<span>{t('profiles.list.unsupportedNote')}</span>
</div>
)}
</div>
)}
</div>
+326 -57
View File
@@ -1,91 +1,360 @@
import { Plus, Trash2, Play } from 'lucide-react';
import { useState } from 'react';
import { Check, Edit, Pause, Play, Plus, Trash2, Volume2, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { CircleButton } from '@/components/ui/circle-button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Slider } from '@/components/ui/slider';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { useDeleteSample, useProfileSamples } from '@/lib/hooks/useProfiles';
import { useServerStore } from '@/stores/serverStore';
import { usePlayerStore } from '@/stores/playerStore';
import { apiClient } from '@/lib/api/client';
import { useDeleteSample, useProfileSamples, useUpdateSample } from '@/lib/hooks/useProfiles';
import { formatAudioDuration } from '@/lib/utils/audio';
import { cn } from '@/lib/utils/cn';
import { SampleUpload } from './SampleUpload';
interface MiniSamplePlayerProps {
audioUrl: string;
}
function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
const { t } = useTranslation();
const audioRef = useRef<HTMLAudioElement | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const audio = new Audio(audioUrl);
audioRef.current = audio;
const handleLoadedMetadata = () => {
setDuration(audio.duration);
setIsLoading(false);
};
const handleTimeUpdate = () => {
setCurrentTime(audio.currentTime);
};
const handleEnded = () => {
setIsPlaying(false);
setCurrentTime(0);
};
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
audio.addEventListener('timeupdate', handleTimeUpdate);
audio.addEventListener('ended', handleEnded);
audio.addEventListener('play', handlePlay);
audio.addEventListener('pause', handlePause);
return () => {
audio.pause();
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
audio.removeEventListener('timeupdate', handleTimeUpdate);
audio.removeEventListener('ended', handleEnded);
audio.removeEventListener('play', handlePlay);
audio.removeEventListener('pause', handlePause);
audio.src = '';
};
}, [audioUrl]);
const handlePlayPause = () => {
if (!audioRef.current) return;
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play();
}
};
const handleSeek = (value: number[]) => {
if (!audioRef.current || duration === 0) return;
const progress = value[0] / 100;
audioRef.current.currentTime = progress * duration;
};
const handleStop = () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
}
setIsPlaying(false);
setCurrentTime(0);
};
return (
<div className="border-t bg-muted/30 px-3 py-2 mt-2">
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={handlePlayPause}
disabled={isLoading}
aria-label={isPlaying ? t('sampleList.player.pause') : t('sampleList.player.play')}
>
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5 ml-0.5" />}
</Button>
<div className="flex-1 min-w-0 flex items-center gap-2">
<Slider
value={duration > 0 ? [(currentTime / duration) * 100] : [0]}
onValueChange={handleSeek}
max={100}
step={0.1}
className="flex-1"
aria-label={t('sampleList.player.position')}
aria-valuetext={t('sampleList.player.positionValue', {
current: formatAudioDuration(currentTime),
total: formatAudioDuration(duration),
})}
/>
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 min-w-[70px]">
<span className="font-mono">{formatAudioDuration(currentTime)}</span>
<span>/</span>
<span className="font-mono">{formatAudioDuration(duration)}</span>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={handleStop}
title={t('sampleList.player.stop')}
aria-label={t('sampleList.player.stopAria')}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}
interface SampleListProps {
profileId: string;
}
export function SampleList({ profileId }: SampleListProps) {
const { t } = useTranslation();
const { data: samples, isLoading } = useProfileSamples(profileId);
const deleteSample = useDeleteSample();
const [uploadOpen, setUploadOpen] = useState(false);
const updateSample = useUpdateSample();
const { toast } = useToast();
const serverUrl = useServerStore((state) => state.serverUrl);
const setAudio = usePlayerStore((state) => state.setAudio);
const currentAudioId = usePlayerStore((state) => state.audioId);
const isPlaying = usePlayerStore((state) => state.isPlaying);
const [uploadOpen, setUploadOpen] = useState(false);
const [editingSampleId, setEditingSampleId] = useState<string | null>(null);
const [editedText, setEditedText] = useState<string>('');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [sampleToDelete, setSampleToDelete] = useState<string | null>(null);
const handleDelete = (sampleId: string) => {
if (confirm('Are you sure you want to delete this sample?')) {
deleteSample.mutate(sampleId);
const handleDeleteClick = (sampleId: string) => {
setSampleToDelete(sampleId);
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = () => {
if (sampleToDelete) {
deleteSample.mutate(sampleToDelete);
setDeleteDialogOpen(false);
setSampleToDelete(null);
}
};
const handlePlay = (audioPath: string, referenceText: string, sampleId: string) => {
const audioUrl = `${serverUrl}${audioPath}`;
setAudio(audioUrl, sampleId, referenceText.substring(0, 50));
const handleStartEdit = (sampleId: string, currentText: string) => {
setEditingSampleId(sampleId);
setEditedText(currentText);
};
const handleCancelEdit = () => {
setEditingSampleId(null);
setEditedText('');
};
const handleSaveEdit = async (sampleId: string) => {
if (!editedText.trim()) {
toast({
title: t('sampleList.toast.invalidText'),
description: t('sampleList.toast.invalidTextDescription'),
variant: 'destructive',
});
return;
}
try {
await updateSample.mutateAsync({ sampleId, referenceText: editedText.trim() });
toast({
title: t('sampleList.toast.updated'),
description: t('sampleList.toast.updatedDescription'),
});
setEditingSampleId(null);
setEditedText('');
} catch (error) {
toast({
title: t('sampleList.toast.updateFailed'),
description:
error instanceof Error ? error.message : t('sampleList.toast.updateFailedFallback'),
variant: 'destructive',
});
}
};
if (isLoading) {
return <div className="text-sm text-muted-foreground">Loading samples...</div>;
return <div className="text-sm text-muted-foreground">{t('sampleList.loading')}</div>;
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Audio Samples</h3>
<Button size="sm" onClick={() => setUploadOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Sample
</Button>
</div>
<div className="space-y-4 pt-4">
{samples && samples.length === 0 ? (
<div className="text-sm text-muted-foreground py-4">
No samples yet. Add your first audio sample.
<div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg">
<Volume2 className="h-8 w-8 text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">{t('sampleList.empty.title')}</p>
<p className="text-xs text-muted-foreground/70 mt-1">{t('sampleList.empty.hint')}</p>
</div>
) : (
<div className="space-y-2">
{samples?.map((sample) => (
<div
key={sample.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex-1">
<p className="text-sm font-medium">{sample.reference_text}</p>
<p className="text-xs text-muted-foreground mt-1">{sample.audio_path}</p>
{samples?.map((sample, index) => {
const isEditing = editingSampleId === sample.id;
return (
<div
key={sample.id}
className={cn(
'group relative rounded-lg border bg-card transition-all duration-200',
isEditing ? 'ring-2 ring-primary/20' : 'hover:border-primary/30',
)}
>
{isEditing ? (
/* Edit Mode */
<div className="p-4 space-y-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
<Edit className="h-3 w-3" />
<span>{t('sampleList.editing')}</span>
</div>
<Textarea
value={editedText}
onChange={(e) => setEditedText(e.target.value)}
className="min-h-[100px] text-sm resize-none"
placeholder={t('sampleList.placeholder')}
autoFocus
/>
<div className="flex items-center justify-end gap-2 pt-1">
<Button
type="button"
size="sm"
variant="ghost"
onClick={handleCancelEdit}
disabled={updateSample.isPending}
>
<X className="h-4 w-4 mr-1" />
{t('common.cancel')}
</Button>
<Button
type="button"
size="sm"
onClick={() => handleSaveEdit(sample.id)}
disabled={updateSample.isPending}
>
<Check className="h-4 w-4 mr-1" />
{updateSample.isPending ? t('sampleList.saving') : t('common.save')}
</Button>
</div>
</div>
) : (
<>
{/* View Mode */}
<div className="flex items-center gap-3 p-3 h-[72px]">
{/* Text Content */}
<div className="flex-1 min-w-0 py-0.5">
<p className="text-sm font-medium line-clamp-2 leading-snug">
{sample.reference_text}
</p>
</div>
{/* Action Buttons */}
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<CircleButton
icon={Edit}
title={t('sampleList.editTranscription')}
onClick={() => handleStartEdit(sample.id, sample.reference_text)}
/>
<CircleButton
icon={Trash2}
title={t('sampleList.deleteSample')}
onClick={() => handleDeleteClick(sample.id)}
disabled={deleteSample.isPending}
/>
</div>
{/* Sample Number Badge */}
<div className="absolute top-1 right-2 text-[10px] text-muted-foreground/50 font-medium">
#{index + 1}
</div>
</div>
{/* Mini Player - Always visible */}
<MiniSamplePlayer audioUrl={apiClient.getSampleUrl(sample.id)} />
</>
)}
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handlePlay(sample.audio_path, sample.reference_text, sample.id)}
className={currentAudioId === sample.id && isPlaying ? 'text-primary' : ''}
>
<Play className="h-4 w-4 mr-1" />
Play
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(sample.id)}
disabled={deleteSample.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
))}
);
})}
</div>
)}
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => setUploadOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
{t('sampleList.addSample')}
</Button>
<p className="text-xs text-muted-foreground text-center px-2">{t('sampleList.note')}</p>
<SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} />
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('sampleList.deleteDialog.title')}</DialogTitle>
<DialogDescription>{t('sampleList.deleteDialog.description')}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDeleteDialogOpen(false);
setSampleToDelete(null);
}}
>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={deleteSample.isPending}
>
{deleteSample.isPending ? t('sampleList.deleteDialog.deleting') : t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+131 -147
View File
@@ -1,6 +1,7 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Mic, Monitor, Upload } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useState, useEffect } from 'react';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import {
@@ -13,21 +14,23 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
import { useTranscription } from '@/lib/hooks/useTranscription';
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
import { Mic, Square, Upload } from 'lucide-react';
import { formatAudioDuration } from '@/lib/utils/audio';
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
import { useTranscription } from '@/lib/hooks/useTranscription';
import { usePlatform } from '@/platform/PlatformContext';
import { AudioSampleRecording } from './AudioSampleRecording';
import { AudioSampleSystem } from './AudioSampleSystem';
import { AudioSampleUpload } from './AudioSampleUpload';
const sampleSchema = z.object({
file: z.instanceof(File, { message: 'Please select an audio file' }),
@@ -46,11 +49,13 @@ interface SampleUploadProps {
}
export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProps) {
const platform = usePlatform();
const addSample = useAddSample();
const transcribe = useTranscription();
const { data: profile } = useProfile(profileId);
const { toast } = useToast();
const [mode, setMode] = useState<'upload' | 'record'>('upload');
const [mode, setMode] = useState<'upload' | 'record' | 'system'>('upload');
const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer();
const form = useForm<SampleFormValues>({
resolver: zodResolver(sampleSchema),
@@ -69,12 +74,16 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
stopRecording,
cancelRecording,
} = useAudioRecording({
maxDurationSeconds: 30,
onRecordingComplete: (blob) => {
maxDurationSeconds: 29,
onRecordingComplete: (blob, recordedDuration) => {
// Convert blob to File object
const file = new File([blob], `recording-${Date.now()}.webm`, {
type: blob.type || 'audio/webm',
});
}) as File & { recordedDuration?: number };
// Store the actual recorded duration to bypass metadata reading issues on Windows
if (recordedDuration !== undefined) {
file.recordedDuration = recordedDuration;
}
form.setValue('file', file, { shouldValidate: true });
toast({
title: 'Recording complete',
@@ -83,6 +92,33 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
},
});
const {
isRecording: isSystemRecording,
duration: systemDuration,
error: systemRecordingError,
isSupported: isSystemAudioSupported,
startRecording: startSystemRecording,
stopRecording: stopSystemRecording,
cancelRecording: cancelSystemRecording,
} = useSystemAudioCapture({
maxDurationSeconds: 29,
onRecordingComplete: (blob, recordedDuration) => {
// Convert blob to File object
const file = new File([blob], `system-audio-${Date.now()}.wav`, {
type: blob.type || 'audio/wav',
}) as File & { recordedDuration?: number };
// Store the actual recorded duration to bypass metadata reading issues on Windows
if (recordedDuration !== undefined) {
file.recordedDuration = recordedDuration;
}
form.setValue('file', file, { shouldValidate: true });
toast({
title: 'System audio captured',
description: 'Audio has been captured successfully.',
});
},
});
// Show recording errors
useEffect(() => {
if (recordingError) {
@@ -94,6 +130,17 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
}
}, [recordingError, toast]);
// Show system audio recording errors
useEffect(() => {
if (systemRecordingError) {
toast({
title: 'System audio capture error',
description: systemRecordingError,
variant: 'destructive',
});
}
}, [systemRecordingError, toast]);
async function handleTranscribe() {
const file = form.getValues('file');
if (!file) {
@@ -110,11 +157,6 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
const result = await transcribe.mutateAsync({ file, language });
form.setValue('referenceText', result.text, { shouldValidate: true });
toast({
title: 'Transcription complete',
description: 'Audio has been transcribed successfully.',
});
} catch (error) {
toast({
title: 'Transcription failed',
@@ -154,14 +196,27 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
if (isRecording) {
cancelRecording();
}
if (isSystemRecording) {
cancelSystemRecording();
}
cleanupAudio();
}
onOpenChange(newOpen);
}
function handleCancelRecording() {
cancelRecording();
// Reset file field by clearing the input
if (mode === 'record') {
cancelRecording();
} else if (mode === 'system') {
cancelSystemRecording();
}
form.resetField('file');
cleanupAudio();
}
function handlePlayPause() {
const file = form.getValues('file');
playPause(file);
}
return (
@@ -176,58 +231,40 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<Tabs value={mode} onValueChange={(v) => setMode(v as 'upload' | 'record')}>
<TabsList className="grid w-full grid-cols-2">
<Tabs value={mode} onValueChange={(v) => setMode(v as 'upload' | 'record' | 'system')}>
<TabsList
className={`grid w-full ${platform.metadata.isTauri && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
>
<TabsTrigger value="upload" className="flex items-center gap-2">
<Upload className="h-4 w-4" />
<Upload className="h-4 w-4 shrink-0" />
Upload
</TabsTrigger>
<TabsTrigger value="record" className="flex items-center gap-2">
<Mic className="h-4 w-4" />
<Mic className="h-4 w-4 shrink-0" />
Record
</TabsTrigger>
{platform.metadata.isTauri && isSystemAudioSupported && (
<TabsTrigger value="system" className="flex items-center gap-2">
<Monitor className="h-4 w-4 shrink-0" />
System Audio
</TabsTrigger>
)}
</TabsList>
<TabsContent value="upload" className="space-y-4">
<FormField
control={form.control}
name="file"
render={({ field: { onChange, value, ...field } }) => (
<FormItem>
<FormLabel>Audio File</FormLabel>
<FormControl>
<div className="flex items-center gap-2">
<Input
type="file"
accept="audio/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
onChange(file);
}
}}
{...field}
/>
{selectedFile && (
<Button
type="button"
variant="outline"
onClick={handleTranscribe}
disabled={transcribe.isPending}
className="flex items-center gap-2"
>
<Mic className="h-4 w-4" />
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'}
</Button>
)}
</div>
</FormControl>
<FormDescription>
Supported formats: WAV, MP3, M4A. Click "Transcribe" to automatically
extract text from the audio.
</FormDescription>
<FormMessage />
</FormItem>
render={({ field: { onChange, name } }) => (
<AudioSampleUpload
file={selectedFile}
onFileChange={onChange}
onTranscribe={handleTranscribe}
onPlayPause={handlePlayPause}
isPlaying={isPlaying}
isTranscribing={transcribe.isPending}
fieldName={name}
/>
)}
/>
</TabsContent>
@@ -237,94 +274,44 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
control={form.control}
name="file"
render={() => (
<FormItem>
<FormLabel>Record Audio</FormLabel>
<FormControl>
<div className="space-y-4">
{!isRecording && !selectedFile && (
<div className="flex flex-col items-center gap-4 p-6 border-2 border-dashed rounded-lg">
<Button
type="button"
onClick={startRecording}
size="lg"
className="flex items-center gap-2"
>
<Mic className="h-5 w-5" />
Start Recording
</Button>
<p className="text-sm text-muted-foreground text-center">
Click to start recording. Maximum duration: 30 seconds.
</p>
</div>
)}
{isRecording && (
<div className="flex flex-col items-center gap-4 p-6 border-2 border-destructive rounded-lg bg-destructive/5">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
<span className="text-lg font-mono font-semibold">
{formatAudioDuration(duration)}
</span>
</div>
</div>
<Button
type="button"
onClick={stopRecording}
variant="destructive"
className="flex items-center gap-2"
>
<Square className="h-4 w-4" />
Stop Recording
</Button>
<p className="text-sm text-muted-foreground text-center">
Recording in progress... ({formatAudioDuration(30 - duration)}{' '}
remaining)
</p>
</div>
)}
{selectedFile && !isRecording && (
<div className="flex flex-col items-center gap-4 p-6 border-2 border-primary rounded-lg bg-primary/5">
<div className="flex items-center gap-2">
<Mic className="h-5 w-5 text-primary" />
<span className="font-medium">Recording complete</span>
</div>
<p className="text-sm text-muted-foreground">
File: {selectedFile.name}
</p>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={handleTranscribe}
disabled={transcribe.isPending}
className="flex items-center gap-2"
>
<Mic className="h-4 w-4" />
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'}
</Button>
<Button
type="button"
variant="outline"
onClick={handleCancelRecording}
className="flex items-center gap-2"
>
Record Again
</Button>
</div>
</div>
)}
</div>
</FormControl>
<FormDescription>
Record audio directly from your microphone. Maximum duration is 30 seconds.
</FormDescription>
<FormMessage />
</FormItem>
<AudioSampleRecording
file={selectedFile}
isRecording={isRecording}
duration={duration}
onStart={startRecording}
onStop={stopRecording}
onCancel={handleCancelRecording}
onTranscribe={handleTranscribe}
onPlayPause={handlePlayPause}
isPlaying={isPlaying}
isTranscribing={transcribe.isPending}
/>
)}
/>
</TabsContent>
{platform.metadata.isTauri && isSystemAudioSupported && (
<TabsContent value="system" className="space-y-4">
<FormField
control={form.control}
name="file"
render={() => (
<AudioSampleSystem
file={selectedFile}
isRecording={isSystemRecording}
duration={systemDuration}
onStart={startSystemRecording}
onStop={stopSystemRecording}
onCancel={handleCancelRecording}
onTranscribe={handleTranscribe}
onPlayPause={handlePlayPause}
isPlaying={isPlaying}
isTranscribing={transcribe.isPending}
/>
)}
/>
</TabsContent>
)}
</Tabs>
<FormField
@@ -340,9 +327,6 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
{...field}
/>
</FormControl>
<FormDescription>
This should match exactly what is spoken in the audio file.
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -0,0 +1,359 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Edit2, Mic, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import * as z from 'zod';
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { SampleList } from '@/components/VoiceProfiles/SampleList';
import { apiClient } from '@/lib/api/client';
import type { EffectConfig } from '@/lib/api/types';
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import {
useDeleteAvatar,
useProfile,
useUpdateProfile,
useUploadAvatar,
} from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore';
import { useServerStore } from '@/stores/serverStore';
function makeProfileSchema(t: (key: string) => string) {
return z.object({
name: z.string().min(1, t('profileForm.validation.nameRequired')).max(100),
description: z.string().max(500).optional(),
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
});
}
type ProfileFormValues = {
name: string;
description?: string;
language: LanguageCode;
};
interface VoiceInspectorProps {
profileId: string;
}
export function VoiceInspector({ profileId }: VoiceInspectorProps) {
const { t } = useTranslation();
const { data: profile } = useProfile(profileId);
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
const updateProfile = useUpdateProfile();
const uploadAvatar = useUploadAvatar();
const deleteAvatar = useDeleteAvatar();
const serverUrl = useServerStore((state) => state.serverUrl);
const { toast } = useToast();
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [avatarError, setAvatarError] = useState(false);
const avatarInputRef = useRef<HTMLInputElement>(null);
const [effectsChain, setEffectsChain] = useState<EffectConfig[]>([]);
const [effectsDirty, setEffectsDirty] = useState(false);
const form = useForm<ProfileFormValues>({
resolver: zodResolver(makeProfileSchema(t)),
defaultValues: {
name: '',
description: '',
language: 'en',
},
});
// Populate form when profile loads
useEffect(() => {
if (profile) {
form.reset({
name: profile.name,
description: profile.description || '',
language: profile.language as LanguageCode,
});
setEffectsChain(profile.effects_chain ?? []);
setEffectsDirty(false);
}
}, [profile, form]);
// Avatar preview
useEffect(() => {
if (profile?.avatar_path) {
setAvatarPreview(`${serverUrl}/profiles/${profile.id}/avatar`);
} else {
setAvatarPreview(null);
}
setAvatarError(false);
}, [profile, serverUrl]);
function handleAvatarFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
toast({
title: t('profileForm.toast.invalidFile'),
description: t('voiceInspector.toast.invalidImageFormat'),
variant: 'destructive',
});
return;
}
if (file.size > 5 * 1024 * 1024) {
toast({
title: t('profileForm.toast.fileTooLarge'),
description: t('profileForm.toast.imageTooLargeDescription'),
variant: 'destructive',
});
return;
}
uploadAvatar.mutate(
{ profileId, file },
{
onSuccess: () => {
setAvatarPreview(URL.createObjectURL(file));
toast({ title: t('voiceInspector.toast.avatarUpdated') });
},
onError: (err) => {
toast({
title: t('profileForm.toast.avatarUploadFailed'),
description: err instanceof Error ? err.message : t('common.unknownError'),
variant: 'destructive',
});
},
},
);
}
async function handleRemoveAvatar() {
if (profile?.avatar_path) {
try {
await deleteAvatar.mutateAsync(profileId);
toast({ title: t('profileForm.toast.avatarRemoved') });
} catch (err) {
toast({
title: t('profileForm.toast.avatarRemoveFailed'),
description: err instanceof Error ? err.message : t('common.unknownError'),
variant: 'destructive',
});
}
}
setAvatarPreview(null);
if (avatarInputRef.current) avatarInputRef.current.value = '';
}
async function onSubmit(data: ProfileFormValues) {
try {
await updateProfile.mutateAsync({
profileId,
data: {
name: data.name,
description: data.description,
language: data.language,
},
});
if (effectsDirty) {
try {
await apiClient.updateProfileEffects(
profileId,
effectsChain.length > 0 ? effectsChain : null,
);
setEffectsDirty(false);
} catch (fxError) {
toast({
title: t('profileForm.toast.effectsUpdateFailed'),
description:
fxError instanceof Error
? fxError.message
: t('profileForm.toast.effectsUpdateFailedFallback'),
variant: 'destructive',
});
return;
}
}
toast({
title: t('profileForm.toast.voiceUpdated'),
description: t('voiceInspector.toast.savedDescription', { name: data.name }),
});
} catch (error) {
toast({
title: t('common.error'),
description: error instanceof Error ? error.message : t('profileForm.toast.saveFailed'),
variant: 'destructive',
});
}
}
if (!profile) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
{t('voiceInspector.loading')}
</div>
);
}
const isDirty = form.formState.isDirty || effectsDirty;
return (
<div className="h-full flex flex-col overflow-hidden">
<div className={cn('flex-1 overflow-y-auto', isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-0">
{/* Avatar */}
<div className="flex justify-center pt-5 pb-3">
<div className="relative group">
<div className="h-20 w-20 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden border-2 border-border">
{avatarPreview && !avatarError ? (
<img
src={avatarPreview}
alt={profile.name}
className="h-full w-full object-cover"
onError={() => setAvatarError(true)}
/>
) : (
<Mic className="h-8 w-8 text-muted-foreground" />
)}
</div>
<button
type="button"
onClick={() => avatarInputRef.current?.click()}
className="absolute inset-0 rounded-full bg-accent/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer"
>
<Edit2 className="h-5 w-5 text-accent-foreground" />
</button>
{avatarPreview && (
<button
type="button"
onClick={handleRemoveAvatar}
disabled={deleteAvatar.isPending}
className="absolute bottom-0 right-0 h-5 w-5 rounded-full bg-background/60 backdrop-blur-sm text-muted-foreground flex items-center justify-center hover:bg-background/80 hover:text-foreground transition-colors shadow-sm border border-border/50"
>
<X className="h-3 w-3" />
</button>
)}
</div>
<input
ref={avatarInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleAvatarFileChange}
className="hidden"
/>
</div>
{/* Fields */}
<div className="space-y-3 px-5">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('profileForm.fields.name')}</FormLabel>
<FormControl>
<Input placeholder={t('profileForm.fields.namePlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('voiceInspector.fields.description')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('profileForm.fields.descriptionPlaceholder')}
rows={2}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>{t('profileForm.fields.language')}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{LANGUAGE_OPTIONS.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Effects */}
<div className="space-y-2">
<FormLabel>{t('profileForm.fields.defaultEffects')}</FormLabel>
<p className="text-xs text-muted-foreground">
{t('voiceInspector.defaultEffectsHint')}
</p>
<EffectsChainEditor
value={effectsChain}
onChange={(chain) => {
setEffectsChain(chain);
setEffectsDirty(true);
}}
compact
/>
</div>
{/* Save */}
{isDirty && (
<Button type="submit" className="w-full" disabled={updateProfile.isPending}>
{updateProfile.isPending
? t('profileForm.actions.saving')
: t('profileForm.actions.saveChanges')}
</Button>
)}
</div>
{/* Samples */}
<div className="px-5 pb-5">
<SampleList profileId={profileId} />
</div>
</form>
</Form>
</div>
</div>
);
}
+265
View File
@@ -0,0 +1,265 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Mic, Plus, Search, Sparkles } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { MultiSelect } from '@/components/ui/multi-select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ProfileForm } from '@/components/VoiceProfiles/ProfileForm';
import { apiClient } from '@/lib/api/client';
import type { VoiceProfileResponse } from '@/lib/api/types';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { useProfiles } from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore';
import { useServerStore } from '@/stores/serverStore';
import { useUIStore } from '@/stores/uiStore';
import { VoiceInspector } from './VoiceInspector';
export function VoicesTab() {
const { t } = useTranslation();
const { data: profiles, isLoading } = useProfiles();
const queryClient = useQueryClient();
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
const selectedVoiceId = useUIStore((state) => state.selectedVoiceId);
const setSelectedVoiceId = useUIStore((state) => state.setSelectedVoiceId);
const scrollRef = useRef<HTMLDivElement>(null);
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
const [search, setSearch] = useState('');
const filteredProfiles = useMemo(() => {
if (!profiles) return [];
if (!search.trim()) return profiles;
const q = search.toLowerCase();
return profiles.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.description?.toLowerCase().includes(q) ||
p.language.toLowerCase().includes(q),
);
}, [profiles, search]);
// Auto-select first profile if none selected
useEffect(() => {
if (!selectedVoiceId && profiles && profiles.length > 0) {
setSelectedVoiceId(profiles[0].id);
}
// Clear selection if selected profile was deleted
if (selectedVoiceId && profiles && !profiles.find((p) => p.id === selectedVoiceId)) {
setSelectedVoiceId(profiles.length > 0 ? profiles[0].id : null);
}
}, [profiles, selectedVoiceId, setSelectedVoiceId]);
// Get channel assignments for each profile
const { data: channelAssignments } = useQuery({
queryKey: ['profile-channels'],
queryFn: async () => {
if (!profiles) return {};
const assignments: Record<string, string[]> = {};
for (const profile of profiles) {
try {
const result = await apiClient.getProfileChannels(profile.id);
assignments[profile.id] = result.channel_ids;
} catch {
assignments[profile.id] = [];
}
}
return assignments;
},
enabled: !!profiles,
});
// Get all channels
const { data: channels } = useQuery({
queryKey: ['channels'],
queryFn: () => apiClient.listChannels(),
});
const handleChannelChange = async (profileId: string, channelIds: string[]) => {
try {
await apiClient.setProfileChannels(profileId, channelIds);
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
} catch (error) {
console.error('Failed to update channels:', error);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-muted-foreground">{t('voicesTab.loading')}</div>
</div>
);
}
return (
<div className="h-full flex gap-0 overflow-hidden -mx-8">
{/* Left: Table */}
<div className="flex-1 min-w-0 flex flex-col relative overflow-hidden">
{/* Scroll Mask */}
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
{/* Fixed Header */}
<div className="absolute top-0 left-0 right-0 z-20 pl-8 pr-8">
<div className="flex items-center gap-3 mb-6">
<h1 className="text-2xl font-bold">{t('voicesTab.title')}</h1>
<div className="flex-1" />
<div className="relative w-[240px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder={t('voicesTab.searchPlaceholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-10 pl-8 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
{t('voicesTab.newVoice')}
</Button>
</div>
</div>
{/* Scrollable Content */}
<div
ref={scrollRef}
className={cn(
'flex-1 overflow-y-auto overflow-x-hidden pt-16 relative z-0',
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
)}
>
<Table className="table-fixed [&_td:first-child]:pl-8 [&_th:first-child]:pl-8">
<TableHeader>
<TableRow>
<TableHead className="w-[30%]">{t('voicesTab.columns.name')}</TableHead>
<TableHead className="w-[10%]">{t('voicesTab.columns.language')}</TableHead>
<TableHead className="w-[10%]">{t('voicesTab.columns.generations')}</TableHead>
<TableHead className="w-[8%]">{t('voicesTab.columns.samples')}</TableHead>
<TableHead className="w-[8%]">{t('voicesTab.columns.effects')}</TableHead>
<TableHead className="w-[24%]">{t('voicesTab.columns.channels')}</TableHead>
<TableHead className="w-6"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredProfiles.map((profile) => (
<VoiceRow
key={profile.id}
profile={profile}
isSelected={selectedVoiceId === profile.id}
onSelect={() => setSelectedVoiceId(profile.id)}
channelIds={channelAssignments?.[profile.id] || []}
channels={channels || []}
onChannelChange={(channelIds) => handleChannelChange(profile.id, channelIds)}
/>
))}
</TableBody>
</Table>
</div>
</div>
{/* Right: Inspector */}
{selectedVoiceId && (
<div className="w-[340px] shrink-0 border-l border-t rounded-tl-xl bg-muted/30">
<VoiceInspector key={selectedVoiceId} profileId={selectedVoiceId} />
</div>
)}
<ProfileForm />
</div>
);
}
interface VoiceRowProps {
profile: VoiceProfileResponse;
isSelected: boolean;
onSelect: () => void;
channelIds: string[];
channels: Array<{ id: string; name: string; is_default: boolean }>;
onChannelChange: (channelIds: string[]) => void;
}
function VoiceRow({
profile,
isSelected,
onSelect,
channelIds,
channels,
onChannelChange,
}: VoiceRowProps) {
const { t } = useTranslation();
const serverUrl = useServerStore((state) => state.serverUrl);
const [avatarError, setAvatarError] = useState(false);
const avatarUrl = profile.avatar_path ? `${serverUrl}/profiles/${profile.id}/avatar` : null;
const enabledEffects = profile.effects_chain?.filter((e) => e.enabled) ?? [];
const effectsSummary = enabledEffects.map((e) => e.type).join(' → ');
return (
<TableRow
className={cn('cursor-pointer', isSelected ? 'bg-muted/50' : 'hover:bg-muted/50')}
onClick={onSelect}
>
<TableCell>
<div className="flex w-full min-w-0 items-center gap-2">
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden">
{avatarUrl && !avatarError ? (
<img
src={avatarUrl}
alt={t('voicesTab.avatarAlt', { name: profile.name })}
className="h-full w-full object-cover"
onError={() => setAvatarError(true)}
/>
) : (
<Mic className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="min-w-0">
<div className="font-medium truncate">{profile.name}</div>
{profile.description && (
<div className="text-sm text-muted-foreground truncate">{profile.description}</div>
)}
</div>
</div>
</TableCell>
<TableCell>{profile.language}</TableCell>
<TableCell>{profile.generation_count}</TableCell>
<TableCell>{profile.sample_count}</TableCell>
<TableCell>
{enabledEffects.length > 0 ? (
<span
className="inline-flex items-center gap-1 text-xs text-accent"
title={effectsSummary}
>
<Sparkles className="h-3 w-3 fill-accent" />
{enabledEffects.length}
</span>
) : (
<span className="text-xs text-muted-foreground">—</span>
)}
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<MultiSelect
options={channels.map((ch) => ({
value: ch.id,
label: ch.is_default ? t('voicesTab.channelDefaultLabel', { name: ch.name }) : ch.name,
}))}
value={channelIds}
onChange={onChannelChange}
placeholder={t('voicesTab.selectChannels')}
className="w-full"
/>
</TableCell>
<TableCell />
</TableRow>
);
}
+114
View File
@@ -0,0 +1,114 @@
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
import { buttonVariants } from './button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
+1 -1
View File
@@ -9,7 +9,7 @@ const badgeVariants = cva(
variant: {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
'border-border bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
+7 -3
View File
@@ -3,14 +3,18 @@ import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
const buttonVariants = cva([
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm',
'font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2',
'focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'
],
{
variants: {
variant: {
default: 'bg-accent text-accent-foreground hover:bg-accent/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
outline: 'border border-input bg-background hover:bg-accent hover:border-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-accent underline-offset-4 hover:underline',
+27 -19
View File
@@ -1,33 +1,41 @@
import { Check } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
export interface CheckboxProps {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
disabled?: boolean;
className?: string;
id?: string;
}
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
({ className, onCheckedChange, ...props }, ref) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (onCheckedChange) {
onCheckedChange(e.target.checked);
}
// Call original onChange if provided
if (props.onChange) {
props.onChange(e);
}
};
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
({ checked = false, onCheckedChange, disabled = false, className, id, ...props }, ref) => {
return (
<input
type="checkbox"
<button
type="button"
ref={ref}
id={id}
role="checkbox"
aria-checked={checked}
disabled={disabled}
onClick={() => {
if (!disabled && onCheckedChange) {
onCheckedChange(!checked);
}
}}
className={cn(
'h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'h-4 w-4 rounded border-2 flex items-center justify-center shrink-0 transition-colors',
checked ? 'bg-accent border-accent' : 'border-muted-foreground/30',
disabled && 'opacity-50 cursor-not-allowed',
!disabled && 'cursor-pointer',
className,
)}
ref={ref}
onChange={handleChange}
{...props}
/>
>
{checked && <Check className="h-3 w-3 text-accent-foreground" />}
</button>
);
},
);
+2 -1
View File
@@ -6,10 +6,11 @@ export interface CircleButtonProps extends React.ButtonHTMLAttributes<HTMLButton
}
const CircleButton = React.forwardRef<HTMLButtonElement, CircleButtonProps>(
({ className, icon: Icon, ...props }, ref) => {
({ className, icon: Icon, type = 'button', ...props }, ref) => {
return (
<button
ref={ref}
type={type}
className={cn(
'h-7 w-7 rounded-full flex items-center justify-center flex-shrink-0',
'hover:bg-muted transition-colors',

Some files were not shown because too many files have changed in this diff Show More