From ea381fe4337e0a4bea82b1d9463448aa3f550cde Mon Sep 17 00:00:00 2001 From: Matt Spencer Date: Fri, 27 Mar 2026 08:29:11 +0000 Subject: [PATCH] Add popup help to elements --- .claude/settings.json | 3 +- crates/synth-core/src/audio_out.rs | 10 +++ crates/synth-core/src/descriptor.rs | 2 + crates/synth-core/src/envelope.rs | 13 ++++ crates/synth-core/src/filter.rs | 10 +++ crates/synth-core/src/lfo.rs | 12 ++++ crates/synth-core/src/oscillator.rs | 10 +++ crates/synth-core/src/vca.rs | 10 +++ crates/synth-visualiser/src/patchbay.rs | 41 +++++++++++- www/bootstrap.js | 86 ++++++++++++++++++++++++- www/index.html | 40 ++++++++++++ 11 files changed, 233 insertions(+), 4 deletions(-) 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(`

${esc(line.slice(3))}

`); + } else if (line.startsWith("> ")) { + if (inUl) { out.push(""); inUl = false; } + out.push(`
${inline(line.slice(2))}
`); + } else if (line.startsWith("- ")) { + if (!inUl) { out.push(""); inUl = false; } + if (line === "") { + out.push("
"); + } else { + 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; + }
Loading WASM module…
+
Analogue Synth Visualiser