Skip to content

Speaker diarization + cross-recording speaker ID #359

Description

@valentinweyer

Speaker diarization + cross-recording speaker ID

Problem

Steno currently attributes speech per-channel only: everything on the mic side is "You," everything on system audio is "Others." Two real cases fall through that:

  • Multiple speakers sharing one side of a call: an in-person conversation captured on a single mic, or several remote participants mixed into system audio, all get lumped into one label each.
  • No memory across recordings: even if a channel is correctly split into distinct speakers, there's no way to know that "Speaker 2" in today's meeting is the same person as "Speaker 2" in last week's, so named participants never accumulate across a user's history.

Solving this properly needs both acoustic diarization (splitting a channel into distinct speakers) and a safe way to attach names to those speakers over time.

What's already built (not a proposal, a working, tested branch)

This is fully implemented and validated locally. Opening this issue first because it's a large unsolicited change and I'd rather align on approach before a big PR lands in your inbox, but wanted to be upfront that the "proposal" is really "here's a finished thing, does the approach look right to you." Happy to give a live showcase/screen-share of it running against real recordings, whichever is more useful than reading this.

Acoustic diarization

A macOS-only Swift/CoreML sidecar (diarize-sidecar/) wrapping FluidAudio's Sortformer diarizer, invoked from Python (src.transcriber._run_steno_diarize). Never from Electron, since the batch pipeline is entirely Python-orchestrated.

  • Per-channel: mic and system-audio channels diarized independently, so multiple speakers sharing one side of a call get labelled "You" / "Speaker 2" / "Speaker 3" instead of lumped together. The dominant-by-duration cluster on each channel keeps the legacy "You"/"Others" label; other clusters are numbered by first chronological appearance across both channels. Any failure (missing binary, timeout, bad output, single-cluster result) falls back to today's exact channel-only behavior, so this can never fail a meeting.
  • Known model limit: the underlying model (NVIDIA's diar_streaming_sortformer_4spk-v2.1, converted to CoreML by FluidAudio) is architecturally capped at 4 speakers per diarization pass. A channel with more than 4 distinct speakers will have some of them conflated rather than separated. Worth flagging plainly since it's a real ceiling on the model, not something the integration can work around.
  • Mono support: recordings with no channel split at all (phone voice memos, single-track imports) now get diarized against the whole file, reusing the same tagging/placeholder-resolution helpers the stereo path uses, treating the single track as the "You" channel for consistency with the pre-diarization convention.

How the diarization pipeline was actually built out

  • Chunking preset, chosen deliberately, not left at default: Sortformer's .default preset (fastV2_1) is tuned for live/streaming responsiveness (0.48s of audio per CoreML invocation), which this app has no use for, since diarization only ever runs on a fully-recorded, already-finished channel. Switched to .highContextV2 (27.2s/invocation): ~56x fewer invocations for the same audio (~400 vs ~22,500 for a 3-hour file). Measured on a real ~21-minute recording: 153s to 23s. Picked V2 over the newer V2.1 because FluidAudio's own docs note V2.1 "may degrade when many speakers are talking simultaneously," a real risk given this app's own earlier crosstalk/echo findings. Caught a real regression during validation: the high-context chunk loader requires a full ~30.4s window before it emits anything, so a 12.25s test clip silently came back with zero segments. Fixed with a 90s minimum-duration guard (real margin above the hard minimum); recordings shorter than that keep using .default. Accuracy re-validated after the switch, not just "it still runs": 98.2% agreement with the previous preset on dominant-speaker attribution, a near-zero spurious "4th speaker" artifact (0.6s total) cleanly disappeared, total detected speech stayed within ~1%.
  • Long-sentence misattribution, fixed at the word level: Parakeet sometimes doesn't break a long, natural run of speech (no strong terminal punctuation) into separate sentences, producing one sentence spanning 40+ seconds of real back-and-forth. The original midpoint-assignment logic forced that whole block onto whichever diarizer segment the midpoint landed in, which is bad when a single mic capturing two people has noisy/overlapping diarizer turn boundaries. Fixed with a word-level fallback: sentences at/above a 5-second threshold that genuinely overlap more than one distinct diarizer speaker get split at the token level (via Parakeet's per-word timestamps) and reassigned per-word, then re-joined into contiguous same-speaker runs. Verified against a real recording: a wrongly-labelled 43-second block became ~14 correctly alternating turns. Known, accepted tradeoff: this occasionally oversplits relative to what a human would perceive as one continuous turn, inherent to single-mic multi-person audio having genuinely noisy diarizer boundaries, not something further tuning fully removes.
  • is_diarised gate bug: the pre-existing check (bool(mic_segments) and bool(system_segments)) discarded the entire labelled transcript whenever one channel had no content, the common case for an in-person conversation with no computer audio playing. Fixed to compute is_diarised from the actual number of distinct speaker labels in the output, not channel presence.
  • Progress streaming without deadlock risk: _run_steno_diarize was a blocking subprocess.run, rewritten to Popen plus two concurrent reader threads so stderr progress can be watched live without risking the classic pipe-deadlock. Real stdout payloads from the sidecar have measured up to ~211KB, past an OS pipe buffer. This is what makes the live per-stage progress below possible.
  • Live per-stage progress: a long recording used to sit on a static "Analyzing transcript" for minutes (measured: 100+ seconds of apparent hang during segmentation alone on a real 21-minute file, while actually healthy). Extended the existing PROGRESS: stdout protocol, previously summarization-only, to transcription and diarization, with real per-chunk embedding-extraction percentages from the sidecar, so the UI shows genuine progress instead of a spinner.

Human-confirmed cross-recording speaker identification

  • PersonProfile/SpeakerPrototype CRUD (src/config.py) with hard-negative and context-aware (in-person vs. remote) evidence
  • Suggestion engine (src/speaker_suggestions.py): gates a candidate match on threshold (minimum similarity to consider at all), margin (the best candidate must beat the runner-up by enough to not be a coin-flip), stability (the match should hold consistently across the meeting's segments, not one lucky moment), and hard-negative evidence (once a user rejects a suggested pairing, that's remembered and excluded going forward), plus same-meeting fragment merging, transcript relabeling, and sample-audio/text extraction so a user can actually hear/read the clip before confirming
  • Approval UI (SpeakerReviewPanel, wired into the meeting detail view): approve / change / new-person / keep-generic, duplicate-name prevention
  • 14 new CLI commands for the full lifecycle (create/rename/delete person profiles, suggest-speakers, confirm-speaker, backfill-speaker-embeddings, speaker-suggestion-report, get-speaker-sample-audio, and more), plus a new speakers IPC group

Honest caveat: cross-meeting matching quality isn't great yet

The suggestion engine's job of deciding "is this Speaker 2 from today's meeting the same real person as someone from a previous recording" is really hard, and right now it doesn't work as well as I'd like. The reason is specific, not just "needs more tuning": validating raw embedding-similarity matching against real ground truth (the AMI Meeting Corpus) found that people sharing a room/mic score artificially similar to each other regardless of true identity, and no amount of threshold/margin adjustment fixed that safely without also rejecting correct matches. So the underlying acoustic signal for cross-recording identity is inherently noisier than I'd want, and the gating above is damage control on top of that, not a solved problem. Auto-assigning a name silently based on it would misattribute real conversations, which is exactly why this is a confirm-first suggestion, never a silent match.

The good news is the part that matters most still works well independent of that: within a single meeting, telling multiple people apart from each other (this is Speaker 1, this is Speaker 2, distinct throughout) is a completely different and much more reliable task than matching identity across recordings. It's relative separation within one fixed acoustic environment, not absolute identity-matching across different sessions/mics/rooms. That part is solid and validated (the 98.2%/near-zero-spurious-speaker numbers above). So even where the cross-meeting "is this the same Julian as last week" suggestion is hit-or-miss, the user still gets a real, immediate win every single meeting: a note that distinguishes "Julian: ..." from "Christian: ..." instead of anonymous "Speaker 2"/"Speaker 3," by diarizing the meeting and then just manually assigning the right names once via the same approval UI. The cross-meeting suggestion is a bonus on top when it works, not the thing carrying the feature.

Testing status

  • 724 unit tests passing (repo-wide, no regressions)
  • Full e2e coverage: 3 new specs (speaker-diarization.t2, real Parakeet/whisper ASR plus a fixture sidecar; speaker-naming.t2, real backend; speaker-review.t1, the panel's four-action interaction surface), plus processing-stages.t1 for the new progress UI
  • Validated end-to-end against real production data while finalizing this branch: rebuilt the full PyInstaller backend and DMG, ran it against my actual ~/Library/Application Support/stenoai. All 7 real person profiles and their embeddings loaded and matched correctly, setup-check/status clean, real meetings rendered correctly in the packaged app
  • Honest caveat: this has had thorough testing from just me on real usage, not broad user testing yet

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions