diff --git a/.claude/settings.json b/.claude/settings.json index 161080f..0bbf442 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -10,7 +10,8 @@ "Bash(/Users/mattsp/projects/sound/build-web.sh)", "Bash(wasm-pack build:*)", "Read(//Users/mattsp/projects/sound/**)", - "Bash(./build-web.sh)" + "Bash(./build-web.sh)", + "Bash(python3 -c \"import sys,json; [print\\(json.loads\\(l\\).keys\\(\\)\\) for l in sys.stdin]\")" ] } } diff --git a/crates/synth-core/src/audio_out.rs b/crates/synth-core/src/audio_out.rs index 8197900..fc07528 100644 --- a/crates/synth-core/src/audio_out.rs +++ b/crates/synth-core/src/audio_out.rs @@ -25,6 +25,16 @@ impl AudioOut { params: &[ ParamDescriptor { id: "level", label: "Level", min: 0.0, max: 1.0, default: 0.8, unit: "", labels: &[] }, ], + description: "\ +## Output — Audio Sink + +The final destination in the signal chain, representing the speakers or DAC. + +Connect the last audio module's output jack to **In**. The green **● LIVE** badge lights up when a cable is connected and audio is flowing. + +**Level** controls the master output volume. + +> The oscilloscope and spectrum analyser always reflect what arrives at this node.", }; } diff --git a/crates/synth-core/src/descriptor.rs b/crates/synth-core/src/descriptor.rs index 5eef161..ba4ab0b 100644 --- a/crates/synth-core/src/descriptor.rs +++ b/crates/synth-core/src/descriptor.rs @@ -71,4 +71,6 @@ pub struct ComponentDescriptor { pub jacks: &'static [JackDescriptor], /// Ordered list of tunable parameters. pub params: &'static [ParamDescriptor], + /// Markdown-formatted help text shown in the patch-bay tooltip. + pub description: &'static str, } diff --git a/crates/synth-core/src/envelope.rs b/crates/synth-core/src/envelope.rs index 0019d66..91c7f8a 100644 --- a/crates/synth-core/src/envelope.rs +++ b/crates/synth-core/src/envelope.rs @@ -39,6 +39,19 @@ impl Adsr { 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", labels: &[] }, ], + description: "\ +## ADSR — Envelope Generator + +Produces a CV signal (0–1) that shapes amplitude over time in four stages: + +- **Attack** — time to rise from 0 to peak after gate opens +- **Decay** — time to fall from peak to sustain level +- **Sustain** — level held while gate remains open +- **Release** — time to fall back to 0 after gate closes + +**This module outputs CV, not audio.** Connect **Env** to a VCA `cv_gain` jack to shape volume, or to `cv_cutoff_hz` on the Filter for a filter envelope. + +> **Tip:** VCO Out → VCA In, Keyboard Gate → ADSR Gate, ADSR Env → VCA cv_gain, VCA Out → Output In", }; pub fn gate_on(&mut self) { self.stage = Stage::Attack; } diff --git a/crates/synth-core/src/filter.rs b/crates/synth-core/src/filter.rs index 67362ed..6aceb97 100644 --- a/crates/synth-core/src/filter.rs +++ b/crates/synth-core/src/filter.rs @@ -42,6 +42,16 @@ impl Svf { 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: "", labels: &[] }, ], + description: "\ +## Filter (SVF) — State-Variable Filter + +2-pole filter that shapes the tonal colour of an audio signal. + +**In** accepts audio; **Out** emits the filtered result. + +**Cutoff** sets the corner frequency — frequencies above this point are attenuated (in low-pass mode). **Res** (resonance) narrows the peak; at 1.0 the filter self-oscillates. + +Connect an LFO to `cv_cutoff_hz` for a sweeping wah effect, or an ADSR for a filter envelope.", }; #[inline] diff --git a/crates/synth-core/src/lfo.rs b/crates/synth-core/src/lfo.rs index 5e0e366..ff7b09b 100644 --- a/crates/synth-core/src/lfo.rs +++ b/crates/synth-core/src/lfo.rs @@ -31,6 +31,18 @@ impl Lfo { 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: "", labels: &["Sine", "Saw", "Sqr", "Tri", "Pls"] }, ], + description: "\ +## LFO — Low-Frequency Oscillator + +Like a VCO but running at sub-audio speeds (0.01–20 Hz). Outputs a CV signal (−1 to +1) for modulation. + +**Rate** controls oscillation speed. **Depth** scales the output amplitude (0 = no modulation, 1 = full swing). + +**Typical uses:** + +- LFO Out → VCO `cv_freq_hz` for **vibrato** (pitch wobble) +- LFO Out → Filter `cv_cutoff_hz` for a **wah/sweep** effect +- LFO Out → VCA `cv_gain` for **tremolo** (volume wobble)", }; #[inline] diff --git a/crates/synth-core/src/oscillator.rs b/crates/synth-core/src/oscillator.rs index 1e1c4a1..af1ce2b 100644 --- a/crates/synth-core/src/oscillator.rs +++ b/crates/synth-core/src/oscillator.rs @@ -37,6 +37,16 @@ impl Vco { 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: "", labels: &["Sine", "Saw", "Sqr", "Tri", "Pls"] }, ], + description: "\ +## VCO — Voltage-Controlled Oscillator + +Generates a periodic audio-rate waveform at the set frequency. + +**Waveforms:** Sine · Saw · Square · Triangle · Pulse + +**Freq** sets the base pitch. Connect an LFO or keyboard CV to `cv_freq_hz` for 1 V/oct pitch modulation (0 V = 440 Hz, +1 V = 880 Hz). + +**Typical chain:** VCO Out → Filter In → VCA In → Output In", }; #[inline] diff --git a/crates/synth-core/src/vca.rs b/crates/synth-core/src/vca.rs index f9c6a1a..63df4b7 100644 --- a/crates/synth-core/src/vca.rs +++ b/crates/synth-core/src/vca.rs @@ -22,6 +22,16 @@ impl Vca { params: &[ ParamDescriptor { id: "gain", label: "Gain", min: 0.0, max: 1.0, default: 1.0, unit: "", labels: &[] }, ], + description: "\ +## VCA — Voltage-Controlled Amplifier + +Scales the audio level of a signal. Audio enters on **In** and exits at a controlled volume on **Out**. + +**Gain** knob sets the base level (0 = silence, 1 = unity gain). + +Connect an ADSR **Env** output to the `cv_gain` jack to apply an amplitude envelope — the CV value multiplies with the knob gain each sample. + +**Typical chain:** VCO Out → VCA In → Output In, with ADSR Env → VCA cv_gain", }; } diff --git a/crates/synth-visualiser/src/patchbay.rs b/crates/synth-visualiser/src/patchbay.rs index d54a2ca..cc272ba 100644 --- a/crates/synth-visualiser/src/patchbay.rs +++ b/crates/synth-visualiser/src/patchbay.rs @@ -141,6 +141,13 @@ impl Module { && y >= self.y && y <= self.y + MOD_HEADER } + /// Hit-test the `?` help badge drawn in the top-right of the header. + fn hit_help_badge(&self, x: f32, y: f32) -> bool { + let bx = self.x + MOD_W - 10.0; + let by = self.y + MOD_HEADER / 2.0; + hypot(x - bx, y - by) <= 9.0 + } + 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() @@ -490,6 +497,18 @@ impl PatchBay { } } + /// Returns the markdown description for the module whose `?` badge is at + /// (x, y), or an empty string if none is hovered. JS calls this on every + /// `mousemove` over the patch bay canvas to drive the tooltip overlay. + pub fn get_tooltip_at(&self, x: f32, y: f32) -> String { + for m in self.modules.iter().rev() { + if m.hit_help_badge(x, y) { + return m.descriptor().description.to_string(); + } + } + String::new() + } + /// 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; } @@ -685,7 +704,25 @@ impl PatchBay { (m.y + MOD_HEADER * 0.70) as f64, ); + // ? help badge — top-right corner of every module header + { + let bx = (m.x + MOD_W - 10.0) as f64; + let by = (m.y + MOD_HEADER / 2.0) as f64; + ctx.begin_path(); + let _ = ctx.arc(bx, by, 7.0, 0.0, core::f64::consts::TAU); + ctx.set_fill_style_str("rgba(0,0,0,0.30)"); + ctx.fill(); + ctx.set_stroke_style_str("rgba(255,255,255,0.25)"); + ctx.set_line_width(1.0); + ctx.stroke(); + ctx.set_fill_style_str("rgba(255,255,255,0.70)"); + ctx.set_font("bold 9px sans-serif"); + ctx.set_text_align("center"); + let _ = ctx.fill_text("?", bx, by + 3.5); + } + // Output node: LIVE / UNPATCHED badge on the right of the header + // (shifted left to clear the ? badge) if is_out { if has_signal { ctx.set_fill_style_str("#22c55e"); @@ -693,7 +730,7 @@ impl PatchBay { ctx.set_text_align("right"); let _ = ctx.fill_text( "● LIVE", - (m.x + MOD_W - 6.0) as f64, + (m.x + MOD_W - 24.0) as f64, (m.y + MOD_HEADER * 0.70) as f64, ); } else { @@ -702,7 +739,7 @@ impl PatchBay { ctx.set_text_align("right"); let _ = ctx.fill_text( "unpatched", - (m.x + MOD_W - 6.0) as f64, + (m.x + MOD_W - 24.0) as f64, (m.y + MOD_HEADER * 0.70) as f64, ); } diff --git a/www/bootstrap.js b/www/bootstrap.js index 64037c5..9711064 100644 --- a/www/bootstrap.js +++ b/www/bootstrap.js @@ -28,6 +28,86 @@ function fitCanvas(canvas) { } } +// ── Tooltip helpers ─────────────────────────────────────────────────────────── + +/** Minimal markdown → HTML converter for the module description tooltips. */ +function mdToHtml(md) { + const lines = md.split("\n"); + const out = []; + let inUl = false; + + for (const raw of lines) { + const line = raw.trimEnd(); + + if (line.startsWith("## ")) { + if (inUl) { out.push(""); inUl = false; } + out.push(`
${inline(line.slice(2))}`); + } else if (line.startsWith("- ")) { + if (!inUl) { out.push("
${inline(line)}
`); + } + } + } + if (inUl) out.push(""); + return out.join(""); +} + +function esc(s) { + return s.replace(/&/g,"&").replace(//g,">"); +} + +function inline(s) { + // `code`, **bold** + return esc(s) + .replace(/`([^`]+)`/g, "$1")
+ .replace(/\*\*([^*]+)\*\*/g, "$1");
+}
+
+const _tooltip = { el: null };
+
+function getTooltipEl() {
+ if (!_tooltip.el) _tooltip.el = document.getElementById("tooltip");
+ return _tooltip.el;
+}
+
+function updateTooltip(clientX, clientY, markdown) {
+ const el = getTooltipEl();
+ if (!el) return;
+ if (!markdown) { hideTooltip(); return; }
+
+ el.innerHTML = mdToHtml(markdown);
+ el.style.display = "block";
+
+ // Position: prefer right of cursor, flip left if off-screen
+ const margin = 14;
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+ let left = clientX + margin;
+ let top = clientY + margin;
+ el.style.left = "0";
+ el.style.top = "0";
+ const tw = el.offsetWidth;
+ const th = el.offsetHeight;
+ if (left + tw > vw - 4) left = clientX - tw - margin;
+ if (top + th > vh - 4) top = clientY - th - margin;
+ el.style.left = left + "px";
+ el.style.top = top + "px";
+}
+
+function hideTooltip() {
+ const el = getTooltipEl();
+ if (el) el.style.display = "none";
+}
+
// ── Resize handle ─────────────────────────────────────────────────────────────
function initResizeHandle() {
@@ -434,8 +514,12 @@ async function bootstrap() {
// ── Patch bay pointer events ──────────────────────────────────────────
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);
+ updateTooltip(e.clientX, e.clientY, patchbay.get_tooltip_at(e.offsetX, e.offsetY));
+ });
pbCanvas.addEventListener("pointerup", e => patchbay.on_pointer_up(e.offsetX, e.offsetY));
+ pbCanvas.addEventListener("pointerleave", () => hideTooltip());
pbCanvas.addEventListener("dblclick", e => patchbay.on_double_click(e.offsetX, e.offsetY));
// ── Patch router: Web Audio graph driven by patch bay topology ────────
diff --git a/www/index.html b/www/index.html
index 89b7e67..20eb99c 100644
--- a/www/index.html
+++ b/www/index.html
@@ -151,10 +151,50 @@
z-index: 100; transition: opacity 0.4s;
}
#loader.hidden { opacity: 0; pointer-events: none; }
+
+ #tooltip {
+ position: fixed;
+ z-index: 200;
+ max-width: 320px;
+ background: #1a1e2c;
+ border: 1px solid #3b4560;
+ border-radius: 8px;
+ padding: 12px 14px;
+ font-size: 0.72rem;
+ line-height: 1.65;
+ color: #c8d0e0;
+ pointer-events: none;
+ box-shadow: 0 6px 28px rgba(0,0,0,0.75);
+ display: none;
+ }
+ #tooltip h2 {
+ font-size: 0.78rem;
+ color: #e8eaf6;
+ margin-bottom: 6px;
+ letter-spacing: 0.04em;
+ }
+ #tooltip strong { color: #a5d8ff; }
+ #tooltip code {
+ background: rgba(255,255,255,0.08);
+ border-radius: 3px;
+ padding: 0 3px;
+ font-size: 0.68rem;
+ color: #94e2d5;
+ }
+ #tooltip blockquote {
+ border-left: 2px solid #3b4560;
+ margin: 6px 0 0;
+ padding-left: 8px;
+ color: #8899aa;
+ }
+ #tooltip ul {
+ margin: 4px 0 0 14px;
+ }