The Daisy Pod hardware arrived (Seed 1.2 / PCM3060). Build the controls
phase on top of the audio spike: encoder turn = tempo, push = play/pause,
buttons step the program list, knob 1 = volume, RGB LED 1 = beat colored
by dynamic, RGB LED 2 = transport. Boot still plays the spike groove so
the spike's decision criteria stay observable.
- pm-synth: add Player::{position,seek,set_volume,last_level} + Synth::
set_master, with host tests (tests/player.rs). Live tempo/program
changes rebuild the Player in thread context and swap it in under a
short critical section, preserving loop phase (seek) on tempo changes.
- pm-daisy: SysTick 1 kHz millis tick, non-blocking beat flash, and
PLAYING/VOLUME/LAST_LEVEL atomics the audio IRQ honors/publishes.
- New modules controls.rs (buttons/encoder/pots, libDaisy debounce +
quadrature decode, 1 kHz poll), leds.rs (active-low RGB + boot
self-test), programs.rs (spike groove at index 0 + pm-grid grooves).
- Pin map verified against libDaisy daisy_pod.cpp + the daisy crate's
pins.rs. Builds clean for all three Seed revisions; 91 KB/128 KB flash.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
498 lines
18 KiB
Rust
498 lines
18 KiB
Rust
//! Polyphonic drum-voice synthesizer — a faithful Rust port of `src/engine.js`'s synthesized
|
|
//! 808/909/GM voices. Transport-agnostic: a `Synth` mixes active `Voice`s sample-by-sample, which
|
|
//! works both for offline rendering (host → .wav, to verify the sound) and real-time buffer fills
|
|
//! on the device later.
|
|
//!
|
|
//! This is the f32 reference. It is `#![no_std]` (mirroring `track-format`) and routes its
|
|
//! transcendental math through `libm`, so the *exact same code* runs on the host (rendered to
|
|
//! .wav by `synthrender`) and on a Cortex-M target with an FPU (the Daisy Seed's STM32H750). The
|
|
//! RP2350/Cortex-M0+ port still wants fixed-point / table oscillators later (no FPU there) — the
|
|
//! voice recipes are the contract either way.
|
|
|
|
#![no_std]
|
|
|
|
extern crate alloc;
|
|
|
|
pub const SR: f32 = 48_000.0;
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum Wave {
|
|
Sine,
|
|
Tri,
|
|
Sq,
|
|
}
|
|
|
|
fn osc(w: Wave, phase: f32) -> f32 {
|
|
let p = phase - libm::floorf(phase);
|
|
match w {
|
|
Wave::Sine => libm::sinf(core::f32::consts::TAU * p),
|
|
Wave::Sq => {
|
|
if p < 0.5 {
|
|
1.0
|
|
} else {
|
|
-1.0
|
|
}
|
|
}
|
|
Wave::Tri => 4.0 * if p < 0.5 { p } else { 1.0 - p } - 1.0,
|
|
}
|
|
}
|
|
|
|
// ----- RBJ biquad (highpass / bandpass), the editor's BiquadFilter -----
|
|
#[derive(Clone, Copy)]
|
|
struct Biquad {
|
|
b0: f32,
|
|
b1: f32,
|
|
b2: f32,
|
|
a1: f32,
|
|
a2: f32,
|
|
x1: f32,
|
|
x2: f32,
|
|
y1: f32,
|
|
y2: f32,
|
|
}
|
|
impl Biquad {
|
|
fn hp(freq: f32, q: f32) -> Self {
|
|
let w0 = core::f32::consts::TAU * freq / SR;
|
|
let (sw, cw) = (libm::sinf(w0), libm::cosf(w0));
|
|
let alpha = sw / (2.0 * q);
|
|
let a0 = 1.0 + alpha;
|
|
Biquad::norm((1.0 + cw) / 2.0, -(1.0 + cw), (1.0 + cw) / 2.0, a0, -2.0 * cw, 1.0 - alpha)
|
|
}
|
|
fn bp(freq: f32, q: f32) -> Self {
|
|
let w0 = core::f32::consts::TAU * freq / SR;
|
|
let (sw, cw) = (libm::sinf(w0), libm::cosf(w0));
|
|
let alpha = sw / (2.0 * q);
|
|
let a0 = 1.0 + alpha;
|
|
Biquad::norm(alpha, 0.0, -alpha, a0, -2.0 * cw, 1.0 - alpha)
|
|
}
|
|
fn norm(b0: f32, b1: f32, b2: f32, a0: f32, a1: f32, a2: f32) -> Self {
|
|
Biquad { b0: b0 / a0, b1: b1 / a0, b2: b2 / a0, a1: a1 / a0, a2: a2 / a0, x1: 0.0, x2: 0.0, y1: 0.0, y2: 0.0 }
|
|
}
|
|
fn process(&mut self, x: f32) -> f32 {
|
|
let y = self.b0 * x + self.b1 * self.x1 + self.b2 * self.x2 - self.a1 * self.y1 - self.a2 * self.y2;
|
|
self.x2 = self.x1;
|
|
self.x1 = x;
|
|
self.y2 = self.y1;
|
|
self.y1 = y;
|
|
y
|
|
}
|
|
}
|
|
|
|
// ----- exponential amp envelope (ampEnv): 0.0001 → peak over `attack`, → 0.0001 over the rest -----
|
|
#[derive(Clone, Copy)]
|
|
struct Env {
|
|
peak: f32,
|
|
dur: f32,
|
|
attack: f32,
|
|
}
|
|
impl Env {
|
|
fn gain(&self, e: f32) -> f32 {
|
|
let peak = self.peak.max(0.0003);
|
|
if e < self.attack {
|
|
0.0001 * libm::powf(peak / 0.0001, e / self.attack)
|
|
} else if e < self.dur {
|
|
peak * libm::powf(0.0001 / peak, (e - self.attack) / (self.dur - self.attack).max(1e-4))
|
|
} else {
|
|
0.0
|
|
}
|
|
}
|
|
}
|
|
|
|
fn rng_next(s: &mut u32) -> f32 {
|
|
let mut x = *s;
|
|
x ^= x << 13;
|
|
x ^= x >> 17;
|
|
x ^= x << 5;
|
|
*s = x;
|
|
(x as f32 / u32::MAX as f32) * 2.0 - 1.0
|
|
}
|
|
|
|
enum Gen {
|
|
Tone { w: Wave, f0: f32, f1: f32, ramp: f32, phase: f32 },
|
|
Noise { rng: u32 },
|
|
Metal { ph: [f32; 6], bp: Biquad, hp: Biquad },
|
|
Squares { f: [f32; 2], ph: [f32; 2] },
|
|
}
|
|
const METAL_RATIOS: [f32; 6] = [2.0, 3.0, 4.16, 5.43, 6.79, 8.21];
|
|
impl Gen {
|
|
fn sample(&mut self, e: f32) -> f32 {
|
|
match self {
|
|
Gen::Tone { w, f0, f1, ramp, phase } => {
|
|
let f = if *f1 <= 0.0 || *f1 == *f0 {
|
|
*f0
|
|
} else if e >= *ramp {
|
|
f1.max(1.0)
|
|
} else {
|
|
*f0 * libm::powf(f1.max(1.0) / *f0, e / *ramp)
|
|
};
|
|
*phase += f / SR;
|
|
osc(*w, *phase)
|
|
}
|
|
Gen::Noise { rng } => rng_next(rng),
|
|
Gen::Metal { ph, bp, hp } => {
|
|
let mut sum = 0.0;
|
|
for i in 0..6 {
|
|
ph[i] += (40.0 * METAL_RATIOS[i]) / SR;
|
|
sum += osc(Wave::Sq, ph[i]);
|
|
}
|
|
hp.process(bp.process(sum))
|
|
}
|
|
Gen::Squares { f, ph } => {
|
|
let mut sum = 0.0;
|
|
for i in 0..2 {
|
|
ph[i] += f[i] / SR;
|
|
sum += osc(Wave::Sq, ph[i]);
|
|
}
|
|
sum
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Part {
|
|
gen: Gen,
|
|
bp: Option<Biquad>,
|
|
env: Env,
|
|
delay: f32,
|
|
t: f32,
|
|
}
|
|
impl Part {
|
|
fn next(&mut self) -> f32 {
|
|
let e = self.t - self.delay;
|
|
self.t += 1.0 / SR;
|
|
if e < 0.0 {
|
|
return 0.0;
|
|
}
|
|
let mut s = self.gen.sample(e);
|
|
if let Some(bp) = &mut self.bp {
|
|
s = bp.process(s);
|
|
}
|
|
s * self.env.gain(e)
|
|
}
|
|
fn done(&self) -> bool {
|
|
self.t - self.delay > self.env.dur + 0.02
|
|
}
|
|
}
|
|
|
|
/// One triggered drum hit = a small set of parts mixed together.
|
|
pub struct Voice {
|
|
parts: alloc::vec::Vec<Part>,
|
|
}
|
|
impl Voice {
|
|
fn next(&mut self) -> f32 {
|
|
let mut s = 0.0;
|
|
for p in &mut self.parts {
|
|
s += p.next();
|
|
}
|
|
s
|
|
}
|
|
fn done(&self) -> bool {
|
|
self.parts.iter().all(|p| p.done())
|
|
}
|
|
}
|
|
|
|
pub struct Synth {
|
|
voices: alloc::vec::Vec<Voice>,
|
|
seed: u32,
|
|
master: f32,
|
|
}
|
|
impl Default for Synth {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
impl Synth {
|
|
pub fn new() -> Self {
|
|
Synth { voices: alloc::vec::Vec::new(), seed: 0x2545_f491, master: 0.7 }
|
|
}
|
|
/// Trigger a voice by name at a click level (2 = accent, 1 = normal, 3 = ghost).
|
|
pub fn trigger(&mut self, name: &str, level: u8) {
|
|
let l = match level {
|
|
2 => 1.0,
|
|
1 => 0.7,
|
|
3 => 0.4,
|
|
_ => 0.7,
|
|
};
|
|
self.seed = self.seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223) | 1;
|
|
if let Some(v) = build(name, l, self.seed) {
|
|
self.voices.push(v);
|
|
}
|
|
}
|
|
/// One mixed mono sample, soft-limited to [-1, 1].
|
|
pub fn next_sample(&mut self) -> f32 {
|
|
let mut s = 0.0;
|
|
for v in &mut self.voices {
|
|
s += v.next();
|
|
}
|
|
self.voices.retain(|v| !v.done());
|
|
libm::tanhf(s * self.master)
|
|
}
|
|
pub fn active(&self) -> usize {
|
|
self.voices.len()
|
|
}
|
|
/// Set the master output gain (volume), applied before the soft limiter. Clamped to a sane
|
|
/// range so a runaway control value can't blow up the mix.
|
|
pub fn set_master(&mut self, v: f32) {
|
|
self.master = v.clamp(0.0, 4.0);
|
|
}
|
|
}
|
|
|
|
// ----- voice recipes (ports of the DRUMS table in engine.js) -----
|
|
fn tone(w: Wave, f0: f32, f1: f32, dur: f32, peak: f32, attack: f32) -> Part {
|
|
Part { gen: Gen::Tone { w, f0, f1, ramp: dur.min(0.09), phase: 0.0 }, bp: None, env: Env { peak, dur, attack }, delay: 0.0, t: 0.0 }
|
|
}
|
|
fn toned(w: Wave, f0: f32, f1: f32, dur: f32, peak: f32, attack: f32, filt: Biquad) -> Part {
|
|
Part { gen: Gen::Tone { w, f0, f1, ramp: dur.min(0.09), phase: 0.0 }, bp: Some(filt), env: Env { peak, dur, attack }, delay: 0.0, t: 0.0 }
|
|
}
|
|
fn noise(filt: Biquad, dur: f32, peak: f32, attack: f32, seed: u32) -> Part {
|
|
Part { gen: Gen::Noise { rng: seed | 1 }, bp: Some(filt), env: Env { peak, dur, attack }, delay: 0.0, t: 0.0 }
|
|
}
|
|
fn metal(dur: f32, hp_freq: f32, peak: f32) -> Part {
|
|
Part {
|
|
gen: Gen::Metal { ph: [0.0; 6], bp: Biquad::bp(10000.0, 0.8), hp: Biquad::hp(hp_freq, 1.0) },
|
|
bp: None,
|
|
env: Env { peak, dur, attack: 0.001 },
|
|
delay: 0.0,
|
|
t: 0.0,
|
|
}
|
|
}
|
|
fn delayed(mut p: Part, d: f32) -> Part {
|
|
p.delay = d;
|
|
p
|
|
}
|
|
fn cowbell(l: f32) -> Voice {
|
|
Voice {
|
|
parts: alloc::vec![Part {
|
|
gen: Gen::Squares { f: [540.0, 800.0], ph: [0.0, 0.0] },
|
|
bp: Some(Biquad::bp(2640.0, 1.2)),
|
|
env: Env { peak: 0.8 * l, dur: 0.3, attack: 0.001 },
|
|
delay: 0.0,
|
|
t: 0.0,
|
|
}],
|
|
}
|
|
}
|
|
|
|
fn build(name: &str, l: f32, seed: u32) -> Option<Voice> {
|
|
use Wave::*;
|
|
let v = |parts| Voice { parts };
|
|
Some(match name {
|
|
"beep" => v(alloc::vec![tone(Sq, if l >= 1.0 { 1600.0 } else { 1100.0 }, 0.0, 0.04, 0.5 * l, 0.002)]),
|
|
"kick" => v(alloc::vec![tone(Sine, 150.0, 50.0, 0.18, 1.0 * l, 0.002)]),
|
|
"snare" => v(alloc::vec![
|
|
tone(Tri, 190.0, 140.0, 0.12, 0.45 * l, 0.002),
|
|
noise(Biquad::hp(1500.0, 1.0), 0.2, 0.8 * l, 0.001, seed),
|
|
]),
|
|
"rim" => v(alloc::vec![toned(Sq, 1700.0, 0.0, 0.04, 0.6 * l, 0.001, Biquad::bp(1700.0, 4.0))]),
|
|
"clap" => v(alloc::vec![
|
|
noise(Biquad::bp(1200.0, 1.4), 0.06, 0.5 * l, 0.001, seed),
|
|
delayed(noise(Biquad::bp(1200.0, 1.4), 0.06, 0.5 * l, 0.001, seed ^ 0x9e3), 0.012),
|
|
delayed(noise(Biquad::bp(1200.0, 1.4), 0.06, 0.85 * l, 0.001, seed ^ 0x1b7), 0.024),
|
|
]),
|
|
"hatClosed" => v(alloc::vec![noise(Biquad::hp(7000.0, 1.0), 0.045, 0.5 * l, 0.001, seed)]),
|
|
"hatOpen" => v(alloc::vec![noise(Biquad::hp(7000.0, 1.0), 0.32, 0.45 * l, 0.002, seed)]),
|
|
"ride" => v(alloc::vec![
|
|
noise(Biquad::bp(6000.0, 0.8), 0.4, 0.32 * l, 0.002, seed),
|
|
tone(Sq, 5200.0, 0.0, 0.1, 0.13 * l, 0.002),
|
|
]),
|
|
"crash" => v(alloc::vec![noise(Biquad::hp(4000.0, 1.0), 0.8, 0.5 * l, 0.002, seed)]),
|
|
"tomLow" => v(alloc::vec![tone(Sine, 150.0, 100.0, 0.25, 0.9 * l, 0.002)]),
|
|
"tomMid" => v(alloc::vec![tone(Sine, 220.0, 150.0, 0.23, 0.9 * l, 0.002)]),
|
|
"tomHigh" => v(alloc::vec![tone(Sine, 300.0, 210.0, 0.20, 0.9 * l, 0.002)]),
|
|
"tambourine" => v(alloc::vec![noise(Biquad::hp(8000.0, 1.0), 0.12, 0.5 * l, 0.001, seed)]),
|
|
"cowbell" | "cowbell808" => cowbell(l),
|
|
"woodblock" | "jamblock" => {
|
|
if name == "jamblock" {
|
|
v(alloc::vec![toned(Sq, 2600.0, 2000.0, 0.045, 0.8 * l, 0.001, Biquad::bp(2000.0, 6.0))])
|
|
} else {
|
|
v(alloc::vec![tone(Tri, 1800.0, 1500.0, 0.06, 0.8 * l, 0.002)])
|
|
}
|
|
}
|
|
"claves" => v(alloc::vec![tone(Sine, 2500.0, 0.0, 0.045, 0.85 * l, 0.002)]),
|
|
"kick808" => v(alloc::vec![
|
|
tone(Sine, 120.0, 45.0, 0.7, 1.0 * l, 0.002),
|
|
noise(Biquad::hp(2000.0, 1.0), 0.008, 0.4 * (l * 0.5), 0.001, seed),
|
|
]),
|
|
"snare808" => v(alloc::vec![
|
|
tone(Tri, 178.0, 168.0, 0.16, 0.4 * l, 0.002),
|
|
tone(Tri, 331.0, 320.0, 0.12, 0.18 * l, 0.002),
|
|
noise(Biquad::hp(1000.0, 1.0), 0.16, 0.7 * l, 0.001, seed),
|
|
]),
|
|
"clap808" => v(alloc::vec![
|
|
noise(Biquad::bp(1100.0, 1.3), 0.05, 0.5 * l, 0.001, seed),
|
|
delayed(noise(Biquad::bp(1100.0, 1.3), 0.05, 0.5 * l, 0.001, seed ^ 0x9e3), 0.01),
|
|
delayed(noise(Biquad::bp(1100.0, 1.3), 0.05, 0.5 * l, 0.001, seed ^ 0x1b7), 0.02),
|
|
delayed(noise(Biquad::bp(1100.0, 1.3), 0.05, 0.85 * l, 0.001, seed ^ 0x511), 0.032),
|
|
]),
|
|
"hat808" => v(alloc::vec![metal(0.045, 7000.0, 0.4 * l)]),
|
|
"openHat808" => v(alloc::vec![metal(0.34, 7000.0, 0.38 * l)]),
|
|
"tom808" => v(alloc::vec![tone(Sine, 120.0, 78.0, 0.34, 0.9 * l, 0.002)]),
|
|
"kick909" => v(alloc::vec![
|
|
tone(Sine, 110.0, 46.0, 0.26, 1.0 * l, 0.002),
|
|
tone(Tri, 280.0, 60.0, 0.035, 0.5 * l, 0.002),
|
|
noise(Biquad::hp(3000.0, 1.0), 0.01, 0.5 * (l * 0.6), 0.001, seed),
|
|
]),
|
|
"snare909" => v(alloc::vec![
|
|
tone(Tri, 190.0, 162.0, 0.09, 0.28 * l, 0.002),
|
|
noise(Biquad::hp(1200.0, 1.0), 0.2, 0.85 * l, 0.001, seed),
|
|
]),
|
|
"clap909" => v(alloc::vec![
|
|
noise(Biquad::bp(1000.0, 1.0), 0.05, 0.6 * l, 0.001, seed),
|
|
delayed(noise(Biquad::bp(1000.0, 1.0), 0.05, 0.6 * l, 0.001, seed ^ 0x9e3), 0.009),
|
|
delayed(noise(Biquad::bp(1000.0, 1.0), 0.05, 0.6 * l, 0.001, seed ^ 0x1b7), 0.018),
|
|
delayed(noise(Biquad::bp(1000.0, 1.0), 0.2, 0.35 * l, 0.001, seed ^ 0x733), 0.018),
|
|
]),
|
|
"hat909" => v(alloc::vec![metal(0.05, 9000.0, 0.4 * l)]),
|
|
"ride909" => v(alloc::vec![
|
|
metal(0.5, 6000.0, 0.3 * l),
|
|
noise(Biquad::bp(7000.0, 0.7), 0.18, 0.18 * l, 0.002, seed),
|
|
]),
|
|
"crash909" => v(alloc::vec![
|
|
metal(0.9, 5000.0, 0.34 * l),
|
|
noise(Biquad::hp(4000.0, 1.0), 0.9, 0.4 * l, 0.002, seed),
|
|
]),
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
// ----- self-running sequencer: the shared front-end driven by both host + device -----
|
|
|
|
use alloc::string::String;
|
|
use alloc::vec::Vec;
|
|
use track_format::Track;
|
|
|
|
/// The editor's default kit: friendly GM names point at the punchier 808/909 renders (engine.js).
|
|
/// Shared so the host preview and the device pick identical voices.
|
|
pub fn default_kit(s: &str) -> &str {
|
|
match s {
|
|
"kick" => "kick909",
|
|
"snare" => "snare909",
|
|
"clap" => "clap909",
|
|
"hatClosed" => "hat909",
|
|
"hatOpen" => "openHat808",
|
|
"ride" => "ride909",
|
|
"crash" => "crash909",
|
|
"cowbell" => "cowbell808",
|
|
other => other,
|
|
}
|
|
}
|
|
|
|
/// A self-running sequencer. It owns the `Synth` and the scheduled click timeline and renders the
|
|
/// groove **sample-by-sample**, looping at the pattern boundary. Decays ring across the loop seam
|
|
/// (voices are never cleared), so a hat tail bleeds correctly into the downbeat.
|
|
///
|
|
/// This is the single piece both front-ends drive identically — the on-device SAI audio callback
|
|
/// (`pm-daisy`) and the host WAV preview (`synthrender`) — which is what makes the host render a
|
|
/// faithful preview of what the hardware will play. The hot path is integer-only (clicks are
|
|
/// pre-resolved to sample indices in [`Player::new`]); no allocation or float-time math per sample.
|
|
pub struct Player {
|
|
synth: Synth,
|
|
/// (sample index, lane, level), sorted by sample index.
|
|
events: Vec<(u64, usize, u8)>,
|
|
/// Resolved (via `default_kit`) voice name per lane.
|
|
lane_voice: Vec<String>,
|
|
loop_samples: u64,
|
|
n: u64,
|
|
ci: usize,
|
|
fired: u32,
|
|
/// Level of the most recently fired click (2 = accent, 1 = normal, 3 = ghost; 0 = none yet).
|
|
/// The firmware reads this to color the beat LED. When several clicks land on one sample the
|
|
/// loudest wins (accent > normal > ghost).
|
|
last_level: u8,
|
|
}
|
|
|
|
impl Player {
|
|
/// Build from a parsed `Track`, scheduling `bars` master bars (the loop length).
|
|
pub fn new(track: &Track, bars: i64) -> Self {
|
|
let mbar = track_format::schedule::master_bar_ns(track);
|
|
let total_ns = mbar * bars.max(1);
|
|
let to_sample = |ns: i64| -> u64 { (ns as f64 / 1.0e9 * SR as f64 + 0.5) as u64 };
|
|
let mut events: Vec<(u64, usize, u8)> = track_format::schedule::render(track, bars)
|
|
.iter()
|
|
.map(|c| (to_sample(c.time_ns), c.lane, c.level))
|
|
.collect();
|
|
events.sort_by_key(|e| e.0);
|
|
let lane_voice = track.lanes.iter().map(|l| String::from(default_kit(&l.sound))).collect();
|
|
Player { synth: Synth::new(), events, lane_voice, loop_samples: to_sample(total_ns).max(1), n: 0, ci: 0, fired: 0, last_level: 0 }
|
|
}
|
|
|
|
/// One mono sample in [-1, 1]. Call at `SR` (48 kHz). Triggers any clicks due at this sample
|
|
/// first, so the click's own first sample is included.
|
|
pub fn next_sample(&mut self) -> f32 {
|
|
// Loudest level among clicks landing on this sample (accent > normal > ghost); published to
|
|
// `last_level` so the beat LED can color by dynamic. Left unchanged on silent samples.
|
|
let mut best: Option<u8> = None;
|
|
while self.ci < self.events.len() && self.events[self.ci].0 <= self.n {
|
|
let (_, lane, level) = self.events[self.ci];
|
|
self.synth.trigger(self.lane_voice[lane].as_str(), level);
|
|
self.fired = self.fired.wrapping_add(1);
|
|
if best.map_or(true, |b| level_rank(level) > level_rank(b)) {
|
|
best = Some(level);
|
|
}
|
|
self.ci += 1;
|
|
}
|
|
if let Some(b) = best {
|
|
self.last_level = b;
|
|
}
|
|
let s = self.synth.next_sample();
|
|
self.n += 1;
|
|
if self.n >= self.loop_samples {
|
|
self.n = 0;
|
|
self.ci = 0;
|
|
}
|
|
s
|
|
}
|
|
|
|
/// Voices currently ringing (for a "load" readout on-device).
|
|
pub fn active(&self) -> usize {
|
|
self.synth.active()
|
|
}
|
|
|
|
/// Total clicks triggered since boot (monotonic, wraps). The firmware watches this to flash a
|
|
/// beat LED — a change since the last block means at least one click fired.
|
|
pub fn fired(&self) -> u32 {
|
|
self.fired
|
|
}
|
|
|
|
/// Position within the loop as a fraction in [0, 1). Used to carry the groove's phase across a
|
|
/// [`Player`] rebuild (e.g. a live tempo change) so the pattern doesn't jump back to bar 1.
|
|
pub fn position(&self) -> f32 {
|
|
self.n as f32 / self.loop_samples as f32
|
|
}
|
|
|
|
/// Jump to `fraction` of the way through the loop (clamped to [0, 1)). Resets the click cursor
|
|
/// to the first event at or after the new position; ringing voices are not affected (a fresh
|
|
/// `Player` has none). Pairs with [`position`](Self::position) for a phase-preserving swap.
|
|
pub fn seek(&mut self, fraction: f32) {
|
|
let f = fraction.clamp(0.0, 0.999_999);
|
|
self.n = (f * self.loop_samples as f32) as u64;
|
|
self.ci = self.events.partition_point(|e| e.0 < self.n);
|
|
}
|
|
|
|
/// Set the master output volume (forwarded to the inner [`Synth`]).
|
|
pub fn set_volume(&mut self, v: f32) {
|
|
self.synth.set_master(v);
|
|
}
|
|
|
|
/// Level of the most recently fired click (2 = accent, 1 = normal, 3 = ghost; 0 = none yet).
|
|
pub fn last_level(&self) -> u8 {
|
|
self.last_level
|
|
}
|
|
}
|
|
|
|
/// Priority of a click level for "loudest wins" when several land on one sample: accent (2) beats
|
|
/// normal (1) beats ghost (3); anything else is lowest.
|
|
fn level_rank(level: u8) -> u8 {
|
|
match level {
|
|
2 => 3,
|
|
1 => 2,
|
|
3 => 1,
|
|
_ => 0,
|
|
}
|
|
}
|
|
|
|
/// The groove the Daisy spike plays on boot. Defined here so the firmware and the host preview
|
|
/// (`synthrender` → `pm-daisy-preview.wav`) share one source of truth — change it in one place and
|
|
/// both follow. A 124-BPM four-on-the-floor 909 pattern with backbeat clap and 8th-note hats.
|
|
pub const SPIKE_PROGRAM: &str = "t124;kick909:4;clap909:4=.X.X;hat909:4/2=.X.X.X.X";
|
|
/// Loop length in master bars for [`SPIKE_PROGRAM`].
|
|
pub const SPIKE_BARS: i64 = 4;
|