Commit Graph
278 Commits
Author SHA1 Message Date
5d6e05ef1b fix(errors): stop giving advice that cannot work (#1347, #1335, #1334) (#1374)
* fix(errors): a failed download is not a broken install (#1347, #1335)

Two reports, one shape: the error text carried both a network cause and a
downstream symptom, the taxonomy matched the symptom first, and the user
was sent to fix something that was never broken.

#1347 -- transcription failed with "transformers ASR pipeline failed to
import (AutoFeatureExtractor) -- your transformers install is incomplete;
reinstall with `uv pip install --reinstall transformers` ... Underlying:
Cannot send a request, as the client has been closed."

The install is fine. The pipeline was DOWNLOADING the feature extractor
when the shared HTTP client closed underneath it (#880). Reinstalling
transformers cannot fix a dropped connection, so the advice was not
merely unhelpful -- it was work the user could repeat forever without
succeeding. New MODEL_DOWNLOAD_INTERRUPTED class, checked before the
import rules, requiring the httpx closed-client wording AND an
import/transformers term so a bare closed-client error elsewhere is left
alone. Its hint says the partial download resumes, since otherwise
someone on a slow link assumes retrying restarts a multi-GB fetch.

#1335 -- a cut TLS connection reached /generate as a bare 500 carrying
`_ssl.c:1016`. core/failure.py has classified that since #1301, but
/generate keeps its own taxonomy and never learned it, so it fell to the
unrecognized-error catch-all. Added to the network signatures there: it
is a dropped download, and the remedy is retry, not Flush.

Both changes are orderings rather than new detections -- the cause now
beats the symptom -- and both keep the case the original rule existed
for: a genuinely broken transformers install still classifies as
TRANSFORMERS_IMPORT, and a failed handshake is still distinguished from a
cut connection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(errors): a Windows paging-file limit is not out-of-memory (#1334)

Same class as the two fixes already on this branch: advice that cannot
work.

The reporter asked, reasonably, whether OmniVoice needs an internet
connection -- generation failed only when they disconnected, with a bare
500 carrying "The paging file is too small for this operation to complete
(os error 1455)". Two separate defects made that unanswerable:

1. /generate matched it in _is_oom_failure and said "Try the Flush button
   to reload the model". Flush cannot help. The hint we had already
   written for this exact class says so outright -- "closing other apps
   usually won't fix it" -- but the generate path never consulted it.
   Now branched before the OOM check, naming the virtual-memory setting
   and stating plainly that it is not a network problem.

2. WINDOWS_PAGING_FILE_TOO_SMALL was absent from
   _CONTEXT_FREE_HINT_CLASSES, so on the raw-500 surface classify()
   identified it correctly and then attached nothing. The user got the OS
   sentence and no next step, despite the detailed remedy sitting in
   _HINTS. Its trigger (1455 with winerror/os error, or the literal
   phrase) is unmistakable, which is the bar that set requires.

Both Python (`WinError 1455`) and Rust (`os error 1455`, from the
safetensors mmap) spellings are covered, and a genuine CUDA OOM still
gets the Flush hint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(errors): tighten both new matches, and move the 1455 expectation

CI caught a real one, and it was my process error: I ran the full sweep
before adding the paging-file change, not after.

tests/test_generation_audio_guard.py listed WinError 1455 among the OOM
signatures and asserted it yields "ran out of memory / try Flush". The
new branch routes it to the paging-file advice instead. That test's
INTENT -- a genuine memory failure must never fall through to the unknown
catch-all -- is preserved and still asserted; 1455 simply gets a more
specific memory message now. Expectation moved, guard kept.

Two over-broad matches tightened (CodeRabbit), both in the same
direction: a rule that fires too widely replaces correct advice with
advice that cannot work, which is the exact defect this branch exists to
fix.

* The TLS EOF wording is OpenSSL's, but nothing stops an unrelated
  component saying something similar, and calling a local fault a network
  problem sends the user to check a connection that was never involved.
  Now gated on an `ssl` marker; the real message always carries it.
* MODEL_DOWNLOAD_INTERRUPTED required "client has been closed" OR
  "cannot send a request". The latter alone is generic enough to appear
  beside an unrelated import failure, where overriding TRANSFORMERS_IMPORT
  would swap correct reinstall advice for a "just retry" that never
  succeeds. Now requires the closed-client wording itself.

Negative regression tests for both, plus the positive cases they must not
cost us. Also resolves core.failure through a fixture at call time rather
than importing it at module level, per the suite convention -- sibling
tests reload these modules and a stale binding makes the file
order-dependent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: debpalash <nizam4103@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:45:29 +05:30
973f7a77a6 fix(generate): a deadline is not an unrecognized error (#1368) (#1373)
* fix(generate): a deadline is not an unrecognized error (#1368)

Reported on macOS/MPS with indextts2:

    TTS engine stopped mid-generation with an error OmniVoice doesn't
    recognize. Retry once; if it keeps failing, please report it with the
    full trace. Underlying error: TimeoutError:

Nothing follows that last colon. TimeoutError is routinely raised with an
empty message, so the user was asked to report a trace that says nothing,
about the one failure mode whose cause is entirely known.

The classification chain already refuses to blame VRAM for network (#880)
and config (#919) failures. A deadline is the same kind of thing -- a
known class with a specific remedy -- and it was falling through to the
catch-all. Worse, a timeout whose message happened to contain an OOM-ish
word would have hit the memory branch and sent the user to Flush for
memory they never ran out of, which is exactly the class bug #880 fixed.

_is_timeout_failure() matches TimeoutError (and asyncio/futures aliases),
the project's own GpuJobTimeoutError by name since it is not a subclass,
and stringified forms from sidecars that wrap the child's error. Checked
BEFORE the OOM branch. "read timed out" is explicitly left to the network
branch: that is a dying model download, which it explains better.

The message names the time limit, the three usual causes, and
OMNIVOICE_GENERATE_TIMEOUT_S so someone on slow hardware has a way
through rather than only an explanation. The "Underlying error" tail is
appended only when the exception actually carries a message -- otherwise
it rendered as a bare `TimeoutError:`, a sentence stopping mid-thought.
Testing that emptiness against str(e), not _safe_exc_text(), which always
prefixes the type name and so is never empty.

Likely the in-the-wild face of #1367: indextts2 is a sidecar, and a
first-use weights download overrunning the 300s generate budget produces
precisely this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style(generate): drop placeholder-free f-prefixes (#1368)

CodeRabbit on #1373: after the interpolation moved into `_tail`, the
message literals no longer interpolate anything, so the f-prefixes were
dead weight and Ruff F541. Ruff does not run in CI, so this is
consistency with the surrounding code rather than a broken gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: debpalash <nizam4103@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 02:43:29 +05:30
Palash DebnathandClaude Opus 5 680c57f9b9 fix(dub): say when a clone reference is gone instead of rendering a default voice (#1331) (#1361)
* fix(dub): say when a clone reference is gone instead of rendering a default voice

Reported on Discord: "if you re-dub individual sentences the voice isn`t
taken from the video — you have to re-dub everything for the voice clone
to work."

Clone references are FILE PATHS into the job`s extracted-clip directory,
and the whole job dict — those paths included — is persisted to
dub_history.job_data so saved projects reopen after a restart. The job
therefore outlives its clips. Reopen a saved dub once the clip directory
has been cleaned, regenerate one line, and every resolution branch hands
the engine a path that is no longer there. Nothing checked it, and an
engine given a missing reference renders UNCLONED rather than failing — so
the line comes back in a default voice matching nothing else in the dub,
with no error anywhere. A full re-dub re-extracts the clips, which is
exactly why that appears to fix it and is the workaround the reporter
found unaided.

Diagnostic ONLY, deliberately: the reference is passed to the engine
unchanged. Nulling it would not alter what the user hears — the engine
already falls back — and it would decide on the engine`s behalf that a
path it cannot stat is unusable, which is untrue for anything resolved
inside a sidecar`s own namespace. The defect is the silence, not the
fallback. (The first cut did null it, and broke seven existing tests that
legitimately assert a synthetic path reaches the engine; that was the
right signal.)

Warns once per segment per job, so a 300-segment dub whose clips were
cleaned logs which lines lost their reference rather than one line per
retry — "some of them" is not actionable.

Root cause of the cleanup itself is not addressed here; this is what makes
the next report carry the paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(dub): key the missing-ref memo on the path, not the segment alone

greptile P1, valid. The single-segment preview endpoint has no segment
identity to pass — it is a "render this text" call — so every preview
shared the key "preview" and only the FIRST missing reference in a job was
ever reported. Every later one, with a different path, was silenced: the
de-duplication meant to stop repetition was swallowing new facts.

Keying on (segment, path) fixes it without an API change, and is more
correct on the render path too: a segment rebound to a second missing clip
is no longer mistaken for the one already reported.

SegmentPreviewRequest also gains an optional segment_id, diagnostic-only
and defaulted to None so existing callers are unaffected — a caller that
supplies it gets the line named instead of a bare "preview".

Three tests; the distinct-paths one fails against the segment-only key
while the repeat-suppression one keeps passing, so the fix cannot be a
blanket removal of the de-duplication.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:56:52 +05:30
Palash DebnathandClaude Opus 5 097589295f fix(tts): a chunk that renders to nothing is no longer dropped in silence (#1330) (#1360)
* fix(tts): a chunk that renders to nothing is no longer dropped in silence

Reported on Discord: "this app dosent generate me the last few sentences.
for rest this app is a banger" — clean audio, just missing the end.

Ruled out by direct probe rather than by reading: split_text_into_chunks
preserves every non-whitespace character (including text with no terminal
punctuation), concatenate_audio_chunks joins everything it is given, and
trim_trailing_silence cuts only from the last VOICED sample so it cannot
remove speech. Those three are pinned by a test now so the elimination
does not have to be redone.

What was left is the filter at the top of the join:

    chunks = [c for c in chunks if c is not None and c.shape[-1] > 0]

When an engine returns nothing for one slice of text, skipping it is still
the right joining behaviour — the alternative is a crash or a gap. Doing
it in silence is not: the waveform looks perfect and is simply short, so
the failure can only be found by reading along while listening.

It now counts the drops, logs at WARNING (this is output the user paid
compute for), and names the lost text where the caller can supply it —
wired through all three generation call sites and audiobook.

Audiobook also pre-filtered before calling, which both hid the same drop a
second time and misaligned rendered from chunks so the concat could not
name what was lost. It passes the full list now.

Not the root cause of the engine returning nothing — that needs a
reproducing input, which is exactly what this makes obtainable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(audiobook): the one-survivor branch skipped the drop reporting

Both reviewers caught the same hole, and they were right: a span that
splits into several chunks where only ONE renders returned that chunk
directly, skipping the join — and therefore skipping the reporting the
join does. The chapter came back short and said nothing, which is this
very bug one branch over. Zero survivors had the same problem.

Rather than add two more conditions to an inline branch, the decision
moves into chunked_tts.join_rendered_chunks: kept/dropped, report, and
return None when nothing rendered so the caller`s dead-render handling
still owns that case instead of receiving a silence buffer. One place that
can be wrong, and a testable one — a second inline copy is how the hole
appeared to begin with, so a test pins that audiobook routes through it.

Also, on CodeRabbit`s test note: test_reporting_never_breaks_the_join now
asserts the report was ATTEMPTED, not merely that nothing blew up —
otherwise it would pass on a build where the reporting does not exist.
test_chunking_itself_loses_no_text stays, with its docstring saying plainly
that it pins an eliminated hypothesis rather than a fixed defect: "the
chunker drops the tail" was the first explanation for #1330, ruling it out
took a probe, and a future splitter change that did lose the tail would
reproduce the reported symptom exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:08:36 +05:30
Palash DebnathandClaude Opus 5 1a4b95890a fix(llm): LM Studio translation failed on a placeholder model name (#1332) (#1359)
* fix(llm): LM Studio translation failed on a placeholder model name (#1332)

Reported as a clean A/B: translation works through Ollama and fails
through LM Studio on the same machine. The difference is one line in the
provider table. LM Studio shipped `local-model` as its default_model,
which is a placeholder, not a model id — LM Studio serves whatever the
user has loaded and 404s a name it does not know. Ollamas default is
`llama3.1`, a real name people actually pull, so the identical code path
worked there.

No name we ship can be right, because the answer depends on what the user
loaded. So ask the server: resolve_model now discovers from /v1/models for
providers whose default is a placeholder, positioned BELOW any explicit
env or stored setting so it can never override a deliberate choice, and
above the default so a server that is down leaves the caller where it was.

Cached per provider — translation resolves the model per segment and a
round-trip each time would trade a broken setup for a slow one — and
dropped whenever a base_url or model edit could invalidate it, since a
stale id would make the users change look like it did nothing.

Also: a 404 from a LOCAL provider is almost never a wrong URL, because the
request reached the server. The generic "check the model name and Base URL
path" sends the user to audit a URL that works, so a local 404 now names
the models that ARE loaded, or says the server has none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(llm): bound the discovery cache both ways; do not over-claim a 404

Three review findings, all valid, all about the cache being permanent in
one direction or absent in the other.

- A failed probe was not remembered, so a stopped LM Studio cost a 5s
  timeout on EVERY translated segment — a 200-segment dub would spend
  1000s discovering nothing, worse than the bug being fixed. Remembered
  for 30s: short enough that starting the server recovers in seconds
  rather than needing a restart.
- A successful discovery was cached forever, so swapping the loaded model
  inside LM Studio 404d every translation until an app restart. Now a 300s
  TTL, plus an immediate invalidation when a local 404 proves the cached
  name is one the server rejects.
- _local_models collapsed a FAILED listing into [], which let the error say
  "reports no loaded models" about a lookup that never happened — a
  confident wrong diagnosis replacing a vague right one. None vs [] are
  now distinct, and the generic 404 text stands when nothing was
  established.

The cache therefore cannot be a dict[str, str]: "no entry" and "we looked
and there was nothing" have to be distinguishable for the negative case to
be cacheable at all.

Five tests, three failing before this change. They age the cache entry
rather than patching time.monotonic — that name is the stdlib`s, shared
with sqlite and logging, and freezing it breaks the settings store
underneath the test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 14:48:56 +05:30
Palash DebnathandClaude Opus 5 8da377a1cb fix(audiobook): say why a chapter failed, and don't hang when an engine stops (#1321) (#1325)
The report arrived as a raw traceback pasted out of a log file — because that is the only place the reason existed. A failed chapter was a red row and the word "failed"; the SSE event carried the literal string "chapter failed to render", the symptom the user could already see.

- Both the per-chapter and the terminal all-failed events are now built with the shared `core.failure` builder: sanitized text, a guaranteed non-empty reason, error class, docs deeplink and hint. `error` keeps mirroring `reason`, so older frontends and the Stories exporter are unaffected. The chapter list shows the reason inline, full text in the row tooltip.
- **A silent infinite hang on the same path.** asyncio refuses to put `StopIteration` into a Future — `_copy_future_state` raises TypeError inside the event loop's own callback, so the `run_in_executor` future is never completed and the caller waits forever, with no error, event or timeout. Reachable from ordinary input: VoxCPM's `next_and_close` is a bare `next(gen)`, so a generator ending without yielding raises it straight into a GPU-pool worker. Fixed at the pool boundary (`_ResilientGpuPool.submit` plus a matching `_cpu_pool` subclass) as `WorkerStopIteration(RuntimeError)`, keeping the original as `__cause__`.
- An all-failed render now marks the job failed. It previously returned without touching job history, so the row stayed `running` and the next startup's orphan sweep read a hopeless render as interrupted and offered it as resumable (Greptile P1).

Tests: 6 pool-guard tests (two hang without the fix, bounded so a hang fails rather than stalls CI), longform e2e extended for both event shapes, the empty-`str(exc)` floor, and both sides of the job-status branch, plus the chapter-list component.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 04:22:50 -07:00
Paolo Antinori 47c51698c9 feat(engines): add omnivoice-subprocess, a crash-isolated (killable) TTS engine (#1292)
* 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.
2026-07-29 02:06:13 -07:00
Palash Debnath b66b09ceaa fix(dub): deleting a dub no longer resurrects it (#1252, #1253) (#1270)
* fix(dub): deleting a dub no longer resurrects it (#1252, #1253)

Split out of #1264. The other six fixes there are independent error-message
changes that needed no corrections; this one is a concurrency change that
needed five rounds, each finding something real in work that looked finished
and tested:

  1. review: merge and save were split, so a delete between them had the row
     written straight back;
  2. review: dict membership cannot express 'withdrawn' — an absent key means
     'not written yet' for a new job and 'deleted' for an established one;
  3. fail-before check: the race test was not testing the race (it asserted
     WHAT happened, never WHEN, and 'save after delete' IS the resurrection);
  4. review: the gate sat in two ingest helpers while eight direct save_job
     call sites bypassed it;
  5. direct check of the reported scenario: the tombstone covered in-flight
     ingests only, so a delete during a RENDER — the common case — still
     resurrected the job.

Riding a release on that record is not a good trade, so it ships on its own.

What it does now: the withdrawal is recorded when a job is DELETED, held in a
bounded LRU (there is no moment at which a delete stops mattering), and checked
inside save_job so every caller inherits it. Re-importing an id is the only
thing that revives it. The lock is re-entrant because the atomic helpers call
save_job while holding it.

Carries #1252's message half too: str(KeyError) is the repr of the key, which
is how the user's own job id became the entire error text.

* fix(dub): expire withdrawal markers by age, not by count

Greptile P1 — the sixth real finding on this fix, and reachable by ordinary
use. DELETE /dub/history selects every row with no limit, so a user with a
large history clearing it mid-render pushed that very job's marker out of a
count-bounded LRU, and the render then wrote it straight back.

Age is the honest policy: what matters is how long ago the delete happened, not
how many others followed it. Six hours outlives any realistic render or
transcribe. The count cap stays only as a memory backstop, raised far above any
real history and documented as such.

Verified fail-before: restoring count-based eviction fails both new tests.

* docs(changelog): the dub delete-resurrection fix (#1252, #1253)
2026-07-26 14:25:32 -07:00
Palash Debnath 73ebd6518d fix(errors): six failures that reached users as raw text (#1262, #1256, #1251, #1247, #1257, #1254) (#1264)
* fix(errors): four failures that reached users as raw OS text (#1262, #1256, #1251, #1252)

#1262 — a voice profile named in any non-latin-1 script 500'd every download
endpoint with "'latin-1' codec can't encode characters in position 22-25".
`attachment; filename="` is exactly 22 characters, so those were the first
four characters of the user's own name. The sanitisers in front of the header
filtered with str.isalnum(), which is True for every alphabetic script — they
stripped punctuation and passed exactly what breaks the header. Ten sites, one
RFC 6266 builder, plus a guard so an eleventh can't be hand-written.

#1256 — a synth died on FileNotFoundError: 'ffprobe' and was reported as "an
error OmniVoice doesn't recognize", on a Mac where the app's own ffprobe was
resolvable the whole time. Our call sites pass explicit paths; a dependency
shelling out by bare name does not. The resolved directories are now published
on PATH, and the failure is classified either way.

#1251 — "The paging file is too small" reached the user as a bare 500. It was
already counted as an OOM, but that remedy (close apps, lighter engine) is
wrong on a 32 GB machine — the fix is a Windows setting, and the hint now says
which. Matched on the code in both the Python and Rust spellings.

#1252/#1253 — deleting a dub mid-import crashed it with `ingest: 'mgw39lx3'`:
str(KeyError) is the repr of the key. The pipeline blind-subscripted a job that
DELETE /dub/history/{id} had popped minutes earlier. It now stops quietly, and
no exception whose str() is a bare value can present itself that way again.

* fix(engines): Unload 400'd, a wrong language said nothing, DRM was retried by hand (#1247, #1257, #1254)

#1247 — list_loaded() advertises in-process engines as `engine:<id>` with
"unloadable": true, but unload() only ever handled tts/diarization/sidecars.
The panel was rendering a button for ids the dispatcher rejected. The engines
already implement unload(); only the routing was missing. The contract test
written for it immediately found a second instance — `capture-asr`, listed the
same way with no branch either — which is why it enumerates the listing rather
than hard-coding ids.

#1257 — MLXAudioBackend.supported_languages() returns ["multi"] on the stated
assumption that "each engine silently ignores languages it doesn't know". It
doesn't; the library raises. So the picker offers all 646 languages and the
rejection arrived as a bare list of 23 codes, naming neither the engine nor the
way out. Enumerating each model's real language set would be a brittle map that
goes stale every engine update — name the engine and the fix instead.

#1254 — reported as intermittent: the same URL failed as DRM-protected, then
succeeded on retry. Real DRM doesn't lapse; the player client varies. That is
the same shape as the 403 case which already escalates through
_YT_PLAYER_CLIENTS, so DRM now routes into it. If every client still refuses,
the failure is classified instead of arriving as a raw yt-dlp line.

* fix(review): close the delete race, narrow the tool match, sanitize the fallback

Greptile P1 + CodeRabbit Major — verified real, and mine: splitting merge from
save left a window where a delete lands between them, so the pending save
UPSERTs the row straight back and a dub the user deleted reappears. Now one
atomic step under _dub_jobs_lock, with both delete endpoints purging rows and
memory under that same lock. That also fixed DELETE /dub/history, which
deleted every row but evicted nothing — an in-flight job survived 'clear
history' outright and re-saved itself on completion.

CodeRabbit Minor (#1256): the media-tool match accepted any message ending in
'ffmpeg'/'ffprobe', so a missing FILE at /tmp/ffmpeg got the 'repair your
media engine' remedy. Now requires the name unquoted-and-unqualified.

CodeRabbit Major (#1262): `fallback` reached the header verbatim whenever the
real name folded away entirely, walking past every guard the name goes
through. Folded like the name.

CodeRabbit Major (#1256): the PATH log printed resolved directories, and a
user-set FFMPEG_PATH sits under their home. Logs a count now.

CodeRabbit Minor (#1262): the subtitle-route assertion also passed against the
pre-fix header; it now asserts filename*= too.

Skipped: 'Highlights bullets must end with (#N)'. CLAUDE.md scopes that to the
### subsections; none of the seven pre-existing highlights carry refs, and
tests/test_changelog_style.py encodes the rule already.

* fix(review): the remaining unlocked save paths, an over-broad signature, two weak tests

Greptile P1 — the mid-pipeline put_job + save_job pairs were still unlocked, so
a clear-history landing between them left a ghost row behind the purge. Both go
through put_and_save_job now; only the final completion gate decides whether a
withdrawn job's work is kept.

CodeRabbit Major (#1257) — 'unsupported language' as a bare prefix also matches
'Unsupported language model configuration', handing a model/config failure
engine-switch advice it has no use for. The loose wordings now require the
rejected thing to be a code or to end there.

CodeRabbit Major (#1257) — the OOM test asserted on SOURCE TEXT, which passes
even if the call is unreachable or its result discarded; #1224 taught this same
lesson on this codebase. Both it and the language rewrite now drive the real
_run_backend_inference with a raising backend.

CodeRabbit Minor (#1257) — 'or "engine" in message' always passed, since the
production template contains the word. Asserts the resolved class name now.

CodeRabbit Major (#1252) — the delete-race test deleted the job BEFORE the
merge, which only re-tested the absent case and would pass with the two steps
still split. It now interleaves a real second thread against a slow save.

CodeRabbit Major (#1256) — a hardcoded /tmp literal trips Ruff S108; built from
tmp_path instead.

* fix(review): a withdrawal must survive the job's first write

CodeRabbit Major — the concern is real, though its suggested fix (gate the
checkpoint on the job already existing) would break creation: an ingest's FIRST
persistence is what creates the entry, so that gate would never pass.

The actual defect is that dict membership cannot express 'withdrawn'. An absent
key means 'not written yet' for a new job and 'deleted' for an established one
— two opposite instructions from one signal. So a clear-history arriving before
the first checkpoint was silently undone by that checkpoint recreating the row,
and the run then persisted its result into history the user had just cleared.

Tombstone it explicitly: the ingest declares itself in flight, a purge marks any
in-flight id withdrawn, and both write paths refuse a withdrawn id. Released in
, so it's bounded by concurrent ingests and can't poison a later run
that reuses the id.

That also fixed clear-history properly: a job with no row yet appears in no id
list, so only an in-flight sweep can catch it.

CodeRabbit Minor — my race test waited on an event that could not be set while
the save held the lock, so it burned its full 2s timeout every run and
synchronised nothing. It now waits for the purge thread to REACH the purge.

* test(dub): the race test was not testing the race

Caught by verifying fail-before rather than trusting the test: splitting merge
from save — the exact resurrection bug — passed all 22 tests.

The assertions checked WHAT happened (the save ran, the row was deleted, the
job left memory) but never WHEN. A save landing after the delete is
indistinguishable from one landing before if you only assert that both
occurred — and 'after' is precisely the resurrection.

Now recorded and asserted as an order. With merge+save atomic the purge cannot
start until the save finishes, so the sequence is always save-then-delete;
split them and it fails with ['delete', 'save'].

That is the second time this test needed rewriting: v1 deleted the job before
the merge and only re-checked the absent case, v2 interleaved a real thread but
asserted the wrong thing. Both looked like tests.

Also documents why the DB write sits inside the lock (atomicity beats a rare
5 s sqlite busy-timeout stall) and that no locked region calls another, so the
non-reentrant lock cannot deadlock — verified by walking every locked region.

* fix(dub): gate the withdrawal at save_job, not at its callers

Greptile P1 — and the same class I'd already fixed, unfixed elsewhere. The
withdrawal check sat in the two ingest helpers, but eight direct save_job call
sites across dub generate / translate / export / core bypass those entirely.
Deleting a dub mid-RENDER therefore still resurrected it, which is at least as
likely as deleting mid-import.

Moved the gate into save_job itself: one choke point, every caller inherits it,
and the ninth cannot forget. That needs a re-entrant lock, since the atomic
helpers call save_job while already holding it — a plain Lock would deadlock
the backend, so a test pins the lock type and another exercises the nested path.

Verified fail-before: removing the gate fails the new test.

* fix(dub): the withdrawal only covered ingests, so it covered almost nothing

Caught by testing the reported scenario directly instead of trusting a green
suite: CI passed, 26 tests passed, and a dub deleted during a RENDER was still
resurrected.

The tombstone was scoped to in-flight ingests. But a dub is imported once and
rendered many times, so the realistic delete lands during a render — long after
its ingest ended — and end_ingest was CLEARING the tombstone at exactly that
point. The rare case was protected and the common one left open.

Now scoped to deletions, not ingests. Kept in a bounded LRU rather than cleared
on completion, because there is no moment at which a delete stops mattering:
any operation still holding that job can persist it. Re-importing an id is the
only thing that legitimately revives it.

Verified fail-before: the previous scoping fails three of the new tests.

* refactor: move the dub delete-resurrection fix to its own PR (#1270)

The six fixes left here are independent error-message changes that needed no
corrections. The dub concurrency change needed five rounds, each finding
something real in work that was already reviewed, tested and CI-green — the
last of them being that the fix did not fix the reported case at all.

Riding a release on that record is a bad trade, so it ships separately as
#1270. This branch keeps #1262, #1256, #1251, #1247, #1257 and #1254; the
KeyError message half goes with the dub PR, since it is that issue's other
half.
2026-07-26 12:39:29 -07:00
debpalash 3e2b089698 docs(changelog): rebuild [Unreleased] after the main merge 2026-07-23 05:43:24 +05:30
debpalash 6d42db5052 Merge main into fix/low-vram-preflight-1226
# Conflicts:
#	CHANGELOG.md
2026-07-23 05:25:25 +05:30
debpalash 95fae6df0d Merge main into fix/download-resume-oom-1224
# Conflicts:
#	CHANGELOG.md
2026-07-23 05:25:19 +05:30
debpalashandClaude Opus 4.8 90bc6f95db fix(download): #1224 review — a settled Hub verdict must not retry
Every HfHubHTTPError was retried (pre-existing, preserved when the guard was
extracted), so a wrong token or a gated repo burned all five attempts with
backoff before showing the user the same message and postponed the install
cooldown. 401/403/404/410 now fail fast; 429/5xx keep retrying.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 04:29:17 +05:30
debpalashandClaude Opus 4.8 4fc494383b fix(download): #1224 review — replace the source-grep tests with real ones
CodeRabbit's Major finding was fair and the most useful of the set: three of
my tests asserted that a NAME appeared in a module's source. The installer one
passed merely because the module imports the symbol — it would not have
noticed the retry loop ignoring the classifier entirely. Tests that can't fail
for the reason they exist are worse than no tests.

Replaced with behaviour:

- the installer's retry decision is now a named helper, tested against a REAL
  httpx.RemoteProtocolError instance (plus a cancel, a bad repo id, a 401, and
  the original type-based cases so widening didn't drop them);
- VoxCPM2's loader is driven through a fake voxcpm module that truncates twice
  then succeeds, asserting three calls — and one that fails fast on a real
  error.

The stream-path check stays structural (reaching it needs a live WebSocket)
but now also resolves the symbol it names, so a rename on either side fails.

Two P1s fixed as well:

- the closed-client reset incremented the same counter as the download
  retries, leaving a resumable multi-GB download one attempt short of its
  configured budget. The two budgets are now genuinely independent.
- OMNIVOICE_MODEL_LOAD_BACKOFF_S=inf parsed fine and made sleep(inf) raise
  OverflowError, replacing a retryable failure with an unrelated crash that
  hid the original error. Non-finite values fall back to the default.

CHANGELOG entries shortened (CodeRabbit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 03:59:31 +05:30
debpalashandClaude Opus 4.8 63d40c33ce fix(synth): #1227/#1221 review — classify on a marker, not on shared wording
Two P1s, both correct:

- The enriched write failure lost its class. _describe_write_failure rewrites
  the message, so the word "libsndfile" no longer appears — classify()
  returned "" and the auto bug report and docs deeplink had nothing to name.
  Worse, my own end-to-end test allowed "" as a pass, which is exactly why it
  went unnoticed. audio_io now emits a stable AUDIO_WRITE_FAILED_MARKER,
  failure.py matches that, and the assertion is exact.

- "error opening" was far too broad. It appears whenever a model, archive or
  config file fails to open, so any such failure was handed the audio-file
  remedy (check your disk, add an antivirus exclusion). Dropped in favour of
  the marker; a regression test pins that a corrupt-model-archive error keeps
  its own guidance.

A third test pins the marker across the two modules — core/ cannot import
services/, so the string is duplicated by necessity, and a reword on either
side would silently un-classify every enriched write failure.

CHANGELOG entries shortened with refs last (CodeRabbit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 03:45:19 +05:30
debpalashandClaude Opus 4.8 6715551c2a fix(routing): #1226 review — scope the VRAM floor to where it was measured
Two P1s, both correct:

- MPS mislabel. HostCaps.vram_gb on MPS is a heuristic (system RAM / 2) for a
  UNIFIED memory pool, so an 8 GB Mac reports 4.0 "VRAM" — comparing that to a
  floor measured on discrete CUDA hardware would warn every small Mac about an
  engine that runs fine there. The caveat is now dedicated-VRAM families only
  (cuda/rocm); MPS has a different memory model and no measured floor.

- Engine-agnostic timeout. _timeout_guidance serves EVERY job on the GPU pool
  (reference transcribe, stream assemble, watermarking, dub steps, CPU-only
  engines on a GPU host), and a hardcoded 6 GB threshold applied without
  knowing whose job it is would confidently misdiagnose most of them. The
  floor is now passed in via run_on_gpu_pool_guarded, defaulting to 0 — so the
  under-provisioned wording is opt-in and only the TTS generate dispatches opt
  in. A test asserts every "TTS generate" dispatch passes it, so the branch
  can't become unreachable in production.

CHANGELOG entries reworded to end with their refs (CodeRabbit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 03:28:22 +05:30
debpalashandClaude Opus 4.8 6cfef5c0cf fix(routing): warn about an under-provisioned GPU before the job, not after (#1226, #1222)
Two users on 4 GB cards (GTX 1650 Ti, Quadro P2000) ran the `omnivoice`
engine, waited out the full compute budget, and were told the job "was too
heavy for the available compute … most often the GPU is VRAM-starved".

The 300s-vs-372s spread between the two reports is purely text length
(`300 + (len-1200)/40`, so 372s ⇒ ~4080 chars) — one bug, not two. Nothing
about the budget is device-aware, and nothing needs to be: the real defect is
that until the moment it failed, routing showed a clean green "accelerated".
`resolve_routing` matched on GPU *family* only, so a 4 GB card and a 24 GB
card were indistinguishable, and no engine declared a VRAM requirement
anywhere in the repo.

- `TTSBackend.min_vram_gb` — advisory metadata alongside `gpu_compat`. Only
  `omnivoice` declares one (6 GB), derived from the pool's own measured
  per-job budget (`_GPU_VRAM_PER_JOB_GB = 5.0`) plus resident weights.
  Inventing floors for engines with no measured figure would put confident
  numbers in the UI that nothing backs.
- `resolve_routing` takes the floor and emits an accelerated-with-caveat
  reason when the host is below it. Reuses the existing caveat channel, so
  the Settings matrix and the synth-time routing notice surface it with no UI
  change. Advisory, never blocking: drivers page to system RAM, and short
  inputs fit where long ones don't. Kernel-risk still outranks it, and a
  failed VRAM probe (0.0) never guesses.
- `_timeout_guidance` names the actual card and its VRAM, and leads with
  "pick a lighter engine" instead of wording that reads as transient
  contention the user can flush their way out of.

Regression test: tests/test_low_vram_advisory.py (8 of 12 fail before),
including that the 300/372 spread really is just text length.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 03:03:32 +05:30
debpalashandClaude Opus 4.8 ed321c56e6 fix(download): retry a truncated model download instead of aborting (#1224)
The reporter's captured log tail, just before the backend was SIGKILLed:

  httpx.RemoteProtocolError: peer closed connection without sending complete
  message body (received 4084175097 bytes, expected 4580080592)

A 4.6 GB model died at 4.0 GB — the single most retry-worthy failure in the
download path, and it was retried nowhere:

- the installer's loop caught (HfHubHTTPError, LocalEntryNotFoundError,
  OSError). httpx.RemoteProtocolError inherits Exception, NOT OSError, so it
  escaped all five attempts. The loop now decides by CLASSIFICATION rather
  than exception type, so the next transport error with a novel type doesn't
  reopen the same hole.
- is_hf_connectivity_error — the single source of truth for "transient
  download failure" — had no truncation signature, so a widened catch alone
  would still have called it permanent. It now knows the httpx wording plus
  the urllib3/http.client equivalents (IncompleteRead, "connection broken",
  "response ended prematurely").
- the engine load path had no retry at all. VoxCPM2 and MOSS-TTS-Nano now go
  through the existing _retry_once_with_fresh_hf_client hook, widened to
  retry transient download failures with a bounded, backed-off budget. The
  HF cache is resumable (correctly-sized blobs are skipped by hash), so a
  retry continues rather than restarting.

The closed-client path (#880) keeps its single-shot budget deliberately: it's
a client-state bug, not a network condition, so a fresh session hitting it
again means repeating won't help. Both budgets are now pinned by tests.

Also logs the low-memory advisory in the STREAMING synth path. /generate has
done this since the earlier 16 GB-Mac reports, but the streaming path — which
the desktop UI tries first — did not, so the load most likely to tip a machine
into an OS OOM kill was the one load leaving no trail in the captured stderr
tail a SIGKILL report has to go on.

Regression test: tests/test_truncated_download_retry.py (9 of 13 fail before).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 02:47:22 +05:30
debpalashandClaude Opus 4.8 8cef017ee5 fix(synth): name the App Control block and the libsndfile failure (#1227, #1221)
`_oom_friendly_reraise` classifies every known way a generate can die; two real
reports fell through to its "an error OmniVoice doesn't recognize" catch-all.

#1227 — `OSError: [WinError 4551] An Application Control policy has blocked
this file`. Windows Smart App Control refused to load a file the engine needs.
Now named, with the setting to change (and the caveat that Windows only lets
you turn Smart App Control off once). WinError 1260 — the same class from
AppLocker / Software Restriction Policies — is matched too, on the numeric
codes, since Windows translates the message text.

#1221 — `LibsndfileError: System error.`, libsndfile's bare wording for an
OS-level audio read/write failure: no path, no errno, no next step. Two parts:

- `audio_io._safe_torchaudio_save` now re-raises write failures naming the
  target, whether its folder exists and is writable, and the drive's free
  space — the facts that identify a full disk, a removed drive, or an
  antivirus/OneDrive lock. (It cannot re-use the original type:
  `LibsndfileError.__init__` takes an integer libsndfile code, so
  `type(e)(message)` builds an exception whose `str()` raises.)
- `_oom_friendly_reraise` covers every other libsndfile surface (reading a
  reference clip, a decode) with the same causes.

Both get a `core.failure` class + hint (WINDOWS_APP_CONTROL_BLOCKED,
AUDIO_IO_FAILED) so the auto bug report and docs deeplink name them.

Regression test: tests/test_synth_error_classes.py — 11 of 12 fail before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 01:55:08 +05:30
debpalash f368a05987 fix(voices): #1219 review — close the /use dedup race + generic materialize error
- /archetypes/{id}/use: re-check the personality row inside the write connection
  right before INSERT (the pre-render SELECT could race a concurrent /use of the
  same archetype). Reuse the winner's row and drop the just-rendered sample. A
  UNIQUE index isn't viable — marketplace/persona imports reuse the personality
  column — so this closes the realistic single-user window (greptile P2).
- VoiceSelector: log the raw materialize error to the console and show a generic,
  actionable localized message instead of interpolating backend detail; drop the
  {{message}} placeholder from voiceSelector.addVoiceFailed across all 21 locales
  (CodeRabbit).
2026-07-21 05:31:22 +05:30
debpalashandClaude Opus 4.8 246582341b feat(voices): pick voice-gallery (archetype) voices anywhere via materialize-on-select (#1219)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 05:10:49 +05:30
debpalashandClaude Opus 4.8 9d027b32c9 feat(audiobook): cast/voice mapping + markup toolbar + live stats + validation (#1217)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 01:54:13 +05:30
debpalash e52ab872c9 fix(audiobook): address #1216 review — abort on unmount, cancelled status, test race
- Abort the in-flight generation on tab unmount (useEffect cleanup): leaving
  mid-render no longer leaves the stream/backend job running, and a late
  done/error can't clobber a newer render's output (CodeRabbit Major).
- Record a client-disconnect Stop as job_store.mark_cancelled, not mark_failed
  — a user stop is a cancellation, not a failure (CodeRabbit).
- AudiobookStopCancel test: reject immediately if the signal already aborted
  before read(), instead of hanging to timeout (CodeRabbit).
2026-07-21 01:38:51 +05:30
debpalashandClaude Opus 4.8 dd4af735f3 feat(audiobook): stop/cancel generation + live per-chapter progress (#1216)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 01:38:51 +05:30
debpalash 84d34658fe Merge #1213: admin routes require the API key in SERVER_MODE (trusted-network privilege escalation) 2026-07-20 23:01:05 +05:30
debpalash 204eff2be1 fix(security): #1213 review — the share PIN must not gate RCE-class admin
CodeRabbit: the 6-digit share PIN is brute-forceable (10^6, no lockout), so
letting it unlock admin over the network was still weak. Admin now requires the
API key (a long operator secret) or loopback; the PIN is consumption-only and
never gates /system/* or /api/settings/*. A PIN-only deployment keeps admin
loopback-only. Docs (api-auth.md, remote-gpu.md) aligned with the conditional
gate (no credential -> admin open; API key -> admin) and the PIN exclusion.
Test inverted: presenting the PIN over the network is now 403 on admin.
2026-07-20 22:57:31 +05:30
debpalash d7608b1bc7 fix(audiobook): address #1208 review — bound expressive knobs, canonical markup tokens in all locales
- Add Field(ge/le) bounds to the expressive knobs so a loopback POST can't pin
  a GPU worker with an absurd num_step or feed the sampler out-of-range values
  (CodeRabbit Major). Regression test in test_audiobook_expressive.py.
- Localized markup hints had translated the bracket PARSER tokens
  ([voice:NAME]->[voz:NOMBRE], [slow]->[lento], mismatched [enfasi]…[/emphasis]),
  so users copying the localized examples would send unsupported tags. Restore
  canonical tokens in all 20 locales, keeping only the prose translated.
- Note the seed override in the changelog control list.
2026-07-20 22:44:56 +05:30
debpalashandClaude Opus 4.8 3b879f298e fix(auth): keep admin gate independent of trusted-network trust under server mode (#1213)
OMNIVOICE_SERVER_MODE=1 made require_loopback an unconditional no-op, so with
OMNIVOICE_TRUSTED_NETWORKS also set a trusted-CIDR client — a consumption-only
exemption that bypasses the PIN/API-key middleware via is_local_host — could
reach the RCE-class admin surface (/system/set-env, /api/settings/*) with no
credential. That collapsed the documented two-tier privilege model
(consumption trust != admin trust) in exactly the "lock the backend with a key,
exempt a LAN proxy for TTS" configuration.

Server mode still can't require true loopback (Docker NAT, #261), but it now
applies the credential rule to admin routes: open only when NO credential is
configured; otherwise the request must present the API key or share PIN.
Trusted-network membership alone never satisfies it. Loopback, credential
holders, and no-credential Docker deployments are unchanged; consumption
routes (require_local / middleware) keep exempting trusted networks.

Regression tests cover the server-mode x trusted-network x credential matrix
(fail-before/pass-after). Docs: new docs/api-auth.md two-tier model + quick
reference; docs/remote-gpu.md corrected (previously documented the hole as
accepted behavior).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:22:08 +05:30
debpalash 7f4d0ad9df chore(audiobook): correct issue refs to #1208 + changelog entries 2026-07-20 22:16:50 +05:30
debpalashandClaude Opus 4.8 a12a7e7ee1 feat(audiobook): expressive maturity — overrides, emotion, cache opt-out, discoverability (#1210)
Audiobook renders were locked to the model's most deterministic preset (32
steps / 2.0 guidance / model-default temps) with no way to change it, which is
why books sounded flatter than the same voice on the Voice page. Open that up
without changing any default byte-for-byte.

- Production Overrides in the Audiobook tab: position_temperature,
  class_temperature, num_step, guidance_scale, postprocess_output (+ seed),
  reusing the Voice page's panel. Unset reproduces today exactly.
- IndexTTS2 graded emotion (emo_vector / emo_text / emo_alpha) reaches the
  longform path via a typed engine-options object; engines that don't
  understand an option ignore it (no crash across the ~14 backends).
- Cache opt-out ("vary repeated lines") so identical lines can get distinct
  takes; default off keeps the content-addressed replay.
- Markup reference now lists the reaction tags that already work in audiobooks;
  docs/expressive-speech.md corrected so no recipe it names is unreachable.
- Fix AudiobookGenerateBody dropping `language`, so audiobook language
  selection actually reaches the backend.

Every new param is folded into BOTH cache layers (chapter + segment) and the
per-chapter preview, so changing a knob re-renders instead of replaying stale
audio — and an all-default request keeps its old cache key, so existing books
don't re-render. Regression tests: tests/test_audiobook_expressive.py (backward
compat, cache-signature loop, preview/render parity, engine-ignores-unknown,
cache opt-out, emotion reaches engine) + audiobookOverrides.test.jsx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:45:04 +05:30
debpalash 865be7510f Merge remote-tracking branch 'origin/main' into fix/1191-tts-stranded-on-cpu
# Conflicts:
#	CHANGELOG.md
2026-07-20 15:56:11 +05:30
debpalashandClaude Opus 4.8 fc7fbf1227 fix(gpu-pool): bound execution, not queue wait (#1190, #1202)
A job queued behind a busy 1-worker pool burned its whole 300s budget
without executing an instruction, then reported "too heavy for the
available compute". The clock now starts when a worker picks the job up;
queue wait has its own generous bound and surfaces as a retryable
saturation error.

Also: reset() no longer cancels innocent queued peers; the timeout
message stops claiming capacity was restored (the abandoned job keeps the
device until it drains); every GPU dispatch uses the shared length-scaled
budget; watermark embeds move off the GPU pool; /v1/audio/speech gets
429/503 + Retry-After; a timed-out batch segment fails the job instead of
shipping a silent gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 14:31:11 +05:30
debpalashandClaude Opus 4.8 220bfd0690 fix(dub): hoist the TTS-restore helpers out of gen()'s finally
CodeQL flagged the `return` inside the finally block ('break'/'return'
in finally swallows in-flight exceptions). The returns lived in a nested
def so nothing was actually swallowed, but the pattern is worth avoiding
outright: the dispatch helpers now live at endpoint scope and the finally
holds straight-line control flow only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 14:28:17 +05:30
debpalashandClaude Opus 4.8 0f70744105 fix(tts): stop stranding the TTS model on CPU after a dub abort (#1191)
`offload_tts_for_asr()` moves the TTS model to CPU to make VRAM room for
WhisperX, but its partner `restore_tts_after_asr()` was only reachable on
the dub-transcribe success path. Any abort, terminal error, or client
disconnect skipped it, and `get_model()` never re-checked placement — so
EVERY subsequent /generate ran on CPU (10-50x slower, CPU pegged) until
the ~15-minute idle unload happened to fire. Reported as "speed varies by
time of day"; it is fully deterministic.

Two independent guarantees:

- Balance the pair at the call site: gen()'s `finally` now pays the
  restore debt on every exit path, chained off the ASR unload so the two
  never contend for VRAM (and fire-and-forget, since the finally also
  runs under GeneratorExit where awaiting is illegal).
- Self-heal placement (the class fix): `get_model()` verifies the model
  is on the resolved target device and moves it back if not, so a future
  unbalanced offload path cannot strand it either. Cheapest-first probe —
  one parameter check on the hot path; unified memory is exempt (its
  offload releases the model rather than moving it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 14:17:47 +05:30
debpalashandClaude Opus 4.8 f3286c5e6e feat(dub): paste a translation from an external source onto existing segments
After transcription the user can paste a translation produced elsewhere
(ChatGPT, DeepL, a human translator) and have it map onto the segments
that already exist — no re-transcription, no timing loss.

Three input shapes are auto-detected: a timestamped .srt/.vtt (cues matched
to segments by time overlap, greedy one-to-one so one long cue can't be
copied onto several rows), numbered lines (`1.` / `2)` / `[3]`, mapped by
number and falling back to order when a model renumbers mid-answer), and
plain lines (positional, blank lines treated as separators rather than
empty translations). Nothing is applied until the preview dialog has shown
every row as before→after with unmatched rows flagged.

Applying goes through `pasteTranslations` in useSegmentEditing, which
mirrors `segmentEditField`'s duties across rows in ONE undo step: write
`text` and `translations[dubLangCode]` in lock-step and clear the stale
machine-translation badges. It never writes `text_original` (the translate
source `handleTranslateAll` reads — overwriting it would poison every later
re-translate) and never touches a language other than the active one.
Changing `text` alone marks those rows stale via the existing per-language
fingerprints, so no new flag is needed.

The new `POST /dub/parse-subtitle-text` is a stateless wrapper over the
existing `services.srt_parser.parse_srt`, so the lenient cue parsing stays
single-sourced instead of being reimplemented in JavaScript.

Also fixes a ReDoS in that parser, reachable today via /dub/import-srt:
`_TIMING_RE` used `^\s*` under re.MULTILINE, so at every line start the
engine consumed all remaining blank lines before failing on the first
digit — quadratic. 20k blank lines already took 1.7s and a 2 MB blank-line
file never returned, pinning the request thread. Horizontal-whitespace-only
classes make the scan linear.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 13:18:51 +05:30
debpalashandClaude Fable 5 23f1767e3c feat(analytics): in-repo publishable token — source builds get the same consent-gated analytics (#1193)
Owner-sanctioned reversal: the publishable write-only PostHog client key is
committed as the in-repo default in backend/core/analytics.py and
frontend/src/utils/analytics.ts (env / baked release token still wins), so
source builds show the same first-run consent ask as installers — skip = off,
nothing is ever sent without an explicit yes. Adds an install_channel property
(installer / docker / source) to lifecycle events, stamped by the desktop shell
via OMNIVOICE_INSTALL_CHANNEL and by the Docker image's existing
OMNIVOICE_SERVER_MODE marker. Guard tests now pin the two-canonical-files
allowlist + same-token invariant, and the uninstall-ping info file works on the
default token.

Fixes #1193

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:51:40 +05:30
debpalashandClaude Fable 5 68a479f6cf review(1198 follow-up): non-blocking ASR unload on the normal completion path too
CodeRabbit's finding covered both unload sites; the harvest fixed only
gen()'s finally. The success path still blocked the event loop for the
gc/CUDA-cache drop on every completed transcription.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:24:23 +05:30
debpalashandClaude Fable 5 0c7428b270 review(1198): fix all five bot findings on-branch
- fallback preflight pins the candidate backend id (Greptile) — also the
  CI empty-cache failure: deep-import fall-through tests get the
  asr_model_installed fixture
- locale ratchet: improvement warns instead of failing CI (CodeRabbit)
- stream-exit ASR unload runs on the GPU pool fire-and-forget instead of
  blocking the event loop (CodeRabbit)
- E741 rename; M4A ftyp sniff scope documented (CodeRabbit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:02:30 +05:30
debpalashandClaude Fable 5 83f943bead fix: bot-review harvest (16 findings) + deterministic style/locale CI + reviewer configs
Harvested and verified every CodeRabbit/Greptile finding from PRs #1175,
#1189, #1192, #1195: 16 real ones fixed (fallback ASR preflight bypass,
VRAM release on stream exit, typed 409 parity, uv env independence,
path-privacy in errors, MCP clone_voice hardening, CaptureWidget WS
guard, test hygiene), 4 refuted with evidence, rest documented as
deliberate design or deferred.

Deterministic CI replaces hand-enforcement: tests/test_changelog_style.py
(quiet one-liner format) and tests/test_locale_parity.py (21-locale
key/placeholder lockstep with a ratchet baseline) — the latter surfaced
and fixes 151 already-broken locale strings. CodeRabbit/Greptile carry
the house rules via .coderabbit.yaml + greptile.json; CLAUDE.md gains
the harvest-before-merge and never-accept-as-is rules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 09:47:41 +05:30
debpalashandClaude Fable 5 00a0c221b8 review(1197): keepalive over the TTS-model wait; retrieve abandoned load-future exceptions
Both Greptile P2s on the PR: the OMNIVOICE_PRELOAD_TTS_ASR get_model()
wait ran between the open comment and the ASR keepalive loop, leaving a
proxy-idle-timeout hole; and a client disconnect abandoned the executor
load future, logging 'Task exception was never retrieved' into crash
forensics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 09:31:43 +05:30
debpalashandClaude Fable 5 2056fddde9 fix(dub): run the transcribe-stream preflight inside the SSE contract (#1196)
The whole transcribe preflight (job lookup, TTS-pipe harvest, ASR-missing
check, eager ASR backend load) ran in the endpoint body, before the
StreamingResponse existed — outside the stream's terminal-event contract:

* an exception on any unguarded preflight line became an HTTP 500, whose
  body EventSource cannot read, so the UI could only show the generic
  'Transcribe stream dropped … likely ASR backend failed to load' guess
  while a perfectly alive backend knew the real cause;
* not one byte (not even response headers) went out until the ASR load
  finished — a first-run multi-GB weight download means minutes of total
  silence, tripping Chrome's hard ~5 min no-response timeout and
  reverse-proxy idle timeouts in front of Docker installs, severing the
  stream with that same generic message.

Move the preflight inside the guarded generator: headers plus an opening
comment byte go out immediately, keepalive comments (invisible to
EventSource) flow every ASR_LOAD_KEEPALIVE_S while the backend loads, and
ANY preflight crash now lands in the #516 finalizer as a structured
error + terminal done. No client changes needed.

Regression tests: preflight crash must stream a structured error (not
raise), and bytes must flow before/during a slow ASR load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 09:18:13 +05:30
debpalashandClaude Fable 5 933743e336 fix: resolve open issue batch — #1172 #1173 #1174 #1185 #1186 #1188
- validate managed binaries before exec; 0-byte GGUF placeholders fail
  actionably instead of "Exec format error" (#1172)
- KittenTTS: tokenizer-measured chunking to the ONNX 512-token cap;
  clear 400 for unspeakable input (#1173)
- clean SIGTERM during weight load: shutdown-aware loader, benign
  cancelled-load classification, lifespan hardening, scoped log
  silencers (transformers load + alembic fileConfig) (#1174)
- broken ASR deep-imports (lightning_fabric) mark the engine
  unavailable with a repair hint and fall through (#1185)
- uv cache + managed Python follow the chosen install drive on
  Windows (cherry-picked cross-drive class fix + spaces/D: tests) (#1186)
- adaptive silence-removal ladder for quiet clone references; localized
  actionable error for truly silent clips, all 21 locales (#1188)
- CHANGELOG: consolidated Unreleased into the quiet one-liner style

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 04:29:33 +05:30
debpalashandClaude Opus 4.8 0ff31f483b fix(dictation): demote a model that decodes nothing instead of re-selecting it
The curated default `sherpa-parakeet-tdt-v3` installs cleanly, loads without
error, and returns an empty token list for clear speech — while whisper and
zipformer transcribe the same bytes. Ruled out: quantisation (fp32 fails too),
sherpa-onnx version (1.13.3 and 1.13.4), decoding method (greedy and
modified_beam), and model_type (explicit and auto-detect). The defect is inside
sherpa-onnx's NeMo-TDT decoder and cannot be fixed here by configuration.

The previous commit made that survivable — a silent model falls back to the
capture ASR engine for the session. This makes it stop recurring.

Hard-coding a different default per OS would be a guess: there is evidence for
Windows only, and blanket-changing the curated pick could regress a platform
that works. So the app observes rather than predicts. When a session hears
speech-level audio and the model returns nothing, that model is demoted ON THIS
MACHINE and is no longer auto-selected; dictation routes to the capture ASR
engine instead. Self-correcting wherever the breakage actually is, a no-op
everywhere it isn't, and no round trip repeated on every later session.

Demotion is never a dead end: explicitly choosing a model in Settings clears it,
so a sherpa upgrade that fixes the decoder is reachable and the user stays in
charge.

Verified end-to-end on a clean backend: with parakeet pinned and no demotions,
one speech session demoted it ("demoted on this machine" logged), after which
dictation_model_id() returns None (capture ASR engine) while the pref is
untouched — and re-picking the model restores it. 12 tests cover the round
trip, per-model scoping, and the user-regains-control path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 11:26:07 -07:00
debpalashandClaude Opus 4.8 730ad5ec6f fix(dictation): survive a model that decodes nothing, and stop stranding the pill
Two bugs that together made dictation look completely dead on Windows.

1. Silent model. The curated default `sherpa-parakeet-tdt-v3` downloads, loads
   with zero errors, and is correctly detected as a TDT model — then returns an
   empty token list for clear speech. Measured on one 18.9s WAV, same machine,
   same sherpa-onnx:

       sherpa-whisper-tiny     -> "Alright, here we are. I hope that's all..."
       sherpa-zipformer-en-20m -> "ANTS BOTH IN WHAT DISGUISED THIS THAT..."
       parakeet-tdt-v3 int8    -> ''     <-- the default
       parakeet-tdt-v3 fp32    -> ''
       parakeet-tdt-v2 int8    -> ''

   Ruled out: quantisation (fp32 fails too), sherpa-onnx version (1.13.3 and
   1.13.4), decoding method (greedy and modified_beam), and `model_type`
   (explicit and auto-detect). The fault is inside sherpa-onnx's NeMo-TDT
   decoder, so the app cannot fix it by config — but it must not present it to
   the user as "dictation is broken". The offline handler now tracks whether it
   ever heard speech-level audio; if the model returns nothing anyway, it falls
   back to the capture ASR engine for that session and returns a `warning` +
   `model_silent` naming the model that failed. The pref is left alone, so the
   user stays in control. A quiet user still yields a quiet result.

2. Stranded pill. Error states deliberately never auto-dismiss so a failed
   paste keeps the transcript on screen. But the mic / model-missing / server /
   connection paths have no transcript to rescue, so they parked the widget on
   the user's screen forever ("the dictation bubble is permanently sticking
   when it's not used"). One effect now dismisses an error pill that carries no
   text, covering every existing path and any added later; delivery failures
   keep their text and their stay-up behaviour.

`is_model_silent` is extracted so the decision is unit-tested rather than
buried in the socket handler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 04:18:14 -07:00
debpalash 32f63469d1 Merge main into curated-ASR branch (resolve CHANGELOG)
# Conflicts:
#	CHANGELOG.md
2026-07-17 17:43:57 -07:00
Paolo Antinori f669687ed1 feat(backend): trust a local network/proxy via OMNIVOICE_TRUSTED_NETWORKS (#1170)
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.
2026-07-17 17:33:18 +02:00
debpalashandClaude Fable 5 63fd497caf feat: TTS-only first run, platform-curated ASR, guided OS permissions, parakeet-mlx
Only the TTS model (~2.4 GB) is required on first run; ASR models are
per-platform curated picks (curated_on in models.yaml) installed on demand.
Every transcription surface returns a typed asr_model_missing error with a
one-click download CTA instead of silently pulling multi-GB Whisper weights.
Settings -> Models is a grouped, platform-aware catalog. New guided
permissions UX (wizard System Check + Settings -> Permissions + mic
pre-flight) with native mic-state checks and OS settings deep-links. New
parakeet-mlx engine brings Parakeet TDT v3 to Apple Silicon (language-gated
capture preference so multilingual dictation never regresses). Docs:
expressive-speech page, Flush/Unload + CPU-fallback triage, clone-length FAQ.
Hardening: preflight fails open for custom model pins, ROCm curation no
longer inherits NVIDIA picks, Windows mic probe reads the NonPackaged
consent key, CaptureWidget setup race fixed, offline-cache CI simulation
fixes so empty-cache runners stay green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:20:54 +05:30
debpalash 3a7368cb26 fix(watermark): route every synthetic-audio producer through one mark_synthetic chokepoint (#1169)
POST /v1/audio/speech returned synthetic audio without the AudioSeal
provenance watermark while /generate marked the same text — and the
audit behind #1169 found the same class of gap in five more producers.
EU AI Act Art. 50(2) (applicable 2026-08-02, expressly carved out of
the open-source exemption in Art. 2(12)) makes machine-readable marking
of synthetic audio a provider obligation, so per-door coverage gaps are
compliance bugs.

Root cause: coverage grew call-site-by-call-site (three separate
embed_watermark calls) with nothing forcing a new producer to opt in.

The fix, per the whole-class rule:

* services/watermark.py grows `mark_synthetic(wav, sr, *, context,
  force=False)` — THE named chokepoint (delegates to embed_watermark:
  pref-gated, no-op without AudioSeal, never raises / degrades to
  unmarked) — plus `will_mark()` for cache-key derivation.
* Gaps closed at the tensor stage, before any encoding:
  - openai_compat `_run_tts` (the reported gap; all response_formats)
  - tts_stream /ws/tts (per-sentence, before PCM16 framing)
  - generation stream=true preview chunks (marked streamed copy; the
    saved take keeps its single whole-take mark in finalize)
  - batch dub pipeline (assembled track, before WAV write / aac mux)
  - longform chapter render — covers /audiobook, /longform/render
    (Stories), /audiobook/preview and /audiobook/resume; the chapter
    cache key now carries a watermark tag so stale unmarked cache
    entries can never be served for a marked-on render
  - dub preview-segment (docstring had declared it exempt)
  - archetype render (served preview + materialized profile reference)
* Already-covered paths (generate finalize, dub segments, persona
  bundles) migrated onto the same chokepoint.
* Documented non-producers/gaps instead of fake coverage:
  /stories/encode is a pure transcoder of user uploads (must NOT mark);
  the opt-in SoniTranslate sidecar synthesizes+muxes externally and is
  a documented provenance gap at its route.
* Regression tests: tests/test_synthetic_audio_watermark_1169.py runs
  detect_watermark() on the actual response audio of every producing
  route (real watermark service, fake AudioSeal nets, fake engine;
  verified fail-before on the pre-fix tree — 11 of 13 fail).
* Recurrence-proofing: tests/test_watermark_route_coverage.py
  structurally asserts every synthesis call site references
  mark_synthetic (justified allowlist), producers keep their call,
  and embed_watermark is never called outside the chokepoint.

Settings toggle semantics are unchanged (the issue's two legal
questions stay flagged for a lawyer, deliberately unanswered here).
2026-07-16 23:08:36 +05:30
zhifu gao 453bf91e7c fix(asr): restore FunASR SenseVoice diarization (#1167)
* fix(asr): restore FunASR SenseVoice diarization

* fix(asr): keep FunASR speaker identities global
2026-07-16 22:59:34 +05:30
debpalash a78b4707fe fix(longform): bind job_store at import so the library route can't resolve a leaked module world
The route did 'from core import job_store' at call time, re-resolving
through sys.modules on every request. Suites that purge and re-import
the core/services tree under a temporary data dir leak that stale world;
after one ran, the route's call-time import saw a different DB_PATH and
returned an empty library despite seeded jobs — the order-dependent
test_route_handler_returns_jobs_envelope full-suite flake. Also: a
failing list_jobs now logs error-level with the stack (it logged a
warning while silently serving an empty library).

Fail-before regression test simulates the leaked world; full combined
suite green (3225 passed).
2026-07-16 21:10:25 +05:30