Explorar o código

Added speaker identification

Efren Yevale Varela hai 1 mes
pai
achega
bd4af7e129

+ 1 - 0
.gitignore

@@ -6,6 +6,7 @@ transcripts/
 audio/
 audit/
 models/
+speakers/
 *.onnx
 *.wav
 .ruff_cache/

+ 33 - 15
AGENTS.md

@@ -11,6 +11,8 @@ Always-on speech-to-text daemon plus a separate voice-assistant layer. Python 3.
 uv sync                                                  # install into .venv (see pywhispercpp below)
 uv sync --extra portaudio                                # sounddevice capture backend only
 uv sync --extra triage                                   # optional: in-process Laya triage lane
+uv sync --extra speaker                                  # optional: in-process speaker embedding lane
+# extras are exclusive under uv sync: pass both flags (--extra speaker --extra triage) to keep both
 uv run pytest -v                                         # full suite (hardware- and network-free)
 uv run pytest tests/test_segmenter.py::test_name -v      # single test
 uv run ruff check .                                      # only static check: E,F,W, line-length 100, no typechecker
@@ -36,12 +38,16 @@ Three processes wired by UNIX sockets under `$XDG_RUNTIME_DIR` (fallback `/tmp`)
 TUI startup is mandatory (no silent headless fallback: socket timeout or early child exit kills all),
 and the supervisor restores the tty's `ISIG` on exit so a killed TUI doesn't leave Ctrl+C dead.
 
-Stateful logic belongs in the assistant. Three single-worker-thread lanes exist by design, and none
-may be parallelized: transcription (faster-whisper isn't thread-safe), all LLM calls
-(`assistant/inference.py` `InferenceQueue`; commands submit with priority `-1` to preempt chat), and
-the **Laya triage lane** (`assistant/laya_lane.py`: one background thread plus one loader, running
-in-process in the assistant). The Laya lane is **not** a process, **not** a socket, and does not
-change supervisor spawn counts (2 without the TUI, 3 with it).
+Stateful logic belongs in the assistant (the speaker embedding lane is the daemon-side exception:
+it owns the prototype store). Four single-worker-thread lanes exist by design, and none may be
+parallelized: transcription (faster-whisper isn't thread-safe), the **speaker embedding lane**
+(in-process in `stt-daemon`: `src/stt_daemon/speakers.py` + `src/stt_daemon/embedder.py`, owns the
+`SpeakerStore`, drains the rename inbox), all LLM calls (`assistant/inference.py` `InferenceQueue`;
+commands submit with priority `-1` to preempt chat), and the **Laya triage lane**
+(`assistant/laya_lane.py`: one background thread plus one loader, running in-process in the
+assistant). Neither the speaker embedding lane nor the Laya lane is a process or a socket, and
+neither changes supervisor spawn counts (2 without the TUI, 3 with it); the daemon socket stays
+publish-only.
 
 `stt-daemon` with no subcommand runs the daemon (legacy `--check` / `--list-sources` still work).
 
@@ -59,6 +65,11 @@ change supervisor spawn counts (2 without the TUI, 3 with it).
 - `[triage]` is the display-only signal panel, off by default (`enabled = false`). Its
   `questions_file` (`questions.json` in the cwd) is private user state and is gitignored, like
   `config.toml`.
+- `[speaker]` is opt-in per-utterance speaker identification, off by default, backed by the
+  `SpeakerConfig` dataclass (12th known section; 14 keys: `enabled`, `model_path`, `provider`,
+  `threshold_id`, `threshold_new`, `min_enroll_s`, `rms_floor`, `margin`, `mint_cooldown_s`,
+  `confirm_s`, `max_prototypes`, `provisional_ttl_h`, `store_dir`, `join_timeout_s`). Needs
+  `uv sync --extra speaker` plus a model under `models/`. Full reference: `docs/SPEAKERS.md`.
 - Never treat the live `config.toml` as a source of defaults; it is gitignored user state.
 
 ## Transcription backends
@@ -72,22 +83,29 @@ change supervisor spawn counts (2 without the TUI, 3 with it).
 
 ## Testing
 
-- `tests/` has 526 tests, all hardware- and network-free. Preserve that: inject fakes instead of
-  touching real `pactl`, audio devices, or an LLM.
+- `tests/` has 701 tests, all hardware- and network-free. Preserve that: inject fakes instead of
+  touching real `pactl`, audio devices, an LLM, or `sherpa_onnx` (the speaker lane uses a fake
+  embedder; no ONNX in tests).
 - Key seams: `Daemon(...)` accepts `capture_factory`, `vad_factory`, `transcriber_factory`,
-  `sink_factory`; `PulseAudio` takes an injectable `runner`; `AssistantService` takes `time_fn`,
-  `executor`, `audit`.
+  `embedder_factory`, `sink_factory`; `PulseAudio` takes an injectable `runner`; `AssistantService`
+  takes `time_fn`, `executor`, `audit`.
+- Speaker coverage lives in `tests/test_speakers.py`, `tests/test_daemon_speaker.py`,
+  `tests/test_assistant_speaker.py`, `tests/test_enroll_assist.py` (the offline seed helper
+  `src/stt_daemon/enroll_assist.py`), and `tests/test_evaluate.py` (the `speaker-eval` scorer
+  `src/stt_daemon/evaluate.py`).
 - No `conftest.py` — per-file fakes are the convention. Tests are the behavioral spec; read them
   before changing behavior.
-- The default suite is green without the optional `triage` extra. Install it (`uv sync --extra
-  triage`) only to exercise the Laya measurement test, which otherwise skips.
+- The default suite is green without the optional `triage` and `speaker` extras. Install `triage`
+  (`uv sync --extra triage`) only to exercise the Laya measurement test, which otherwise skips;
+  `speaker` (`uv sync --extra speaker`) is needed only for real ONNX embeddings, never the suite.
 
 ## Conventions & gotchas
 
-- Never commit gitignored runtime output: `config.toml`, `transcripts/`, `audio/`, `audit/`, `.omo/`.
+- Never commit gitignored runtime output: `config.toml`, `transcripts/`, `audio/`, `audit/`,
+  `speakers/` (biometric), `models/`, `.omo/`.
 - New assistant tools: register in `assistant/tools.py`. Model-supplied arguments are JSON-parsed and
   schema-validated before running; set `side_effect` and `requires_confirmation` for anything
   non-read-only.
 - Whisper models must be multilingual — do not use `.en` variants (defaults auto-detect es/en).
-- `docs/` holds the full references (config, CLI, audio setup, assistant, output, troubleshooting,
-  development); `.omo/plans/` holds local design plans (gitignored).
+- `docs/` holds the full references (config, CLI, audio setup, assistant, speaker identification,
+  output, troubleshooting, development); `.omo/plans/` holds local design plans (gitignored).

+ 30 - 0
README.md

@@ -71,10 +71,39 @@ uv sync --extra portaudio
 
 # (Optional) Enable the triage panel (installs the pinned in-process Laya lane):
 uv sync --extra triage
+
+# (Optional) Enable speaker identification (installs sherpa-onnx):
+uv sync --extra speaker
 ```
 
 The `triage` extra is opt-in: the default sync does not install Laya, and the test suite stays green without it.
 
+The `speaker` extra is likewise opt-in and **off by default**. To enable speaker identification,
+run `uv sync --extra speaker` and place `nemo_en_titanet_small.onnx` under `models/` (gitignored):
+
+```bash
+curl -L -o models/nemo_en_titanet_small.onnx \
+  "https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/nemo_en_titanet_small.onnx"
+```
+
+Then set `model_path` and `enabled = true` under `[speaker]` in `config.toml`. Without the extra
+and model, the default sync is ONNX-free and speaker status stays `"none"`. Note that
+`uv sync --extra speaker` is exclusive: it pins the environment to the default deps plus
+`speaker`, which drops the `triage` extra and its stack. To keep both, pass both flags:
+`uv sync --extra speaker --extra triage`. See
+[docs/SPEAKERS.md](docs/SPEAKERS.md) for the full reference. You can also pre-enroll a known
+speaker from existing WAV files with `stt-daemon speaker-enroll`; see the pre-enrollment
+section of [docs/SPEAKERS.md](docs/SPEAKERS.md#pre-enroll-from-a-wav-file). For a guided flow,
+`stt-daemon speaker-enroll-seed` finds the clips closest to a seed you trust and lets you review
+each one before enrolling; see
+[docs/SPEAKERS.md](docs/SPEAKERS.md#assisted-enrollment-from-a-seed-clip). Profiles can be tuned by
+re-running assisted enrollment (prototypes append, and the oldest is evicted at the cap), and the
+store can be wiped and rebuilt; see [docs/SPEAKERS.md](docs/SPEAKERS.md) for both. Assisted review
+now flags near-duplicate and likely-other-speaker candidates and both enrollment commands strip
+near-duplicates from the profile automatically, while `stt-daemon speaker-eval --labeled DIR`
+scores the store against labeled clips; see
+[docs/SPEAKERS.md](docs/SPEAKERS.md#measuring-accuracy-speaker-eval).
+
 ## Quick Start
 
 Copy the example config and edit as needed:
@@ -115,6 +144,7 @@ See [docs/CLI.md](docs/CLI.md) for every subcommand and [docs/CONFIGURATION.md](
 - [docs/CLI.md](docs/CLI.md) — `stt-daemon` subcommands and common overrides.
 - [docs/ASSISTANT.md](docs/ASSISTANT.md) — the assistant layer: modes, command flow, LLM, tools, audit.
 - [docs/TRIAGE.md](docs/TRIAGE.md) — the triage panel: Laya lane, escalation cascade, and questions file.
+- [docs/SPEAKERS.md](docs/SPEAKERS.md) — speaker identification: enrollment, thresholds, store, and commands.
 - [docs/OUTPUT.md](docs/OUTPUT.md) — stdout, JSONL, and WAV output formats and fields.
 - [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) — common errors and how to fix them.
 - [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) — code structure, testing, linting, and packaging.

+ 16 - 0
config.example.toml

@@ -106,3 +106,19 @@ extract_timeout_s    = 30.0
 device               = ""
 preload              = ["multilingual"]
 default_checkpoint   = "multilingual"
+
+[speaker]
+enabled           = false
+model_path        = ""
+provider          = "cpu"
+threshold_id      = 0.60
+threshold_new     = 0.45
+min_enroll_s      = 2.0
+rms_floor         = 0.0
+margin            = 0.04
+mint_cooldown_s   = 60.0
+confirm_s         = 12.0
+max_prototypes    = 8
+provisional_ttl_h = 24.0
+store_dir         = "speakers"
+join_timeout_s    = 2.0

+ 42 - 3
docs/ASSISTANT.md

@@ -69,14 +69,33 @@ refused unless a TUI is attached.
 ## Control protocol
 
 `stt-ctl` verbs (one message each): `command-start`, `command-execute`, `command-cancel`,
-`command-edit --text "..."`, `chat-toggle`, `clear`, `status`.
+`command-edit --text "..."`, `chat-toggle`, `clear`, `status`, `speaker-list`,
+`speaker-name <id> <name>`.
 
 Clients attach by sending `subscribe` first (`viewer: true` for the TUI, which makes the
 assistant treat it as a watcher for the `require_viewer` gate).
 
+Speaker control adds the inbound types `{"type":"speaker_list"}` and
+`{"type":"speaker_name","speaker_id":"spk_0001","name":"Efren"}` (the `speaker-list` and
+`speaker-name <id> <name>` verbs). `speaker_list` replies
+`{"type":"speaker_list","speakers":[{"id","name","status","cumulative_s","sample_path","created_at"}...]}`;
+`speaker_name` replies `{"type":"ack","command":"speaker_name","ok":true}`. Errors:
+`{"type":"error","code":"unknown_speaker"}` when the id is not in the assistant's cached roster,
+and `{"type":"error","code":"bad_message"}` for an empty name.
+
 Assistant → clients: `mode`, `command_text`, `transcript`, `speech_start`/`speech_stop`,
-`tool_call`, `tool_result`, `chat_message`, `cleared`, `heartbeat`, `error`, plus the direct
-replies `ack` (command accepted) and `status`.
+`tool_call`, `tool_result`, `chat_message`, `speakers`, `cleared`, `heartbeat`, `error`, plus the
+direct replies `ack` (command accepted), `status`, and `speaker_list`.
+
+The `speakers` event carries `{"speakers":[...]}`. The daemon fires it on **mint / confirm /
+rename / prune only** (not on every adapt); the assistant caches that roster to answer
+`speaker-list` and validate `speaker-name`, and relays it to viewers.
+
+`transcript` events also carry `seg_id`, `speaker_id`, `speaker_status`
+(`matched|uncertain|unknown|none`), `speaker`, and `speaker_score`. The assistant resolves the
+display before relaying: the name when matched and named, otherwise the literal `"Unknown"`. The
+TUI renders it in the header between the language tag and duration (bold green when named, bold
+red otherwise).
 
 ## Audit
 
@@ -88,6 +107,12 @@ Only **actions** are persisted, to `audit/YYYY-MM-DD.jsonl`:
  "confirmed":null,"ok":true,"result":"...","latency_ms":412}
 ```
 
+Renaming a speaker writes its own record shape:
+
+```json
+{"ts":"...","mode":"speaker","action":"name","speaker_id":"spk_0001","name":"Efren"}
+```
+
 Commands and chat history are volatile — held in memory and discarded.
 
 ## Conversation memory (compaction)
@@ -99,6 +124,9 @@ are summarized by the same endpoint (using `compaction_system_prompt`, capped at
 `compaction_max_tokens`) and replaced by one rolling summary, while the most recent
 `compaction_keep_turns` stay verbatim. Large evictions are summarized in chunks and folded.
 
+Each transcript enters the chat context prefixed with its resolved speaker display:
+`"<Name>: <text>"`, or `"Unknown: <text>"` when the speaker is unknown.
+
 The summary is injected as a second `system` message, so the prefix (`system` + summary) is
 **stable between compactions** — a compaction invalidates the server's prefix cache *once*, and
 subsequent turns are append-only and cache-friendly. If summarization fails (error, empty reply,
@@ -138,6 +166,17 @@ content stage and is merged over the signals. The panel renders both; it is disp
 no modes, commands, or tools. Leaving chat mode does not clear the panel or its buffer; an explicit
 `clear` does.
 
+## Speakers
+
+Opt-in per-utterance speaker identification runs in the daemon (see
+[SPEAKERS.md](SPEAKERS.md)). The assistant caches the daemon's `speakers` roster to answer
+`speaker-list` and validate `speaker-name`, relays the event to viewers, and resolves each
+transcript's display before broadcasting it.
+
+`delete` and `merge` are not supported in v1; the documented path is a manual purge (stop the
+daemon, remove the id from `speakers/speakers.json` and `prototypes.npz`, then restart, or wipe
+`speakers/`). The optional LLM tool `set_speaker_name` is deferred, not implemented.
+
 ## TUI keys
 
 `a` arm · `Enter` execute · `Esc` cancel · `e` edit · `t` chat toggle · `c` clear · `q` quit

+ 99 - 0
docs/CLI.md

@@ -111,6 +111,105 @@ uv run stt-daemon up
 uv run stt-tui
 ```
 
+### `speaker-enroll` — Pre-enroll a speaker from WAV files
+
+```bash
+uv run stt-daemon speaker-enroll --name Efren --wav audio/2026-09-28/143022-123-pause.wav
+```
+
+Registers a known speaker up front, without waiting for the daemon to mint a provisional id
+from a live voice. `--name` is required; pass `--wav` once per clip, and each file
+contributes one prototype (capped at `max_prototypes`, oldest evicted). On success the
+command prints something like `enrolled 'Efren' as spk_0002 (2 clip(s), 7.4s)`.
+
+Each WAV must be 16 kHz mono 16-bit PCM; the clips the daemon saves under `audio/` already
+are, so they need no conversion, and each must be at least `[speaker].min_enroll_s` seconds.
+The store is single-writer and owned by the daemon, so stop it before enrolling, or pass
+`--force` to bypass the refusal. Like every subcommand, `speaker-enroll` also accepts
+`--config`.
+
+`--dedupe-threshold F` (default `0.98`) sets the cosine at or above which two prototypes of the
+same speaker count as duplicates. The command strips near-duplicate prototypes, including any
+pre-existing ones for the named speaker, before it finishes, and reports it as
+`removed N duplicate prototype(s) from the ...`.
+
+Example with several clips:
+
+```bash
+uv run stt-daemon speaker-enroll --name Efren \
+  --wav audio/2026-09-28/143022-123-pause.wav \
+  --wav audio/2026-09-28/150210-456-end_of_turn.wav
+```
+
+### `speaker-enroll-seed` — Assisted enrollment from a seed clip
+
+```bash
+uv run stt-daemon speaker-enroll-seed --name "Efren Yevale Varela" --seed audio/efren-seed.wav
+```
+
+Enrolls from ONE clip you are certain is the speaker. The command scans `--audio-dir` (default
+`[output].audio_dir`), embeds each `*.wav`, ranks them by cosine similarity to the seed, and offers
+the ones at or above `--min-score` (default `0.55`) and at least `[speaker].min_enroll_s` seconds
+for review. `--name` and `--seed` are required; the seed must be 16 kHz mono 16-bit PCM.
+
+Review is interactive: each candidate shows `[i/N] <filename> (score X.XX, Y.Ys)` and you choose
+Accept, Skip, Play, or Quit. `--count N` (default `5`) caps how many reviewed clips are accepted;
+`--yes` skips the prompts and auto-accepts the top candidates (required when stdin is not a TTY);
+`--no-play` disables playback; `--max-scan N` caps how many files are embedded. Quit or Ctrl+C
+writes nothing and prints the equivalent `stt-daemon speaker-enroll --wav ...` command. Like
+`speaker-enroll`, this refuses to run while a daemon appears to be running unless `--force` is
+passed, and accepts `--config`.
+
+Each candidate is flagged during review: `duplicate` when its embedding is already covered by one
+of that speaker's prototypes (cosine at or above `--dedupe-threshold`, default `0.98`), and
+`other-speaker:<name>` when it scores highly against a different enrolled speaker (cosine at or
+above `--other-threshold`, default `0.45`), a contamination hint. Accepting a `duplicate` is
+refused with `skipping <name>: already covered by this profile`, without consuming the accept
+budget. The incoming set (seed plus accepted clips) is de-duplicated before enrolling and
+pre-existing near-duplicate prototypes of that speaker are removed afterward, both reported on
+stdout.
+
+Example, accepting three reviewed clips non-interactively:
+
+```bash
+uv run stt-daemon speaker-enroll-seed --name "Efren Yevale Varela" \
+  --seed audio/efren-seed.wav --count 3 --yes
+```
+
+**Tuning and rebuilding.** Running either enrollment command for a name that already exists
+**appends** prototypes (the oldest is evicted once `max_prototypes` is reached) rather than resetting
+the profile, so you tune by enrolling more diverse clips and flush a bad prototype by adding enough
+new ones. To start clean, stop the daemon, delete `speakers/`, and re-enroll. After any enrollment,
+tune-up, or wipe, **restart the daemon** so it reloads the store. See the
+[Tuning an existing profile](SPEAKERS.md#tuning-an-existing-profile) and
+[Wipe and rebuild](SPEAKERS.md#wipe-and-rebuild) sections of
+[docs/SPEAKERS.md](SPEAKERS.md).
+
+### `speaker-eval` — Measure accuracy against labeled clips
+
+```bash
+uv run stt-daemon speaker-eval --labeled labeled/
+```
+
+Scores the store against a directory of labeled clips. Each immediate subdirectory of `--labeled`
+is a speaker display name and holds that person's `*.wav` clips (16 kHz mono 16-bit PCM). The
+command embeds each clip, labels it with the same matcher the daemon uses, and prints a plain-text
+report: `total`, `accuracy`, `correct`, `wrong`, `uncertain (top-1 correct/total)`, `unknown`, a
+`per-speaker` table (`name | n | correct | acc`), the top `confusions` (`true -> predicted (count)`,
+up to 10), and `margins` (`count`, `mean`, `p10` of top1 - top2). The run is read-only, so it never
+writes the store and the daemon does not need to be stopped. It requires the model and
+`uv sync --extra speaker`, and fails clearly if the store was built with a different embedding
+model (dimension mismatch).
+
+`--min-accuracy F` (optional) exits non-zero when measured accuracy is below `F`, so it works as a
+tuning gate.
+
+Example, using it as a gate:
+
+```bash
+uv run stt-daemon speaker-eval --labeled labeled/ --min-accuracy 0.9
+```
+
 ### (Legacy) `--list-sources` flag
 
 ```bash

+ 42 - 0
docs/CONFIGURATION.md

@@ -130,8 +130,50 @@ extract_timeout_s    = 30.0                 # Extraction request timeout
 device               = ""                   # "" = auto -> None (Laya picks the multilingual checkpoint's default device)
 preload              = ["multilingual"]     # Fixed default: only the multilingual checkpoint is preloaded
 default_checkpoint   = "multilingual"       # Router default; multilingual is pinned for every call
+
+[speaker]                                   # Speaker identification (sherpa-onnx embedding lane; off by default)
+enabled           = false                    # Master switch: false = no embedding lane, every record status "none"
+model_path        = ""                       # Path to the sherpa-onnx embedding .onnx (e.g. "models/nemo_en_titanet_small.onnx"); empty/unset disables
+provider          = "cpu"                    # "cpu" | "cuda"; "cuda" needs a CUDA-enabled sherpa wheel, else warns and falls back to "cpu"
+threshold_id      = 0.60                     # T_id: cosine score at/above which a turn matches an existing prototype
+threshold_new     = 0.45                     # T_new: cosine score below which a turn may mint a new provisional
+min_enroll_s      = 2.0                      # Mint/adapt duration gate: utterances shorter than this never create/adapt
+rms_floor         = 0.0                      # Mint/adapt level gate: utterances quieter than this RMS never create/adapt
+margin            = 0.04                     # Best-2nd margin: require top1 - top2 >= this before adapt/mint decisions
+mint_cooldown_s   = 60.0                     # Cooldown between mints (no second mint within this many seconds)
+confirm_s         = 12.0                     # Cumulative speech seconds a provisional needs to become confirmed
+max_prototypes    = 8                        # Cap on stored prototypes per speaker
+provisional_ttl_h = 24.0                     # Prune provisionals that never reach confirm_s within this many hours (startup)
+store_dir         = "speakers"               # Store root (cwd-relative); gitignored (biometric artifacts)
+join_timeout_s    = 2.0                      # Embedding-lane join timeout before emitting the transcript
+```
+
+`[speaker]` adds opt-in speaker identification: a dedicated in-process sherpa-onnx embedding lane
+maps each utterance to a vector, which is matched (max over stored prototypes) against the local
+`store_dir` store to label the turn. With `enabled = false` (the default) the lane is never built
+and every record keeps `speaker_status = "none"`. With `enabled = true` but no `model_path` (or a
+model that fails to load) the feature disables gracefully: it warns once and never crashes the
+daemon.
+
+Place the embedding model, `nemo_en_titanet_small.onnx` (CC-BY-4.0, 192-dim), under `models/`,
+which is **gitignored**; set `model_path = "models/nemo_en_titanet_small.onnx"`. Download it
+with (note the upstream release tag's spelling, `speaker-recongition-models`):
+
+```bash
+curl -L -o models/nemo_en_titanet_small.onnx \
+  "https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/nemo_en_titanet_small.onnx"
 ```
 
+The installed `onnxruntime` exposes only `CPUExecutionProvider`, so `provider = "cpu"` is the
+default and the recommended value; `provider = "cuda"` requires a **CUDA-enabled sherpa-onnx
+wheel** (not `onnxruntime-gpu` beside the CPU wheel) and otherwise warns and falls back to
+`"cpu"`. See [SPEAKERS.md](SPEAKERS.md) for enrollment, thresholds, the store layout, and the
+`stt-ctl` commands.
+
+`threshold_id`, `threshold_new`, `margin`, and the mint gates are **model- and channel-dependent**.
+The defaults are a starting point only — calibrate them on real far-field audio from the actual
+microphone/room before trusting the labels, and use the per-record scores to tune.
+
 `backend` picks the transcription engine. `faster-whisper` is the default, so nothing changes
 unless you opt in. `pywhispercpp` selects the whisper.cpp backend (GPU via the vendored CUDA
 wheel; see [PYWHISPERCPP.md](PYWHISPERCPP.md)). `model`, `language`, `beam_size`,

+ 7 - 1
docs/DEVELOPMENT.md

@@ -20,6 +20,7 @@ Tests cover:
 - **Provisioning**: `setup`/`ensure`/`teardown`, orphan-name cleanup, and `clear` idempotency
 - **Event bus**: envelope codec, ring replay, subscriber fan-out
 - **Assistant**: mode machine, command readiness/timeouts, tool schema validation, LLM client/history trimming, audit log
+- **Speakers**: prototype matching, mint/confirm/adapt guards, daemon wiring, assistant roster and naming, offline pre-enrollment (`tests/test_speaker_enroll.py`)
 - **TUI**: pure view-state reducer and control-socket client
 
 ## Code structure
@@ -38,6 +39,11 @@ src/stt_daemon/
 ├── picker.py            - Interactive device picker (questionary / numbered menu)
 ├── segmenter.py         - VAD + pre-roll buffer + utterance segmentation (core)
 ├── transcriber.py       - Faster-Whisper wrapper
+├── speakers.py          - Speaker prototype store, matcher, mint/adapt guards
+├── embedder.py          - sherpa-onnx embedding extractor (lazy import, build_embedder)
+├── enroll.py            - Offline pre-enrollment from WAV files (`speaker-enroll`)
+├── enroll_assist.py     - Assisted seed-based enrollment review (`speaker-enroll-seed`)
+├── evaluate.py          - Labeled-clip accuracy scorer (`speaker-eval`)
 ├── output.py            - Sinks (stdout, JSONL, optional WAV)
 ├── daemon.py            - Orchestrator (threads, queues, event emission)
 ├── events.py            - NDJSON event envelope + codec
@@ -57,7 +63,7 @@ src/stt_daemon/
     ├── client.py        - Control-socket client
     └── app.py           - Textual app + `stt-tui` entry point
 
-tests/                    - 359 tests, no audio hardware or live LLM required
+tests/                    - 701 tests, no audio hardware or live LLM required
 
 scripts/
 ├── audio-sink.sh        - Thin wrapper over `stt-daemon setup|clear|list`

+ 20 - 0
docs/OUTPUT.md

@@ -24,6 +24,26 @@ measured (e.g. `emit()` called directly without `transcribe_s`).
 `chunk_pause_ms` because it passed `soft_max_s`), `hard_cap` (`max_utterance_s`
 reached), or `flush` (shutdown mid-utterance).
 
+**Speaker fields** — every JSONL record carries five additive, back-compatible
+speaker fields (always present; `null` when unknown):
+```json
+{"ts": "2026-09-24T10:01:03.123456", "lang": "en", "lang_prob": 0.98, "duration_s": 3.2, "rms": 0.031, "end_reason": "pause", "text": "turn on the lights", "seg_id": 142, "speaker_id": "spk_0001", "speaker_status": "matched", "speaker": "Efren", "speaker_score": 0.71}
+```
+
+- `seg_id` — stable correlation key for the segment (int, `null` when unknown).
+- `speaker_id` — stable cluster key for cross-reference (e.g. `"spk_0001"`, `null` when unknown).
+- `speaker_status` — one of:
+  - `matched` — best score >= `T_id` (threshold_id).
+  - `uncertain` — `T_new <= score < T_id` (threshold_new <= score < threshold_id).
+  - `unknown` — below `T_new` and no profile minted this utterance.
+  - `none` — no embedding computed (model off, audio too short, or error).
+- `speaker` — display label: matched+named -> the name (`"Efren"`),
+  matched+unnamed -> `"spk_0001"`, uncertain -> `"<top1>?"` (e.g. `"spk_0001?"`),
+  unknown/none -> `"?"`.
+- `speaker_score` — the winning cosine similarity, rounded to 4 dp (`null` when unknown).
+
+Existing tooling that ignores unknown JSON keys keeps working unchanged.
+
 **Audio files** — when `save_audio = true`, each transcribed utterance is also
 written as a 16 kHz mono WAV under `audio_dir/YYYY-MM-DD/`, and the record gains
 an `"audio"` field with its path:

+ 435 - 0
docs/SPEAKERS.md

@@ -0,0 +1,435 @@
+# Speaker Identification
+
+Speaker identification labels each transcribed utterance with who spoke it. A dedicated
+in-process sherpa-onnx embedding lane maps the clip to a vector, and the vector is matched
+against a local store of per-speaker prototypes. The label then rides the transcript to the
+TUI header and into the JSONL record. There is no separate process, no socket, and no change
+to the supervisor's child count.
+
+The feature is off by default (`[speaker].enabled = false`), and the extra it needs is
+opt-in, so a plain `uv sync` and the existing test suite behave exactly as before. When it is
+off, or the model is missing, or the library fails to load, the feature no-ops: no lane is
+built, no store is touched, the daemon still runs, and every record carries
+`speaker_status = "none"`.
+
+## What the transcript shows
+
+- **TUI header.** The speaker sits between the language tag and the duration, for example
+  `10:01:03  EN  Efren  3.2s  pause`. Named speakers render in bold green; every other
+  speaker, including provisionals and `Unknown`, renders in bold red. The literal
+  `Unknown` is shown for unmatched turns.
+- **JSONL.** Existing records are enriched in place; there is one append-only file and no
+  separate annotated file. Five additive, back-compatible fields are added: `seg_id`,
+  `speaker_id`, `speaker_status`, `speaker`, and `speaker_score`. All are `null` or
+  `"none"` when the feature is off or the turn could not be identified. See
+  [OUTPUT.md](OUTPUT.md) for the exact field semantics.
+
+## How matching works
+
+A speaker profile is a set of L2-normalized embedding prototypes, capped at
+`max_prototypes` (8). A turn's score is the maximum cosine similarity over all of that
+speaker's prototypes, never a centroid. The score lands the turn in one of four status bands:
+
+| status | condition | behavior |
+|---|---|---|
+| `matched` | score >= `threshold_id` (`0.60`) | Reports the matched speaker; may adapt the profile. |
+| `uncertain` | `threshold_new` (`0.45`) <= score < `0.60` | Reports the top-1 speaker with a `?` suffix; never enrolls and never adapts. |
+| `unknown` | score < `0.45` | No label; eligible for auto-enrollment after the guards pass. |
+| `none` | no embedding computed | Feature off, audio too short, or an error. |
+
+The two thresholds and `margin` are starting points, not measured values. They are
+**model- and channel-dependent**, so calibrate them on real far-field audio from the actual
+microphone and room before trusting the labels, and use the per-record `speaker_score` to
+tune. `speaker_status` is the coarse answer; the score is what you optimize against.
+
+## Auto-enrollment and promotion
+
+An unknown voice is minted as a provisional `spk_NNNN` only after every guard passes:
+
+- Duration: the utterance is at least `min_enroll_s` (2.0 s).
+- Level: the utterance is at or above `rms_floor` (default `0.0`, i.e. no gate).
+- Hysteresis: two consecutive mutually-similar misses, so a single odd clip cannot mint.
+- Cooldown: no second mint within `mint_cooldown_s` (60 s).
+- Margin: the best prototype beats the second best by at least `margin` (0.04).
+
+A provisional profile is promoted to `confirmed` once it accumulates `confirm_s` (12 s) of
+speech. Until then it is labeled with its provisional id. A provisional that never confirms
+is pruned after `provisional_ttl_h` (24 h). `stt-ctl speaker-name` names a provisional, and
+naming it deletes the rolling sample that was kept for the enrollment decision. The name is
+then shown everywhere the speaker appears.
+
+## Store layout and privacy
+
+Everything lives under `store_dir` (`speakers`), relative to the working directory, and is
+gitignored:
+
+```
+speakers/
+  speakers.json          # roster: ids, names, status, cumulative speech seconds
+  prototypes.npz         # normalized embedding prototypes per speaker
+  samples/               # rolling <id>.wav samples held for enrollment decisions
+  inbox/                 # rename requests the daemon applies
+    rejected/            # malformed requests
+```
+
+Directories are created `0700` and sample WAVs `0600`. Samples are deleted as soon as a
+profile is named or pruned. **Embeddings and samples are biometric data.** Treat `speakers/`
+as private user state, not as a shareable artifact. Identity here is a recoverable cache, not
+an archive: re-enrolling restores it.
+
+## Commands
+
+List the roster (id, name, status, cumulative seconds, sample path):
+
+```bash
+uv run stt-ctl speaker-list
+```
+
+Name a provisional speaker:
+
+```bash
+uv run stt-ctl speaker-name spk_0001 Efren
+```
+
+Naming writes a rename request to `speakers/inbox/`, which the daemon applies on its next
+tick; an unknown id returns an error. The assistant caches the roster from `speakers` bus
+events, so a name that has not yet flowed through an event can be reported as
+`unknown_speaker` (see [TROUBLESHOOTING.md](TROUBLESHOOTING.md)). Renaming a speaker that
+already has a name overwrites it. There is no `delete` or `merge` in v1.
+
+## Install and enable
+
+The dependency lives behind an optional extra:
+
+```bash
+# 1. Install the speaker extra (adds sherpa-onnx)
+uv sync --extra speaker
+
+# 2. Download the embedding model into the gitignored models/ directory
+curl -L -o models/nemo_en_titanet_small.onnx \
+  "https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/nemo_en_titanet_small.onnx"
+
+# 3. Point config.toml at it and turn it on
+#    [speaker]
+#    enabled = true
+#    model_path = "models/nemo_en_titanet_small.onnx"
+```
+
+The extra pulls two distributions, `sherpa-onnx` (the Python API) and `sherpa-onnx-core` (the
+wheel that bundles the native `libonnxruntime.so`). Extras are exclusive under `uv sync`:
+`uv sync --extra speaker` matches the default deps plus `speaker` exactly, so it removes the
+`triage` extra and its stack (`laya`, `torch`, `transformers`, ...). To keep both, request both:
+`uv sync --extra speaker --extra triage`.
+
+The model is `nemo_en_titanet_small.onnx`, 38.4 MB, 192-dimensional, CC-BY-4.0, and not
+gated. The `models/` directory is gitignored, so the download is local-only. Note the
+upstream release tag's spelling, `speaker-recongition-models`; the typo is in the URL and
+must be copied verbatim.
+
+The model is CPU by design (`provider = "cpu"`, `num_threads = 1`). `provider = "cuda"`
+requires a CUDA-enabled sherpa wheel; without one it warns and falls back to `cpu`. The
+installed `onnxruntime` exposes only `CPUExecutionProvider`, so `cuda` changes nothing
+without a different wheel.
+
+If the Spanish/English quality is not enough for your room, two larger sherpa models are
+drop-in alternatives:
+
+| model | size | dim |
+|---|---|---|
+| `3dspeaker_speech_campplus_sv_en_voxceleb_16k.onnx` | 28 MB | 192 |
+| `wespeaker_en_voxceleb_resnet34_LM.onnx` | 25 MB | 256 |
+
+Same download host and URL shape; only the filename changes.
+
+## Pre-enroll from a WAV file
+
+Enrollment is reactive by default: the daemon mints a provisional `spk_NNNN` on an unknown
+live voice, and you name it afterwards with `stt-ctl speaker-name`. To register a known
+speaker up front from clips you already have, run `stt-daemon speaker-enroll` with one or
+more WAV files:
+
+```bash
+uv run stt-daemon speaker-enroll --name Efren --wav audio/2026-09-28/143022-123-pause.wav
+```
+
+Repeat `--wav` to enroll from several clips; each file contributes one prototype, capped at
+`max_prototypes` with the oldest evicted:
+
+```bash
+uv run stt-daemon speaker-enroll --name Efren \
+  --wav audio/2026-09-28/143022-123-pause.wav \
+  --wav audio/2026-09-28/150210-456-end_of_turn.wav
+```
+
+On success the command prints `enrolled 'Efren' as spk_0002 (2 clip(s), 7.4s)`. The `--name`
+and `--wav` flags are required; `--force` bypasses the running-daemon refusal, and `--config`
+is the same global override as every other subcommand.
+
+Each WAV must be **16 kHz mono 16-bit PCM**. No resampling is performed: stereo, a different
+sample rate, or a non-WAV file is rejected with a clear error. The clips the daemon saves
+under `audio/YYYY-MM-DD/` when `[output].save_audio = true` are already in that format, so
+they need no conversion. Each clip must also be at least `[speaker].min_enroll_s` seconds
+(default `2.0`); shorter clips are rejected. This is the "is it enough audio?" check.
+
+The profile is created `confirmed` with the given name, and unlike a named provisional it
+stores no sample WAV; samples exist only for the listen-before-naming flow. Explicit
+enrollment bypasses the automatic mint guards (duration, level, consecutive-miss, cooldown,
+and margin), because you are asserting the identity rather than inferring it; the CLI's
+duration gate is the only length check. Re-enrolling an existing name appends a prototype to
+that same speaker rather than creating a duplicate.
+
+Enrollment does **not** need `[speaker].enabled = true`; you can pre-enroll before turning
+the feature on. It does need the model and the extra: `uv sync --extra speaker` and
+`[speaker].model_path` set.
+
+The store is single-writer and owned by the daemon. **Stop the daemon before enrolling**, or
+the in-memory store can overwrite your change on its next save. The command detects the
+daemon's event socket and refuses unless `--force` is passed. Storage is unchanged:
+`speakers/speakers.json` and `speakers/prototypes.npz` under `store_dir`, gitignored and
+biometric.
+
+## Assisted enrollment from a seed clip
+
+Enrolling from one long clip produces weak, polluted prototypes: a long `pause` segment can hold
+several speakers, so its embedding lands somewhere between them. `speaker-enroll-seed` takes ONE
+clip you are certain is the speaker, finds the clips in the audio directory that are most similar
+to it, and lets you review each one before it joins the profile:
+
+```bash
+uv run stt-daemon speaker-enroll-seed --name "Efren Yevale Varela" --seed audio/efren-seed.wav
+```
+
+Flags:
+
+- `--name` (required) — speaker display name.
+- `--seed` (required) — a 16 kHz mono 16-bit WAV known to be that speaker.
+- `--count N` (default `5`) — how many reviewed clips to accept.
+- `--min-score F` (default `0.55`) — minimum cosine to the seed for a clip to be offered.
+- `--audio-dir DIR` (default `[output].audio_dir`) — where to scan for `*.wav`.
+- `--max-scan N` (default `0` = all) — cap the number of files embedded, useful on large dirs.
+- `--dedupe-threshold F` (default `0.98`) — cosine at or above which a candidate counts as already
+  covered by one of that speaker's prototypes.
+- `--other-threshold F` (default `0.45`) — cosine at or above which a candidate is flagged as
+  scoring against a different enrolled speaker.
+- `--yes` — non-interactive: auto-accept the top `--count` candidates.
+- `--no-play` — do not attempt audio playback during review.
+- `--force` — enroll even when a daemon appears to be running.
+- `--config` — the same global override as every subcommand.
+
+The flow:
+
+1. Loads config and refuses if a daemon appears to be running (its event socket exists) unless
+   `--force` is passed.
+2. Requires the model and `uv sync --extra speaker`, exactly like `speaker-enroll`.
+3. Fails with a clear error if the stored prototype dimension differs from the model's: the store
+   was built with a different model, so wipe `store_dir` and re-enroll.
+4. Scans the audio directory, embeds each clip, ranks by cosine similarity to the seed, and keeps
+   clips at least `[speaker].min_enroll_s` seconds long and at or above `--min-score`.
+5. Reviews each candidate interactively (unless `--yes`), showing
+   `[i/N] <filename> (score X.XX, Y.Ys)` with four actions: **Accept**, **Skip** (show the next
+   candidate), **Play** (listen, then decide again), or **Quit** (stop, enroll nothing).
+6. Enrolls the seed (when it is at least `min_enroll_s`) plus the accepted clips, then prints
+   something like
+   `enrolled 'Efren Yevale Varela' as spk_0001 from seed: 4 clip(s) (seed + 3 reviewed, 16.9s, 4 prototype(s))`.
+
+If the seed is shorter than `min_enroll_s` it is used only as the query, and a warning is printed.
+If nothing is accepted, or the store would exceed `max_prototypes` (an eviction warning), the
+command says so and no partial write happens.
+
+**Review warnings.** Each candidate is flagged during review:
+
+- `duplicate` — its embedding is already covered by one of that speaker's existing prototypes
+  (cosine at or above `--dedupe-threshold`, default `0.98`).
+- `other-speaker:<name>` — it scores highly against a **different** enrolled speaker's profile
+  (cosine at or above `--other-threshold`, default `0.45`), which is a contamination hint.
+
+Choosing **Accept** on a `duplicate` is refused: it prints
+`skipping <name>: already covered by this profile` and moves on without consuming the accept
+budget. An `other-speaker:<name>` flag does not block the accept; it is a hint to listen harder.
+
+**Duplicate removal.** Both commands guarantee no duplicates are added. The incoming set (seed plus
+accepted clips) is greedily de-duplicated before enrolling, printed as
+`skipped N duplicate clip(s) already covered ...`, and after enrolling the command removes any
+pre-existing near-duplicate prototypes of that speaker, printed as
+`removed N duplicate prototype(s) from the ...`. Plain `speaker-enroll` removes pre-existing
+duplicates for the named speaker too, and reports it. The semantics are the same in both commands:
+for each speaker, prototypes are walked in insertion order and the **first of each cluster** is
+kept; a prototype is dropped when its cosine to an already-kept prototype of the **same** speaker
+is at or above the threshold. The first prototype is always kept. This is what cleans up the store
+when several near-identical clips were enrolled.
+
+**Abort semantics.** Pressing **Quit** or Ctrl+C during review writes nothing. The command prints
+the clips accepted so far and an equivalent manual `stt-daemon speaker-enroll --wav ...` command,
+so you can run the explicit form yourself.
+
+**Playback.** Review tries `paplay`, then `aplay`, then `ffplay`. If none exist, review continues
+without sound; `--no-play` skips playback entirely.
+
+**Non-interactive.** A non-TTY without `--yes` errors out; it never hangs waiting for input.
+
+Prefer short `end_of_turn` clips for enrollment. Long `pause` or `end_of_turn` segments can hold
+more than one speaker, which pollutes a prototype; that is exactly why the helper makes you listen
+and review each clip. The plain `speaker-enroll --wav ...` command still exists for enrolling an
+explicit clip list you already trust, as described in
+[Pre-enroll from a WAV file](#pre-enroll-from-a-wav-file).
+
+## Tuning an existing profile
+
+Re-running `speaker-enroll-seed` or `speaker-enroll` for a name that already exists does **not**
+reset the profile. It reuses the same profile id (`spk_NNNN`) and **appends** the new clips as
+additional prototypes, and `cumulative_s` keeps growing. Tuning is therefore additive: you steer a
+profile by adding good clips, and you flush a bad one by adding enough new clips to push the oldest
+out.
+
+**Broaden coverage.** Add several diverse short `end_of_turn` clips, in the 1-5 s range you actually
+speak (enrollment still requires each clip to clear `min_enroll_s`, so the usable ones start at 2 s
+by default), varying phrase, level, and session. More spread across the ways you really sound means
+short live utterances score higher. This is what fixed a real case where a 1.74 s utterance landed
+below `threshold_new`: the profile held one narrow prototype, and a handful of varied short clips
+lifted the live score into range.
+
+**Flush a bad or polluted prototype.** Eviction is oldest-first (FIFO), so adding new clips pushes
+the oldest ones out. Each speaker holds at most `max_prototypes` (default `8`). To evict the oldest
+one, add enough clips to exceed the cap: at 7 of 8, adding **2** more drops the oldest, i.e.
+`max_prototypes - current + 1` additions; to evict the **N** oldest, add
+`max_prototypes - current + N`. There is no per-prototype delete and no replace. Duplicates,
+though, are handled for you: both enrollment commands drop near-duplicate prototypes
+automatically and the review flags them as `duplicate`, so flushing is the way to remove a bad
+prototype that is not a duplicate, short of wiping the store.
+
+**`max_prototypes` is itself a lever.** Raising it (e.g. `16`) keeps more prototypes before eviction,
+giving broader coverage at a marginal cost in false-match risk. Lowering it forces eviction sooner
+and keeps the profile tighter.
+
+**What to accept.** Listen with **Play**. Accept clean single-speaker clips; **skip anything with
+a second voice**, which is the whole point of the review. Long `pause` segments are the usual
+offenders. Near-duplicate clips are flagged `duplicate` and cannot be accepted, and both commands
+strip near-duplicates from the profile automatically, so you rarely need to worry about them.
+
+**What tuning cannot do.** It cannot delete a specific prototype or reorder them, and it does not
+change thresholds: `threshold_id` and `threshold_new` are config, not the helper. `--yes` skips the
+review entirely and auto-accepts the top `--count` candidates, so it cannot catch a clip containing
+a second voice; do **not** use `--yes` for tune-ups.
+
+**Verify.** Restart the daemon so it loads the updated store, then speak and watch the TUI header
+(your name vs `Unknown`). With `[output].jsonl = true`, check the `speaker`, `speaker_status`, and
+`speaker_score` fields. Repeatedly `uncertain` or `unknown` for your own voice means the profile
+needs more or more diverse clips. A confident **wrong** name usually means overlapping speakers in
+the enrollment clips, or a genuinely hard voice pair.
+
+## Measuring accuracy (speaker-eval)
+
+Prototype geometry tells you the distances between your own enrolled clips, not whether the matcher
+gets real utterances right. `speaker-eval` scores the store against labeled clips and prints a
+plain-text report, so you get a real accuracy number instead of guessing:
+
+```bash
+uv run stt-daemon speaker-eval --labeled labeled/
+```
+
+`--labeled DIR` is required. Each immediate subdirectory is a speaker **display name** and holds
+that person's `*.wav` clips, 16 kHz mono 16-bit PCM like enrollment:
+
+```
+labeled/
+  Efren Yevale Varela/
+    001.wav
+    002.wav
+  Nombre Apellido/
+    001.wav
+```
+
+The command embeds every clip, labels it with the same matcher the daemon uses, and prints `total`,
+`accuracy`, `correct`, `wrong`, `uncertain (top-1 correct/total)`, and `unknown`; a `per-speaker`
+table (`name | n | correct | acc`); the top `confusions` as `true -> predicted (count)`, up to 10;
+and `margins` with the `count`, `mean`, and `p10` of the top1 - top2 score gap over prototypes.
+
+The run is read-only: it never writes the store, so the daemon does not need to be stopped. It does
+need the model and `uv sync --extra speaker`, exactly like enrollment. If the store was built with
+a different embedding model (dimension mismatch), it fails clearly instead of scoring nonsense:
+wipe `store_dir` and re-enroll.
+
+To use it as a tuning gate, pass `--min-accuracy F`, which exits non-zero when measured accuracy
+is below `F`:
+
+```bash
+uv run stt-daemon speaker-eval --labeled labeled/ --min-accuracy 0.9
+```
+
+Label a handful of clips you are sure about per speaker. The accuracy and the confusion pairs are
+the fastest way to see whether a profile needs more diverse clips, whether two voices genuinely
+collide, or whether the thresholds need retuning.
+
+## Wipe and rebuild
+
+Wipe the store when a profile is polluted beyond easy flushing, when you switch embedding models
+(the store must be rebuilt if the model dimension changes), or when the roster got messy. Wiping
+removes **all** speakers. To remove just one, use the manual purge below, or flush it with added
+clips as in [Tuning an existing profile](#tuning-an-existing-profile).
+
+1. **Stop the daemon.** The store is single-writer.
+2. **Delete the store:**
+
+   ```bash
+   rm -rf speakers/
+   ```
+
+   `speakers/` is gitignored and a recoverable cache; `models/` is untouched.
+3. **Re-enroll each speaker** from one clip you are sure of, reviewing the suggestions:
+
+   ```bash
+   uv run stt-daemon speaker-enroll-seed --name "Nombre Apellido" --seed path/to/a-clip-you-trust.wav --count 5
+   ```
+
+   Repeat per speaker. The plain `speaker-enroll --wav ...` form works too when you have an
+   explicit list. The next run rebuilds an empty store the moment it finds none.
+4. **Restart the daemon** so it loads the fresh store.
+
+This is the broad version of the manual purge below, which removes a single speaker instead of the
+whole roster.
+
+## Configuration reference
+
+All keys live under `[speaker]`. The defaults are what you get with no config file.
+`[speaker]` is the 12th known section; unknown keys inside it are silently ignored, like
+every other section.
+
+| key | default | meaning |
+|---|---|---|
+| `enabled` | `false` | Master switch. `false` means no lane, no store, and every record `speaker_status = "none"`. |
+| `model_path` | `null` | Path to the sherpa-onnx embedding `.onnx`. Unset or missing disables the feature. |
+| `provider` | `"cpu"` | `cpu` or `cuda`. `cuda` needs a CUDA-enabled sherpa wheel, else warns and falls back to `cpu`. |
+| `threshold_id` | `0.60` | `T_id`: cosine score at or above which a turn matches an existing prototype. |
+| `threshold_new` | `0.45` | `T_new`: cosine score below which a turn may mint a new provisional; the lower edge of `uncertain`. |
+| `min_enroll_s` | `2.0` | Mint/adapt duration gate: shorter utterances never create or adapt a profile. |
+| `rms_floor` | `0.0` | Mint/adapt level gate: quieter utterances never create or adapt a profile. |
+| `margin` | `0.04` | Best-2nd margin required before an adapt or mint decision. |
+| `mint_cooldown_s` | `60.0` | Minimum spacing between mints. |
+| `confirm_s` | `12.0` | Cumulative speech seconds a provisional needs to be promoted to `confirmed`. |
+| `max_prototypes` | `8` | Cap on stored prototypes per speaker. |
+| `provisional_ttl_h` | `24.0` | Prune provisionals that never confirm within this many hours, checked at startup. |
+| `store_dir` | `"speakers"` | Store root, cwd-relative; gitignored because it holds biometric artifacts. |
+| `join_timeout_s` | `2.0` | Embedding-lane join timeout before the transcript is emitted. |
+
+## The one-label-per-segment limitation
+
+Identification runs on the whole VAD segment and produces **one speaker label per
+segment**. If two people speak within one segment, the clip yields a single blended
+embedding, which usually lands in `uncertain` or `unknown`; such clips are never enrolled
+and never adapted. Natural turn-taking, where a pause separates the speakers, splits into
+separate segments and labels each one correctly. If mixed-speaker segments are common in
+your audio, lower `[vad].silence_ms` (within reason) so pauses break turns sooner, or accept
+that overlapping speech is out of scope for v1.
+
+## Manual purge
+
+`delete` and `merge` are not supported in v1. To remove a speaker, stop the daemon, remove
+the id from `speakers/speakers.json` and its rows from `speakers/prototypes.npz`, then
+restart. To start clean, stop the daemon and wipe `speakers/` entirely; the next run rebuilds
+an empty store. Because identity is a recoverable cache, re-enrollment restores it.
+
+## Troubleshooting
+
+For a missing `sherpa-onnx` install, a missing model, everything showing `Unknown`, naming
+that reports `unknown_speaker`, or resetting the store, see the Speaker identification
+section of [TROUBLESHOOTING.md](TROUBLESHOOTING.md).

+ 78 - 0
docs/TROUBLESHOOTING.md

@@ -123,3 +123,81 @@ You can override the cache:
 export HF_HOME=/path/to/cache
 uv run stt-daemon --model small
 ```
+
+## Speaker identification
+
+See [SPEAKERS.md](SPEAKERS.md) for the feature reference.
+
+### `sherpa-onnx` not installed
+
+**Cause:** the speaker extra is opt-in and a plain `uv sync` does not install `sherpa-onnx`.
+
+**Fix:**
+
+```bash
+uv sync --extra speaker
+```
+
+### `ImportError: libonnxruntime.so: cannot open shared object file`
+
+**Cause:** the native onnxruntime that `sherpa-onnx-core` bundles is missing. This happens when
+the environment was synced without the extra, or before the core wheel was included.
+
+**Fix:** re-sync the speaker extra, which now installs `sherpa-onnx-core`:
+
+```bash
+uv sync --extra speaker
+```
+
+If you also use the triage panel, request both extras so neither stack is dropped:
+
+```bash
+uv sync --extra speaker --extra triage
+```
+
+The daemon itself only warns and keeps running with `speaker_status = "none"`; the hard error
+appears when you run the one-off `stt-daemon speaker-enroll`.
+
+### Model missing, or `enabled = true` with no `model_path`
+
+**Cause:** with `enabled = true` but `model_path` unset, missing, or pointing at a model that
+fails to load, the feature disables gracefully. It warns once and never crashes the daemon:
+no lane is built, no store is touched, and every record keeps `speaker_status = "none"`.
+
+**Fix:** download the model into the gitignored `models/` directory and set `model_path`:
+
+```bash
+curl -L -o models/nemo_en_titanet_small.onnx \
+  "https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/nemo_en_titanet_small.onnx"
+# then: [speaker] model_path = "models/nemo_en_titanet_small.onnx"
+```
+
+### Every turn shows `Unknown`
+
+**Cause:** either the feature is disabled (check `speaker_status = "none"` in the JSONL
+records), or the thresholds do not fit your audio. `threshold_id` (0.60) and `threshold_new`
+(0.45) are model- and channel-dependent starting points; the wrong model for the room, or a
+bad microphone, can keep every score below `threshold_new`.
+
+**Fix:** confirm `enabled = true` and a loadable `model_path` first. Then calibrate on real
+far-field audio: read `speaker_score` from the JSONL records and lower the thresholds until
+repeated voices cross them, or switch to a fallback model. See [SPEAKERS.md](SPEAKERS.md) for
+the bands and the model options.
+
+### Naming reports `unknown_speaker`
+
+**Cause:** `stt-ctl speaker-name` failed because the id is not in the assistant's cached
+roster. The assistant caches speakers from the `speakers` bus events, so an id that has not
+yet appeared in an event is unknown to it.
+
+**Fix:** run `uv run stt-ctl speaker-list` to see the ids the assistant knows, then retry with
+one of those. If the roster looks stale, let a turn settle so a fresh `speakers` event
+arrives, or restart the assistant.
+
+### Resetting the store
+
+**Cause:** a bad prototype, an unwanted provisional, or a corrupt store.
+
+**Fix:** `delete` and `merge` are not supported in v1. Stop the daemon, remove the id from
+`speakers/speakers.json` and its rows from `speakers/prototypes.npz`, then restart. To start
+clean, stop the daemon, delete `speakers/`, and restart.

+ 4 - 0
pyproject.toml

@@ -25,6 +25,10 @@ dependencies = [
 portaudio = ["sounddevice>=0.4.6"]
 # In-process Laya multilingual triage lane: `uv sync --extra triage`, pinned in [tool.uv.sources].
 triage = ["laya"]
+# In-process sherpa-onnx speaker-embedding lane. `sherpa-onnx` is a meta wheel
+# that requires `sherpa-onnx-core` (the wheel bundling libonnxruntime.so); list
+# it explicitly so the lock and `uv sync` always install the native runtime.
+speaker = ["sherpa-onnx>=1.12.0", "sherpa-onnx-core>=1.12.0"]
 
 [dependency-groups]
 dev = ["pytest>=8", "ruff"]

+ 16 - 0
src/stt_daemon/assistant/control.py

@@ -260,6 +260,22 @@ class ControlServer:
             self._ack_or_error(client, command, service.toggle_chat(), "cannot toggle chat now")
         elif command == "clear":
             self._ack_or_error(client, command, service.clear(), "nothing to clear")
+        elif command == "speaker_list":
+            self._send(client, "speaker_list", speakers=service.speaker_list())
+        elif command == "speaker_name":
+            speaker_id = str(message.get("speaker_id", ""))
+            name = str(message.get("name", ""))
+            if not name:
+                self._send(client, "error", code="bad_message", message="name is required")
+            elif service.speaker_name(speaker_id, name):
+                self._send(client, "ack", command="speaker_name", ok=True)
+            else:
+                self._send(
+                    client,
+                    "error",
+                    code="unknown_speaker",
+                    message=f"unknown speaker: {speaker_id}",
+                )
         else:
             self._send(client, "error", code="unknown_command", message=f"unknown: {command}")
 

+ 83 - 4
src/stt_daemon/assistant/service.py

@@ -5,9 +5,13 @@ maintains the mode machine, assembles command text, and exposes an API that the
 Phase 3 control socket will call.
 """
 
+import json
 import logging
+import os
+import tempfile
 import threading
 import time
+import uuid
 from typing import Any, Callable
 
 from .. import events
@@ -38,7 +42,7 @@ HEARTBEAT_EVENT = "heartbeat"
 CAPTURING = "capturing"
 READY = "ready"
 EXECUTING = "executing"
-RELAYED_TYPES = (events.TRANSCRIPT, events.SPEECH_START, events.SPEECH_STOP)
+RELAYED_TYPES = (events.TRANSCRIPT, events.SPEECH_START, events.SPEECH_STOP, events.SPEAKERS)
 
 _TRIAGE_HISTORY_MAX = 200
 
@@ -102,6 +106,9 @@ class AssistantService:
         self._pending_chat: list[str] = []
         self.triage_history: list[str] = []
 
+        # Speaker roster cache (display-name join only; the daemon owns identity).
+        self._speakers: dict[str, dict] = {}
+
         self._triage = triage
         self._laya_lane = laya_lane
         self._triage_extract_fn = triage_extract_fn
@@ -400,6 +407,65 @@ class AssistantService:
         self._reset_triage()
         return True
 
+    def speaker_list(self) -> list[dict]:
+        """Return the cached speaker roster sorted by id (empty when nothing cached)."""
+        with self._lock:
+            return [dict(self._speakers[key]) for key in sorted(self._speakers)]
+
+    def speaker_name(self, speaker_id: str, name: str) -> bool:
+        """Request a display name for a cached speaker id (mode-orthogonal).
+
+        Writes an atomic rename-request file into the daemon's inbox; the
+        daemon applies it. Returns False when the id is unknown or the
+        write fails (never raises).
+        """
+        with self._lock:
+            if speaker_id not in self._speakers:
+                return False
+        inbox = os.path.join(self.cfg.speaker.store_dir, "inbox")
+        payload = {"speaker_id": speaker_id, "name": name}
+        try:
+            os.makedirs(inbox, exist_ok=True)
+            try:
+                os.chmod(inbox, 0o700)
+            except OSError as e:
+                logger.warning("failed to secure speaker inbox %s: %s", inbox, e)
+            fd, tmp = tempfile.mkstemp(dir=inbox, suffix=".tmp")
+            try:
+                with os.fdopen(fd, "w", encoding="utf-8") as f:
+                    json.dump(payload, f, ensure_ascii=False)
+                    f.write("\n")
+                os.replace(tmp, os.path.join(inbox, f"{uuid.uuid4().hex}.json"))
+            except OSError:
+                try:
+                    os.unlink(tmp)
+                except OSError:
+                    pass
+                raise
+        except OSError as e:  # noqa: BLE001 - naming must not fail the operation
+            logger.warning("speaker rename request failed: %s", e)
+            return False
+        if self._audit is not None:
+            try:
+                self._audit.record(
+                    mode="speaker", action="name", speaker_id=speaker_id, name=name
+                )
+            except Exception as e:  # noqa: BLE001 - auditing is best effort
+                logger.warning("speaker audit failed: %s", e)
+        return True
+
+    @staticmethod
+    def _speaker_display(event: dict) -> str:
+        raw = event.get("speaker")
+        speaker_id = event.get("speaker_id")
+        named = (
+            event.get("speaker_status") == "matched"
+            and isinstance(raw, str)
+            and bool(raw)
+            and raw != speaker_id
+        )
+        return str(raw) if named else "Unknown"
+
     # ------------------------------------------------------------------ events
 
     def handle_event(self, event: dict) -> None:
@@ -407,7 +473,19 @@ class AssistantService:
         etype = event.get("type")
         now = self._now()
 
-        if etype in RELAYED_TYPES:
+        if etype == events.SPEAKERS:
+            with self._lock:
+                self._speakers = {
+                    str(item.get("id")): dict(item)
+                    for item in (event.get("speakers") or [])
+                    if isinstance(item, dict) and item.get("id") is not None
+                }
+            self._relay(event)
+        elif etype == events.TRANSCRIPT:
+            resolved = dict(event)
+            resolved["speaker"] = self._speaker_display(event)
+            self._relay(resolved)
+        elif etype in RELAYED_TYPES:
             self._relay(event)
 
         if etype == events.STATUS:
@@ -503,8 +581,9 @@ class AssistantService:
                     self.command_text = f"{self.command_text} {text}".strip()
         elif self.modes.mode is Mode.CHAT:
             with self._lock:
-                self.chat_history.append(text)
-                self._pending_chat.append(text)
+                labeled = f"{self._speaker_display(event)}: {text}"
+                self.chat_history.append(labeled)
+                self._pending_chat.append(labeled)
                 self._last_chat_activity = now
             if self._is_exit_phrase(text):
                 self._exit_chat("exit_phrase")

+ 374 - 0
src/stt_daemon/cli.py

@@ -4,15 +4,27 @@ import argparse
 import logging
 import signal
 import sys
+from pathlib import Path
 
 import numpy as np
 
 from .audio import list_sources, open_capture
+from .bus import default_socket_path
 from .config import load_config
 from .daemon import Daemon
+from .embedder import SherpaEmbedder
+from .enroll import enroll_from_files
+from .enroll_assist import (
+    EnrollmentAborted,
+    QuestionaryReview,
+    play_wav,
+    seed_enroll,
+)
+from .evaluate import accuracy, evaluate, format_report
 from .picker import select_sources
 from .provisioner import Provisioner
 from .pulse import PulseAudio
+from .speakers import SpeakerStore
 from .state import StateStore
 from .transcriber import build_transcriber
 from .supervisor import Supervisor
@@ -152,6 +164,128 @@ def main() -> int:
         help="Also launch the TUI (quitting the TUI stops everything)",
     )
 
+    # speaker-enroll subcommand
+    enroll_parser = subparsers.add_parser(
+        "speaker-enroll",
+        parents=[common],
+        help="Pre-enroll a speaker from existing WAV files",
+    )
+    enroll_parser.add_argument(
+        "--name",
+        type=str,
+        required=True,
+        help="Speaker name to register",
+    )
+    enroll_parser.add_argument(
+        "--wav",
+        type=str,
+        action="append",
+        required=True,
+        help="WAV clip (16 kHz mono 16-bit); repeat for multiple clips",
+    )
+    enroll_parser.add_argument(
+        "--force",
+        action="store_true",
+        help="Enroll even when a daemon appears to be running",
+    )
+    enroll_parser.add_argument(
+        "--dedupe-threshold",
+        type=float,
+        default=0.98,
+        help="Cosine >= this removes a same-speaker prototype as duplicate "
+        "(default: 0.98)",
+    )
+
+    # speaker-enroll-seed subcommand
+    seed_parser = subparsers.add_parser(
+        "speaker-enroll-seed",
+        parents=[common],
+        help="Enroll a speaker from a seed clip plus reviewed similar clips",
+    )
+    seed_parser.add_argument(
+        "--name",
+        type=str,
+        required=True,
+        help="Speaker name to register",
+    )
+    seed_parser.add_argument(
+        "--seed",
+        type=str,
+        required=True,
+        help="Seed WAV clip (16 kHz mono 16-bit) known to be the speaker",
+    )
+    seed_parser.add_argument(
+        "--count",
+        type=int,
+        default=5,
+        help="How many reviewed clips to accept (default: 5)",
+    )
+    seed_parser.add_argument(
+        "--min-score",
+        type=float,
+        default=0.55,
+        help="Minimum cosine to the seed to rank a candidate (default: 0.55)",
+    )
+    seed_parser.add_argument(
+        "--audio-dir",
+        type=str,
+        default=None,
+        help="Directory to scan for WAV clips (default: [output] audio_dir)",
+    )
+    seed_parser.add_argument(
+        "--max-scan",
+        type=int,
+        default=0,
+        help="Cap the number of files embedded (default: 0 = all)",
+    )
+    seed_parser.add_argument(
+        "--yes",
+        action="store_true",
+        help="Non-interactive: auto-accept the top --count candidates",
+    )
+    seed_parser.add_argument(
+        "--no-play",
+        action="store_true",
+        help="Do not attempt audio playback during review",
+    )
+    seed_parser.add_argument(
+        "--force",
+        action="store_true",
+        help="Enroll even when a daemon appears to be running",
+    )
+    seed_parser.add_argument(
+        "--dedupe-threshold",
+        type=float,
+        default=0.98,
+        help="Cosine >= this flags/removes a same-speaker duplicate (default: 0.98)",
+    )
+    seed_parser.add_argument(
+        "--other-threshold",
+        type=float,
+        default=0.45,
+        help="Cosine >= this flags a candidate as another speaker's (default: 0.45)",
+    )
+
+    # speaker-eval subcommand
+    eval_parser = subparsers.add_parser(
+        "speaker-eval",
+        parents=[common],
+        help="Score the speaker store against a labeled WAV tree",
+    )
+    eval_parser.add_argument(
+        "--labeled",
+        type=str,
+        required=True,
+        help="Directory whose immediate subdirectories are speaker display names, "
+        "each containing *.wav clips",
+    )
+    eval_parser.add_argument(
+        "--min-accuracy",
+        type=float,
+        default=None,
+        help="Fail (exit 1) when accuracy is below this fraction",
+    )
+
     args = parser.parse_args()
 
     # Handle legacy compatibility: --list-sources at top level
@@ -173,6 +307,12 @@ def main() -> int:
         return _handle_clear(args)
     elif args.command == "up":
         return _handle_up(args)
+    elif args.command == "speaker-enroll":
+        return _handle_speaker_enroll(args)
+    elif args.command == "speaker-enroll-seed":
+        return _handle_speaker_enroll_seed(args)
+    elif args.command == "speaker-eval":
+        return _handle_speaker_eval(args)
     else:
         # No subcommand given: run daemon with optional check
         if hasattr(args, "check") and args.check:
@@ -421,6 +561,240 @@ def _handle_up(args) -> int:
         return 1
 
 
+def _handle_speaker_enroll(args) -> int:
+    """Pre-enroll a speaker from existing WAV files (offline, daemon stopped)."""
+    cfg = load_config(args.config if hasattr(args, "config") else None)
+    socket_path = cfg.events.socket or default_socket_path()
+    if not getattr(args, "force", False) and Path(socket_path).exists():
+        print(
+            "A daemon appears to be running "
+            f"(socket {socket_path} exists); stop it or pass --force",
+            file=sys.stderr,
+        )
+        return 1
+    model_path = cfg.speaker.model_path
+    if not model_path or not Path(model_path).exists():
+        print(
+            "Speaker model not found: set the [speaker] model_path key and run "
+            "`uv sync --extra speaker`",
+            file=sys.stderr,
+        )
+        return 1
+    try:
+        embedder = SherpaEmbedder(model_path, provider=cfg.speaker.provider)
+    except RuntimeError as e:
+        print(f"Error: {e}", file=sys.stderr)
+        return 1
+    speaker_cfg = cfg.speaker
+    store = SpeakerStore(
+        Path(speaker_cfg.store_dir),
+        threshold_id=speaker_cfg.threshold_id,
+        threshold_new=speaker_cfg.threshold_new,
+        min_enroll_s=speaker_cfg.min_enroll_s,
+        rms_floor=speaker_cfg.rms_floor,
+        margin=speaker_cfg.margin,
+        mint_cooldown_s=speaker_cfg.mint_cooldown_s,
+        confirm_s=speaker_cfg.confirm_s,
+        max_prototypes=speaker_cfg.max_prototypes,
+        provisional_ttl_h=speaker_cfg.provisional_ttl_h,
+    )
+    store.load()
+    try:
+        speaker_id = enroll_from_files(
+            store, embedder, args.name, args.wav, speaker_cfg.min_enroll_s
+        )
+    except ValueError as e:
+        print(f"Error: {e}", file=sys.stderr)
+        return 1
+    clips = len(args.wav)
+    total_s = store.speakers[speaker_id]["cumulative_s"]
+    success = (
+        f"enrolled '{args.name.strip()}' as {speaker_id} "
+        f"({clips} clip(s), {total_s:.1f}s)"
+    )
+    removed = store.dedupe(
+        args.name, threshold=getattr(args, "dedupe_threshold", 0.98)
+    )
+    if removed > 0:
+        store.save()
+        success += f", removed {removed} duplicate prototype(s)"
+    print(success)
+    return 0
+
+
+def _handle_speaker_enroll_seed(args) -> int:
+    """Enroll a speaker from a seed clip plus reviewed similar clips."""
+    from .enroll import read_wav
+
+    cfg = load_config(args.config if hasattr(args, "config") else None)
+    socket_path = cfg.events.socket or default_socket_path()
+    if not getattr(args, "force", False) and Path(socket_path).exists():
+        print(
+            "A daemon appears to be running "
+            f"(socket {socket_path} exists); stop it or pass --force",
+            file=sys.stderr,
+        )
+        return 1
+    model_path = cfg.speaker.model_path
+    if not model_path or not Path(model_path).exists():
+        print(
+            "Speaker model not found: set the [speaker] model_path key and run "
+            "`uv sync --extra speaker`",
+            file=sys.stderr,
+        )
+        return 1
+    try:
+        embedder = SherpaEmbedder(model_path, provider=cfg.speaker.provider)
+    except RuntimeError as e:
+        print(f"Error: {e}", file=sys.stderr)
+        return 1
+    speaker_cfg = cfg.speaker
+    store = SpeakerStore(
+        Path(speaker_cfg.store_dir),
+        threshold_id=speaker_cfg.threshold_id,
+        threshold_new=speaker_cfg.threshold_new,
+        min_enroll_s=speaker_cfg.min_enroll_s,
+        rms_floor=speaker_cfg.rms_floor,
+        margin=speaker_cfg.margin,
+        mint_cooldown_s=speaker_cfg.mint_cooldown_s,
+        confirm_s=speaker_cfg.confirm_s,
+        max_prototypes=speaker_cfg.max_prototypes,
+        provisional_ttl_h=speaker_cfg.provisional_ttl_h,
+    )
+    store.load()
+    auto = bool(getattr(args, "yes", False))
+    if not auto and not sys.stdin.isatty():
+        print(
+            "Not a TTY and --yes not given; pass --yes for non-interactive "
+            "auto-accept mode",
+            file=sys.stderr,
+        )
+        return 1
+    count = int(getattr(args, "count", 5) or 0)
+    min_score = float(getattr(args, "min_score", 0.55))
+    audio_dir = getattr(args, "audio_dir", None)
+    max_scan = int(getattr(args, "max_scan", 0) or 0)
+    no_play = bool(getattr(args, "no_play", False))
+    review = QuestionaryReview(allow_play=not no_play)
+    play = (lambda _path: None) if no_play else play_wav
+    try:
+        accepted = seed_enroll(
+            cfg, embedder, store, args.name, args.seed,
+            count=count, min_score=min_score, audio_dir=audio_dir,
+            review=review, play=play, max_scan=max_scan, auto=auto,
+            dup_threshold=getattr(args, "dedupe_threshold", 0.98),
+            other_threshold=getattr(args, "other_threshold", 0.45),
+        )
+    except EnrollmentAborted:
+        return 1
+    except KeyboardInterrupt:
+        print("Aborted.", file=sys.stderr)
+        return 1
+    except ValueError as e:
+        print(f"Error: {e}", file=sys.stderr)
+        return 1
+    if not accepted:
+        return 1
+    cleaned = args.name.strip()
+    speaker_id = next(
+        (sid for sid, meta in store.speakers.items() if meta["name"] == cleaned),
+        None,
+    )
+    try:
+        seed_samples, _ = read_wav(args.seed)
+        seed_ok = len(seed_samples) / 16000.0 >= speaker_cfg.min_enroll_s
+    except ValueError:
+        seed_ok = False
+    total_clips = len(accepted) + (1 if seed_ok else 0)
+    if speaker_id is not None:
+        meta = store.speakers[speaker_id]
+        reviewed = f"seed + {len(accepted)} reviewed" if seed_ok else (
+            f"{len(accepted)} reviewed, seed query-only")
+        print(f"enrolled '{cleaned}' as {speaker_id} from seed: "
+              f"{total_clips} clip(s) ({reviewed}, {meta['cumulative_s']:.1f}s, "
+              f"{meta['prototypes']} prototype(s))")
+    else:
+        print(f"enrolled '{cleaned}' from seed: {total_clips} clip(s)")
+    return 0
+
+
+def _handle_speaker_eval(args) -> int:
+    """Score the speaker store against a labeled WAV tree (read-only)."""
+    try:
+        cfg = load_config(args.config if hasattr(args, "config") else None)
+    except Exception as e:
+        print(f"Error: {e}", file=sys.stderr)
+        return 1
+    model_path = cfg.speaker.model_path
+    if not model_path or not Path(model_path).exists():
+        print(
+            "Speaker model not found: set the [speaker] model_path key and run "
+            "`uv sync --extra speaker`",
+            file=sys.stderr,
+        )
+        return 1
+    try:
+        embedder = SherpaEmbedder(model_path, provider=cfg.speaker.provider)
+    except RuntimeError as e:
+        print(f"Error: {e}", file=sys.stderr)
+        return 1
+    speaker_cfg = cfg.speaker
+    store = SpeakerStore(
+        Path(speaker_cfg.store_dir),
+        threshold_id=speaker_cfg.threshold_id,
+        threshold_new=speaker_cfg.threshold_new,
+        min_enroll_s=speaker_cfg.min_enroll_s,
+        rms_floor=speaker_cfg.rms_floor,
+        margin=speaker_cfg.margin,
+        mint_cooldown_s=speaker_cfg.mint_cooldown_s,
+        confirm_s=speaker_cfg.confirm_s,
+        max_prototypes=speaker_cfg.max_prototypes,
+        provisional_ttl_h=speaker_cfg.provisional_ttl_h,
+    )
+    store.load()
+    try:
+        probe = np.asarray(
+            embedder.embed(np.zeros(1600, dtype=np.float32)), dtype=np.float32
+        ).reshape(-1)
+        model_dim = int(probe.shape[0])
+    except Exception:
+        model_dim = None
+    if model_dim is not None:
+        try:
+            proto_ids = getattr(store, "_proto_ids", None) or []
+            vectors = getattr(store, "_vectors", None)
+            if proto_ids and vectors is not None and getattr(vectors, "size", 0):
+                stored_dim = int(vectors.shape[1])
+            else:
+                stored_dim = None
+        except Exception:
+            stored_dim = None
+        if stored_dim is not None and stored_dim != model_dim:
+            print(
+                f"Error: speaker store was built with a different model "
+                f"(stored dim {stored_dim} != model dim {model_dim}); "
+                f"wipe {store.root} and re-enroll from scratch",
+                file=sys.stderr,
+            )
+            return 1
+    try:
+        result = evaluate(store, embedder, Path(args.labeled))
+    except Exception as e:
+        print(f"Error: {e}", file=sys.stderr)
+        return 1
+    print(format_report(result))
+    min_accuracy = getattr(args, "min_accuracy", None)
+    if min_accuracy is not None:
+        acc = accuracy(result)
+        if acc < float(min_accuracy):
+            print(
+                f"accuracy {acc:.3f} below --min-accuracy {float(min_accuracy)}",
+                file=sys.stderr,
+            )
+            return 1
+    return 0
+
+
 def _check(args) -> int:
     """Test audio capture and transcriber (legacy --check or run --check)."""
     cfg = load_config(args.config)

+ 52 - 0
src/stt_daemon/config.py

@@ -19,6 +19,7 @@ KNOWN_SECTIONS = {
     "audit",
     "llm",
     "triage",
+    "speaker",
 }
 
 
@@ -175,6 +176,25 @@ class TriageConfig:
     default_checkpoint: str = "multilingual"
 
 
+@dataclass
+class SpeakerConfig:
+    """Speaker-identification configuration (sherpa-onnx embedding lane)."""
+    enabled: bool = False
+    model_path: str | None = None
+    provider: str = "cpu"
+    threshold_id: float = 0.60
+    threshold_new: float = 0.45
+    min_enroll_s: float = 2.0
+    rms_floor: float = 0.0
+    margin: float = 0.04
+    mint_cooldown_s: float = 60.0
+    confirm_s: float = 12.0
+    max_prototypes: int = 8
+    provisional_ttl_h: float = 24.0
+    store_dir: str = "speakers"
+    join_timeout_s: float = 2.0
+
+
 @dataclass
 class SinkConfig:
     """Virtual audio sink configuration."""
@@ -198,6 +218,7 @@ class Config:
     audit: AuditConfig = field(default_factory=AuditConfig)
     llm: LlmConfig = field(default_factory=LlmConfig)
     triage: TriageConfig = field(default_factory=TriageConfig)
+    speaker: SpeakerConfig = field(default_factory=SpeakerConfig)
 
 
 def load_config(path: str | None = None) -> Config:
@@ -482,4 +503,35 @@ def load_config(path: str | None = None) -> Config:
                 ),
             )
 
+        # Speaker section
+        if "speaker" in data:
+            speaker_data = data["speaker"]
+            provider = speaker_data.get("provider", cfg.speaker.provider)
+            provider = provider.strip().lower()
+            if provider not in ("cpu", "cuda"):
+                logger.warning(
+                    "Unknown speaker provider %r; falling back to 'cpu'", provider
+                )
+                provider = "cpu"
+            cfg.speaker = SpeakerConfig(
+                enabled=speaker_data.get("enabled", cfg.speaker.enabled),
+                model_path=speaker_data.get("model_path", cfg.speaker.model_path),
+                provider=provider,
+                threshold_id=speaker_data.get("threshold_id", cfg.speaker.threshold_id),
+                threshold_new=speaker_data.get("threshold_new", cfg.speaker.threshold_new),
+                min_enroll_s=speaker_data.get("min_enroll_s", cfg.speaker.min_enroll_s),
+                rms_floor=speaker_data.get("rms_floor", cfg.speaker.rms_floor),
+                margin=speaker_data.get("margin", cfg.speaker.margin),
+                mint_cooldown_s=speaker_data.get(
+                    "mint_cooldown_s", cfg.speaker.mint_cooldown_s
+                ),
+                confirm_s=speaker_data.get("confirm_s", cfg.speaker.confirm_s),
+                max_prototypes=speaker_data.get("max_prototypes", cfg.speaker.max_prototypes),
+                provisional_ttl_h=speaker_data.get(
+                    "provisional_ttl_h", cfg.speaker.provisional_ttl_h
+                ),
+                store_dir=speaker_data.get("store_dir", cfg.speaker.store_dir),
+                join_timeout_s=speaker_data.get("join_timeout_s", cfg.speaker.join_timeout_s),
+            )
+
     return cfg

+ 9 - 2
src/stt_daemon/ctl.py

@@ -20,19 +20,25 @@ COMMAND_MAP = {
     "chat-toggle": "chat_toggle",
     "clear": "clear",
     "status": "status",
+    "speaker-list": "speaker_list",
+    "speaker-name": "speaker_name",
 }
 
 READ_TIMEOUT_S = 1.0
 READ_CHUNK = 65536
 
 
-def build_message(command: str, text: str | None = None) -> dict:
+def build_message(command: str, text: str | None = None, rest: list[str] | None = None) -> dict:
     """Map a CLI verb to a control-socket message."""
     if command not in COMMAND_MAP:
         raise ValueError(f"unknown command: {command}")
     message = {"type": COMMAND_MAP[command]}
     if text is not None:
         message["text"] = text
+    if command == "speaker-name":
+        args = list(rest or [])
+        message["speaker_id"] = args[0] if len(args) > 0 else ""
+        message["name"] = args[1] if len(args) > 1 else ""
     return message
 
 
@@ -40,6 +46,7 @@ def main() -> int:
     """Entry point for `stt-ctl`."""
     parser = argparse.ArgumentParser(description="Send one command to the running assistant")
     parser.add_argument("command", choices=sorted(COMMAND_MAP))
+    parser.add_argument("rest", nargs="*", help="Extra args (speaker-name: <id> <name>)")
     parser.add_argument("--text", default=None, help="Text for command-edit")
     parser.add_argument("--config", default=None, help="Path to config.toml")
     parser.add_argument("--socket", default=None, help="Override the control socket path")
@@ -51,7 +58,7 @@ def main() -> int:
         path = cfg.assistant.control_socket or default_control_path()
 
     try:
-        message = build_message(args.command, args.text)
+        message = build_message(args.command, args.text, args.rest)
     except ValueError as e:
         print(f"error: {e}", file=sys.stderr)
         return 2

+ 316 - 1
src/stt_daemon/daemon.py

@@ -1,12 +1,18 @@
 """Main daemon that orchestrates capture, segmentation, and transcription."""
 
 import collections
+import concurrent.futures
 import itertools
+import json
 import logging
 import math
+import os
 import queue
+import shutil
+import stat
 import threading
 import time
+from pathlib import Path
 from typing import Any, Callable, Optional
 
 import numpy as np
@@ -17,9 +23,207 @@ from .bus import EventPublisher
 from .config import Config
 from .output import TranscriptSink
 from .segmenter import FrameSegmenter, Utterance, make_vad
+from .speakers import SpeakerStore
 from .transcriber import TranscriberLike, build_transcriber
 
 
+class _LaneJob:
+    """One embedding request: audio in, Outcome (or exception) out via future."""
+
+    __slots__ = ("seg_id", "audio", "dur_s", "rms", "future")
+
+    def __init__(
+        self,
+        seg_id: int,
+        audio: np.ndarray,
+        dur_s: float,
+        rms: float,
+        future: concurrent.futures.Future,
+    ) -> None:
+        self.seg_id = seg_id
+        self.audio = audio
+        self.dur_s = dur_s
+        self.rms = rms
+        self.future = future
+
+
+_REQUEST_MAX_BYTES = 65536
+
+
+class _EmbeddingLane:
+    """Single-worker speaker-embedding lane.
+
+    Confinement rule: ALL SpeakerStore access (match/process, inbox rename,
+    prune-persisted saves) happens on this lane's one worker thread. The
+    transcription worker only submits jobs and awaits the returned future, so
+    the lane never touches faster-whisper and the daemon socket stays
+    publish-only. Startup-time load()/prune() in Daemon._initialize runs
+    single-threaded before this lane starts, so it cannot race the worker.
+    """
+
+    def __init__(
+        self,
+        store: SpeakerStore,
+        embedder: Any,
+        publish: Callable[..., None],
+        inbox_dir: Path,
+        logger: logging.Logger,
+    ) -> None:
+        self._store = store
+        self._embedder = embedder
+        self._publish = publish
+        self._inbox_dir = Path(inbox_dir)
+        self._logger = logger
+        self._queue: queue.Queue = queue.Queue()
+        self._stop_event = threading.Event()
+        self._thread: threading.Thread | None = None
+
+    def start(self) -> None:
+        """Start the single worker thread (non-daemon, like the other lanes)."""
+        self._thread = threading.Thread(target=self._run, daemon=False)
+        self._thread.start()
+
+    def stop(self) -> None:
+        """Signal the worker and join it."""
+        self._stop_event.set()
+        if self._thread is not None:
+            self._thread.join(timeout=5)
+            self._thread = None
+
+    def submit(
+        self, seg_id: int, audio: np.ndarray, dur_s: float, rms: float
+    ) -> concurrent.futures.Future:
+        """Enqueue one embedding job; the transcription lane awaits the future."""
+        future: concurrent.futures.Future = concurrent.futures.Future()
+        self._queue.put(_LaneJob(seg_id, audio, dur_s, rms, future))
+        return future
+
+    def drain_inbox(self) -> None:
+        """Apply pending rename requests; quarantine bad ones. Never raises."""
+        try:
+            files = sorted(self._inbox_dir.glob("*.json"))
+        except OSError as e:
+            self._logger.warning("speaker inbox unreadable (%s)", e)
+            return
+        for path in files:
+            try:
+                self._apply_request(path)
+            except Exception as e:  # noqa: BLE001 -- boundary: drain must never raise
+                self._logger.warning("speaker inbox request %s failed: %s", path.name, e)
+                self._quarantine(path)
+
+    def _apply_request(self, path: Path) -> None:
+        try:
+            st = os.lstat(path)
+        except OSError as e:
+            self._logger.warning("ignoring unreadable speaker request %s (%s)", path.name, e)
+            self._quarantine(path)
+            return
+        if not stat.S_ISREG(st.st_mode):
+            self._logger.warning(
+                "ignoring non-regular speaker request %s (not a regular file)", path.name
+            )
+            self._quarantine(path)
+            return
+        if st.st_size > _REQUEST_MAX_BYTES:
+            self._logger.warning(
+                "ignoring oversized speaker request %s (%d bytes)", path.name, st.st_size
+            )
+            self._quarantine(path)
+            return
+        try:
+            fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
+        except OSError as e:
+            self._logger.warning("ignoring unreadable speaker request %s (%s)", path.name, e)
+            self._quarantine(path)
+            return
+        try:
+            chunks: list[bytes] = []
+            remaining = _REQUEST_MAX_BYTES + 1
+            while remaining > 0:
+                try:
+                    chunk = os.read(fd, remaining)
+                except OSError as e:
+                    self._logger.warning(
+                        "ignoring unreadable speaker request %s (%s)", path.name, e
+                    )
+                    self._quarantine(path)
+                    return
+                if not chunk:
+                    break
+                chunks.append(chunk)
+                remaining -= len(chunk)
+            raw = b"".join(chunks)
+        finally:
+            try:
+                os.close(fd)
+            except OSError:
+                pass
+        if len(raw) > _REQUEST_MAX_BYTES:
+            self._logger.warning(
+                "ignoring oversized speaker request %s (%d bytes)", path.name, len(raw)
+            )
+            self._quarantine(path)
+            return
+        try:
+            payload = json.loads(raw.decode("utf-8"))
+        except (OSError, ValueError) as e:
+            self._logger.warning("ignoring malformed speaker request %s (%s)", path.name, e)
+            self._quarantine(path)
+            return
+        if (
+            not isinstance(payload, dict)
+            or not isinstance(payload.get("speaker_id"), str)
+            or not isinstance(payload.get("name"), str)
+        ):
+            self._logger.warning("ignoring malformed speaker request %s (bad shape)", path.name)
+            self._quarantine(path)
+            return
+        if not self._store.rename(payload["speaker_id"], payload["name"]):
+            self._logger.warning(
+                "ignoring speaker request %s (unknown id %r)", path.name, payload["speaker_id"]
+            )
+            self._quarantine(path)
+            return
+        self._store.save()
+        self._publish(events.SPEAKERS, speakers=self._store.snapshot())
+        try:
+            path.unlink()
+        except OSError as e:
+            self._logger.warning("failed to delete applied speaker request %s (%s)", path.name, e)
+
+    def _quarantine(self, path: Path) -> None:
+        try:
+            rejected = self._inbox_dir / "rejected"
+            rejected.mkdir(parents=True, exist_ok=True)
+            shutil.move(str(path), str(rejected / path.name))
+        except OSError as e:
+            self._logger.warning("failed to quarantine speaker request %s (%s)", path.name, e)
+
+    def _run(self) -> None:
+        # A request file may have landed while the daemon was down; apply it
+        # on startup before the first job so renames never wait a full tick.
+        self.drain_inbox()
+        while not self._stop_event.is_set():
+            try:
+                job = self._queue.get(timeout=0.5)
+            except queue.Empty:
+                self.drain_inbox()
+                continue
+            try:
+                emb = self._embedder.embed(job.audio)
+                outcome = self._store.process(emb, job.dur_s, job.rms, job.audio)
+                if outcome.event in ("mint", "confirm"):
+                    self._store.save()
+                    self._publish(events.SPEAKERS, speakers=self._store.snapshot())
+                if not job.future.done():
+                    job.future.set_result(outcome)
+            except Exception as e:  # noqa: BLE001 -- boundary: lane must resolve every future
+                if not job.future.done():
+                    job.future.set_exception(e)
+            self.drain_inbox()
+
+
 class Daemon:
     """Always-on STT daemon with worker threads."""
 
@@ -32,6 +236,7 @@ class Daemon:
         vad_factory: Callable[[int], Any] | None = None,
         transcriber_factory: Callable[[], Any] | None = None,
         sink_factory: Callable[[], Any] | None = None,
+        embedder_factory: Callable[[], Any] | None = None,
     ):
         """Initialize the daemon.
 
@@ -42,12 +247,14 @@ class Daemon:
             vad_factory: Override VAD creation (used by tests).
             transcriber_factory: Override transcriber creation (used by tests).
             sink_factory: Override transcript sink creation (used by tests).
+            embedder_factory: Override speaker embedder creation (used by tests).
         """
         self.cfg = cfg
         self._capture_factory = capture_factory or (lambda: open_capture(self.cfg.audio))
         self._vad_factory = vad_factory or make_vad
         self._transcriber_factory = transcriber_factory or self._build_transcriber
         self._sink_factory = sink_factory or self._build_sink
+        self._embedder_factory = embedder_factory or self._build_embedder
 
         # Set up logging
         self.logger = logging.getLogger("stt_daemon")
@@ -63,6 +270,8 @@ class Daemon:
         self.transcriber: Optional[TranscriberLike] = None
         self.sink: Optional[TranscriptSink] = None
         self.publisher: Optional[EventPublisher] = None
+        self.speaker_store: Optional[SpeakerStore] = None
+        self._speaker_lane: Optional[_EmbeddingLane] = None
 
         # Threading
         self.segment_queue: queue.Queue = queue.Queue(maxsize=cfg.daemon.queue_size)
@@ -110,6 +319,11 @@ class Daemon:
             sample_rate=self.cfg.audio.sample_rate,
         )
 
+    def _build_embedder(self) -> Any:
+        from .embedder import build_embedder
+
+        return build_embedder(self.cfg)
+
     def _initialize(self) -> None:
         """Initialize all components and start publishing events."""
         self.logger.info("Initializing audio capture...")
@@ -142,9 +356,50 @@ class Daemon:
 
         self._started_at = time.time()
         self._start_publisher()
+        self._start_speaker_lane()
 
         self.logger.info("Daemon ready. Listening...")
 
+    def _start_speaker_lane(self) -> None:
+        """Build the speaker store + embedding lane (no-op when disabled).
+
+        Runs single-threaded before any lane thread starts; creates nothing
+        on disk when the embedder resolves to None.
+        """
+        try:
+            embedder = self._embedder_factory()
+        except Exception as e:  # noqa: BLE001 -- boundary: a bad factory must not crash
+            self.logger.warning("Speaker identification disabled (%s)", e)
+            return
+        if embedder is None:
+            return
+        speaker_cfg = self.cfg.speaker
+        store = SpeakerStore(
+            Path(speaker_cfg.store_dir),
+            threshold_id=speaker_cfg.threshold_id,
+            threshold_new=speaker_cfg.threshold_new,
+            min_enroll_s=speaker_cfg.min_enroll_s,
+            rms_floor=speaker_cfg.rms_floor,
+            margin=speaker_cfg.margin,
+            mint_cooldown_s=speaker_cfg.mint_cooldown_s,
+            confirm_s=speaker_cfg.confirm_s,
+            max_prototypes=speaker_cfg.max_prototypes,
+            provisional_ttl_h=speaker_cfg.provisional_ttl_h,
+        )
+        store.load()
+        removed = store.prune()
+        if removed:
+            store.save()
+            self._publish(events.SPEAKERS, speakers=store.snapshot())
+        self.speaker_store = store
+        self._speaker_lane = _EmbeddingLane(
+            store,
+            embedder,
+            self._publish,
+            Path(speaker_cfg.store_dir) / "inbox",
+            self.logger,
+        )
+
     def _start_publisher(self) -> None:
         """Start the publish-only event bus (best effort; never fatal)."""
         publisher = EventPublisher(
@@ -178,6 +433,8 @@ class Daemon:
         self._worker_thread = threading.Thread(target=self._worker_loop, daemon=False)
         self._reader_thread.start()
         self._worker_thread.start()
+        if self._speaker_lane is not None:
+            self._speaker_lane.start()
 
     def _reader_loop(self) -> None:
         """Reader thread: capture frames, segment them, and publish events."""
@@ -340,9 +597,23 @@ class Daemon:
         self._last_worker_activity_ts = time.time()
         try:
             if self.transcriber and self.sink:
+                speaker_future = None
+                if self._speaker_lane is not None:
+                    speaker_future = self._speaker_lane.submit(
+                        utt.seg_id, utt.audio, utt.duration_s, utt.rms
+                    )
                 started = time.perf_counter()
                 result = self.transcriber.transcribe(utt.audio)
                 transcribe_s = time.perf_counter() - started
+                seg_id = utt.seg_id
+                speaker_id: str | None = None
+                speaker_status = "none"
+                speaker: str | None = "?"
+                speaker_score: float | None = None
+                if speaker_future is not None:
+                    speaker_id, speaker_status, speaker, speaker_score = (
+                        self._resolve_speaker(speaker_future)
+                    )
                 try:
                     audio_path = self.sink.emit(
                         result,
@@ -351,6 +622,11 @@ class Daemon:
                         utt.reason,
                         utt.audio,
                         transcribe_s=transcribe_s,
+                        seg_id=seg_id,
+                        speaker_id=speaker_id,
+                        speaker_status=speaker_status,
+                        speaker=speaker,
+                        speaker_score=speaker_score,
                     )
                 except Exception as e:
                     # A sink failure must not drop the transcript from the bus.
@@ -361,7 +637,7 @@ class Daemon:
                     self._last_transcript_ts = time.time()
                     self._publish(
                         events.TRANSCRIPT,
-                        seg_id=utt.seg_id,
+                        seg_id=seg_id,
                         text=result.text,
                         lang=result.language,
                         lang_prob=result.language_probability,
@@ -369,6 +645,10 @@ class Daemon:
                         rms=utt.rms,
                         end_reason=utt.reason,
                         audio=audio_path,
+                        speaker_id=speaker_id,
+                        speaker_status=speaker_status,
+                        speaker=speaker,
+                        speaker_score=speaker_score,
                     )
         except Exception as e:
             self.logger.error(f"Transcription error: {e}")
@@ -383,6 +663,38 @@ class Daemon:
                 end_reason=utt.reason,
             )
 
+    def _resolve_speaker(
+        self, future: concurrent.futures.Future
+    ) -> tuple[str | None, str, str | None, float | None]:
+        """Await one embedding outcome; degrade to "none" on any failure."""
+        try:
+            outcome = future.result(timeout=self.cfg.speaker.join_timeout_s)
+        except Exception as e:  # noqa: BLE001 -- TimeoutError or lane failure -> "none"
+            self.logger.debug("Speaker lookup failed (%s)", e)
+            return None, "none", "?", None
+        try:
+            return self._speaker_fields(outcome)
+        except Exception as e:  # noqa: BLE001 -- mapping must never break emit
+            self.logger.warning("Speaker mapping failed (%s)", e)
+            return None, "none", "?", None
+
+    def _speaker_fields(self, outcome) -> tuple[str | None, str, str | None, float | None]:
+        """Map a lane outcome to (speaker_id, speaker_status, speaker, speaker_score)."""
+        match = outcome.match
+        status = match.status
+        speakers = self.speaker_store.speakers if self.speaker_store is not None else {}
+        if status == "matched":
+            name = speakers.get(match.speaker_id, {}).get("name") if match.speaker_id else None
+            return match.speaker_id, "matched", name or match.speaker_id, match.score
+        if status == "uncertain":
+            top = match.top_id
+            name = speakers.get(top, {}).get("name") if top else None
+            return None, "uncertain", f"{(name or top)}?", match.score
+        if status == "unknown":
+            minted = match.speaker_id if outcome.event == "mint" else None
+            return minted, "unknown", "?", None
+        return None, "none", "?", None
+
     def _drain_remaining(self) -> None:
         """Announce any queued-but-unprocessed segments on shutdown."""
         while True:
@@ -415,6 +727,9 @@ class Daemon:
             self._reader_thread.join(timeout=5)
         if self._worker_thread:
             self._worker_thread.join(timeout=5)
+        if self._speaker_lane is not None:
+            self._speaker_lane.stop()
+            self._speaker_lane = None
 
         if self.transcriber:
             self.transcriber.close()

+ 83 - 0
src/stt_daemon/embedder.py

@@ -0,0 +1,83 @@
+"""Speaker embedding extraction (sherpa-onnx, lazy import).
+
+The test suite must never need sherpa-onnx: ``sherpa_onnx`` is imported
+lazily inside :class:`SherpaEmbedder.__init__`, never at module level.
+"""
+
+import logging
+from pathlib import Path
+from typing import Protocol
+
+import numpy as np
+
+logger = logging.getLogger(__name__)
+
+
+class EmbedderLike(Protocol):
+    """Extract a speaker embedding from float32 mono 16 kHz audio."""
+
+    def embed(self, audio: np.ndarray) -> np.ndarray:
+        """Return a 1-D float32 embedding vector."""
+        ...
+
+
+class SherpaEmbedder:
+    """sherpa-onnx speaker-embedding extractor (single-threaded)."""
+
+    def __init__(self, model_path: str, provider: str = "cpu", num_threads: int = 1) -> None:
+        try:
+            import sherpa_onnx  # noqa: PLC0415 -- lazy: tests must not need sherpa-onnx
+        except ModuleNotFoundError as e:
+            raise RuntimeError(
+                "sherpa-onnx is not installed; run `uv sync --extra speaker`"
+            ) from e
+        except (ImportError, OSError) as e:
+            raise RuntimeError(
+                f"failed to import sherpa-onnx ({e}); run `uv sync --extra speaker`"
+                " to reinstall the native runtime"
+            ) from e
+        path = str(model_path)
+        try:
+            config = sherpa_onnx.SpeakerEmbeddingExtractorConfig(
+                model=path,
+                num_threads=int(num_threads),
+                debug=False,
+                provider=str(provider),
+            )
+            if not config.validate():
+                raise ValueError(f"invalid speaker embedding config ({config})")
+            self._extractor = sherpa_onnx.SpeakerEmbeddingExtractor(config)
+        except Exception as e:
+            raise RuntimeError(f"failed to load speaker embedding model {path!r}: {e}") from e
+
+    def embed(self, audio: np.ndarray) -> np.ndarray:
+        """Extract a 1-D float32 embedding for one utterance."""
+        samples = np.asarray(audio, dtype=np.float32).reshape(-1)
+        stream = self._extractor.create_stream()
+        stream.accept_waveform(sample_rate=16000, waveform=samples)
+        stream.input_finished()
+        if not self._extractor.is_ready(stream):
+            raise RuntimeError("speaker embedding extractor is not ready")
+        vector = np.asarray(self._extractor.compute(stream), dtype=np.float32)
+        return vector.reshape(-1)
+
+
+def build_embedder(cfg) -> EmbedderLike | None:
+    """Build the speaker embedder, or None (with ONE warning) when disabled.
+
+    Never raises: missing config, missing model file, and import/model
+    failures all degrade to ``None`` (speaker_status="none" downstream).
+    """
+    speaker = cfg.speaker
+    if not speaker.enabled:
+        logger.warning("speaker identification disabled ([speaker] enabled = false)")
+        return None
+    model_path = speaker.model_path
+    if not model_path or not Path(model_path).exists():
+        logger.warning("speaker identification disabled: model not found (%r)", model_path)
+        return None
+    try:
+        return SherpaEmbedder(model_path, provider=speaker.provider, num_threads=1)
+    except Exception as e:  # noqa: BLE001 -- boundary: model failure must not crash
+        logger.warning("speaker identification disabled: %s", e)
+        return None

+ 63 - 0
src/stt_daemon/enroll.py

@@ -0,0 +1,63 @@
+"""Offline speaker pre-enrollment from existing WAV files.
+
+Reads 16 kHz mono 16-bit PCM WAVs (the daemon's own saved format), extracts
+one embedding per clip, and registers a confirmed named profile.
+"""
+
+import wave
+from collections.abc import Sequence
+from pathlib import Path
+
+import numpy as np
+
+_SAMPLE_RATE = 16000
+
+
+def read_wav(path: str | Path) -> tuple[np.ndarray, int]:
+    """Read a 16 kHz mono 16-bit PCM WAV as float32 in [-1, 1].
+
+    Raises:
+        ValueError: When the file is not a WAV, is stereo/multichannel,
+            is not 16 kHz, or has an unsupported sample width.
+    """
+    try:
+        with wave.open(str(path), "rb") as wav:
+            nchannels = wav.getnchannels()
+            sampwidth = wav.getsampwidth()
+            framerate = wav.getframerate()
+            frames = wav.readframes(wav.getnframes())
+    except wave.Error as e:
+        raise ValueError(f"{path}: not a WAV file ({e})") from e
+    except (OSError, EOFError) as e:
+        raise ValueError(f"{path}: not a WAV file ({e})") from e
+    if nchannels != 1:
+        raise ValueError(f"{path}: expected mono WAV, got {nchannels} channels")
+    if framerate != _SAMPLE_RATE:
+        raise ValueError(f"{path}: expected 16 kHz WAV, got {framerate} Hz")
+    if sampwidth != 2:
+        raise ValueError(f"{path}: unsupported sample width ({sampwidth * 8}-bit)")
+    samples = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
+    return samples, _SAMPLE_RATE
+
+
+def enroll_from_files(store, embedder, name: str, wav_paths: Sequence[str | Path],
+                       min_enroll_s: float) -> str:
+    """Enroll ``name`` from WAV clips; persists the store and returns the id.
+
+    Each clip must be at least ``min_enroll_s`` long or a ``ValueError`` is
+    raised. The store is saved before the id is returned.
+    """
+    embeddings: list[np.ndarray] = []
+    total_s = 0.0
+    for wav_path in wav_paths:
+        samples, _ = read_wav(wav_path)
+        duration_s = len(samples) / _SAMPLE_RATE
+        if duration_s < min_enroll_s:
+            raise ValueError(
+                f"{wav_path}: too short ({duration_s:.2f}s < {min_enroll_s}s)"
+            )
+        embeddings.append(embedder.embed(samples))
+        total_s += duration_s
+    speaker_id = store.enroll(name, embeddings, dur_s=total_s)
+    store.save()
+    return speaker_id

+ 468 - 0
src/stt_daemon/enroll_assist.py

@@ -0,0 +1,468 @@
+"""Seed-based assisted speaker enrollment.
+
+The user supplies ONE clip they are certain is the speaker (the seed). This
+module finds the most similar clips in the audio directory and lets the user
+review each one (listen + accept/skip) before enrolling.
+
+Hardware- and network-free: only ``numpy``, stdlib, and ``questionary``.
+"""
+
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Protocol
+
+import numpy as np
+
+from .enroll import enroll_from_files, read_wav
+
+_SAMPLE_RATE = 16000
+
+
+@dataclass(frozen=True)
+class Candidate:
+    """A ranked enrollment candidate: path, cosine to the seed, duration."""
+
+    path: Path
+    score: float
+    duration_s: float
+    embedding: np.ndarray | None = field(default=None, compare=False, repr=False)
+    flags: tuple[str, ...] = field(default=(), compare=False)
+
+
+class EnrollmentAborted(Exception):
+    """Review ended via 'quit' or Ctrl+C; nothing was written.
+
+    Carries the candidates accepted so far so the caller can print them and
+    suggest an equivalent manual ``speaker-enroll`` command.
+    """
+
+    def __init__(self, accepted, message="enrollment aborted") -> None:
+        super().__init__(message)
+        self.accepted = list(accepted)
+
+
+class ClipReview(Protocol):
+    """Interactive per-candidate review decision."""
+
+    def choose(self, candidate: Candidate, accepted: int, target: int) -> str:
+        """Return one of: 'accept' | 'skip' | 'play' | 'quit'."""
+        ...
+
+
+class QuestionaryReview:
+    """Default interactive review; uses ``questionary`` (already a dependency)."""
+
+    def __init__(self, allow_play: bool = True) -> None:
+        self.allow_play = allow_play
+
+    def choose(self, candidate: Candidate, accepted: int, target: int) -> str:
+        """Prompt for one candidate; Ctrl+C / dismiss counts as 'quit'."""
+        import questionary  # noqa: PLC0415 -- lazy: only needed interactively
+
+        choices = [
+            questionary.Choice("Accept (enroll this clip)", value="accept"),
+            questionary.Choice("Skip", value="skip"),
+        ]
+        if self.allow_play:
+            choices.append(questionary.Choice("Play (listen, then decide)", value="play"))
+        choices.append(questionary.Choice("Quit (stop, enroll nothing)", value="quit"))
+        answer = questionary.select(
+            f"[{accepted + 1}/{target}] {candidate.path.name} "
+            f"(score {candidate.score:.2f}, {candidate.duration_s:.1f}s)",
+            choices=choices,
+        ).ask()
+        return answer if answer in ("accept", "skip", "play", "quit") else "quit"
+
+
+def play_wav(path: Path) -> str | None:
+    """Best-effort playback via paplay, then aplay, then ffplay.
+
+    Returns the command used, or None when none is available / playback fails.
+    """
+    import shutil  # noqa: PLC0415 -- stdlib, lazy to keep import time down
+    import subprocess  # noqa: PLC0415 -- stdlib, lazy to keep import time down
+
+    commands = (
+        (["paplay", str(path)], "paplay"),
+        (["aplay", "-q", str(path)], "aplay"),
+        (["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", str(path)], "ffplay"),
+    )
+    for cmd, name in commands:
+        if shutil.which(cmd[0]) is None:
+            continue
+        try:
+            subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL,
+                           stderr=subprocess.DEVNULL)
+        except (subprocess.SubprocessError, OSError):
+            continue
+        return name
+    return None
+
+
+def _cosine(a: np.ndarray, b: np.ndarray) -> float:
+    """Cosine similarity, or -1.0 when either side is unusable/mismatched."""
+    first = np.asarray(a, dtype=np.float32).reshape(-1)
+    second = np.asarray(b, dtype=np.float32).reshape(-1)
+    if first.shape != second.shape:
+        return -1.0
+    first_norm = float(np.linalg.norm(first))
+    second_norm = float(np.linalg.norm(second))
+    if first_norm <= 0.0 or second_norm <= 0.0:
+        return -1.0
+    return float(first @ second / (first_norm * second_norm))
+
+
+def _resolve(path: Path) -> Path:
+    try:
+        return Path(path).resolve()
+    except OSError:
+        return Path(path)
+
+
+def _unit(vector) -> np.ndarray | None:
+    """L2-normalized float32 copy, or None when the vector is unusable."""
+    try:
+        flat = np.asarray(vector, dtype=np.float32).reshape(-1)
+    except (TypeError, ValueError):
+        return None
+    norm = float(np.linalg.norm(flat))
+    if norm <= 0.0 or not np.isfinite(norm):
+        return None
+    return (flat / norm).astype(np.float32, copy=False)
+
+
+def _store_view(store):
+    """Snapshot (ids, unit vectors, name lookup) of the prototype store.
+
+    Returns ([ids], matrix-or-None, {id: display}). Never raises.
+    """
+    try:
+        ids = list(getattr(store, "_proto_ids", None) or [])
+        vectors = getattr(store, "_vectors", None)
+        profiles = getattr(store, "_speakers", None) or {}
+        names: dict[str, str] = {}
+        for sid in ids:
+            profile = profiles.get(sid) if hasattr(profiles, "get") else None
+            display = getattr(profile, "name", None)
+            if isinstance(profile, dict):
+                display = profile.get("name")
+            names[sid] = display or sid
+        if not ids or vectors is None or getattr(vectors, "size", 0) == 0:
+            return ids, None, names
+        matrix = np.asarray(vectors, dtype=np.float32)
+        if matrix.shape[0] != len(ids):
+            return ids, None, names
+        return ids, matrix, names
+    except Exception:  # noqa: BLE001 -- flags are advisory; ranking must survive
+        return [], None, {}
+
+
+def _candidate_flags(embedding: np.ndarray | None, *, store, speaker_name: str,
+                     dup_threshold: float, other_threshold: float) -> tuple[str, ...]:
+    """Flag a candidate against existing store prototypes.
+
+    ``"duplicate"`` when the max cosine to the target speaker's prototypes is
+    ``>= dup_threshold``; ``"other-speaker:<name>"`` for the highest-scoring
+    different speaker at ``>= other_threshold``. Never raises.
+    """
+    try:
+        if embedding is None:
+            return ()
+        ids, matrix, names = _store_view(store)
+        if matrix is None or not ids:
+            return ()
+        if matrix.shape[1] != embedding.shape[0]:
+            return ()
+        cleaned = str(speaker_name).strip()
+        profiles = getattr(store, "_speakers", None) or {}
+        target_ids = set()
+        for sid in ids:
+            profile = profiles.get(sid) if hasattr(profiles, "get") else None
+            pname = getattr(profile, "name", None)
+            if isinstance(profile, dict):
+                pname = profile.get("name")
+            if pname is not None and str(pname).strip() == cleaned:
+                target_ids.add(sid)
+        sims = matrix @ embedding
+        flags: list[str] = []
+        if target_ids:
+            best_same = max(float(s) for s, pid in zip(sims, ids) if pid in target_ids)
+            if best_same >= dup_threshold:
+                flags.append("duplicate")
+        best_other: tuple[float, str] | None = None
+        for score, pid in zip(sims, ids):
+            if pid in target_ids:
+                continue
+            cosine = float(score)
+            display = names.get(pid) or pid
+            if best_other is None or cosine > best_other[0]:
+                best_other = (cosine, display)
+        if best_other is not None and best_other[0] >= other_threshold:
+            flags.append(f"other-speaker:{best_other[1]}")
+        return tuple(flags)
+    except Exception:  # noqa: BLE001 -- flags are advisory; ranking must survive
+        return ()
+
+
+def rank_candidates(embedder, seed_audio, paths, *, min_enroll_s,
+                    min_score, exclude=(), store=None, speaker_name=None,
+                    dup_threshold=0.98,
+                    other_threshold=0.45) -> tuple[list[Candidate], int]:
+    """Embed each path, cosine to seed_audio; keep duration/score gates.
+
+    Drops ``exclude`` (resolved paths) and unreadable files. Every returned
+    candidate carries its unit-normed ``embedding``; when ``store`` and
+    ``speaker_name`` are given each candidate is also flagged against the
+    target speaker's existing prototypes (``"duplicate"``) and the best
+    different speaker (``"other-speaker:<name>"``). Returns
+    (ranked_candidates sorted by score desc, skipped_count).
+    """
+    seed_vector = np.asarray(embedder.embed(
+        np.asarray(seed_audio, dtype=np.float32)), dtype=np.float32)
+    excluded = {_resolve(p) for p in exclude}
+    ranked: list[Candidate] = []
+    skipped = 0
+    for raw in paths:
+        path = Path(raw)
+        if _resolve(path) in excluded:
+            continue
+        try:
+            audio, _ = read_wav(path)
+        except (ValueError, OSError):
+            skipped += 1
+            continue
+        duration_s = len(audio) / _SAMPLE_RATE
+        if duration_s < min_enroll_s:
+            continue
+        try:
+            vector = embedder.embed(audio)
+        except Exception:  # noqa: BLE001 -- one bad clip must not kill ranking
+            skipped += 1
+            continue
+        score = _cosine(seed_vector, vector)
+        if score < min_score:
+            continue
+        embedding = _unit(vector)
+        if embedding is None:
+            embedding = np.asarray(vector, dtype=np.float32).reshape(-1)
+        flags: tuple[str, ...] = ()
+        if store is not None and speaker_name is not None:
+            flags = _candidate_flags(
+                _unit(vector), store=store, speaker_name=speaker_name,
+                dup_threshold=dup_threshold, other_threshold=other_threshold)
+        ranked.append(Candidate(path=path, score=score, duration_s=duration_s,
+                                embedding=embedding, flags=flags))
+    ranked.sort(key=lambda c: c.score, reverse=True)
+    return ranked, skipped
+
+
+def review_candidates(candidates, review, target, play=None) -> list[Candidate]:
+    """Walk candidates in order, calling review.choose() per candidate.
+
+    'accept' collects (stops at target), 'skip' moves on, 'play' plays and
+    re-asks the SAME candidate, 'quit' stops early. A candidate flagged
+    ``"duplicate"`` is never accepted: choosing 'accept' prints a note and
+    moves on without consuming the target budget. Returns accepted.
+    """
+    play_fn = play if play is not None else play_wav
+    accepted: list[Candidate] = []
+    index = 0
+    if target <= 0:
+        return accepted
+    while index < len(candidates):
+        if len(accepted) >= target:
+            break
+        candidate = candidates[index]
+        action = review.choose(candidate, len(accepted), target)
+        if action == "accept":
+            if "duplicate" in getattr(candidate, "flags", ()):
+                print(f"skipping {candidate.path.name}: "
+                      f"already covered by this profile")
+                index += 1
+                continue
+            accepted.append(candidate)
+            index += 1
+        elif action == "skip":
+            index += 1
+        elif action == "play":
+            play_fn(candidate.path)
+        elif action == "quit":
+            break
+        else:
+            raise ValueError(f"unknown review action {action!r}")
+    return accepted
+
+
+def _incoming_cosine(a: np.ndarray, b: np.ndarray) -> float:
+    """Cosine between two embeddings, or -1.0 when unusable/mismatched."""
+    first = np.asarray(a, dtype=np.float32).reshape(-1)
+    second = np.asarray(b, dtype=np.float32).reshape(-1)
+    if first.shape != second.shape:
+        return -1.0
+    first_norm = float(np.linalg.norm(first))
+    second_norm = float(np.linalg.norm(second))
+    if first_norm <= 0.0 or second_norm <= 0.0:
+        return -1.0
+    return float(first @ second / (first_norm * second_norm))
+
+
+def _greedy_dedupe(items: list, dup_threshold: float) -> tuple[list, int]:
+    """Keep items greedily by embedding: drop an item whose cosine to any
+    already-kept item is ``>= dup_threshold``.
+
+    ``items`` are ``(path, embedding-or-None, candidate-or-None)`` triples in
+    priority order (the seed first). Items without an embedding are always
+    kept but never disqualify others. Returns (kept_items, dropped_count).
+    """
+    kept: list = []
+    kept_embs: list[np.ndarray] = []
+    dropped = 0
+    for item in items:
+        _path, embedding, _candidate = item
+        duplicate = False
+        if embedding is not None:
+            duplicate = any(
+                _incoming_cosine(embedding, other) >= dup_threshold
+                for other in kept_embs
+            )
+        if duplicate:
+            dropped += 1
+        else:
+            kept.append(item)
+            if embedding is not None:
+                kept_embs.append(embedding)
+    return kept, dropped
+
+
+class _ReviewTracker:
+    """Wraps a ClipReview to observe accepts and 'quit' for seed_enroll."""
+
+    def __init__(self, inner) -> None:
+        self._inner = inner
+        self.accepted: list[Candidate] = []
+        self.quit = False
+
+    def choose(self, candidate: Candidate, accepted: int, target: int) -> str:
+        action = self._inner.choose(candidate, accepted, target)
+        if action == "accept":
+            self.accepted.append(candidate)
+        elif action == "quit":
+            self.quit = True
+        return action
+
+
+def _stored_dim(store) -> int | None:
+    vectors = getattr(store, "_vectors", None)
+    ids = getattr(store, "_proto_ids", None)
+    if ids and vectors is not None and getattr(vectors, "size", 0):
+        return int(vectors.shape[1])
+    return None
+
+
+def _print_abort(name: str, seed_path: Path, seed_ok: bool,
+                 accepted: list[Candidate]) -> None:
+    print(f"aborted; {len(accepted)} clip(s) accepted, nothing enrolled")
+    for candidate in accepted:
+        print(f"  accepted: {candidate.path} (score {candidate.score:.2f})")
+    wavs = ([seed_path] if seed_ok else []) + [c.path for c in accepted]
+    if wavs:
+        parts = " ".join(f"--wav {p}" for p in wavs)
+        print(f"to enroll manually, run: stt-daemon speaker-enroll "
+              f"--name {name} {parts}")
+
+
+def seed_enroll(cfg, embedder, store, name, seed_path, *, count=5,
+                min_score=0.55, audio_dir=None, review=None, play=play_wav,
+                max_scan=0, auto=False, dup_threshold=0.98,
+                other_threshold=0.45) -> list[Candidate]:
+    """Orchestrates scan -> rank -> review -> enroll. Returns accepted candidates.
+
+    Raises EnrollmentAborted (no write) on 'quit'/Ctrl+C during review, and
+    ValueError on an unreadable seed or a store/model dimension mismatch.
+    Returns [] without writing when nothing is accepted.
+
+    Duplicate protection: candidates are flagged against the store at rank
+    time (``review`` refuses ``"duplicate"`` accepts); the incoming set (seed
+    + accepted, including the ``auto`` path) is greedily deduped by embedding
+    before enrolling; and pre-existing duplicate prototypes are removed from
+    the store afterwards (persisted only when something was removed).
+    """
+    seed_path = Path(seed_path)
+    seed_audio, _ = read_wav(seed_path)
+    min_enroll_s = float(cfg.speaker.min_enroll_s)
+    seed_vector = np.asarray(embedder.embed(seed_audio), dtype=np.float32).reshape(-1)
+    stored_dim = _stored_dim(store)
+    if stored_dim is not None and stored_dim != int(seed_vector.shape[0]):
+        raise ValueError(
+            f"speaker store was built with a different model "
+            f"(stored dim {stored_dim} != model dim {seed_vector.shape[0]}); "
+            f"wipe {store.root} and re-enroll from scratch"
+        )
+    seed_duration_s = len(seed_audio) / _SAMPLE_RATE
+    seed_ok = seed_duration_s >= min_enroll_s
+    if not seed_ok:
+        print(f"warning: seed {seed_path.name} is {seed_duration_s:.2f}s "
+              f"< {min_enroll_s:.2f}s; used as query only, not enrolled")
+    base = Path(audio_dir) if audio_dir is not None else Path(cfg.output.audio_dir)
+    scanned = sorted(p for p in base.rglob("*.wav") if p.is_file()) if base.exists() else []
+    seed_resolved = _resolve(seed_path)
+    files = [p for p in scanned if _resolve(p) != seed_resolved]
+    total = len(files)
+    if max_scan and max_scan > 0 and len(files) > max_scan:
+        files = files[:max_scan]
+        print(f"scanning {len(files)} of {total} file(s) (max-scan {max_scan})")
+    elif total:
+        print(f"scanning {total} file(s) in {base}")
+    ranked, skipped = rank_candidates(
+        embedder, seed_audio, files, min_enroll_s=min_enroll_s,
+        min_score=min_score, exclude=(seed_resolved,), store=store,
+        speaker_name=name, dup_threshold=dup_threshold,
+        other_threshold=other_threshold)
+    if skipped:
+        print(f"skipped {skipped} unreadable file(s)")
+    print(f"found {len(ranked)} candidate(s) with score >= {min_score:.2f}")
+    if auto:
+        accepted = list(ranked[:max(count, 0)])
+    else:
+        tracker = _ReviewTracker(review if review is not None else QuestionaryReview())
+        try:
+            reviewed = review_candidates(ranked, tracker, count, play=play)
+        except KeyboardInterrupt:
+            so_far = [c for c in tracker.accepted
+                      if "duplicate" not in getattr(c, "flags", ())]
+            _print_abort(name, seed_path, seed_ok, so_far)
+            raise EnrollmentAborted(so_far, "interrupted") from None
+        if tracker.quit:
+            _print_abort(name, seed_path, seed_ok, list(reviewed))
+            raise EnrollmentAborted(list(reviewed), "quit")
+        accepted = list(reviewed)
+    if not accepted:
+        if not ranked:
+            print("no similar clips found; nothing enrolled")
+        else:
+            print("no clips accepted; nothing enrolled")
+        return []
+    seed_unit = _unit(seed_vector)
+    incoming = ([(seed_path, seed_unit, None)] if seed_ok else []) + [
+        (c.path, c.embedding, c) for c in accepted
+    ]
+    kept, dropped = _greedy_dedupe(incoming, dup_threshold)
+    if dropped:
+        print(f"skipped {dropped} duplicate clip(s) already covered "
+              f"by the seed/selection")
+    kept_paths = [path for path, _emb, _cand in kept]
+    accepted = [cand for _path, _emb, cand in kept if cand is not None]
+    cleaned = name.strip()
+    existing = sum(m.get("prototypes", 0) for m in store.speakers.values()
+                   if m.get("name") == cleaned)
+    cap = int(cfg.speaker.max_prototypes)
+    if existing + len(kept_paths) > cap:
+        print(f"warning: {existing} existing + {len(kept_paths)} new prototype(s) "
+              f"exceeds max_prototypes={cap}; oldest will be evicted")
+    enroll_from_files(store, embedder, name, kept_paths, min_enroll_s)
+    removed = store.dedupe(name, threshold=dup_threshold)
+    if removed > 0:
+        store.save()
+        print(f"removed {removed} duplicate prototype(s) from the "
+              f"'{cleaned}' profile")
+    return accepted

+ 277 - 0
src/stt_daemon/evaluate.py

@@ -0,0 +1,277 @@
+"""Accuracy evaluation core for speaker identification.
+
+Scores a labeled tree (``labeled_root/<speaker name>/*.wav``) against a
+:class:`SpeakerStore` without mutating it: each clip is read with
+:func:`enroll.read_wav`, embedded, classified with :meth:`store.match`, and
+bucketed into correct / wrong / uncertain / unknown.
+
+Margins use the best two **prototype** scores (max cosine over every
+prototype, matching :meth:`SpeakerStore._search` semantics): ``top1 - top2``
+over all prototypes in ``store._vectors``, not per-speaker centroids.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+
+import numpy as np
+
+from .enroll import read_wav
+
+
+@dataclass(frozen=True)
+class EvalResult:
+    total: int
+    correct: int  # status == "matched" and predicted name == true name
+    wrong: int  # status == "matched" and predicted name != true name
+    uncertain_correct: int  # status == "uncertain" but the top-1 name is correct
+    uncertain_wrong: int  # status == "uncertain" and the top-1 name is wrong
+    unknown: int  # status == "unknown" or "none"
+    per_speaker: dict[str, tuple[int, int]]  # true name -> (total, correct)
+    confusions: dict[tuple[str, str], int]  # (true, predicted) -> count, only for `wrong`
+    margins: tuple[float, ...]  # (top1 - top2) for every clip with a scored match
+    labeled: tuple[tuple[str, str], ...]  # (predicted_status, relative path) per clip
+
+
+def _zeroed() -> EvalResult:
+    return EvalResult(
+        total=0,
+        correct=0,
+        wrong=0,
+        uncertain_correct=0,
+        uncertain_wrong=0,
+        unknown=0,
+        per_speaker={},
+        confusions={},
+        margins=(),
+        labeled=(),
+    )
+
+
+def _normalize(emb: np.ndarray | None) -> np.ndarray | None:
+    """L2-normalized float32 copy, or None when the embedding is unusable."""
+    if emb is None:
+        return None
+    try:
+        vector = np.asarray(emb, dtype=np.float32)
+    except (TypeError, ValueError):
+        return None
+    if vector.ndim != 1 or vector.size == 0 or not np.isfinite(vector).all():
+        return None
+    norm = float(np.linalg.norm(vector))
+    if norm <= 0.0:
+        return None
+    return (vector / norm).astype(np.float32, copy=False)
+
+
+def _top_scores(emb: np.ndarray, store) -> tuple[np.ndarray, list] | None:
+    """Normalized scores over all prototypes, or None when unscorable.
+
+    Returns ``(scores, proto_ids)`` with scores as float64 cosine values.
+    Empty stores and dim mismatches yield None.
+    """
+    vectors = store._vectors
+    proto_ids = store._proto_ids
+    if len(proto_ids) == 0:
+        return None
+    try:
+        vectors = np.asarray(vectors, dtype=np.float64)
+    except (TypeError, ValueError):
+        return None
+    if vectors.ndim != 2 or vectors.shape[0] != len(proto_ids):
+        return None
+    normalized = _normalize(emb)
+    if normalized is None:
+        return None
+    if vectors.shape[1] != normalized.shape[0]:
+        return None
+    scores = vectors @ normalized.astype(np.float64)
+    return scores.reshape(-1), list(proto_ids)
+
+
+def _top1_name(emb: np.ndarray, store) -> str | None:
+    """Display name of the best-scoring prototype's speaker, or None."""
+    scored = _top_scores(emb, store)
+    if scored is None:
+        return None
+    scores, proto_ids = scored
+    if scores.size == 0:
+        return None
+    best_id = proto_ids[int(np.argmax(scores))]
+    try:
+        meta = store.speakers.get(best_id)
+    except Exception:
+        return None
+    if not meta:
+        return None
+    return meta.get("name") or None
+
+
+def _margin(emb: np.ndarray, store) -> float | None:
+    """Top1-minus-top2 over the best two prototype scores, or None."""
+    scored = _top_scores(emb, store)
+    if scored is None:
+        return None
+    scores, _ = scored
+    if scores.size < 2:
+        return None
+    order = np.argsort(scores)[::-1]
+    return float(scores[int(order[0])] - scores[int(order[1])])
+
+
+def accuracy(result: EvalResult) -> float:
+    """Fraction of clips correctly matched, or 0.0 when there are none."""
+    if result.total == 0:
+        return 0.0
+    return result.correct / result.total
+
+
+def evaluate(store, embedder, labeled_root: Path) -> EvalResult:
+    """Score ``labeled_root/<speaker>/*.wav`` against the store (read-only).
+
+    Never writes to the store and never raises on an empty/missing
+    directory: those yield a zeroed result with ``total=0``.
+    """
+    root = Path(labeled_root)
+    try:
+        is_dir = root.is_dir()
+    except OSError:
+        return _zeroed()
+    if not is_dir:
+        return _zeroed()
+
+    try:
+        subdirs = sorted(
+            (p for p in root.iterdir() if p.is_dir()),
+            key=lambda p: p.name,
+        )
+    except OSError:
+        return _zeroed()
+
+    total = 0
+    correct = 0
+    wrong = 0
+    uncertain_correct = 0
+    uncertain_wrong = 0
+    unknown = 0
+    per_speaker: dict[str, list[int]] = {}
+    confusions: dict[tuple[str, str], int] = {}
+    margins: list[float] = []
+    labeled: list[tuple[str, str]] = []
+
+    for sub in subdirs:
+        true_name = sub.name
+        try:
+            clips = sorted(
+                (p for p in sub.iterdir() if p.is_file() and p.suffix.lower() == ".wav"),
+                key=lambda p: p.name,
+            )
+        except OSError:
+            continue
+        for clip in clips:
+            try:
+                samples, _ = read_wav(clip)
+            except Exception:
+                continue
+            try:
+                emb = embedder.embed(samples)
+            except Exception:
+                continue
+            try:
+                match = store.match(emb)
+            except Exception:
+                continue
+            status = match.status
+            rel = clip.relative_to(root).as_posix()
+            labeled.append((status, rel))
+            total += 1
+            entry = per_speaker.setdefault(true_name, [0, 0])
+            entry[0] += 1
+
+            margin = _margin(emb, store)
+            if margin is not None:
+                margins.append(margin)
+
+            if status == "matched":
+                predicted: str | None = None
+                if match.speaker_id is not None:
+                    try:
+                        meta = store.speakers.get(match.speaker_id)
+                    except Exception:
+                        meta = None
+                    if meta:
+                        predicted = meta.get("name") or None
+                    if not predicted:
+                        predicted = match.speaker_id
+                if predicted is not None and predicted == true_name:
+                    correct += 1
+                    entry[1] += 1
+                else:
+                    wrong += 1
+                    confusions[(true_name, str(predicted))] = (
+                        confusions.get((true_name, str(predicted)), 0) + 1
+                    )
+            elif status == "uncertain":
+                top1 = _top1_name(emb, store)
+                if top1 is not None and top1 == true_name:
+                    uncertain_correct += 1
+                else:
+                    uncertain_wrong += 1
+            else:  # "unknown" or "none" (or any future unscored status)
+                unknown += 1
+
+    return EvalResult(
+        total=total,
+        correct=correct,
+        wrong=wrong,
+        uncertain_correct=uncertain_correct,
+        uncertain_wrong=uncertain_wrong,
+        unknown=unknown,
+        per_speaker={name: (v[0], v[1]) for name, v in per_speaker.items()},
+        confusions=dict(confusions),
+        margins=tuple(margins),
+        labeled=tuple(labeled),
+    )
+
+
+def format_report(result: EvalResult) -> str:
+    """Render a deterministic plain-text report for an evaluation result."""
+    acc = accuracy(result)
+    uncertain_total = result.uncertain_correct + result.uncertain_wrong
+    lines = [
+        f"total: {result.total}",
+        f"accuracy: {acc:.3f}",
+        f"correct: {result.correct}",
+        f"wrong: {result.wrong}",
+        f"uncertain (top-1 correct/total): "
+        f"{result.uncertain_correct}/{uncertain_total}",
+        f"unknown: {result.unknown}",
+        "",
+        "per-speaker:",
+        "name | n | correct | acc",
+    ]
+    for name in sorted(result.per_speaker):
+        n, n_correct = result.per_speaker[name]
+        speaker_acc = (n_correct / n) if n else 0.0
+        lines.append(f"{name} | {n} | {n_correct} | {speaker_acc:.3f}")
+    lines += ["", "confusions:"]
+    ordered = sorted(
+        result.confusions.items(), key=lambda kv: (-kv[1], kv[0][0], kv[0][1])
+    )[:10]
+    if not ordered:
+        lines.append("(none)")
+    for (true_name, predicted), count in ordered:
+        lines.append(f"{true_name} -> {predicted}  ({count})")
+    lines += ["", "margins:"]
+    if result.margins:
+        arr = np.asarray(result.margins, dtype=np.float64)
+        mean = float(np.mean(arr))
+        p10 = float(np.percentile(arr, 10))
+        lines.append(f"count: {len(result.margins)}")
+        lines.append(f"mean: {mean:.4f}")
+        lines.append(f"p10: {p10:.4f}")
+    else:
+        lines.append("count: 0")
+        lines.append("(no scored matches)")
+    return "\n".join(lines) + "\n"

+ 2 - 0
src/stt_daemon/events.py

@@ -12,6 +12,7 @@ SPEECH_STOP = "speech_stop"
 HEARTBEAT = "heartbeat"
 STATUS = "status"
 RING_END = "ring_end"
+SPEAKERS = "speakers"
 
 ALL_TYPES = frozenset(
     {
@@ -22,6 +23,7 @@ ALL_TYPES = frozenset(
         HEARTBEAT,
         STATUS,
         RING_END,
+        SPEAKERS,
     }
 )
 

+ 16 - 0
src/stt_daemon/output.py

@@ -62,6 +62,12 @@ class TranscriptSink:
         reason: str = "unknown",
         audio: np.ndarray | None = None,
         transcribe_s: float | None = None,
+        *,
+        seg_id: int | None = None,
+        speaker_id: str | None = None,
+        speaker_status: str = "none",
+        speaker: str | None = None,
+        speaker_score: float | None = None,
     ) -> str | None:
         """Emit a transcript result.
 
@@ -73,6 +79,11 @@ class TranscriptSink:
             audio: Utterance samples (float32, mono); saved when save_audio is enabled.
             transcribe_s: Wall-clock seconds spent transcribing this utterance.
                 None omits the field from the record.
+            seg_id: Stable correlation key for the segment.
+            speaker_id: Stable cluster key for cross-reference (None when unknown).
+            speaker_status: matched | uncertain | unknown | none.
+            speaker: Display label (name, spk_NNNN, spk_NNNN?, or ?).
+            speaker_score: Cosine of the winning match (None when unknown).
 
         Returns:
             Path of the saved audio file, or None if nothing was saved/emitted.
@@ -112,6 +123,11 @@ class TranscriptSink:
                 "rms": round(rms, 5),
                 "end_reason": reason,
                 "text": result.text,
+                "seg_id": seg_id,
+                "speaker_id": speaker_id,
+                "speaker_status": speaker_status,
+                "speaker": speaker,
+                "speaker_score": round(speaker_score, 4) if speaker_score is not None else None,
             }
             if audio_path is not None:
                 record["audio"] = str(audio_path)

+ 706 - 0
src/stt_daemon/speakers.py

@@ -0,0 +1,706 @@
+"""Speaker prototype store, matcher, and enrollment guards.
+
+Owns the daemon-side identity state: L2-normalized prototypes per speaker,
+matched/uncertain/unknown classification, guarded provisional minting,
+adaptation, promotion, naming, and pruning. The search score is the maximum
+cosine over every prototype of every speaker (never a centroid).
+
+On-disk layout under ``root`` (cwd-relative, gitignored)::
+
+    speakers.json     metadata
+    prototypes.npz    {"ids": str[N], "vectors": float32 (N, dim)}, all L2-normalized
+    samples/<id>.wav  rolling sample for a provisional profile
+    inbox/            rename request files (drained by the daemon, not here)
+
+``speakers.json`` shape::
+
+    {"version": 1, "next_id": int, "last_mint_ts": float | null,
+     "speakers": {"spk_0001": {"name": str | null,
+                               "status": "provisional" | "confirmed",
+                               "cumulative_s": float, "created_at": float,
+                               "sample": str | null, "prototypes": int}}}
+
+Single-writer by design (the daemon embedding lane); no locking. ``load()`` and
+``process()`` never raise: failures log a warning and degrade to an empty store
+or an ``unknown``/no-event outcome.
+"""
+
+import json
+import logging
+import os
+import tempfile
+import time
+from collections.abc import Callable, Mapping, Sequence
+from dataclasses import dataclass
+from pathlib import Path
+
+import numpy as np
+
+from .output import write_wav
+
+logger = logging.getLogger(__name__)
+
+_MATCHED = "matched"
+_UNCERTAIN = "uncertain"
+_UNKNOWN = "unknown"
+_PROVISIONAL = "provisional"
+_CONFIRMED = "confirmed"
+_MINT = "mint"
+_CONFIRM = "confirm"
+_ADAPT = "adapt"
+_VERSION = 1
+_JSON_NAME = "speakers.json"
+_NPZ_NAME = "prototypes.npz"
+_SAMPLES_DIR = "samples"
+
+
+@dataclass(frozen=True, slots=True)
+class Match:
+    """Result of matching one embedding against the prototype store."""
+
+    speaker_id: str | None
+    status: str
+    score: float | None
+    top_id: str | None
+
+
+@dataclass(frozen=True, slots=True)
+class Outcome:
+    """A processed utterance: the match plus the store event it triggered."""
+
+    match: Match
+    event: str | None
+
+
+_NO_MATCH = Match(speaker_id=None, status=_UNKNOWN, score=None, top_id=None)
+
+
+@dataclass
+class _Profile:
+    """Mutable per-speaker metadata entry (mutation is this container's purpose)."""
+
+    name: str | None
+    status: str
+    cumulative_s: float
+    created_at: float
+    sample: str | None
+    prototypes: int
+
+
+def _profile_dict(profile: _Profile) -> dict:
+    """Serialize one profile to the metadata JSON shape."""
+    return {
+        "name": profile.name,
+        "status": profile.status,
+        "cumulative_s": profile.cumulative_s,
+        "created_at": profile.created_at,
+        "sample": profile.sample,
+        "prototypes": profile.prototypes,
+    }
+
+
+class SpeakerStore:
+    """Prototype store and matcher with mint/adapt guards.
+
+    Thresholds and guards are explicit parameters so this module stays
+    config-agnostic. ``threshold_id`` (matched), ``threshold_new`` (uncertain
+    floor), ``min_enroll_s``/``rms_floor`` (mint/adapt gates), ``margin``
+    (best-2nd separation), ``mint_cooldown_s``, ``confirm_s`` (cumulative
+    promotion), ``max_prototypes``, ``provisional_ttl_h`` and ``sample_rate``.
+    """
+
+    def __init__(
+        self,
+        root: Path,
+        *,
+        threshold_id: float = 0.60,
+        threshold_new: float = 0.45,
+        min_enroll_s: float = 2.0,
+        rms_floor: float = 0.0,
+        margin: float = 0.04,
+        mint_cooldown_s: float = 60.0,
+        confirm_s: float = 12.0,
+        max_prototypes: int = 8,
+        provisional_ttl_h: float = 24.0,
+        clock: Callable[[], float] = time.time,
+        logger: logging.Logger | None = None,
+        sample_rate: int = 16000,
+    ) -> None:
+        self._root = Path(root)
+        self._threshold_id = float(threshold_id)
+        self._threshold_new = float(threshold_new)
+        self._min_enroll_s = float(min_enroll_s)
+        self._rms_floor = float(rms_floor)
+        self._margin = float(margin)
+        self._mint_cooldown_s = float(mint_cooldown_s)
+        self._confirm_s = float(confirm_s)
+        self._max_prototypes = max(1, int(max_prototypes))
+        self._provisional_ttl_h = float(provisional_ttl_h)
+        self._clock = clock
+        self._logger = logger if logger is not None else logging.getLogger(__name__)
+        self._sample_rate = int(sample_rate)
+        self._json_path = self._root / _JSON_NAME
+        self._npz_path = self._root / _NPZ_NAME
+        self._clear()
+
+    # -- read-only inspection -------------------------------------------------
+
+    @property
+    def root(self) -> Path:
+        """Store root directory."""
+        return self._root
+
+    @property
+    def speakers(self) -> Mapping[str, dict]:
+        """Read-only copy of the per-speaker metadata mapping."""
+        return {sid: _profile_dict(profile) for sid, profile in self._speakers.items()}
+
+    def speaker_ids(self) -> list[str]:
+        """Known speaker ids, sorted."""
+        return sorted(self._speakers)
+
+    def snapshot(self) -> list[dict]:
+        """Per-speaker metadata (sorted by id) for the SPEAKERS bus event."""
+        return [
+            {
+                "id": speaker_id,
+                "name": profile.name,
+                "status": profile.status,
+                "cumulative_s": profile.cumulative_s,
+                "sample_path": self._sample_path(profile),
+                "created_at": profile.created_at,
+            }
+            for speaker_id, profile in sorted(self._speakers.items())
+        ]
+
+    def match(self, emb: np.ndarray) -> Match:
+        """Classify an embedding without mutating the store."""
+        normalized = self._normalize(emb)
+        if normalized is None:
+            return _NO_MATCH
+        top1, _top2, best_id = self._search(normalized)
+        if top1 is None or best_id is None:
+            return _NO_MATCH
+        if top1 >= self._threshold_id:
+            return Match(speaker_id=best_id, status=_MATCHED, score=top1, top_id=None)
+        if top1 >= self._threshold_new:
+            return Match(speaker_id=None, status=_UNCERTAIN, score=top1, top_id=best_id)
+        return _NO_MATCH
+
+    # -- persistence ----------------------------------------------------------
+
+    def load(self) -> None:
+        """Load metadata and prototypes; corrupt input yields an empty store.
+
+        Never raises and never prunes: startup code decides when to call
+        ``prune()``.
+        """
+        self._clear()
+        if not self._json_path.exists() and not self._npz_path.exists():
+            return
+        try:
+            meta = json.loads(self._json_path.read_text(encoding="utf-8"))
+            ids, vectors = self._read_prototypes()
+            self._speakers, self._next_id, self._last_mint_ts = self._parse_meta(meta)
+        except Exception as e:  # noqa: BLE001 -- boundary: an unreadable store must not crash
+            self._clear()
+            self._logger.warning("speaker store unreadable (%s); starting empty", e)
+            return
+        self._proto_ids, self._vectors = self._sanitize(ids, vectors)
+
+    def save(self) -> None:
+        """Atomically persist ``speakers.json`` and ``prototypes.npz``."""
+        self._ensure_private_dir(self._root)
+        meta = {
+            "version": _VERSION,
+            "next_id": self._next_id,
+            "last_mint_ts": self._last_mint_ts,
+            "speakers": {sid: _profile_dict(p) for sid, p in self._speakers.items()},
+        }
+        json_tmp: str | None = None
+        npz_tmp: str | None = None
+        try:
+            fd, json_tmp = tempfile.mkstemp(dir=self._root, prefix=".speakers-", suffix=".tmp")
+            with os.fdopen(fd, "w", encoding="utf-8") as f:
+                json.dump(meta, f, indent=2)
+            fd, npz_tmp = tempfile.mkstemp(dir=self._root, prefix=".speakers-", suffix=".tmp")
+            with os.fdopen(fd, "wb") as f:
+                np.savez(f, ids=np.array(self._proto_ids, dtype=np.str_), vectors=self._vectors)
+            os.replace(json_tmp, self._json_path)
+            json_tmp = None
+            os.replace(npz_tmp, self._npz_path)
+            npz_tmp = None
+        except BaseException:
+            for tmp in (json_tmp, npz_tmp):
+                if tmp is not None:
+                    try:
+                        os.unlink(tmp)
+                    except OSError:
+                        pass
+            raise
+
+    # -- mutation -------------------------------------------------------------
+
+    def process(
+        self,
+        emb: np.ndarray | None,
+        dur_s: float,
+        rms: float,
+        audio: np.ndarray | None = None,
+    ) -> Outcome:
+        """Match an utterance, then adapt or mint it subject to the guards.
+
+        Never raises; an internal failure logs a warning and returns an
+        ``unknown``/no-event outcome. Adapted matches add a prototype (oldest
+        evicted at the cap) and ``dur_s`` of cumulative speech; a provisional
+        that reaches ``confirm_s`` becomes ``confirmed`` (event ``confirm``).
+        Minting requires duration+level, two consecutive mutually similar
+        misses, the mint cooldown, and the best-2nd margin.
+
+        Args:
+            emb: Speaker embedding (any scale; normalized here).
+            dur_s: Utterance duration in seconds.
+            rms: Utterance RMS level.
+            audio: Optional float32 mono samples; on mint they become the
+                rolling ``samples/<id>.wav`` (16-bit PCM at ``sample_rate``).
+        """
+        try:
+            return self._process(emb, dur_s, rms, audio)
+        except Exception as e:  # noqa: BLE001 -- boundary: process() must never raise
+            self._logger.warning("speaker processing failed: %s", e)
+            return Outcome(_NO_MATCH, None)
+
+    def add_prototype(
+        self, speaker_id: str, emb: np.ndarray, dur_s: float, rms: float
+    ) -> None:
+        """Add a prototype to a speaker and advance its cumulative speech.
+
+        Enforces the per-speaker cap (oldest prototype evicted) and normalizes
+        the embedding. Duration/level policy lives in ``process()``; ``rms`` is
+        part of the frozen API but unused here.
+        """
+        profile = self._speakers.get(speaker_id)
+        if profile is None:
+            self._logger.warning("ignoring prototype for unknown speaker %s", speaker_id)
+            return
+        normalized = self._normalize(emb)
+        if normalized is None:
+            return
+        if self._proto_ids and self._vectors.shape[1] != normalized.shape[0]:
+            self._warn_dim_mismatch(normalized.shape[0])
+            return
+        self._append_prototype(speaker_id, normalized)
+        profile.cumulative_s += float(dur_s)
+
+    def rename(self, speaker_id: str, name: str) -> bool:
+        """Name a speaker, delete its sample, and persist.
+
+        Returns ``False`` when the id is unknown (nothing is written).
+        """
+        profile = self._speakers.get(speaker_id)
+        if profile is None:
+            return False
+        profile.name = name
+        self._delete_sample(profile)
+        self.save()
+        return True
+
+    def enroll(
+        self, name: str, embeddings: Sequence[np.ndarray], dur_s: float = 0.0
+    ) -> str:
+        """Register a speaker from pre-existing embeddings (offline enrollment).
+
+        Bypasses the mint guards (duration/level/consecutive/cooldown/margin):
+        explicit enrollment is trusted and the CLI enforces a duration floor.
+        Reuses the id of an existing same-named profile (promoting it to
+        ``confirmed``) or allocates ``spk_{next_id:04d}`` as ``confirmed``.
+        Adds every embedding via ``add_prototype`` (cap + oldest-eviction
+        apply); ``dur_s`` is added to ``cumulative_s`` exactly once.
+
+        Does NOT call ``save()``; the caller persists.
+        """
+        cleaned = name.strip()
+        if not cleaned:
+            raise ValueError("speaker name must not be empty")
+        speaker_id: str | None = None
+        for sid, profile in self._speakers.items():
+            if profile.name == cleaned:
+                speaker_id = sid
+                break
+        if speaker_id is None:
+            speaker_id = f"spk_{self._next_id:04d}"
+            self._next_id += 1
+            self._speakers[speaker_id] = _Profile(
+                name=cleaned,
+                status=_CONFIRMED,
+                cumulative_s=0.0,
+                created_at=self._clock(),
+                sample=None,
+                prototypes=0,
+            )
+        else:
+            profile = self._speakers[speaker_id]
+            profile.status = _CONFIRMED
+        for emb in embeddings:
+            self.add_prototype(speaker_id, emb, 0.0, 0.0)
+        profile = self._speakers[speaker_id]
+        profile.cumulative_s += float(dur_s)
+        return speaker_id
+
+    def prune(self, now: float | None = None) -> list[str]:
+        """Drop provisional speakers older than ``provisional_ttl_h``.
+
+        Deletes their samples and prototypes and returns the removed ids. Not
+        persisted here; callers ``save()`` when they want it on disk.
+        """
+        current = self._clock() if now is None else now
+        ttl_s = self._provisional_ttl_h * 3600.0
+        removed = [
+            speaker_id
+            for speaker_id, profile in self._speakers.items()
+            if profile.status == _PROVISIONAL and current - profile.created_at > ttl_s
+        ]
+        for speaker_id in removed:
+            self._delete_sample(self._speakers[speaker_id])
+            self._remove_prototypes(speaker_id)
+            del self._speakers[speaker_id]
+        return sorted(removed)
+
+    def dedupe(self, speaker_id: str | None = None, *, threshold: float = 0.99) -> int:
+        """Drop near-duplicate prototypes within each targeted speaker.
+
+        Walks each speaker's prototypes in insertion order and keeps the first
+        of each cluster; a prototype is dropped when its cosine to an
+        already-kept prototype of the same speaker is ``>= threshold``. The
+        first prototype is always kept, so no speaker loses its last one.
+        ``speaker_id`` may be a speaker id or a display name (assisted
+        enrollment passes the profile name); ``None`` covers all speakers.
+
+        Updates ``_proto_ids``/``_vectors`` (rebuilt once, never mutated while
+        iterating) and every targeted profile's ``prototypes`` count. Does NOT
+        call ``save()``. Never raises: an empty store or unknown id yields 0.
+        Returns the number of prototypes removed.
+        """
+        try:
+            if not self._proto_ids or self._vectors.size == 0:
+                return 0
+            if speaker_id is None:
+                targets = sorted(self._speakers)
+            elif speaker_id in self._speakers:
+                targets = [speaker_id]
+            else:
+                cleaned = str(speaker_id).strip()
+                targets = [
+                    sid
+                    for sid, profile in self._speakers.items()
+                    if profile.name == cleaned
+                ]
+                if not targets:
+                    return 0
+            keep = [True] * len(self._proto_ids)
+            for sid in targets:
+                kept: list[np.ndarray] = []
+                for index, pid in enumerate(self._proto_ids):
+                    if pid != sid:
+                        continue
+                    vector = self._vectors[index]
+                    if any(float(vector @ other) >= threshold for other in kept):
+                        keep[index] = False
+                    else:
+                        kept.append(vector)
+            removed = sum(1 for wanted in keep if not wanted)
+            if removed == 0:
+                return 0
+            mask = np.array(keep, dtype=bool)
+            self._proto_ids = [
+                pid for pid, wanted in zip(self._proto_ids, keep) if wanted
+            ]
+            self._vectors = self._vectors[mask]
+            for sid in targets:
+                profile = self._speakers.get(sid)
+                if profile is not None:
+                    profile.prototypes = self._proto_ids.count(sid)
+            return removed
+        except Exception as e:  # noqa: BLE001 -- boundary: dedupe() must never raise
+            self._logger.warning("speaker dedupe failed: %s", e)
+            return 0
+
+    # -- pipeline internals ---------------------------------------------------
+
+    def _process(
+        self,
+        emb: np.ndarray | None,
+        dur_s: float,
+        rms: float,
+        audio: np.ndarray | None,
+    ) -> Outcome:
+        normalized = self._normalize(emb)
+        if normalized is None:
+            return Outcome(_NO_MATCH, None)
+        top1, top2, best_id = self._search(normalized)
+        if top1 is not None and top1 >= self._threshold_id:
+            return self._adapt_or_label(normalized, best_id, dur_s, rms, top1, top2)
+        if top1 is not None and top1 >= self._threshold_new:
+            self._last_miss = None
+            return Outcome(Match(None, _UNCERTAIN, top1, best_id), None)
+        return self._mint_or_label(normalized, dur_s, rms, audio, top1, top2)
+
+    def _adapt_or_label(
+        self,
+        emb: np.ndarray,
+        speaker_id: str | None,
+        dur_s: float,
+        rms: float,
+        top1: float,
+        top2: float | None,
+    ) -> Outcome:
+        self._last_miss = None
+        matched = Match(speaker_id, _MATCHED, top1, None)
+        if speaker_id is None or not self._guards_pass(dur_s, rms, top1, top2):
+            return Outcome(matched, None)
+        self.add_prototype(speaker_id, emb, dur_s, rms)
+        profile = self._speakers.get(speaker_id)
+        if profile is None:
+            return Outcome(matched, None)
+        if profile.status == _PROVISIONAL and profile.cumulative_s >= self._confirm_s:
+            profile.status = _CONFIRMED
+            return Outcome(matched, _CONFIRM)
+        return Outcome(matched, _ADAPT)
+
+    def _mint_or_label(
+        self,
+        emb: np.ndarray,
+        dur_s: float,
+        rms: float,
+        audio: np.ndarray | None,
+        top1: float | None,
+        top2: float | None,
+    ) -> Outcome:
+        similar = (
+            self._last_miss is not None
+            and float(self._last_miss @ emb) >= self._threshold_id
+        )
+        self._last_miss = emb
+        if not similar or not self._guards_pass(dur_s, rms, top1, top2):
+            return Outcome(_NO_MATCH, None)
+        if not self._cooldown_elapsed():
+            return Outcome(_NO_MATCH, None)
+        return self._mint(emb, dur_s, audio)
+
+    def _mint(self, emb: np.ndarray, dur_s: float, audio: np.ndarray | None) -> Outcome:
+        speaker_id = f"spk_{self._next_id:04d}"
+        self._next_id += 1
+        self._last_mint_ts = self._clock()
+        self._last_miss = None
+        sample = self._write_sample(speaker_id, audio) if audio is not None else None
+        self._speakers[speaker_id] = _Profile(
+            name=None,
+            status=_PROVISIONAL,
+            cumulative_s=float(dur_s),
+            created_at=self._last_mint_ts,
+            sample=sample,
+            prototypes=1,
+        )
+        self._append_prototype(speaker_id, emb)
+        self._logger.info("minted provisional speaker %s", speaker_id)
+        return Outcome(Match(speaker_id, _UNKNOWN, None, None), _MINT)
+
+    def _guards_pass(
+        self, dur_s: float, rms: float, top1: float | None, top2: float | None
+    ) -> bool:
+        if dur_s < self._min_enroll_s or rms < self._rms_floor:
+            return False
+        return top2 is None or top1 is None or top1 - top2 >= self._margin
+
+    def _cooldown_elapsed(self) -> bool:
+        if self._last_mint_ts is None:
+            return True
+        return self._clock() - self._last_mint_ts >= self._mint_cooldown_s
+
+    # -- prototype matrix -----------------------------------------------------
+
+    def _search(self, emb: np.ndarray) -> tuple[float | None, float | None, str | None]:
+        """Best score, second-best score, and best speaker id over all prototypes."""
+        if not self._proto_ids:
+            return None, None, None
+        if self._vectors.shape[1] != emb.shape[0]:
+            self._warn_dim_mismatch(emb.shape[0])
+            return None, None, None
+        scores = self._vectors @ emb
+        order = np.argsort(scores)[::-1]
+        best = int(order[0])
+        second = float(scores[int(order[1])]) if order.size > 1 else None
+        return float(scores[best]), second, self._proto_ids[best]
+
+    def _append_prototype(self, speaker_id: str, emb: np.ndarray) -> None:
+        rows = [i for i, pid in enumerate(self._proto_ids) if pid == speaker_id]
+        if len(rows) >= self._max_prototypes:
+            oldest = rows[0]
+            del self._proto_ids[oldest]
+            self._vectors = np.delete(self._vectors, oldest, axis=0)
+        self._proto_ids.append(speaker_id)
+        if self._vectors.size == 0:
+            self._vectors = emb.reshape(1, -1)
+        else:
+            self._vectors = np.vstack([self._vectors, emb])
+        profile = self._speakers.get(speaker_id)
+        if profile is not None:
+            profile.prototypes = self._proto_ids.count(speaker_id)
+
+    def _remove_prototypes(self, speaker_id: str) -> None:
+        keep = [pid != speaker_id for pid in self._proto_ids]
+        self._proto_ids = [pid for pid, wanted in zip(self._proto_ids, keep) if wanted]
+        if self._vectors.size:
+            self._vectors = self._vectors[keep]
+
+    def _warn_dim_mismatch(self, dim: int) -> None:
+        if self._dim_warned:
+            return
+        self._dim_warned = True
+        self._logger.warning(
+            "speaker embedding dim %d does not match stored prototypes dim %d; ignoring",
+            dim,
+            self._vectors.shape[1],
+        )
+
+    # -- serialization helpers ------------------------------------------------
+
+    def _read_prototypes(self) -> tuple[list[str], np.ndarray]:
+        if not self._npz_path.exists():
+            raise FileNotFoundError(f"{self._npz_path} is missing")
+        with np.load(self._npz_path) as data:
+            stored_ids = np.asarray(data["ids"]).astype(np.str_).reshape(-1)
+            ids = [str(value) for value in stored_ids]
+            vectors = np.asarray(data["vectors"], dtype=np.float32)
+        if vectors.ndim != 2 or len(ids) != vectors.shape[0]:
+            raise ValueError("prototypes.npz ids and vectors disagree")
+        return ids, vectors
+
+    def _parse_meta(self, meta: dict) -> tuple[dict[str, _Profile], int, float | None]:
+        if not isinstance(meta, dict) or meta.get("version") != _VERSION:
+            raise ValueError("unsupported speakers.json version")
+        raw_speakers = meta.get("speakers", {})
+        if not isinstance(raw_speakers, dict):
+            raise ValueError("speakers.json 'speakers' must be an object")
+        speakers: dict[str, _Profile] = {}
+        for speaker_id, raw in raw_speakers.items():
+            status = str(raw["status"])
+            if status not in (_PROVISIONAL, _CONFIRMED):
+                raise ValueError(f"unknown speaker status {status!r}")
+            speakers[str(speaker_id)] = _Profile(
+                name=raw.get("name"),
+                status=status,
+                cumulative_s=float(raw["cumulative_s"]),
+                created_at=float(raw["created_at"]),
+                sample=raw.get("sample"),
+                prototypes=int(raw.get("prototypes", 0)),
+            )
+        next_id = max(1, int(meta.get("next_id") or 1))
+        last_mint = meta.get("last_mint_ts")
+        return speakers, next_id, None if last_mint is None else float(last_mint)
+
+    def _sanitize(
+        self, ids: list[str], vectors: np.ndarray
+    ) -> tuple[list[str], np.ndarray]:
+        keep = [i for i, pid in enumerate(ids) if pid in self._speakers]
+        if len(keep) != len(ids):
+            self._logger.warning(
+                "dropping %d prototype(s) with no speaker metadata", len(ids) - len(keep)
+            )
+        kept_ids = [ids[i] for i in keep]
+        kept = vectors[keep]
+        if kept.size:
+            norms = np.linalg.norm(kept, axis=1, keepdims=True)
+            kept = np.divide(kept, norms, out=np.zeros_like(kept), where=norms > 0)
+        for speaker_id, profile in self._speakers.items():
+            if not self._sample_contained(profile.sample):
+                self._logger.warning(
+                    "dropping speaker sample reference escaping the store: %r",
+                    profile.sample,
+                )
+                profile.sample = None
+            profile.prototypes = kept_ids.count(speaker_id)
+        return kept_ids, kept.astype(np.float32, copy=False)
+
+    # -- files and small helpers ----------------------------------------------
+
+    def _sample_path(self, profile: _Profile) -> str | None:
+        return str(self._root / profile.sample) if profile.sample else None
+
+    def _write_sample(self, speaker_id: str, audio: np.ndarray) -> str | None:
+        relative = f"{_SAMPLES_DIR}/{speaker_id}.wav"
+        path = self._root / relative
+        try:
+            self._ensure_private_dir(path.parent)
+            write_wav(path, np.asarray(audio, dtype=np.float32), self._sample_rate)
+            try:
+                os.chmod(path, 0o600)
+            except OSError as e:
+                self._logger.warning("failed to chmod speaker sample %s: %s", path, e)
+        except (OSError, ValueError) as e:
+            self._logger.warning("failed to write speaker sample %s: %s", path, e)
+            return None
+        return relative
+
+    def _ensure_private_dir(self, path: Path) -> None:
+        """Create a biometric store directory with mode 0700; fix existing dirs."""
+        try:
+            path.mkdir(parents=True, exist_ok=True)
+            os.chmod(path, 0o700)
+        except OSError as e:
+            self._logger.warning("failed to secure directory %s: %s", path, e)
+
+    def _sample_contained(self, sample: str | None) -> bool:
+        """True when the stored sample reference resolves inside the store root."""
+        if not sample:
+            return True
+        try:
+            root = self._root.resolve()
+            full = (self._root / sample).resolve()
+            full.relative_to(root)
+        except (OSError, ValueError, RuntimeError):
+            return False
+        return True
+
+    def _delete_sample(self, profile: _Profile) -> None:
+        if profile.sample is None:
+            return
+        if not self._sample_contained(profile.sample):
+            self._logger.warning(
+                "dropping speaker sample reference escaping the store: %r", profile.sample
+            )
+            profile.sample = None
+            return
+        path = self._root / profile.sample
+        try:
+            path.unlink()
+        except FileNotFoundError:
+            pass
+        except OSError as e:
+            self._logger.warning("failed to delete speaker sample %s: %s", path, e)
+        profile.sample = None
+
+    @staticmethod
+    def _normalize(emb: np.ndarray | None) -> np.ndarray | None:
+        """L2-normalized float32 copy, or None when the embedding is unusable."""
+        if emb is None:
+            return None
+        try:
+            vector = np.asarray(emb, dtype=np.float32)
+        except (TypeError, ValueError):
+            return None
+        if vector.ndim != 1 or vector.size == 0 or not np.isfinite(vector).all():
+            return None
+        norm = float(np.linalg.norm(vector))
+        if norm <= 0.0:
+            return None
+        return (vector / norm).astype(np.float32, copy=False)
+
+    def _clear(self) -> None:
+        self._speakers: dict[str, _Profile] = {}
+        self._proto_ids: list[str] = []
+        self._vectors = np.zeros((0, 0), dtype=np.float32)
+        self._next_id = 1
+        self._last_mint_ts: float | None = None
+        self._last_miss: np.ndarray | None = None
+        self._dim_warned = False

+ 13 - 0
src/stt_daemon/tui/state.py

@@ -25,6 +25,8 @@ MODE_STYLES = {
 TIME_STYLE = "bold cyan"
 LANG_TAG_STYLE = "bold yellow"
 REASON_STYLE = "italic dim"
+SPEAKER_NAMED_STYLE = "bold green"
+SPEAKER_UNKNOWN_STYLE = "bold red"
 
 LANG_TEXT_STYLES = {
     "es": "green",
@@ -100,6 +102,7 @@ class ViewState:
     triage_state: str = "disabled"
     triage_reason: str = ""
     triage_epoch: int = 0
+    speakers: list[dict] = field(default_factory=list)
 
     def ingest(self, event: dict) -> None:
         """Fold one event into the view state."""
@@ -159,6 +162,8 @@ class ViewState:
             self.command_text = event.get("text", "")
             self.command_edited = bool(event.get("edited", False))
             self.phase = event.get("phase", self.phase)
+        elif etype == "speakers":
+            self.speakers = list(event.get("speakers") or [])
         elif etype == "transcript":
             self.transcripts.append(event)
             del self.transcripts[:-MAX_TRANSCRIPTS]
@@ -229,12 +234,20 @@ class ViewState:
             text_style = LANG_TEXT_STYLES.get(lang, DEFAULT_LANG_TEXT_STYLE)
             reason = str(e.get("end_reason") or "").strip()
             duration = _num(e.get("duration_s"))
+            speaker = str(e.get("speaker") or "Unknown")
+            speaker_style = (
+                SPEAKER_NAMED_STYLE
+                if e.get("speaker_status") == "matched" and speaker not in ("", "Unknown")
+                else SPEAKER_UNKNOWN_STYLE
+            )
 
             block = Text()
             block.append(_fmt_clock(e.get("ts")), style=TIME_STYLE)
             block.append("  ")
             block.append(lang.upper(), style=LANG_TAG_STYLE)
             block.append("  ")
+            block.append(speaker, style=speaker_style)
+            block.append("  ")
             block.append(f"{duration:.1f}s" if duration is not None else "?", style="dim")
             if reason:
                 block.append("  ")

+ 4 - 4
tests/test_assistant_service.py

@@ -263,7 +263,7 @@ def test_chat_accumulates_turns():
     for text in ("hello there", "how are you"):
         service.handle_event(_transcript(clock, text))
 
-    assert service.chat_history == ["hello there", "how are you"]
+    assert service.chat_history == ["Unknown: hello there", "Unknown: how are you"]
 
 
 def test_execute_requires_readiness():
@@ -369,12 +369,12 @@ def test_pending_chat_is_not_lost_across_threads():
     while time.time() < deadline:
         service.tick()
         words = sum(len(text.split()) for text in dispatched)
-        if not producer.is_alive() and words == total:
+        if not producer.is_alive() and words == 2 * total:
             break
         time.sleep(0.002)
     producer.join(timeout=5)
 
-    assert sum(len(text.split()) for text in dispatched) == total
+    assert sum(len(text.split()) for text in dispatched) == 2 * total
 
 
 def test_chat_notify_emits_compaction_note():
@@ -404,7 +404,7 @@ def test_clear_empties_chat_history():
     for text in ("hello", "how are you"):
         service.handle_event(_transcript(clock, text))
 
-    assert service.chat_history == ["hello", "how are you"]
+    assert service.chat_history == ["Unknown: hello", "Unknown: how are you"]
     assert len(service._pending_chat) == 2
 
     assert service.clear() is True

+ 284 - 0
tests/test_assistant_speaker.py

@@ -0,0 +1,284 @@
+"""Tests for Phase E: assistant speaker cache, display resolution, naming, and ctl."""
+
+import itertools
+import json
+import socket
+from pathlib import Path
+
+from stt_daemon import events
+from stt_daemon.assistant.control import _Client, ControlServer
+from stt_daemon.assistant.service import AssistantService
+from stt_daemon.config import Config
+from stt_daemon.ctl import COMMAND_MAP, build_message
+
+_sequence = itertools.count(1000)
+
+
+class Clock:
+    def __init__(self, start=1000.0):
+        self.t = float(start)
+
+    def __call__(self):
+        return self.t
+
+
+class FakeSubscriber:
+    def events(self):
+        return iter(())
+
+    def close(self):
+        pass
+
+
+class FakeAudit:
+    def __init__(self):
+        self.records: list[dict] = []
+
+    def record(self, **fields):
+        self.records.append(fields)
+
+
+def _service(tmp_path, audit=None):
+    cfg = Config()
+    cfg.speaker.store_dir = str(tmp_path / "speakers")
+    clock = Clock()
+    relayed: list[dict] = []
+    service = AssistantService(
+        cfg,
+        FakeSubscriber(),
+        time_fn=clock,
+        audit=audit,
+        relay=relayed.append,
+    )
+    return service, relayed, clock
+
+
+def _speakers_event(clock, speakers):
+    return {
+        "v": 1,
+        "type": events.SPEAKERS,
+        "seq": next(_sequence),
+        "ts": clock(),
+        "speakers": speakers,
+    }
+
+
+def _transcript(clock, text, **fields):
+    event = {
+        "v": 1,
+        "type": events.TRANSCRIPT,
+        "seq": next(_sequence),
+        "ts": clock(),
+        "text": text,
+    }
+    event.update(fields)
+    return event
+
+
+_ROSTER = [
+    {
+        "id": "spk_0002",
+        "name": "",
+        "status": "provisional",
+        "cumulative_s": 3.0,
+        "sample_path": "speakers/samples/spk_0002.wav",
+        "created_at": "2026-09-28T10:00:00",
+    },
+    {
+        "id": "spk_0001",
+        "name": "Efren",
+        "status": "confirmed",
+        "cumulative_s": 20.0,
+        "sample_path": "",
+        "created_at": "2026-09-28T09:00:00",
+    },
+]
+
+
+def _cached(service, clock):
+    service.handle_event(_speakers_event(clock, _ROSTER))
+
+
+def test_speakers_event_caches_roster_sorted_by_id(tmp_path):
+    service, _, clock = _service(tmp_path)
+    assert service.speaker_list() == []
+
+    _cached(service, clock)
+
+    roster = service.speaker_list()
+    assert [entry["id"] for entry in roster] == ["spk_0001", "spk_0002"]
+    assert roster[0]["name"] == "Efren"
+
+
+def test_speakers_event_is_relayed_to_viewers(tmp_path):
+    service, relayed, clock = _service(tmp_path)
+    event = _speakers_event(clock, _ROSTER)
+    service.handle_event(event)
+
+    assert len(relayed) == 1
+    assert relayed[0]["type"] == events.SPEAKERS
+
+
+def test_transcript_matched_named_relays_name(tmp_path):
+    service, relayed, clock = _service(tmp_path)
+    event = _transcript(
+        clock,
+        "hello",
+        seg_id=1,
+        speaker_id="spk_0001",
+        speaker_status="matched",
+        speaker="Efren",
+        speaker_score=0.8,
+    )
+    service.handle_event(event)
+
+    assert relayed[0]["speaker"] == "Efren"
+    assert relayed[0]["speaker_id"] == "spk_0001"
+    assert relayed[0]["speaker_status"] == "matched"
+    assert relayed[0]["speaker_score"] == 0.8
+    assert event["speaker"] == "Efren"
+
+
+def test_transcript_unmatched_cases_relay_unknown(tmp_path):
+    cases = [
+        {"speaker_id": "spk_0002", "speaker_status": "matched", "speaker": "spk_0002"},
+        {"speaker_id": "spk_0002", "speaker_status": "uncertain", "speaker": "spk_0002?"},
+        {"speaker_id": None, "speaker_status": "unknown", "speaker": "?"},
+        {"speaker_id": None, "speaker_status": "none", "speaker": "?"},
+    ]
+    for fields in cases:
+        service, relayed, clock = _service(tmp_path)
+        event = _transcript(clock, "hello", seg_id=1, speaker_score=None, **fields)
+        service.handle_event(event)
+
+        assert relayed[0]["speaker"] == "Unknown"
+        assert relayed[0]["speaker_id"] == fields["speaker_id"]
+        assert relayed[0]["speaker_status"] == fields["speaker_status"]
+
+
+def test_speaker_name_known_id_writes_inbox_request(tmp_path):
+    audit = FakeAudit()
+    service, _, clock = _service(tmp_path, audit=audit)
+    _cached(service, clock)
+
+    assert service.speaker_name("spk_0002", "Ana") is True
+
+    inbox = Path(service.cfg.speaker.store_dir) / "inbox"
+    files = list(inbox.glob("*.json"))
+    assert len(files) == 1
+    assert json.loads(files[0].read_text(encoding="utf-8")) == {
+        "speaker_id": "spk_0002",
+        "name": "Ana",
+    }
+    assert audit.records == [
+        {"mode": "speaker", "action": "name", "speaker_id": "spk_0002", "name": "Ana"}
+    ]
+
+
+def test_speaker_name_unknown_id_writes_nothing(tmp_path):
+    audit = FakeAudit()
+    service, _, clock = _service(tmp_path, audit=audit)
+    _cached(service, clock)
+
+    assert service.speaker_name("spk_9999", "Nobody") is False
+
+    inbox = Path(service.cfg.speaker.store_dir) / "inbox"
+    assert not inbox.exists() or list(inbox.glob("*.json")) == []
+    assert audit.records == []
+
+
+def test_speaker_name_inbox_is_private(tmp_path):
+    service, _, clock = _service(tmp_path)
+    _cached(service, clock)
+
+    assert service.speaker_name("spk_0002", "Ana") is True
+
+    inbox = Path(service.cfg.speaker.store_dir) / "inbox"
+    assert (inbox.stat().st_mode & 0o077) == 0
+
+
+def test_chat_context_uses_display_labels(tmp_path):
+    service, _, clock = _service(tmp_path)
+    service.toggle_chat()
+    service.handle_event(
+        _transcript(
+            clock, "hi", speaker_id="spk_0001", speaker_status="matched", speaker="Efren"
+        )
+    )
+    service.handle_event(
+        _transcript(clock, "yo", speaker_id=None, speaker_status="none", speaker="?")
+    )
+
+    assert service.chat_history == ["Efren: hi", "Unknown: yo"]
+    assert service._pending_chat == ["Efren: hi", "Unknown: yo"]
+    assert service.triage_history == ["hi", "yo"]
+
+
+def _dispatch(service, message):
+    cfg = Config()
+    server = ControlServer(cfg, service)
+    left, right = socket.socketpair()
+    try:
+        client = _Client(left, 256)
+        server._dispatch(client, message)
+        return json.loads(bytes(client.queue.popleft()).decode("utf-8"))
+    finally:
+        left.close()
+        right.close()
+
+
+def test_control_speaker_list_reply_shape(tmp_path):
+    service, _, clock = _service(tmp_path)
+    _cached(service, clock)
+
+    reply = _dispatch(service, {"type": "speaker_list"})
+
+    assert reply["type"] == "speaker_list"
+    assert [entry["id"] for entry in reply["speakers"]] == ["spk_0001", "spk_0002"]
+
+
+def test_control_speaker_name_ack(tmp_path):
+    service, _, clock = _service(tmp_path)
+    _cached(service, clock)
+
+    reply = _dispatch(
+        service, {"type": "speaker_name", "speaker_id": "spk_0001", "name": "Efren"}
+    )
+
+    assert reply["type"] == "ack"
+    assert reply["command"] == "speaker_name"
+
+
+def test_control_speaker_name_unknown_id(tmp_path):
+    service, _, clock = _service(tmp_path)
+    _cached(service, clock)
+
+    reply = _dispatch(
+        service, {"type": "speaker_name", "speaker_id": "spk_9999", "name": "Nobody"}
+    )
+
+    assert reply["type"] == "error"
+    assert reply["code"] == "unknown_speaker"
+
+
+def test_control_speaker_name_empty_name(tmp_path):
+    service, _, clock = _service(tmp_path)
+    _cached(service, clock)
+
+    reply = _dispatch(
+        service, {"type": "speaker_name", "speaker_id": "spk_0001", "name": ""}
+    )
+
+    assert reply["type"] == "error"
+    assert reply["code"] == "bad_message"
+
+
+def test_ctl_command_map_and_build_message():
+    assert COMMAND_MAP["speaker-list"] == "speaker_list"
+    assert COMMAND_MAP["speaker-name"] == "speaker_name"
+    assert build_message("speaker-list") == {"type": "speaker_list"}
+    assert build_message("speaker-name", rest=["spk_0001", "Efren"]) == {
+        "type": "speaker_name",
+        "speaker_id": "spk_0001",
+        "name": "Efren",
+    }

+ 207 - 0
tests/test_cli_speaker_eval.py

@@ -0,0 +1,207 @@
+"""Tests for the speaker-eval CLI plus the new enroll threshold flags.
+
+Hardware- and network-free: WAVs are synthesized with stdlib ``wave`` (a DC
+level encodes the "speaker id"), the embedder is a fake, and the real
+sherpa-onnx package is never required.
+"""
+
+import argparse
+import io
+import wave
+from contextlib import redirect_stderr, redirect_stdout
+from pathlib import Path
+
+import numpy as np
+
+import stt_daemon.cli as cli_module
+from stt_daemon.enroll import enroll_from_files
+from stt_daemon.speakers import SpeakerStore
+
+A = 0.30
+B = -0.30
+
+
+def write_const_wav(path: Path, seconds: float, value: float) -> Path:
+    """Write a constant-level int16 PCM WAV; the level encodes the speaker."""
+    count = int(seconds * 16000)
+    samples = np.full(count, value, dtype=np.float32)
+    frames = (samples * 32767.0).astype(np.int16)
+    with wave.open(str(path), "wb") as handle:
+        handle.setnchannels(1)
+        handle.setsampwidth(2)
+        handle.setframerate(16000)
+        handle.writeframes(frames.tobytes())
+    return path
+
+
+class LevelEmbedder:
+    """Deterministic embedding from the clip DC level (the fake speaker id)."""
+
+    def __init__(self, dim: int = 2) -> None:
+        self.dim = dim
+
+    def embed(self, audio: np.ndarray) -> np.ndarray:
+        mean = float(np.mean(np.asarray(audio, dtype=np.float32)))
+        if self.dim == 2:
+            vector = [1.0, 0.0] if mean > 0.15 else (
+                [0.0, 1.0] if mean < -0.15 else [0.7071, 0.7071])
+        else:
+            vector = [1.0, 0.0, 0.0] if mean > 0.15 else [0.0, 1.0, 0.0]
+        return np.array(vector, dtype=np.float32)
+
+
+class RotEmbedder:
+    """Same-speaker clips with distinct-but-similar embeddings (20 deg/call)."""
+
+    def embed(self, audio: np.ndarray) -> np.ndarray:
+        mean = float(np.mean(np.asarray(audio, dtype=np.float32)))
+        if mean < -0.15:
+            return np.array([-1.0, 0.0], dtype=np.float32)
+        RotEmbedder.calls = getattr(RotEmbedder, "calls", 0) + 1
+        angle = np.deg2rad(20.0 * RotEmbedder.calls)
+        vector = np.array([np.cos(angle), np.sin(angle)], dtype=np.float32)
+        return vector / np.linalg.norm(vector)
+
+
+def write_cfg(tmp_path: Path, *, model_ok: bool = True) -> Path:
+    """Write a temp config with a temp speaker store; return the cfg path."""
+    model = tmp_path / "model.onnx"
+    if model_ok:
+        model.write_bytes(b"fake-model")
+    cfg_path = tmp_path / "config.toml"
+    cfg_path.write_text(
+        "[speaker]\n"
+        f'model_path = "{model}"\n'
+        f'store_dir = "{tmp_path / "speakers"}"\n'
+        "[events]\n"
+        f'socket = "{tmp_path / "bus.sock"}"\n',
+        encoding="utf-8",
+    )
+    return cfg_path
+
+
+def patch_embedder(monkeypatch, embedder) -> None:
+    """Point cli.SherpaEmbedder at a fake wrapping ``embedder``."""
+
+    class FakeSherpa:
+        def __init__(self, model_path, provider="cpu"):
+            self._embedder = embedder
+
+        def embed(self, audio):
+            return embedder.embed(audio)
+
+    monkeypatch.setattr(cli_module, "SherpaEmbedder", FakeSherpa)
+
+
+def enroll_name(store_dir: Path, name: str, clip: Path,
+                embedder, min_enroll_s: float = 2.0) -> None:
+    """Enroll one clip under ``name`` into the temp store."""
+    store = SpeakerStore(store_dir)
+    enroll_from_files(store, embedder, name, [clip], min_enroll_s)
+
+
+def run_eval(monkeypatch, tmp_path, *, labeled, min_accuracy=None,
+             model_ok=True, dim=2, pre_enroll_dim=None):
+    """Drive ``_handle_speaker_eval`` with fakes; return (rc, out, err)."""
+    cfg_path = write_cfg(tmp_path, model_ok=model_ok)
+    if pre_enroll_dim is not None:
+        clip = write_const_wav(tmp_path / "enroll.wav", 2.5, A)
+        enroll_name(tmp_path / "speakers", "Efren", clip,
+                    LevelEmbedder(dim=pre_enroll_dim))
+    patch_embedder(monkeypatch, LevelEmbedder(dim=dim))
+    args = argparse.Namespace(
+        config=str(cfg_path), labeled=str(labeled), min_accuracy=min_accuracy)
+    out, err = io.StringIO(), io.StringIO()
+    with redirect_stdout(out), redirect_stderr(err):
+        rc = cli_module._handle_speaker_eval(args)
+    return rc, out.getvalue(), err.getvalue()
+
+
+def make_labeled(tmp_path: Path, *, value: float = A,
+                 name: str = "Efren") -> Path:
+    """Build labeled/<name>/clip.wav at the given DC level."""
+    labeled = tmp_path / "labeled"
+    clip_dir = labeled / name
+    clip_dir.mkdir(parents=True, exist_ok=True)
+    write_const_wav(clip_dir / "clip.wav", 2.5, value)
+    return labeled
+
+
+def test_eval_report_success(monkeypatch, tmp_path):
+    clip = write_const_wav(tmp_path / "enroll.wav", 2.5, A)
+    enroll_name(tmp_path / "speakers", "Efren", clip, LevelEmbedder(dim=2))
+    labeled = make_labeled(tmp_path)
+
+    rc, out, _ = run_eval(monkeypatch, tmp_path, labeled=labeled)
+
+    assert rc == 0
+    assert "total: 1" in out
+    assert "accuracy: 1.000" in out
+
+
+def test_eval_below_min_accuracy(monkeypatch, tmp_path):
+    clip = write_const_wav(tmp_path / "enroll.wav", 2.5, A)
+    enroll_name(tmp_path / "speakers", "Efren", clip, LevelEmbedder(dim=2))
+    labeled = make_labeled(tmp_path, value=B)
+
+    rc, _, err = run_eval(monkeypatch, tmp_path, labeled=labeled,
+                          min_accuracy=0.5)
+
+    assert rc == 1
+    assert "below --min-accuracy" in err
+
+
+def test_eval_missing_model(monkeypatch, tmp_path):
+    labeled = make_labeled(tmp_path)
+
+    rc, _, err = run_eval(monkeypatch, tmp_path, labeled=labeled,
+                          model_ok=False)
+
+    assert rc == 1
+    assert "model" in err.lower()
+
+
+def test_eval_dim_mismatch(monkeypatch, tmp_path):
+    labeled = make_labeled(tmp_path)
+
+    rc, _, err = run_eval(monkeypatch, tmp_path, labeled=labeled,
+                          dim=2, pre_enroll_dim=3)
+
+    assert rc == 1
+    assert "different model" in err
+
+
+def test_eval_missing_labeled_path(monkeypatch, tmp_path):
+    write_cfg(tmp_path)
+    patch_embedder(monkeypatch, LevelEmbedder(dim=2))
+    args = argparse.Namespace(
+        config=str(tmp_path / "config.toml"),
+        labeled=str(tmp_path / "does-not-exist"), min_accuracy=None)
+    out, err = io.StringIO(), io.StringIO()
+    with redirect_stdout(out), redirect_stderr(err):
+        rc = cli_module._handle_speaker_eval(args)
+
+    assert rc == 0
+    assert "total: 0" in out.getvalue()
+
+
+def test_seed_handler_forwards_new_thresholds(monkeypatch, tmp_path):
+    audio_dir = tmp_path / "audio"
+    audio_dir.mkdir(exist_ok=True)
+    seed = write_const_wav(audio_dir / "seed.wav", 2.5, A)
+    write_const_wav(audio_dir / "a1.wav", 2.5, A)
+    write_const_wav(audio_dir / "b1.wav", 2.5, B)
+    cfg_path = write_cfg(tmp_path)
+    RotEmbedder.calls = 0
+    patch_embedder(monkeypatch, RotEmbedder())
+    args = argparse.Namespace(
+        config=str(cfg_path), name="Efren", seed=str(seed), count=1,
+        min_score=0.55, audio_dir=str(audio_dir), max_scan=0, yes=True,
+        no_play=True, force=True, dedupe_threshold=0.97,
+        other_threshold=0.44)
+    out, err = io.StringIO(), io.StringIO()
+    with redirect_stdout(out), redirect_stderr(err):
+        rc = cli_module._handle_speaker_enroll_seed(args)
+
+    assert rc == 0
+    assert "from seed" in out.getvalue()

+ 104 - 0
tests/test_config.py

@@ -653,3 +653,107 @@ def test_questions_json_gitignored():
     """The user's private questions.json is never committed (like config.toml)."""
     lines = (REPO_ROOT / ".gitignore").read_text().splitlines()
     assert "questions.json" in lines
+
+
+def test_speaker_defaults():
+    """The [speaker] section defaults match the plan's 14-key list."""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        import os
+        old_cwd = os.getcwd()
+        try:
+            os.chdir(tmpdir)
+            cfg = load_config()
+        finally:
+            os.chdir(old_cwd)
+
+    assert cfg.speaker.enabled is False
+    assert cfg.speaker.model_path is None
+    assert cfg.speaker.provider == "cpu"
+    assert cfg.speaker.threshold_id == 0.60
+    assert cfg.speaker.threshold_new == 0.45
+    assert cfg.speaker.min_enroll_s == 2.0
+    assert cfg.speaker.rms_floor == 0.0
+    assert cfg.speaker.margin == 0.04
+    assert cfg.speaker.mint_cooldown_s == 60.0
+    assert cfg.speaker.confirm_s == 12.0
+    assert cfg.speaker.max_prototypes == 8
+    assert cfg.speaker.provisional_ttl_h == 24.0
+    assert cfg.speaker.store_dir == "speakers"
+    assert cfg.speaker.join_timeout_s == 2.0
+
+
+def test_speaker_config_fields_parsed():
+    """Every [speaker] key is applied from TOML."""
+    config_toml = """
+[speaker]
+enabled = true
+model_path = "models/nemo_en_titanet_small.onnx"
+provider = "cuda"
+threshold_id = 0.7
+threshold_new = 0.5
+min_enroll_s = 3.0
+rms_floor = 0.01
+margin = 0.05
+mint_cooldown_s = 30.0
+confirm_s = 8.0
+max_prototypes = 4
+provisional_ttl_h = 12.0
+store_dir = "voiceprints"
+join_timeout_s = 1.5
+"""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        config_path = Path(tmpdir) / "config.toml"
+        config_path.write_text(config_toml)
+
+        cfg = load_config(str(config_path))
+
+    assert cfg.speaker.enabled is True
+    assert cfg.speaker.model_path == "models/nemo_en_titanet_small.onnx"
+    assert cfg.speaker.provider == "cuda"
+    assert cfg.speaker.threshold_id == 0.7
+    assert cfg.speaker.threshold_new == 0.5
+    assert cfg.speaker.min_enroll_s == 3.0
+    assert cfg.speaker.rms_floor == 0.01
+    assert cfg.speaker.margin == 0.05
+    assert cfg.speaker.mint_cooldown_s == 30.0
+    assert cfg.speaker.confirm_s == 8.0
+    assert cfg.speaker.max_prototypes == 4
+    assert cfg.speaker.provisional_ttl_h == 12.0
+    assert cfg.speaker.store_dir == "voiceprints"
+    assert cfg.speaker.join_timeout_s == 1.5
+
+
+def test_unknown_speaker_key_is_ignored():
+    """An unknown key inside [speaker] is silently ignored (per-field .get)."""
+    config_toml = """
+[speaker]
+enabled = true
+bogus_key = 42
+"""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        config_path = Path(tmpdir) / "config.toml"
+        config_path.write_text(config_toml)
+
+        cfg = load_config(str(config_path))
+
+    assert cfg.speaker.enabled is True
+    assert not hasattr(cfg.speaker, "bogus_key")
+    assert cfg.speaker.provider == "cpu"
+
+
+def test_unknown_speaker_provider_falls_back_to_cpu(caplog):
+    """An unknown speaker provider falls back to 'cpu' with a warning."""
+    config_toml = """
+[speaker]
+provider = "bogus"
+"""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        config_path = Path(tmpdir) / "config.toml"
+        config_path.write_text(config_toml)
+
+        with caplog.at_level("WARNING", logger="stt_daemon.config"):
+            cfg = load_config(str(config_path))
+
+    assert cfg.speaker.provider == "cpu"
+    assert "Unknown speaker provider" in caplog.text
+    assert "bogus" in caplog.text

+ 2 - 0
tests/test_daemon_events.py

@@ -104,11 +104,13 @@ class FakeSink:
         reason="unknown",
         audio=None,
         transcribe_s=None,
+        **kwargs,
     ):
         if not result.text:
             return None
         self.emitted.append(result.text)
         self.transcribe_times.append(transcribe_s)
+        self.last_kwargs = dict(kwargs)
         return None
 
     def close(self):

+ 620 - 0
tests/test_daemon_speaker.py

@@ -0,0 +1,620 @@
+"""Daemon speaker-identification lane: embedder seam, join-before-emit, inbox.
+
+All hardware- and network-free: a fake embedder returns fixed vectors and the
+store lives in tmp_path. No ONNX model, no sherpa-onnx import.
+"""
+
+import json
+import logging
+import os
+import sys
+import threading
+import time
+import types
+
+import numpy as np
+import pytest
+
+from stt_daemon import events
+from stt_daemon.config import Config
+from stt_daemon.daemon import Daemon
+from stt_daemon.embedder import SherpaEmbedder, build_embedder
+from stt_daemon.segmenter import Utterance
+from stt_daemon.transcriber import TranscriptResult
+
+VEC_A = np.array([1.0, 0.0, 0.0], dtype=np.float32)
+VEC_B = np.array([0.0, 1.0, 0.0], dtype=np.float32)
+
+
+class FakeEmbedder:
+    """Returns canned vectors; tracks peak concurrent embed() calls."""
+
+    def __init__(self, vectors=None, delay=0.0, error=None):
+        self._vectors = list(vectors) if vectors else []
+        self._delay = delay
+        self._error = error
+        self.calls = 0
+        self.max_in_flight = 0
+        self._in_flight = 0
+        self._lock = threading.Lock()
+
+    def embed(self, audio):
+        with self._lock:
+            self.calls += 1
+            self._in_flight += 1
+            self.max_in_flight = max(self.max_in_flight, self._in_flight)
+        try:
+            if self._delay:
+                time.sleep(self._delay)
+            if self._error is not None:
+                raise self._error
+            if self._vectors:
+                return np.array(self._vectors.pop(0), dtype=np.float32)
+            return np.array(VEC_A, dtype=np.float32)
+        finally:
+            with self._lock:
+                self._in_flight -= 1
+
+
+class FakeTranscriber:
+    def __init__(self, text="hello"):
+        self.text = text
+
+    def transcribe(self, audio):
+        return TranscriptResult(language="en", language_probability=0.9, text=self.text)
+
+    def close(self):
+        pass
+
+
+class FakeSink:
+    def __init__(self):
+        self.records = []
+
+    def emit(self, result, duration_s, rms=0.0, reason="unknown", audio=None,
+             transcribe_s=None, **kwargs):
+        if not result.text:
+            return None
+        self.records.append(dict(kwargs))
+        return None
+
+    def close(self):
+        pass
+
+
+class FakeCapture:
+    def frames(self):
+        return iter(())
+
+    def healthy(self):
+        return True
+
+    def close(self):
+        pass
+
+
+def _utt(seg_id, duration_s=3.0, rms=0.05):
+    return Utterance(
+        audio=np.zeros(1600, dtype=np.float32),
+        duration_s=duration_s,
+        started_at=0.0,
+        rms=rms,
+        reason="pause",
+        seg_id=seg_id,
+    )
+
+
+def _config(tmp_path, **overrides):
+    cfg = Config()
+    cfg.events.socket = str(tmp_path / "events.sock")
+    cfg.output.stdout = False
+    cfg.output.jsonl_dir = None
+    cfg.output.save_audio = False
+    cfg.speaker.enabled = True
+    cfg.speaker.model_path = str(tmp_path / "model.onnx")
+    cfg.speaker.store_dir = str(tmp_path / "speakers")
+    for key, value in overrides.items():
+        setattr(cfg.speaker, key, value)
+    return cfg
+
+
+def _daemon_with_lane(cfg, embedder, published=None):
+    """Wire a daemon with fakes and a started speaker lane (no threads but lane)."""
+    sink = FakeSink()
+    daemon = Daemon(
+        cfg,
+        log_level=logging.WARNING,
+        transcriber_factory=lambda: FakeTranscriber(),
+        sink_factory=lambda: sink,
+        embedder_factory=lambda: embedder,
+    )
+    daemon.transcriber = FakeTranscriber()
+    daemon.sink = sink
+    collected = [] if published is None else published
+    daemon._publish = lambda type_, **fields: collected.append((type_, fields))
+    daemon._start_speaker_lane()
+    assert daemon._speaker_lane is not None
+    daemon._speaker_lane.start()
+    return daemon, sink, collected
+
+
+def _transcripts(collected):
+    return [fields for type_, fields in collected if type_ == events.TRANSCRIPT]
+
+
+def _speakers_events(collected):
+    return [fields for type_, fields in collected if type_ == events.SPEAKERS]
+
+
+def test_no_sherpa_import_at_module_level():
+    import stt_daemon.embedder  # noqa: F401 -- must import without the optional extra
+
+    assert "sherpa_onnx" not in sys.modules
+
+
+def test_build_embedder_none_when_disabled_or_missing(tmp_path, caplog):
+    cfg = _config(tmp_path)
+    cfg.speaker.enabled = False
+    with caplog.at_level(logging.WARNING):
+        assert build_embedder(cfg) is None
+    assert "speaker" in caplog.text.lower()
+
+    cfg.speaker.enabled = True
+    cfg.speaker.model_path = None
+    with caplog.at_level(logging.WARNING):
+        assert build_embedder(cfg) is None
+
+    cfg.speaker.model_path = str(tmp_path / "nope.onnx")
+    with caplog.at_level(logging.WARNING):
+        assert build_embedder(cfg) is None
+
+
+def test_mint_then_matched_flow(tmp_path):
+    cfg = _config(tmp_path)
+    cfg.speaker.confirm_s = 100.0  # keep the third utterance at adapt, not confirm
+    daemon, sink, collected = _daemon_with_lane(cfg, FakeEmbedder())
+
+    daemon._process(_utt(1))
+    daemon._process(_utt(2))
+    daemon._process(_utt(3))
+    daemon.stop()
+
+    transcripts = _transcripts(collected)
+    assert len(transcripts) == 3
+    for fields in transcripts:
+        for key in ("speaker_id", "speaker_status", "speaker", "speaker_score"):
+            assert key in fields
+        assert fields["seg_id"] in (1, 2, 3)
+
+    assert transcripts[0]["speaker_status"] == "unknown"
+    assert transcripts[0]["speaker_id"] is None
+
+    assert transcripts[1]["speaker_status"] == "unknown"
+    assert transcripts[1]["speaker_id"] == "spk_0001"
+    assert transcripts[1]["speaker_score"] is None
+
+    assert transcripts[2]["speaker_status"] == "matched"
+    assert transcripts[2]["speaker_id"] == "spk_0001"
+    assert transcripts[2]["speaker"] == "spk_0001"  # unnamed: id as label
+    assert transcripts[2]["speaker_score"] == pytest.approx(1.0)
+
+    # Sink saw the same five fields, including seg_id.
+    assert sink.records[2]["seg_id"] == 3
+    assert sink.records[2]["speaker_id"] == "spk_0001"
+    assert sink.records[2]["speaker_status"] == "matched"
+
+    # Mint publishes SPEAKERS; adapt does not.
+    assert len(_speakers_events(collected)) == 1
+
+    # Matched + named resolves the display name.
+    assert daemon.speaker_store.rename("spk_0001", "Efren") is True
+    daemon2, sink2, collected2 = _daemon_with_lane(cfg, FakeEmbedder())
+    daemon2._process(_utt(4))
+    daemon2.stop()
+    assert _transcripts(collected2)[0]["speaker"] == "Efren"
+
+
+def test_speakers_published_on_confirm_not_adapt(tmp_path):
+    cfg = _config(tmp_path)
+    cfg.speaker.confirm_s = 6.0
+    daemon, _, collected = _daemon_with_lane(cfg, FakeEmbedder())
+
+    daemon._process(_utt(1))
+    daemon._process(_utt(2))  # mint (cumulative 3.0)
+    assert len(_speakers_events(collected)) == 1
+    daemon._process(_utt(3))  # adapt -> cumulative 6.0 -> confirm
+    daemon.stop()
+
+    assert len(_speakers_events(collected)) == 2
+    assert _speakers_events(collected)[-1]["speakers"][0]["status"] == "confirmed"
+    assert daemon.speaker_store.speakers["spk_0001"]["status"] == "confirmed"
+
+
+def test_stalled_embedder_times_out_without_blocking(tmp_path):
+    cfg = _config(tmp_path, join_timeout_s=0.2)
+    daemon, sink, collected = _daemon_with_lane(cfg, FakeEmbedder(delay=2.0))
+
+    started = time.monotonic()
+    daemon._process(_utt(1))
+    elapsed = time.monotonic() - started
+    daemon.stop()
+
+    assert elapsed < 2.0
+    transcripts = _transcripts(collected)
+    assert len(transcripts) == 1  # transcription still emitted
+    assert transcripts[0]["speaker_status"] == "none"
+    assert transcripts[0]["speaker_id"] is None
+    assert sink.records[0]["speaker_status"] == "none"
+
+
+def test_embedder_error_degrades_to_none(tmp_path):
+    cfg = _config(tmp_path)
+    daemon, _, collected = _daemon_with_lane(cfg, FakeEmbedder(error=RuntimeError("boom")))
+
+    daemon._process(_utt(1))  # must not raise
+    daemon.stop()
+
+    transcripts = _transcripts(collected)
+    assert len(transcripts) == 1
+    assert transcripts[0]["speaker_status"] == "none"
+
+
+@pytest.mark.parametrize("enabled,model_path", [(False, None), (True, None)])
+def test_disabled_or_missing_model_has_no_lane(tmp_path, monkeypatch, enabled, model_path):
+    monkeypatch.chdir(tmp_path)
+    cfg = Config()
+    cfg.output.stdout = False
+    cfg.speaker.enabled = enabled
+    cfg.speaker.model_path = model_path
+    cfg.speaker.store_dir = "speakers"
+    sink = FakeSink()
+    daemon = Daemon(
+        cfg,
+        log_level=logging.WARNING,
+        transcriber_factory=lambda: FakeTranscriber(),
+        sink_factory=lambda: sink,
+    )
+    daemon.transcriber = FakeTranscriber()
+    daemon.sink = sink
+    collected = []
+    daemon._publish = lambda type_, **fields: collected.append((type_, fields))
+    daemon._start_speaker_lane()
+
+    assert daemon._speaker_lane is None
+    assert daemon.speaker_store is None
+
+    daemon._process(_utt(1))
+    transcripts = _transcripts(collected)
+    assert transcripts[0]["speaker_status"] == "none"
+    assert transcripts[0]["speaker_id"] is None
+    assert transcripts[0]["speaker"] == "?"
+    assert transcripts[0]["speaker_score"] is None
+    assert not (tmp_path / "speakers").exists()
+
+
+def test_inbox_valid_request_renames_and_publishes(tmp_path):
+    cfg = _config(tmp_path)
+    daemon, _, collected = _daemon_with_lane(cfg, FakeEmbedder())
+    daemon._process(_utt(1))
+    daemon._process(_utt(2))  # mint spk_0001
+    before = len(_speakers_events(collected))
+
+    inbox = tmp_path / "speakers" / "inbox"
+    inbox.mkdir(parents=True, exist_ok=True)
+    request = inbox / "req-1.json"
+    request.write_text(json.dumps({"speaker_id": "spk_0001", "name": "Efren"}))
+
+    daemon._speaker_lane.drain_inbox()
+
+    assert daemon.speaker_store.speakers["spk_0001"]["name"] == "Efren"
+    assert not request.exists()
+    assert len(_speakers_events(collected)) == before + 1
+    daemon.stop()
+
+
+def test_inbox_malformed_and_unknown_id_quarantined(tmp_path, caplog):
+    cfg = _config(tmp_path)
+    daemon, _, collected = _daemon_with_lane(cfg, FakeEmbedder())
+    before = len(_speakers_events(collected))
+
+    inbox = tmp_path / "speakers" / "inbox"
+    inbox.mkdir(parents=True, exist_ok=True)
+    bad = inbox / "bad.json"
+    bad.write_text("{not json")
+    wrong_shape = inbox / "shape.json"
+    wrong_shape.write_text(json.dumps({"speaker_id": "spk_0001"}))
+    unknown = inbox / "unknown.json"
+    unknown.write_text(json.dumps({"speaker_id": "spk_9999", "name": "Ghost"}))
+
+    with caplog.at_level(logging.WARNING):
+        daemon._speaker_lane.drain_inbox()
+    daemon.stop()
+
+    rejected = inbox / "rejected"
+    assert (rejected / "bad.json").exists()
+    assert (rejected / "shape.json").exists()
+    assert (rejected / "unknown.json").exists()
+    assert len(_speakers_events(collected)) == before  # no publish on reject
+    assert "speaker" in caplog.text.lower()
+
+
+def test_inbox_request_present_before_start_is_applied(tmp_path):
+    cfg = _config(tmp_path)
+    cfg.speaker.confirm_s = 100.0
+    first, _, _ = _daemon_with_lane(cfg, FakeEmbedder())
+    first._process(_utt(1))
+    first._process(_utt(2))  # mint spk_0001, persisted
+    first.stop()
+
+    inbox = tmp_path / "speakers" / "inbox"
+    inbox.mkdir(parents=True, exist_ok=True)
+    (inbox / "pending.json").write_text(
+        json.dumps({"speaker_id": "spk_0001", "name": "Efren"})
+    )
+
+    sink = FakeSink()
+    daemon = Daemon(
+        cfg,
+        log_level=logging.WARNING,
+        transcriber_factory=lambda: FakeTranscriber(),
+        sink_factory=lambda: sink,
+        embedder_factory=lambda: FakeEmbedder(),
+    )
+    daemon.transcriber = FakeTranscriber()
+    daemon.sink = sink
+    collected = []
+    daemon._publish = lambda type_, **fields: collected.append((type_, fields))
+    daemon._start_speaker_lane()
+    daemon._speaker_lane.start()
+
+    deadline = time.time() + 5.0
+    while time.time() < deadline:
+        if daemon.speaker_store.speakers.get("spk_0001", {}).get("name") == "Efren":
+            break
+        time.sleep(0.01)
+    daemon.stop()
+
+    assert daemon.speaker_store.speakers["spk_0001"]["name"] == "Efren"
+    assert not (inbox / "pending.json").exists()
+
+
+def test_embedding_lane_is_serial(tmp_path):
+    cfg = _config(tmp_path)
+    embedder = FakeEmbedder(delay=0.02)
+    daemon, _, _ = _daemon_with_lane(cfg, embedder)
+    lane = daemon._speaker_lane
+
+    futures = [lane.submit(i, np.zeros(8, dtype=np.float32), 3.0, 0.05) for i in range(6)]
+    for future in futures:
+        future.result(timeout=10)
+    daemon.stop()
+
+    assert embedder.calls == 6
+    assert embedder.max_in_flight == 1
+
+
+# --- real SherpaEmbedder against a fake sherpa_onnx module --------------------
+
+
+def _fake_sherpa_module(calls, vector, valid=True):
+    """Fake sherpa_onnx implementing the verified API surface.
+
+    Mirrors the official API: ``SpeakerEmbeddingExtractorConfig(model,
+    num_threads=1, debug=False, provider="cpu")`` with ``validate()``, and
+    ``SpeakerEmbeddingExtractor`` with ``create_stream / is_ready / compute``
+    plus a readonly ``dim``. Unknown config kwargs raise TypeError, like the
+    real pybind class.
+    """
+    module = types.ModuleType("sherpa_onnx")
+
+    class Config:
+        def __init__(self, model, num_threads=1, debug=False, provider="cpu"):
+            calls.append(
+                (
+                    "config",
+                    {
+                        "model": model,
+                        "num_threads": num_threads,
+                        "debug": debug,
+                        "provider": provider,
+                    },
+                )
+            )
+
+        def validate(self):
+            calls.append(("validate",))
+            return valid
+
+    class Stream:
+        def accept_waveform(self, sample_rate, waveform):
+            calls.append(("accept_waveform", sample_rate, tuple(np.asarray(waveform).shape)))
+
+        def input_finished(self):
+            calls.append(("input_finished",))
+
+    class Extractor:
+        def __init__(self, config):
+            calls.append(("extractor",))
+
+        @property
+        def dim(self):
+            return len(vector)
+
+        def create_stream(self):
+            calls.append(("create_stream",))
+            return Stream()
+
+        def is_ready(self, stream):
+            calls.append(("is_ready",))
+            return True
+
+        def compute(self, stream):
+            calls.append(("compute",))
+            return np.array(vector, dtype=np.float32)
+
+    module.SpeakerEmbeddingExtractorConfig = Config
+    module.SpeakerEmbeddingExtractor = Extractor
+    return module
+
+
+def test_sherpa_embedder_uses_verified_api(monkeypatch):
+    calls = []
+    vector = np.arange(192, dtype=np.float32)
+    monkeypatch.setitem(sys.modules, "sherpa_onnx", _fake_sherpa_module(calls, vector))
+
+    embedder = SherpaEmbedder("model.onnx")
+    out = embedder.embed(np.zeros(1600, dtype=np.float32))
+
+    assert out.shape == (192,)
+    assert out.dtype == np.float32
+    np.testing.assert_array_equal(out, vector)
+    assert [call[0] for call in calls] == [
+        "config",
+        "validate",
+        "extractor",
+        "create_stream",
+        "accept_waveform",
+        "input_finished",
+        "is_ready",
+        "compute",
+    ]
+    assert calls[0][1] == {
+        "model": "model.onnx",
+        "num_threads": 1,
+        "debug": False,
+        "provider": "cpu",
+    }
+    assert calls[4][1] == 16000  # extractor runs at 16 kHz
+
+
+def test_sherpa_embedder_invalid_config_raises(monkeypatch):
+    calls = []
+    monkeypatch.setitem(
+        sys.modules, "sherpa_onnx", _fake_sherpa_module(calls, np.zeros(2), valid=False)
+    )
+    with pytest.raises(RuntimeError, match="failed to load"):
+        SherpaEmbedder("model.onnx")
+
+
+def test_sherpa_embedder_without_package_raises(monkeypatch):
+    monkeypatch.setitem(sys.modules, "sherpa_onnx", None)  # import -> ImportError
+    with pytest.raises(RuntimeError, match="sherpa-onnx is not installed"):
+        SherpaEmbedder("model.onnx")
+
+
+def test_sherpa_embedder_native_lib_failure_surfaces_cause(monkeypatch):
+    monkeypatch.setitem(sys.modules, "sherpa_onnx", None)  # import -> ModuleNotFoundError
+    with pytest.raises(RuntimeError, match="uv sync --extra speaker"):
+        SherpaEmbedder("model.onnx")
+
+    import builtins
+
+    real_import = builtins.__import__
+
+    def fake_import(name, *args, **kwargs):
+        if name == "sherpa_onnx":
+            raise ImportError(
+                "libonnxruntime.so: cannot open shared object file: No such file or directory"
+            )
+        return real_import(name, *args, **kwargs)
+
+    monkeypatch.delitem(sys.modules, "sherpa_onnx", raising=False)
+    monkeypatch.setattr(builtins, "__import__", fake_import)
+    with pytest.raises(RuntimeError, match="libonnxruntime.so"):
+        SherpaEmbedder("model.onnx")
+
+
+def _lane_without_thread(cfg, embedder, collected):
+    """Build a daemon + lane without starting the worker (deterministic drain)."""
+    sink = FakeSink()
+    daemon = Daemon(
+        cfg,
+        log_level=logging.WARNING,
+        transcriber_factory=lambda: FakeTranscriber(),
+        sink_factory=lambda: sink,
+        embedder_factory=lambda: embedder,
+    )
+    daemon.transcriber = FakeTranscriber()
+    daemon.sink = sink
+    daemon._publish = lambda type_, **fields: collected.append((type_, fields))
+    daemon._start_speaker_lane()
+    assert daemon._speaker_lane is not None
+    return daemon
+
+
+def test_inbox_fifo_quarantined_without_blocking(tmp_path):
+    cfg = _config(tmp_path)
+    collected: list = []
+    daemon = _lane_without_thread(cfg, FakeEmbedder(), collected)
+    lane = daemon._speaker_lane
+    assert lane is not None
+    try:
+        inbox = tmp_path / "speakers" / "inbox"
+        inbox.mkdir(parents=True, exist_ok=True)
+        fifo = inbox / "evil.json"
+        try:
+            os.mkfifo(fifo)
+        except (AttributeError, OSError, NotImplementedError) as e:
+            pytest.skip(f"mkfifo unsupported: {e}")
+        before = len(_speakers_events(collected))
+
+        worker = threading.Thread(target=lane.drain_inbox)
+        worker.start()
+        worker.join(timeout=5)
+        assert not worker.is_alive()  # must not block on the FIFO
+
+        assert not os.path.lexists(fifo)
+        assert os.path.lexists(inbox / "rejected" / "evil.json")
+        assert len(_speakers_events(collected)) == before
+    finally:
+        daemon.stop()
+
+
+def test_inbox_symlink_quarantined_target_untouched(tmp_path):
+    cfg = _config(tmp_path)
+    collected: list = []
+    daemon = _lane_without_thread(cfg, FakeEmbedder(), collected)
+    lane = daemon._speaker_lane
+    assert lane is not None
+    try:
+        inbox = tmp_path / "speakers" / "inbox"
+        inbox.mkdir(parents=True, exist_ok=True)
+        target = tmp_path / "target.txt"
+        target.write_text("secret", encoding="utf-8")
+        link = inbox / "link.json"
+        try:
+            link.symlink_to(target)
+        except (OSError, NotImplementedError) as e:
+            pytest.skip(f"symlink unsupported: {e}")
+        before = len(_speakers_events(collected))
+
+        lane.drain_inbox()
+
+        assert not os.path.lexists(link)
+        assert os.path.lexists(inbox / "rejected" / "link.json")
+        assert target.read_text(encoding="utf-8") == "secret"
+        assert len(_speakers_events(collected)) == before
+    finally:
+        daemon.stop()
+
+
+def test_inbox_oversized_request_quarantined(tmp_path):
+    cfg = _config(tmp_path)
+    collected: list = []
+    daemon = _lane_without_thread(cfg, FakeEmbedder(), collected)
+    lane = daemon._speaker_lane
+    assert lane is not None
+    try:
+        inbox = tmp_path / "speakers" / "inbox"
+        inbox.mkdir(parents=True, exist_ok=True)
+        big = inbox / "big.json"
+        big.write_bytes(b"x" * (65536 + 1024))
+        before = len(_speakers_events(collected))
+
+        lane.drain_inbox()
+
+        assert not big.exists()
+        assert (inbox / "rejected" / "big.json").exists()
+        assert len(_speakers_events(collected)) == before
+    finally:
+        daemon.stop()

+ 867 - 0
tests/test_enroll_assist.py

@@ -0,0 +1,867 @@
+"""Tests for seed-based assisted speaker enrollment.
+
+Hardware- and network-free: WAVs are synthesized with stdlib ``wave`` (a DC
+level encodes the "speaker id"), the embedder is a fake, reviews are scripted,
+and playback is a recording stub. The real sherpa-onnx package, questionary,
+and audio players are never required.
+"""
+
+import argparse
+import io
+import shutil
+import subprocess
+import sys
+import wave
+from contextlib import redirect_stderr, redirect_stdout
+from pathlib import Path
+
+import numpy as np
+import pytest
+
+import stt_daemon.cli as cli_module
+import stt_daemon.enroll_assist as ea
+from stt_daemon.config import load_config
+from stt_daemon.enroll import enroll_from_files
+from stt_daemon.enroll_assist import (
+    Candidate,
+    EnrollmentAborted,
+    QuestionaryReview,
+    play_wav,
+    rank_candidates,
+    review_candidates,
+    seed_enroll,
+)
+from stt_daemon.speakers import SpeakerStore
+
+A = 0.30
+B = -0.30
+
+
+def write_const_wav(path: Path, seconds: float, value: float,
+                    sample_rate: int = 16000, nchannels: int = 1) -> Path:
+    """Write a constant-level int16 PCM WAV; the level encodes the speaker."""
+    count = int(seconds * sample_rate)
+    samples = np.full(count, value, dtype=np.float32)
+    frames = (samples * 32767.0).astype(np.int16)
+    if nchannels == 2:
+        frames = np.stack([frames, frames], axis=1).reshape(-1)
+    with wave.open(str(path), "wb") as handle:
+        handle.setnchannels(nchannels)
+        handle.setsampwidth(2)
+        handle.setframerate(sample_rate)
+        handle.writeframes(frames.tobytes())
+    return path
+
+
+class ConstEmbedder:
+    """Deterministic embedding from the clip DC level (the fake speaker id)."""
+
+    def __init__(self, dim: int = 2) -> None:
+        self.dim = dim
+        self.calls = 0
+
+    def embed(self, audio: np.ndarray) -> np.ndarray:
+        self.calls += 1
+        mean = float(np.mean(np.asarray(audio, dtype=np.float32)))
+        if self.dim == 2:
+            if mean > 0.15:
+                vector = [1.0, 0.0]
+            elif mean < -0.15:
+                vector = [0.0, 1.0]
+            else:
+                vector = [0.7071, 0.7071]
+        else:
+            vector = [1.0, 0.0, 0.0] if mean > 0.15 else [0.0, 1.0, 0.0]
+        return np.array(vector, dtype=np.float32)
+
+
+class VariedEmbedder:
+    """Same-speaker clips with distinct-but-similar embeddings.
+
+    Rotates the A-speaker vector 20 degrees per call, so same-speaker clips
+    score high (neighbors at cosine 0.94: above ``min_score``, below the
+    ``dup_threshold`` duplicate floor) instead of collapsing to exact
+    duplicates the way ConstEmbedder does. B-speaker clips map opposite the
+    seed so they are always filtered by ``min_score``. Tracks ``calls`` like
+    ConstEmbedder.
+    """
+
+    def __init__(self, dim: int = 2) -> None:
+        self.dim = dim
+        self.calls = 0
+
+    def embed(self, audio: np.ndarray) -> np.ndarray:
+        self.calls += 1
+        mean = float(np.mean(np.asarray(audio, dtype=np.float32)))
+        if mean < -0.15:
+            return np.array([-1.0, 0.0], dtype=np.float32)
+        angle = np.deg2rad(20.0 * self.calls)
+        vector = np.array([np.cos(angle), np.sin(angle)], dtype=np.float32)
+        return vector / np.linalg.norm(vector)
+
+
+class ScriptReview:
+    """Scripted ClipReview; records every choose() call."""
+
+    def __init__(self, actions) -> None:
+        self.actions = list(actions)
+        self.seen: list = []
+
+    def choose(self, candidate, accepted, target) -> str:
+        self.seen.append((candidate, accepted, target))
+        if not self.actions:
+            return "skip"
+        return self.actions.pop(0)
+
+
+def write_cfg(tmp_path: Path, *, min_enroll_s: float = 2.0,
+              max_prototypes: int = 8, model_ok: bool = True):
+    """Write a temp config; return (cfg, audio_dir, store_dir, model)."""
+    model = tmp_path / "model.onnx"
+    if model_ok:
+        model.write_bytes(b"fake-model")
+    audio_dir = tmp_path / "audio"
+    audio_dir.mkdir(exist_ok=True)
+    store_dir = tmp_path / "speakers"
+    cfg_path = tmp_path / "config.toml"
+    cfg_path.write_text(
+        "[speaker]\n"
+        f'model_path = "{model}"\n'
+        f"min_enroll_s = {min_enroll_s}\n"
+        f"max_prototypes = {max_prototypes}\n"
+        f'store_dir = "{store_dir}"\n'
+        "[output]\n"
+        f'audio_dir = "{audio_dir}"\n'
+        "[events]\n"
+        f'socket = "{tmp_path / "bus.sock"}"\n',
+        encoding="utf-8",
+    )
+    return load_config(str(cfg_path)), audio_dir, store_dir
+
+
+def make_store(cfg) -> SpeakerStore:
+    """Build a SpeakerStore honoring cfg (mirrors the CLI handler)."""
+    speaker_cfg = cfg.speaker
+    return SpeakerStore(
+        Path(speaker_cfg.store_dir),
+        threshold_id=speaker_cfg.threshold_id,
+        threshold_new=speaker_cfg.threshold_new,
+        min_enroll_s=speaker_cfg.min_enroll_s,
+        rms_floor=speaker_cfg.rms_floor,
+        margin=speaker_cfg.margin,
+        mint_cooldown_s=speaker_cfg.mint_cooldown_s,
+        confirm_s=speaker_cfg.confirm_s,
+        max_prototypes=speaker_cfg.max_prototypes,
+        provisional_ttl_h=speaker_cfg.provisional_ttl_h,
+    )
+
+
+def record_enroll(monkeypatch):
+    """Wrap ea.enroll_from_files to record enrolled paths; return the log."""
+    called: list = []
+    real = ea.enroll_from_files
+
+    def fake(store, embedder, name, paths, min_s):
+        called.append(list(paths))
+        return real(store, embedder, name, paths, min_s)
+
+    monkeypatch.setattr(ea, "enroll_from_files", fake)
+    return called
+
+
+def store_files(store_dir: Path) -> bool:
+    return (store_dir / "speakers.json").exists()
+
+
+def fresh_speakers(store_dir: Path) -> dict:
+    store = SpeakerStore(store_dir)
+    store.load()
+    return dict(store.speakers)
+
+
+# -- rank_candidates ------------------------------------------------------------
+
+
+def test_rank_orders_by_score_and_counts_skipped(tmp_path):
+    seed = write_const_wav(tmp_path / "seed.wav", 2.5, A)
+    good_a = write_const_wav(tmp_path / "a.wav", 2.5, A)
+    good_b = write_const_wav(tmp_path / "b.wav", 2.5, B)
+    (tmp_path / "notes.wav").write_text("not audio", encoding="utf-8")
+    write_const_wav(tmp_path / "stereo.wav", 2.5, A, nchannels=2)
+    write_const_wav(tmp_path / "lowrate.wav", 2.5, A, sample_rate=8000)
+    from stt_daemon.enroll import read_wav
+    seed_audio, _ = read_wav(seed)
+
+    ranked, skipped = rank_candidates(
+        ConstEmbedder(), seed_audio,
+        [good_a, good_b, tmp_path / "notes.wav", tmp_path / "stereo.wav",
+         tmp_path / "lowrate.wav", tmp_path / "missing.wav"],
+        min_enroll_s=2.0, min_score=0.55)
+
+    assert [c.path for c in ranked] == [good_a]
+    assert skipped == 4
+    assert ranked[0].score == pytest.approx(1.0)
+    assert ranked[0].duration_s == pytest.approx(2.5)
+
+
+def test_rank_excludes_seed_and_short_clips(tmp_path):
+    seed = write_const_wav(tmp_path / "seed.wav", 2.5, A)
+    short = write_const_wav(tmp_path / "short.wav", 0.5, A)
+    long_a = write_const_wav(tmp_path / "long.wav", 2.5, A)
+    from stt_daemon.enroll import read_wav
+    seed_audio, _ = read_wav(seed)
+
+    ranked, _ = rank_candidates(
+        ConstEmbedder(), seed_audio, [seed, short, long_a],
+        min_enroll_s=2.0, min_score=0.55, exclude=(seed.resolve(),))
+
+    assert [c.path for c in ranked] == [long_a]
+
+
+def test_rank_min_score_floor(tmp_path):
+    seed = write_const_wav(tmp_path / "seed.wav", 2.5, A)
+    mid = write_const_wav(tmp_path / "mid.wav", 2.5, 0.0)
+    other = write_const_wav(tmp_path / "other.wav", 2.5, B)
+    from stt_daemon.enroll import read_wav
+    seed_audio, _ = read_wav(seed)
+
+    ranked, _ = rank_candidates(
+        ConstEmbedder(), seed_audio, [other, mid],
+        min_enroll_s=2.0, min_score=0.55)
+
+    assert [c.path for c in ranked] == [mid]
+    assert ranked[0].score == pytest.approx(0.7071, abs=1e-3)
+
+
+# -- review_candidates ----------------------------------------------------------
+
+
+def test_review_accept_skip_play_quit(tmp_path):
+    a = Candidate(Path("a.wav"), 0.9, 2.5)
+    b = Candidate(Path("b.wav"), 0.8, 2.5)
+    c = Candidate(Path("c.wav"), 0.7, 2.5)
+    played: list = []
+    review = ScriptReview(["accept", "skip", "play", "accept"])
+
+    accepted = review_candidates(
+        [a, b, c], review, 5, play=lambda p: played.append(p))
+
+    assert accepted == [a, c]
+    assert played == [c.path]
+    assert [s[0] for s in review.seen] == [a, b, c, c]
+
+
+def test_review_play_does_not_consume_candidate():
+    a = Candidate(Path("a.wav"), 0.9, 2.5)
+    review = ScriptReview(["play", "play", "accept"])
+
+    accepted = review_candidates([a], review, 1, play=lambda p: None)
+
+    assert accepted == [a]
+    assert len(review.seen) == 3
+
+
+def test_review_quit_returns_accepted_so_far():
+    a = Candidate(Path("a.wav"), 0.9, 2.5)
+    b = Candidate(Path("b.wav"), 0.8, 2.5)
+    review = ScriptReview(["accept", "quit"])
+
+    assert review_candidates([a, b], review, 5, play=lambda p: None) == [a]
+
+
+def test_review_count_stops_accepting():
+    cands = [Candidate(Path(f"{i}.wav"), 0.9, 2.5) for i in range(4)]
+
+    accepted = review_candidates(
+        cands, ScriptReview(["accept"] * 4), 2, play=lambda p: None)
+
+    assert len(accepted) == 2
+
+
+def test_review_rejects_unknown_action():
+    with pytest.raises(ValueError, match="unknown review action"):
+        review_candidates(
+            [Candidate(Path("a.wav"), 0.9, 2.5)], ScriptReview(["dance"]),
+            1, play=lambda p: None)
+
+
+# -- play_wav -------------------------------------------------------------------
+
+
+def test_play_wav_no_players_returns_none(monkeypatch, tmp_path):
+    monkeypatch.setattr(shutil, "which", lambda _name: None)
+
+    assert play_wav(tmp_path / "clip.wav") is None
+
+
+def test_play_wav_uses_first_available(monkeypatch, tmp_path):
+    monkeypatch.setattr(shutil, "which",
+                        lambda name: "/usr/bin/paplay" if name == "paplay" else None)
+    ran: list = []
+    monkeypatch.setattr(subprocess, "run",
+                        lambda *a, **k: ran.append(a[0]) or
+                        subprocess.CompletedProcess(a[0], 0))
+
+    assert play_wav(tmp_path / "clip.wav") == "paplay"
+    assert ran[0][0] == "paplay"
+
+
+# -- seed_enroll ----------------------------------------------------------------
+
+
+def seed_and_clips(audio_dir: Path, *, seed_value=A, seed_s=2.5,
+                   clips=((("a1.wav", A), ("a2.wav", A), ("b1.wav", B)))):
+    seed = write_const_wav(audio_dir / "seed.wav", seed_s, seed_value)
+    paths = [write_const_wav(audio_dir / name, 2.5, value)
+             for name, value in clips]
+    return seed, paths
+
+
+def test_seed_enroll_auto_enrolls_seed_plus_top_count(monkeypatch, tmp_path, capsys):
+    cfg, audio_dir, store_dir = write_cfg(tmp_path)
+    seed, _ = seed_and_clips(audio_dir)
+    enrolled_log = record_enroll(monkeypatch)
+
+    accepted = seed_enroll(cfg, VariedEmbedder(), make_store(cfg), "Efren",
+                           seed, count=2, auto=True)
+
+    assert [c.path.name for c in accepted] == ["a1.wav", "a2.wav"]
+    assert [p.name for p in enrolled_log[0]] == ["seed.wav", "a1.wav", "a2.wav"]
+    assert store_files(store_dir)
+    assert fresh_speakers(store_dir)["spk_0001"]["name"] == "Efren"
+
+
+def test_seed_enroll_short_seed_is_query_only(monkeypatch, tmp_path, capsys):
+    cfg, audio_dir, store_dir = write_cfg(tmp_path)
+    seed, _ = seed_and_clips(audio_dir, seed_s=0.5)
+    enrolled_log = record_enroll(monkeypatch)
+
+    accepted = seed_enroll(cfg, VariedEmbedder(), make_store(cfg), "Efren",
+                           seed, count=2, auto=True)
+
+    assert len(accepted) == 2
+    assert [p.name for p in enrolled_log[0]] == ["a1.wav", "a2.wav"]
+    assert "query only" in capsys.readouterr().out
+
+
+def test_seed_enroll_seed_excluded_from_candidates(tmp_path):
+    cfg, audio_dir, _ = write_cfg(tmp_path)
+    seed, _ = seed_and_clips(audio_dir)
+
+    accepted = seed_enroll(cfg, VariedEmbedder(), make_store(cfg), "Efren",
+                           seed, count=10, auto=True)
+
+    assert seed not in [c.path for c in accepted]
+    assert len(accepted) == 2
+
+
+def test_seed_enroll_fewer_candidates_than_count(monkeypatch, tmp_path):
+    cfg, audio_dir, store_dir = write_cfg(tmp_path)
+    seed, _ = seed_and_clips(audio_dir, clips=(("a1.wav", A),))
+    enrolled_log = record_enroll(monkeypatch)
+
+    accepted = seed_enroll(cfg, VariedEmbedder(), make_store(cfg), "Efren",
+                           seed, count=5, auto=True)
+
+    assert len(accepted) == 1
+    assert len(enrolled_log[0]) == 2
+    assert store_files(store_dir)
+
+
+def test_seed_enroll_all_skipped_writes_nothing(tmp_path, capsys):
+    cfg, audio_dir, store_dir = write_cfg(tmp_path)
+    seed, _ = seed_and_clips(audio_dir)
+
+    accepted = seed_enroll(cfg, ConstEmbedder(), make_store(cfg), "Efren",
+                           seed, review=ScriptReview(["skip"] * 3))
+
+    assert accepted == []
+    assert not store_files(store_dir)
+    assert "nothing enrolled" in capsys.readouterr().out
+
+
+def test_seed_enroll_empty_dir_writes_nothing(tmp_path, capsys):
+    cfg, audio_dir, store_dir = write_cfg(tmp_path)
+    seed = write_const_wav(tmp_path / "seed.wav", 2.5, A)
+
+    accepted = seed_enroll(cfg, ConstEmbedder(), make_store(cfg), "Efren",
+                           seed, count=3, auto=True)
+
+    assert accepted == []
+    assert not store_files(store_dir)
+    assert "nothing enrolled" in capsys.readouterr().out
+
+
+def test_seed_enroll_quit_aborts_without_writing(tmp_path, capsys):
+    cfg, audio_dir, store_dir = write_cfg(tmp_path)
+    seed, _ = seed_and_clips(audio_dir)
+
+    with pytest.raises(EnrollmentAborted):
+        seed_enroll(cfg, ConstEmbedder(), make_store(cfg), "Efren", seed,
+                    review=ScriptReview(["accept", "quit"]))
+
+    assert not store_files(store_dir)
+    out = capsys.readouterr().out
+    assert "nothing enrolled" in out
+    assert "speaker-enroll" in out
+
+
+def test_seed_enroll_keyboard_interrupt_aborts_without_writing(tmp_path):
+    cfg, audio_dir, store_dir = write_cfg(tmp_path)
+    seed, _ = seed_and_clips(audio_dir)
+
+    class ExplodingReview:
+        def choose(self, candidate, accepted, target):
+            raise KeyboardInterrupt
+
+    with pytest.raises(EnrollmentAborted):
+        seed_enroll(cfg, ConstEmbedder(), make_store(cfg), "Efren", seed,
+                    review=ExplodingReview())
+
+    assert not store_files(store_dir)
+
+
+def test_seed_enroll_dim_mismatch_errors_before_writing(tmp_path):
+    cfg, audio_dir, store_dir = write_cfg(tmp_path)
+    seed, _ = seed_and_clips(audio_dir)
+    old = make_store(cfg)
+    enroll_from_files(old, ConstEmbedder(dim=3), "Efren", [seed], 2.0)
+    store = make_store(cfg)
+    store.load()
+
+    with pytest.raises(ValueError, match="different model"):
+        seed_enroll(cfg, ConstEmbedder(dim=2), store, "Efren", seed, auto=True)
+
+    assert fresh_speakers(store_dir)["spk_0001"]["prototypes"] == 1
+
+
+def test_seed_enroll_max_prototypes_warns_and_evicts(tmp_path, capsys):
+    cfg, audio_dir, store_dir = write_cfg(tmp_path, max_prototypes=2)
+    seed, _ = seed_and_clips(audio_dir)
+
+    seed_enroll(cfg, VariedEmbedder(), make_store(cfg), "Efren", seed,
+                count=2, auto=True)
+
+    assert "evict" in capsys.readouterr().out
+    assert fresh_speakers(store_dir)["spk_0001"]["prototypes"] == 2
+
+
+def test_seed_enroll_max_scan_caps_embedding(tmp_path, capsys):
+    cfg, audio_dir, _ = write_cfg(tmp_path)
+    seed = write_const_wav(audio_dir / "seed.wav", 2.5, A)
+    for i in range(5):
+        write_const_wav(audio_dir / f"clip{i}.wav", 2.5, A)
+    embedder = VariedEmbedder()
+
+    accepted = seed_enroll(cfg, embedder, make_store(cfg), "Efren", seed,
+                           count=5, max_scan=2, auto=True)
+
+    assert len(accepted) == 2
+    # seed embed (dim check) + seed embed (rank) + 2 candidates + enroll of 3
+    assert embedder.calls == 7
+    assert "max-scan 2" in capsys.readouterr().out
+
+
+# -- CLI handler ----------------------------------------------------------------
+
+
+def run_seed_handler(monkeypatch, tmp_path, *, name="Efren", seed=None,
+                     count=5, min_score=0.55, audio_dir=None, max_scan=0,
+                     yes=False, no_play=False, force=False, model_ok=True,
+                     tty=True, review=None, min_enroll_s=2.0,
+                     max_prototypes=8, dim=2, pre_enroll_dim=None,
+                     embedder_cls: type = ConstEmbedder):
+    """Drive ``_handle_speaker_enroll_seed`` with fakes; return (rc, out, err)."""
+    cfg, default_audio, store_dir = write_cfg(
+        tmp_path, min_enroll_s=min_enroll_s, max_prototypes=max_prototypes,
+        model_ok=model_ok)
+    if audio_dir is None:
+        audio_dir = default_audio
+    if seed is None:
+        seed = write_const_wav(Path(audio_dir) / "seed.wav", 2.5, A)
+    if pre_enroll_dim is not None:
+        old = SpeakerStore(store_dir)
+        enroll_from_files(old, ConstEmbedder(dim=pre_enroll_dim), name,
+                          [seed], min_enroll_s)
+    fake = embedder_cls(dim=dim)
+
+    class FakeSherpa:
+        def __init__(self, model_path, provider="cpu"):
+            self._embedder = fake
+
+        def embed(self, audio):
+            return fake.embed(audio)
+
+    monkeypatch.setattr(cli_module, "SherpaEmbedder", FakeSherpa)
+    if review is not None:
+        monkeypatch.setattr(cli_module, "QuestionaryReview", lambda **k: review)
+    if tty:
+        monkeypatch.setattr(sys, "stdin", _TtyStdin())
+    else:
+        monkeypatch.setattr(sys, "stdin", io.StringIO())
+    args = argparse.Namespace(
+        config=str(tmp_path / "config.toml"), name=name, seed=str(seed),
+        count=count, min_score=min_score, audio_dir=str(audio_dir),
+        max_scan=max_scan, yes=yes, no_play=no_play, force=force,
+    )
+    out, err = io.StringIO(), io.StringIO()
+    with redirect_stdout(out), redirect_stderr(err):
+        rc = cli_module._handle_speaker_enroll_seed(args)
+    return rc, out.getvalue(), err.getvalue()
+
+
+class _TtyStdin(io.StringIO):
+    def isatty(self):
+        return True
+
+
+def _audio_with_clips(tmp_path: Path, sub="audio") -> Path:
+    audio_dir = tmp_path / sub
+    audio_dir.mkdir(exist_ok=True)
+    write_const_wav(audio_dir / "seed.wav", 2.5, A)
+    write_const_wav(audio_dir / "a1.wav", 2.5, A)
+    write_const_wav(audio_dir / "a2.wav", 2.5, A)
+    write_const_wav(audio_dir / "b1.wav", 2.5, B)
+    return audio_dir
+
+
+def test_cli_auto_success(monkeypatch, tmp_path):
+    audio_dir = _audio_with_clips(tmp_path)
+
+    rc, out, _ = run_seed_handler(monkeypatch, tmp_path, audio_dir=audio_dir,
+                                  count=2, yes=True,
+                                  embedder_cls=VariedEmbedder)
+
+    assert rc == 0
+    assert "enrolled 'Efren' as spk_0001 from seed" in out
+    assert "3 clip(s) (seed + 2 reviewed" in out
+
+
+def test_cli_short_seed_query_only(monkeypatch, tmp_path):
+    audio_dir = tmp_path / "audio"
+    audio_dir.mkdir(exist_ok=True)
+    seed = write_const_wav(audio_dir / "seed.wav", 0.5, A)
+    write_const_wav(audio_dir / "a1.wav", 2.5, A)
+
+    rc, out, _ = run_seed_handler(monkeypatch, tmp_path, audio_dir=audio_dir,
+                                  seed=seed, count=1, yes=True)
+
+    assert rc == 0
+    assert "query-only" in out
+
+
+def test_cli_non_tty_without_yes_errors(monkeypatch, tmp_path):
+    audio_dir = _audio_with_clips(tmp_path)
+
+    rc, _, err = run_seed_handler(monkeypatch, tmp_path, audio_dir=audio_dir,
+                                  tty=False, yes=False)
+
+    assert rc == 1
+    assert "--yes" in err
+
+
+def test_cli_non_tty_with_yes_auto_accepts(monkeypatch, tmp_path):
+    audio_dir = _audio_with_clips(tmp_path)
+
+    rc, out, _ = run_seed_handler(monkeypatch, tmp_path, audio_dir=audio_dir,
+                                  tty=False, yes=True, count=1,
+                                  embedder_cls=VariedEmbedder)
+
+    assert rc == 0
+    assert "from seed" in out
+
+
+def test_cli_daemon_guard_and_force(monkeypatch, tmp_path):
+    audio_dir = _audio_with_clips(tmp_path)
+    (tmp_path / "bus.sock").write_text("", encoding="utf-8")
+
+    rc, _, err = run_seed_handler(monkeypatch, tmp_path, audio_dir=audio_dir,
+                                  yes=True)
+    assert rc == 1
+    assert "daemon" in err.lower()
+
+    rc, _, _ = run_seed_handler(monkeypatch, tmp_path, audio_dir=audio_dir,
+                                yes=True, force=True,
+                                embedder_cls=VariedEmbedder)
+    assert rc == 0
+
+
+def test_cli_missing_model(monkeypatch, tmp_path):
+    audio_dir = _audio_with_clips(tmp_path)
+
+    rc, _, err = run_seed_handler(monkeypatch, tmp_path, audio_dir=audio_dir,
+                                  yes=True, model_ok=False)
+
+    assert rc == 1
+    assert "model" in err.lower()
+
+
+def test_cli_dim_mismatch_no_write(monkeypatch, tmp_path):
+    audio_dir = _audio_with_clips(tmp_path)
+    seed = audio_dir / "seed.wav"
+
+    rc, _, err = run_seed_handler(monkeypatch, tmp_path, audio_dir=audio_dir,
+                                  seed=seed, yes=True, dim=2, pre_enroll_dim=3)
+
+    assert rc == 1
+    assert "different model" in err
+    assert fresh_speakers(tmp_path / "speakers")["spk_0001"]["prototypes"] == 1
+
+
+def test_cli_quit_no_write(monkeypatch, tmp_path):
+    audio_dir = _audio_with_clips(tmp_path)
+
+    rc, out, _ = run_seed_handler(
+        monkeypatch, tmp_path, audio_dir=audio_dir,
+        review=ScriptReview(["accept", "quit"]))
+
+    assert rc == 1
+    assert not (tmp_path / "speakers" / "speakers.json").exists()
+    assert "speaker-enroll" in out
+
+
+def test_cli_no_play_disables_playback(monkeypatch, tmp_path):
+    audio_dir = _audio_with_clips(tmp_path)
+    seen: dict = {}
+
+    class SpyReviewFactory:
+        def __init__(self, **kwargs):
+            seen.update(kwargs)
+
+        def choose(self, candidate, accepted, target):
+            return "accept" if accepted < 1 else "quit"
+
+    monkeypatch.setattr(cli_module, "QuestionaryReview", SpyReviewFactory)
+    monkeypatch.setattr(ea, "play_wav", lambda p: (_ for _ in ()).throw(
+        AssertionError("playback attempted")))
+
+    rc, _, _ = run_seed_handler(monkeypatch, tmp_path, audio_dir=audio_dir,
+                                count=2, no_play=True)
+
+    assert seen.get("allow_play") is False
+    assert rc == 1
+
+
+def test_cli_no_candidates_no_write(monkeypatch, tmp_path):
+    audio_dir = tmp_path / "audio"
+    audio_dir.mkdir(exist_ok=True)
+    seed = write_const_wav(audio_dir / "seed.wav", 2.5, A)
+
+    rc, out, _ = run_seed_handler(monkeypatch, tmp_path, audio_dir=audio_dir,
+                                  seed=seed, yes=True)
+
+    assert rc == 1
+    assert "nothing enrolled" in out
+    assert not (tmp_path / "speakers" / "speakers.json").exists()
+
+
+def test_questionary_review_defaults():
+    import dataclasses
+
+    review = QuestionaryReview()
+
+    assert review.allow_play is True
+    candidate = Candidate(Path("x.wav"), 0.5, 1.0)
+    with pytest.raises(dataclasses.FrozenInstanceError):
+        candidate.score = 0.1  # type: ignore[misc]
+
+
+# -- duplicate protection -------------------------------------------------------
+
+
+def _store_with(root: Path, profiles) -> SpeakerStore:
+    store = SpeakerStore(root)
+    for name, vecs in profiles:
+        store.enroll(name, [np.array(v, dtype=np.float32) for v in vecs])
+    return store
+
+
+def test_candidate_equality_ignores_embedding_and_flags():
+    first = Candidate(Path("a.wav"), 0.9, 2.5,
+                      embedding=np.array([1.0, 0.0], dtype=np.float32),
+                      flags=("duplicate",))
+    second = Candidate(Path("a.wav"), 0.9, 2.5,
+                       embedding=np.array([0.0, 1.0], dtype=np.float32),
+                       flags=())
+
+    assert first == second
+    assert hash(first) == hash(second)
+    assert first != Candidate(Path("b.wav"), 0.9, 2.5)
+
+
+def test_rank_sets_unit_embeddings_without_store(tmp_path):
+    seed = write_const_wav(tmp_path / "seed.wav", 2.5, A)
+    clip = write_const_wav(tmp_path / "a.wav", 2.5, A)
+    from stt_daemon.enroll import read_wav
+    seed_audio, _ = read_wav(seed)
+
+    ranked, _ = rank_candidates(ConstEmbedder(), seed_audio, [clip],
+                                min_enroll_s=2.0, min_score=0.55)
+
+    assert len(ranked) == 1
+    embedding = ranked[0].embedding
+    assert embedding is not None
+    assert embedding.dtype == np.float32
+    assert float(np.linalg.norm(embedding)) == pytest.approx(1.0)
+    assert ranked[0].flags == ()
+
+
+def test_rank_flags_duplicate_against_target(tmp_path):
+    seed = write_const_wav(tmp_path / "seed.wav", 2.5, A)
+    clip = write_const_wav(tmp_path / "a.wav", 2.5, A)
+    from stt_daemon.enroll import read_wav
+    seed_audio, _ = read_wav(seed)
+    store = _store_with(tmp_path / "store", [("Efren", [[1.0, 0.0]])])
+
+    ranked, _ = rank_candidates(ConstEmbedder(), seed_audio, [clip],
+                                min_enroll_s=2.0, min_score=0.55,
+                                store=store, speaker_name="Efren")
+
+    assert ranked[0].flags == ("duplicate",)
+
+
+def test_rank_flags_best_other_speaker_by_display_name(tmp_path):
+    seed = write_const_wav(tmp_path / "seed.wav", 2.5, A)
+    clip = write_const_wav(tmp_path / "b.wav", 2.5, B)
+    from stt_daemon.enroll import read_wav
+    seed_audio, _ = read_wav(seed)
+    store = _store_with(tmp_path / "store", [("Efren", [[1.0, 0.0]]),
+                                             ("Rival", [[0.0, 1.0]]),
+                                             ("Mid", [[0.7071, 0.7071]])])
+
+    ranked, _ = rank_candidates(ConstEmbedder(), seed_audio, [clip],
+                                min_enroll_s=2.0, min_score=-1.0,
+                                store=store, speaker_name="Efren")
+
+    assert ranked[0].flags == ("other-speaker:Rival",)
+
+
+def test_rank_no_flags_without_store_or_name(tmp_path):
+    seed = write_const_wav(tmp_path / "seed.wav", 2.5, A)
+    clip = write_const_wav(tmp_path / "a.wav", 2.5, A)
+    from stt_daemon.enroll import read_wav
+    seed_audio, _ = read_wav(seed)
+    store = _store_with(tmp_path / "store", [("Efren", [[1.0, 0.0]])])
+
+    ranked, _ = rank_candidates(ConstEmbedder(), seed_audio, [clip],
+                                min_enroll_s=2.0, min_score=0.55,
+                                store=store)
+
+    assert ranked[0].flags == ()
+
+
+def test_review_refuses_duplicate_accept_without_consuming_budget(capsys):
+    dup = Candidate(Path("dup.wav"), 0.99, 2.5, flags=("duplicate",))
+    good = Candidate(Path("good.wav"), 0.9, 2.5)
+    review = ScriptReview(["accept", "accept"])
+
+    accepted = review_candidates([dup, good], review, 1, play=lambda p: None)
+
+    assert accepted == [good]
+    out = capsys.readouterr().out
+    assert "dup.wav" in out
+    assert "already covered" in out
+
+
+def test_seed_enroll_auto_dedupes_incoming_set(monkeypatch, tmp_path, capsys):
+    cfg, audio_dir, _ = write_cfg(tmp_path)
+    seed, _ = seed_and_clips(audio_dir)
+    enrolled_log = record_enroll(monkeypatch)
+
+    accepted = seed_enroll(cfg, ConstEmbedder(), make_store(cfg), "Efren",
+                           seed, count=2, auto=True)
+
+    assert accepted == []
+    assert [p.name for p in enrolled_log[0]] == ["seed.wav"]
+    assert "duplicate" in capsys.readouterr().out
+
+
+def test_seed_enroll_review_path_dedupes_incoming_set(monkeypatch, tmp_path, capsys):
+    cfg, audio_dir, _ = write_cfg(tmp_path)
+    seed, _ = seed_and_clips(audio_dir)
+    enrolled_log = record_enroll(monkeypatch)
+
+    accepted = seed_enroll(cfg, ConstEmbedder(), make_store(cfg), "Efren",
+                           seed, review=ScriptReview(["accept", "accept"]))
+
+    assert accepted == []
+    assert [p.name for p in enrolled_log[0]] == ["seed.wav"]
+    assert "duplicate" in capsys.readouterr().out
+
+
+def test_seed_enroll_review_vetoes_store_duplicate(tmp_path, capsys):
+    cfg, audio_dir, store_dir = write_cfg(tmp_path)
+    seed, _ = seed_and_clips(audio_dir)
+    store = make_store(cfg)
+    enroll_from_files(store, ConstEmbedder(), "Efren", [seed], 2.0)
+
+    accepted = seed_enroll(cfg, ConstEmbedder(), store, "Efren", seed,
+                           review=ScriptReview(["accept"] * 5))
+
+    assert accepted == []
+    out = capsys.readouterr().out
+    assert "already covered" in out
+    assert "nothing enrolled" in out
+    assert fresh_speakers(store_dir)["spk_0001"]["prototypes"] == 1
+
+
+def test_seed_enroll_removes_store_duplicates_and_saves(tmp_path, capsys):
+    cfg, audio_dir, store_dir = write_cfg(tmp_path)
+    seed, _ = seed_and_clips(audio_dir)
+    store = make_store(cfg)
+    enroll_from_files(store, ConstEmbedder(), "Efren",
+                      [seed, audio_dir / "a1.wav"], 2.0)
+    saves: list = []
+    real_save = store.save
+
+    def counting_save():
+        saves.append(1)
+        return real_save()
+
+    store.save = counting_save  # type: ignore[method-assign]
+
+    accepted = seed_enroll(cfg, VariedEmbedder(), store, "Efren", seed,
+                           count=2, auto=True)
+
+    assert [c.path.name for c in accepted] == ["a1.wav", "a2.wav"]
+    out = capsys.readouterr().out
+    assert "removed 1 duplicate" in out
+    assert len(saves) == 2  # enroll_from_files save + post-dedupe save
+    assert (store_dir / "speakers.json").exists()
+    assert fresh_speakers(store_dir)["spk_0001"]["prototypes"] == 4
+
+
+def test_cli_forwards_dedupe_and_other_thresholds(monkeypatch, tmp_path):
+    audio_dir = _audio_with_clips(tmp_path)
+    seed = audio_dir / "seed.wav"
+    write_cfg(tmp_path)
+    seen: dict = {}
+
+    def fake_seed_enroll(cfg, embedder, store, name, seed_path, **kwargs):
+        seen.update(kwargs)
+        return [Candidate(audio_dir / "a1.wav", 0.99, 2.5)]
+
+    monkeypatch.setattr(cli_module, "seed_enroll", fake_seed_enroll)
+    fake = ConstEmbedder()
+
+    class FakeSherpa:
+        def __init__(self, model_path, provider="cpu"):
+            pass
+
+        def embed(self, audio):
+            return fake.embed(audio)
+
+    monkeypatch.setattr(cli_module, "SherpaEmbedder", FakeSherpa)
+    args = argparse.Namespace(
+        config=str(tmp_path / "config.toml"), name="Efren", seed=str(seed),
+        count=2, min_score=0.55, audio_dir=str(audio_dir),
+        max_scan=0, yes=True, no_play=True, force=True,
+        dedupe_threshold=0.97, other_threshold=0.44,
+    )
+    out, err = io.StringIO(), io.StringIO()
+    with redirect_stdout(out), redirect_stderr(err):
+        rc = cli_module._handle_speaker_enroll_seed(args)
+
+    assert rc == 0
+    assert seen.get("dup_threshold") == pytest.approx(0.97)
+    assert seen.get("other_threshold") == pytest.approx(0.44)

+ 214 - 0
tests/test_evaluate.py

@@ -0,0 +1,214 @@
+"""Hardware/network-free tests for stt_daemon.evaluate."""
+
+import wave
+
+import numpy as np
+
+from stt_daemon.evaluate import EvalResult, accuracy, evaluate, format_report
+from stt_daemon.speakers import SpeakerStore
+
+_SAMPLE_RATE = 16000
+
+ALICE = np.array([1.0, 0.0, 0.0], dtype=np.float32)
+BOB = np.array([0.0, 1.0, 0.0], dtype=np.float32)
+# Cosine 0.5 to ALICE, 0.0 to BOB -> uncertain with top-1 alice (default thresholds).
+ALICE_UNCERTAIN = np.array([0.5, 0.0, 0.8660254], dtype=np.float32)
+UNKNOWN_VEC = np.array([0.0, 0.0, 1.0], dtype=np.float32)
+
+MARKERS = {
+    1001: ALICE,
+    1002: BOB,
+    1003: ALICE_UNCERTAIN,
+    1004: UNKNOWN_VEC,
+}
+
+
+class FakeEmbedder:
+    """Return a controlled unit vector keyed by the first int16 sample."""
+
+    def __init__(self, mapping=None):
+        self.mapping = dict(MARKERS if mapping is None else mapping)
+
+    def embed(self, audio):
+        marker = int(round(float(np.asarray(audio).reshape(-1)[0]) * 32768.0))
+        return np.array(self.mapping[marker], dtype=np.float32)
+
+
+def _write_wav(path, marker, nframes=1600):
+    path.parent.mkdir(parents=True, exist_ok=True)
+    frames = np.zeros(nframes, dtype=np.int16)
+    frames[0] = np.int16(marker)
+    with wave.open(str(path), "wb") as wav:
+        wav.setnchannels(1)
+        wav.setsampwidth(2)
+        wav.setframerate(_SAMPLE_RATE)
+        wav.writeframes(frames.tobytes())
+
+
+def _write_wav_rate(path, marker, framerate):
+    path.parent.mkdir(parents=True, exist_ok=True)
+    frames = np.full(1600, marker, dtype=np.int16)
+    with wave.open(str(path), "wb") as wav:
+        wav.setnchannels(1)
+        wav.setsampwidth(2)
+        wav.setframerate(framerate)
+        wav.writeframes(frames.tobytes())
+
+
+def _write_stereo_wav(path):
+    path.parent.mkdir(parents=True, exist_ok=True)
+    frames = np.zeros((1600, 2), dtype=np.int16)
+    with wave.open(str(path), "wb") as wav:
+        wav.setnchannels(2)
+        wav.setsampwidth(2)
+        wav.setframerate(_SAMPLE_RATE)
+        wav.writeframes(frames.tobytes())
+
+
+def _make_store(root):
+    store = SpeakerStore(root)
+    store.enroll("alice", [ALICE])
+    store.enroll("bob", [BOB])
+    return store
+
+
+def test_all_correct_accuracy_one(tmp_path):
+    store = _make_store(tmp_path / "store")
+    labeled = tmp_path / "labeled"
+    _write_wav(labeled / "alice" / "a.wav", 1001)
+    _write_wav(labeled / "bob" / "b.wav", 1002)
+    result = evaluate(store, FakeEmbedder(), labeled)
+    assert result.total == 2
+    assert result.correct == 2
+    assert result.wrong == 0
+    assert result.unknown == 0
+    assert accuracy(result) == 1.0
+    assert result.per_speaker == {"alice": (1, 1), "bob": (1, 1)}
+    assert result.confusions == {}
+    assert len(result.margins) == 2
+    assert result.labeled == (("matched", "alice/a.wav"), ("matched", "bob/b.wav"))
+
+
+def test_wrong_match_populates_confusions(tmp_path):
+    store = _make_store(tmp_path / "store")
+    labeled = tmp_path / "labeled"
+    _write_wav(labeled / "alice" / "a.wav", 1002)  # bob's voice under alice/
+    result = evaluate(store, FakeEmbedder(), labeled)
+    assert result.total == 1
+    assert result.correct == 0
+    assert result.wrong == 1
+    assert result.confusions == {("alice", "bob"): 1}
+    assert result.per_speaker == {"alice": (1, 0)}
+    assert accuracy(result) == 0.0
+
+
+def test_uncertain_top1_correct(tmp_path):
+    store = _make_store(tmp_path / "store")
+    labeled = tmp_path / "labeled"
+    _write_wav(labeled / "alice" / "u.wav", 1003)
+    result = evaluate(store, FakeEmbedder(), labeled)
+    assert result.total == 1
+    assert result.uncertain_correct == 1
+    assert result.uncertain_wrong == 0
+    assert result.correct == 0 and result.wrong == 0 and result.unknown == 0
+    assert result.labeled[0][0] == "uncertain"
+    assert len(result.margins) == 1
+
+
+def test_uncertain_top1_wrong_and_unknown(tmp_path):
+    store = _make_store(tmp_path / "store")
+    labeled = tmp_path / "labeled"
+    # Uncertain vector closest to alice but filed under bob -> uncertain_wrong.
+    _write_wav(labeled / "bob" / "u.wav", 1003)
+    _write_wav(labeled / "alice" / "x.wav", 1004)
+    result = evaluate(store, FakeEmbedder(), labeled)
+    assert result.uncertain_wrong == 1
+    assert result.unknown == 1
+    assert result.total == 2
+
+
+def test_unreadable_and_bad_format_skipped(tmp_path):
+    store = _make_store(tmp_path / "store")
+    labeled = tmp_path / "labeled"
+    _write_wav(labeled / "alice" / "good.wav", 1001)
+    (labeled / "alice" / "bad.wav").parent.mkdir(parents=True, exist_ok=True)
+    (labeled / "alice" / "bad.wav").write_bytes(b"not a wav at all")
+    _write_wav_rate(labeled / "alice" / "rate8k.wav", 1001, 8000)
+    _write_stereo_wav(labeled / "alice" / "stereo.wav")
+    result = evaluate(store, FakeEmbedder(), labeled)
+    assert result.total == 1
+    assert result.correct == 1
+    assert result.labeled == (("matched", "alice/good.wav"),)
+
+
+def test_unknown_speaker_directory_still_evaluated(tmp_path):
+    store = _make_store(tmp_path / "store")
+    labeled = tmp_path / "labeled"
+    _write_wav(labeled / "carol" / "c.wav", 1001)  # matches alice, true name carol
+    result = evaluate(store, FakeEmbedder(), labeled)
+    assert result.total == 1
+    assert result.wrong == 1
+    assert result.confusions == {("carol", "alice"): 1}
+    assert result.per_speaker == {"carol": (1, 0)}
+
+
+def test_empty_and_missing_directory(tmp_path):
+    store = _make_store(tmp_path / "store")
+    empty = tmp_path / "empty"
+    empty.mkdir()
+    result = evaluate(store, FakeEmbedder(), empty)
+    assert result.total == 0
+    assert accuracy(result) == 0.0
+    assert result.margins == ()
+    assert result.labeled == ()
+    missing = tmp_path / "does-not-exist"
+    result2 = evaluate(store, FakeEmbedder(), missing)
+    assert result2.total == 0
+    assert accuracy(result2) == 0.0
+
+
+def test_format_report_contents_and_order(tmp_path):
+    result = EvalResult(
+        total=3,
+        correct=1,
+        wrong=1,
+        uncertain_correct=1,
+        uncertain_wrong=0,
+        unknown=0,
+        per_speaker={"bob": (2, 1), "alice": (1, 0)},
+        confusions={("alice", "bob"): 1},
+        margins=(0.5, 1.0),
+        labeled=(("matched", "alice/a.wav"),),
+    )
+    report = format_report(result)
+    assert "total: 3" in report
+    assert "accuracy: 0.333" in report
+    assert "correct: 1" in report
+    assert "wrong: 1" in report
+    assert "uncertain (top-1 correct/total): 1/1" in report
+    assert "unknown: 0" in report
+    assert "alice | 1 | 0 | 0.000" in report
+    assert "bob | 2 | 1 | 0.500" in report
+    assert report.index("alice |") < report.index("bob |")
+    assert "alice -> bob  (1)" in report
+    assert "count: 2" in report
+    assert "mean: 0.7500" in report
+    assert "p10:" in report
+    # Empty margins guard.
+    empty_report = format_report(
+        EvalResult(0, 0, 0, 0, 0, 0, {}, {}, (), ()),
+    )
+    assert "count: 0" in empty_report
+
+
+def test_margins_nonempty_and_store_untouched(tmp_path):
+    store = _make_store(tmp_path / "store")
+    before_speakers = store.speakers
+    before_vectors = np.array(store._vectors, copy=True)
+    labeled = tmp_path / "labeled"
+    _write_wav(labeled / "alice" / "a.wav", 1001)
+    result = evaluate(store, FakeEmbedder(), labeled)
+    assert len(result.margins) == 1
+    assert result.margins[0] > 0.0
+    assert store.speakers == before_speakers
+    assert np.array_equal(np.asarray(store._vectors), before_vectors)

+ 6 - 0
tests/test_events.py

@@ -73,4 +73,10 @@ def test_all_types_is_complete():
         events.HEARTBEAT,
         events.STATUS,
         events.RING_END,
+        events.SPEAKERS,
     }
+
+
+def test_speakers_event_constant():
+    assert events.SPEAKERS == "speakers"
+    assert events.SPEAKERS in events.ALL_TYPES

+ 42 - 0
tests/test_output.py

@@ -206,3 +206,45 @@ def test_failed_serialization_leaves_jsonl_untouched(tmp_path):
     assert path.read_text() == before
 
 
+def test_jsonl_includes_speaker_fields(tmp_path):
+    sink = TranscriptSink(stdout=False, jsonl_dir=str(tmp_path), jsonl=True)
+    sink.emit(
+        _result(),
+        3.2,
+        rms=0.031,
+        reason="pause",
+        seg_id=142,
+        speaker_id="spk_0001",
+        speaker_status="matched",
+        speaker="Efren",
+        speaker_score=0.712345,
+    )
+
+    record = json.loads(next(tmp_path.glob("*.jsonl")).read_text().splitlines()[0])
+    assert record["seg_id"] == 142
+    assert record["speaker_id"] == "spk_0001"
+    assert record["speaker_status"] == "matched"
+    assert record["speaker"] == "Efren"
+    assert record["speaker_score"] == round(0.712345, 4)
+
+
+def test_jsonl_speaker_fields_default_to_nulls(tmp_path):
+    sink = TranscriptSink(stdout=False, jsonl_dir=str(tmp_path), jsonl=True)
+    sink.emit(_result(), 2.5, rms=0.04, reason="pause")
+
+    record = json.loads(next(tmp_path.glob("*.jsonl")).read_text().splitlines()[0])
+    assert record["seg_id"] is None
+    assert record["speaker_id"] is None
+    assert record["speaker_status"] == "none"
+    assert record["speaker"] is None
+    assert record["speaker_score"] is None
+
+
+def test_jsonl_speaker_score_rounded_to_4dp(tmp_path):
+    sink = TranscriptSink(stdout=False, jsonl_dir=str(tmp_path), jsonl=True)
+    sink.emit(_result(), 1.0, rms=0.02, reason="pause", speaker_score=0.123456)
+
+    record = json.loads(next(tmp_path.glob("*.jsonl")).read_text().splitlines()[0])
+    assert record["speaker_score"] == round(0.123456, 4)
+
+

+ 241 - 0
tests/test_speaker_enroll.py

@@ -0,0 +1,241 @@
+"""Tests for offline speaker pre-enrollment (WAV reading + CLI handler).
+
+Hardware- and network-free: WAVs are synthesized with stdlib ``wave`` and the
+embedder is a fake; the real sherpa-onnx package is never required.
+"""
+
+import argparse
+import wave
+from pathlib import Path
+
+import numpy as np
+import pytest
+
+import stt_daemon.cli as cli_module
+from stt_daemon.enroll import enroll_from_files, read_wav
+from stt_daemon.speakers import SpeakerStore
+
+
+def write_mono_wav(path: Path, samples: np.ndarray, sample_rate: int = 16000,
+                   nchannels: int = 1) -> None:
+    """Write int16 PCM WAV frames from float32 samples in [-1, 1]."""
+    frames = (np.asarray(samples, dtype=np.float32) * 32767.0).astype(np.int16)
+    if nchannels == 2:
+        frames = np.stack([frames, frames], axis=1).reshape(-1)
+    with wave.open(str(path), "wb") as wav:
+        wav.setnchannels(nchannels)
+        wav.setsampwidth(2)
+        wav.setframerate(sample_rate)
+        wav.writeframes(frames.tobytes())
+
+
+def make_wav(path: Path, seconds: float, sample_rate: int = 16000,
+             nchannels: int = 1) -> Path:
+    """Write a sine clip of ``seconds`` and return the path."""
+    count = int(seconds * sample_rate)
+    t = np.arange(count, dtype=np.float32) / sample_rate
+    samples = 0.5 * np.sin(2.0 * np.pi * 440.0 * t).astype(np.float32)
+    write_mono_wav(path, samples, sample_rate, nchannels)
+    return path
+
+
+class FakeEmbedder:
+    """Deterministic unit-norm embedding from audio energy."""
+
+    def __init__(self) -> None:
+        self.calls = 0
+
+    def embed(self, audio: np.ndarray) -> np.ndarray:
+        self.calls += 1
+        energy = float(np.mean(np.asarray(audio, dtype=np.float32) ** 2))
+        vector = np.array([energy + self.calls, 1.0], dtype=np.float32)
+        return vector / np.linalg.norm(vector)
+
+
+# -- read_wav -----------------------------------------------------------------
+
+
+def test_read_wav_happy_path(tmp_path):
+    wav = make_wav(tmp_path / "clip.wav", seconds=1.0)
+
+    samples, rate = read_wav(wav)
+
+    assert rate == 16000
+    assert samples.dtype == np.float32
+    assert len(samples) == 16000
+    assert float(np.abs(samples).max()) <= 1.0
+
+
+def test_read_wav_rejects_stereo(tmp_path):
+    wav = make_wav(tmp_path / "stereo.wav", seconds=1.0, nchannels=2)
+
+    with pytest.raises(ValueError, match="mono"):
+        read_wav(wav)
+
+
+def test_read_wav_rejects_non_16k(tmp_path):
+    wav = make_wav(tmp_path / "clip8k.wav", seconds=1.0, sample_rate=8000)
+
+    with pytest.raises(ValueError, match="16 kHz"):
+        read_wav(wav)
+
+
+def test_read_wav_rejects_non_wav(tmp_path):
+    bogus = tmp_path / "notes.txt"
+    bogus.write_text("not audio", encoding="utf-8")
+
+    with pytest.raises(ValueError, match="not a WAV"):
+        read_wav(bogus)
+
+
+def test_read_wav_rejects_unsupported_width(tmp_path):
+    wav = tmp_path / "clip8bit.wav"
+    with wave.open(str(wav), "wb") as handle:
+        handle.setnchannels(1)
+        handle.setsampwidth(1)
+        handle.setframerate(16000)
+        handle.writeframes(bytes([128] * 16000))
+
+    with pytest.raises(ValueError, match="sample width"):
+        read_wav(wav)
+
+
+# -- enroll_from_files ----------------------------------------------------------
+
+
+def test_enroll_from_files_enrolls_and_saves(tmp_path):
+    wav = make_wav(tmp_path / "clip.wav", seconds=2.5)
+    store = SpeakerStore(tmp_path / "store")
+    embedder = FakeEmbedder()
+
+    speaker_id = enroll_from_files(store, embedder, "Efren", [wav], 2.0)
+
+    assert speaker_id == "spk_0001"
+    assert embedder.calls == 1
+    assert (tmp_path / "store" / "speakers.json").exists()
+    fresh = SpeakerStore(tmp_path / "store")
+    fresh.load()
+    assert fresh.speakers[speaker_id]["name"] == "Efren"
+    assert fresh.speakers[speaker_id]["status"] == "confirmed"
+
+
+def test_enroll_from_files_rejects_short_clip(tmp_path):
+    wav = make_wav(tmp_path / "short.wav", seconds=0.5)
+    store = SpeakerStore(tmp_path / "store")
+
+    with pytest.raises(ValueError, match="too short"):
+        enroll_from_files(store, FakeEmbedder(), "Efren", [wav], 2.0)
+
+
+# -- CLI handler ------------------------------------------------------------------
+
+
+def run_handler(monkeypatch, tmp_path, *, name="Efren", wavs=None,
+                model_ok=True, min_enroll_s=2.0, force=False):
+    """Drive ``_handle_speaker_enroll`` with fakes; return (rc, out, err)."""
+    if wavs is None:
+        wavs = [make_wav(tmp_path / "clip.wav", seconds=2.5)]
+    model = tmp_path / "model.onnx"
+    if model_ok:
+        model.write_bytes(b"fake-model")
+    cfg_path = tmp_path / "config.toml"
+    cfg_path.write_text(
+        "[speaker]\n"
+        f'model_path = "{model}"\n'
+        f"min_enroll_s = {min_enroll_s}\n"
+        f'store_dir = "{tmp_path / "speakers"}"\n'
+        "[events]\n"
+        f'socket = "{tmp_path / "bus.sock"}"\n',
+        encoding="utf-8",
+    )
+
+    fake = FakeEmbedder()
+
+    class FakeSherpa:
+        def __init__(self, model_path, provider="cpu"):
+            self.embedder = fake
+
+        def embed(self, audio):
+            return fake.embed(audio)
+
+    monkeypatch.setattr(cli_module, "SherpaEmbedder", FakeSherpa)
+    args = argparse.Namespace(
+        config=str(cfg_path), name=name, wav=[str(w) for w in wavs], force=force
+    )
+    import io
+    from contextlib import redirect_stderr, redirect_stdout
+
+    out, err = io.StringIO(), io.StringIO()
+    with redirect_stdout(out), redirect_stderr(err):
+        rc = cli_module._handle_speaker_enroll(args)
+    return rc, out.getvalue(), err.getvalue()
+
+
+def test_cli_handler_success(monkeypatch, tmp_path):
+    rc, out, _ = run_handler(monkeypatch, tmp_path)
+
+    assert rc == 0
+    assert "enrolled 'Efren' as spk_0001" in out
+    assert "1 clip(s)" in out
+
+
+def test_cli_handler_missing_model(monkeypatch, tmp_path):
+    rc, _, err = run_handler(monkeypatch, tmp_path, model_ok=False)
+
+    assert rc == 1
+    assert "model" in err.lower()
+
+
+def test_cli_handler_short_wav(monkeypatch, tmp_path):
+    short = make_wav(tmp_path / "short.wav", seconds=0.5)
+
+    rc, _, err = run_handler(monkeypatch, tmp_path, wavs=[short])
+
+    assert rc == 1
+    assert "too short" in err
+
+
+def test_cli_handler_dedupes_and_saves(monkeypatch, tmp_path):
+    """Two identical clips collapse to one prototype; the line says so."""
+    wavs = [make_wav(tmp_path / f"clip{i}.wav", seconds=2.5) for i in range(2)]
+    model = tmp_path / "model.onnx"
+    model.write_bytes(b"fake-model")
+    cfg_path = tmp_path / "config.toml"
+    cfg_path.write_text(
+        "[speaker]\n"
+        f'model_path = "{model}"\n'
+        "min_enroll_s = 2.0\n"
+        f'store_dir = "{tmp_path / "speakers"}"\n'
+        "[events]\n"
+        f'socket = "{tmp_path / "bus.sock"}"\n',
+        encoding="utf-8",
+    )
+
+    class ConstEmbedder:
+        def embed(self, audio):
+            return np.array([1.0, 0.0], dtype=np.float32)
+
+    class FakeSherpa:
+        def __init__(self, model_path, provider="cpu"):
+            self._embedder = ConstEmbedder()
+
+        def embed(self, audio):
+            return self._embedder.embed(audio)
+
+    monkeypatch.setattr(cli_module, "SherpaEmbedder", FakeSherpa)
+    args = argparse.Namespace(
+        config=str(cfg_path), name="Efren",
+        wav=[str(w) for w in wavs], force=True, dedupe_threshold=0.98,
+    )
+    import io
+    from contextlib import redirect_stderr, redirect_stdout
+
+    out, err = io.StringIO(), io.StringIO()
+    with redirect_stdout(out), redirect_stderr(err):
+        rc = cli_module._handle_speaker_enroll(args)
+
+    assert rc == 0
+    assert "removed 1 duplicate prototype(s)" in out.getvalue()
+    fresh = SpeakerStore(tmp_path / "speakers")
+    fresh.load()
+    assert fresh.speakers["spk_0001"]["prototypes"] == 1

+ 763 - 0
tests/test_speakers.py

@@ -0,0 +1,763 @@
+"""Tests for the speaker prototype store, matcher, and enrollment guards.
+
+Deterministic synthetic vectors only: no ONNX, no network, no audio hardware.
+"""
+
+import json
+import logging
+import wave
+from pathlib import Path
+
+import numpy as np
+import pytest
+
+import stt_daemon.speakers as speakers_module
+from stt_daemon.speakers import Match, Outcome, SpeakerStore
+
+
+class FakeClock:
+    """Injectable monotonic-enough clock for cooldown/TTL tests."""
+
+    def __init__(self, now: float = 1000.0) -> None:
+        self.now = now
+
+    def __call__(self) -> float:
+        return self.now
+
+    def advance(self, seconds: float) -> None:
+        self.now += seconds
+
+
+def at_angle(degrees: float) -> np.ndarray:
+    """Unit 2-D vector at ``degrees`` from the x axis."""
+    radians = np.deg2rad(degrees)
+    return np.array([np.cos(radians), np.sin(radians)], dtype=np.float32)
+
+
+def unit(x: float, y: float) -> np.ndarray:
+    """Unit 2-D vector from raw components (cosine with [1, 0] equals x/norm)."""
+    vector = np.array([x, y], dtype=np.float32)
+    return vector / np.linalg.norm(vector)
+
+
+def make_store(tmp_path: Path, **kwargs) -> SpeakerStore:
+    """Store with mint cooldown disabled by default so multi-mint tests are simple."""
+    kwargs.setdefault("mint_cooldown_s", 0.0)
+    return SpeakerStore(tmp_path, **kwargs)
+
+
+def mint(store: SpeakerStore, emb: np.ndarray, dur_s: float = 2.5, rms: float = 0.1,
+         audio: np.ndarray | None = None) -> Outcome:
+    """Feed two consecutive identical misses so the utterance mints a provisional."""
+    store.process(emb, dur_s, rms)
+    outcome = store.process(emb, dur_s, rms, audio=audio)
+    assert outcome.event == "mint"
+    return outcome
+
+
+# -- match thresholds ---------------------------------------------------------
+
+
+def test_match_reports_matched_above_threshold(tmp_path):
+    store = make_store(tmp_path)
+    mint(store, at_angle(0))
+
+    match = store.match(unit(0.8, 0.6))
+
+    assert match.status == "matched"
+    assert match.speaker_id == "spk_0001"
+    assert match.score == pytest.approx(0.8, abs=1e-4)
+    assert match.top_id is None
+
+
+def test_match_reports_uncertain_between_thresholds(tmp_path):
+    store = make_store(tmp_path)
+    mint(store, at_angle(0))
+
+    match = store.match(unit(0.5, 0.8660254))
+
+    assert match.status == "uncertain"
+    assert match.speaker_id is None
+    assert match.top_id == "spk_0001"
+    assert match.score == pytest.approx(0.5, abs=1e-4)
+
+
+def test_match_reports_unknown_below_threshold(tmp_path):
+    store = make_store(tmp_path)
+    mint(store, at_angle(0))
+
+    match = store.match(unit(0.2, 0.9797959))
+
+    assert match == Match(None, "unknown", None, None)
+
+
+def test_match_normalizes_scaled_embedding(tmp_path):
+    store = make_store(tmp_path)
+    mint(store, at_angle(0))
+
+    match = store.match(np.array([7.0, 0.0], dtype=np.float32))
+
+    assert match.status == "matched"
+    assert match.score == pytest.approx(1.0, abs=1e-6)
+
+
+def test_match_with_empty_store_is_unknown(tmp_path):
+    store = make_store(tmp_path)
+
+    assert store.match(at_angle(0)) == Match(None, "unknown", None, None)
+
+
+def test_match_unusable_embedding_is_unknown(tmp_path):
+    store = make_store(tmp_path)
+    mint(store, at_angle(0))
+
+    unusable = [
+        np.zeros(2, dtype=np.float32),
+        np.array([], dtype=np.float32),
+        np.array([np.nan, 1.0], dtype=np.float32),
+    ]
+    for emb in unusable:
+        assert store.match(emb) == Match(None, "unknown", None, None)
+
+
+def test_dimension_mismatch_is_unknown_and_warns_once(tmp_path, caplog):
+    store = make_store(tmp_path)
+    mint(store, at_angle(0))
+
+    with caplog.at_level(logging.WARNING):
+        first = store.match(np.ones(3, dtype=np.float32))
+        store.match(np.ones(3, dtype=np.float32))
+
+    assert first == Match(None, "unknown", None, None)
+    assert len([r for r in caplog.records if r.levelno == logging.WARNING]) == 1
+
+
+def test_process_unusable_embedding_returns_unknown_without_event(tmp_path):
+    store = make_store(tmp_path)
+
+    outcome = store.process(np.zeros(2, dtype=np.float32), 3.0, 0.1)
+
+    assert outcome == Outcome(Match(None, "unknown", None, None), None)
+    assert store.process(None, 3.0, 0.1) == Outcome(Match(None, "unknown", None, None), None)
+    assert store.speaker_ids() == []
+
+
+# -- mint guards --------------------------------------------------------------
+
+
+def test_single_unknown_does_not_mint(tmp_path):
+    store = make_store(tmp_path)
+
+    outcome = store.process(at_angle(0), 2.5, 0.1)
+
+    assert outcome == Outcome(Match(None, "unknown", None, None), None)
+    assert store.speaker_ids() == []
+
+
+def test_two_dissimilar_misses_do_not_mint(tmp_path):
+    store = make_store(tmp_path)
+
+    store.process(at_angle(0), 2.5, 0.1)
+    outcome = store.process(at_angle(90), 2.5, 0.1)
+
+    assert outcome.event is None
+    assert store.speaker_ids() == []
+
+
+def test_two_similar_misses_mint_provisional(tmp_path):
+    store = make_store(tmp_path)
+
+    outcome = mint(store, at_angle(0), dur_s=3.0)
+
+    assert outcome.match == Match("spk_0001", "unknown", None, None)
+    row = store.snapshot()[0]
+    assert row["id"] == "spk_0001"
+    assert row["name"] is None
+    assert row["status"] == "provisional"
+    assert row["cumulative_s"] == pytest.approx(3.0)
+    assert row["sample_path"] is None
+
+
+def test_mint_writes_16bit_mono_sample(tmp_path):
+    store = make_store(tmp_path)
+
+    mint(store, at_angle(0), audio=np.zeros(1600, dtype=np.float32))
+
+    sample = tmp_path / "samples" / "spk_0001.wav"
+    assert sample.exists()
+    with wave.open(str(sample), "rb") as wav:
+        assert wav.getnchannels() == 1
+        assert wav.getsampwidth() == 2
+        assert wav.getframerate() == 16000
+        assert wav.getnframes() == 1600
+    assert store.snapshot()[0]["sample_path"] == str(sample)
+
+
+def test_mint_requires_min_enroll_duration(tmp_path):
+    store = make_store(tmp_path, min_enroll_s=2.0)
+
+    store.process(at_angle(0), 1.0, 0.1)
+    outcome = store.process(at_angle(0), 1.0, 0.1)
+    assert outcome.event is None
+    assert store.speaker_ids() == []
+
+    outcome = store.process(at_angle(0), 2.5, 0.1)
+    assert outcome.event == "mint"
+
+
+def test_mint_requires_rms_floor(tmp_path):
+    store = make_store(tmp_path, rms_floor=0.05)
+
+    store.process(at_angle(0), 2.5, 0.01)
+    outcome = store.process(at_angle(0), 2.5, 0.01)
+    assert outcome.event is None
+    assert store.speaker_ids() == []
+
+    outcome = store.process(at_angle(0), 2.5, 0.2)
+    assert outcome.event == "mint"
+
+
+def test_mint_cooldown_blocks_second_speaker(tmp_path):
+    clock = FakeClock()
+    store = make_store(tmp_path, clock=clock, mint_cooldown_s=60.0)
+    mint(store, at_angle(0))
+
+    store.process(at_angle(90), 2.5, 0.1)
+    outcome = store.process(at_angle(90), 2.5, 0.1)
+    assert outcome.event is None
+    assert store.speaker_ids() == ["spk_0001"]
+
+    clock.advance(60.0)
+    outcome = store.process(at_angle(90), 2.5, 0.1)
+
+    assert outcome.event == "mint"
+    assert store.speaker_ids() == ["spk_0001", "spk_0002"]
+
+
+def test_mint_blocked_when_best_two_are_within_margin(tmp_path):
+    store = make_store(tmp_path)
+    mint(store, np.array([1.0, 0.0, 0.0], dtype=np.float32))
+    mint(store, np.array([0.0, 1.0, 0.0], dtype=np.float32))
+    between = np.array([0.4, 0.38, np.sqrt(1 - 0.16 - 0.1444)], dtype=np.float32)
+
+    store.process(between, 2.5, 0.1)
+    outcome = store.process(between, 2.5, 0.1)
+
+    assert outcome.event is None
+    assert store.speaker_ids() == ["spk_0001", "spk_0002"]
+
+
+def test_mint_allowed_when_unknown_is_clearly_novel(tmp_path):
+    store = make_store(tmp_path)
+    mint(store, np.array([1.0, 0.0, 0.0], dtype=np.float32))
+    mint(store, np.array([0.0, 1.0, 0.0], dtype=np.float32))
+    novel = np.array([0.3, 0.1, np.sqrt(1 - 0.09 - 0.01)], dtype=np.float32)
+
+    store.process(novel, 2.5, 0.1)
+    outcome = store.process(novel, 2.5, 0.1)
+
+    assert outcome.event == "mint"
+    assert store.speaker_ids() == ["spk_0001", "spk_0002", "spk_0003"]
+
+
+def test_matched_result_resets_miss_buffer(tmp_path):
+    store = make_store(tmp_path)
+    mint(store, at_angle(0))
+
+    store.process(at_angle(90), 2.5, 0.1)
+    store.process(at_angle(10), 2.5, 0.1)
+    outcome = store.process(at_angle(90), 2.5, 0.1)
+
+    assert outcome.event is None
+    assert store.speaker_ids() == ["spk_0001"]
+
+
+def test_uncertain_result_resets_miss_buffer(tmp_path):
+    store = make_store(tmp_path)
+    mint(store, at_angle(0))
+
+    store.process(at_angle(90), 2.5, 0.1)
+    store.process(unit(0.5, 0.8660254), 2.5, 0.1)
+    outcome = store.process(at_angle(90), 2.5, 0.1)
+
+    assert outcome.event is None
+    assert store.speaker_ids() == ["spk_0001"]
+
+
+def test_uncertain_never_adapts(tmp_path):
+    store = make_store(tmp_path)
+    mint(store, at_angle(0))
+
+    outcome = store.process(unit(0.5, 0.8660254), 2.5, 0.1)
+
+    assert outcome.match.status == "uncertain"
+    assert outcome.match.top_id == "spk_0001"
+    assert outcome.event is None
+    assert store.speakers["spk_0001"]["cumulative_s"] == pytest.approx(2.5)
+    assert store.speakers["spk_0001"]["prototypes"] == 1
+
+
+# -- adapt guards, cap, and promotion -----------------------------------------
+
+
+def test_short_utterance_matches_but_does_not_adapt(tmp_path):
+    store = make_store(tmp_path)
+    mint(store, at_angle(0))
+
+    outcome = store.process(at_angle(20), 1.0, 0.1)
+
+    assert outcome.match.status == "matched"
+    assert outcome.event is None
+    assert store.speakers["spk_0001"]["cumulative_s"] == pytest.approx(2.5)
+    assert store.speakers["spk_0001"]["prototypes"] == 1
+
+
+def test_quiet_utterance_matches_but_does_not_adapt(tmp_path):
+    store = make_store(tmp_path, rms_floor=0.05)
+    mint(store, at_angle(0), rms=0.2)
+
+    outcome = store.process(at_angle(20), 2.5, 0.01)
+
+    assert outcome.match.status == "matched"
+    assert outcome.event is None
+    assert store.speakers["spk_0001"]["prototypes"] == 1
+
+
+def test_adapt_caps_prototypes_and_replaces_oldest(tmp_path):
+    store = make_store(tmp_path, max_prototypes=3)
+    mint(store, at_angle(0))
+
+    for angle in (20, 40, 60):
+        outcome = store.process(at_angle(angle), 2.5, 0.1)
+        assert outcome.event == "adapt"
+
+    assert store._proto_ids == ["spk_0001", "spk_0001", "spk_0001"]
+    assert store.speakers["spk_0001"]["prototypes"] == 3
+    angles = [
+        float(np.rad2deg(np.arctan2(row[1], row[0]))) for row in store._vectors
+    ]
+    assert angles == pytest.approx([20, 40, 60], abs=0.05)
+
+
+def test_provisional_promotes_on_cumulative_seconds(tmp_path):
+    store = make_store(tmp_path, confirm_s=5.0, min_enroll_s=1.0)
+    mint(store, at_angle(0), dur_s=2.0)
+
+    first = store.process(at_angle(20), 2.0, 0.1)
+    assert first.event == "adapt"
+    assert store.speakers["spk_0001"]["status"] == "provisional"
+
+    second = store.process(at_angle(40), 2.0, 0.1)
+    assert second.match.status == "matched"
+    assert second.event == "confirm"
+    assert store.speakers["spk_0001"]["status"] == "confirmed"
+    assert store.speakers["spk_0001"]["cumulative_s"] == pytest.approx(6.0)
+
+    third = store.process(at_angle(60), 2.0, 0.1)
+    assert third.event == "adapt"
+    assert store.speakers["spk_0001"]["status"] == "confirmed"
+
+
+# -- prune, rename, snapshot --------------------------------------------------
+
+
+def test_prune_removes_starved_provisional_and_sample(tmp_path):
+    clock = FakeClock(1000.0)
+    store = make_store(tmp_path, clock=clock, provisional_ttl_h=24.0, confirm_s=1.0)
+    audio = np.zeros(1600, dtype=np.float32)
+    mint(store, at_angle(0), dur_s=2.0, audio=audio)
+    mint(store, at_angle(90), dur_s=2.0)
+    store.process(at_angle(90), 2.0, 0.1)
+    sample = tmp_path / "samples" / "spk_0001.wav"
+    assert sample.exists()
+
+    removed = store.prune(now=1000.0 + 25 * 3600)
+
+    assert removed == ["spk_0001"]
+    assert not sample.exists()
+    assert store.speaker_ids() == ["spk_0002"]
+    assert store.speakers["spk_0002"]["status"] == "confirmed"
+    assert "spk_0001" not in store._proto_ids
+
+
+def test_prune_keeps_fresh_provisional(tmp_path):
+    clock = FakeClock(1000.0)
+    store = make_store(tmp_path, clock=clock, provisional_ttl_h=24.0)
+    mint(store, at_angle(0))
+
+    assert store.prune() == []
+    assert store.speaker_ids() == ["spk_0001"]
+
+
+def test_load_does_not_prune(tmp_path):
+    store = make_store(tmp_path, clock=FakeClock(1000.0))
+    mint(store, at_angle(0))
+    store.save()
+
+    later = make_store(tmp_path, clock=FakeClock(1000.0 + 25 * 3600))
+    later.load()
+
+    assert later.speaker_ids() == ["spk_0001"]
+    assert later.prune() == ["spk_0001"]
+
+
+def test_rename_unknown_id_returns_false(tmp_path):
+    store = make_store(tmp_path)
+
+    assert store.rename("spk_9999", "Nobody") is False
+    assert not (tmp_path / "speakers.json").exists()
+
+
+def test_rename_names_speaker_and_deletes_sample(tmp_path):
+    store = make_store(tmp_path)
+    mint(store, at_angle(0), audio=np.zeros(1600, dtype=np.float32))
+    sample = tmp_path / "samples" / "spk_0001.wav"
+    assert sample.exists()
+
+    renamed = store.rename("spk_0001", "Efren")
+
+    assert renamed is True
+    assert not sample.exists()
+    assert store.snapshot()[0]["name"] == "Efren"
+    assert store.snapshot()[0]["sample_path"] is None
+    reloaded = SpeakerStore(tmp_path)
+    reloaded.load()
+    assert reloaded.snapshot()[0]["name"] == "Efren"
+
+
+def test_snapshot_shape_and_sorting(tmp_path):
+    store = make_store(tmp_path)
+    mint(store, at_angle(0))
+    mint(store, at_angle(90))
+
+    snapshot = store.snapshot()
+
+    assert store.root == tmp_path
+    assert [row["id"] for row in snapshot] == ["spk_0001", "spk_0002"]
+    assert set(snapshot[0]) == {
+        "id",
+        "name",
+        "status",
+        "cumulative_s",
+        "sample_path",
+        "created_at",
+    }
+    assert snapshot[0]["cumulative_s"] == pytest.approx(2.5)
+    assert isinstance(snapshot[0]["created_at"], float)
+
+
+# -- persistence --------------------------------------------------------------
+
+
+def test_save_load_round_trips_metadata_and_prototypes(tmp_path):
+    store = make_store(tmp_path)
+    mint(store, at_angle(0))
+    store.process(at_angle(20), 2.5, 0.1)
+    store.save()
+
+    reloaded = make_store(tmp_path)
+    reloaded.load()
+
+    assert reloaded.snapshot() == store.snapshot()
+    assert reloaded.speaker_ids() == ["spk_0001"]
+    assert reloaded._vectors.shape == store._vectors.shape
+    assert np.allclose(reloaded._vectors, store._vectors)
+    assert reloaded.match(at_angle(0)).status == "matched"
+
+
+def test_load_continues_speaker_numbering(tmp_path):
+    store = make_store(tmp_path)
+    mint(store, at_angle(0))
+    store.save()
+
+    reloaded = make_store(tmp_path)
+    reloaded.load()
+    outcome = mint(reloaded, at_angle(90))
+
+    assert outcome.match.speaker_id == "spk_0002"
+
+
+def test_failed_save_preserves_targets_and_cleans_tmp(tmp_path, monkeypatch):
+    store = make_store(tmp_path)
+    mint(store, at_angle(0))
+    store.save()
+    before_json = (tmp_path / "speakers.json").read_bytes()
+    before_npz = (tmp_path / "prototypes.npz").read_bytes()
+
+    def crash(src, dst):
+        raise OSError("simulated crash")
+
+    monkeypatch.setattr(speakers_module.os, "replace", crash)
+    with pytest.raises(OSError):
+        store.save()
+
+    assert list(tmp_path.glob(".speakers-*.tmp")) == []
+    assert (tmp_path / "speakers.json").read_bytes() == before_json
+    assert (tmp_path / "prototypes.npz").read_bytes() == before_npz
+    fresh = SpeakerStore(tmp_path)
+    fresh.load()
+    assert fresh.speaker_ids() == ["spk_0001"]
+
+
+def test_load_ignores_stray_tmp_file(tmp_path):
+    store = make_store(tmp_path)
+    mint(store, at_angle(0))
+    store.save()
+    (tmp_path / ".speakers-leftover.tmp").write_text("half-written")
+
+    reloaded = SpeakerStore(tmp_path)
+    reloaded.load()
+
+    assert reloaded.speaker_ids() == ["spk_0001"]
+
+
+def test_load_corrupt_json_starts_empty(tmp_path, caplog):
+    (tmp_path / "speakers.json").write_text("{not json")
+    store = SpeakerStore(tmp_path)
+
+    with caplog.at_level(logging.WARNING):
+        store.load()
+
+    assert store.speaker_ids() == []
+    assert store.match(at_angle(0)) == Match(None, "unknown", None, None)
+    assert len([r for r in caplog.records if r.levelno == logging.WARNING]) == 1
+
+
+def test_load_corrupt_npz_starts_empty(tmp_path, caplog):
+    store = make_store(tmp_path)
+    mint(store, at_angle(0))
+    store.save()
+    (tmp_path / "prototypes.npz").write_bytes(b"garbage")
+
+    fresh = SpeakerStore(tmp_path)
+    with caplog.at_level(logging.WARNING):
+        fresh.load()
+
+    assert fresh.speaker_ids() == []
+    assert any(r.levelno == logging.WARNING for r in caplog.records)
+
+
+def test_store_root_samples_dir_and_wav_are_private(tmp_path):
+    store = make_store(tmp_path)
+    mint(store, at_angle(0), audio=np.zeros(1600, dtype=np.float32))
+    store.save()
+
+    speaker_id = store.speaker_ids()[0]
+    wav = tmp_path / "samples" / f"{speaker_id}.wav"
+
+    assert wav.exists()
+    assert (tmp_path.stat().st_mode & 0o077) == 0
+    assert ((tmp_path / "samples").stat().st_mode & 0o077) == 0
+    assert (wav.stat().st_mode & 0o077) == 0
+
+
+def test_sample_escape_dropped_on_load_without_unlink(tmp_path, caplog):
+    store = make_store(tmp_path)
+    mint(store, at_angle(0))
+    store.save()
+
+    outside = tmp_path / "outside.wav"
+    outside.write_bytes(b"sentinel")
+    meta_path = tmp_path / "speakers.json"
+    meta = json.loads(meta_path.read_text(encoding="utf-8"))
+    speaker_id = next(iter(meta["speakers"]))
+    meta["speakers"][speaker_id]["sample"] = "../outside.wav"
+    meta_path.write_text(json.dumps(meta), encoding="utf-8")
+
+    fresh = SpeakerStore(tmp_path)
+    with caplog.at_level(logging.WARNING):
+        fresh.load()
+
+    assert fresh.speakers[speaker_id]["sample"] is None
+    assert outside.read_bytes() == b"sentinel"  # never unlinked outside the root
+    assert "sample" in caplog.text.lower() or "store" in caplog.text.lower()
+
+
+# -- offline enrollment --------------------------------------------------------
+
+
+def test_enroll_creates_confirmed_named_profile(tmp_path):
+    store = make_store(tmp_path)
+
+    speaker_id = store.enroll("Efren", [at_angle(0), at_angle(10)], dur_s=7.4)
+
+    assert speaker_id == "spk_0001"
+    assert store.speaker_ids() == ["spk_0001"]
+    meta = store.speakers[speaker_id]
+    assert meta["name"] == "Efren"
+    assert meta["status"] == "confirmed"
+    assert meta["cumulative_s"] == pytest.approx(7.4)
+    assert meta["prototypes"] == 2
+    snapshot = store.snapshot()
+    assert snapshot[0]["id"] == "spk_0001"
+    assert snapshot[0]["name"] == "Efren"
+    assert snapshot[0]["status"] == "confirmed"
+
+
+def test_enroll_same_name_reuses_id_and_appends(tmp_path):
+    store = make_store(tmp_path)
+    first = store.enroll("Efren", [at_angle(0)], dur_s=2.0)
+
+    second = store.enroll("Efren", [at_angle(10)], dur_s=3.0)
+
+    assert second == first
+    assert store.speaker_ids() == [first]
+    meta = store.speakers[first]
+    assert meta["prototypes"] == 2
+    assert meta["cumulative_s"] == pytest.approx(5.0)
+
+
+def test_enroll_respects_prototype_cap(tmp_path):
+    store = make_store(tmp_path, max_prototypes=2)
+    speaker_id = store.enroll(
+        "Efren", [at_angle(0), at_angle(10), at_angle(20)], dur_s=6.0
+    )
+
+    assert store.speakers[speaker_id]["prototypes"] == 2
+    assert store.speakers[speaker_id]["cumulative_s"] == pytest.approx(6.0)
+
+
+def test_enroll_round_trips_through_save_load(tmp_path):
+    store = make_store(tmp_path)
+    speaker_id = store.enroll("Efren", [at_angle(0)], dur_s=2.5)
+    store.save()
+
+    fresh = SpeakerStore(tmp_path)
+    fresh.load()
+
+    assert fresh.speaker_ids() == [speaker_id]
+    assert fresh.speakers[speaker_id]["name"] == "Efren"
+    assert fresh.speakers[speaker_id]["status"] == "confirmed"
+    match = fresh.match(at_angle(0))
+    assert match.status == "matched"
+    assert match.speaker_id == speaker_id
+
+
+def test_enroll_empty_name_raises(tmp_path):
+    store = make_store(tmp_path)
+
+    with pytest.raises(ValueError):
+        store.enroll("   ", [at_angle(0)], dur_s=1.0)
+
+
+def test_enroll_promotes_existing_named_profile_to_confirmed(tmp_path):
+    store = make_store(tmp_path)
+    mint(store, at_angle(0))
+    speaker_id = store.speaker_ids()[0]
+    assert store.speakers[speaker_id]["status"] == "provisional"
+    store.rename(speaker_id, "Efren")
+
+    reused = store.enroll("Efren", [at_angle(5)], dur_s=1.0)
+
+    assert reused == speaker_id
+    assert store.speakers[speaker_id]["status"] == "confirmed"
+    assert store.speakers[speaker_id]["name"] == "Efren"
+
+
+# -- dedupe -------------------------------------------------------------------
+
+
+def test_dedupe_removes_exact_duplicate_and_keeps_first(tmp_path):
+    store = make_store(tmp_path)
+    speaker_id = store.enroll("Efren", [at_angle(0), at_angle(0), at_angle(90)])
+
+    removed = store.dedupe(speaker_id)
+
+    assert removed == 1
+    assert store._proto_ids == [speaker_id, speaker_id]
+    angles = [float(np.rad2deg(np.arctan2(row[1], row[0]))) for row in store._vectors]
+    assert angles == pytest.approx([0, 90], abs=0.05)
+    assert store.speakers[speaker_id]["prototypes"] == 2
+
+
+def test_dedupe_removes_near_duplicates_at_default_threshold(tmp_path):
+    store = make_store(tmp_path)
+    speaker_id = store.enroll("Efren", [at_angle(0), at_angle(5), at_angle(40)])
+
+    removed = store.dedupe(speaker_id)
+
+    assert removed == 1  # cos(5deg) ~= 0.996 >= 0.99; cos(40deg) ~= 0.77 kept
+    assert store.speakers[speaker_id]["prototypes"] == 2
+
+
+def test_dedupe_threshold_controls_aggressiveness(tmp_path):
+    store = make_store(tmp_path)
+    speaker_id = store.enroll("Efren", [at_angle(0), at_angle(20)])
+
+    assert store.dedupe(speaker_id) == 0  # cos(20deg) ~= 0.94 < 0.99
+    assert store.speakers[speaker_id]["prototypes"] == 2
+    assert store.dedupe(speaker_id, threshold=0.9) == 1
+    assert store.speakers[speaker_id]["prototypes"] == 1
+
+
+def test_dedupe_none_covers_all_speakers(tmp_path):
+    store = make_store(tmp_path)
+    first = store.enroll("Efren", [at_angle(0), at_angle(0)])
+    second = store.enroll("Rival", [at_angle(90), at_angle(90)])
+
+    removed = store.dedupe()
+
+    assert removed == 2
+    assert store.speakers[first]["prototypes"] == 1
+    assert store.speakers[second]["prototypes"] == 1
+    assert store.match(at_angle(0)).speaker_id == first
+    assert store.match(at_angle(90)).speaker_id == second
+
+
+def test_dedupe_single_id_leaves_other_speakers_untouched(tmp_path):
+    store = make_store(tmp_path)
+    first = store.enroll("Efren", [at_angle(0), at_angle(0)])
+    second = store.enroll("Rival", [at_angle(90), at_angle(90)])
+
+    removed = store.dedupe(first)
+
+    assert removed == 1
+    assert store.speakers[first]["prototypes"] == 1
+    assert store.speakers[second]["prototypes"] == 2
+    assert store._proto_ids.count(second) == 2
+
+
+def test_dedupe_accepts_display_name(tmp_path):
+    store = make_store(tmp_path)
+    store.enroll("Efren", [at_angle(0), at_angle(0)])
+
+    removed = store.dedupe("Efren")
+
+    assert removed == 1
+    assert store.speakers["spk_0001"]["prototypes"] == 1
+
+
+def test_dedupe_empty_store_and_unknown_id_return_zero(tmp_path):
+    store = make_store(tmp_path)
+
+    assert store.dedupe() == 0
+    assert store.dedupe("spk_9999") == 0
+    assert store.dedupe("Nobody") == 0
+    assert store.dedupe(None, threshold=0.5) == 0
+
+    store.enroll("Efren", [at_angle(0)])
+    assert store.dedupe("spk_9999") == 0
+    assert store.speakers["spk_0001"]["prototypes"] == 1
+
+
+def test_dedupe_does_not_save(tmp_path):
+    store = make_store(tmp_path)
+    speaker_id = store.enroll("Efren", [at_angle(0), at_angle(0)])
+
+    removed = store.dedupe(speaker_id)
+
+    assert removed == 1
+    assert not (tmp_path / "speakers.json").exists()
+    assert not (tmp_path / "prototypes.npz").exists()
+
+
+def test_dedupe_keeps_at_least_one_prototype(tmp_path):
+    store = make_store(tmp_path)
+    speaker_id = store.enroll("Efren", [at_angle(0), at_angle(0), at_angle(0)])
+
+    removed = store.dedupe(speaker_id, threshold=0.0)
+
+    assert removed == 2
+    assert store._proto_ids == [speaker_id]
+    assert store.speakers[speaker_id]["prototypes"] == 1
+    assert store.match(at_angle(0)).status == "matched"

+ 115 - 1
tests/test_tui_state.py

@@ -1,6 +1,13 @@
 """Tests for the TUI view-state reducer."""
 
-from stt_daemon.tui.state import MAX_TRANSCRIPTS, ViewState
+import pytest
+
+from stt_daemon.tui.state import (
+    MAX_TRANSCRIPTS,
+    SPEAKER_NAMED_STYLE,
+    SPEAKER_UNKNOWN_STYLE,
+    ViewState,
+)
 
 
 def test_mode_event_updates_mode_and_style():
@@ -140,6 +147,113 @@ def test_transcript_block_header_has_clock_lang_duration_and_reason():
     assert body.strip() == "hola mundo"
 
 
+def _header_span_style(block, fragment: str) -> str:
+    for span in block.spans:
+        if block.plain[span.start : span.end] == fragment:
+            return str(span.style)
+    raise AssertionError(f"no span rendering {fragment!r} in {block.plain!r}")
+
+
+def test_transcript_header_places_named_speaker_between_lang_and_duration():
+    state = ViewState()
+    state.ingest(
+        {
+            "type": "transcript",
+            "ts": 1759073916.19,
+            "lang": "en",
+            "duration_s": 3.24,
+            "end_reason": "pause",
+            "speaker": "Efren",
+            "speaker_id": "spk_0001",
+            "speaker_status": "matched",
+            "speaker_score": 0.87,
+            "text": "turn on the lights",
+        }
+    )
+    block = state.transcript_lines()[0]
+    header, body = block.plain.split("\n")
+    assert header.index("EN") < header.index("Efren") < header.index("3.2s")
+    assert _header_span_style(block, "Efren") == SPEAKER_NAMED_STYLE == "bold green"
+    assert body.strip() == "turn on the lights"
+
+
+def test_transcript_header_renders_matched_but_unnamed_speaker_as_unknown():
+    state = ViewState()
+    state.ingest(
+        {
+            "type": "transcript",
+            "ts": 1759073916.19,
+            "lang": "en",
+            "duration_s": 3.24,
+            "speaker": "Unknown",
+            "speaker_id": "spk_0002",
+            "speaker_status": "matched",
+            "text": "hello there",
+        }
+    )
+    block = state.transcript_lines()[0]
+    header = block.plain.split("\n")[0]
+    assert header.index("EN") < header.index("Unknown") < header.index("3.2s")
+    assert _header_span_style(block, "Unknown") == SPEAKER_UNKNOWN_STYLE == "bold red"
+
+
+@pytest.mark.parametrize("status", ["uncertain", "unknown", "none"])
+def test_transcript_header_renders_unmatched_statuses_as_unknown(status):
+    state = ViewState()
+    state.ingest(
+        {
+            "type": "transcript",
+            "ts": 1759073916.19,
+            "lang": "es",
+            "duration_s": 1.0,
+            "speaker": "Unknown",
+            "speaker_status": status,
+            "text": "hola",
+        }
+    )
+    block = state.transcript_lines()[0]
+    assert "Unknown" in block.plain
+    assert _header_span_style(block, "Unknown") == SPEAKER_UNKNOWN_STYLE
+
+
+def test_transcript_header_without_speaker_field_renders_unknown():
+    state = ViewState()
+    state.ingest(
+        {
+            "type": "transcript",
+            "ts": 1759073916.19,
+            "lang": "en",
+            "duration_s": 1.0,
+            "text": "no speaker field",
+        }
+    )
+    block = state.transcript_lines()[0]
+    assert _header_span_style(block, "Unknown") == SPEAKER_UNKNOWN_STYLE
+
+
+def test_speakers_event_populates_the_speaker_snapshot():
+    speakers = [
+        {
+            "id": "spk_0001",
+            "name": "Efren",
+            "status": "confirmed",
+            "cumulative_s": 42.5,
+            "sample_path": "speakers/samples/spk_0001.wav",
+            "created_at": "2026-09-28T12:00:00",
+        }
+    ]
+    state = ViewState()
+    state.ingest({"type": "speakers", "speakers": speakers})
+    assert state.speakers == speakers
+
+
+def test_speakers_event_without_payload_clears_the_snapshot():
+    state = ViewState()
+    state.speakers = [{"id": "spk_0001"}]
+    state.ingest({"type": "speakers"})
+    assert state.speakers == []
+
+
 def test_transcript_time_language_and_text_colors_are_disjoint():
     state = ViewState()
     for lang in ("es", "en", "fr", "de", "pt", "it", "zz"):

+ 75 - 1
uv.lock

@@ -1507,6 +1507,74 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
 ]
 
+[[package]]
+name = "sherpa-onnx"
+version = "1.13.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/5d/9c19acbe7eebd09cee4b69408532f44dc09e71b6025518ed7dd9c0509d6d/sherpa_onnx-1.13.8.tar.gz", hash = "sha256:68e638f745df120a7fae268b7bae0eaab2362b5ad76fe3389bece1d3cd63272c", size = 1057541, upload-time = "2026-09-10T15:05:43.696Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/10/43/ac0418d7336b38df63a098bbb4d2c78d8d6ea981454de535ee94b1da2816/sherpa_onnx-1.13.8-cp311-cp311-linux_armv7l.whl", hash = "sha256:5a324650a38f2d1dfac12305d5ee5f83265c805513e2e2b6dae7c52e3bf9eee6", size = 12229581, upload-time = "2026-09-10T16:21:45.287Z" },
+    { url = "https://files.pythonhosted.org/packages/3a/53/1248cf11cbba23e2b9b9e0cfed8945b73956398d331e89b61c8e2297c4ec/sherpa_onnx-1.13.8-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:b4c2e9c5fd12fe3ac6a21d76b45f16442d3b1d80691d81fbb0f8acfef250f5a3", size = 4434699, upload-time = "2026-09-10T14:54:30.83Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/be/c2b2a42ffcb22224fa95c62280cdc1f955f30f21805a2fda7fd182539628/sherpa_onnx-1.13.8-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:a00d9ceeb4d9531d2f5bd90d194d53408461c5aa6ff46e65f8776ed37007f3d0", size = 2332701, upload-time = "2026-09-10T15:01:40.938Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/74/dafb3c1c1ff82fc00abd098ade44811e48f3af88b4e3c2f1628567d0e80b/sherpa_onnx-1.13.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8bb2b86ce44b5c5bb9949977177ddc36954c51097f81284d5ac48588e821ec07", size = 2141306, upload-time = "2026-09-10T15:36:50.677Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/02/f2300e5cb07a611afcac5699a97c8d6f229f53a7c751882818ed40a7f72f/sherpa_onnx-1.13.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b56808d19a79368dcaa507d02a4c1ce537ca713fab9fedd91256b4ec599c6bf7", size = 4177610, upload-time = "2026-09-10T15:11:29.024Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/c6/1fe91047af08b30806f18fac55639037d3cf0247aeec97b7b7884407d6bf/sherpa_onnx-1.13.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:94fd1476b56ed36b851da8db9bd5144e92f669007ad54e0d8094913e4fbf1418", size = 4398381, upload-time = "2026-09-10T15:05:05.363Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/d2/b7601292ccc18f35cdb39a82f1bbb11e4ffe210bd235179c9da3715761f1/sherpa_onnx-1.13.8-cp311-cp311-win32.whl", hash = "sha256:de271d3338358dfb7cea1f780e7d99738437c0d9969f11b5bcf5d6660c6734b7", size = 1964883, upload-time = "2026-09-10T15:23:22.705Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/95/9325a66149d5a54c53161eba9ba77f8ea7068dc8f921c3d8b75174261211/sherpa_onnx-1.13.8-cp311-cp311-win_amd64.whl", hash = "sha256:171e6fac715bae20e11829e8dbfc70ed990ede1b35e6332f8121b27691a002de", size = 2283357, upload-time = "2026-09-10T15:52:44.166Z" },
+    { url = "https://files.pythonhosted.org/packages/86/e6/e86545b64d55e134f9eb15641d69943bc34b68d9db187043c6e95d002123/sherpa_onnx-1.13.8-cp311-cp311-win_arm64.whl", hash = "sha256:41c3433fc044936808a9799b89a5e4c4f7b79d68eda8600d900a3f3724074099", size = 2242706, upload-time = "2026-09-10T15:16:42.968Z" },
+    { url = "https://files.pythonhosted.org/packages/e0/74/f88231aa67e7713b2509da3aa53756b9c85f42fc7a3abe3a4b825e2974bc/sherpa_onnx-1.13.8-cp312-cp312-linux_armv7l.whl", hash = "sha256:6e50efbe69ff07f8b62d2d01f9d7f6e5854033be4b8d940fe9b575e873b22584", size = 12230846, upload-time = "2026-09-10T16:56:15.576Z" },
+    { url = "https://files.pythonhosted.org/packages/a0/35/ed6cc5d2e29f29c9778975bec44ac5e8069e1a3528f7ff1306cbdcdab585/sherpa_onnx-1.13.8-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:17ff957b1b33849671262ddbac51815a5a82286f963091bddf85540c9e728c12", size = 4457476, upload-time = "2026-09-10T15:45:38.598Z" },
+    { url = "https://files.pythonhosted.org/packages/36/e6/a19257d7c60bf04c07f8d0772b943b6596b6b10cfb0694b7794d36630d40/sherpa_onnx-1.13.8-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:ef218d6545a9dcc2aff7043f39026c7d78d6cf46a3876d450d508feebffc00a9", size = 2350796, upload-time = "2026-09-10T14:23:58.591Z" },
+    { url = "https://files.pythonhosted.org/packages/78/6e/8d9b92e95896ec500084e706529f5637eff797246e74c498922f6aa13a53/sherpa_onnx-1.13.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:04b9268c348e9f4bd61315754ad06b02a3c185d3d7770acd8e2dcee9c2e039f9", size = 2146858, upload-time = "2026-09-10T14:49:23.355Z" },
+    { url = "https://files.pythonhosted.org/packages/1f/5f/22e1571146b2c0b581657562d5919b69da13ed93dc70049f42d45fe9e84c/sherpa_onnx-1.13.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2520b1e7b779a29a493a8cec893612218dfad7304786bf3f64df2ff7ac6c3302", size = 4176945, upload-time = "2026-09-10T15:21:43.965Z" },
+    { url = "https://files.pythonhosted.org/packages/13/78/2f712b7408ddbb64614c20388912414705b728516224e737b63f2adec94a/sherpa_onnx-1.13.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6949773017647febc0c3696dffb2c67dd3febd4737b87e3dd135a42773704a06", size = 4400575, upload-time = "2026-09-10T14:49:42.265Z" },
+    { url = "https://files.pythonhosted.org/packages/39/62/a6cc995ea40fd88a14b5d312d7b86b49ab65213e501f15fabe00cf79e1e5/sherpa_onnx-1.13.8-cp312-cp312-win32.whl", hash = "sha256:90e0c7b16fe6be32361dc3f6d2a66e8f74e8085079e91da1bee1acea772b769d", size = 1965883, upload-time = "2026-09-10T15:11:47.827Z" },
+    { url = "https://files.pythonhosted.org/packages/24/af/33e9dcaa527bbc0c2526959713626607a46da2d7b19871b7773d8607d356/sherpa_onnx-1.13.8-cp312-cp312-win_amd64.whl", hash = "sha256:566e49ad3fb2aa8ceb723a52f9809aa6c2ee0a5eb3b0946c3fd2374af807a5c6", size = 2287856, upload-time = "2026-09-10T15:39:28.995Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/0e/237f9baaa3da5c029a06803a7587d4717ec191284d6f3972c679683ad6d8/sherpa_onnx-1.13.8-cp312-cp312-win_arm64.whl", hash = "sha256:1613f14db38b6936098c660f610baee95541378edbdec39a973a591d8df58f5b", size = 2249666, upload-time = "2026-09-10T14:54:55.475Z" },
+    { url = "https://files.pythonhosted.org/packages/c8/c5/172fddb0ab34bed7f645df376d4c2e19deb5b3bc336ca06e1f7a52777e48/sherpa_onnx-1.13.8-cp313-cp313-linux_armv7l.whl", hash = "sha256:bbd93a09fa01fb32c222614a7f939495d8966fc15e1cb3943d7a79d3fe7291bd", size = 12230180, upload-time = "2026-09-10T16:28:11.636Z" },
+    { url = "https://files.pythonhosted.org/packages/cf/7c/21316b8e438598da6f54cc5eea6994fbb9d508caf4afd8292b76802b244c/sherpa_onnx-1.13.8-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:d2f50ad1b1e1918c0358270846288bdc1636b130bea4d9d85088a14bb7de69b1", size = 4458011, upload-time = "2026-09-10T16:02:34.034Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/4d/b442afd30eff64b83112b54fa866c5d2e3a5aeec13ee8bd665c7b146fba1/sherpa_onnx-1.13.8-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:5f8df54514af5025b9d428403dd9a27f6fe252b5e162311bf5c47228a50d0cc5", size = 2351015, upload-time = "2026-09-10T14:37:10.823Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/7b/9829c8e222e6103fd3571523252974c1095c49ae7d1511babf46c4f0d6ea/sherpa_onnx-1.13.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2c5a1799c326715b1ee69dae3be48883ddc6fb922fb813067606f7e9a29ea234", size = 2147122, upload-time = "2026-09-10T15:47:29.125Z" },
+    { url = "https://files.pythonhosted.org/packages/13/e3/115476caa9f80cd5f55e4e7b777a6c2d01a133d10f8fae574ba869fc48c3/sherpa_onnx-1.13.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:916ec38242e779ee3a99f7b161bd1bf3b02a7fad34cb073d091021c0469e0b58", size = 4177700, upload-time = "2026-09-10T14:27:20.564Z" },
+    { url = "https://files.pythonhosted.org/packages/8f/eb/b77acde02d9eee359436ade9239d9ade4351b09fbe85387a293f341c19ed/sherpa_onnx-1.13.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e5d870fb0648befb94e260c11de4cf4b293b2a90a63e3a6a2869be698927bc4", size = 4401242, upload-time = "2026-09-10T15:28:26.857Z" },
+    { url = "https://files.pythonhosted.org/packages/04/3d/a5f41332fe672e139f542541cb1afecf96addcbb72ee4bf84b0f9ba3dd22/sherpa_onnx-1.13.8-cp313-cp313-win32.whl", hash = "sha256:4bbc4dbbd539fd83c28aa7a076e0e5e1c5c4c4cc9e31fc5bd956b4e8b032fcfc", size = 1966530, upload-time = "2026-09-10T15:51:56.826Z" },
+    { url = "https://files.pythonhosted.org/packages/52/1f/a96759b33dab99647e57f94040d678c332e0d7eb595127d1e0b4e0b9af27/sherpa_onnx-1.13.8-cp313-cp313-win_amd64.whl", hash = "sha256:195455d8d6cc49f616e9d459b7d08f10a32daf2ddcbe90d4d479a8ba9aab28bf", size = 2288187, upload-time = "2026-09-10T15:59:35.179Z" },
+    { url = "https://files.pythonhosted.org/packages/71/96/86a9571b95336e9968519acedcb33f17fb4c3512e961344f6daed47636cc/sherpa_onnx-1.13.8-cp313-cp313-win_arm64.whl", hash = "sha256:15e2d2ac2524e42efb40057f6e1d7cc53529417e791f5cf1bdd721a0ec2b1f7e", size = 2245867, upload-time = "2026-09-10T14:52:43.668Z" },
+    { url = "https://files.pythonhosted.org/packages/58/e9/e7669a9f7a927e33f20b9bf98ffbe8d11c7f60c6e8f492bd0bc59dcb94d7/sherpa_onnx-1.13.8-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:533440358bbfc5796bc8cf85fe2bc08d0c91de5200fc03da2d3eb656caec705b", size = 2364130, upload-time = "2026-09-10T14:04:10.482Z" },
+    { url = "https://files.pythonhosted.org/packages/83/68/d76f8dd62b7475a0583c57cfaaebbb1de693b42405a17796613fa7592227/sherpa_onnx-1.13.8-cp314-cp314-android_24_armeabi_v7a.whl", hash = "sha256:4757a983a0b2757ae55e6179b0f5369381316b1efcdfd5a35b6ccb341fae3daa", size = 2433241, upload-time = "2026-09-10T14:04:22.379Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/fe/86cbd751f3b36f59ac0333b1ebbf91312c2fc5031c6cf54095a54735b690/sherpa_onnx-1.13.8-cp314-cp314-android_24_x86.whl", hash = "sha256:c0bd1249e4ff8a4f93c8b7eb7e906b8d894738556379b28e78817cbdf996eb72", size = 2527240, upload-time = "2026-09-10T14:10:43.529Z" },
+    { url = "https://files.pythonhosted.org/packages/a4/00/bd069882f9a707a7e0cdad32f9447b95e756d4fe1421419ba0e6d9a2fcd2/sherpa_onnx-1.13.8-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e787ab8e2c859bae1589b9dc729ca2d66096505db028f2969b1c3a380a986a38", size = 2588008, upload-time = "2026-09-10T14:35:08.781Z" },
+    { url = "https://files.pythonhosted.org/packages/76/b9/832bab8dc5116bb29b0ff7cc1aed5122ab2115c6bb947140073f6930fe67/sherpa_onnx-1.13.8-cp314-cp314-linux_armv7l.whl", hash = "sha256:40a7a26e4af197f6b46e73e73cc6792c3401b12377e7d8d674766a5cb67ce4ac", size = 12228389, upload-time = "2026-09-10T16:44:33.115Z" },
+    { url = "https://files.pythonhosted.org/packages/91/2c/4aee534542f2de43c3dadfbe7d436fdecfb28e4c6d5a12f75febdc3f5f8e/sherpa_onnx-1.13.8-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c8121c87eb80c86a8dbe2cf54ed11ca1150ae0001223bac902471e7b4608e1a9", size = 4461555, upload-time = "2026-09-10T15:58:23.745Z" },
+    { url = "https://files.pythonhosted.org/packages/f5/fb/e8a289419f38ed9eb1882d22c12a2680254e5eb945e8e2517c5b2281dd23/sherpa_onnx-1.13.8-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:22f699e1fb2e8dafe02b1521ce74e0928b38cb68ad19f481380acf51e6a6965b", size = 2351239, upload-time = "2026-09-10T15:07:53.4Z" },
+    { url = "https://files.pythonhosted.org/packages/33/f5/ff7f45799a1a77f165410fce5080be2cb59cfc98375bf4a56d7d1fe80540/sherpa_onnx-1.13.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7fa6ec595a8b4bba2b91e6f8e58647777050ac4eb9845f5a5026c20636c0e2be", size = 2149334, upload-time = "2026-09-10T15:19:02.491Z" },
+    { url = "https://files.pythonhosted.org/packages/4f/9a/51821829b5735b3d7ce607992a62fe93d5f7215cda324c11191c77d49b9b/sherpa_onnx-1.13.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:45f16c9fabc3d7ce31ba9722ff8196627ddcaa5ffd02413ad0664ad1952ba419", size = 4182835, upload-time = "2026-09-10T14:39:02.405Z" },
+    { url = "https://files.pythonhosted.org/packages/2d/e2/b1af63c1f6a9e0025cbc9aa3b1814ba97ede63db0ad9f297bb9f268fdfc2/sherpa_onnx-1.13.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97309e4b5d9850490085da61e35b0fca002a39ec9da93cd9e21d2d0dcc1cc62a", size = 4403669, upload-time = "2026-09-10T14:53:18.178Z" },
+    { url = "https://files.pythonhosted.org/packages/b3/34/48d01cc8581f3d4354eb14f1c91a04b52c4c2024ae7c3ce465355aa6d227/sherpa_onnx-1.13.8-cp314-cp314-win32.whl", hash = "sha256:bf5f95bfd992f5dd3f897f57fc13f9d0174e16e48c19d43a24923cc5fb61ded1", size = 2006786, upload-time = "2026-09-10T15:39:06.995Z" },
+    { url = "https://files.pythonhosted.org/packages/24/a5/100a7c200e696b2756838b0bdb83e9cf96f62e524d44d43daba0a8e9d6fe/sherpa_onnx-1.13.8-cp314-cp314-win_amd64.whl", hash = "sha256:1ced230bdd02f4d50106ce5c41e5f9142b45248e67ca532c84439d0475932bb8", size = 2353481, upload-time = "2026-09-10T15:51:06.243Z" },
+    { url = "https://files.pythonhosted.org/packages/1f/38/ce056f2cdcfa9abcf40ea572cc8ece2fac4fe19a5fe2f1ec589cf71e412c/sherpa_onnx-1.13.8-cp314-cp314-win_arm64.whl", hash = "sha256:ecce26fe96e4b733356568fac1e99df7038e9871b94b70d9f731e2ea8656db67", size = 2314981, upload-time = "2026-09-10T15:08:22.649Z" },
+]
+
+[[package]]
+name = "sherpa-onnx-core"
+version = "1.13.8"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/81/bd/20952589f89451094a48935a8098dbf36eb252335f7023662c9bd2123c1c/sherpa_onnx_core-1.13.8-py3-none-android_24_arm64_v8a.whl", hash = "sha256:6bbde45a9f2efcc8647429cbb8e06718bfd4c2a55f831eb5482ffb5ab227af07", size = 9996193, upload-time = "2026-09-10T14:04:15.273Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/95/5390b364fcfd6ee543721f5676757259b79a978efda785d6031c51abaab9/sherpa_onnx_core-1.13.8-py3-none-android_24_armeabi_v7a.whl", hash = "sha256:19d2a6a649d3680a06c63a1a7d9121ec29f0a24552febc2c9c795739fccb901d", size = 9331932, upload-time = "2026-09-10T14:04:25.681Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/80/128e7e5721d7a1e8538319a624feecfd358d27ccdb7396ef01f246e28961/sherpa_onnx_core-1.13.8-py3-none-android_24_x86.whl", hash = "sha256:a48778c45300c87bd528770da9426696a338b17621ce50963aa9b4c7337f4a67", size = 11352266, upload-time = "2026-09-10T14:10:48.218Z" },
+    { url = "https://files.pythonhosted.org/packages/be/56/85f4f0830a34e44ff09490db07fddc971dad718b0379ac493f4e615e5ee1/sherpa_onnx_core-1.13.8-py3-none-android_24_x86_64.whl", hash = "sha256:8ac9a05109105cd263ca16f09ffc7ec077979c95ddf185ff3d62b83dd929ce29", size = 11133563, upload-time = "2026-09-10T14:35:13.398Z" },
+    { url = "https://files.pythonhosted.org/packages/86/9d/3df22f9c151f4158c99cb38c2bf75cb864c9306316f16870b58ab0252dc7/sherpa_onnx_core-1.13.8-py3-none-macosx_10_15_universal2.whl", hash = "sha256:b279ad024b37f2999834abc5583fe9e70df0fb3f37c20a2d1e76e75ad2ccb1a1", size = 20398884, upload-time = "2026-09-10T14:25:35.095Z" },
+    { url = "https://files.pythonhosted.org/packages/17/2d/98e309811cff9fec48ae48a051b4caab44046755f6bc58daab0c475f40cb/sherpa_onnx_core-1.13.8-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:917422880287baa0f0ae1bd10d9528fa78af8cc7c233d2a953a185a4fe8d83f2", size = 10878335, upload-time = "2026-09-10T14:05:06.488Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/2a/2a47b423fe009bbbcb6d7eed7bcba755d6f9ae923b5664c7208128c32d06/sherpa_onnx_core-1.13.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9312bbd46c93e31cecd3abda9cc8e71881d86cc3da3c35c7445ab04025b9fc3e", size = 9552498, upload-time = "2026-09-10T14:20:49.971Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/72/02227b14cd3fb3d504a8814ad144ed321a53331948c893821e02f4abb1d7/sherpa_onnx_core-1.13.8-py3-none-manylinux2014_aarch64.whl", hash = "sha256:a51a03d55c376c32e15dd63ea852236042ba91a5cd73e253bbf7cf21f6582ba9", size = 13353426, upload-time = "2026-09-10T14:04:50.344Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/ca/e27c1fb5c54b181d4a5450f3d5c789d9da02eb42dc9b20cfdab5dda4c864/sherpa_onnx_core-1.13.8-py3-none-manylinux2014_x86_64.whl", hash = "sha256:4da90acf435373d7b2ba9cc0be806e7e274d28f63f5780880b5dea6721626e36", size = 10642497, upload-time = "2026-09-10T14:33:21.435Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/e1/98b4cc1ebcb1345af440bdef50c23ae4a34c1308167c7a793d5098c13e6d/sherpa_onnx_core-1.13.8-py3-none-manylinux_2_35_armv7l.whl", hash = "sha256:2d875f5de5856e30f54917222463ddac9fcdfd4191c188b6f0e7f7c04482afa4", size = 10143758, upload-time = "2026-09-10T15:58:46.203Z" },
+    { url = "https://files.pythonhosted.org/packages/de/7d/e5a1de0269ed3d646f430beb1156f17b39899a1cae612dcf6e29a2867248/sherpa_onnx_core-1.13.8-py3-none-win32.whl", hash = "sha256:44f7daed251d96da4a26486fcc42142cf863d8ba760c6bfececd0f210de40414", size = 14772677, upload-time = "2026-09-10T14:54:26.666Z" },
+    { url = "https://files.pythonhosted.org/packages/94/38/64356ad97f68fffcf01fe7545407ad18a2ea86c1ffae5b0950f7fac73638/sherpa_onnx_core-1.13.8-py3-none-win_amd64.whl", hash = "sha256:5579e80196d516e6dae23c8f629292ce3142ab8869925d32b94612fd86f93733", size = 16903581, upload-time = "2026-09-10T15:10:50.349Z" },
+    { url = "https://files.pythonhosted.org/packages/e4/e0/236bde7b6b9909d2f7f4282b9e18f5fa8d64b841e63265b508a86c3687a8/sherpa_onnx_core-1.13.8-py3-none-win_arm64.whl", hash = "sha256:85e9b12e9e985d73ad9165a1122c396e2c9384256a4023cd7cf9c91bc8d32073", size = 16067491, upload-time = "2026-09-10T14:16:26.19Z" },
+]
+
 [[package]]
 name = "sounddevice"
 version = "0.5.6"
@@ -1541,6 +1609,10 @@ dependencies = [
 portaudio = [
     { name = "sounddevice" },
 ]
+speaker = [
+    { name = "sherpa-onnx" },
+    { name = "sherpa-onnx-core" },
+]
 triage = [
     { name = "laya" },
 ]
@@ -1558,11 +1630,13 @@ requires-dist = [
     { name = "numpy", specifier = ">=1.26" },
     { name = "pywhispercpp", marker = "python_full_version == '3.13.*'", path = "vendor/pywhispercpp-1.5.1-cp313-cp313-linux_x86_64.whl" },
     { name = "questionary", specifier = ">=2.0" },
+    { name = "sherpa-onnx", marker = "extra == 'speaker'", specifier = ">=1.12.0" },
+    { name = "sherpa-onnx-core", marker = "extra == 'speaker'", specifier = ">=1.12.0" },
     { name = "sounddevice", marker = "extra == 'portaudio'", specifier = ">=0.4.6" },
     { name = "textual", specifier = ">=0.80" },
     { name = "webrtcvad-wheels", specifier = ">=2.0.14" },
 ]
-provides-extras = ["portaudio", "triage"]
+provides-extras = ["portaudio", "triage", "speaker"]
 
 [package.metadata.requires-dev]
 dev = [