Add different wave and filter types
This commit is contained in:
41
crates/synth-core/src/audio_out.rs
Normal file
41
crates/synth-core/src/audio_out.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
//! Audio output sink — final stage in the signal chain.
|
||||||
|
//!
|
||||||
|
//! In the patch bay this is the node you connect the last module to; it
|
||||||
|
//! represents the physical speakers / DAC.
|
||||||
|
|
||||||
|
use crate::{AudioProcessor, config::SampleRate};
|
||||||
|
use crate::descriptor::{ComponentDescriptor, Direction, JackDescriptor, ParamDescriptor, SignalKind};
|
||||||
|
|
||||||
|
pub struct AudioOut {
|
||||||
|
pub level: f32,
|
||||||
|
sample_rate: SampleRate,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioOut {
|
||||||
|
pub fn new(sample_rate: SampleRate) -> Self {
|
||||||
|
Self { level: 0.8, sample_rate }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const DESCRIPTOR: ComponentDescriptor = ComponentDescriptor {
|
||||||
|
kind: "out",
|
||||||
|
label: "Output",
|
||||||
|
jacks: &[
|
||||||
|
JackDescriptor { id: "audio_in", label: "In", direction: Direction::Input, signal: SignalKind::Audio },
|
||||||
|
],
|
||||||
|
params: &[
|
||||||
|
ParamDescriptor { id: "level", label: "Level", min: 0.0, max: 1.0, default: 0.8, unit: "", labels: &[] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const B: usize> AudioProcessor<B> for AudioOut {
|
||||||
|
fn process(&mut self, out: &mut [f32; B]) {
|
||||||
|
for s in out.iter_mut() {
|
||||||
|
*s *= self.level;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
let _ = self.sample_rate; // kept for API symmetry
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,10 @@ pub struct ParamDescriptor {
|
|||||||
pub default: f32,
|
pub default: f32,
|
||||||
/// Display unit string (e.g. `"Hz"`, `"s"`, `""`).
|
/// Display unit string (e.g. `"Hz"`, `"s"`, `""`).
|
||||||
pub unit: &'static str,
|
pub unit: &'static str,
|
||||||
|
/// When non-empty, this param is a discrete enum selector.
|
||||||
|
/// Each entry is the display label for that step (step 0 = index 0, etc.).
|
||||||
|
/// `min` should be 0.0 and `max` should be `(labels.len() - 1) as f32`.
|
||||||
|
pub labels: &'static [&'static str],
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full compile-time descriptor for a DSP component type.
|
/// Full compile-time descriptor for a DSP component type.
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ impl Adsr {
|
|||||||
JackDescriptor { id: "env_out", label: "Env", direction: Direction::Output, signal: SignalKind::Cv },
|
JackDescriptor { id: "env_out", label: "Env", direction: Direction::Output, signal: SignalKind::Cv },
|
||||||
],
|
],
|
||||||
params: &[
|
params: &[
|
||||||
ParamDescriptor { id: "attack_s", label: "Attack", min: 0.001, max: 4.0, default: 0.01, unit: "s" },
|
ParamDescriptor { id: "attack_s", label: "Attack", min: 0.001, max: 4.0, default: 0.01, unit: "s", labels: &[] },
|
||||||
ParamDescriptor { id: "decay_s", label: "Decay", min: 0.001, max: 4.0, default: 0.1, unit: "s" },
|
ParamDescriptor { id: "decay_s", label: "Decay", min: 0.001, max: 4.0, default: 0.1, unit: "s", labels: &[] },
|
||||||
ParamDescriptor { id: "sustain", label: "Sustain", min: 0.0, max: 1.0, default: 0.7, unit: "" },
|
ParamDescriptor { id: "sustain", label: "Sustain", min: 0.0, max: 1.0, default: 0.7, unit: "", labels: &[] },
|
||||||
ParamDescriptor { id: "release_s", label: "Release", min: 0.001, max: 8.0, default: 0.3, unit: "s" },
|
ParamDescriptor { id: "release_s", label: "Release", min: 0.001, max: 8.0, default: 0.3, unit: "s", labels: &[] },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -40,9 +40,8 @@ impl Svf {
|
|||||||
JackDescriptor { id: "audio_out", label: "Out", direction: Direction::Output, signal: SignalKind::Audio },
|
JackDescriptor { id: "audio_out", label: "Out", direction: Direction::Output, signal: SignalKind::Audio },
|
||||||
],
|
],
|
||||||
params: &[
|
params: &[
|
||||||
ParamDescriptor { id: "cutoff_hz", label: "Cutoff", min: 20.0, max: 20_000.0, default: 2_000.0, unit: "Hz" },
|
ParamDescriptor { id: "cutoff_hz", label: "Cutoff", min: 20.0, max: 20_000.0, default: 2_000.0, unit: "Hz", labels: &[] },
|
||||||
ParamDescriptor { id: "resonance", label: "Res", min: 0.0, max: 1.0, default: 0.5, unit: "" },
|
ParamDescriptor { id: "resonance", label: "Res", min: 0.0, max: 1.0, default: 0.5, unit: "", labels: &[] },
|
||||||
ParamDescriptor { id: "mode", label: "Mode", min: 0.0, max: 3.0, default: 0.0, unit: "" },
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ impl Lfo {
|
|||||||
JackDescriptor { id: "cv_out", label: "Out", direction: Direction::Output, signal: SignalKind::Cv },
|
JackDescriptor { id: "cv_out", label: "Out", direction: Direction::Output, signal: SignalKind::Cv },
|
||||||
],
|
],
|
||||||
params: &[
|
params: &[
|
||||||
ParamDescriptor { id: "rate_hz", label: "Rate", min: 0.01, max: 20.0, default: 2.0, unit: "Hz" },
|
ParamDescriptor { id: "rate_hz", label: "Rate", min: 0.01, max: 20.0, default: 2.0, unit: "Hz", labels: &[] },
|
||||||
ParamDescriptor { id: "depth", label: "Depth", min: 0.0, max: 1.0, default: 0.5, unit: "" },
|
ParamDescriptor { id: "depth", label: "Depth", min: 0.0, max: 1.0, default: 0.5, unit: "", labels: &[] },
|
||||||
ParamDescriptor { id: "waveform", label: "Wave", min: 0.0, max: 4.0, default: 0.0, unit: "" },
|
ParamDescriptor { id: "waveform", label: "Wave", min: 0.0, max: 4.0, default: 0.0, unit: "", labels: &["Sine", "Saw", "Sqr", "Tri", "Pls"] },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ pub mod vca;
|
|||||||
pub mod lfo;
|
pub mod lfo;
|
||||||
pub mod midi;
|
pub mod midi;
|
||||||
pub mod patch;
|
pub mod patch;
|
||||||
|
pub mod audio_out;
|
||||||
|
|
||||||
pub use config::SampleRate;
|
pub use config::SampleRate;
|
||||||
pub use math::{db_to_linear, linear_to_db, lerp, midi_note_to_hz};
|
pub use math::{db_to_linear, linear_to_db, lerp, midi_note_to_hz};
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ impl Vco {
|
|||||||
JackDescriptor { id: "audio_out", label: "Out", direction: Direction::Output, signal: SignalKind::Audio },
|
JackDescriptor { id: "audio_out", label: "Out", direction: Direction::Output, signal: SignalKind::Audio },
|
||||||
],
|
],
|
||||||
params: &[
|
params: &[
|
||||||
ParamDescriptor { id: "freq_hz", label: "Freq", min: 20.0, max: 20_000.0, default: 440.0, unit: "Hz" },
|
ParamDescriptor { id: "freq_hz", label: "Freq", min: 20.0, max: 20_000.0, default: 440.0, unit: "Hz", labels: &[] },
|
||||||
ParamDescriptor { id: "waveform", label: "Wave", min: 0.0, max: 4.0, default: 1.0, unit: "" },
|
ParamDescriptor { id: "waveform", label: "Wave", min: 0.0, max: 4.0, default: 1.0, unit: "", labels: &["Sine", "Saw", "Sqr", "Tri", "Pls"] },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ impl Vca {
|
|||||||
JackDescriptor { id: "audio_out", label: "Out", direction: Direction::Output, signal: SignalKind::Audio },
|
JackDescriptor { id: "audio_out", label: "Out", direction: Direction::Output, signal: SignalKind::Audio },
|
||||||
],
|
],
|
||||||
params: &[
|
params: &[
|
||||||
ParamDescriptor { id: "gain", label: "Gain", min: 0.0, max: 1.0, default: 1.0, unit: "" },
|
ParamDescriptor { id: "gain", label: "Gain", min: 0.0, max: 1.0, default: 1.0, unit: "", labels: &[] },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,18 +11,20 @@ use synth_core::filter::Svf;
|
|||||||
use synth_core::envelope::Adsr;
|
use synth_core::envelope::Adsr;
|
||||||
use synth_core::vca::Vca;
|
use synth_core::vca::Vca;
|
||||||
use synth_core::lfo::Lfo;
|
use synth_core::lfo::Lfo;
|
||||||
|
use synth_core::audio_out::AudioOut;
|
||||||
|
|
||||||
// ── Component registry — single source of truth ──────────────────────────────
|
// ── Component registry — single source of truth ──────────────────────────────
|
||||||
|
|
||||||
/// All available component types, sourced directly from synth-core.
|
/// All available component types, sourced directly from synth-core.
|
||||||
/// Adding a new component to synth-core and this array is all that's needed
|
/// Adding a new component to synth-core and this array is all that's needed
|
||||||
/// for it to appear in the patch bay palette.
|
/// for it to appear in the patch bay palette.
|
||||||
const REGISTRY: [ComponentDescriptor; 5] = [
|
const REGISTRY: [ComponentDescriptor; 6] = [
|
||||||
Vco::DESCRIPTOR,
|
Vco::DESCRIPTOR,
|
||||||
Svf::DESCRIPTOR,
|
Svf::DESCRIPTOR,
|
||||||
Adsr::DESCRIPTOR,
|
Adsr::DESCRIPTOR,
|
||||||
Vca::DESCRIPTOR,
|
Vca::DESCRIPTOR,
|
||||||
Lfo::DESCRIPTOR,
|
Lfo::DESCRIPTOR,
|
||||||
|
AudioOut::DESCRIPTOR,
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Visual constants ──────────────────────────────────────────────────────────
|
// ── Visual constants ──────────────────────────────────────────────────────────
|
||||||
@@ -50,6 +52,7 @@ fn module_color(kind: &str) -> &'static str {
|
|||||||
"adsr" => "#2a9d3c",
|
"adsr" => "#2a9d3c",
|
||||||
"vca" => "#c47a2e",
|
"vca" => "#c47a2e",
|
||||||
"lfo" => "#2a8f9d",
|
"lfo" => "#2a8f9d",
|
||||||
|
"out" => "#9d1515",
|
||||||
_ => "#555555",
|
_ => "#555555",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,6 +119,27 @@ impl Module {
|
|||||||
&& y >= self.y && y <= self.y + self.height()
|
&& y >= self.y && y <= self.y + self.height()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hit test for parameter rows. Returns `(param_idx, current_val, min, max)`.
|
||||||
|
fn hit_param(&self, x: f32, y: f32) -> Option<(usize, f32, f32, f32)> {
|
||||||
|
let desc = self.descriptor();
|
||||||
|
if desc.params.is_empty() { return None; }
|
||||||
|
let tx = self.x + 8.0;
|
||||||
|
let tw = MOD_W - 16.0;
|
||||||
|
if x < tx || x > tx + tw { return None; }
|
||||||
|
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;
|
||||||
|
let params_top = self.y + MOD_HEADER + jack_rows * JACK_ROW + 3.0;
|
||||||
|
for (pi, p) in desc.params.iter().enumerate() {
|
||||||
|
let py = params_top + 4.0 + pi as f32 * PARAM_ROW;
|
||||||
|
if y >= py && y <= py + PARAM_ROW {
|
||||||
|
let val = self.param_values.get(pi).copied().unwrap_or(p.default);
|
||||||
|
return Some((pi, val, p.min, p.max));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn hit_jack(&self, x: f32, y: f32) -> Option<(String, Direction, SignalKind)> {
|
fn hit_jack(&self, x: f32, y: f32) -> Option<(String, Direction, SignalKind)> {
|
||||||
let desc = self.descriptor();
|
let desc = self.descriptor();
|
||||||
let mut in_idx = 0usize;
|
let mut in_idx = 0usize;
|
||||||
@@ -159,6 +183,16 @@ enum DragState {
|
|||||||
cur_x: f32,
|
cur_x: f32,
|
||||||
cur_y: f32,
|
cur_y: f32,
|
||||||
},
|
},
|
||||||
|
/// Dragging a parameter slider. `start_y` / `start_val` are anchored at
|
||||||
|
/// the pointer-down position so the value is relative to where you clicked.
|
||||||
|
Param {
|
||||||
|
module_id: u32,
|
||||||
|
param_idx: usize,
|
||||||
|
start_y: f32,
|
||||||
|
start_val: f32,
|
||||||
|
min: f32,
|
||||||
|
max: f32,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PatchBay ──────────────────────────────────────────────────────────────────
|
// ── PatchBay ──────────────────────────────────────────────────────────────────
|
||||||
@@ -171,6 +205,12 @@ pub struct PatchBay {
|
|||||||
cables: Vec<VisCable>,
|
cables: Vec<VisCable>,
|
||||||
drag: DragState,
|
drag: DragState,
|
||||||
next_id: u32,
|
next_id: u32,
|
||||||
|
/// Incremented whenever the patch topology changes (modules added/removed,
|
||||||
|
/// cables added/removed). JS polls this to know when to rebuild the audio graph.
|
||||||
|
patch_version: u32,
|
||||||
|
/// Incremented whenever a parameter value changes. JS polls this to update
|
||||||
|
/// audio params without rebuilding the full graph.
|
||||||
|
params_version: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
@@ -193,6 +233,8 @@ impl PatchBay {
|
|||||||
cables: Vec::new(),
|
cables: Vec::new(),
|
||||||
drag: DragState::Idle,
|
drag: DragState::Idle,
|
||||||
next_id: 1,
|
next_id: 1,
|
||||||
|
patch_version: 0,
|
||||||
|
params_version: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +245,7 @@ impl PatchBay {
|
|||||||
self.next_id += 1;
|
self.next_id += 1;
|
||||||
let param_values = desc.params.iter().map(|p| p.default).collect();
|
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 });
|
self.modules.push(Module { id, kind: desc.kind, label: desc.label, x, y, param_values });
|
||||||
|
self.patch_version += 1;
|
||||||
id
|
id
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
@@ -213,6 +256,21 @@ impl PatchBay {
|
|||||||
pub fn remove_module(&mut self, id: u32) {
|
pub fn remove_module(&mut self, id: u32) {
|
||||||
self.modules.retain(|m| m.id != id);
|
self.modules.retain(|m| m.id != id);
|
||||||
self.cables.retain(|c| c.src_module != id && c.dst_module != id);
|
self.cables.retain(|c| c.src_module != id && c.dst_module != id);
|
||||||
|
self.patch_version += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a counter that increments on every structural patch change
|
||||||
|
/// (module added/removed, cable added/removed). JS polls this each frame
|
||||||
|
/// to know when to rebuild the Web Audio graph.
|
||||||
|
pub fn patch_version(&self) -> u32 {
|
||||||
|
self.patch_version
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a counter that increments whenever a parameter value changes.
|
||||||
|
/// JS polls this each frame to apply live audio param updates without a
|
||||||
|
/// full graph rebuild.
|
||||||
|
pub fn params_version(&self) -> u32 {
|
||||||
|
self.params_version
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update a parameter value (param_idx is the position in the descriptor's params slice).
|
/// Update a parameter value (param_idx is the position in the descriptor's params slice).
|
||||||
@@ -220,6 +278,7 @@ impl PatchBay {
|
|||||||
if let Some(m) = self.modules.iter_mut().find(|m| m.id == module_id) {
|
if let Some(m) = self.modules.iter_mut().find(|m| m.id == module_id) {
|
||||||
if param_idx < m.param_values.len() {
|
if param_idx < m.param_values.len() {
|
||||||
m.param_values[param_idx] = value;
|
m.param_values[param_idx] = value;
|
||||||
|
self.params_version += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,6 +314,43 @@ impl PatchBay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parameter interaction (between jacks and header).
|
||||||
|
// Enum params select a segment immediately on click; continuous params start a drag.
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum ParamAction { Enum(u32, usize, usize), Continuous(u32, usize, f32, f32, f32) }
|
||||||
|
let action = (0..n).rev().find_map(|i| {
|
||||||
|
let m = &self.modules[i];
|
||||||
|
m.hit_param(x, y).map(|(pidx, start_val, min, max)| {
|
||||||
|
let desc = m.descriptor();
|
||||||
|
let p = &desc.params[pidx];
|
||||||
|
if !p.labels.is_empty() {
|
||||||
|
let tx = m.x + 8.0;
|
||||||
|
let tw = MOD_W - 16.0;
|
||||||
|
let n_lbls = p.labels.len() as f32;
|
||||||
|
let seg = ((x - tx) / tw * n_lbls).floor().clamp(0.0, n_lbls - 1.0) as usize;
|
||||||
|
ParamAction::Enum(m.id, pidx, seg)
|
||||||
|
} else {
|
||||||
|
ParamAction::Continuous(m.id, pidx, start_val, min, max)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
match action {
|
||||||
|
Some(ParamAction::Enum(module_id, param_idx, seg)) => {
|
||||||
|
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] = seg as f32;
|
||||||
|
self.params_version += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Some(ParamAction::Continuous(module_id, param_idx, start_val, min, max)) => {
|
||||||
|
self.drag = DragState::Param { module_id, param_idx, start_y: y, start_val, min, max };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
|
||||||
// Header drag
|
// Header drag
|
||||||
for i in (0..n).rev() {
|
for i in (0..n).rev() {
|
||||||
if self.modules[i].hit_header(x, y) {
|
if self.modules[i].hit_header(x, y) {
|
||||||
@@ -284,6 +380,18 @@ impl PatchBay {
|
|||||||
cur_x: x, cur_y: y,
|
cur_x: x, cur_y: y,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
DragState::Param { module_id, param_idx, start_y, start_val, min, max } => {
|
||||||
|
// 120 px of travel = full range; drag up increases value
|
||||||
|
let range = max - min;
|
||||||
|
let delta = (start_y - y) / 120.0 * range;
|
||||||
|
let new_val = (start_val + delta).clamp(min, max);
|
||||||
|
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] = new_val;
|
||||||
|
self.params_version += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
DragState::Idle => {}
|
DragState::Idle => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -313,6 +421,7 @@ impl PatchBay {
|
|||||||
dst_module: dst_m,
|
dst_module: dst_m,
|
||||||
dst_jack: dst_j,
|
dst_jack: dst_j,
|
||||||
});
|
});
|
||||||
|
self.patch_version += 1;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -460,15 +569,38 @@ impl PatchBay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn draw_modules(&self, ctx: &CanvasRenderingContext2d) {
|
fn draw_modules(&self, ctx: &CanvasRenderingContext2d) {
|
||||||
|
// If a param drag is active, extract which module/param is highlighted
|
||||||
|
let active_param: Option<(u32, usize)> = match &self.drag {
|
||||||
|
DragState::Param { module_id, param_idx, .. } => Some((*module_id, *param_idx)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
for m in &self.modules {
|
for m in &self.modules {
|
||||||
self.draw_module(ctx, m);
|
let has_signal = self.cables.iter().any(|c| c.dst_module == m.id);
|
||||||
|
let active_pidx = active_param.and_then(|(mid, pi)| if mid == m.id { Some(pi) } else { None });
|
||||||
|
self.draw_module(ctx, m, has_signal, active_pidx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_module(&self, ctx: &CanvasRenderingContext2d, m: &Module) {
|
fn draw_module(&self, ctx: &CanvasRenderingContext2d, m: &Module, has_signal: bool, active_param: Option<usize>) {
|
||||||
let desc = m.descriptor();
|
let desc = m.descriptor();
|
||||||
let mh = m.height();
|
let mh = m.height();
|
||||||
let color = module_color(m.kind);
|
let color = module_color(m.kind);
|
||||||
|
let is_out = m.kind == "out";
|
||||||
|
|
||||||
|
// For the output node: draw a coloured outer glow ring first so it
|
||||||
|
// sits behind the module body.
|
||||||
|
if is_out {
|
||||||
|
let glow = if has_signal { "#22c55e" } else { "#4a1010" };
|
||||||
|
ctx.set_stroke_style_str(glow);
|
||||||
|
ctx.set_line_width(if has_signal { 2.5 } else { 1.0 });
|
||||||
|
rounded_rect(
|
||||||
|
ctx,
|
||||||
|
(m.x - 4.0) as f64, (m.y - 4.0) as f64,
|
||||||
|
(MOD_W + 8.0) as f64, (mh + 8.0) as f64,
|
||||||
|
10.0,
|
||||||
|
);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
// Body
|
// Body
|
||||||
ctx.set_fill_style_str("#20242e");
|
ctx.set_fill_style_str("#20242e");
|
||||||
@@ -484,12 +616,36 @@ impl PatchBay {
|
|||||||
ctx.set_fill_style_str("rgba(255,255,255,0.95)");
|
ctx.set_fill_style_str("rgba(255,255,255,0.95)");
|
||||||
ctx.set_font("bold 12px monospace");
|
ctx.set_font("bold 12px monospace");
|
||||||
ctx.set_text_align("center");
|
ctx.set_text_align("center");
|
||||||
|
let label_cx = if is_out { m.x + MOD_W * 0.38 } else { m.x + MOD_W * 0.5 };
|
||||||
let _ = ctx.fill_text(
|
let _ = ctx.fill_text(
|
||||||
m.label,
|
m.label,
|
||||||
(m.x + MOD_W * 0.5) as f64,
|
label_cx as f64,
|
||||||
(m.y + MOD_HEADER * 0.70) as f64,
|
(m.y + MOD_HEADER * 0.70) as f64,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Output node: LIVE / UNPATCHED badge on the right of the header
|
||||||
|
if is_out {
|
||||||
|
if has_signal {
|
||||||
|
ctx.set_fill_style_str("#22c55e");
|
||||||
|
ctx.set_font("bold 9px monospace");
|
||||||
|
ctx.set_text_align("right");
|
||||||
|
let _ = ctx.fill_text(
|
||||||
|
"● LIVE",
|
||||||
|
(m.x + MOD_W - 6.0) as f64,
|
||||||
|
(m.y + MOD_HEADER * 0.70) as f64,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ctx.set_fill_style_str("rgba(255,255,255,0.28)");
|
||||||
|
ctx.set_font("9px monospace");
|
||||||
|
ctx.set_text_align("right");
|
||||||
|
let _ = ctx.fill_text(
|
||||||
|
"unpatched",
|
||||||
|
(m.x + MOD_W - 6.0) as f64,
|
||||||
|
(m.y + MOD_HEADER * 0.70) as f64,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Outer border
|
// Outer border
|
||||||
ctx.set_stroke_style_str("rgba(255,255,255,0.07)");
|
ctx.set_stroke_style_str("rgba(255,255,255,0.07)");
|
||||||
ctx.set_line_width(1.0);
|
ctx.set_line_width(1.0);
|
||||||
@@ -536,23 +692,63 @@ impl PatchBay {
|
|||||||
|
|
||||||
// Parameters
|
// Parameters
|
||||||
for (pi, p) in desc.params.iter().enumerate() {
|
for (pi, p) in desc.params.iter().enumerate() {
|
||||||
|
let is_active = active_param == Some(pi);
|
||||||
let py = params_top + 4.0 + pi as f32 * PARAM_ROW;
|
let py = params_top + 4.0 + pi as f32 * PARAM_ROW;
|
||||||
let val = m.param_values.get(pi).copied().unwrap_or(p.default);
|
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 tx = m.x + 8.0;
|
||||||
let tw = MOD_W - 16.0;
|
let tw = MOD_W - 16.0;
|
||||||
|
|
||||||
|
if !p.labels.is_empty() {
|
||||||
|
// ── Enum selector: a row of clickable labelled segments ───────
|
||||||
|
let n_segs = p.labels.len();
|
||||||
|
let selected = val.round() as usize;
|
||||||
|
let seg_w = tw / n_segs as f32;
|
||||||
|
let btn_y = py + 2.0;
|
||||||
|
let btn_h = PARAM_ROW - 4.0;
|
||||||
|
|
||||||
|
ctx.set_font("bold 8px monospace");
|
||||||
|
for (li, lbl) in p.labels.iter().enumerate() {
|
||||||
|
let bx = tx + li as f32 * seg_w;
|
||||||
|
let bw = seg_w - 1.0;
|
||||||
|
if li == selected {
|
||||||
|
ctx.set_fill_style_str(color);
|
||||||
|
} else {
|
||||||
|
ctx.set_fill_style_str("#181b22");
|
||||||
|
}
|
||||||
|
ctx.fill_rect(bx as f64, btn_y as f64, bw as f64, btn_h as f64);
|
||||||
|
ctx.set_fill_style_str(if li == selected {
|
||||||
|
"rgba(255,255,255,0.95)"
|
||||||
|
} else {
|
||||||
|
"rgba(140,145,160,0.7)"
|
||||||
|
});
|
||||||
|
ctx.set_text_align("center");
|
||||||
|
let _ = ctx.fill_text(
|
||||||
|
lbl,
|
||||||
|
(bx + bw * 0.5) as f64,
|
||||||
|
(btn_y + btn_h * 0.72) as f64,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// ── Continuous slider ─────────────────────────────────────────
|
||||||
|
let ratio = ((val - p.min) / (p.max - p.min)).clamp(0.0, 1.0);
|
||||||
let track_y = py + 13.0;
|
let track_y = py + 13.0;
|
||||||
|
|
||||||
|
// Active row highlight
|
||||||
|
if is_active {
|
||||||
|
ctx.set_fill_style_str("rgba(255,255,255,0.05)");
|
||||||
|
ctx.fill_rect(m.x as f64, py as f64, MOD_W as f64, PARAM_ROW as f64);
|
||||||
|
}
|
||||||
|
|
||||||
// Track bg
|
// Track bg
|
||||||
ctx.set_fill_style_str("#181b22");
|
ctx.set_fill_style_str("#181b22");
|
||||||
ctx.fill_rect(tx as f64, track_y as f64, tw as f64, 3.0);
|
ctx.fill_rect(tx as f64, track_y as f64, tw as f64, if is_active { 4.0 } else { 3.0 });
|
||||||
// Track fill
|
// Track fill
|
||||||
ctx.set_fill_style_str(color);
|
ctx.set_fill_style_str(if is_active { "rgba(255,255,255,0.85)" } else { color });
|
||||||
ctx.fill_rect(tx as f64, track_y as f64, (tw * ratio) as f64, 3.0);
|
ctx.fill_rect(tx as f64, track_y as f64, (tw * ratio) as f64, if is_active { 4.0 } else { 3.0 });
|
||||||
|
|
||||||
// Labels
|
// Labels
|
||||||
ctx.set_fill_style_str("rgba(160,165,180,0.8)");
|
ctx.set_fill_style_str(if is_active { "rgba(230,235,255,1.0)" } else { "rgba(160,165,180,0.8)" });
|
||||||
ctx.set_font("9px sans-serif");
|
ctx.set_font(if is_active { "bold 9px sans-serif" } else { "9px sans-serif" });
|
||||||
ctx.set_text_align("left");
|
ctx.set_text_align("left");
|
||||||
let _ = ctx.fill_text(p.label, tx as f64, (py + 10.0) as f64);
|
let _ = ctx.fill_text(p.label, tx as f64, (py + 10.0) as f64);
|
||||||
ctx.set_text_align("right");
|
ctx.set_text_align("right");
|
||||||
@@ -563,6 +759,7 @@ impl PatchBay {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_cables(&self, ctx: &CanvasRenderingContext2d) {
|
fn draw_cables(&self, ctx: &CanvasRenderingContext2d) {
|
||||||
for cable in &self.cables {
|
for cable in &self.cables {
|
||||||
|
|||||||
428
www/bootstrap.js
vendored
428
www/bootstrap.js
vendored
@@ -1,10 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* bootstrap.js — ES module entry point.
|
* bootstrap.js — ES module entry point.
|
||||||
*
|
*
|
||||||
* Initialises the WASM module, wires canvas elements to Rust-exported types,
|
* The PatchRouter class mirrors the visual patch bay into a Web Audio graph.
|
||||||
* keeps canvas drawing buffers in sync with their CSS size, provides a
|
* Sound only flows when the Output node has a cable connected; the signal
|
||||||
* draggable resize handle for the patch bay, and drives a monophonic
|
* travels through whatever chain the user has assembled. The engine's
|
||||||
* synthesiser from the virtual keyboard and computer keyboard.
|
* AnalyserNode sits after the Output node, so the oscilloscope and spectrum
|
||||||
|
* always reflect exactly what feeds the Output.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import init, {
|
import init, {
|
||||||
@@ -17,9 +18,6 @@ import init, {
|
|||||||
} from "./pkg/synth_visualiser.js";
|
} from "./pkg/synth_visualiser.js";
|
||||||
|
|
||||||
// ── Canvas buffer sizing ──────────────────────────────────────────────────────
|
// ── Canvas buffer sizing ──────────────────────────────────────────────────────
|
||||||
// The canvas *attribute* (width/height) sets the drawing-buffer resolution.
|
|
||||||
// The CSS size only controls how it's displayed. We keep them in sync so the
|
|
||||||
// Rust code always draws at 1:1 pixels.
|
|
||||||
|
|
||||||
function fitCanvas(canvas) {
|
function fitCanvas(canvas) {
|
||||||
const w = Math.round(canvas.clientWidth);
|
const w = Math.round(canvas.clientWidth);
|
||||||
@@ -38,9 +36,7 @@ function initResizeHandle() {
|
|||||||
let dragging = false, startY = 0, startH = 0;
|
let dragging = false, startY = 0, startH = 0;
|
||||||
|
|
||||||
handle.addEventListener("pointerdown", e => {
|
handle.addEventListener("pointerdown", e => {
|
||||||
dragging = true;
|
dragging = true; startY = e.clientY; startH = panel.offsetHeight;
|
||||||
startY = e.clientY;
|
|
||||||
startH = panel.offsetHeight;
|
|
||||||
handle.setPointerCapture(e.pointerId);
|
handle.setPointerCapture(e.pointerId);
|
||||||
handle.classList.add("active");
|
handle.classList.add("active");
|
||||||
});
|
});
|
||||||
@@ -48,71 +44,302 @@ function initResizeHandle() {
|
|||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
panel.style.height = Math.max(80, startH - (e.clientY - startY)) + "px";
|
panel.style.height = Math.max(80, startH - (e.clientY - startY)) + "px";
|
||||||
});
|
});
|
||||||
const stopDrag = () => { dragging = false; handle.classList.remove("active"); };
|
const stop = () => { dragging = false; handle.classList.remove("active"); };
|
||||||
handle.addEventListener("pointerup", stopDrag);
|
handle.addEventListener("pointerup", stop);
|
||||||
handle.addEventListener("pointercancel", stopDrag);
|
handle.addEventListener("pointercancel", stop);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Monophonic synthesiser ────────────────────────────────────────────────────
|
// ── PatchRouter ───────────────────────────────────────────────────────────────
|
||||||
// Uses the Web Audio API directly. All notes are routed through the engine's
|
//
|
||||||
// input GainNode → AnalyserNode → speakers so the oscilloscope and spectrum
|
// Reads get_patch_json() whenever patch_version() changes, then rebuilds a
|
||||||
// analyser reflect the keyboard output.
|
// Web Audio graph that mirrors the visual patch bay topology.
|
||||||
|
//
|
||||||
|
// Module → Web Audio mapping:
|
||||||
|
// vco → OscillatorNode (sawtooth default; frequency driven by keyboard)
|
||||||
|
// svf → BiquadFilterNode (cutoff + Q from params; LFO can modulate frequency)
|
||||||
|
// vca → GainNode (gain from param; driven by ADSR envelope if patched)
|
||||||
|
// adsr → (no node) drives the VCA GainNode gain via parameter automation
|
||||||
|
// lfo → OscillatorNode + GainNode (low-freq oscillator modulating AudioParams)
|
||||||
|
// out → GainNode (level param) → masterEnv → engine analyser → speakers
|
||||||
|
//
|
||||||
|
// Signal only reaches the speakers when an "out" module has an audio_in cable.
|
||||||
|
|
||||||
function createSynth(audioCtx, inputNode) {
|
class PatchRouter {
|
||||||
let osc = null;
|
constructor(audioCtx, engineInputNode) {
|
||||||
let envGain = null;
|
this.ctx = audioCtx;
|
||||||
let noteCache = -1;
|
|
||||||
|
|
||||||
function midiToHz(n) {
|
// masterEnv: a final GainNode between the Out module and the engine
|
||||||
return 440 * Math.pow(2, (n - 69) / 12);
|
// input (analyser). When no ADSR envelope is in the chain, noteOn/Off
|
||||||
|
// drives this gain directly. When an ADSR drives a VCA, masterEnv
|
||||||
|
// stays at 1.0 and the VCA gain carries the envelope shape.
|
||||||
|
this.masterEnv = audioCtx.createGain();
|
||||||
|
this.masterEnv.gain.value = 0;
|
||||||
|
this.masterEnv.connect(engineInputNode);
|
||||||
|
|
||||||
|
this.nodes = new Map(); // moduleId → nodeInfo object
|
||||||
|
this.lastVersion = -1;
|
||||||
|
this.outHasCable = false; // true when Out.audio_in has a cable
|
||||||
|
this.hasAdsrVca = false; // true when a VCA is driven by ADSR
|
||||||
|
this.activeNote = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureRunning() {
|
// Call each frame. Rebuilds the audio graph only when the patch changes.
|
||||||
if (audioCtx.state !== "running") await audioCtx.resume();
|
sync(version, patchJson) {
|
||||||
|
if (version === this.lastVersion) return;
|
||||||
|
this.lastVersion = version;
|
||||||
|
this._rebuild(JSON.parse(patchJson));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
// Call each frame when params_version changes. Updates audio params in-place
|
||||||
async noteOn(midiNote) {
|
// without tearing down and rebuilding the Web Audio graph.
|
||||||
await ensureRunning();
|
updateParams(patchJson) {
|
||||||
if (midiNote === noteCache) return;
|
const patch = JSON.parse(patchJson);
|
||||||
|
for (const m of patch.modules) {
|
||||||
|
const info = this.nodes.get(m.id);
|
||||||
|
if (!info) continue;
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
switch (m.kind) {
|
||||||
|
case "vco": {
|
||||||
|
if (this.activeNote < 0) {
|
||||||
|
info.node.frequency.setTargetAtTime(m.params.freq_hz ?? 440, now, 0.01);
|
||||||
|
}
|
||||||
|
const wt = (["sine","sawtooth","square","triangle","sawtooth"])[Math.round(m.params.waveform ?? 1)] ?? "sawtooth";
|
||||||
|
if (info.node.type !== wt) info.node.type = wt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "svf":
|
||||||
|
info.node.frequency.setTargetAtTime(m.params.cutoff_hz ?? 2000, now, 0.01);
|
||||||
|
info.node.Q.setTargetAtTime(0.5 + (m.params.resonance ?? 0.5) * 19.5, now, 0.01);
|
||||||
|
break;
|
||||||
|
case "vca":
|
||||||
|
if (!info.adsrControlled) {
|
||||||
|
info.node.gain.setTargetAtTime(m.params.gain ?? 1.0, now, 0.01);
|
||||||
|
}
|
||||||
|
info.staticGain = m.params.gain ?? 1.0;
|
||||||
|
break;
|
||||||
|
case "adsr":
|
||||||
|
info.attack = m.params.attack_s ?? 0.01;
|
||||||
|
info.decay = m.params.decay_s ?? 0.1;
|
||||||
|
info.sustain = m.params.sustain ?? 0.7;
|
||||||
|
info.release = m.params.release_s ?? 0.3;
|
||||||
|
break;
|
||||||
|
case "lfo":
|
||||||
|
info.oscNode.frequency.setTargetAtTime(m.params.rate_hz ?? 2, now, 0.01);
|
||||||
|
info.node.gain.setTargetAtTime((m.params.depth ?? 0.5) * 600, now, 0.01);
|
||||||
|
break;
|
||||||
|
case "out":
|
||||||
|
info.node.gain.setTargetAtTime(m.params.level ?? 0.8, now, 0.01);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const now = audioCtx.currentTime;
|
// ── noteOn / noteOff ──────────────────────────────────────────────────────
|
||||||
if (!osc) {
|
|
||||||
// Fresh attack
|
noteOn(midiNote, retrigger = true) {
|
||||||
osc = audioCtx.createOscillator();
|
// Unblock AudioContext on first user gesture
|
||||||
envGain = audioCtx.createGain();
|
if (this.ctx.state !== "running") this.ctx.resume();
|
||||||
osc.type = "sawtooth";
|
|
||||||
osc.frequency.value = midiToHz(midiNote);
|
const wasActive = this.activeNote >= 0;
|
||||||
envGain.gain.setValueAtTime(0.0001, now);
|
this.activeNote = midiNote;
|
||||||
envGain.gain.exponentialRampToValueAtTime(0.3, now + 0.015);
|
if (!this.outHasCable) return;
|
||||||
osc.connect(envGain);
|
|
||||||
envGain.connect(inputNode);
|
const hz = this._midiHz(midiNote);
|
||||||
osc.start();
|
const now = this.ctx.currentTime;
|
||||||
|
|
||||||
|
// Update all VCO frequencies
|
||||||
|
for (const [, info] of this.nodes) {
|
||||||
|
if (info.kind !== "vco") continue;
|
||||||
|
if (wasActive && !retrigger) {
|
||||||
|
// Glide: smooth frequency transition, no envelope retrigger
|
||||||
|
info.node.frequency.setTargetAtTime(hz, now, 0.02);
|
||||||
} else {
|
} else {
|
||||||
// Glide to new pitch without retriggering the envelope
|
info.node.frequency.setValueAtTime(hz, now);
|
||||||
osc.frequency.setTargetAtTime(midiToHz(midiNote), now, 0.015);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!retrigger && wasActive) return; // glide: done
|
||||||
|
|
||||||
|
// Trigger envelope
|
||||||
|
if (this.hasAdsrVca) {
|
||||||
|
// ADSR-driven VCA: masterEnv is a pass-through at 1.0
|
||||||
|
this.masterEnv.gain.cancelScheduledValues(now);
|
||||||
|
this.masterEnv.gain.setValueAtTime(1.0, now);
|
||||||
|
|
||||||
|
for (const [, info] of this.nodes) {
|
||||||
|
if (info.kind !== "vca" || !info.adsrControlled) continue;
|
||||||
|
const adsr = this.nodes.get(info.adsrId);
|
||||||
|
if (!adsr) continue;
|
||||||
|
const g = info.node.gain;
|
||||||
|
g.cancelScheduledValues(now);
|
||||||
|
g.setValueAtTime(0.0001, now);
|
||||||
|
g.linearRampToValueAtTime(info.staticGain, now + adsr.attack);
|
||||||
|
g.linearRampToValueAtTime(info.staticGain * adsr.sustain, now + adsr.attack + adsr.decay);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No ADSR: simple attack gate on masterEnv
|
||||||
|
const g = this.masterEnv.gain;
|
||||||
|
g.cancelScheduledValues(now);
|
||||||
|
g.setValueAtTime(0.0001, now);
|
||||||
|
g.exponentialRampToValueAtTime(0.3, now + 0.015);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
noteCache = midiNote;
|
|
||||||
},
|
|
||||||
|
|
||||||
noteOff() {
|
noteOff() {
|
||||||
if (!osc) return;
|
this.activeNote = -1;
|
||||||
const now = audioCtx.currentTime;
|
const now = this.ctx.currentTime;
|
||||||
envGain.gain.cancelScheduledValues(now);
|
|
||||||
envGain.gain.setValueAtTime(envGain.gain.value, now);
|
|
||||||
envGain.gain.exponentialRampToValueAtTime(0.0001, now + 0.28);
|
|
||||||
const o = osc, g = envGain;
|
|
||||||
setTimeout(() => { try { o.stop(); o.disconnect(); g.disconnect(); } catch (_) {} }, 400);
|
|
||||||
osc = null; envGain = null; noteCache = -1;
|
|
||||||
},
|
|
||||||
|
|
||||||
activeNote() { return noteCache; },
|
if (this.hasAdsrVca) {
|
||||||
};
|
for (const [, info] of this.nodes) {
|
||||||
|
if (info.kind !== "vca" || !info.adsrControlled) continue;
|
||||||
|
const adsr = this.nodes.get(info.adsrId);
|
||||||
|
if (!adsr) continue;
|
||||||
|
const g = info.node.gain;
|
||||||
|
g.cancelScheduledValues(now);
|
||||||
|
g.setValueAtTime(Math.max(g.value, 0.0001), now);
|
||||||
|
g.exponentialRampToValueAtTime(0.0001, now + adsr.release);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const g = this.masterEnv.gain;
|
||||||
|
g.cancelScheduledValues(now);
|
||||||
|
g.setValueAtTime(Math.max(g.value, 0.0001), now);
|
||||||
|
g.exponentialRampToValueAtTime(0.0001, now + 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isOutputPatched() { return this.outHasCable; }
|
||||||
|
|
||||||
|
// ── Private ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_rebuild(patch) {
|
||||||
|
// Tear down: stop oscillators, disconnect all nodes
|
||||||
|
for (const [, info] of this.nodes) {
|
||||||
|
if (info.oscNode) { try { info.oscNode.stop(); info.oscNode.disconnect(); } catch(_){} }
|
||||||
|
if (info.node) { try { info.node.disconnect(); } catch(_){} }
|
||||||
|
}
|
||||||
|
this.nodes.clear();
|
||||||
|
|
||||||
|
// Create Web Audio nodes for each visual module
|
||||||
|
for (const m of patch.modules) {
|
||||||
|
const info = this._makeNode(m);
|
||||||
|
if (info) this.nodes.set(m.id, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine topology flags before wiring
|
||||||
|
this.outHasCable = patch.cables.some(c => {
|
||||||
|
const dst = patch.modules.find(m => m.id === c.dst);
|
||||||
|
return dst?.kind === "out" && c.dst_jack === "audio_in";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark VCAs that have an ADSR patched into their cv_in
|
||||||
|
for (const c of patch.cables) {
|
||||||
|
if (c.dst_jack !== "cv_in") continue;
|
||||||
|
const srcMod = patch.modules.find(m => m.id === c.src);
|
||||||
|
const dstInfo = this.nodes.get(c.dst);
|
||||||
|
if (srcMod?.kind === "adsr" && dstInfo?.kind === "vca") {
|
||||||
|
dstInfo.adsrControlled = true;
|
||||||
|
dstInfo.adsrId = c.src;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.hasAdsrVca = [...this.nodes.values()].some(n => n.kind === "vca" && n.adsrControlled);
|
||||||
|
|
||||||
|
// Wire audio and CV cables
|
||||||
|
for (const c of patch.cables) this._wire(c, patch);
|
||||||
|
|
||||||
|
// If a note is held across the rebuild, reapply it
|
||||||
|
if (this.activeNote >= 0 && this.outHasCable) {
|
||||||
|
this.noteOn(this.activeNote, true);
|
||||||
|
} else if (!this.outHasCable) {
|
||||||
|
// Ensure silence immediately when output becomes unpatched
|
||||||
|
this.masterEnv.gain.cancelScheduledValues(0);
|
||||||
|
this.masterEnv.gain.setValueAtTime(0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_makeNode(m) {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
switch (m.kind) {
|
||||||
|
case "vco": {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = (["sine","sawtooth","square","triangle","sawtooth"])[Math.round(m.params.waveform ?? 1)] ?? "sawtooth";
|
||||||
|
osc.frequency.value = m.params.freq_hz ?? 440;
|
||||||
|
osc.start();
|
||||||
|
return { kind: "vco", node: osc, oscNode: osc };
|
||||||
|
}
|
||||||
|
case "svf": {
|
||||||
|
const f = ctx.createBiquadFilter();
|
||||||
|
f.type = "lowpass";
|
||||||
|
f.frequency.value = m.params.cutoff_hz ?? 2000;
|
||||||
|
f.Q.value = 0.5 + (m.params.resonance ?? 0.5) * 19.5;
|
||||||
|
return { kind: "svf", node: f };
|
||||||
|
}
|
||||||
|
case "vca": {
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.value = m.params.gain ?? 1.0;
|
||||||
|
return { kind: "vca", node: g, adsrControlled: false, adsrId: null,
|
||||||
|
staticGain: m.params.gain ?? 1.0 };
|
||||||
|
}
|
||||||
|
case "adsr": {
|
||||||
|
// Pure data — drives VCA gain automation in noteOn/Off, no Web Audio node
|
||||||
|
return { kind: "adsr", node: null,
|
||||||
|
attack: m.params.attack_s ?? 0.01,
|
||||||
|
decay: m.params.decay_s ?? 0.1,
|
||||||
|
sustain: m.params.sustain ?? 0.7,
|
||||||
|
release: m.params.release_s ?? 0.3 };
|
||||||
|
}
|
||||||
|
case "lfo": {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = "sine";
|
||||||
|
osc.frequency.value = m.params.rate_hz ?? 2;
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
gain.gain.value = (m.params.depth ?? 0.5) * 600; // modulation depth in Hz
|
||||||
|
osc.connect(gain);
|
||||||
|
osc.start();
|
||||||
|
return { kind: "lfo", node: gain, oscNode: osc };
|
||||||
|
}
|
||||||
|
case "out": {
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.value = m.params.level ?? 0.8;
|
||||||
|
g.connect(this.masterEnv); // always routes to the analyser chain
|
||||||
|
return { kind: "out", node: g };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_wire(cable, patch) {
|
||||||
|
const src = this.nodes.get(cable.src);
|
||||||
|
const dst = this.nodes.get(cable.dst);
|
||||||
|
if (!src?.node || !dst?.node) return; // ADSR has node:null — skip
|
||||||
|
|
||||||
|
// ── Audio signal cables ────────────────────────────────────────────
|
||||||
|
if (cable.dst_jack === "audio_in") {
|
||||||
|
try { src.node.connect(dst.node); } catch(_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CV modulation cables ───────────────────────────────────────────
|
||||||
|
if (cable.dst_jack === "cv_in") {
|
||||||
|
if (src.kind === "lfo") {
|
||||||
|
if (dst.kind === "svf") {
|
||||||
|
// LFO modulates filter cutoff frequency
|
||||||
|
try { src.node.connect(dst.node.frequency); } catch(_) {}
|
||||||
|
} else if (dst.kind === "vco") {
|
||||||
|
// LFO modulates VCO pitch (vibrato)
|
||||||
|
try { src.node.connect(dst.node.frequency); } catch(_) {}
|
||||||
|
}
|
||||||
|
// LFO → VCA: not wired (VCA cv_in is for ADSR envelope only)
|
||||||
|
}
|
||||||
|
// ADSR → VCA: handled by parameter automation in noteOn/Off, not a node connection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_midiHz(note) {
|
||||||
|
return 440 * Math.pow(2, (note - 69) / 12);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Computer keyboard map (standard DAW layout) ───────────────────────────────
|
// ── Computer keyboard map (standard DAW layout) ───────────────────────────────
|
||||||
// Lower row Z–M → C3–B3, with S D G H J for sharps
|
|
||||||
// Upper row Q–U → C4–B4, with 2 3 5 6 7 for sharps
|
|
||||||
|
|
||||||
const KEY_NOTE = {
|
const KEY_NOTE = {
|
||||||
z:48, s:49, x:50, d:51, c:52, v:53, g:54, b:55, h:56, n:57, j:58, m:59,
|
z:48, s:49, x:50, d:51, c:52, v:53, g:54, b:55, h:56, n:57, j:58, m:59,
|
||||||
@@ -150,17 +377,16 @@ async function bootstrap() {
|
|||||||
const allCanvases = [oscCanvas, spCanvas, pbCanvas, kbCanvas];
|
const allCanvases = [oscCanvas, spCanvas, pbCanvas, kbCanvas];
|
||||||
|
|
||||||
allCanvases.forEach(fitCanvas);
|
allCanvases.forEach(fitCanvas);
|
||||||
|
|
||||||
// Keep buffers in sync when panels are resized
|
|
||||||
const ro = new ResizeObserver(() => allCanvases.forEach(fitCanvas));
|
const ro = new ResizeObserver(() => allCanvases.forEach(fitCanvas));
|
||||||
allCanvases.forEach(c => ro.observe(c));
|
allCanvases.forEach(c => ro.observe(c));
|
||||||
|
|
||||||
// ── Default patch bay layout ──────────────────────────────────────────
|
// ── Default patch bay layout ──────────────────────────────────────────
|
||||||
const cw = pbCanvas.width || pbCanvas.clientWidth || 800;
|
const cw = pbCanvas.width || pbCanvas.clientWidth || 800;
|
||||||
patchbay.add_module("vco", cw * 0.12, 80);
|
patchbay.add_module("vco", cw * 0.08, 80);
|
||||||
patchbay.add_module("adsr", cw * 0.32, 80);
|
patchbay.add_module("adsr", cw * 0.26, 80);
|
||||||
patchbay.add_module("svf", cw * 0.55, 80);
|
patchbay.add_module("svf", cw * 0.46, 80);
|
||||||
patchbay.add_module("vca", cw * 0.76, 80);
|
patchbay.add_module("vca", cw * 0.66, 80);
|
||||||
|
patchbay.add_module("out", cw * 0.84, 80);
|
||||||
|
|
||||||
// ── Patch bay pointer events ──────────────────────────────────────────
|
// ── 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));
|
||||||
@@ -168,40 +394,37 @@ async function bootstrap() {
|
|||||||
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));
|
pbCanvas.addEventListener("dblclick", e => patchbay.on_double_click(e.offsetX, e.offsetY));
|
||||||
|
|
||||||
// ── Synthesiser ───────────────────────────────────────────────────────
|
// ── Patch router: Web Audio graph driven by patch bay topology ────────
|
||||||
const audioCtx = engine.audio_context();
|
const audioCtx = engine.audio_context();
|
||||||
const inputNode = engine.input_node();
|
const inputNode = engine.input_node();
|
||||||
const synth = createSynth(audioCtx, inputNode);
|
const router = new PatchRouter(audioCtx, inputNode);
|
||||||
|
|
||||||
// ── Virtual keyboard pointer events ───────────────────────────────────
|
// ── Virtual keyboard pointer events ───────────────────────────────────
|
||||||
let pointerDown = false;
|
let kbDown = false;
|
||||||
|
|
||||||
kbCanvas.addEventListener("pointerdown", e => {
|
kbCanvas.addEventListener("pointerdown", e => {
|
||||||
pointerDown = true;
|
kbDown = true;
|
||||||
kbCanvas.setPointerCapture(e.pointerId);
|
kbCanvas.setPointerCapture(e.pointerId);
|
||||||
const note = keyboard.on_pointer_down(e.offsetX, e.offsetY);
|
const note = keyboard.on_pointer_down(e.offsetX, e.offsetY);
|
||||||
if (note >= 0) synth.noteOn(note);
|
if (note >= 0) router.noteOn(note, true);
|
||||||
else synth.noteOff();
|
else router.noteOff();
|
||||||
});
|
});
|
||||||
|
|
||||||
kbCanvas.addEventListener("pointermove", e => {
|
kbCanvas.addEventListener("pointermove", e => {
|
||||||
const result = keyboard.on_pointer_move(e.offsetX, e.offsetY);
|
const result = keyboard.on_pointer_move(e.offsetX, e.offsetY);
|
||||||
if (result === -2) return; // hover only, no synthesis change
|
if (result === -2) return; // hover only
|
||||||
if (pointerDown) {
|
if (kbDown) {
|
||||||
if (result >= 0) synth.noteOn(result); // noteOn handles glide
|
if (result >= 0) router.noteOn(result, false); // glide
|
||||||
else synth.noteOff();
|
else router.noteOff();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
kbCanvas.addEventListener("pointerup", e => {
|
kbCanvas.addEventListener("pointerup", e => {
|
||||||
pointerDown = false;
|
kbDown = false;
|
||||||
keyboard.on_pointer_up(e.offsetX, e.offsetY);
|
keyboard.on_pointer_up(e.offsetX, e.offsetY);
|
||||||
synth.noteOff();
|
router.noteOff();
|
||||||
});
|
});
|
||||||
|
|
||||||
kbCanvas.addEventListener("pointerleave", () => {
|
kbCanvas.addEventListener("pointerleave", () => {
|
||||||
const result = keyboard.on_pointer_leave();
|
if (kbDown) { kbDown = false; keyboard.on_pointer_leave(); router.noteOff(); }
|
||||||
if (result === -1) synth.noteOff(); // was pressing
|
else { keyboard.on_pointer_leave(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Computer keyboard events ──────────────────────────────────────────
|
// ── Computer keyboard events ──────────────────────────────────────────
|
||||||
@@ -213,26 +436,20 @@ async function bootstrap() {
|
|||||||
if (note === undefined) return;
|
if (note === undefined) return;
|
||||||
heldKeys.add(e.key.toLowerCase());
|
heldKeys.add(e.key.toLowerCase());
|
||||||
keyboard.set_active_note(note);
|
keyboard.set_active_note(note);
|
||||||
synth.noteOn(note);
|
router.noteOn(note, !heldKeys.size > 1); // retrigger only if first key
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("keyup", e => {
|
document.addEventListener("keyup", e => {
|
||||||
const key = e.key.toLowerCase();
|
const key = e.key.toLowerCase();
|
||||||
heldKeys.delete(key);
|
heldKeys.delete(key);
|
||||||
const note = KEY_NOTE[key];
|
if (KEY_NOTE[key] === undefined) return;
|
||||||
if (note === undefined) return;
|
const remaining = [...heldKeys].filter(k => KEY_NOTE[k] !== undefined);
|
||||||
|
if (remaining.length > 0) {
|
||||||
// Only stop if no other mapped key is still held
|
const last = KEY_NOTE[remaining.at(-1)];
|
||||||
const anyHeld = [...heldKeys].some(k => KEY_NOTE[k] !== undefined);
|
keyboard.set_active_note(last);
|
||||||
if (!anyHeld) {
|
router.noteOn(last, false); // glide to last held key
|
||||||
keyboard.set_active_note(-1);
|
|
||||||
synth.noteOff();
|
|
||||||
} else {
|
} else {
|
||||||
// Switch to the last remaining held key
|
keyboard.set_active_note(-1);
|
||||||
const lastKey = [...heldKeys].filter(k => KEY_NOTE[k] !== undefined).at(-1);
|
router.noteOff();
|
||||||
const lastNote = KEY_NOTE[lastKey];
|
|
||||||
keyboard.set_active_note(lastNote);
|
|
||||||
synth.noteOn(lastNote);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -246,7 +463,22 @@ async function bootstrap() {
|
|||||||
|
|
||||||
// ── Render loop ───────────────────────────────────────────────────────
|
// ── Render loop ───────────────────────────────────────────────────────
|
||||||
let last = performance.now();
|
let last = performance.now();
|
||||||
|
let lastParamsVersion = -1;
|
||||||
function frame(now) {
|
function frame(now) {
|
||||||
|
// Sync Web Audio graph to patch bay topology when patch changes
|
||||||
|
router.sync(patchbay.patch_version(), patchbay.get_patch_json());
|
||||||
|
|
||||||
|
// Apply live param changes without full graph rebuild
|
||||||
|
const pv = patchbay.params_version();
|
||||||
|
if (pv !== lastParamsVersion) {
|
||||||
|
lastParamsVersion = pv;
|
||||||
|
router.updateParams(patchbay.get_patch_json());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update output-node status label
|
||||||
|
const outLabel = router.isOutputPatched() ? "patched" : "unpatched";
|
||||||
|
status.textContent = router.isOutputPatched() ? "Running · output patched" : "Running · output unpatched";
|
||||||
|
|
||||||
oscilloscope.draw();
|
oscilloscope.draw();
|
||||||
spectrum.draw();
|
spectrum.draw();
|
||||||
patchbay.draw();
|
patchbay.draw();
|
||||||
@@ -259,12 +491,12 @@ async function bootstrap() {
|
|||||||
|
|
||||||
loader.classList.add("hidden");
|
loader.classList.add("hidden");
|
||||||
|
|
||||||
// Show keyboard hint briefly then fade it
|
// Brief keyboard hint
|
||||||
const hint = document.getElementById("kb-hint");
|
const hint = document.getElementById("kb-hint");
|
||||||
if (hint) {
|
if (hint) {
|
||||||
hint.style.transition = "opacity 1s";
|
hint.style.transition = "opacity 1s";
|
||||||
setTimeout(() => { hint.style.opacity = "1"; }, 2000);
|
setTimeout(() => { hint.style.opacity = "1"; }, 2000);
|
||||||
setTimeout(() => { hint.style.opacity = "0"; }, 6000);
|
setTimeout(() => { hint.style.opacity = "0"; }, 7000);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user