Compare commits

..
Author SHA1 Message Date
James Pine f4d21504e3 Mobile companion app + paired-device backend
New iOS-first companion (Expo SDK 54 + NativeWind v4) with three tabs:
Captures (the hero — floating gold mic, live mic-meter waveform,
expand-row playback), Generate (profile picker + speak + autoplay +
recent), and Voices (searchable profile list).

Pairing (V0): backend mints a one-time token, mobile scans/pastes the
voicebox:// URL, server returns a long-lived bearer it stores only as a
SHA-256 hash. Bearer-or-loopback auth applied to every user-data router
so binding 0.0.0.0 doesn't leak existing endpoints. Loopback callers
(the desktop app) keep their friction-free access.

Desktop Settings → Mobile pane: live host picker (LAN / Tailscale auto-
detected via the App-bundle binary path on macOS), QR rendering,
5-minute expiry countdown, copyable URL fallback, paired-device list
with revoke. Auto-closes when a new device pairs.

just dev now binds the backend to 0.0.0.0 so paired phones can reach
it — and just setup-python pins mlx-audio==0.4.1 + mlx-lm so fresh
Apple Silicon worktrees get a working STT path on first install.
2026-04-25 17:09:32 -07:00
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
246 changed files with 31542 additions and 2521 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.4.1
current_version = 0.5.0
commit = True
tag = True
tag_name = v{new_version}
+3
View File
@@ -29,11 +29,14 @@ jobs:
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
+47 -13
View File
@@ -26,17 +26,6 @@ jobs:
args: ""
python-version: "3.12"
backend: "pytorch"
- platform: "ubuntu-22.04"
# --config override disables updater-artifact generation on Linux.
# tauri.conf.json has createUpdaterArtifacts: "v1Compatible" which
# on Linux wants to synthesize a .AppImage.tar.gz by downloading
# linuxdeploy at build time — this is what silently hangs CI
# (see v0.4.2 round 2, 25 min of no output after rpm bundling).
# We ship deb+rpm only; Linux users update via apt/dnf, not the
# Tauri in-app updater.
args: '--target x86_64-unknown-linux-gnu --bundles deb,rpm --verbose --config {"bundle":{"createUpdaterArtifacts":false}}'
python-version: "3.12"
backend: "pytorch"
runs-on: ${{ matrix.platform }}
@@ -106,8 +95,10 @@ jobs:
# 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;
# the rest (sounddevice, miniaudio, protobuf, sentencepiece, pyyaml,
# jinja2) are pulled in by other engines.
# 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
@@ -123,6 +114,7 @@ jobs:
run: |
cd backend
python build_binary.py
python build_binary.py --shim
# Get platform tuple
PLATFORM=$(rustc --print host-tuple)
@@ -132,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
@@ -235,6 +229,46 @@ jobs:
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:
+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"
}
}
}
}
+151 -2
View File
@@ -5,7 +5,152 @@
# Changelog
## [Unreleased]
## [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
@@ -595,7 +740,11 @@ The first public release of Voicebox — an open-source voice synthesis studio p
Tauri v2, React, TypeScript, Tailwind CSS, FastAPI, Qwen3-TTS, Whisper, SQLite
[Unreleased]: https://github.com/jamiepine/voicebox/compare/v0.4.1...HEAD
[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
+2 -2
View File
@@ -359,7 +359,7 @@ Releases are managed by maintainers:
## Troubleshooting
See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues and solutions.
See [docs/content/docs/overview/troubleshooting.mdx](docs/content/docs/overview/troubleshooting.mdx) for common issues and solutions.
**Quick fixes:**
@@ -372,7 +372,7 @@ See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues and sol
- Open an issue for bugs or feature requests
- Check existing issues and discussions
- Review the codebase to understand patterns
- See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues
- See [docs/content/docs/overview/troubleshooting.mdx](docs/content/docs/overview/troubleshooting.mdx) for common issues
## Additional Resources
+174 -41
View File
@@ -5,9 +5,9 @@
<h1 align="center">Voicebox</h1>
<p align="center">
<strong>The open-source voice synthesis studio.</strong><br/>
Clone voices. Generate speech. Apply effects. Build voice-powered apps.<br/>
All running locally on your machine.
<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">
@@ -33,7 +33,8 @@
<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="#api">API</a> •
<a href="docs/content/docs/overview/troubleshooting.mdx">Troubleshooting</a>
</p>
<br/>
@@ -62,17 +63,22 @@
## What is Voicebox?
Voicebox is a **local-first voice cloning studio** — a free and open-source alternative to ElevenLabs. Clone voices from a few seconds of audio or pick from 50+ preset voices, generate speech in 23 languages across 7 TTS engines, apply post-processing effects, and compose multi-voice projects with a timeline editor.
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.
- **Complete privacy** — models and voice data stay on your machine
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
- **Cloning and preset voices** — zero-shot cloning from a reference sample, or curated preset voices via Kokoro (50 voices) and Qwen CustomVoice (9 voices)
- **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
- **API-first** — REST API for integrating voice synthesis into your own projects
- **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
@@ -91,6 +97,8 @@ Voicebox is a **local-first voice cloning studio** — a free and open-source al
> **Linux** — Pre-built binaries are not yet available. See [voicebox.sh/linux-install](https://voicebox.sh/linux-install) for build-from-source instructions.
> **Having trouble?** See the [Troubleshooting Guide](docs/content/docs/overview/troubleshooting.mdx) for common install, generation, model-download, and GPU issues.
---
## Features
@@ -182,12 +190,69 @@ Multi-voice timeline editor for conversations, podcasts, and narratives.
- Auto-playback with synchronized playhead
- Version pinning per track clip
### Recording & Transcription
### Global Dictation & Voice Input
- In-app recording with waveform visualization
- System audio capture (macOS and Windows)
- Automatic transcription powered by Whisper (including Whisper Turbo)
- Export recordings in multiple formats
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.
- **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
### Speech-to-Text
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.
| Size | Notes |
| ----------------------------- | -------------------------------------------------- |
| Base / Small / Medium / Large | Standard Whisper quality ladder |
| Turbo | ~8x faster than Whisper Large, minimal quality loss |
More engines (Parakeet v3, Qwen3-ASR) are planned — see [Roadmap](#roadmap).
### Captures
Every dictation, in-app recording, and uploaded audio file lands in the Captures tab — original audio paired with transcript, always preserved.
- **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
### Agent Voice Output
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.
```ts
// In any MCP-aware agent:
await voicebox.speak({
text: "Deploy complete.",
profile: "Morgan",
});
```
Also exposed as `POST /speak` for anything that doesn't speak MCP — ACP, A2A, shell scripts, custom harnesses.
- **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
### Voice Personalities
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.
- **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
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.
**Local LLM options:** Qwen3 0.6B / 1.7B / 4B, sharing the TTS runtime (MLX on Apple Silicon, PyTorch elsewhere).
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.
### Model Management
@@ -211,55 +276,121 @@ Multi-voice timeline editor for conversations, podcasts, and narratives.
## API
Voicebox exposes a full REST API for integrating voice synthesis into your own apps.
Voicebox exposes a REST API for integrating voice I/O into your own apps and agents.
```bash
# Generate speech
curl -X POST http://localhost:17493/generate \
curl -X POST http://127.0.0.1:17493/generate \
-H "Content-Type: application/json" \
-d '{"text": "Hello world", "profile_id": "abc123", "language": "en"}'
# List voice profiles
curl http://localhost:17493/profiles
# Create a profile
curl -X POST http://localhost:17493/profiles \
# 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" \
-d '{"name": "My Voice", "language": "en"}'
-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
```
**Use cases:** game dialogue, podcast production, accessibility tools, voice assistants, content automation.
`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`.
Full API documentation available at `http://localhost:17493/docs`.
### 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) |
| 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 |
| Effects | Pedalboard (Spotify) |
| Transcription | Whisper / Whisper Turbo (PyTorch or MLX) |
| Inference | MLX (Apple Silicon) / PyTorch (CUDA/ROCm/XPU/CPU) |
| Database | SQLite |
| Audio | WaveSurfer.js, librosa |
| 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 |
| ----------------------- | ---------------------------------------------- |
| **Real-time Streaming** | Stream audio as it generates, word by word |
| **Voice Design** | Create new voices from text descriptions |
| **More Models** | XTTS, Bark, and other open-source voice models |
| **Plugin Architecture** | Extend with custom models and effects |
| **Mobile Companion** | Control Voicebox from your phone |
| 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.
@@ -283,6 +414,8 @@ Install [just](https://github.com/casey/just): `brew install just` or `cargo ins
**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
+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>
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@voicebox/app",
"version": "0.4.2",
"version": "0.5.0",
"private": true,
"type": "module",
"scripts": {
@@ -44,11 +44,15 @@
"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-qr-code": "^2.0.18",
"react-sound-visualizer": "^1.4.0",
"tailwind-merge": "^2.5.4",
"wavesurfer.js": "^7.0.0",
+35 -1
View File
@@ -1,17 +1,29 @@
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 { useServerStore } from '@/stores/serverStore';
import {
getDefaultServerUrl,
isLoopbackVoiceboxServerUrl,
useServerStore,
} from '@/stores/serverStore';
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.
@@ -64,6 +76,19 @@ const LOADING_MESSAGES = [
];
function App() {
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);
@@ -73,6 +98,10 @@ function App() {
// 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) {
@@ -105,6 +134,11 @@ function App() {
// Setup window close handler and auto-start server when running in Tauri (production only)
useEffect(() => {
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;
}
+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 { 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>
);
}
+39 -39
View File
@@ -1,6 +1,7 @@
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 {
@@ -33,6 +34,7 @@ interface AudioDevice {
}
export function AudioTab() {
const { t } = useTranslation();
const platform = usePlatform();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editingChannel, setEditingChannel] = useState<string | null>(null);
@@ -119,14 +121,14 @@ export function AudioTab() {
if (channelsLoading || devicesLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-muted-foreground">Loading...</div>
<div className="text-muted-foreground">{t('audioChannels.loading')}</div>
</div>
);
}
const handleChannelDelete = async (e: React.MouseEvent, channelId: string) => {
e.stopPropagation();
if (await confirm('Delete this channel?')) {
if (await confirm(t('audioChannels.confirmDelete'))) {
deleteChannel.mutate(channelId);
}
};
@@ -140,10 +142,10 @@ export function AudioTab() {
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">Audio Channels</h2>
<h2 className="text-2xl font-bold">{t('audioChannels.title')}</h2>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
New Channel
{t('audioChannels.newChannel')}
</Button>
</div>
@@ -158,13 +160,10 @@ export function AudioTab() {
{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">
No audio channels yet. Create your first channel to route voices to specific
devices.
</p>
<p className="text-muted-foreground mb-4">{t('audioChannels.empty.message')}</p>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Channel
{t('audioChannels.empty.action')}
</Button>
</div>
) : (
@@ -195,7 +194,7 @@ export function AudioTab() {
<div className="space-y-2.5 ml-10">
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">
Output Devices
{t('audioChannels.labels.outputDevices')}
</div>
<div className="flex flex-wrap gap-1.5">
{channel.device_ids.length > 0
@@ -224,7 +223,7 @@ export function AudioTab() {
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">
Assigned Voices
{t('audioChannels.labels.assignedVoices')}
</div>
<ChannelVoicesList channelId={channel.id} />
</div>
@@ -270,13 +269,13 @@ export function AudioTab() {
)}
>
<div className="shrink-0 mb-4">
<h3 className="text-lg font-semibold">Available Devices</h3>
<h3 className="text-lg font-semibold">{t('audioChannels.devices.title')}</h3>
<p className="text-sm text-muted-foreground mt-1">
{selectedChannelId
? selectedChannel?.is_default
? 'Default channel uses system default device'
: 'Click devices to add or remove them from the selected channel'
: 'Select a channel to assign devices'}
? t('audioChannels.devices.defaultNote')
: t('audioChannels.devices.toggleHint')
: t('audioChannels.devices.selectHint')}
</p>
</div>
{allDevices.length > 0 ? (
@@ -344,8 +343,8 @@ export function AudioTab() {
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground text-center">
{platform.metadata.isTauri
? 'No audio devices found'
: 'Audio device selection requires Tauri'}
? t('audioChannels.devices.empty')
: t('audioChannels.devices.requiresTauri')}
</p>
</div>
)}
@@ -394,6 +393,7 @@ export function AudioTab() {
}
function ChannelVoicesList({ channelId }: { channelId: string }) {
const { t } = useTranslation();
const { data: voices } = useQuery({
queryKey: ['channel-voices', channelId],
queryFn: () => apiClient.getChannelVoices(channelId),
@@ -416,7 +416,7 @@ function ChannelVoicesList({ channelId }: { channelId: string }) {
</Badge>
))
) : (
<span className="text-sm text-muted-foreground">No voices assigned</span>
<span className="text-sm text-muted-foreground">{t('audioChannels.noVoicesAssigned')}</span>
)}
</div>
);
@@ -430,6 +430,7 @@ interface CreateChannelDialogProps {
}
function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateChannelDialogProps) {
const { t } = useTranslation();
const [name, setName] = useState('');
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
@@ -445,23 +446,21 @@ function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateCh
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Audio Channel</DialogTitle>
<DialogDescription>
Create a new audio channel (bus) to route voices to specific output devices.
</DialogDescription>
<DialogTitle>{t('audioChannels.createDialog.title')}</DialogTitle>
<DialogDescription>{t('audioChannels.createDialog.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="channel-name">Channel Name</Label>
<Label htmlFor="channel-name">{t('audioChannels.fields.name')}</Label>
<Input
id="channel-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Virtual Cable, Broadcast"
placeholder={t('audioChannels.fields.namePlaceholder')}
/>
</div>
<div>
<Label>Output Devices</Label>
<Label>{t('audioChannels.labels.outputDevices')}</Label>
<Select
value={selectedDevices[0] || ''}
onValueChange={(value) => {
@@ -471,12 +470,12 @@ function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateCh
}}
>
<SelectTrigger>
<SelectValue placeholder="Select device" />
<SelectValue placeholder={t('audioChannels.selectDevice')} />
</SelectTrigger>
<SelectContent>
{devices.map((device) => (
<SelectItem key={device.id} value={device.id}>
{device.name} {device.is_default && '(default)'}
{device.name} {device.is_default && `(${t('audioChannels.defaultSuffix')})`}
</SelectItem>
))}
</SelectContent>
@@ -509,10 +508,10 @@ function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateCh
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
{t('common.cancel')}
</Button>
<Button onClick={handleSubmit} disabled={!name.trim()}>
Create
{t('audioChannels.createDialog.action')}
</Button>
</DialogFooter>
</DialogContent>
@@ -545,6 +544,7 @@ function EditChannelDialog({
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);
@@ -560,16 +560,16 @@ function EditChannelDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit Channel</DialogTitle>
<DialogDescription>Update channel settings and voice assignments.</DialogDescription>
<DialogTitle>{t('audioChannels.editDialog.title')}</DialogTitle>
<DialogDescription>{t('audioChannels.editDialog.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="edit-channel-name">Channel Name</Label>
<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>Output Devices</Label>
<Label>{t('audioChannels.labels.outputDevices')}</Label>
<Select
value=""
onValueChange={(value) => {
@@ -579,12 +579,12 @@ function EditChannelDialog({
}}
>
<SelectTrigger>
<SelectValue placeholder="Add device" />
<SelectValue placeholder={t('audioChannels.addDevice')} />
</SelectTrigger>
<SelectContent>
{devices.map((device) => (
<SelectItem key={device.id} value={device.id}>
{device.name} {device.is_default && '(default)'}
{device.name} {device.is_default && `(${t('audioChannels.defaultSuffix')})`}
</SelectItem>
))}
</SelectContent>
@@ -615,7 +615,7 @@ function EditChannelDialog({
)}
</div>
<div>
<Label>Assigned Voices</Label>
<Label>{t('audioChannels.labels.assignedVoices')}</Label>
<Select
value=""
onValueChange={(value) => {
@@ -625,7 +625,7 @@ function EditChannelDialog({
}}
>
<SelectTrigger>
<SelectValue placeholder="Add voice" />
<SelectValue placeholder={t('audioChannels.addVoice')} />
</SelectTrigger>
<SelectContent>
{profiles.map((profile) => (
@@ -663,10 +663,10 @@ function EditChannelDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
{t('common.cancel')}
</Button>
<Button onClick={handleSubmit} disabled={!name.trim()}>
Save
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
@@ -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>
);
}
@@ -18,6 +18,7 @@ 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 {
@@ -55,6 +56,7 @@ export function EffectsChainEditor({
compact = false,
showPresets = true,
}: EffectsChainEditorProps) {
const { t } = useTranslation();
const [expandedId, setExpandedId] = useState<string | null>(null);
// Maintain stable IDs for each effect across renders.
@@ -177,17 +179,27 @@ export function EffectsChainEditor({
}}
>
<SelectTrigger className="h-8 flex-1 text-xs focus:ring-0 focus:ring-offset-0">
<SelectValue placeholder="Load preset..." />
<SelectValue placeholder={t('effects.chain.loadPreset')} />
</SelectTrigger>
<SelectContent>
{presets?.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
{p.description && (
<span className="ml-1 text-muted-foreground">- {p.description}</span>
)}
</SelectItem>
))}
{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>
@@ -198,7 +210,7 @@ export function EffectsChainEditor({
className="h-8 px-2 text-xs text-muted-foreground"
onClick={clearAll}
>
Clear
{t('effects.chain.clear')}
</Button>
)}
</div>
@@ -229,12 +241,12 @@ export function EffectsChainEditor({
<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="Add effect..." />
<SelectValue placeholder={t('effects.chain.addEffect')} />
</SelectTrigger>
<SelectContent>
{availableEffects.effects.map((e) => (
<SelectItem key={e.type} value={e.type}>
{e.label}
{t(`effects.types.${e.type}.label`, { defaultValue: e.label })}
</SelectItem>
))}
</SelectContent>
@@ -270,6 +282,7 @@ function SortableEffectItem({
onToggleEnabled,
onUpdateParam,
}: SortableEffectItemProps) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
});
@@ -280,7 +293,9 @@ function SortableEffectItem({
zIndex: isDragging ? 10 : undefined,
};
const label = effectDef?.label ?? effect.type;
const label = t(`effects.types.${effect.type}.label`, {
defaultValue: effectDef?.label ?? effect.type,
});
return (
<div
@@ -328,16 +343,16 @@ function SortableEffectItem({
effect.enabled ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
)}
onClick={onToggleEnabled}
title={effect.enabled ? 'Disable' : 'Enable'}
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 hover:text-destructive"
className="p-0.5 text-muted-foreground "
onClick={onRemove}
title="Remove"
title={t('effects.chain.remove')}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@@ -352,7 +367,9 @@ function SortableEffectItem({
<div key={paramName} className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-[11px] text-muted-foreground">
{paramDef.description}
{t(`effects.types.${effect.type}.params.${paramName}`, {
defaultValue: paramDef.description,
})}
</Label>
<span className="text-[11px] font-mono tabular-nums text-foreground">
{currentValue.toFixed(
+67 -54
View File
@@ -1,6 +1,7 @@
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';
@@ -25,6 +26,7 @@ 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);
@@ -95,6 +97,18 @@ export function EffectsDetail() {
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;
@@ -115,8 +129,8 @@ export function EffectsDetail() {
setAudioWithAutoPlay(url, `preview-${Date.now()}`, null, 'Effects Preview');
} catch (error) {
toast({
title: 'Preview failed',
description: error instanceof Error ? error.message : 'Unknown error',
title: t('effects.toast.previewFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
variant: 'destructive',
});
} finally {
@@ -130,7 +144,7 @@ export function EffectsDetail() {
async function handleSaveNew() {
if (!name.trim()) {
toast({ title: 'Name required', variant: 'destructive' });
toast({ title: t('effects.toast.nameRequired'), variant: 'destructive' });
return;
}
setSaving(true);
@@ -143,11 +157,14 @@ export function EffectsDetail() {
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
setIsCreatingNew(false);
setSelectedPresetId(created.id);
toast({ title: 'Preset saved', description: `"${created.name}" has been created.` });
toast({
title: t('effects.toast.saved'),
description: t('effects.toast.createdDescription', { name: created.name }),
});
} catch (error) {
toast({
title: 'Failed to save',
description: error instanceof Error ? error.message : 'Unknown error',
title: t('effects.toast.saveFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
variant: 'destructive',
});
} finally {
@@ -166,11 +183,11 @@ export function EffectsDetail() {
});
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
queryClient.invalidateQueries({ queryKey: ['effect-preset', selectedPresetId] });
toast({ title: 'Preset updated' });
toast({ title: t('effects.toast.updated') });
} catch (error) {
toast({
title: 'Failed to save',
description: error instanceof Error ? error.message : 'Unknown error',
title: t('effects.toast.saveFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
variant: 'destructive',
});
} finally {
@@ -179,15 +196,15 @@ export function EffectsDetail() {
}
function handleSaveAsNew() {
// Open the dialog with a suggested name based on the current preset
setSaveAsName(`${name} (Copy)`);
const sourceName = isBuiltIn ? presetName : name;
setSaveAsName(t('effects.saveAs.suggestedName', { name: sourceName }));
setSaveAsDescription(description);
setSaveAsDialogOpen(true);
}
async function handleSaveAsConfirm() {
if (!saveAsName.trim()) {
toast({ title: 'Name required', variant: 'destructive' });
toast({ title: t('effects.toast.nameRequired'), variant: 'destructive' });
return;
}
setSaving(true);
@@ -200,11 +217,14 @@ export function EffectsDetail() {
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
setSaveAsDialogOpen(false);
setSelectedPresetId(created.id);
toast({ title: 'Preset saved', description: `"${created.name}" has been created.` });
toast({
title: t('effects.toast.saved'),
description: t('effects.toast.createdDescription', { name: created.name }),
});
} catch (error) {
toast({
title: 'Failed to save',
description: error instanceof Error ? error.message : 'Unknown error',
title: t('effects.toast.saveFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
variant: 'destructive',
});
} finally {
@@ -220,11 +240,11 @@ export function EffectsDetail() {
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
setSelectedPresetId(null);
setWorkingChain([]);
toast({ title: 'Preset deleted' });
toast({ title: t('effects.toast.deleted') });
} catch (error) {
toast({
title: 'Failed to delete',
description: error instanceof Error ? error.message : 'Unknown error',
title: t('effects.toast.deleteFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
variant: 'destructive',
});
} finally {
@@ -237,7 +257,7 @@ export function EffectsDetail() {
<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">Select a preset or create a new one</p>
<p className="text-sm">{t('effects.placeholder')}</p>
</div>
</div>
);
@@ -245,10 +265,13 @@ export function EffectsDetail() {
return (
<div className="flex flex-col h-full min-h-0">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">
{isCreatingNew ? 'New Preset' : isBuiltIn ? preset?.name : 'Edit Preset'}
{isCreatingNew
? t('effects.detail.newTitle')
: isBuiltIn
? presetName
: t('effects.detail.editTitle')}
</h2>
<div className="flex items-center gap-2">
{!isBuiltIn && !isCreatingNew && (
@@ -256,12 +279,12 @@ export function EffectsDetail() {
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive hover:text-destructive gap-1.5"
className="h-8 text-destructive gap-1.5"
onClick={handleDelete}
disabled={deleting}
>
<Trash2 className="h-3.5 w-3.5" />
{deleting ? 'Deleting...' : 'Delete'}
{deleting ? t('effects.detail.deleting') : t('common.delete')}
</Button>
<Button
size="sm"
@@ -270,7 +293,7 @@ export function EffectsDetail() {
disabled={saving || workingChain.length === 0}
>
<Save className="h-3.5 w-3.5" />
{saving ? 'Saving...' : 'Save'}
{saving ? t('effects.detail.saving') : t('common.save')}
</Button>
</>
)}
@@ -282,7 +305,7 @@ export function EffectsDetail() {
disabled={saving || workingChain.length === 0}
>
<Save className="h-3.5 w-3.5" />
{saving ? 'Saving...' : 'Save Preset'}
{saving ? t('effects.detail.saving') : t('effects.detail.savePreset')}
</Button>
)}
{isBuiltIn && (
@@ -294,51 +317,46 @@ export function EffectsDetail() {
disabled={saving}
>
<Save className="h-3.5 w-3.5" />
{saving ? 'Saving...' : 'Save as Custom'}
{saving ? t('effects.detail.saving') : t('effects.detail.saveAsCustom')}
</Button>
)}
</div>
</div>
{/* Scrollable content */}
<div className="flex-1 min-h-0 overflow-y-auto space-y-5 pr-1">
{/* Name & description */}
{(isCreatingNew || !isBuiltIn) && (
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-xs">Name</Label>
<Label className="text-xs">{t('effects.fields.name')}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My preset..."
placeholder={t('effects.fields.namePlaceholder')}
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Description</Label>
<Label className="text-xs">{t('effects.fields.description')}</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe what this preset does..."
placeholder={t('effects.fields.descriptionPlaceholder')}
className="min-h-[60px] resize-none"
/>
</div>
</div>
)}
{/* Built-in description (read-only) */}
{isBuiltIn && preset?.description && (
<p className="text-sm text-muted-foreground">{preset.description}</p>
{isBuiltIn && presetDescription && (
<p className="text-sm text-muted-foreground">{presetDescription}</p>
)}
{/* Effects chain editor */}
<EffectsChainEditor value={workingChain} onChange={setWorkingChain} showPresets={false} />
<Separator />
{/* Preview section */}
<div className="space-y-3">
<Label className="text-xs">Preview</Label>
<Label className="text-xs">{t('effects.preview.label')}</Label>
<div className="flex items-center gap-2">
<GenerationPicker
selectedId={previewGenId}
@@ -355,38 +373,33 @@ export function EffectsDetail() {
{previewLoading ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Processing...
{t('effects.preview.processing')}
</>
) : (
<>
<Play className="h-3.5 w-3.5" />
Preview
{t('effects.preview.button')}
</>
)}
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
Preview applies effects to the clean version without saving.
</p>
<p className="text-[11px] text-muted-foreground">{t('effects.preview.hint')}</p>
</div>
</div>
{/* Save as Custom dialog */}
<Dialog open={saveAsDialogOpen} onOpenChange={setSaveAsDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Save as Custom Preset</DialogTitle>
<DialogDescription>
Create a new custom preset based on the current effects chain.
</DialogDescription>
<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">Name</Label>
<Label className="text-xs">{t('effects.fields.name')}</Label>
<Input
value={saveAsName}
onChange={(e) => setSaveAsName(e.target.value)}
placeholder="My preset..."
placeholder={t('effects.fields.namePlaceholder')}
className="h-9"
autoFocus
onKeyDown={(e) => {
@@ -397,22 +410,22 @@ export function EffectsDetail() {
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Description</Label>
<Label className="text-xs">{t('effects.fields.description')}</Label>
<Textarea
value={saveAsDescription}
onChange={(e) => setSaveAsDescription(e.target.value)}
placeholder="Describe what this preset does..."
placeholder={t('effects.fields.descriptionPlaceholder')}
className="min-h-[60px] resize-none"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSaveAsDialogOpen(false)} disabled={saving}>
Cancel
{t('common.cancel')}
</Button>
<Button onClick={handleSaveAsConfirm} disabled={saving || !saveAsName.trim()}>
<Save className="h-3.5 w-3.5 mr-1.5" />
{saving ? 'Saving...' : 'Save'}
{saving ? t('effects.detail.saving') : t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
+88 -70
View File
@@ -1,5 +1,14 @@
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';
@@ -7,6 +16,7 @@ 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);
@@ -41,75 +51,74 @@ export function EffectsList() {
}
return (
<div className="flex flex-col h-full min-h-0">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Effects</h2>
<Button variant="outline" size="sm" className="h-8 gap-1.5" onClick={handleCreateNew}>
<Plus className="h-3.5 w-3.5" />
New Preset
</Button>
</div>
<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>
{/* Scrollable list */}
<div className="flex-1 min-h-0 overflow-y-auto space-y-4">
{/* Built-in presets */}
{builtIn.length > 0 && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
Built-in
</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>
)}
{/* User presets */}
{userPresets.length > 0 && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
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>
)}
{/* New preset placeholder */}
{isCreatingNew && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
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">Unsaved Preset</span>
<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>
<p className="text-xs text-muted-foreground mt-1">
Configure effects in the panel on the right.
</p>
</div>
</div>
)}
</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>
);
}
@@ -122,7 +131,16 @@ function PresetCard({
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
@@ -139,19 +157,19 @@ function PresetCard({
<Wand2
className={cn('h-4 w-4 shrink-0', isSelected ? 'text-accent' : 'text-muted-foreground')}
/>
<span className="text-sm font-medium truncate">{preset.name}</span>
<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">
built-in
{t('effects.badge.builtin')}
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-1 pl-6">
{preset.description || 'No description'}
{description || t('effects.noDescription')}
</p>
<div className="flex items-center gap-2 mt-1.5 pl-6">
<span className="text-[10px] text-muted-foreground">
{effectCount} effect{effectCount !== 1 ? 's' : ''}
{t('effects.effectCount', { count: effectCount })}
</span>
<span className="text-[10px] text-muted-foreground/50">
{preset.effects_chain
+2 -2
View File
@@ -3,7 +3,7 @@ import { EffectsList } from './EffectsList';
export function EffectsTab() {
return (
<div className="flex flex-col h-full min-h-0 overflow-hidden">
<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">
@@ -11,7 +11,7 @@ export function EffectsTab() {
</div>
{/* Right - Detail / editor */}
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex-1 min-h-0 flex flex-col pr-8">
<EffectsDetail />
</div>
</div>
@@ -1,8 +1,9 @@
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMatchRoute } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
import { Loader2, SlidersHorizontal, Sparkles } from 'lucide-react';
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 {
@@ -13,6 +14,7 @@ import {
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';
@@ -34,6 +36,7 @@ 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);
@@ -50,6 +53,21 @@ export function FloatingGenerateBox({
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({
@@ -173,6 +191,10 @@ export function FloatingGenerateBox({
) {
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)
@@ -233,10 +255,10 @@ export function FloatingGenerateBox({
<motion.div
ref={containerRef}
className={cn(
'fixed right-auto',
'fixed',
isStoriesRoute
? // Position aligned with story list: after sidebar + padding, width 360px
'left-[calc(5rem+2rem)] w-[360px]'
? // 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={{
@@ -276,10 +298,12 @@ export function FloatingGenerateBox({
onChange={field.onChange}
placeholder={
isStoriesRoute && currentStory
? `Generate speech for "${currentStory.name}"... (type / for effects)`
? t('generation.placeholder.storyWithEffects', {
name: currentStory.name,
})
: selectedProfile
? `Type / for effects like [laugh], [sigh]...`
: 'Select a voice profile above...'
? 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={{
@@ -302,10 +326,12 @@ export function FloatingGenerateBox({
}}
placeholder={
isStoriesRoute && currentStory
? `Generate speech for "${currentStory.name}"...`
? t('generation.placeholder.story', { name: currentStory.name })
: selectedProfile
? `Generate speech using ${selectedProfile.name}...`
: 'Select a voice profile above...'
? 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={{
@@ -325,35 +351,90 @@ export function FloatingGenerateBox({
/>
</motion.div>
<div className="relative shrink-0">
<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
? 'Generating...'
: !selectedProfileId
? 'Select a voice profile first'
: 'Generate speech'
}
>
{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
? 'Generating...'
: !selectedProfileId
? 'Select a voice profile first'
: 'Generate speech'}
</span>
</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>
@@ -363,7 +444,6 @@ export function FloatingGenerateBox({
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
className="absolute top-0 right-[calc(100%+0.5rem)]"
>
<div className="group relative">
<Button
@@ -379,20 +459,49 @@ export function FloatingGenerateBox({
)}
aria-label={
isInstructExpanded
? 'Hide delivery instructions'
: 'Show delivery instructions'
? 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]">
Delivery instructions (tone, emotion, pace)
{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>
@@ -414,7 +523,7 @@ export function FloatingGenerateBox({
<FormControl>
<Textarea
{...field}
placeholder="Delivery instructions — e.g. Speak slowly with warmth, Authoritative and clear..."
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}
@@ -444,7 +553,7 @@ export function FloatingGenerateBox({
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="Select a voice..." />
<SelectValue placeholder={t('generation.voiceSelector.placeholder')} />
</SelectTrigger>
<SelectContent>
{profiles?.map((profile) => (
@@ -457,6 +566,7 @@ export function FloatingGenerateBox({
</div>
)}
<FormField
control={form.control}
name="language"
@@ -498,16 +608,16 @@ export function FloatingGenerateBox({
}
>
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
<SelectValue placeholder="No effects" />
<SelectValue placeholder={t('generation.effects.none')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="text-xs">
No effects
{t('generation.effects.none')}
</SelectItem>
{selectedProfile?.effects_chain &&
selectedProfile.effects_chain.length > 0 && (
<SelectItem value="_profile" className="text-xs">
Profile default
{t('generation.effects.profileDefault')}
</SelectItem>
)}
{effectPresets?.map((preset) => (
@@ -1,220 +0,0 @@
import { Loader2, Mic } from 'lucide-react';
import { useEffect } from 'react';
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 { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
import { useProfile } from '@/lib/hooks/useProfiles';
import { useUIStore } from '@/stores/uiStore';
import {
applyEngineSelection,
EngineModelSelector,
getEngineDescription,
} from './EngineModelSelector';
import { ParalinguisticInput } from './ParalinguisticInput';
function getEngineSelectValue(engine: string): string {
if (engine === 'qwen') return 'qwen:1.7B';
if (engine === 'qwen_custom_voice') return 'qwen_custom_voice:1.7B';
if (engine === 'tada') return 'tada:1B';
return engine;
}
export function GenerationForm() {
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const { data: selectedProfile } = useProfile(selectedProfileId || '');
const { form, handleSubmit, isPending } = useGenerationForm();
useEffect(() => {
if (!selectedProfile) {
return;
}
if (selectedProfile.language) {
form.setValue('language', selectedProfile.language as LanguageCode);
}
const preferredEngine = selectedProfile.default_engine || selectedProfile.preset_engine;
if (preferredEngine) {
applyEngineSelection(form, getEngineSelectValue(preferredEngine));
}
}, [form, selectedProfile]);
async function onSubmit(data: Parameters<typeof handleSubmit>[0]) {
await handleSubmit(data, selectedProfileId);
}
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>
<span className="text-sm text-muted-foreground">{selectedProfile.language}</span>
</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>
{form.watch('engine') === 'chatterbox_turbo' ? (
<ParalinguisticInput
value={field.value}
onChange={field.onChange}
placeholder="Enter text... type / for effects like [laugh], [sigh]"
className="min-h-[150px] rounded-md border border-input bg-background px-3 py-2"
/>
) : (
<Textarea
placeholder="Enter the text you want to generate..."
className="min-h-[150px]"
{...field}
/>
)}
</FormControl>
<FormDescription>
{form.watch('engine') === 'chatterbox_turbo'
? 'Max 5000 characters. Type / to insert sound effects.'
: 'Max 5000 characters'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch('engine') === 'qwen_custom_voice' && (
<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">
<FormItem>
<FormLabel>Model</FormLabel>
<EngineModelSelector form={form} selectedProfile={selectedProfile} />
<FormDescription>
{getEngineDescription(form.watch('engine') || 'qwen')}
</FormDescription>
</FormItem>
<FormField
control={form.control}
name="language"
render={({ field }) => {
const engineLangs = getLanguageOptionsForEngine(form.watch('engine') || 'qwen');
return (
<FormItem>
<FormLabel>Language</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{engineLangs.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
<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={isPending || !selectedProfileId}>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating...
</>
) : (
'Generate Speech'
)}
</Button>
</form>
</Form>
</CardContent>
</Card>
);
}
+41 -66
View File
@@ -14,7 +14,9 @@ import {
Wand2,
} from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AudioBars } from '@/components/AudioBars';
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { Button } from '@/components/ui/button';
import {
@@ -56,38 +58,8 @@ import { formatDate, formatDuration, formatEngineName } from '@/lib/utils/format
import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore';
// ─── Audio Bars ─────────────────────────────────────────────────────────────
function AudioBars({ mode }: { mode: 'idle' | 'generating' | 'playing' }) {
const barColor = mode !== 'idle' ? 'bg-accent' : 'bg-muted-foreground/40';
return (
<div className="flex items-center gap-[2px] h-5">
{[0, 1, 2, 3, 4].map((i) => (
<motion.div
key={`${mode}-${i}`}
className={`w-[3px] rounded-full ${barColor}`}
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>
);
}
// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS WITH INFINITE SCROLL
export function HistoryTable() {
const { t } = useTranslation();
const [page, setPage] = useState(0);
const [allHistory, setAllHistory] = useState<HistoryResponse[]>([]);
const [total, setTotal] = useState(0);
@@ -460,7 +432,7 @@ export function HistoryTable() {
<div className="flex flex-col h-full min-h-0 relative">
{history.length === 0 ? (
<div className="text-center py-12 px-5 border-2 border-dashed mb-5 border-muted rounded-md text-muted-foreground flex-1 flex items-center justify-center">
No voice generations, yet...
{t('history.empty')}
</div>
) : (
<>
@@ -472,7 +444,7 @@ export function HistoryTable() {
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground hover:text-destructive"
className="h-7 text-xs text-muted-foreground"
onClick={() => setClearFailedDialogOpen(true)}
disabled={clearFailed.isPending}
>
@@ -671,46 +643,47 @@ export function HistoryTable() {
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground"
aria-label="Actions"
aria-label={t('history.actions.menu')}
disabled={isGenerating}
>
<MoreHorizontal className="h-2 w-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}>
<DropdownMenuItem
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
>
<Play className="mr-2 h-4 w-4" />
Play
{t('history.actions.play')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDownloadAudio(gen.id, gen.text)}
disabled={exportGenerationAudio.isPending}
>
<Download className="mr-2 h-4 w-4" />
Export Audio
{t('history.actions.exportAudio')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExportPackage(gen.id, gen.text)}
disabled={exportGeneration.isPending}
>
<FileArchive className="mr-2 h-4 w-4" />
Export Package
{t('history.actions.exportPackage')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleApplyEffects(gen.id)}>
<Wand2 className="mr-2 h-4 w-4" />
Apply Effects
{t('history.actions.applyEffects')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleRegenerate(gen.id)}>
<RotateCcw className="mr-2 h-4 w-4" />
Regenerate
{t('history.actions.regenerate')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
disabled={deleteGeneration.isPending}
// className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
{t('common.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -802,10 +775,9 @@ export function HistoryTable() {
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Generation</DialogTitle>
<DialogTitle>{t('history.deleteDialog.title')}</DialogTitle>
<DialogDescription>
Are you sure you want to delete this generation from "{generationToDelete?.name}"?
This action cannot be undone.
{t('history.deleteDialog.body', { name: generationToDelete?.name })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -816,14 +788,14 @@ export function HistoryTable() {
setGenerationToDelete(null);
}}
>
Cancel
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={deleteGeneration.isPending}
>
{deleteGeneration.isPending ? 'Deleting...' : 'Delete'}
{deleteGeneration.isPending ? t('history.deleteDialog.deleting') : t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
@@ -832,23 +804,23 @@ export function HistoryTable() {
<Dialog open={clearFailedDialogOpen} onOpenChange={setClearFailedDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Clear failed generations</DialogTitle>
<DialogTitle>{t('history.clearFailedDialog.title')}</DialogTitle>
<DialogDescription>
This will permanently delete {failedCount} failed{' '}
{failedCount === 1 ? 'generation' : 'generations'} from your history. This cannot be
undone.
{t('history.clearFailedDialog.body', { count: failedCount })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setClearFailedDialogOpen(false)}>
Cancel
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={handleClearFailedConfirm}
disabled={clearFailed.isPending}
>
{clearFailed.isPending ? 'Clearing...' : 'Clear all'}
{clearFailed.isPending
? t('history.clearFailedDialog.clearing')
: t('history.clearFailedDialog.clearAll')}
</Button>
</DialogFooter>
</DialogContent>
@@ -857,9 +829,9 @@ export function HistoryTable() {
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Import Generation</DialogTitle>
<DialogTitle>{t('history.importDialog.title')}</DialogTitle>
<DialogDescription>
Import the generation from "{selectedFile?.name}". This will add it to your history.
{t('history.importDialog.body', { name: selectedFile?.name })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -873,13 +845,15 @@ export function HistoryTable() {
}
}}
>
Cancel
{t('common.cancel')}
</Button>
<Button
onClick={handleImportConfirm}
disabled={importGeneration.isPending || !selectedFile}
>
{importGeneration.isPending ? 'Importing...' : 'Import'}
{importGeneration.isPending
? t('history.importDialog.importing')
: t('history.importDialog.action')}
</Button>
</DialogFooter>
</DialogContent>
@@ -888,21 +862,20 @@ export function HistoryTable() {
<Dialog open={effectsDialogOpen} onOpenChange={setEffectsDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Apply Effects</DialogTitle>
<DialogDescription>
Configure post-processing effects to apply to this generation. A new version will be
created.
</DialogDescription>
<DialogTitle>{t('history.effectsDialog.title')}</DialogTitle>
<DialogDescription>{t('history.effectsDialog.body')}</DialogDescription>
</DialogHeader>
{effectsTargetVersions.length > 1 && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Source</label>
<label className="text-xs font-medium text-muted-foreground">
{t('history.effectsDialog.sourceLabel')}
</label>
<Select
value={effectsSourceVersionId ?? ''}
onValueChange={(val) => setEffectsSourceVersionId(val || null)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Select source version" />
<SelectValue placeholder={t('history.effectsDialog.sourcePlaceholder')} />
</SelectTrigger>
<SelectContent>
{effectsTargetVersions.map((v) => (
@@ -924,13 +897,15 @@ export function HistoryTable() {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEffectsDialogOpen(false)}>
Cancel
{t('common.cancel')}
</Button>
<Button
onClick={handleApplyEffectsConfirm}
disabled={applyingEffects || effectsChain.length === 0}
>
{applyingEffects ? 'Applying...' : 'Apply'}
{applyingEffects
? t('history.effectsDialog.applying')
: t('history.effectsDialog.apply')}
</Button>
</DialogFooter>
</DialogContent>
@@ -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>
);
}
+13 -23
View File
@@ -1,5 +1,6 @@
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';
@@ -20,6 +21,7 @@ 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);
@@ -39,8 +41,8 @@ export function MainEditor() {
if (file) {
if (!file.name.endsWith('.voicebox.zip')) {
toast({
title: 'Invalid file type',
description: 'Please select a valid .voicebox.zip file',
title: t('main.import.invalidTitle'),
description: t('main.import.invalidDescription'),
variant: 'destructive',
});
return;
@@ -60,13 +62,13 @@ export function MainEditor() {
fileInputRef.current.value = '';
}
toast({
title: 'Profile imported',
description: 'Voice profile imported successfully',
title: t('main.import.successTitle'),
description: t('main.import.successDescription'),
});
},
onError: (error) => {
toast({
title: 'Failed to import profile',
title: t('main.import.failedTitle'),
description: error.message,
variant: 'destructive',
});
@@ -76,21 +78,17 @@ export function MainEditor() {
};
return (
// Main view: Profiles top left, Generator bottom left, History right
<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-6 h-full min-h-0 overflow-hidden relative">
{/* Left Column */}
<div className="flex flex-col min-h-0 overflow-hidden relative lg:overflow-hidden">
{/* Scroll Mask - Always visible, behind content */}
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" />
{/* Fixed Header */}
<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" />
Import Voice
{t('main.importVoice')}
</Button>
<input
ref={fileInputRef}
@@ -101,13 +99,12 @@ export function MainEditor() {
/>
<Button onClick={() => setDialogOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
Create Voice
{t('main.createVoice')}
</Button>
</div>
</div>
</div>
{/* Scrollable Content */}
<div
ref={scrollRef}
className={cn('flex-1 min-h-0 overflow-y-auto pt-14 pb-4', isPlayerVisible && 'lg:pb-32')}
@@ -120,25 +117,18 @@ export function MainEditor() {
</div>
</div>
{/* Divider - single column only */}
{/* <div className="border-t border-border -my-3 lg:hidden" /> */}
{/* Right Column - History */}
<div className="flex flex-col min-h-0 overflow-hidden">
<HistoryTable />
</div>
{/* Floating Generate Box */}
<FloatingGenerateBox isPlayerOpen={!!audioUrl} />
{/* Import Dialog */}
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Import Profile</DialogTitle>
<DialogTitle>{t('main.import.dialogTitle')}</DialogTitle>
<DialogDescription>
Import the profile from "{selectedFile?.name}". This will create a new profile with
all samples.
{t('main.import.dialogDescription', { name: selectedFile?.name })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -152,13 +142,13 @@ export function MainEditor() {
}
}}
>
Cancel
{t('common.cancel')}
</Button>
<Button
onClick={handleImportConfirm}
disabled={importProfile.isPending || !selectedFile}
>
{importProfile.isPending ? 'Importing...' : 'Import'}
{importProfile.isPending ? t('main.import.importing') : t('main.import.action')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,116 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Slider } from '@/components/ui/slider';
import { useServerStore } from '@/stores/serverStore';
export function GenerationSettings() {
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
const setMaxChunkChars = useServerStore((state) => state.setMaxChunkChars);
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
const setCrossfadeMs = useServerStore((state) => state.setCrossfadeMs);
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
const setNormalizeAudio = useServerStore((state) => state.setNormalizeAudio);
const autoplayOnGenerate = useServerStore((state) => state.autoplayOnGenerate);
const setAutoplayOnGenerate = useServerStore((state) => state.setAutoplayOnGenerate);
return (
<Card role="region" aria-label="Generation Settings" tabIndex={0}>
<CardHeader>
<CardTitle>Generation Settings</CardTitle>
<CardDescription>
Controls for long text generation. These settings apply to all engines.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<label htmlFor="maxChunkChars" className="text-sm font-medium leading-none">
Auto-chunking limit
</label>
<span className="text-sm tabular-nums text-muted-foreground">
{maxChunkChars} chars
</span>
</div>
<Slider
id="maxChunkChars"
value={[maxChunkChars]}
onValueChange={([value]) => setMaxChunkChars(value)}
min={100}
max={5000}
step={50}
aria-label="Auto-chunking character limit"
/>
<p className="text-sm text-muted-foreground">
Long text is split into chunks at sentence boundaries before generating. Lower values
can improve quality for long outputs.
</p>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<label htmlFor="crossfadeMs" className="text-sm font-medium leading-none">
Chunk crossfade
</label>
<span className="text-sm tabular-nums text-muted-foreground">
{crossfadeMs === 0 ? 'Cut' : `${crossfadeMs}ms`}
</span>
</div>
<Slider
id="crossfadeMs"
value={[crossfadeMs]}
onValueChange={([value]) => setCrossfadeMs(value)}
min={0}
max={200}
step={10}
aria-label="Chunk crossfade duration"
/>
<p className="text-sm text-muted-foreground">
Blends audio between chunks to smooth transitions. Set to 0 for a hard cut.
</p>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="normalizeAudio"
checked={normalizeAudio}
onCheckedChange={setNormalizeAudio}
className="mt-[6px]"
/>
<div className="space-y-1">
<label
htmlFor="normalizeAudio"
className="text-sm font-medium leading-none cursor-pointer"
>
Normalize audio
</label>
<p className="text-sm text-muted-foreground">
Adjusts output volume to a consistent level across generations.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="autoplayOnGenerate"
checked={autoplayOnGenerate}
onCheckedChange={setAutoplayOnGenerate}
className="mt-[6px]"
/>
<div className="space-y-1">
<label
htmlFor="autoplayOnGenerate"
className="text-sm font-medium leading-none cursor-pointer"
>
Autoplay on generate
</label>
<p className="text-sm text-muted-foreground">
Automatically play audio when a generation completes.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
@@ -366,7 +366,7 @@ export function GpuAcceleration() {
<Button
onClick={handleDelete}
variant="ghost"
className="w-full text-muted-foreground hover:text-destructive"
className="w-full text-muted-foreground "
size="sm"
>
<Trash2 className="h-4 w-4 mr-2" />
@@ -18,6 +18,7 @@ import {
X,
} from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import {
AlertDialog,
AlertDialogAction,
@@ -82,6 +83,12 @@ const MODEL_DESCRIPTIONS: Record<string, string> = {
'Whisper Large (1.5B parameters). Best accuracy for speech-to-text across multiple languages.',
'whisper-turbo':
'Whisper Large v3 Turbo. Pruned for significantly faster inference while maintaining near-large accuracy.',
'qwen3-0.6b':
'Qwen3 0.6B — smallest of the Qwen3 instruct family. Very fast on CPU, runs at ~400 MB quantized on Apple Silicon. Good for dictation refinement and short completions.',
'qwen3-1.7b':
'Qwen3 1.7B — balanced size and quality. Handles subtle self-corrections and technical vocabulary better than the 0.6B. Runs at ~1.1 GB quantized on Apple Silicon.',
'qwen3-4b':
'Qwen3 4B — highest quality local refinement and longer-form reasoning. ~2.5 GB quantized on Apple Silicon, ~8 GB at full precision on PyTorch.',
};
function formatDownloads(n: number): string {
@@ -119,6 +126,7 @@ function formatBytes(bytes: number): string {
}
export function ModelManagement() {
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const platform = usePlatform();
@@ -270,8 +278,8 @@ export function ModelManagement() {
setDownloadingModel(null);
setDownloadingDisplayName(null);
toast({
title: 'Download failed',
description: error instanceof Error ? error.message : 'Unknown error',
title: t('models.toast.downloadFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
variant: 'destructive',
});
}
@@ -309,8 +317,8 @@ export function ModelManagement() {
setDownloadingModel(prevDownloadingModel);
setDownloadingDisplayName(prevDownloadingDisplayName);
toast({
title: 'Cancel failed',
description: 'Could not cancel the download task.',
title: t('models.toast.cancelFailed'),
description: t('models.toast.cancelFailedDescription'),
variant: 'destructive',
});
},
@@ -336,8 +344,10 @@ export function ModelManagement() {
},
onSuccess: async () => {
toast({
title: 'Model deleted',
description: `${modelToDelete?.displayName || 'Model'} has been deleted successfully.`,
title: t('models.toast.deleted'),
description: t('models.toast.deletedDescription', {
name: modelToDelete?.displayName || t('models.defaultName'),
}),
});
setDeleteDialogOpen(false);
setModelToDelete(null);
@@ -348,7 +358,7 @@ export function ModelManagement() {
},
onError: (error: Error) => {
toast({
title: 'Delete failed',
title: t('models.toast.deleteFailed'),
description: error.message,
variant: 'destructive',
});
@@ -361,15 +371,15 @@ export function ModelManagement() {
},
onSuccess: async (_data, modelName) => {
toast({
title: 'Model unloaded',
description: `${modelName} has been unloaded from memory.`,
title: t('models.toast.unloaded'),
description: t('models.toast.unloadedDescription', { name: modelName }),
});
await queryClient.invalidateQueries({ queryKey: ['modelStatus'], refetchType: 'all' });
await queryClient.refetchQueries({ queryKey: ['modelStatus'] });
},
onError: (error: Error) => {
toast({
title: 'Unload failed',
title: t('models.toast.unloadFailed'),
description: error.message,
variant: 'destructive',
});
@@ -377,7 +387,7 @@ export function ModelManagement() {
});
const formatSize = (sizeMb?: number): string => {
if (!sizeMb) return 'Unknown size';
if (!sizeMb) return t('models.unknownSize');
if (sizeMb < 1024) return `${sizeMb.toFixed(1)} MB`;
return `${(sizeMb / 1024).toFixed(2)} GB`;
};
@@ -407,11 +417,13 @@ export function ModelManagement() {
m.model_name.startsWith('kokoro'),
) ?? [];
const whisperModels = modelStatus?.models.filter((m) => m.model_name.startsWith('whisper')) ?? [];
const llmModels = modelStatus?.models.filter((m) => m.model_name.startsWith('qwen3-')) ?? [];
// Build sections
const sections: { label: string; models: ModelStatus[] }[] = [
{ label: 'Voice Generation', models: voiceModels },
{ label: 'Transcription', models: whisperModels },
{ label: t('models.sections.voiceGeneration'), models: voiceModels },
{ label: t('models.sections.transcription'), models: whisperModels },
{ label: t('models.sections.languageModels'), models: llmModels },
];
// Get detail modal state for selected model
@@ -427,16 +439,14 @@ export function ModelManagement() {
// Derive license from HF data
const license =
hfModelInfo?.cardData?.license ||
hfModelInfo?.tags?.find((t) => t.startsWith('license:'))?.replace('license:', '');
hfModelInfo?.tags?.find((tag) => tag.startsWith('license:'))?.replace('license:', '');
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="shrink-0 pb-4">
<h1 className="text-lg font-semibold">Models</h1>
<p className="text-sm text-muted-foreground">
Download and manage AI models for voice generation and transcription
</p>
<h1 className="text-lg font-semibold">{t('models.title')}</h1>
<p className="text-sm text-muted-foreground">{t('models.subtitle')}</p>
</div>
{/* Model storage location */}
@@ -444,7 +454,7 @@ export function ModelManagement() {
<div className="shrink-0 pb-4 border-b mb-4">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<span className="text-xs text-muted-foreground">Storage location</span>
<span className="text-xs text-muted-foreground">{t('models.storage.location')}</span>
<p
className="text-xs font-mono text-muted-foreground/70 truncate"
title={cacheDir.path}
@@ -461,12 +471,12 @@ export function ModelManagement() {
try {
await platform.filesystem.openPath(cacheDir.path);
} catch {
toast({ title: 'Failed to open model folder', variant: 'destructive' });
toast({ title: t('models.toast.openFolderFailed'), variant: 'destructive' });
}
}}
>
<FolderOpen className="h-3 w-3" />
Open
{t('models.storage.open')}
</Button>
<Button
variant="ghost"
@@ -475,12 +485,12 @@ export function ModelManagement() {
onClick={async () => {
try {
const newDir = await platform.filesystem.pickDirectory(
'Choose model storage folder',
t('models.storage.pickerTitle'),
);
if (!newDir) return;
setPendingMigrateDir(newDir);
} catch {
toast({ title: 'Failed to open folder picker', variant: 'destructive' });
toast({ title: t('models.toast.pickerFailed'), variant: 'destructive' });
}
}}
disabled={migrating}
@@ -490,7 +500,7 @@ export function ModelManagement() {
) : (
<FolderOpen className="h-3 w-3" />
)}
{migrating ? 'Migrating...' : 'Change'}
{migrating ? t('models.storage.migrating') : t('models.storage.change')}
</Button>
{customModelsDir && (
<Button
@@ -500,13 +510,13 @@ export function ModelManagement() {
disabled={migrating}
onClick={async () => {
setCustomModelsDir(null);
toast({ title: 'Reset to default location. Restarting server...' });
toast({ title: t('models.toast.resetToDefault') });
await platform.lifecycle.restartServer('');
queryClient.invalidateQueries();
}}
>
<RotateCcw className="h-3 w-3" />
Reset
{t('models.storage.reset')}
</Button>
)}
</div>
@@ -520,7 +530,7 @@ export function ModelManagement() {
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : modelStatus ? (
<div className="flex-1 min-h-0 overflow-y-auto space-y-6">
<div className="flex-1 min-h-0 overflow-y-auto space-y-6 pb-6">
{sections.map((section) => (
<div key={section.label}>
<h2 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">
@@ -565,7 +575,7 @@ export function ModelManagement() {
<div className="text-[10px] text-muted-foreground truncate">
{hasProgress
? `${formatBytes(dl.current ?? 0)} / ${formatBytes(dl.total!)} (${pct.toFixed(0)}%)`
: dl?.filename || 'Connecting...'}
: dl?.filename || t('models.progress.connecting')}
</div>
</div>
);
@@ -576,12 +586,12 @@ export function ModelManagement() {
<div className="shrink-0 flex items-center gap-2">
{hasError && (
<Badge variant="destructive" className="text-[10px] h-5">
Error
{t('common.error')}
</Badge>
)}
{model.loaded && (
<Badge className="text-[10px] h-5 bg-accent/15 text-accent border-accent/30 hover:bg-accent/15">
Loaded
{t('models.status.loaded')}
</Badge>
)}
{model.downloaded && !isDownloading && !hasError && (
@@ -613,7 +623,7 @@ export function ModelManagement() {
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
<span>Problems</span>
<span>{t('models.problems.title')}</span>
<Badge variant="destructive" className="text-[10px] h-4 px-1.5 rounded-full">
{errorCount}
</Badge>
@@ -626,7 +636,7 @@ export function ModelManagement() {
disabled={clearAllMutation.isPending}
>
<RotateCcw className="h-3 w-3 mr-1" />
Clear All
{t('models.problems.clearAll')}
</Button>
</div>
{consoleOpen && (
@@ -645,13 +655,13 @@ export function ModelManagement() {
) : (
<>
{': '}
<span className="text-[#808080]">
No error details available. Try downloading again.
</span>
<span className="text-[#808080]">{t('models.problems.noDetails')}</span>
</>
)}
<div className="text-[#6a9955] mt-0.5">
started at {new Date(dl.started_at).toLocaleString()}
{t('models.problems.startedAt', {
time: new Date(dl.started_at).toLocaleString(),
})}
</div>
</div>
))}
@@ -692,13 +702,13 @@ export function ModelManagement() {
{freshSelectedModel.loaded && (
<Badge className="text-xs bg-accent/15 text-accent border-accent/30 hover:bg-accent/15">
<CircleCheck className="h-3 w-3 mr-1" />
Loaded
{t('models.status.loaded')}
</Badge>
)}
{selectedState?.hasError && (
<Badge variant="destructive" className="text-xs">
<CircleX className="h-3 w-3 mr-1" />
Error
{t('common.error')}
</Badge>
)}
</div>
@@ -707,7 +717,7 @@ export function ModelManagement() {
{hfLoading && freshSelectedModel.hf_repo_id && (
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
<Loader2 className="h-3 w-3 animate-spin" />
Loading model info...
{t('models.detail.loadingInfo')}
</div>
)}
@@ -734,23 +744,29 @@ export function ModelManagement() {
)}
{hfModelInfo.author && (
<Badge variant="outline" className="text-[10px]">
by {hfModelInfo.author}
{t('models.detail.byAuthor', { author: hfModelInfo.author })}
</Badge>
)}
</div>
{/* Stats row */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1" title="Downloads">
<span
className="flex items-center gap-1"
title={t('models.detail.downloads')}
>
<Download className="h-3.5 w-3.5" />
{formatDownloads(hfModelInfo.downloads)}
</span>
<span className="flex items-center gap-1" title="Likes">
<span className="flex items-center gap-1" title={t('models.detail.likes')}>
<Heart className="h-3.5 w-3.5" />
{formatDownloads(hfModelInfo.likes)}
</span>
{license && (
<span className="flex items-center gap-1" title="License">
<span
className="flex items-center gap-1"
title={t('models.detail.license')}
>
<Scale className="h-3.5 w-3.5" />
{formatLicense(license)}
</span>
@@ -762,8 +778,12 @@ export function ModelManagement() {
<div>
<span className="text-xs text-muted-foreground">
{hfModelInfo.cardData.language.length > 10
? `${hfModelInfo.cardData.language.length} languages supported`
: `Languages: ${hfModelInfo.cardData.language.join(', ')}`}
? t('models.detail.languagesCount', {
count: hfModelInfo.cardData.language.length,
})
: t('models.detail.languagesList', {
list: hfModelInfo.cardData.language.join(', '),
})}
</span>
</div>
)}
@@ -774,7 +794,9 @@ export function ModelManagement() {
{freshSelectedModel.downloaded && freshSelectedModel.size_mb && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<HardDrive className="h-3.5 w-3.5" />
<span>{formatSize(freshSelectedModel.size_mb)} on disk</span>
<span>
{t('models.detail.onDisk', { size: formatSize(freshSelectedModel.size_mb) })}
</span>
</div>
)}
@@ -796,7 +818,7 @@ export function ModelManagement() {
className="flex-1"
>
<Download className="h-4 w-4 mr-2" />
Retry Download
{t('models.actions.retry')}
</Button>
<Button
size="sm"
@@ -825,7 +847,7 @@ export function ModelManagement() {
<div className="text-xs text-muted-foreground">
{hasProgress
? `${formatBytes(dl.current ?? 0)} / ${formatBytes(dl.total!)} (${pct.toFixed(1)}%)`
: dl?.filename || 'Connecting to HuggingFace...'}
: dl?.filename || t('models.progress.connectingHf')}
</div>
</>
);
@@ -858,7 +880,9 @@ export function ModelManagement() {
) : (
<Unplug className="h-4 w-4 mr-2" />
)}
{unloadMutation.isPending ? 'Unloading...' : 'Unload'}
{unloadMutation.isPending
? t('models.actions.unloading')
: t('models.actions.unload')}
</Button>
)}
<Button
@@ -875,13 +899,13 @@ export function ModelManagement() {
disabled={freshSelectedModel.loaded}
title={
freshSelectedModel.loaded
? 'Unload model before deleting'
: 'Delete model'
? t('models.actions.unloadFirst')
: t('models.actions.deleteModel')
}
className="flex-1"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Model
{t('models.actions.deleteModel')}
</Button>
</div>
) : (
@@ -891,7 +915,7 @@ export function ModelManagement() {
className="flex-1"
>
<Download className="h-4 w-4 mr-2" />
Download
{t('models.actions.download')}
</Button>
)}
</div>
@@ -905,20 +929,23 @@ export function ModelManagement() {
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Model</AlertDialogTitle>
<AlertDialogTitle>{t('models.deleteDialog.title')}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete <strong>{modelToDelete?.displayName}</strong>?
<Trans
i18nKey="models.deleteDialog.body"
values={{ name: modelToDelete?.displayName }}
components={{ strong: <strong /> }}
/>
{modelToDelete?.sizeMb && (
<>
{' '}
This will free up {formatSize(modelToDelete.sizeMb)} of disk space. The model will
need to be re-downloaded if you want to use it again.
{t('models.deleteDialog.sizeNote', { size: formatSize(modelToDelete.sizeMb) })}
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (modelToDelete) {
@@ -931,10 +958,10 @@ export function ModelManagement() {
{deleteMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Deleting...
{t('models.deleteDialog.deleting')}
</>
) : (
'Delete'
t('common.delete')
)}
</AlertDialogAction>
</AlertDialogFooter>
@@ -948,11 +975,8 @@ export function ModelManagement() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Move models to new location?</AlertDialogTitle>
<AlertDialogDescription>
The server will shut down while models are being moved to the new folder. It will
restart automatically once the migration is complete.
</AlertDialogDescription>
<AlertDialogTitle>{t('models.migrateDialog.title')}</AlertDialogTitle>
<AlertDialogDescription>{t('models.migrateDialog.description')}</AlertDialogDescription>
</AlertDialogHeader>
<div
className="text-xs font-mono text-muted-foreground bg-muted/50 rounded px-3 py-2 truncate"
@@ -961,7 +985,7 @@ export function ModelManagement() {
{pendingMigrateDir}
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
if (!pendingMigrateDir) return;
@@ -973,7 +997,7 @@ export function ModelManagement() {
total: 0,
progress: 0,
status: 'downloading',
filename: 'Preparing...',
filename: t('models.migrateDialog.preparing'),
});
try {
// Start the migration (background task)
@@ -984,8 +1008,8 @@ export function ModelManagement() {
setMigrating(false);
setMigrationProgress(null);
toast({
title: 'No models to migrate',
description: 'Download at least one model before changing the storage location.',
title: t('models.toast.noModelsToMigrate'),
description: t('models.toast.noModelsToMigrateDescription'),
});
setPendingMigrateDir(null);
return;
@@ -1003,7 +1027,7 @@ export function ModelManagement() {
resolve();
} else if (data.status === 'error') {
es.close();
reject(new Error(data.error || 'Migration failed'));
reject(new Error(data.error || t('models.toast.migrationFailed')));
}
} catch {
/* ignore parse errors */
@@ -1011,7 +1035,7 @@ export function ModelManagement() {
};
es.onerror = () => {
es.close();
reject(new Error('Lost connection during migration'));
reject(new Error(t('models.toast.migrationConnectionLost')));
};
});
@@ -1021,15 +1045,16 @@ export function ModelManagement() {
total: 1,
progress: 100,
status: 'complete',
filename: 'Restarting server...',
filename: t('models.migrateDialog.restartingServer'),
});
await platform.lifecycle.restartServer(newDir);
queryClient.invalidateQueries();
toast({ title: 'Models moved successfully' });
toast({ title: t('models.toast.migrated') });
} catch (e) {
toast({
title: 'Migration failed',
description: e instanceof Error ? e.message : 'Failed to migrate models',
title: t('models.toast.migrationFailed'),
description:
e instanceof Error ? e.message : t('models.toast.migrationFailedGeneric'),
variant: 'destructive',
});
} finally {
@@ -1038,7 +1063,7 @@ export function ModelManagement() {
}
}}
>
Move Models
{t('models.migrateDialog.action')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -1050,11 +1075,11 @@ export function ModelManagement() {
<div className="w-full max-w-md px-8 space-y-6 text-center">
<div className="space-y-2">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-muted-foreground" />
<h2 className="text-lg font-semibold">Moving models</h2>
<h2 className="text-lg font-semibold">{t('models.migrate.title')}</h2>
<p className="text-sm text-muted-foreground">
{migrationProgress.status === 'complete'
? 'Restarting server...'
: 'The server is offline while models are being moved.'}
? t('models.migrateDialog.restartingServer')
: t('models.migrate.offline')}
</p>
</div>
{migrationProgress.total > 0 && (
@@ -1075,4 +1100,3 @@ export function ModelManagement() {
</div>
);
}
+51 -14
View File
@@ -1,7 +1,9 @@
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 }) {
@@ -16,6 +18,7 @@ function FadeIn({ delay = 0, children }: { delay?: number; children: ReactNode }
}
export function AboutPage() {
const { t } = useTranslation();
const platform = usePlatform();
const [version, setVersion] = useState('');
@@ -57,14 +60,13 @@ export function AboutPage() {
<FadeIn delay={160}>
<p className="text-sm text-muted-foreground leading-relaxed max-w-sm">
The open-source voice synthesis studio. Clone voices, generate speech, apply effects,
and build voice-powered apps — all running locally on your machine.
{t('settings.about.tagline')}
</p>
</FadeIn>
<FadeIn delay={240}>
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<span>Created by</span>
<span>{t('settings.about.createdBy')}</span>
<a
href="https://github.com/jamiepine"
target="_blank"
@@ -92,7 +94,7 @@ export function AboutPage() {
>
<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>
Buy me a coffee
{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
@@ -115,17 +117,52 @@ export function AboutPage() {
</div>
</FadeIn>
<FadeIn delay={400}>
{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">
Licensed under{' '}
<a
href="https://github.com/jamiepine/voicebox/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
className="hover:text-muted-foreground/60 transition-colors"
>
MIT
</a>
<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>
@@ -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>
);
}
@@ -1,5 +1,6 @@
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';
@@ -176,6 +177,7 @@ function inlineMarkdown(text: string): React.ReactNode {
}
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;
@@ -185,7 +187,9 @@ function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
<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">dev</Badge>}
{entry.version === 'Unreleased' && (
<Badge variant="outline">{t('settings.changelog.devBadge')}</Badge>
)}
</div>
<div className={isLong && !expanded ? 'max-h-48 overflow-hidden relative' : ''}>
@@ -200,7 +204,7 @@ function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
onClick={() => setExpanded(!expanded)}
className="text-xs text-accent hover:underline mt-2"
>
{expanded ? 'Show less' : 'Show more'}
{expanded ? t('settings.changelog.showLess') : t('settings.changelog.showMore')}
</button>
)}
</div>
+109 -59
View File
@@ -1,7 +1,8 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { AlertCircle, ArrowUpRight, Book, Download, Loader2, RefreshCw } from 'lucide-react';
import { useEffect, useState } from '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';
@@ -13,15 +14,20 @@ 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';
const connectionSchema = z.object({
serverUrl: z.string().url('Please enter a valid URL'),
});
function makeConnectionSchema(invalidUrl: string) {
return z.object({
serverUrl: z.string().url(invalidUrl),
});
}
type ConnectionFormValues = z.infer<typeof connectionSchema>;
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);
@@ -32,8 +38,12 @@ export function GeneralPage() {
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: zodResolver(connectionSchema),
resolver,
defaultValues: { serverUrl },
});
@@ -41,14 +51,21 @@ export function GeneralPage() {
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: 'Server URL updated',
description: `Connected to ${data.serverUrl}`,
title: t('settings.general.serverUrl.updatedTitle'),
description: t('settings.general.serverUrl.updatedDescription', { url: data.serverUrl }),
});
}
@@ -63,7 +80,7 @@ export function GeneralPage() {
>
<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">Read the Docs</div>
<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" />
@@ -83,8 +100,10 @@ export function GeneralPage() {
<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">Join the Discord</div>
<div className="text-xs text-muted-foreground">Get help & share voices</div>
<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>
@@ -92,8 +111,8 @@ export function GeneralPage() {
<SettingSection>
<SettingRow
title="Server URL"
description="The address of your voicebox backend server."
title={t('settings.general.serverUrl.title')}
description={t('settings.general.serverUrl.description')}
action={
<ConnectionStatus health={health} isLoading={isLoading} healthError={healthError} />
}
@@ -114,7 +133,7 @@ export function GeneralPage() {
/>
{isDirty && (
<Button type="submit" size="sm">
Save
{t('common.save')}
</Button>
)}
</form>
@@ -122,8 +141,8 @@ export function GeneralPage() {
</SettingRow>
<SettingRow
title="Keep server running when app closes"
description="The server will continue running in the background after closing the app."
title={t('settings.general.keepServerRunning.title')}
description={t('settings.general.keepServerRunning.description')}
htmlFor="keepServerRunning"
action={
<Toggle
@@ -135,17 +154,17 @@ export function GeneralPage() {
console.error('Failed to sync setting to Rust:', error);
setKeepServerRunningOnClose(!checked);
toast({
title: 'Failed to update setting',
description: 'Could not sync setting to backend.',
title: t('settings.general.keepServerRunning.failedTitle'),
description: t('settings.general.keepServerRunning.failedDescription'),
variant: 'destructive',
});
return;
});
toast({
title: 'Setting updated',
title: t('settings.general.keepServerRunning.updatedTitle'),
description: checked
? 'Server will continue running when app closes'
: 'Server will stop when app closes',
? t('settings.general.keepServerRunning.runningDescription')
: t('settings.general.keepServerRunning.stoppedDescription'),
});
}}
/>
@@ -154,8 +173,8 @@ export function GeneralPage() {
{platform.metadata.isTauri && (
<SettingRow
title="Allow network access"
description="Makes the server accessible from other devices on your network. Restart the app after changing."
title={t('settings.general.networkAccess.title')}
description={t('settings.general.networkAccess.description')}
htmlFor="allowNetworkAccess"
action={
<Toggle
@@ -164,16 +183,28 @@ export function GeneralPage() {
onCheckedChange={(checked: boolean) => {
setMode(checked ? 'remote' : 'local');
toast({
title: 'Setting updated',
title: t('settings.general.networkAccess.updatedTitle'),
description: checked
? 'Network access enabled. Restart the app to apply.'
: 'Network access disabled. Restart the app to apply.',
? 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} />
@@ -192,11 +223,14 @@ function ConnectionStatus({
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">Connecting</span>
<span className="text-xs text-muted-foreground">
{t('settings.general.connection.connecting')}
</span>
</div>
);
}
@@ -207,7 +241,7 @@ function ConnectionStatus({
<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">Offline</span>
<span className="text-xs text-destructive">{t('settings.general.connection.offline')}</span>
</div>
);
}
@@ -218,7 +252,9 @@ function ConnectionStatus({
<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">Online</span>
<span className="text-xs text-muted-foreground">
{t('settings.general.connection.online')}
</span>
</div>
);
}
@@ -226,35 +262,41 @@ function ConnectionStatus({
}
function UpdatesSection() {
const { t } = useTranslation();
const platform = usePlatform();
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
const [currentVersion, setCurrentVersion] = useState<string>('');
const [currentVersion, setCurrentVersion] = useState<string | null>('');
const isDev = !import.meta.env?.PROD;
useEffect(() => {
platform.metadata
.getVersion()
.then(setCurrentVersion)
.catch(() => setCurrentVersion('Unknown'));
.catch(() => setCurrentVersion(null));
}, [platform]);
const versionLabel = currentVersion ?? t('common.unknown');
return (
<SettingSection title="App Updates" description={`v${currentVersion}${isDev ? ' (dev)' : ''}`}>
<SettingSection
title={t('settings.general.updates.title')}
description={`v${versionLabel}${isDev ? t('settings.general.updates.devSuffix') : ''}`}
>
{isDev ? (
<SettingRow
title="Development mode"
description="Auto-updates are disabled in development mode."
title={t('settings.general.updates.devMode.title')}
description={t('settings.general.updates.devMode.description')}
/>
) : (
<>
<SettingRow
title="Check for updates"
title={t('settings.general.updates.check.title')}
description={
status.available
? `Version ${status.version} available`
? t('settings.general.updates.check.available', { version: status.version })
: status.checking
? 'Checking...'
: "You're up to date"
? t('settings.general.updates.check.checking')
: t('settings.general.updates.check.upToDate')
}
action={
<Button
@@ -266,13 +308,13 @@ function UpdatesSection() {
<RefreshCw
className={`h-3.5 w-3.5 mr-1.5 ${status.checking ? 'animate-spin' : ''}`}
/>
Check
{t('settings.general.updates.check.button')}
</Button>
}
/>
{status.error && (
<SettingRow title="Update 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}
@@ -282,19 +324,19 @@ function UpdatesSection() {
{status.available && !status.downloading && !status.readyToInstall && (
<SettingRow
title={`Update to ${status.version}`}
description="Download and install the latest version."
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" />
Download
{t('settings.general.updates.download.button')}
</Button>
}
/>
)}
{status.downloading && (
<SettingRow title="Downloading update...">
<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">
@@ -316,12 +358,14 @@ function UpdatesSection() {
{status.readyToInstall && (
<SettingRow
title="Update ready to install"
description={`Version ${status.version} has been downloaded. Restart to complete.`}
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" />
Restart Now
{t('settings.general.updates.ready.button')}
</Button>
}
/>
@@ -332,25 +376,31 @@ function UpdatesSection() {
);
}
const API_ENDPOINTS = [
{ method: 'POST', path: '/generate', label: 'Generate speech' },
{ method: 'GET', path: '/health', label: 'Server status' },
{ method: 'GET', path: '/profiles', label: 'List voices' },
{ method: 'GET', path: '/history', label: 'Past generations' },
];
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">API Access</h3>
<h3 className="text-sm font-medium">{t('settings.general.api.title')}</h3>
<p className="text-sm text-muted-foreground">
Integrate Voicebox into your workflow via the REST API at{' '}
<code className="text-xs bg-muted px-1 py-0.5 rounded font-mono">{serverUrl}</code>
<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">
{API_ENDPOINTS.map((ep) => (
{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 ${
@@ -371,7 +421,7 @@ function ApiReferenceCard({ serverUrl }: { serverUrl: string }) {
rel="noopener noreferrer"
className="text-accent hover:underline"
>
View the full API reference
{t('settings.general.api.viewReference')}
</a>
</p>
</div>
+82 -29
View File
@@ -1,23 +1,30 @@
import { FolderOpen } from 'lucide-react';
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 maxChunkChars = useServerStore((state) => state.maxChunkChars);
const setMaxChunkChars = useServerStore((state) => state.setMaxChunkChars);
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
const setCrossfadeMs = useServerStore((state) => state.setCrossfadeMs);
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
const setNormalizeAudio = useServerStore((state) => state.setNormalizeAudio);
const autoplayOnGenerate = useServerStore((state) => state.autoplayOnGenerate);
const setAutoplayOnGenerate = useServerStore((state) => state.setAutoplayOnGenerate);
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);
@@ -46,17 +53,18 @@ export function GenerationPage() {
}, [platform, generationsPath]);
return (
<div className="space-y-8 max-w-2xl">
<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="Generation"
description="Controls for long text generation. These settings apply to all engines."
title={t('settings.generation.title')}
description={t('settings.generation.description')}
>
<SettingRow
title="Auto-chunking limit"
description="Long text is split into chunks at sentence boundaries. Lower values can improve quality for long outputs."
title={t('settings.generation.chunkLimit.title')}
description={t('settings.generation.chunkLimit.description')}
action={
<span className="text-sm tabular-nums text-muted-foreground">
{maxChunkChars} chars
{t('settings.generation.chunkLimit.value', { chars: maxChunkChars })}
</span>
}
>
@@ -64,19 +72,22 @@ export function GenerationPage() {
id="maxChunkChars"
value={[maxChunkChars]}
onValueChange={([value]) => setMaxChunkChars(value)}
onValueCommit={([value]) => update({ max_chunk_chars: value })}
min={100}
max={5000}
step={50}
aria-label="Auto-chunking character limit"
aria-label={t('settings.generation.chunkLimit.title')}
/>
</SettingRow>
<SettingRow
title="Chunk crossfade"
description="Blends audio between chunks to smooth transitions. Set to 0 for a hard cut."
title={t('settings.generation.crossfade.title')}
description={t('settings.generation.crossfade.description')}
action={
<span className="text-sm tabular-nums text-muted-foreground">
{crossfadeMs === 0 ? 'Cut' : `${crossfadeMs}ms`}
{crossfadeMs === 0
? t('settings.generation.crossfade.cut')
: t('settings.generation.crossfade.ms', { ms: crossfadeMs })}
</span>
}
>
@@ -84,42 +95,43 @@ export function GenerationPage() {
id="crossfadeMs"
value={[crossfadeMs]}
onValueChange={([value]) => setCrossfadeMs(value)}
onValueCommit={([value]) => update({ crossfade_ms: value })}
min={0}
max={200}
step={10}
aria-label="Chunk crossfade duration"
aria-label={t('settings.generation.crossfade.title')}
/>
</SettingRow>
<SettingRow
title="Normalize audio"
description="Adjusts output volume to a consistent level across generations."
title={t('settings.generation.normalize.title')}
description={t('settings.generation.normalize.description')}
htmlFor="normalizeAudio"
action={
<Toggle
id="normalizeAudio"
checked={normalizeAudio}
onCheckedChange={setNormalizeAudio}
onCheckedChange={(v) => update({ normalize_audio: v })}
/>
}
/>
<SettingRow
title="Autoplay on generate"
description="Automatically play audio when a generation completes."
title={t('settings.generation.autoplay.title')}
description={t('settings.generation.autoplay.description')}
htmlFor="autoplayOnGenerate"
action={
<Toggle
id="autoplayOnGenerate"
checked={autoplayOnGenerate}
onCheckedChange={setAutoplayOnGenerate}
onCheckedChange={(v) => update({ autoplay_on_generate: v })}
/>
}
/>
<SettingRow
title="Generations folder"
description={generationsPath ?? 'Where generated audio files are stored on disk.'}
title={t('settings.generation.folder.title')}
description={generationsPath ?? t('settings.generation.folder.description')}
action={
<Button
variant="outline"
@@ -128,11 +140,52 @@ export function GenerationPage() {
disabled={opening || !generationsPath}
>
<FolderOpen className="h-3.5 w-3.5 mr-1.5" />
Open
{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>
);
}
+48 -46
View File
@@ -1,6 +1,7 @@
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';
@@ -40,9 +41,9 @@ function GpuIcon({ className }: { className?: string }) {
}
function GpuInfoCard({ health }: { health: HealthResponse }) {
const { t } = useTranslation();
const hasGpu = health.gpu_available && health.gpu_type;
// Parse GPU name from type string like "CUDA (NVIDIA RTX 4090)" or "MPS (Apple M2 Pro)"
const gpuName = hasGpu
? health.gpu_type!.replace(/^(CUDA|ROCm|MPS|Metal|XPU|DirectML)\s*\((.+)\)$/, '$2') ||
health.gpu_type!
@@ -64,7 +65,7 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
<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 : 'CPU Only'}</div>
<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 ? (
<>
@@ -78,12 +79,14 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
{health.vram_used_mb != null && health.vram_used_mb > 0 && (
<>
<span className="text-border">|</span>
<span>{health.vram_used_mb.toFixed(0)} MB VRAM</span>
<span>
{t('settings.gpu.vramUsed', { mb: health.vram_used_mb.toFixed(0) })}
</span>
</>
)}
</>
) : (
<span>No GPU acceleration detected</span>
<span>{t('settings.gpu.noAcceleration')}</span>
)}
</div>
</div>
@@ -93,7 +96,9 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
<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">Active</span>
<span className="text-[10px] font-medium text-muted-foreground">
{t('settings.gpu.active')}
</span>
</div>
)}
</div>
@@ -102,6 +107,7 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
}
export function GpuPage() {
const { t } = useTranslation();
const platform = usePlatform();
const queryClient = useQueryClient();
const serverUrl = useServerStore((state) => state.serverUrl);
@@ -111,6 +117,12 @@ export function GpuPage() {
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,
@@ -153,7 +165,7 @@ export function GpuPage() {
refetchCudaStatus();
} else if (data.status === 'error') {
eventSource.close();
setError(data.error || 'Download failed');
setError(data.error || tRef.current('settings.gpu.errors.downloadFailed'));
setDownloadProgress(null);
refetchCudaStatus();
}
@@ -218,7 +230,7 @@ export function GpuPage() {
await apiClient.downloadCudaBackend();
refetchCudaStatus();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Failed to start download';
const msg = e instanceof Error ? e.message : t('settings.gpu.errors.downloadStart');
if (msg.includes('already downloaded')) {
refetchCudaStatus();
} else {
@@ -230,9 +242,9 @@ export function GpuPage() {
const handleRestart = async () => {
setError(null);
try {
await restartServerWithPolling('Restart failed');
await restartServerWithPolling(t('settings.gpu.errors.restartFailed'));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Restart failed');
setError(e instanceof Error ? e.message : t('settings.gpu.errors.restartFailed'));
}
};
@@ -241,9 +253,9 @@ export function GpuPage() {
setRestartPhase('stopping');
try {
await apiClient.deleteCudaBackend();
await restartServerWithPolling('Failed to switch to CPU');
await restartServerWithPolling(t('settings.gpu.errors.switchCpu'));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to switch to CPU');
setError(e instanceof Error ? e.message : t('settings.gpu.errors.switchCpu'));
refetchCudaStatus();
}
};
@@ -254,7 +266,7 @@ export function GpuPage() {
await apiClient.deleteCudaBackend();
refetchCudaStatus();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to delete CUDA backend');
setError(e instanceof Error ? e.message : t('settings.gpu.errors.deleteCuda'));
}
};
@@ -278,21 +290,21 @@ export function GpuPage() {
<div className="space-y-8 max-w-2xl">
<GpuInfoCard health={health} />
{/* CUDA section — only when no native GPU and not already on CUDA */}
{!hasNativeGpu && !isCurrentlyCuda && (
<SettingSection
title="CUDA Backend"
description="NVIDIA GPU acceleration via a downloadable CUDA backend."
title={t('settings.gpu.cuda.title')}
description={t('settings.gpu.cuda.description')}
>
{/* Download progress */}
{cudaDownloading && downloadProgress && (
<SettingRow title="Downloading CUDA backend...">
<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 ? 'Updating...' : 'Downloading...')}
(cudaAvailable
? t('settings.gpu.cuda.updating')
: t('settings.gpu.cuda.downloadingShort'))}
</span>
<span>
{downloadProgress.total > 0
@@ -304,23 +316,21 @@ export function GpuPage() {
</SettingRow>
)}
{/* Restart in progress */}
{restartPhase !== 'idle' && (
<SettingRow
title={
restartPhase === 'ready'
? 'Server restarted successfully'
? t('settings.gpu.restart.ready')
: restartPhase === 'waiting'
? 'Restarting server...'
: 'Stopping server...'
? t('settings.gpu.restart.waiting')
: t('settings.gpu.restart.stopping')
}
action={<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
/>
)}
{/* Error */}
{error && (
<SettingRow title="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>
@@ -328,17 +338,16 @@ export function GpuPage() {
</SettingRow>
)}
{/* Actions */}
{restartPhase === 'idle' && !cudaDownloading && (
<>
{!cudaAvailable && !isCurrentlyCuda && (
<SettingRow
title="Download CUDA backend"
description="~2.4 GB download. Requires an NVIDIA GPU with CUDA support."
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" />
Download
{t('settings.gpu.download.button')}
</Button>
}
/>
@@ -346,12 +355,12 @@ export function GpuPage() {
{cudaAvailable && !isCurrentlyCuda && platform.metadata.isTauri && (
<SettingRow
title="Switch to CUDA backend"
description="CUDA backend is downloaded and ready. Restart to enable."
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" />
Restart
{t('settings.gpu.switchToCuda.button')}
</Button>
}
/>
@@ -359,12 +368,12 @@ export function GpuPage() {
{isCurrentlyCuda && platform.metadata.isTauri && (
<SettingRow
title="Switch to CPU backend"
description="Disable GPU acceleration. You can re-download CUDA later."
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" />
Switch
{t('settings.gpu.switchToCpu.button')}
</Button>
}
/>
@@ -372,17 +381,17 @@ export function GpuPage() {
{cudaAvailable && !isCurrentlyCuda && (
<SettingRow
title="Remove CUDA backend"
description="Delete the downloaded CUDA binary to free disk space."
title={t('settings.gpu.remove.title')}
description={t('settings.gpu.remove.description')}
action={
<Button
onClick={handleDelete}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive"
className="text-muted-foreground "
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
Remove
{t('settings.gpu.remove.button')}
</Button>
}
/>
@@ -392,14 +401,7 @@ export function GpuPage() {
</SettingSection>
)}
<p className="text-xs text-muted-foreground/60 leading-relaxed">
Voicebox automatically detects and uses the best available GPU on your system. On Apple
Silicon Macs, the MLX backend runs natively on the Neural Engine and GPU via Metal
Performance Shaders (MPS), with no additional setup required. On Windows and Linux with
NVIDIA GPUs, you can download an optional CUDA backend for hardware-accelerated inference.
AMD ROCm, Intel XPU, and DirectML are also supported where available through PyTorch. When
no GPU is detected, Voicebox falls back to CPU — all engines still work, just slower.
</p>
<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>
);
}
+8 -11
View File
@@ -1,4 +1,5 @@
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';
@@ -32,6 +33,7 @@ function LogLine({ entry }: { entry: LogEntry }) {
}
export function LogsPage() {
const { t } = useTranslation();
const entries = useLogStore((s) => s.entries);
const clear = useLogStore((s) => s.clear);
const containerRef = useRef<HTMLDivElement>(null);
@@ -56,9 +58,9 @@ export function LogsPage() {
<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">Server Logs</h3>
<h3 className="text-sm font-medium">{t('settings.logs.title')}</h3>
<p className="text-sm text-muted-foreground">
{entries.length} {entries.length === 1 ? 'line' : 'lines'}
{t('settings.logs.lineCount', { count: entries.length })}
</p>
</div>
<div className="flex items-center gap-2">
@@ -71,11 +73,11 @@ export function LogsPage() {
containerRef.current?.scrollTo({ top: containerRef.current.scrollHeight });
}}
>
Scroll to bottom
{t('settings.logs.scrollToBottom')}
</Button>
)}
<Button variant="outline" size="sm" onClick={clear}>
Clear
{t('settings.logs.clear')}
</Button>
</div>
</div>
@@ -87,13 +89,8 @@ export function LogsPage() {
>
{entries.length === 0 ? (
<div className="text-sm text-muted-foreground/50 font-mono space-y-1">
<p>No log output yet.</p>
{!import.meta.env?.PROD && (
<p>
Server logs are only captured when the app manages the server process (production
builds).
</p>
)}
<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} />)
+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>
);
}
+219
View File
@@ -0,0 +1,219 @@
import { Lock, MoreHorizontal, Plus, Smartphone, WifiOff } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useToast } from '@/components/ui/use-toast';
import {
usePairedDevices,
useRevokePairedDevice,
} from '@/lib/hooks/usePairedDevices';
import type { PairedDeviceResponse } from '@/lib/api/types';
import { PairDeviceDialog } from './PairDeviceDialog';
import { SettingRow, SettingSection } from './SettingRow';
function formatRelative(iso: string | null): string {
if (!iso) return 'never';
const then = new Date(iso).getTime();
const diffSec = Math.floor((Date.now() - then) / 1000);
if (diffSec < 60) return 'just now';
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
if (diffSec < 86400 * 30) return `${Math.floor(diffSec / 86400)}d ago`;
return new Date(iso).toLocaleDateString();
}
export function MobilePage() {
const [pairOpen, setPairOpen] = useState(false);
const devices = usePairedDevices();
const revoke = useRevokePairedDevice();
const { toast } = useToast();
const active = (devices.data ?? []).filter((d) => !d.revoked);
const revoked = (devices.data ?? []).filter((d) => d.revoked);
function handleRevoke(d: PairedDeviceResponse) {
revoke.mutate(d.id, {
onSuccess: () => {
toast({
title: 'Device revoked',
description: `${d.name} can no longer reach this Voicebox.`,
});
},
onError: (e) => {
toast({
title: 'Revoke failed',
description: e instanceof Error ? e.message : String(e),
variant: 'destructive',
});
},
});
}
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="Mobile"
description="Pair your phone to dictate, browse captures, and generate from anywhere on your network."
>
<SettingRow
title="Paired devices"
description={
active.length === 0
? 'No devices yet — pair your phone to get started.'
: `${active.length} active${revoked.length > 0 ? `, ${revoked.length} revoked` : ''}`
}
action={
<Button onClick={() => setPairOpen(true)} size="sm" className="gap-1.5">
<Plus className="h-3.5 w-3.5" />
Pair device
</Button>
}
/>
{devices.data && devices.data.length > 0 ? (
<div className="pt-3 space-y-2">
{[...active, ...revoked].map((d) => (
<DeviceRow
key={d.id}
device={d}
onRevoke={() => handleRevoke(d)}
revoking={revoke.isPending && revoke.variables === d.id}
/>
))}
</div>
) : devices.isLoading ? (
<div className="pt-3 text-sm text-muted-foreground">Loading devices…</div>
) : (
<EmptyState onPair={() => setPairOpen(true)} />
)}
</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">About pairing</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Pairing creates a long-lived bearer that only your phone holds.
Voicebox stores just a hash — there's no path to recover the
bearer if the device loses it.
</p>
</div>
<div className="space-y-3">
<h3 className="text-sm font-semibold">How it works</h3>
<ul className="space-y-3 text-sm text-muted-foreground">
<li className="flex gap-2.5">
<Smartphone className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">Local-first.</span>{' '}
Your phone talks to this Voicebox over LAN or Tailscale — no cloud,
no relay.
</span>
</li>
<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">Bearer-only.</span>{' '}
The bearer is shown to the device once at pairing time and
never persisted server-side in plaintext.
</span>
</li>
<li className="flex gap-2.5">
<WifiOff className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">Revocable.</span>{' '}
Revoke any device here — its bearer stops working immediately.
</span>
</li>
</ul>
</div>
</aside>
<PairDeviceDialog open={pairOpen} onOpenChange={setPairOpen} />
</div>
);
}
function DeviceRow({
device,
onRevoke,
revoking,
}: {
device: PairedDeviceResponse;
onRevoke: () => void;
revoking: boolean;
}) {
return (
<div
className={`flex items-center justify-between gap-3 rounded-lg border px-4 py-3 ${
device.revoked ? 'border-border/50 bg-muted/20 opacity-60' : 'border-border bg-card'
}`}
>
<div className="flex items-center gap-3 min-w-0">
<div
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${
device.revoked ? 'bg-muted' : 'bg-accent/15'
}`}
>
<Smartphone
className={`h-4 w-4 ${device.revoked ? 'text-muted-foreground' : 'text-accent'}`}
/>
</div>
<div className="min-w-0">
<div className="text-sm font-medium truncate">
{device.name}
{device.revoked ? (
<span className="ml-2 text-[10px] uppercase tracking-wider text-muted-foreground">
revoked
</span>
) : null}
</div>
<div className="text-xs text-muted-foreground">
Last seen {formatRelative(device.last_seen_at)} · paired{' '}
{formatRelative(device.created_at)}
</div>
</div>
</div>
{!device.revoked ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7" disabled={revoking}>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onRevoke} className="text-destructive">
Revoke
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
);
}
function EmptyState({ onPair }: { onPair: () => void }) {
return (
<div className="pt-6 flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border/60 px-6 py-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-accent/10">
<Smartphone className="h-5 w-5 text-accent" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium">No paired devices</p>
<p className="text-xs text-muted-foreground max-w-[280px]">
Pair your phone to dictate captures, queue generations, and play back voices on the go.
</p>
</div>
<Button onClick={onPair} size="sm" className="mt-2 gap-1.5">
<Plus className="h-3.5 w-3.5" />
Pair device
</Button>
</div>
);
}
@@ -0,0 +1,240 @@
import { Check, Copy, Loader2, RefreshCw, Smartphone } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import QRCode from 'react-qr-code';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useToast } from '@/components/ui/use-toast';
import {
PAIRED_DEVICES_KEY,
useInitPairing,
usePairedDevices,
usePairHostCandidates,
} from '@/lib/hooks/usePairedDevices';
import { useQueryClient } from '@tanstack/react-query';
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
function formatRemaining(ms: number): string {
if (ms <= 0) return 'expired';
const total = Math.floor(ms / 1000);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
export function PairDeviceDialog({ open, onOpenChange }: Props) {
const { toast } = useToast();
const qc = useQueryClient();
const candidates = usePairHostCandidates(open);
const initPairing = useInitPairing();
const devices = usePairedDevices({ polling: open });
const [selectedHost, setSelectedHost] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [now, setNow] = useState(() => Date.now());
// The IDs that existed when the dialog opened — anything new is a fresh
// pairing we should celebrate.
const [baselineDeviceIds, setBaselineDeviceIds] = useState<Set<string> | null>(null);
// On open: snapshot baseline devices, default-select the first non-loopback
// candidate, and mint the first token.
useEffect(() => {
if (!open) {
setBaselineDeviceIds(null);
setSelectedHost(null);
initPairing.reset();
return;
}
if (devices.data && baselineDeviceIds === null) {
setBaselineDeviceIds(new Set(devices.data.map((d) => d.id)));
}
if (candidates.data && candidates.data.length > 0 && selectedHost === null) {
const preferred =
candidates.data.find((c) => c.kind !== 'loopback') ?? candidates.data[0];
setSelectedHost(preferred.address);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, candidates.data, devices.data]);
// Re-mint the token whenever the host selection changes (the URL embeds
// the host, so a new selection means a new QR).
useEffect(() => {
if (!open || !selectedHost) return;
initPairing.mutate(selectedHost);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, selectedHost]);
// Wall-clock tick for the countdown.
useEffect(() => {
if (!open) return;
const id = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, [open]);
// Detect a freshly paired device and close + toast.
useEffect(() => {
if (!open || !devices.data || baselineDeviceIds === null) return;
const newDevice = devices.data.find(
(d) => !baselineDeviceIds.has(d.id) && !d.revoked,
);
if (newDevice) {
toast({
title: 'Device paired',
description: `${newDevice.name} is now connected.`,
});
qc.invalidateQueries({ queryKey: PAIRED_DEVICES_KEY });
onOpenChange(false);
}
}, [open, devices.data, baselineDeviceIds, toast, qc, onOpenChange]);
const pairing = initPairing.data;
const expiresAtMs = pairing ? new Date(pairing.expires_at).getTime() : 0;
const remainingMs = expiresAtMs - now;
const expired = pairing != null && remainingMs <= 0;
const qrValue = useMemo(() => pairing?.pairing_url ?? '', [pairing]);
async function handleCopy() {
if (!pairing) return;
try {
await navigator.clipboard.writeText(pairing.pairing_url);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch (e) {
toast({
title: 'Copy failed',
description: e instanceof Error ? e.message : 'Could not access clipboard',
variant: 'destructive',
});
}
}
function handleRegenerate() {
if (!selectedHost) return;
initPairing.mutate(selectedHost);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Smartphone className="h-4 w-4 text-accent" />
Pair a new device
</DialogTitle>
<DialogDescription>
Open Voicebox on your phone, tap <span className="text-foreground">Get started → Pair</span>,
then point its camera at this QR.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
{/* Host picker */}
<div className="flex flex-col gap-1.5">
<label className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
Reachable at
</label>
<Select
value={selectedHost ?? ''}
onValueChange={(v) => setSelectedHost(v)}
disabled={!candidates.data || candidates.data.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={candidates.isError ? 'Could not load' : 'Detecting addresses…'}
/>
</SelectTrigger>
<SelectContent>
{candidates.data?.map((c) => (
<SelectItem key={c.address} value={c.address}>
<span className="flex items-center gap-2">
<span className="font-mono text-xs">{c.address}</span>
<span className="text-xs text-muted-foreground">— {c.label}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{candidates.isError ? (
<p className="text-xs text-destructive leading-snug">
{(candidates.error as Error)?.message ??
'Failed to fetch /pair/host-candidates — is the backend up to date?'}
</p>
) : null}
</div>
{/* QR */}
<div className="flex items-center justify-center rounded-xl border border-border bg-white p-6 min-h-[260px]">
{initPairing.isPending && !pairing ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
) : initPairing.isError ? (
<p className="text-sm text-destructive text-center">
{(initPairing.error as Error)?.message ?? 'Failed to mint token'}
</p>
) : qrValue ? (
<QRCode value={qrValue} size={220} />
) : (
<p className="text-sm text-muted-foreground">No host selected</p>
)}
</div>
{/* Countdown + regenerate */}
{pairing ? (
<div className="flex items-center justify-between text-xs">
<span className={expired ? 'text-destructive' : 'text-muted-foreground'}>
{expired
? 'QR expired — regenerate to continue'
: `Expires in ${formatRemaining(remainingMs)}`}
</span>
<button
type="button"
onClick={handleRegenerate}
className="inline-flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className="h-3 w-3" />
{expired ? 'Regenerate' : 'New QR'}
</button>
</div>
) : null}
{/* Copyable URL */}
{pairing ? (
<div className="flex flex-col gap-1.5">
<label className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
Or paste this URL on the phone
</label>
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2">
<code className="flex-1 truncate text-xs font-mono">{pairing.pairing_url}</code>
<Button size="sm" variant="ghost" onClick={handleCopy} className="h-7 px-2">
{copied ? <Check className="h-3.5 w-3.5 text-accent" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
</div>
</div>
) : null}
<p className="text-[11px] text-muted-foreground leading-snug">
The token is single-use and expires in 5 minutes. Once paired, your phone holds a
long-lived bearer that only it knows — Voicebox stores just a hash. Revoke any time.
</p>
</div>
</DialogContent>
</Dialog>
);
}
+19 -8
View File
@@ -1,14 +1,19 @@
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 {
label: string;
labelKey?: string;
label?: string;
path:
| '/settings'
| '/settings/generation'
| '/settings/captures'
| '/settings/mcp'
| '/settings/mobile'
| '/settings/gpu'
| '/settings/logs'
| '/settings/changelog'
@@ -17,15 +22,21 @@ interface SettingsTab {
}
const tabs: SettingsTab[] = [
{ label: 'General', path: '/settings' },
{ label: 'Generation', path: '/settings/generation' },
{ label: 'GPU', path: '/settings/gpu', tauriOnly: true },
{ label: 'Logs', path: '/settings/logs', tauriOnly: true },
{ label: 'Changelog', path: '/settings/changelog' },
{ label: 'About', path: '/settings/about' },
{ 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' },
// Plain-string label for V0 — translation keys come when mobile graduates
// out of "experimental" status.
{ label: 'Mobile', path: '/settings/mobile' },
{ 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();
@@ -52,7 +63,7 @@ export function SettingsLayout() {
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30',
)}
>
{tab.label}
{tab.label ?? (tab.labelKey ? t(tab.labelKey) : '')}
</Link>
);
})}
+1 -1
View File
@@ -14,7 +14,7 @@ export function SettingSection({
}) {
return (
<div className="space-y-1">
{title && <h3 className="text-sm font-medium">{title}</h3>}
{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}
@@ -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>
);
}
+21 -21
View File
@@ -1,6 +1,7 @@
import { Link, useMatchRoute } from '@tanstack/react-router';
import { AudioLines, Box, Mic, Settings, Speaker, Volume2, Wand2 } from 'lucide-react';
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';
@@ -12,17 +13,24 @@ interface SidebarProps {
isMacOS?: boolean;
}
const tabs = [
{ id: 'main', path: '/', icon: Volume2, label: 'Generate' },
{ id: 'stories', path: '/stories', icon: AudioLines, label: 'Stories' },
{ id: 'voices', path: '/voices', icon: Mic, label: 'Voices' },
{ id: 'effects', path: '/effects', icon: Wand2, label: 'Effects' },
{ id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
{ id: 'models', path: '/models', icon: Box, label: 'Models' },
{ id: 'settings', path: '/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({ isMacOS }: SidebarProps) {
const { t } = useTranslation();
const matchRoute = useMatchRoute();
const isPlayerOpen = !!usePlayerStore((s) => s.audioUrl);
const platform = usePlatform();
@@ -39,15 +47,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
>
{/* Logo */}
<div className="mb-2">
<img
src={voiceboxLogo}
alt="Voicebox"
className="w-12 h-12 object-contain"
style={{
filter:
'drop-shadow(0 0 6px hsl(var(--accent) / 0.5)) drop-shadow(0 0 14px hsl(var(--accent) / 0.35)) drop-shadow(0 0 28px hsl(var(--accent) / 0.2))',
}}
/>
<img src={voiceboxLogo} alt="Voicebox" className="sidebar-logo w-12 h-12 object-contain" />
</div>
{/* Navigation Buttons */}
@@ -72,8 +72,8 @@ export function Sidebar({ isMacOS }: SidebarProps) {
? '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)}
>
{isActive && (
<div
@@ -102,7 +102,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
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"
>
Update
{t('nav.updateBadge')}
</Link>
)}
</div>
+2 -2
View File
@@ -7,7 +7,7 @@ export function StoriesTab() {
const audioUrl = usePlayerStore((state) => state.audioUrl);
return (
<div className="flex flex-col h-full min-h-0 overflow-hidden">
<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 */}
@@ -16,7 +16,7 @@ export function StoriesTab() {
</div>
{/* Right Column - Story Content */}
<div className="flex flex-col min-h-0 overflow-hidden flex-1">
<div className="flex flex-col min-h-0 overflow-hidden flex-1 pr-8">
<StoryContent />
</div>
+36 -13
View File
@@ -1,7 +1,8 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Mic, MoreHorizontal, Play, Trash2 } from 'lucide-react';
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,
@@ -20,6 +21,7 @@ interface StoryChatItemProps {
storyId: string;
index: number;
onRemove: () => void;
onRegenerate?: () => void;
currentTimeMs: number;
isPlaying: boolean;
dragHandleProps?: React.HTMLAttributes<HTMLButtonElement>;
@@ -29,11 +31,13 @@ interface StoryChatItemProps {
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);
@@ -81,7 +85,9 @@ export function StoryChatItem({
{/* Voice Avatar */}
<div className="shrink-0">
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center overflow-hidden">
{!avatarError ? (
{item.engine === 'import' ? (
<Music className="h-5 w-5 text-muted-foreground" />
) : !avatarError ? (
<img
src={avatarUrl}
alt={`${item.profile_name} avatar`}
@@ -100,39 +106,56 @@ export function StoryChatItem({
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<span className="font-medium text-sm">{item.profile_name}</span>
<span className="text-xs text-muted-foreground">{item.language}</span>
<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>
<Textarea
value={item.text}
className="flex-1 resize-none text-sm text-muted-foreground select-text bg-card cursor-text"
readOnly
onDoubleClick={handlePlay}
/>
{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="Actions">
<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" />
Play from here
{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" />
Remove from Story
{t('storyContent.itemActions.removeFromStory')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
+137 -23
View File
@@ -15,13 +15,15 @@ import {
} from '@dnd-kit/sortable';
import { Link } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
import { Download, Plus } from 'lucide-react';
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,
@@ -36,6 +38,7 @@ 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();
@@ -44,7 +47,12 @@ export function StoryContent() {
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('');
@@ -70,8 +78,12 @@ export function StoryContent() {
// Track editor is shown when story has items
const hasBottomBar = story && story.items.length > 0;
// Calculate dynamic bottom padding: track editor + gap
const bottomPadding = hasBottomBar ? trackEditorHeight + 24 : 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(
@@ -136,6 +148,19 @@ export function StoryContent() {
}
}, [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;
@@ -147,7 +172,7 @@ export function StoryContent() {
{
onError: (error) => {
toast({
title: 'Failed to remove item',
title: t('storyContent.toast.removeFailed'),
description: error.message,
variant: 'destructive',
});
@@ -179,7 +204,7 @@ export function StoryContent() {
{
onError: (error) => {
toast({
title: 'Failed to reorder items',
title: t('storyContent.toast.reorderFailed'),
description: error.message,
variant: 'destructive',
});
@@ -199,7 +224,7 @@ export function StoryContent() {
{
onError: (error) => {
toast({
title: 'Failed to export audio',
title: t('storyContent.toast.exportFailed'),
description: error.message,
variant: 'destructive',
});
@@ -208,6 +233,33 @@ export function StoryContent() {
);
};
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;
@@ -223,7 +275,7 @@ export function StoryContent() {
},
onError: (error) => {
toast({
title: 'Failed to add generation',
title: t('storyContent.toast.addFailed'),
description: error.message,
variant: 'destructive',
});
@@ -236,8 +288,8 @@ export function StoryContent() {
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">Select a story</p>
<p className="text-sm">Choose a story from the list to view its content</p>
<p className="text-lg font-medium mb-2">{t('storyContent.selectStory.title')}</p>
<p className="text-sm">{t('storyContent.selectStory.hint')}</p>
</div>
</div>
);
@@ -246,7 +298,7 @@ export function StoryContent() {
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-muted-foreground">Loading story...</div>
<div className="text-muted-foreground">{t('storyContent.loading')}</div>
</div>
);
}
@@ -255,17 +307,62 @@ export function StoryContent() {
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">Story not found</p>
<p className="text-sm">The selected story could not be loaded</p>
<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">
<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="flex items-center justify-between mb-4 px-1">
<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 && (
@@ -291,7 +388,7 @@ export function StoryContent() {
</div>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
Generating {pendingCount} {pendingCount === 1 ? 'audio' : 'audios'}
{t('storyContent.generatingCount', { count: pendingCount })}
</span>
</Link>
</motion.div>
@@ -301,22 +398,34 @@ export function StoryContent() {
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Add
{t('storyContent.add')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="p-2 border-b">
<div className="p-2 border-b space-y-2">
<Input
placeholder="Search by name or transcript..."
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 ? 'No matching generations found' : 'No available generations'}
{searchQuery
? t('storyContent.searchNoMatches')
: t('storyContent.searchNoAvailable')}
</div>
) : (
availableGenerations.map((gen) => (
@@ -344,7 +453,7 @@ export function StoryContent() {
disabled={exportAudio.isPending}
>
<Download className="mr-2 h-4 w-4" />
Export Audio
{t('storyContent.exportAudio')}
</Button>
)}
</div>
@@ -353,13 +462,13 @@ export function StoryContent() {
{/* Content */}
<div
ref={scrollRef}
className="flex-1 min-h-0 overflow-y-auto space-y-3"
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">No items in this story</p>
<p className="text-xs mt-2">Generate speech using the box below to add items</p>
<p className="text-sm">{t('storyContent.empty.title')}</p>
<p className="text-xs mt-2">{t('storyContent.empty.hint')}</p>
</div>
) : (
<DndContext
@@ -388,6 +497,11 @@ export function StoryContent() {
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}
/>
+135 -99
View File
@@ -1,5 +1,6 @@
import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertDialog,
AlertDialogAction,
@@ -10,6 +11,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -27,6 +29,15 @@ import {
} 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 {
@@ -41,6 +52,7 @@ 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);
@@ -60,6 +72,7 @@ export function StoryList() {
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
@@ -72,8 +85,8 @@ export function StoryList() {
const handleCreateStory = () => {
if (!newStoryName.trim()) {
toast({
title: 'Name required',
description: 'Please enter a story name',
title: t('stories.toast.nameRequired'),
description: t('stories.toast.nameRequiredDescription'),
variant: 'destructive',
});
return;
@@ -91,13 +104,13 @@ export function StoryList() {
setNewStoryName('');
setNewStoryDescription('');
toast({
title: 'Story created',
description: `"${story.name}" has been created`,
title: t('stories.toast.created'),
description: t('stories.toast.createdDescription', { name: story.name }),
});
},
onError: (error) => {
toast({
title: 'Failed to create story',
title: t('stories.toast.createFailed'),
description: error.message,
variant: 'destructive',
});
@@ -116,8 +129,8 @@ export function StoryList() {
const handleUpdateStory = () => {
if (!editingStory || !newStoryName.trim()) {
toast({
title: 'Name required',
description: 'Please enter a story name',
title: t('stories.toast.nameRequired'),
description: t('stories.toast.nameRequiredDescription'),
variant: 'destructive',
});
return;
@@ -140,7 +153,7 @@ export function StoryList() {
},
onError: (error) => {
toast({
title: 'Failed to update story',
title: t('stories.toast.updateFailed'),
description: error.message,
variant: 'destructive',
});
@@ -168,7 +181,7 @@ export function StoryList() {
},
onError: (error) => {
toast({
title: 'Failed to delete story',
title: t('stories.toast.deleteFailed'),
description: error.message,
variant: 'destructive',
});
@@ -176,85 +189,113 @@ export function StoryList() {
});
};
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">Loading stories...</div>
<div className="text-muted-foreground">{t('stories.loading')}</div>
</div>
);
}
const storyList = stories || [];
const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0;
return (
<div className="h-full 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" />
<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>
{/* Fixed Header */}
<div className="absolute top-0 left-0 right-0 z-20">
<div className="flex items-center justify-between mb-4 px-1">
<h2 className="text-2xl font-bold">Stories</h2>
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
<Plus className="mr-2 h-4 w-4" />
New Story
</Button>
</div>
</div>
{/* Scrollable Story List */}
<div
className="flex-1 overflow-y-auto pt-14 relative z-0"
<ListPaneScroll
style={{ paddingBottom: hasTrackEditor ? `${trackEditorHeight + 140}px` : '170px' }}
>
{storyList.length === 0 ? (
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
<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">No stories yet</p>
<p className="text-xs mt-2">Create your first story to get started</p>
<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="space-y-0.5">
{storyList.map((story) => (
<div
key={story.id}
role="button"
tabIndex={0}
className={cn(
'px-5 py-3 rounded-lg transition-colors group flex items-center cursor-pointer',
selectedStoryId === story.id ? 'bg-muted' : 'hover:bg-muted/50',
)}
aria-label={`Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}`}
aria-pressed={selectedStoryId === story.id}
onClick={() => setSelectedStoryId(story.id)}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSelectedStoryId(story.id);
}
}}
>
<div className="flex items-start justify-between gap-2 w-full min-w-0">
<div className="flex-1 min-w-0 text-left overflow-hidden">
<h3 className="text-sm font-medium truncate">{story.name}</h3>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
<span>
{story.item_count} {story.item_count === 1 ? 'item' : 'items'}
<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>
<span>·</span>
<span>{formatDate(story.updated_at)}</span>
<div className="flex-1" />
</div>
</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="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
className="absolute top-2 right-2 h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
aria-label={`Actions for ${story.name}`}
aria-label={t('stories.row.actionsLabel', { name: story.name })}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
@@ -262,39 +303,37 @@ export function StoryList() {
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEditClick(story)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
{t('common.edit')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteClick(story.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
{t('common.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
);
})}
</div>
)}
</div>
</ListPaneScroll>
{/* Create Story Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Story</DialogTitle>
<DialogDescription>
Create a new story to organize your voice generations into conversations.
</DialogDescription>
<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">Name</Label>
<Label htmlFor="story-name">{t('stories.fields.name')}</Label>
<Input
id="story-name"
placeholder="My Story"
placeholder={t('stories.fields.namePlaceholder')}
value={newStoryName}
onChange={(e) => setNewStoryName(e.target.value)}
onKeyDown={(e) => {
@@ -305,10 +344,10 @@ export function StoryList() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="story-description">Description (optional)</Label>
<Label htmlFor="story-description">{t('stories.fields.descriptionLabel')}</Label>
<Textarea
id="story-description"
placeholder="A conversation between..."
placeholder={t('stories.fields.descriptionPlaceholder')}
value={newStoryDescription}
onChange={(e) => setNewStoryDescription(e.target.value)}
rows={3}
@@ -317,28 +356,29 @@ export function StoryList() {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
Cancel
{t('common.cancel')}
</Button>
<Button onClick={handleCreateStory} disabled={createStory.isPending}>
{createStory.isPending ? 'Creating...' : 'Create'}
{createStory.isPending
? t('stories.createDialog.creating')
: t('stories.createDialog.action')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Story Dialog */}
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Story</DialogTitle>
<DialogDescription>Update the story name and description.</DialogDescription>
<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">Name</Label>
<Label htmlFor="edit-story-name">{t('stories.fields.name')}</Label>
<Input
id="edit-story-name"
placeholder="My Story"
placeholder={t('stories.fields.namePlaceholder')}
value={newStoryName}
onChange={(e) => setNewStoryName(e.target.value)}
onKeyDown={(e) => {
@@ -349,10 +389,10 @@ export function StoryList() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-story-description">Description (optional)</Label>
<Label htmlFor="edit-story-description">{t('stories.fields.descriptionLabel')}</Label>
<Textarea
id="edit-story-description"
placeholder="A conversation between..."
placeholder={t('stories.fields.descriptionPlaceholder')}
value={newStoryDescription}
onChange={(e) => setNewStoryDescription(e.target.value)}
rows={3}
@@ -361,39 +401,35 @@ export function StoryList() {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
Cancel
{t('common.cancel')}
</Button>
<Button onClick={handleUpdateStory} disabled={updateStory.isPending}>
{updateStory.isPending ? 'Saving...' : 'Save'}
{updateStory.isPending ? t('stories.editDialog.saving') : t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Story Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the story and all its items. This action cannot be
undone.
</AlertDialogDescription>
<AlertDialogTitle>{t('stories.deleteDialog.title')}</AlertDialogTitle>
<AlertDialogDescription>{t('stories.deleteDialog.description')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<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 ? 'Deleting...' : 'Delete'}
{deleteStory.isPending ? t('stories.deleteDialog.deleting') : t('common.delete')}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</ListPane>
);
}
@@ -7,9 +7,12 @@ import {
Pause,
Play,
Plus,
RotateCcw,
Scissors,
Square,
Trash2,
Volume2,
VolumeX,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
@@ -20,6 +23,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Slider } from '@/components/ui/slider';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type { StoryItemDetail } from '@/lib/api/types';
@@ -30,8 +35,10 @@ import {
useSetStoryItemVersion,
useSplitStoryItem,
useTrimStoryItem,
useUpdateStoryItemVolume,
} from '@/lib/hooks/useStories';
import { cn } from '@/lib/utils/cn';
import { useGenerationStore } from '@/stores/generationStore';
import { useStoryStore } from '@/stores/storyStore';
// Clip waveform component with trim support
@@ -75,8 +82,19 @@ function ClipWaveform({
const waveColor = getCSSVar('--accent-foreground');
// Hand WaveSurfer a muted <audio> element so the MediaElement backend
// can never bleed audio. Web Audio is doing the actual playback in
// useStoryPlayback; this clip waveform exists purely for the visual.
// Without this, long imported clips (MP3 / M4A) end up audible from
// wavesurfer's own element on top of the timeline, and that element
// doesn't get paused by stopAllSources().
const mediaElement = document.createElement('audio');
mediaElement.muted = true;
mediaElement.preload = 'metadata';
const wavesurfer = WaveSurfer.create({
container: waveformRef.current,
media: mediaElement,
waveColor,
progressColor: waveColor,
cursorWidth: 0,
@@ -118,6 +136,66 @@ function ClipWaveform({
);
}
// Per-clip volume popover. Local state drives the slider during a drag so
// each pointer-move pixel doesn't fire a PATCH; commits on release.
function ClipVolumePopover({
storyId,
itemId,
volume,
onChange,
}: {
storyId: string;
itemId: string;
volume: number;
onChange: (value: number) => void;
}) {
const [localVolume, setLocalVolume] = useState(volume);
// Re-sync when the selected clip changes or the persisted value updates
// out-of-band (split/duplicate carry the value forward).
useEffect(() => {
setLocalVolume(volume);
}, [volume, itemId, storyId]);
const display = Math.round(localVolume * 100);
const Icon = localVolume === 0 ? VolumeX : Volume2;
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title={`Volume — ${display}%`}
aria-label="Adjust clip volume"
>
<Icon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="center" className="w-56 p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground">Volume</span>
<span className="text-xs tabular-nums">{display}%</span>
</div>
<Slider
value={[localVolume * 100]}
onValueChange={([v]) => setLocalVolume(v / 100)}
onValueCommit={([v]) => onChange(v / 100)}
min={0}
max={200}
step={1}
aria-label="Clip volume"
/>
<div className="flex justify-between mt-2 text-[10px] text-muted-foreground tabular-nums">
<span>0%</span>
<span>100%</span>
<span>200%</span>
</div>
</PopoverContent>
</Popover>
);
}
interface StoryTrackEditorProps {
storyId: string;
items: StoryItemDetail[];
@@ -125,15 +203,21 @@ interface StoryTrackEditorProps {
const TRACK_HEIGHT = 48;
const TIME_RULER_HEIGHT = 24; // h-6 = 1.5rem = 24px
const MIN_PIXELS_PER_SECOND = 10;
const MAX_PIXELS_PER_SECOND = 200;
const DEFAULT_PIXELS_PER_SECOND = 50;
const SCRUB_BAR_HEIGHT = 16;
const LABEL_COL_WIDTH = 64; // w-16 = 4rem = 64px
// Zoom is expressed to the user as how many seconds of timeline are visible
// at once. Min scope = the most you can zoom IN; max scope = the entire
// project. Default scope is what we land on when the editor first measures.
const MIN_VISIBLE_SECONDS = 10;
const DEFAULT_VISIBLE_SECONDS = 60;
const FALLBACK_PIXELS_PER_SECOND = 50; // used until containerWidth is measured
const DEFAULT_TRACKS = [1, 0, -1]; // Default 3 tracks
const MIN_EDITOR_HEIGHT = 120;
const MAX_EDITOR_HEIGHT = 500;
export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const [pixelsPerSecond, setPixelsPerSecond] = useState(DEFAULT_PIXELS_PER_SECOND);
const [pixelsPerSecond, setPixelsPerSecond] = useState(FALLBACK_PIXELS_PER_SECOND);
const hasAppliedDefaultZoomRef = useRef(false);
const [draggingItem, setDraggingItem] = useState<string | null>(null);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
@@ -149,7 +233,13 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const duplicateItem = useDuplicateStoryItem();
const removeItem = useRemoveStoryItem();
const setItemVersion = useSetStoryItemVersion();
const updateVolume = useUpdateStoryItemVolume();
const { toast } = useToast();
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
// User-added empty tracks. Live in component state because a track only
// earns its keep once a clip lands on it — no need to persist an unused
// row across reloads.
const [extraTracks, setExtraTracks] = useState<number[]>([]);
// Selection state
const selectedClipId = useStoryStore((state) => state.selectedClipId);
@@ -258,10 +348,32 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
stop();
};
// Calculate unique tracks from items, always showing at least 3 default tracks
// Calculate unique tracks from items, always showing at least 3 default
// tracks. ``extraTracks`` lets the user open a fresh row without first
// having to drag a clip there.
const tracks = useMemo(() => {
const trackSet = new Set([...DEFAULT_TRACKS, ...items.map((item) => item.track)]);
const trackSet = new Set([
...DEFAULT_TRACKS,
...items.map((item) => item.track),
...extraTracks,
]);
return Array.from(trackSet).sort((a, b) => b - a); // Higher tracks on top
}, [items, extraTracks]);
const handleAddTrackAbove = useCallback(() => {
setExtraTracks((prev) => {
const all = new Set([...DEFAULT_TRACKS, ...items.map((i) => i.track), ...prev]);
const next = (all.size > 0 ? Math.max(...all) : 0) + 1;
return [...prev, next];
});
}, [items]);
const handleAddTrackBelow = useCallback(() => {
setExtraTracks((prev) => {
const all = new Set([...DEFAULT_TRACKS, ...items.map((i) => i.track), ...prev]);
const next = (all.size > 0 ? Math.min(...all) : 0) - 1;
return [...prev, next];
});
}, [items]);
// Track container width for full-width minimum
@@ -282,6 +394,44 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
return () => observer.disconnect();
}, []);
// Horizontal scrollbar state
const [timelineScrollLeft, setTimelineScrollLeft] = useState(0);
const [scrollbarTrackWidth, setScrollbarTrackWidth] = useState(0);
const scrollbarTrackRef = useRef<HTMLDivElement>(null);
const scrollbarDragRef = useRef<{
mode: 'pan' | 'left' | 'right';
startX: number;
startScrollLeft: number;
startPixelsPerSecond: number;
} | null>(null);
// Anchor the visible left/right edge time during a zoom drag so the edge
// the user isn't dragging stays pinned in place across pixelsPerSecond changes.
const zoomAnchorRef = useRef<{ type: 'left' | 'right'; timeMs: number } | null>(null);
// Mirror the timeline's scrollLeft into state so the scrollbar thumb tracks it
useEffect(() => {
const el = tracksRef.current;
if (!el) return;
const onScroll = () => setTimelineScrollLeft(el.scrollLeft);
el.addEventListener('scroll', onScroll);
setTimelineScrollLeft(el.scrollLeft);
return () => el.removeEventListener('scroll', onScroll);
}, []);
// Track scrollbar track width for thumb sizing
useEffect(() => {
const el = scrollbarTrackRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
setScrollbarTrackWidth(entry.contentRect.width);
}
});
ro.observe(el);
setScrollbarTrackWidth(el.clientWidth);
return () => ro.disconnect();
}, []);
// Calculate effective duration (accounting for trims)
const getEffectiveDuration = (item: StoryItemDetail) => {
return item.duration * 1000 - (item.trim_start_ms || 0) - (item.trim_end_ms || 0);
@@ -293,6 +443,41 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
return Math.max(...items.map((item) => item.start_time_ms + getEffectiveDuration(item)), 10000);
}, [items, getEffectiveDuration]);
// Zoom bounds are framed in seconds-of-timeline-visible-at-once (the
// "scope") rather than abstract pixels-per-second so the bar reflects
// something meaningful: fully zoomed out shows the entire project, fully
// zoomed in shows MIN_VISIBLE_SECONDS. Convert to pixels using the visible
// track area (container minus the sticky label column).
const visibleTrackWidth = Math.max(0, containerWidth - LABEL_COL_WIDTH);
const projectSeconds = totalDurationMs / 1000;
const { minPps, maxPps } = useMemo(() => {
if (visibleTrackWidth <= 0 || projectSeconds <= 0) {
return { minPps: 10, maxPps: 200 };
}
const min = visibleTrackWidth / projectSeconds;
const max = visibleTrackWidth / MIN_VISIBLE_SECONDS;
// For projects shorter than MIN_VISIBLE_SECONDS the entire bar collapses
// to one point; clamp so the range stays non-inverted.
return { minPps: min, maxPps: Math.max(max, min) };
}, [visibleTrackWidth, projectSeconds]);
// Apply the default scope (60 s, or the whole project if shorter) once we
// have a real measurement to convert it into pixels-per-second.
useEffect(() => {
if (hasAppliedDefaultZoomRef.current) return;
if (visibleTrackWidth <= 0) return;
const defaultScope = Math.min(DEFAULT_VISIBLE_SECONDS, Math.max(projectSeconds, MIN_VISIBLE_SECONDS));
setPixelsPerSecond(visibleTrackWidth / defaultScope);
hasAppliedDefaultZoomRef.current = true;
}, [visibleTrackWidth, projectSeconds]);
// Re-clamp the current zoom whenever the bounds shift (project length
// changed, window resized) so the user can't end up parked outside the
// valid range from a previous session.
useEffect(() => {
setPixelsPerSecond((prev) => Math.max(minPps, Math.min(maxPps, prev)));
}, [minPps, maxPps]);
// Calculate timeline width - at least full container width
const contentWidth = (totalDurationMs / 1000) * pixelsPerSecond + 200; // Content width with padding
const timelineWidth = Math.max(contentWidth, containerWidth);
@@ -324,11 +509,11 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const pixelsToMs = useCallback((px: number) => (px / pixelsPerSecond) * 1000, [pixelsPerSecond]);
const handleZoomIn = () => {
setPixelsPerSecond((prev) => Math.min(prev * 1.5, MAX_PIXELS_PER_SECOND));
setPixelsPerSecond((prev) => Math.min(prev * 1.5, maxPps));
};
const handleZoomOut = () => {
setPixelsPerSecond((prev) => Math.max(prev / 1.5, MIN_PIXELS_PER_SECOND));
setPixelsPerSecond((prev) => Math.max(prev / 1.5, minPps));
};
// Resize handlers
@@ -374,7 +559,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const handleTimelineClick = (e: React.MouseEvent<HTMLElement>) => {
if (!tracksRef.current || draggingItem || trimmingItem) return;
const rect = tracksRef.current.getBoundingClientRect();
const x = e.clientX - rect.left + tracksRef.current.scrollLeft;
const x = e.clientX - rect.left + tracksRef.current.scrollLeft - LABEL_COL_WIDTH;
const timeMs = Math.max(0, pixelsToMs(x));
seek(timeMs);
// Deselect clip when clicking on timeline
@@ -505,7 +690,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const item = items.find((i) => i.id === selectedClipId);
if (!item) return;
const splitTimeMs = currentTimeMs - item.start_time_ms;
// currentTimeMs is driven by audio playback and arrives as a float;
// the backend's StoryItemSplit.split_time_ms is `int`, so round before
// sending or pydantic rejects the request.
const splitTimeMs = Math.round(currentTimeMs - item.start_time_ms);
const effectiveDuration = getEffectiveDuration(item);
if (splitTimeMs <= 0 || splitTimeMs >= effectiveDuration) {
@@ -590,6 +778,20 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
);
}, [selectedClipId, storyId, removeItem, toast, setSelectedClipId]);
const handleRegenerate = useCallback(async () => {
if (!selectedItem) return;
try {
await apiClient.regenerateGeneration(selectedItem.generation_id);
addPendingGeneration(selectedItem.generation_id);
} catch (error) {
toast({
title: 'Failed to regenerate',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
}
}, [selectedItem, addPendingGeneration, toast]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -654,7 +856,13 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
y: e.clientY - rect.top,
});
setDragPosition({
x: rect.left - tracksRef.current.getBoundingClientRect().left + tracksRef.current.scrollLeft,
// Subtract label column width because clips live in a sub-container offset
// by LABEL_COL_WIDTH, so dragPosition.x is stored in timeline-local coords.
x:
rect.left -
tracksRef.current.getBoundingClientRect().left +
tracksRef.current.scrollLeft -
LABEL_COL_WIDTH,
// Subtract ruler height since clips are positioned relative to tracks area, not the scrollable container
y: rect.top - tracksRef.current.getBoundingClientRect().top - TIME_RULER_HEIGHT,
});
@@ -666,7 +874,12 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
if (!draggingItem || !tracksRef.current) return;
const rect = tracksRef.current.getBoundingClientRect();
const x = e.clientX - rect.left + tracksRef.current.scrollLeft - dragOffset.x;
const x =
e.clientX -
rect.left +
tracksRef.current.scrollLeft -
dragOffset.x -
LABEL_COL_WIDTH;
// Subtract ruler height since clips are positioned relative to tracks area
const y = e.clientY - rect.top - dragOffset.y - TIME_RULER_HEIGHT;
@@ -762,7 +975,106 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
// Calculate tracks area height
const tracksAreaHeight = tracks.length * TRACK_HEIGHT;
const timelineContainerHeight = editorHeight - 40; // Subtract toolbar height
const timelineContainerHeight = editorHeight - 40 - SCRUB_BAR_HEIGHT;
// Scrollbar thumb geometry
const maxTimelineScroll = Math.max(0, timelineWidth - containerWidth);
const visibleRatio = timelineWidth > 0 ? Math.min(1, containerWidth / timelineWidth) : 1;
const thumbWidth = Math.max(24, visibleRatio * scrollbarTrackWidth);
const thumbRange = Math.max(0, scrollbarTrackWidth - thumbWidth);
const thumbLeft =
maxTimelineScroll > 0 && thumbRange > 0
? (timelineScrollLeft / maxTimelineScroll) * thumbRange
: 0;
const canScrollHorizontally = maxTimelineScroll > 0;
const handleScrollbarMouseDown = useCallback(
(mode: 'pan' | 'left' | 'right') => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
scrollbarDragRef.current = {
mode,
startX: e.clientX,
startScrollLeft: timelineScrollLeft,
startPixelsPerSecond: pixelsPerSecond,
};
},
[timelineScrollLeft, pixelsPerSecond],
);
// After a zoom drag updates pixelsPerSecond, snap scrollLeft so the anchored
// edge (left or right of the visible window) stays at the same time.
useEffect(() => {
const anchor = zoomAnchorRef.current;
if (!anchor || !tracksRef.current) return;
const timePx = (anchor.timeMs / 1000) * pixelsPerSecond;
tracksRef.current.scrollLeft =
anchor.type === 'left' ? Math.max(0, timePx) : Math.max(0, timePx - containerWidth);
}, [pixelsPerSecond, containerWidth]);
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
const drag = scrollbarDragRef.current;
if (!drag || !tracksRef.current) return;
const deltaX = e.clientX - drag.startX;
if (drag.mode === 'pan') {
if (thumbRange <= 0) return;
const deltaScroll = (deltaX / thumbRange) * maxTimelineScroll;
tracksRef.current.scrollLeft = Math.max(
0,
Math.min(maxTimelineScroll, drag.startScrollLeft + deltaScroll),
);
return;
}
if (scrollbarTrackWidth <= 0 || containerWidth <= 0) return;
// Recompute the thumb width that corresponded to the drag start, then
// apply the mouse delta to the dragged edge.
const startTimelinePx =
(totalDurationMs / 1000) * drag.startPixelsPerSecond + 200;
const startThumbWidth = Math.max(
30,
Math.min(scrollbarTrackWidth, (containerWidth / startTimelinePx) * scrollbarTrackWidth),
);
const newThumbWidth = Math.max(
30,
Math.min(
scrollbarTrackWidth,
drag.mode === 'right' ? startThumbWidth + deltaX : startThumbWidth - deltaX,
),
);
const newTimelinePx = (containerWidth / newThumbWidth) * scrollbarTrackWidth;
const rawPps = (newTimelinePx - 200) / (totalDurationMs / 1000);
const newPps = Math.max(minPps, Math.min(maxPps, rawPps));
zoomAnchorRef.current =
drag.mode === 'right'
? {
type: 'left',
timeMs: (drag.startScrollLeft / drag.startPixelsPerSecond) * 1000,
}
: {
type: 'right',
timeMs:
((drag.startScrollLeft + containerWidth) / drag.startPixelsPerSecond) * 1000,
};
setPixelsPerSecond(newPps);
};
const onMouseUp = () => {
scrollbarDragRef.current = null;
zoomAnchorRef.current = null;
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
}, [maxTimelineScroll, thumbRange, scrollbarTrackWidth, containerWidth, totalDurationMs, minPps, maxPps]);
if (items.length === 0) {
return null;
@@ -836,6 +1148,31 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
>
<Copy className="h-4 w-4" />
</Button>
{selectedItem && (
<ClipVolumePopover
storyId={storyId}
itemId={selectedItem.id}
volume={selectedItem.volume}
onChange={(value) =>
updateVolume.mutate(
{
storyId,
itemId: selectedItem.id,
data: { volume: value },
},
{
onError: (error) => {
toast({
title: 'Failed to update volume',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
},
},
)
}
/>
)}
<Button
variant="ghost"
size="icon"
@@ -846,6 +1183,18 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
>
<Trash2 className="h-4 w-4" />
</Button>
{selectedItem?.engine !== 'import' && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleRegenerate}
title="Regenerate"
aria-label="Regenerate clip"
>
<RotateCcw className="h-4 w-4" />
</Button>
)}
{hasMultipleVersions && (
<>
<div className="w-px h-4 bg-border mx-1" />
@@ -916,44 +1265,25 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
</div>
</div>
{/* Timeline container with track labels sidebar */}
<div className="flex" style={{ height: `${timelineContainerHeight}px` }}>
{/* Track labels sidebar - fixed width */}
<div className="w-16 shrink-0 border-r bg-muted/20 overflow-hidden">
{/* Spacer for time ruler */}
<div className="h-6 border-b bg-muted/30" />
{/* Track labels */}
<div style={{ height: `${tracksAreaHeight}px` }}>
{tracks.map((trackNumber, index) => (
<div
key={trackNumber}
className={cn(
'border-b flex items-center justify-center',
index % 2 === 0 ? 'bg-background' : 'bg-muted/10',
)}
style={{ height: `${TRACK_HEIGHT}px` }}
>
<span className="text-[10px] text-muted-foreground select-none">
{trackNumber}
</span>
</div>
))}
</div>
</div>
{/* Scrollable timeline area */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */}
{/* Timeline scroll container */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */}
<div
ref={tracksRef}
className="overflow-auto relative"
style={{ height: `${timelineContainerHeight}px` }}
onMouseMove={draggingItem ? handleDragMove : undefined}
onMouseUp={draggingItem ? handleDragEnd : undefined}
onMouseLeave={draggingItem ? handleDragEnd : undefined}
>
{/* Ruler row: corner spacer + time ruler, sticky to top */}
<div
ref={tracksRef}
className="overflow-auto relative flex-1"
onMouseMove={draggingItem ? handleDragMove : undefined}
onMouseUp={draggingItem ? handleDragEnd : undefined}
onMouseLeave={draggingItem ? handleDragEnd : undefined}
className="flex sticky top-0 z-30"
style={{ width: `${timelineWidth + LABEL_COL_WIDTH}px` }}
>
{/* Time ruler - clickable to seek */}
<div className="w-16 h-6 shrink-0 border-b border-r bg-muted/30 sticky left-0 z-40" />
<button
type="button"
className="h-6 border-b bg-muted/20 sticky top-0 z-10 cursor-pointer text-left"
className="h-6 border-b bg-muted/20 cursor-pointer text-left relative"
style={{ width: `${timelineWidth}px` }}
onClick={handleTimelineClick}
aria-label="Seek timeline"
@@ -971,27 +1301,72 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
</div>
))}
</button>
</div>
{/* Tracks area */}
<div
className="relative"
style={{ width: `${timelineWidth}px`, height: `${tracksAreaHeight}px` }}
>
{/* Track backgrounds - pointer-events-none to allow clicks to pass through */}
{tracks.map((trackNumber, index) => (
{/* Tracks area (rows with sticky labels + clips sub-container) */}
<div
className="relative"
style={{
width: `${timelineWidth + LABEL_COL_WIDTH}px`,
height: `${tracksAreaHeight}px`,
}}
>
{/* Per-track rows: label and background as flex siblings guarantee alignment */}
{tracks.map((trackNumber, index) => {
const isFirst = index === 0;
const isLast = index === tracks.length - 1;
return (
<div
key={trackNumber}
className={cn(
'absolute left-0 right-0 border-b pointer-events-none',
index % 2 === 0 ? 'bg-background' : 'bg-muted/10',
)}
className="absolute left-0 right-0 flex"
style={{
top: `${index * TRACK_HEIGHT}px`,
height: `${TRACK_HEIGHT}px`,
}}
/>
))}
>
<div className="w-16 shrink-0 border-b border-r flex items-center justify-center sticky left-0 z-20 h-full bg-background">
<div className="absolute inset-0 bg-muted/20 pointer-events-none" />
<span className="relative text-[10px] text-muted-foreground select-none">
{trackNumber}
</span>
{isFirst && (
<button
type="button"
onClick={handleAddTrackAbove}
title="Add track above"
aria-label="Add track above"
className="absolute top-0 right-0 left-0 h-3 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/40 transition-colors"
>
<Plus className="h-2.5 w-2.5" />
</button>
)}
{isLast && (
<button
type="button"
onClick={handleAddTrackBelow}
title="Add track below"
aria-label="Add track below"
className="absolute bottom-0 right-0 left-0 h-3 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/40 transition-colors"
>
<Plus className="h-2.5 w-2.5" />
</button>
)}
</div>
<div
className={cn(
'border-b flex-1 pointer-events-none',
index % 2 === 0 ? 'bg-background' : 'bg-muted/10',
)}
/>
</div>
);
})}
{/* Clip/playhead/seek layer offset past the label column */}
<div
className="absolute top-0 bottom-0"
style={{ left: `${LABEL_COL_WIDTH}px`, width: `${timelineWidth}px` }}
>
{/* Click area for seeking - z-index lower than clips */}
<button
type="button"
@@ -1052,7 +1427,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
{/* Clip label */}
<div className="absolute top-0 left-1 right-1 z-10">
<p className="text-[9px] font-medium text-accent-foreground truncate">
{item.profile_name}
{item.engine === 'import' ? item.text : item.profile_name}
</p>
</div>
{/* Waveform */}
@@ -1101,6 +1476,55 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
</div>
</div>
</div>
{/* Horizontal timeline scrollbar + zoom handles */}
<div
className="flex border-t bg-background/40"
style={{ height: `${SCRUB_BAR_HEIGHT}px` }}
>
<div className="w-16 shrink-0 border-r" />
<div
ref={scrollbarTrackRef}
className="relative flex-1 overflow-hidden select-none px-1"
>
<div
className="absolute top-1 bottom-1 bg-foreground/10 hover:bg-foreground/15 transition-colors group rounded-full"
style={{ width: `${thumbWidth}px`, left: `${thumbLeft}px` }}
>
{/* Left zoom handle */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: mouse-driven edge handle */}
<div
role="slider"
aria-label="Zoom from left edge"
aria-valuenow={Math.round(pixelsPerSecond)}
aria-valuemin={Math.round(minPps)}
aria-valuemax={Math.round(maxPps)}
className="absolute top-0 bottom-0 left-0 w-1.5 cursor-ew-resize bg-foreground/25 hover:bg-foreground/40 transition-colors rounded-l-full"
onMouseDown={handleScrollbarMouseDown('left')}
/>
{/* Pan area */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: mouse-driven drag area */}
<div
className={cn(
'absolute top-0 bottom-0 left-1.5 right-1.5',
canScrollHorizontally ? 'cursor-grab active:cursor-grabbing' : 'cursor-default',
)}
onMouseDown={canScrollHorizontally ? handleScrollbarMouseDown('pan') : undefined}
/>
{/* Right zoom handle */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: mouse-driven edge handle */}
<div
role="slider"
aria-label="Zoom from right edge"
aria-valuenow={Math.round(pixelsPerSecond)}
aria-valuemin={Math.round(minPps)}
aria-valuemax={Math.round(maxPps)}
className="absolute top-0 bottom-0 right-0 w-1.5 cursor-ew-resize bg-foreground/25 hover:bg-foreground/40 transition-colors rounded-r-full"
onMouseDown={handleScrollbarMouseDown('right')}
/>
</div>
</div>
</div>
</div>
</div>
);
@@ -1,5 +1,6 @@
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';
@@ -48,6 +49,7 @@ export function AudioSampleRecording({
isTranscribing = false,
showWaveform = true,
}: AudioSampleRecordingProps) {
const { t } = useTranslation();
const [audioStream, setAudioStream] = useState<MediaStream | null>(null);
// Request microphone access when component mounts
@@ -90,10 +92,10 @@ export function AudioSampleRecording({
className="relative z-10 flex items-center gap-2"
>
<Mic className="h-5 w-5" />
Start Recording
{t('audioSample.startRecording')}
</Button>
<p className="relative z-10 text-sm text-muted-foreground text-center">
Click to start recording. Maximum duration: 30 seconds.
{t('audioSample.recordHint')}
</p>
</div>
)}
@@ -115,10 +117,10 @@ export function AudioSampleRecording({
className="relative z-10 flex items-center gap-2 bg-accent text-accent-foreground hover:bg-accent/90"
>
<Square className="h-4 w-4" />
Stop Recording
{t('audioSample.stopRecording')}
</Button>
<p className="relative z-10 text-sm text-muted-foreground text-center">
{formatAudioDuration(30 - duration)} remaining
{t('audioSample.remaining', { time: formatAudioDuration(30 - duration) })}
</p>
</div>
)}
@@ -127,16 +129,18 @@ export function AudioSampleRecording({
<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">Recording complete</span>
<span className="font-medium">{t('audioSample.recordingComplete')}</span>
</div>
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
<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 ? 'Pause' : 'Play'}
aria-label={isPlaying ? t('audioSample.pause') : t('audioSample.play')}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
@@ -148,7 +152,7 @@ export function AudioSampleRecording({
className="flex items-center gap-2"
>
<Mic className="h-4 w-4" />
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
</Button>
<Button
type="button"
@@ -156,7 +160,7 @@ export function AudioSampleRecording({
onClick={onCancel}
className="flex items-center gap-2"
>
Record Again
{t('audioSample.recordAgain')}
</Button>
</div>
</div>
@@ -1,4 +1,5 @@
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';
@@ -28,6 +29,7 @@ export function AudioSampleSystem({
isPlaying,
isTranscribing = false,
}: AudioSampleSystemProps) {
const { t } = useTranslation();
return (
<FormItem>
<FormControl>
@@ -36,10 +38,10 @@ export function AudioSampleSystem({
<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" />
Start Capture
{t('audioSample.startCapture')}
</Button>
<p className="text-sm text-muted-foreground text-center">
Capture audio from your system. Maximum duration: 30 seconds.
{t('audioSample.systemHint')}
</p>
</div>
)}
@@ -61,10 +63,10 @@ export function AudioSampleSystem({
className="flex items-center gap-2"
>
<Square className="h-4 w-4" />
Stop Capture
{t('audioSample.stopCapture')}
</Button>
<p className="text-sm text-muted-foreground text-center">
{formatAudioDuration(30 - duration)} remaining
{t('audioSample.remaining', { time: formatAudioDuration(30 - duration) })}
</p>
</div>
)}
@@ -73,16 +75,18 @@ export function AudioSampleSystem({
<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">Capture complete</span>
<span className="font-medium">{t('audioSample.captureComplete')}</span>
</div>
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
<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 ? 'Pause' : 'Play'}
aria-label={isPlaying ? t('audioSample.pause') : t('audioSample.play')}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
@@ -94,7 +98,7 @@ export function AudioSampleSystem({
className="flex items-center gap-2"
>
<Mic className="h-4 w-4" />
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
</Button>
<Button
type="button"
@@ -102,7 +106,7 @@ export function AudioSampleSystem({
onClick={onCancel}
className="flex items-center gap-2"
>
Capture Again
{t('audioSample.captureAgain')}
</Button>
</div>
</div>
@@ -1,5 +1,6 @@
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';
@@ -26,6 +27,7 @@ export function AudioSampleUpload({
isDisabled = false,
fieldName,
}: AudioSampleUploadProps) {
const { t } = useTranslation();
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -90,19 +92,21 @@ export function AudioSampleUpload({
className="flex items-center gap-2"
>
<Upload className="h-5 w-5" />
Choose File
{t('audioSample.chooseFile')}
</Button>
<p className="text-sm text-muted-foreground text-center">
Click to choose a file or drag and drop. Maximum duration: 30 seconds.
{t('audioSample.uploadHint')}
</p>
</>
) : (
<>
<div className="flex items-center gap-2">
<Upload className="h-5 w-5 text-primary" />
<span className="font-medium">File uploaded</span>
<span className="font-medium">{t('audioSample.fileUploaded')}</span>
</div>
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
<p className="text-sm text-muted-foreground text-center">
{t('audioSample.fileLabel', { name: file.name })}
</p>
<div className="flex gap-2">
<Button
type="button"
@@ -110,7 +114,7 @@ export function AudioSampleUpload({
variant="outline"
onClick={onPlayPause}
disabled={isValidating}
aria-label={isPlaying ? 'Pause' : 'Play'}
aria-label={isPlaying ? t('audioSample.pause') : t('audioSample.play')}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
@@ -122,7 +126,7 @@ export function AudioSampleUpload({
className="flex items-center gap-2"
>
<Mic className="h-4 w-4" />
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
</Button>
<Button
type="button"
@@ -134,7 +138,7 @@ export function AudioSampleUpload({
}
}}
>
Remove
{t('audioSample.remove')}
</Button>
</div>
</>
@@ -1,5 +1,6 @@
import { Download, Edit, Sparkles, 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';
@@ -29,6 +30,7 @@ interface ProfileCardProps {
}
export function ProfileCard({ profile, disabled }: ProfileCardProps) {
const { t } = useTranslation();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const deleteProfile = useDeleteProfile();
@@ -41,7 +43,6 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
const isSelected = selectedProfileId === profile.id;
const handleSelect = () => {
// If disabled but already selected, bounce the selection to re-trigger engine auto-switch
if (disabled && isSelected) {
setSelectedProfileId(null);
setTimeout(() => setSelectedProfileId(profile.id), 0);
@@ -79,9 +80,10 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
}
};
const selectLabel = isSelected
? `${profile.name}, ${profile.language}. Selected as voice for generation.`
: `${profile.name}, ${profile.language}. Select as voice for generation.`;
const selectLabel = t(
isSelected ? 'profiles.card.selectLabelSelected' : 'profiles.card.selectLabel',
{ name: profile.name, language: profile.language },
);
return (
<>
@@ -89,7 +91,7 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
className={cn(
'cursor-pointer transition-all flex flex-col h-[162px]',
disabled ? 'opacity-40 hover:opacity-60' : 'hover:shadow-md',
isSelected && !disabled && 'ring-2 ring-accent shadow-md',
isSelected && !disabled && 'ring-2 border-transparent ring-accent shadow-md',
)}
onClick={handleSelect}
tabIndex={0}
@@ -105,7 +107,7 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
</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 flex items-center gap-1.5">
<Badge variant="outline" className="text-xs h-5 px-1.5 text-muted-foreground">
@@ -118,19 +120,22 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
)}
{profile.voice_type === 'designed' && (
<Badge variant="secondary" className="text-xs h-5 px-1.5">
designed
{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={Download}
onClick={handleExport}
disabled={exportProfile.isPending}
aria-label="Export profile"
aria-label={t('profiles.card.export')}
/>
<CircleButton
icon={Edit}
@@ -138,13 +143,13 @@ export function ProfileCard({ profile, disabled }: 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>
@@ -153,21 +158,21 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
<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>
+185 -122
View File
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { Edit2, Mic, Monitor, Music, Upload, 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 { Badge } from '@/components/ui/badge';
@@ -71,30 +72,40 @@ const DEFAULT_ENGINE_OPTIONS = [
{ value: 'kokoro', label: 'Kokoro 82M' },
] as const;
const baseProfileSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
description: z.string().max(500).optional(),
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
sampleFile: z.instanceof(File).optional(),
referenceText: z.string().max(1000).optional(),
avatarFile: z.instanceof(File).optional(),
});
function makeProfileSchema(t: (key: string) => string) {
const baseProfileSchema = 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[]]),
personality: z.string().max(2000).optional(),
sampleFile: z.instanceof(File).optional(),
referenceText: z.string().max(1000).optional(),
avatarFile: z.instanceof(File).optional(),
});
const profileSchema = baseProfileSchema.refine(
(data) => {
// If sample file is provided, reference text is required
if (data.sampleFile && (!data.referenceText || data.referenceText.trim().length === 0)) {
return false;
}
return true;
},
{
message: 'Reference text is required when adding a sample',
path: ['referenceText'],
},
);
return baseProfileSchema.refine(
(data) => {
if (data.sampleFile && (!data.referenceText || data.referenceText.trim().length === 0)) {
return false;
}
return true;
},
{
message: t('profileForm.validation.referenceRequired'),
path: ['referenceText'],
},
);
}
type ProfileFormValues = z.infer<typeof profileSchema>;
type ProfileFormValues = {
name: string;
description?: string;
language: LanguageCode;
personality?: string;
sampleFile?: File;
referenceText?: string;
avatarFile?: File;
};
// Helper to convert File to base64
async function fileToBase64(file: File): Promise<string> {
@@ -119,6 +130,7 @@ function base64ToFile(base64: string, fileName: string, fileType: string): File
}
export function ProfileForm() {
const { t } = useTranslation();
const platform = usePlatform();
const open = useUIStore((state) => state.profileDialogOpen);
const setOpen = useUIStore((state) => state.setProfileDialogOpen);
@@ -151,11 +163,12 @@ export function ProfileForm() {
const [defaultEngine, setDefaultEngine] = useState<string>('');
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
resolver: zodResolver(makeProfileSchema(t)),
defaultValues: {
name: '',
description: '',
language: 'en',
personality: '',
sampleFile: undefined,
referenceText: '',
avatarFile: undefined,
@@ -175,7 +188,10 @@ export function ProfileForm() {
if (duration > MAX_AUDIO_DURATION_SECONDS) {
form.setError('sampleFile', {
type: 'manual',
message: `Audio is too long (${formatAudioDuration(duration)}). Maximum duration is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
message: t('profileForm.validation.audioTooLong', {
duration: formatAudioDuration(duration),
max: formatAudioDuration(MAX_AUDIO_DURATION_SECONDS),
}),
});
} else {
form.clearErrors('sampleFile');
@@ -184,14 +200,13 @@ export function ProfileForm() {
.catch((error) => {
console.error('Failed to get audio duration:', error);
setAudioDuration(null);
// For recordings, we auto-stop at max duration, so we can skip validation errors
const isRecordedFile =
selectedFile.name.startsWith('recording-') ||
selectedFile.name.startsWith('system-audio-');
if (!isRecordedFile) {
form.setError('sampleFile', {
type: 'manual',
message: 'Failed to validate audio file. Please try a different file.',
message: t('profileForm.validation.audioFailed'),
});
} else {
// Clear any existing errors for recorded files
@@ -205,7 +220,7 @@ export function ProfileForm() {
setAudioDuration(null);
form.clearErrors('sampleFile');
}
}, [selectedFile, form]);
}, [selectedFile, form, t]);
const {
isRecording,
@@ -226,8 +241,8 @@ export function ProfileForm() {
}
form.setValue('sampleFile', file, { shouldValidate: true });
toast({
title: 'Recording complete',
description: 'Audio has been recorded successfully.',
title: t('profileForm.toast.recordingComplete'),
description: t('profileForm.toast.recordingCompleteDescription'),
});
},
});
@@ -252,8 +267,8 @@ export function ProfileForm() {
}
form.setValue('sampleFile', file, { shouldValidate: true });
toast({
title: 'System audio captured',
description: 'Audio has been captured successfully.',
title: t('profileForm.toast.systemAudioCaptured'),
description: t('profileForm.toast.systemAudioCapturedDescription'),
});
},
});
@@ -282,23 +297,22 @@ export function ProfileForm() {
useEffect(() => {
if (recordingError) {
toast({
title: 'Recording error',
title: t('profileForm.toast.recordingError'),
description: recordingError,
variant: 'destructive',
});
}
}, [recordingError, toast]);
}, [recordingError, toast, t]);
// Show system audio recording errors
useEffect(() => {
if (systemRecordingError) {
toast({
title: 'System audio capture error',
title: t('profileForm.toast.systemAudioError'),
description: systemRecordingError,
variant: 'destructive',
});
}
}, [systemRecordingError, toast]);
}, [systemRecordingError, toast, t]);
// Handle avatar preview
useEffect(() => {
@@ -320,6 +334,7 @@ export function ProfileForm() {
name: editingProfile.name,
description: editingProfile.description || '',
language: editingProfile.language as LanguageCode,
personality: editingProfile.personality || '',
sampleFile: undefined,
referenceText: undefined,
avatarFile: undefined,
@@ -333,6 +348,7 @@ export function ProfileForm() {
name: profileFormDraft.name,
description: profileFormDraft.description,
language: profileFormDraft.language as LanguageCode,
personality: profileFormDraft.personality || '',
referenceText: profileFormDraft.referenceText,
sampleFile: undefined,
avatarFile: undefined,
@@ -357,6 +373,7 @@ export function ProfileForm() {
name: '',
description: '',
language: 'en',
personality: '',
sampleFile: undefined,
referenceText: undefined,
avatarFile: undefined,
@@ -388,8 +405,8 @@ export function ProfileForm() {
const file = form.getValues('sampleFile');
if (!file) {
toast({
title: 'No file selected',
description: 'Please select an audio file first.',
title: t('profileForm.toast.noFile'),
description: t('profileForm.toast.noFileDescription'),
variant: 'destructive',
});
return;
@@ -402,8 +419,9 @@ export function ProfileForm() {
form.setValue('referenceText', result.text, { shouldValidate: true });
} catch (error) {
toast({
title: 'Transcription failed',
description: error instanceof Error ? error.message : 'Failed to transcribe audio',
title: t('profileForm.toast.transcribeFailed'),
description:
error instanceof Error ? error.message : t('profileForm.toast.transcribeFailedFallback'),
variant: 'destructive',
});
}
@@ -429,16 +447,16 @@ export function ProfileForm() {
if (file) {
if (!file.type.startsWith('image/')) {
toast({
title: 'Invalid file type',
description: 'Please select an image file (PNG, JPG, or WebP)',
title: t('profileForm.toast.invalidFile'),
description: t('profileForm.toast.invalidImageFormat'),
variant: 'destructive',
});
return;
}
if (file.size > 5 * 1024 * 1024) {
toast({
title: 'File too large',
description: 'Image must be less than 5MB',
title: t('profileForm.toast.fileTooLarge'),
description: t('profileForm.toast.imageTooLargeDescription'),
variant: 'destructive',
});
return;
@@ -452,13 +470,13 @@ export function ProfileForm() {
try {
await deleteAvatar.mutateAsync(editingProfileId);
toast({
title: 'Avatar removed',
description: 'Avatar image has been removed successfully.',
title: t('profileForm.toast.avatarRemoved'),
description: t('profileForm.toast.avatarRemovedDescription'),
});
} catch (error) {
toast({
title: 'Failed to remove avatar',
description: error instanceof Error ? error.message : 'Unknown error',
title: t('profileForm.toast.avatarRemoveFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
variant: 'destructive',
});
}
@@ -481,6 +499,7 @@ export function ProfileForm() {
description: data.description,
language: data.language,
default_engine: defaultEngine || undefined,
personality: data.personality?.trim() ? data.personality.trim() : undefined,
},
});
@@ -493,9 +512,11 @@ export function ProfileForm() {
});
} catch (avatarError) {
toast({
title: 'Avatar upload failed',
title: t('profileForm.toast.avatarUploadFailed'),
description:
avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
avatarError instanceof Error
? avatarError.message
: t('profileForm.toast.avatarUploadFailedFallback'),
variant: 'destructive',
});
}
@@ -510,9 +531,11 @@ export function ProfileForm() {
);
} catch (fxError) {
toast({
title: 'Effects update failed',
title: t('profileForm.toast.effectsUpdateFailed'),
description:
fxError instanceof Error ? fxError.message : 'Failed to save effects chain',
fxError instanceof Error
? fxError.message
: t('profileForm.toast.effectsUpdateFailedFallback'),
variant: 'destructive',
});
return;
@@ -520,15 +543,15 @@ export function ProfileForm() {
}
toast({
title: 'Voice updated',
description: `"${data.name}" has been updated successfully.`,
title: t('profileForm.toast.voiceUpdated'),
description: t('profileForm.toast.voiceUpdatedDescription', { name: data.name }),
});
} else if (voiceSource === 'builtin') {
// Creating preset profile from built-in voice
if (!selectedPresetVoiceId) {
toast({
title: 'No voice selected',
description: 'Please select a built-in voice.',
title: t('profileForm.toast.noVoiceSelected'),
description: t('profileForm.toast.noVoiceSelectedDescription'),
variant: 'destructive',
});
return;
@@ -542,6 +565,7 @@ export function ProfileForm() {
preset_engine: selectedPresetEngine,
preset_voice_id: selectedPresetVoiceId,
default_engine: selectedPresetEngine,
personality: data.personality?.trim() ? data.personality.trim() : undefined,
});
// Handle avatar upload if provided
@@ -553,17 +577,19 @@ export function ProfileForm() {
});
} catch (avatarError) {
toast({
title: 'Avatar upload failed',
title: t('profileForm.toast.avatarUploadFailed'),
description:
avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
avatarError instanceof Error
? avatarError.message
: t('profileForm.toast.avatarUploadFailedFallback'),
variant: 'destructive',
});
}
}
toast({
title: 'Profile created',
description: `"${data.name}" has been created with a built-in voice.`,
title: t('profileForm.toast.profileCreated'),
description: t('profileForm.toast.profileCreatedBuiltin', { name: data.name }),
});
} else {
// Creating cloned profile: require sample file and reference text
@@ -573,11 +599,11 @@ export function ProfileForm() {
if (!sampleFile) {
form.setError('sampleFile', {
type: 'manual',
message: 'Audio sample is required',
message: t('profileForm.validation.sampleRequired'),
});
toast({
title: 'Audio sample required',
description: 'Please provide an audio sample to create the voice profile.',
title: t('profileForm.toast.sampleRequired'),
description: t('profileForm.toast.sampleRequiredDescription'),
variant: 'destructive',
});
return;
@@ -586,42 +612,48 @@ export function ProfileForm() {
if (!referenceText || referenceText.trim().length === 0) {
form.setError('referenceText', {
type: 'manual',
message: 'Reference text is required',
message: t('profileForm.validation.referenceTextRequired'),
});
toast({
title: 'Reference text required',
description: 'Please provide the reference text for the audio sample.',
title: t('profileForm.toast.referenceTextRequired'),
description: t('profileForm.toast.referenceTextRequiredDescription'),
variant: 'destructive',
});
return;
}
// Validate audio duration before creating profile
try {
const duration = await getAudioDuration(sampleFile);
if (duration > MAX_AUDIO_DURATION_SECONDS) {
form.setError('sampleFile', {
type: 'manual',
message: `Audio is too long (${formatAudioDuration(duration)}). Maximum duration is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
message: t('profileForm.validation.audioTooLong', {
duration: formatAudioDuration(duration),
max: formatAudioDuration(MAX_AUDIO_DURATION_SECONDS),
}),
});
toast({
title: 'Invalid audio file',
description: `Audio duration is ${formatAudioDuration(duration)}, but maximum is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
title: t('profileForm.toast.invalidAudio'),
description: t('profileForm.toast.invalidAudioDescription', {
duration: formatAudioDuration(duration),
max: formatAudioDuration(MAX_AUDIO_DURATION_SECONDS),
}),
variant: 'destructive',
});
return; // Prevent form submission
return;
}
} catch (error) {
form.setError('sampleFile', {
type: 'manual',
message: 'Failed to validate audio file. Please try a different file.',
message: t('profileForm.validation.audioFailed'),
});
toast({
title: 'Validation error',
description: error instanceof Error ? error.message : 'Failed to validate audio file',
title: t('profileForm.toast.validationError'),
description:
error instanceof Error ? error.message : t('profileForm.validation.audioFailed'),
variant: 'destructive',
});
return; // Prevent form submission
return;
}
// Creating: create profile, then add sample
@@ -630,6 +662,7 @@ export function ProfileForm() {
description: data.description,
language: data.language,
default_engine: defaultEngine || undefined,
personality: data.personality?.trim() ? data.personality.trim() : undefined,
});
// Convert non-WAV uploads to WAV so the backend can always use soundfile.
@@ -670,8 +703,8 @@ export function ProfileForm() {
}
toast({
title: 'Profile created',
description: `"${data.name}" has been created with a sample.`,
title: t('profileForm.toast.profileCreated'),
description: t('profileForm.toast.profileCreatedSample', { name: data.name }),
});
} catch (sampleError) {
let rollbackSucceeded = false;
@@ -680,23 +713,26 @@ export function ProfileForm() {
rollbackSucceeded = true;
} catch (rollbackError) {
toast({
title: 'Rollback failed',
title: t('profileForm.toast.rollbackFailed'),
description:
rollbackError instanceof Error
? rollbackError.message
: 'Created profile could not be removed after sample upload failure.',
: t('profileForm.toast.rollbackFailedDescription'),
variant: 'destructive',
});
}
const rollbackSuffix = rollbackSucceeded
? ` ${t('profileForm.toast.profileRolledBack')}`
: '';
toast({
title: 'Failed to add sample',
title: t('profileForm.toast.sampleFailed'),
description:
sampleError instanceof Error
? `${sampleError.message}${rollbackSucceeded ? ' The profile was rolled back.' : ''}`
? `${sampleError.message}${rollbackSuffix}`
: rollbackSucceeded
? 'Failed to add sample. The profile was rolled back.'
: 'Failed to add sample.',
? t('profileForm.toast.sampleFailedRolledBack')
: t('profileForm.toast.sampleFailedDescription'),
variant: 'destructive',
});
return;
@@ -710,8 +746,8 @@ export function ProfileForm() {
setOpen(false);
} catch (error) {
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to save profile',
title: t('common.error'),
description: error instanceof Error ? error.message : t('profileForm.toast.saveFailed'),
variant: 'destructive',
});
}
@@ -729,6 +765,7 @@ export function ProfileForm() {
name: values.name || '',
description: values.description || '',
language: values.language || 'en',
personality: values.personality || '',
referenceText: values.referenceText || '',
sampleMode,
};
@@ -768,16 +805,18 @@ export function ProfileForm() {
<div className="max-w-5xl h-[85vh] mx-auto my-auto w-full flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle className="text-2xl">
{editingProfileId ? 'Edit Voice' : 'Create Voice'}
{editingProfileId ? t('profileForm.editTitle') : t('profileForm.createTitle')}
</DialogTitle>
<DialogDescription>
{editingProfileId
? 'Update your voice profile details and manage samples.'
: 'Create a new voice profile from an audio sample or a built-in voice.'}
? t('profileForm.editDescription')
: t('profileForm.createDescription')}
</DialogDescription>
{isCreating && profileFormDraft && (
<div className="flex items-center gap-2 pt-2">
<span className="text-xs text-muted-foreground">Draft restored</span>
<span className="text-xs text-muted-foreground">
{t('profileForm.draftRestored')}
</span>
<Button
type="button"
variant="ghost"
@@ -789,14 +828,16 @@ export function ProfileForm() {
name: '',
description: '',
language: 'en',
personality: '',
sampleFile: undefined,
referenceText: '',
avatarFile: undefined,
});
setSampleMode('record');
}}
>
<X className="h-3 w-3 mr-1" />
Discard
{t('profileForm.discard')}
</Button>
</div>
)}
@@ -822,7 +863,7 @@ export function ProfileForm() {
}`}
>
<Mic className="h-3.5 w-3.5" />
Clone from audio
{t('profileForm.source.clone')}
</button>
<button
type="button"
@@ -834,20 +875,17 @@ export function ProfileForm() {
}`}
>
<Music className="h-3.5 w-3.5" />
Built-in voice
{t('profileForm.source.builtin')}
</button>
</div>
</div>
{voiceSource === 'builtin' ? (
<div className="space-y-4">
<FormDescription>
Choose a pre-built voice. These don't require an audio sample.
</FormDescription>
<FormDescription>{t('profileForm.builtin.hint')}</FormDescription>
{/* Engine selector */}
<FormItem>
<FormLabel>Engine</FormLabel>
<FormLabel>{t('profileForm.fields.engine')}</FormLabel>
<Select
value={selectedPresetEngine}
onValueChange={setSelectedPresetEngine}
@@ -866,7 +904,7 @@ export function ProfileForm() {
{/* Voice picker */}
<FormItem>
<FormLabel>Voice</FormLabel>
<FormLabel>{t('profileForm.fields.voice')}</FormLabel>
<div className="grid grid-cols-2 gap-1.5 max-h-[340px] overflow-y-auto pr-1">
{presetVoices.map((voice: PresetVoice) => (
<button
@@ -921,16 +959,16 @@ export function ProfileForm() {
>
<TabsTrigger value="upload" className="flex items-center gap-2">
<Upload className="h-4 w-4 shrink-0" />
Upload
{t('profileForm.sampleTabs.upload')}
</TabsTrigger>
<TabsTrigger value="record" className="flex items-center gap-2">
<Mic className="h-4 w-4 shrink-0" />
Record
{t('profileForm.sampleTabs.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
{t('profileForm.sampleTabs.system')}
</TabsTrigger>
)}
</TabsList>
@@ -1008,10 +1046,10 @@ export function ProfileForm() {
name="referenceText"
render={({ field }) => (
<FormItem>
<FormLabel>Reference Text</FormLabel>
<FormLabel>{t('profileForm.fields.referenceText')}</FormLabel>
<FormControl>
<Textarea
placeholder="Enter the exact text spoken in the audio..."
placeholder={t('profileForm.fields.referenceTextPlaceholder')}
className="min-h-[100px]"
{...field}
/>
@@ -1031,7 +1069,7 @@ export function ProfileForm() {
<div className="space-y-4 pt-4">
<div className="rounded-lg border border-border p-4 space-y-3">
<div className="text-sm font-medium text-muted-foreground">
Built-in Voice
{t('profileForm.builtin.badge')}
</div>
<div className="flex items-center gap-3">
<div className="text-lg font-semibold">
@@ -1060,8 +1098,7 @@ export function ProfileForm() {
})()}
</div>
<p className="text-xs text-muted-foreground">
This profile uses a built-in voice. The voice cannot be changed after
creation.
{t('profileForm.builtin.note')}
</p>
</div>
) : (
@@ -1087,7 +1124,7 @@ export function ProfileForm() {
{avatarPreview ? (
<img
src={avatarPreview}
alt="Avatar preview"
alt={t('profileForm.avatar.alt')}
className="h-full w-full object-cover"
/>
) : (
@@ -1131,9 +1168,9 @@ export function ProfileForm() {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel>{t('profileForm.fields.name')}</FormLabel>
<FormControl>
<Input placeholder="My Voice" {...field} />
<Input placeholder={t('profileForm.fields.namePlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -1145,21 +1182,45 @@ export function ProfileForm() {
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description (Optional)</FormLabel>
<FormLabel>{t('profileForm.fields.descriptionLabel')}</FormLabel>
<FormControl>
<Textarea placeholder="Describe this voice..." {...field} />
<Textarea
placeholder={t('profileForm.fields.descriptionPlaceholder')}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="personality"
render={({ field }) => (
<FormItem>
<FormLabel>{t('profileForm.fields.personalityLabel')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('profileForm.fields.personalityPlaceholder')}
className="min-h-[96px]"
{...field}
/>
</FormControl>
<FormDescription>
{t('profileForm.fields.personalityHint')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>Language</FormLabel>
<FormLabel>{t('profileForm.fields.language')}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
@@ -1180,7 +1241,7 @@ export function ProfileForm() {
/>
<FormItem>
<FormLabel>Default Engine</FormLabel>
<FormLabel>{t('profileForm.fields.defaultEngine')}</FormLabel>
<Select
value={defaultEngine || '_none'}
onValueChange={(v) => {
@@ -1192,11 +1253,13 @@ export function ProfileForm() {
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="No preference" />
<SelectValue placeholder={t('profileForm.fields.noPreference')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="_none">No preference</SelectItem>
<SelectItem value="_none">
{t('profileForm.fields.noPreference')}
</SelectItem>
{availableDefaultEngines.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
@@ -1205,15 +1268,15 @@ export function ProfileForm() {
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Auto-selects this engine when the profile is chosen.
{t('profileForm.fields.defaultEngineHint')}
</p>
</FormItem>
{editingProfileId && (
<div className="space-y-2">
<FormLabel>Default Effects</FormLabel>
<FormLabel>{t('profileForm.fields.defaultEffects')}</FormLabel>
<p className="text-xs text-muted-foreground">
Effects applied automatically to all new generations with this voice.
{t('profileForm.fields.defaultEffectsHint')}
</p>
<EffectsChainEditor
value={profileEffectsChain}
@@ -1230,7 +1293,7 @@ export function ProfileForm() {
<div className="flex gap-2 justify-end mt-6 pt-4 border-t">
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
{t('common.cancel')}
</Button>
<Button
type="submit"
@@ -1239,10 +1302,10 @@ export function ProfileForm() {
}
>
{createProfile.isPending || updateProfile.isPending || addSample.isPending
? 'Saving...'
? t('profileForm.actions.saving')
: editingProfileId
? 'Save Changes'
: 'Create Profile'}
? t('profileForm.actions.saveChanges')
: t('profileForm.actions.createProfile')}
</Button>
</div>
</form>
@@ -1,5 +1,6 @@
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';
@@ -11,6 +12,7 @@ import { ProfileForm } from './ProfileForm';
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);
@@ -45,7 +47,9 @@ export function ProfileList() {
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>
);
}
@@ -73,12 +77,10 @@ export function ProfileList() {
<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 Voice
{t('profiles.list.createVoice')}
</Button>
</CardContent>
</Card>
@@ -99,7 +101,7 @@ export function ProfileList() {
{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>Only supported voice profiles can be selected for the current model.</span>
<span>{t('profiles.list.unsupportedNote')}</span>
</div>
)}
</div>
+33 -34
View File
@@ -1,5 +1,6 @@
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 {
@@ -24,6 +25,7 @@ interface MiniSamplePlayerProps {
}
function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
const { t } = useTranslation();
const audioRef = useRef<HTMLAudioElement | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
@@ -102,7 +104,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
className="h-7 w-7 shrink-0"
onClick={handlePlayPause}
disabled={isLoading}
aria-label={isPlaying ? 'Pause sample' : 'Play sample'}
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>
@@ -114,8 +116,11 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
max={100}
step={0.1}
className="flex-1"
aria-label="Sample playback position"
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
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>
@@ -130,8 +135,8 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
size="icon"
className="h-7 w-7 shrink-0"
onClick={handleStop}
title="Stop"
aria-label="Stop playback"
title={t('sampleList.player.stop')}
aria-label={t('sampleList.player.stopAria')}
>
<X className="h-3.5 w-3.5" />
</Button>
@@ -145,6 +150,7 @@ interface SampleListProps {
}
export function SampleList({ profileId }: SampleListProps) {
const { t } = useTranslation();
const { data: samples, isLoading } = useProfileSamples(profileId);
const deleteSample = useDeleteSample();
const updateSample = useUpdateSample();
@@ -181,8 +187,8 @@ export function SampleList({ profileId }: SampleListProps) {
const handleSaveEdit = async (sampleId: string) => {
if (!editedText.trim()) {
toast({
title: 'Invalid text',
description: 'Reference text cannot be empty.',
title: t('sampleList.toast.invalidText'),
description: t('sampleList.toast.invalidTextDescription'),
variant: 'destructive',
});
return;
@@ -191,22 +197,23 @@ export function SampleList({ profileId }: SampleListProps) {
try {
await updateSample.mutateAsync({ sampleId, referenceText: editedText.trim() });
toast({
title: 'Sample updated',
description: 'Reference text has been updated successfully.',
title: t('sampleList.toast.updated'),
description: t('sampleList.toast.updatedDescription'),
});
setEditingSampleId(null);
setEditedText('');
} catch (error) {
toast({
title: 'Update failed',
description: error instanceof Error ? error.message : 'Failed to update sample',
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 (
@@ -214,10 +221,8 @@ export function SampleList({ profileId }: SampleListProps) {
{samples && samples.length === 0 ? (
<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">No samples yet</p>
<p className="text-xs text-muted-foreground/70 mt-1">
Add your first audio sample to get started
</p>
<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">
@@ -237,13 +242,13 @@ export function SampleList({ profileId }: SampleListProps) {
<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>Editing transcription</span>
<span>{t('sampleList.editing')}</span>
</div>
<Textarea
value={editedText}
onChange={(e) => setEditedText(e.target.value)}
className="min-h-[100px] text-sm resize-none"
placeholder="Enter reference text..."
placeholder={t('sampleList.placeholder')}
autoFocus
/>
<div className="flex items-center justify-end gap-2 pt-1">
@@ -255,7 +260,7 @@ export function SampleList({ profileId }: SampleListProps) {
disabled={updateSample.isPending}
>
<X className="h-4 w-4 mr-1" />
Cancel
{t('common.cancel')}
</Button>
<Button
type="button"
@@ -264,7 +269,7 @@ export function SampleList({ profileId }: SampleListProps) {
disabled={updateSample.isPending}
>
<Check className="h-4 w-4 mr-1" />
{updateSample.isPending ? 'Saving...' : 'Save'}
{updateSample.isPending ? t('sampleList.saving') : t('common.save')}
</Button>
</div>
</div>
@@ -283,12 +288,12 @@ export function SampleList({ profileId }: SampleListProps) {
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<CircleButton
icon={Edit}
title="Edit transcription"
title={t('sampleList.editTranscription')}
onClick={() => handleStartEdit(sample.id, sample.reference_text)}
/>
<CircleButton
icon={Trash2}
title="Delete sample"
title={t('sampleList.deleteSample')}
onClick={() => handleDeleteClick(sample.id)}
disabled={deleteSample.isPending}
/>
@@ -317,24 +322,18 @@ export function SampleList({ profileId }: SampleListProps) {
onClick={() => setUploadOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
Add Sample
{t('sampleList.addSample')}
</Button>
<p className="text-xs text-muted-foreground text-center px-2">
Note: A single 30-second sample is the sweet spot. Quality may decrease with multiple
samples. In a future update samples might be interchangeable and tagged for varying styles
of the same voice.
</p>
<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>Delete Sample</DialogTitle>
<DialogDescription>
Are you sure you want to delete this audio sample? This action cannot be undone.
</DialogDescription>
<DialogTitle>{t('sampleList.deleteDialog.title')}</DialogTitle>
<DialogDescription>{t('sampleList.deleteDialog.description')}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
@@ -344,14 +343,14 @@ export function SampleList({ profileId }: SampleListProps) {
setSampleToDelete(null);
}}
>
Cancel
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={deleteSample.isPending}
>
{deleteSample.isPending ? 'Deleting...' : 'Delete'}
{deleteSample.isPending ? t('sampleList.deleteDialog.deleting') : t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
+51 -32
View File
@@ -2,6 +2,7 @@ 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';
@@ -38,19 +39,26 @@ import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore';
import { useServerStore } from '@/stores/serverStore';
const profileSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
description: z.string().max(500).optional(),
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
});
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 = z.infer<typeof profileSchema>;
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;
@@ -68,7 +76,7 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
const [effectsDirty, setEffectsDirty] = useState(false);
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
resolver: zodResolver(makeProfileSchema(t)),
defaultValues: {
name: '',
description: '',
@@ -104,32 +112,31 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
if (!file) return;
if (!file.type.startsWith('image/')) {
toast({
title: 'Invalid file type',
description: 'Please select PNG, JPG, or WebP',
title: t('profileForm.toast.invalidFile'),
description: t('voiceInspector.toast.invalidImageFormat'),
variant: 'destructive',
});
return;
}
if (file.size > 5 * 1024 * 1024) {
toast({
title: 'File too large',
description: 'Image must be less than 5MB',
title: t('profileForm.toast.fileTooLarge'),
description: t('profileForm.toast.imageTooLargeDescription'),
variant: 'destructive',
});
return;
}
// Upload immediately
uploadAvatar.mutate(
{ profileId, file },
{
onSuccess: () => {
setAvatarPreview(URL.createObjectURL(file));
toast({ title: 'Avatar updated' });
toast({ title: t('voiceInspector.toast.avatarUpdated') });
},
onError: (err) => {
toast({
title: 'Avatar upload failed',
description: err instanceof Error ? err.message : 'Unknown error',
title: t('profileForm.toast.avatarUploadFailed'),
description: err instanceof Error ? err.message : t('common.unknownError'),
variant: 'destructive',
});
},
@@ -141,11 +148,11 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
if (profile?.avatar_path) {
try {
await deleteAvatar.mutateAsync(profileId);
toast({ title: 'Avatar removed' });
toast({ title: t('profileForm.toast.avatarRemoved') });
} catch (err) {
toast({
title: 'Failed to remove avatar',
description: err instanceof Error ? err.message : 'Unknown error',
title: t('profileForm.toast.avatarRemoveFailed'),
description: err instanceof Error ? err.message : t('common.unknownError'),
variant: 'destructive',
});
}
@@ -174,19 +181,25 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
setEffectsDirty(false);
} catch (fxError) {
toast({
title: 'Effects update failed',
description: fxError instanceof Error ? fxError.message : 'Failed to save effects',
title: t('profileForm.toast.effectsUpdateFailed'),
description:
fxError instanceof Error
? fxError.message
: t('profileForm.toast.effectsUpdateFailedFallback'),
variant: 'destructive',
});
return;
}
}
toast({ title: 'Voice updated', description: `"${data.name}" saved.` });
toast({
title: t('profileForm.toast.voiceUpdated'),
description: t('voiceInspector.toast.savedDescription', { name: data.name }),
});
} catch (error) {
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to save profile',
title: t('common.error'),
description: error instanceof Error ? error.message : t('profileForm.toast.saveFailed'),
variant: 'destructive',
});
}
@@ -195,7 +208,7 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
if (!profile) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Loading...
{t('voiceInspector.loading')}
</div>
);
}
@@ -256,9 +269,9 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel>{t('profileForm.fields.name')}</FormLabel>
<FormControl>
<Input placeholder="My Voice" {...field} />
<Input placeholder={t('profileForm.fields.namePlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -270,9 +283,13 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormLabel>{t('voiceInspector.fields.description')}</FormLabel>
<FormControl>
<Textarea placeholder="Describe this voice..." rows={2} {...field} />
<Textarea
placeholder={t('profileForm.fields.descriptionPlaceholder')}
rows={2}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -284,7 +301,7 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>Language</FormLabel>
<FormLabel>{t('profileForm.fields.language')}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
@@ -306,9 +323,9 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
{/* Effects */}
<div className="space-y-2">
<FormLabel>Default Effects</FormLabel>
<FormLabel>{t('profileForm.fields.defaultEffects')}</FormLabel>
<p className="text-xs text-muted-foreground">
Applied automatically to new generations with this voice.
{t('voiceInspector.defaultEffectsHint')}
</p>
<EffectsChainEditor
value={effectsChain}
@@ -323,7 +340,9 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
{/* Save */}
{isDirty && (
<Button type="submit" className="w-full" disabled={updateProfile.isPending}>
{updateProfile.isPending ? 'Saving...' : 'Save Changes'}
{updateProfile.isPending
? t('profileForm.actions.saving')
: t('profileForm.actions.saveChanges')}
</Button>
)}
</div>
+16 -13
View File
@@ -1,6 +1,7 @@
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';
@@ -25,6 +26,7 @@ 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);
@@ -95,7 +97,7 @@ export function VoicesTab() {
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-muted-foreground">Loading voices...</div>
<div className="text-muted-foreground">{t('voicesTab.loading')}</div>
</div>
);
}
@@ -110,12 +112,12 @@ export function VoicesTab() {
{/* 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">Voices</h1>
<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="Search voices..."
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"
@@ -123,7 +125,7 @@ export function VoicesTab() {
</div>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
New Voice
{t('voicesTab.newVoice')}
</Button>
</div>
</div>
@@ -139,12 +141,12 @@ export function VoicesTab() {
<Table className="table-fixed [&_td:first-child]:pl-8 [&_th:first-child]:pl-8">
<TableHeader>
<TableRow>
<TableHead className="w-[30%]">Name</TableHead>
<TableHead className="w-[10%]">Language</TableHead>
<TableHead className="w-[10%]">Generations</TableHead>
<TableHead className="w-[8%]">Samples</TableHead>
<TableHead className="w-[8%]">Effects</TableHead>
<TableHead className="w-[24%]">Channels</TableHead>
<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>
@@ -194,6 +196,7 @@ function VoiceRow({
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;
@@ -212,7 +215,7 @@ function VoiceRow({
{avatarUrl && !avatarError ? (
<img
src={avatarUrl}
alt={`${profile.name} avatar`}
alt={t('voicesTab.avatarAlt', { name: profile.name })}
className="h-full w-full object-cover"
onError={() => setAvatarError(true)}
/>
@@ -248,11 +251,11 @@ function VoiceRow({
<MultiSelect
options={channels.map((ch) => ({
value: ch.id,
label: `${ch.name}${ch.is_default ? ' (Default)' : ''}`,
label: ch.is_default ? t('voicesTab.channelDefaultLabel', { name: ch.name }) : ch.name,
}))}
value={channelIds}
onChange={onChannelChange}
placeholder="Select channels..."
placeholder={t('voicesTab.selectChannels')}
className="w-full"
/>
</TableCell>
+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',
+22
View File
@@ -0,0 +1,22 @@
import { useEffect } from 'react';
import { useUIStore } from '@/stores/uiStore';
export function useThemeSync() {
const theme = useUIStore((s) => s.theme);
useEffect(() => {
if (theme !== 'system') {
document.documentElement.classList.toggle('dark', theme === 'dark');
return;
}
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const apply = () => {
document.documentElement.classList.toggle('dark', mq.matches);
};
apply();
mq.addEventListener('change', apply);
return () => mq.removeEventListener('change', apply);
}, [theme]);
}
+40
View File
@@ -0,0 +1,40 @@
import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import { initReactI18next } from 'react-i18next';
import en from './locales/en/translation.json';
import ja from './locales/ja/translation.json';
import zhCN from './locales/zh-CN/translation.json';
import zhTW from './locales/zh-TW/translation.json';
export const SUPPORTED_LANGUAGES = [
{ code: 'en', label: 'English' },
{ code: 'ja', label: '日本語' },
{ code: 'zh-CN', label: '简体中文' },
{ code: 'zh-TW', label: '繁體中文' },
] as const;
export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]['code'];
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
ja: { translation: ja },
'zh-CN': { translation: zhCN },
'zh-TW': { translation: zhTW },
},
fallbackLng: 'en',
supportedLngs: SUPPORTED_LANGUAGES.map((l) => l.code),
load: 'currentOnly',
interpolation: { escapeValue: false },
react: { useSuspense: false },
detection: {
order: ['localStorage', 'navigator'],
lookupLocalStorage: 'voicebox:lang',
caches: ['localStorage'],
},
});
export default i18n;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+20 -15
View File
@@ -44,24 +44,24 @@
:root {
--background: 0 0% 95%;
--foreground: 222.2 84% 4.9%;
--foreground: 0 0% 5%;
--card: 0 0% 97%;
--card-foreground: 222.2 84% 4.9%;
--card-foreground: 0 0% 5%;
--popover: 0 0% 97%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 92%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 90%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 43 50% 50%;
--accent-foreground: 222.2 47.4% 11.2%;
--popover-foreground: 0 0% 5%;
--primary: 43 55% 58%;
--primary-foreground: 0 0% 100%;
--secondary: 0 0% 92%;
--secondary-foreground: 0 0% 11%;
--muted: 0 0% 90%;
--muted-foreground: 0 0% 47%;
--accent: 43 55% 58%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 85%;
--input: 214.3 31.8% 88%;
--ring: 222.2 84% 4.9%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 85%;
--input: 0 0% 88%;
--ring: 0 0% 5%;
--sidebar: 0 0% 92%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
@@ -157,6 +157,11 @@
opacity: 0;
}
.dark .sidebar-logo {
filter: drop-shadow(0 0 6px hsl(var(--accent) / 0.5))
drop-shadow(0 0 14px hsl(var(--accent) / 0.35)) drop-shadow(0 0 28px hsl(var(--accent) / 0.2));
}
/* react-loaders */
.line-scale-pulse-out-rapid > div,
.line-scale > div {
+191
View File
@@ -18,6 +18,7 @@ import type {
ModelDownloadRequest,
ModelStatusListResponse,
PresetVoice,
PersonalityTextResponse,
ProfileSampleResponse,
StoryCreate,
StoryDetailResponse,
@@ -29,11 +30,29 @@ import type {
StoryItemSplit,
StoryItemTrim,
StoryItemVersionUpdate,
StoryItemVolumeUpdate,
StoryResponse,
TranscriptionResponse,
VoiceProfileCreate,
VoiceProfileResponse,
WhisperModelSize,
CaptureListResponse,
CaptureResponse,
CaptureCreateResponse,
CaptureReadinessResponse,
CaptureRefineRequest,
CaptureRetranscribeRequest,
CaptureSettings,
CaptureSettingsUpdate,
CaptureSource,
GenerationSettings,
GenerationSettingsUpdate,
MCPClientBinding,
MCPClientBindingListResponse,
MCPClientBindingUpsert,
HostCandidate,
PairInitResponse,
PairedDeviceResponse,
} from './types';
function formatErrorDetail(detail: unknown, fallback: string): string {
@@ -115,6 +134,17 @@ class ApiClient {
});
}
// ── Personality-driven text generation ─────────────────────────────
// Compose produces a fresh in-character utterance the UI drops into
// the generate textarea. Rewrite now happens server-side inside
// `/generate` when `personality: true` is passed in the request body.
async composeWithPersonality(profileId: string): Promise<PersonalityTextResponse> {
return this.request<PersonalityTextResponse>(`/profiles/${profileId}/compose`, {
method: 'POST',
});
}
async addProfileSample(
profileId: string,
file: File,
@@ -246,6 +276,20 @@ class ApiClient {
});
}
async importAudio(file: File): Promise<GenerationResponse> {
const form = new FormData();
form.append('file', file);
const res = await fetch(`${this.getBaseUrl()}/generate/import`, {
method: 'POST',
body: form,
});
if (!res.ok) {
const detail = await res.text().catch(() => res.statusText);
throw new Error(detail || `HTTP ${res.status}`);
}
return res.json();
}
async toggleFavorite(generationId: string): Promise<{ is_favorited: boolean }> {
return this.request<{ is_favorited: boolean }>(`/history/${generationId}/favorite`, {
method: 'POST',
@@ -381,6 +425,122 @@ class ApiClient {
return response.json();
}
// Captures
async listCaptures(limit = 50, offset = 0): Promise<CaptureListResponse> {
return this.request<CaptureListResponse>(
`/captures?limit=${limit}&offset=${offset}`,
);
}
async getCapture(captureId: string): Promise<CaptureResponse> {
return this.request<CaptureResponse>(`/captures/${captureId}`);
}
async createCapture(
file: File,
options?: {
source?: CaptureSource;
language?: LanguageCode;
sttModel?: WhisperModelSize;
},
): Promise<CaptureCreateResponse> {
const formData = new FormData();
formData.append('file', file);
formData.append('source', options?.source ?? 'file');
if (options?.language) formData.append('language', options.language);
if (options?.sttModel) formData.append('stt_model', options.sttModel);
const url = `${this.getBaseUrl()}/captures`;
const response = await fetch(url, { method: 'POST', body: formData });
if (!response.ok) {
const error = await response.json().catch(() => ({
detail: response.statusText,
}));
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
}
return response.json();
}
async deleteCapture(captureId: string): Promise<{ message: string }> {
return this.request<{ message: string }>(`/captures/${captureId}`, {
method: 'DELETE',
});
}
async refineCapture(
captureId: string,
body: CaptureRefineRequest,
): Promise<CaptureResponse> {
return this.request<CaptureResponse>(`/captures/${captureId}/refine`, {
method: 'POST',
body: JSON.stringify(body),
});
}
async retranscribeCapture(
captureId: string,
body: CaptureRetranscribeRequest,
): Promise<CaptureResponse> {
return this.request<CaptureResponse>(`/captures/${captureId}/retranscribe`, {
method: 'POST',
body: JSON.stringify(body),
});
}
getCaptureAudioUrl(captureId: string): string {
return `${this.getBaseUrl()}/captures/${captureId}/audio`;
}
// Settings
async getCaptureSettings(): Promise<CaptureSettings> {
return this.request<CaptureSettings>('/settings/captures');
}
async getCaptureReadiness(): Promise<CaptureReadinessResponse> {
return this.request<CaptureReadinessResponse>('/capture/readiness');
}
async updateCaptureSettings(patch: CaptureSettingsUpdate): Promise<CaptureSettings> {
return this.request<CaptureSettings>('/settings/captures', {
method: 'PUT',
body: JSON.stringify(patch),
});
}
async getGenerationSettings(): Promise<GenerationSettings> {
return this.request<GenerationSettings>('/settings/generation');
}
async updateGenerationSettings(
patch: GenerationSettingsUpdate,
): Promise<GenerationSettings> {
return this.request<GenerationSettings>('/settings/generation', {
method: 'PUT',
body: JSON.stringify(patch),
});
}
// MCP bindings — per-MCP-client voice/engine/personality mapping.
async listMCPBindings(): Promise<MCPClientBindingListResponse> {
return this.request<MCPClientBindingListResponse>('/mcp/bindings');
}
async upsertMCPBinding(
data: MCPClientBindingUpsert,
): Promise<MCPClientBinding> {
return this.request<MCPClientBinding>('/mcp/bindings', {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteMCPBinding(clientId: string): Promise<{ deleted: string }> {
return this.request<{ deleted: string }>(
`/mcp/bindings/${encodeURIComponent(clientId)}`,
{ method: 'DELETE' },
);
}
// Model Management
async getModelStatus(): Promise<ModelStatusListResponse> {
return this.request<ModelStatusListResponse>('/models/status');
@@ -614,6 +774,17 @@ class ApiClient {
});
}
async updateStoryItemVolume(
storyId: string,
itemId: string,
data: StoryItemVolumeUpdate,
): Promise<StoryItemDetail> {
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/volume`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async splitStoryItem(
storyId: string,
itemId: string,
@@ -752,6 +923,26 @@ class ApiClient {
return response.blob();
}
// Mobile pairing
async getPairHostCandidates(): Promise<HostCandidate[]> {
return this.request<HostCandidate[]>('/pair/host-candidates');
}
async initPairing(host: string): Promise<PairInitResponse> {
return this.request<PairInitResponse>(
`/pair/init?host=${encodeURIComponent(host)}`,
{ method: 'POST' },
);
}
async listPairedDevices(): Promise<PairedDeviceResponse[]> {
return this.request<PairedDeviceResponse[]>('/devices');
}
async revokePairedDevice(deviceId: string): Promise<void> {
await this.request<void>(`/devices/${deviceId}`, { method: 'DELETE' });
}
}
export const apiClient = new ApiClient();
+178
View File
@@ -12,6 +12,8 @@ export interface VoiceProfileCreate {
preset_voice_id?: string;
design_prompt?: string;
default_engine?: string;
/** Free-form character prompt used by compose and the `/generate` personality-rewrite path. */
personality?: string;
}
export interface VoiceProfileResponse {
@@ -26,12 +28,19 @@ export interface VoiceProfileResponse {
preset_voice_id?: string;
design_prompt?: string;
default_engine?: string;
personality?: string | null;
generation_count: number;
sample_count: number;
created_at: string;
updated_at: string;
}
/** Response returned by /profiles/{id}/compose. */
export interface PersonalityTextResponse {
text: string;
model_size: string;
}
export interface PresetVoice {
voice_id: string;
name: string;
@@ -71,6 +80,8 @@ export interface GenerationRequest {
| 'tada'
| 'kokoro';
instruct?: string;
/** When true and the profile has a personality prompt, input text is rewritten in-character before TTS. */
personality?: boolean;
max_chunk_chars?: number;
crossfade_ms?: number;
normalize?: boolean;
@@ -127,6 +138,118 @@ export interface HistoryListResponse {
export type WhisperModelSize = 'base' | 'small' | 'medium' | 'large' | 'turbo';
export type Qwen3ModelSize = '0.6B' | '1.7B' | '4B';
export type CaptureSource = 'dictation' | 'recording' | 'file';
/**
* Snapshot of the accessibility-focused UI element at chord-start. Emitted
* from Rust as part of the ``dictate:start`` payload so the frontend can
* pass it back to ``paste_final_text`` once the final text is ready.
*/
export interface FocusSnapshot {
pid: number;
bundle_id: string | null;
role: string | null;
}
export interface RefinementFlags {
smart_cleanup: boolean;
self_correction: boolean;
preserve_technical: boolean;
}
export interface CaptureResponse {
id: string;
audio_path: string;
source: CaptureSource;
language?: string | null;
duration_ms?: number | null;
transcript_raw: string;
transcript_refined?: string | null;
stt_model?: string | null;
llm_model?: string | null;
refinement_flags?: RefinementFlags | null;
created_at: string;
}
export interface CaptureListResponse {
items: CaptureResponse[];
total: number;
}
/**
* Response of ``POST /captures``. Adds ``auto_refine`` and ``allow_auto_paste``
* — the server's current settings captured at request time — so the client
* can decide whether to chain a refine call and whether to fire the
* synthetic-paste pipeline without relying on its own (possibly stale) copy
* of capture_settings.
*/
export interface CaptureCreateResponse extends CaptureResponse {
auto_refine: boolean;
allow_auto_paste: boolean;
}
export interface CaptureRefineRequest {
flags?: RefinementFlags;
model_size?: Qwen3ModelSize;
}
export interface CaptureRetranscribeRequest {
model?: WhisperModelSize;
language?: LanguageCode;
}
export interface CaptureSettings {
stt_model: WhisperModelSize;
language: string;
auto_refine: boolean;
llm_model: Qwen3ModelSize;
smart_cleanup: boolean;
self_correction: boolean;
preserve_technical: boolean;
allow_auto_paste: boolean;
default_playback_voice_id: string | null;
/** Whether the global keyboard hotkey is armed. Off by default — turning
* this on triggers the macOS Input Monitoring TCC prompt. */
hotkey_enabled: boolean;
/** keytap key names. Defaults are platform-specific right-hand modifiers. */
chord_push_to_talk_keys: string[];
/** keytap key names. Toggle adds Space to the platform-specific PTT chord. */
chord_toggle_to_talk_keys: string[];
}
export type CaptureSettingsUpdate = Partial<CaptureSettings>;
/**
* One row in the dictation readiness checklist. ``model_name`` is the
* canonical id understood by ``POST /models/download`` so the UI can wire a
* one-click "Download" button without a second lookup.
*/
export interface ModelReadiness {
ready: boolean;
model_name: string;
display_name: string;
size: string;
size_mb?: number | null;
}
/** Backend half of the dictation readiness check. The frontend combines this
* with TCC permission state into the full checklist used by useDictationReadiness. */
export interface CaptureReadinessResponse {
stt: ModelReadiness;
llm: ModelReadiness;
}
export interface GenerationSettings {
max_chunk_chars: number;
crossfade_ms: number;
normalize_audio: boolean;
autoplay_on_generate: boolean;
}
export type GenerationSettingsUpdate = Partial<GenerationSettings>;
export interface TranscriptionRequest {
language?: LanguageCode;
model?: WhisperModelSize;
@@ -268,11 +391,17 @@ export interface StoryItemDetail {
duration: number;
seed?: number;
instruct?: string;
engine?: string;
volume: number;
generation_created_at: string;
versions?: GenerationVersionResponse[];
active_version_id?: string;
}
export interface StoryItemVolumeUpdate {
volume: number;
}
export interface StoryItemVersionUpdate {
version_id: string | null;
}
@@ -367,3 +496,52 @@ export interface ApplyEffectsRequest {
label?: string;
set_as_default?: boolean;
}
/* ─── MCP ─────────────────────────────────────────────────────────────── */
export interface MCPClientBinding {
client_id: string;
label: string | null;
profile_id: string | null;
default_engine: string | null;
default_personality: boolean;
last_seen_at: string | null;
created_at: string;
updated_at: string;
}
export interface MCPClientBindingUpsert {
client_id: string;
label?: string | null;
profile_id?: string | null;
default_engine?: string | null;
default_personality?: boolean;
}
export interface MCPClientBindingListResponse {
items: MCPClientBinding[];
}
/* ─── Mobile pairing (V0) ────────────────────────────────────────────── */
export type HostCandidateKind = 'lan' | 'tailscale' | 'loopback';
export interface HostCandidate {
address: string; // host:port
label: string; // human-friendly name
kind: HostCandidateKind;
}
export interface PairInitResponse {
token: string;
expires_at: string;
pairing_url: string; // voicebox://pair?host=…&token=…
}
export interface PairedDeviceResponse {
id: string;
name: string;
revoked: boolean;
created_at: string;
last_seen_at: string | null;
}
+11 -5
View File
@@ -8,7 +8,7 @@ interface UseAudioRecordingOptions {
}
export function useAudioRecording({
maxDurationSeconds = 29,
maxDurationSeconds,
onRecordingComplete,
}: UseAudioRecordingOptions = {}) {
const platform = usePlatform();
@@ -124,8 +124,11 @@ export function useAudioRecording({
console.error('MediaRecorder error:', event);
};
// Start recording
mediaRecorder.start(100); // Collect data every 100ms
// WebKit's MediaRecorder drops the WebM EBML header from chunks when
// started with a timeslice, so concatenated blobs fail to parse in
// both AudioContext and ffmpeg. Starting with no timeslice produces
// exactly one dataavailable on stop() with a valid container.
mediaRecorder.start();
setIsRecording(true);
startTimeRef.current = Date.now();
@@ -135,8 +138,11 @@ export function useAudioRecording({
const elapsed = (Date.now() - startTimeRef.current) / 1000;
setDuration(elapsed);
// Auto-stop at max duration
if (elapsed >= maxDurationSeconds) {
// Auto-stop at max duration when the caller opts in — dictation
// sessions pass undefined and run until the user releases the
// chord or hits stop; voice-clone sample recorders pass 29s to
// keep reference clips short.
if (maxDurationSeconds !== undefined && elapsed >= maxDurationSeconds) {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
setIsRecording(false);
@@ -0,0 +1,328 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { emit as tauriEmit } from '@tauri-apps/api/event';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { PillState } from '@/components/CapturePill/CapturePill';
import { apiClient } from '@/lib/api/client';
import type {
CaptureListResponse,
CaptureResponse,
CaptureSource,
} from '@/lib/api/types';
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
/**
* Broadcast to sibling Tauri webviews that the captures list has changed.
* The main CapturesTab listens, seeds its React Query cache, and focuses the
* new row, so uploads from the floating dictate window show up live.
*
* ``capture:created`` carries the full response so the sibling can seed its
* cache before the refetch lands — otherwise the selection-guard effect
* would snap back to ``captures[0]`` in the race window between
* ``setSelectedId(new)`` and the list actually containing the new row.
*
* No-op in web mode — there are no siblings to notify.
*/
function broadcastCreated(capture: CaptureResponse) {
tauriEmit('capture:created', { capture }).catch(() => {
/* not running inside Tauri; nothing to sync to */
});
}
function broadcastUpdated(id: string) {
tauriEmit('capture:updated', { id }).catch(() => {
/* not running inside Tauri; nothing to sync to */
});
}
const REST_FADE_MS = 900;
// How long the green "Done" pill stays visible after refine (or transcribe,
// when auto-refine is off) completes, before the fade-out begins.
const COMPLETED_DWELL_MS = 2000;
// Long enough to read a full backend stack message and click-to-copy.
const ERROR_PILL_VISIBLE_MS = 6000;
// Short self-explanatory notices (e.g. "Recording too short, canceled") —
// there's nothing to read or copy, so clear out quickly.
const BRIEF_NOTICE_MS = 2000;
// MediaRecorder.start(100) emits its first chunk ~100ms in, but the webm
// container header isn't guaranteed to be finalised that quickly — anything
// under half a second tends to produce a blob neither AudioContext.decode
// nor ffmpeg will accept. Caught client-side and surfaced as a friendly
// "Recording too short, canceled" pill instead of bubbling up a 400.
const MIN_RECORDING_DURATION_S = 0.5;
const SHORT_RECORDING_MESSAGE = 'Recording too short, canceled';
export type CapturePillState = PillState | 'hidden';
export interface UseCaptureRecordingSessionOptions {
/**
* Fired after a capture row is created on the server. Callers can use this
* to select the new capture or emit a Tauri event to a sibling window.
*/
onCaptureCreated?: (capture: CaptureResponse) => void;
/**
* Fired with the final delivered text — refined if ``auto_refine`` was on
* for this capture, raw transcript otherwise. Used by the floating
* dictate window to hand the text off to the Rust auto-paste pipeline.
*
* ``allowAutoPaste`` snapshots the setting at chord-start so a refine that
* lands after the user flips the toggle still uses the value the capture
* was created under.
*/
onFinalText?: (
text: string,
capture: CaptureResponse,
allowAutoPaste: boolean,
) => void;
}
export interface UseCaptureRecordingSessionResult {
pillState: CapturePillState;
pillElapsedMs: number;
errorMessage: string | null;
isRecording: boolean;
isUploading: boolean;
isRefining: boolean;
startRecording: () => void;
stopRecording: () => void;
toggleRecording: () => void;
dismissError: () => void;
uploadFile: (file: File, source: CaptureSource) => void;
refine: (captureId: string) => void;
}
/**
* Owns the full record → transcribe → refine → rest lifecycle behind the
* capture pill. The pill component and the Dictate/Stop button are the only
* consumers; everything else (cache seeding, error toasts, settings reads) is
* internal so the hook can be reused from a floating Tauri window without the
* containing tab.
*/
export function useCaptureRecordingSession(
options: UseCaptureRecordingSessionOptions = {},
): UseCaptureRecordingSessionResult {
const queryClient = useQueryClient();
// Every capture setting is resolved server-side. ``stt_model``,
// ``llm_model`` and refine flags are read from the capture_settings table
// inside POST /captures and /captures/*/refine, and ``auto_refine`` comes
// back on the create response so the client decides whether to chain a
// refine call using a value that can't go stale across sibling webviews.
const [pillState, setPillState] = useState<CapturePillState>('hidden');
const [frozenElapsedMs, setFrozenElapsedMs] = useState(0);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const restTimerRef = useRef<number | null>(null);
const errorTimerRef = useRef<number | null>(null);
// Mutation callbacks close over stale pillState otherwise.
const pillStateRef = useRef<CapturePillState>('hidden');
pillStateRef.current = pillState;
const onCaptureCreatedRef = useRef(options.onCaptureCreated);
onCaptureCreatedRef.current = options.onCaptureCreated;
const onFinalTextRef = useRef(options.onFinalText);
onFinalTextRef.current = options.onFinalText;
// Snapshot of ``allow_auto_paste`` from the capture-create response —
// held so the refine onSuccess (which only sees the plain CaptureResponse)
// can still pass the original setting through to onFinalText.
const allowAutoPasteRef = useRef<boolean>(true);
const clearRestTimer = useCallback(() => {
if (restTimerRef.current !== null) {
window.clearTimeout(restTimerRef.current);
restTimerRef.current = null;
}
}, []);
const clearErrorTimer = useCallback(() => {
if (errorTimerRef.current !== null) {
window.clearTimeout(errorTimerRef.current);
errorTimerRef.current = null;
}
}, []);
const scheduleHidePill = useCallback(() => {
clearRestTimer();
setPillState('completed');
// Two-hop timer: show the green "Done" pill for COMPLETED_DWELL_MS,
// then hand off to the existing rest-fade before unmounting.
restTimerRef.current = window.setTimeout(() => {
setPillState('rest');
restTimerRef.current = window.setTimeout(() => {
setPillState('hidden');
restTimerRef.current = null;
}, REST_FADE_MS);
}, COMPLETED_DWELL_MS);
}, [clearRestTimer]);
const showError = useCallback(
(message: string, durationMs: number = ERROR_PILL_VISIBLE_MS) => {
clearRestTimer();
clearErrorTimer();
setErrorMessage(message || 'Something went wrong');
setPillState('error');
errorTimerRef.current = window.setTimeout(() => {
setPillState('hidden');
setErrorMessage(null);
errorTimerRef.current = null;
}, durationMs);
},
[clearRestTimer, clearErrorTimer],
);
const dismissError = useCallback(() => {
clearErrorTimer();
setPillState('hidden');
setErrorMessage(null);
}, [clearErrorTimer]);
useEffect(
() => () => {
clearRestTimer();
clearErrorTimer();
},
[clearRestTimer, clearErrorTimer],
);
const refineMutation = useMutation({
// Empty body — backend resolves flags and model from capture_settings.
mutationFn: async (captureId: string) => apiClient.refineCapture(captureId, {}),
onSuccess: (data, captureId) => {
queryClient.invalidateQueries({ queryKey: ['captures'] });
broadcastUpdated(captureId);
if (pillStateRef.current === 'refining') scheduleHidePill();
const finalText = data.transcript_refined ?? data.transcript_raw;
if (finalText) {
onFinalTextRef.current?.(finalText, data, allowAutoPasteRef.current);
}
},
onError: (err: Error) => {
showError(err.message || 'Refinement failed');
},
});
const uploadMutation = useMutation({
mutationFn: async ({ file, source }: { file: File; source: CaptureSource }) =>
apiClient.createCapture(file, { source }),
onSuccess: (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 };
});
queryClient.invalidateQueries({ queryKey: ['captures'] });
broadcastCreated(capture);
onCaptureCreatedRef.current?.(capture);
allowAutoPasteRef.current = capture.allow_auto_paste;
if (capture.auto_refine) {
setPillState('refining');
refineMutation.mutate(capture.id);
} else {
if (pillStateRef.current === 'transcribing') scheduleHidePill();
if (capture.transcript_raw) {
onFinalTextRef.current?.(
capture.transcript_raw,
capture,
capture.allow_auto_paste,
);
}
}
},
onError: (err: Error) => {
// Backend's librosa-audioread fallback returns a 400 with this shape
// for tiny/corrupt webm blobs that slip past the client guard —
// translate it to the same friendly message so the user sees one
// consistent cause, not an opaque decode error.
const msg = err.message || '';
if (/could not decode/i.test(msg) || /empty or corrupt/i.test(msg)) {
showError(SHORT_RECORDING_MESSAGE, BRIEF_NOTICE_MS);
} else {
showError(msg || 'Upload failed');
}
},
});
const {
isRecording,
duration,
startRecording: beginAudioRecording,
stopRecording,
error: recordError,
} = useAudioRecording({
onRecordingComplete: (blob, recordedDuration) => {
// Trigger-happy tap — MediaRecorder hasn't emitted a usable chunk yet
// so the blob is empty or unparseable. Surface it as a transient pill
// so the user sees their recording was recognised and canceled.
if (!blob.size || (recordedDuration ?? 0) < MIN_RECORDING_DURATION_S) {
showError(SHORT_RECORDING_MESSAGE, BRIEF_NOTICE_MS);
return;
}
setFrozenElapsedMs(Math.round((recordedDuration ?? 0) * 1000));
setPillState('transcribing');
const extension = blob.type.includes('wav')
? 'wav'
: blob.type.includes('webm')
? 'webm'
: 'bin';
const file = new File([blob], `dictation-${Date.now()}.${extension}`, {
type: blob.type,
});
uploadMutation.mutate({ file, source: 'dictation' });
},
});
useEffect(() => {
if (recordError) {
showError(recordError);
}
}, [recordError, showError]);
const startRecording = useCallback(() => {
if (isRecording) return;
clearRestTimer();
setFrozenElapsedMs(0);
setPillState('recording');
beginAudioRecording();
}, [isRecording, beginAudioRecording, clearRestTimer]);
const toggleRecording = useCallback(() => {
if (isRecording) {
stopRecording();
return;
}
startRecording();
}, [isRecording, startRecording, stopRecording]);
const uploadFile = useCallback(
(file: File, source: CaptureSource) => {
uploadMutation.mutate({ file, source });
},
[uploadMutation],
);
const refine = useCallback(
(captureId: string) => {
refineMutation.mutate(captureId);
},
[refineMutation],
);
const pillElapsedMs =
pillState === 'recording' ? Math.round(duration * 1000) : frozenElapsedMs;
return {
pillState,
pillElapsedMs,
errorMessage,
isRecording,
isUploading: uploadMutation.isPending,
isRefining: refineMutation.isPending,
startRecording,
stopRecording,
toggleRecording,
dismissError,
uploadFile,
refine,
};
}
+54
View File
@@ -0,0 +1,54 @@
import { invoke } from '@tauri-apps/api/core';
import { useEffect } from 'react';
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { usePlatform } from '@/platform/PlatformContext';
/**
* Spawn (or quiet) the global hotkey monitor based on the saved
* `capture_settings.hotkey_enabled` flag and the recording readiness gates,
* and keep its bindings in sync with the user's chord choices.
*
* Boot sequence:
* - hotkey_enabled = false OR a recording gate is missing → call
* `disable_hotkey` (no-op if monitor was never spawned). Crucially, we do
* *not* call `enable_hotkey` in this state, so the macOS Input Monitoring
* TCC prompt is never triggered for users who haven't opted in, AND the
* chord physically can't fire when models aren't downloaded — preventing
* the "stuck pill" failure mode where dictation triggers but has nowhere
* to land.
* - hotkey_enabled = true AND recording gates green → call `enable_hotkey` with
* the saved chords. This creates the CGEventTap and triggers the TCC
* prompt on first opt-in. Re-runs whenever a gate flips green (e.g. the
* user finishes downloading Whisper in another tab) so the chord
* auto-arms without making the user toggle off/on.
*
* Call once from the main app shell.
*/
export function useChordSync() {
const platform = usePlatform();
const { settings } = useCaptureSettings();
const { canRecord } = useDictationReadiness();
const enabled = settings?.hotkey_enabled;
const pushKeys = settings?.chord_push_to_talk_keys;
const toggleKeys = settings?.chord_toggle_to_talk_keys;
useEffect(() => {
if (!platform.metadata.isTauri) return;
if (enabled === undefined || !pushKeys || !toggleKeys) return;
const shouldArm = enabled && canRecord;
const command = shouldArm ? 'enable_hotkey' : 'disable_hotkey';
const args = shouldArm ? { pushToTalk: pushKeys, toggleToTalk: toggleKeys } : {};
invoke(command, args).catch((err) => {
console.warn(`[chord-sync] ${command} failed:`, err);
});
}, [
platform.metadata.isTauri,
enabled,
canRecord,
// Stringify so a referentially-new array with the same content
// doesn't fire a redundant invoke on every settings refetch.
pushKeys?.join(','),
toggleKeys?.join(','),
]);
}
+109
View File
@@ -0,0 +1,109 @@
import { useQuery } from '@tanstack/react-query';
import { useAccessibilityPermission } from '@/components/AccessibilityGate/AccessibilityGate';
import { useInputMonitoringPermission } from '@/components/InputMonitoringGate/InputMonitoringGate';
import { apiClient } from '@/lib/api/client';
import type { ModelReadiness } from '@/lib/api/types';
import { usePlatform } from '@/platform/PlatformContext';
const READINESS_POLL_INTERVAL_MS = 5_000;
export type ReadinessGate = 'stt' | 'llm' | 'input_monitoring' | 'accessibility';
export interface DictationReadiness {
isLoading: boolean;
canRecord: boolean;
allReady: boolean;
/** Subset of gates that are NOT yet satisfied — what the checklist renders. */
missing: ReadinessGate[];
stt: ModelReadiness | undefined;
llm: ModelReadiness | undefined;
inputMonitoring: boolean;
accessibility: boolean;
refetch: () => void;
openInputMonitoringSettings: () => Promise<void>;
openAccessibilitySettings: () => Promise<void>;
recheckInputMonitoring: () => Promise<boolean>;
recheckAccessibility: () => Promise<boolean>;
}
/**
* Single source of truth for dictation readiness.
*
* ``canRecord`` covers the gates that must be green before the chord can
* start recording. ``allReady`` also includes Accessibility, which only gates
* synthetic paste — dictation still records and lands in Captures without it.
*
* Gates:
* - stt / llm: backend ``/capture/readiness`` (polled, since downloads
* finish out-of-band — e.g. user kicks off a download in another tab and
* expects the toggle to auto-unlock when it lands)
* - input_monitoring / accessibility: macOS TCC checks via Tauri commands
* (rechecked on window focus by the underlying hooks)
*
* Hotkey-enabled is the user's intent toggle and is intentionally *not*
* a gate here — that's `useChordSync`'s concern.
*/
export function useDictationReadiness(): DictationReadiness {
const platform = usePlatform();
const isTauri = platform.metadata.isTauri;
const {
needsPermission: inputMonNeeds,
recheck: recheckInputMon,
openSettings: openInputMon,
} = useInputMonitoringPermission();
const {
needsPermission: a11yNeeds,
recheck: recheckA11y,
openSettings: openA11y,
} = useAccessibilityPermission();
const { data, isLoading, refetch } = useQuery({
queryKey: ['capture-readiness'],
queryFn: () => apiClient.getCaptureReadiness(),
// Poll only while a model is still missing/downloading. Once both are
// green the endpoint's answer can't change until the user swaps models
// in settings, and that path invalidates the query explicitly from
// useSettings. refetchOnWindowFocus stays gated to the same condition.
refetchInterval: (query) => {
const d = query.state.data;
return d && d.stt.ready && d.llm.ready ? false : READINESS_POLL_INTERVAL_MS;
},
refetchOnWindowFocus: (query) => {
const d = query.state.data;
return !(d && d.stt.ready && d.llm.ready);
},
});
// On the web build there's no TCC layer — treat both as granted so the
// checklist doesn't block users who can't even open System Settings.
const inputMonitoring = isTauri ? !inputMonNeeds : true;
const accessibility = isTauri ? !a11yNeeds : true;
const sttReady = data?.stt.ready ?? false;
const llmReady = data?.llm.ready ?? false;
const missing: ReadinessGate[] = [];
if (!sttReady) missing.push('stt');
if (!llmReady) missing.push('llm');
if (!inputMonitoring) missing.push('input_monitoring');
if (!accessibility) missing.push('accessibility');
const canRecord = sttReady && llmReady && inputMonitoring;
return {
isLoading,
canRecord,
allReady: missing.length === 0,
missing,
stt: data?.stt,
llm: data?.llm,
inputMonitoring,
accessibility,
refetch: () => {
refetch();
},
openInputMonitoringSettings: openInputMon,
openAccessibilitySettings: openA11y,
recheckInputMonitoring: recheckInputMon,
recheckAccessibility: recheckA11y,
};
}
+9 -4
View File
@@ -8,8 +8,8 @@ import type { EffectConfig } from '@/lib/api/types';
import { LANGUAGE_CODES, type LanguageCode } from '@/lib/constants/languages';
import { useGeneration } from '@/lib/hooks/useGeneration';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import { useGenerationSettings } from '@/lib/hooks/useSettings';
import { useGenerationStore } from '@/stores/generationStore';
import { useServerStore } from '@/stores/serverStore';
import { useUIStore } from '@/stores/uiStore';
const generationSchema = z.object({
@@ -29,6 +29,7 @@ const generationSchema = z.object({
'kokoro',
])
.optional(),
personality: z.boolean().optional(),
});
export type GenerationFormValues = z.infer<typeof generationSchema>;
@@ -43,9 +44,10 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
const { toast } = useToast();
const generation = useGeneration();
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
const { settings: genSettings } = useGenerationSettings();
const maxChunkChars = genSettings?.max_chunk_chars ?? 800;
const crossfadeMs = genSettings?.crossfade_ms ?? 50;
const normalizeAudio = genSettings?.normalize_audio ?? true;
const selectedEngine = useUIStore((state) => state.selectedEngine);
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
@@ -65,6 +67,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
modelSize: '1.7B',
instruct: '',
engine: (selectedEngine as GenerationFormValues['engine']) || 'qwen',
personality: false,
...options.defaultValues,
},
});
@@ -149,6 +152,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
model_size: hasModelSizes ? data.modelSize : undefined,
engine,
instruct: supportsInstruct ? data.instruct || undefined : undefined,
personality: data.personality || undefined,
max_chunk_chars: maxChunkChars,
crossfade_ms: crossfadeMs,
normalize: normalizeAudio,
@@ -166,6 +170,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
modelSize: data.modelSize,
instruct: '',
engine: data.engine,
personality: data.personality,
});
options.onSuccess?.(result.id);
} catch (error) {
+13 -4
View File
@@ -2,17 +2,22 @@ import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { useGenerationSettings } from '@/lib/hooks/useSettings';
import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useServerStore } from '@/stores/serverStore';
interface GenerationStatusEvent {
id: string;
status: 'loading_model' | 'generating' | 'completed' | 'failed' | 'not_found';
duration?: number;
error?: string;
source?: string;
}
// Agent-initiated generations are played by the floating pill, not the
// main-window AudioPlayer. Skip autoplay here to avoid double-playback.
const AGENT_SOURCES = new Set(['mcp', 'rest']);
/**
* Subscribes to SSE for all pending generations. When a generation completes,
* invalidates the history query, removes it from pending, and auto-plays
@@ -26,7 +31,8 @@ export function useGenerationProgress() {
const removePendingStoryAdd = useGenerationStore((s) => s.removePendingStoryAdd);
const isPlaying = usePlayerStore((s) => s.isPlaying);
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
const autoplayOnGenerate = useServerStore((s) => s.autoplayOnGenerate);
const { settings: genSettings } = useGenerationSettings();
const autoplayOnGenerate = genSettings?.autoplay_on_generate ?? true;
// Keep refs to avoid stale closures in EventSource handlers
const isPlayingRef = useRef(isPlaying);
@@ -109,8 +115,11 @@ export function useGenerationProgress() {
// });
}
// Auto-play if enabled and nothing is currently playing
if (autoplayRef.current && !isPlayingRef.current) {
// Auto-play if enabled and nothing is currently playing.
// Skip agent-initiated sources — the floating pill window
// plays those itself.
const isAgentSpeak = data.source ? AGENT_SOURCES.has(data.source) : false;
if (autoplayRef.current && !isPlayingRef.current && !isAgentSpeak) {
const genAudioUrl = apiClient.getAudioUrl(id);
setAudioWithAutoPlay(genAudioUrl, id, '', '');
}
+60
View File
@@ -0,0 +1,60 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import type {
MCPClientBindingListResponse,
MCPClientBindingUpsert,
} from '@/lib/api/types';
const MCP_BINDINGS_KEY = ['settings', 'mcp', 'bindings'] as const;
/** Manage per-MCP-client voice bindings (Claude Code → Morgan, etc.). */
export function useMCPBindings() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: MCP_BINDINGS_KEY,
queryFn: () => apiClient.listMCPBindings(),
// Keep fresh while the Settings page is open — the ``last_seen_at``
// timestamp is useful for confirming an install works, and we want it
// to tick forward when a client connects.
refetchInterval: 10_000,
});
const upsertMutation = useMutation({
mutationFn: (data: MCPClientBindingUpsert) =>
apiClient.upsertMCPBinding(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: MCP_BINDINGS_KEY });
},
});
const deleteMutation = useMutation({
mutationFn: (clientId: string) => apiClient.deleteMCPBinding(clientId),
onMutate: async (clientId) => {
await queryClient.cancelQueries({ queryKey: MCP_BINDINGS_KEY });
const prev =
queryClient.getQueryData<MCPClientBindingListResponse>(MCP_BINDINGS_KEY);
if (prev) {
queryClient.setQueryData<MCPClientBindingListResponse>(
MCP_BINDINGS_KEY,
{ items: prev.items.filter((b) => b.client_id !== clientId) },
);
}
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) queryClient.setQueryData(MCP_BINDINGS_KEY, ctx.prev);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: MCP_BINDINGS_KEY });
},
});
return {
bindings: query.data?.items ?? [],
isLoading: query.isLoading,
upsert: upsertMutation.mutate,
upsertAsync: upsertMutation.mutateAsync,
remove: deleteMutation.mutate,
};
}
+57
View File
@@ -0,0 +1,57 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
export const PAIRED_DEVICES_KEY = ['paired-devices'] as const;
export const PAIR_HOST_CANDIDATES_KEY = ['pair-host-candidates'] as const;
/**
* List all paired (and revoked) devices. Pass ``polling: true`` while the
* pair dialog is open so the device list refreshes when the user finishes
* scanning on their phone — this is how the desktop UI detects success
* without needing an SSE stream.
*/
export function usePairedDevices({ polling = false }: { polling?: boolean } = {}) {
return useQuery({
queryKey: PAIRED_DEVICES_KEY,
queryFn: () => apiClient.listPairedDevices(),
refetchInterval: polling ? 2000 : false,
});
}
/**
* The candidate addresses the desktop can embed in the QR (LAN, Tailscale,
* loopback). Cached for the lifetime of the dialog — interfaces don't
* change often enough to be worth re-polling.
*/
export function usePairHostCandidates(enabled: boolean) {
return useQuery({
queryKey: PAIR_HOST_CANDIDATES_KEY,
queryFn: () => apiClient.getPairHostCandidates(),
enabled,
staleTime: Infinity,
});
}
/**
* Mint a fresh pairing token for a chosen host. The result is short-lived
* (5 min); the dialog should re-mint when expiry is hit.
*/
export function useInitPairing() {
return useMutation({
mutationFn: (host: string) => apiClient.initPairing(host),
});
}
/**
* Revoke a paired device. Invalidates the device list so the row disappears
* on success.
*/
export function useRevokePairedDevice() {
const qc = useQueryClient();
return useMutation({
mutationFn: (deviceId: string) => apiClient.revokePairedDevice(deviceId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: PAIRED_DEVICES_KEY });
},
});
}
+106
View File
@@ -0,0 +1,106 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import type {
CaptureSettings,
CaptureSettingsUpdate,
GenerationSettings,
GenerationSettingsUpdate,
} from '@/lib/api/types';
const CAPTURE_SETTINGS_KEY = ['settings', 'captures'] as const;
const GENERATION_SETTINGS_KEY = ['settings', 'generation'] as const;
/**
* Hook for capture/refine defaults. Reads from the server and writes partial
* updates with optimistic cache mutation so toggles stay snappy while the
* PUT round-trip settles.
*/
export function useCaptureSettings() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: CAPTURE_SETTINGS_KEY,
queryFn: () => apiClient.getCaptureSettings(),
staleTime: Infinity,
});
const mutation = useMutation({
mutationFn: (patch: CaptureSettingsUpdate) => apiClient.updateCaptureSettings(patch),
onMutate: async (patch) => {
await queryClient.cancelQueries({ queryKey: CAPTURE_SETTINGS_KEY });
const previous = queryClient.getQueryData<CaptureSettings>(CAPTURE_SETTINGS_KEY);
if (previous) {
queryClient.setQueryData<CaptureSettings>(CAPTURE_SETTINGS_KEY, {
...previous,
...patch,
});
}
return { previous };
},
onError: (_err, _patch, ctx) => {
if (ctx?.previous) {
queryClient.setQueryData(CAPTURE_SETTINGS_KEY, ctx.previous);
}
},
onSettled: (data, _err, patch) => {
if (data) queryClient.setQueryData(CAPTURE_SETTINGS_KEY, data);
// /capture/readiness resolves stt_model / llm_model live on each
// call, but its cached response keeps serving the previous
// model's state until the next 5 s poll. Invalidate on model
// swaps so the readiness checklist re-checks immediately.
if (patch.stt_model !== undefined || patch.llm_model !== undefined) {
queryClient.invalidateQueries({ queryKey: ['capture-readiness'] });
}
},
});
return {
settings: query.data,
isLoading: query.isLoading,
update: mutation.mutate,
};
}
/**
* Hook for long-form TTS generation defaults. Same optimistic pattern as
* ``useCaptureSettings``.
*/
export function useGenerationSettings() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: GENERATION_SETTINGS_KEY,
queryFn: () => apiClient.getGenerationSettings(),
staleTime: Infinity,
});
const mutation = useMutation({
mutationFn: (patch: GenerationSettingsUpdate) =>
apiClient.updateGenerationSettings(patch),
onMutate: async (patch) => {
await queryClient.cancelQueries({ queryKey: GENERATION_SETTINGS_KEY });
const previous = queryClient.getQueryData<GenerationSettings>(GENERATION_SETTINGS_KEY);
if (previous) {
queryClient.setQueryData<GenerationSettings>(GENERATION_SETTINGS_KEY, {
...previous,
...patch,
});
}
return { previous };
},
onError: (_err, _patch, ctx) => {
if (ctx?.previous) {
queryClient.setQueryData(GENERATION_SETTINGS_KEY, ctx.previous);
}
},
onSettled: (data) => {
if (data) queryClient.setQueryData(GENERATION_SETTINGS_KEY, data);
},
});
return {
settings: query.data,
isLoading: query.isLoading,
update: mutation.mutate,
};
}
+21
View File
@@ -9,6 +9,7 @@ import type {
StoryItemSplit,
StoryItemTrim,
StoryItemVersionUpdate,
StoryItemVolumeUpdate,
} from '@/lib/api/types';
import { usePlatform } from '@/platform/PlatformContext';
@@ -154,6 +155,26 @@ export function useTrimStoryItem() {
});
}
export function useUpdateStoryItemVolume() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
storyId,
itemId,
data,
}: {
storyId: string;
itemId: string;
data: StoryItemVolumeUpdate;
}) => apiClient.updateStoryItemVolume(storyId, itemId, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
},
});
}
export function useSplitStoryItem() {
const queryClient = useQueryClient();
+27 -1
View File
@@ -5,6 +5,7 @@ import { useStoryStore } from '@/stores/storyStore';
interface ActiveSource {
source: AudioBufferSourceNode;
clipGain: GainNode;
itemId: string;
generationId: string;
startTimeMs: number;
@@ -61,11 +62,29 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
const stopSource = useCallback((itemId: string) => {
const activeSource = activeSourcesRef.current.get(itemId);
if (activeSource) {
// Detach onended first so the natural-end handler doesn't race with
// the explicit teardown below and re-delete a fresh entry that has
// already been re-scheduled at this id.
activeSource.source.onended = null;
try {
activeSource.source.stop();
} catch {
// Source may have already stopped
}
// Hard-cut the audio graph regardless of whether stop() actually
// halted the buffer. Long imports were leaking audio when stop()
// was called on a source that was scheduled with a multi-minute
// duration; disconnecting from the destination guarantees silence.
try {
activeSource.source.disconnect();
} catch {
// already disconnected
}
try {
activeSource.clipGain.disconnect();
} catch {
// already disconnected
}
activeSourcesRef.current.delete(itemId);
}
}, []);
@@ -264,10 +283,17 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
const source = audioContext.createBufferSource();
source.buffer = buffer;
source.connect(masterGainRef.current || audioContext.destination);
// Per-clip gain so each item can override its level independently
// of the master volume. Falls through 1.0 for any item without a
// saved value (older rows pre-migration).
const clipGain = audioContext.createGain();
clipGain.gain.value = typeof item.volume === 'number' ? item.volume : 1;
source.connect(clipGain);
clipGain.connect(masterGainRef.current || audioContext.destination);
const activeSource: ActiveSource = {
source,
clipGain,
itemId: item.id,
generationId: item.generation_id,
startTimeMs: item.start_time_ms,
+10
View File
@@ -0,0 +1,10 @@
export type Sponsor = {
name: string;
url: string;
logoSrc: string;
logoAlt?: string;
/** Set true for solid-black logos that need to flip white in dark mode. */
invertOnDark?: boolean;
};
export const SPONSORS: Sponsor[] = [];
+30 -5
View File
@@ -1,4 +1,6 @@
import { formatDistance } from 'date-fns';
import { ja, zhCN, zhTW } from 'date-fns/locale';
import i18n from '@/i18n';
export function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
@@ -6,15 +8,25 @@ export function formatDuration(seconds: number): string {
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function getDateLocale() {
switch (i18n.language) {
case 'ja':
return ja;
case 'zh-CN':
return zhCN;
case 'zh-TW':
return zhTW;
default:
return undefined;
}
}
export function formatDate(date: string | Date): string {
// Parse the date string - if it doesn't have timezone info, treat it as UTC
let dateObj: Date;
if (typeof date === 'string') {
// If the string doesn't end with Z or have timezone offset, assume it's UTC
const dateStr = date.trim();
if (!dateStr.includes('Z') && !dateStr.match(/[+-]\d{2}:\d{2}$/)) {
// No timezone info, treat as UTC
dateObj = new Date(dateStr + 'Z');
dateObj = new Date(`${dateStr}Z`);
} else {
dateObj = new Date(dateStr);
}
@@ -22,7 +34,20 @@ export function formatDate(date: string | Date): string {
dateObj = date;
}
return formatDistance(dateObj, new Date(), { addSuffix: true }).replace(/^about /i, '');
return formatDistance(dateObj, new Date(), {
addSuffix: true,
locale: getDateLocale(),
}).replace(/^about /i, '');
}
export function formatAbsoluteDate(date: string | Date): string {
const dateObj = typeof date === 'string' ? new Date(date) : date;
return dateObj.toLocaleString(i18n.language, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
const ENGINE_DISPLAY_NAMES: Record<string, string> = {
+168
View File
@@ -0,0 +1,168 @@
/**
* Stable key-name vocabulary shared with the Rust `key_codes` module.
*
* The chord persistence layer stores keytap `Key` variant names ("MetaRight",
* "AltGr", "KeyA", …) so the same array round-trips losslessly between
* the picker UI, the SQLite settings row, and the global hotkey listener.
*
* This module owns the conversions between three vocabularies:
* - browser `KeyboardEvent` (`event.code` like "MetaRight" / "AltRight")
* - canonical chord key names (matches keytap variants)
* - human display labels ("⌘", "⌥", "A", …)
*/
/**
* Map a `KeyboardEvent` to the canonical key name we persist. Returns
* `null` for keys we don't support in chords (dead keys, IME composition,
* etc.).
*
* Browser quirk: right-Option on macOS is reported as `"AltRight"`; keytap
* calls it `"AltGr"`. Normalize to keytap's name so the Rust side recognizes
* it without an aliasing layer.
*/
export function canonicalKeyFromEvent(event: KeyboardEvent): string | null {
const code = event.code;
if (!code) return null;
switch (code) {
case 'AltLeft':
return 'Alt';
case 'AltRight':
return 'AltGr';
case 'BracketLeft':
return 'LeftBracket';
case 'BracketRight':
return 'RightBracket';
case 'Semicolon':
return 'SemiColon';
case 'Backslash':
return 'BackSlash';
case 'Backquote':
return 'BackQuote';
case 'Period':
return 'Dot';
case 'Enter':
return 'Return';
case 'ArrowUp':
return 'UpArrow';
case 'ArrowDown':
return 'DownArrow';
case 'ArrowLeft':
return 'LeftArrow';
case 'ArrowRight':
return 'RightArrow';
default:
// Browser names like "MetaRight", "MetaLeft", "ControlLeft",
// "ShiftRight", "Space", "KeyA", "Digit1", "F5" all match the
// keytap variant names directly.
if (
/^(Meta|Control|Shift)(Left|Right)$/.test(code) ||
/^Key[A-Z]$/.test(code) ||
/^Digit[0-9]$/.test(code) ||
/^F([1-9]|1[0-2])$/.test(code) ||
['Space', 'Tab', 'Backspace', 'Delete', 'Escape', 'Insert',
'Home', 'End', 'PageUp', 'PageDown', 'CapsLock', 'Function',
'Minus', 'Equal', 'Quote', 'Comma', 'Slash'].includes(code)
) {
return code;
}
return null;
}
}
const PLATFORM_IS_MAC =
typeof navigator !== 'undefined' && /mac/i.test(navigator.platform);
export function defaultChordKeys(mode: 'push' | 'toggle'): string[] {
const base = PLATFORM_IS_MAC
? ['MetaRight', 'AltGr']
: ['ControlRight', 'ShiftRight'];
return mode === 'toggle' ? [...base, 'Space'] : base;
}
/**
* Pretty label for a canonical key name. Picks platform-appropriate
* modifier glyphs so macOS users see ⌘ and Windows/Linux users see Win.
*/
export function displayLabelForKey(name: string): string {
switch (name) {
case 'MetaLeft':
case 'MetaRight':
return PLATFORM_IS_MAC ? '⌘' : 'Win';
case 'Alt':
return PLATFORM_IS_MAC ? '⌥' : 'Alt';
case 'AltGr':
return PLATFORM_IS_MAC ? '⌥' : 'AltGr';
case 'ControlLeft':
case 'ControlRight':
return PLATFORM_IS_MAC ? '⌃' : 'Ctrl';
case 'ShiftLeft':
case 'ShiftRight':
return PLATFORM_IS_MAC ? '⇧' : 'Shift';
case 'CapsLock':
return '⇪';
case 'Function':
return 'fn';
case 'Space':
return 'Space';
case 'Tab':
return '⇥';
case 'Return':
return '↵';
case 'Backspace':
return '⌫';
case 'Delete':
return '⌦';
case 'Escape':
return 'Esc';
case 'UpArrow':
return '↑';
case 'DownArrow':
return '↓';
case 'LeftArrow':
return '←';
case 'RightArrow':
return '→';
}
if (/^Key([A-Z])$/.test(name)) return name.slice(3);
if (/^Num([0-9])$/.test(name)) return name.slice(3);
if (/^F([1-9]|1[0-2])$/.test(name)) return name;
return name;
}
/**
* Side-aware suffix to disambiguate left vs right modifier variants
* — the tiny "R" badge that lets a user see the chord defaults to the
* right-hand keys.
*/
export function modifierSideHint(name: string): 'L' | 'R' | null {
if (name === 'MetaRight' || name === 'AltGr' || name === 'ControlRight' || name === 'ShiftRight') {
return 'R';
}
if (name === 'MetaLeft' || name === 'Alt' || name === 'ControlLeft' || name === 'ShiftLeft') {
return 'L';
}
return null;
}
/**
* Sort a chord's keys so the kbd pills always render in a predictable
* order: modifiers first (Ctrl, Opt, Shift, Cmd), main key last. Matches
* how every macOS shortcut docs list the keys.
*/
const SORT_ORDER: Record<string, number> = {
ControlLeft: 0, ControlRight: 0,
Alt: 1, AltGr: 1,
ShiftLeft: 2, ShiftRight: 2,
MetaLeft: 3, MetaRight: 3,
Function: 4,
CapsLock: 5,
};
export function sortChordKeys(keys: string[]): string[] {
return [...keys].sort((a, b) => {
const sa = SORT_ORDER[a] ?? 99;
const sb = SORT_ORDER[b] ?? 99;
if (sa !== sb) return sa - sb;
return a.localeCompare(b);
});
}
+1
View File
@@ -3,6 +3,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './i18n';
import './index.css';
import { queryClient } from './lib/queryClient';
+30 -6
View File
@@ -6,16 +6,19 @@ import {
redirect,
} from '@tanstack/react-router';
import { AppFrame } from '@/components/AppFrame/AppFrame';
import { AudioTab } from '@/components/AudioTab/AudioTab';
import { CapturesTab } from '@/components/CapturesTab/CapturesTab';
import { EffectsTab } from '@/components/EffectsTab/EffectsTab';
import { MainEditor } from '@/components/MainEditor/MainEditor';
import { ModelsTab } from '@/components/ModelsTab/ModelsTab';
import { AboutPage } from '@/components/ServerTab/AboutPage';
import { CapturesPage } from '@/components/ServerTab/CapturesPage';
import { ChangelogPage } from '@/components/ServerTab/ChangelogPage';
import { GeneralPage } from '@/components/ServerTab/GeneralPage';
import { GenerationPage } from '@/components/ServerTab/GenerationPage';
import { GpuPage } from '@/components/ServerTab/GpuPage';
import { LogsPage } from '@/components/ServerTab/LogsPage';
import { MCPPage } from '@/components/ServerTab/MCPPage';
import { MobilePage } from '@/components/ServerTab/MobilePage';
import { SettingsLayout } from '@/components/ServerTab/ServerTab';
import { Sidebar } from '@/components/Sidebar';
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
@@ -111,11 +114,11 @@ const voicesRoute = createRoute({
component: VoicesTab,
});
// Audio route
const audioRoute = createRoute({
// Captures route (prototype — will replace AudioTab once the new flow is ready)
const capturesRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/audio',
component: AudioTab,
path: '/captures',
component: CapturesTab,
});
// Effects route
@@ -152,6 +155,24 @@ const settingsGenerationRoute = createRoute({
component: GenerationPage,
});
const settingsCapturesRoute = createRoute({
getParentRoute: () => settingsRoute,
path: '/captures',
component: CapturesPage,
});
const settingsMCPRoute = createRoute({
getParentRoute: () => settingsRoute,
path: '/mcp',
component: MCPPage,
});
const settingsMobileRoute = createRoute({
getParentRoute: () => settingsRoute,
path: '/mobile',
component: MobilePage,
});
const settingsGpuRoute = createRoute({
getParentRoute: () => settingsRoute,
path: '/gpu',
@@ -189,13 +210,16 @@ const serverRedirectRoute = createRoute({
const routeTree = rootRoute.addChildren([
indexRoute,
storiesRoute,
capturesRoute,
voicesRoute,
audioRoute,
effectsRoute,
modelsRoute,
settingsRoute.addChildren([
settingsGeneralRoute,
settingsGenerationRoute,
settingsCapturesRoute,
settingsMCPRoute,
settingsMobileRoute,
settingsGpuRoute,
settingsLogsRoute,
settingsChangelogRoute,
+35 -25
View File
@@ -15,18 +15,6 @@ interface ServerStore {
keepServerRunningOnClose: boolean;
setKeepServerRunningOnClose: (keepRunning: boolean) => void;
maxChunkChars: number;
setMaxChunkChars: (value: number) => void;
crossfadeMs: number;
setCrossfadeMs: (value: number) => void;
normalizeAudio: boolean;
setNormalizeAudio: (value: boolean) => void;
autoplayOnGenerate: boolean;
setAutoplayOnGenerate: (value: boolean) => void;
customModelsDir: string | null;
setCustomModelsDir: (dir: string | null) => void;
}
@@ -39,10 +27,44 @@ function invalidateAllServerData() {
queryClient.invalidateQueries();
}
export function getDefaultServerUrl(): string {
const fallback = 'http://127.0.0.1:17493';
if (!import.meta.env.PROD || typeof window === 'undefined') {
return fallback;
}
const { protocol, origin, hostname } = window.location;
if (
(protocol === 'http:' || protocol === 'https:') &&
origin &&
hostname !== 'tauri.localhost'
) {
return origin;
}
return fallback;
}
export function isLoopbackVoiceboxServerUrl(url: string): boolean {
try {
const parsed = new URL(url);
return (
parsed.port === '17493' &&
(parsed.hostname === '127.0.0.1' ||
parsed.hostname === 'localhost' ||
parsed.hostname === '[::1]' ||
parsed.hostname === '::1')
);
} catch {
return false;
}
}
export const useServerStore = create<ServerStore>()(
persist(
(set, get) => ({
serverUrl: 'http://127.0.0.1:17493',
serverUrl: getDefaultServerUrl(),
setServerUrl: (url) => {
const prev = get().serverUrl;
set({ serverUrl: url });
@@ -60,18 +82,6 @@ export const useServerStore = create<ServerStore>()(
keepServerRunningOnClose: false,
setKeepServerRunningOnClose: (keepRunning) => set({ keepServerRunningOnClose: keepRunning }),
maxChunkChars: 800,
setMaxChunkChars: (value) => set({ maxChunkChars: value }),
crossfadeMs: 50,
setCrossfadeMs: (value) => set({ crossfadeMs: value }),
normalizeAudio: true,
setNormalizeAudio: (value) => set({ normalizeAudio: value }),
autoplayOnGenerate: true,
setAutoplayOnGenerate: (value) => set({ autoplayOnGenerate: value }),
customModelsDir: null,
setCustomModelsDir: (dir) => set({ customModelsDir: dir }),
}),
+54 -25
View File
@@ -1,10 +1,25 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type Theme = 'light' | 'dark' | 'system';
function resolveTheme(theme: Theme): 'light' | 'dark' {
if (theme !== 'system') return theme;
if (typeof window === 'undefined') return 'dark';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyTheme(theme: Theme) {
if (typeof document === 'undefined') return;
document.documentElement.classList.toggle('dark', resolveTheme(theme) === 'dark');
}
// Draft state for the create voice profile form
export interface ProfileFormDraft {
name: string;
description: string;
language: string;
personality: string;
referenceText: string;
sampleMode: 'upload' | 'record' | 'system';
// Note: File objects can't be persisted, so we store metadata
@@ -44,37 +59,51 @@ interface UIStore {
setProfileFormDraft: (draft: ProfileFormDraft | null) => void;
// Theme
theme: 'light' | 'dark';
setTheme: (theme: 'light' | 'dark') => void;
theme: Theme;
setTheme: (theme: Theme) => void;
}
export const useUIStore = create<UIStore>((set) => ({
sidebarOpen: true,
setSidebarOpen: (open) => set({ sidebarOpen: open }),
export const useUIStore = create<UIStore>()(
persist(
(set) => ({
sidebarOpen: true,
setSidebarOpen: (open) => set({ sidebarOpen: open }),
profileDialogOpen: false,
setProfileDialogOpen: (open) => set({ profileDialogOpen: open }),
editingProfileId: null,
setEditingProfileId: (id) => set({ editingProfileId: id }),
profileDialogOpen: false,
setProfileDialogOpen: (open) => set({ profileDialogOpen: open }),
editingProfileId: null,
setEditingProfileId: (id) => set({ editingProfileId: id }),
generationDialogOpen: false,
setGenerationDialogOpen: (open) => set({ generationDialogOpen: open }),
generationDialogOpen: false,
setGenerationDialogOpen: (open) => set({ generationDialogOpen: open }),
selectedProfileId: null,
setSelectedProfileId: (id) => set({ selectedProfileId: id }),
selectedProfileId: null,
setSelectedProfileId: (id) => set({ selectedProfileId: id }),
selectedEngine: 'qwen',
setSelectedEngine: (engine) => set({ selectedEngine: engine }),
selectedEngine: 'qwen',
setSelectedEngine: (engine) => set({ selectedEngine: engine }),
selectedVoiceId: null,
setSelectedVoiceId: (id) => set({ selectedVoiceId: id }),
selectedVoiceId: null,
setSelectedVoiceId: (id) => set({ selectedVoiceId: id }),
profileFormDraft: null,
setProfileFormDraft: (draft) => set({ profileFormDraft: draft }),
profileFormDraft: null,
setProfileFormDraft: (draft) => set({ profileFormDraft: draft }),
theme: 'light',
setTheme: (theme) => {
set({ theme });
document.documentElement.classList.toggle('dark', theme === 'dark');
},
}));
theme: 'system',
setTheme: (theme) => {
set({ theme });
applyTheme(theme);
},
}),
{
name: 'voicebox-ui',
partialize: (state) => ({
selectedProfileId: state.selectedProfileId,
theme: state.theme,
}),
onRehydrateStorage: () => (state) => {
if (state) applyTheme(state.theme);
},
},
),
);
+1 -1
View File
@@ -1,3 +1,3 @@
# Backend package
__version__ = "0.4.1"
__version__ = "0.5.0"
+111 -78
View File
@@ -4,6 +4,7 @@ import asyncio
import logging
import os
import sys
from contextlib import asynccontextmanager
from pathlib import Path
@@ -47,7 +48,7 @@ from fastapi.middleware.cors import CORSMiddleware
from urllib.parse import quote
from . import __version__, config, database
from .services import tts, transcribe
from .services import tts, transcribe, llm
from .database import get_db
from .utils.platform_detect import get_backend_type
from .utils.progress import get_progress_manager
@@ -68,15 +69,46 @@ def safe_content_disposition(disposition_type: str, filename: str) -> str:
def create_app() -> FastAPI:
"""Create and configure the FastAPI application."""
from .mcp_server.server import build_mcp_server, compose_lifespan
from .mcp_server.context import ClientIdMiddleware
# Build the MCP app up-front so we can wire its lifespan into FastAPI's —
# FastMCP's Streamable HTTP transport only works if its session manager
# runs inside the parent ASGI lifespan.
mcp = build_mcp_server()
mcp_app = mcp.http_app(path="/", transport="http")
@asynccontextmanager
async def voicebox_lifespan(app: FastAPI):
await _run_startup(app)
try:
yield
finally:
# Paired with _run_startup via try/finally: runs whether or
# not the nested MCP lifespan entered cleanly, so a partial
# startup still unloads whatever models were loaded.
await _run_shutdown()
# compose_lifespan enters factories in order (voicebox startup →
# MCP startup) and exits in LIFO (MCP teardown first → models
# unload last). That ordering matters on shutdown: FastMCP's
# __aexit__ cancels in-flight session tasks, and we want that to
# happen *before* _run_shutdown yanks the TTS / Whisper / LLM
# models out from under any MCP request that was still generating.
lifespan = compose_lifespan(voicebox_lifespan, mcp_app.router.lifespan_context)
application = FastAPI(
title="voicebox API",
description="Production-quality Qwen3-TTS voice cloning API",
version=__version__,
lifespan=lifespan,
)
_configure_cors(application)
application.add_middleware(ClientIdMiddleware)
register_routers(application)
_register_lifecycle(application)
application.mount("/mcp", mcp_app)
logger.info("MCP: mounted at /mcp")
_mount_frontend(application)
return application
@@ -179,103 +211,104 @@ def _get_gpu_status() -> str:
return "None (CPU only)"
def _register_lifecycle(application: FastAPI) -> None:
"""Attach startup and shutdown event handlers."""
async def _run_startup(application: FastAPI) -> None:
"""Database init, warnings, model-cache prep. Runs on lifespan entry."""
import platform
import sys
@application.on_event("startup")
async def startup_event():
import platform
import sys
logger.info("Voicebox v%s starting up", __version__)
logger.info(
"Python %s on %s %s (%s)",
sys.version.split()[0],
platform.system(),
platform.release(),
platform.machine(),
)
logger.info("Voicebox v%s starting up", __version__)
logger.info(
"Python %s on %s %s (%s)",
sys.version.split()[0],
platform.system(),
platform.release(),
platform.machine(),
)
database.init_db()
database.init_db()
from .database.session import _db_path
from .database.session import _db_path
logger.info("Database: %s", _db_path)
logger.info("Data directory: %s", config.get_data_dir())
logger.info("Database: %s", _db_path)
logger.info("Data directory: %s", config.get_data_dir())
init_queue()
init_queue()
# Mark stale "generating" records as failed -- leftovers from a killed process
from sqlalchemy import text as sa_text
# Mark stale "generating" records as failed -- leftovers from a killed process
from sqlalchemy import text as sa_text
db = next(get_db())
try:
result = db.execute(
sa_text(
"UPDATE generations SET status = 'failed', "
"error = 'Server was shut down during generation' "
"WHERE status IN ('generating', 'loading_model')"
)
db = next(get_db())
try:
result = db.execute(
sa_text(
"UPDATE generations SET status = 'failed', "
"error = 'Server was shut down during generation' "
"WHERE status IN ('generating', 'loading_model')"
)
if result.rowcount > 0:
logger.info("Marked %d stale generation(s) as failed", result.rowcount)
)
if result.rowcount > 0:
logger.info("Marked %d stale generation(s) as failed", result.rowcount)
from .database import VoiceProfile as DBVoiceProfile, Generation as DBGeneration
from .database import VoiceProfile as DBVoiceProfile, Generation as DBGeneration
profile_count = db.query(DBVoiceProfile).count()
generation_count = db.query(DBGeneration).count()
logger.info("Profiles: %d, Generations: %d", profile_count, generation_count)
profile_count = db.query(DBVoiceProfile).count()
generation_count = db.query(DBGeneration).count()
logger.info("Profiles: %d, Generations: %d", profile_count, generation_count)
db.commit()
except Exception as e:
db.rollback()
logger.warning("Could not clean up stale generations: %s", e)
finally:
db.close()
db.commit()
except Exception as e:
db.rollback()
logger.warning("Could not clean up stale generations: %s", e)
finally:
db.close()
backend_type = get_backend_type()
logger.info("Backend: %s", backend_type.upper())
logger.info("GPU: %s", _get_gpu_status())
backend_type = get_backend_type()
logger.info("Backend: %s", backend_type.upper())
logger.info("GPU: %s", _get_gpu_status())
# Warn if GPU architecture is not supported by this PyTorch build
from .backends.base import check_cuda_compatibility
from .backends.base import check_cuda_compatibility
_compatible, _cuda_warning = check_cuda_compatibility()
if not _compatible:
logger.warning("GPU COMPATIBILITY: %s", _cuda_warning)
_compatible, _cuda_warning = check_cuda_compatibility()
if not _compatible:
logger.warning("GPU COMPATIBILITY: %s", _cuda_warning)
from .services.cuda import check_and_update_cuda_binary
from .services.cuda import check_and_update_cuda_binary
create_background_task(check_and_update_cuda_binary())
create_background_task(check_and_update_cuda_binary())
try:
progress_manager = get_progress_manager()
progress_manager._set_main_loop(asyncio.get_running_loop())
except Exception as e:
logger.warning("Could not initialize progress manager event loop: %s", e)
try:
progress_manager = get_progress_manager()
progress_manager._set_main_loop(asyncio.get_running_loop())
except Exception as e:
logger.warning("Could not initialize progress manager event loop: %s", e)
try:
from huggingface_hub import constants as hf_constants
try:
from huggingface_hub import constants as hf_constants
cache_dir = Path(hf_constants.HF_HUB_CACHE)
cache_dir.mkdir(parents=True, exist_ok=True)
logger.info("Model cache: %s", cache_dir)
except Exception as e:
logger.warning("Could not create HuggingFace cache directory: %s", e)
cache_dir = Path(hf_constants.HF_HUB_CACHE)
cache_dir.mkdir(parents=True, exist_ok=True)
logger.info("Model cache: %s", cache_dir)
except Exception as e:
logger.warning("Could not create HuggingFace cache directory: %s", e)
logger.info("Ready")
logger.info("Ready")
@application.on_event("shutdown")
async def shutdown_event():
logger.info("Voicebox server shutting down...")
try:
tts.unload_tts_model()
except Exception:
logger.exception("Failed to unload TTS model")
try:
transcribe.unload_whisper_model()
except Exception:
logger.exception("Failed to unload Whisper model")
async def _run_shutdown() -> None:
"""Unload models on lifespan exit."""
logger.info("Voicebox server shutting down...")
try:
tts.unload_tts_model()
except Exception:
logger.exception("Failed to unload TTS model")
try:
transcribe.unload_whisper_model()
except Exception:
logger.exception("Failed to unload Whisper model")
try:
llm.unload_llm_model()
except Exception:
logger.exception("Failed to unload LLM model")
app = create_app()
+174 -7
View File
@@ -5,12 +5,22 @@ Provides a unified interface for MLX and PyTorch backends,
and a model config registry that eliminates per-engine dispatch maps.
"""
# Install HF compatibility patches before any backend imports transformers /
# huggingface_hub. The module runs ``patch_transformers_mistral_regex`` at
# import time, which wraps transformers' tokenizer load against the
# unconditional HuggingFace metadata call that otherwise raises on
# HF_HUB_OFFLINE=1 and on network failures.
from ..utils import hf_offline_patch # noqa: F401
import threading
from dataclasses import dataclass, field
from typing import Protocol, Optional, Tuple, List
from typing_extensions import runtime_checkable
import numpy as np
DEFAULT_LLM_MAX_TOKENS = 512
DEFAULT_LLM_TEMPERATURE = 0.7
from ..utils.platform_detect import get_backend_type
LANGUAGE_CODE_TO_NAME = {
@@ -153,11 +163,47 @@ class STTBackend(Protocol):
...
@runtime_checkable
class LLMBackend(Protocol):
"""Protocol for local LLM (chat/completion) backend implementations."""
async def load_model(self, model_size: str) -> None:
"""Load LLM weights and tokenizer."""
...
async def generate(
self,
prompt: str,
system: Optional[str] = None,
max_tokens: int = DEFAULT_LLM_MAX_TOKENS,
temperature: float = DEFAULT_LLM_TEMPERATURE,
model_size: Optional[str] = None,
examples: Optional[list[tuple[str, str]]] = None,
) -> str:
"""Run a single-turn chat completion and return the assistant reply.
``examples`` is an optional list of ``(user, assistant)`` pairs
prepended to the conversation as proper chat turns — small models
pattern-match on inline system-prompt examples (echoing them
verbatim for unrelated inputs), but treat structured turns as
data and generalize instead. Used by the refinement service.
"""
...
def unload_model(self) -> None:
...
def is_loaded(self) -> bool:
...
# Global backend instances
_tts_backend: Optional[TTSBackend] = None
_tts_backends: dict[str, TTSBackend] = {}
_tts_backends_lock = threading.Lock()
_stt_backend: Optional[STTBackend] = None
_llm_backends: dict[str, LLMBackend] = {}
_llm_backends_lock = threading.Lock()
# Supported TTS engines — keyed by engine name, value is the backend class import path.
# The factory function uses this for the if/elif chain; the model configs live on the backend classes.
@@ -171,13 +217,17 @@ TTS_ENGINES = {
"kokoro": "Kokoro",
}
LLM_ENGINES = {
"qwen_llm": "Qwen3 LLM",
}
def _get_qwen_model_configs() -> list[ModelConfig]:
"""Return Qwen model configs with backend-aware HF repo IDs."""
backend_type = get_backend_type()
if backend_type == "mlx":
repo_1_7b = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16"
repo_0_6b = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16" # 0.6B not available in MLX, falls back
repo_0_6b = "mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16"
else:
repo_1_7b = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
repo_0_6b = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
@@ -358,9 +408,66 @@ def _get_whisper_configs() -> list[ModelConfig]:
]
def _get_qwen_llm_configs() -> list[ModelConfig]:
"""Return Qwen3 LLM configs with backend-aware HF repo IDs.
MLX path uses 4-bit community quantizations for Apple Silicon; PyTorch path
uses the upstream instruct weights.
"""
backend_type = get_backend_type()
if backend_type == "mlx":
repo_0_6 = "mlx-community/Qwen3-0.6B-4bit"
repo_1_7 = "mlx-community/Qwen3-1.7B-4bit"
repo_4 = "mlx-community/Qwen3-4B-4bit"
else:
repo_0_6 = "Qwen/Qwen3-0.6B"
repo_1_7 = "Qwen/Qwen3-1.7B"
repo_4 = "Qwen/Qwen3-4B"
common_languages = [
"en", "zh", "ja", "ko", "de", "fr", "ru", "pt", "es", "it",
]
return [
ModelConfig(
model_name="qwen3-0.6b",
display_name="Qwen3 0.6B",
engine="qwen_llm",
hf_repo_id=repo_0_6,
model_size="0.6B",
size_mb=400 if backend_type == "mlx" else 1400,
languages=common_languages,
),
ModelConfig(
model_name="qwen3-1.7b",
display_name="Qwen3 1.7B",
engine="qwen_llm",
hf_repo_id=repo_1_7,
model_size="1.7B",
size_mb=1100 if backend_type == "mlx" else 3500,
languages=common_languages,
),
ModelConfig(
model_name="qwen3-4b",
display_name="Qwen3 4B",
engine="qwen_llm",
hf_repo_id=repo_4,
model_size="4B",
size_mb=2500 if backend_type == "mlx" else 8000,
languages=common_languages,
),
]
def get_all_model_configs() -> list[ModelConfig]:
"""Return the full list of model configs (TTS + STT)."""
return _get_qwen_model_configs() + _get_qwen_custom_voice_configs() + _get_non_qwen_tts_configs() + _get_whisper_configs()
"""Return the full list of model configs (TTS + STT + LLM)."""
return (
_get_qwen_model_configs()
+ _get_qwen_custom_voice_configs()
+ _get_non_qwen_tts_configs()
+ _get_whisper_configs()
+ _get_qwen_llm_configs()
)
def get_tts_model_configs() -> list[ModelConfig]:
@@ -368,6 +475,16 @@ def get_tts_model_configs() -> list[ModelConfig]:
return _get_qwen_model_configs() + _get_qwen_custom_voice_configs() + _get_non_qwen_tts_configs()
def get_llm_model_configs() -> list[ModelConfig]:
"""Return only LLM model configs."""
return _get_qwen_llm_configs()
def get_stt_model_configs() -> list[ModelConfig]:
"""Return only STT (Whisper) model configs."""
return _get_whisper_configs()
# Lookup helpers — these replace the if/elif chains in main.py
@@ -433,7 +550,7 @@ async def ensure_model_cached_or_raise(engine: str, model_size: str = "default")
def unload_model_by_config(config: ModelConfig) -> bool:
"""Unload a model given its config. Returns True if it was loaded, False otherwise."""
from . import get_tts_backend_for_engine
from ..services import tts, transcribe
from ..services import tts, transcribe, llm as llm_service
if config.engine == "whisper":
whisper_model = transcribe.get_whisper_model()
@@ -442,6 +559,14 @@ def unload_model_by_config(config: ModelConfig) -> bool:
return True
return False
if config.engine == "qwen_llm":
backend = llm_service.get_llm_model()
loaded_size = getattr(backend, "_current_model_size", None) or getattr(backend, "model_size", None)
if backend.is_loaded() and loaded_size == config.model_size:
backend.unload_model()
return True
return False
if config.engine == "qwen":
tts_model = tts.get_tts_model()
loaded_size = getattr(tts_model, "_current_model_size", None) or getattr(tts_model, "model_size", None)
@@ -469,13 +594,18 @@ def unload_model_by_config(config: ModelConfig) -> bool:
def check_model_loaded(config: ModelConfig) -> bool:
"""Check if a model is currently loaded."""
from . import get_tts_backend_for_engine
from ..services import tts, transcribe
from ..services import tts, transcribe, llm as llm_service
try:
if config.engine == "whisper":
whisper_model = transcribe.get_whisper_model()
return whisper_model.is_loaded() and getattr(whisper_model, "model_size", None) == config.model_size
if config.engine == "qwen_llm":
backend = llm_service.get_llm_model()
loaded_size = getattr(backend, "_current_model_size", None) or getattr(backend, "model_size", None)
return backend.is_loaded() and loaded_size == config.model_size
if config.engine == "qwen":
tts_model = tts.get_tts_model()
loaded_size = getattr(tts_model, "_current_model_size", None) or getattr(tts_model, "model_size", None)
@@ -495,7 +625,7 @@ def check_model_loaded(config: ModelConfig) -> bool:
def get_model_load_func(config: ModelConfig):
"""Return a callable that loads/downloads the model."""
from . import get_tts_backend_for_engine
from ..services import tts, transcribe
from ..services import tts, transcribe, llm as llm_service
if config.engine == "whisper":
return lambda: transcribe.get_whisper_model().load_model(config.model_size)
@@ -506,6 +636,9 @@ def get_model_load_func(config: ModelConfig):
if config.engine == "qwen_custom_voice":
return lambda: get_tts_backend_for_engine(config.engine).load_model(config.model_size)
if config.engine == "qwen_llm":
return lambda: llm_service.get_llm_model().load_model(config.model_size)
return lambda: get_tts_backend_for_engine(config.engine).load_model()
@@ -606,9 +739,43 @@ def get_stt_backend() -> STTBackend:
return _stt_backend
def get_llm_backend() -> LLMBackend:
"""Get or create the default Qwen3 LLM backend based on platform."""
return get_llm_backend_for_engine("qwen_llm")
def get_llm_backend_for_engine(engine: str) -> LLMBackend:
"""Get or create an LLM backend for the given engine."""
global _llm_backends
if engine in _llm_backends:
return _llm_backends[engine]
with _llm_backends_lock:
if engine in _llm_backends:
return _llm_backends[engine]
if engine == "qwen_llm":
backend_type = get_backend_type()
if backend_type == "mlx":
from .qwen_llm_backend import MLXQwenLLMBackend
backend = MLXQwenLLMBackend()
else:
from .qwen_llm_backend import PyTorchQwenLLMBackend
backend = PyTorchQwenLLMBackend()
else:
raise ValueError(f"Unknown LLM engine: {engine}. Supported: {list(LLM_ENGINES.keys())}")
_llm_backends[engine] = backend
return backend
def reset_backends():
"""Reset backend instances (useful for testing)."""
global _tts_backend, _tts_backends, _stt_backend
global _tts_backend, _tts_backends, _stt_backend, _llm_backends
_tts_backend = None
_tts_backends.clear()
_stt_backend = None
_llm_backends.clear()
+11 -11
View File
@@ -20,7 +20,6 @@ ensure_original_qwen_config_cached()
from . import TTSBackend, STTBackend, LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS
from .base import is_model_cached, combine_voice_prompts as _combine_voice_prompts, model_load_progress
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
from ..utils.hf_offline_patch import force_offline_if_cached
class MLXTTSBackend:
@@ -45,11 +44,9 @@ class MLXTTSBackend:
Returns:
HuggingFace Hub model ID for MLX
"""
# MLX model mapping
mlx_model_map = {
"1.7B": "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16",
# 0.6B not yet converted to MLX format
"0.6B": "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16", # Fallback to 1.7B
"0.6B": "mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16",
}
if model_size not in mlx_model_map:
@@ -101,8 +98,7 @@ class MLXTTSBackend:
logger.info("Loading MLX TTS model %s...", model_size)
with force_offline_if_cached(is_cached, model_name):
self.model = load(model_path)
self.model = load(model_path)
self._current_model_size = model_size
self.model_size = model_size
@@ -220,10 +216,12 @@ class MLXTTSBackend:
logger.warning("Regenerating without voice prompt.")
ref_audio = None
# Check if model supports voice cloning via generate method
# MLX API may support ref_audio parameter directly
# Inference runs with the process's default HF_HUB_OFFLINE
# state. Forcing offline here (previously used to avoid lazy
# mlx_audio lookups hanging when the network drops mid-inference,
# issue #462) regressed online users because libraries make
# legitimate metadata calls during generation.
try:
# Try with voice cloning parameters if supported
if ref_audio:
# Check if generate accepts ref_audio parameter
import inspect
@@ -311,8 +309,7 @@ class MLXSTTBackend:
model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
logger.info("Loading MLX Whisper model %s...", model_size)
with force_offline_if_cached(is_cached, progress_model_name):
self.model = load(model_name)
self.model = load(model_name)
self.model_size = model_size
logger.info("MLX Whisper model %s loaded successfully", model_size)
@@ -351,6 +348,9 @@ class MLXSTTBackend:
if language:
decode_options["language"] = language
# Inference runs with the process's default HF_HUB_OFFLINE
# state — see the comment in MLXTTSBackend.generate for the
# regression this revert fixes (issue #462).
result = self.model.generate(str(audio_path), **decode_options)
# Extract text from result
+27 -21
View File
@@ -21,7 +21,6 @@ from .base import (
)
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
from ..utils.audio import load_audio
from ..utils.hf_offline_patch import force_offline_if_cached
class PyTorchTTSBackend:
@@ -106,21 +105,20 @@ class PyTorchTTSBackend:
from huggingface_hub import constants as hf_constants
tts_cache_dir = hf_constants.HF_HUB_CACHE
with force_offline_if_cached(is_cached, model_name):
if self.device == "cpu":
self.model = Qwen3TTSModel.from_pretrained(
model_path,
cache_dir=tts_cache_dir,
torch_dtype=torch.float32,
low_cpu_mem_usage=False,
)
else:
self.model = Qwen3TTSModel.from_pretrained(
model_path,
cache_dir=tts_cache_dir,
device_map=self.device,
torch_dtype=torch.bfloat16,
)
if self.device == "cpu":
self.model = Qwen3TTSModel.from_pretrained(
model_path,
cache_dir=tts_cache_dir,
torch_dtype=torch.float32,
low_cpu_mem_usage=False,
)
else:
self.model = Qwen3TTSModel.from_pretrained(
model_path,
cache_dir=tts_cache_dir,
device_map=self.device,
torch_dtype=torch.bfloat16,
)
self._current_model_size = model_size
self.model_size = model_size
@@ -174,6 +172,10 @@ class PyTorchTTSBackend:
def _create_prompt_sync():
"""Run synchronous voice prompt creation in thread pool."""
# Inference runs with the process's default HF_HUB_OFFLINE
# state. Forcing offline here (issue #462) regressed online
# users whose libraries issue legitimate metadata lookups
# during voice-prompt creation.
return self.model.create_voice_clone_prompt(
ref_audio=str(audio_path),
ref_text=reference_text,
@@ -227,7 +229,8 @@ class PyTorchTTSBackend:
if seed is not None:
manual_seed(seed, self.device)
# Generate audio - this is the blocking operation
# See _create_prompt_sync comment — inference runs with the
# process's default HF_HUB_OFFLINE state (issue #462).
wavs, sample_rate = self.model.generate_voice_clone(
text=text,
voice_clone_prompt=voice_prompt,
@@ -292,9 +295,8 @@ class PyTorchSTTBackend:
model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
logger.info("Loading Whisper model %s on %s...", model_size, self.device)
with force_offline_if_cached(is_cached, progress_model_name):
self.processor = WhisperProcessor.from_pretrained(model_name)
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
self.processor = WhisperProcessor.from_pretrained(model_name)
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
self.model.to(self.device)
self.model_size = model_size
@@ -334,8 +336,12 @@ class PyTorchSTTBackend:
def _transcribe_sync():
"""Run synchronous transcription in thread pool."""
# Load audio
audio, sr = load_audio(audio_path, sample_rate=16000)
audio, _sr = load_audio(audio_path, sample_rate=16000)
# Inference runs with the process's default HF_HUB_OFFLINE
# state — forcing offline here (issue #462) broke online users
# whose `get_decoder_prompt_ids` / tokenizer calls issue
# legitimate metadata lookups.
# Process audio
inputs = self.processor(
audio,
@@ -203,6 +203,10 @@ class QwenCustomVoiceBackend:
if instruct:
kwargs["instruct"] = instruct
# Inference runs with the process's default HF_HUB_OFFLINE
# state. Forcing offline here (issue #462) regressed online
# users whose libraries issue legitimate metadata lookups
# during generation.
wavs, sample_rate = self.model.generate_custom_voice(**kwargs)
return wavs[0], sample_rate
+290
View File
@@ -0,0 +1,290 @@
"""
Qwen3 LLM backend implementations.
Provides MLX (Apple Silicon, 4-bit community quants) and PyTorch
(transformers AutoModelForCausalLM) paths that share the same
`LLMBackend` protocol and model-load progress plumbing as the TTS
and STT engines.
"""
import asyncio
import logging
from typing import Optional
from . import LLMBackend, DEFAULT_LLM_MAX_TOKENS, DEFAULT_LLM_TEMPERATURE
from .base import (
is_model_cached,
get_torch_device,
empty_device_cache,
manual_seed,
model_load_progress,
)
from ..utils.hf_offline_patch import force_offline_if_cached
logger = logging.getLogger(__name__)
PYTORCH_HF_REPOS = {
"0.6B": "Qwen/Qwen3-0.6B",
"1.7B": "Qwen/Qwen3-1.7B",
"4B": "Qwen/Qwen3-4B",
}
MLX_HF_REPOS = {
"0.6B": "mlx-community/Qwen3-0.6B-4bit",
"1.7B": "mlx-community/Qwen3-1.7B-4bit",
"4B": "mlx-community/Qwen3-4B-4bit",
}
def _progress_name(model_size: str) -> str:
return f"qwen3-{model_size.lower()}"
def _build_messages(
prompt: str,
system: Optional[str],
examples: Optional[list[tuple[str, str]]] = None,
) -> list[dict]:
messages: list[dict] = []
if system:
messages.append({"role": "system", "content": system})
if examples:
for user_text, assistant_text in examples:
messages.append({"role": "user", "content": user_text})
messages.append({"role": "assistant", "content": assistant_text})
messages.append({"role": "user", "content": prompt})
return messages
class PyTorchQwenLLMBackend:
"""Qwen3 LLM backend using HuggingFace transformers."""
def __init__(self, model_size: str = "0.6B"):
self.model = None
self.tokenizer = None
self.model_size = model_size
self._current_model_size: Optional[str] = None
self.device = self._get_device()
def _get_device(self) -> str:
return get_torch_device(allow_xpu=True, allow_directml=True, allow_mps=True)
def is_loaded(self) -> bool:
return self.model is not None
def _get_model_path(self, model_size: str) -> str:
if model_size not in PYTORCH_HF_REPOS:
raise ValueError(f"Unknown Qwen3 size: {model_size}")
return PYTORCH_HF_REPOS[model_size]
def _is_model_cached(self, model_size: str) -> bool:
return is_model_cached(self._get_model_path(model_size))
async def load_model(self, model_size: Optional[str] = None) -> None:
if model_size is None:
model_size = self.model_size
if self.model is not None and self._current_model_size == model_size:
return
if self.model is not None and self._current_model_size != model_size:
self.unload_model()
await asyncio.to_thread(self._load_model_sync, model_size)
def _load_model_sync(self, model_size: str) -> None:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
progress_model_name = _progress_name(model_size)
is_cached = self._is_model_cached(model_size)
repo = self._get_model_path(model_size)
with model_load_progress(progress_model_name, is_cached):
logger.info("Loading Qwen3 %s on %s...", model_size, self.device)
with force_offline_if_cached(is_cached, progress_model_name):
self.tokenizer = AutoTokenizer.from_pretrained(repo)
dtype = torch.float16 if self.device in ("cuda", "mps") else torch.float32
self.model = AutoModelForCausalLM.from_pretrained(
repo,
dtype=dtype,
)
self.model.to(self.device)
self.model.eval()
self._current_model_size = model_size
self.model_size = model_size
logger.info("Qwen3 %s loaded successfully", model_size)
def unload_model(self) -> None:
if self.model is None:
return
del self.model
del self.tokenizer
self.model = None
self.tokenizer = None
self._current_model_size = None
empty_device_cache(self.device)
logger.info("Qwen3 unloaded")
async def generate(
self,
prompt: str,
system: Optional[str] = None,
max_tokens: int = DEFAULT_LLM_MAX_TOKENS,
temperature: float = DEFAULT_LLM_TEMPERATURE,
model_size: Optional[str] = None,
examples: Optional[list[tuple[str, str]]] = None,
) -> str:
await self.load_model(model_size)
return await asyncio.to_thread(
self._generate_sync, prompt, system, max_tokens, temperature, examples
)
def _generate_sync(
self,
prompt: str,
system: Optional[str],
max_tokens: int,
temperature: float,
examples: Optional[list[tuple[str, str]]] = None,
) -> str:
import torch
messages = _build_messages(prompt, system, examples)
text = self.tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
enable_thinking=False,
)
inputs = self.tokenizer(text, return_tensors="pt").to(self.device)
do_sample = temperature > 0
generate_kwargs = {
"max_new_tokens": max_tokens,
"do_sample": do_sample,
"pad_token_id": self.tokenizer.eos_token_id,
}
if do_sample:
generate_kwargs["temperature"] = temperature
generate_kwargs["top_p"] = 0.9
with torch.no_grad():
output_ids = self.model.generate(**inputs, **generate_kwargs)
input_len = inputs["input_ids"].shape[1]
new_tokens = output_ids[0, input_len:]
return self.tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
class MLXQwenLLMBackend:
"""Qwen3 LLM backend using mlx-lm (Apple Silicon)."""
def __init__(self, model_size: str = "0.6B"):
self.model = None
self.tokenizer = None
self.model_size = model_size
self._current_model_size: Optional[str] = None
def is_loaded(self) -> bool:
return self.model is not None
def _get_model_path(self, model_size: str) -> str:
if model_size not in MLX_HF_REPOS:
raise ValueError(f"Unknown Qwen3 size: {model_size}")
return MLX_HF_REPOS[model_size]
def _is_model_cached(self, model_size: str) -> bool:
return is_model_cached(
self._get_model_path(model_size),
weight_extensions=(".safetensors", ".bin", ".npz"),
)
async def load_model(self, model_size: Optional[str] = None) -> None:
if model_size is None:
model_size = self.model_size
if self.model is not None and self._current_model_size == model_size:
return
if self.model is not None and self._current_model_size != model_size:
self.unload_model()
await asyncio.to_thread(self._load_model_sync, model_size)
def _load_model_sync(self, model_size: str) -> None:
from mlx_lm import load as mlx_load
progress_model_name = _progress_name(model_size)
is_cached = self._is_model_cached(model_size)
repo = self._get_model_path(model_size)
with model_load_progress(progress_model_name, is_cached):
logger.info("Loading Qwen3 %s via MLX...", model_size)
with force_offline_if_cached(is_cached, progress_model_name):
loaded = mlx_load(repo)
# mlx_lm.load returns (model, tokenizer) by default and
# (model, tokenizer, config) when return_config=True.
self.model = loaded[0]
self.tokenizer = loaded[1]
self._current_model_size = model_size
self.model_size = model_size
logger.info("Qwen3 %s (MLX) loaded successfully", model_size)
def unload_model(self) -> None:
if self.model is None:
return
del self.model
del self.tokenizer
self.model = None
self.tokenizer = None
self._current_model_size = None
logger.info("Qwen3 (MLX) unloaded")
async def generate(
self,
prompt: str,
system: Optional[str] = None,
max_tokens: int = DEFAULT_LLM_MAX_TOKENS,
temperature: float = DEFAULT_LLM_TEMPERATURE,
model_size: Optional[str] = None,
examples: Optional[list[tuple[str, str]]] = None,
) -> str:
await self.load_model(model_size)
return await asyncio.to_thread(
self._generate_sync, prompt, system, max_tokens, temperature, examples
)
def _generate_sync(
self,
prompt: str,
system: Optional[str],
max_tokens: int,
temperature: float,
examples: Optional[list[tuple[str, str]]] = None,
) -> str:
from mlx_lm import generate as mlx_generate
from mlx_lm.sample_utils import make_sampler
messages = _build_messages(prompt, system, examples)
chat_prompt = self.tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
enable_thinking=False,
)
sampler = make_sampler(temp=temperature, top_p=0.9) if temperature > 0 else None
text = mlx_generate(
self.model,
self.tokenizer,
prompt=chat_prompt,
max_tokens=max_tokens,
sampler=sampler,
verbose=False,
)
return text.strip()
+139 -2
View File
@@ -287,8 +287,36 @@ def build_server(cuda=False):
"en_core_web_sm",
"--hidden-import",
"en_core_web_sm",
# unidic-lite ships the MeCab dictionary used by fugashi (pulled in
# by misaki[ja]). The dict lives in unidic_lite/dicdir/ and is
# discovered via the package's DICDIR constant, so the data files
# must be collected or Japanese Kokoro voices crash at runtime.
"--collect-all",
"unidic_lite",
"--hidden-import",
"loguru",
# MCP server — Streamable-HTTP endpoint and the 4 voicebox.* tools.
# FastMCP pulls in a chain of deps (mcp, cyclopts, openapi-pydantic,
# etc.) that don't auto-discover cleanly under PyInstaller, so we
# collect them whole. Small compared to torch.
"--hidden-import",
"backend.mcp_server",
"--hidden-import",
"backend.mcp_server.server",
"--hidden-import",
"backend.mcp_server.tools",
"--hidden-import",
"backend.mcp_server.context",
"--hidden-import",
"backend.mcp_server.resolve",
"--hidden-import",
"backend.mcp_server.events",
"--collect-all",
"fastmcp",
"--collect-all",
"mcp",
"--hidden-import",
"sse_starlette",
]
)
@@ -345,10 +373,16 @@ def build_server(cuda=False):
"mlx_audio.tts",
"--hidden-import",
"mlx_audio.stt",
"--hidden-import",
"mlx_lm",
"--hidden-import",
"backend.backends.qwen_llm_backend",
"--collect-submodules",
"mlx",
"--collect-submodules",
"mlx_audio",
"--collect-submodules",
"mlx_lm",
# Use --collect-all so PyInstaller bundles both data files AND
# native shared libraries (.dylib, .metallib) for MLX.
# Previously only --collect-data was used, which caused MLX to
@@ -358,6 +392,11 @@ def build_server(cuda=False):
"mlx",
"--collect-all",
"mlx_audio",
# mlx_lm ships chat_templates/ JSON files and loads tool_parsers
# submodules dynamically via importlib at tokenizer load time,
# which --hidden-import alone can't resolve.
"--collect-all",
"mlx_lm",
]
)
elif not cuda:
@@ -441,12 +480,110 @@ def build_server(cuda=False):
logger.info("Binary built in %s", backend_dir / "dist" / binary_name)
def build_shim():
"""Build the voicebox-mcp stdio shim as a tiny standalone binary.
This is the bridge for MCP clients that only speak stdio — it proxies
JSON-RPC to the main voicebox-server's /mcp endpoint. Keep it small: no
torch, no ML deps, just httpx + asyncio.
"""
backend_dir = Path(__file__).parent
args = [
"mcp_shim/__main__.py",
"--onefile",
"--name",
"voicebox-mcp",
# Stdio-only — no console hiding needed on Windows since the parent
# MCP client is spawning this as a child process and wants stdio.
"--hidden-import",
"backend.mcp_shim",
"--hidden-import",
"backend.mcp_shim.__main__",
"--hidden-import",
"httpx",
"--hidden-import",
"httpx._transports.default",
"--hidden-import",
"anyio",
# Exclude everything heavy that httpx/asyncio don't actually need so
# the binary stays tiny (~15 MB instead of ~400 MB).
"--exclude-module",
"torch",
"--exclude-module",
"transformers",
"--exclude-module",
"mlx",
"--exclude-module",
"mlx_audio",
"--exclude-module",
"mlx_lm",
"--exclude-module",
"qwen_tts",
"--exclude-module",
"chatterbox",
"--exclude-module",
"zipvoice",
"--exclude-module",
"tada",
"--exclude-module",
"kokoro",
"--exclude-module",
"misaki",
"--exclude-module",
"spacy",
"--exclude-module",
"librosa",
"--exclude-module",
"numba",
"--exclude-module",
"numpy",
"--exclude-module",
"pedalboard",
"--exclude-module",
"fastapi",
"--exclude-module",
"uvicorn",
"--exclude-module",
"sqlalchemy",
"--exclude-module",
"fastmcp",
"--exclude-module",
"mcp",
]
dist_dir = str(backend_dir / "dist")
build_dir = str(backend_dir / "build")
args.extend(
[
"--distpath",
dist_dir,
"--workpath",
build_dir,
"--noconfirm",
"--clean",
]
)
os.chdir(backend_dir)
PyInstaller.__main__.run(args)
logger.info("Shim built: %s", backend_dir / "dist" / "voicebox-mcp")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Build voicebox-server binary")
parser = argparse.ArgumentParser(description="Build voicebox binaries")
parser.add_argument(
"--cuda",
action="store_true",
help="Build CUDA-enabled binary (voicebox-server-cuda)",
)
parser.add_argument(
"--shim",
action="store_true",
help="Build the voicebox-mcp stdio shim binary instead of the server",
)
cli_args = parser.parse_args()
build_server(cuda=cli_args.cuda)
if cli_args.shim:
build_shim()
else:
build_server(cuda=cli_args.cuda)
+7
View File
@@ -119,6 +119,13 @@ def get_generations_dir() -> Path:
return path
def get_captures_dir() -> Path:
"""Get captures directory path."""
path = _data_dir / "captures"
path.mkdir(parents=True, exist_ok=True)
return path
def get_cache_dir() -> Path:
"""Get cache directory path."""
path = _data_dir / "cache"

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