vssaas-hosted-cancellation
100
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> |
||
|
|
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> |
||
|
|
e8bb174a0b |
fix(startup): explain a lost port race instead of exiting 1 in silence (#1364) (#1370)
* fix(startup): explain a lost port race instead of exiting 1 in silence (#1364) #1223 gave a port conflict a dedicated exit code, and handles the race between our pre-probe and uvicorn's real bind by re-probing after uvicorn dies. That only helps while the other process is STILL holding the port. The common case is an orphaned backend from the previous session which is itself shutting down. It releases the port between uvicorn's failed bind and our re-probe, the probe reports "free", and the user gets a bare `exit code 1` -- for a crash we had already fully diagnosed. Reported on Windows with the tell-tale ordering: `Application startup complete` (uvicorn's lifespan runs before the bind), then `[Errno 10048] error while attempting to bind`, then a plain exit 1. uvicorn already hands us the answer: its startup does `logger.error(exc); sys.exit(1)` with the OSError itself as the record's message, so the errno is available as an object -- no locale-dependent string matching, which is the trap #1223 exists to avoid. Observe that record, believe it first, and keep the re-probe as the fallback. The watcher also pins the uvicorn.error level to at most ERROR: a filter only runs on records the logger emits, so a higher level would drop the bind failure and silently restore the unexplained exit 1. No-op today (uvicorn defaults to INFO and nothing here raises it) -- it stops the mechanism being disarmed at a distance later. Two regression tests, both driving the real uvicorn. The race is simulated deterministically by making both probes report the port free while it is genuinely held, which is exactly the state the race leaves us in; verified to exit 1 without the watcher and 78 with it. The assertion is on our own wording, not "already in use" -- that phrase is also in uvicorn's own English log line, so matching it would pass against the unfixed build and would be the very locale-dependent match #1223 forbids. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(startup): pin what the bind watcher actually depends on (#1364) Review round on #1370. greptile P1 claimed uvicorn's logging setup removes the filter, making the watcher inert. Measured against the installed uvicorn: it does not. `dictConfig` replaces a logger's HANDLERS and leaves its FILTERS alone, and the end-to-end test already exercised the real `uvicorn.run` and got exit 78. The conclusion was right for a different reason, though, and it caught a genuine mistake: uvicorn resets `uvicorn.error`'s LEVEL from its config during startup -- after the defensive `setLevel(ERROR)` this added. That guard was dead code offering false assurance, so it is gone. The real precondition is that the guarded `uvicorn.run()` must not raise log_level above ERROR, since a filter only runs on records the logger emits. Both behaviours are now pinned by tests that measure the installed uvicorn rather than assuming it, so a version that starts clearing filters, or a change that quietens the guarded serve call, fails loudly instead of silently restoring the unexplained exit 1. The log_level test is scoped to the guarded call -- the --health-check smoke path sets log_level="warning", which is below ERROR and irrelevant. Also pins the production wiring itself (CodeRabbit): the end-to-end tests rebuild the guard from extracted source, so they would still pass if main.py stopped installing the filter or stopped consulting it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(startup): check log_level by AST, not regex (#1364) CodeRabbit on #1370: the regex only recognised string literals, so `log_level=settings.level` -- or any computed value -- matched nothing and the assertion passed while verifying nothing. That is the same class of bug as #1357's pin that did not apply: a check that looks present and is inert. Parsed with ast now; a non-literal is an explicit failure rather than a silent skip, and the guarded call is located by walking to the addFilter and taking the uvicorn.run after it instead of by source order. Verified against three mutations of main.py: literal critical -> FAILED computed value -> FAILED literal warning -> passed (below ERROR, must not fail) 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> |
||
|
|
c819372449 |
fix(install): make the torch pin reach the Colab and Docker installs (#1357) (#1369)
* fix(install): make the torch pin reach the Colab and Docker installs (#1357) #1358 pinned the trio in `[tool.uv] constraint-dependencies`, which fixes `uv sync` / `uv lock` / `uv run`. It does nothing for `uv pip install` -- that is the pip-compatible interface and ignores project-level uv settings -- and `uv pip install --system --no-cache .` is exactly what both the Colab notebook and deploy/Dockerfile run. Measured on one Python 3.12 environment, same command, pin present: without --constraint: torch 2.13.0 torchaudio 2.11.0 torchvision 0.28.0 with --constraint: torch 2.8.0 torchaudio 2.8.0 torchvision 0.23.0 So the reported install path was still resolving the three on their bare lower bounds (`torch>=2.4`, `torchvision>=0.19`), free to move torch past a torchvision built for an older ABI -- which is the reported failure, `operator torchvision::nms does not exist`, against the preinstalled torchvision in Colab's /usr/local/lib/python3.12/dist-packages/. The pins move to deploy/torch-constraints.txt and are passed explicitly at both call sites. No local version segment, so PEP 440 matches the base images' +cu128 and +rocm6.4 builds instead of replacing them -- the property the ROCm image depends on. Also extends the Docker guard, which asserted on torch and torchaudio only, omitting the one package that actually broke. It now imports torchvision.ops and touches nms, so an ABI mismatch fails the build rather than shipping. Recurrence: docker.yml builds only on push to main, never on a PR, so nothing would have caught a silent regression here before it shipped. tests/test_torch_constraints_are_applied.py fails if the file drifts from pyproject, if either call site drops --constraint, if the Dockerfile stops COPYing the file, or if the guard stops covering torchvision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(install): assert the constraint in the argv, not the cell text (#1357) CodeRabbit on #1369, both findings valid. The notebook check scanned the whole cell, so it passed when --constraint was deleted from the run([...]) list but its explanatory comment survived -- exactly the "the pin looks present but does not apply" shape this PR exists to fix. It now parses the cell with ast and asserts --constraint is in the argument list AND immediately followed by the constraints file. Verified by deleting the flag from the argv while keeping the comment: the test fails. Also drops test_the_notebook_is_still_valid_json_and_has_its_cells -- it passed before the change and duplicated JSON parsing the constraint test already does. A tautology. 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> |
||
|
|
3183bf5fcd |
fix(engines): downmix along the channel axis, not axis 0 (#1328) (#1366)
* fix(engines): downmix along the channel axis, not axis 0 (#1328) Found while reviewing #1328. Every subprocess sidecar guards its PCM conversion with a defensive `arr.mean(axis=0)`, which is correct only for channels-first audio. For a channels-last (N, 2) array `squeeze()` keeps both axes and the mean runs across TIME: every output sample becomes the mean of two neighbouring samples and the render collapses to 2 samples. That is not a downmix, it is a destroyed waveform played back as noise. Unreachable in all five today because every engine returns mono -- which is precisely why it could sit there being wrong. Nothing runs it, so nothing reports it, and the first engine or SDK version to emit stereo gets noise with no error anywhere. Pick the channel axis instead of assuming it, and loop so a stray extra axis reduces the whole way to mono; previously a (2, N, 2) array stayed 2-D after one mean and produced a PCM buffer whose length disagreed with the n_samples in the frame -- a desynchronized audio frame rather than a merely wrong-sounding one. Downmixing correctly rather than raising (the choice PocketTTS made on #1328): these five are shipping engines, and turning a render that works today into an error is a regression risk that the actual defect -- the wrong axis -- does not require taking. The sidecars run under different interpreters (confucius4 and dots.tts each have their own venv), so they cannot import a shared helper and the duplication cannot be refactored away. The recurrence guard is therefore a test that holds all five to the same behaviour at once, so a sixth copy pasted into a new sidecar fails there rather than shipping: 20 of its 30 cases fail before this change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(engines): let a broken sidecar fail instead of skipping CodeRabbit Major on #1366: the blanket `except Exception -> pytest.skip` turned a syntax error or an import-time regression in any of the five sidecars into a skip, so this regression suite could pass CI while running nothing. All five are stdlib-only at import (torch and the model load lazily on the first synthesize), so there is no optional dependency to tolerate -- an import failure here is a real defect in a shipping engine. Unguarded. 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> |
||
|
|
c117bca09e |
fix(release): rebuild + cryptographically verify the preview updater manifest (#1327) (#1362)
* fix(release): rebuild + cryptographically verify the preview updater manifest Since ~2026-07-13 every nightly matrix leg logs 'Signature not found for the updater JSON. Skipping upload...' - tauri-action uploads the bundles and .sig companions but never refreshes latest.json. Combined with the 'Clear this arch's stale preview updater bundle' step (which deletes and replaces the version-less macOS tar.gz every night), the preview manifest's darwin signatures no longer match the published files: macOS Preview users hit 'The signature verification failed' on every update (latest.json frozen at 2026-07-13, tar.gz replaced nightly). Two changes, both in the single post-matrix preview-notes job (no per-leg race): 1. Rebuild latest.json from the release's real assets and their .sig companions, then clobber-upload. The manifest can no longer drift from the files it describes, regardless of what tauri-action's own updater-JSON path does or skips. 2. Extend the existing manifest verification with a cryptographic check: every signature in latest.json must verify (minisign file sig + trusted-comment sig) against the artifact it points at, using the updater pubkey from tauri.conf.json. Parity and version format both passed for 2+ weeks while every darwin entry was unverifiable - this is the check that was missing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(release): refuse a preview manifest built from two different runs Bot review findings on this branch, all fixed here: - The AppImage and MSI were picked independently by highest run number and the larger N became *the* version, so a matrix where one leg failed or was re-run published a manifest advertising X.Y.Z-5 while handing Windows users the -4 MSI. That is the same manifest/artifact drift this job exists to end, reintroduced by the fix for it. Require both legs to come from one run and fail loudly otherwise: leaving the previous manifest in place is a visible, already-understood state; shipping a mismatched one is not. The darwin tarballs carry no run number, so the signature check in the following step is what pins those to the published bytes. - persist-credentials: false on the checkout — nothing here pushes to git. - Floor-pin the cryptography install; this step decides whether a signed manifest is trustworthy, so it is the one dependency worth a bound. tests/test_release_preview_manifest_rebuild.py runs the step body extracted from release.yml against stubbed gh, so it cannot drift from the workflow. Fails before / passes after on the mismatch case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(changelog): note the preview updater manifest fix (#1327) Co-Authored-By: Pinkers01 <pinky.bouw@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci(release): verify the preview manifest before publishing it, not after Two more review findings on this branch, both valid, both about the manifest being wrong in a way the existing checks structurally cannot see. greptile P1 — verification ran AFTER the clobber-upload. A manifest that failed the check was already live and stayed served; the job merely went red, and every macOS Preview user stayed broken until someone noticed. Verification now runs against the file about to be published, and the upload is the last thing in the step. A refusal leaves the previous manifest in place, which is a visible, already-understood state. CodeRabbit — the darwin entries were not tied to this run. The version comes from the AppImage name; the macOS tarballs were only checked for existence. Signature verification cannot help there, because a stale tarball and its stale .sig match each other perfectly — so a run whose macOS legs never uploaded would advertise this version while serving Mac users the previous build, and since those clients keep reporting the old version the updater would re-offer it forever. They are now bound by upload time, with two minutes of slack for legs that finish apart. The selection rules move out of the YAML heredoc into scripts/build_preview_manifest.py. Three findings in a row have been about WHICH artifacts may be described together, and a heredoc can only be tested by extracting it and stubbing a shell — which is what the previous test file did, asserting against gh stubs rather than against the rules. build_manifest is pure: assets in, manifest out, ManifestRefused on anything it will not describe. 14 tests, including both new refusals and two that pin the workflow still calls the module and still uploads last — an inline copy would pass every other test and ship the original bug. Co-Authored-By: Pinkers01 <pinky.bouw@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Pinkers01 <pinky.bouw@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
946ef97af7 |
test: stop the load-budget test racing the AudioSeal cold start (#1363)
test_speech_survives_a_load_slower_than_the_generate_budget passed in a full session and failed when the file was run alone — the wrong way round, because the isolated run is the honest one. The cause is not ordering as such. Watermarking runs INSIDE the generate budget, and its first call in a process loads the AudioSeal model. That takes longer than the 0.2s budget this test deliberately sets, so the request 503`d on the watermark rather than on anything to do with the load/generate split it exists to prove. In a full session an earlier test had already warmed AudioSeal, so it passed for a reason unrelated to what it asserts. Disabled for the duration. The budget asymmetry is what is under test; the watermark is an unrelated cold start that happened to ride in the same window. Verified isolated, in a session with its sibling timeout suites, and repeatedly. Pre-existing on main, unrelated to any current change — found while running related suites for #1338. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
c03e3f2525 |
fix(gpu): record WHERE a wedged job was stuck when its budget expires (#1338) (#1355)
* fix(gpu): record WHERE a wedged job was stuck when its budget expires Three reporters on v0.4.2 hit "TTS generate ran for more than 300s of actual compute time and was abandoned" (#1338, #1329, #1348) — two of them on an RTX 3050 and an RTX 3060, rendering a single sentence. That is not a machine too slow for the job. The message says "too heavy for the available compute" because it is the only story the timeout path can tell. And nothing in the log could contradict it. The timeout branch logged THAT the budget was exceeded, reset the pool, and returned. The worker cannot be cancelled, so it was still running on a real stack — and we threw that away, which is why every report of this class arrives undiagnosable and the only advice available is "reproduce it under a debugger". sys._current_frames() reads the frame of every live thread including one wedged inside a C call, which is exactly this case. Filtered to gpu-pool workers so the log names the stuck job rather than the web server, capped at 25 frames, and it can never raise — a diagnostic that throws would replace a real GpuJobTimeoutError with an unrelated crash. Ordering is load-bearing and asserted: the capture runs BEFORE reset(), because reset() swaps in a fresh executor and the wedged thread then stops being identifiable as a pool worker — the diagnostic would still run, still log, and be empty, which looks like it worked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(gpu): redact home paths from captured stacks; label stale workers Both review findings on this PR, both valid. CodeRabbit (CWE-532): traceback frames carry absolute source paths, which on a user machine start with their home directory — their account name. This log lands in backend.log, which goes into diagnostic bundles and prefilled bug reports, so it has to be sanitized like every other surfaced text. Reuses core.failure.sanitize rather than inventing a second answer; if sanitizing itself fails the stacks are dropped, not logged raw. greptile P1: a wedged worker survives reset() — it cannot be cancelled and keeps running under the same gpu-pool name the replacement pool uses. The second timeout in a session would log both with nothing to tell them apart, and the stale one is the more misleading, since it names an operation that is not the job that just failed. The live pool is now identified through its own thread set and the others are marked STALE. That set comes from ThreadPoolExecutor._threads, which is private, so unknown internals degrade to labelling nothing rather than to failing — a diagnostic that vanishes because an attribute moved is worse than an unlabelled one, and that degrade path has its own test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
95de0afb02 |
fix(appimage): the preload probe never ran, and tested the wrong value (#1333) (#1356)
* fix(appimage): the preload probe never ran, and tested the wrong value Two CodeRabbit Majors on #1354, landed after merge because I merged before reading them. Both silently DISABLED the feature rather than breaking loudly, which is the shape that survives a green suite. 1. `command -v true` answers with the shell BUILTIN — the bare word "true", not a path — so `[ -x "true" ]` was false on every host, the probe always failed, the preload never happened, and #1333 was left exactly as it was. A builtin never involves the dynamic loader, so it could not have tested anything even if it had run. Now resolves a real binary (/usr/bin/true, /bin/true, or /bin/sh -c : as the guaranteed last resort). 2. The probe took our library alone, but the exported value appends any inherited LD_PRELOAD — so the probe could pass while the environment the app actually gets fails. It now probes the final value. The suite missed both because every existing case overrides the probe via OMNIVOICE_APPRUN_PRELOAD_PROBE. The new default-probe case is what closes that hole; the inherited-entry case is the discriminator for (2). Both fail against the previous AppRun. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(appimage): drop a probe fallback that could never resolve; cover /bin/sh CodeRabbit, valid. The PATH entry looked up `coreutils`, which is not an executable name, so that branch could never resolve — a fallback in shape only. Deleted rather than repaired: a PATH lookup is what caused the original builtin bug, and the list already terminates at /bin/sh, which is present on any host that can run this script. That left the real last resort untested, which is how the branch above it shipped broken in the first place. `sh` needs `-c :` where `true` needs no argument, and with no argument `sh` reads stdin and hangs — so the new case points the probe override at the real /bin/sh and fails loudly if that branch is wrong (verified: breaking the argument turns it red). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
810b598739 |
fix(appimage): let the host GStreamer win, and stop sharing its registry (#1333) (#1354)
* fix(appimage): let the host GStreamer win, and stop sharing its registry (#1333) Recording from the AppImage failed with "No microphone found" on a Debian 13 host whose audio stack the reporter verified healthy (pactl, wpctl, gst-launch with both pulsesrc and pipewiresrc), while the same build`s raw binary recorded fine. GST_DEBUG=2 named it: WARN GST_REGISTRY gst_registry_binary_check_magic: Binary registry magic version is different : 1.23.90 != 1.3.0 GStreamer element appsink not found. Please install it. linuxdeploy bundles libgstreamer-1.0 because WebKit links it, but not the plugins: those are dlopen`d, so nothing static can see them to copy. The bundled core falls back to the host plugin directory, whose plugins were built against the host core, the version check rejects them, and the scan yields nothing. appsink is one of the casualties and it is the element WebKit hands a capture stream to, so getUserMedia() rejects NotFoundError. Same class as #1258 (frozen bundled library against a host that moved on) in a different library, which is why OMNIVOICE_PREFER_SYSTEM_WEBKIT=1 did nothing for the reporter. Since we ship no plugins, the host core is the only one that can agree with the plugins that will load — so prefer it, with OMNIVOICE_PREFER_SYSTEM_GSTREAMER=0 as the escape hatch. Also isolate the registry cache. GStreamer keys ~/.cache/gstreamer-1.0/ registry.<arch>.bin by architecture alone, so two cores of different versions clobber each other`s file: that makes the failure depend on which app ran last, and the AppImage corrupts the cache for every other GStreamer app on the machine. Both directions go away with a private path. AppRun.test.sh covers host-present, host-absent and opt-out; all three fail against the previous AppRun. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(appimage): cover the ldconfig discovery path; docs fixes CodeRabbit, all three valid: - every GStreamer case forced ldconfig to fail, so the runtime-only-host fallback (no -dev package, hence no .pc file) was never exercised. The cases now select their discovery path, and the new ldconfig one fails if that branch is removed. - MD040: the GST_DEBUG fence had no language tag. - the registry cache path follows XDG_CACHE_HOME when set; ~/.cache is only the default. Documented, along with WHY the shared file is a problem. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(appimage): compose LD_LIBRARY_PATH once; host WebKit stays first CI caught a real regression, not a flaky test. The GStreamer block prepended its own directory, which put it AHEAD of the host WebKit dir — and "host WebKit first" is the invariant #1258 turns on. On a host where the two libraries live in different directories that silently changes which WebKit resolves. It only showed on Linux because the WebKit ldpath cases do not stub away a real host GStreamer, so the runner had one to find and macOS did not. Reproduced locally with an ldconfig shim, and confirmed the ordering is what fixes it: with the old order the suite is 19/2, with this one 21/0. Both decisions now compose one path in one place — host WebKit, host GStreamer, bundle, inherited — so neither preference is weakened and the ordering is stated where it is applied rather than implied by two independent prepends. Same directory for both (the common case) is not listed twice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(appimage): preload the host GStreamer instead of hoisting its libdir greptile P1, valid. The host GStreamer lives in a general system library directory (/usr/lib/x86_64-linux-gnu on Debian), so putting that directory ahead of ${HERE}/usr/lib replaced EVERY other bundled library with the host copy — loader symbol errors, startup crashes, or a blank window on a distro we never built against. One library needs to come from the host and the mechanism has to be that narrow. LD_PRELOAD names exactly that library and leaves the search path alone, so the WebKit ordering from #1258 is untouched too (and this removes the composed-LD_LIBRARY_PATH block that only existed to keep the two prepends from fighting). The preload is inherited by the Python backend, where nothing links GStreamer and it is inert — the accepted cost. Tests now assert both halves: the library IS preloaded, and the libdir is NOT hoisted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(appimage): verify the host GStreamer loads before preloading it greptile P1, valid. The host core links GLib and the bundle ships GLib too, resolved bundle-first — so a host GStreamer built against newer GLib than we bundle fails its relocations and the app does not start at all. That is strictly worse than the broken microphone this PR fixes. Taking host GLib as well is not an option either: GLib is what WebKit is built against, so pulling it from the host reopens #961/#1258. Rather than predict the pairing, test it. The loader processes LD_PRELOAD for any binary, so running `true` under the exact environment the app will get is a complete check of whether the library loads there — a missing dependency or an unresolved version tag ("version GLIB_2.84 not found") fails it and nothing else runs. On failure the preload is skipped, the app starts on the bundled core, and a warning names the mismatch so the user has a thread to pull rather than a silent half-fix. OMNIVOICE_APPRUN_PRELOAD_PROBE lets the suite choose the outcome, matching the existing OMNIVOICE_APPRUN_WK_MARKER precedent; the new case fails if the guard is removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e60c5364ea |
fix(errors): strip terminal colour codes before a failure reaches the user (#1344) (#1353)
* fix(errors): strip terminal colour codes before a failure reaches the user (#1344) yt-dlp colourizes stderr whenever it thinks a terminal is attached, and the frozen backend's pipes are enough for it to think so. A restricted-video failure surfaced as `download: ^[[0;31mERROR:^[[0m [youtube] …`, which reads as an OmniVoice bug rather than a message from YouTube. Fixed at build_failure, the choke point every surfaced failure passes through, so the whole class is covered — ffmpeg, uv, pip, cargo and anything else that colours its output, not just the reported command. Order matters and is pinned by a test: strip_ffmpeg_banner anchors on "ffmpeg version " at the start of a line, so a leading colour code would hide it and quietly reinstate #1309 for any colour-emitting ffmpeg build. The pattern covers CSI and OSC (window title / hyperlink) sequences, not just SGR colour, and never empties a non-empty message. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(errors): escape-only text falls back to the error class, not to bytes CodeRabbit, both valid: - strip_ansi kept the original when stripping left nothing visible, so build_failure copied raw escape bytes into reason/error/detail — the very thing this PR exists to stop. build_failure already falls back to the exception class name for an empty reason, and a class name is a real answer where a run of escapes is not, so let it do that. - the test file bound `from core import failure` at import time. Sibling suites reload and purge core.* between tests, so that alias can outlive the module the app uses and the file would assert against a stale copy while looking green — #1269 exactly. Resolved via a fixture at call time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8f9f778307 |
fix(scripts): desktop-prod:run wiped the data it was documented to preserve (#1333) (#1339)
* fix(scripts): desktop-prod:run wiped the data it was documented to preserve (#1333) `scripts/desktop-prod.sh` emulates a first install, so wiping is its default: it removes the app data dir, `~/.omnivoice` (the SQLite database, every voice profile, all outputs), the Tauri logs and the WebKit profile. `--keep-data` is the only thing that suppresses that block. `--skip-build` is an independent flag that only skips the cargo compile, and `desktop-prod:run` passed it alone — while the script's own header calls that command "re-launch last build (skip compile)" and its closing banner tells you to use it that way. So "just start it again without recompiling" silently deleted the developer's voice profiles and project database, every time. The fix is in the package scripts rather than the flag parsing: making --skip-build imply --keep-data would remove a legitimate combination (fresh data without paying for a recompile). The two stay independent, and the help text now says so. desktop-fresh:run is deliberately untouched — that script is a stricter new-user emulation, so wiping is the point of its name. Tests pin all three rules, and were confirmed fail-before by reverting the desktop-prod:run line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(scripts): kill the live instance before every launch, not only before a wipe Greptile P1 on this PR, and it was a regression I introduced. The app registers tauri_plugin_single_instance, and that callback ignores the incoming argv — it just refocuses the window the RUNNING process already owns. So starting a second copy over a live one does nothing visible. That was previously masked: kill_running_instances sat inside the `KEEP_DATA = false` branch, so every run happened to kill first *because* every run wiped. Adding --keep-data to the re-launch aliases removed the wipe and would have taken the kill with it — `desktop-prod:run:pill` would have left the user in studio mode with --pill silently discarded, and plain `desktop-prod:run` would have refocused the OLD build instead of the one just compiled, which is the entire point of that command. The kill is now unconditional, before the wipe branch. Its two reasons are independent — zombie-backend-after-wipe, and single-instance-swallows-argv — and only the first was ever about wiping. Adjusted its closing line, which said "safe to wipe" and now also runs when nothing is being wiped. New test asserts the call is not nested inside the KEEP_DATA branch; confirmed fail-before by moving it back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(scripts): scope the kill to this checkout, warn about an installed app Making kill_running_instances unconditional (so --keep-data re-launches still get past single-instance) widened the blast radius of its pgrep: "OmniVoice Studio.app" also matches an installed /Applications copy, so desktop-prod:run would kill the shipped app a developer was using and take their unsaved work with it. That was previously masked — the kill only ran on wipe runs, where a clean slate had been asked for explicitly. Scope the pattern to ${TAURI_DIR}/target/debug/, which covers both launch shapes and nothing else. An installed instance still gets named rather than ignored: single-instance keys on the bundle id, so it swallows this launch too, and silence would just trade one confusing failure for another. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5f39f9ff84 |
test: stop streamDropError's tests depending on a live local backend (#1326)
Three tests in `frontend/src/test/streamDropError.test.ts` failed on any machine that happened to be running OmniVoice, and passed in CI. They exercise the no-crash-marker branch, which since #1242 asks whether the backend is still answering before repeating the caller's "it crashed" guess — and they left that probe unstubbed. `_probeBackendAlive` does a real `fetch` at the configured API origin, so the assertion was really "is anything listening on port 3900 right now": nothing in CI, the developer's own app locally. Same fails-locally/passes-in-CI shape as #1269. - Every test in that branch now states which answer it wants (DEAD / ALIVE) instead of inheriting one from the environment. - The previously uncovered side — a live backend, where the #1242 proxy-buffering message replaces the caller's guess — gets a test of its own rather than being asserted by accident on developer machines. - `backlog/` added to .gitignore: the `backlog` CLI task tracker writes a config plus one markdown file per task into the repo root, and a contributor running it locally had three swept into a PR that was otherwise a single script (#1322). Frontend suite with the app running locally: 1 file / 3 tests failing → 208 files / 1643 tests passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
a89c5e8fb3 |
test: patch the model_manager module main actually uses (#1269) (#1324)
`tests/backend/conftest.py` purges `main` / `core*` / `api*` / `services*` from `sys.modules` after every test it owns, and `backend/tests/` modules bind `import services.model_manager as mm` at COLLECTION time — so in a combined `pytest tests/ backend/tests/` session the alias and the live module are two different objects. Two tests read the wrong one; CI's split invocation hid both. - `test_lifespan_shutdown_mid_load_is_clean_and_clears_sentinel` patched the stale alias then drove main's lifespan, which loads through the live module. It now runs inside a purge/restore context and resolves from `sys.modules` after importing main, so the failure is deterministic standalone. - The autouse `_clean_model_manager_shutdown_state` fixture cleaned only `sys.modules` while `test_shutdown_state_isolation.py` dirtied the alias. It now cleans the live module and any module-typed alias in the requesting test module — the idiom already used there for `asr_backend` — taking both the `import x.y as z` binding (package attribute) and the `sys.modules` entry. - New fail-before/pass-after pair guards the alias half of the fixture in isolation. Test-infrastructure only; no production code touched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
168e8e5c61 |
fix(macos): declare the floor the app actually delivers (13.3, not 12) (#1314)
* fix(macos): declare the floor the app actually delivers (13.3, not 12) The app declared minimumSystemVersion 12.0 and the docs promised Monterey, while the frontend required Safari 16.4 in three independent places: Vite's default build target (baseline-widely-available = safari16.4), Tailwind v4's own documented floor, and `@property` throughout its generated utilities. On Monterey's WKWebView 15.6 the focus ring and accent surfaces resolve invalid, and a bundled dependency ships a RegExp lookbehind that is a PARSE-time SyntaxError no polyfill can reach. Option B — actually supporting 15.6 — means setting build.target back, replacing 64 color-mix() calls, dropping Tailwind v4 and replacing that dependency, indefinitely, for an OS that stopped receiving security updates in late 2024. The council was unanimous on A, and the precedent is uniform (Chrome 117, Electron 27, VS Code, Firefox 116). minimumSystemVersion is also the guard: macOS itself refuses to launch a bundle below it, so a Monterey user gets an explicit OS refusal rather than an app that opens to a blank window — which matters because the Tauri updater has no per-OS gating of its own. Docs updated in the same change (README support table, docs/install/macos.md) and the webCompat floor assertion re-derived to 16.4, so the post-floor API list must be revisited the next time the floor moves. Closes #1268 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(macos): raise the floor in the macOS overlay too, and assert it Greptile P1, and correct: Tauri merges tauri.macos.conf.json OVER the base config for a macOS build, and that file carried its own minimumSystemVersion: 12.0. Changing the base config alone decided nothing — the shipped bundle would have stayed Monterey-installable while the base config, the README and the install docs all said 13.3. Worse, the guard I added read only the base config, so it would have gone on passing. A test that validates the wrong file is not a guard; it now asserts both, with a comment saying why the overlay is the one that ships. Also per review: the changelog entry was an editorial paragraph rather than a one-line entry, and the section was missing ### Docs. Both fixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(webcompat): the module header still described the old 12.0 floor The floor moved to 13.3/Safari 16.4 in this PR and the test was re-derived, but webCompat.js still told the next reader the oldest supported WebView was 15.6 — which would make every fill here look mandatory instead of retained for Linux's unpinnable WebKitGTK (CodeRabbit). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
850ae8e192 |
test: reset model-manager shutdown state between backend tests (#1320)
* test: reset model-manager shutdown state between backend tests Two leaks, one of them mine. 1. `model_manager._shutting_down` is a module-global Event and the GPU pool is a module-global executor. Any test that runs the app lifespan flips both on the way out (begin_shutdown + _reset_gpu_pool) and nothing puts them back — correct in production, where the process is ending; wrong across a combined session. A test arriving with the flag set finds a shut-down executor, so its first run_in_executor raises "cannot schedule new futures after shutdown", which the preload path classifies as benign and swallows. The symptom is a load that silently never starts. Reset before AND after: before so an inherited flag cannot decide the test, after so a test that legitimately shuts down does not hand it on. 2. tests/test_torch_compile_path_gate.py assigned services.settings_store into sys.modules directly instead of via monkeypatch.setitem. That leaks process-wide out of collection and breaks every later import of the real module. backend/tests/test_no_module_stubs.py exists to catch exactly that, and caught it — I introduced it two commits ago while isolating the Settings gate for a review finding. #1269 stays open for its last failure, which is a different root cause: test_lifespan_shutdown_mid_load fails because a reload fixture in tests/ replaces services.model_manager, so the test patches one module object while main's lifespan uses another (verified: `same=False`). That is the duplicate- module class, not a state leak, and needs its own fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: let the shutdown-state reset fail loudly CodeRabbit Major: the broad try/except meant a reset that raised left the next test with stale shutdown or executor state — precisely the order-dependent failure the fixture exists to remove, while looking like it had worked. That is the same silent-fail-open shape as the watermark and ffmpeg bugs fixed earlier in this cycle. If reset_shutdown_flag() or _reset_gpu_pool() can raise, that is a real problem in model_manager and it should be loud. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: assert the shutdown-state reset fixture actually resets (CodeRabbit) Ordered pair: one test leaves the module globals exactly as the lifespan leaves them, the next asserts it arrived clean — delete the fixture and the second fails. Plus a mechanical guard that the reset stays un-swallowed, so a future try/except cannot make the fixture look like it worked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
26bcd95088 |
docs(engines): publish the engine-acceptance bar (#1319)
The project carries 14 TTS + 11 ASR engines across 4 platforms with one maintainer. That breadth is an asset only while every one of them still works; otherwise it is a pile of support queues, and the first-run promise is what pays for it. So: engines are hired for a named job, not added to a list. Documents the job map (each job has one holder), the seven conditions, the deprecation rule for engines that lose their steward and their smoke test, and the out-of-tree path. The point is to make "no" a property of the bar rather than a judgement of the contributor — and to make "yes" fast when a proposal clears it. #1306 is the first proposal judged against it. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
576756f2ad |
fix(dev): name auto-reload as a cause when the dev backend goes quiet (#1318)
#1261: dev mode, "Failed to fetch", the backend had answered 10 minutes earlier, three dub uploads in quick succession — and the message offered only "it most likely crashed or was killed mid-request". `bun run dev` runs uvicorn with --reload. Any file change, including a save while a request is in flight, restarts the process and drops the connection. At the transport layer that is indistinguishable from a crash, and the fix is simply to retry — which the message never suggested, so a developer went looking for a Python traceback that was never written. The dev copy now names auto-reload, says to retry first, and keeps the terminal and omnivoice.log as the fallback for when it really did die. Server-mode copy is untouched: there is no reloader there, so the crash reading is right. Translated in all 21 locales. Closes #1261 Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9c0ca38b3f |
fix(dub): stop blaming the ASR model when the stream is cut by a proxy (#1317)
streamDropError() already consults the crash forensics, but "no crash marker" is not "the backend died and we missed it". Outside the Tauri shell there is no death watcher at all, so that branch is where every browser and Docker user lands — and the caller's fallback asserted a cause on their behalf: "Likely ASR backend failed to load". #1242 reported exactly that, in `server` mode, with the backend having answered 20 s earlier. Nothing had crashed and nothing had failed to load, so the message sent them after a model that was fine. It now asks instead of assuming. If the backend is still answering, the process did not go away, which rules the guess out — and in a served or containerised deployment a stream that dies while the server is healthy is characteristically a reverse proxy buffering or timing out the SSE connection, so the message says that and gives the two settings that fix it. A real crash marker still wins over the probe, and when the backend is gone too the caller's message stands. The dub fallback no longer names a cause either, since it is only reached when both signals are inconclusive. Closes #1242 Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0c4d5073db |
fix(engines): verify MOSS-TTS-Nano's entry point, don't just import the module (#1316)
is_available() only checked that `moss_tts_nano` imported. The reporter had the package installed, so the engine advertised itself ready, they switched to it, and the first generate died with `cannot import name 'MossTTSNano'`. An availability check that does not verify the API it will actually call is a check that lies. MOSS-TTS-Nano is installed straight from git with no pinned release and its exported class has changed, so this does not chase the current name: it resolves among the names upstream has used, requires the candidate to actually have `from_pretrained` (matching on name alone would relocate the failure, not fix it), and when nothing matches it reports unavailable — naming what the module DOES export, so the report becomes a one-line fix instead of a dead end. A missing package and a renamed class stay separate messages: different problems, different fixes. 10 tests, including every historical class name and the ready-then-crash shape that started this. Closes #1287 Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
15afc6611d |
fix(linux): AppImage blank window on Mesa 26.1+ hosts (#1258, #1244) (#1265)
* fix(linux): AppImage blank window on Mesa 26.1+ hosts (#1258, #1244) The AppImage bundles an Ubuntu-built WebKitGTK but ships no libEGL, so that bundled WebKit runs against the HOST's Mesa. On Mesa >= 26.1 it calls eglGetPlatformDisplay() in a way the newer driver rejects and the app dies before it paints: Could not create default EGL display: EGL_BAD_PARAMETER. Aborting... No environment variable helps, because the failure is in EGL display creation — before WebKit consults any rendering-path flag. #1258 confirmed WEBKIT_DISABLE_DMABUF_RENDERER, WEBKIT_DMABUF_RENDERER_FORCE_SHM, WEBKIT_SKIA_ENABLE_CPU_RENDERING, EGL_PLATFORM=surfaceless and MESA_LOADER_DRIVER_OVERRIDE=swrast all fail identically. Chasing the build runner's WebKit (#961 bumped 22.04 -> 24.04) cannot fix this class: what we bundle is frozen and host Mesa keeps moving. So when the host has a WebKitGTK at least as new as ours, let it win — the bundle still fills every gap, and a host without WebKitGTK is untouched. That is exactly why building from source works on the hardware where the AppImage does not. The compositing workaround is re-decided against whichever library ends up running, and AppRun.test.sh — which had never been wired into CI — now runs there, so this logic stops being a regression test nothing executes. * fix(review): the ordering change was a no-op; name the host libdir explicitly CodeRabbit Major — correct, and it made the whole fix inert. LD_LIBRARY_PATH is searched AHEAD of the linker's default paths no matter where in that variable a directory sits, so on a normal launch (empty LD_LIBRARY_PATH) the bundle remained the only explicit search directory and still won. Merely appending it changed nothing. The host's WebKit libdir is now named explicitly, ahead of ours. The new tests fail 3/3 against the previous version. Greptile P1 — a host with the runtime but no -dev package has no .pc file, so pkg-config can't answer and the check rejected a perfectly good system WebKit. The libdir probe now falls back to ldconfig, and OMNIVOICE_PREFER_SYSTEM_WEBKIT gives those users an explicit opt-in (=0 opts out) rather than gambling on an unverified version, which would risk the #961 regression. CodeRabbit — my changelog script had also inserted the CI entry into the published 0.4.0 section. Removed; it belongs only under Unreleased. CodeRabbit — the docs' source-build fallback used 'cd frontend', not the repo-root flow the rest of the page documents. Fixed. |
||
|
|
2de27038d9 |
docs(rules): the parity rule governs behaviour, not performance (#1315)
An automated reviewer raised a Critical asking for torch.compile to be disabled by default on every platform "so the default is uniform", citing the cross-platform parity rule. Following it would have slowed down every Linux CUDA user to match hosts that cannot compile at all. Read literally, the rule forbids GPU support: CUDA, MPS, DirectML and Triton availability are all host-dependent by design. It was always about what a user can SEE AND DO, not about throughput — so it now says that, in CLAUDE.md and AGENTS.md, and .coderabbit.yaml tells the reviewers directly so the finding stops regenerating every month. An optimization skipped where it physically cannot work is not a parity violation. A feature usable on one OS but not another still is. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
42d017ef9b |
fix(errors): strip ffmpeg's banner so the failure is the message (#1311)
* fix(errors): strip ffmpeg's banner so the failure is the message ffmpeg and ffprobe print a version + configuration banner to stderr on every invocation, before doing any work. When a command fails we capture that stderr and it becomes the error, so #1309's reporter was shown several hundred characters of build flags — "ffmpeg version N-125781-gacf6b520c1-20260727 … --pkg-config-flags=--static --enable-gpl …" — and not one word about why the extract failed. The diagnosis is always AFTER the banner. Stripped centrally in build_failure() rather than at the extract call site: every stage that shells out to ffmpeg (dub prep, export, retime, media probe) captures the same stderr and had the same problem. Done before classify() runs, too — matching docs topics against a build configuration string is how a real topic gets missed. A message that is ONLY a banner keeps the banner: unhelpful beats empty. Closes #1309 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(errors): assert the banner is GONE, not just that the error is present CodeRabbit: the classification test only checked that the post-banner text appeared in `reason` — which was true before the fix too, since the banner was simply prepended to it. It passed against the code it was written to catch. Now asserts the banner markers are absent and that `reason` STARTS with the real error, since burying the diagnosis after 300 characters of build flags is the actual user complaint. Fails without strip_ffmpeg_banner(). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b4fc74fa65 |
feat(contact): add the project X account alongside Discord (#1313)
* feat(contact): add the project X account alongside Discord Adds https://x.com/fs01c137y as a channel on the in-app Contact page and in the README, next to the existing Discord links — updates, releases, and what is being built next, for people who would rather not sit in a chat server. Follows the ContactPage convention: the URL is a module constant so no surface can drift, and the card explains WHEN to use the channel rather than being a bare link. lucide dropped its Twitter glyph, so the icon is Megaphone, which reads as announcements anyway. contact.follow_* translated in all 21 locales; ContactPage test extended to pin the URL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(contact): correct the X handle to @idebpalash Owner's current account. All six references updated in lockstep — README nav row, badge row, CTA block and contributing list, plus the ContactPage constant and the test that pins it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1600f02abf |
feat(privacy): build the watermark toggle the app already promised (#1308)
* feat(privacy): build the watermark toggle the app already promised errors → enterprise_faq.a_watermark has told users "Commercial licensees can disable it in Settings → Privacy" since watermarking shipped. That control did not exist: /watermark/status and /watermark/settings had zero callers in the frontend, is_enabled() passes no env= to resolve() so there was no environment escape either, and the only way off was hand-editing prefs.json. A shipped instruction that cannot be followed is worse than no instruction. Adds the control, wired to the endpoints that were already there. It mirrors AnalyticsOptIn's shape but inverts its default — analytics is OFF until you opt in, provenance marking is ON until you opt out — and hides itself when AudioSeal is unavailable, since an inert switch over a mark that cannot be embedded is the same lie in the other direction. Also corrects the FAQ string in all 21 locales: the toggle is available to everyone, not only commercial licensees, and it only affects audio generated after the change. (Whether disabling should be licence-gated is a product decision — the text now describes what the app actually does.) 6 tests: reflects backend state rather than assuming it, turns off, turns back on, renders nothing when AudioSeal is missing or the backend is unreachable, and does not optimistically flip when the update fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(privacy): translate the watermark strings, retry the status fetch, guard unmount CodeRabbit Major — the five new strings relied on defaultValue, so every non-English user saw English. Translated privacy.watermark_title/subtitle/ on_toast/off_toast/failed in all 21 locales. Greptile P1 — the status fetch was one-shot, so opening Privacy while the backend was restarting hid the control for the rest of the session. Being findable is the control's entire purpose (the FAQ tells people it is here), so it now retries once after 2s before giving up. CodeRabbit — a toggle resolving after the tab closes no longer sets state or toasts over whatever screen the user moved to. Tests: the two "renders nothing" cases now wait for the request to SETTLE rather than merely to start (the initial render is empty, so they would have passed even if the control appeared afterwards), plus a case proving recovery from a failed first fetch. 7 passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7dd7e89e7b |
fix(errors): explain a cut TLS connection instead of printing _ssl.c:1016 (#1312)
* fix(errors): explain a cut TLS connection instead of printing _ssl.c:1016 #1301 surfaced as "500 Internal Server Error: [SSL: UNEXPECTED_EOF_WHILE_ READING] EOF occurred in violation of protocol (_ssl.c:1016)" — meaningless to a user, and unclassified: the existing SSL branch requires "handshake" or "certificate verify failed", so this fell through with no hint at all. Deliberately a SEPARATE class from SSL_HANDSHAKE_FAILURE rather than widening it. That class means a proxy re-signed the certificate with a CA certifi does not trust, and its advice is to set SSL_CERT_FILE or add an antivirus exclusion. Here the handshake never failed on trust — the socket was cut mid-exchange, usually flaky Wi-Fi, a reconnecting VPN, a captive portal, or a server dropping a long transfer. Sending that user to fix their certificate store is sending them to fix something that is not broken. Classified before the handshake branch, because the raw text contains "ssl" and the broader branch would otherwise claim it. Added to _CONTEXT_FREE_HINT_CLASSES since its trigger is an exact OpenSSL string — that matters here, because the raw 500 handler is precisely where the reporter met it. Closes #1301 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(errors): scope the resume guarantee, and require a TLS marker Greptile P1 — the hint promised "OmniVoice resumes partial downloads rather than starting over", unqualified. That is verified for HF model downloads (snapshot_download) and segmented_download, but the hint is static and also reaches media fetches where nothing guarantees it. Shipping an instruction that is not true is the exact class of bug this session has been removing, so the guarantee is now scoped to models. CodeRabbit — the matcher accepted either EOF phrase with no ssl marker. "unexpected EOF" is a phrase a parser or another transport can produce, and those would have been handed VPN/proxy advice. The OpenSSL text always carries the marker, so requiring it costs nothing. Also fixes a tautology: test_not_mistaken_for_a_cert_trust_problem asserted != SSL_HANDSHAKE_FAILURE, which the OLD classifier satisfied by returning "". It now pins the exact class. 2 of the 9 tests fail against the previous commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4f1135ac62 |
fix(compile): skip torch.compile when the torch lib path has whitespace (#1310)
* fix(compile): skip torch.compile when the torch lib path has whitespace Inductor passes the torch library directory to clang++/g++ as an unquoted -L flag, so a path containing a space splits into two arguments and the compile dies with "no such file or directory: 'Support/...'". The quoting bug is inside PyTorch and we cannot fix it — but a path we already know cannot compile is one we should not spend a compile attempt on. The cost was never a broken generation (eager mode is the documented fallback); it was a guaranteed-failing compile on every load, whose clang wreckage got swallowed by `except Exception: logger.info(...)` and then ate a chunk of the captured log tail. That is exactly how it surfaced in #1259, where it was not the actual fault but crowded out the output that was. Not platform-specific: macOS keeps app data under ~/Library/Application Support/ and a Windows profile is routinely C:/Users/First Last. OMNIVOICE_FORCE_TORCH_COMPILE=1 still overrides, consistent with the arch gate. An unreadable torch path fails open — no evidence is not evidence of a problem. Closes #1266 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(compile): redact the torch path in the skip reason; tighten the tests CodeRabbit Major: the reason string embedded the absolute torch lib path, and both log branches print it — so a home directory (and anything secret-shaped in the path) went into omnivoice.log and any pasted bug report. It now goes through core.failure.sanitize(), the same redaction every other user-facing failure text uses, with a basename fallback if that import ever fails. Also per review: the Settings gate is isolated in the test helper (it was reading the real settings_store, so a persisted perf.torch_compile_disabled=1 could have decided these tests instead of the path logic), and two logging contracts are now asserted rather than merely exercised — the forced-override warning, and the skip message naming OMNIVOICE_FORCE_TORCH_COMPILE. A skip the user cannot discover how to override is a dead end. DECLINED: CodeRabbit's Critical asks for torch.compile to be disabled by default on every platform "so the default is uniform". That would make every Linux CUDA user slower to satisfy a rule about USER-VISIBLE default behaviour — and torch.compile is not user-visible, it is an internal optimization whose absence shows up only as speed. The function has always diverged by host by design: device != "cuda" skips, and no-Triton skips (which is every Windows install). This gate adds no new divergence; it declines a compile that is GUARANTEED to fail on that host, which is the same shape as the existing arch gate. Disabling a working optimization everywhere would be the regression. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
817e9a9c8b |
test: stop config-path leakage across test modules (#1269) (#1307)
* test: stop config-path leakage across test modules (#1269) Ten test modules share a fixture shape: monkeypatch OMNIVOICE_DATA_DIR to a tmp_path, then importlib.reload(core.config) (plus core.db, a router, main). monkeypatch restores the ENV VAR at teardown — and nothing reloads the modules back, so the path constants keep pointing at that test's tmp_path for the rest of the session. In a combined `pytest tests/ backend/tests/` run that produced three different answers to "where is the voices directory": OMNIVOICE_DATA_DIR .../omnivoice-test-data-vna0ywre (correct) core.config.VOICES_DIR .../test_fitted_srt_last_cue_withi0/… (leaked) profiles.VOICES_DIR .../test_clone_profile_save_saniti0/… (leaked) — which is why the personas import tests wrote a file to one directory and asserted it existed in another. Restores at MODULE teardown, and that boundary is the design. Function scope was wrong: tests/smoke/test_boot_smoke.py has a module-scoped fixture that deliberately aims core.config at a frozen fixture directory for the length of that file, and a per-test restore reset it between that module's own tests. Within a module a fixture cannot tell deliberate setup from a leak; across modules there is no ambiguity. Snapshot/restore of the constants rather than re-reloading: a reload would re-register FastAPI routes and rebuild module state as a side effect, while a setattr is inert. It also re-syncs modules that copied a value out of core.config — a reload fixture typically imports the router under test for the first time, so it has no earlier value to put back. 3 of the 4 failures are fixed. test_lifespan_shutdown_mid_load_is_clean_and_ clears_sentinel still fails in a combined run for an unrelated reason (its preload never reaches run_in_executor); #1269 stays open for that one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: import core.config when snapshotting, don't just probe sys.modules Greptile P1: a sys.modules-only probe returns {} when this module is the first to import core.config — and the empty-snapshot guard then skips restoration entirely, so the module most likely to reload config was the one least protected. Importing is cheap and idempotent, and tests/conftest.py has already pointed OMNIVOICE_DATA_DIR at a throwaway dir before any fixture runs, so the captured values are the right ones. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8287d0c476 |
fix(crash): stop blaming VRAM for native faults (#1305)
* fix(crash): stop blaming VRAM for native faults #1275 (Windows 0xC0000005 on an RTX 2080 SUPER) and #1293 (SIGSEGV on Linux) both fell through to "you ran out of VRAM while loading the ASR model" — so the advice was to flush a model that had nothing to do with it. A segfault is bad machine code, not slow memory exhaustion; the real causes are a GPU driver that disagrees with the bundled CUDA runtime, or a weight file that downloaded incompletely and is being memory-mapped. Windows has no signals here, so the shell sees the raw NTSTATUS as a negative exit code — those are matched explicitly or they read as an ordinary non-zero exit. Deliberately narrow: only SIGILL and SIGSEGV, whose numbers are identical on every POSIX platform. SIGABRT stays on the VRAM path because abort() is how a fatal CUDA error exits, including an async out-of-memory — an existing test pins that, and it caught this when the first cut was too greedy. SIGBUS is excluded because its number is platform-dependent (7 on Linux, 10 on macOS, where 10 is SIGUSR1 on Linux). Repeat offenders are now pointed at the crash-isolated subprocess engine that landed in #1292 — it takes the sidecar down instead of the whole backend. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(crash): offer both isolated engines, and translate the guidance Greptile P1: the crash marker records HOW the process died, not which subsystem was running — a segfault during transcription looks identical to one during synthesis. Naming only the TTS escape hatch sent ASR crashes to a fix that leaves the crashing path untouched. Both are now offered so the user picks the one they were using; #1304 supplies the ASR side. CodeRabbit Major: the new guidance was hardcoded English, which the localization rule forbids. All four hints in crashCauseHint now route through i18next with the English as defaultValue — so a missing key still renders exactly what it rendered before (no regression, no test churn) while the strings become translatable. crash_port_in_use, crash_oom_kill and crash_native_fault are translated in all 21 locales. Also fixes a test that claimed to prove repeat-fault behaviour while calling the hint once; it now asserts what the message actually has to contain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5afcdf787d |
fix(engines): warn before a long CPU synth burns the whole budget (#1302)
* fix(engines): warn before a long CPU synth burns the whole budget #1288 closed the under-provisioned-GPU gap but left the CPU one open, and I missed it: a CPU-only host is a BENIGN routing verdict, so routingNotice() correctly stays silent — yet #1299 and #1260 are exactly that shape, CPU hosts that hit the 300s budget on long text with no warning at all. "Nothing is misconfigured" and "this will finish in time" are different claims. Threshold is the backend's own definition of past-short: generate_timeout_for() gives the first 1200 characters the flat budget before extending it, so ordinary sentences on a CPU laptop stay quiet and only the shape that actually times out is flagged. Hardware caveats still take precedence — one toast, and it names the real reason rather than generic advice. 5 tests; engines.cpuLongText translated in all 21 locales. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(engines): don't tell CPU-tuned engines to switch to themselves Greptile P1. The advice names OmniVoice GGUF and Supertonic-3 as the CPU-tuned alternatives — shown to someone already running one of them, it is advice to switch to what they are using. Those two now get the same warning without the self-referential clause; the engine set matches the backend's own timeout message so the two can't disagree about who is CPU-tuned. Also documents the preflight in docs/performance.md (docs-sync rule): both warning shapes, why the threshold is 1200 characters (it is the figure the budget itself uses), that they are advisory and once-per-engine-per-session, and the CPU-tuned exception. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
36e3397613 |
fix(cuda): stop sending every RTX 40-series card to the CPU (#1289)
* fix(engines): warn about under-provisioned hardware before the synth, not after Six reports are the same story: #1240, #1246, #1248, #1277, #1283, #1284 — 4 GB and 6 GB cards running an engine that wants 6 GB, each one waiting out the full 300s compute budget to be told the job "was too heavy". The routing layer knew the whole time. The error text even names the card and the figure. The caveat only ever surfaced on the engine-PICK toast, so it reached people who changed engines and nobody whose engine was already selected — the default, or one persisted from a previous session. That is most users. /generate does return X-OmniVoice-Routing, but a response header arrives when the job ends, five minutes too late to be a warning. So the check moves to the chokepoint every synth path shares (api/generate.ts, same argument as the in-flight count). Fire-and-forget: never awaited, so it cannot add latency to the request it warns about; never throws, so an unreachable backend costs a warning rather than a generate; once per engine+reason per session, so it informs instead of nagging. Advisory, not blocking — the driver can page to system RAM and short inputs fit where long ones don't. Extracts routingNotice() as the single frontend mirror of the backend's routing_notice(). Two callers now need "is this verdict worth interrupting for", and two inline copies would drift — invisibly, until someone on DirectML or an unavailable engine gets a hardware warning for a normal pick. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(cuda): stop sending every RTX 40-series card to the CPU The SM-arch gate required the device's exact tag in get_arch_list(). NVIDIA's rules are not exact, and PyTorch depends on that: SASS is binary-compatible UPWARD within a major version, so the official wheels ship sm_80/sm_86 and deliberately no sm_89 — the 8.6 kernels already cover Ada. Exact matching therefore declared sm_89 unsupported, check_device_compatibility() returned False, and get_best_device() silently returned "cpu". That is every RTX 4060/4070/4080/4090, not just the reporter's card (#1285) — each one running TTS on the CPU on hardware that works fine, with a message telling them their GPU was unsupported. cuda_build_covers() now applies the real rules: sm_XY covers same-major devices with minor >= Y; compute_XY PTX JITs forward to anything newer; an a/f suffix (sm_90a) is architecture-specific and matches exactly. Unparseable entries are skipped, and an empty arch list still degrades to "compatible" — the pre-existing fail-open contract. The remediation text also pointed at a NIGHTLY index for what is a stable supported card; it now names the stable cu128 index. 12 tests: the Ada regression, Jetson Orin (8.7), downward-within-major and cross-major rejection, PTX forward-JIT, arch-specific suffixes, and a genuine sm_120-on-old-wheel mismatch so the gate is proven to still work. Closes #1285 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(cuda): resolve app modules at call time, not import time The tests/** review contract forbids module-level imports of app modules — they go stale under sys.modules pollution from other suites, which is the live cause of #1269's cross-suite failures. Binds core.device_caps per call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: restore generate.ts and generatePreflight.test.js from main Conflict markers were committed in the previous merge — `git add` on the directory staged both files as resolved while the markers were still in them. Both belong to #1288 and are unchanged by this PR, so they take main's version verbatim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
07263ef42e |
fix(engines): warn about under-provisioned hardware before the synth, not after (#1288)
* fix(engines): warn about under-provisioned hardware before the synth, not after Six reports are the same story: #1240, #1246, #1248, #1277, #1283, #1284 — 4 GB and 6 GB cards running an engine that wants 6 GB, each one waiting out the full 300s compute budget to be told the job "was too heavy". The routing layer knew the whole time. The error text even names the card and the figure. The caveat only ever surfaced on the engine-PICK toast, so it reached people who changed engines and nobody whose engine was already selected — the default, or one persisted from a previous session. That is most users. /generate does return X-OmniVoice-Routing, but a response header arrives when the job ends, five minutes too late to be a warning. So the check moves to the chokepoint every synth path shares (api/generate.ts, same argument as the in-flight count). Fire-and-forget: never awaited, so it cannot add latency to the request it warns about; never throws, so an unreachable backend costs a warning rather than a generate; once per engine+reason per session, so it informs instead of nagging. Advisory, not blocking — the driver can page to system RAM and short inputs fit where long ones don't. Extracts routingNotice() as the single frontend mirror of the backend's routing_notice(). Two callers now need "is this verdict worth interrupting for", and two inline copies would drift — invisibly, until someone on DirectML or an unavailable engine gets a hardware warning for a normal pick. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(engines): route streaming synthesis through the generate chokepoint streamGenerateSpeech POSTed /generate via apiFetch directly — a second, parallel door. Everything attached to "the one call every synth path shares" therefore did not apply to it: the in-flight count that stops the updater relaunching mid-synthesis, and the new under-provisioned-hardware preflight (Greptile P1). A chokepoint with two doors is not a chokepoint. Also fixes two cache defects in the preflight itself: - An engine pick left the cached /engines response describing the PREVIOUS engine for up to 60s, so switching engines and generating immediately warned about the one you just left — or stayed silent about the one you just chose. notifyEngineSelected() now drops the cache, and hands over the caveat it just displayed so the preflight does not repeat the same sentence seconds later. - A rejected listEngines() promise stayed cached for the full TTL, silencing the caveat for a minute after the backend came back. It is now evicted, but only if it is still the current entry, so a racing newer fetch survives. 6 new tests; 2 of the 3 streaming ones fail before this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(engines): hold the in-flight claim for the whole stream, not just headers generateSpeech releases its claim when the Response resolves — when the HEADERS arrive — but a streaming synth generates audio for as long as the body is read. Routing streaming through it gave it a count for the first time, then dropped that count to zero for the entire synthesis, so the updater saw idle and was free to relaunch mid-stream (Greptile P1). streamGenerateSpeech now wraps the whole operation in withTtsInflight(). Nesting is harmless because the store tracks a count, not a boolean — the inner claim just bumps it to 2 and back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3553965f41 |
ci(windows): make the ffmpeg retry test the outcome, not choco's exit code (#1290)
* ci(windows): make the ffmpeg retry test the outcome, not choco's exit code The chocolatey feed 503'd; choco printed "Unable to find package 'ffmpeg'" and "installed 0/0 packages" — then exited 0. The retry loop added on 2026-07-20 for this exact class was `choco install ... && break`, so it broke out on attempt 1, no backoff ran, and the job died one line later on `ffmpeg: command not found`. It took #1281 red on an unrelated change. A retry that trusts a lying exit code is not a retry. The loop now exits on `command -v ffmpeg` and still fails the job loudly when ffmpeg never arrives. Tests extract the real step body from ci.yml and run it against a stubbed choco; 2 of the 4 fail against the previous loop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(ci): pin PATH to the stub dir so the retry test can't false-green The harness inherited the ambient PATH, so a real ffmpeg satisfied `command -v` and the loop exited on attempt 1 — every assertion passed against a broken workflow. It happened twice: /opt/homebrew/bin locally, then /usr/bin on the Linux runner, which is what took this PR red. PATH is now the stub dir alone, with the few real tools the stubs need symlinked in, and stub shebangs are absolute (`/usr/bin/env bash` cannot resolve bash when PATH is one directory). test_harness_actually_hides_ffmpeg asserts the sandbox is a sandbox, so the next leak fails loudly instead of quietly passing. 2 of 5 fail against the old `&& break` loop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ci: give the Windows smoke leg its own timeout, per-leg not shared Smoke (Windows) has been dying at 10m08s inside `uv sync`, and the shared 10-minute budget made it self-perpetuating: the leg is killed before the post-step saves the uv cache, so the next run starts cold and dies the same way. Nothing primes the cache, so it never gets faster. Measured on run 30385710466 — Linux 65s, macOS 65s, Windows still installing torch when the job was killed. Windows now gets 25 minutes, priced for one cold install to finish and populate the cache; warm runs land nowhere near it. Per-leg rather than raising the shared value, so a genuine hang on Linux or macOS still fails fast instead of inheriting Windows' allowance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ci(windows): skip the backoff after the final attempt; tighten the tests CodeRabbit, both valid: - The loop announced "retrying in 90s" and slept after attempt 3, though no fourth attempt exists — 90s added to an already-doomed job. - The retry tests asserted `attempts >= N`, so a regression that kept going after ffmpeg appeared would still pass. Pinned to exact counts, plus a case asserting the final attempt announces no retry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
602ea6f53e |
ci(release): stop the macOS preview updater bundle colliding with itself (#1281)
* fix(engines): show the under-provisioned-VRAM warning instead of discarding it Four of the open low-VRAM reports (#1240, #1246, #1248 on 4 GB cards; #1277 on 6 GB) share one shape: the user generates, waits out the entire 300s compute budget, and is then told the job "was too heavy for the available compute". The warning existed the whole time. Routing computes it (#1226's `_caveat`: "…has 4.0 GB VRAM; this engine wants about 6 GB. It will run, but expect slow generations that may time out"), and `/engines/select` echoes it in `routing_reason` — but notifyEngineSelected only surfaced a reason when `routing_status === 'cpu_fallback'`. The VRAM caveat rides on an ACCELERATED verdict, so it fell through to the green "switched" success toast and was thrown away. The user was told everything was fine, then waited five minutes to find out it wasn't. Now any caveat on the echo raises a warn-tone toast naming it, with a longer duration since it lists the ways around the limit. This covers the kernel-risk caveat on the same path. Deliberately still ADVISORY, not blocking — matching the routing layer's documented contract (the driver can page to system RAM, and short inputs fit where long ones don't). The engine is still selected; the user just finds out now instead of after the timeout. This is the first-run path too: the wizard's library step shares notifyEngineSelected. Fail-before verified: both new tests fail against the previous version. Known remaining gap: a user whose engine is already selected sees this only when they re-pick. A generate-time preflight would close that, but it needs a "once per session, not per generate" design — filed as follow-up rather than guessed at here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci(release): stop the macOS preview updater bundle colliding with itself The nightly preview run has failed on both macOS legs since early July: Uploading OmniVoice Studio_x64.app.tar.gz... ##[error]Validation Failed: {"resource":"ReleaseAsset", "code":"already_exists","field":"name"} `preview` is a ROLLING release, reused every night, and macOS updater artifacts are the only ones Tauri names without the version: OmniVoice Studio_0.4.1-103_x64.dmg unique per run — uploads fine OmniVoice Studio_x64.app.tar.gz constant — collides on run 2+ Consequences, verified against the live release: the macOS updater bundles on `preview` were last written 2026-07-04 (x64) and 2026-07-05 (aarch64), and latest.json 2026-07-13 — three weeks stale as of today. Preview-channel macOS users had no working update path. The failure also lands AFTER the dmg upload, so each run looked partly successful while going red. Deletes this arch's updater bundle before the upload. Matches the STORED asset name by querying the release rather than guessing the spelling — GitHub rewrites spaces to dots, so "OmniVoice Studio_x64.app.tar.gz" is stored as "OmniVoice.Studio_x64.app.tar.gz" and a literal delete-asset by the uploaded name would silently no-op. Scoped to the preview path (a v* tag creates a fresh release with nothing to collide with) and to the job's own arch, so the parallel aarch64/x64 legs can't touch each other's assets. Verified the filter against all 209 live preview assets: it matches exactly the 4 colliding updater files and no versioned artifact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci(release): fail loud when the preview asset sweep can't do its job The cleanup step treated every `gh` failure as "nothing to clear" — 401, 403, 429 and network errors included. That reintroduces the outage it was written to fix, with the evidence removed: the stale bundle survives, the Tauri upload dies with `already_exists`, and the one step that could have explained why is green. Three weeks of broken macOS Preview updates started exactly this way. Only an absent release/asset is benign now. A 404 on view means "no preview release yet" (GH_TOKEN is scoped to this repo, so 404 really is absence); a 404 on delete means someone already removed it, which satisfies the goal. Every other failure fails the step with the reason printed. An unexpected arch is also fatal rather than a silent skip — same class of blind spot. Adds tests/test_release_preview_asset_cleanup.py, which extracts this step's real shell body from release.yml (so it cannot drift) and runs it against a stubbed `gh`: 6 of the 8 cases fail against the previous version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
574b2832c5 |
fix(engines): show the under-provisioned-VRAM warning instead of discarding it (#1280)
* fix(engines): show the under-provisioned-VRAM warning instead of discarding it Four of the open low-VRAM reports (#1240, #1246, #1248 on 4 GB cards; #1277 on 6 GB) share one shape: the user generates, waits out the entire 300s compute budget, and is then told the job "was too heavy for the available compute". The warning existed the whole time. Routing computes it (#1226's `_caveat`: "…has 4.0 GB VRAM; this engine wants about 6 GB. It will run, but expect slow generations that may time out"), and `/engines/select` echoes it in `routing_reason` — but notifyEngineSelected only surfaced a reason when `routing_status === 'cpu_fallback'`. The VRAM caveat rides on an ACCELERATED verdict, so it fell through to the green "switched" success toast and was thrown away. The user was told everything was fine, then waited five minutes to find out it wasn't. Now any caveat on the echo raises a warn-tone toast naming it, with a longer duration since it lists the ways around the limit. This covers the kernel-risk caveat on the same path. Deliberately still ADVISORY, not blocking — matching the routing layer's documented contract (the driver can page to system RAM, and short inputs fit where long ones don't). The engine is still selected; the user just finds out now instead of after the timeout. This is the first-run path too: the wizard's library step shares notifyEngineSelected. Fail-before verified: both new tests fail against the previous version. Known remaining gap: a user whose engine is already selected sees this only when they re-pick. A generate-time preflight would close that, but it needs a "once per session, not per generate" design — filed as follow-up rather than guessed at here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(i18n): write the caveat toast string, don't just translate a placeholder CodeRabbit flagged engines.selectWithCaveat as untranslated in 20 locales. It was worse than that: en.json said "{{engine}}: {{reason}}" too, so the English string had never been written and every "translation" was a faithful copy of a non-sentence. All 21 languages would have shown a bare "omnivoice: <English backend text>". Writes the en sentence, translates it into all 20, and translates engines.selectCpuFallback alongside it — same function, same toast, and it was English-only in every locale (missing-key ratchet tightened 518 -> 517, zh-CN 511 -> 510). Adds test_no_placeholder_only_values to pin the class. Parity tests cannot catch this: the key is present everywhere and the placeholders match exactly. Only the absence of prose gives it away, so the guard checks en.json too — that is where this one started. A bot catching a mechanical rule twice means the rule belongs in CI (CLAUDE.md, Token economy). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(engines): only warn on accelerated+caveat, mirroring routing_notice() Greptile P1: testing a bare `routing_reason` also fires on benign verdicts. Routing rule 5 gives a Windows DirectML host cpu_only + an explanatory reason on a perfectly normal pick, and rule 6 attaches one to `unavailable` — neither is a hardware warning, but both drew a 10s amber toast. routing_notice() in engine_routing.py is the canonical predicate (cpu_fallback always, accelerated only with a reason); the frontend now matches it. Two tests, both failing before. Also translates settings.engine_switched, which shipped as the identical "{{family}} → {{engine}}" in all 21 files — an untranslated success toast everywhere (CodeRabbit). That was the sole _PLACEHOLDER_ONLY_ALLOWLIST entry, so the allowlist is now empty and the guard has no exceptions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * style: oxfmt the new toast test cases Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b01c635bfe |
release: v0.4.2 (#1279)
Version files (package.json + the three mirrors) and both lockfiles bumped 0.4.1 → 0.4.2; [Unreleased] renamed to [0.4.2] — 2026-07-28. Ships five user-facing fixes: the update toast, the restart guard that used to discard in-flight work, the shutdown-is-not-a-crash 503, the self-healing half-downloaded model, and the dub-history purge ordering — plus the "Dismiss" mistranslation in five locales. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e9307f2dc0 |
fix(errors): key the no-report rule on the shutdown marker, not on 503 (#1278)
* fix(errors): key the no-report rule on the shutdown marker, not on 503 Self-caught regression from #1272. Suppressing the "Report" action for every 503 was too broad: 503 is also how a real engine-load timeout and an unavailable engine are reported (#1246, #1260, and #1277, filed hours ago with a 503 in its very title). That would have removed the report button from exactly the class of failure users need to be able to file — silencing real bugs to hide a benign one. The backend now tags only the shutdown case with a `[shutting_down]` marker, following the existing `[clone_ref_unusable]` convention, and the UI matches the marker instead of the status. A 503 that is a genuine failure keeps its report action. Localized message added to all 21 locales; the backend test pins the marker so the cross-layer contract can't be renamed away. Fail-before verified: the two "still offers Report for a 503" tests fail against the shipped blanket-503 version. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * i18n(de): formal register for the two new German strings Review: the surrounding errors.* strings and update.retry use "Sie"; both new strings used the informal form. Turkish is consistently informal (install, retry) so it stays as-is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c3d0d6b123 |
feat(update): announce updates as a toast with actions, not a wall of text (#1272)
* fix(dub): purge order was hash-dependent, and the cap could evict a live marker main went red on my own test. Two distinct defects, both mine. 1. `targets = set(job_ids)` made iteration order depend on PYTHONHASHSEED, so which markers a cap-forced trim discarded was luck. That is why the test passed locally and failed in CI — verified: the old code passes at seeds 0/7/42 and fails at 12345. Now a de-duplicated list in caller order. 2. The size cap could evict markers the CURRENT purge had just recorded. Those are the newest and the likeliest to still be held by a running job, so dropping one is precisely the resurrection this mechanism exists to prevent. A 'clear history' larger than the cap forced exactly that. The cap now never touches the current purge, making the real bound cap + one purge — stated plainly rather than implied. Tests now pin both: identical survivors across runs, and an oversized purge keeping all of its own markers. Verified across six hash seeds; full suite green under 12345, the seed that reddened main. * feat(update): announce updates as a toast with actions, not a wall of text A version's release notes ARE the whole changelog section — v0.4.1's was 42 bullets. Any surface that renders them inline becomes unusable: older builds put them in a blocking OS dialog that filled the screen and had to be dismissed before the app could be touched. Removing that dialog left the opposite failure. The only remaining signal was a 6-pixel dot beside the version number in the footer, which is easy to never notice — so users either got shouted at or told nothing. A toast is the middle: it names the version, offers Install and restart / What's new / Later, and leaves. The notes stay one click away in Settings → Updates, where there is room. Keyed by version so the 6-hourly re-check replaces rather than stacks, and it never auto-dismisses — an update the user hasn't answered is still true. Install declines while a generation is running, since the relaunch would lose it. Strings added to all 21 locales, translated rather than English-filled. Tests pin the shape that matters: the toast takes no notes prop at all, renders under 200 characters, and cannot stack duplicates. * fix(update): don't relaunch while work is in flight; share the busy check Review of #1272 found the restart guard was `dubStep === 'generating'` and nothing else. Installing an update relaunches the process, so that permitted throwing away a dub upload, a transcription, a translation, an export or a standalone TTS synth. The same narrow check was written twice — in the new toast and in UpdatesPanel — so the two could also drift apart. Replaced with a single `isAppBusy(state)` in utils/appBusy, unioning the signals the store actually has: the dub state machine, the floating status pill (which every long background operation already pushes to), and `ttsGenerating` — a new transient store field mirroring useTTS's local `isGenerating`, which lived in a hook where no global check could see it. `dubStep === 'editing'` is deliberately not busy: it waits on the user, and counting it would block updates for as long as a transcript stays open. Also from review: - A failed lazy import of the toast fell into the outer catch, whose setUpdateIdle() erased the update that had just been found. The announcement is optional; the available state is not. - "Dismiss" was machine-translated into the employment sense — terminate an employee — in de/ja/ru/zh-CN/zh-TW, on close buttons and one aria-label. Swept every locale rather than the four lines that were flagged. - Hoisted the test's mock state with vi.hoisted. CodeRabbit's changelog finding is declined: Highlights bullets carry no issue refs by design, which tests/test_changelog_style.py enforces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(shutdown): a quit mid-generate is not a 500, and not a bug report (#1276) #1174 made a model load interrupted by shutdown benign for the background preload, but a *request* that triggered a load took the generic unhandled- exception path: crash log, ERROR traceback, and an error-journal entry that feeds the bug-report pipeline. Quitting the app with a generate queued surfaced "500 Internal Server Error: model load skipped: backend shutting down" and offered to file a GitHub issue for a normal teardown. Nothing failed — the process is exiting. The handler now answers 503 with Retry-After and an actionable detail, ahead of the crash-log/journal writes. Frontend half of the same bug: toastErrorWithReport offered "Report" for any error. A 503 means "not now, try again" by definition, so it now shows the backend's message without the report action — covering a still-warming backend too, not just this shutdown path. Fail-before/pass-after tests on both sides, including that the shutdown case leaves no crash-log entry and no journal record. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(models): repair a half-downloaded model whichever way it reports (#1273) transformers has two unrelated wordings for "this snapshot has no weight shard", sharing no words: hub load "<repo> does not appear to have a file named …" local dir "Error no file named model.safetensors, … found in directory …" The self-heal and the failure classifier both matched only the first. The second is what a load of a *subfolder* inside a cached snapshot raises — exactly where an interrupted download leaves a half-written repo — so the reporter got neither the automatic repair (delete broken entries → re-download → retry) nor an actionable hint, just a raw 500. Their disk had 10.6 GB free, i.e. a download that ran out of room. The phrase list now lives once in core.failure, so the healer and the error text cannot disagree about what an interrupted download looks like. Both fragments of the second wording must match — "no file named" alone is ordinary English and must not claim the class. Also hardens the #1276 handler found by running both suites in one session: services.model_manager can be imported under two module names, making two distinct ModelLoadInterruptedByShutdown classes and breaking a bare isinstance — which silently restored the 500 this fix exists to remove. Now matched by isinstance OR class name, with a test that raises a same-named class from a different module. Verified against the full tests/ + backend/tests/ session: the only remaining failures are the four pre-existing #1269 isolation leaks, unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(update): count synths in flight at the chokepoint, not in one caller Review round 2. Both Greptile P1s were the same weakness in my first pass: tracking the synth in useTTS meant only the Generate tab counted (voice previews, the compare modal, the stories editor and profile previews call generateSpeech directly and were invisible), and a boolean meant two overlapping syntheses cleared each other — whichever settled first reported "idle" while the other was still running, so Install and restart discarded it. Moved to `api/generate.ts`, around the one `/generate` call all seven paths share, as a count with a `finally` release (so an abort or a network error frees it too). A future synth caller is covered without opting in. Fail-before verified: the overlap test fails against the boolean version. Also from review: - UpdatesPanel re-reads the busy state at click time; the render-time snapshot only exists to disable the button, and work can start after the last render. - The 503 now carries the allowed-origin CORS headers. Without them the browser reports a bare CORS failure and the actionable detail — the whole point of the fix — never reaches the user. Both error responses build them through one helper now. - Log `request.url.path`, not the full URL: a query string can carry tokens and newlines. - `update.busy` said "finish your dub first" in all 21 locales, but the guard now covers uploads, transcription, translation, export and synth. Rewritten as work-in-progress wording, translated per locale. - ru `common.dismissStatus` was "reject status"; missed in the earlier sweep. Declined: CodeRabbit's request for a `(#NNN)` ref on the Highlights bullet — Highlights carry no refs by design (CLAUDE.md), and tests/test_changelog_style.py enforces it. Also declined gating the cache re-download behind a confirmation: the self-heal already existed and already ran for the sibling wording, this only stops it missing half the class, and it stays behind the existing _hf_offline() check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(update): grey out Install while work is running CI lint caught `busy` as unused after the click-time check moved into the handler — and that exposed a real gap: `busy` had never been wired to anything. The comment claimed it disabled the button; it didn't. Clicking Install during a synth just bounced a toast back. Now it does what it said: the Install and Restart buttons are disabled while work is in flight, with `update.busy` as the tooltip. The click-time read stays the authority, since work can start between the last render and the click — the disabled state is the explanation, not the safety. Adds the first UpdatesPanel test. The case it pins hardest is the inverse of the bug: a busy predicate stuck at true would make the app permanently un-updatable, which is worse than what this fixes. So it asserts enabled when idle and during dub 'editing' (which waits on the user), disabled during an upload, a translation, and one or more synths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f99832de90 |
fix(dub): purge order was hash-dependent, and the cap could evict a live marker (#1271)
main went red on my own test. Two distinct defects, both mine. 1. `targets = set(job_ids)` made iteration order depend on PYTHONHASHSEED, so which markers a cap-forced trim discarded was luck. That is why the test passed locally and failed in CI — verified: the old code passes at seeds 0/7/42 and fails at 12345. Now a de-duplicated list in caller order. 2. The size cap could evict markers the CURRENT purge had just recorded. Those are the newest and the likeliest to still be held by a running job, so dropping one is precisely the resurrection this mechanism exists to prevent. A 'clear history' larger than the cap forced exactly that. The cap now never touches the current purge, making the real bound cap + one purge — stated plainly rather than implied. Tests now pin both: identical survivors across runs, and an oversized purge keeping all of its own markers. Verified across six hash seeds; full suite green under 12345, the seed that reddened main. |
||
|
|
9736fd4859 |
release: v0.4.1 (#1239)
* release: v0.4.1 Seven user-reported issues fixed since v0.4.0 (#1221–#1229). Version bumped across the single source of truth (frontend/package.json) and its three toolchain mirrors; [Unreleased] renamed to the release section that release.yml extracts verbatim as the GitHub Release body. Docker tag examples in docs/install/docker.md and deploy/dockerhub-overview.md updated to 0.4.1 (docs-sync rule). * release: #1239 review — sync Cargo.lock to 0.4.1 Greptile: the manifest said 0.4.1 while Cargo.lock still recorded 0.4.0, so a `cargo build --locked` (and the Tauri bundler's own locked build) would fail on the mismatch. Regenerating locally updated it but it was never staged. * release: re-sync [0.4.1] after the fix merges, date it 2026-07-27 Picks up everything merged since the section was first written: the first-run wizard chrome (#1241), the MCP host allowlist (#1249), the macOS 12 startup crash (#1245), and the six error-message fixes (#1247, #1251, #1254, #1256, #1257, #1262). Deliberately NOT included: - the dub delete-resurrection fix (#1252, #1253) — split to #1270 after it needed six rounds of correction, the last two finding that the fix did not close the reported case and that its own bound reintroduced it; - the Linux AppImage WebKit fix (#1258, #1244) — held on #1265 pending confirmation on a Mesa 26.1 host, which nobody has run. |
||
|
|
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) |
||
|
|
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. |
||
|
|
e3fa40a952 |
fix(macos): the app was dead on arrival on macOS 12 (Monterey) (#1245) (#1263)
* fix(macos): the app was dead on arrival on macOS 12 (Monterey) (#1245) `useRealtimeEvents` polls backend health with `AbortSignal.timeout(2000)` on mount. That method landed in Safari 16.0, but tauri.conf.json declares `minimumSystemVersion: "12.0"` and docs/install/macos.md promises macOS 12 — which ships WKWebView 15.6. So on the floor we advertise, the first React render threw, the tree unmounted, and the backend was never started: the reporter's whole session was one `view:launchpad` and "no backend response this session". Fixed as a class, not a call site. `utils/webCompat.js` fills the gap once, imported first in main.jsx before any app chunk; a deterministic test fails CI if app code reaches for any other post-15.6 API that nothing fills in. * fix(review): correct the version facts, widen the guard, stop overclaiming Standing review found three factual errors and one overclaim. Verified each against caniuse/WebKit before changing anything — two of my labels were wrong and one of the reviewer's corrections would itself have been wrong to apply blindly. - Array#toSorted/toReversed/toSpliced are Safari 16.0, not 16.4 (caniuse). - String#isWellFormed is 16.4, not 17.0 (WebKit 16.4 release notes). - abort(reason) shipped in Safari 15.4, so it IS honoured on our 15.6 floor — my comment claimed the opposite, which invited 'simplifying' the polyfill to a bare abort() and silently turning every TimeoutError into an AbortError. - Array#with was missing from the denylist. It is the change-by-copy sibling most likely to be reached for (arr.with(i, v) in React state), so it was the most likely next instance of the exact bug this guards. Added, with Array.fromAsync, String#toWellFormed, Element#checkVisibility and URL.parse. The overclaim: the changelog said the app 'opens again, instead of a dead window'. It launches — but Tailwind v4's own floor is Safari 16.4, vite's default target is 16.4, and index.css uses color-mix() (16.2) 64 times, so Monterey renders it wrong. A bundled dependency also ships a RegExp lookbehind literal, which is a parse-time SyntaxError no polyfill can reach. Changelog now claims only what is true; the floor question is #1268. The guard's blind spots — syntax, dependencies, CSS, computed access — are now stated in the test file rather than left implied. |
||
|
|
1bfcedab43 |
chore(agents): a standing reviewer carrying the project's own standards (#1267)
Encodes what CLAUDE.md already requires — root-cause not symptom, whole class not one instance, fail-before/pass-after tests, cross-platform parity, keep main green — as a reviewer that attacks a change before it lands. Deliberately a critic, not an approver: it can judge that work meets the bar, it cannot authorise publishing, and it is told to say so when the remaining decision is the owner's. A reviewer that agrees with the author is worth nothing — two bugs this week were caught only because a reviewer went looking for a blind spot, including a Linux fix that turned out to be completely inert. Tracked rather than left in local state so the standards travel with the code. |
||
|
|
e6ca31d7a6 |
fix(first-run): keep Continue + the HF token box on screen; no status bar yet (#1241)
* fix(first-run): keep Continue + the HF token box on screen; no status bar yet Reported with a screenshot: on Models & engines the "Set token" card was clipped at the window edge and the Continue button was off-screen entirely — unreachable without resizing. Root cause is one bug, and it explains both halves of the report. App.jsx sizes `.app-wizard-wrap` to stop above the fixed LogsFooter, but SetupWizard's own root was `fixed inset-0` — so it laid itself out against the VIEWPORT, escaped that box, and put its pinned footer row underneath the status bar. The row was already correctly pinned (shrink-0, outside the scroller); it was simply painted over. - SetupWizard's root is `absolute inset-0`, filling the frame it is given, plus `pb-4` so the pinned row clears the window edge. - The wizard and the pre-wizard splash render no LogsFooter at all: it is studio chrome, and the rule is that it appears once you land on home. - `.app-wizard-wrap` reserves nothing below it any more, so no dead 28px gap is left where the footer used to be. Verified by driving the real frontend in Chromium against a stub backend, not only in jsdom: before, `.logs-footer` is present and overlaps the action row; after, it is absent and every button sits within the viewport. Regression test pins both halves — either one alone reintroduces the clip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(test): oxfmt; changelog refs the wizard PR (#1241), not issue #1240 * test(first-run): pin the bottom clearance and check each pre-studio branch CodeRabbit (#1241): the root-class test passed with `pb-4` removed, and the LogsFooter count-of-1 would still pass if the mount MOVED from the studio into the splash. Both now assert the thing they mean. * style(test): oxfmt the SetupWizardChrome assertions --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b722ca7ad1 |
Merge pull request #1236 from debpalash/fix/download-resume-oom-1224
fix(download): retry a truncated model download instead of aborting (#1224) |
||
|
|
fe3efe3633 |
Merge pull request #1235 from debpalash/fix/port-in-use-1223
fix(startup): report a port conflict as a port conflict (#1223) |
||
|
|
dbc87dbfe2 |
Merge pull request #1238 from debpalash/fix/changelog-structure
docs(changelog): repair the [Unreleased] structure |
||
|
|
00da2d3c94 |
Merge pull request #1237 from debpalash/fix/low-vram-preflight-1226
fix(routing): warn about an under-provisioned GPU before the job, not after (#1226, #1222) |
||
|
|
916e075eb3 |
Merge pull request #1232 from debpalash/fix/lazy-model-import-1229
fix(startup): don't let one optional transformers symbol kill the backend (#1229) |
||
|
|
2df159e3f7 |
Merge pull request #1234 from debpalash/fix/dub-download-errno22-1225
fix(dub): name the folder when a URL ingest fails on a disk error (#1225) |
||
|
|
a93f8f0c6c |
Merge pull request #1233 from debpalash/fix/synth-error-classes-1227-1221
fix(synth): name the App Control block and the libsndfile failure (#1227, #1221) |
||
|
|
e02f5b231b |
Merge pull request #1230 from debpalash/fix/rocm-arch-gate-1228
fix(rocm): stop force-routing every AMD GPU to the CPU (#1228) |
||
|
|
f73846b1c9 |
Merge pull request #1220 from debpalash/feat/gallery-voices-stories-dub
feat(voices): gallery voices in the Stories + Dub pickers; drop the redundant dub presets (#1220) |
||
|
|
0d351f9a22 |
Merge pull request #1219 from debpalash/feat/gallery-voices-in-picker
feat(voices): pick voice-gallery (archetype) voices anywhere via materialize-on-select (#1219) |
||
|
|
ed01c16990 |
Merge pull request #1218 from debpalash/fix/webview-download-hijack
fix(export): audiobook/story download hijacked the desktop app; blank-guard fallback broken on macOS (#1218) |
||
|
|
56ca0ceb83 |
Merge pull request #1217 from debpalash/feat/audiobook-cast-authoring
feat(audiobook): cast/voice mapping (fixes multi-voice) + markup toolbar + stats + validation (#1217) |
||
|
|
c2c63121b4 |
Merge pull request #1216 from debpalash/feat/audiobook-stop-progress
feat(audiobook): stop/cancel generation + live per-chapter progress (#1216) |
||
|
|
fb8be231e8 |
Merge pull request #1214 from debpalash/feat/audiobook-sample-script
feat(audiobook): Load-sample button + compact, grouped tab UI (#1214) |
||
|
|
94891f434f |
Merge pull request #1215 from debpalash/feat/first-run-english-prompt
feat(i18n): first-run offer to switch the UI to English (#1215) |
||
|
|
3a9807e4d3 |
Merge pull request #1207 from debpalash/test/conflict-marker-guard
test: fail CI on unresolved git conflict markers in tracked files |
||
|
|
259b6131db |
Merge pull request #1205 from debpalash/fix/1177-surface-backend-diagnosis
fix(backend): surface the shell's start-failure diagnosis instead of "can't reach the backend" (#1177) |
||
|
|
df3cf3e34c |
Merge pull request #1204 from debpalash/fix/1191-tts-stranded-on-cpu
fix(tts): stop stranding the TTS model on CPU after a dub abort (#1191) |
||
|
|
fec9dd07ce |
Merge pull request #1206 from debpalash/fix/1190-gpu-pool-queue-accounting
fix(gpu-pool): bound execution, not queue wait (#1190, #1202) |
||
|
|
6750c59791 |
Merge pull request #1203 from debpalash/feat/paste-translation
feat(dub): paste a translation from an external source onto existing segments |
||
|
|
eb7ae2ecd5 |
Merge pull request #1200 from debpalash/fix/wizard-continue-hidden
fix(wizard): keep Continue pinned — scroll the models list, not the page |
||
|
|
3e35ac3290 |
Merge pull request #1201 from debpalash/feat/1193-source-build-analytics
feat(analytics): in-repo publishable token — source builds get the same consent-gated analytics |
||
|
|
b6b4f31fa6 |
Merge pull request #1199 from debpalash/fix/1198-followup-unload-normal-path
review(1198 follow-up): non-blocking ASR unload on the normal completion path too |
||
|
|
e7cb322f3e |
Merge pull request #1198 from debpalash/fix/bot-harvest-review-infra
Bot-review harvest (16 fixes), deterministic style/locale CI, reviewer configs |
||
|
|
c4f26216da |
Merge pull request #1197 from debpalash/fix/1196-transcribe-stream-drop
fix(dub): transcribe stream opens instantly and survives long ASR loads |
||
|
|
76bd951e9b |
Merge pull request #1192 from agudmund/feat/dismissible-notifications
feat(ui): dismissible info/warn system notifications |
||
|
|
983ef700e6 |
Merge pull request #1195 from paoloantinori/feat/mcp-clone-voice
feat(mcp): clone_voice tool — clone a new voice from reference audio (#1194) |
||
|
|
c191c6ef91 |
Merge pull request #1189 from debpalash/fix/issue-batch-1172-1188
Fix open issue batch: exec-format 500, Kitten ONNX cap, SIGTERM load race, lightning_fabric rot, Windows install drives, quiet clone refs |
||
|
|
f1dbd03171 |
Merge pull request #1187 from debpalash/fix/never-blank-screen
Never a blank screen: production-bundle gate + stop dev instances colliding |
||
|
|
0542e3a92f |
Merge pull request #1175 from debpalash/feat/tts-only-firstrun-curated-asr-permissions
TTS-only first run, platform-curated ASR, guided OS permissions, parakeet-mlx |
||
|
|
fb7f161102 |
Merge pull request #1184 from debpalash/fix/dub-demucs-sync-pipe-stderr
fix(dub): stop demucs crashing under the Windows SelectorEventLoop fallback |
||
|
|
e7fff522b9 |
Merge pull request #1171 from paoloantinori/feat/trusted-networks
feat(backend): trust a local network/proxy via OMNIVOICE_TRUSTED_NETWORKS (#1170) |
||
|
|
f343af8482 |
Merge pull request #1183 from debpalash/fix/model-load-shutdown-race
fix(model): shutdown-during-load logs calmly instead of a fake crash |
||
|
|
238e3bf39f |
Merge pull request #1182 from debpalash/fix/crash-banner-under-navbar
fix(ui): stop the top navbar from hiding the backend-crash notice |
||
|
|
f1a58b2768 |
Merge pull request #1181 from debpalash/fix/setup-py-utf8-windows
fix(setup): UTF-8 output so setup.py doesn't crash on piped stdout (Windows) |
||
|
|
86cf33134d |
Merge pull request #1179 from debpalash/feat/fastest-mirror-autodetect
feat(bootstrap): auto region-detect by fastest mirror (latency race, not just reachability) |
||
|
|
f255449fdd |
Merge pull request #1178 from debpalash/fix/windows-black-screen-and-console-windows
fix(windows): production black screen (splash TDZ) + first-run console-window storm |
||
|
|
f4fe0844ba |
fix(dev): self-heal cargo PATH so bun desktop works from a stale terminal (#1180)
* fix(dev): self-heal cargo PATH so `bun desktop` works from a stale terminal `tauri dev` shells out to cargo, so `bun desktop` died with "failed to run 'cargo metadata' ... program not found" on any terminal opened before rustup was installed — the shell holds a stale PATH snapshot without ~/.cargo/bin even though cargo is installed and on the persisted User PATH (a new terminal finds it). That's a confusing first-run-from-source papercut, hit repeatedly on Windows. The frontend `desktop` script now runs through scripts/desktop-dev.mjs, which prepends ~/.cargo/bin when cargo isn't already resolvable, then launches `tauri dev` with that healed env. Cross-platform (~/.cargo/bin everywhere), a no-op when cargo is already on PATH, and it passes an explicit env with the correct-case Path key (Bun doesn't propagate process.env mutations to children, and Windows uses "Path" not "PATH"). If Rust isn't installed at all, it prints an actionable install hint instead of the cryptic cargo error. Verified E2E: from a cargo-less PATH, `tauri dev` now compiles instead of failing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(changelog): note the bun desktop cargo-PATH self-heal (#1180) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: debpalash <tapudattaht@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d206856905 |
docs: live downloads badge + fix AGPL-3.0 license detection (#1168)
Two small README/packaging fixes. 1. Add a self-updating total-downloads badge next to the stars badge. shields.io re-queries the GitHub releases API on every page view, so the number (currently ~137k across 563 release assets) stays current without ever editing the README again. 2. Fix license detection. GitHub's API reported this repo as "Other" / NOASSERTION, which breaks the license UI and the corporate license scanners that gate adoption — the exact users the commercial license exception is for. The cause was packaging, not content: LICENSE carried a 53-line plain-language notice prepended above the AGPL text, which pushes the file below licensee's similarity threshold. The AGPL-3.0 body was already byte-identical to the canonical text at gnu.org. LICENSE is now the verbatim canonical AGPL-3.0 text and nothing else. The notice — including the commercial-license offer, the scope section, and the Apache-2.0 carve-out for the bundled omnivoice/ model — moves verbatim to LICENSE-NOTICE.md, linked from LICENSE and the README. No terms changed. Co-authored-by: user <user@users-MacBook-Air.local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
99e01610bb |
feat(docker): publish ROCm/AMD GPU image variant (#1165) (#1166)
The Docker image was CUDA-only, so AMD GPUs (e.g. RX 7900 XTX under Podman) silently ran on CPU. Every preview and release now also ships a ROCm variant built from the same Dockerfile: - deploy/Dockerfile: parameterize the runtime base with a BASE_IMAGE build-arg (default unchanged: pytorch/pytorch 2.8.0 CUDA). Add PIP/UV_BREAK_SYSTEM_PACKAGES for the ROCm base's PEP-668-marked Ubuntu 24.04 Python (no-op on the conda CUDA base), and a build-time GPU_FLAVOR guard asserting the dependency install did not clobber the base image's GPU torch/torchaudio — a future dep bump that forces a torch reinstall now fails the build instead of shipping a CPU-only "ROCm" image. - .github/workflows/docker.yml: new build-and-push-rocm job (separate job for runner disk — the ROCm base is ~25 GB unpacked, so it frees the preinstalled toolchains first). Tags mirror the CUDA semantics with a -rocm suffix (:rocm rolling preview, :stable-rocm, :X.Y.Z-rocm, :X.Y-rocm, :sha-xxxx-rocm) on both GHCR and Docker Hub, same secret gating. flavor latest=false so release tags can't clobber :latest. No cache-to: the ROCm layers would blow the 10 GB GHA cache budget. - deploy/docker-compose.yml: new opt-in 'rocm' profile passing the GPU through via /dev/kfd + /dev/dri, with HSA_OVERRIDE_GFX_VERSION=11.0.0 documented (user-set, not baked in — backend auto-sets it for known consumer GFX IDs). - Docs-sync: docker.md (ROCm quick start incl. Podman/Quadlet, tag table, troubleshooting), dockerhub-overview.md, README AMD note, linux.md ROCm section cross-link, CHANGELOG [Unreleased]. Base image: rocm/pytorch:rocm7.2.4_ubuntu24.04_py3.12_pytorch_release_2.8.0 — torch 2.8.0 exactly matches the CUDA image (identical resolution, so uv keeps it), py3.12 satisfies requires-python >=3.11 (the ubuntu22.04 variants are py3.10 and do not). Closes #1165 Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0d4eb0f269 |
release: freeze v0.3.22 — version bump, lockfiles, changelog (#1150)
The dubbing release. package.json (source of truth) + the three mirrors (Cargo.toml, pyproject.toml, version.py fallback) to 0.3.22; uv.lock + Cargo.lock refreshed; CHANGELOG's Unreleased section (29 entries) becomes ## [0.3.22] — 2026-07-14 with the headline, split Added blocks merged. Gates on the frozen content, all green before any version mutation: backend 3033 + 204, frontend 1253, format, lockstep 6/6. Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
018cdcb47f |
fix(dub): hydrate partial translations on tab switch; dialect guard moves into the store (#1149)
* fix(dub): hydrate partial translations on tab switch; dialect guard moves into the store Review round on #1148, both findings real: - Greptile P1 "missing translations leave mixed text": the in-browser translations map can be PARTIAL (tracks generated before per-language persistence, partial regens); the non-destructive switch then left those rows in the previous language under a single-language preview. New GET /dub/segments-text/{job}?lang= exposes segments_i18n (the authoritative per-language map every generate rebuilds); the tab click hydrates only the gap rows, failure-silent, and skips stale responses if the user switched again mid-fetch. - CodeRabbit "clear stale dialect": the dropdown paths each cleared a non-matching dubDialect by hand; the guard now lives inside switchDubLangCode so every caller (dropdown, multi-language loop, preview tabs, future ones) inherits it. Matching dialects survive. Tests: endpoint (i18n map served, never-generated track -> empty map, legacy job -> empty map), hydration (stored rows swap instantly, missing row hydrates from the mock backend and is cached into translations), dialect guard (cleared on mismatch, kept on match). Suites: dub sweep 262, frontend 1253, both green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(api): register /dub/segments-text in the route-inventory snapshot The inventory guard caught the new endpoint exactly as designed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
22a513a404 |
fix(dub): Export-step language tabs switch the transcript segments too (#1148)
Owner request with screenshot: the Original/Bengali/German/… pills above a finished dub only swapped the preview VIDEO; the segment list kept showing the last generated/edited language — German audio playing over Bengali text. The pills now also route through switchDubLangCode (the P1.2 user-driven language switch: outgoing text snapshotted into translations[prev], incoming swapped in, non-destructive when no saved entry exists) plus setDubLang — exactly what the language dropdown and the multi-language generate loop already do, so fingerprint/staleness semantics are identical. The Original pill deliberately leaves the editing language untouched: there is no 'original' editing language, and every row already renders the original line under its translation. Tests: clicking the German pill swaps segment text to the stored German translation, snapshots the outgoing Bengali, and sets dubLangCode; the Original pill leaves dubLangCode alone. Fail-before verified (wiring stashed → text swap test fails). Full frontend suite: 1251 passed. Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
9ecb810946 |
fix(shell): version-gate crash markers, pin the WebView repair contract, run the Rust suite in CI (#1145)
* fix(shell): version-gate crash markers, pin the WebView repair contract, run the Rust suite in CI Two deferred items from the recurrence audit, plus the CI gap that made them possible: - crash.rs: a persisted "backend crashed" marker now only surfaces for the release that wrote it. After an upgrade, markers from the previous version (quite possibly the build whose crash the upgrade fixed) are ignored and pruned on read instead of resurfacing unacknowledged as if the new build had crashed. backend_version gains #[serde(default)] so legacy version-less markers still deserialize — as "", which the gate treats as stale by design. Preview stamps (X.Y.Z-N) count as their release. - commands.rs: the #879 WebView2 cache repair's filesystem half is extracted into clear_webview_cache_at() (paths + retry policy as parameters, zero behavior change) and its contract is pinned by tests: no marker → nothing touched; marker consumed first, unconditionally (one-shot — a failing repair can never loop across launches); missing cache is success; a locked cache is retried then abandoned with a log, never bricking startup. - ci.yml: the Tauri shell check only ran `cargo check`, which neither compiles nor runs #[cfg(test)] code — so the shell's ~90 unit tests (crash.rs, reset.rs, bootstrap.rs, …) never executed anywhere in CI. `cargo test --lib` now runs them natively on all three OSes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(shell): crash-notice read path is strictly read-only — a prune-save there could destroy a fresh marker Greptile's P1 is real, and hotter than stated: get_last_backend_crash is not just a startup check — streamDropError (#1119) polls it every second for 8 s after a stream drops, which is exactly when the death watcher is inside record_crash's load→push→save. The previous commit's read path did load→prune→save when stale-version markers existed (the post-upgrade state), so a poll could load the pre-crash snapshot, lose the race, and save over the freshly recorded marker — silently deleting the only evidence of the crash it was being polled to find. Smallest fix: reads never write. The read path (extracted as read_notice_from(path, version) so the contract is testable) filters stale-version markers in memory only; disk pruning stays on the write paths (record_crash, acknowledge_backend_crash), where load-modify-save already existed pre-PR and is paced by a crash or a user click rather than a 1 Hz poll. Regression test pins the file as byte-identical across reads, stale markers filtered and current ones surfacing as before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
283ef36b13 |
feat(dub): Voice match toggle — per-line prosody vs one consistent reference per speaker (#1147)
* feat(dub): Voice match toggle — per-line prosody vs one consistent reference per speaker Owner report: "still 4 segments different in voice as they are 4 times done from each segment?" — Wave 3.2 clones each dub line from a reference cut from its OWN source audio (great prosody match), but the voice IDENTITY drifts line to line, and heuristic-diarized jobs have no pooled speaker clones to anchor it. The precedence was hardcoded; now it's a per-dub-job setting. DubRequest.voice_match: - "per_line" (DEFAULT, unchanged): segment clip preferred, speaker clone fallback — byte-identical to the previous behaviour. - "consistent": ONE reference per speaker for the whole dub. `auto:` bindings use the pooled speaker clone; when none exists (heuristic diarization skips extraction entirely — the key case) a deterministic pick among that speaker's segment clips (longest ≥3 s, tie-break lowest segment id) is reused for every line. Server-default self `auto-seg:` bindings join the pick (they're what prepare stamps on heuristic jobs — the Voice dropdown can't even render them, so no user choice is overridden); explicit CROSS auto-seg bindings still honour their clip. The shared pick is multi-use, so it stays warm in the clone-prompt cache (#1132 cache_ref semantics) at both the main generate and the OOM-retry call site. voice_match is part of the segment fingerprint when non-default (mixed in like track_lang, so all stored hashes keep their values): flipping the toggle marks segments stale instead of letting "Regen changed" splice mixed-identity voices (#281 class). The client sends the mode on both /tools/incremental recompute paths. UI: a compact Voice-match Segmented control next to the Timing picker in the dub panel, persisted in the prefs slice; labels + tooltips in all 21 locales. Tests: resolution through the real dub_generate path for both modes (incl. the 4-segment heuristic job unifying on one ref — fail-before/pass-after), pick determinism + tie-breaks, schema validation, fingerprint semantics, and frontend store→request wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(changelog): Voice match toggle entry under Unreleased (#1147) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
3aa2ef285a |
fix(engines): bundle en_core_web_sm — no mid-generation GitHub download (#1146)
* fix(engines): bundle en_core_web_sm — no mid-generation GitHub download Post-merge review finding on #1144 (valid): with pip present, misaki/spaCy's first-use auto-download WORKS now — meaning the first English MLX-Audio generation performs a raw GitHub release download that (a) bypasses the app's entire HF-mirror/endpoint system (restricted-network users have no recourse mid-generation) and (b) fails offline. Local-first says default features shouldn't spring surprise outbound requests at generation time. en_core_web_sm-3.8.0 is now a pinned URL dependency in pyproject/uv.lock (~12 MB wheel): it arrives at install/update time via the normal dependency flow (where network failures are visible and retried), survives drift-sync by construction, and spacy.util.is_package() finds it so misaki never triggers its downloader at all. The #1143 containment stays as the backstop for any other CLI-shaped dependency. Also clarifies the venv test per review: pytest's interpreter IS the uv-synced venv in CI and the packaged app, so find_spec verifies the lock; the test now also pins the bundled model. Validated: uv sync --frozen clean; en_core_web_sm importable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(engines): direct-URL dependency + frozen-bundle collection (review) Two of three review P1s were real: - Docker build break: `uv add` wrote a bare "en-core-web-sm" dependency with the URL only in [tool.uv.sources] — Docker's `uv pip install --system .` reads project metadata only, would resolve the bare name against PyPI (where spaCy models don't exist), and the image build fails. Now a direct "name @ url" dependency, the same form kittentts has always used, so every installer (uv sync, pip, Docker) sees the same source. Re-locked; uv sync --frozen clean. - Frozen bundle: backend.spec ships mlx_audio, whose Kokoro path loads en_core_web_sm DYNAMICALLY (spacy.load by name) — PyInstaller never sees the import, so a frozen build would hit misaki's downloader at first English generation. collect_all('en_core_web_sm') added inside the mac-ARM block (plain data package, no nanobind hazard — the reason collect_all is banned for mlx itself doesn't apply). Declined with precedent: "hard-coded GitHub URL breaks restricted networks" — kittentts has shipped as exactly this GitHub-release URL form in the same dependency list since it was added; install-time GitHub fetches are the project's accepted pattern (the bootstrap's gh-proxy mirror exists for restricted networks), unlike mid-generation fetches, which this PR removes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
db12b94145 |
fix(engines): ship pip in the managed venv — the #1133 root trigger (#1144)
The containment fix (#1143) makes a CLI-shaped dependency's sys.exit survivable; this removes the reason it fired at all. mlx-audio's Kokoro phonemizer (misaki) auto-downloads en_core_web_sm via spacy.cli.download, which shells out to `python -m pip install <url>` — and uv-managed venvs ship no pip, so the download always failed. Why a real dependency instead of installing pip (or the model) ad-hoc at engine load: the updater's drift sync reconciles the venv against the lockfile (#1029/#1030, --inexact), so anything outside the lock is stripped on the next update — the failure would quietly return after every release. pip in pyproject/uv.lock survives sync by construction. Validated against all lock consumers: uv sync --frozen clean; Docker's `uv pip install --system .` reads pyproject; version-lockstep test reads only the version field. Regression test asserts pip is importable in the managed env. Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
5302170688 |
fix(engines): contain SystemExit at the pool boundary — a CLI-shaped dependency killed the backend (#1133) (#1143)
* fix(engines): contain SystemExit at the pool boundary — a CLI-shaped dependency killed the backend (#1133) Auto-report #1133 (8GB M1, v0.3.21, engine mlx-audio, exit code 1 at 21s uptime) carried the whole story in its stderr tail: mlx-audio's Kokoro pipeline uses misaki's G2P, whose __init__ runs spacy.cli.download() IN PROCESS when en_core_web_sm is missing. spaCy's downloader is written as a CLI: with no pip in the venv (uv-managed venvs ship none), its error printer calls sys.exit(1). SystemExit is not an Exception, so every except Exception on the path waved it through; it rode the executor future into the event loop, where uvicorn treats SystemExit as "shut down" — backend dead. Class fix, not a spacy special-case: _contain_system_exit() wraps every callable dispatched through run_on_gpu_pool_guarded (all engine loads AND generates funnel through it, #1033) and asr_backend.run_transcribe_guarded, converting SystemExit into a RuntimeError that names the real failure mode. Any engine dependency written as a CLI is now covered on both the TTS and ASR sides. Not done here (follow-up candidates): pre-provisioning en_core_web_sm for the Kokoro/mlx-audio path so the download never triggers, and/or shipping pip into the managed venv. Both are provisioning decisions; this PR makes the failure survivable and honest first. Tests: SystemExit from a pool job -> RuntimeError naming SystemExit(code), executor still usable afterwards; same for the transcribe guard. Both fail with the containment reverted. Full suite: 3016 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(engines): containment helper moves to a leaf module (CodeQL cyclic-import) utils/containment is stdlib-only, so model_manager and asr_backend both import it at module top with no cycle — the call-time back-import CodeQL flagged is gone. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
780ff1f6cf |
fix(tts): Vietnamese consistency — Voice vs Audiobook divergences (#1142)
* fix(tts): Vietnamese consistency — Voice vs Audiobook divergences (#1139) Three root causes behind "Vietnamese Voice generation is inconsistent compared to Audiobook": 1. Numbers: num2words' vi cardinals are wrong for 2001-2099 (misused "lẻ": 2024 → "hai nghìn lẻ hai mươi bốn") and vi has no year form, so normalization mangled the years the engine used to read natively. Vietnamese now keeps its digits, and _num2words_lang's display-name path now gates on _NUM2WORDS_LANGS like the ISO path (the loophole that let "Vietnamese" bypass the vetting "vi" would have failed). 2. Seed: the longform resolver fetched a profile's pinned seed but only the cache signature ever used it — book renders ran unseeded. Both longform synth wrappers now seed torch per segment via the new pure segment_seed(base_seed, text) helper (crc32-decorrelated, order- and cache-independent, mirroring /generate's used_seed + i). 3. Quality preset: the audiobook synth inherited num_step=32 / guidance_scale=2.0 from model-config defaults by accident of omission while /generate defaults to 16 — the main audible gap. Now explicit (LONGFORM_NUM_STEP / LONGFORM_GUIDANCE_SCALE), pinned by a test so upstream default drift can't silently change books. The Voice-page fast default (16) is deliberately unchanged. Also (issue part 3): the finished audiobook's player + Download link lived in component useState and evaporated on tab switch — the last render's filename is now store-backed and persisted. Regression tests fail-before/pass-after (verified by stashing the fix): vi digit passthrough + vetted-set gate invariant; segment_seed + seeding in both synth branches + explicit preset kwargs; lastOutput store round-trip. Full backend suite 3004 passed; frontend 1237 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ui): loadProject clears lastOutput; document longform seeding contracts (review) Review-bot findings on #1142, evaluated: - FIXED (Greptile P1 "Output Escapes Its Project" + CodeRabbit): loadProject now resets lastOutput like newProject already did, so loading project B never presents A's finished render as B's output. Regression test added (set lastOutput → loadProject → cleared). - REFUTED (P1 "Global RNG Races Between Workers"): the exposure is identical to /generate's existing #526 seeding — generation.py calls torch.manual_seed on the same global RNG inside the same GPU pool, and has since that PR. The pool is 1 worker on MPS/CPU and small-VRAM CUDA (model_manager._pick_gpu_workers), where determinism is strict; a >1-worker CUDA pool is best-effort for BOTH paths. A race-free fix means threading a per-call torch.Generator through the model's samplers app-wide (covering /generate too) — out of scope for this PR and pointless to do one-sided. Contract now documented on _seed_segment_rng. - REFUTED (P2 "Repeated Text Reuses One Seed"): identical takes for identical repeated lines is the pipeline's shipped semantic — the content-addressed SegmentCache (segment_cache_key hashes text + voice sig, not position) already replays one WAV for every identical span — and seeding only activates when the user pinned a seed, i.e. asked for reproducibility. Position-based keys would shift every later span's seed on a one-paragraph insert, breaking the cache-independent partial re-render guarantee. Documented on segment_seed. - DECLINED (P2 "Persisted Filename Can Outlive File"): longform outputs in OUTPUTS_DIR are not auto-pruned (prune_cache_dir bounds only longform_cache), so a dangling name requires manual deletion; auto-clearing on an <audio> error would instead wipe a valid link whenever the backend is briefly down at mount. Projects → Audiobooks stays the authoritative library. Also rebased onto main past #1141 (CHANGELOG resolved keeping both Unreleased→Fixed entries, this PR's on top). Affected suites: 264 passed; frontend format clean, 1245 tests passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
d6f24dafd5 |
feat(hardening): six recurrence guards from the closed-issue-history audit (#1141)
* feat(hardening): six recurrence guards from the closed-issue-history audit An agent audit swept every closed issue, clustered the error classes, and checked each for fix + regression test + upgrade/reinstall survival. Six of the "fixed but fragile" gaps are closed here; each guard has a regression test in tests/test_recurrence_hardening.py (9 tests). 1. Evict-then-load (class 1, ~90 issues): a plain TTS load on a tight unified-memory box could still be OS-killed — the dub path frees memory before ASR loads (#1119) but nothing did before a TTS load. _make_room_before_tts_load() releases the idle capture-ASR model, clone prompts, and allocator caches when free RAM < the unified headroom. Deliberately NOT admission control: the #1111 decision (advisory-only, never refuse a load on an estimate) stands; this only does earlier what idle reclaim does later, and roomy machines skip it entirely. 2. Honest SIGKILL attribution (class 1): crashCauseHint() says "the OS ran out of memory (RAM)" for signal 9 instead of guessing VRAM on machines that have none. VRAM guidance kept for real GPU aborts (signal 6 etc.). 3. Clone-kind save sanitize (class 3, recurred 3x): the server-side instruct heal was gated to design-kind; a clone profile saved by any bypassing client could persist prose that 400s on every use. profiles.py now sanitizes both kinds at the single choke point. 4. Stale user_env validation (class 5): ~/.config/omnivoice/env is inherited verbatim by reinstalls; path-valued keys (OMNIVOICE_CACHE_DIR/DATA_DIR) that don't exist and can't be created are dropped for the run with a loud log line (file untouched — replugging the drive restores the setting). The two #480 precedence tests updated to use creatable paths (they test precedence, not path validity). 5. omni_ui schema guard (class 6): sanitizeOmniUi() whitelists + shape-checks every persisted field before restore — one malformed field used to throw mid-restore and silently discard everything after it, and every future field re-opened the #1067 class. Includes a lockstep test failing when useAppData reads a field missing from the schema. 6. safe_replace EXDEV helper (class 7): os.replace across devices raises EXDEV (the Windows D:-drive Errno 18/22 class); utils/fsops.safe_replace degrades to copy+fsync+replace. Adopted at the two cross-directory movers (log rotation, persona restore); temp-sibling writers stay on os.replace. Plus: the generate timeout scales with text length (class 4's 503 wave — +1s per 40 chars past the first 1200, env floor respected), so long texts on slow hardware stop dying at exactly 300s with a "set an env var" remedy. Deliberately NOT done, with reasons: - ASR auto-promotion to the crash-isolated engine after a wedge: the code records an explicit owner rule against silent engine switching (asr_backend.py "we never switch engines automatically") — flagged to the owner instead of overridden. - Rust items (webview cache-clear unit test, crash-marker versioning across updates): deferred to their own PR — the local cargo target was reclaimed for disk space, so they can't be verified locally right now. Full suite: 2999 backend + 1243 frontend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(changelog): correct PR ref to #1141 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(hardening): review round — reclaim at the shared load boundary, write-probe path validation Both Greptile P1s were real: - "Startup preload skips reclaim": _make_room_before_tts_load() ran only in get_model(); preload_model() calls _load_model_with_timeout() directly, so a memory-tight machine was protected on demand loads but could still be OS-killed during the startup preload — the exact window the guard exists for. The reclaim now lives in _load_model_with_timeout(), the boundary both callers share. - "Read-only paths pass validation": an existing directory on a read-only mount passes makedirs+isdir but fails on first real use, so the stale setting survived validation only to break downloads later. The check now probes actual write capability (create+delete a probe file). New test with a chmod-0o500 dir (skipped under root, where the probe cannot fail). - CodeQL: the two intentional best-effort excepts in fsops.py now carry their explanatory comments. Full suite: 3000 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
dc3527ab05 |
fix(dub): stereo, full-band music bed — separate the HQ extraction, pin the mix to stereo (#1138)
* fix(dub): stereo, full-band music bed — separate the HQ extraction, pin the mix to stereo Owner asked for a channels/Hz/samples comparison of a dub against its original to tune generation toward the source. The measurements found a class, not a knob: L/R correlation: original 0.754, dub 1.000 (mono in a stereo container) stereo width (S/M): 0.375 vs 0.003 LUFS: -17.8 vs -17.2 (already fine) Two stacked causes: 1. INGEST: Demucs separated audio.wav — the 16 kHz MONO extraction made for ASR. The music bed therefore inherited mono AND an 8 kHz bandwidth ceiling at its source (Demucs upsamples to 44.1 kHz internally, so the stems LOOKED like 44.1k stereo files while carrying neither). Ingest now extracts a second full-quality file (44.1 kHz stereo, pcm_s16le) just for separation; ASR keeps its 16 kHz mono file; Demucs cost is ~unchanged (it resampled to 44.1 kHz internally either way). Best-effort: if the HQ extraction fails, separation falls back to the ASR file — exactly the old behavior. The stem-move path follows the input's basename. 2. MIX: amix negotiates ONE channel layout across inputs, and the synthesized voice is mono — so even a true-stereo bed was collapsed at the mix. bed_mix_filter now pins BOTH legs to stereo (aformat=channel_layouts=stereo); upmixing the mono voice duplicates it dead-center, which is where dubbed dialogue belongs anyway. Verified with real ffmpeg: the new graph preserves a stereo bed's width through the mix (and the ingest test pins that demucs receives audio_hq.wav with -ac 2 -ar 44100 while ASR keeps -ac 1 -ar 16000). Both tests fail with their half of the fix reverted. Full suite: 2989 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(dub): pre-HQ stem caches are not reused (review) Greptile P1, real: the content-hash cache restores a previous job's stems for the same video and skips Demucs — so every video processed BEFORE the HQ-extraction change would keep its 16 kHz-mono-derived bed forever, and the fix would never apply to exactly the videos users re-upload to hear the difference. find_cached_job now requires the audio_hq.wav marker in the cached job dir; older candidates are skipped with a log line and separation reruns once at full quality. Regression test covers both directions of the gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |