Files
analogue_synth/crates/synth-embedded/src/audio.rs
Matt Spencer 496b6bdc71 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
2026-03-23 15:06:31 +00:00

166 lines
5.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}
}