* fix(openai-compat): reuse cached engine instances in _resolve_engine
The direct engine-ID path in /v1/audio/speech constructed a fresh
backend per request (return cls()). For SubprocessBackend engines that
meant: a new sidecar process, a full torch import and an engine model
reload on EVERY request (measured ~28s floor per pockettts request on
an M3 Pro), plus another atexit hook registration each time — exactly
what get_engine_instance_for()'s docstring warns against.
Route the explicit-ID path through the same cached-singleton seam the
active-engine path already uses. Unknown/unavailable IDs keep their
400s; tts-1/tts-1-hd and the OmniVoiceBackend special case are
unchanged.
* fix(openai-compat): unload the outgoing engine on explicit-ID switches
Review follow-up (Greptile/CodeRabbit on #1614): caching instances without
a switch rule would let each distinct explicit engine ID stay resident,
accumulating sidecars / multi-GB in-process models. Mirror
get_active_tts_backend's MM2-01 switch rule: a different explicit ID
(omnivoice included, which resolves to the active engine) unloads the
outgoing instance first, best-effort.
* fix(openai-compat): evict via the shared single-engine-resident seam, not a router-local cache
The explicit-ID unload cache (13c14e2c) kept its own instance ref keyed by
model id. The shared engine cache is deliberately keyed by CLASS (registry
rebinds, idle sweeps and engine_memory eviction all mutate it), so the
router's id-keyed ref could go stale and keep serving an instance the
lifecycle system no longer tracked — caught by
test_openai_speech_toggle_off_sends_raw_text in full-suite order, and it
also introduced a novel unload path that ignored the
OMNIVOICE_SINGLE_ENGINE_RESIDENT opt-out.
Drop the router-local cache entirely: _resolve_engine returns the shared
cached singleton (get_engine_instance_for), and create_speech calls
evict_other_tts_engines(backend.id) before warming the engine — the exact
seam /generate uses. That covers every transition (explicit id → explicit
id, explicit id → tts-1/omnivoice aliases), honors the policy opt-out, and
leaves no per-router state to drift. Regression pinned at the route level in
test_speech_request_evicts_other_resident_engines.
* chore(changelog): trim the #1614 entry to the one-liner limit
415 chars against the 400 the style test allows — CI would have failed on it.
---------
Co-authored-by: debpalash <4178343+debpalash@users.noreply.github.com>
Fix server-mode admin authentication recovery without trapping PIN-only deployments, and prevent stale 403 responses from clearing or superseding newly issued sessions. Includes backend/frontend regression coverage, docs, and changelog credit for @paoloantinori.
Four-angle /simplify on the cumulative branch diff:
- The idle-reaper grace flag now lives entirely inside _get_generator's
lock: the prefetch claims it only when THAT call builds the model, and
every other getter call consumes it. This deletes the duplicated
call-site clears in embed/detect (detect no longer touches the
generator's grace at all — it was clearing a flag for a model it never
uses), and closes the lock-gap window where the prefetch's claim could
land on an already-used model, which the old comment claimed was
impossible.
- Shared _env_float(name, default) for main.py's three inline float-env
parsers (capture delay, watermark delay, MCP start timeout): one
NaN/negative-rejecting implementation instead of three drifting
copies; the older two lacked the isfinite guard entirely.
- Test cleanups: dead isinstance-Future assert half removed, the
fake-audioseal Event-wait simplified to sleep, the reaper-diversion
guard simplified to a plain no-op lambda, stale setdefault sentence
dropped from the conftest comment.
Skipped with reason: merging the double will_mark() gate (they guard
different invariants — pool creation vs model load, both tested) and
hoisting the reaper guard to conftest (an autouse module-attr patch
would break tests that verify release_idle_models directly).
CodeRabbit on 28c7bace:
1. (Major) get_watermark_pool's double-checked pattern re-read the
global after an unlocked null-check, so shutdown_watermark_pool's
reset could land in between and the caller received None. The
executor is now captured and returned under _watermark_pool_lock.
2. (Minor) the idle-grace test overwrote _prefetched_unused after the
embed call, making the embed's clearing unobservable — a failing
embed would have passed unnoticed. It now asserts the flag directly,
and a guard diverts any leaked idle reaper (idle_worker resolves
release_idle_models per call) to a no-op for the test's duration.
Second CI red on the same test, different assert: the conftest fix killed
the leaked PRELOAD task, but a test lifespan that exits without shutdown
also leaves idle_worker running, and idle_worker calls
release_idle_models on these same module globals from another thread —
re-stamping _last_used mid-test. Each phase of the test now re-
establishes its preconditions immediately before its release call and
pins now= to a far-future monotonic, so an interleaved reaper tick
cannot change the outcome. Verified against the full 5801-test suite
run in one process.
The shutdown drain killed the module singleton with no replacement, so
any process that keeps running after a lifespan shutdown — the CI suite
does exactly this — dead-submitted on the next watermark op: "cannot
schedule new futures after shutdown" (CI red; independently confirmed
by Greptile P1, CodeRabbit Major, and the plugin code review at 95/100
confidence). shutdown_watermark_pool() now resets the singleton under
its build lock before draining, so the next get_watermark_pool() hands
out a live replacement. Regression test covers
drained-pool-refuses + replacement-accepts.
Same round, minor findings: the drain's except now logs with exc_info
instead of a bare pass (GHAS CodeQL empty-except); the watermark delay
knob rejects negative/non-finite overrides (CodeRabbit); conftest sets
OMNIVOICE_PRELOAD_WATERMARK=0 unconditionally so a stray export from
the runner shell cannot re-enable background warm-ups mid-suite
(CodeRabbit).
Two CI failures, both understood:
1. test_shutdown_preload_race_1000 pins the production _cancel_and_await
_tasks call site by regex; the new fifth handle broke the pattern. The
guard now pins all FIVE handles (its property — every preload handle
awaited under one generous bound — is unchanged).
2. test_prefetched_model_gets_one_extra_idle_window flaked only in the
full suite: many tests boot the app lifespan, and any that exits
without a lifespan shutdown leaves the deferred watermark-preload
task pending — 35s later it fires mid-suite in another thread and
re-stamps _last_used under whatever test is running. conftest now
defaults OMNIVOICE_PRELOAD_WATERMARK=0 for the test session (a test
can still opt in), and the grace test neutralizes will_mark so a
leaked warm-up can't touch it.
Bot findings: Greptile P1 + CodeRabbit — cancelling the preload task
doesn't stop a watermark-pool thread already inside the ~42s cold
import, and nothing drained that pool at shutdown (only the GPU pool
was reset). Shutdown now drains the watermark pool's queue
(shutdown(wait=False, cancel_futures=True)) — bounded abandon, same
documented reality that Python can't kill a running thread. CodeRabbit
Major: the warm-up reads its own delay knob
(OMNIVOICE_PRELOAD_WATERMARK_DELAY, default 35s) instead of reusing the
capture-ASR delay, so a capture env override no longer retimes it.
CodeRabbit Minor: the _prefetched_unused claim/clear transitions now
happen under _generator_lock, so the retention grace can't be granted
to a model that has actually been used; the test fixture resets all
lifecycle globals.
Skipped with reason: gating prefetch on local-checkpoint presence — the
warm-up downloads only what the first embed would download anyway;
time-shifting that download is the feature, not a new network call.
The first mark_synthetic serialized the audioseal import plus the
generator load INSIDE the first synthesis — measured at ~42s inline on
a cold filesystem (macOS, 2026-08-17 report), pushing a cold first
synthesis to ~87s and 3s past a 90s client timeout. The generator now
warms on a background task ~35s after boot (+5s past the capture-ASR
warm so the two cold imports don't contend), on the watermark pool,
cancellable at shutdown (OMNIVOICE_PRELOAD_WATERMARK=0 opts out; the
pool is only created when will_mark() says watermarking is active, and
setup-half failures log immediately instead of surfacing at shutdown).
Because the prefetch thread races the first embed, the lazy builds now
hold per-model locks — one build per model, no cross-blocking: a
detector load no longer queues behind a ~42s generator build, and
release_idle_models takes both locks in a fixed order. A
prefetch-warmed, never-used generator survives ONE extra idle-reaper
window so a first synthesis shortly after boot still finds it warm;
real embed/detect use clears the grace.
Also: embed/detect failures now log the full traceback (exc_info). The
catch-all printed only the message, which today left a
ModuleNotFoundError('getopt') inside AudioSeal's forward undiagnosable
from the log — audio silently ships unmarked when this fires.
CodeRabbit #1562 findings, both real:
- makeLoader is now generation-guarded: the initial retry loop overlaps
freely with WS-triggered reloads, and a slow in-flight response could
resolve AFTER a fresher reload and overwrite its list with stale data.
Each invocation bumps a generation; only the newest may setState.
- The regression test awaited the queue via sleep-polling; it now uses
asyncio.wait_for(q.get()) so a failure surfaces as TimeoutError instead
of depending on 10ms poll timing (repo rule: no sleeps as sync).
PUT/DELETE /profiles (rename, delete, revoke consent) and the history/export
mutators are sync FastAPI endpoints: their bodies run in threadpool workers
where asyncio.get_running_loop() raises, so event_bus.emit() hit the
RuntimeError branch and silently dropped the "profiles" event. The UI only
refetches the voice list on that event, so after a rename the list kept stale
state, and a reload during that window could land on an empty panel (no
retry on the initial load either) — which reads to a user as "all my voices
are gone" even though nothing was deleted.
emit() now captures the serving loop in subscribe() and hands off from
foreign threads via call_soon_threadsafe (async callers are unchanged).
Also: the initial list loads in useAppData retry until FIRST success via
retryInitialLoad — a WS-triggered reload failure still keeps the previous
list, but the first load has nothing to keep. Loaders gained {rethrow: true}
for the initial path so the retry actually engages (they swallow errors by
design elsewhere); an integration test pins that wiring.
Tests: tests/test_event_bus_thread_emit.py fails on the old emit (verified
by stashing the fix) and passes with it; a live two-instance probe confirmed
PUT rename → WS event arrives on the fixed build and never on the original.
- Licence-accept gate: add pockettts to _LICENSE_ALLOWED_ENGINES + PocketTTSLicenseDialog (MIT code + CC-BY-4.0 weights + gated-access notice).
- Gated-weights preflight: POCKETTTS_GATED_WEIGHTS in core/failure.py (hint + classify rule), so a gated-repo download surfaces as a typed error naming the agreement, not a raw failure.
- Frontend: PocketTTSLicenseDialog registered in EngineCompatibilityMatrix; i18n keys in en.json.
Remaining deferred items: CI smoke (stub sidecar integration test), four-platform install verification.
Every sidecar carries its own copy of the length-prefixed JSON-over-stdio wire protocol (_send, _recv, MAX_FRAME_BYTES), and only one of them was covered. This parametrizes the protocol invariants across all nine — send/recv roundtrip, EOF as an orderly shutdown, the oversized-frame cap that stops a corrupt length header allocating unbounded memory, and the truncated body that would otherwise hang the parent — so a bug in any single sidecar's copy is caught without a per-engine test file.
Modules are resolved through importlib inside a fixture rather than bound at collection, which keeps the suite honest under sys.modules pollution.
Thanks @paoloantinori!
The dub overlay said "Transcribing with Whisper…" whatever ASR engine was actually running, in all 21 languages — a user debugging a slow or failing transcription would go read Whisper's docs.
Contributor fixed 16 locales; the remaining 5 (ar, hi, id, ja, pl) transliterate the brand rather than keeping the Latin spelling, so the sweep missed them. Those are corrected, the guard is extended to every transcription stage label rather than the one reported, and its boundary is ASCII-letter-based — Python's \b is unicode-aware, so \bwhisper\b does not match "Whisperで文字起こし中".
Thanks @paoloantinori!
Makes tests/probe/test_triage.py pass on a fork checkout: the repo owner is not stable across forks, and detect_repo() legitimately returns None where there is no GitHub origin at all (source tarball, git archive, Docker build context) — so that case skips rather than trading one environment assumption for another.
Thanks @paoloantinori!
* fix(engines): path-aware GPU-pool slot in SubprocessASRBackend.transcribe()
transcribe() had the same on-pool self-deadlock that generate() had (fixed in
#1298): a bare no-op submitted to the GPU pool, but run_transcribe_guarded
dispatches it via run_in_executor(_gpu_pool), already on a pool worker, so on
a 1-worker (MPS) pool the no-op queued behind the job running it and timed
out before the sidecar spawned. IsolatedFasterWhisperBackend on MPS hit this
on every transcription.
Mirror generate()'s path-aware slot block (on-pool skip via
running_on_gpu_pool; off-pool _occupy hold) in transcribe(). The pattern is
duplicated rather than extracted into a shared helper to avoid reworking
generate(), which just shipped (#1298) with a CodeQL fix; extracting a shared
contextmanager is a clean follow-up. Regression test added (transcribe
dispatched on a pool worker).
* fix(engines): import threading in subprocess_asr
transcribe()'s off-pool slot hold uses threading.Event(), but the module never
imported threading — every subprocess-ASR transcribe raised NameError, and the
three round-trip tests failed in CI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(engines): cover the off-pool transcribe branch
Only the on-pool path had a test, so `threading.Event()` in the off-pool
branch shipped with `threading` never imported — every direct caller hit
NameError before the sidecar started. Both bots caught it on review; nothing
in the suite did. A branch with no test is how a one-word bug reaches CI.
Also asserts the slot is genuinely released afterwards. Fails without the
import fix; the pre-existing on-pool test still passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: debpalash <4178343+debpalash@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(engines): make SubprocessBackend.generate() path-aware on the GPU pool
generate()'s slot handling had two bugs:
1. On-pool self-deadlock: /v1/audio/speech and /generate (and audiobook,
dub, batch) dispatch generate() via run_on_gpu_pool_guarded, already on
a pool worker, so the inner slot submit queued behind the very job
running it on a 1-worker (MPS) pool and timed out before the sidecar
spawned. Every subprocess engine surfaced the in-process 300s-abandon
instead of synthesizing.
2. Off-pool no hold: the off-pool slot was a bare no-op that released the
worker before _spawn(), so off-pool callers (engine self-test,
diagnostics) could synthesize concurrently with a pool job and
over-subscribe the GPU.
Make the slot block path-aware: on-pool callers skip (the outer
run_on_gpu_pool_guarded already holds _running for the whole sidecar
exchange); off-pool callers hold a real slot for the whole synthesis via
an _occupy task that blocks the worker until _held is set in the finally.
Single release point in the finally.
Regression tests: generate dispatched on a pool worker (on-pool skip) and
a concurrent pool job blocked during an off-pool generate (off-pool hold).
Both verified fail-before / pass-after.
Supersedes #1296 (on-pool-skip-only). Closes#1295, #1297.
* Address review: couple on-pool skip to the pool prefix; fix comment
/simplify + /code-review flagged that the on-pool skip keyed on the literal
"gpu-pool" string, decoupled from _build_gpu_pool's thread_name_prefix. A
rename would silently re-introduce the exact self-deadlock this PR fixes (and
the tests can't catch it, since they hardcode the prefix). Centralise the
prefix in _GPU_POOL_THREAD_PREFIX + a running_on_gpu_pool() helper, used by
_build_gpu_pool, the skip in generate(), and _heal_tts_placement.
Also fix the comment: the Settings engine self-test rejects subprocess-isolated
engines with a 400, so the only real off-pool caller is the diagnose.py
deep-synth probe.
* fix(engines): bind slot_future before the off-pool branch
CodeQL py/uninitialized-local-variable (error, blocking CI). `_held is not
None` does imply slot_future was assigned, so the current code is correct —
but the two are only coupled by convention, which the analyser cannot see and
a third exit path would quietly break. Binds it to None up front and guards
the cancel.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(engines): make the slot-hold regression deterministic and leak-free
CodeRabbit, valid on both counts. The test used sleep(0.8)/sleep(0.5) as
synchronization — the tests/** contract forbids it, and on a slow runner the
marker could be enqueued before the generator had reserved anything, so the
assertion passed for the wrong reason. It now waits on an event signalled when
the slot task actually starts, and asserts "did not run" via a result()
timeout rather than a bare sleep.
Cleanup moved into finally: an assertion failure used to leak the sidecar
process and the pool thread into the rest of the session.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: debpalash <4178343+debpalash@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(engines): add omnivoice-subprocess, a crash-isolated TTS engine
The default in-process OmniVoice engine runs on the GPU ThreadPoolExecutor.
When a generate or load exceeds its execution budget the pool is "reset", but
the abandoned worker thread cannot be killed (Python cannot interrupt a native
torch/MPS call), so it keeps holding the device until it finishes on its own
and later synths queue behind it and hang. The reset restores pool capacity
but not the device. This is the residual root cause behind the closed#730
and #1190: the messaging/reset mitigations address the symptom, not the
device-holding zombie.
Add an opt-in `omnivoice-subprocess` engine that runs the same model in a
child process via SubprocessBackend. A child process can be hard-killed: on a
recv-timeout the watchdog calls proc.kill(), reclaiming VRAM/device, and the
next request transparently respawns a fresh sidecar. The in-process engine
remains the default, so existing users see no change; this is an opt-in for
unattended / scheduled / reaction-triggered synthesis where a stuck job must
self-recover instead of hanging until a manual restart.
Base-class and mitigation changes that ship with it:
- SubprocessBackend.generate() now consumes non-terminal {"op":"progress"}
frames a sidecar emits during a cold load (previously the first cold
generate after spawn failed, then worked on retry). Additive: engines that
reply with audio directly are unaffected.
- recv_timeout_s is overridable per engine (default 60s unchanged); the new
engine sets it to the generate budget so a long-but-valid synth is not
falsely killed while a wedged one still is.
- make_room_before_generate(): free idle GPU memory before a warm, heavy
generate. The cold-load path already evicted; the warm path skipped it, so a
long synth on a VRAM-tight MPS box could contend its way into the budget.
Verified end-to-end against the live model (cold / warm / recovery-after-kill)
and under a sustained + concurrent-pressure soak: killed-worker recovery 5/5,
chunked long text 9/9, no memory leak.
* Address review: install_hint + move make_room into get_model
- Add `omnivoice-subprocess` to `_INSTALL_HINTS`; the
test_install_hints_cover_all_registered_backends gate requires every
registered backend to carry one (this was the CI failure).
- Move the warm-generate VRAM eviction out of the /generate and
/v1/audio/speech routes and into get_model()'s warm-return path, so EVERY
native TTS generate is covered (REST, WS TTS, dub, batch, audiobook), not
just the two REST routes. Drops the now-redundant per-route wiring.
(Greptile P1: the per-route placement missed the other generation surfaces.)
* Address review: drop dead long-text eviction path; log probe failure
- _should_make_room_for_generate: the long-text headroom boost became dead
code once the eviction moved into get_model() (which has no text), so the
long-text branch never fired. Removed the text param, the long-text
threshold/multiplier branch, and the now-unused _env_float helper. The core
RAM-tight gate (the part that matters on a starved box) is unchanged.
- Log the available_memory probe failure at debug instead of silently
swallowing it (CodeRabbit: silent swallow breaks the debug trail).
- Tests updated for the text-agnostic policy.
* fix(engines): stop subprocess generate() self-deadlock on 1-worker pools
SubprocessBackend.generate() acquires a GPU-pool slot for accounting, but
/v1/audio/speech and /generate dispatch backend.generate() via
run_on_gpu_pool_guarded, i.e. already ON a pool worker. On a 1-worker pool
(MPS) the inner pool.submit queued behind the very job running it and
slot_future.result(timeout=10) raised before the sidecar ever spawned, so
omnivoice-subprocess (and every other subprocess engine on MPS) surfaced the
in-process 300s-abandon instead of synthesizing.
Skip the slot acquisition when current_thread() is already a gpu-pool worker;
the outer guard already accounts for the slot. Direct callers (off the pool)
still acquire one. Regression test added (generate on a pool worker).
* Address review: reword slot-skip comment (fixes watermark-coverage CI) + simplify
- The slot-skip comment said "dispatch backend.generate() via", and
test_watermark_route_coverage's _SYNTH_CALL regex matches the literal
backend.generate( anywhere in a module, so it counted subprocess_backend.py
as a synthesis producer that must reference mark_synthetic (it doesn't — the
routes apply mark_synthetic; the engine sits below the chokepoint, like
tts_backend.py). Reworded to "dispatch generate() via".
- Fold in the simplify refinement: single negated predicate, import+pool
moved into the acquire branch.
* feat(mcp): OMNIVOICE_MCP_ALLOWED_HOSTS env var for transport-security allowlist (#1249)
Agents running in Docker containers (or on other machines) connect via a
hostname like host.containers.internal, which the MCP SDK's DNS-rebinding
guard rejects with 421. Add OMNIVOICE_MCP_ALLOWED_HOSTS (comma-separated
host patterns) that extends both allowed_hosts and allowed_origins in
create_mcp_server(). Default empty → no behavior change.
Test: assert the env var extends the allowlist + origins. Docs: mcp.md
notes the env var for Docker/LAN agents.
* fix(changelog): move MCP_ALLOWED_HOSTS entry after Highlights per quiet style
* fix(mcp): add https:// origins for HTTPS reverse proxy clients (greptile P1)
* docs(mcp): add security note for remote agent connections (coderabbit)
AI agents driving OmniVoice via MCP could use and list voices but couldn't
create one. Add a clone_voice MCP tool that takes a base64-encoded reference
audio sample (consistent with transcribe's audio_base64 pattern), decodes it,
and POSTs it as a multipart ref_audio to POST /profiles (kind=clone). Returns
the new profile_id so the agent can immediately use it with generate_speech.
Update test_mcp_mount.py to include clone_voice in the asserted tool surface.
CHANGELOG entry.
Self-hosting behind a reverse proxy or on a LAN used to force a blunt choice:
OMNIVOICE_SERVER_MODE (trust every non-loopback source) or the API-key/PIN gates
— which a proxy that strips the Authorization header breaks for browser clients
entirely.
Add OMNIVOICE_TRUSTED_NETWORKS (comma-separated CIDRs) whose addresses are
treated as trusted by the CONSUMPTION gates (PIN/API-key middleware, dictation
WebSocket) via is_local_host — a LAN/proxy client is exempted from consumption
auth. Admin routes (require_loopback → /system/set-env, /api/settings/*) stay
true-loopback-only (is_loopback, not is_local_host) to preserve the two-tier
privilege model: consumption trust ≠ admin trust (RCE-class surface). Opt-in,
default empty → zero behavior change. The granular companion to server-mode (#261).
Tests: is_loopback / is_local_host / require_loopback contract for trusted CIDRs,
adjacent subnets, malformed entries, the two-tier split (trusted-network rejected
by the admin gate), and the default (no-trust) case. Docs + CHANGELOG.
* fix(remote-auth): route API-key 401 to an API-key gate, not the PIN form
When OMNIVOICE_API_KEY is set (remote-backend mode), a non-loopback browser
gets 401 "API key required" from BearerKeyMiddleware. But client.ts fired
`ov:pin-required` on every 401, surfacing the PIN gate — whose payload
(sessionStorage ov_pin / X-OmniVoice-Pin) can never satisfy the API-key
middleware. A remote user was stuck on a PIN form they could not pass.
Read the 401 `detail` and dispatch a single `ov:auth-required` CustomEvent
carrying the mode; RemoteAuthGate renders the matching PIN or API-key form.
Adds a `?api_key=` deep-link bootstrap (one-shot — scrubbed from the URL so a
reload can't re-clobber a corrected key) and a guarded saveApiKey helper.
Backend is unchanged — the two 401s are distinguishable by their `detail`
body ("API key required" vs "PIN required"). Docs: remote-gpu.md gains a
"From a browser" subsection for the new ?api_key= deep link.
* fix(remote-auth): preserve URL hash when scrubbing credentials
The replaceState that scrubs ?api_key=/?pin= rebuilt the URL from pathname
(+ optional query) and dropped url.hash, nuking any deep-link fragment
(e.g. #settings). Rebuild with pathname + (?query) + hash.
Addresses greptile + coderabbit review feedback on #1154.
* fix(remote-auth): guard 401 routing against a non-string/malformed detail
String(detail) can itself throw on a 401 detail whose toString is broken
(e.g. { toString: null }), aborting the auth-event dispatch. Match only real
strings with typeof; anything else falls back to PIN mode.
Addresses coderabbit's 17:03 re-review finding on #1154.
* fix(remote-auth): read the deep-link API key from the URL fragment (#api_key=)
Move the remote-backend deep link from ?api_key= (query) to #api_key=
(fragment): fragments are never sent to the server, so the durable key stays
out of the GPU box's and any reverse proxy's request logs on the page load
(greptile P1). ?pin= stays on the query (QR flow, session PIN).
The bootstrap is extracted into a pure, unit-tested _parseDeepLinkCredentials
helper (pin from the query, api_key from the fragment, one-shot scrub of both,
plus a legacy ?api_key= scrubbed-without-reading so a stray query key never
lingers). Docs document #api_key= with encoding guidance for keys containing
+ / & / # / =.
Locks down the resume path from the parent commit:
- Every AudioContext is tracked at construction (the wrap is active)
- unlockAudio() resumes all suspended tracked contexts in parallel
- Idempotent: repeated calls do not re-resume
- Contexts created after unlock are not re-resumed by a second call
- resume() rejections are swallowed — one bad context doesn't block others
- installAudioUnlock() is idempotent (the _installed gate works)
The unlock path was the fix for the "click does nothing" bug on Linux
Firefox/Chrome and Android Chrome, where AudioContexts created before a
user gesture stay suspended — decodeAudioData hangs → WaveSurfer's ready
never fires → the play button never enables. Without this test, breaking
the gesture wiring silently regresses every non-macOS browser.
Adds the __resetForTesting() export so the unlock can be exercised
repeatedly against the same module instance (the unlock is meant to be
a one-shot per page load).
Browser autoplay policy (Linux Firefox/Chrome, Android Chrome, mobile
Safari): AudioContexts created before a user gesture start in "suspended"
state — decodeAudioData hangs and WaveSurfer's `ready` event never fires.
The play button gated on `ready` stays disabled forever, so the click
silently does nothing and no /audio/ request ever fires.
macOS Safari/Chrome are more lenient (typically auto-resume on first
interaction) which masked the bug cross-platform.
Fix has three parts:
1. `frontend/src/utils/audioUnlock.js` (new) — monkey-patches
`window.AudioContext` (and `webkitAudioContext`) to track every
instance ever created. Exports `installAudioUnlock()` which wires a
one-time pointerdown/keydown/touchstart listener that resumes all
suspended contexts on the first user gesture. The patch MUST install
before any module constructs an AudioContext, so this file is imported
first in main.jsx before the dynamic import of main-app.jsx.
2. `frontend/src/main.jsx` — imports and installs the unlock before any
other module loads.
3. `frontend/src/components/WaveformPlayer.jsx` — three changes:
- Remove the `Loader` spinner that gated on `ready`. The spinner
itself was a visual signal that the user was waiting on a state
the browser refuses to produce without user interaction.
- Button is now `disabled={!resolvedUrl}` — clickable as soon as the
audio URL exists, so the user's click IS the gesture that unlocks
the AudioContext.
- `togglePlay()` explicitly awaits `unlockAudio()` before calling
`playPause()` to close any race with the global gesture listener.
The console warning "An AudioContext was prevented from starting
automatically" may still appear once on page load — that's the
informational signal that the pre-gesture context was created suspended;
it's harmless because we explicitly resume on first interaction.
Tested: Linux Firefox 151.0.3 — before fix, clicking play did nothing
(no /audio/ request fired, button never enabled). After fix, single
click on play resumes AudioContext + starts playback, waveform animates.
Pairs with the audio/wav MIME fix in the same PR — both bugs had the
same user-visible symptom (silent play button on Linux) but different
root causes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Python's `mimetypes.guess_type()` returns `audio/x-wav` for `.wav` and
`audio/x-flac` for `.flac` — vendor-experimental types (x- prefix) that
were never IANA-registered. macOS Chrome/Safari MIME-sniff leniently via
CoreAudio so playback works there, but Linux Chrome/Firefox (FFmpeg) and
Android Chrome (ExoPlayer) strictly honor the declared type and treat
the x- variants as download-only.
Result: the play button in the browser web app silently did nothing on
Linux/Android (download prompt instead of inline playback), while the
Tauri desktop shell worked because its WebView is lenient. The
diagnostic signal — Chromium short-circuits to download BEFORE the
<audio> element sees the response, so no MEDIA_ERR_SRC_NOT_SUPPORTED
fires; just a download prompt that's easy to miss.
Fix: register `audio/wav` and `audio/flac` (the IANA-canonical types)
via `mimetypes.add_type()` before the StaticFiles mounts in main.py.
No browser-side workaround exists (no chrome://flags, no about:config
pref) — the server is the only place this can be fixed.
Existing comment in dub_export.py:766 already acknowledges this exact
quirk for video files; this applies the same treatment to audio.
Test: regression test in test_api.py asserts `/audio/<file>.wav` returns
`Content-Type: audio/wav`. Without the fix this returns `audio/x-wav`.
Ref: https://www.iana.org/assignments/media-types/media-types.xhtml#audio
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>