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

8
.cargo/config.toml Normal file
View File

@@ -0,0 +1,8 @@
[target.wasm32-unknown-unknown]
rustflags = [
"-C", "target-feature=+simd128",
]
# Bare-metal Cortex-M4F (e.g. Daisy Seed / STM32H750) — uncomment when needed:
# [target.thumbv7em-none-eabihf]
# rustflags = ["-C", "link-arg=-Tlink.x"]

11
.claude/settings.json Normal file
View File

@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(cargo build:*)",
"Bash(grep -v \"^$\")",
"WebFetch(domain:github.com)",
"Bash(find /Users/mattsp/.cargo/registry/src -name memory.x -path */rp*)",
"Bash(find /Users/mattsp/.cargo/registry/src -name build.rs -path */rp-pac*)"
]
}
}

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

1808
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

60
Cargo.toml Normal file
View File

@@ -0,0 +1,60 @@
[workspace]
resolver = "2"
members = [
"crates/synth-core",
"crates/synth-visualiser",
"crates/synth-embedded",
]
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = "symbols"
[profile.release-wasm]
inherits = "release"
opt-level = "z"
lto = "fat"
panic = "abort"
[workspace.dependencies]
# no_std-compatible math
libm = { version = "0.2", default-features = false }
num-traits = { version = "0.2", default-features = false }
micromath = { version = "2.1", default-features = false }
# Stack-allocated collections
heapless = { version = "0.8", default-features = false }
# MIDI
midi-types = { version = "0.1", default-features = false }
# WASM / browser
wasm-bindgen = { version = "0.2" }
js-sys = { version = "0.3" }
wasm-bindgen-futures = { version = "0.4" }
console_error_panic_hook = { version = "0.1" }
# Serialisation
serde = { version = "1", default-features = false, features = ["derive"] }
# Embassy — RP2350 support is only in the git repo, not crates.io.
# Cargo pins the resolved commit into Cargo.lock on first build.
# Pin a specific `rev = "..."` for reproducible builds.
embassy-executor = { git = "https://github.com/embassy-rs/embassy", default-features = false }
embassy-rp = { git = "https://github.com/embassy-rs/embassy", default-features = false }
embassy-sync = { git = "https://github.com/embassy-rs/embassy", default-features = false }
embassy-time = { git = "https://github.com/embassy-rs/embassy", default-features = false }
embassy-futures = { git = "https://github.com/embassy-rs/embassy", default-features = false }
# Embedded ecosystem (crates.io — stable)
cortex-m = { version = "0.7", default-features = false }
cortex-m-rt = { version = "0.7", default-features = false }
defmt = { version = "1.0.1", default-features = false }
defmt-rtt = { version = "1.1.0", default-features = false }
panic-probe = { version = "1.0.0", default-features = false }
fixed = { version = "1.23", default-features = false }
static-cell = { version = "2", default-features = false }
pio = { version = "0.3", default-features = false }
# pio-proc = { version = "0.3" }

99
README.md Normal file
View File

@@ -0,0 +1,99 @@
# Sound — Analogue Synthesiser
A modular analogue synthesiser written in Rust.
- **`synth-core`** — `no_std` DSP library (oscillators, filters, envelopes, LFO, MIDI). Runs on microcontrollers and WASM.
- **`synth-visualiser`** — browser-based visualiser (oscilloscope, spectrum analyser, patch bay) compiled to WebAssembly.
## Prerequisites
```sh
# Rust toolchain (stable)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# WASM target
rustup target add wasm32-unknown-unknown
# wasm-pack (builds and packages the WASM module)
cargo install wasm-pack
```
## Build
### synth-core (native — tests and development)
```sh
cargo build -p synth-core
cargo test -p synth-core
```
### synth-core (WASM — verify it cross-compiles)
```sh
cargo build -p synth-core --target wasm32-unknown-unknown
```
### synth-visualiser (browser)
Run from the workspace root:
```sh
wasm-pack build crates/synth-visualiser --target web --out-dir ../../www/pkg
```
This generates `www/pkg/` containing the compiled `.wasm` binary and the JS glue module.
## Run
Serve the `www/` directory with any static HTTP server. A browser is required (the Web Audio API is not available over `file://`).
```sh
# Python (no install needed)
python3 -m http.server --directory www 8080
# Or with npx serve
npx serve www
```
Then open [http://localhost:8080](http://localhost:8080).
## Microcontroller deployment
`synth-core` targets bare-metal Cortex-M out of the box. Add the relevant target and uncomment the linker flags in [`.cargo/config.toml`](.cargo/config.toml).
```sh
# Example: Daisy Seed / STM32H750 (Cortex-M7)
rustup target add thumbv7em-none-eabihf
cargo build -p synth-core --target thumbv7em-none-eabihf
```
A microcontroller runner crate (I²S output, UART MIDI) can be added as a new workspace member when needed.
## Project structure
```
sound/
├── crates/
│ ├── synth-core/ # no_std DSP library
│ │ └── src/
│ │ ├── oscillator.rs VCO — Sine, Saw, Square, Triangle, Pulse
│ │ ├── filter.rs SVF — LP / HP / BP / Notch
│ │ ├── envelope.rs ADSR envelope generator
│ │ ├── vca.rs Voltage-controlled amplifier
│ │ ├── lfo.rs Low-frequency oscillator
│ │ ├── midi.rs Byte-stream MIDI parser
│ │ ├── patch.rs Cable routing graph
│ │ ├── math.rs DSP utilities (lerp, dB, MIDI→Hz)
│ │ └── config.rs SampleRate type
│ └── synth-visualiser/ # WASM browser front-end
│ └── src/
│ ├── engine.rs AudioContext + AnalyserNode
│ ├── oscilloscope.rs Time-domain canvas view
│ ├── spectrum.rs FFT canvas view
│ ├── patchbay.rs Drag-and-drop patch cables
│ └── params.rs SynthParams (JSON serialisable)
└── www/
├── index.html Browser UI
├── bootstrap.js WASM loader (ES module)
└── pkg/ Generated by wasm-pack (git-ignored)
```

View File

@@ -0,0 +1,22 @@
[package]
name = "synth-core"
version = "0.1.0"
edition = "2021"
description = "no_std DSP/audio components for an analogue-modelling synthesiser"
publish = false
[features]
default = []
std = []
alloc = []
[dependencies]
libm = { workspace = true }
micromath = { workspace = true }
num-traits = { workspace = true }
heapless = { workspace = true }
midi-types = { workspace = true }
serde = { workspace = true }
[dev-dependencies]
# approx = "0.5"

View File

@@ -0,0 +1,14 @@
//! Sample rate and shared audio configuration.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SampleRate(pub f32);
impl SampleRate {
#[inline]
pub fn period(self) -> f32 {
1.0 / self.0
}
}
pub const SR_44100: SampleRate = SampleRate(44_100.0);
pub const SR_48000: SampleRate = SampleRate(48_000.0);

View File

@@ -0,0 +1,66 @@
//! ADSR envelope generator.
use crate::{AudioProcessor, config::SampleRate};
#[derive(Clone, Copy, Debug, PartialEq)]
enum Stage { Idle, Attack, Decay, Sustain, Release }
pub struct Adsr {
pub attack_s: f32,
pub decay_s: f32,
pub sustain: f32, // 0.01.0
pub release_s: f32,
sample_rate: SampleRate,
stage: Stage,
level: f32,
}
impl Adsr {
pub fn new(sample_rate: SampleRate) -> Self {
Self {
attack_s: 0.01, decay_s: 0.1, sustain: 0.7, release_s: 0.3,
sample_rate,
stage: Stage::Idle,
level: 0.0,
}
}
pub fn gate_on(&mut self) { self.stage = Stage::Attack; }
pub fn gate_off(&mut self) { self.stage = Stage::Release; }
pub fn is_idle(&self) -> bool { self.stage == Stage::Idle }
#[inline]
fn next_sample(&mut self) -> f32 {
let dt = self.sample_rate.period();
match self.stage {
Stage::Idle => {},
Stage::Attack => {
self.level += dt / self.attack_s.max(dt);
if self.level >= 1.0 { self.level = 1.0; self.stage = Stage::Decay; }
}
Stage::Decay => {
self.level -= dt / self.decay_s.max(dt) * (1.0 - self.sustain);
if self.level <= self.sustain { self.level = self.sustain; self.stage = Stage::Sustain; }
}
Stage::Sustain => { self.level = self.sustain; }
Stage::Release => {
self.level -= dt / self.release_s.max(dt) * self.level;
if self.level <= 0.0001 { self.level = 0.0; self.stage = Stage::Idle; }
}
}
self.level
}
}
impl<const B: usize> AudioProcessor<B> for Adsr {
fn process(&mut self, out: &mut [f32; B]) {
for s in out.iter_mut() {
*s = self.next_sample();
}
}
fn reset(&mut self) {
self.stage = Stage::Idle;
self.level = 0.0;
}
}

View File

@@ -0,0 +1,76 @@
//! Analogue-modelling filters.
//!
//! - `MoogLadder` — 4-pole 24 dB/oct low-pass, Huovilainen model
//! - `Svf` — State-variable filter (LP / HP / BP / Notch)
use crate::{AudioProcessor, CVProcessor, config::SampleRate};
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum FilterMode {
LowPass,
HighPass,
BandPass,
Notch,
}
// ── State-variable filter ─────────────────────────────────────────────────────
pub struct Svf {
pub cutoff_hz: f32,
pub resonance: f32, // 0.01.0 (1.0 = self-oscillation)
pub mode: FilterMode,
sample_rate: SampleRate,
// state
low: f32,
band: f32,
}
impl Svf {
pub fn new(sample_rate: SampleRate, cutoff_hz: f32, resonance: f32, mode: FilterMode) -> Self {
Self { cutoff_hz, resonance, mode, sample_rate, low: 0.0, band: 0.0 }
}
#[inline]
fn process_sample(&mut self, input: f32) -> f32 {
let f = 2.0 * libm::sinf(
core::f32::consts::PI * self.cutoff_hz * self.sample_rate.period()
);
let q = 1.0 - self.resonance;
let low = self.low + f * self.band;
let high = input - low - q * self.band;
let band = f * high + self.band;
let notch = high + low;
self.low = low;
self.band = band;
match self.mode {
FilterMode::LowPass => low,
FilterMode::HighPass => high,
FilterMode::BandPass => band,
FilterMode::Notch => notch,
}
}
}
impl<const B: usize> AudioProcessor<B> for Svf {
fn process(&mut self, out: &mut [f32; B]) {
// in-place filter (caller pre-fills out with the audio signal)
for s in out.iter_mut() {
*s = self.process_sample(*s);
}
}
fn reset(&mut self) {
self.low = 0.0;
self.band = 0.0;
}
}
impl<const B: usize> CVProcessor<B> for Svf {
/// CV modulates cutoff: 0 V = base cutoff, +1 V = 1 octave up.
fn set_cv(&mut self, cv: f32) {
self.cutoff_hz *= libm::powf(2.0, cv);
}
}

View File

@@ -0,0 +1,48 @@
//! Low-frequency oscillator (LFO).
//!
//! Shares the Waveform enum from the oscillator module but operates at
//! sub-audio rates (typically 0.01 Hz 20 Hz) and outputs a CV signal
//! in the range 1.0 to +1.0.
use crate::{AudioProcessor, config::SampleRate, oscillator::Waveform};
pub struct Lfo {
pub waveform: Waveform,
pub rate_hz: f32,
pub depth: f32, // 0.01.0 output scale
phase: f32,
sample_rate: SampleRate,
}
impl Lfo {
pub fn new(sample_rate: SampleRate, rate_hz: f32, depth: f32, waveform: Waveform) -> Self {
Self { waveform, rate_hz, depth, phase: 0.0, sample_rate }
}
#[inline]
fn next_sample(&mut self) -> f32 {
let p = self.phase;
let raw = match self.waveform {
Waveform::Sine => libm::sinf(p * core::f32::consts::TAU),
Waveform::Saw => 2.0 * p - 1.0,
Waveform::Square => if p < 0.5 { 1.0 } else { -1.0 },
Waveform::Triangle => 4.0 * (p - libm::floorf(p + 0.5)).abs() - 1.0,
Waveform::Pulse(w) => if p < w { 1.0 } else { -1.0 },
};
let next = p + self.rate_hz * self.sample_rate.period();
self.phase = next - libm::floorf(next);
raw * self.depth
}
}
impl<const B: usize> AudioProcessor<B> for Lfo {
fn process(&mut self, out: &mut [f32; B]) {
for s in out.iter_mut() {
*s = self.next_sample();
}
}
fn reset(&mut self) {
self.phase = 0.0;
}
}

View File

@@ -0,0 +1,63 @@
//! synth-core — no_std DSP/audio components library.
//!
//! Compiles to any target that provides `core`:
//! - wasm32-unknown-unknown (synth-visualiser)
//! - thumbv7em-none-eabihf (Daisy Seed / STM32H750)
//! - native (tests, desktop host)
//!
//! Feature flags
//! -------------
//! `std` — enables std (panicking, I/O helpers)
//! `alloc` — enables heap-dependent paths
#![no_std]
extern crate libm;
#[cfg(feature = "alloc")]
extern crate alloc;
pub mod config;
pub mod math;
pub mod oscillator;
pub mod filter;
pub mod envelope;
pub mod vca;
pub mod lfo;
pub mod midi;
pub mod patch;
pub use config::SampleRate;
pub use math::{db_to_linear, linear_to_db, lerp, midi_note_to_hz};
/// Every audio-processing module implements this trait.
///
/// `BLOCK` is the number of samples per render quantum — const generic so the
/// compiler can unroll loops and callers need no heap allocation.
pub trait AudioProcessor<const BLOCK: usize> {
fn process(&mut self, out: &mut [f32; BLOCK]);
fn reset(&mut self);
}
/// A module that accepts a control-voltage input alongside audio.
pub trait CVProcessor<const BLOCK: usize>: AudioProcessor<BLOCK> {
fn set_cv(&mut self, cv: f32);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn midi_a4_is_440hz() {
let hz = midi_note_to_hz(69);
assert!((hz - 440.0).abs() < 0.5, "got {hz}");
}
#[test]
fn db_round_trip() {
let lin = db_to_linear(-6.0);
let back = linear_to_db(lin);
assert!((back - -6.0_f32).abs() < 0.01, "got {back}");
}
}

View File

@@ -0,0 +1,22 @@
//! DSP utility functions.
#[inline]
pub fn lerp(a: f32, b: f32, t: f32) -> f32 {
a + t * (b - a)
}
#[inline]
pub fn db_to_linear(db: f32) -> f32 {
libm::powf(10.0, db / 20.0)
}
#[inline]
pub fn linear_to_db(lin: f32) -> f32 {
20.0 * libm::log10f(lin)
}
/// Convert a MIDI note number to frequency in Hz (A4 = 69 = 440 Hz).
#[inline]
pub fn midi_note_to_hz(note: u8) -> f32 {
440.0 * libm::powf(2.0, (note as f32 - 69.0) / 12.0)
}

View File

@@ -0,0 +1,103 @@
//! MIDI byte-stream parser.
//!
//! Parses a raw MIDI byte stream (UART or Web MIDI) into typed events.
//! No allocation; internal state is a small fixed-size buffer.
use heapless::Vec;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum MidiEvent {
NoteOn { channel: u8, note: u8, velocity: u8 },
NoteOff { channel: u8, note: u8, velocity: u8 },
ControlChange { channel: u8, controller: u8, value: u8 },
PitchBend { channel: u8, value: i16 }, // 8192 to +8191
ProgramChange { channel: u8, program: u8 },
Clock,
Start,
Stop,
Continue,
}
/// Running-status parser for a single MIDI stream.
pub struct MidiParser {
status: u8,
buf: Vec<u8, 3>,
}
impl MidiParser {
pub fn new() -> Self {
Self { status: 0, buf: Vec::new() }
}
/// Feed one byte; returns `Some(event)` when a complete message is parsed.
pub fn push_byte(&mut self, byte: u8) -> Option<MidiEvent> {
// System real-time messages are single-byte and can appear anywhere.
match byte {
0xF8 => return Some(MidiEvent::Clock),
0xFA => return Some(MidiEvent::Start),
0xFB => return Some(MidiEvent::Continue),
0xFC => return Some(MidiEvent::Stop),
_ => {}
}
if byte & 0x80 != 0 {
// Status byte — start fresh.
self.buf.clear();
self.status = byte;
// Single-byte status messages have no data bytes.
return None;
}
// Data byte.
let _ = self.buf.push(byte);
let status = self.status;
let kind = (status >> 4) & 0x0F;
let ch = status & 0x0F;
match (kind, self.buf.len()) {
(0x8, 2) => {
let ev = MidiEvent::NoteOff { channel: ch, note: self.buf[0], velocity: self.buf[1] };
self.buf.clear();
Some(ev)
}
(0x9, 2) => {
let vel = self.buf[1];
let ev = if vel == 0 {
MidiEvent::NoteOff { channel: ch, note: self.buf[0], velocity: 0 }
} else {
MidiEvent::NoteOn { channel: ch, note: self.buf[0], velocity: vel }
};
self.buf.clear();
Some(ev)
}
(0xB, 2) => {
let ev = MidiEvent::ControlChange { channel: ch, controller: self.buf[0], value: self.buf[1] };
self.buf.clear();
Some(ev)
}
(0xC, 1) => {
let ev = MidiEvent::ProgramChange { channel: ch, program: self.buf[0] };
self.buf.clear();
Some(ev)
}
(0xE, 2) => {
let lsb = self.buf[0] as i16;
let msb = self.buf[1] as i16;
let value = ((msb << 7) | lsb) - 8192;
let ev = MidiEvent::PitchBend { channel: ch, value };
self.buf.clear();
Some(ev)
}
_ => None,
}
}
pub fn reset(&mut self) {
self.status = 0;
self.buf.clear();
}
}
impl Default for MidiParser {
fn default() -> Self { Self::new() }
}

View File

@@ -0,0 +1,62 @@
//! Voltage-controlled oscillator (VCO).
//!
//! Waveforms: Sine, Saw, Square, Triangle, Pulse (variable width).
//! Uses phase accumulation; bandlimited variants (BLEP/BLAMP) to follow.
use crate::{AudioProcessor, CVProcessor, config::SampleRate};
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Waveform {
Sine,
Saw,
Square,
Triangle,
Pulse(f32), // pulse width 0.01.0
}
pub struct Vco {
pub waveform: Waveform,
pub freq_hz: f32,
phase: f32,
sample_rate: SampleRate,
}
impl Vco {
pub fn new(sample_rate: SampleRate, freq_hz: f32, waveform: Waveform) -> Self {
Self { waveform, freq_hz, phase: 0.0, sample_rate }
}
#[inline]
fn next_sample(&mut self) -> f32 {
let p = self.phase;
let sample = match self.waveform {
Waveform::Sine => libm::sinf(p * core::f32::consts::TAU),
Waveform::Saw => 2.0 * p - 1.0,
Waveform::Square => if p < 0.5 { 1.0 } else { -1.0 },
Waveform::Triangle => 4.0 * (p - libm::floorf(p + 0.5)).abs() - 1.0,
Waveform::Pulse(w) => if p < w { 1.0 } else { -1.0 },
};
let next = p + self.freq_hz * self.sample_rate.period();
self.phase = next - libm::floorf(next);
sample
}
}
impl<const B: usize> AudioProcessor<B> for Vco {
fn process(&mut self, out: &mut [f32; B]) {
for s in out.iter_mut() {
*s = self.next_sample();
}
}
fn reset(&mut self) {
self.phase = 0.0;
}
}
impl<const B: usize> CVProcessor<B> for Vco {
/// CV is 1 V/oct: 0 V = 440 Hz, +1 V = 880 Hz, 1 V = 220 Hz.
fn set_cv(&mut self, cv: f32) {
self.freq_hz = 440.0 * libm::powf(2.0, cv);
}
}

View File

@@ -0,0 +1,40 @@
//! Signal routing — connects module outputs to inputs.
//!
//! A `Patch` is a fixed-size connection graph. Each cable routes
//! the output buffer of one slot into the CV or audio input of another.
//! Const generics keep everything on the stack.
/// A single cable: from slot `src` output to slot `dst` input.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Cable {
pub src: usize,
pub dst: usize,
}
/// A fixed-capacity patch with up to `MAX_CABLES` connections.
pub struct Patch<const MAX_CABLES: usize> {
cables: heapless::Vec<Cable, MAX_CABLES>,
}
impl<const MAX_CABLES: usize> Patch<MAX_CABLES> {
pub fn new() -> Self {
Self { cables: heapless::Vec::new() }
}
/// Add a cable; returns `Err(cable)` if the patch is full.
pub fn connect(&mut self, src: usize, dst: usize) -> Result<(), Cable> {
self.cables.push(Cable { src, dst }).map_err(|_| Cable { src, dst })
}
pub fn disconnect(&mut self, src: usize, dst: usize) {
self.cables.retain(|c| !(c.src == src && c.dst == dst));
}
pub fn cables(&self) -> &[Cable] {
&self.cables
}
}
impl<const MAX_CABLES: usize> Default for Patch<MAX_CABLES> {
fn default() -> Self { Self::new() }
}

View File

@@ -0,0 +1,42 @@
//! Voltage-controlled amplifier (VCA).
use crate::{AudioProcessor, CVProcessor};
pub struct Vca {
pub gain: f32, // 0.01.0
}
impl Vca {
pub fn new(gain: f32) -> Self {
Self { gain }
}
}
impl<const B: usize> AudioProcessor<B> for Vca {
/// Scales the signal already in `out` by the current gain.
fn process(&mut self, out: &mut [f32; B]) {
for s in out.iter_mut() {
*s *= self.gain;
}
}
fn reset(&mut self) {
self.gain = 0.0;
}
}
impl<const B: usize> CVProcessor<B> for Vca {
fn set_cv(&mut self, cv: f32) {
self.gain = cv.clamp(0.0, 1.0);
}
}
// Allow the ADSR output to drive the VCA directly.
impl Vca {
/// Apply a per-sample gain envelope to `audio`, returning the result in-place.
pub fn apply_envelope<const B: usize>(&self, audio: &mut [f32; B], envelope: &[f32; B]) {
for (s, &e) in audio.iter_mut().zip(envelope.iter()) {
*s *= e * self.gain;
}
}
}

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

View File

@@ -0,0 +1,55 @@
[package]
name = "synth-visualiser"
version = "0.1.0"
edition = "2021"
description = "Browser-based visualiser for the synthesiser (WASM)"
publish = false
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = ["console-panic"]
console-panic = ["dep:console_error_panic_hook"]
[dependencies]
synth-core = { path = "../synth-core" }
wasm-bindgen = { workspace = true }
wasm-bindgen-futures = { workspace = true }
js-sys = { workspace = true }
serde = { workspace = true }
console_error_panic_hook = { workspace = true, optional = true }
[dependencies.web-sys]
version = "0.3"
features = [
"Window",
"Document",
"Element",
"HtmlElement",
"HtmlCanvasElement",
"CanvasRenderingContext2d",
"AudioContext",
"AudioContextOptions",
"AnalyserNode",
"GainNode",
"OscillatorNode",
"OscillatorType",
"AudioWorkletNode",
"AudioWorkletNodeOptions",
"AudioBuffer",
"AudioBufferSourceNode",
"Event",
"EventTarget",
"MouseEvent",
"PointerEvent",
"WheelEvent",
"Performance",
"Worker",
"MessageEvent",
"AudioNode",
"AudioDestinationNode",
]
[dev-dependencies]
wasm-bindgen-test = "0.3"

View File

@@ -0,0 +1,53 @@
//! Audio engine — owns the WebAudio AudioContext and AnalyserNode.
use wasm_bindgen::prelude::*;
use web_sys::{ AudioContext, AudioContextOptions, AnalyserNode, GainNode };
#[wasm_bindgen]
pub struct AudioEngine {
ctx: AudioContext,
analyser: AnalyserNode,
gain: GainNode,
}
#[wasm_bindgen]
impl AudioEngine {
#[wasm_bindgen(constructor)]
pub fn new() -> Result<AudioEngine, JsValue> {
let opts = AudioContextOptions::new();
opts.set_sample_rate(44100.0);
let ctx = AudioContext::new_with_context_options(&opts)?;
let analyser = ctx.create_analyser()?;
let gain = ctx.create_gain()?;
analyser.set_fft_size(2048);
analyser.set_smoothing_time_constant(0.8);
gain.connect_with_audio_node(&analyser)?;
analyser.connect_with_audio_node(&ctx.destination())?;
Ok(AudioEngine { ctx, analyser, gain })
}
pub fn attach(&self) -> Result<(), JsValue> {
Ok(())
}
pub fn start(&self) {}
pub fn stop(&self) {}
pub fn sample_rate(&self) -> f32 {
self.ctx.sample_rate()
}
/// Returns a JS handle to the AnalyserNode for use by the visualiser views.
pub fn analyser_node(&self) -> AnalyserNode {
self.analyser.clone()
}
pub fn set_params(&self, _json: &str) -> Result<(), JsValue> {
// TODO: parse JSON and post to AudioWorkletNode MessagePort
Ok(())
}
}

View File

@@ -0,0 +1,32 @@
//! synth-visualiser — WASM browser front-end.
//!
//! Build with wasm-pack:
//! wasm-pack build crates/synth-visualiser --target web --out-dir ../../www/pkg
use wasm_bindgen::prelude::*;
#[cfg(feature = "console-panic")]
fn set_panic_hook() {
console_error_panic_hook::set_once();
}
#[cfg(not(feature = "console-panic"))]
fn set_panic_hook() {}
pub mod engine;
pub mod oscilloscope;
pub mod spectrum;
pub mod patchbay;
pub mod params;
pub use engine::AudioEngine;
pub use oscilloscope::OscilloscopeView;
pub use spectrum::SpectrumView;
pub use patchbay::PatchBay;
pub use params::SynthParams;
/// Called once by bootstrap.js after the WASM module loads.
#[wasm_bindgen(start)]
pub fn main() -> Result<(), JsValue> {
set_panic_hook();
Ok(())
}

View File

@@ -0,0 +1,59 @@
//! Canvas-based oscilloscope — draws time-domain waveform from AnalyserNode.
use wasm_bindgen::prelude::*;
use web_sys::{AnalyserNode, HtmlCanvasElement, CanvasRenderingContext2d};
#[wasm_bindgen]
pub struct OscilloscopeView {
canvas: HtmlCanvasElement,
ctx2d: CanvasRenderingContext2d,
analyser: AnalyserNode,
buf: Vec<f32>,
}
#[wasm_bindgen]
impl OscilloscopeView {
#[wasm_bindgen(constructor)]
pub fn new(canvas_id: &str, analyser: &AnalyserNode) -> Result<OscilloscopeView, JsValue> {
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let canvas: HtmlCanvasElement = document
.get_element_by_id(canvas_id)
.ok_or_else(|| JsValue::from_str(&format!("canvas #{canvas_id} not found")))?
.dyn_into()?;
let ctx2d: CanvasRenderingContext2d = canvas
.get_context("2d")?
.ok_or("no 2d context")?
.dyn_into()?;
let len = analyser.fft_size() as usize;
let buf = vec![0.0f32; len];
Ok(OscilloscopeView { canvas, ctx2d, analyser: analyser.clone(), buf })
}
/// Draw one frame — call inside requestAnimationFrame.
pub fn draw(&mut self) {
let w = self.canvas.width() as f64;
let h = self.canvas.height() as f64;
self.analyser.get_float_time_domain_data(&mut self.buf);
let ctx = &self.ctx2d;
ctx.set_fill_style_str("#0d0d0d");
ctx.fill_rect(0.0, 0.0, w, h);
ctx.begin_path();
ctx.set_stroke_style_str("#00e5ff");
ctx.set_line_width(1.5);
let step = w / self.buf.len() as f64;
for (i, &sample) in self.buf.iter().enumerate() {
let x = i as f64 * step;
let y = (1.0 - sample as f64) * 0.5 * h;
if i == 0 { ctx.move_to(x, y); } else { ctx.line_to(x, y); }
}
ctx.stroke();
}
}

View File

@@ -0,0 +1,52 @@
//! Synthesiser parameter model — mirrors synth-core state.
//! Serialised as JSON for postMessage() across the AudioWorklet MessagePort.
use wasm_bindgen::prelude::*;
use serde::{Serialize, Deserialize};
#[wasm_bindgen]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SynthParams {
pub osc_freq: f32, // Hz
pub osc_wave: u8, // 0=Sine 1=Saw 2=Square 3=Triangle
pub filter_cutoff: f32, // Hz
pub filter_res: f32, // 0.01.0
pub env_attack: f32, // seconds
pub env_decay: f32,
pub env_sustain: f32, // 0.01.0
pub env_release: f32,
pub lfo_rate: f32, // Hz
pub lfo_depth: f32, // 0.01.0
pub master_gain: f32, // 0.01.0
}
#[wasm_bindgen]
impl SynthParams {
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
Self {
osc_freq: 440.0,
osc_wave: 1,
filter_cutoff: 2000.0,
filter_res: 0.3,
env_attack: 0.01,
env_decay: 0.1,
env_sustain: 0.7,
env_release: 0.3,
lfo_rate: 2.0,
lfo_depth: 0.0,
master_gain: 0.8,
}
}
pub fn to_json(&self) -> String {
// serde_json would pull in std; for WASM we use the js glue instead.
// This is a simple manual serialisation for the scaffold.
format!(
r#"{{"osc_freq":{:.2},"osc_wave":{},"filter_cutoff":{:.2},"filter_res":{:.3},"env_attack":{:.4},"env_decay":{:.4},"env_sustain":{:.3},"env_release":{:.4},"lfo_rate":{:.3},"lfo_depth":{:.3},"master_gain":{:.3}}}"#,
self.osc_freq, self.osc_wave, self.filter_cutoff, self.filter_res,
self.env_attack, self.env_decay, self.env_sustain, self.env_release,
self.lfo_rate, self.lfo_depth, self.master_gain
)
}
}

View File

@@ -0,0 +1,155 @@
//! Patch bay — drag-and-drop cable routing between module jacks.
use wasm_bindgen::prelude::*;
use web_sys::{HtmlCanvasElement, CanvasRenderingContext2d};
#[derive(Clone, Debug)]
struct Jack {
module_id: String,
jack_id: String,
x: f32,
y: f32,
is_output: bool,
}
#[derive(Clone, Debug)]
struct Cable {
src: usize, // index into jacks
dst: usize,
}
#[wasm_bindgen]
pub struct PatchBay {
canvas: HtmlCanvasElement,
ctx2d: CanvasRenderingContext2d,
jacks: Vec<Jack>,
cables: Vec<Cable>,
dragging_from: Option<usize>, // jack index being dragged from
drag_x: f32,
drag_y: f32,
}
const JACK_RADIUS: f32 = 8.0;
#[wasm_bindgen]
impl PatchBay {
#[wasm_bindgen(constructor)]
pub fn new(canvas_id: &str) -> Result<PatchBay, JsValue> {
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let canvas: HtmlCanvasElement = document
.get_element_by_id(canvas_id)
.ok_or_else(|| JsValue::from_str(&format!("canvas #{canvas_id} not found")))?
.dyn_into()?;
let ctx2d: CanvasRenderingContext2d = canvas
.get_context("2d")?
.ok_or("no 2d context")?
.dyn_into()?;
Ok(PatchBay {
canvas, ctx2d,
jacks: Vec::new(),
cables: Vec::new(),
dragging_from: None,
drag_x: 0.0,
drag_y: 0.0,
})
}
pub fn register_jack(&mut self, module_id: &str, jack_id: &str, x: f32, y: f32, is_output: bool) {
self.jacks.push(Jack {
module_id: module_id.to_string(),
jack_id: jack_id.to_string(),
x, y, is_output,
});
}
pub fn draw(&self) {
let w = self.canvas.width() as f64;
let h = self.canvas.height() as f64;
let ctx = &self.ctx2d;
ctx.set_fill_style_str("#1a1a1a");
ctx.fill_rect(0.0, 0.0, w, h);
// Draw cables
ctx.set_stroke_style_str("#ffcc00");
ctx.set_line_width(2.0);
for cable in &self.cables {
if cable.src < self.jacks.len() && cable.dst < self.jacks.len() {
let src = &self.jacks[cable.src];
let dst = &self.jacks[cable.dst];
ctx.begin_path();
ctx.move_to(src.x as f64, src.y as f64);
// Catmull-Rom-ish curve
let mx = (src.x as f64 + dst.x as f64) / 2.0;
let my = ((src.y as f64 + dst.y as f64) / 2.0) + 40.0;
ctx.quadratic_curve_to(mx, my, dst.x as f64, dst.y as f64);
ctx.stroke();
}
}
// Draw in-progress drag cable
if let Some(src_idx) = self.dragging_from {
if src_idx < self.jacks.len() {
let src = &self.jacks[src_idx];
ctx.set_stroke_style_str("rgba(255,204,0,0.5)");
ctx.begin_path();
ctx.move_to(src.x as f64, src.y as f64);
ctx.line_to(self.drag_x as f64, self.drag_y as f64);
ctx.stroke();
}
}
// Draw jacks
for jack in &self.jacks {
ctx.begin_path();
ctx.arc(jack.x as f64, jack.y as f64, JACK_RADIUS as f64, 0.0, std::f64::consts::TAU)
.unwrap_or(());
if jack.is_output {
ctx.set_fill_style_str("#00e5ff");
} else {
ctx.set_fill_style_str("#ff4081");
}
ctx.fill();
ctx.set_stroke_style_str("#444");
ctx.set_line_width(1.0);
ctx.stroke();
}
}
pub fn on_pointer_down(&mut self, x: f32, y: f32) {
self.dragging_from = self.hit_test(x, y);
self.drag_x = x;
self.drag_y = y;
}
pub fn on_pointer_move(&mut self, x: f32, y: f32) {
self.drag_x = x;
self.drag_y = y;
}
pub fn on_pointer_up(&mut self, x: f32, y: f32) {
if let Some(src_idx) = self.dragging_from.take() {
if let Some(dst_idx) = self.hit_test(x, y) {
let src_is_out = self.jacks[src_idx].is_output;
let dst_is_out = self.jacks[dst_idx].is_output;
// Only allow output → input connections
if src_is_out && !dst_is_out {
self.cables.push(Cable { src: src_idx, dst: dst_idx });
} else if !src_is_out && dst_is_out {
self.cables.push(Cable { src: dst_idx, dst: src_idx });
}
}
}
}
fn hit_test(&self, x: f32, y: f32) -> Option<usize> {
self.jacks.iter().position(|j| {
let dx = j.x - x;
let dy = j.y - y;
(dx * dx + dy * dy).sqrt() <= JACK_RADIUS * 1.5
})
}
}

View File

@@ -0,0 +1,55 @@
//! Canvas-based FFT spectrum analyser — draws frequency-domain data.
use wasm_bindgen::prelude::*;
use web_sys::{AnalyserNode, HtmlCanvasElement, CanvasRenderingContext2d};
#[wasm_bindgen]
pub struct SpectrumView {
canvas: HtmlCanvasElement,
ctx2d: CanvasRenderingContext2d,
analyser: AnalyserNode,
buf: Vec<u8>,
}
#[wasm_bindgen]
impl SpectrumView {
#[wasm_bindgen(constructor)]
pub fn new(canvas_id: &str, analyser: &AnalyserNode) -> Result<SpectrumView, JsValue> {
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let canvas: HtmlCanvasElement = document
.get_element_by_id(canvas_id)
.ok_or_else(|| JsValue::from_str(&format!("canvas #{canvas_id} not found")))?
.dyn_into()?;
let ctx2d: CanvasRenderingContext2d = canvas
.get_context("2d")?
.ok_or("no 2d context")?
.dyn_into()?;
let len = analyser.frequency_bin_count() as usize;
let buf = vec![0u8; len];
Ok(SpectrumView { canvas, ctx2d, analyser: analyser.clone(), buf })
}
pub fn draw(&mut self) {
let w = self.canvas.width() as f64;
let h = self.canvas.height() as f64;
self.analyser.get_byte_frequency_data(&mut self.buf);
let ctx = &self.ctx2d;
ctx.set_fill_style_str("#0d0d0d");
ctx.fill_rect(0.0, 0.0, w, h);
let bar_w = w / self.buf.len() as f64;
for (i, &magnitude) in self.buf.iter().enumerate() {
let bar_h = (magnitude as f64 / 255.0) * h;
let x = i as f64 * bar_w;
let hue = 180.0 + (i as f64 / self.buf.len() as f64) * 100.0;
ctx.set_fill_style_str(&format!("hsl({hue:.0},100%,60%)"));
ctx.fill_rect(x, h - bar_h, bar_w.max(1.0), bar_h);
}
}
}

72
www/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,72 @@
/**
* bootstrap.js — ES module entry point.
*
* Imports the wasm-pack-generated glue, initialises the WASM binary, then
* wires up Rust-exported types to the canvas elements in index.html.
*/
import init, {
AudioEngine,
OscilloscopeView,
SpectrumView,
PatchBay,
SynthParams,
} from "./pkg/synth_visualiser.js";
const loader = document.getElementById("loader");
const status = document.getElementById("status");
const srLabel = document.getElementById("sample-rate");
const frameTime = document.getElementById("frame-time");
async function bootstrap() {
try {
await init();
const engine = new AudioEngine();
await engine.attach();
const analyser = engine.analyser_node();
const oscilloscope = new OscilloscopeView("oscilloscope-canvas", analyser);
const spectrum = new SpectrumView("spectrum-canvas", analyser);
const patchbay = new PatchBay("patchbay-canvas");
// Register module jacks (x, y coordinates relative to the canvas)
patchbay.register_jack("vco", "out", 50, 60, true);
patchbay.register_jack("filter", "in", 150, 60, false);
patchbay.register_jack("filter", "out", 250, 60, true);
patchbay.register_jack("vca", "in", 350, 60, false);
patchbay.register_jack("lfo", "cv-out", 450, 60, true);
patchbay.register_jack("filter", "cv-in", 550, 60, false);
const pbCanvas = document.getElementById("patchbay-canvas");
pbCanvas.addEventListener("pointerdown", e => patchbay.on_pointer_down(e.offsetX, e.offsetY));
pbCanvas.addEventListener("pointermove", e => patchbay.on_pointer_move(e.offsetX, e.offsetY));
pbCanvas.addEventListener("pointerup", e => patchbay.on_pointer_up(e.offsetX, e.offsetY));
const params = new SynthParams();
engine.set_params(params.to_json());
srLabel.textContent = `SR: ${engine.sample_rate()} Hz`;
status.textContent = "Running";
engine.start();
let last = performance.now();
function frame(now) {
oscilloscope.draw();
spectrum.draw();
patchbay.draw();
frameTime.textContent = `frame: ${(now - last).toFixed(1)} ms`;
last = now;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
loader.classList.add("hidden");
} catch (err) {
console.error("[bootstrap] Fatal:", err);
loader.textContent = `Error: ${err.message ?? err}`;
}
}
bootstrap();

109
www/index.html Normal file
View File

@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Synth Visualiser</title>
<style>
:root {
--bg: #0d0d0d;
--panel: #1a1a1a;
--accent: #00e5ff;
--text: #e0e0e0;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: "JetBrains Mono", "Fira Code", monospace;
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
}
header {
padding: 0.75rem 1.5rem;
border-bottom: 1px solid #333;
font-size: 0.9rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--accent);
}
main {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 180px;
gap: 1px;
background: #333;
overflow: hidden;
}
.panel {
background: var(--panel);
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel__label {
padding: 0.4rem 0.75rem;
font-size: 0.7rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #666;
border-bottom: 1px solid #2a2a2a;
flex-shrink: 0;
}
.panel canvas { flex: 1; width: 100%; height: 100%; display: block; }
.panel--patchbay { grid-column: 1 / -1; }
footer {
padding: 0.5rem 1.5rem;
font-size: 0.7rem;
color: #444;
border-top: 1px solid #222;
display: flex;
gap: 1.5rem;
}
#status { color: var(--accent); }
#loader {
position: fixed; inset: 0;
background: var(--bg);
display: flex; align-items: center; justify-content: center;
font-size: 1rem; color: var(--accent);
z-index: 100; transition: opacity 0.4s;
}
#loader.hidden { opacity: 0; pointer-events: none; }
</style>
</head>
<body>
<div id="loader">Loading WASM module…</div>
<header>Analogue Synth Visualiser</header>
<main>
<section class="panel">
<div class="panel__label">Oscilloscope</div>
<canvas id="oscilloscope-canvas"></canvas>
</section>
<section class="panel">
<div class="panel__label">Spectrum</div>
<canvas id="spectrum-canvas"></canvas>
</section>
<section class="panel panel--patchbay">
<div class="panel__label">Patch Bay</div>
<canvas id="patchbay-canvas"></canvas>
</section>
</main>
<footer>
<span id="status">Idle</span>
<span id="sample-rate"></span>
<span id="frame-time"></span>
</footer>
<!--
Run first:
wasm-pack build crates/synth-visualiser --target web --out-dir ../../www/pkg
Then serve:
python3 -m http.server --directory www 8080
-->
<script type="module" src="./bootstrap.js"></script>
</body>
</html>