Add audio nodes to the patch bay
Also make the patch bay responsive.
This commit is contained in:
@@ -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
47
build-web.sh
Executable 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"
|
||||||
70
crates/synth-core/src/descriptor.rs
Normal file
70
crates/synth-core/src/descriptor.rs
Normal 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],
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.0–1.0
|
pub gain: f32, // 0.0–1.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 {
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
84
www/bootstrap.js
vendored
@@ -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());
|
||||||
|
|||||||
117
www/index.html
117
www/index.html
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user