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:
55
crates/synth-visualiser/Cargo.toml
Normal file
55
crates/synth-visualiser/Cargo.toml
Normal 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"
|
||||
53
crates/synth-visualiser/src/engine.rs
Normal file
53
crates/synth-visualiser/src/engine.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
32
crates/synth-visualiser/src/lib.rs
Normal file
32
crates/synth-visualiser/src/lib.rs
Normal 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(())
|
||||
}
|
||||
59
crates/synth-visualiser/src/oscilloscope.rs
Normal file
59
crates/synth-visualiser/src/oscilloscope.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
52
crates/synth-visualiser/src/params.rs
Normal file
52
crates/synth-visualiser/src/params.rs
Normal 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.0–1.0
|
||||
pub env_attack: f32, // seconds
|
||||
pub env_decay: f32,
|
||||
pub env_sustain: f32, // 0.0–1.0
|
||||
pub env_release: f32,
|
||||
pub lfo_rate: f32, // Hz
|
||||
pub lfo_depth: f32, // 0.0–1.0
|
||||
pub master_gain: f32, // 0.0–1.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
|
||||
)
|
||||
}
|
||||
}
|
||||
155
crates/synth-visualiser/src/patchbay.rs
Normal file
155
crates/synth-visualiser/src/patchbay.rs
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
55
crates/synth-visualiser/src/spectrum.rs
Normal file
55
crates/synth-visualiser/src/spectrum.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user