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