Merge pull request #26 from Garygunn94/feat/metronome-click

Make the Click option audible: metronome in live playback
This commit is contained in:
Brandon Thomas
2026-10-09 18:11:29 -04:00
committed by GitHub
4 changed files with 150 additions and 6 deletions
+5
View File
@@ -278,6 +278,11 @@ pub fn gain_to_db(g: f32) -> f32 {
(20.0 * a.log10()).max(MIN_DB)
}
/// MIDI note number to frequency in Hz (equal temperament, A4 = note 69 = 440 Hz).
pub fn midi_to_hz(note: f64) -> f64 {
440.0 * 2f64.powf((note - 69.0) / 12.0)
}
#[cfg(test)]
mod trait_default_tests {
use super::*;
+2 -6
View File
@@ -110,10 +110,6 @@ fn drums(sr: f64, frames: usize) -> (Vec<f32>, Vec<f32>, Vec<f32>) {
(k, s, h)
}
fn midi_hz(n: f64) -> f64 {
440.0 * 2f64.powf((n - 69.0) / 12.0)
}
/// Chord roots per bar (A minor, F, C, G progression), as MIDI notes.
const PROG: [[u8; 4]; 4] = [[57, 60, 64, 69], [53, 57, 60, 65], [48, 52, 55, 60], [55, 59, 62, 67]];
@@ -127,7 +123,7 @@ fn bass(sr: f64, frames: usize) -> Vec<f32> {
for (eighth, semi, beats) in pattern {
let at = bar * beat * 4 + eighth * beat / 2;
let len = (beats * beat as f64) as usize;
let f = midi_hz(root + semi);
let f = soundcraft_dsp::midi_to_hz(root + semi);
let mut ph = 0.0f64;
for i in 0..len {
let Some(o) = out.get_mut(at + i) else { break };
@@ -152,7 +148,7 @@ fn pad(sr: f64, frames: usize) -> (Vec<f32>, Vec<f32>) {
let chord = PROG[bar % 4];
let at = bar * bar_len;
for (vi, n) in chord.iter().enumerate() {
let f = midi_hz(f64::from(*n) + 12.0);
let f = soundcraft_dsp::midi_to_hz(f64::from(*n) + 12.0);
for (side, buf) in [(-1.0f64, &mut l), (1.0, &mut r)] {
let det = 1.0 + side * 0.0035 * (vi as f64 + 1.0);
let mut ph = vi as f64 * 0.17;
+142
View File
@@ -299,6 +299,9 @@ pub struct MixEngine {
pub monitor_only: bool,
/// Recording: record-armed tracks hear their input (auto input).
pub recording: bool,
/// Mix the metronome (Options > Click) into the output. Playback turns this on; offline renders
/// (bounces, stems, exports) leave it off so the click is never printed into a file.
pub metronome: bool,
/// Third-party instances come from [`MixEngine::adopt`] instead of being created in `sync`.
external: bool,
/// Instances handed over by the host, waiting for `sync` to place them.
@@ -363,6 +366,7 @@ impl MixEngine {
input: Vec::new(),
monitor_only: false,
recording: false,
metronome: false,
external: false,
adopt: HashMap::new(),
retired: Vec::new(),
@@ -655,6 +659,13 @@ impl MixEngine {
self.meters.entry(t.id).or_default().measure(&self.main, frames, 0.0);
}
}
// Metronome: summed after the master fader, so it reaches the speakers without passing
// through any track, send or insert. Only the playback engine sets `metronome`.
// Not while monitoring only (transport stopped): the position does not advance there, so the
// start of the same beat would repeat every block as a buzz.
if self.metronome && !self.monitor_only && s.edit.click && !(s.edit.flag("click.only_during_record") && !self.recording) {
click::render(s, pos, frames, &mut self.main);
}
// Output: min(out.len(), main channels) channels; any further output channels are silent.
for (c, o) in out.iter_mut().enumerate() {
let src = self.main.get(c);
@@ -1075,6 +1086,67 @@ fn receives_solo(s: &Session, t: &Track) -> bool {
.any(|o| o.mixer.solo && (o.mixer.output == Route::Bus(*b) || o.mixer.sends.iter().flatten().any(|sn| sn.target == Route::Bus(*b))))
}
/// Metronome: a short synthesized blip on every beat of the tempo map, accented on beat 1 of
/// each bar. Settings come from `setup.click_countoff` (`click.*` values).
mod click {
use super::*;
/// Blip length in seconds.
const LEN_S: f64 = 0.03;
pub fn render(s: &Session, pos: Samples, frames: usize, main: &mut [Vec<f32>]) {
let sr = s.sample_rate;
let rate = sr.as_f64();
let len = (LEN_S * rate) as i64;
let end = pos.saturating_add(frames as i64);
let gain = f64::from(db_to_gain(s.edit.value("click.volume_db", -6.0).clamp(-60.0, 12.0) as f32));
let accent = (note_hz(s.edit.value("click.accent_note", 84.0)), s.edit.value("click.accent_velocity", 127.0));
let normal = (note_hz(s.edit.value("click.normal_note", 72.0)), s.edit.value("click.normal_velocity", 100.0));
// Start one blip length early so a click that began in an earlier block still rings into this one.
let t0 = s.tempo.samples_to_ticks(pos.saturating_sub(len), sr).max(0).saturating_sub(1);
let mut tick = t0.saturating_sub(s.tempo.bar_beat_at_tick(t0).tick);
// Blocks hold a few beats at most; the guard only protects against a malformed tempo map.
for _ in 0..4096 {
let at = s.tempo.tick_to_samples(tick, sr);
if at >= end {
break;
}
if at.saturating_add(len) > pos {
let (hz, vel) = if s.tempo.bar_beat_at_tick(tick).beat == 1 { accent } else { normal };
let amp = gain * (vel.clamp(1.0, 127.0) / 127.0);
blip(main, at - pos, frames, hz, amp as f32, rate);
}
tick = tick.saturating_add(s.tempo.meter_at_tick(tick).ticks_per_beat());
}
}
/// Adds one decaying sine blip whose first sample sits `rel` frames into the block (negative
/// when it began before the block) to every channel.
fn blip(main: &mut [Vec<f32>], rel: i64, frames: usize, hz: f64, amp: f32, rate: f64) {
let len = (LEN_S * rate) as usize;
let w = std::f64::consts::TAU * hz / rate;
let first = usize::try_from(-rel).unwrap_or(0);
for i in first..len {
let Ok(at) = usize::try_from(rel + i as i64) else { continue };
if at >= frames {
break;
}
let env = (-6.0 * i as f64 / len as f64).exp() as f32;
let v = ((w * i as f64).sin() as f32) * env * amp;
for ch in main.iter_mut() {
if let Some(d) = ch.get_mut(at) {
*d += v;
}
}
}
}
/// Click pitch for a MIDI note setting (clamped to the MIDI range).
fn note_hz(note: f64) -> f64 {
soundcraft_dsp::midi_to_hz(note.clamp(0.0, 127.0))
}
}
fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut [Vec<f32>], _scratch: &mut [f32]) {
let ClipContent::Audio { source, offset } = clip.content else { return };
let block = Range { start: pos, end: pos.saturating_add(frames as i64) };
@@ -1934,6 +2006,76 @@ mod tests {
assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0)));
}
#[test]
fn stopped_monitoring_does_not_click() {
let mut s = Session::default();
s.edit.click = true;
let mut eng = MixEngine::new(48_000.0, 512);
eng.metronome = true;
eng.monitor_only = true;
let mut out = vec![vec![0.0f32; 512]; 2];
for _ in 0..4 {
eng.render(&s, 0, 512, &mut out);
assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0)));
}
}
/// Renders `len` frames from zero the way playback does (metronome on), in blocks of `block`.
fn render_playback(s: &Session, len: usize, block: usize, recording: bool) -> Vec<Vec<f32>> {
let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);
eng.metronome = true;
eng.recording = recording;
let mut out = vec![vec![0.0f32; len]; 2];
let mut tmp = vec![vec![0.0f32; block]; 2];
let mut done = 0usize;
while done < len {
let n = (len - done).min(block);
eng.render(s, done as i64, n, &mut tmp);
for (o, t) in out.iter_mut().zip(tmp.iter()) {
if let (Some(d), Some(src)) = (o.get_mut(done..done + n), t.get(..n)) {
d.copy_from_slice(src);
}
}
done += n;
}
out
}
#[test]
fn click_sounds_on_every_beat_only_in_playback() {
let mut s = Session::default();
let beat = s.sample_rate.as_f64() as usize * 60 / 120; // 120 BPM quarter notes
let len = beat * 8;
s.edit.click = true;
// Offline renders (bounces, stems) never print the metronome.
let offline = render_range(&s, Range::new(0, len as i64), 512);
assert!(offline.iter().all(|c| c.iter().all(|x| *x == 0.0)));
let out = render_playback(&s, len, 512, false);
for b in 0..8usize {
let at = b * beat;
let on: f32 = out[0][at..at + 200].iter().map(|x| x.abs()).fold(0.0, f32::max);
let off: f32 = out[0][at + beat / 2..at + beat / 2 + 200].iter().map(|x| x.abs()).fold(0.0, f32::max);
assert!(on > 0.1, "beat {b} should click, peak {on}");
assert_eq!(off, 0.0, "between beats must stay silent");
assert_eq!(out[0][at], out[1][at], "both channels get the click");
}
// Blocks that start mid-beat render the same clicks as a single pass.
let split = render_playback(&s, len, 97, false);
assert_eq!(out, split);
}
#[test]
fn click_only_during_record_follows_recording() {
let mut s = Session::default();
s.edit.click = true;
s.edit.set_flag("click.only_during_record", true);
let idle = render_playback(&s, 48_000, 512, false);
assert!(idle.iter().all(|c| c.iter().all(|x| *x == 0.0)));
let rec = render_playback(&s, 48_000, 512, true);
assert!(rec.iter().any(|c| c.iter().any(|x| x.abs() > 0.1)));
}
/// A session with one track of `fmt` playing a distinct DC level per channel (0.1, 0.2, …).
fn multichannel_session(fmt: ChannelFormat, main: ChannelFormat, frames: usize) -> (Session, TrackId) {
let mut s = Session::default();
+1
View File
@@ -99,6 +99,7 @@ struct AudioState {
fn new_engine(sr: f32) -> MixEngine {
let mut m = MixEngine::new(sr, BLOCK);
m.set_external_instances(true);
m.metronome = true;
m
}