The image build+push succeeds, but the "Update Docker Hub description" step
403s (Forbidden) — DOCKERHUB_TOKEN can push yet lacks description-edit scope, a
common limitation of fine-grained Docker Hub tokens. That cosmetic overview
sync was failing the whole Docker (GHCR) run on main.
Mark the step continue-on-error so a creds-scope mismatch no longer reds-out an
otherwise-successful build. To actually sync the overview, the token needs
read/write (incl. description) scope, or use the account password.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Spec PR 4. A front door onto the existing chapter parser: import a file, get a
chapter-delimited script in the editor.
Backend (new services/longform_import.py — pure, stdlib only, no new dep):
- chapterize_plaintext(text): inserts `# ` headings ahead of short standalone
chapter-title lines (Chapter/Part/Prologue/…); no-op if the text already has
H1s; long "Chapter …" sentences stay prose. ReDoS-safe (anchored, per-line).
- epub_to_chapter_script(bytes): parses EPUB (zipfile + ElementTree +
html.parser) in spine order → `# Title` + stripped body per document; skips
empty/nav pages; the heading becomes the chapter title (not narrated). Raises
ValueError on a malformed EPUB. ET.fromstring annotated `# nosec B314` (local
user file, no external-entity expansion).
- POST /audiobook/import (UploadFile) → {text, chapters}.
Frontend: an Import button (.txt/.md/.epub) that fills the script editor.
Tests: tests/test_longform_import.py (9) incl. an in-memory synthetic EPUB
(spine order, empty-doc skip, tag stripping, bad-zip). 64 backend + 326 frontend
green; build clean; en.json valid.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hub.docker.com/r/palashdeb/omnivoice-studio overview was managed by
hand and had gone stale (stuck at the sha-f86beb0 era, missing the tag
table, audiobook/long-form, Supertonic-3, server-mode networking notes).
Add deploy/dockerhub-overview.md as the source of truth and a
peter-evans/dockerhub-description step in docker.yml that pushes it to
Docker Hub on main pushes. Gated identically to the image push: only when
DOCKERHUB_TOKEN is set, so forks / GHCR-only runs are unaffected.
Overview adds the :latest=preview / :stable=release tag semantics (matching
docs/install/docker.md), the current feature set, server-mode + LAN
networking notes, and shields badges. Short description is 98/100 chars.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(audiobook): per-chapter preview + resume + chapter fault-isolation (PR 3/8)
Builds on the shared core (#408) and metadata UI (#409). Chapter-level control,
the spec's PR 3.
Shared core:
- chapter_cache_key(spans, sr, engine_id, voice_sig) — deterministic content
hash of a chapter's audio inputs. Same inputs → reuse; any change (text,
voice, order, pauses, sr, engine, resolved-voice signature) → re-render.
Backend (audiobook router):
- Chapter WAVs are now content-addressed in OUTPUTS_DIR/audiobook_cache. A
re-run after a failure/interruption reuses already-rendered chapters and only
synthesizes the missing/changed ones (resume). Job emits `cached` per chapter
and `cached_chapters`/`failed_chapters` on done.
- Per-chapter fault isolation: a chapter that throws emits `chapter_error` and
the job continues; the m4b assembles from the successful chapters. Re-running
retries only the failed (un-cached) chapters.
- POST /audiobook/preview — render a single chapter to audition it; shares the
same cache so a preview warms the full run and a re-preview is instant.
- _build_synth now exposes resolve + engine_id; _prepare_synth unifies the
omnivoice/generic paths for both the job and preview.
Frontend:
- Plan view: a ▶ preview button per chapter with inline playback.
- Done panel: "reused N chapters" + "N failed — click Create to retry" notes.
Tests: chapter_cache_key determinism + sensitivity (8); preview validation +
cache-hit-skips-synth (3). 55 backend + 326 frontend green; build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(audiobook): mark cache-key SHA1 usedforsecurity=False (bandit B324)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surfaces the shared-render-core capabilities (PR 1, #408) in the Audiobook tab.
Backend:
- POST /audiobook/cover — multipart cover upload (jpg/png, 8 MB cap), returns a
server-side path passed back as cover_path. Unit-tested via the handler
directly (no main+torch import).
Frontend:
- api/audiobook.ts: AudiobookGenerateBody (format/loudness/cover_path/metadata)
+ audiobookUploadCover(file).
- AudiobookTab: format select (M4B/MP3), loudness select (off/ACX/podcast,
default off), and a "Cover & details" panel — cover picker with preview +
title/author/narrator/year/genre/description. On create, the cover uploads
first, then the job runs with metadata + format + loudness.
- en.json: audiobook.* keys for the new controls.
Tests: tests/test_audiobook_cover.py (4) green; frontend vitest 326 green; prod
build clean; CJK + i18n-parity gates pass.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First slice of the Stories+Audiobook convergence (spec:
docs/specs/2026-06-13-stories-audiobook-maturity.md). Both features will compile
to one server-side chapterized renderer; this lands the shared pure builders and
wires them behind Audiobook.
New `backend/services/longform_render.py` (all pure, unit-tested without
ffmpeg/torch):
- build_ffmetadata(chapters, global_meta) — FFMETADATA1 with an optional global
tag block (title/author→artist/narrator→composer/year→date/genre/description→
comment) + chapter table.
- build_loudnorm_filter(preset) — `-af loudnorm` for ACX (~-19 LUFS, -3 dBTP) or
podcast (-16 LUFS); off/unknown → None. Opt-in, so default behavior stays
platform-identical.
- validate_cover_image — jpg/png + 8 MB cap guard.
- build_render_cmd — generalizes the m4b mux: m4b|mp3, optional cover
(attached_pic) + loudness, bitrate validated.
- build_concat_list — moved here.
`services/audiobook.py`: build_chapter_ffmetadata / build_m4b_cmd / build_concat_list
are now backward-compatible wrappers over the core (existing imports + tests
unchanged).
`POST /audiobook`: now accepts optional `format` (m4b|mp3), `loudness`,
`cover_path`, and `metadata` and passes them through — backend-complete; the UI
for these lands in PR 2.
Tests: tests/test_longform_render.py (28) + existing test_audiobook.py (11) green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CSS `zoom` is honoured by Chromium (the macOS/Windows webview) but IGNORED by
WebKitGTK (the Linux webview). The shell sized itself to `100vw/scale` ×
`100vh/scale` expecting `zoom` to magnify it back to full size; on Linux the
magnification never happened, so at the default uiScale of 1.3 the whole app
rendered at 1/1.3 ≈ 77% of the window, leaving black bands on the right and
bottom (a cross-platform default-parity P0 — 1.3 ships out of the box).
Switch to `transform: scale(var(--ui-scale))` + `transform-origin: top left`,
which scales identically on every engine and doesn't alter how vw/vh resolve,
so `declared (100vw/scale) × scale` fills the viewport exactly. Drop the inline
`zoom` (keep setting the `--ui-scale` CSS var the transform reads).
Verified on the real WebKitGTK webview (Tauri debug build, localStorage
uiScale=1.3): shell now fills edge-to-edge — header, content, and logs footer
all reach the window edges; no black bands.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the dynamic engine load/unload slice. The idle reaper (#401) frees
sidecar VRAM after 5 min; this adds a user-initiated "free VRAM now" path so
multi-engine users don't have to wait:
- subprocess_backend: `list_live_sidecars()`, `unload_sidecar(id)`,
`unload_all_sidecars()` via a shared `_force_reap(predicate)` — busy-guarded
exactly like the idle reaper (non-blocking lock; a sidecar mid-synth is
skipped, never interrupted; next request respawns it).
- system.py: `/model/loaded` now surfaces live sidecars as unloadable rows;
`/model/unload/{sidecar:<id>|sidecars}` frees one or all. The existing
generic flush panel picks these up with zero frontend change.
Also refresh CLAUDE.md stale version notes: main is 0.3.6 (latest release
v0.3.5 + 1 patch); the v0.3.0-as-unreleased framing in the project/cadence
notes is corrected to the v0.3.x continuous-to-main reality.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The persona-gallery surface already exists (VoiceGallery Community zone +
community.py manifest + marketplace .omnivoice bundles). The blocker for §R3's
'synthetic-only' gate was data integrity: a *designed* persona lost its
kind='design' (and vd_states) when imported from the community gallery or
round-tripped through a bundle — silently demoting it to a clone.
- community.py /use: a 'preset' (rendered from instruct) imports as
kind='design'; a 'voice' (real reference clip) as 'clone'.
- marketplace.py: extract a pure _bundle_metadata() (dedupes export+publish)
that captures kind + vd_states; import restores them. Old bundles without
the keys import as 'clone' (backward-compatible).
This makes 'accept only designed/synthetic voices' enforceable instead of
everything defaulting to clone. No new persona-gallery feature was built — that
would duplicate the existing community/marketplace surface.
4 torch-free tests (isolated DB): _bundle_metadata captures design + defaults
to clone; import round-trip preserves design kind+vd_states; legacy bundle →
clone. docs §R3 status updated.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Frontend for the audiobook backend (#402/#403): a dedicated Audiobook tab.
- pages/AudiobookTab.jsx: script textarea + default-voice picker (reuses the
app's profiles), 'Preview plan' (POST /audiobook/plan → chapter list) and
'Create' (POST /audiobook → reads the SSE stream, shows per-chapter progress
+ assembling, then an <audio> player + m4b download via the /audio mount).
- api/audiobook.ts: typed plan() + generate() (returns the raw streaming
Response).
- utils/sseParse.js: pure splitSSEBuffer/parseSSELine helpers for reading the
POST event-stream (EventSource is GET-only) — unit-tested (the buffer/line
handling is the easy thing to get subtly wrong).
- NavRail + App.jsx wiring (lazy tab, hideSidebar); i18n keys in en.json.
All strings via i18n (CJK gate green). 7 new SSE tests; full vitest 326 +
vite build green. Runtime-unverifiable here (Tauri webview) — wants an in-app
pass. docs §R3 updated.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Completes the audiobook backend: POST /audiobook renders each chapter through
the active TTS engine (synthesize_chapter + chunked_tts), writes per-chapter
WAVs, then muxes a chapterized m4b (FFMETADATA1 chapters via build_m4b_cmd +
concat demuxer). Progress streams as SSE (started/chapter/assembling/done/
error), recorded to job_store. ffmpeg-gated — emits an error event and stops
when ffmpeg is absent (m4b is the only output).
- services/audiobook.build_concat_list: pure ffmpeg concat-list builder with
proper single-quote escaping (no arg injection). Unit-tested.
- router: voice resolution (compact form of generation.py's locked/design/
clone cases) cached per id; OmniVoice native model path + generic TTSBackend
path; chapter synthesis runs on the GPU pool, ffmpeg via run_ffmpeg.
Reuses the tested building blocks from #402 (parser, synthesize_chapter,
FFMETADATA + m4b argv builders) — the new router glue is thin and
import-checked by CI. Deferred: epub/pdf ingest, ACX loudnorm mastering,
crash-resume, UI. 15 audiobook tests (added concat-list); docs §R3 updated.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(audiobook): chapterized audiobook core + plan preview (Wave 5)
First cut of the long-form vertical (parity §R3). Engine-agnostic core in
services/audiobook.py:
- parse_audiobook_script: pure parser. Markdown '# H1' headings → chapters;
inline [voice:NAME] switches the narrator; [pause …] is delegated to the
shared omnivoice.utils.text.parse_pause_markers so audiobooks and single-shot
synthesis keep one pause dialect. Returns a chapter/span plan.
- synthesize_chapter: orchestration via an injected synth(text, voice) callable
(reuses chunked_tts split + crossfade, stitches inter-span silence) — so it's
unit-testable with a stub backend, no model/GPU.
- build_chapter_ffmetadata + build_m4b_cmd: pure FFMETADATA1 [CHAPTER] builder
and faststart-m4b concat-demux argv (bitrate-validated, no injection).
POST /audiobook/plan returns the parsed plan (no TTS/ffmpeg, no side effects).
Deferred (follow-ups): the streaming synth job + chapterized-m4b run, epub/pdf
ingest (new dep), ACX loudnorm mastering, crash-resume, UI.
14 tests: parser (chapters/voice/pause/intro/empties/to_dict), FFMETADATA
offsets+escaping, m4b argv + bitrate guard, and stub-synth orchestration
(span+silence stitching, voice threading). docs §R3 status updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(audiobook): linear-time regexes (CodeQL ReDoS)
CodeQL flagged polynomial backtracking on user-provided input in three
regexes reachable from the new POST /audiobook/plan endpoint:
- _VOICE_RE: \s*(...)\s* → single [^\]]* class, stripped in code.
- _HEADING_RE: trailing [ \t]* removed; title captured greedily + stripped.
- _PAUSE_RE (omnivoice/utils/text.py): the numeric spec is now an atomic
group (?>…) so its leading \s+ can't backtrack against the trailing \s*.
Behavior-preserving (Python >=3.11 already required); 14 pause tests + 14
audiobook tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(audiobook): require non-space heading title start (CodeQL ReDoS)
The previous _HEADING_RE '[ \t]+(.+)' still let the leading whitespace class
and the title '.+' both match the same tab run (overlap → polynomial). Anchor
the title capture with \S so the two can't overlap. 14 audiobook tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(audiobook): exclude '[' from voice-tag content (CodeQL ReDoS)
[^\]]* still matched '[', so a run of nested [voice: prefixes produced
overlapping finditer match attempts → O(n^2). Excluding both brackets
([^\]\[]) makes matches non-overlapping and linear. A voice name never
contains a bracket. 14 audiobook tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Parity Action 13 (dynamic load/unload), subprocess-engine half. A subprocess
engine's sidecar holds a process — and, for GPU engines, VRAM — for the life
of the backend, even after the user switches engines. The default in-process
OmniVoice model already idle-unloads (model_manager.idle_worker); this gives
the subprocess engine class the same treatment.
subprocess_backend gains a background reaper (lazy daemon thread, started on
first spawn) that shuts down sidecars idle past OMNIVOICE_SIDECAR_IDLE_TIMEOUT_S
(default 300 s; <= 0 disables). The next request transparently respawns one via
the existing dead-process relaunch. Safety: the reaper only acts while holding
the per-backend lock acquired NON-blockingly, so it can never run mid-op — if
an op holds the lock it skips that backend this round. Reuses the idempotent
shutdown() (which doesn't take the lock, so no re-entrancy). Each backend tracks
last-use and registers in a weak live-set.
Scope: subprocess engines only (the heavy, VRAM-holding, process-isolated
class). In-process non-default engines and cross-engine VRAM preemption remain
TODO — get_active_tts_backend returns a fresh instance per call, so those need
an instance-tracking refactor.
6 reaper tests via the stdlib echo sidecar (no torch): kills idle, respawns,
skips busy (lock held), recent-use kept, disabled at <=0, ignores dead. The 3
subprocess suites pass together (24).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Completes Action 8: dictate-over-playback echo cancellation now works
end-to-end, gated behind a new off-by-default 'aecEnabled' pref so the
standard dictation + playback paths are untouched when off.
- utils/aec/{pcm,farEndBus,micCapture,playbackTap}.js + public/aec-worklet.js:
AudioWorklet captures the mic as raw int16 PCM; a player tap routes playback
output through Web Audio to a singleton far-end bus. Pure framing/encode
helpers are unit-tested.
- CaptureWidget: when aecEnabled, opens /ws/transcribe?aec=1, streams tagged
PCM (0x00 mic / 0x01 far-end) instead of MediaRecorder/WebM. Default path
unchanged; no POST fallback in AEC mode (the WS is the sole channel).
- WaveformPlayer: while actually playing AND aecEnabled, taps its decoded
output as the echo reference. Gated on isPlaying so only the one active
player holds an AudioContext (well under the browser cap); audio stays
audible (source always reconnected to destination).
- Settings → Capture: AecPanel toggle. prefsSlice: aecEnabled (persisted).
Runtime-unverifiable here (jsdom has no Web Audio); needs in-app testing in
the Tauri shell. 7 new pure-helper tests; full vitest (319) + vite build green.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(dictation): opt-in NLMS AEC for dictate-over-playback (Wave 8b)
Dictating while OmniVoice plays audio (TTS preview, dub, video) leaks the
loudspeaker signal into the mic, and the streaming ASR transcribes that
bleed. Browser echoCancellation varies per platform/webview — it can't be a
cross-platform default — so this adds a server-side canceller that behaves
identically everywhere.
services/aec.py ports Patter's NlmsEchoCanceller (MIT): a time-domain NLMS
adaptive filter with a Geigel double-talk detector, warm-up step ramp, and
far-end staleness pass-through. /ws/transcribe gains an opt-in '?aec=1[&sr=]'
mode: frames are raw int16 mono PCM tagged with a 1-byte prefix (0x00 mic,
0x01 playback reference); the mic is cleaned against the reference before
buffering, and the cleaned PCM is muxed via stdlib wave (not ffmpeg). Without
the param the protocol and behaviour are byte-for-byte unchanged.
Backend ships dark (no new deps — numpy already pinned); frontend far-end
streaming is a follow-up. Tests cover echo attenuation, double-talk
preservation, cold/stale pass-through, param validation, and the framing
helpers — all pure-numpy/stdlib so they skip the torch ASR stack.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(capture_ws): stubs accept the new pcm_sr kwarg
_transcribe_buffer/_transcribe_buffer_full gained an optional pcm_sr kwarg
for the AEC PCM path; the protocol-test stubs had fixed signatures and
raised TypeError on it, so the handler sent 'error' instead of 'final'.
Accept **kw in the stubs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(asr): crash-isolated faster-whisper subprocess backend (Wave 4.2)
Native ASR engines (faster-whisper / CTranslate2) can segfault on GPU
teardown — a process-level crash that kills the whole backend. Running the
engine in a child process turns that into a failed job: the sidecar dies,
the parent raises a decorated error (engine id + device), and the next
request respawns a fresh sidecar.
- services/subprocess_asr.py: SubprocessASRBackend reuses
SubprocessBackend's wire protocol + lifecycle — including
respawn-on-dead-process (_spawn relaunches when the child isn't alive) and
GPU-slot acquire/release — adding a 'transcribe' op (the TTS 'generate'
surface is stubbed). IsolatedFasterWhisperBackend wraps faster-whisper
using the PARENT venv (already a dep — only the process boundary is new);
opt-in via OMNIVOICE_ASR_BACKEND=faster-whisper-isolated.
- engines/_asr_sidecar/main.py: the faster-whisper runner (stdlib wire
protocol; torch/CT2 import lazily so the ready handshake fits the timeout).
- engines/_echo/main.py: a 'transcribe' echo op so the round-trip + crash
recovery are testable without a real engine.
- asr_backend._REGISTRY is now a lazy dict (mirrors the TTS registry) so the
isolated backend lists/resolves without importing the subprocess stack
unless selected.
Tests (echo sidecar, stdlib-only): round-trip, single long-lived sidecar
across calls, crash-mid-transcribe → decorated error + backend healthy +
next call respawns, registry exposure, generate-not-supported.
Spec 7 / parity program Wave 4.2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(asr): deterministic crash test + drift marker for lazy ASR registry (Wave 4.2 CI)
CI surfaced two issues:
- The echo crash test relied on the crash-AFTER-reply hook, whose reply
may still reach the parent (timing-dependent) — and a leaked
OMNIVOICE_ECHO_CRASH from a sibling subprocess test poisoned the
non-crash tests. Fix: a deterministic OMNIVOICE_ECHO_CRASH_NO_REPLY hook
that exits BEFORE replying (guaranteed dead pipe → decorated error), and
the asr fixture clears both crash envs so the round-trip/two-call tests
can't inherit a leak.
- check-docs-drift's _ASR_MARKER didn't match the new lazy registry line
(_LazyASRRegistry({); updated the marker + the self-test fixture.
Verified the no-reply crash hook by driving the sidecar directly
(reply=None, exit 1); drift self-test + real-repo check green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(asr): allowlist the 'segments' op so transcribe replies aren't dropped (Wave 4.2 CI)
The parent's PARENT_INBOUND_OPS frozenset gated inbound sidecar frames but
never included 'segments' — the ASR transcribe reply op. _recv() dropped the
frame as disallowed, tail-recursed, hit EOF, and returned None, so every
transcribe surfaced as a bogus 'sidecar crashed mid-transcription'. TTS
('audio') was allowlisted; ASR ('segments') was missed. Add it (and list
'transcribe' in the informational SIDECAR_INBOUND_OPS), update the exact-shape
allowlist test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The 2-column sidebar-hidden template still sent the nav rail to
grid-column 3 — it overflowed into an implicit column and the reserved
48px slot rendered as a dead black band beside it. Rail now maps to
column 2 under that combo (and history-panel to column 1 under
rail-right+collapsed). Verified in WebKit: main/footer edges meet the
rail exactly.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
- category chips collapse behind an 'Identity' recipe line (male · elderly
· …) that the describe box rewrites live; all-Auto starts expanded
- right rail leads with an ACTIVE VOICE card: name, kind badge, recipe,
identity sample player, + New; empty card carries verbs
- empty saved-voices states point at the action ('Describe one in Voice ←')
- script column stacks naturally (no void before VOICE)
Spec: docs/specs/voice-console-10x.md §1.5, §2.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
P1 (fold): language, steps, and the overrides disclosure move into a
pinned action bar with SYNTHESIZE — the primary CTA is visible at every
window size (verified 1280×720 and 1400×900 in WebKit); Cmd/Ctrl+Enter
synthesizes from anywhere; overrides expand upward above the bar.
P2 (hierarchy/consistency): two kickers only (SCRIPT, VOICE — method
toggle inline); the four redundant headers removed; the old PROMPT preset
chips merge with personalities into one edge-faded scrollable 'Starting
points' lane; the 14-chip tag wall becomes a ⊕ Insert popover at the
script corner (click-outside dismiss).
Spec: docs/specs/voice-console-10x.md §1.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The model manager already lists/deletes cached models; this adds the
remaining high-value slice — an in-app HF mirror setting so users behind
restricted networks (e.g. the Great Firewall) can route downloads through
hf-mirror.com or any HF_ENDPOINT. Persisted to the durable per-user env
(survives Tauri/Finder launches); HF reads HF_ENDPOINT at import, so the
override applies on restart (surfaced in the UI).
- GET/PUT /api/settings/hf-mirror (loopback-gated): presets (official +
hf-mirror.com), http(s) validation, empty clears to official.
- Models-tab panel with quick-picks + free-text field + restart note.
(Skipped 'hf cache verify' — version-fragile across huggingface_hub
releases and low value vs the mirror, which the China/Russia network
research flagged as the real gap.)
3 endpoint tests (default, set+trim+clear, non-http rejection).
Spec §R4(c) / parity program Wave 4.3.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
MLXWhisperBackend / MLXAudioBackend is_available() caught only ImportError.
In a PyInstaller bundle mlx's native dylib/metallib can fail to load even
when the package imports, raising OSError/RuntimeError — which would
propagate and crash the registry scan instead of reporting the backend
unavailable. Broaden to (ImportError, OSError, RuntimeError) so the picker
falls back cleanly. 6 tests across all three exception types.
The capture ASR path already prefers MLX Turbo on Apple Silicon
(get_capture_asr_backend), so this hardening is the remaining slice of
Spec 6 / Wave 4.4.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The old max-height:3em guillotined the third line mid-glyph. Now a true
-webkit-line-clamp with ellipsis, leading [tag] control tokens stripped
from the display (full text stays in the tooltip and restore flows), and
clicking the title toggles the full prompt.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Explain why dedicated-venv engines (IndexTTS2) add disk (a second torch +
CUDA libs: Linux cu128 ~0.83 GiB, Windows ~3.2 GiB), and how uv's link-mode
dedup (clone on macOS/Linux, hardlink on Windows) shares identical wheels
for free — provided UV_CACHE_DIR and the venvs are on the same filesystem.
Key policy: pin the same torch build as the parent whenever the engine
allows, since only identical wheels dedupe; UV_LINK_MODE=hardlink on Linux
ext4. Linked from the IndexTTS engine doc.
Spec §R4(a) / parity program Wave 4.5.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Link the published Docker Hub repo (https://hub.docker.com/r/palashdeb/
omnivoice-studio) as an official image alongside GHCR in the README install
list and docker.md header. Same images, same tags.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Each batch now uses input seeking (-ss before -i, frame-accurate under
re-encode) plus a bounded read (-t window+0.5s), with chunk times shifted
into window-relative coordinates — long-video Smart Fit exports drop from
O(n²) decode cost to O(n).
Fixes#382
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Canonical English added from the t() defaultValues introduced by the
overhaul PRs (#374-#381); 20 parallel translation passes added each key
to every locale (placeholders, product names, and existing per-locale
terminology preserved). All locale files parse; CJK gate + 312 frontend
tests green.
Fixes#383
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
- #380: vite:preloadError (old hashed assets after an update) triggers a
one-time reload to pick up the fresh manifest; session flag prevents loops
- #284: check_device_compatibility's warning (e.g. Blackwell sm_120 on a
pre-cu128 torch) now appears in the notification panel as an error with
the pip fix — a log line never reached affected users while synthesis
silently produced noise. Cached once per process.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Live-debugged in Playwright WebKit with a pause() stack hook: the media
'play' event fired twice (a stale WaveSurfer instance's listeners survive
a destroy() that throws mid-teardown under StrictMode double-mount), so
the second claimPlayback stopped the current owner — this very element.
play → instant self-pause → 'click does nothing'.
- 'play' handler only claims when it doesn't already own the slot
- per-instance stale flag inert-izes leaked handlers
- cleanup detaches handlers (unAll) BEFORE destroy so a throwing destroy
can't leak them
Verified in WebKit: paused=false, currentTime advancing, 0 stray pause calls.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
- #373: drop will-change:transform on the segment lane (persistent
compositor layer made the semi-transparent boxes vanish during
playback/drag on some Windows GPUs) + raise region alpha 0.30→0.45
- #352: validate a finished snapshot actually contains weights (>5 MB
file) so interrupted downloads fail at install time with a re-download
hint; loader translates the opaque transformers error into the same
guidance
- GET /history prunes rows whose audio file is gone instead of serving
dead 404 players forever
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
With an external `media`, wavesurfer's `url` option only fetches for peak
decoding and never assigns the element's src — so the waveform drew but
play() had nothing to play. Set src on the in-DOM <audio> via JSX (same
pattern as WaveformTimeline) and stop passing `url`. Also surface
playPause() rejections instead of swallowing them.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
One 'studio' navigation mode replaces the clone/design pair; the split
lives on as a 'Define voice' toggle (From audio / By design) at the top
of Voice Source. Selecting a saved profile sets the method from its kind.
- uiSlice: AppMode + 'studio'; defineMethod ('audio'|'design') persisted
- legacy shims: localStorage mode + restoreHistory map clone/design →
studio + method; history mode VALUES unchanged
- NavRail/Header: single Voice entry (Fingerprint, #d3869b)
- CloneDesignTab/WorkspaceVoices/useTTS/useProfiles/Gallery/Launchpad/
Sidebar: definition-method semantics moved off the navigation mode
Build clean; 312/312 tests; tsc clean; no setMode('clone'|'design') left.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(dub): break the dialect↔cinematic guidance loop (#372, #373)
- Cinematic toggle refuses the pick when no LLM endpoint is configured,
pointing at Settings → Credentials → LLM endpoint
- backend Fast fallback now syncs the quality toggle to 'fast'
- the dialect warning no longer fires alongside the cinematic-no-LLM
warning (the pair formed the loop), and both messages point at the
LLM endpoint settings instead of each other
Fixes#372
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ffmpeg): validate resolved ffmpeg/ffprobe actually runs — fall through on WinError 193 (#360, #361, #362)
A corrupt or wrong-arch imageio-ffmpeg download (and WindowsApps alias
stubs) passes os.path.isfile/shutil.which but explodes at spawn with
'[WinError 193] %1 is not a valid Win32 application', killing
transcription with an opaque 500. Every resolution step now probes the
candidate with '-version' (cached per process), logs the rejected
basename, and falls through to the next source.
Fixes#362Fixes#361Fixes#360
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Migration 0005_unified_profiles (0004 taken by mcp bindings):
- voice_profiles.kind TEXT DEFAULT 'clone' ('clone' | 'design'), backfilled
- voice_profiles.vd_states TEXT NULL — JSON of design category picks
- mirrored in _BASE_SCHEMA; idempotent _has_column guards; downgrade drops
POST /profiles:
- ref_audio now optional; kind + vd_states form fields with validation
(clone requires audio; design requires vd_states JSON object + instruct)
- design profiles render a deterministic identity sample (seed 42) through
the shared archetype renderer — one TTS code path
POST /generate:
- profile resolution branches on profile.kind (authoritative) instead of
the brittle is_locked/instruct inference; legacy pre-0005 rows keep the
old inference as fallback; history.mode records profile.kind
Frontend:
- 'Save design as profile' in the Design tab (vd_states + buildDesignInstruct)
- selecting a design profile restores its sliders (vd_states) for re-editing
Also unforks the alembic chain (0004_mcp + my 0004 both revised 0003 →
multiple heads broke alembic upgrade head and the 0003 migration tests).
Tests: tests/test_profile_unification.py — validation, design-create with
mocked renderer, migration up/backfill/downgrade. 18/18 profile tests,
312/312 frontend, related backend suite green.
Note: docs/specs/voice-studio-unification.md (on feat/studio-ux-overhaul)
still says 0004 — renumber to 0005 when branches meet.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(studio): workspace UX overhaul — right-side panels, shared waveform player, dub pipeline UX, setup polish, UI-wide fixes
Voice workspace (specs: docs/specs/voice-studio-unification.md, workspace-connectivity.md):
- Right-side panels replace the left sidebar for clone/design and dub:
WorkspaceVoices (saved profiles), WorkspaceHistory (scoped history with
All/Clone/Design filters), WorkspaceProjects (dub projects)
- Prompt restacked over Voice Source in one definition column (spec §1)
- Gallery "Use voice" now hands off via pendingProfileId and lands in clone
- Shared <WaveformPlayer> (wavesurfer + in-DOM media element for Tauri
WebKit, blob routing via preview endpoint, 404 -> "audio file missing")
replaces every bare <audio controls>; lazy-mounted via IntersectionObserver
Dub:
- Pipeline stepper (Upload -> Prepare -> Transcribe -> Edit -> Generate -> Export)
- Multi-language preview switcher pills (Original + per-track, ElevenLabs-style)
- Batch multi-language generation via langOverride loop
- FloatingPill: bottom-center, suppressed on its homeMode tab (no dup progress)
- Transcript skeleton shimmer (no fake data), progress overlays the video,
exports demoted behind Generate, empty right-panels collapse
Chrome/layout:
- Nav rail is full-window-height; content yields to the logs footer via
padding-bottom; footer joins the rail edge (no overlap at any UI scale)
- UI scale 60–175% slider with zoom-compensated container sizing
- LogsFooter: merged single Logs tab when collapsed, per-source tabs on
expand; Updates chip lives with the logs tabs
- Gallery: three independently scrollable filter lanes, uniform 26px controls
- Font picker as live-preview grid; double-click titlebar maximize fixed
(single mousedown detail-2 handler)
First-run:
- Setup wizard: pinned action row + scrollable content at every window size,
one-line head-ellipsized paths, height budget for short windows, library
rows back to one-line grammar, raw i18n key + duplicate host fixed
Performance/i18n/consistency sweep (10-agent scan, 47 fixes):
- i18n locales lazy-loaded per language (i18n chunk 1.84 MB -> 76 kB)
- Undefined CSS vars replaced with real tokens across 8 stylesheets;
hardcoded hexes tokenized; emoji swept to lucide icons app-wide
- Poll throttling (sysinfo subscription scoped to Header, logs 45s when
collapsed, rAF only during playback), hardcoded strings moved to t()
Build clean; 312/312 tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(studio): re-flow clone/design columns (grid rows collapsed in restack) + strip placeholder emoji across locales
The base .studio-column grid (minmax(0,1fr) rows) collapsed to 0 height
inside the new auto-height definition column, overlapping every panel in
design mode — found via Playwright visual pass. Columns now re-flow as
natural-height flex stacks. Also removed the leftover pencil emoji from
clone.prompt_placeholder in all 21 locales.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(design): compact the design control stack — 2-up facet selects, scrollable tag row, tighter rhythm
English accent + Chinese dialect dropdowns share one row (full-width on
narrow), insertable tag chips collapse from three wrapped rows to one
scrollable line, and describe/personality spacing tightens — the whole
design stack now fits a single viewport.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(spec): unification migration renumbered 0004 — upstream 0003 is voice-profile consent
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ci): clear hardcoded-CJK gate — ASCII '+' in spec wireframes, reword voiceIcons comment
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(spec): migration is 0005 — 0004 taken by mcp bindings upstream
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Push the same images (same tag set: :latest rolling main, :stable/:X.Y.Z
releases, :sha-) to docker.io/palashdeb/omnivoice-studio alongside GHCR.
Gated on DOCKERHUB_USERNAME/DOCKERHUB_TOKEN secrets — without them the
build still publishes to GHCR only. Docs-sync: docker.md mirror note.
Requires repo secrets: DOCKERHUB_USERNAME, DOCKERHUB_TOKEN.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(dub): Smart Fit phase B — per-segment video retime export, drift absorption, fitted subtitles
Executes the video side of the Smart Fit plans persisted by Phase A
(job["fit_plans"], #347) at export and preview time.
Backend:
- services/video_retime.py (new, clean-room): two-tier retime executor.
≤48 chunks → the proven single-pass split/trim/setpts/concat
filter_complex; above → batches of 40 chunks rendered to intermediate
slices (identical libx264 medium/crf20 params, keyframe at t=0) joined
losslessly with the concat demuxer. Slices are CFR-resampled (fps=)
because setpts leaves VFR-ish timestamps that broke tpad and drifted a
frame per retimed chunk on ffmpeg 7.x. Temp slices cleaned on success
AND failure/abort.
- Drift absorption: fitted track longer than retimed video → freeze-frame
tail (tpad=stop_mode=clone) predicted into the last slice / single-pass
graph, with residual mux-side tpad; video longer → silence-pad the dub
audio chain (apad=whole_dur). ±50 ms tolerance.
- VFR guard: probe r_frame_rate vs avg_frame_rate; normalise with fps=
before trim/setpts; probe failure degrades gracefully.
- Plan resolution: _video_retime_plan_for spans legacy video_stretch_plans
(byte-identical resolution + command construction) and fit_plans, gated
on the track's own timing_strategy so stale plans never retime a track
re-generated under another strategy.
- Fitted subtitles: /dub/srt + /dub/vtt accept ?lang= and serve cue times
from fitted_segments for Smart Fit tracks; _write_burn_srt does the
same for burn-in. burn_subs+retime is now allowed for smart_fit (burn
runs AFTER the retime graph); still rejected for legacy stretch_video.
- /dub/preview-video resolves the same plan so in-app preview matches
export.
- Fallback ladder: batch encode failure/timeouts → un-retimed export with
a structured core.failure warning (X-Dub-Export-Warning header +
job["last_export_warning"]); concat join rejection → one single-pass
retry while ≤96 chunks; abort → 409 + proc kill via run_ffmpeg job_id
registration (/dub/abort reaches export encodes now) + temp cleanup.
Frontend:
- Export drawer passes ?lang= on subtitle exports and shows an i18n'd
re-encode cost note (~0.5–2× video length on CPU) when a retiming
strategy is active — translated in all 21 locales.
Tests: tests/test_smart_fit_export.py — plan resolution, batch math,
graph parity + new stages, fitted-cue SRT/VTT/burn selection, burn
policy, VFR detection; ffmpeg-gated integration renders both executor
tiers (batch size forced to 2) and the real /dub/download endpoint,
ffprobing durations within ±50 ms across both pad branches. All existing
dub export/subtitle/preview/timing tests pass unchanged.
Refs docs/competitive-analysis.md Action 1 (dub-length fitting v2);
completes Smart Fit (Phase A = #347).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(security): sanitize Smart Fit retime work paths at every sink (CodeQL py/path-injection)
The job_id-derived retime work path (retimed_*.mp4 / preview_retimed_*.tmp.mp4)
flowed unguarded from dub_export into prepare_smart_fit_video /
render_retimed_video and their derived slice/concat paths and ffmpeg argv.
Apply the repo's proven inline realpath+startswith containment pattern
(helpers/commonpath are not recognized — see #309/#328/#329/#348):
- dub_export.py: validate work_path against DUB_DIR at both construction
sites (export + preview) and pass the validated realpath onward.
- video_retime.py: make both entry points self-defending — realpath +
DUB_DIR containment on out_path/work_path before any derivation, raising
RetimeError(stage="plan") on escape; slices_dir/slice_path/list_path and
RetimeDecision.file_path now all derive from the sanitized value. DUB_DIR
is read via module attribute so test fixtures reloading core.config work.
- ffmpeg_utils.py: document that all caller-assembled argv paths are
realpath-validated upstream.
- tests: sandbox DUB_DIR in the executor integration tests (tmp_path) so
the new guard sees the test workspace.
No behavior change for valid (server-built) paths — the guard only fires
on traversal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(smart-fit): patch DUB_DIR on video_retime's own config ref — survives suite-wide reload
The retime guard reads video_retime._config.DUB_DIR at call time; the
sandbox fixture patched a fresh 'import core.config' instead. Another
test reloads core.config in the full suite, so the two module refs
diverged — the patch missed and the guard rejected the test's tmp paths
(green in isolation, red in CI's full run). Patch the exact ref the
guard dereferences.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dub): resolve DUB_DIR live at call time in retime guards — survive full-suite reload
The path-containment guards bound DUB_DIR via a module-level
'from core import config as _config'. Other tests importlib.reload()
core.config (sandboxing OMNIVOICE_DATA_DIR), after which the guard
checked containment against a stale DUB_DIR while dub_export built the
path under the reloaded one — every retime path then 'escaped the dub
workspace' (green file-alone, red full-suite: the 5 integration
failures CI hit). Re-import DUB_DIR locally in each guard so it always
reads the current sys.modules value; simplify the sandbox fixture to
patch the canonical module. Verified: full backend suite green on the
Smart Fit tests (the 2 remaining settings_store failures are
pre-existing on main, unrelated — local data-dir artifact).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(security): clear CodeQL alerts on Smart Fit export — job_id allowlist, proc-registry decouple
- py/path-injection (8, video_retime.py): validate job_id with a strict
inline regex allowlist (re.fullmatch [A-Za-z0-9_-]{1,64}) at the entry
of dub_download and dub_preview_video, before it reaches any filesystem
path or ffmpeg argv. The existing realpath containment guards stay as
defense-in-depth; the regex barrier is the sanitizer CodeQL recognizes
through the service-module call chain.
- py/log-injection (4): newline-strip job_id inline at the logger calls
in ffmpeg_utils.run_ffmpeg and the two retime-fallback logger.error
sites in dub_export.
- py/empty-except (3): best-effort cleanup os.remove handlers now log
the OSError at debug instead of bare pass (video_retime + both
dub_export mux finally blocks; _discard_tmp too for consistency).
- py/cyclic-import (2): break the dub_pipeline <-> ffmpeg_utils cycle
for real — the subprocess registry (register_proc/unregister_proc/
kill_job_procs/has_active_procs + state) moves to a new stdlib-only
leaf module services/proc_registry.py. ffmpeg_utils now imports it at
module top (no lazy import); dub_pipeline re-exports every name so
dub_core aliases and tests keep working unchanged.
No behavior change for valid inputs; invalid job ids now get a clean
400 instead of a 404/containment error.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dub): address #350 review — cancelled-vs-failed retime, logged best-effort excepts, redacted probe logs, narrowed test assert
- rc<0 (killed by user cancel) now raises RetimeError(stage='aborted')
instead of reporting an ordinary render failure (CodeRabbit)
- best-effort cleanup/QC-event excepts log at debug instead of bare pass
(CodeQL empty-except x3)
- probe failure logs use basename, not full user paths (CodeRabbit/CodeQL)
- test_render_cleans_slices_on_failure asserts RetimeError, not Exception
Rebuttals (no change needed, see PR comment): fitted-cue subtitles track
the fitted AUDIO timeline which is correct even on retime fallback;
the planner only emits stretch ratios >1 so the early-exit guard is a
true no-op check; '\'' is ffmpeg's own utility quoting for concat lists;
has_active_procs is an intentional re-export (noqa'd).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Smart Fit Phase A (planner) + the export-side video retime + audio stretch
already shipped (#347 + dub_export stretch filter). The last piece of
Spec 1 was the subtitle timeline: under stretch_video the dubbed audio
plays at FITTED positions, but the standalone SRT/VTT export still used the
original segment times — so external subtitles drifted against the dubbed
video.
- services/fitted_subtitles.py (pure, tested): map_time_to_fitted() +
fitted_cues() remap original cue times onto the same per-chunk
{orig→new, stretch_ratio} plan the video stretch uses, with a
monotonicity guard.
- dub_export SRT + VTT endpoints: when a job used stretch_video, cues are
regenerated from the plan (subtitles track actual dub placement); no
plan → original times, unchanged. New optional ?lang= selects the track.
7 pure tests (chunk-bound mapping, linear interpolation, unit-rate tail,
fitted cues, monotonicity, empty-plan identity).
Spec 1 (remaining) / parity program Wave 3.1.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
After a dub is generated, re-recognize the synthetic audio and compare what
the ASR heard against what we asked the TTS to say. Lines that drift are
flagged for the user to re-listen / re-dub — turning subtitle timing and
pronunciation from trusted math into measured truth, and doubling as an
automatic dub-quality check.
Design delta from pyvideotrans (which lets recognized text REPLACE the
subtitles wholesale): we keep the generated text authoritative and use the
second pass only for MEASUREMENT — a per-line drift score + measured
start/end that feed the incremental re-dub loop, never silently overwriting
the translation.
- services/dub_qc.py (pure, tested): word_error_rate (normalized token edit
distance, case/punct-insensitive, script-agnostic) + score_dub (matches
recognized segments to dub segments by time overlap, concatenates the
hypothesis, scores drift, derives measured bounds).
- POST /dub/qc/{job_id}: runs the active ASR backend on the dubbed track in
the GPU pool, annotates each segment with qc_drift/qc_flagged/
qc_recognized/qc_measured_start-end (non-destructive — content untouched),
persists, emits a qc_done job event. Opt-in, never fatal.
- Frontend: dubQc() API fn + a red 'Verify' badge on flagged segment rows
(en.json keys; other locales fall back).
12 pure scoring tests (identical/substitution/empty/no-overlap/multi-segment
matching/measured-timing); endpoint validated in CI.
Spec 5 / parity program Wave 3.3.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Cut each long-enough dub segment's clone reference from the isolated vocals
at that segment's own timestamps, so the dub of each line carries the
prosody/emotion of its source line — finer than one reference per speaker.
Reimplemented from the clean-room spec (pyvideotrans per-line ref idea); our
design delta is a quality floor with fallback.
- services/speaker_clone.py: extract_segment_refs() keyed by segment id;
reference transcript is the SOURCE text (text_original), since the vocals
slice is source-language audio. Floor at MIN_SEGMENT_REF_DURATION_S=3.0
(not the per-speaker 5.0, which most dialogue lines fall under) — shorter
lines are omitted and fall back to the per-speaker clone, so it's a strict
improvement, never a regression.
- dub_core: run extraction at transcribe (per_segment_refs query param,
default on), store job['segment_clones'], default each unassigned
segment's profile_id to 'auto-seg:{id}' when it has its own ref, else the
existing 'auto:{speaker}'. Forcing per-speaker (per_segment_refs=false)
is supported for long-form consistency.
- dub_generate _gen: resolve 'auto-seg:' from segment_clones, ahead of the
per-speaker 'auto:' path. profile_id is already a fingerprint field, so
flipping the mode re-dubs automatically (no _GEN_INPUT_FIELDS change).
7 pure tests over a synthetic vocals wav (own-ref for long lines,
short-line omission/fallback, source-text transcript, bounds clamping,
floor boundary). Pipeline wiring validated in CI.
Spec 4 / parity program Wave 3.2.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(mcp): MCP server v1 — mount on /mcp, per-agent voice binding, stdio shim (Wave 2.2)
The FastMCP server (previously dead code, never mounted) is now mounted on
the main FastAPI app at /mcp via Streamable HTTP, with its session manager
composed into the app lifespan through an AsyncExitStack (best-effort: a
missing mcp package or OMNIVOICE_MCP_DISABLE=1 never breaks startup).
streamable_http_path set to '/' so the sub-mount lands at /mcp, not
/mcp/mcp. Adds the 'mcp' dependency (1.27.x).
Per-agent voice binding (Spec 2 headline): each MCP client sends an
X-OmniVoice-Client-Id header; generate_speech resolves the voice as
explicit arg > the client's binding > global default > app default. New
mcp_client_bindings table (alembic 0004 + _BASE_SCHEMA, additive/idempotent),
services/mcp_bindings.py (CRUD + resolve_voice + best-effort last_seen),
and a loopback-gated REST router (/api/mcp/bindings) the Settings panel
drives.
New transcribe tool (base64 audio in, 200 MB cap). Stdio shim
(backend/mcp_shim, httpx-only, ported from voicebox MIT) proxies stdio
clients to the mounted endpoint and forwards OMNIVOICE_CLIENT_ID as the
binding header. Settings → Sharing gains an MCP bindings panel. Docs:
docs/mcp.md (both connection modes + binding REST) and docs/mcp.json
updated to the shim form.
Tests: bindings service + resolution precedence + migration up/down (pure,
run locally); REST CRUD + mount-not-404 + disable-flag (main-importing,
validated in CI). MCP build + mount + initialize handshake verified
out-of-band (no torch).
Spec: docs/competitive-analysis.md Spec 2 / parity program Wave 2.2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(mcp): assert /mcp mount via app.routes, not a lifespan client
The two main-importing mount tests ran the app lifespan, which now starts
the FastMCP session manager and binds asyncio queues to the test loop —
contaminating later lifespan-running tests ('bound to a different event
loop'). The mount happens at import time, so inspecting app.routes for the
/mcp Mount is the correct loop-free assertion. Same fix shape as the
Wave 0.2 consent tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(mcp): stop reload-main poisoning across the MCP test files
Root cause of the CI failure: the bindings REST fixture set
OMNIVOICE_MCP_DISABLE=1 and reloaded main but never restored it, so a
later 'from main import app' in test_mcp_mount saw /mcp un-mounted
({'/audio','/voice_audio'}). Reloading main mutates the shared module for
every subsequent test.
- REST fixture: drop the disable flag (the mount is harmless without a
lifespan), yield the client, and restore main (+ core.config/db) to the
default data dir in teardown so the global module is clean again.
- test_main_mounts_mcp_route: reload main with the disable flag cleared so
the assertion is independent of any earlier reload.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
A '>>>>>>>' marker from the #365 rebase was committed at the tail of the
LLM-endpoint block, making the module unparseable. Strip it; settings.py
parses clean and the endpoint tests pass.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
A focused Settings panel for the OpenAI-compatible LLM that powers
cinematic translate, glossary auto-extract, and dictation refinement
(Wave 2.1). Persistence reuses the existing TRANSLATE_BASE_URL /
TRANSLATE_MODEL / TRANSLATE_API_KEY env vars (already in system.py
PERSISTENT_KEYS, restored at startup), so llm_backend/translator
resolution is unchanged — vLLM is a verified drop-in, Ollama ignores the
key, vLLM/LM Studio require it.
- GET/PUT /api/settings/llm-endpoint (loopback-gated): read shape returns
base_url, model, masked key, and live availability; PUT treats a null
field as unchanged and an empty string as clear (so the key isn't wiped
by a base-url-only save). Key is masked to last-4 in the read path,
never echoed.
- Credentials-tab panel with one-click presets (Ollama/LM Studio/vLLM/
OpenAI), base URL + model + optional key fields, and a reachable/not
status badge.
6 endpoint tests (read shape, set+mask, null-unchanged, empty-clears,
local-url-no-key, short-key masking); availability assertions guarded on
openai being installed.
Spec: parity program Wave 2.4 / competitive-analysis §R2 rung 4.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Agentic v1: OmniVoice is a provider, not the orchestrator. Its existing
OpenAI-compatible API already serves everything pipecat/LiveKit need
(POST /v1/audio/speech with pcm/wav, voice-profile id, speed; default
24 kHz output matching pipecat's OpenAITTSService) — so this is docs + an
example + a contract test, no new endpoint.
- docs/agentic-voice.md: the provider recipe for pipecat (base_url to
:3900/v1) and LiveKit, the remote-backend note (bearer from 2.3), the
consent-locked-voice nudge (0.2), and an explicit telephony-is-deferred
scope box.
- examples/agentic/pipecat_minimal.py: lazy-import skeleton wiring the
OmniVoice STT/TTS services (importable without pipecat installed).
- tests/test_agentic_provider_contract.py: pins the /v1/audio/speech
request shape pipecat sends (pcm + wav formats, voice-profile passthrough,
speed) so the documented recipe can't silently break. Validated in CI.
Spec: Action 15 / §R1 v1 / parity program Wave 2.5.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Run inference on a remote GPU box, drive it from the desktop app — opt-in,
off by default (loopback-only is unchanged when no key is set).
Backend:
- BearerKeyMiddleware (main.py): when OMNIVOICE_API_KEY is set, every
non-loopback HTTP + WebSocket request must present it (Authorization:
Bearer, ?api_key=, or the ov_key cookie set on first auth). Pure ASGI
(no response buffering), loopback always bypasses, SPA shell stays
reachable. Constant-time compare, never logged.
- ws_remote_authorized() in dependencies; capture_ws lets a keyed
non-loopback client through its inline loopback guard (the thin-client
dictation case: mic local, GPU remote).
Frontend:
- api/client.ts: ov_backend_url (localStorage) is the top-precedence base
override; new wsUrl() derives ws scheme + host from the API base (not
window.location, which lies in the Tauri webview) and appends ?api_key.
apiFetch attaches the bearer header. Both WS call sites (dictation,
events) routed through wsUrl; the HTTP transcribe fallback through
apiFetch.
- Settings > Sharing > Remote backend panel: URL + key fields, a
test-connection probe against {url}/health, save-and-reload.
Docs: docs/remote-gpu.md — the Tailscale recipe (MagicDNS + Serve, never
Funnel, headscale note, plain-HTTP-is-sniffable warning, PIN-vs-key split).
Tests: 10 bearer-middleware cases (inert without env, loopback bypass,
401 without/pass with key via header+query, wrong key, shell exemption,
plain-ASGI guard, WS handshake reject/accept). Validated in CI.
Spec: parity program Wave 2.3 / competitive-analysis §R2 rungs 1-3.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Phase 2 of Spec 3, on top of Wave 1.1's deterministic collapse. Prompt
design ported from voicebox (MIT): 'text filter, not an assistant' base
instruction + three toggleable sections (smart_cleanup, self_correction,
preserve_technical) + 7 few-shot examples passed as STRUCTURED chat turns
(small local models echo inline examples). Runs through the user's own
Ollama/LM Studio/OpenAI-compat endpoint via llm_backend — new additive
chat_messages() on the adapter; chat() now delegates to it.
Pass-through is the contract: with no LLM configured (backend 'off'),
on any error/timeout, or on an empty reply, the raw transcript stands —
identical default behavior on every platform. Refinement runs off-thread
on FINALS only; the WS final dict gains optional refined_text and the
dictation pill pastes refined_text ?? text (raw kept in history).
Settings: GET/PUT /api/settings/dictation-refinement (loopback-gated,
persisted in the settings table) + a Capture-tab panel with the master
switch + per-flag toggles and a 'no LLM configured' hint.
15 new unit tests: prompt sections per flag, structured few-shot message
shape, and the full maybe_refine pass-through matrix (off backend,
disabled config, LLM failure, empty reply, empty input).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
pyvideotrans drives OmniVoice as a per-line clone backend (their
videotrans/tts/_omnivoice.py — being replaced upstream with a REST
integration against POST /generate). This contract suite pins the exact
multipart shape that integration sends (text + uploaded ref_audio +
ref_text + language name + num_step/guidance_scale/speed/denoise/
postprocess flags -> audio/wav with X-Audio-Duration) so a /generate
change that would silently break the 17.9k-star upstream fails our CI —
the engine-compat constraint extended to an external consumer.
Engine stubbed; validated in CI (local torch/Triton segfault on
main-importing tests, see project memory).
Spec 11 / parity program Wave 1.3 (our-repo half).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Ports Patter's SentenceChunker (MIT, attribution header) behavior-identical
— all 61 upstream golden parity scenarios ship as fixtures and pass,
including documented quirks (current_behavior xfail semantics mirrored from
their parity runner). Terminator tables carry functional CJK; file added to
the test_no_hardcoded_cjk allowlist per convention.
/ws/tts now splits the request into sentences and synthesizes each in turn,
streaming the first sentence's PCM while later sentences are still
generating — the time-to-first-audio win on multi-sentence input.
Single-sentence requests behave exactly like the old single-shot path;
'start' metadata still waits for the first generation so lazy-loading
engines report their true sample rate. Italian comma-decimal guard
hard-disables aggressive first-clause flush per upstream.
Spec 8a (docs/competitive-analysis.md) / parity program Wave 1.4.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Ports voicebox's chunked TTS (MIT, attribution header) with two deliberate
changes: the concat half is reworked for torch tensors (matching what our
inference helpers feed the effect chain, incl. multi-channel on the last
axis), and the sample rate comes from the engine's declared rate instead
of the first chunk (fixes a latent upstream bug).
Long text (> max_chunk_chars, default 800) splits at sentence boundaries
(abbreviation/decimal-aware, bracket tags atomic, fullwidth enders via
unicode escapes for the CJK gate) -> per-chunk generation with
deterministic seed variation (seed+i) -> linear crossfade join (default
50 ms, 0 = hard cut) -> effect chain + watermark once on the joined audio.
Wired into BOTH inference paths (OmniVoice-native _run_inference and the
engine-adapter _run_backend_inference) beside the existing [pause]
stitcher; [pause] inputs keep their dedicated path. Short text is
byte-for-byte the old single-shot path; max_chunk_chars=0 disables.
New /generate form params: max_chunk_chars (>=0, default 800),
crossfade_ms (0-1000, default 50).
Tests: 15 model-free unit tests (split priorities, abbreviation/decimal/
tag guards, crossfade math incl. multichannel + clamping) + 3 stubbed-
engine endpoint tests (long text fans out with no words lost, short text
single-shot, 0 disables). Endpoint tests validated in CI — this machine
has a pre-existing local torch/Triton segfault on any main-importing test.
Spec: voicebox deep dive 1 / parity program Wave 1.2 / #346
unlimited-length item.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>