Skip to main content

audiobox/synth/
chip.rs

1//! Opt-in tracker primitives. This is not a cycle-accurate console emulator.
2use super::prepared::BeatClock;
3use super::{Duty, NoiseMode, Oscillator, Pitch, Waveform};
4use crate::{Error, Limits, Result};
5
6pub const MAX_MACRO_VALUES: usize = 64;
7pub const MAX_SAMPLE_VALUES: usize = 65_536;
8/// NTSC CPU cycles per noise-register shift, as documented by NESdev's APU Noise.
9pub const NTSC_NOISE_PERIODS: [u16; 16] = [
10    4, 8, 16, 32, 64, 96, 128, 160, 202, 254, 380, 508, 762, 1016, 2034, 4068,
11];
12/// Rounded NTSC CPU clock used by this model, in Hz.
13pub const NTSC_CLOCK: u32 = 1_789_773;
14
15/// A macro's step interval. Frame clocks are explicit simulation rates, independent
16/// of audio callback size. Tick clocks are relative to the note's first sample.
17#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
18pub enum MacroStep {
19    /// One step per `frames` simulation frames at `hz` frames/second; both 1..=1000.
20    Frames { frames: u16, hz: u16 },
21    /// One step per 1..=960 score ticks.
22    Ticks(u16),
23}
24impl Default for MacroStep {
25    fn default() -> Self {
26        Self::Frames { frames: 1, hz: 60 }
27    }
28}
29
30/// An integer instrument sequence. Index zero applies to the first sample. At its
31/// end it holds, or loops to `loop_start` after playing the initial prefix once.
32/// Volume values are 0..=15; duty values 0..=3 select 12.5/25/50/75 percent;
33/// arpeggio values are -48..=48 semitones; fine pitch is -1200..=1200 cents.
34#[derive(Clone, Debug, Eq, Hash, PartialEq)]
35pub struct Macro {
36    pub values: Vec<i16>,
37    pub step: MacroStep,
38    pub loop_start: Option<usize>,
39}
40impl Macro {
41    pub fn new(values: Vec<i16>, step: MacroStep) -> Result<Self> {
42        let m = Self {
43            values,
44            step,
45            loop_start: None,
46        };
47        m.validate()?;
48        Ok(m)
49    }
50    pub fn validate(&self) -> Result<()> {
51        if self.values.is_empty()
52            || self.values.len() > MAX_MACRO_VALUES
53            || self.loop_start.is_some_and(|i| i >= self.values.len())
54        {
55            return Err(Error::invalid(
56                "A macro needs 1..=64 values and an in-range loop start",
57            ));
58        }
59        let valid = match self.step {
60            MacroStep::Frames { frames, hz } => {
61                (1..=1000).contains(&frames) && (1..=1000).contains(&hz)
62            }
63            MacroStep::Ticks(t) => (1..=960).contains(&t),
64        };
65        if !valid {
66            return Err(Error::invalid("Invalid macro clock interval"));
67        }
68        Ok(())
69    }
70    /// Query a validated macro at a relative note age. Steps use a rational clock
71    /// (no cumulative rounding). Tick boundaries can differ by one sample from
72    /// globally floor-rounded score note positions.
73    pub fn value_at(
74        &self,
75        frame: u64,
76        sample_rate: u32,
77        tempo: u16,
78        ticks_per_quarter: u16,
79    ) -> Result<i16> {
80        self.validate()?;
81        Limits::default().check(0, 1, sample_rate)?;
82        if !(1..=999).contains(&tempo) || !(1..=960).contains(&ticks_per_quarter) {
83            return Err(Error::invalid("Invalid macro score clock"));
84        }
85        Ok(self.value(frame, sample_rate, tempo, ticks_per_quarter))
86    }
87    fn value(&self, age: u64, rate: u32, tempo: u16, ticks: u16) -> i16 {
88        self.value_clock(age, rate, tempo, ticks, None)
89    }
90    fn value_clock(
91        &self,
92        age: u64,
93        rate: u32,
94        tempo: u16,
95        ticks: u16,
96        clock: Option<BeatClock>,
97    ) -> i16 {
98        let index = match self.step {
99            MacroStep::Frames { frames, hz } => {
100                age as u128 * hz as u128 / (rate as u128 * frames as u128)
101            }
102            MacroStep::Ticks(step) => clock.map_or_else(
103                || age as u128 * tempo as u128 * ticks as u128 / (rate as u128 * 60 * step as u128),
104                |c| c.numerator * ticks as u128 / (c.denominator * step as u128),
105            ),
106        };
107        let n = self.values.len();
108        let index = if index < n as u128 {
109            index as usize
110        } else if let Some(start) = self.loop_start {
111            start + ((index - start as u128) % (n - start) as u128) as usize
112        } else {
113            n - 1
114        };
115        self.values[index]
116    }
117}
118
119/// A looping 4-bit waveform, read with sample-and-hold. 2..=256 entries, 0..=15.
120/// Values map linearly from -1 to +1; tables and PCM are intentionally not anti-aliased.
121#[derive(Clone, Debug, Eq, Hash, PartialEq)]
122pub struct Wavetable {
123    pub values: Vec<u8>,
124}
125impl Wavetable {
126    pub fn new(values: Vec<u8>) -> Result<Self> {
127        let table = Self { values };
128        table.validate()?;
129        Ok(table)
130    }
131    pub fn validate(&self) -> Result<()> {
132        if !(2..=256).contains(&self.values.len()) || self.values.iter().any(|&v| v > 15) {
133            return Err(Error::invalid("A wavetable needs 2..=256 four-bit values"));
134        }
135        Ok(())
136    }
137}
138
139/// Unsigned low-rate PCM, 2..=8 bits stored in one byte/value. Sample-and-hold with
140/// optional whole-sample looping. Pitch is relative to `root`; it follows gate/ADSR.
141/// A non-looping sample stops at its data end even when its note is still held.
142#[derive(Clone, Debug, Eq, Hash, PartialEq)]
143pub struct ChipSample {
144    pub values: Vec<u8>,
145    pub bits: u8,
146    /// 100..=32000 Hz.
147    pub sample_rate: u32,
148    pub root: Pitch,
149    pub looping: bool,
150}
151impl ChipSample {
152    pub fn new(values: Vec<u8>, bits: u8, sample_rate: u32) -> Result<Self> {
153        let sample = Self {
154            values,
155            bits,
156            sample_rate,
157            root: Pitch::C4,
158            looping: false,
159        };
160        sample.validate()?;
161        Ok(sample)
162    }
163    pub fn validate(&self) -> Result<()> {
164        if self.values.is_empty()
165            || self.values.len() > MAX_SAMPLE_VALUES
166            || !(2..=8).contains(&self.bits)
167            || !(100..=32_000).contains(&self.sample_rate)
168        {
169            return Err(Error::invalid(
170                "PCM needs 1..=65536 values, 2..=8 bits and 100..=32000 Hz",
171            ));
172        }
173        let maximum = (1u16 << self.bits) - 1;
174        if self.values.iter().any(|&v| v as u16 > maximum) {
175            return Err(Error::invalid("PCM value exceeds its bit depth"));
176        }
177        Ok(())
178    }
179}
180
181/// Eleven-bit NTSC period models: f=1789773/(divider*(timer+1)). Pulse divider=16,
182/// timer=8..=2047; triangle divider=32, timer=0..=2047. Requests clamp to this range
183/// rather than modelling hardware mute rules. See NESdev APU Pulse/Triangle.
184#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
185pub enum PitchTable {
186    NtscPulse,
187    NtscTriangle,
188}
189impl PitchTable {
190    pub fn timer(self, frequency: f64) -> Result<u16> {
191        if !frequency.is_finite() || frequency <= 0.0 {
192            return Err(Error::invalid(
193                "Pitch-table frequency must be finite and positive",
194            ));
195        }
196        let (divider, minimum) = self.bounds();
197        Ok((NTSC_CLOCK as f64 / (divider * frequency) - 1.0)
198            .round()
199            .clamp(minimum, 2047.0) as u16)
200    }
201    pub fn frequency(self, timer: u16) -> Result<f64> {
202        let (divider, minimum) = self.bounds();
203        if !(minimum as u16..=2047).contains(&timer) {
204            return Err(Error::invalid("Timer is outside the selected pitch table"));
205        }
206        Ok(NTSC_CLOCK as f64 / (divider * (timer as f64 + 1.0)))
207    }
208    fn bounds(self) -> (f64, f64) {
209        match self {
210            Self::NtscPulse => (16.0, 8.0),
211            Self::NtscTriangle => (32.0, 0.0),
212        }
213    }
214    fn quantize(self, frequency: f64) -> f64 {
215        if !frequency.is_finite() || frequency <= 0.0 {
216            return 0.0;
217        }
218        let (divider, minimum) = self.bounds();
219        let timer = (NTSC_CLOCK as f64 / (divider * frequency) - 1.0)
220            .round()
221            .clamp(minimum, 2047.0);
222        NTSC_CLOCK as f64 / (divider * (timer + 1.0))
223    }
224}
225
226#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
227pub enum ChipSource {
228    #[default]
229    Waveform,
230    Wavetable(Wavetable),
231    Sample(ChipSample),
232    /// Fixed noise-register clock, bypassing the legacy note-frequency*93 clock.
233    Noise {
234        period: u8,
235    },
236}
237
238/// Optional tracker controls. Volume quantization rounds the combined ADSR,
239/// instrument volume, channel volume, velocity and volume-macro gain to 0..=15.
240/// Pitch quantization runs after slide/vibrato and additive macro pitch changes.
241#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
242pub struct ChipInstrument {
243    pub source: ChipSource,
244    pub volume: Option<Macro>,
245    pub duty: Option<Macro>,
246    pub arpeggio: Option<Macro>,
247    pub pitch: Option<Macro>,
248    pub volume_4bit: bool,
249    pub pitch_table: Option<PitchTable>,
250}
251impl ChipInstrument {
252    pub(super) fn validate(&self, waveform: Waveform) -> Result<()> {
253        for (m, lo, hi) in [
254            (&self.volume, 0, 15),
255            (&self.duty, 0, 3),
256            (&self.arpeggio, -48, 48),
257            (&self.pitch, -1200, 1200),
258        ] {
259            if let Some(m) = m {
260                m.validate()?;
261                if m.values.iter().any(|&v| !(lo..=hi).contains(&v)) {
262                    return Err(Error::invalid("Macro value is outside its lane's range"));
263                }
264            }
265        }
266        match &self.source {
267            ChipSource::Wavetable(w) => w.validate()?,
268            ChipSource::Sample(s) => s.validate()?,
269            ChipSource::Noise { period } => {
270                if *period > 15 || !matches!(waveform, Waveform::Noise(_)) {
271                    return Err(Error::invalid(
272                        "Period noise needs wave=noise and period 0..=15",
273                    ));
274                }
275                if self.arpeggio.is_some() || self.pitch.is_some() || self.pitch_table.is_some() {
276                    return Err(Error::invalid(
277                        "Fixed-period noise does not take pitch controls",
278                    ));
279                }
280            }
281            _ => (),
282        }
283        if matches!(
284            self.source,
285            ChipSource::Wavetable(_) | ChipSource::Sample(_)
286        ) && waveform != Waveform::default()
287        {
288            return Err(Error::invalid(
289                "Custom chip sources do not use a melodic waveform",
290            ));
291        }
292        if self.duty.is_some()
293            && (!matches!(self.source, ChipSource::Waveform)
294                || !matches!(waveform, Waveform::Pulse(_)))
295        {
296            return Err(Error::invalid("Duty macros need a pulse waveform"));
297        }
298        if let Some(table) = self.pitch_table {
299            let valid = matches!(self.source, ChipSource::Waveform)
300                && match table {
301                    PitchTable::NtscPulse => matches!(waveform, Waveform::Pulse(_)),
302                    PitchTable::NtscTriangle => waveform == Waveform::Triangle,
303                };
304            if !valid {
305                return Err(Error::invalid(
306                    "Pitch table does not match its waveform source",
307                ));
308            }
309        }
310        Ok(())
311    }
312}
313
314/// Clock frequency of one validated period entry, independent of note pitch.
315pub fn noise_clock(period: u8) -> Result<f64> {
316    NTSC_NOISE_PERIODS
317        .get(period as usize)
318        .map(|&p| NTSC_CLOCK as f64 / p as f64)
319        .ok_or_else(|| Error::invalid("Noise period must be 0..=15"))
320}
321
322#[derive(Clone, Debug)]
323pub(super) struct ChipPlan {
324    settings: ChipInstrument,
325    rate: u32,
326    tempo: u16,
327    ticks: u16,
328    root_hz: f64,
329}
330impl ChipPlan {
331    pub(super) fn new(settings: &ChipInstrument, rate: u32, tempo: u16, ticks: u16) -> Self {
332        Self {
333            settings: settings.clone(),
334            root_hz: if let ChipSource::Sample(sample) = &settings.source {
335                sample.root.frequency()
336            } else {
337                1.0
338            },
339            rate,
340            tempo,
341            ticks,
342        }
343    }
344    fn lane(&self, lane: &Option<Macro>, age: u64, default: i16, clock: Option<BeatClock>) -> i16 {
345        lane.as_ref().map_or(default, |m| {
346            m.value_clock(age, self.rate, self.tempo, self.ticks, clock)
347        })
348    }
349    pub(super) fn frequency(&self, frequency: f64, age: u64, clock: Option<BeatClock>) -> f64 {
350        let cents = self.lane(&self.settings.arpeggio, age, 0, clock) as f64 * 100.0
351            + self.lane(&self.settings.pitch, age, 0, clock) as f64;
352        let frequency = if cents == 0.0 {
353            frequency
354        } else {
355            frequency * crate::math::exp2(cents / 1200.0)
356        };
357        self.settings
358            .pitch_table
359            .map_or(frequency, |table| table.quantize(frequency))
360    }
361    pub(super) fn gain(&self, level: f64, age: u64, clock: Option<BeatClock>) -> f64 {
362        let gain = level * self.lane(&self.settings.volume, age, 15, clock) as f64 / 15.0;
363        if self.settings.volume_4bit {
364            (gain.clamp(0.0, 1.0) * 15.0).round() / 15.0
365        } else {
366            gain
367        }
368    }
369}
370
371const ONE: u64 = 1 << 32;
372#[derive(Clone, Debug)]
373pub(super) struct ChipVoice {
374    oscillator: Oscillator,
375    aliasing: super::Aliasing,
376    phase: u64,
377}
378impl ChipVoice {
379    pub(super) fn new(aliasing: super::Aliasing) -> Self {
380        Self {
381            oscillator: Oscillator::new(aliasing),
382            aliasing,
383            phase: 0,
384        }
385    }
386    pub(super) fn reset(&mut self) {
387        *self = Self::new(self.aliasing);
388    }
389    /// Returns a value and whether a finite PCM source just ended. All state advances
390    /// even when a volume macro/ADSR/velocity happens to be zero.
391    pub(super) fn sample(
392        &mut self,
393        plan: &ChipPlan,
394        waveform: Waveform,
395        frequency: f64,
396        age: u64,
397        clock: Option<BeatClock>,
398    ) -> (f64, bool) {
399        let frequency = if frequency.is_finite() {
400            frequency.max(0.0)
401        } else {
402            0.0
403        };
404        match &plan.settings.source {
405            ChipSource::Waveform => {
406                let waveform = if plan.settings.duty.is_some() {
407                    Waveform::Pulse(match plan.lane(&plan.settings.duty, age, 2, clock) {
408                        0 => Duty::Eighth,
409                        1 => Duty::Quarter,
410                        2 => Duty::Half,
411                        _ => Duty::ThreeQuarters,
412                    })
413                } else {
414                    waveform
415                };
416                (self.oscillator.next(waveform, frequency, plan.rate), false)
417            }
418            ChipSource::Wavetable(table) => {
419                let index = ((self.phase as u128 * table.values.len() as u128) >> 32) as usize;
420                let value = table.values[index] as f64 * 2.0 / 15.0 - 1.0;
421                let frequency = frequency.min(plan.rate as f64 / 2.0);
422                self.phase =
423                    (self.phase + (frequency / plan.rate as f64 * ONE as f64).round() as u64) % ONE;
424                (value, false)
425            }
426            ChipSource::Sample(sample) => {
427                let n = sample.values.len() as u64;
428                let index = (self.phase >> 32).min(n - 1) as usize;
429                let value =
430                    sample.values[index] as f64 * 2.0 / ((1u16 << sample.bits) - 1) as f64 - 1.0;
431                let step = sample.sample_rate as f64 / plan.rate as f64 * frequency / plan.root_hz;
432                let end = n * ONE;
433                let increment = (step * ONE as f64).round() as u64;
434                self.phase = if sample.looping {
435                    (self.phase + increment % end) % end
436                } else {
437                    self.phase.saturating_add(increment)
438                };
439                let finished = self.phase >= end && !sample.looping;
440                (value, finished)
441            }
442            ChipSource::Noise { period } => {
443                let mode = if let Waveform::Noise(mode) = waveform {
444                    mode
445                } else {
446                    NoiseMode::Long
447                };
448                let clock = NTSC_CLOCK as f64 / NTSC_NOISE_PERIODS[*period as usize] as f64;
449                (self.oscillator.clocked_noise(mode, clock, plan.rate), false)
450            }
451        }
452    }
453}