Skip to content

Meeting speaker diarization — feature spec & exploration#68

Open
gbrunoo wants to merge 5 commits into
sebsto:spec/meeting-speaker-diarizationfrom
gbrunoo:meeting-diarizer
Open

Meeting speaker diarization — feature spec & exploration#68
gbrunoo wants to merge 5 commits into
sebsto:spec/meeting-speaker-diarizationfrom
gbrunoo:meeting-diarizer

Conversation

@gbrunoo

@gbrunoo gbrunoo commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

Branch setup for exploring speaker diarization in the meeting transcriber — separating the "Others" audio stream into individual speakers ("Speaker 1", "Speaker 2", etc.).

Branched from the meeting-transcriber work (#63) with the upstream SPM layout (#62) already merged in, so this stays in sync with main.

Background

The current meeting transcriber does simple binary routing:

  • Microphone → "You"
  • System audio (ScreenCaptureKit) → "Others"

This is attribution by input source, not by voice identity. Diarization would identify distinct speakers within the system audio stream.

Proposed approach

  1. Speaker embedding model — extract per-segment voice fingerprints (e.g. ECAPA-TDNN / TitaNet), ported to CoreML for on-device inference
  2. Voice Activity Detection — segment system audio into speech turns
  3. Incremental clustering — group embeddings into speaker identities without knowing the speaker count upfront
  4. Model changes — extend MeetingTranscript.Speaker from a fixed enum to dynamic speaker identities (e.g. .other(id: Int, label: String?))
  5. UI — display detected speakers and allow optional renaming

Constraints to respect

  • Privacy: audio stays in-memory only, never written to disk (consistent with existing guarantees)
  • On-device: no network/cloud inference
  • Streaming: must work in near-real-time, not just post-meeting

Status

🚧 Exploratory / spec stage — opening this against spec/meeting-speaker-diarization to align on design before implementation.

Open questions

  • Best on-device diarization model that converts cleanly to CoreML?
  • Streaming vs. post-meeting clustering tradeoff for accuracy?
  • Compute/thermal budget when running embeddings alongside transcription?

@sebsto

sebsto commented Jun 24, 2026

Copy link
Copy Markdown
Owner

Thank you @gbrunoo for the PR !
Can you resync now that I merged #67 ?

gbrunoo added 4 commits June 24, 2026 22:32
Implements the spec from PR sebsto#64: splits the system-audio ("Others")
track into per-speaker labels (Speaker 1, Speaker 2, …) using FluidAudio's
SortformerDiarizer. The mic track stays "You".

- MeetingSpeaker becomes .you / .others(speakerIndex: Int?) with a
  displayName computed property (0-based storage, +1 only at display).
- New MeetingDiarizer actor wraps SortformerDiarizer: warmUp() loads the
  CoreML model, ingest() feeds chunks, dominantSpeaker(in:) resolves the
  speaker over a time window. Sortformer slots map to monotonic per-meeting
  indices. Failures degrade gracefully to plain "Others".
- MeetingAudioEngine exposes a single sampleRate constant and emits the
  system-audio stream as timestamped SystemAudioChunk values.
- MeetingStateManager warms up the diarizer on start (when enabled),
  routes system chunks through ingest + dominantSpeaker, resets on stop.
- MeetingTranscriptView uses displayName with a per-speaker color palette.
- SettingsStore gains meetingDiarizationEnabled (default false, opt-in);
  Settings adds a Meeting section toggle.
- Cold-start nil speaker renders as "Others" until the diarizer warms up.

Tests: MeetingDiarizerTests for the pre-init no-op contract, updated
MeetingTranscript/AudioEngine tests for the new APIs. Diarization quality
is verified via the manual E2E plan in the spec.
The diarizer warmUp() (model load/download, several seconds) ran before
meetingState flipped to .recording, so the button stayed on 'Start
Meeting' with no feedback, and the meetingState==.idle guard was still
true during the gap — a second click double-triggered startCapture().

- Add an isStarting re-entrancy guard.
- Start capture and flip to .recording immediately for instant feedback;
  warm up the diarizer concurrently inside the recording task group.
- Check diarizationActive per-chunk so diarization engages once warmup
  completes mid-meeting.
- MeetingDiarizer now tracks the meeting-relative time of its first
  ingested chunk and offsets segment times, keeping speaker windows
  aligned when the diarizer starts after the meeting has begun.
- stopMeeting resets the diarizer unconditionally (safe no-op) to avoid
  a race with the concurrent warmup task.
- Switch Sortformer from the default fastV2_1 config to balancedV2: same
  ~1s latency but a much larger FIFO (188 vs 40) for better quality, and
  v2 weights handle overlapping/high-speaker-count audio better than v2.1
  (which is documented to degrade and likely caused phantom speakers).
- Give the system-audio path its own shorter chunk size (~3s vs the mic's
  ~5s) so each transcript entry spans less time, reducing how often a
  speaker change inside one entry gets flattened to a single label.
Local editor config and agent skills should not be committed to the repo.
@gbrunoo gbrunoo force-pushed the meeting-diarizer branch from c206890 to 8bfbf52 Compare June 24, 2026 20:32
@gbrunoo

gbrunoo commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

Synced now :)

Fixes issue sebsto#65: without headphones, remote participants' speech plays
out of the speakers and leaks back into the microphone, so the same
utterance was transcribed twice — once as 'Others' (system audio) and
once as 'You' (mic echo).

- MeetingTranscript.appendSuppressingEcho() de-duplicates across the two
  tracks: a 'You' entry that closely matches a recent 'Others' entry is
  dropped, and an arriving 'Others' entry removes a prior 'You' echo
  (handles either arrival order). Matching uses normalized text +
  Levenshtein similarity within an 8s window, skipping short (<3 word)
  backchannels to avoid dropping genuine speech.
- MeetingStateManager routes both transcription paths through record(),
  which applies suppression when enabled (text is never logged).
- SettingsStore gains meetingEchoSuppressionEnabled (default true,
  opt-out); Settings adds a 'Suppress Microphone Echo' toggle.
- Tests: MeetingEchoSuppressionTests for the de-dup contract and
  SettingsStore default/persistence/restore coverage.
@gbrunoo

gbrunoo commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator Author

@sebsto I might have found a fix for #65 but it is clearly a beta feature for now.

Approach: since true audio-level AEC isn't feasible for audio played by another app, this de-duplicates at the transcript layer. A mic ("You") entry is compared against the concatenation of nearby "Others" speech and suppressed when ≥60% of its words are covered (fuzzy per-word matching to absorb ASR drift, e.g. "démonté"/"démonter"). It handles both arrival orders, mic-first entries are pruned once the matching remote lines come in.

It's a best-effort heuristic, so I've labeled the Settings toggle "Suppress Microphone Echo (Beta)" (default on, opt-out). Two tunable knobs: echoCoverageThreshold (0.6) and echoTokenSimilarityThreshold (0.8). Added unit tests covering the merged-span + drift case from a real call, mic-first pruning, and the coverage helper.

Known limitation: in the mic-first case there's a brief visible flicker ("You" line appears, then is replaced by the diarized remote line). Happy to add a short grace delay to remove it if you'd prefer.

@gbrunoo gbrunoo self-assigned this Jun 25, 2026
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