diff --git a/crates/dsp/src/lib.rs b/crates/dsp/src/lib.rs index 6b6c0235..7572ac3c 100644 --- a/crates/dsp/src/lib.rs +++ b/crates/dsp/src/lib.rs @@ -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::*; diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index bc8a2a2e..1e7daa00 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -110,10 +110,6 @@ fn drums(sr: f64, frames: usize) -> (Vec, Vec, Vec) { (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 { 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, Vec) { 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; diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index ecc15af9..ef70b574 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -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]) { + 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], 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], _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> { + 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(); diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index 6bc6b29e..1e48e69d 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -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 }