Initial commit of a toy analogue audio generator

Key components:
- no_std core so it can be used bare metal on embedded systems
- default implementation for RPi 2350, with midi input and I2s output using PIO
- Visualiser for the web to play with on a dev system
This commit is contained in:
2026-03-23 15:06:31 +00:00
commit 496b6bdc71
34 changed files with 3662 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
[package]
name = "synth-core"
version = "0.1.0"
edition = "2021"
description = "no_std DSP/audio components for an analogue-modelling synthesiser"
publish = false
[features]
default = []
std = []
alloc = []
[dependencies]
libm = { workspace = true }
micromath = { workspace = true }
num-traits = { workspace = true }
heapless = { workspace = true }
midi-types = { workspace = true }
serde = { workspace = true }
[dev-dependencies]
# approx = "0.5"

View File

@@ -0,0 +1,14 @@
//! Sample rate and shared audio configuration.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SampleRate(pub f32);
impl SampleRate {
#[inline]
pub fn period(self) -> f32 {
1.0 / self.0
}
}
pub const SR_44100: SampleRate = SampleRate(44_100.0);
pub const SR_48000: SampleRate = SampleRate(48_000.0);

View File

@@ -0,0 +1,66 @@
//! ADSR envelope generator.
use crate::{AudioProcessor, config::SampleRate};
#[derive(Clone, Copy, Debug, PartialEq)]
enum Stage { Idle, Attack, Decay, Sustain, Release }
pub struct Adsr {
pub attack_s: f32,
pub decay_s: f32,
pub sustain: f32, // 0.01.0
pub release_s: f32,
sample_rate: SampleRate,
stage: Stage,
level: f32,
}
impl Adsr {
pub fn new(sample_rate: SampleRate) -> Self {
Self {
attack_s: 0.01, decay_s: 0.1, sustain: 0.7, release_s: 0.3,
sample_rate,
stage: Stage::Idle,
level: 0.0,
}
}
pub fn gate_on(&mut self) { self.stage = Stage::Attack; }
pub fn gate_off(&mut self) { self.stage = Stage::Release; }
pub fn is_idle(&self) -> bool { self.stage == Stage::Idle }
#[inline]
fn next_sample(&mut self) -> f32 {
let dt = self.sample_rate.period();
match self.stage {
Stage::Idle => {},
Stage::Attack => {
self.level += dt / self.attack_s.max(dt);
if self.level >= 1.0 { self.level = 1.0; self.stage = Stage::Decay; }
}
Stage::Decay => {
self.level -= dt / self.decay_s.max(dt) * (1.0 - self.sustain);
if self.level <= self.sustain { self.level = self.sustain; self.stage = Stage::Sustain; }
}
Stage::Sustain => { self.level = self.sustain; }
Stage::Release => {
self.level -= dt / self.release_s.max(dt) * self.level;
if self.level <= 0.0001 { self.level = 0.0; self.stage = Stage::Idle; }
}
}
self.level
}
}
impl<const B: usize> AudioProcessor<B> for Adsr {
fn process(&mut self, out: &mut [f32; B]) {
for s in out.iter_mut() {
*s = self.next_sample();
}
}
fn reset(&mut self) {
self.stage = Stage::Idle;
self.level = 0.0;
}
}

View File

@@ -0,0 +1,76 @@
//! Analogue-modelling filters.
//!
//! - `MoogLadder` — 4-pole 24 dB/oct low-pass, Huovilainen model
//! - `Svf` — State-variable filter (LP / HP / BP / Notch)
use crate::{AudioProcessor, CVProcessor, config::SampleRate};
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum FilterMode {
LowPass,
HighPass,
BandPass,
Notch,
}
// ── State-variable filter ─────────────────────────────────────────────────────
pub struct Svf {
pub cutoff_hz: f32,
pub resonance: f32, // 0.01.0 (1.0 = self-oscillation)
pub mode: FilterMode,
sample_rate: SampleRate,
// state
low: f32,
band: f32,
}
impl Svf {
pub fn new(sample_rate: SampleRate, cutoff_hz: f32, resonance: f32, mode: FilterMode) -> Self {
Self { cutoff_hz, resonance, mode, sample_rate, low: 0.0, band: 0.0 }
}
#[inline]
fn process_sample(&mut self, input: f32) -> f32 {
let f = 2.0 * libm::sinf(
core::f32::consts::PI * self.cutoff_hz * self.sample_rate.period()
);
let q = 1.0 - self.resonance;
let low = self.low + f * self.band;
let high = input - low - q * self.band;
let band = f * high + self.band;
let notch = high + low;
self.low = low;
self.band = band;
match self.mode {
FilterMode::LowPass => low,
FilterMode::HighPass => high,
FilterMode::BandPass => band,
FilterMode::Notch => notch,
}
}
}
impl<const B: usize> AudioProcessor<B> for Svf {
fn process(&mut self, out: &mut [f32; B]) {
// in-place filter (caller pre-fills out with the audio signal)
for s in out.iter_mut() {
*s = self.process_sample(*s);
}
}
fn reset(&mut self) {
self.low = 0.0;
self.band = 0.0;
}
}
impl<const B: usize> CVProcessor<B> for Svf {
/// CV modulates cutoff: 0 V = base cutoff, +1 V = 1 octave up.
fn set_cv(&mut self, cv: f32) {
self.cutoff_hz *= libm::powf(2.0, cv);
}
}

View File

@@ -0,0 +1,48 @@
//! Low-frequency oscillator (LFO).
//!
//! Shares the Waveform enum from the oscillator module but operates at
//! sub-audio rates (typically 0.01 Hz 20 Hz) and outputs a CV signal
//! in the range 1.0 to +1.0.
use crate::{AudioProcessor, config::SampleRate, oscillator::Waveform};
pub struct Lfo {
pub waveform: Waveform,
pub rate_hz: f32,
pub depth: f32, // 0.01.0 output scale
phase: f32,
sample_rate: SampleRate,
}
impl Lfo {
pub fn new(sample_rate: SampleRate, rate_hz: f32, depth: f32, waveform: Waveform) -> Self {
Self { waveform, rate_hz, depth, phase: 0.0, sample_rate }
}
#[inline]
fn next_sample(&mut self) -> f32 {
let p = self.phase;
let raw = match self.waveform {
Waveform::Sine => libm::sinf(p * core::f32::consts::TAU),
Waveform::Saw => 2.0 * p - 1.0,
Waveform::Square => if p < 0.5 { 1.0 } else { -1.0 },
Waveform::Triangle => 4.0 * (p - libm::floorf(p + 0.5)).abs() - 1.0,
Waveform::Pulse(w) => if p < w { 1.0 } else { -1.0 },
};
let next = p + self.rate_hz * self.sample_rate.period();
self.phase = next - libm::floorf(next);
raw * self.depth
}
}
impl<const B: usize> AudioProcessor<B> for Lfo {
fn process(&mut self, out: &mut [f32; B]) {
for s in out.iter_mut() {
*s = self.next_sample();
}
}
fn reset(&mut self) {
self.phase = 0.0;
}
}

View File

@@ -0,0 +1,63 @@
//! synth-core — no_std DSP/audio components library.
//!
//! Compiles to any target that provides `core`:
//! - wasm32-unknown-unknown (synth-visualiser)
//! - thumbv7em-none-eabihf (Daisy Seed / STM32H750)
//! - native (tests, desktop host)
//!
//! Feature flags
//! -------------
//! `std` — enables std (panicking, I/O helpers)
//! `alloc` — enables heap-dependent paths
#![no_std]
extern crate libm;
#[cfg(feature = "alloc")]
extern crate alloc;
pub mod config;
pub mod math;
pub mod oscillator;
pub mod filter;
pub mod envelope;
pub mod vca;
pub mod lfo;
pub mod midi;
pub mod patch;
pub use config::SampleRate;
pub use math::{db_to_linear, linear_to_db, lerp, midi_note_to_hz};
/// Every audio-processing module implements this trait.
///
/// `BLOCK` is the number of samples per render quantum — const generic so the
/// compiler can unroll loops and callers need no heap allocation.
pub trait AudioProcessor<const BLOCK: usize> {
fn process(&mut self, out: &mut [f32; BLOCK]);
fn reset(&mut self);
}
/// A module that accepts a control-voltage input alongside audio.
pub trait CVProcessor<const BLOCK: usize>: AudioProcessor<BLOCK> {
fn set_cv(&mut self, cv: f32);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn midi_a4_is_440hz() {
let hz = midi_note_to_hz(69);
assert!((hz - 440.0).abs() < 0.5, "got {hz}");
}
#[test]
fn db_round_trip() {
let lin = db_to_linear(-6.0);
let back = linear_to_db(lin);
assert!((back - -6.0_f32).abs() < 0.01, "got {back}");
}
}

View File

@@ -0,0 +1,22 @@
//! DSP utility functions.
#[inline]
pub fn lerp(a: f32, b: f32, t: f32) -> f32 {
a + t * (b - a)
}
#[inline]
pub fn db_to_linear(db: f32) -> f32 {
libm::powf(10.0, db / 20.0)
}
#[inline]
pub fn linear_to_db(lin: f32) -> f32 {
20.0 * libm::log10f(lin)
}
/// Convert a MIDI note number to frequency in Hz (A4 = 69 = 440 Hz).
#[inline]
pub fn midi_note_to_hz(note: u8) -> f32 {
440.0 * libm::powf(2.0, (note as f32 - 69.0) / 12.0)
}

View File

@@ -0,0 +1,103 @@
//! MIDI byte-stream parser.
//!
//! Parses a raw MIDI byte stream (UART or Web MIDI) into typed events.
//! No allocation; internal state is a small fixed-size buffer.
use heapless::Vec;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum MidiEvent {
NoteOn { channel: u8, note: u8, velocity: u8 },
NoteOff { channel: u8, note: u8, velocity: u8 },
ControlChange { channel: u8, controller: u8, value: u8 },
PitchBend { channel: u8, value: i16 }, // 8192 to +8191
ProgramChange { channel: u8, program: u8 },
Clock,
Start,
Stop,
Continue,
}
/// Running-status parser for a single MIDI stream.
pub struct MidiParser {
status: u8,
buf: Vec<u8, 3>,
}
impl MidiParser {
pub fn new() -> Self {
Self { status: 0, buf: Vec::new() }
}
/// Feed one byte; returns `Some(event)` when a complete message is parsed.
pub fn push_byte(&mut self, byte: u8) -> Option<MidiEvent> {
// System real-time messages are single-byte and can appear anywhere.
match byte {
0xF8 => return Some(MidiEvent::Clock),
0xFA => return Some(MidiEvent::Start),
0xFB => return Some(MidiEvent::Continue),
0xFC => return Some(MidiEvent::Stop),
_ => {}
}
if byte & 0x80 != 0 {
// Status byte — start fresh.
self.buf.clear();
self.status = byte;
// Single-byte status messages have no data bytes.
return None;
}
// Data byte.
let _ = self.buf.push(byte);
let status = self.status;
let kind = (status >> 4) & 0x0F;
let ch = status & 0x0F;
match (kind, self.buf.len()) {
(0x8, 2) => {
let ev = MidiEvent::NoteOff { channel: ch, note: self.buf[0], velocity: self.buf[1] };
self.buf.clear();
Some(ev)
}
(0x9, 2) => {
let vel = self.buf[1];
let ev = if vel == 0 {
MidiEvent::NoteOff { channel: ch, note: self.buf[0], velocity: 0 }
} else {
MidiEvent::NoteOn { channel: ch, note: self.buf[0], velocity: vel }
};
self.buf.clear();
Some(ev)
}
(0xB, 2) => {
let ev = MidiEvent::ControlChange { channel: ch, controller: self.buf[0], value: self.buf[1] };
self.buf.clear();
Some(ev)
}
(0xC, 1) => {
let ev = MidiEvent::ProgramChange { channel: ch, program: self.buf[0] };
self.buf.clear();
Some(ev)
}
(0xE, 2) => {
let lsb = self.buf[0] as i16;
let msb = self.buf[1] as i16;
let value = ((msb << 7) | lsb) - 8192;
let ev = MidiEvent::PitchBend { channel: ch, value };
self.buf.clear();
Some(ev)
}
_ => None,
}
}
pub fn reset(&mut self) {
self.status = 0;
self.buf.clear();
}
}
impl Default for MidiParser {
fn default() -> Self { Self::new() }
}

View File

@@ -0,0 +1,62 @@
//! Voltage-controlled oscillator (VCO).
//!
//! Waveforms: Sine, Saw, Square, Triangle, Pulse (variable width).
//! Uses phase accumulation; bandlimited variants (BLEP/BLAMP) to follow.
use crate::{AudioProcessor, CVProcessor, config::SampleRate};
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Waveform {
Sine,
Saw,
Square,
Triangle,
Pulse(f32), // pulse width 0.01.0
}
pub struct Vco {
pub waveform: Waveform,
pub freq_hz: f32,
phase: f32,
sample_rate: SampleRate,
}
impl Vco {
pub fn new(sample_rate: SampleRate, freq_hz: f32, waveform: Waveform) -> Self {
Self { waveform, freq_hz, phase: 0.0, sample_rate }
}
#[inline]
fn next_sample(&mut self) -> f32 {
let p = self.phase;
let sample = match self.waveform {
Waveform::Sine => libm::sinf(p * core::f32::consts::TAU),
Waveform::Saw => 2.0 * p - 1.0,
Waveform::Square => if p < 0.5 { 1.0 } else { -1.0 },
Waveform::Triangle => 4.0 * (p - libm::floorf(p + 0.5)).abs() - 1.0,
Waveform::Pulse(w) => if p < w { 1.0 } else { -1.0 },
};
let next = p + self.freq_hz * self.sample_rate.period();
self.phase = next - libm::floorf(next);
sample
}
}
impl<const B: usize> AudioProcessor<B> for Vco {
fn process(&mut self, out: &mut [f32; B]) {
for s in out.iter_mut() {
*s = self.next_sample();
}
}
fn reset(&mut self) {
self.phase = 0.0;
}
}
impl<const B: usize> CVProcessor<B> for Vco {
/// CV is 1 V/oct: 0 V = 440 Hz, +1 V = 880 Hz, 1 V = 220 Hz.
fn set_cv(&mut self, cv: f32) {
self.freq_hz = 440.0 * libm::powf(2.0, cv);
}
}

View File

@@ -0,0 +1,40 @@
//! Signal routing — connects module outputs to inputs.
//!
//! A `Patch` is a fixed-size connection graph. Each cable routes
//! the output buffer of one slot into the CV or audio input of another.
//! Const generics keep everything on the stack.
/// A single cable: from slot `src` output to slot `dst` input.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Cable {
pub src: usize,
pub dst: usize,
}
/// A fixed-capacity patch with up to `MAX_CABLES` connections.
pub struct Patch<const MAX_CABLES: usize> {
cables: heapless::Vec<Cable, MAX_CABLES>,
}
impl<const MAX_CABLES: usize> Patch<MAX_CABLES> {
pub fn new() -> Self {
Self { cables: heapless::Vec::new() }
}
/// Add a cable; returns `Err(cable)` if the patch is full.
pub fn connect(&mut self, src: usize, dst: usize) -> Result<(), Cable> {
self.cables.push(Cable { src, dst }).map_err(|_| Cable { src, dst })
}
pub fn disconnect(&mut self, src: usize, dst: usize) {
self.cables.retain(|c| !(c.src == src && c.dst == dst));
}
pub fn cables(&self) -> &[Cable] {
&self.cables
}
}
impl<const MAX_CABLES: usize> Default for Patch<MAX_CABLES> {
fn default() -> Self { Self::new() }
}

View File

@@ -0,0 +1,42 @@
//! Voltage-controlled amplifier (VCA).
use crate::{AudioProcessor, CVProcessor};
pub struct Vca {
pub gain: f32, // 0.01.0
}
impl Vca {
pub fn new(gain: f32) -> Self {
Self { gain }
}
}
impl<const B: usize> AudioProcessor<B> for Vca {
/// Scales the signal already in `out` by the current gain.
fn process(&mut self, out: &mut [f32; B]) {
for s in out.iter_mut() {
*s *= self.gain;
}
}
fn reset(&mut self) {
self.gain = 0.0;
}
}
impl<const B: usize> CVProcessor<B> for Vca {
fn set_cv(&mut self, cv: f32) {
self.gain = cv.clamp(0.0, 1.0);
}
}
// Allow the ADSR output to drive the VCA directly.
impl Vca {
/// Apply a per-sample gain envelope to `audio`, returning the result in-place.
pub fn apply_envelope<const B: usize>(&self, audio: &mut [f32; B], envelope: &[f32; B]) {
for (s, &e) in audio.iter_mut().zip(envelope.iter()) {
*s *= e * self.gain;
}
}
}