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
166 lines
5.0 KiB
Rust
166 lines
5.0 KiB
Rust
//! 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);
|
||
}
|
||
}
|
||
}
|