# v2.25.0 — confident answers that were wrong

**Released 2026-08-17.** Upgrade:

```sh
sudo snap refresh yazses     # snap
pipx upgrade yazses          # or: uv tool upgrade yazses
```

Nothing here crashed. Every one of these ran to completion, printed something
plausible, and was wrong — an empty transcript announced as success, a speaker count
invented rather than capped, a privacy guard that answered differently depending on
which Python you were running. A program that fails loudly gets fixed. These did not.

Two of them were found by **fuzzing** — the config loader and the MCP server, fifteen
adversarial inputs each. Both held on the invariant they were built for and leaked on
the half nobody had looked at. Two more were found by **running the product** rather
than reading it, including a regression this project shipped in v2.24.0.

## `yazses transcribe` told you it worked when it hadn't

This is the surface most people meet YazSes on. It needs no microphone, no hotkey and
no re-login, so it is what the container and Codespace trials run — and it had four
separate ways to mislead you.

**An empty transcript was reported as success.** Audio with nothing recognisable in
it — music, silence, or speech in a language an English-only model cannot read —
produced an empty file, `Wrote transcript.txt`, and the *"if this was useful, a star
helps"* pointer. Success, by every signal the command gives.

It now says the transcript is empty and names causes you can act on, the useful one
being the English-only model — which you can neither see nor guess from a blank file.
The check reads `result.utterances` rather than the rendered text, because a VTT with
no cues is still `WEBVTT`: a check on the string would have stayed silent for exactly
one of the five formats.

**`--max-speakers` invented speakers instead of capping them.** `--help` called it
*"Upper bound on the auto-detected speaker count"*. On the shipped `sherpa` diarizer
it becomes `FastClusteringConfig(num_clusters=N)` — an **exact** count. Measured
against sherpa-onnx 1.13.5 on two well-separated synthetic speakers:

| `num_clusters` | clusters produced |
|---|---|
| `-1` (auto) | 2 ✓ |
| `2` | 2 ✓ |
| `4` | **4** |
| `6` | **6** |

So `--max-speakers 6` on a three-person recording did not allow up to six, it
manufactured six by splitting real speakers apart. The help text, the epilog and the
config comments now say what it does. `0` still auto-detects and remains the default,
so recordings and meetings are unaffected unless a bound was set deliberately. A real
upper bound is not implemented — that needs a second clustering pass and the models to
verify end to end. The measurement is recorded so whoever has them need not
rediscover it.

**`--min-speakers` did nothing at all.** Only the pyannote adapter reads it, and that
is one of the backends this build does not ship. Asking for at least three speakers
got no error, no effect, and a transcript that ignored the floor. It now says so
before transcription starts, names the backend actually in use, and points at
`--speakers`.

**An undecodable file showed you the ffmpeg command line.** Pointing `transcribe` at a
`.docx`, a truncated download or a renamed file printed a `CalledProcessError` with
the full argv and `exit status 183` — naming neither the problem nor anything you
could do. `load_audio` already promised better; its docstring says it raises
`RuntimeError` when no decoder can read the file, but the ffmpeg fallback was
unguarded, so that promise held only when ffmpeg was **absent**. The function broke
its own contract on its most likely failure. It now raises the documented error and
lists formats that work.

## A privacy guard answered differently on Python 3.11 and 3.12

`is_loopback_endpoint` trusted `IPv6Address.is_loopback`, and CPython only began
counting IPv4-mapped addresses as loopback in **3.13**. On 3.11 and 3.12 the same
endpoint — `http://[::ffff:127.0.0.1]:11434`, which genuinely reaches your machine —
was refused with a message about sending dictated text off-device.

This project supports 3.11 through 3.14, so a config that worked on one machine was
rejected on another for no reason a user could see. It now resolves the mapping
itself, so every supported interpreter agrees. The security direction was never wrong
— the older interpreters were the stricter — but a guard whose answer depends on the
interpreter cannot be reasoned about.

## `doctor` told a running daemon to start

Its closing line contradicted its own warning three lines above:

```
[WARN] Daemon: running (PID 4054, state idle) — ... run `yazses restart` ...
▲ Good to go (3 optional warnings above) — run `yazses start`, then hold ...
```

The verdict decided "is it running?" from the check's **tag** (`== "OK"`), which held
only while a running daemon was always OK. v2.24.0 introduced a running-but-`WARN`
state for a stale daemon and did not update this line — so a demonstrably running
daemon read as stopped, and the last thing you are told to do named the wrong command.

The regression was ours, shipped in v2.24.0 and found by running the command on a
machine whose daemon predated the upgrade — not a state a fixture can manufacture.
The verdict now reads the daemon check's own detail rather than re-asking the OS, so
the bottom line cannot disagree with the lines above it.

## `vad_threshold = inf` loaded cleanly and stopped dictation completely

Config loading is deliberately total: a bad file yields a working daemon, the
documented default, and a problem `doctor` reports. `nan` and `inf` are floats, so the
type check waved them through — no repair, no problem reported.

The silence gate is `mean(|audio|) < threshold`, so `inf` discards **every** burst —
you hold the key, speak, and nothing is ever typed — while `nan` makes the comparison
false forever and the gate stops gating. Either way `doctor` reported no config
problem, because as far as the checker was concerned nothing was wrong.

Found by fuzzing the loader with fifteen adversarial files — binary, null bytes,
unparseable TOML, 200 KB strings, deep nesting, RTL unicode. **None made it raise**,
which is the invariant holding; these two were accepted *too* quietly.

## Also

- **The MCP server handed agents Python internals.** A missing argument returned
  `TypeError: transcribe_tool.<locals>._run() missing 1 required positional
  argument`. Arguments are now bound against the tool's signature first, and a
  mismatch is answered from the JSON Schema `tools/list` already publishes. Found by
  fuzzing over a real pipe: the protocol handling came back clean — correct `-32700`,
  `-32600` and `-32601`, notifications unanswered, 200 KB payloads and 200-deep
  nesting survived. Only the half an agent reads was wrong.
- **A bare JSON array over IPC got no reply and a traceback in the log.** `[1,2,3]`,
  `42` and `null` are valid JSON and none has a `.get`, so that one class of malformed
  input raised `AttributeError` past a server that catches `ValueError` — a closed
  socket, no reply, and a traceback that reads like a crash to whoever later opens
  `yazses logs` about something real. Fixed in the parser, so the guarantee holds for
  every caller.
- **`yazses transcribe --format json` now has a documented shape**, with a field table
  and the two things a parser could not have guessed: `speaker` is a cluster id, not a
  person, and does not carry across files; and the empty shapes differ. Nothing about
  the output changed — it was already more dependable than it was described.
