Skip to main content

polyvoice/segmentation/
decoder.rs

1//! Powerset 7-class decoder for `pyannote/segmentation-3.0`.
2//!
3//! Each frame's 7-vector of logits is interpreted as one of:
4//!
5//! | Class | Set | Is overlap |
6//! |---|---|---|
7//! | 0 | ∅ (silence) | no |
8//! | 1 | {0} | no |
9//! | 2 | {1} | no |
10//! | 3 | {2} | no |
11//! | 4 | {0, 1} | yes |
12//! | 5 | {0, 2} | yes |
13//! | 6 | {1, 2} | yes |
14//!
15//! The decoder takes argmax over softmax, returning a `FrameLabel` that also
16//! carries the full softmax vector, so the aggregator can average and remap
17//! probabilities without recomputing the softmax from the logits.
18
19use crate::segmentation::SegmentationError;
20use crate::types::Confidence;
21
22/// Number of local speakers the powerset scheme addresses: the solo classes
23/// `{0}`, `{1}`, `{2}` and the three pair classes built from them.
24pub const MAX_LOCAL_SPEAKERS: usize = 3;
25
26/// Number of powerset classes: silence + 3 solo + 3 pairs.
27pub const NUM_POWERSET_CLASSES: usize = 7;
28
29/// One of the seven powerset classes, identifying which speakers are active.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum PowersetClass {
32    Silence,
33    Speaker(u8),
34    Pair(u8, u8),
35}
36
37impl PowersetClass {
38    /// True for classes 4–6 (two speakers active simultaneously).
39    pub const fn is_overlap(self) -> bool {
40        matches!(self, PowersetClass::Pair(_, _))
41    }
42
43    /// { true }
44    /// `pub fn speakers(self) -> Vec<u8>`
45    /// { ret.len() <= 2 }
46    /// Local speaker indices active in this class.
47    pub fn speakers(self) -> Vec<u8> {
48        match self {
49            PowersetClass::Silence => Vec::new(),
50            PowersetClass::Speaker(s) => vec![s],
51            PowersetClass::Pair(a, b) => vec![a, b],
52        }
53    }
54
55    /// Class index in the 7-class powerset scheme — the inverse of
56    /// [`PowersetDecoder::class_for_index`] for the classes it can produce.
57    /// Pair order is normalized (`Pair(1, 0)` indexes like `Pair(0, 1)`).
58    /// Values outside the scheme (an out-of-range solo speaker or an
59    /// unexpressible pair) return 0, matching the historical remap fallback.
60    pub(crate) const fn index(self) -> usize {
61        match self {
62            PowersetClass::Silence => 0,
63            PowersetClass::Speaker(s) => 1 + s as usize,
64            PowersetClass::Pair(a, b) => {
65                let (lo, hi) = if a < b { (a, b) } else { (b, a) };
66                match (lo, hi) {
67                    (0, 1) => 4,
68                    (0, 2) => 5,
69                    (1, 2) => 6,
70                    _ => 0,
71                }
72            }
73        }
74    }
75
76    /// The class whose speaker set is exactly `speakers` — the checked inverse
77    /// of [`Self::index`]. Pair order is normalized, so `[1, 0]` yields
78    /// `Pair(0, 1)`. Returns `None` for sets the powerset scheme cannot
79    /// express (more than two speakers, a duplicated speaker, or an
80    /// out-of-range local index) instead of silently mapping to silence.
81    pub(crate) fn from_speakers(speakers: &[u8]) -> Option<PowersetClass> {
82        match speakers {
83            [] => Some(PowersetClass::Silence),
84            [s] if (*s as usize) < MAX_LOCAL_SPEAKERS => Some(PowersetClass::Speaker(*s)),
85            [a, b] => {
86                let (lo, hi) = if a < b { (*a, *b) } else { (*b, *a) };
87                match (lo, hi) {
88                    (0, 1) => Some(PowersetClass::Pair(0, 1)),
89                    (0, 2) => Some(PowersetClass::Pair(0, 2)),
90                    (1, 2) => Some(PowersetClass::Pair(1, 2)),
91                    _ => None,
92                }
93            }
94            _ => None,
95        }
96    }
97}
98
99/// Decoded label for a single audio frame.
100#[derive(Debug, Clone, Copy, PartialEq)]
101pub struct FrameLabel {
102    pub class: PowersetClass,
103    /// Maximum-class softmax probability (∈ [0, 1]). Useful for confidence reporting.
104    pub max_softmax: f32,
105    /// Full softmax vector over the powerset classes (sums to 1). Carried so
106    /// the aggregator can average and permute probabilities without
107    /// recomputing the softmax from the logits.
108    pub probs: [f32; NUM_POWERSET_CLASSES],
109}
110
111/// { true }
112/// `pub(crate) fn softmax(logits: &[f32; NUM_POWERSET_CLASSES]) -> [f32; NUM_POWERSET_CLASSES]`
113/// { ret.iter().all(|p| p.is_finite()) }
114/// Stable softmax over one frame's class logits: subtract the max logit for
115/// numerical stability, then normalize. A degenerate zero sum (only possible
116/// with NaN logits) falls back to a unit denominator so the output stays finite.
117pub(crate) fn softmax(logits: &[f32; NUM_POWERSET_CLASSES]) -> [f32; NUM_POWERSET_CLASSES] {
118    let mut max_logit = f32::NEG_INFINITY;
119    for &l in logits {
120        if l > max_logit {
121            max_logit = l;
122        }
123    }
124    let mut probs = [0.0_f32; NUM_POWERSET_CLASSES];
125    let mut sum = 0.0_f32;
126    for (p, &l) in probs.iter_mut().zip(logits.iter()) {
127        *p = (l - max_logit).exp();
128        sum += *p;
129    }
130    // Guard against degenerate sum (sum=0 would only happen with NaN logits).
131    let inv_sum = if sum > 0.0 { 1.0 / sum } else { 1.0 };
132    for p in probs.iter_mut() {
133        *p *= inv_sum;
134    }
135    probs
136}
137
138/// Stateless decoder; methods are associated functions because no per-instance
139/// configuration is needed.
140pub struct PowersetDecoder;
141
142impl PowersetDecoder {
143    /// Convert a 7-class index (0..=6) to its `PowersetClass`.
144    pub const fn class_for_index(idx: usize) -> Option<PowersetClass> {
145        match idx {
146            0 => Some(PowersetClass::Silence),
147            1 => Some(PowersetClass::Speaker(0)),
148            2 => Some(PowersetClass::Speaker(1)),
149            3 => Some(PowersetClass::Speaker(2)),
150            4 => Some(PowersetClass::Pair(0, 1)),
151            5 => Some(PowersetClass::Pair(0, 2)),
152            6 => Some(PowersetClass::Pair(1, 2)),
153            _ => None,
154        }
155    }
156
157    /// { true }
158    /// `pub fn decode_frame(logits: &[f32]) -> Result<FrameLabel, SegmentationError>`
159    /// { true }
160    /// Decode one frame given its 7-vector of logits.
161    pub fn decode_frame(logits: &[f32]) -> Result<FrameLabel, SegmentationError> {
162        if logits.len() != NUM_POWERSET_CLASSES {
163            return Err(SegmentationError::InvalidOutputShape {
164                actual_shape: vec![logits.len()],
165            });
166        }
167        let logits: &[f32; NUM_POWERSET_CLASSES] =
168            logits
169                .first_chunk()
170                .ok_or(SegmentationError::InvalidOutputShape {
171                    actual_shape: vec![logits.len()],
172                })?;
173        let probs = softmax(logits);
174        let mut argmax = 0_usize;
175        let mut max_softmax = 0.0_f32;
176        for (i, &p) in probs.iter().enumerate() {
177            if p > max_softmax {
178                max_softmax = p;
179                argmax = i;
180            }
181        }
182        let class = Self::class_for_index(argmax).ok_or(SegmentationError::InvalidOutputShape {
183            actual_shape: vec![argmax],
184        })?;
185        Ok(FrameLabel {
186            class,
187            max_softmax,
188            probs,
189        })
190    }
191
192    /// { true }
193    /// `pub fn decode_window( logits_flat: &[f32], num_frames: usize, ) -> Result<Vec<FrameLabel>, SegmentationError>`
194    /// { ret.as_ref().map_or(true, |v| v.len() == num_frames) }
195    /// Decode every frame in a flat row-major `[num_frames, 7]` buffer.
196    pub fn decode_window(
197        logits_flat: &[f32],
198        num_frames: usize,
199    ) -> Result<Vec<FrameLabel>, SegmentationError> {
200        if logits_flat.len() != num_frames * NUM_POWERSET_CLASSES {
201            return Err(SegmentationError::InvalidOutputShape {
202                actual_shape: vec![logits_flat.len()],
203            });
204        }
205        let mut out = Vec::with_capacity(num_frames);
206        for i in 0..num_frames {
207            let frame = &logits_flat[i * NUM_POWERSET_CLASSES..(i + 1) * NUM_POWERSET_CLASSES];
208            out.push(Self::decode_frame(frame)?);
209        }
210        Ok(out)
211    }
212
213    /// { true }
214    /// pub fn frame_confidence(softmax: f32) -> Confidence
215    /// { ret.get() >= 0.0 && ret.get() <= 1.0 }
216    /// Convert a softmax probability into a `Confidence`. Clamps tiny over-/underflows
217    /// to the valid `[0, 1]` range so we never panic on numerical artifacts.
218    pub fn frame_confidence(softmax: f32) -> Confidence {
219        let clamped = softmax.clamp(0.0, 1.0);
220        // `Confidence::new` validates the closed range; clamped is guaranteed valid.
221        Confidence::new(clamped).unwrap_or_default()
222    }
223}
224
225#[allow(clippy::unwrap_used)]
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    fn approx(a: f32, b: f32) -> bool {
231        (a - b).abs() < 1e-6
232    }
233
234    #[test]
235    fn class_0_is_silence() {
236        let logits = [10.0_f32, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
237        let label = PowersetDecoder::decode_frame(&logits).unwrap();
238        assert_eq!(label.class, PowersetClass::Silence);
239        assert!(!label.class.is_overlap());
240    }
241
242    #[test]
243    fn class_1_is_speaker_0() {
244        let logits = [1.0_f32, 10.0, 1.0, 1.0, 1.0, 1.0, 1.0];
245        let label = PowersetDecoder::decode_frame(&logits).unwrap();
246        assert_eq!(label.class, PowersetClass::Speaker(0));
247    }
248
249    #[test]
250    fn class_3_is_speaker_2() {
251        let logits = [1.0_f32, 1.0, 1.0, 10.0, 1.0, 1.0, 1.0];
252        let label = PowersetDecoder::decode_frame(&logits).unwrap();
253        assert_eq!(label.class, PowersetClass::Speaker(2));
254    }
255
256    #[test]
257    fn class_4_is_overlap_pair_0_1() {
258        let logits = [1.0_f32, 1.0, 1.0, 1.0, 10.0, 1.0, 1.0];
259        let label = PowersetDecoder::decode_frame(&logits).unwrap();
260        assert_eq!(label.class, PowersetClass::Pair(0, 1));
261        assert!(label.class.is_overlap());
262    }
263
264    #[test]
265    fn class_5_is_overlap_pair_0_2() {
266        let logits = [1.0_f32, 1.0, 1.0, 1.0, 1.0, 10.0, 1.0];
267        let label = PowersetDecoder::decode_frame(&logits).unwrap();
268        assert_eq!(label.class, PowersetClass::Pair(0, 2));
269    }
270
271    #[test]
272    fn class_6_is_overlap_pair_1_2() {
273        let logits = [1.0_f32, 1.0, 1.0, 1.0, 1.0, 1.0, 10.0];
274        let label = PowersetDecoder::decode_frame(&logits).unwrap();
275        assert_eq!(label.class, PowersetClass::Pair(1, 2));
276    }
277
278    #[test]
279    fn rejects_wrong_logit_count() {
280        let logits = [1.0_f32, 2.0, 3.0];
281        assert!(PowersetDecoder::decode_frame(&logits).is_err());
282    }
283
284    #[test]
285    fn max_softmax_is_softmax_of_argmax_class() {
286        let logits = [0.0_f32; 7];
287        let label = PowersetDecoder::decode_frame(&logits).unwrap();
288        assert!(approx(label.max_softmax, 1.0 / 7.0));
289    }
290
291    #[test]
292    fn confidence_clamps_to_valid_range() {
293        let logits = [-1e6_f32, -1e6, -1e6, -1e6, -1e6, -1e6, 0.0];
294        let label = PowersetDecoder::decode_frame(&logits).unwrap();
295        assert!(label.max_softmax > 0.99);
296        assert!(label.max_softmax <= 1.0 + 1e-6);
297    }
298
299    #[test]
300    fn class_method_returns_speaker_set() {
301        assert_eq!(PowersetClass::Silence.speakers(), Vec::<u8>::new());
302        assert_eq!(PowersetClass::Speaker(0).speakers(), vec![0]);
303        assert_eq!(PowersetClass::Pair(0, 2).speakers(), vec![0, 2]);
304        assert_eq!(PowersetClass::Pair(1, 2).speakers(), vec![1, 2]);
305    }
306
307    #[test]
308    fn decode_window_iterates_over_frames() {
309        let logits_flat: Vec<f32> = vec![
310            10.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 10.0, 1.0, 1.0, 1.0, 1.0,
311        ];
312        let labels = PowersetDecoder::decode_window(&logits_flat, 2).unwrap();
313        assert_eq!(labels.len(), 2);
314        assert_eq!(labels[0].class, PowersetClass::Silence);
315        assert_eq!(labels[1].class, PowersetClass::Speaker(1));
316    }
317
318    #[test]
319    fn decode_window_rejects_misshaped_buffer() {
320        let logits_flat = vec![1.0_f32; 8];
321        assert!(PowersetDecoder::decode_window(&logits_flat, 1).is_err());
322    }
323
324    #[test]
325    fn confidence_construction_via_helper() {
326        let c = PowersetDecoder::frame_confidence(1.0_f32 + 1e-7);
327        assert!((c.get() - 1.0).abs() < 1e-5);
328
329        let c = PowersetDecoder::frame_confidence(-1e-7);
330        assert!(c.get() >= 0.0);
331    }
332
333    #[test]
334    fn probs_sum_to_one_and_match_max_softmax() {
335        let logits = [0.5_f32, 2.0, -1.0, 0.3, 1.0, -0.2, 0.7];
336        let label = PowersetDecoder::decode_frame(&logits).unwrap();
337        let sum: f32 = label.probs.iter().sum();
338        assert!((sum - 1.0).abs() < 1e-5, "probs must sum to 1, got {sum}");
339        let argmax = label
340            .probs
341            .iter()
342            .enumerate()
343            .max_by(|a, b| a.1.total_cmp(b.1))
344            .map(|(i, _)| i)
345            .unwrap();
346        assert!(approx(label.max_softmax, label.probs[argmax]));
347        assert_eq!(PowersetDecoder::class_for_index(argmax), Some(label.class));
348    }
349
350    #[test]
351    fn class_index_round_trips_through_class_for_index() {
352        for idx in 0..NUM_POWERSET_CLASSES {
353            let class = PowersetDecoder::class_for_index(idx).unwrap();
354            assert_eq!(class.index(), idx, "round-trip failed for {idx}");
355            assert_eq!(
356                PowersetClass::from_speakers(&class.speakers()),
357                Some(class),
358                "from_speakers round-trip failed for {idx}"
359            );
360        }
361    }
362
363    #[test]
364    fn from_speakers_normalizes_pair_order() {
365        assert_eq!(
366            PowersetClass::from_speakers(&[1, 0]),
367            Some(PowersetClass::Pair(0, 1))
368        );
369        assert_eq!(
370            PowersetClass::from_speakers(&[2, 1]),
371            Some(PowersetClass::Pair(1, 2))
372        );
373    }
374
375    #[test]
376    fn from_speakers_rejects_unexpressible_sets() {
377        assert_eq!(PowersetClass::from_speakers(&[0, 1, 2]), None);
378        assert_eq!(PowersetClass::from_speakers(&[3]), None);
379        assert_eq!(PowersetClass::from_speakers(&[1, 1]), None);
380        assert_eq!(PowersetClass::from_speakers(&[0, 3]), None);
381    }
382
383    /// NaN logits make the softmax sum NaN; the degenerate-sum guard must
384    /// fall back to a unit denominator instead of panicking or dividing by
385    /// zero. NaN comparisons are all false, so the argmax stays at class 0.
386    #[test]
387    fn nan_logits_use_safe_softmax_denominator() {
388        let logits = [f32::NAN; 7];
389        let label = PowersetDecoder::decode_frame(&logits).unwrap();
390        assert_eq!(label.class, PowersetClass::Silence);
391        let probs = softmax(&logits);
392        assert_eq!(probs.len(), NUM_POWERSET_CLASSES);
393    }
394}