Skip to content

fix(offline-diarizer): pyannote-parity clustering — threshold semantics, constraint count, constrained assignment#802

Open
Alex-Wengg wants to merge 1 commit into
mainfrom
fix/offline-diarizer-clustering-parity
Open

fix(offline-diarizer): pyannote-parity clustering — threshold semantics, constraint count, constrained assignment#802
Alex-Wengg wants to merge 1 commit into
mainfrom
fix/offline-diarizer-clustering-parity

Conversation

@Alex-Wengg

Copy link
Copy Markdown
Member

Why is this change needed?

Fixes #801: OfflineDiarizerManager with .default config and with clustering.numSpeakers = 2 returned the same speaker count (2) but materially different partitions (69% vs 92% correctly-attributed speech on the reporter's 2-speaker clip). Every observation in the reporter's 20-config sweep decodes exactly to three porting bugs against pyannote community-1's VBxClustering:

1. AHC threshold was inverted (issue Q2 — confirmed). The config value is documented as a "Euclidean distance threshold for unit-normalized embeddings", and pyannote applies it directly as the dendrogram cut: fcluster(dendrogram, threshold, criterion="distance"). Our port reinterpreted it as a cosine similarity and cut at sqrt(2 − 2t), which inverts the knob (raising it split more instead of merging more) and shifted the default 0.6 to an effective cut of 0.894. The reporter's sweep maps perfectly through this formula: 0.3/0.4/0.45 → cuts 1.18/1.10/1.05 (merged to 1 speaker), while 0.6–1.1 → cuts 0.894–0.0 (byte-identical near-singleton AHC output). The value is now applied directly, clamped/validated to (0, 2].

2. Speaker-count constraints compared against the wrong count (issue Q1 — it's a bug). pyannote decides whether to fall back to K-Means by comparing the requested count against auto_num_clusters — the clusters VBx actually kept (pi > 1e-7). We compared against the AHC warm-start count, which on this audio was far above 2 (AHC produced near-singletons, VBx collapsed them to 2). So numSpeakers=2 always triggered K-Means re-clustering — a completely different algorithm — even though auto had already detected 2. Hence same N, different partition. The check now uses the new VBxOutput.activeClusterCount. This also explains the reporter's side observations: minSpeakers=2 never bound (compared against the inflated count), and numSpeakers=2 + threshold=0.5 "cancelling the win" (cut 1.0 made AHC itself return 2, so the constraint stopped binding).

3. Missing constrained assignment (the actual quality gap). Fixing (2) alone would regress the reporter's clip to the bad 69% partition — the K-Means path was accidentally winning. pyannote assigns embeddings with constrained_argmax: local speakers sharing a segmentation chunk must map to distinct clusters (Hungarian matching per chunk, maximizing total similarity). Our plain per-embedding argmax let two co-chunk speakers snap to the same centroid, silently absorbing one speaker's turns into another's — the mechanism behind the bad 82s-boundary partition. Ported it behind Clustering.constrainedAssignment (default true), auto-disabled when the count is forced via K-Means, matching pyannote. The Hungarian solver moved from DiarizationDER.swift into an internal HungarianAssignment enum with a rectangular max-score wrapper.

Behavior change / migration

The threshold knob keeps its numeric default (0.6, the community-1 value) but its meaning changes to pyannote's: larger = more merging = fewer speakers. Old values map to new ones via sqrt(2 − 2·old) — old 0.6 behaved like 0.894, and the old AMI advice of --threshold 0.7 corresponds to 0.775 today. Benchmarks.md and CLI help are updated; the AMI-SDM offline table should be re-benchmarked on this branch since it was measured under the old semantics.

Validation

  • Unit tests for the threshold direction/extremes, activeClusterCount, the Hungarian solver, and the constrained assigner (including the exact Offline diarizer: auto clustering and numSpeakers=2 return the same speaker count but different partitions (69% vs 92% correctly-attributed speech) #801 failure mode: two co-chunk embeddings both nearest to the same centroid).
  • swift build + swift-format clean; XCTest runs in CI (unavailable locally).
  • The reporter offered to re-run patches against their private audio — a build of this branch with stock .default config is the ideal check; the prediction is the auto path now finds the 62s boundary on its own.
  • Caveat: pyannote community-1's config.yaml is gated on HF, so the shipped ahc_threshold (assumed 0.6-as-distance, within the code's Uniform(0.5, 0.8) prior) is inferred from source semantics, not read from the config.

…cs, constraint count, constrained assignment (#801)

Three porting bugs against pyannote community-1, all surfaced by #801 where
auto clustering and numSpeakers=2 returned the same speaker count (2) but
materially different partitions (69% vs 92% correctly-attributed speech):

1. AHC threshold: the config value was documented as a Euclidean cut
   distance (pyannote applies it directly via fcluster(..., "distance")),
   but the implementation reinterpreted it as a cosine similarity and cut
   at sqrt(2 - 2t). This inverted the knob's direction (raising the value
   split more instead of merging more) and shifted the default 0.6 to an
   effective cut of 0.894. The value is now applied directly, clamped to
   [0, 2]; config validation and CLI help updated to match. Old configs
   map to new ones via sqrt(2 - 2*old): old 0.6 ≈ new 0.894, old 0.7 ≈
   new 0.775.

2. Speaker-count constraints compared against the AHC warm-start cluster
   count instead of the number of clusters VBx actually kept (pi > 1e-7,
   pyannote's auto_num_clusters). Since the user-visible detected count is
   the post-collapse survivor count, numSpeakers=N triggered K-Means
   re-clustering even when VBx had already collapsed to exactly N,
   producing a different partition at the same count. The check now uses
   VBxOutput.activeClusterCount.

3. Embedding-to-centroid assignment was a plain per-embedding argmax,
   which lets two local speakers sharing a segmentation chunk snap to the
   same centroid — silently absorbing one speaker's turns into another's
   (the mechanism behind the bad 82s-boundary partition in #801). Ported
   pyannote's constrained_argmax: per chunk, local speakers are matched to
   distinct clusters via Hungarian assignment maximizing total similarity.
   Controlled by Clustering.constrainedAssignment (default true) and
   automatically disabled when the count is forced via K-Means, matching
   pyannote. The Hungarian solver moved from DiarizationDER.swift into an
   internal HungarianAssignment enum with a rectangular max-score wrapper.

Docs: Benchmarks.md threshold guidance rewritten for the new semantics
(the old --threshold 0.7 advice corresponds to 0.775 today); min/max/
numSpeakers doc comments now state that the floor alone cannot correct an
under-partition and that numSpeakers is a target, not a guarantee.
@github-actions

Copy link
Copy Markdown

VAD Benchmark Results

Performance Comparison

Dataset Accuracy Precision Recall F1-Score RTFx Files
MUSAN 94.0% 89.3% 100.0% 94.3% 796.6x faster 50
VOiCES 94.0% 89.3% 100.0% 94.3% 793.9x faster 50

Dataset Details

  • MUSAN: Music, Speech, and Noise dataset - standard VAD evaluation
  • VOiCES: Voices Obscured in Complex Environmental Settings - tests robustness in real-world conditions

✅: Average F1-Score above 70%

@github-actions

Copy link
Copy Markdown

Parakeet EOU Benchmark Results ✅

Status: Benchmark passed
Chunk Size: 320ms
Files Tested: 100/100

Performance Metrics

Metric Value Description
WER (Avg) 7.03% Average Word Error Rate
WER (Med) 4.17% Median Word Error Rate
RTFx 11.20x Real-time factor (higher = faster)
Total Audio 470.6s Total audio duration processed
Total Time 44.7s Total processing time

Streaming Metrics

Metric Value Description
Avg Chunk Time 0.045s Average chunk processing time
Max Chunk Time 0.089s Maximum chunk processing time
EOU Detections 0 Total End-of-Utterance detections

Test runtime: 1m11s • 07/16/2026, 05:22 PM EST

RTFx = Real-Time Factor (higher is better) • Processing includes: Model inference, audio preprocessing, state management, and file I/O

@github-actions

Copy link
Copy Markdown

PocketTTS Smoke Test ✅

Check Result
Build
Model download
Model load
Synthesis pipeline
Output WAV ✅ (157.5 KB)

Runtime: 0m25s

Note: PocketTTS uses CoreML MLState (macOS 15) KV cache + Mimi streaming state. CI VM lacks physical GPU — audio quality and performance may differ from Apple Silicon.

@github-actions

Copy link
Copy Markdown

ASR Benchmark Results ✅

Status: All benchmarks passed

Parakeet v3 (multilingual)

Dataset WER Avg WER Med RTFx Status
test-clean 0.57% 0.00% 5.08x
test-other 1.19% 0.00% 2.94x

Parakeet v2 (English-optimized)

Dataset WER Avg WER Med RTFx Status
test-clean 0.80% 0.00% 5.45x
test-other 1.00% 0.00% 3.46x

Streaming (v3)

Metric Value Description
WER 0.00% Word Error Rate in streaming mode
RTFx 0.59x Streaming real-time factor
Avg Chunk Time 1.486s Average time to process each chunk
Max Chunk Time 1.927s Maximum chunk processing time
First Token 1.752s Latency to first transcription token
Total Chunks 31 Number of chunks processed

Streaming (v2)

Metric Value Description
WER 0.00% Word Error Rate in streaming mode
RTFx 0.62x Streaming real-time factor
Avg Chunk Time 1.444s Average time to process each chunk
Max Chunk Time 1.674s Maximum chunk processing time
First Token 1.439s Latency to first transcription token
Total Chunks 31 Number of chunks processed

Streaming tests use 5 files with 0.5s chunks to simulate real-time audio streaming

25 files per dataset • Test runtime: 7m8s • 07/16/2026, 05:27 PM EST

RTFx = Real-Time Factor (higher is better) • Calculated as: Total audio duration ÷ Total processing time
Processing time includes: Model inference on Apple Neural Engine, audio preprocessing, state resets between files, token-to-text conversion, and file I/O
Example: RTFx of 2.0x means 10 seconds of audio processed in 5 seconds (2x faster than real-time)

Expected RTFx Performance on Physical M1 Hardware:

• M1 Mac: ~28x (clean), ~25x (other)
• CI shows ~0.5-3x due to virtualization limitations

Testing methodology follows HuggingFace Open ASR Leaderboard

@github-actions

Copy link
Copy Markdown

Offline VBx Pipeline Results

Speaker Diarization Performance (VBx Batch Mode)

Optimal clustering with Hungarian algorithm for maximum accuracy

Metric Value Target Status Description
DER 10.4% <20% Diarization Error Rate (lower is better)
RTFx 8.21x >1.0x Real-Time Factor (higher is faster)

Offline VBx Pipeline Timing Breakdown

Time spent in each stage of batch diarization

Stage Time (s) % Description
Model Download 18.356 14.4 Fetching diarization models
Model Compile 7.867 6.2 CoreML compilation
Audio Load 0.061 0.0 Loading audio file
Segmentation 31.512 24.7 VAD + speech detection
Embedding 127.489 99.8 Speaker embedding extraction
Clustering (VBx) 0.113 0.1 Hungarian algorithm + VBx clustering
Total 127.770 100 Full VBx pipeline

Speaker Diarization Research Comparison

Offline VBx achieves competitive accuracy with batch processing

Method DER Mode Description
FluidAudio (Offline) 10.4% VBx Batch On-device CoreML with optimal clustering
FluidAudio (Streaming) 17.7% Chunk-based First-occurrence speaker mapping
Research baseline 18-30% Various Standard dataset performance

Pipeline Details:

  • Mode: Offline VBx with Hungarian algorithm for optimal speaker-to-cluster assignment
  • Segmentation: VAD-based voice activity detection
  • Embeddings: WeSpeaker-compatible speaker embeddings
  • Clustering: PowerSet with VBx refinement
  • Accuracy: Higher than streaming due to optimal post-hoc mapping

🎯 Offline VBx Test • AMI Corpus ES2004a • 1049.0s meeting audio • 159.1s processing • Test runtime: 2m 42s • 07/16/2026, 05:28 PM EST

@github-actions

Copy link
Copy Markdown

Speaker Diarization Benchmark Results

Speaker Diarization Performance

Evaluating "who spoke when" detection accuracy

Metric Value Target Status Description
DER 15.1% <30% Diarization Error Rate (lower is better)
JER 24.9% <25% Jaccard Error Rate
RTFx 26.94x >1.0x Real-Time Factor (higher is faster)

Diarization Pipeline Timing Breakdown

Time spent in each stage of speaker diarization

Stage Time (s) % Description
Model Download 10.636 27.3 Fetching diarization models
Model Compile 4.558 11.7 CoreML compilation
Audio Load 0.042 0.1 Loading audio file
Segmentation 11.680 30.0 Detecting speech regions
Embedding 19.467 50.0 Extracting speaker voices
Clustering 7.787 20.0 Grouping same speakers
Total 38.949 100 Full pipeline

Speaker Diarization Research Comparison

Research baselines typically achieve 18-30% DER on standard datasets

Method DER Notes
FluidAudio 15.1% On-device CoreML
Research baseline 18-30% Standard dataset performance

Note: RTFx shown above is from GitHub Actions runner. On Apple Silicon with ANE:

  • M2 MacBook Air (2022): Runs at 150 RTFx real-time
  • Performance scales with Apple Neural Engine capabilities

🎯 Speaker Diarization Test • AMI Corpus ES2004a • 1049.0s meeting audio • 38.9s diarization time • Test runtime: 2m 42s • 07/16/2026, 05:29 PM EST

@github-actions

Copy link
Copy Markdown

Sortformer High-Latency Benchmark Results

ES2004a Performance (30.4s latency config)

Metric Value Target Status
DER 30.3% <35%
Miss Rate 28.2% - -
False Alarm 0.9% - -
Speaker Error 1.2% - -
RTFx 15.3x >1.0x
Speakers 4/4 - -

Sortformer High-Latency • ES2004a • Runtime: 3m 14s • 2026-07-16T21:31:53.534Z

@github-actions

Copy link
Copy Markdown

Supertonic3 Smoke Test ✅

Check Result
Build
Model download (incl. VectorEstimatorVariants/ int4 buckets)
Model load
Synthesis pipeline (--ve-variant int4)
Output WAV ✅ (364.7 KB)

Runtime: 0m11s

Note: CI VMs lack a physical Neural Engine; the ANE-bucketed VectorEstimator falls back to CPU here. This validates download + variant resolution + synthesis, not ANE residency/perf.

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.

Offline diarizer: auto clustering and numSpeakers=2 return the same speaker count but different partitions (69% vs 92% correctly-attributed speech)

1 participant