Skip to content

comparison & uncertainty stage for analyze_audio.py#512

Merged
satra merged 14 commits into
alphafrom
20260508-173136-compare-uncertainty
May 10, 2026
Merged

comparison & uncertainty stage for analyze_audio.py#512
satra merged 14 commits into
alphafrom
20260508-173136-compare-uncertainty

Conversation

@satra

@satra satra commented May 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a fourth stage to scripts/analyze_audio.py that consumes the per-task outputs from the existing pipeline and emits comparison artefacts in four classes:

  1. Raw vs enhanced — per (task, model) timeline of where the same model gave a different answer on the raw input vs the speech-enhanced input.
  2. Within-stream — model-pair disagreements per pass (pyannote vs Sortformer; six C(4,2) ASR pairs; AST vs YAMNet).
  3. Cross-stream — ASR-implied speech-presence vs diarization, AST/YAMNet "Speech" subtree vs diarization, ASR-derived ARPAbet phonemes (via g2p_en) vs PPG argmax sequence.
  4. Uncertainty — per-region confidence harvested from the underlying models (Whisper avg_logprob, AST/YAMNet top-1 score + entropy), aggregated via configurable --uncertainty-aggregator (default min).

Outputs (all additive — --skip comparisons produces bit-identical legacy output):

  • <run_dir>/<pass>/comparisons/<task>/<a>_vs_<b>.parquet (within-stream + raw_vs_enhanced)
  • <run_dir>/<pass>/comparisons/cross_stream/...parquet
  • <run_dir>/disagreements.json — top-N ranked by combined uncertainty
  • <run_dir>/timeline.png — multi-row aligned plot showing diarization speakers, ASR token spans, scene-window strips, and per-comparator disagreement bars colored by combined uncertainty
  • LS bundle: one fixed-enum Labels track per (pass, comparison_kind, task, model_pair); ASR-vs-ASR pairs get a sibling TextArea with WER + both transcripts

New CLI flags: --skip-comparisons {raw_vs_enhanced,within_stream,cross_stream}, --cross-stream-win-length (0.2 s), --cross-stream-hop-length (0.1 s), --uncertainty-aggregator {min,mean,harmonic_mean,disagreement_weighted}, --phoneme-disagreement-threshold (0.50), --speech-presence-labels, --asr-reference-model, --diarization-boundary-shift-ms (50.0), --disagreements-top-n (100).

Spec workflow

Followed the full spec-kit cycle: /speckit.specify/speckit.clarify (5 questions) → /speckit.plan/speckit.tasks (57 tasks across 7 phases) → /speckit.analyze (11 findings, all remediated) → /speckit.implement. Every clarification, plan decision, and contract is captured under specs/20260508-173136-compare-uncertainty/.

E2E validation

Run on a 38.4 s real-speech WAV (twin-1.wav) with the full default model list:

Task raw_16k enhanced_16k
diarization (pyannote + sortformer) ok / ok ok / ok
ast / yamnet / features ok / ok / ok ok / ok / ok
asr (whisper / granite / canary-qwen / qwen3-asr) ok / ok / ok / ok ok / ok / ok / ok
alignment (granite / canary-qwen via MMS) ok / ok ok / ok
embeddings (ECAPA + ResNet) ok / ok ok / ok

38 comparison parquets produced; disagreements.json carries 16,513 total rows with 10,133 disagreements; timeline.png rendered as a stacked aligned figure. Full validation report at artifacts/compare_uncertainty_e2e_validation.md (not committed).

Test plan

  • uv run pytest src/tests/scripts/ — 38 passed, 7 skipped (~24 s); covers all four comparison kinds plus the four documented degradation modes.
  • uv run pytest --ignore=src/tests/scripts -x -q — 399 passed, 1 failed; the single failure is the same voice_cloning_test order-flake from PR audio analysis script + multilingual MMS aligner + 4 new ASR backends #510 (passes in isolation; touches nothing the comparator changes). SC-005 (no regression) holds.
  • uv run mypy scripts/analyze_audio.py — clean.
  • uv run ruff check scripts/analyze_audio.py src/tests/scripts/ — clean.

Known gap (follow-up)

disagreements.json ranking currently sorts NaN entries to the top because senselab's HuggingFaceASR wrapper does not opt into Whisper's per-chunk avg_logprob. The harvester (_whisper_chunk_confidence) is correct — it just has nothing to read. Documented in the validation note; small follow-up PR will surface avg_logprob from the HF pipeline.

🤖 Generated with Claude Code

Version

Published prerelease version: 1.3.1-alpha.29

Changelog

🐛 Bug Fix

  • comparison & uncertainty stage for analyze_audio.py #512 (@satra)
  • audio analysis script + multilingual MMS aligner + 4 new ASR backends #510 (@satra)

Authors: 1

satra and others added 7 commits May 8, 2026 20:22
Lays the foundation for the comparison & uncertainty stage. Adds the
new CLI flags, cache key composition, ComparisonRow column lists,
parquet writer with comparator_provenance metadata, ComparisonGrid
dataclass + iter_buckets, _token_overlaps_window helper, uncertainty
aggregator (min/mean/harmonic_mean/disagreement_weighted), LS XML
extension hooks for comparator tracks, and disagreements.json
index builder.

This commit alone produces no reviewer-visible output — the
user-story phases (US1–US4) plug differencer logic into this
plumbing in subsequent commits.

Constitution checks:
- §VIII (No Hardcoded Parameters): every comparator threshold and
  grid resolution is a CLI flag; defaults documented.
- §IV (CI Must Stay Green): existing 12 script tests still pass;
  11 new comparator-foundational tests added (23 total, all green).
- §VII (Simplicity First): comparator code lives inline in
  analyze_audio.py; no new senselab task module needed.

Adds g2p-en to the [nlp] extra (small wheel; needed by the future
US3 ASR↔PPG comparator). NLTK averaged_perceptron_tagger_eng
auto-downloads on first use.

Bumps cache schema 2 → 3 to reflect the new comparator key shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the per-task differencer registry (_diff_diarization,
_diff_classification, _diff_asr) and the top-level
compare_raw_vs_enhanced driver. Wires it into main() so a default
two-pass run now emits parquet sidecars under
<run_dir>/comparisons/raw_vs_enhanced/<task>/<model>.parquet plus a
disagreements.json index ranking the top-N flagged regions.

LS bundle additions: one Labels track per (task, model) actually
compared, named pass_pair__compare__<task>__<model> with the fixed
enum {agree, disagree, incomparable, one_sided}; ASR-vs-ASR pairs
also get a sibling TextArea track carrying WER + both transcripts.
Track names are appended via build_labelstudio_config(... ,
comparator_tracks=tracks); a run with --skip comparisons produces
bit-identical output to current main (FR-005, SC-005 anchored by
test_comparator_skip_no_op_preserves_ls_bundle_shape).

Cache key composition follows the comparison_cache_key helper from
Phase 2, using the upstream-task cache_keys so re-running an
upstream task cleanly invalidates downstream comparisons.

Tests added (29 total now in src/tests/scripts/, all green):
- test_raw_vs_enhanced_diarization_diff covers boundary_shift
- test_raw_vs_enhanced_asr_text_diff covers per-window WER + a/b text
- test_raw_vs_enhanced_handles_failed_pass covers the incomparable
  edge case from FR-010 / US1 acceptance scenario 3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds compare_within_stream that, for each task with two or more
successful models in a pass, iterates itertools.combinations and
emits one parquet per ordered model pair under
<run_dir>/<pass>/comparisons/<task>/<a>_vs_<b>.parquet (US2 layout
in contracts/comparison-row.parquet.md). For ASR pairs the WER row
schema is reused; for diarization the speaks_a/speaks_b shape; for
classification (AST vs YAMNet) the top1/score/entropy shape, with
the existing scene_agreement.json semantics preserved as a strict
subset.

LS bundle: one Labels track per (pass, task, model_pair) with the
fixed enum {agree, disagree, incomparable, one_sided}; ASR pairs
also emit a sibling TextArea track carrying WER + both transcripts.

ASR-vs-ASR honors --asr-reference-model (default Whisper turbo) so
the soft-reference side is consistent across runs.

Tests added (29 total now, all green):
- test_within_stream_asr_pair_emits_wer covers the WER per-bucket path
- test_within_stream_single_model_no_op covers the graceful no-op
  when a task has only one successful model
- test_within_stream_classification_superset_of_scene_agreement
  covers the AST-vs-YAMNet matching-grid path

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds compare_cross_stream and three sub-comparators:

- _diff_asr_vs_diarization: per cross-stream-grid bucket, sets
  asr_says_speech via _token_overlaps_window (Q4 clarification:
  any transcript token whose timestamp range overlaps the bucket
  counts) and diar_says_speech via diarization-segment overlap.
- _diff_classification_vs_diarization: per bucket, picks the
  AST/YAMNet window whose center is closest to the bucket center
  and asks whether its top-1 label is in the configurable
  --speech-presence-labels allowlist (default: AudioSet 'Speech'
  subtree); compares to diarization the same way.
- _diff_asr_vs_ppg: per bucket, derives ARPAbet phonemes from the
  ASR transcript via g2p_en, argmaxes the PPG output frames, and
  reports a continuous phoneme_per (jiwer.wer) plus a boolean
  phoneme_disagreement flag at >= --phoneme-disagreement-threshold
  (default 0.50; Q5 two-tier clarification).

PPG path gracefully degrades when no PPG result is available —
records "PPG backend not provisioned" in disagreements.json's
incomparable_reasons rather than crashing (FR-010, US3 acceptance
scenario 4).

LS bundle: one Labels track per (pass, stream_pair, model_pair)
with the fixed enum {agree, disagree, incomparable, one_sided}.

Tests added (37 total now in src/tests/scripts/, all green):
- test_cross_stream_asr_speech_in_silent_window
- test_cross_stream_classification_speech_allowlist
- test_cross_stream_ppg_unavailable_degrades_gracefully
- test_cross_stream_phoneme_per_two_tier (continuous PER + boolean flag)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds per-region confidence harvesters and threads combined_uncertainty
through every comparator emitted by the previous phases:

- _whisper_chunk_confidence converts Whisper's per-chunk
  avg_logprob -> exp(avg_logprob) confidence in [0, 1] and surfaces
  no_speech_prob alongside.
- _whisper_bucket_confidence averages overlapping chunks within a
  comparison-grid bucket (FR-007: arithmetic mean of native events
  inside one bucket).
- _enrich_diff_asr_with_confidence + _enrich_diff_classification_with_confidence
  populate confidence_a/b and combined_uncertainty in-place on the
  rows produced by the existing differencers.
- _models_without_native_confidence walks the summary and lists the
  pyannote/Sortformer/Granite/Canary-Qwen/Qwen3-ASR backends — these
  contribute to comparisons but lack a native scalar in v1, per
  research.md §2.

The disagreements.json index now ranks regions by combined_uncertainty
descending (NaN-last). The aggregator is the configurable
--uncertainty-aggregator (default min — most-doubtful model wins;
mean / harmonic_mean / disagreement_weighted available). Per FR-007
the missing-confidence model list rides on the index for
transparency.

Tests added (38 total now in src/tests/scripts/, all green):
- test_uncertainty_whisper_avg_logprob_extracted
- test_uncertainty_aggregator_min_default_via_compare (end-to-end:
  build two ASR results with different avg_logprob, verify the
  parquet's combined_uncertainty matches 1 - min(...))
- test_uncertainty_missing_signal_dropped_not_zeroed
- test_disagreements_index_top_n_and_ordering
- test_models_without_native_confidence_lists_known_null

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds build_aligned_timeline_plot which reads a finished run dir and
emits <run_dir>/timeline.png — a stacked, time-aligned figure
showing diarization speaker bars per (pass, model), ASR token
spans per (pass, model), AST/YAMNet scene-window strips colored
by top-1 score, and one comparator row per parquet with
disagreement bars colored by combined_uncertainty (Reds; deeper red
= higher uncertainty).

Hooked into main() at the very end of a run (after disagreements.json
is written) so every default invocation now produces the figure
alongside the existing parquets / JSON. Best-effort: a plot failure
is logged to stderr and the rest of the outputs are unaffected.

Plot row layout, top-down:
- One row per (pass, diarization_model) — speaker-colored bars.
- One row per (pass, asr_model) — token spans (filled bars from
  chunk timestamps, or a single span for text-only ASR after
  auto-align).
- One row per (pass, scene-classifier) — score-shaded windows.
- One row per comparator parquet — disagreement bars; comparator
  agreement is rendered as the empty grey background.

Constitution checks:
- §VII (Simplicity First): one new function, no new module; uses
  matplotlib (already pulled in by pyannote-audio).
- §VIII (No Hardcoded Parameters): figure size scales with
  duration; no hardcoded paths beyond the conventional run_dir.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
T052-T058 marked complete in tasks.md after full E2E validation.

E2E (run twin-1_20260509-032631):
- All 13 tasks succeed across both raw_16k + enhanced_16k passes
- 38 comparator parquets emitted (within_stream / cross_stream /
  raw_vs_enhanced)
- disagreements.json: 16,513 rows, 10,133 disagreements, top-100
- timeline.png: aligned multi-row plot rendered

Senselab full suite: 399 passed, 1 failed (the same voice_cloning
order-flake from PR #510; passes in isolation; unrelated to
comparator). SC-005 (no regression in existing senselab outputs)
holds.

Smoke tests: 38 passed, 7 skipped.

Known v1 gap documented in artifacts/compare_uncertainty_e2e_validation.md:
the senselab HuggingFaceASR wrapper does not currently surface
Whisper's avg_logprob, so the disagreements.json ranking has only
NaN combined_uncertainty values across the whole run. The harvester
is correct — it has nothing to read. Follow-up PR will opt the HF
pipeline into per-chunk avg_logprob.

T059 (PR open) intentionally left unchecked — pending a fresh push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@satra
satra temporarily deployed to docs-preview May 9, 2026 05:04 — with GitHub Actions Inactive
PR #512 opened against alpha:
#512

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@satra
satra temporarily deployed to docs-preview May 9, 2026 05:05 — with GitHub Actions Inactive

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive 'Comparison & Uncertainty' stage to the audio analysis pipeline, enabling automated detection of discrepancies between raw and enhanced audio, disagreements between different models of the same task, and cross-stream inconsistencies. Feedback identifies several critical improvements: including 'ast' and 'yamnet' in raw-vs-enhanced comparisons, refining the 'disagreement_weighted' aggregator with meaningful severity metrics, and removing hardcoded temporal parameters from the timeline plot. Additionally, the reviewer suggests improving the robustness of classification timestamps, managing NLTK dependencies during environment setup to avoid runtime failures, and implementing fallback logic for confidence harvesting when word-level timestamps are unavailable.

Comment thread scripts/analyze_audio.py Outdated
Comment thread scripts/analyze_audio.py Outdated
Comment thread scripts/analyze_audio.py Outdated
Comment thread scripts/analyze_audio.py Outdated
Comment thread scripts/analyze_audio.py Outdated
Comment thread scripts/analyze_audio.py Outdated
Comment thread scripts/analyze_audio.py Outdated
CI's mypy 1.20.1 flagged two var-annotated diagnostics that the
local mypy 1.18.x missed:

- test_comparator_skip_no_op_preserves_ls_bundle_shape: annotate
  ``summary: dict[str, Any]`` so the empty-dict literal doesn't
  collapse to ``dict[str, dict[str, dict[Never, Never]]]``.
- test_comparison_cache_key_changes_with_inputs: convert the
  ``base = dict(...)`` factory call to a literal ``{...}`` with
  an explicit ``dict[str, Any]`` annotation so the inferred value
  type accepts the heterogeneous values (str / list / dict).
  Pull the unpacked-params expression out into a local so mypy
  doesn't try to type-check ``{**base["params"], ...}`` against
  the wrong base inference.

No code changes; all pre-commit hooks pass locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@satra
satra temporarily deployed to docs-preview May 9, 2026 05:08 — with GitHub Actions Inactive
Applies the 5 actionable Gemini comments on the comparator stage:

- HIGH (line 1526): compare_raw_vs_enhanced now also runs for AST and
  YAMNet — they're top-level summary keys (single-model tasks) rather
  than under by_model. Their parquet rows carry real time spans driven
  by the per-task win/hop and combined_uncertainty derived from the
  top-1 score via _enrich_diff_classification_with_confidence.
- MEDIUM (line 725): _enrich_diff_asr_with_confidence now passes the
  per-row WER into _aggregate_uncertainty as ``mismatch_severity`` so
  the disagreement_weighted aggregator actually scales with the
  textual disagreement (matches FR-003 intent; otherwise it
  collapsed to ``mean``).
- MEDIUM (line 991/995): build_aligned_timeline_plot now reads AST
  and YAMNet win/hop from ``summary["scene_window"]`` rather than
  hardcoded 10.24/0.96 constants. CLI overrides surface in the plot
  correctly.
- MEDIUM (line 1316): _diff_classification now uses ``win_length_a``
  + new ``hop_length_a`` to compute real per-row start/end timestamps
  rather than integer placeholders. Both call sites
  (raw_vs_enhanced AST/YAMNet and within_stream AST-vs-YAMNet) pass
  the explicit hop.
- MEDIUM (line 2234): _whisper_bucket_confidence now falls back to
  the segment-level avg_logprob when the parent ScriptLine has no
  chunks — covers post-aligned text-only ASR backends like Granite
  / Canary-Qwen if they ever surface a per-segment scalar in a
  future senselab release.

Also tightens the matplotlib colormap usage to mypy 1.20-compliant
``matplotlib.colormaps.get_cmap`` form (was ``plt.cm.<name>`` which
mypy now flags as attr-defined).

Did NOT change: NLTK runtime download (line 1914). The download
is a one-time cmudict + averaged_perceptron_tagger_eng fetch
(small files); air-gapped envs can pre-populate ~/nltk_data via the
existing ``T001`` setup task before running. Will revisit if a
follow-up requires fully offline operation.

All 38 script smoke tests still pass; mypy + ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@satra
satra temporarily deployed to docs-preview May 9, 2026 14:39 — with GitHub Actions Inactive
@satra

satra commented May 9, 2026

Copy link
Copy Markdown
Collaborator Author

@gemini-code-assist thanks for the thorough review. Pushed 12833a2c addressing the 5 actionable findings:

Applied:

  • HIGH (line 1526): compare_raw_vs_enhanced now also runs for AST and YAMNet (they're top-level keys, not under by_model). Their parquet rows carry real time spans + combined_uncertainty from the top-1 score.
  • MED (line 725): _enrich_diff_asr_with_confidence now passes the per-row WER as mismatch_severity, so the disagreement_weighted aggregator actually scales with the textual disagreement (was collapsing to mean).
  • MED (line 991/995): plot reads AST/YAMNet win/hop from summary["scene_window"] instead of the 10.24/0.96 constants — CLI overrides now surface correctly.
  • MED (line 1316): _diff_classification accepts a new hop_length_a and uses real per-row start/end timestamps rather than integer placeholders. Both call sites updated.
  • MED (line 2234): _whisper_bucket_confidence now falls back to the segment-level avg_logprob when the parent ScriptLine has no chunks — covers the post-aligned text-only path.

Not applied:

  • MED (line 1914) NLTK runtime download: kept as-is for v1. The download is a small one-time cmudict + averaged_perceptron_tagger_eng fetch; air-gapped users can pre-populate ~/nltk_data via the existing T001 setup. Will revisit if fully-offline becomes a hard constraint.

Mypy + ruff clean; all 38 script smoke tests still pass.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Thanks for the update, @satra. The implementation looks solid and addresses the actionable feedback from the review. The decision to keep the NLTK download as-is for v1 is reasonable given the existing T001 setup for air-gapped environments. The integration of the comparison stage into the existing cache and the inclusion of the disagreements.json index are great additions for auditability. I'll mark this as resolved.

…ertainty

The audio_analysis workflow lands as a standalone module under
src/senselab/audio/workflows/audio_analysis/ with the analyze_audio script
as a thin wrapper.

Highlights from this iteration:

- Speech-aware embedding clustering: a YAMNet → AST → openSMILE-loudness
  fallback mask is built per embedding window; non-speech windows are tagged
  NOISE and skip clustering, so silent / music / vehicle stretches no longer
  inflate the speaker count.
- Spectral clustering on a precomputed cosine-similarity affinity is now
  the default, with k-means as the per-k fallback. New CLI flag
  --clustering-algorithm exposes the choice.
- Min-cluster-size guard (max(5, 10% of valid windows)) rejects partitions
  where a 2-3 window outlier is propping up the silhouette score.
- Cross-model speaker unification (clustering.py + plot.py) processes the
  synthetic embedding source first to seed the centroid pool, then freezes
  the pool — so a noisy 3-segment pyannote / sortformer label can't spawn a
  unified third "speaker" beyond what the embedding source validated.

Validation: twin-1.wav unified-speaker count drops from 3 → 2; English
two-speaker conversation stays at 2 (no regression). 57/57 workflow tests
pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@satra
satra temporarily deployed to docs-preview May 10, 2026 16:07 — with GitHub Actions Inactive
…rmat)

- global_summary.py: explicit `list[float]` annotations on stoi/pesq/sisdr accumulators
- test_aggregate.py: `dict[str, dict[str, Any]]` annotations on votes literals
  (mypy 1.20 narrows mixed-value literals to dict[str, object])
- Rename test_*.py → *_test.py to match repo convention
- pyproject.toml: pretty-format-toml auto-fix

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@satra
satra temporarily deployed to docs-preview May 10, 2026 16:40 — with GitHub Actions Inactive
Review findings addressed:

- HIGH: ``min_cluster_size`` floor was 5 windows but ``min_windows_for_clustering``
  is 4 — for passes with 4-9 valid windows every k≥2 partition got rejected
  silently and we fell to single-cluster regime with no diagnostic. Lowered
  floor to ``max(2, round(0.10 * n))`` so the gate is reachable; record a
  ``failures`` entry when all partitions are rejected by min-size.
- HIGH: ``compute_uncertainty_axes`` mutates the caller's ``passes`` dict
  (injects the synthetic embedding diar source). Removed the "pure function"
  claim from the module docstring and documented the mutation explicitly.
- MEDIUM: ``_speech_window_mask`` is a YAMNet veto, not a fallback ladder.
  Documented the veto explicitly (YAMNet decides; AST only when YAMNet
  missing; loudness only when both missing) and the known YAMNet-confusion
  failure modes (child voices flagged as "Music") plus the mitigation.
- MEDIUM: extracted ``assign_unified_clusters_with_seed_phase`` shared
  helper in clustering.py; both ``cluster_speaker_labels_by_embedding`` and
  the plot's ``_cluster_speakers_by_embedding`` now share the seed-then-
  freeze logic.
- LOW: replaced the schema-progression block comment + hardcoded
  ``"schema_version": 1`` parquet literal with a reference to the
  ``_CACHE_SCHEMA_VERSION`` constant so they can never drift.
- LOW: surfaced the spectral→k-means fallback through the ``failures`` dict
  (per-k entries) and recorded which algorithm actually produced the
  partition in the cluster output.

New: same-speaker post-merge step. Spectral / k-means at k≥2 sometimes
splits one speaker's recording into sub-clusters when prosody / distance
varies. After silhouette-driven k* selection, the closest centroid pair is
iteratively merged while cosine similarity ≥ ``merge_threshold`` (default
0.55). The threshold keeps similar-voiced but distinct speakers (same
gender + age + family resemblance ~cos_sim 0.3) separate while folding in
clear-cut same-speaker prosodic outliers (typical adult VoxCeleb same-
speaker cos_sim 0.6+). No target speaker count is assumed anywhere.

New: single-utterance / quiet-environment path. When 1+ valid speech
windows exist but the count is below ``min_windows_for_clustering``, the
clusterer returns ``n_speakers=1`` with all valid windows tagged ``S0``
instead of skipping. This is the "single speaker, just one word in an
otherwise quiet recording" case the prior fall-through silently dropped.

Validation: both ``twin-1.wav`` (2 distinct child speakers, one speaking
briefly) and the English two-speaker conversation still report
``n_speakers=2``; merge step does not fire on either (centroid cos_sim
0.31 and 0.15 respectively, both below 0.55). 57/57 workflow tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@satra
satra temporarily deployed to docs-preview May 10, 2026 20:59 — with GitHub Actions Inactive
…hold

Two-pass speaker-axis fix surfaced by ``audio_48khz_mono_16bits.wav`` (a 5-second
clip where 4 distinct speakers each say ``This is <Name>``):

The synthetic embedding source's per-pass clusterer correctly found 4 distinct
speakers in both raw_16k and enhanced_16k. The plot's cross-pass alignment
``_cluster_speakers_by_embedding`` then collapsed them to 2 because it ran
``assign_unified_clusters_with_seed_phase`` with a single 0.5 cosine threshold
that didn't distinguish "within-pass distinct speakers (validated by silhouette
+ min-size + post-merge)" from "cross-pass same-speaker match". For short
similar-phonetic utterances, different speakers can sit at cos_sim > 0.5 and
got merged.

Fix: ``assign_unified_clusters_with_seed_phase`` is now group-aware:

- ``seed_groups: list[list[(key, mean_emb)]]`` — each inner list comes from a
  single per-source clustering. Within a group, every label gets its own
  distinct centroid (no within-group merging — those clusters were already
  validated upstream).
- Across groups, items merge when centroid cosine ≥ ``cross_group_threshold``
  (default 0.75 — same-speaker cos_sim across raw/enh is typically 0.85+,
  different speakers within a pass sit around 0.30-0.50).
- ``other_items`` (pyannote / sortformer) still snap to the seed pool at the
  legacy ``cosine_threshold`` (0.5) and never spawn new clusters.

Both callers updated:

- ``cluster_speaker_labels_by_embedding`` (per-pass): one seed group =
  embedding source's labels for this pass.
- Plot's ``_cluster_speakers_by_embedding`` (cross-pass): one seed group
  per (pass, embedding_source) so raw and enhanced cluster sets stay
  intact and align speaker-to-speaker across passes at 0.75.

Validation (cache-hit reruns after the fix):

- audio_48khz_mono_16bits (4 distinct speakers, brief utterances): legend
  S0–S3, 4 colors visible per embedding-source row.
- twin-1 (2 distinct child speakers): legend S0/S1, no collapse.
- english_conversation_higgs (2 adult speakers): legend S0/S1, no regression.

The per-pass speaker clustering is unchanged; only the cross-pass alignment
in the timeline plot + the per-pass cross-model unification in clustering.py
were affected. Embedding clustering remains independent for raw and enhanced
passes — same-speaker matching across them happens only when cos_sim crosses
the 0.75 cross-group bar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@satra
satra merged commit bcf612c into alpha May 10, 2026
9 checks passed
@satra
satra deleted the 20260508-173136-compare-uncertainty branch May 10, 2026 22:53
github-actions Bot added a commit that referenced this pull request May 10, 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.

1 participant