Skip to main content

whosaid

Local, speaker-attributed transcription for Apple Silicon — who said what, on your Mac, nothing leaves the machine.

License: MIT Platform: macOS Apple Silicon

whosaid demo — speaker-attributed transcription on the terminal

whosaid pairs Whisper speech-to-text with speaker diarization to turn a meeting, interview, call, or podcast recording into a transcript that says who spoke when — running fully offline and on-device on an Apple Silicon Mac, with no cloud service, no API keys, and no Hugging Face token. Think of it as whisper + speaker labels + voice-based speaker recognition, in one command.

Example output (meeting.speakers.txt):

[00:00:04] Alice: Thanks for jumping on, I know it's late for you.
[00:00:11] SPEAKER_01: No problem at all, happy to make it work.
[00:00:19] Alice: Let's start with the roadmap for next quarter.
[00:00:27] SPEAKER_01: Sounds good, I've got a few updates on that.

Why whosaid

  • Meetings, interviews, calls, podcasts — get a transcript where every turn is attributed to a person, not just a wall of text.
  • Privacy by construction — audio, text, and voice embeddings never leave your Mac. There's no cloud step to opt out of, because there isn't one.
  • Your name on your own lines — a one-time ~45s voice enrollment teaches whosaid your voice, so your turns read as your name instead of SPEAKER_00.
  • Tells you how many people spoke — the number of distinct speakers is auto-detected and reported up front, with per-speaker turn counts and talk time.
  • Speaker cards to identify who's who — for every speaker, whosaid writes a card of that voice's most representative snippets, so you can read a few lines and know who was talking.
  • Remembers people you name — identify a speaker once with whosaid relabel, and their voiceprint is saved to a private local registry so they're auto-named in every future transcript.
  • Fast on long recordings — recordings over ~15 min are diarized in parallel windows and stitched back into consistent speakers by voiceprint, so an 80-minute meeting is minutes, not tens of minutes, recovering the same speakers as a single-pass run.
  • No accounts, no API keys, no Hugging Face token — every model comes from an open, ungated source.
  • Usable from an AI agent, too — whosaid mcp exposes transcribe, relabel, and doctor (among others) as MCP tools for Claude Code, Claude Desktop, and other MCP clients, with the same local-only guarantee as the CLI.

How it compares

Looking for a MacWhisper, whisperX, or aTrain alternative? If you want speaker-attributed transcription that runs fully offline on Apple Silicon and can put real names on voices — not just SPEAKER_00 labels — that's the gap whosaid fills. Here's how it stacks up:

whosaid whisperX plain mlx-whisper cloud transcription APIs
Speaker labels Yes Yes No Varies by provider
Names speakers by voice Yes (enrollment) No No No
Remembers speakers across meetings Yes — persistent local voiceprint registry No No No
Runs fully offline Yes Partial — needs a gated model download Yes No
Parallel diarization on long audio Yes No — (no diarization) Varies by provider
Needs an account / token No Yes — Hugging Face token for gated pyannote models No Yes — API key
Install weight ffmpeg + uv, ephemeral environments torch + pyannote + the full HF stack mlx-whisper only None (network client only)

Quickstart

git clone https://github.com/sblattj/whosaid && cd whosaid   # get the code
./bootstrap.sh                                                # check deps, download models, install ~/.local/bin/whosaid
whosaid enroll                                                # ~45s reading a printed passage — teaches whosaid your voice
whosaid path/to/meeting.m4a                                   # transcribe + diarize + label -> meeting.speakers.txt (and friends)

If ~/.local/bin is not on your shell's PATH, add it or invoke the installed command by its absolute path. WHOSAID_INSTALL_DIR=/another/bin ./whosaid install selects another install directory. The installed command is a symlink to the checkout, so updating the checkout updates the command without copying or duplicating the implementation.

Commands

Command What it does
./bootstrap.sh [--yes] (also whosaid setup) Capability check, dependency install, model pre-download, and command installation. Idempotent — safe to re-run.
whosaid install [--force] Install/update the command symlink in ~/.local/bin (or WHOSAID_INSTALL_DIR). Refuses to replace an unrelated command, and refuses (unless --force) to install from a checkout under /tmp, /private/tmp, /var/tmp, or $TMPDIR — that symlink would dangle once the OS cleans the temp directory up.
whosaid enroll [Name] Records ~45s from the mic reading a printed passage, saves voices/<Name>.wav. See Speaker identity: enrollment clips vs. the registry.
whosaid enroll <Name> --from FILE [--ss T] [--t D|--to T] [--force] Extracts a clip from an existing recording instead of the mic (extract → verify → save, no interaction). --ss/--t/--to accept seconds or M:SS/H:MM:SS; same ≥15s/non-silent bar as mic enrollment.
whosaid record [--label L] Foreground mic capture to recordings/<timestamp>[-label].m4a, then transcribes automatically.
whosaid <audio>… [flags] The default command: transcribe + diarize + label one or more audio files.
whosaid relabel <base> SPEAKER_02=Jane … Put real names on clusters after reading the speaker cards. Rewrites the transcript + cards and saves each named voiceprint to the local registry for future transcripts. No re-transcription.
whosaid relabel <base> --auto Re-apply naming to an existing transcript with no assignments: re-runs registry matching + the absorb pass over the cached sidecar and rewrites the transcript + cards. Picks up voices enrolled after the transcript was made, and folds phantom cluster splits of one person into a single speaker. No re-transcription, no re-diarization. In a meeting workspace the base is transcript. See Speaker identity: enrollment clips vs. the registry.
whosaid samples <base> [-o DIR] [--audio FILE] [--per-speaker N] [--seconds S] [--json] Export one short representative WAV per speaker cluster — the longest diarized segment, clamped to --seconds (default 8) — so you can listen and confirm an identity before trusting an auto-label or enrolling. Cuts from the sidecar's own source.path, or an explicit --audio FILE for a sidecar written before that metadata existed.
whosaid doctor Read-only environment report.
whosaid version | whosaid --version | whosaid -V Print the installed version (plus a git describe suffix when run from a git checkout).

Enroll from an existing recording

Already have a clip of the person speaking? Skip the mic and cut a reference straight from it:

whosaid enroll Alice --from meeting.m4a --ss 3:20 --t 20   # or --to 3:40

Meeting workspaces

One-off transcriptions are files; a recurring meeting series is a corpus. The meeting-workspace layer gives that corpus a home: every recording is transcribed into a dated folder, each meeting can carry generated action items, and one roll-up produces the index, the audit, and a living action-item list across all of them — still entirely offline.

whosaid ingest — a batch into dated folders

whosaid ingest weekly/*.m4a --into ./meetings --folder-by created \
  --tz America/Los_Angeles --action-items

Every file is transcribed with the full set of whosaid <audio>… flags passed through, into meetings/YYYY-MM-DD-HHMM/. The timestamp comes from the recording's own container creation_time rendered in --tz (default UTC), falling back to the file's mtime — so folder order reflects when meetings actually happened, not when you got around to copying the files. Ingest is idempotent by source sha256: re-running a batch never re-transcribes or duplicates a recording.

With --action-items, each meeting also gets an action-items.md. Generation is pluggable: the hook command receives the speaker-labeled transcript on stdin, plus WHOSAID_SPEAKERS and WHOSAID_TRANSCRIPT_PATH in its environment, and whatever it writes to stdout becomes the markdown. Pass it per-run with --hook CMD, or set WHOSAID_ACTION_ITEMS_HOOK once. With no hook, a skeleton is written instead and everything stays offline. Example hook — illustrative only; any command that turns stdin into markdown works:

#!/bin/sh
# Drafts action items with a local Ollama model.
ollama run llama3.2 "List this meeting's action items as markdown bullets (Owner: task):"

whosaid roll-up — index, audit, and the action-item corpus

whosaid roll-up ./meetings --action-items
  • Coverage index — _INDEX.md holds one row per meeting (created date, duration, and whether it is transcribed, diarized, and has action items), plus a nothing-missing audit that flags orphan directories and stale manifest entries, and a recurring-topics section that surfaces themes appearing across meetings. Both output paths are overridable with -o and --action-items-out.
  • Action-item corpus — with --action-items, _ACTION-ITEMS.md deduplicates items across meetings (by text similarity; threshold --similarity-threshold, 0.5–1.0, default 0.82) and groups them by owner, then status. Ids are stable (AI-001… and never renumber), each item carries first_seen/last_seen dates and its occurrence list, and open/ongoing/resolved statuses survive re-runs — so the corpus reads as living history across the series, not a per-meeting snapshot. Items can carry a free-form type, rendered in parens after the status, and pairs scoring just under the threshold are surfaced in a Possible duplicates (review) section at the end.

Roll-up is incremental and append-only by default: re-running with nothing new writes nothing. --rebuild is the escape hatch — it resets the manifest and corpus and regenerates both from the folders on disk.

State is two plain JSON files in the workspace directory, _workspace.json (the manifest) and _action-items.json (the corpus; it also records the similarity_threshold in effect). Both are safe to read and hand-edit — marking an item resolved by hand is the intended way to close one the extractor phrased wrong. Hand edits made directly in _ACTION-ITEMS.md are folded back on the next roll-up and survive re-runs: statuses, types, retitles, and (merged AI-NNN) merge annotations (the merged item stays at its id, rendered collapsed as [merged → AI-NNN]). Only --rebuild discards them.

Use it from an AI agent (MCP)

whosaid's local, private, GPU transcription and speaker diarization are also exposed as MCP tools, so any MCP client — Claude Code, Claude Desktop, and others — can call them directly instead of shelling out to the CLI. Launch is whosaid mcp, a stdio server that needs only uv, which whosaid already requires; audio never leaves the machine, exactly as with the CLI.

Add it to your MCP client config:

{
  "mcpServers": {
    "whosaid": { "command": "/ABSOLUTE/PATH/TO/whosaid", "args": ["mcp"] }
  }
}

command is the path to the whosaid script itself — the checkout's ./whosaid, or the installed ~/.local/bin/whosaid symlink.

Tool What it does
whosaid_transcribe Transcribes + diarizes an audio file and writes the labeled transcript, speaker cards, and a sidecar for relabeling.
whosaid_relabel Names SPEAKER_NN clusters and remembers them — saved to the local registry and auto-applied to every future transcript.
whosaid_list_speakers Read-only: lists enrolled voices and registry names already known.
whosaid_doctor Read-only readiness check — models cached, deps present, mic/audio devices — run this first when a transcribe fails.
whosaid_enroll_from_file Enrolls a named voice from an existing audio clip, no mic needed.
whosaid_samples Exports one short representative WAV per speaker cluster (longest segment, clamped to seconds) so you can listen and confirm an identity before trusting a label.

enroll and record (microphone capture) stay CLI-only — they need an interactive terminal and Microphone permission. The first whosaid_transcribe call downloads ~1.5 GB of models; call whosaid_doctor first to check readiness.

Key flags (on whosaid <audio>…)

Flag Meaning
-o, --outdir DIR Output directory (default: alongside the input file).
-m, --model NAME Whisper model to use.
--accurate Use the full large-v3 model instead of the default large-v3-turbo.
-l, --lang LANG Force the transcription language.
-f, --format FMT Output format: txt, srt, vtt, tsv, json, or all.
-n, --name NAME Override the output base name (single input only; default: derived from the input filename).
--speakers N Exact number of speakers. Overrides auto-detect (and any --min/--max-speakers).
--min-speakers N Lower bound on the auto-detected speaker count.
--max-speakers N Upper bound on the auto-detected speaker count; also lowers the hard cap of 20. Use a range when you know roughly who was in the room but not exactly — an estimate that lands on the bound is reported as unreliable rather than shipped as a result.
--expected-speakers A,B,C Roster of people you expect in this recording (comma-separated, repeatable). Each name must already be a known voice — an enrolled clip or a registry entry. Clustering is anchored to their voiceprints: a turn within --anchor-threshold of one of them is pinned to that person, everyone else is clustered into new speakers, and a listed person who never speaks is dropped. This is the flag for a recurring team with varying attendance, where --speakers N would merge distinct people.
--anchor-threshold F Per-turn cosine required before --expected-speakers pins a turn to a known voice. Default 0.70; turns below it fall through to ordinary clustering rather than taking a low-confidence name.
-j, --jobs N Parallel diarization workers for long audio (default: auto, ~cores−2, capped at 8).
--chunk-seconds S Window length for parallel diarization (default: auto — about --jobs windows, min 300s).
--no-chunk Diarize the whole file in a single pass (disable parallel chunking).
--match-threshold F Cosine similarity a known voice must reach before it may claim a cluster (alias --ref-threshold). Default 0.50; a cluster whose best candidate scores below F keeps its anonymous SPEAKER_NN label rather than taking a low-confidence name. Raise it (e.g. 0.6) if you see wrong names, lower it to catch more.
--absorb-threshold F Cosine similarity at which a still-unnamed cluster is folded into a known voice, merging phantom splits of one person. Default 0.85.
--no-diarize Skip diarization; write the plain transcript only.

Environment variables

Variable Purpose
WHOSAID_MODEL Default Whisper model, overridden by -m.
WHOSAID_LANG Default transcription language, overridden by -l.
WHOSAID_VOICE_REFS Override the directory of enrollment voice clips (default: voices/).
WHOSAID_SPEAKER_DB Local speaker registry of named voiceprints (default: ~/.config/whosaid/speakers.json). Private, never pushed.
WHOSAID_MATCH_THRESHOLD Default registry/reference match threshold, overridden by --match-threshold (default: 0.50).
WHOSAID_ABSORB_THRESHOLD Default absorb-pass threshold, overridden by --absorb-threshold (default: 0.85).
WHOSAID_ANCHOR_THRESHOLD Default per-turn anchoring threshold for --expected-speakers, overridden by --anchor-threshold (default: 0.70).
DIARIZE_EMB_NAME Speaker-embedding model. Default is NeMo nemo_en_titanet_small.onnx (English-native, ~2.5× faster than ERes2Net in sherpa's benchmark). Alternatives from the same release: 3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx (English ERes2Net) or …_zh-cn_… for Mandarin. Registry voiceprints are keyed by model, so switching re-enrolls speakers.
WHOSAID_REC_DEVICE avfoundation input device used by record and enroll.
WHOSAID_INSTALL_DIR Command install directory used by whosaid install (default: ~/.local/bin).
HF_HOME Hugging Face cache location (where the Whisper model lands).
SHERPA_DIARIZE_CACHE Diarization model cache location (default: ~/.cache/sherpa-diarization).

Speaker identity: enrollment clips vs. the registry

whosaid has two independent ways to put a name on a SPEAKER_NN cluster, and knowing which one is driving a given label matters — this was GitHub issue #1's other point of confusion.

1. Enrollment clips (voices/, WHOSAID_VOICE_REFS). Every *.wav/*.m4a file in the directory is embedded fresh on every transcribe and passed to the diarizer as --ref Name=path (the --ref "$refname=$ref" loop in the whosaid script, run before each diarize call). Written by whosaid enroll [Name] (mic, or --from FILE to cut a clip from an existing recording). Because a clip is re-embedded every run, it survives a DIARIZE_EMB_NAME change with no extra step: the embedding is always current for whichever model is active.

2. The registry (~/.config/whosaid/speakers.json, WHOSAID_SPEAKER_DB). A JSON file of {name, model, embedding, added} records, written once by whosaid relabel SPEAKER_XX=Name (or --save-speaker on a transcribe) and reused forever with no re-embedding. Each entry is keyed by the embedding model it was saved under, so switching DIARIZE_EMB_NAME silently orphans every registry entry for the old model — they don't error, they just stop matching — until you relabel again under the new model.

Which one actually names a cluster. name_clusters() (lib/diarize_sherpa.py) runs three passes in order, and a later pass only touches a cluster the earlier ones left unnamed: (1) registry one-best, (2) --ref enrollment clips, (3) absorb (folds a still-unnamed cluster into a known voice — registry or clip — above --absorb-threshold). Passes 1 and 2 both gate on --match-threshold (default 0.50, env WHOSAID_MATCH_THRESHOLD): below it a cluster keeps its anonymous label rather than take a low-confidence name.

Inspecting each. ls voices/ lists enrollment clips; whosaid doctor prints the registry's voiceprint count and path; the MCP whosaid_list_speakers tool lists both at once. <base>.diarization.json's registry_matches records which pass named (or nearly named) every cluster, so you can tell exactly which mechanism fired — and whosaid samples <base> lets you listen to a clip per cluster to confirm a label before trusting either one.

How it works

 audio file
     |
     v
 MLX Whisper (Metal GPU)  ------------->  <base>.txt / .srt / .vtt / .tsv / .json
     |
     v
 sherpa-onnx diarization (CPU)  -------->  <base>.rttm
     |
     v
 cosine-match vs voices/*.wav  --------->  <base>.speakers.txt

Transcription and diarization run as two independent local stages that get merged at the end. Transcription uses MLX Whisper (mlx-community/whisper-large-v3-turbo by default, or the full large-v3 with --accurate) on the Mac's GPU via Metal, through a hallucination-hardened decode path: the temperature-fallback ladder stays enabled, condition_on_previous_text is turned off, and a hallucination-silence threshold keeps dead air from turning into repeated-token filler. Diarization runs on the CPU via sherpa-onnx offline diarization: pyannote's segmentation-3.0 ONNX model finds who's speaking when, a speaker-embedding model (NeMo TitaNet-small by default, configurable via DIARIZE_EMB_NAME) embeds each turn, and clustering (optionally hinted by --speakers N) groups turns into speakers. Both diarization models are small (~30 MB total), ungated GitHub releases — no Hugging Face token required — cached locally in ~/.cache/sherpa-diarization/. Finally, every clip in voices/ — plus every voiceprint in your local registry — is embedded the same way and matched to a cluster by cosine similarity (a match at or above --match-threshold, default 0.50, names the cluster); unmatched clusters keep a SPEAKER_00-style label. Aside from the one-time model downloads, everything runs in ephemeral uv environments, so there's no persistent Python install left behind on your machine.

Tell it who to expect. When you already know the roster, --expected-speakers Alice,Bob,Carol turns diarization from a guess into a lookup. Every listed name must already be a known voice (an enrolled clip in voices/, or a registry entry), and clustering is then anchored: each turn's voiceprint is compared to each expected person's, a turn at cosine ≥ --anchor-threshold (default 0.70) is pinned directly to that person, and only the residual turns — guests, strangers, and the occasional turn that scored low — go through the ordinary count estimate and clustering. Anyone on the list who never speaks is silently dropped, so one roster works for a standing meeting whose attendance changes week to week. That is the difference from --speakers N: an exact count merges two distinct people the moment fewer than N show up, and auto-detect over-segments and then leaves a major speaker's averaged cluster unmatched. The threshold sits deliberately high — TitaNet-small scores same-speaker turns at ~0.90 (p10 ~0.72) against ~0.25 for strangers (p90 ~0.45), and a false anchor puts a real name on the wrong words, while a missed one merely falls through to clustering where the absorb pass can still recover it. Each anchor's result (turns, mean_cosine) lands in the sidecar under anchors, and each anchored cluster in registry_matches with pass: "anchor". Because anchoring needs per-turn voiceprints, this flag forces the chunked diarization path at any recording length.

Long recordings run in parallel. For audio over ~15 minutes, diarization splits into non-overlapping time windows that are segmented and embedded concurrently across CPU workers, then a single global clustering pass over every turn's voiceprint recovers speakers that stay consistent across window boundaries. Because the clustering sees all turns at once, it recovers the same speakers as a single-pass run while finishing several times faster. Pass --no-chunk to force a single pass, or --jobs/--chunk-seconds to tune it.

Output files

File Contents
<base>.txt Plain transcript.
<base>.srt SubRip subtitles.
<base>.vtt WebVTT subtitles.
<base>.tsv Tab-separated segments with timestamps.
<base>.json Full Whisper segment output.
<base>.rttm Raw diarization turns, standard RTTM format.
<base>.speakers.txt Speaker-labeled transcript: Whisper text merged with diarization turns and enrollment names.
<base>.speaker-cards.txt One card per speaker with turn count, talk time, and representative snippets — read it to identify who each SPEAKER_NN is, then name them with whosaid relabel.
<base>.diarization.json Cached segments + per-cluster voiceprints, so whosaid relabel can rename and persist speakers without re-diarizing. Also carries registry_matches and source (below).
<base>.samples/ Created on demand by whosaid samples <base>: one short representative WAV per speaker cluster (SPEAKER_NN[-Name].wav), for a quick human listen before trusting an auto-label.

The sidecar's two machine-readable extras, so a consumer never has to scrape stderr or shell out to ffprobe:

Sidecar key Contents
registry_matches One record per naming decision, including near-misses: {"cluster": "SPEAKER_03", "name": "Alice", "similarity": 0.919, "threshold": 0.5, "matched": true, "pass": "registry"}. pass is registry, ref, or absorb; matched: false means the cluster stayed SPEAKER_NN because similarity < threshold. Refreshed by whosaid relabel --auto, and also printed in the transcribe JSON line.
source Recording provenance: {"path": "/abs/path.m4a", "duration_seconds": 1834.2, "creation_time": "2026-09-14T18:02:11.000000Z"}. creation_time is the container tag, or null when the file carries none.

Match confidence and the threshold. Auto-naming only asserts a name when the cluster's cosine similarity to a known voiceprint reaches --match-threshold (default 0.50); below it the cluster keeps its SPEAKER_NN label — see the >= ref_threshold guards in name_clusters() (lib/diarize_sherpa.py). The default was raised from 0.40 to 0.50 because on real meeting audio TitaNet-small produced wrong assertions in the 0.40–0.53 band, while genuine same-speaker matches score far higher — in the end-to-end test the enrolled reference matches its cluster at 0.986, against 0.194 for the nearest stranger, so 0.50 sits in a wide empty gap. Use registry_matches to see exactly how close every near-miss came, then lower the threshold deliberately if a real speaker is being missed.

Troubleshooting

  • whosaid: command not found after it used to work. whosaid install makes ~/.local/bin/whosaid a symlink to the checkout — it doesn't copy the implementation. If that checkout lived in a temp directory (/tmp, /private/tmp, $TMPDIR, e.g. a clone under /private/tmp/whosaid-latest/), the OS eventually purges it and the symlink starts pointing at nothing, so the shell reports a plain "command not found" with no hint why. whosaid install (and ./bootstrap.sh) now refuse to install from a temp-directory checkout in the first place unless you pass --force. To fix an already-dangling install: clone/move the checkout to a stable location (e.g. ~/code/whosaid) and run whosaid install again from there. Because the installed symlink itself is what's broken, whosaid doctor can't be run by name to confirm this — run ./whosaid doctor from the fresh checkout instead; it reports a DANGLING install line when it finds this.
  • Enroll/record produces silence. This is almost always a macOS microphone permission problem: go to System Settings → Privacy & Security → Microphone and grant access to your terminal app. macOS feeds an unauthorized app silent zeros instead of an error, so whosaid detects this by checking the captured volume rather than trusting a clean exit code.
  • Doesn't run on my Intel Mac. MLX is Apple-Silicon-only, so whosaid requires an arm64 Mac.
  • First run is slow. The first ./bootstrap.sh (or first transcribe, if you skip it) downloads the Whisper model (~1.5 GB) and the diarization models (~30 MB). Every run after that uses the local cache.
  • Long recordings degrade into repeated text. This is Whisper's well-known hallucination/repetition-collapse failure mode, most likely on long or low-signal audio. It's why whosaid calls the mlx-whisper library directly (lib/transcribe_mlx.py) instead of the bare CLI: the bare CLI's single-temperature default is exactly the configuration that lets this happen, whereas the library call keeps the temperature-fallback ladder, disables conditioning on previous text, and applies a hallucination-silence threshold.
  • Speakers show up as SPEAKER_00 / SPEAKER_01 instead of a name. No enrolled voice or registry entry matched closely enough. Read <base>.speaker-cards.txt to tell who each cluster is, then run whosaid relabel <base> SPEAKER_01=Name — this labels them and remembers them for next time. (Enrollment via whosaid enroll <Name> still works too.) Naming uses a cosine-similarity threshold (--match-threshold, default 0.50), so a short or noisy sample can fall just short of it. Check registry_matches in <base>.diarization.json for the exact similarity of every near-miss, then lower the threshold deliberately if a real speaker is being missed.
  • The same person shows up as two speakers (phantom split), or a known voice stays UNIDENTIFIED. On long recordings the diarizer can split one voice across several clusters. A registry/enrolled voice names its single closest cluster, so the extra clusters used to stay unnamed. whosaid now runs an absorb pass: any still-unnamed cluster whose voiceprint is within --absorb-threshold (default 0.85) of a known voice is folded into that person, and the speaker cards merge those clusters into one card. To apply this to a transcript you already have, run whosaid relabel <base> --auto — it re-names from the registry + absorb pass with no re-transcription.
  • Distinct people get merged into one speaker (or the count is too low). The speaker-embedding model must match the spoken language. whosaid defaults to an English-native model (NeMo TitaNet-small); on English audio the Mandarin-trained model cannot tell similar voices apart and collapses them. For predominantly Mandarin audio, set DIARIZE_EMB_NAME to the …zh-cn… model from the same release.
  • The speaker count looks one too high, with a cluster that has ~1 second of speech. Forcing --speakers N too high can carve a phantom cluster out of crosstalk. Omit --speakers to auto-detect, which is usually more accurate.
  • Auto-detect reports far too many speakers on a long recording (it used to always say 20). Fixed in the current version. The speaker count is now estimated by average-linkage agglomerative clustering of the per-turn voiceprints, cut at cosine 0.58 (env WHOSAID_COUNT_THRESHOLD; calibrated against real TitaNet-small embeddings, where same-speaker turns measure ~0.90 and different speakers ~0.25). The previous estimator compared each turn to a single earlier turn rather than to a cluster, so on any recording with real channel variation it kept opening new clusters until it pinned at the hard cap of 20. The cap is now a bound, not a target: if the estimate lands on it, the count is reported as untrustworthy — a WARNING: line is written into <base>.speaker-cards.txt right under the count, and count_warning / count_estimate appear in <base>.diarization.json and in the JSON on stdout. If you know roughly how many people were present, pass --min-speakers/--max-speakers; if you know exactly, pass --speakers N. Note this estimator runs on the chunked path (recordings over 15 minutes, or any --chunk-seconds); shorter recordings use sherpa's own clustering, which takes an exact count only, so a --min-speakers/--max-speakers range there is reported as unenforced unless the two are equal.

Testing

./test/e2e.sh is a fully offline smoke test: it synthesizes a two-speaker dialog with two macOS say voices, builds a one-clip voice enrollment for one of them, and runs the real transcribe + diarize + name pipeline against it end to end — then asserts the speaker-labeled transcript names the enrolled speaker and labels the other speaker distinctly. Run ./bootstrap.sh once first so the models are cached locally; the test itself makes no network calls.

./test/version_test.sh is a fast, offline unit test that checks whosaid version, --version, and -V all print a matching whosaid X.Y.Z line and exit 0.

./test/install_guard_test.sh covers the temp-directory install guard: refusal without --force, success with it, install --check-only, and whosaid doctor detecting a dangling install symlink.

./test/enroll_from_file_test.sh covers whosaid enroll --from in isolation (time-window parsing, the 15s/silence floor, and the --force overwrite guard) with synthesized say audio — no model download required.

./test/samples_test.sh covers whosaid samples (longest-segment picking, the --seconds clamp, --per-speaker, naming, output format, and the source/--audio fallback) against a hand-written sidecar and synthesized say audio — no model download required.

License

MIT — see LICENSE.

Metadata

Release files for whosaid 1.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for whosaid 1.1.0
File Size Uploaded
whosaid-1.1.0.tar.gz 792.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for whosaid 1.1.0
File Interpreter ABI Platform
whosaid-1.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 858.9 kB

Release files / whosaid-1.1.0.tar.gz

Download URL whosaid-1.1.0.tar.gz
Size 792.8 kB
Tags Source
SHA-256 checksum
How to use checksums
5b78dee2fc910e16568a3cef13b1e4ff78d6c9c58f6879bbb36e8311f3662074
BLAKE2b-256 checksum
How to use checksums
19d5ee51fe29cfbf04e6abc9a69155ae376c8078e0f2e60359294d62b2e3de8a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.5

Release files / whosaid-1.1.0-py3-none-any.whl

Download URL whosaid-1.1.0-py3-none-any.whl
Size 66.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
af389a2e09631c3c33b35cb2625f375a22f34079418f8d147cbcd65ca55e6035
BLAKE2b-256 checksum
How to use checksums
b2eb2dce0d1f966b08e35d5f5d443b9f9779c7a568ad5733564cd4bbf7057f25
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.5

Release history Release notifications | RSS feed

1.14.0

2 release files

1.13.4

2 release files

This release

1.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page