diff --git a/core/crates/solstone-core-speakers-onnx/src/lib.rs b/core/crates/solstone-core-speakers-onnx/src/lib.rs index bb782bace..ae539fdca 100644 --- a/core/crates/solstone-core-speakers-onnx/src/lib.rs +++ b/core/crates/solstone-core-speakers-onnx/src/lib.rs @@ -8,7 +8,7 @@ mod wespeaker; use std::error::Error; use std::fmt; -use solstone_core_speakers::{PYANNOTE_WINDOW_S, WESPEAKER_MEL_BINS}; +use solstone_core_speakers::{PYANNOTE_SAMPLE_RATE_HZ, PYANNOTE_WINDOW_S, WESPEAKER_MEL_BINS}; pub use pyannote::PyannoteSegmenter; pub use wespeaker::{SpeakerEmbedding, WespeakerEmbedder}; @@ -86,7 +86,7 @@ impl fmt::Display for SpeakerOnnxError { actual_samples, } => write!( formatter, - "pyannote ONNX audio window must have {expected_samples} samples ({PYANNOTE_WINDOW_S}s at 16 kHz), got {actual_samples}" + "pyannote ONNX audio window must have {expected_samples} samples ({PYANNOTE_WINDOW_S}s at {PYANNOTE_SAMPLE_RATE_HZ} Hz), got {actual_samples}" ), Self::InvalidModelIo { detail } => { write!(formatter, "speaker ONNX model IO mismatch: {detail}") diff --git a/core/crates/solstone-core-speakers/src/segmentation.rs b/core/crates/solstone-core-speakers/src/segmentation.rs index 91eda1b02..0cc83c8e1 100644 --- a/core/crates/solstone-core-speakers/src/segmentation.rs +++ b/core/crates/solstone-core-speakers/src/segmentation.rs @@ -198,17 +198,16 @@ where })?; validate_window_log_probs(window_index, &log_probs)?; let frame_start = frame_start_for_sample(start_sample, samples_per_frame); - let requested_frame_end = frame_start + log_probs.frames(); - let frame_end = requested_frame_end.min(num_frames); - let used_frames = frame_end.saturating_sub(frame_start); - let stats_matrix = truncated_log_probs(&log_probs, used_frames) - .expect("validated pyannote log-probs can be truncated"); + let frame_end = frame_start + log_probs.frames(); + // A validated window has exactly the expected frame count; with starts at or before the + // final full window, round_ties_even(x) <= ceil(x) keeps frame_end within num_frames. + debug_assert!(frame_end <= num_frames); window_stats.push( - compute_speaker_window_stats(&stats_matrix) + compute_speaker_window_stats(&log_probs) .expect("validated pyannote log-probs have the expected class count"), ); - for local_frame in 0..used_frames { + for local_frame in 0..log_probs.frames() { let global_frame = frame_start + local_frame; let source_start = local_frame * PYANNOTE_CLASS_COUNT; let target_start = global_frame * PYANNOTE_CLASS_COUNT; @@ -254,9 +253,9 @@ pub fn compute_speaker_window_stats( overlap_frames: 0, }); } - let active_slot_count = counts[1..4] + let active_slot_count = PYANNOTE_SINGLE_SPEAKER_CLASSES .iter() - .filter(|count| (**count as f64 / speech_frames as f64) >= SLOT_ACTIVE_MIN_SHARE) + .filter(|class| (counts[**class] as f64 / speech_frames as f64) >= SLOT_ACTIVE_MIN_SHARE) .count(); let overlap_frames = PYANNOTE_OVERLAP_CLASSES .iter() @@ -354,27 +353,6 @@ fn validate_window_log_probs( Ok(()) } -fn truncated_log_probs( - log_probs: &FeatureMatrix, - used_frames: usize, -) -> Result { - if used_frames == log_probs.frames() { - return Ok(log_probs.clone()); - } - let len = - used_frames - .checked_mul(log_probs.bins()) - .ok_or(SpeakerFeatureError::ShapeOverflow { - frames: used_frames, - bins: log_probs.bins(), - })?; - FeatureMatrix::from_row_major( - used_frames, - log_probs.bins(), - log_probs.data()[..len].to_vec(), - ) -} - fn average_accumulated_log_probs( accum: &[f64], counts: &[usize], @@ -597,7 +575,10 @@ mod tests { PYANNOTE_OVERLAP_STRIDE_S, |_, window| { inspected = true; - assert_eq!(window.len(), 10 * PYANNOTE_SAMPLE_RATE_HZ as usize); + assert_eq!( + window.len(), + PYANNOTE_WINDOW_S as usize * PYANNOTE_SAMPLE_RATE_HZ as usize + ); assert!(window[..audio.len()].iter().all(|sample| *sample == 0.25)); assert!(window[audio.len()..].iter().all(|sample| *sample == 0.0)); Ok::(class_matrix(&vec![0; PYANNOTE_FRAMES_PER_WINDOW])) @@ -625,25 +606,7 @@ mod tests { #[test] fn segmentation_accumulates_f64_floors_counts_then_narrows_to_f32() { - let audio = vec![0.0; 14 * PYANNOTE_SAMPLE_RATE_HZ as usize]; - let result = run_pyannote_segmentation_pass( - &audio, - PYANNOTE_SAMPLE_RATE_HZ, - PYANNOTE_DIARIZE_STRIDE_S, - |window_index, _window| { - let mut matrix = zero_matrix(); - match window_index { - 0 => set_log_prob(&mut matrix, 236, 1, 100_000_000.0), - 1 => set_log_prob(&mut matrix, 118, 1, 1.0), - 2 => set_log_prob(&mut matrix, 0, 1, -100_000_000.0), - _ => {} - } - Ok::(matrix) - }, - ) - .expect("segmentation pass"); - let expected = (1.0_f64 / 3.0) as f32; - let actual = result.avg_log_probs.row(236).expect("frame 236")[1]; + let (actual, expected) = accumulated_mean_case(); assert_eq!(actual, expected); @@ -685,7 +648,7 @@ mod tests { #[test] fn no_speech_buffer_returns_zero_overlap_fraction() { - let audio = vec![0.0; 10 * PYANNOTE_SAMPLE_RATE_HZ as usize]; + let audio = vec![0.0; PYANNOTE_WINDOW_S as usize * PYANNOTE_SAMPLE_RATE_HZ as usize]; let result = run_pyannote_segmentation_pass( &audio, @@ -701,25 +664,61 @@ mod tests { } #[test] - fn window_stats_use_post_tail_truncation_argmax() { - let mut classes = vec![4; PYANNOTE_FRAMES_PER_WINDOW]; - classes[0] = 1; - classes[1] = 1; - let full = class_matrix(&classes); - let truncated = truncated_log_probs(&full, 2).expect("truncated matrix"); + fn validated_window_frame_count_keeps_every_pass_window_within_num_frames() { + let cases = [ + ("sub_window_tight", 137, PYANNOTE_OVERLAP_STRIDE_S), + ( + "stride_aligned_overlap", + 20 * PYANNOTE_SAMPLE_RATE_HZ as usize, + PYANNOTE_OVERLAP_STRIDE_S, + ), + ( + "non_stride_aligned_overlap", + 13 * PYANNOTE_SAMPLE_RATE_HZ as usize, + PYANNOTE_OVERLAP_STRIDE_S, + ), + ( + "diarize_stride", + 14 * PYANNOTE_SAMPLE_RATE_HZ as usize, + PYANNOTE_DIARIZE_STRIDE_S, + ), + ]; + let window_samples = PYANNOTE_WINDOW_S as usize * PYANNOTE_SAMPLE_RATE_HZ as usize; + let samples_per_frame = window_samples as f64 / PYANNOTE_FRAMES_PER_WINDOW as f64; + let mut saw_tight_boundary = false; + + for (name, samples, stride_s) in cases { + let audio = indexed_audio(samples); + let len_padded = samples.max(window_samples); + let num_frames = (len_padded as f64 / samples_per_frame).ceil() as usize; + let mut inspected_windows = 0_usize; + + run_pyannote_segmentation_pass( + &audio, + PYANNOTE_SAMPLE_RATE_HZ, + stride_s, + |_, window| { + let start_sample = window[0] as usize; + let frame_start = frame_start_for_sample(start_sample, samples_per_frame); + let frame_end = frame_start + PYANNOTE_FRAMES_PER_WINDOW; + assert!( + frame_end <= num_frames, + "{name}: frame_end={frame_end} num_frames={num_frames}" + ); + saw_tight_boundary |= frame_end == num_frames; + inspected_windows += 1; + Ok::(class_matrix(&vec![ + 0; + PYANNOTE_FRAMES_PER_WINDOW + ])) + }, + ) + .expect("segmentation pass"); - assert_eq!( - compute_speaker_window_stats(&truncated).expect("stats"), - SpeakerWindowStats { - speech_frames: 2, - active_slot_count: 1, - overlap_frames: 0, - } - ); - assert_ne!( - compute_speaker_window_stats(&full).expect("stats"), - compute_speaker_window_stats(&truncated).expect("stats") - ); + assert!(inspected_windows > 0, "{name}"); + } + + assert!(saw_tight_boundary); } #[test] @@ -861,7 +860,7 @@ mod tests { #[test] fn malformed_window_logprob_shape_reports_specific_error_variant() { - let audio = vec![0.0; 10 * PYANNOTE_SAMPLE_RATE_HZ as usize]; + let audio = vec![0.0; PYANNOTE_WINDOW_S as usize * PYANNOTE_SAMPLE_RATE_HZ as usize]; let error = run_pyannote_segmentation_pass( &audio, PYANNOTE_SAMPLE_RATE_HZ, @@ -894,10 +893,11 @@ mod tests { #[test] fn segmentation_comparison_fails_when_value_exceeds_tolerance() { - let actual = vec![0.25_f32, 0.5]; - let mut expected = actual.clone(); - let tolerance = 1e-6_f32; - expected[1] += tolerance * 2.0; + let (actual_value, expected_value) = accumulated_mean_case(); + let actual = vec![actual_value]; + let mut expected = vec![expected_value]; + let tolerance = 0.0_f32; + expected[0] = f32::from_bits(expected[0].to_bits() + 1); assert!(matrix_comparison_error("segmentation", &actual, &expected, tolerance).is_some()); } @@ -906,6 +906,29 @@ mod tests { (0..samples).map(|index| index as f32).collect() } + fn accumulated_mean_case() -> (f32, f32) { + let audio = vec![0.0; 14 * PYANNOTE_SAMPLE_RATE_HZ as usize]; + let result = run_pyannote_segmentation_pass( + &audio, + PYANNOTE_SAMPLE_RATE_HZ, + PYANNOTE_DIARIZE_STRIDE_S, + |window_index, _window| { + let mut matrix = zero_matrix(); + match window_index { + 0 => set_log_prob(&mut matrix, 236, 1, 100_000_000.0), + 1 => set_log_prob(&mut matrix, 118, 1, 1.0), + 2 => set_log_prob(&mut matrix, 0, 1, -100_000_000.0), + _ => {} + } + Ok::(matrix) + }, + ) + .expect("segmentation pass"); + let actual = result.avg_log_probs.row(236).expect("frame 236")[1]; + let expected = (1.0_f64 / 3.0) as f32; + (actual, expected) + } + fn zero_matrix() -> FeatureMatrix { FeatureMatrix::from_row_major( PYANNOTE_FRAMES_PER_WINDOW, diff --git a/docs/PORTING.md b/docs/PORTING.md index 99f7ac9a4..921df225b 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -204,6 +204,10 @@ tolerance. boundaries for interval selection, speaker-evidence gating, sentence assignment, and k-selection. Silhouette scores are compared with `CLUSTER_SCORE_ABS_TOLERANCE`; selected k values and cluster labels remain exact. +Native pyannote segmentation logic now lives in `solstone-core-speakers`, with +ONNX model execution isolated in `solstone-core-speakers-onnx`. +`speaker_stage_boundaries.json` remains a branch-boundary fixture; model-pass +numeric parity is carried by the `tests/verify_speaker_differential.py` bundles. `tests/verify_indexer_differential.py` runs the indexer differential harness and writes its report under the harness work directory unless `--report` is supplied.