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

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);
}
}
}