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:
12
crates/synth-embedded/.cargo/config.toml
Normal file
12
crates/synth-embedded/.cargo/config.toml
Normal 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"
|
||||
44
crates/synth-embedded/Cargo.toml
Normal file
44
crates/synth-embedded/Cargo.toml
Normal 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.
|
||||
10
crates/synth-embedded/build.rs
Normal file
10
crates/synth-embedded/build.rs
Normal 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());
|
||||
}
|
||||
8
crates/synth-embedded/memory.x
Normal file
8
crates/synth-embedded/memory.x
Normal 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);
|
||||
165
crates/synth-embedded/src/audio.rs
Normal file
165
crates/synth-embedded/src/audio.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
66
crates/synth-embedded/src/main.rs
Normal file
66
crates/synth-embedded/src/main.rs
Normal 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());
|
||||
}
|
||||
112
crates/synth-embedded/src/midi.rs
Normal file
112
crates/synth-embedded/src/midi.rs
Normal 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 0–4 s
|
||||
74 => p.cutoff_hz = cutoff_cc_to_hz(value), // filter cutoff
|
||||
75 => p.decay_s = value as f32 / 127.0 * 4.0, // decay 0–4 s
|
||||
72 => p.release_s = value as f32 / 127.0 * 8.0, // release 0–8 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 0–127 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)
|
||||
}
|
||||
58
crates/synth-embedded/src/params.rs
Normal file
58
crates/synth-embedded/src/params.rs
Normal 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.0–1.0
|
||||
|
||||
// Amp
|
||||
pub volume: f32, // 0.0–1.0
|
||||
|
||||
// ADSR
|
||||
pub attack_s: f32,
|
||||
pub decay_s: f32,
|
||||
pub sustain: f32, // 0.0–1.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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user