Add audio nodes to the patch bay

Also make the patch bay responsive.
This commit is contained in:
2026-03-26 08:47:47 +00:00
parent c3cb7aa84b
commit c8ef3df460
13 changed files with 1055 additions and 152 deletions

View File

@@ -5,7 +5,9 @@
"Bash(grep -v \"^$\")", "Bash(grep -v \"^$\")",
"WebFetch(domain:github.com)", "WebFetch(domain:github.com)",
"Bash(find /Users/mattsp/.cargo/registry/src -name memory.x -path */rp*)", "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*)" "Bash(find /Users/mattsp/.cargo/registry/src -name build.rs -path */rp-pac*)",
"Bash(chmod +x /Users/mattsp/projects/sound/build-web.sh)",
"Bash(/Users/mattsp/projects/sound/build-web.sh)"
] ]
} }
} }

47
build-web.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Build the synth-visualiser WASM package and place it in www/pkg/.
#
# Usage:
# ./build-web.sh # release build (optimised)
# ./build-web.sh --dev # dev build (faster, no wasm-opt)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# ── Argument parsing ──────────────────────────────────────────────────────────
MODE="release"
WASM_PACK_PROFILE="--release"
for arg in "$@"; do
case "$arg" in
--dev) MODE="dev"; WASM_PACK_PROFILE="--dev" ;;
--help) echo "Usage: $0 [--dev]"; exit 0 ;;
*) echo "Unknown argument: $arg"; exit 1 ;;
esac
done
# ── Preflight checks ──────────────────────────────────────────────────────────
if ! command -v wasm-pack &>/dev/null; then
echo "error: wasm-pack not found. Install it with:"
echo " cargo install wasm-pack"
exit 1
fi
if ! rustup target list --installed | grep -q "wasm32-unknown-unknown"; then
echo "Adding wasm32-unknown-unknown target..."
rustup target add wasm32-unknown-unknown
fi
# ── Build ─────────────────────────────────────────────────────────────────────
echo "Building synth-visualiser ($MODE)..."
wasm-pack build crates/synth-visualiser \
$WASM_PACK_PROFILE \
--target web \
--out-dir ../../www/pkg
echo ""
echo "Done. Serve with:"
echo " npx serve www"
echo " or"
echo " python3 -m http.server --directory www 8080"

View File

@@ -0,0 +1,70 @@
//! Compile-time metadata descriptors for synth-core DSP components.
//!
//! Each component exposes a `DESCRIPTOR` constant that describes its display
//! name, jack ports, and adjustable parameters. The synth-visualiser reads
//! these descriptors to build the patch-bay UI, ensuring the visual
//! representation stays in sync with the actual DSP API without any
//! duplication.
//!
//! All fields use `'static` references so the data lives in read-only memory
//! with zero run-time cost on both embedded and WASM targets.
/// Which direction a signal flows through a jack.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Direction {
Input,
Output,
}
/// The kind of signal a jack carries.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SignalKind {
/// Audio-rate signal (e.g. VCO output, filter output).
Audio,
/// Control-voltage signal (e.g. LFO output, envelope output, pitch CV).
Cv,
/// Boolean gate signal (e.g. ADSR gate input).
Gate,
}
/// Metadata for a single jack (input or output port) on a component.
#[derive(Clone, Copy, Debug)]
pub struct JackDescriptor {
/// Short machine-readable routing key (e.g. `"cv_in"`).
pub id: &'static str,
/// Human-readable label shown next to the jack in the patch bay.
pub label: &'static str,
pub direction: Direction,
pub signal: SignalKind,
}
/// Metadata for a single adjustable parameter on a component.
#[derive(Clone, Copy, Debug)]
pub struct ParamDescriptor {
/// Short machine-readable identifier (e.g. `"freq_hz"`).
pub id: &'static str,
/// Human-readable label shown in the patch bay.
pub label: &'static str,
pub min: f32,
pub max: f32,
pub default: f32,
/// Display unit string (e.g. `"Hz"`, `"s"`, `""`).
pub unit: &'static str,
}
/// Full compile-time descriptor for a DSP component type.
///
/// Stored as a `const` associated item on each component struct so that the
/// patch-bay can query `Vco::DESCRIPTOR`, `Svf::DESCRIPTOR`, etc. without any
/// runtime overhead.
#[derive(Clone, Copy, Debug)]
pub struct ComponentDescriptor {
/// Short type identifier used as a routing and serialisation key (e.g. `"vco"`).
pub kind: &'static str,
/// Human-readable component name shown in the palette (e.g. `"VCO"`).
pub label: &'static str,
/// Ordered list of jacks.
pub jacks: &'static [JackDescriptor],
/// Ordered list of tunable parameters.
pub params: &'static [ParamDescriptor],
}

View File

@@ -1,6 +1,7 @@
//! ADSR envelope generator. //! ADSR envelope generator.
use crate::{AudioProcessor, config::SampleRate}; use crate::{AudioProcessor, config::SampleRate};
use crate::descriptor::{ComponentDescriptor, Direction, JackDescriptor, ParamDescriptor, SignalKind};
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
enum Stage { Idle, Attack, Decay, Sustain, Release } enum Stage { Idle, Attack, Decay, Sustain, Release }
@@ -25,6 +26,21 @@ impl Adsr {
} }
} }
pub const DESCRIPTOR: ComponentDescriptor = ComponentDescriptor {
kind: "adsr",
label: "ADSR",
jacks: &[
JackDescriptor { id: "gate_in", label: "Gate", direction: Direction::Input, signal: SignalKind::Gate },
JackDescriptor { id: "env_out", label: "Env", direction: Direction::Output, signal: SignalKind::Cv },
],
params: &[
ParamDescriptor { id: "attack_s", label: "Attack", min: 0.001, max: 4.0, default: 0.01, unit: "s" },
ParamDescriptor { id: "decay_s", label: "Decay", min: 0.001, max: 4.0, default: 0.1, unit: "s" },
ParamDescriptor { id: "sustain", label: "Sustain", min: 0.0, max: 1.0, default: 0.7, unit: "" },
ParamDescriptor { id: "release_s", label: "Release", min: 0.001, max: 8.0, default: 0.3, unit: "s" },
],
};
pub fn gate_on(&mut self) { self.stage = Stage::Attack; } pub fn gate_on(&mut self) { self.stage = Stage::Attack; }
pub fn gate_off(&mut self) { self.stage = Stage::Release; } pub fn gate_off(&mut self) { self.stage = Stage::Release; }
pub fn is_idle(&self) -> bool { self.stage == Stage::Idle } pub fn is_idle(&self) -> bool { self.stage == Stage::Idle }

View File

@@ -4,6 +4,7 @@
//! - `Svf` — State-variable filter (LP / HP / BP / Notch) //! - `Svf` — State-variable filter (LP / HP / BP / Notch)
use crate::{AudioProcessor, CVProcessor, config::SampleRate}; use crate::{AudioProcessor, CVProcessor, config::SampleRate};
use crate::descriptor::{ComponentDescriptor, Direction, JackDescriptor, ParamDescriptor, SignalKind};
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub enum FilterMode { pub enum FilterMode {
@@ -30,6 +31,21 @@ impl Svf {
Self { cutoff_hz, resonance, mode, sample_rate, low: 0.0, band: 0.0 } Self { cutoff_hz, resonance, mode, sample_rate, low: 0.0, band: 0.0 }
} }
pub const DESCRIPTOR: ComponentDescriptor = ComponentDescriptor {
kind: "svf",
label: "Filter",
jacks: &[
JackDescriptor { id: "audio_in", label: "In", direction: Direction::Input, signal: SignalKind::Audio },
JackDescriptor { id: "cv_in", label: "CV", direction: Direction::Input, signal: SignalKind::Cv },
JackDescriptor { id: "audio_out", label: "Out", direction: Direction::Output, signal: SignalKind::Audio },
],
params: &[
ParamDescriptor { id: "cutoff_hz", label: "Cutoff", min: 20.0, max: 20_000.0, default: 2_000.0, unit: "Hz" },
ParamDescriptor { id: "resonance", label: "Res", min: 0.0, max: 1.0, default: 0.5, unit: "" },
ParamDescriptor { id: "mode", label: "Mode", min: 0.0, max: 3.0, default: 0.0, unit: "" },
],
};
#[inline] #[inline]
fn process_sample(&mut self, input: f32) -> f32 { fn process_sample(&mut self, input: f32) -> f32 {
let f = 2.0 * libm::sinf( let f = 2.0 * libm::sinf(

View File

@@ -5,6 +5,7 @@
//! in the range 1.0 to +1.0. //! in the range 1.0 to +1.0.
use crate::{AudioProcessor, config::SampleRate, oscillator::Waveform}; use crate::{AudioProcessor, config::SampleRate, oscillator::Waveform};
use crate::descriptor::{ComponentDescriptor, Direction, JackDescriptor, ParamDescriptor, SignalKind};
pub struct Lfo { pub struct Lfo {
pub waveform: Waveform, pub waveform: Waveform,
@@ -19,6 +20,19 @@ impl Lfo {
Self { waveform, rate_hz, depth, phase: 0.0, sample_rate } Self { waveform, rate_hz, depth, phase: 0.0, sample_rate }
} }
pub const DESCRIPTOR: ComponentDescriptor = ComponentDescriptor {
kind: "lfo",
label: "LFO",
jacks: &[
JackDescriptor { id: "cv_out", label: "Out", direction: Direction::Output, signal: SignalKind::Cv },
],
params: &[
ParamDescriptor { id: "rate_hz", label: "Rate", min: 0.01, max: 20.0, default: 2.0, unit: "Hz" },
ParamDescriptor { id: "depth", label: "Depth", min: 0.0, max: 1.0, default: 0.5, unit: "" },
ParamDescriptor { id: "waveform", label: "Wave", min: 0.0, max: 4.0, default: 0.0, unit: "" },
],
};
#[inline] #[inline]
fn next_sample(&mut self) -> f32 { fn next_sample(&mut self) -> f32 {
let p = self.phase; let p = self.phase;

View File

@@ -18,6 +18,7 @@ extern crate libm;
extern crate alloc; extern crate alloc;
pub mod config; pub mod config;
pub mod descriptor;
pub mod math; pub mod math;
pub mod oscillator; pub mod oscillator;
pub mod filter; pub mod filter;

View File

@@ -4,6 +4,7 @@
//! Uses phase accumulation; bandlimited variants (BLEP/BLAMP) to follow. //! Uses phase accumulation; bandlimited variants (BLEP/BLAMP) to follow.
use crate::{AudioProcessor, CVProcessor, config::SampleRate}; use crate::{AudioProcessor, CVProcessor, config::SampleRate};
use crate::descriptor::{ComponentDescriptor, Direction, JackDescriptor, ParamDescriptor, SignalKind};
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub enum Waveform { pub enum Waveform {
@@ -26,6 +27,19 @@ impl Vco {
Self { waveform, freq_hz, phase: 0.0, sample_rate } Self { waveform, freq_hz, phase: 0.0, sample_rate }
} }
pub const DESCRIPTOR: ComponentDescriptor = ComponentDescriptor {
kind: "vco",
label: "VCO",
jacks: &[
JackDescriptor { id: "cv_in", label: "CV", direction: Direction::Input, signal: SignalKind::Cv },
JackDescriptor { id: "audio_out", label: "Out", direction: Direction::Output, signal: SignalKind::Audio },
],
params: &[
ParamDescriptor { id: "freq_hz", label: "Freq", min: 20.0, max: 20_000.0, default: 440.0, unit: "Hz" },
ParamDescriptor { id: "waveform", label: "Wave", min: 0.0, max: 4.0, default: 1.0, unit: "" },
],
};
#[inline] #[inline]
fn next_sample(&mut self) -> f32 { fn next_sample(&mut self) -> f32 {
let p = self.phase; let p = self.phase;

View File

@@ -1,6 +1,7 @@
//! Voltage-controlled amplifier (VCA). //! Voltage-controlled amplifier (VCA).
use crate::{AudioProcessor, CVProcessor}; use crate::{AudioProcessor, CVProcessor};
use crate::descriptor::{ComponentDescriptor, Direction, JackDescriptor, ParamDescriptor, SignalKind};
pub struct Vca { pub struct Vca {
pub gain: f32, // 0.01.0 pub gain: f32, // 0.01.0
@@ -10,6 +11,19 @@ impl Vca {
pub fn new(gain: f32) -> Self { pub fn new(gain: f32) -> Self {
Self { gain } Self { gain }
} }
pub const DESCRIPTOR: ComponentDescriptor = ComponentDescriptor {
kind: "vca",
label: "VCA",
jacks: &[
JackDescriptor { id: "audio_in", label: "In", direction: Direction::Input, signal: SignalKind::Audio },
JackDescriptor { id: "cv_in", label: "CV", direction: Direction::Input, signal: SignalKind::Cv },
JackDescriptor { id: "audio_out", label: "Out", direction: Direction::Output, signal: SignalKind::Audio },
],
params: &[
ParamDescriptor { id: "gain", label: "Gain", min: 0.0, max: 1.0, default: 1.0, unit: "" },
],
};
} }
impl<const B: usize> AudioProcessor<B> for Vca { impl<const B: usize> AudioProcessor<B> for Vca {

View File

@@ -53,3 +53,8 @@ features = [
[dev-dependencies] [dev-dependencies]
wasm-bindgen-test = "0.3" wasm-bindgen-test = "0.3"
# Tell wasm-pack's bundled wasm-opt to accept the bulk-memory and SIMD
# instructions that rustc emits for wasm32-unknown-unknown.
[package.metadata.wasm-pack.profile.release]
wasm-opt = ["-O4", "--enable-bulk-memory", "--enable-simd", "--enable-nontrapping-float-to-int"]

View File

@@ -1,36 +1,178 @@
//! Patch bay — drag-and-drop cable routing between module jacks. //! Patch bay — drag-and-drop module-based synth configurator.
//!
//! Visual metadata is derived directly from synth-core component descriptors,
//! so the patch bay stays in sync with the DSP implementation automatically.
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use web_sys::{HtmlCanvasElement, CanvasRenderingContext2d}; use web_sys::{HtmlCanvasElement, CanvasRenderingContext2d};
use synth_core::descriptor::{ComponentDescriptor, Direction, SignalKind};
use synth_core::oscillator::Vco;
use synth_core::filter::Svf;
use synth_core::envelope::Adsr;
use synth_core::vca::Vca;
use synth_core::lfo::Lfo;
// ── Component registry — single source of truth ──────────────────────────────
/// All available component types, sourced directly from synth-core.
/// Adding a new component to synth-core and this array is all that's needed
/// for it to appear in the patch bay palette.
const REGISTRY: [ComponentDescriptor; 5] = [
Vco::DESCRIPTOR,
Svf::DESCRIPTOR,
Adsr::DESCRIPTOR,
Vca::DESCRIPTOR,
Lfo::DESCRIPTOR,
];
// ── Visual constants ──────────────────────────────────────────────────────────
const MOD_W: f32 = 168.0;
const MOD_HEADER: f32 = 26.0;
const JACK_ROW: f32 = 24.0;
const PARAM_ROW: f32 = 20.0;
const JACK_R: f32 = 7.0;
const PALETTE_H: f32 = 48.0;
const PALETTE_BTN_W: f32 = 76.0;
const PALETTE_PAD: f32 = 8.0;
fn module_height(desc: &ComponentDescriptor) -> f32 {
let n_in = desc.jacks.iter().filter(|j| j.direction == Direction::Input).count();
let n_out = desc.jacks.iter().filter(|j| j.direction == Direction::Output).count();
let jack_rows = n_in.max(n_out) as f32;
MOD_HEADER + jack_rows * JACK_ROW + 6.0 + desc.params.len() as f32 * PARAM_ROW + 8.0
}
fn module_color(kind: &str) -> &'static str {
match kind {
"vco" => "#3b6fd4",
"svf" => "#8b3bcc",
"adsr" => "#2a9d3c",
"vca" => "#c47a2e",
"lfo" => "#2a8f9d",
_ => "#555555",
}
}
fn signal_color(kind: SignalKind) -> &'static str {
match kind {
SignalKind::Audio => "#f59e0b",
SignalKind::Cv => "#06b6d4",
SignalKind::Gate => "#22c55e",
}
}
// ── Data structures ───────────────────────────────────────────────────────────
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct Jack { struct Module {
module_id: String, id: u32,
jack_id: String, kind: &'static str,
label: &'static str,
x: f32, x: f32,
y: f32, y: f32,
is_output: bool, param_values: Vec<f32>,
}
impl Module {
fn descriptor(&self) -> &'static ComponentDescriptor {
REGISTRY.iter().find(|d| d.kind == self.kind).expect("unknown kind")
}
fn height(&self) -> f32 {
module_height(self.descriptor())
}
/// Returns the canvas position of a jack by id.
fn jack_pos(&self, jack_id: &str) -> Option<(f32, f32)> {
let desc = self.descriptor();
let mut in_idx = 0usize;
let mut out_idx = 0usize;
for j in desc.jacks {
if j.direction == Direction::Input {
if j.id == jack_id {
let y = self.y + MOD_HEADER + in_idx as f32 * JACK_ROW + JACK_ROW / 2.0;
return Some((self.x, y));
}
in_idx += 1;
} else {
if j.id == jack_id {
let y = self.y + MOD_HEADER + out_idx as f32 * JACK_ROW + JACK_ROW / 2.0;
return Some((self.x + MOD_W, y));
}
out_idx += 1;
}
}
None
}
fn hit_header(&self, x: f32, y: f32) -> bool {
x >= self.x && x <= self.x + MOD_W
&& y >= self.y && y <= self.y + MOD_HEADER
}
fn hit_body(&self, x: f32, y: f32) -> bool {
x >= self.x && x <= self.x + MOD_W
&& y >= self.y && y <= self.y + self.height()
}
fn hit_jack(&self, x: f32, y: f32) -> Option<(String, Direction, SignalKind)> {
let desc = self.descriptor();
let mut in_idx = 0usize;
let mut out_idx = 0usize;
for j in desc.jacks {
let (jx, jy) = if j.direction == Direction::Input {
let jy = self.y + MOD_HEADER + in_idx as f32 * JACK_ROW + JACK_ROW / 2.0;
in_idx += 1;
(self.x, jy)
} else {
let jy = self.y + MOD_HEADER + out_idx as f32 * JACK_ROW + JACK_ROW / 2.0;
out_idx += 1;
(self.x + MOD_W, jy)
};
if hypot(x - jx, y - jy) <= JACK_R * 2.0 {
return Some((j.id.to_string(), j.direction, j.signal));
}
}
None
}
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct Cable { struct VisCable {
src: usize, // index into jacks src_module: u32,
dst: usize, src_jack: String,
src_signal: SignalKind,
dst_module: u32,
dst_jack: String,
} }
#[derive(Clone, Debug)]
enum DragState {
Idle,
Module { id: u32, off_x: f32, off_y: f32 },
Cable {
from_module: u32,
from_jack: String,
from_dir: Direction,
from_signal: SignalKind,
cur_x: f32,
cur_y: f32,
},
}
// ── PatchBay ──────────────────────────────────────────────────────────────────
#[wasm_bindgen] #[wasm_bindgen]
pub struct PatchBay { pub struct PatchBay {
canvas: HtmlCanvasElement, canvas: HtmlCanvasElement,
ctx2d: CanvasRenderingContext2d, ctx2d: CanvasRenderingContext2d,
jacks: Vec<Jack>, modules: Vec<Module>,
cables: Vec<Cable>, cables: Vec<VisCable>,
dragging_from: Option<usize>, // jack index being dragged from drag: DragState,
drag_x: f32, next_id: u32,
drag_y: f32,
} }
const JACK_RADIUS: f32 = 8.0;
#[wasm_bindgen] #[wasm_bindgen]
impl PatchBay { impl PatchBay {
#[wasm_bindgen(constructor)] #[wasm_bindgen(constructor)]
@@ -41,28 +183,151 @@ impl PatchBay {
.get_element_by_id(canvas_id) .get_element_by_id(canvas_id)
.ok_or_else(|| JsValue::from_str(&format!("canvas #{canvas_id} not found")))? .ok_or_else(|| JsValue::from_str(&format!("canvas #{canvas_id} not found")))?
.dyn_into()?; .dyn_into()?;
let ctx2d: CanvasRenderingContext2d = canvas let ctx2d: CanvasRenderingContext2d = canvas
.get_context("2d")? .get_context("2d")?
.ok_or("no 2d context")? .ok_or("no 2d context")?
.dyn_into()?; .dyn_into()?;
Ok(PatchBay { Ok(PatchBay {
canvas, ctx2d, canvas, ctx2d,
jacks: Vec::new(), modules: Vec::new(),
cables: Vec::new(), cables: Vec::new(),
dragging_from: None, drag: DragState::Idle,
drag_x: 0.0, next_id: 1,
drag_y: 0.0,
}) })
} }
pub fn register_jack(&mut self, module_id: &str, jack_id: &str, x: f32, y: f32, is_output: bool) { /// Instantiate a component by kind at (x, y). Returns the module id, or 0 on error.
self.jacks.push(Jack { pub fn add_module(&mut self, kind: &str, x: f32, y: f32) -> u32 {
module_id: module_id.to_string(), if let Some(desc) = REGISTRY.iter().find(|d| d.kind == kind) {
jack_id: jack_id.to_string(), let id = self.next_id;
x, y, is_output, self.next_id += 1;
let param_values = desc.params.iter().map(|p| p.default).collect();
self.modules.push(Module { id, kind: desc.kind, label: desc.label, x, y, param_values });
id
} else {
0
}
}
/// Remove a module and all cables connected to it.
pub fn remove_module(&mut self, id: u32) {
self.modules.retain(|m| m.id != id);
self.cables.retain(|c| c.src_module != id && c.dst_module != id);
}
/// Update a parameter value (param_idx is the position in the descriptor's params slice).
pub fn set_param(&mut self, module_id: u32, param_idx: usize, value: f32) {
if let Some(m) = self.modules.iter_mut().find(|m| m.id == module_id) {
if param_idx < m.param_values.len() {
m.param_values[param_idx] = value;
}
}
}
pub fn on_pointer_down(&mut self, x: f32, y: f32) {
// Palette click → add module
if y < PALETTE_H {
let idx = ((x - PALETTE_PAD) / (PALETTE_BTN_W + PALETTE_PAD)) as usize;
if idx < REGISTRY.len() {
let kind = REGISTRY[idx].kind;
let cx = self.canvas.width() as f32 / 2.0 - MOD_W / 2.0;
let cy = PALETTE_H + 20.0 + (self.modules.len() as f32 % 5.0) * 24.0;
self.add_module(kind, cx, cy);
}
return;
}
// Jacks have priority over headers (check top-most module first)
let n = self.modules.len();
for i in (0..n).rev() {
let m = &self.modules[i];
if let Some((jack_id, dir, signal)) = m.hit_jack(x, y) {
let mid = m.id;
self.drag = DragState::Cable {
from_module: mid,
from_jack: jack_id,
from_dir: dir,
from_signal: signal,
cur_x: x,
cur_y: y,
};
return;
}
}
// Header drag
for i in (0..n).rev() {
if self.modules[i].hit_header(x, y) {
let id = self.modules[i].id;
let off_x = x - self.modules[i].x;
let off_y = y - self.modules[i].y;
// Bring to front
let m = self.modules.remove(i);
self.modules.push(m);
self.drag = DragState::Module { id, off_x, off_y };
return;
}
}
}
pub fn on_pointer_move(&mut self, x: f32, y: f32) {
match self.drag.clone() {
DragState::Module { id, off_x, off_y } => {
if let Some(m) = self.modules.iter_mut().find(|m| m.id == id) {
m.x = (x - off_x).max(0.0);
m.y = (y - off_y).max(PALETTE_H + 2.0);
}
}
DragState::Cable { from_module, from_jack, from_dir, from_signal, .. } => {
self.drag = DragState::Cable {
from_module, from_jack, from_dir, from_signal,
cur_x: x, cur_y: y,
};
}
DragState::Idle => {}
}
}
pub fn on_pointer_up(&mut self, x: f32, y: f32) {
let drag = self.drag.clone();
self.drag = DragState::Idle;
if let DragState::Cable { from_module, from_jack, from_dir, from_signal, .. } = drag {
for mi in 0..self.modules.len() {
if self.modules[mi].id == from_module { continue; }
if let Some((jack_id, dir, _)) = self.modules[mi].hit_jack(x, y) {
let (src_m, src_j, dst_m, dst_j) =
if from_dir == Direction::Output && dir == Direction::Input {
(from_module, from_jack, self.modules[mi].id, jack_id)
} else if from_dir == Direction::Input && dir == Direction::Output {
(self.modules[mi].id, jack_id, from_module, from_jack)
} else {
break;
};
// One cable per input jack
self.cables.retain(|c| !(c.dst_module == dst_m && c.dst_jack == dst_j));
self.cables.push(VisCable {
src_module: src_m,
src_jack: src_j,
src_signal: from_signal,
dst_module: dst_m,
dst_jack: dst_j,
}); });
break;
}
}
}
}
/// Double-click removes the topmost module under the cursor.
pub fn on_double_click(&mut self, x: f32, y: f32) {
if y < PALETTE_H { return; }
if let Some(id) = self.modules.iter().rev()
.find(|m| m.hit_body(x, y))
.map(|m| m.id)
{
self.remove_module(id);
}
} }
pub fn draw(&self) { pub fn draw(&self) {
@@ -70,86 +335,392 @@ impl PatchBay {
let h = self.canvas.height() as f64; let h = self.canvas.height() as f64;
let ctx = &self.ctx2d; let ctx = &self.ctx2d;
ctx.set_fill_style_str("#1a1a1a"); // Background
ctx.set_fill_style_str("#111418");
ctx.fill_rect(0.0, 0.0, w, h); ctx.fill_rect(0.0, 0.0, w, h);
// Draw cables // Subtle grid
ctx.set_stroke_style_str("#ffcc00"); ctx.set_fill_style_str("#191d24");
ctx.set_line_width(2.0); let step = 24.0_f64;
for cable in &self.cables { let mut gx = 0.0_f64;
if cable.src < self.jacks.len() && cable.dst < self.jacks.len() { while gx < w {
let src = &self.jacks[cable.src]; let mut gy = PALETTE_H as f64;
let dst = &self.jacks[cable.dst]; while gy < h {
ctx.begin_path(); ctx.fill_rect(gx, gy, 1.5, 1.5);
ctx.move_to(src.x as f64, src.y as f64); gy += step;
// Catmull-Rom-ish curve }
let mx = (src.x as f64 + dst.x as f64) / 2.0; gx += step;
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(); self.draw_palette(ctx);
self.draw_cables(ctx);
self.draw_modules(ctx);
self.draw_drag_cable(ctx);
}
/// Returns a JSON array describing every registered component type,
/// with jack and parameter metadata sourced from synth-core descriptors.
pub fn available_components(&self) -> String {
let mut out = String::from("[");
for (i, desc) in REGISTRY.iter().enumerate() {
if i > 0 { out.push(','); }
out.push_str(&format!(
r#"{{"kind":"{kind}","label":"{label}","jacks":{jacks},"params":{params}}}"#,
kind = desc.kind,
label = desc.label,
jacks = jacks_to_json(desc),
params = params_to_json(desc),
));
}
out.push(']');
out
}
/// Returns a JSON snapshot of the current patch: module positions,
/// parameter values, and all cable connections.
pub fn get_patch_json(&self) -> String {
let mut out = String::from(r#"{"modules":["#);
for (i, m) in self.modules.iter().enumerate() {
if i > 0 { out.push(','); }
let desc = m.descriptor();
out.push_str(&format!(
r#"{{"id":{id},"kind":"{kind}","x":{x},"y":{y},"params":{{"#,
id = m.id,
kind = m.kind,
x = m.x as i32,
y = m.y as i32,
));
for (pi, p) in desc.params.iter().enumerate() {
if pi > 0 { out.push(','); }
let v = m.param_values.get(pi).copied().unwrap_or(p.default);
out.push_str(&format!(r#""{}":{:.4}"#, p.id, v));
}
out.push_str("}}");
}
out.push_str(r#"],"cables":["#);
for (i, c) in self.cables.iter().enumerate() {
if i > 0 { out.push(','); }
out.push_str(&format!(
r#"{{"src":{sm},"src_jack":"{sj}","dst":{dm},"dst_jack":"{dj}"}}"#,
sm = c.src_module,
sj = c.src_jack,
dm = c.dst_module,
dj = c.dst_jack,
));
}
out.push_str("]}");
out
} }
} }
// Draw in-progress drag cable // ── Private drawing ───────────────────────────────────────────────────────────
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 impl PatchBay {
for jack in &self.jacks { fn draw_palette(&self, ctx: &CanvasRenderingContext2d) {
ctx.begin_path(); let w = self.canvas.width() as f64;
ctx.arc(jack.x as f64, jack.y as f64, JACK_RADIUS as f64, 0.0, std::f64::consts::TAU) let ph = PALETTE_H as f64;
.unwrap_or(());
if jack.is_output { // Panel background
ctx.set_fill_style_str("#00e5ff"); ctx.set_fill_style_str("#1a1e26");
} else { ctx.fill_rect(0.0, 0.0, w, ph);
ctx.set_fill_style_str("#ff4081");
} // Bottom border
ctx.fill(); ctx.set_stroke_style_str("#2c3040");
ctx.set_stroke_style_str("#444");
ctx.set_line_width(1.0); ctx.set_line_width(1.0);
ctx.begin_path();
ctx.move_to(0.0, ph);
ctx.line_to(w, ph);
ctx.stroke();
for (i, desc) in REGISTRY.iter().enumerate() {
let bx = PALETTE_PAD + i as f32 * (PALETTE_BTN_W + PALETTE_PAD);
let by = 6.0_f32;
let bw = PALETTE_BTN_W;
let bh = PALETTE_H - 12.0;
ctx.set_fill_style_str(module_color(desc.kind));
rounded_rect(ctx, bx as f64, by as f64, bw as f64, bh as f64, 5.0);
ctx.fill();
ctx.set_fill_style_str("rgba(255,255,255,0.92)");
ctx.set_font("bold 11px monospace");
ctx.set_text_align("center");
let _ = ctx.fill_text(
desc.label,
(bx + bw * 0.5) as f64,
(by + bh * 0.62) as f64,
);
}
// Hint
ctx.set_fill_style_str("rgba(120,130,150,0.6)");
ctx.set_font("10px sans-serif");
ctx.set_text_align("right");
let _ = ctx.fill_text("click to add · drag header to move · dbl-click to remove", w - 8.0, ph - 6.0);
}
fn draw_modules(&self, ctx: &CanvasRenderingContext2d) {
for m in &self.modules {
self.draw_module(ctx, m);
}
}
fn draw_module(&self, ctx: &CanvasRenderingContext2d, m: &Module) {
let desc = m.descriptor();
let mh = m.height();
let color = module_color(m.kind);
// Body
ctx.set_fill_style_str("#20242e");
rounded_rect(ctx, m.x as f64, m.y as f64, MOD_W as f64, mh as f64, 7.0);
ctx.fill();
// Header
ctx.set_fill_style_str(color);
rounded_rect_top(ctx, m.x as f64, m.y as f64, MOD_W as f64, MOD_HEADER as f64, 7.0);
ctx.fill();
// Header label
ctx.set_fill_style_str("rgba(255,255,255,0.95)");
ctx.set_font("bold 12px monospace");
ctx.set_text_align("center");
let _ = ctx.fill_text(
m.label,
(m.x + MOD_W * 0.5) as f64,
(m.y + MOD_HEADER * 0.70) as f64,
);
// Outer border
ctx.set_stroke_style_str("rgba(255,255,255,0.07)");
ctx.set_line_width(1.0);
rounded_rect(ctx, m.x as f64, m.y as f64, MOD_W as f64, mh as f64, 7.0);
ctx.stroke();
// Jacks
let mut in_idx = 0usize;
let mut out_idx = 0usize;
for j in desc.jacks {
let sig_color = signal_color(j.signal);
if j.direction == Direction::Input {
let jx = m.x;
let jy = m.y + MOD_HEADER + in_idx as f32 * JACK_ROW + JACK_ROW / 2.0;
draw_jack(ctx, jx as f64, jy as f64, JACK_R as f64, sig_color, false);
ctx.set_fill_style_str("rgba(190,195,210,0.75)");
ctx.set_font("10px sans-serif");
ctx.set_text_align("left");
let _ = ctx.fill_text(j.label, (jx + JACK_R + 6.0) as f64, (jy + 4.0) as f64);
in_idx += 1;
} else {
let jx = m.x + MOD_W;
let jy = m.y + MOD_HEADER + out_idx as f32 * JACK_ROW + JACK_ROW / 2.0;
draw_jack(ctx, jx as f64, jy as f64, JACK_R as f64, sig_color, true);
ctx.set_fill_style_str("rgba(190,195,210,0.75)");
ctx.set_font("10px sans-serif");
ctx.set_text_align("right");
let _ = ctx.fill_text(j.label, (jx - JACK_R - 6.0) as f64, (jy + 4.0) as f64);
out_idx += 1;
}
}
// Divider above params
let jack_rows = in_idx.max(out_idx) as f32;
let params_top = m.y + MOD_HEADER + jack_rows * JACK_ROW + 3.0;
if !desc.params.is_empty() {
ctx.set_stroke_style_str("rgba(255,255,255,0.05)");
ctx.set_line_width(1.0);
ctx.begin_path();
ctx.move_to((m.x + 8.0) as f64, params_top as f64);
ctx.line_to((m.x + MOD_W - 8.0) as f64, params_top as f64);
ctx.stroke(); ctx.stroke();
} }
// Parameters
for (pi, p) in desc.params.iter().enumerate() {
let py = params_top + 4.0 + pi as f32 * PARAM_ROW;
let val = m.param_values.get(pi).copied().unwrap_or(p.default);
let ratio = ((val - p.min) / (p.max - p.min)).clamp(0.0, 1.0);
let tx = m.x + 8.0;
let tw = MOD_W - 16.0;
let track_y = py + 13.0;
// Track bg
ctx.set_fill_style_str("#181b22");
ctx.fill_rect(tx as f64, track_y as f64, tw as f64, 3.0);
// Track fill
ctx.set_fill_style_str(color);
ctx.fill_rect(tx as f64, track_y as f64, (tw * ratio) as f64, 3.0);
// Labels
ctx.set_fill_style_str("rgba(160,165,180,0.8)");
ctx.set_font("9px sans-serif");
ctx.set_text_align("left");
let _ = ctx.fill_text(p.label, tx as f64, (py + 10.0) as f64);
ctx.set_text_align("right");
let _ = ctx.fill_text(
&format_param(val, p.unit),
(tx + tw) as f64,
(py + 10.0) as f64,
);
}
} }
pub fn on_pointer_down(&mut self, x: f32, y: f32) { fn draw_cables(&self, ctx: &CanvasRenderingContext2d) {
self.dragging_from = self.hit_test(x, y); for cable in &self.cables {
self.drag_x = x; let sp = self.modules.iter().find(|m| m.id == cable.src_module)
self.drag_y = y; .and_then(|m| m.jack_pos(&cable.src_jack));
let dp = self.modules.iter().find(|m| m.id == cable.dst_module)
.and_then(|m| m.jack_pos(&cable.dst_jack));
if let (Some((x1, y1)), Some((x2, y2))) = (sp, dp) {
draw_cable(ctx, x1 as f64, y1 as f64, x2 as f64, y2 as f64,
signal_color(cable.src_signal), 1.0);
}
}
} }
pub fn on_pointer_move(&mut self, x: f32, y: f32) { fn draw_drag_cable(&self, ctx: &CanvasRenderingContext2d) {
self.drag_x = x; if let DragState::Cable { from_module, ref from_jack, from_signal, cur_x, cur_y, .. } = self.drag {
self.drag_y = y; if let Some((x1, y1)) = self.modules.iter()
} .find(|m| m.id == from_module)
.and_then(|m| m.jack_pos(from_jack))
pub fn on_pointer_up(&mut self, x: f32, y: f32) { {
if let Some(src_idx) = self.dragging_from.take() { draw_cable(ctx, x1 as f64, y1 as f64, cur_x as f64, cur_y as f64,
if let Some(dst_idx) = self.hit_test(x, y) { signal_color(from_signal), 0.55);
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> { // ── Free drawing helpers ──────────────────────────────────────────────────────
self.jacks.iter().position(|j| {
let dx = j.x - x; fn draw_jack(ctx: &CanvasRenderingContext2d, x: f64, y: f64, r: f64, color: &str, is_output: bool) {
let dy = j.y - y; // Outer ring
(dx * dx + dy * dy).sqrt() <= JACK_RADIUS * 1.5 ctx.begin_path();
}) let _ = ctx.arc(x, y, r + 2.5, 0.0, core::f64::consts::TAU);
ctx.set_fill_style_str("#161920");
ctx.fill();
ctx.set_stroke_style_str(color);
ctx.set_line_width(1.5);
ctx.stroke();
// Inner fill
ctx.begin_path();
let _ = ctx.arc(x, y, r * 0.55, 0.0, core::f64::consts::TAU);
ctx.set_fill_style_str(if is_output { color } else { "#0d0f14" });
ctx.fill();
}
fn draw_cable(
ctx: &CanvasRenderingContext2d,
x1: f64, y1: f64, x2: f64, y2: f64,
color: &str, alpha: f64,
) {
let dx = (x2 - x1).abs();
let sag = (dx * 0.35 + 40.0).min(100.0);
let cpx1 = x1 + (x2 - x1) * 0.35;
let cpy1 = y1 + sag;
let cpx2 = x2 - (x2 - x1) * 0.35;
let cpy2 = y2 + sag;
ctx.set_global_alpha(alpha * 0.25);
ctx.begin_path();
ctx.move_to(x1, y1);
ctx.bezier_curve_to(cpx1, cpy1, cpx2, cpy2, x2, y2);
ctx.set_stroke_style_str(color);
ctx.set_line_width(5.0);
ctx.stroke();
ctx.set_global_alpha(alpha);
ctx.begin_path();
ctx.move_to(x1, y1);
ctx.bezier_curve_to(cpx1, cpy1, cpx2, cpy2, x2, y2);
ctx.set_stroke_style_str(color);
ctx.set_line_width(2.0);
ctx.stroke();
ctx.set_global_alpha(1.0);
}
fn rounded_rect(ctx: &CanvasRenderingContext2d, x: f64, y: f64, w: f64, h: f64, r: f64) {
ctx.begin_path();
ctx.move_to(x + r, y);
ctx.line_to(x + w - r, y);
let _ = ctx.arc_to(x + w, y, x + w, y + r, r);
ctx.line_to(x + w, y + h - r);
let _ = ctx.arc_to(x + w, y + h, x + w - r, y + h, r);
ctx.line_to(x + r, y + h);
let _ = ctx.arc_to(x, y + h, x, y + h - r, r);
ctx.line_to(x, y + r);
let _ = ctx.arc_to(x, y, x + r, y, r);
ctx.close_path();
}
fn rounded_rect_top(ctx: &CanvasRenderingContext2d, x: f64, y: f64, w: f64, h: f64, r: f64) {
ctx.begin_path();
ctx.move_to(x + r, y);
ctx.line_to(x + w - r, y);
let _ = ctx.arc_to(x + w, y, x + w, y + r, r);
ctx.line_to(x + w, y + h);
ctx.line_to(x, y + h);
ctx.line_to(x, y + r);
let _ = ctx.arc_to(x, y, x + r, y, r);
ctx.close_path();
}
// ── JSON helpers ──────────────────────────────────────────────────────────────
fn jacks_to_json(desc: &ComponentDescriptor) -> String {
let mut out = String::from("[");
for (i, j) in desc.jacks.iter().enumerate() {
if i > 0 { out.push(','); }
out.push_str(&format!(
r#"{{"id":"{id}","label":"{label}","direction":"{dir}","signal":"{sig}"}}"#,
id = j.id,
label = j.label,
dir = if j.direction == Direction::Output { "output" } else { "input" },
sig = match j.signal {
SignalKind::Audio => "audio",
SignalKind::Cv => "cv",
SignalKind::Gate => "gate",
},
));
}
out.push(']');
out
}
fn params_to_json(desc: &ComponentDescriptor) -> String {
let mut out = String::from("[");
for (i, p) in desc.params.iter().enumerate() {
if i > 0 { out.push(','); }
out.push_str(&format!(
r#"{{"id":"{id}","label":"{label}","min":{min:.4},"max":{max:.4},"default":{def:.4},"unit":"{unit}"}}"#,
id = p.id,
label = p.label,
min = p.min,
max = p.max,
def = p.default,
unit = p.unit,
));
}
out.push(']');
out
}
fn format_param(val: f32, unit: &str) -> String {
match unit {
"Hz" => {
if val >= 1000.0 {
format!("{:.1}k", val / 1000.0)
} else {
format!("{:.0}Hz", val)
} }
} }
"s" => format!("{:.2}s", val),
_ => format!("{:.2}", val),
}
}
#[inline]
fn hypot(dx: f32, dy: f32) -> f32 {
(dx * dx + dy * dy).sqrt()
}

84
www/bootstrap.js vendored
View File

@@ -1,8 +1,9 @@
/** /**
* bootstrap.js — ES module entry point. * bootstrap.js — ES module entry point.
* *
* Imports the wasm-pack-generated glue, initialises the WASM binary, then * Initialises the WASM module, wires canvas elements to Rust-exported types,
* wires up Rust-exported types to the canvas elements in index.html. * keeps canvas drawing buffers in sync with their CSS size, and provides a
* draggable resize handle for the patch bay panel.
*/ */
import init, { import init, {
@@ -13,12 +14,62 @@ import init, {
SynthParams, SynthParams,
} from "./pkg/synth_visualiser.js"; } from "./pkg/synth_visualiser.js";
// ── Canvas buffer sizing ──────────────────────────────────────────────────────
// A <canvas> has two independent sizes:
// - CSS display size (width/height CSS properties) — how large it appears
// - Drawing buffer (width/height HTML attributes) — actual pixel resolution
//
// We must keep them in sync; if the buffer is smaller than the display size the
// browser stretches it and everything looks blurry / oversized.
function fitCanvas(canvas) {
const w = Math.round(canvas.clientWidth);
const h = Math.round(canvas.clientHeight);
if (w > 0 && h > 0 && (canvas.width !== w || canvas.height !== h)) {
canvas.width = w;
canvas.height = h;
}
}
// ── Resize handle ─────────────────────────────────────────────────────────────
function initResizeHandle() {
const handle = document.getElementById("resize-handle");
const panel = document.getElementById("patchbay-panel");
let dragging = false;
let startY = 0;
let startH = 0;
handle.addEventListener("pointerdown", e => {
dragging = true;
startY = e.clientY;
startH = panel.offsetHeight;
handle.setPointerCapture(e.pointerId);
handle.classList.add("active");
});
handle.addEventListener("pointermove", e => {
if (!dragging) return;
// Dragging up (negative dy) increases the panel height
const dy = e.clientY - startY;
const h = Math.max(80, startH - dy);
panel.style.height = h + "px";
});
handle.addEventListener("pointerup", () => { dragging = false; handle.classList.remove("active"); });
handle.addEventListener("pointercancel",() => { dragging = false; handle.classList.remove("active"); });
}
// ── Bootstrap ─────────────────────────────────────────────────────────────────
const loader = document.getElementById("loader"); const loader = document.getElementById("loader");
const status = document.getElementById("status"); const status = document.getElementById("status");
const srLabel = document.getElementById("sample-rate"); const srLabel = document.getElementById("sample-rate");
const frameTime = document.getElementById("frame-time"); const frameTime = document.getElementById("frame-time");
async function bootstrap() { async function bootstrap() {
initResizeHandle();
try { try {
await init(); await init();
@@ -30,18 +81,31 @@ async function bootstrap() {
const spectrum = new SpectrumView("spectrum-canvas", analyser); const spectrum = new SpectrumView("spectrum-canvas", analyser);
const patchbay = new PatchBay("patchbay-canvas"); const patchbay = new PatchBay("patchbay-canvas");
// Register module jacks (x, y coordinates relative to the canvas) // Fit all canvas buffers to their current CSS layout size before we
patchbay.register_jack("vco", "out", 50, 60, true); // ask Rust for canvas.width() to position the default modules.
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"); const pbCanvas = document.getElementById("patchbay-canvas");
const oscCanvas = document.getElementById("oscilloscope-canvas");
const specCanvas = document.getElementById("spectrum-canvas");
const allCanvases = [oscCanvas, specCanvas, pbCanvas];
allCanvases.forEach(fitCanvas);
// Seed a default patch using the now-correct canvas width.
const cw = pbCanvas.width || pbCanvas.clientWidth || 800;
patchbay.add_module("vco", cw * 0.12, 80);
patchbay.add_module("adsr", cw * 0.32, 80);
patchbay.add_module("svf", cw * 0.55, 80);
patchbay.add_module("vca", cw * 0.76, 80);
// Keep canvas buffers in sync whenever the panel is resized.
const ro = new ResizeObserver(() => allCanvases.forEach(fitCanvas));
allCanvases.forEach(c => ro.observe(c));
// Patch bay pointer events
pbCanvas.addEventListener("pointerdown", e => patchbay.on_pointer_down(e.offsetX, e.offsetY)); 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("pointermove", e => patchbay.on_pointer_move(e.offsetX, e.offsetY));
pbCanvas.addEventListener("pointerup", e => patchbay.on_pointer_up(e.offsetX, e.offsetY)); pbCanvas.addEventListener("pointerup", e => patchbay.on_pointer_up(e.offsetX, e.offsetY));
pbCanvas.addEventListener("dblclick", e => patchbay.on_double_click(e.offsetX, e.offsetY));
const params = new SynthParams(); const params = new SynthParams();
engine.set_params(params.to_json()); engine.set_params(params.to_json());

View File

@@ -10,58 +10,128 @@
--panel: #1a1a1a; --panel: #1a1a1a;
--accent: #00e5ff; --accent: #00e5ff;
--text: #e0e0e0; --text: #e0e0e0;
--border: #2a2a2a;
--handle: #222;
--handle-hover:#2e2e2e;
--patchbay-h: 340px;
} }
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
overflow: hidden;
}
body { body {
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font-family: "JetBrains Mono", "Fira Code", monospace; font-family: "JetBrains Mono", "Fira Code", monospace;
display: grid; display: flex;
grid-template-rows: auto 1fr auto; flex-direction: column;
min-height: 100dvh;
} }
header { header {
padding: 0.75rem 1.5rem; padding: 0.6rem 1.5rem;
border-bottom: 1px solid #333; border-bottom: 1px solid var(--border);
font-size: 0.9rem; font-size: 0.85rem;
letter-spacing: 0.15em; letter-spacing: 0.15em;
text-transform: uppercase; text-transform: uppercase;
color: var(--accent); color: var(--accent);
flex-shrink: 0;
} }
main { main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
/* Top two panels side-by-side */
.panels-top {
flex: 1;
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 180px;
gap: 1px; gap: 1px;
background: #333; background: var(--border);
min-height: 60px;
overflow: hidden; overflow: hidden;
} }
/* Draggable divider between top panels and patch bay */
.resize-handle {
flex-shrink: 0;
height: 6px;
background: var(--handle);
cursor: ns-resize;
border-top: 1px solid #333;
border-bottom: 1px solid #111;
user-select: none;
touch-action: none;
}
.resize-handle:hover,
.resize-handle.active {
background: var(--handle-hover);
}
/* Widen the grab area without making the visual gap bigger */
.resize-handle::before {
content: "";
display: block;
height: 12px;
margin-top: -3px;
}
/* Patch bay panel — height controlled by JS drag */
.panel--patchbay {
flex-shrink: 0;
height: var(--patchbay-h);
min-height: 80px;
max-height: calc(100vh - 120px);
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--panel);
}
.panel { .panel {
background: var(--panel); background: var(--panel);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
.panel__label { .panel__label {
padding: 0.4rem 0.75rem; padding: 0.35rem 0.75rem;
font-size: 0.7rem; font-size: 0.65rem;
letter-spacing: 0.1em; letter-spacing: 0.1em;
text-transform: uppercase; text-transform: uppercase;
color: #666; color: #555;
border-bottom: 1px solid #2a2a2a; border-bottom: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
} }
.panel canvas { flex: 1; width: 100%; height: 100%; display: block; }
.panel--patchbay { grid-column: 1 / -1; } /* Canvas fills remaining space; buffer sizing is done in JS */
.panel canvas {
flex: 1;
width: 100%;
height: 100%;
display: block;
}
footer { footer {
padding: 0.5rem 1.5rem; flex-shrink: 0;
font-size: 0.7rem; padding: 0.4rem 1.5rem;
font-size: 0.65rem;
color: #444; color: #444;
border-top: 1px solid #222; border-top: 1px solid #1e1e1e;
display: flex; display: flex;
gap: 1.5rem; gap: 1.5rem;
} }
#status { color: var(--accent); } #status { color: var(--accent); }
#loader { #loader {
position: fixed; inset: 0; position: fixed; inset: 0;
background: var(--bg); background: var(--bg);
@@ -78,6 +148,7 @@
<header>Analogue Synth Visualiser</header> <header>Analogue Synth Visualiser</header>
<main> <main>
<div class="panels-top">
<section class="panel"> <section class="panel">
<div class="panel__label">Oscilloscope</div> <div class="panel__label">Oscilloscope</div>
<canvas id="oscilloscope-canvas"></canvas> <canvas id="oscilloscope-canvas"></canvas>
@@ -86,7 +157,11 @@
<div class="panel__label">Spectrum</div> <div class="panel__label">Spectrum</div>
<canvas id="spectrum-canvas"></canvas> <canvas id="spectrum-canvas"></canvas>
</section> </section>
<section class="panel panel--patchbay"> </div>
<div class="resize-handle" id="resize-handle" title="Drag to resize patch bay"></div>
<section class="panel panel--patchbay" id="patchbay-panel">
<div class="panel__label">Patch Bay</div> <div class="panel__label">Patch Bay</div>
<canvas id="patchbay-canvas"></canvas> <canvas id="patchbay-canvas"></canvas>
</section> </section>
@@ -98,12 +173,6 @@
<span id="frame-time"></span> <span id="frame-time"></span>
</footer> </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> <script type="module" src="./bootstrap.js"></script>
</body> </body>
</html> </html>