The translation uninstall route runs `pip uninstall -y <package>` on the
app's own environment. Two entries made that break something else:
- The LLM engine's package is openai, a core dependency that Settings →
LLM Providers also uses. Unlike Argos, it was not marked builtin, so
uninstalling that engine removed it from the app.
- Google, DeepL, Microsoft and MyMemory share deep_translator. Uninstalling
any one removed it for all four.
The route now asks uninstall_blocker() first. It refuses (400) a package
VoiceStudio itself depends on, read from the installed package metadata so
there is no second list to keep in step, and refuses (409) a package
another engine shares, naming the engines that would stop working. The
openai entry is marked builtin as well, and a test requires every entry
backed by an app dependency to be.
mlx_supported() fails three ways. Only the non-Apple branch is a platform
gap; Apple Silicon whose PyTorch cannot use MPS was left on the generic
line, and the live-host test asserted a platform reason even there, so it
would fail on such a Mac. That case now has its own owned sentence, and
the live test asserts only the branch its host can produce, with literal
cases covering the rest on every machine.
The public reason sanitizer had no category for a host the engine cannot
run on at all, the same gap that hid the license button. "MLX requires
Apple Silicon" and PocketTTS's Intel-Mac reason became the generic
"check installation" line, and "not supported on this platform" became
"isn't installed yet", which an existing test asserted. Each sent people
after an install that could never work.
A platform category, matched after the license and before the install
and file checks, now says the engine doesn't run on this platform and
points at its guide. mlx-audio's "Apple Silicon only" wording is left
out of the markers: it also appears on an M-series Mac when the package
is simply missing, where installing does help.
The Model Catalogue shows an engine's license Accept button only when its
reason matches /license not accepted/i. public_backends() replaces probe
text with owned sentences, and no category covered a license gate, so the
reason arrived as the generic "Engine unavailable" line and the only way
to enable Supertonic-3 or PocketTTS never rendered (#2017).
A license category, matched first, keeps those words. The test reads the
regex out of EngineCompatibilityMatrix.jsx, so a wording change on either
side fails CI instead of silently hiding the button.
Three engines that shipped as terminal-only setups now install from Model
Catalogue → Engines with the existing sidecar installer, which is
generalised to take a per-engine venv interpreter, install target, import
probe and host gate.
Each engine gets DATA_DIR/engines/<id>/ with its own checkout and .venv;
every uv pip install passes --python for that venv, never the app's
interpreter. Switching the active engine only changes a pref, so moving
between engines and back cannot corrupt a working one, and uninstalling one
removes only its own folder. Tests pin both invariants for every spec.
MOSS-TTS-v1.5's [torch-runtime] extra pins torch==2.9.1+cu128, which exists
only on PyTorch's index, so its manual install and its bootstrap could never
resolve (#2015). core.torch_indexes defines the index once for the
installer and the bootstrap, and a test ties it to the app's own
pytorch-cuda index.
Install buttons appear only where the install can work: MOSS on CUDA hosts,
dots.tts off Windows (upstream publishes no Windows install). A direct POST
on an unsupported host gets a 409 with the reason. An engine with no
one-click install now points at its guide, not at a page with no Install
button.
Closes#1960.
The report was "400 Bad Request: Invalid source language code" and nothing
else. That cannot be acted on or triaged: it does not say which of the ninety
or so codes was wrong, so neither the user nor a maintainer reading the
auto-filed issue can tell whether the picker offered something the backend does
not accept, or a stale preference from an older build is still being sent.
I could not determine the cause from the report, which is exactly the problem.
Naming the code makes the next one answerable instead of guessing at this one.
The value is a language code chosen from a menu, not private data, and the
engine validator a few lines away already echoes its input the same way.
Also adds the check I actually wanted while investigating: a test that reads
the picker's own LANG_CODES and asserts the backend accepts every one of them,
so a code added to the menu cannot silently become a 400. It passes today —
the menu and the allowlist do agree — which is how I ruled that out as the
cause rather than assuming it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1949.
Settings offers three notations. Only Respelling substitutes text today; IPA
and CMU rows save cleanly, are validated, get a badge and can be toggled on,
then get dropped before term matching and are never read again.
That much is Phase 1 behaving as designed. The defect is that it was INVISIBLE:
"Test a sentence" answered "No entries match — spoken as written" for a term
that does match. Not a degraded answer, a wrong one — and it sent the user off
to re-type an entry that was already correct, or to convert it to Respelling,
where a phoneme string is then read as graphemes.
docs/specs/01-expressive-tts.md asked for exactly the opposite: such entries
"passed through and flagged 'phoneme not honored on this engine' (parity-rule:
visible degradation)". That flag was never implemented. This is it.
The dry run reports inert entries separately, and the panel names them. The
substitution path is deliberately untouched — this does NOT start feeding raw
phoneme strings into the grapheme stream, which is the thing Phase 1 refuses on
purpose, and a test pins that it still refuses.
Not Phase 2. Lowering IPA/CMU to engine markup is a real feature per engine and
stays open; what changes here is that the gap is now honest rather than silent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1879.
mlx-audio raises a bare ValueError in its own vocabulary — "No conditionals
available. Either provide audio_prompt/audio_prompt_sr for voice cloning, or
ensure conds.safetensors is in the model directory." — and the generate route
passed it straight through as the 400 detail. The user was told to supply an
argument they have no way to name and to check for a file they have never heard
of, when what happened is simply that they asked to clone with nothing to clone
from.
Classified now, with a remedy in the user's terms: pick a profile that has a
saved reference clip, or record one. It also notes that a designed voice with
no saved reference cannot be cloned from, which is the case that produces this.
The route still passes through every ValueError it cannot classify. Most are
VoiceStudio's own validation messages and are exactly what the user should
read, so replacing them wholesale would have been a regression — tests pin four
of them as untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1866.
Model Catalogue → Engines showed "Engine unavailable. Check installation and
configuration." and "Last error: A previous engine check failed." for engines
the user had simply never installed. Neither names a missing package, a missing
step, or a next action, and the second reads like a crash or a poisoned cache
rather than "you have not installed this yet" — so a normal, expected state
looked like a fault.
The probe's own sentence still cannot cross the boundary: it carries exception
text, local paths and sometimes credentials, which is why it was replaced in
the first place. What changed is that the private diagnostic is now CLASSIFIED
into a VoiceStudio-owned category — package not installed, needs configuring,
file missing or unreadable — exactly the shape _public_routing_reason already
uses for routing. Anything unrecognised keeps the old generic sentence rather
than asserting a cause the probe never gave.
test_docs_url_survives_the_public_metadata_scrub pinned the generic wording
while testing something else; it now asserts what it is actually about, that no
private text survives.
Also skips the exec-bit placeholder test on Windows, where os.access(X_OK) is
true for any existing file so the assertion cannot fail — it errored the whole
module on a Windows checkout. Pre-existing, unrelated to this change, and in
the way of running these tests at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Bot review raised one blocking and several real findings across these PRs.
Each is fixed here rather than merged and followed up.
#1892 — apply_pypi_index_env() now runs for EVERY uv invocation, and with the
default region "auto" it called the UNCACHED auto_detect_region(), racing two
live network probes with a 4s timeout per uv call. On a blocked or offline
network that is a repeated multi-second stall, and it multiplies the outbound
calls a local-first app makes unasked. The probe is memoised for the life of
the process. It also now clears UV_INDEX_URL before setting it, so a stale
ambient value cannot outrank the region the user picked.
#1925 — backend.rs trimmed OMNIVOICE_LOG_DIR for its emptiness guard but built
the path from the RAW value, while the Python reader strips it. A padded value
therefore had the writer and the reader looking at different directories, which
is the divergence the PR exists to close.
#1920 — the rotation walk caught bare OSError, so a PermissionError or a real
I/O failure was swallowed and the panel silently rendered less. Only the race
the guard exists for (a file that rolled away, and on Windows the handler's own
sharing violation) is skipped now; anything else surfaces.
#1951 — the fix was right but shipped no tests and no changelog entry. Both
added, including a case pinning that the wizard preflight and the diagnostic
route the same host the same way, since they carry separate copies of the
branch.
#1923 — the cell hardcodes the CTranslate2 model, but if the cuDNN 8 step
failed the backend falls back to PyTorch Whisper and downloads a second
multi-gigabyte model. The cell now says so while the download it just spent is
still on screen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
`round(s.get("end", 0), 2)` does not defend against a stored None: the key is
present, so `.get` returns the None rather than the default, and `round` raises
TypeError: type NoneType doesn't define __round__ method
`max(s.get("end", 0) for s in segments)` on the line above raises first when any
other segment is timed:
TypeError: '>' not supported between instances of 'NoneType' and 'float'
Two engines reach these builders with end=None. `_sherpa_result` sets
duration=None when it cannot derive one from the sample rate, and sherpa is the
first capture engine. `OpenAICompatASRBackend._adapt_response` emits end=None for
every plain-text response, which is what a server that rejects verbose_json
returns, and that backend is selectable as the active one used by accurate mode.
Measure the duration from the segments that carry a number, and pass the nulls
through. That is the shape the segment list already renders since #1904 — it
shows whichever half of the range is known — and it keeps the honest null the
producers deliberately write instead of inventing a zero.
capture_ws.py has the same two lines and gets the same treatment; it also emits
end=None itself in five of its own streaming payloads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The GPU routing "accelerated" caveat branch in preflight and diagnose
always showed the driver/arch "may fail at kernel launch" fix hint,
even when the actual reason was a low-VRAM advisory unrelated to
drivers or torch. Gate that message on KERNEL_RISK_MARKER and show an
accurate VRAM-appropriate hint otherwise.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Achado do CodeRabbit (fora do diff) no PR #1942. Depois de desacoplar os dois
sinais, a mensagem passou a ser escolhida so por `_segmented_off` — mas na
penultima tentativa o plano devolve (True, True): o acelerador esta esgotado E
a tentativa re-lanca, entao o `snapshot_download` so entra na PROXIMA. O log
dizia "falling back to snapshot_download" enquanto na verdade ia retentar.
Sao tres estados distintos e ler so um sinal funde dois deles. A frase virou o
helper puro `_segmented_retry_note(disable, reraise)`, testado nos tres ramos —
inclusive o (True, True), que e o que a revisao pediu para cobrir.
Conflito unico em `install_model`: a `main` (#1926) acrescentou
`and not allow_patterns` a condicao do acelerador, e este branch trocou
`_attempt == 1` por `not _segmented_off`. As duas guardas valem e foram
mantidas juntas.
O comentario acima da condicao ainda dizia que qualquer falha cai no
snapshot_download; atualizado para a regra atual (falha nao-transitoria, ou a
ultima tentativa), com ponteiro para `_segmented_retry_plan`.
Achado P1 do Greptile no PR #1942: com `disable` e `reraise` amarrados um ao
outro, a tentativa 4 de 5 desligava o acelerador E ja caia no
`snapshot_download` na mesma iteracao. Resultado: o acelerador ficava com 3
tentativas em vez de 4, e uma nova queda abandonava o manifesto reaproveitavel
uma tentativa antes do necessario, recomecando por um arquivo separado — que e
exatamente o que este helper existe para evitar.
Os dois sinais agora sao independentes: a tentativa que esgota o acelerador
ainda re-lanca, entao o caminho simples comeca na ULTIMA tentativa. Acelerador
fica com 1-4, `snapshot_download` com a 5.
Tambem blindei o caso de o acelerador falhar ja na ultima tentativa: ali nao ha
para onde re-lancar, entao a decisao vira "caminho simples agora" em vez de
estourar o laco sem nunca ter tentado o fallback.
Os testes do helper passaram a resolver o modulo da app em tempo de execucao,
como pede a instrucao de caminho para tests/**/*.py (import de modulo da app no
topo fica velho se um teste anterior sujar o sys.modules) — apontado pelo
CodeRabbit no mesmo round.
Achados do CodeRabbit no PR #1942.
O mais grave: com o erro classificado como transitorio, o codigo mantinha o
acelerador ligado mas caia direto no `snapshot_download` na MESMA tentativa. Se
esse download desse certo, o laco terminava e o manifesto do `.part` nunca era
reusado — exatamente o recomeco-do-zero que a correcao existe para impedir.
Agora o erro transitorio e propagado para o retry externo, cuja proxima
tentativa reentra no `_segmented_snapshot` e retoma do manifesto. A decisao
virou o helper puro `_segmented_retry_plan`, testavel direto (o laco mora dentro
de `install_model`, uma rota de ~200 linhas). A ultima tentativa fica reservada
para o caminho simples, entao o acelerador continua sem poder ser o motivo de um
install falhar de vez.
Tambem deste round de revisao:
- `Invoke-CimMethod ... Terminate` tinha o retorno descartado com `$null =`. O
Win32_Process.Terminate reporta falha pelo ReturnValue, nao lancando: um kill
negado por permissao era reportado como sucesso e a porta seguia presa. Agora
o ReturnValue e validado, com exit 4 proprio e a mensagem carregando o codigo.
- O teste de concorrencia era vazio: o handler sincrono do MockTransport retorna
antes de qualquer outra task rodar, entao `peak` nunca passava de 1 e a
asserção `peak <= 4` passava sem exercitar o semaforo. Passou a segurar as
requisicoes abertas com um asyncio.Event e a exigir `peak == 4` (verificado:
com o semaforo afrouxado para 1000, o teste acusa 31).
- A doc dizia que OMNIVOICE_DOWNLOAD_MAX_WORKERS limita as faixas e que origem
sem Range cai no snapshot_download. Nenhum dos dois: `_segmented_snapshot` nao
passa `num_connections` (usa as 8 padrao) e origem sem Range vira stream unico
dentro do proprio acelerador.
- Entradas de Highlights do CHANGELOG sem o `(#NNNN)` exigido.
O downloader segmentado gravava progresso no manifesto apenas quando um
segmento INTEIRO terminava, e dimensionava os segmentos como
tamanho/num_connections. Num blob de 806 MB isso dava 8 segmentos de ~100 MB:
numa conexão que cai a cada ~50 MB nenhum segmento jamais completava, o
manifesto nunca era escrito e cada tentativa recomeçava do zero.
Pior, o acelerador só rodava na PRIMEIRA tentativa (`_attempt == 1`), então
depois da primeira queda todas as retentativas iam para o `snapshot_download`
e o `.part` acumulado ficava órfão para sempre.
Agora os segmentos são limitados a 16 MB e a concorrência passa a ser
controlada por semáforo (antes vinha da própria contagem de segmentos), e o
acelerador é preservado entre tentativas quando o erro é de rede — reusando
`_is_retryable_download_error`, que já é a fonte única dessa classificação.
Ele só é desligado de vez quando a falha NÃO é transitória, ou seja, quando o
acelerador de fato não serve naquele host.
Reproduzido em rede real: `peer closed connection without sending complete
message body (received 54260979, expected 100708200)`.
CodeRabbit: the docstring said $XDG_STATE_HOME/VoiceStudio where the code says
OmniVoice. Checked against the writer rather than guessing which side was
wrong -- backend.rs::backend_log_path() joins "OmniVoice" on Linux, so the
code was right and the docstring was a pre-existing error. It matters because
that docstring is what gets read when telling a Linux user where the file is.
Reading backend_log_path() to settle it turned up something worth fixing in
the resolver this PR introduced: Rust checks OMNIVOICE_LOG_DIR before any
per-OS default, and nothing on the Python side knew about it. The backend is a
child of the shell, so an ambient override reaches both processes -- a
resolver that ignored it would look in the per-OS default while the writer
wrote somewhere else. That is the same divergence class as the desktop Logs
panel in #1782, and leaving a newly added resolver knowingly wrong was not an
option.
Two tests: the override moves both candidates, and a whitespace-only value
falls back to the default, matching the writer's !dir.trim().is_empty() guard.
The platform parity test now also clears OMNIVOICE_LOG_DIR so it stays a
statement about the defaults.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chang-Jin-Lee <ckdwls525@gmail.com>
/system/logs/tauri/clear iterated every entry in _tauri_log_candidates() and
truncated each one, including backend_err.log -- the spawned backend's stderr.
Three things make that data loss rather than a tidy-up.
The tab that owns the button does not show it. On desktop the Frontend/Tauri
panel goes through the Rust read_log_tail command, whose tauri_log_path()
resolves tauri.log and nothing else, so the user truncates a file they were
never shown.
backend.rs::open_err_log_for_run() opens it APPEND-ONLY so "a respawn must not
destroy the previous run's evidence" (#1510) and rotates it to .1 rather than
truncating. It manages its own size; clearing it from here only undoes that
design. The same file's spawn diagnostics are described there as "retained in
backend_err.log across runs and lands verbatim in bug reports".
A native death -- a Windows access violation, a SIGSEGV -- writes nothing to
the Python log by construction, so this file is the only record it happened.
#1777 and #1782 are both threads where the maintainer had to ask a reporter
for it by hand.
Clear is narrowed to the shell's own log. The READ path is unchanged: the
candidate list was split into two halves and recomposed, and a parametrized
test pins that /system/logs/tauri still reaches all four files in the same
order on darwin, linux and win32 -- the recompose is where a slip would
silently hide a log.
Refs #1510
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chang-Jin-Lee <ckdwls525@gmail.com>
Three review findings, all applied.
Greptile P1, read race: a rollover can rename a candidate between the
existence check and the open, and the handler exposes no lock a route can
take. Per-file OSError now skips that file instead of 500ing the whole panel
-- which is what the single-file version did in the same situation, so this is
strictly better than before rather than a new guarantee. A roll landing
mid-walk can still shift which chunk a file holds, so a tail taken at that
instant may repeat or miss a block; the panel re-polls every 5s and the next
read is clean. Buying strict consistency would mean reaching into logging's
internals from a route.
Greptile P1, clear race: enumerating first left a window where a rollover
created a backup after the scan and its history survived a Clear that
reported success. Clear now works off the fixed name set -- every name the
handler can write is known up front, so there is nothing to enumerate and no
snapshot to go stale.
CodeRabbit: the CHANGELOG lines ended in (#1782), which reads as "this fixes
#1782" when the desktop path defect that thread is about is untouched. Now
(#1920).
Two tests added, both red before: a candidate vanishing mid-walk still fills
the request from the next file, and a Clear whose scan reported nothing still
empties the backups.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chang-Jin-Lee <ckdwls525@gmail.com>
main.py rolls omnivoice.log at 2 MB into .1/.2/.3, and /system/logs read only
the current file. For the minutes after a rollover the Backend tab showed a
handful of lines while up to 6 MB of history sat in omnivoice.log.1. Measured
with 3 lines in the current file and 500 in each of two backups: tail=200
returned 3 lines and reported total_lines: 3.
That is the panel CONTRIBUTING and the engine guides tell a reporter to paste
from, so the gap costs a round trip on every bug report that lands near a
roll.
The tail now reaches into the rotated siblings, but only when the current file
cannot satisfy the request -- the panel polls every 5s and opening 6 MB of
backups on each call would be a bad trade for a case that only matters right
after a roll. The response gains a `paths` list so a report can say whether
its tail crossed a boundary.
Clear is in the same commit because the two are coupled: it truncated only
omnivoice.log, so it freed almost nothing, and once the tail can see the
backups a Clear that leaves them looks like it did nothing at all.
Found while reading #1782, and it does NOT close it. That thread's blank panel
is the desktop path, which never reaches this route -- details in a comment
there.
Refs #1782
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chang-Jin-Lee <ckdwls525@gmail.com>
Greptile P1: `failure=unavailable` reads as a classification of the cause, but
SubprocessBackend.health_check() swallows its own exceptions by contract, so a
dead sidecar and a package that was never installed both return (False, msg)
and land in the same bucket. Rename to `probe=`, with `raised:<Class>` and
`returned-unavailable` as the two values, so the field states what the probe
did and claims nothing about why. The limitation and what it would take to fix
it properly (structured failure metadata from the probes) are named in the
comment and the test docstring.
Greptile P2: drop the trailing arrow glyph from the Learn more button. It sat
outside t(), and a bare "→" points the wrong way once the app switches to an
RTL locale. InfoHint hardcodes the same glyph and would want the same
treatment, but that is not this PR.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chang-Jin-Lee <ckdwls525@gmail.com>