Skip to main content

sva_samples/measure/
self_similarity.rs

1// Concern: how a target's stretches resemble one another: chroma and band-level matrices, stripes, novelty | Non-concern: picking partials, band filters, verdicts | IO: (planes, Asked) -> SelfSimilarity
2
3use super::bands;
4use super::harmony::{above, measured};
5use super::pitch::{HIGHEST_HZ, LOWEST_HZ, midi_of};
6use super::sliding::{self, Span};
7
8/// The most cells one side of the matrix holds: its lower triangle is 524 800 entries.
9pub const MAX_CELLS: usize = 1024;
10const CLASSES: usize = 12;
11
12pub const MODEL: &str = "self-similarity matrix of Foote, J. (1999), Visualizing music and \
13                         audio using self-similarity, Proc. ACM Multimedia 77-80, read for \
14                         repetition as Bartsch, M. A. and Wakefield, G. H. (2001), To catch a \
15                         chorus: using chroma-based representations for audio thumbnailing, \
16                         Proc. IEEE WASPAA 15-18, and for boundaries as Foote, J. (2000), \
17                         Automatic audio segmentation using a measure of audio novelty, Proc. \
18                         IEEE ICME 452-455, each step as Mueller, M. (2015), Fundamentals of \
19                         Music Processing, Springer, ch. 4 (FMP) writes it. Frames: a Hann \
20                         frame of `frame_window_secs` of each channel centred every \
21                         `hop_secs` from the target's start, their power per bin averaged over \
22                         the channels, its partials picked and refined \
23                         as the harmonic readings pick them (see their method), \
24                         `resolution_hz` apart before refinement, so two partials within about \
25                         that of each other, as two notes a semitone apart under some 17 \
26                         times `resolution_hz`, are not resolved. \
27                         Chroma view: per frame each partial at or above `floor_db` from \
28                         `min_hz` to `max_hz` adds its squared amplitude to the pitch class \
29                         of its nearest equal-tempered note (A4 = 440 Hz), C first; \
30                         harmonics are not removed, so a tone's third harmonic adds to its \
31                         fifth's class, as Bartsch and Wakefield's chroma does. Timbre view: \
32                         the RMS of each ERB band of `bands` (`centres_hz`), every channel's \
33                         band energy averaged, each band's level \
34                         in dB above `floor_db`, zero under it. Cells: `frames_per_cell` \
35                         frames, `cell_secs`, centred every `cell_secs` from the start as \
36                         every framed reading steps, `from`/`to` the first frame centre in it \
37                         and the next cell's, the first clipped at the target's start, and \
38                         frames past the last cell's `to`, under half a cell, in none; a \
39                         cell's chroma is its frames' mean, its band levels those of its \
40                         bands' mean energy over that span. similarity(i, j): the cosine of \
41                         cells i and j's vectors, in [0, 1] as no entry is negative, and 0 \
42                         where either vector is all zero (silence, or nothing over the floor), \
43                         itself included. matrix, where asked: row i lists \
44                         similarity(i, i - lag) for lag 0 to i, the lower triangle in \
45                         time-lag form; it is symmetric. Stripes: a repeat is a stripe \
46                         parallel to the diagonal. Each diagonal is first averaged along its \
47                         length over `smooth_cells` cells centred on each, over the cells it \
48                         holds (FMP 4.2.2's path enhancement), so a held chord's square block \
49                         does not hide which lag repeats; a cell is on a stripe where that \
50                         average exceeds the one at one cell less lag and is no lower than \
51                         the one at one cell more, in its row (a missing neighbour counts as \
52                         lower); a stripe is a run of such cells at one lag, `cells` long, \
53                         `later` repeating `earlier` `lag_secs` before it. Lags from one \
54                         cell; a repeat under two cells away merges with the diagonal and is \
55                         not read. A stripe's ends stand within half `smooth_cells` of where \
56                         the repeat's do. `similarity_mean`/`_min` are the raw similarity along \
57                         it, unaveraged. Transposition, chroma view: the same search runs on \
58                         the matrix of each cell against each earlier cell's chroma rotated up \
59                         `shift_semitones` (Goto, M. (2003), A chorus-section detecting \
60                         method for musical audio signals, Proc. IEEE ICASSP 437-440; FMP \
61                         4.2.2), shifts -5 to 6, so an exact repeat reads at shift 0 and one \
62                         transposed up two semitones at shift 2; a shift is mod 12, so a \
63                         repeat an octave up reads at 0, and a sustained glide reads as \
64                         transposed repeats of itself at every shift. A symmetric chord (an \
65                         augmented triad at 4, a diminished seventh at 3) reads at more than \
66                         one shift. The matrix lists shift 0 alone; the timbre view has no \
67                         shift. No threshold: every stripe is counted in `stripes_found`, \
68                         the `listed` with the largest summed similarity listed, largest \
69                         first, so a short weak stripe from noise reads with its small sum, \
70                         never dropped unasked. Novelty: per boundary between two adjacent \
71                         cells, sum over a and b of K(a, b) similarity(i + a, i + b), a and b \
72                         from -L to L - 1 cells, L = `kernel_cells`, K Foote's checkerboard \
73                         sign(u_a) sign(u_b) tapered by a Gaussian exp(-u^2 / (2 L^2)) at \
74                         u = a + 1/2 (FMP's default variance), normalized so sum |K| = 1, \
75                         cells past the target zero (so a boundary near either end reads \
76                         against silence); in [-0.5, 0.5], 0.5 for a cut between two \
77                         blocks each wholly alike within and wholly unlike across. `values` \
78                         from `from_secs` by `cell_secs`; peaks: those over the one before \
79                         and no lower than the one after, a missing neighbour counting as \
80                         lower, so the first boundary peaks wherever the second is no higher, \
81                         silence included, the `listed` highest, highest first, every one \
82                         counted in `peaks_found`. Exact path: none; a \
83                         closed form is read off its rendered samples, because a frame's \
84                         partials over a floor and a band's energy over a span are stated by \
85                         no closed form";
86
87/// Each reading's arguments: the frame `hop_secs`, the matrix's `cell_secs`, the stripe
88/// average's `smooth_secs`, the novelty kernel's whole `window_secs`, the floor, how many
89/// stripes and peaks are listed, and whether the cells and matrices are.
90#[derive(Clone, Copy, Debug, PartialEq)]
91pub struct Asked {
92    pub hop_secs: f64,
93    pub cell_secs: f64,
94    pub smooth_secs: f64,
95    pub window_secs: f64,
96    pub floor_db: f64,
97    pub listed: usize,
98    pub curve: bool,
99}
100
101/// `least_cell_secs` is the finest cell that fits the cap; `most_secs` the longest target
102/// the cell asked fits.
103#[derive(Clone, Copy, Debug, PartialEq)]
104pub struct TooManyCells {
105    pub cells: usize,
106    pub cap: usize,
107    pub cell_secs: f64,
108    pub held_secs: f64,
109    pub least_cell_secs: f64,
110    pub most_secs: f64,
111}
112
113/// Why no matrix is read. `widest_secs` is the widest novelty window the cap holds a side at
114/// `cell_secs`; `least_cell_secs` the finest cell every one of whose spans holds a sample of
115/// the band envelopes at `band_hz`.
116#[derive(Clone, Copy, Debug, PartialEq)]
117pub enum Unfit {
118    RateUnderBands {
119        rate_hz: f64,
120        least_hz: f64,
121    },
122    TooManyCells(TooManyCells),
123    CellUnderBandStep {
124        cell_secs: f64,
125        least_cell_secs: f64,
126        band_hz: f64,
127    },
128    KernelPastCap {
129        window_secs: f64,
130        kernel_cells: usize,
131        cap: usize,
132        cell_secs: f64,
133        widest_secs: f64,
134    },
135}
136
137#[derive(Clone, Copy, Debug, PartialEq, Eq)]
138pub enum View {
139    Chroma,
140    Timbre,
141}
142
143impl View {
144    pub fn key(self) -> &'static str {
145        match self {
146            View::Chroma => "chroma",
147            View::Timbre => "timbre",
148        }
149    }
150}
151
152#[derive(Clone, Debug, PartialEq)]
153pub struct Cell {
154    pub t_secs: f64,
155    pub from_secs: f64,
156    pub to_secs: f64,
157    /// Mean squared amplitude per frame, C to B.
158    pub chroma: [f64; CLASSES],
159    /// dB against full scale, one per band in `centres_hz`.
160    pub levels_db: Vec<f64>,
161}
162
163#[derive(Clone, Copy, Debug, PartialEq)]
164pub struct Stripe {
165    pub shift_semitones: i32,
166    pub lag_secs: f64,
167    pub earlier: (f64, f64),
168    pub later: (f64, f64),
169    pub cells: usize,
170    pub similarity_mean: f64,
171    pub similarity_min: f64,
172}
173
174#[derive(Clone, Copy, Debug, PartialEq)]
175pub struct Peak {
176    pub t_secs: f64,
177    pub novelty: f64,
178}
179
180#[derive(Clone, Debug, PartialEq)]
181pub struct Reading {
182    pub view: View,
183    pub stripes_found: usize,
184    /// Largest summed similarity first.
185    pub stripes: Vec<Stripe>,
186    /// One per boundary, the first at `novelty_from_secs`, `cell_secs` apart.
187    pub novelty: Vec<f64>,
188    pub peaks_found: usize,
189    /// Highest first.
190    pub peaks: Vec<Peak>,
191    /// Row i holds lags 0 to i; listed where asked.
192    pub matrix: Option<Vec<Vec<f64>>>,
193}
194
195#[derive(Clone, Debug, PartialEq)]
196pub struct SelfSimilarity {
197    pub hop_secs: f64,
198    pub frame_window_secs: f64,
199    pub resolution_hz: f64,
200    pub cell_secs: f64,
201    pub frames_per_cell: usize,
202    pub cell_count: usize,
203    pub smooth_cells: usize,
204    pub kernel_cells: usize,
205    pub floor_db: f64,
206    pub listed: usize,
207    pub min_hz: f64,
208    pub max_hz: f64,
209    pub centres_hz: Vec<f64>,
210    pub novelty_from_secs: f64,
211    /// Listed where asked.
212    pub cells: Option<Vec<Cell>>,
213    pub views: Vec<Reading>,
214}
215
216pub fn measure(
217    planes: &[&[f64]],
218    sample_rate: f64,
219    start_secs: f64,
220    asked: Asked,
221) -> Result<SelfSimilarity, Unfit> {
222    if bands::centres(sample_rate).is_empty() {
223        return Err(Unfit::RateUnderBands {
224            rate_hz: sample_rate,
225            least_hz: bands::least_rate_hz(),
226        });
227    }
228    let held = crate::measure::held(planes);
229    let frame_hop = ((asked.hop_secs * sample_rate).round() as usize).max(1);
230    let per_cell = ((asked.cell_secs * sample_rate / frame_hop as f64).round() as usize).max(1);
231    let cell_hop = per_cell * frame_hop;
232    let cell_secs = cell_hop as f64 / sample_rate;
233    let count = held.div_ceil(cell_hop);
234    if count > MAX_CELLS {
235        let least = held.div_ceil(MAX_CELLS * frame_hop) * frame_hop;
236        return Err(Unfit::TooManyCells(TooManyCells {
237            cells: count,
238            cap: MAX_CELLS,
239            cell_secs,
240            held_secs: held as f64 / sample_rate,
241            least_cell_secs: least as f64 / sample_rate,
242            most_secs: (MAX_CELLS * cell_hop) as f64 / sample_rate,
243        }));
244    }
245    let kernel_cells = ((asked.window_secs / (2.0 * cell_secs)).round() as usize).max(1);
246    if kernel_cells > MAX_CELLS {
247        return Err(Unfit::KernelPastCap {
248            window_secs: asked.window_secs,
249            kernel_cells,
250            cap: MAX_CELLS,
251            cell_secs,
252            widest_secs: (2 * MAX_CELLS * cell_hop) as f64 / sample_rate,
253        });
254    }
255    let split = bands::analyze(planes, sample_rate, start_secs);
256    let band_len = split.bands.first().map_or(0, |b| b.rms.len());
257    let grid = |cell_hop: usize, rate: f64, len: usize| {
258        let secs = cell_hop as f64 / sample_rate;
259        sliding::spans(held, sample_rate, start_secs, secs, rate, secs, len).spans
260    };
261    let holds = |cell_hop: usize| {
262        grid(cell_hop, split.rate_hz, band_len)
263            .iter()
264            .all(|s| s.from < s.to)
265    };
266    if band_len > 0 && !holds(cell_hop) {
267        let least = (per_cell + 1..)
268            .map(|k| k * frame_hop)
269            .find(|&hop| holds(hop))
270            .expect("a cell two band steps wide holds one");
271        return Err(Unfit::CellUnderBandStep {
272            cell_secs,
273            least_cell_secs: least as f64 / sample_rate,
274            band_hz: split.rate_hz,
275        });
276    }
277    let (moments, framing) = measured::moments(
278        planes,
279        sample_rate,
280        start_secs,
281        frame_hop as f64 / sample_rate,
282    );
283    let window = framing.window.expect("measured frames have a window");
284    let chroma: Vec<[f64; CLASSES]> = moments
285        .iter()
286        .map(|m| {
287            let mut held = [0.0; CLASSES];
288            for p in above(m, asked.floor_db) {
289                if (LOWEST_HZ..=HIGHEST_HZ).contains(&p.hz) {
290                    held[(midi_of(p.hz).round() as i64).rem_euclid(12) as usize] +=
291                        p.amplitude * p.amplitude;
292                }
293            }
294            held
295        })
296        .collect();
297    let framed = grid(cell_hop, sample_rate / frame_hop as f64, chroma.len());
298    let banded = grid(cell_hop, split.rate_hz, band_len);
299    let cells: Vec<Cell> = framed
300        .iter()
301        .zip(&banded)
302        .map(|(f, b)| cell(f, b, &chroma, &split))
303        .collect();
304    let smooth_cells = ((asked.smooth_secs / cell_secs).round() as usize).max(1);
305    let read = Read {
306        cells: &cells,
307        cell_secs,
308        smooth_cells,
309        kernel_cells,
310        listed: asked.listed,
311        curve: asked.curve,
312    };
313    let chroma_of = |c: &Cell| c.chroma.to_vec();
314    let timbre_of = |c: &Cell| {
315        c.levels_db
316            .iter()
317            .map(|l| (l - asked.floor_db).max(0.0))
318            .collect()
319    };
320    Ok(SelfSimilarity {
321        hop_secs: framing.hop_secs,
322        frame_window_secs: window.secs,
323        resolution_hz: window.resolution_hz,
324        cell_secs,
325        frames_per_cell: per_cell,
326        cell_count: cells.len(),
327        smooth_cells,
328        kernel_cells,
329        floor_db: asked.floor_db,
330        listed: asked.listed,
331        min_hz: LOWEST_HZ,
332        max_hz: HIGHEST_HZ,
333        centres_hz: split.bands.iter().map(|b| b.centre_hz).collect(),
334        novelty_from_secs: start_secs + 0.5 * cell_secs,
335        views: vec![
336            read.view(View::Chroma, chroma_of, -5..=6),
337            read.view(View::Timbre, timbre_of, 0..=0),
338        ],
339        cells: asked.curve.then_some(cells),
340    })
341}
342
343fn cell(f: &Span, b: &Span, chroma: &[[f64; CLASSES]], split: &bands::Bands) -> Cell {
344    let frames = &chroma[f.from..f.to];
345    let mut mean = [0.0; CLASSES];
346    for frame in frames {
347        for (m, e) in mean.iter_mut().zip(frame) {
348            *m += e / frames.len() as f64;
349        }
350    }
351    let levels_db = split
352        .bands
353        .iter()
354        .map(|band| {
355            let held = &band.rms[b.from..b.to];
356            let energy = held.iter().map(|r| r * r).sum::<f64>() / held.len() as f64;
357            10.0 * libm::log10(energy)
358        })
359        .collect();
360    Cell {
361        t_secs: f.t_secs,
362        from_secs: f.from_secs,
363        to_secs: f.to_secs,
364        chroma: mean,
365        levels_db,
366    }
367}
368
369struct Read<'a> {
370    cells: &'a [Cell],
371    cell_secs: f64,
372    smooth_cells: usize,
373    kernel_cells: usize,
374    listed: usize,
375    curve: bool,
376}
377
378impl Read<'_> {
379    /// `shifts` rotate each earlier cell's vector, which only chroma's twelve classes mean.
380    fn view(
381        &self,
382        view: View,
383        vector: impl Fn(&Cell) -> Vec<f64>,
384        shifts: std::ops::RangeInclusive<i32>,
385    ) -> Reading {
386        let vectors: Vec<Vec<f64>> = self.cells.iter().map(vector).collect();
387        let norms: Vec<f64> = vectors
388            .iter()
389            .map(|v| v.iter().map(|x| x * x).sum::<f64>().sqrt())
390            .collect();
391        let mut raw = Vec::new();
392        let mut stripes = Vec::new();
393        for shift in shifts {
394            let k = shift.rem_euclid(CLASSES as i32) as usize;
395            let rotated = |j: usize, p: usize| {
396                let v = &vectors[j];
397                v[(p + v.len() - k % v.len()) % v.len()]
398            };
399            let held = diagonals(self.cells.len(), |i, j| {
400                let dot: f64 = (0..vectors[i].len())
401                    .map(|p| vectors[i][p] * rotated(j, p))
402                    .sum();
403                match norms[i] > 0.0 && norms[j] > 0.0 {
404                    true => dot / (norms[i] * norms[j]),
405                    false => 0.0,
406                }
407            });
408            stripes.extend(self.stripes(&held, shift));
409            if shift == 0 {
410                raw = held;
411            }
412        }
413        stripes.sort_by(|a, b| {
414            let sum = |s: &Stripe| s.similarity_mean * s.cells as f64;
415            sum(b)
416                .total_cmp(&sum(a))
417                .then(a.later.0.total_cmp(&b.later.0))
418        });
419        let novelty = novelty(&raw, self.kernel_cells);
420        let mut peaks: Vec<Peak> = (0..novelty.len())
421            .filter(|&i| {
422                (i == 0 || novelty[i] > novelty[i - 1])
423                    && novelty.get(i + 1).is_none_or(|next| novelty[i] >= *next)
424            })
425            .map(|i| Peak {
426                t_secs: self.cells[0].t_secs + (i as f64 + 0.5) * self.cell_secs,
427                novelty: novelty[i],
428            })
429            .collect();
430        peaks.sort_by(|a, b| b.novelty.total_cmp(&a.novelty));
431        let (stripes_found, peaks_found) = (stripes.len(), peaks.len());
432        stripes.truncate(self.listed);
433        peaks.truncate(self.listed);
434        let matrix = self.curve.then(|| {
435            (0..raw.len())
436                .map(|i| (0..=i).map(|lag| raw[lag][i - lag]).collect())
437                .collect()
438        });
439        Reading {
440            view,
441            stripes_found,
442            stripes,
443            novelty,
444            peaks_found,
445            peaks,
446            matrix,
447        }
448    }
449
450    /// Ridges of the averaged diagonals, as MODEL states, run along each lag.
451    fn stripes(&self, raw: &[Vec<f64>], shift: i32) -> Vec<Stripe> {
452        let smooth: Vec<Vec<f64>> = raw.iter().map(|d| averaged(d, self.smooth_cells)).collect();
453        let mut found = Vec::new();
454        for lag in 1..smooth.len() {
455            let on = |j: usize| {
456                let v = smooth[lag][j];
457                v > smooth[lag - 1][j + 1] && (j == 0 || v >= smooth[lag + 1][j - 1])
458            };
459            let mut run: Option<usize> = None;
460            for j in 0..=smooth[lag].len() {
461                match (run, j < smooth[lag].len() && on(j)) {
462                    (None, true) => run = Some(j),
463                    (Some(from), false) => {
464                        found.push(self.stripe(&raw[lag][from..j], shift, lag, from));
465                        run = None;
466                    }
467                    _ => {}
468                }
469            }
470        }
471        found
472    }
473
474    fn stripe(&self, along: &[f64], shift: i32, lag: usize, from: usize) -> Stripe {
475        let to = from + along.len() - 1;
476        let span = |a: usize, b: usize| (self.cells[a].from_secs, self.cells[b].to_secs);
477        Stripe {
478            shift_semitones: shift,
479            lag_secs: lag as f64 * self.cell_secs,
480            earlier: span(from, to),
481            later: span(from + lag, to + lag),
482            cells: along.len(),
483            similarity_mean: along.iter().sum::<f64>() / along.len() as f64,
484            similarity_min: along.iter().copied().fold(f64::INFINITY, f64::min),
485        }
486    }
487}
488
489/// `held[lag][j]` is `similarity(j + lag, j)`.
490fn diagonals(n: usize, similarity: impl Fn(usize, usize) -> f64) -> Vec<Vec<f64>> {
491    (0..n)
492        .map(|lag| (0..n - lag).map(|j| similarity(j + lag, j)).collect())
493        .collect()
494}
495
496/// The mean over `len` entries centred on each, over those the diagonal holds.
497fn averaged(d: &[f64], len: usize) -> Vec<f64> {
498    let before = (len - 1) / 2;
499    let after = len - 1 - before;
500    let mut sums = vec![0.0; d.len() + 1];
501    for (i, v) in d.iter().enumerate() {
502        sums[i + 1] = sums[i] + v;
503    }
504    (0..d.len())
505        .map(|j| {
506            let lo = j.saturating_sub(before);
507            let hi = (j + after + 1).min(d.len());
508            (sums[hi] - sums[lo]) / (hi - lo) as f64
509        })
510        .collect()
511}
512
513/// Foote's tapered checkerboard over the boundary before each cell but the first.
514fn novelty(raw: &[Vec<f64>], half: usize) -> Vec<f64> {
515    let n = raw.len();
516    let taper: Vec<f64> = (0..2 * half)
517        .map(|a| {
518            let u = a as f64 - half as f64 + 0.5;
519            u.signum() * libm::exp(-u * u / (2.0 * (half * half) as f64))
520        })
521        .collect();
522    let total: f64 = taper.iter().map(|g| g.abs()).sum::<f64>().powi(2);
523    let at = |p: usize, q: usize| raw[p.abs_diff(q)][p.min(q)];
524    (1..n)
525        .map(|i| {
526            let first = i.saturating_sub(half);
527            let last = (i + half).min(n);
528            let mut sum = 0.0;
529            for p in first..last {
530                for q in first..last {
531                    sum += taper[p + half - i] * taper[q + half - i] * at(p, q);
532                }
533            }
534            sum / total
535        })
536        .collect()
537}