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,12 @@
[build]
target = "thumbv8m.main-none-eabihf" # Cortex-M33 (RP2350)
[target.thumbv8m.main-none-eabihf]
rustflags = [
"-C", "link-arg=-Tdefmt.x", # defmt-rtt section placement
"-C", "link-arg=-Tlink.x", # cortex-m-rt startup/vector table (includes memory.x)
]
runner = "probe-rs run --chip RP2350"
[env]
DEFMT_LOG = "debug"

View File

@@ -0,0 +1,44 @@
[package]
name = "synth-embedded"
version = "0.1.0"
edition = "2021"
description = "Embassy/RP2350 audio engine — I2S out, MIDI in, synth-core DSP"
publish = false
[[bin]]
name = "synth-embedded"
path = "src/main.rs"
[dependencies]
synth-core = { path = "../synth-core" }
embassy-executor = { workspace = true, features = [
"platform-cortex-m",
"executor-thread",
"defmt",
] }
embassy-rp = { workspace = true, features = [
"rp235xa", # Pico 2 (30 GPIOs); use rp235xb for the 48-pin variant
"rt", # enables rp-pac/rt → generates device.x interrupt vectors
"time-driver",
"defmt",
"unstable-pac",
] }
embassy-sync = { workspace = true, features = ["defmt"] }
embassy-time = { workspace = true, features = ["defmt", "defmt-timestamp-uptime"] }
embassy-futures = { workspace = true }
cortex-m = { workspace = true, features = ["inline-asm", "critical-section-single-core"] }
cortex-m-rt = { workspace = true, features = ["device"] }
defmt = { workspace = true }
defmt-rtt = { workspace = true }
panic-probe = { workspace = true, features = ["print-defmt"] }
fixed = { workspace = true }
heapless = { workspace = true }
libm = { workspace = true }
pio = { workspace = true }
# pio-proc = { workspace = true }
# Profile overrides must live in the workspace root Cargo.toml, not here.
# See /Cargo.toml for release/dev profile settings.

View File

@@ -0,0 +1,10 @@
use std::{env, fs, path::PathBuf};
fn main() {
println!("cargo:rerun-if-changed=memory.x");
// Copy memory.x into OUT_DIR so the `-Tmemory.x` linker flag can find it.
let out = PathBuf::from(env::var("OUT_DIR").unwrap());
fs::copy("memory.x", out.join("memory.x")).unwrap();
println!("cargo:rustc-link-search={}", out.display());
}

View File

@@ -0,0 +1,8 @@
/* RP2350 (Pico 2): 2 MB XIP flash, 520 KB SRAM */
MEMORY {
FLASH : ORIGIN = 0x10000000, LENGTH = 2M
RAM : ORIGIN = 0x20000000, LENGTH = 520K
}
/* cortex-m-rt places the stack at the top of RAM. */
_stack_start = ORIGIN(RAM) + LENGTH(RAM);

View File

@@ -0,0 +1,165 @@
//! I2S audio output via PIO + DMA, using the embassy-rp `PioI2sOut` driver.
//!
//! Targets a PCM5102A (or compatible) I2S DAC wired as:
//! GPIO 9 → BCK (bit clock)
//! GPIO 10 → LRCK (word select)
//! GPIO 11 → DATA
//!
//! # Buffer format
//! `PioI2sOut` expects a flat `&[u32]` of interleaved stereo words:
//! `[L0, R0, L1, R1, …]`. Each word holds a 24-bit signed sample
//! left-justified in bits 31..8, which is the format expected by PCM5102A in
//! 32-bit I2S mode.
//!
//! # Double-buffering
//! Two static DMA buffers (A and B) alternate:
//! 1. DMA drains buffer A → PIO TX FIFO.
//! 2. CPU renders the next block into buffer B.
//! 3. Swap and repeat.
//!
//! At 48 kHz with 256 frames per block (5.33 ms/block) the Cortex-M33 at
//! 150 MHz has ~10× headroom for the DSP render.
use embassy_executor::task;
use embassy_rp::{
peripherals::{DMA_CH0, PIN_9, PIN_10, PIN_11, PIO0},
pio::Pio,
pio_programs::i2s::{PioI2sOut, PioI2sOutProgram},
Peri,
};
use synth_core::{
config::SR_48000,
envelope::Adsr,
filter::{FilterMode, Svf},
math::midi_note_to_hz,
oscillator::Vco,
vca::Vca,
AudioProcessor,
};
use crate::{Irqs, PARAMS};
/// Stereo frames per render block (one frame = left sample + right sample).
const BLOCK_FRAMES: usize = 256;
/// DMA buffer length in u32 words (two words per frame: L and R).
const BLOCK_WORDS: usize = BLOCK_FRAMES * 2;
const SAMPLE_RATE: u32 = 48_000;
const BIT_DEPTH: u32 = 24;
// Ping-pong DMA buffers. Placed in .bss (zero-initialised RAM) at link time.
// SAFETY: only `audio_task` ever accesses these after `main` has run.
static mut DMA_BUF_A: [u32; BLOCK_WORDS] = [0; BLOCK_WORDS];
static mut DMA_BUF_B: [u32; BLOCK_WORDS] = [0; BLOCK_WORDS];
/// Convert a synth-core f32 sample (1.0 to +1.0) to a 24-bit signed integer
/// left-justified in a u32, as expected by PCM5102A in 32-bit I2S mode.
#[inline]
fn f32_to_i2s(s: f32) -> u32 {
let clamped = if s > 1.0 { 1.0 } else if s < -1.0 { -1.0 } else { s };
let i24 = (clamped * 8_388_607.0) as i32;
// Left-justify: move to bits 31..8
(i24 << 8) as u32
}
/// Render one `BLOCK_FRAMES`-frame stereo block into `buf` using synth-core DSP.
fn render(
buf: &mut [u32; BLOCK_WORDS],
vco: &mut Vco,
adsr: &mut Adsr,
filt: &mut Svf,
vca: &mut Vca,
snap: &crate::params::SynthParams,
) {
let hz = midi_note_to_hz(snap.note) * libm::powf(2.0_f32, snap.pitch_bend / 12.0);
vco.freq_hz = hz;
vco.waveform = snap.waveform;
filt.cutoff_hz = snap.cutoff_hz;
filt.resonance = snap.resonance;
vca.gain = snap.volume;
if snap.gate {
adsr.gate_on();
} else {
adsr.gate_off();
}
let mut audio: [f32; BLOCK_FRAMES] = [0.0; BLOCK_FRAMES];
let mut env: [f32; BLOCK_FRAMES] = [0.0; BLOCK_FRAMES];
vco.process(&mut audio);
adsr.process(&mut env);
filt.process(&mut audio);
vca.apply_envelope(&mut audio, &env);
for (i, &s) in audio.iter().enumerate() {
let word = f32_to_i2s(s);
buf[i * 2] = word; // Left
buf[i * 2 + 1] = word; // Right (mono → stereo)
}
}
#[task]
pub async fn audio_task(
pio0: Peri<'static, PIO0>,
bck: Peri<'static, PIN_9>,
lrck: Peri<'static, PIN_10>,
data: Peri<'static, PIN_11>,
dma: Peri<'static, DMA_CH0>,
) {
let Pio { mut common, sm0, .. } = Pio::new(pio0, Irqs);
let program = PioI2sOutProgram::new(&mut common);
let mut i2s = PioI2sOut::new(
&mut common,
sm0,
dma,
Irqs,
data,
bck,
lrck,
SAMPLE_RATE,
BIT_DEPTH,
&program,
);
i2s.start();
defmt::info!("I2S PIO running: {}Hz, {}-bit", SAMPLE_RATE, BIT_DEPTH);
// Initialise DSP modules.
let init = { PARAMS.lock().await.clone() };
let sr = SR_48000;
let mut vco = Vco::new(sr, midi_note_to_hz(init.note), init.waveform);
let mut adsr = Adsr::new(sr);
let mut filt = Svf::new(sr, init.cutoff_hz, init.resonance, FilterMode::LowPass);
let mut vca = Vca::new(init.volume);
// SAFETY: we are the sole owner of these statics from this point on.
let buf_a = unsafe { &mut *core::ptr::addr_of_mut!(DMA_BUF_A) };
let buf_b = unsafe { &mut *core::ptr::addr_of_mut!(DMA_BUF_B) };
// Pre-fill both buffers before starting the DMA loop.
{
let snap = PARAMS.lock().await.clone();
render(buf_a, &mut vco, &mut adsr, &mut filt, &mut vca, &snap);
render(buf_b, &mut vco, &mut adsr, &mut filt, &mut vca, &snap);
}
// Double-buffered audio loop:
// 1. Send the active buffer via DMA.
// 2. Render the next block into the inactive buffer.
// 3. Await DMA completion and swap roles.
loop {
i2s.write(buf_a).await;
{
let snap = PARAMS.lock().await.clone();
render(buf_b, &mut vco, &mut adsr, &mut filt, &mut vca, &snap);
}
i2s.write(buf_b).await;
{
let snap = PARAMS.lock().await.clone();
render(buf_a, &mut vco, &mut adsr, &mut filt, &mut vca, &snap);
}
}
}

View File

@@ -0,0 +1,66 @@
//! synth-embedded — RP2350 / Embassy audio engine entry point.
//!
//! Hardware assumed (adjust pin numbers in spawner calls to match your wiring):
//! GPIO 9 → I2S BCK (bit clock)
//! GPIO 10 → I2S LRCK (word select / left-right clock)
//! GPIO 11 → I2S DATA
//! GPIO 1 → UART0 RX (MIDI in, 31250 baud)
//!
//! Build:
//! cargo build --release
//!
//! Flash (requires probe-rs and a debug probe):
//! cargo run --release
#![no_std]
#![no_main]
use defmt_rtt as _;
use panic_probe as _;
use embassy_executor::Spawner;
use embassy_rp::{
bind_interrupts, dma,
peripherals::{DMA_CH0, DMA_CH1, PIO0, UART0},
pio::InterruptHandler as PioIrqHandler,
uart::InterruptHandler as UartIrqHandler,
};
use embassy_sync::{blocking_mutex::raw::ThreadModeRawMutex, mutex::Mutex};
mod audio;
mod midi;
mod params;
use params::SynthParams;
/// Shared synthesiser state — written by the MIDI task, read by the audio task.
///
/// `ThreadModeRawMutex` is appropriate here: both tasks run on core 0 and
/// the mutex is never accessed from an interrupt handler.
pub static PARAMS: Mutex<ThreadModeRawMutex, SynthParams> = Mutex::new(SynthParams::new());
// Register Embassy interrupt handlers for the peripherals we use.
bind_interrupts!(struct Irqs {
PIO0_IRQ_0 => PioIrqHandler<PIO0>;
UART0_IRQ => UartIrqHandler<UART0>;
DMA_IRQ_0 => dma::InterruptHandler<DMA_CH0>, dma::InterruptHandler<DMA_CH1>;
});
#[embassy_executor::main]
async fn main(spawner: Spawner) {
let p = embassy_rp::init(Default::default());
defmt::info!("synth-embedded starting on RP2350");
// Audio output via PIO0 + DMA (I2S to PCM5102A or compatible DAC).
spawner.spawn(audio::audio_task(
p.PIO0,
p.PIN_9, // BCK
p.PIN_10, // LRCK
p.PIN_11, // DATA
p.DMA_CH0,
).unwrap());
// MIDI input via UART0 RX.
spawner.spawn(midi::midi_task(p.UART0, p.PIN_1, p.DMA_CH1).unwrap());
}

View File

@@ -0,0 +1,112 @@
//! UART MIDI input task.
//!
//! Reads bytes at 31250 baud, feeds them to synth-core's `MidiParser`, then
//! updates the shared `PARAMS` mutex with each decoded event.
use embassy_executor::task;
use embassy_rp::{
peripherals::{DMA_CH1, PIN_1, UART0},
uart::{Config as UartConfig, DataBits, Parity, StopBits, UartRx},
Peri,
};
use synth_core::{
midi::{MidiEvent, MidiParser},
oscillator::Waveform,
};
use crate::{Irqs, PARAMS};
#[task]
pub async fn midi_task(
uart0: Peri<'static, UART0>,
rx: Peri<'static, PIN_1>,
rx_dma: Peri<'static, DMA_CH1>,
) {
let mut cfg = UartConfig::default();
cfg.baudrate = 31_250;
cfg.data_bits = DataBits::DataBits8;
cfg.parity = Parity::ParityNone;
cfg.stop_bits = StopBits::STOP1;
let mut uart = UartRx::new(uart0, rx, Irqs, rx_dma, cfg);
let mut parser = MidiParser::new();
let mut byte = [0u8; 1];
defmt::info!("MIDI task running on UART0 RX (GPIO1)");
loop {
match uart.read(&mut byte).await {
Ok(()) => {
if let Some(event) = parser.push_byte(byte[0]) {
handle_event(event).await;
}
}
Err(_) => {
// Framing errors happen on cable connect/disconnect.
// Log and re-sync: the parser's running-status model
// will recover on the next status byte.
defmt::warn!("UART framing error — resync");
parser.reset();
}
}
}
}
async fn handle_event(event: MidiEvent) {
let mut p = PARAMS.lock().await;
match event {
MidiEvent::NoteOn { note, velocity, .. } => {
defmt::debug!("NoteOn note={} vel={}", note, velocity);
p.note = note;
p.velocity = velocity;
p.gate = true;
}
MidiEvent::NoteOff { note, .. } => {
defmt::debug!("NoteOff note={}", note);
if p.note == note {
p.gate = false;
p.velocity = 0;
}
}
MidiEvent::ControlChange { controller, value, .. } => {
match controller {
// Standard CC map
1 => p.resonance = value as f32 / 127.0, // mod wheel → resonance
7 => p.volume = value as f32 / 127.0, // volume
73 => p.attack_s = value as f32 / 127.0 * 4.0, // attack 04 s
74 => p.cutoff_hz = cutoff_cc_to_hz(value), // filter cutoff
75 => p.decay_s = value as f32 / 127.0 * 4.0, // decay 04 s
72 => p.release_s = value as f32 / 127.0 * 8.0, // release 08 s
_ => {}
}
}
MidiEvent::PitchBend { value, .. } => {
// 8192..+8191 → 2.0..+2.0 semitones
p.pitch_bend = value as f32 / 8192.0 * 2.0;
}
MidiEvent::ProgramChange { program, .. } => {
p.waveform = match program % 5 {
0 => Waveform::Sine,
1 => Waveform::Saw,
2 => Waveform::Square,
3 => Waveform::Triangle,
_ => Waveform::Pulse(0.25),
};
}
// Ignore clock/transport
_ => {}
}
}
/// Map CC value 0127 to filter cutoff 80 Hz 18 kHz on a linear scale.
#[inline]
fn cutoff_cc_to_hz(value: u8) -> f32 {
80.0 + (value as f32 / 127.0) * (18_000.0 - 80.0)
}

View File

@@ -0,0 +1,58 @@
//! Shared synthesiser parameters.
//!
//! `SynthParams` is a plain-data snapshot. The Embassy `Mutex` in `main.rs`
//! provides exclusive access. `new()` is `const` so the mutex can be placed in
//! a `static` without a runtime initialiser.
use synth_core::{
config::{SampleRate, SR_48000},
oscillator::Waveform,
};
#[derive(Clone)]
pub struct SynthParams {
pub sample_rate: SampleRate,
// Voice state (monophonic)
pub note: u8, // MIDI note number
pub velocity: u8, // 0 = silent
pub gate: bool,
// Oscillator
pub waveform: Waveform,
pub pitch_bend: f32, // semitones, 2.0 to +2.0
// Filter (SVF)
pub cutoff_hz: f32,
pub resonance: f32, // 0.01.0
// Amp
pub volume: f32, // 0.01.0
// ADSR
pub attack_s: f32,
pub decay_s: f32,
pub sustain: f32, // 0.01.0
pub release_s: f32,
}
impl SynthParams {
/// `const` so this can initialise a `static Mutex`.
pub const fn new() -> Self {
Self {
sample_rate: SR_48000,
note: 69, // A4
velocity: 0,
gate: false,
waveform: Waveform::Saw,
pitch_bend: 0.0,
cutoff_hz: 2000.0,
resonance: 0.2,
volume: 0.8,
attack_s: 0.01,
decay_s: 0.1,
sustain: 0.7,
release_s: 0.3,
}
}
}