Skip to content

feat(speakers): human-confirmed cross-recording speaker identification - #1

Draft
valentinweyer wants to merge 23 commits into
diarization-onlyfrom
speaker-identity
Draft

feat(speakers): human-confirmed cross-recording speaker identification#1
valentinweyer wants to merge 23 commits into
diarization-onlyfrom
speaker-identity

Conversation

@valentinweyer

Copy link
Copy Markdown
Owner

Description
Adds human-confirmed cross-recording speaker identification on top of the diarization work in stenolabs#455: once a diarized speaker cluster exists ("Speaker 2", "Speaker 3", ...), a person can be named once, and the same voice gets suggested (never auto-applied) the next time it shows up in a different recording. This depends on stenolabs#455 and is opened against that branch (diarization-only) rather than main, so the diff here is identity-only. It will retarget to main automatically once stenolabs#455 merges.

The suggestion engine is deliberately not silent. Every match is a human-confirmed suggestion, shown with a sample audio clip so the person confirming can actually listen before accepting. This was a direct design response to validating against the AMI Meeting Corpus: same-room/same-mic speakers score artificially similar to each other regardless of true identity, so a distance threshold alone is not safe to auto-apply. Confirming person A next to person B in the same meeting also writes a mutual hard negative (their embeddings become explicit negative evidence against each other), which is what keeps repeat false positives down over time.

Speaker embeddings are extracted by the same Swift/CoreML sidecar diarization already uses (diarize-sidecar/), via FluidAudio's WeSpeaker model, one call per channel, no second diarization pass.

Ben, three of the specific points you raised on stenolabs#359 turned into real fixes rather than just answers, since verifying them against the actual code surfaced a genuine gap:

Deletion was incomplete. delete_person_profile used to only remove the deleted person's own profile entry. Confirming two different people in the same meeting writes mutual hard negatives, so deleting one person left their voice sample sitting inside the other person's hard_negatives list indefinitely. Fixed: deletion now walks the deleted person's own confirmed evidence and strips the matching hard-negative entry from every other profile, the exact reverse of how they were created. Verified end to end against a real on-disk profile store, not just unit tested.
There was no way to turn identity matching off independently of diarization. Added a setting (default on) that, when off, stops per-meeting speaker embeddings from ever being extracted into speaker_clusters or persisted to a {stem}_speakers.json sidecar, and stops self-voice matching. Diarization's own "Speaker N" splitting is untouched, since that only depends on diarizer segments, not embeddings. The manual backfill-speaker-embeddings CLI (the one path that bypasses the normal per-meeting flow) checks the same setting and refuses when it's off, so the toggle can't be silently bypassed.
Bundle size: measured rather than guessed. The identity half's actual model footprint is 13.1 MB (pyannote_segmentation.mlmodelc 5.5 MB + wespeaker_v2.mlmodelc 7.6 MB, FluidAudio's own WeSpeaker embedding model), downloaded on first use and cached the same way Sortformer's own diarization model already is, not bundled into the app at build time.
On your other two: Windows already confirmed safe. _resolve_steno_diarize() returns None immediately off-darwin, before any diarization or identity code runs, and there's no separate Windows code path anywhere in speaker_suggestions.py or config.py to fail. The UI side is just silent (no diarization data means the speaker review panel doesn't render, no error, no message either) rather than crashing. The 4-speaker cap is unchanged from stenolabs#455, still silent conflation past 4 speakers, no detection heuristic added here.

Type of Change
New feature
Testing
811 backend unit tests passing (python -m unittest discover tests), including the mutual-hard-negative deletion fix (verified against a real on-disk profile store showing the entry is gone after delete, and that an unrelated hard negative on a different meeting survives) and the new setting (verified that disabling it produces an empty speaker_clusters and that self-voice matching does not run, using a self-voiceprint deliberately set up to match if the gate were not working).
ruff check clean on every file this PR touches (same pre-existing, unrelated simple_recorder.py violations as main, nothing new).
Renderer tsc --noEmit clean, eslint clean (0 errors) on the new Settings toggle and the speaker review panel.
Manually verified the full CLI path for the new setting, including backfill-speaker-embeddings correctly refusing with a clear error when identity matching is disabled.
Additional Notes
Speaker embeddings live inside the same config.json as every other setting, under person_profiles, not a separate file. The org auto-backup path only ever uploads generated meeting markdown and transcript text, it never reads config.json, so embeddings are not included in that backup, though that's a consequence of the backup pipeline's scope rather than a deliberate exclusion filter, worth being precise about if it comes up.

Adds a macOS-only Swift/CoreML sidecar (diarize-sidecar, wrapping
FluidAudio's Sortformer) that diarizes the mic and system-audio channels
independently, so multiple speakers sharing one side of a call (in-person
conversations on mic, or multiple remote participants on system audio) get
labelled "You" / "Speaker 2" / "Speaker 3" instead of being 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
behaviour, so this can never fail a meeting.

Two fixes from testing against a real in-person recording:
- is_diarised now reflects whether the output actually has more than one
  speaker label, not whether both channels had content — the old check
  discarded the whole labelled transcript whenever one channel (e.g. system
  audio with nothing playing) was empty.
- Long Parakeet sentences that span multiple real diarizer turns (no strong
  punctuation break in a long run of speech) are now split at the word
  level and reassigned per-word, instead of forcing the entire block onto
  whichever diarizer segment the sentence's midpoint happened to land in.
Mono recordings (many imports — phone voice memos, single-track exports)
had no channel split to fall back on, so they got zero speaker labelling,
even when the track genuinely has multiple speakers. Runs steno-diarize
directly against the whole file instead, reusing the same per-channel
tagging/placeholder-resolution helpers the stereo path uses, treating the
single track as the "You" channel — consistent with the pre-diarization
convention of attributing an unlabelled mono recording to the user. A
single real speaker still produces a plain, unlabelled transcript exactly
as before.
Sortformer was configured with .default (fastV2_1: 0.48s of audio per
CoreML invocation, ~1.04s latency) -- tuned for live/streaming
responsiveness this app has no use for, since diarization only ever
runs on a fully-recorded, already-finished channel. Switched to
.highContextV2 (27.2s per invocation) for recordings long enough to
benefit: ~56x fewer invocations for the same audio (~400 vs ~22,500 for
a 3-hour file). Measured on a real ~21-minute recording: 153s -> 23s.

V2, not V2.1: FluidAudio's own docs note V2.1 "may degrade when many
speakers are talking simultaneously" -- a real risk given this app's
crosstalk/echo findings from earlier diarization work.

Real regression found and fixed during validation: highContextV2's
chunk loader requires a full ~30.4s window before it emits anything at
all -- a 12.25s test clip came back with zero segments. Added
sortformerHighContextMinDuration (90s, real margin above the hard
minimum) so recordings shorter than that keep using .default.

Accuracy validated against the same real file, not just "it still
runs": 98.2% agreement on the dominant speaker's per-second attribution,
a near-zero spurious "4th speaker" (0.6s total) cleanly disappeared,
and total detected speech stayed within ~1%. The GPU-vs-ANE compute
units env var wired into the manual/backfill CLI paths in a prior
commit (measured separately: 23.0s ANE vs 18.0s GPU on the same file)
stays opt-in-only -- the normal per-meeting pipeline keeps the
power/thermal-efficient ANE default.
Ports the diarization-relevant slice of 04c2be1 (which mixed diarization,
progress, and identity concerns in one commit) onto the standalone
diarization branch, minus everything identity-specific:

- CHANNEL_DOMINANCE_THRESHOLD: a channel with one overwhelmingly dominant
  speaker is treated as single-speaker rather than spawning a phantom
  second speaker from a misdiarization blip.
- CHANNEL_DETECT_TIMEOUT_S: the channel-count probe's fixed 15s timeout
  silently dropped long WebM recordings to mono; scaled to 60s.
- _run_steno_diarize rewritten to Popen + two reader threads (avoids the
  classic pipe-deadlock on large stdout payloads) with a real JSON
  decoder scan for the last valid segment array, tolerating FluidAudio/
  CoreML warning text before, between, or after the payload.
- _heartbeat_while_waiting + PROGRESS:diarize:{label}:start/:done so a
  long diarization pass doesn't look hung to Electron's inactivity
  watchdog or sit on a static spinner.
- Pre-processing audio start log line, so loudnorm's two-pass analysis
  doesn't look like a hang on a long recording either.

The sidecar's Output contract stays a bare segment array (this branch
never extracts voiceprint embeddings), so the parser and its tests are
array-only rather than the array-or-object form the full identity branch
needs.
Renders the PROGRESS:diarize:{label}:start/:done markers (added to the
backend in the previous commit) as a real UI stage instead of a static
"Analyzing transcript" spinner sitting through a diarization pass that
can run for minutes on a long recording.

- New 'diarizing' stage with an elapsed-time ticker (this branch's
  sidecar has no per-chunk checkpoint to report a percentage from, so
  a plain "(Ns)" counter is the only way to show the stage is alive).
- Fixes a real bug the new diarize progress lines would otherwise hit:
  the processingProgress handler used to key off ANY PROGRESS: line
  unconditionally to flip transcribing -> summarizing; without a
  prefix check, a PROGRESS:diarize:* line would have prematurely
  jumped the stage to "summarizing" while diarization was still
  running. Now branches on the PROGRESS:summarize:/PROGRESS:diarize:
  prefix explicitly.
- Clears chunkProgress on every stage transition (summarize-complete,
  processing-complete, retry) so a stale diarizing/summarizing
  sub-label can't leak into finalizing/error/a retried run.
- main.js: PROGRESS:diarize:* markers now persisted to the on-disk
  pipeline log (already true for HEARTBEAT); the live renderer forward
  needed no change since the existing PROGRESS: forwarder is generic.
- New processing-stages.t1.spec.ts (mock IPC, real webContents.send
  events) -- Processing.tsx had zero test coverage before this.
canRetry (Processing.tsx) requires both retryAudioFile (from
processing-complete's audioFile field) and activeSession (from
recording.sessionName) to be truthy. The spec reached /meetings/processing
via a bare URL hash with no active mock recording, so activeSession stayed
null and the retry-button assertion hung waiting on a permanently-disabled
button. Start a mock recording first, matching how the screen is actually
reached in real usage, and include audioFile in the failure payload.
Adds cross-recording speaker identification as a human-confirmed
suggestion flow rather than silent auto-matching: validating raw
embedding-similarity auto-matching against real ground truth (AMI
Meeting Corpus) found that people sharing a room/mic score
artificially similar regardless of true identity, which no
threshold/margin tuning fixed safely.

- PersonProfile/SpeakerPrototype CRUD (src/config.py) with hard-negative
  and context-aware (in-person vs remote) evidence.
- Suggestion service (src/speaker_suggestions.py): threshold + margin +
  stability + hard-negative gating, same-meeting fragment merging,
  transcript relabeling, sample-audio/text extraction for identification.
- CLI surface for backfill/validation: list/create/rename/delete person
  profiles, suggest-speakers, confirm-speaker, backfill-speaker-embeddings,
  speaker-suggestion-report, get-speaker-sample-audio.
- Approval UI (SpeakerReviewPanel, useSpeakerSuggestions) wired into
  MeetingDetail: approve/change/new-person/keep-generic, duplicate-name
  prevention, delete with row-visibility preservation.
- New `speakers` IPC group (main.js/preload.js/ipc.ts), with
  parsePythonFailureJson fixing a real bug where a graceful CLI failure's
  clean JSON was discarded in favor of a raw stderr-wrapped error.
- e2e coverage: speaker-naming.t2 (real backend) and speaker-review.t1
  (mock IPC, covers the panel's four-action interaction surface).
Re-adds the WeSpeaker embedding-extraction path (buildOverlapExcludedMasks,
resampleMask, aggregateCentroids, accumulateChunkEmbeddings,
extractSortformerEmbeddings) and the {"segments","speakers"} Output
contract stripped from the diarization-only branch, where nothing
consumed it. The identity half built on this branch needs real per-speaker
embeddings to match against stored voiceprints/person profiles.
Restores a change that predates the RFC stenolabs#327 main.js/backend-cli.js split
and was lost when d487541 was cherry-picked onto post-split main.js.
Python defaults to block-buffering stdout/stderr when they're piped (not
a TTY), so a logger.info() call can sit unflushed for minutes on a long
operation -- starving Electron's inactivity watchdog of the HEARTBEAT:/
PROGRESS: lines it needs to tell real silence from buffered-but-alive.
Harmless for non-Python binaries (ollama/ffmpeg), which never read this
env var.
Restores the 'diarizing' stage's embedding-percentage sub-progress
(PROGRESS:diarize:{label}:embedding:{i}/{n}, now real again since the
sidecar re-extracts embeddings) and PROGRESS:transcribe:{done}/{total}
handling, both dropped from the diarization-only branch where neither
the sidecar nor the ASR backend emitted them.
Widens the diarization-only branch's PROGRESS:diarize:-only disk-log
allowlist to cover any PROGRESS: line, with the same 10s throttle
HEARTBEAT already gets for the high-frequency ones (transcribe sample
counts, diarize embedding chunks) -- both now real again since the
sidecar re-extracts embeddings and transcribe progress is wired back in.
Stage-transition markers (start/done, summarize step/reducing) stay
unthrottled since they're rare.
Restores src/transcriber.py and its test file to the original
identity-branch content: VOICEPRINT_DISTANCE_THRESHOLD/CONFIDENCE_MARGIN,
_voiceprint_distance, _apply_voiceprint_matches, the speaker_suggestions
import, allow_self_match/clusters_out on _tag_channel_segments, and
speaker_clusters assembly in transcribe_diarised/_transcribe_diarised_mono.
Zero upstream drift on this file (confirmed: only cloud ASR added then
reverted), so restoring wholesale rather than hand-splicing onto the
diarization-only branch's rewrite guarantees byte-for-byte fidelity with
what's actually shipping, with no risk of a manual-port transcription error.
…ceprint

Rebased onto current main (post mic-selection merge, Settings redesign,
pill dock). Speaker-suggestion refinements, transcriber tweaks, and new
CLI test coverage (backfill-participants, full-reprocess, meeting-pipeline
transcribe-audio).
…ative hard-negatives

- Store channel on SpeakerPrototype/hard-negative entries; a
  (meeting_id, diarization_speaker_id) pair alone is ambiguous since mic
  and system channels number diarizer clusters independently, and this
  collision was silently poisoning hard-negative evidence across channels
- confirm-speaker now reassigns a cluster on re-confirm ("Change"):
  removes the superseded person's prototype and hard negatives instead of
  both people keeping conflicting evidence for the same voice
- Hard-negative suppression is relative to positive evidence, not an
  absolute cutoff -- a strong match no longer gets discarded by an
  ordinary-distance negative from someone else's confirmation
- suggest_speakers_for_meeting enforces person exclusivity across ALL
  channels of a meeting (was per-channel), assigning clusters in
  best-distance order instead of sorted-id order
- Add repair-speaker-profiles CLI (dry-run by default) to clean up
  existing collision-created negatives/duplicates and backfill channel
  on legacy prototypes from their meeting's sidecar
- Add a self-match diagnostics section to speaker-suggestion-report
Bridges the two separate speaker-identity systems: someone confirmed as a
named PersonProfile (via confirm-speaker) had no way to power the self
("You") voiceprint from that evidence, so self-matching stayed unenrolled
even when a person's own confirmed prototypes existed.

New enroll-self-from-person CLI feeds a person's positive prototypes
(never hard_negatives) through the existing save_voiceprint running-
centroid + recent-samples machinery, oldest-first. Prefers mic-channel
prototypes (self-match only ever runs on the mic channel), falling back
to all prototypes if none are on mic.
Same gap as the diarization-only branch: canRetry (Processing.tsx)
requires both retryAudioFile (from processing-complete's audioFile field)
and activeSession (from recording.sessionName) to be truthy. Start a mock
recording first, matching how the screen is actually reached in real
usage, and include audioFile in the failure payload.
…hing

Two real gaps found while verifying claims for an upstream reviewer
(Ben/Optic00 on stenolabs#359), not just answering questions:

1. delete_person_profile only removed the deleted person's own profile
   entry. confirm-speaker writes MUTUAL hard negatives whenever two
   different people are confirmed in the same meeting+channel -- the
   deleted person's own voice sample kept living on inside everyone
   else's hard_negatives list forever. Now walks the deleted person's own
   prototypes (each already tagged with the meeting/channel/sid they were
   confirmed under) and reuses the existing remove_speaker_evidence to
   strip the matching hard-negative entry from every other profile --
   the exact reverse of how confirm-speaker created them.

2. No setting existed to disable cross-recording speaker identification
   independently of diarization. Adds identity_matching_enabled (default
   on), gated at transcriber.py's _tag_channel_segments call sites: when
   off, allow_self_match is always False and clusters_out is always None,
   so per-meeting speaker embeddings never reach speaker_clusters and are
   never persisted to a {stem}_speakers.json sidecar. Diarization's own
   "Speaker N" splitting is untouched, since it only depends on diarizer
   segments, not embeddings. The manual backfill-speaker-embeddings CLI
   (the one path that bypasses the normal per-meeting flow) checks the
   same setting and refuses when disabled, so the toggle can't be
   silently bypassed.

Full Config/CLI/IPC/Settings-UI wiring for the new setting follows the
existing auto_install_when_idle pattern exactly.
…invariant

CI found this failing 3/3 times on the macOS T2 pipeline lane, not a flake.

isSayAvailable() only probed `say -v ?` (listing voices), which exits 0
even on a runner where `say` can't actually synthesize speech -- observed
producing ~170 bytes of near-silent PCM (~5ms) instead of real audio,
presumably missing voice assets. That let micBoundarySeconds come out at
~0.5s instead of the several seconds a real sentence takes, which fed
straight into a tailSeconds formula with an unconditional 1.5s floor
(Math.max(1.5, ...)) that made the very next assertion
(tailSeconds < micBoundarySeconds) mathematically impossible to satisfy
below a 1.5s boundary. Passed locally only because real speech synthesis
on a real Mac comfortably exceeds that.

Fixes both: isSayAvailable() now synthesizes a short real phrase and
measures what it actually wrote, skipping loudly (existing test.skip path)
when it's implausibly short, rather than trusting a voice-list probe that
doesn't exercise synthesis at all. tailSeconds is now a bounded fraction
of micBoundarySeconds with no floor above it, so the invariant holds for
any micBoundarySeconds > 0 -- not just relying on the environment guard to
keep it out of the impossible range.
This PR makes two existing statements false. docs/faq.mdx's "Can Steno
record in-person meetings?" said in-person recordings have no speaker
labels at all -- after this PR, the mono/mic-only path is exactly where
acoustic diarization gets used, so that's now the headline case that
gains labels, not the one that lacks them.

docs/features/recording.mdx's "Speaker labels" section had the same gap
from the other direction: it described labeling as something that only
happens when system audio is on (the [You]/[Others] channel split),
omitting the new within-channel acoustic split entirely.

Both now describe the real constraint precisely: up to four distinct
voices per channel, not four total, since diarization runs independently
on each channel. Framed that way because it matters for the common case
-- a two-person call is one person per channel, comfortably under the
per-channel limit either side, so most users never approach it, but a
flat "four speakers" would incorrectly suggest otherwise.
parsePythonFailureJson() in main.js recovers a graceful {"success":
false, "error": ...} a CLI command printed to stdout before exiting
non-zero, but runPythonScript's rejection never attached .stdout to
the Error, so the recovery always failed and every graceful CLI
failure (e.g. "already exists" on a duplicate person name) surfaced
as a generic "Python script failed with code 1: <stderr>" instead.
@Optic00

Optic00 commented Aug 2, 2026

Copy link
Copy Markdown

I ran both branches on top of current main against real recordings and against public CC-licensed talks. Diarization holds up; the identity half is reachable in fewer situations than it looks. Proposals that came out of this are on stenolabs#359 so this stays about the PR.

What works

Two media.ccc.de talks, same two speakers one year apart, mono. Seeded from the first, froze the library, ran the report:

result
both re-identified in the later talk 0.0508 / 0.0848
assignments swapped no
control talk, two other people 0 suggestions (0.55-0.72)
mutual hard negatives present in both profiles

Nothing sits between 0.08 and 0.55. Two more things in your favour: in a 56-minute two-party call the live path produced exactly one cluster per channel, so the phantom speaker I flagged on stenolabs#455 does not become a ghost participant. And in one talk your identity layer re-merged a speaker that Sortformer had split into two clusters.

Three ways the panel never appears

  1. Cold start. SpeakerReviewPanel.tsx:219 returns null when no row is actionable, and with an empty library every row is status='none' with zero candidates (measured on a real meeting: 1 mic + 4 system clusters, all of them). confirmSpeaker is called only from this component, so there is no other way to create the first profile. Hiding non-actionable rows is clearly deliberate; it just also hides the entrance.
  2. Automatic notes off. simple_recorder.py:1329 returns 110 lines before write_speakers_sidecar (1488), after diarization has already run. Reprocess does not pick it up either (3319 writes the snapshot, no sidecar call). A sidecar call before that return would cost nothing, the clusters are already in memory.
  3. Backfill. It writes the sidecar but not is_diarised, which the panel gates on (:198). 3 of 12 meetings here had complete speaker data and no panel.

Delete keeps the voice embeddings

Reproduced end to end: after deleting a diarized meeting through the app, note, transcript and audio were gone and the 84 KB _speakers.json was still on disk. app/main.js:4810 lists only ['_reports.json', '_original.json']. The comment above it already makes the argument, written for _original.json, and it weighs more here because this file holds embeddings rather than text. Same class as the _original.json miss in stenolabs#446: a new per-meeting sidecar does not inherit the delete lifecycle.

The 4-speaker cap, now observed rather than inferred

In a real 6-person recording the person who was present confirmed by ear that one suggested cluster held two speakers who briefly overlap early on. main.swift:7 explains why: four fixed slots, a model property rather than a bug. But nothing says so, and that user nearly confirmed the merged cluster as a single person, which would have poisoned the profile library permanently. Suggestions for this are on stenolabs#359.

One change I made to your code

Merging with stenolabs#441 conflicted in Processing.tsx. I kept your progress logic but resolved the stage headings to translation keys and added processing.stage.diarizing; your sub-labels stay English. You never reviewed that, so better said than found in a diff. If you would structure the progress state differently, that resolution is mine to redo.

valentinweyer and others added 3 commits August 3, 2026 22:52
… speaker-identity

# Conflicts:
#	app/e2e-mock-ipc.js
#	app/renderer/src/routes/Processing.tsx
feat(diarize): per-channel acoustic speaker diarization (Sortformer)
The diarization work landed on the shared branch as the rebased commits
(stenolabs#455), while this branch was cut from their pre-rebase originals, so the
same changes arrived twice with different ancestry. Every conflicting hunk
was that duplication and is resolved to this branch's side; the merge then
introduced a second copy of formatElapsedSeconds, which is removed again.

Genuinely new from the shared branch, and the only net change here: the
processing-stages spec now launches with fakeAudio (the CI runner has no
audio device) and waits for the generation bump to settle before emitting.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants