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,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);
}
}
}