mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-27 03:56:45 -06:00
detect_transients (Tab to Transient, Separate at Transients, Beat Detective) missed most onsets that weren't the loudest around, and put notes that start over a ringing note 25-35 ms early, in the previous note's tail. Its threshold was a share of the loudest flux in the analysed audio, so quiet hits vanished next to loud ones and the result changed with where the analysis started. It placed each onset on the first sample reaching a quarter of the frame's peak, which a ringing note already does. - Vibrato-suppressed spectral flux: 21 ms windows 5.3 ms apart, log magnitudes on 24 bands per octave from 30 Hz to 20 kHz, each band compared with the loudest of its neighbours in the frame before, so vibrato and chorus don't count. Channels are analysed as the sum of their energies, so stereo content that cancels in mono still counts. - A peak must be the highest within 30 ms and exceed the mean flux from 100 ms before to 70 ms after by a fixed amount (2.5 at sensitivity 0.5), so quiet hits count and the result doesn't depend on where the audio starts. - A fast release smears energy between a note's partials, which looks like a rise. A detection also needs a 3 dB step in its high-passed energy, or total energy that grows in its frame or the next. - Each onset goes to the step edge of the high-passed energy (the sample where the next 3 ms gain most over the 6 ms before), then back to where the energy crosses the midpoint between the two: 0.3-0.5 ms before percussive attacks. On 29 synthetic cases with known onsets (drums with ghost notes; drum-synth grooves dry and through reverb, compression and limiting; 180 bpm; flams and claps; legato piano; plucked bass and guitar; strums; 808 kicks; hand drums; synth bass, leads, pads and staccato notes; string swells; a vibrato pad; quiet hits over noise and hum; stereo pairs; formant syllables), the F-measure went from 0.658 to 0.977. It found 1398 onsets instead of 870, with 5 false ones instead of 315; all 5 are vowels starting after a consonant. Still missed: hats under a crash, ghost notes under a louder hit's tail, and slow string swells. The old offline_meters test bursts stopped dead at -37 dB, an audible click that now counts as a transient; they ring out instead.
150 lines
5.4 KiB
Rust
150 lines
5.4 KiB
Rust
use soundcraft_dsp::offline::detect_transients;
|
|
use std::f32::consts::TAU;
|
|
|
|
const SR: f32 = 48_000.0;
|
|
|
|
fn at(secs: f32) -> usize {
|
|
(secs * SR) as usize
|
|
}
|
|
|
|
fn mix(len: f32, parts: &[(f32, Vec<f32>)]) -> Vec<f32> {
|
|
let mut x = vec![0.0; at(len)];
|
|
for (start, sig) in parts {
|
|
let fade = at(0.03).min(sig.len());
|
|
for (i, v) in sig.iter().enumerate() {
|
|
let left = sig.len() - i;
|
|
let g = if left < fade { 0.5 - 0.5 * (std::f32::consts::PI * left as f32 / fade as f32).cos() } else { 1.0 };
|
|
if let Some(o) = x.get_mut(at(*start) + i) {
|
|
*o += v * g;
|
|
}
|
|
}
|
|
}
|
|
x
|
|
}
|
|
|
|
fn noise(seed: &mut u32) -> f32 {
|
|
*seed ^= *seed << 13;
|
|
*seed ^= *seed >> 17;
|
|
*seed ^= *seed << 5;
|
|
*seed as f32 / u32::MAX as f32 * 2.0 - 1.0
|
|
}
|
|
|
|
fn hit(level: f32, decay: f32, seed: &mut u32) -> Vec<f32> {
|
|
let mut prev = 0.0;
|
|
(0..at(decay * 6.0))
|
|
.map(|i| {
|
|
let w = noise(seed);
|
|
let hp = w - prev;
|
|
prev = w;
|
|
level * hp * (-(i as f32) / SR / decay).exp()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn note(f: f32, level: f32, secs: f32) -> Vec<f32> {
|
|
(0..at(secs))
|
|
.map(|i| {
|
|
let t = i as f32 / SR;
|
|
let s: f32 = (1..=8).map(|k| (TAU * f * k as f32 * t).sin() * (-t * (0.6 + 0.4 * k as f32)).exp() / k as f32).sum();
|
|
level * s * (t / 0.002).min(1.0)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn drums(len: f32) -> (Vec<f32>, Vec<usize>) {
|
|
let mut seed = 7;
|
|
let mut parts = Vec::new();
|
|
let mut onsets = Vec::new();
|
|
let mut t = 0.3;
|
|
while t < len - 0.5 {
|
|
parts.push((t, hit(0.9, 0.04, &mut seed)));
|
|
onsets.push(at(t));
|
|
for k in 1..4 {
|
|
parts.push((t + 0.14 * k as f32, hit(0.09, 0.03, &mut seed)));
|
|
onsets.push(at(t + 0.14 * k as f32));
|
|
}
|
|
t += 0.56;
|
|
}
|
|
(mix(len, &parts), onsets)
|
|
}
|
|
|
|
fn near(found: &[usize], onsets: &[usize], tolerance: usize) -> bool {
|
|
found.len() == onsets.len() && found.iter().zip(onsets).all(|(f, o)| f.abs_diff(*o) <= tolerance)
|
|
}
|
|
|
|
#[test]
|
|
fn notes_starting_over_a_ringing_note_are_placed_on_their_attacks() {
|
|
let starts = [0.3, 0.72, 1.14, 1.56, 1.98, 2.4];
|
|
let freqs = [196.0, 261.6, 196.0, 329.6, 246.9, 392.0];
|
|
let parts: Vec<(f32, Vec<f32>)> =
|
|
starts.iter().zip(freqs).enumerate().map(|(k, (s, f))| (*s, note(f, 0.2 + 0.1 * (k % 3) as f32, 1.1))).collect();
|
|
let found = detect_transients(&[mix(4.0, &parts)], SR, 0.5);
|
|
let onsets: Vec<usize> = starts.iter().map(|s| at(*s)).collect();
|
|
assert!(near(&found, &onsets, at(0.003)), "{found:?} vs {onsets:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn quieter_hits_between_loud_ones_count() {
|
|
let (x, onsets) = drums(6.0);
|
|
let found = detect_transients(&[x], SR, 0.5);
|
|
assert!(near(&found, &onsets, at(0.001)), "{found:?} vs {onsets:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_held_chord_with_vibrato_and_chorus_has_no_transients() {
|
|
let mut ph = [0.0f32; 6];
|
|
let x: Vec<f32> = (0..at(6.0))
|
|
.map(|i| {
|
|
let t = i as f32 / SR;
|
|
let mut s = 0.0;
|
|
for (j, f) in [220.0f32, 277.2, 329.6].iter().enumerate() {
|
|
for (d, sign) in [(0, 1.0f32), (1, -1.0)] {
|
|
let p = &mut ph[j * 2 + d];
|
|
*p += TAU * f * (1.0 + sign * 0.004 * (TAU * (0.3 + 0.07 * j as f32) * t).sin()) / SR;
|
|
s += (1..=6).map(|k| (*p * k as f32).sin() / k as f32).sum::<f32>();
|
|
}
|
|
}
|
|
0.04 * s * (t / 0.4).min(1.0) * (1.0 + 0.3 * (TAU * 4.0 * t).sin())
|
|
})
|
|
.collect();
|
|
let found: Vec<usize> = detect_transients(&[x], SR, 0.5).into_iter().filter(|p| *p > at(0.5)).collect();
|
|
assert!(found.is_empty(), "{found:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn transients_do_not_depend_on_where_the_analysed_audio_starts() {
|
|
let (mut x, _) = drums(8.0);
|
|
let from = at(3.3);
|
|
x.iter_mut().skip(from - at(0.4)).for_each(|v| *v *= 0.05);
|
|
let whole = detect_transients(&[x.clone()], SR, 0.5);
|
|
let part: Vec<usize> = detect_transients(&[x.get(from..).unwrap_or_default().to_vec()], SR, 0.5).into_iter().map(|p| p + from).collect();
|
|
let settled = |v: &[usize]| v.iter().copied().filter(|p| *p > from + at(0.25)).collect::<Vec<_>>();
|
|
assert!(!settled(&whole).is_empty());
|
|
assert_eq!(settled(&whole), settled(&part), "{whole:?} vs {part:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_fast_release_is_not_a_transient() {
|
|
let starts: Vec<f32> = (0..8).map(|k| 0.3 + k as f32 * 0.5).collect();
|
|
let note: Vec<f32> = (0..at(0.4))
|
|
.map(|i| {
|
|
let t = i as f32 / SR;
|
|
let level = if t < 0.15 { (t / 0.01).min(1.0) } else { 0.001f32.powf((t - 0.15) / 0.04) };
|
|
let saw: f32 = (1..=20).map(|k| (TAU * 659.0 * k as f32 * t).sin() / k as f32).sum();
|
|
0.2 * level * saw
|
|
})
|
|
.collect();
|
|
let parts: Vec<(f32, Vec<f32>)> = starts.iter().map(|s| (*s, note.clone())).collect();
|
|
let found = detect_transients(&[mix(4.5, &parts)], SR, 0.5);
|
|
let onsets: Vec<usize> = starts.iter().map(|s| at(*s)).collect();
|
|
assert!(near(&found, &onsets, at(0.002)), "{found:?} vs {onsets:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn channels_that_cancel_when_mixed_still_show_their_transients() {
|
|
let (left, onsets) = drums(4.0);
|
|
let right: Vec<f32> = left.iter().map(|v| -v).collect();
|
|
let found = detect_transients(&[left, right], SR, 0.5);
|
|
assert!(near(&found, &onsets, at(0.001)), "{found:?} vs {onsets:?}");
|
|
}
|