diff --git a/crates/synth-core/README.md b/crates/synth-core/README.md new file mode 100644 index 0000000..2e51aec --- /dev/null +++ b/crates/synth-core/README.md @@ -0,0 +1,227 @@ +# synth-core + +A `no_std` DSP library providing analogue-modelling synthesizer building blocks. Designed to compile for any target — embedded bare-metal (Cortex-M), WebAssembly, and native — with no heap allocation. + +## Overview + +`synth-core` is the shared engine that powers both the RP2350 firmware (`synth-embedded`) and the browser visualiser (`synth-visualiser`). All modules operate on fixed-size blocks of `f32` samples using const-generic arrays, so every allocation happens on the stack at compile time. + +``` +VCO → Filter → VCA ← ADSR + ↑ ↑ +LFO LFO +``` + +## Modules + +### `oscillator` — Voltage-Controlled Oscillator + +Phase-accumulator oscillator with five waveforms. + +```rust +use synth_core::{config::SR_48000, oscillator::{Vco, Waveform}, AudioProcessor}; + +let mut vco = Vco::new(SR_48000, 440.0, Waveform::Saw); +vco.waveform = Waveform::Square; +vco.freq_hz = 880.0; + +let mut buf = [0.0f32; 256]; +vco.process(&mut buf); // fills buf with one block of audio +``` + +**Waveforms:** `Sine`, `Saw`, `Square`, `Triangle`, `Pulse(f32)` (duty cycle 0–1). + +### `filter` — State-Variable Filter + +Two-integrator SVF with selectable mode and voltage-controlled cutoff. + +```rust +use synth_core::{config::SR_48000, filter::{FilterMode, Svf}, AudioProcessor}; + +let mut filt = Svf::new(SR_48000, 2_000.0, 0.5, FilterMode::LowPass); +filt.cutoff_hz = 1_000.0; +filt.resonance = 0.8; // 0.0 = flat, 1.0 = self-oscillation + +filt.process(&mut buf); // in-place +``` + +**Modes:** `LowPass`, `HighPass`, `BandPass`, `Notch`. + +### `envelope` — ADSR Envelope Generator + +Classic Attack / Decay / Sustain / Release envelope. + +```rust +use synth_core::{config::SR_48000, envelope::Adsr, AudioProcessor}; + +let mut adsr = Adsr::new(SR_48000); +adsr.attack_s = 0.01; +adsr.decay_s = 0.2; +adsr.sustain = 0.7; // 0.0–1.0 +adsr.release_s = 0.5; + +adsr.gate_on(); // key press +let mut env = [0.0f32; 256]; +adsr.process(&mut env); // outputs 0.0–1.0 amplitude envelope + +adsr.gate_off(); // key release +``` + +### `vca` — Voltage-Controlled Amplifier + +Multiplies audio by a gain value, optionally driven by an envelope. + +```rust +use synth_core::{config::SR_48000, vca::Vca}; + +let mut vca = Vca::new(0.8); // fixed gain +vca.apply_envelope(&mut audio, &env); // per-sample multiply by envelope +``` + +### `lfo` — Low-Frequency Oscillator + +Same waveform engine as the VCO, tuned to sub-audio rates. + +```rust +use synth_core::{config::SR_48000, lfo::Lfo, oscillator::Waveform, AudioProcessor}; + +let mut lfo = Lfo::new(SR_48000); +lfo.rate_hz = 5.0; +lfo.depth = 0.5; +lfo.waveform = Waveform::Sine; + +let mut cv = [0.0f32; 256]; +lfo.process(&mut cv); // outputs ±depth CV +``` + +### `midi` — MIDI Byte-Stream Parser + +Parses a raw MIDI byte stream (running status supported) into typed events. + +```rust +use synth_core::midi::{MidiParser, MidiEvent}; + +let mut parser = MidiParser::new(); + +// Feed bytes from UART / USB +if let Some(event) = parser.push_byte(byte) { + match event { + MidiEvent::NoteOn { channel, note, velocity } => { /* ... */ } + MidiEvent::NoteOff { channel, note, velocity } => { /* ... */ } + MidiEvent::ControlChange { channel, controller, value } => { /* ... */ } + MidiEvent::PitchBend { channel, value } => { /* ... */ } + MidiEvent::ProgramChange { channel, program } => { /* ... */ } + _ => {} + } +} +``` + +### `patch` — Signal Routing / Patch Bay + +Fixed-capacity cable graph connecting named outputs to inputs, no heap required. + +```rust +use synth_core::patch::Patch; + +let mut patch: Patch<16> = Patch::new(); // up to 16 cables +patch.connect("vco.out", "filter.in"); +patch.connect("lfo.out", "filter.cv"); + +for cable in patch.cables() { + println!("{} → {}", cable.src, cable.dst); +} +``` + +### `math` — Utility Functions + +```rust +use synth_core::math::{midi_note_to_hz, db_to_linear, linear_to_db, lerp}; + +let freq = midi_note_to_hz(69); // 440.0 Hz (A4) +let gain = db_to_linear(-6.0); // ≈ 0.501 +``` + +### `config` — Sample Rate + +```rust +use synth_core::config::{SampleRate, SR_44100, SR_48000}; + +let period = SR_48000.period(); // 1.0 / 48000.0 +``` + +## Traits + +### `AudioProcessor` + +Implemented by every DSP module. + +| Method | Description | +|--------|-------------| +| `process(&mut self, out: &mut [f32; BLOCK])` | Fill `out` with one block of samples | +| `reset(&mut self)` | Reset all internal state (phase, integrators, …) | + +### `CVProcessor` + +Extends `AudioProcessor` for modules that accept a control-voltage input. + +| Method | Description | +|--------|-------------| +| `set_cv(&mut self, cv: f32)` | Apply modulation (1 V/octave convention for pitch) | + +## Complete Voice Example + +```rust +use synth_core::{ + config::SR_48000, + oscillator::{Vco, Waveform}, + filter::{FilterMode, Svf}, + envelope::Adsr, + vca::Vca, + AudioProcessor, +}; + +const BLOCK: usize = 256; + +let sr = SR_48000; +let mut vco = Vco::new(sr, 440.0, Waveform::Saw); +let mut filt = Svf::new(sr, 2_000.0, 0.4, FilterMode::LowPass); +let mut adsr = Adsr::new(sr); +let mut vca = Vca::new(1.0); + +adsr.gate_on(); + +let mut audio = [0.0f32; BLOCK]; +let mut env = [0.0f32; BLOCK]; + +loop { + vco.process(&mut audio); + adsr.process(&mut env); + filt.process(&mut audio); + vca.apply_envelope(&mut audio, &env); + // send `audio` to output device +} +``` + +## Dependencies + +| Crate | Purpose | +|-------|---------| +| `libm` | `no_std`-compatible transcendental math (`sin`, `pow`, …) | +| `micromath` | Fast numerical approximations | +| `num-traits` | Numeric trait abstractions | +| `heapless` | Fixed-size `Vec`/`ArrayVec` without allocation | +| `midi-types` | MIDI type definitions | +| `serde` | Optional serialization support | + +## Building + +```bash +# Native (for tests) +cargo test -p synth-core + +# WebAssembly +cargo build -p synth-core --target wasm32-unknown-unknown + +# Embedded (Cortex-M33, RP2350) +cargo build -p synth-core --target thumbv8m.main-none-eabihf +``` diff --git a/crates/synth-embedded/README.md b/crates/synth-embedded/README.md new file mode 100644 index 0000000..f4c1340 --- /dev/null +++ b/crates/synth-embedded/README.md @@ -0,0 +1,128 @@ +# synth-embedded + +Real-time audio synthesizer firmware for the **Raspberry Pi Pico 2** (RP2350), built on the [Embassy](https://embassy.dev) async runtime. Outputs 48 kHz stereo I2S audio to a PCM5102A DAC and accepts MIDI input over UART. + +## Hardware + +| GPIO | Signal | Connected to | +|------|--------|-------------| +| 9 | BCK (bit clock) | PCM5102A BCK | +| 10 | LRCK (word select) | PCM5102A LRCK | +| 11 | DATA | PCM5102A DIN | +| 1 | UART0 RX | MIDI IN (optocoupler) | + +Any PCM5102A-compatible I2S DAC (e.g., the CJMCU-5102 module) works. Standard MIDI current-loop interface: 5 mA optocoupler on GPIO 1, no UART TX required. + +## Architecture + +Two Embassy tasks run concurrently on core 0: + +``` +┌──────────────┐ Mutex ┌──────────────┐ +│ midi_task │ ──────────────────────────▶│ audio_task │ +│ (UART RX) │ NoteOn/Off, CC, PB, PC │ (PIO I2S) │ +└──────────────┘ └──────────────┘ + │ │ + GPIO 1 GPIO 9/10/11 + 31 250 baud 48 kHz I2S +``` + +### `audio_task` + +1. Initialises the `PioI2sOut` driver (PIO0 state machine 0, 24-bit, 48 kHz). +2. Creates the DSP chain: **VCO → SVF → VCA ← ADSR**. +3. Runs a double-buffered DMA loop — while one 256-frame block transfers to the PIO FIFO, the next block is rendered on the CPU. + +**Block size:** 256 frames × 2 channels × 4 bytes = 2 KB per buffer, 5.33 ms per block. At 150 MHz the Cortex-M33 has roughly 10× headroom for the DSP render. + +**Sample format:** 24-bit signed PCM, left-justified in bits 31..8 of a `u32` word, interleaved L/R: `[L0, R0, L1, R1, …]`. + +### `midi_task` + +Reads raw bytes from UART0 at 31 250 baud (standard MIDI) and feeds them to `synth_core::MidiParser`. Decoded events update the shared `PARAMS` mutex: + +| Event | Action | +|-------|--------| +| Note On | Set note, velocity, gate = true | +| Note Off | Clear gate if note matches | +| CC 1 (mod wheel) | Filter resonance | +| CC 7 | Master volume | +| CC 72 | Release time (0–8 s) | +| CC 73 | Attack time (0–4 s) | +| CC 74 | Filter cutoff (80 Hz–18 kHz) | +| CC 75 | Decay time (0–4 s) | +| Pitch Bend | ±2 semitones | +| Program Change | Waveform (0=Sine 1=Saw 2=Square 3=Triangle 4=Pulse) | + +## Building + +### Prerequisites + +```bash +# Rust target for RP2350 Cortex-M33 +rustup target add thumbv8m.main-none-eabihf + +# probe-rs for flashing +cargo install probe-rs-tools +``` + +### Build + +```bash +cargo build -p synth-embedded --release +``` + +### Flash + +Connect a debug probe (Raspberry Pi Debug Probe, J-Link, etc.) and run: + +```bash +cargo run -p synth-embedded --release +# or equivalently: +probe-rs run --chip RP2350 target/thumbv8m.main-none-eabihf/release/synth-embedded +``` + +The `runner` in `.cargo/config.toml` calls `probe-rs run` automatically. + +### Debug logging + +`defmt` logs are streamed over RTT. View them with: + +```bash +probe-rs attach --chip RP2350 --log-format '{t} {L} {s}' +``` + +The `DEFMT_LOG` environment variable (set to `debug` by default in `.cargo/config.toml`) controls verbosity. + +## Linker Setup + +The crate uses a custom `memory.x` to describe the RP2350's 2 MB flash and 520 KB SRAM. `build.rs` copies it to the build output directory so `cortex-m-rt`'s `link.x` can find it via `INCLUDE memory.x`. + +`.cargo/config.toml` passes two linker scripts: +- `-Tdefmt.x` — places the defmt log sections +- `-Tlink.x` — cortex-m-rt startup, vector table, and `INCLUDE memory.x` + +## Dependencies + +| Crate | Purpose | +|-------|---------| +| `synth-core` | DSP modules (VCO, SVF, ADSR, VCA, MIDI parser) | +| `embassy-executor` | Async task executor (Cortex-M) | +| `embassy-rp` | RP2350 HAL + PIO I2S driver | +| `embassy-sync` | `Mutex` for shared parameter state | +| `embassy-time` | Timekeeping | +| `cortex-m` / `cortex-m-rt` | ARM Cortex-M runtime | +| `defmt` + `defmt-rtt` | Structured logging over RTT | +| `panic-probe` | Panic messages sent to debug probe | +| `fixed` | Fixed-point clock divisor math | +| `libm` | `no_std` math (`powf` for pitch bend) | + +## Customisation + +**Change the oscillator waveform default** — edit `SynthParams::new()` in `src/params.rs`. + +**Change block size** — edit `BLOCK_FRAMES` in `src/audio.rs`. Larger blocks reduce CPU wake-up overhead; smaller blocks reduce latency. + +**Add a second voice** — instantiate a second `(Vco, Adsr, Svf, Vca)` tuple in `audio_task`, mix both outputs before the DMA write. + +**Change GPIO pins** — update the constants in `src/main.rs` and re-wire accordingly. diff --git a/crates/synth-visualiser/README.md b/crates/synth-visualiser/README.md new file mode 100644 index 0000000..c9df834 --- /dev/null +++ b/crates/synth-visualiser/README.md @@ -0,0 +1,232 @@ +# synth-visualiser + +A browser-based synthesizer front-end compiled to WebAssembly. Provides real-time oscilloscope and spectrum analysis via the Web Audio API, a drag-and-drop patch-bay for signal routing, and a parameter model that can be posted to an `AudioWorkletNode`. + +## Features + +- **Oscilloscope** — time-domain waveform display using `AnalyserNode.getFloatTimeDomainData()` +- **Spectrum analyser** — frequency-magnitude bar chart using `AnalyserNode.getByteFrequencyData()` +- **Patch bay** — canvas-based jack-and-cable UI for routing signal connections +- **Parameter model** — `SynthParams` struct that serialises to JSON for worklet messaging + +## Building + +### Prerequisites + +```bash +cargo install wasm-pack +``` + +### Build + +```bash +wasm-pack build crates/synth-visualiser --target web --out-dir ../../www/pkg +``` + +This produces: +``` +www/pkg/ + synth_visualiser.wasm # compiled WASM module + synth_visualiser.js # JS glue / bindings + synth_visualiser.d.ts # TypeScript type declarations + package.json +``` + +### Serve + +```bash +# Any static HTTP server works; WASM requires a server (not file://) +npx serve www/ +# or +python3 -m http.server --directory www/ +``` + +## API + +All public types are exported via `wasm-bindgen` and available as ES module exports. + +### `AudioEngine` + +Wraps an `AudioContext`, `AnalyserNode`, and `GainNode`. + +```js +import { AudioEngine } from './pkg/synth_visualiser.js'; + +const engine = new AudioEngine(); +// AudioContext is created suspended; resume after a user gesture: +engine.audio_context().resume(); + +const analyser = engine.analyser_node(); +const sr = engine.sample_rate(); // 44100 +``` + +The signal chain is: `GainNode → AnalyserNode → AudioContext.destination`. + +FFT size: 2048. Smoothing time constant: 0.8. + +### `OscilloscopeView` + +Renders the time-domain waveform onto a ``. + +```js +import { OscilloscopeView } from './pkg/synth_visualiser.js'; + +const scope = new OscilloscopeView('scope-canvas', analyser); + +function frame() { + scope.draw(); + requestAnimationFrame(frame); +} +requestAnimationFrame(frame); +``` + +`draw()` fetches 1024 float samples from the analyser and draws a cyan line on a dark background. + +### `SpectrumView` + +Renders the frequency-magnitude bar chart onto a ``. + +```js +import { SpectrumView } from './pkg/synth_visualiser.js'; + +const spectrum = new SpectrumView('spectrum-canvas', analyser); + +function frame() { + spectrum.draw(); + requestAnimationFrame(frame); +} +requestAnimationFrame(frame); +``` + +`draw()` fetches 1024 frequency bins (byte magnitudes 0–255) and draws colour-coded bars, graduating from cyan at low frequencies to magenta at high frequencies. + +### `PatchBay` + +Interactive cable-routing UI on a ``. + +```js +import { PatchBay } from './pkg/synth_visualiser.js'; + +const patchbay = new PatchBay('patchbay-canvas'); + +// Register jacks: (module_id, jack_id, x, y, is_output) +patchbay.register_jack('vco', 'out', 60, 80, true); +patchbay.register_jack('filter', 'in', 200, 80, false); +patchbay.register_jack('lfo', 'out', 60, 200, true); +patchbay.register_jack('filter', 'cv', 200, 200, false); + +// Forward pointer events from the canvas element +const canvas = document.getElementById('patchbay-canvas'); +canvas.addEventListener('pointerdown', e => patchbay.on_pointer_down(e.offsetX, e.offsetY)); +canvas.addEventListener('pointermove', e => patchbay.on_pointer_move(e.offsetX, e.offsetY)); +canvas.addEventListener('pointerup', e => patchbay.on_pointer_up(e.offsetX, e.offsetY)); + +function frame() { + patchbay.draw(); + requestAnimationFrame(frame); +} +requestAnimationFrame(frame); +``` + +- Output jacks are drawn in **cyan**, input jacks in **pink**. +- Drag from an output jack to an input jack to connect a cable. +- Cables are drawn as quadratic Bézier curves. +- Only output → input connections are valid; the drag is reversed automatically if needed. + +### `SynthParams` + +Parameter object that mirrors the embedded firmware's `SynthParams`. + +```js +import { SynthParams } from './pkg/synth_visualiser.js'; + +const params = new SynthParams(); +params.osc_freq = 440.0; +params.osc_wave = 1; // 0=Sine 1=Saw 2=Square 3=Triangle +params.filter_cutoff = 2000.0; +params.filter_res = 0.5; +params.env_attack = 0.01; +params.env_decay = 0.2; +params.env_sustain = 0.7; +params.env_release = 0.5; +params.lfo_rate = 5.0; +params.lfo_depth = 0.3; +params.master_gain = 0.8; + +const json = params.to_json(); // → JSON string for AudioWorklet postMessage +``` + +## Complete Example + +```html + + + + + + + + + + +``` + +## Project Structure + +``` +src/ + lib.rs — wasm-bindgen entry, feature flags + engine.rs — AudioEngine (AudioContext + AnalyserNode + GainNode) + oscilloscope.rs — OscilloscopeView + spectrum.rs — SpectrumView + patchbay.rs — PatchBay (jacks, cables, hit-testing, drag) + params.rs — SynthParams (mirrors embedded firmware params) +``` + +## Dependencies + +| Crate | Purpose | +|-------|---------| +| `wasm-bindgen` | Rust ↔ JavaScript bindings | +| `wasm-bindgen-futures` | `async/await` and `Promise` interop | +| `js-sys` | Raw JavaScript API access | +| `web-sys` | WebAPI bindings (AudioContext, Canvas 2D, Pointer events) | +| `serde` | Derive macros for JSON serialization | +| `console_error_panic_hook` | Forward Rust panics to the browser console | +| `synth-core` | Shared DSP types (e.g., `Waveform`) | + +## Features + +| Feature | Default | Description | +|---------|---------|-------------| +| `console-panic` | ✓ | Install `console_error_panic_hook` for readable panic messages |