Add popup help to elements
This commit is contained in:
@@ -10,7 +10,8 @@
|
|||||||
"Bash(/Users/mattsp/projects/sound/build-web.sh)",
|
"Bash(/Users/mattsp/projects/sound/build-web.sh)",
|
||||||
"Bash(wasm-pack build:*)",
|
"Bash(wasm-pack build:*)",
|
||||||
"Read(//Users/mattsp/projects/sound/**)",
|
"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]\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ impl AudioOut {
|
|||||||
params: &[
|
params: &[
|
||||||
ParamDescriptor { id: "level", label: "Level", min: 0.0, max: 1.0, default: 0.8, unit: "", labels: &[] },
|
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.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,4 +71,6 @@ pub struct ComponentDescriptor {
|
|||||||
pub jacks: &'static [JackDescriptor],
|
pub jacks: &'static [JackDescriptor],
|
||||||
/// Ordered list of tunable parameters.
|
/// Ordered list of tunable parameters.
|
||||||
pub params: &'static [ParamDescriptor],
|
pub params: &'static [ParamDescriptor],
|
||||||
|
/// Markdown-formatted help text shown in the patch-bay tooltip.
|
||||||
|
pub description: &'static str,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,19 @@ impl Adsr {
|
|||||||
ParamDescriptor { id: "sustain", label: "Sustain", min: 0.0, max: 1.0, default: 0.7, unit: "", labels: &[] },
|
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: &[] },
|
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; }
|
pub fn gate_on(&mut self) { self.stage = Stage::Attack; }
|
||||||
|
|||||||
@@ -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: "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: &[] },
|
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]
|
#[inline]
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ impl Lfo {
|
|||||||
ParamDescriptor { id: "depth", label: "Depth", min: 0.0, max: 1.0, default: 0.5, unit: "", labels: &[] },
|
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"] },
|
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]
|
#[inline]
|
||||||
|
|||||||
@@ -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: "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"] },
|
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]
|
#[inline]
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ impl Vca {
|
|||||||
params: &[
|
params: &[
|
||||||
ParamDescriptor { id: "gain", label: "Gain", min: 0.0, max: 1.0, default: 1.0, unit: "", labels: &[] },
|
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",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,13 @@ impl Module {
|
|||||||
&& y >= self.y && y <= self.y + MOD_HEADER
|
&& 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 {
|
fn hit_body(&self, x: f32, y: f32) -> bool {
|
||||||
x >= self.x && x <= self.x + MOD_W
|
x >= self.x && x <= self.x + MOD_W
|
||||||
&& y >= self.y && y <= self.y + self.height()
|
&& 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.
|
/// Double-click removes the topmost module under the cursor.
|
||||||
pub fn on_double_click(&mut self, x: f32, y: f32) {
|
pub fn on_double_click(&mut self, x: f32, y: f32) {
|
||||||
if y < PALETTE_H { return; }
|
if y < PALETTE_H { return; }
|
||||||
@@ -685,7 +704,25 @@ impl PatchBay {
|
|||||||
(m.y + MOD_HEADER * 0.70) as f64,
|
(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
|
// Output node: LIVE / UNPATCHED badge on the right of the header
|
||||||
|
// (shifted left to clear the ? badge)
|
||||||
if is_out {
|
if is_out {
|
||||||
if has_signal {
|
if has_signal {
|
||||||
ctx.set_fill_style_str("#22c55e");
|
ctx.set_fill_style_str("#22c55e");
|
||||||
@@ -693,7 +730,7 @@ impl PatchBay {
|
|||||||
ctx.set_text_align("right");
|
ctx.set_text_align("right");
|
||||||
let _ = ctx.fill_text(
|
let _ = ctx.fill_text(
|
||||||
"● LIVE",
|
"● 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,
|
(m.y + MOD_HEADER * 0.70) as f64,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -702,7 +739,7 @@ impl PatchBay {
|
|||||||
ctx.set_text_align("right");
|
ctx.set_text_align("right");
|
||||||
let _ = ctx.fill_text(
|
let _ = ctx.fill_text(
|
||||||
"unpatched",
|
"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,
|
(m.y + MOD_HEADER * 0.70) as f64,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
86
www/bootstrap.js
vendored
86
www/bootstrap.js
vendored
@@ -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("</ul>"); inUl = false; }
|
||||||
|
out.push(`<h2>${esc(line.slice(3))}</h2>`);
|
||||||
|
} else if (line.startsWith("> ")) {
|
||||||
|
if (inUl) { out.push("</ul>"); inUl = false; }
|
||||||
|
out.push(`<blockquote>${inline(line.slice(2))}</blockquote>`);
|
||||||
|
} else if (line.startsWith("- ")) {
|
||||||
|
if (!inUl) { out.push("<ul>"); inUl = true; }
|
||||||
|
out.push(`<li>${inline(line.slice(2))}</li>`);
|
||||||
|
} else {
|
||||||
|
if (inUl) { out.push("</ul>"); inUl = false; }
|
||||||
|
if (line === "") {
|
||||||
|
out.push("<br>");
|
||||||
|
} else {
|
||||||
|
out.push(`<p>${inline(line)}</p>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inUl) out.push("</ul>");
|
||||||
|
return out.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function inline(s) {
|
||||||
|
// `code`, **bold**
|
||||||
|
return esc(s)
|
||||||
|
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ─────────────────────────────────────────────────────────────
|
// ── Resize handle ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function initResizeHandle() {
|
function initResizeHandle() {
|
||||||
@@ -434,8 +514,12 @@ async function bootstrap() {
|
|||||||
|
|
||||||
// ── 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));
|
||||||
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("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));
|
pbCanvas.addEventListener("dblclick", e => patchbay.on_double_click(e.offsetX, e.offsetY));
|
||||||
|
|
||||||
// ── Patch router: Web Audio graph driven by patch bay topology ────────
|
// ── Patch router: Web Audio graph driven by patch bay topology ────────
|
||||||
|
|||||||
@@ -151,10 +151,50 @@
|
|||||||
z-index: 100; transition: opacity 0.4s;
|
z-index: 100; transition: opacity 0.4s;
|
||||||
}
|
}
|
||||||
#loader.hidden { opacity: 0; pointer-events: none; }
|
#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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="loader">Loading WASM module…</div>
|
<div id="loader">Loading WASM module…</div>
|
||||||
|
<div id="tooltip"></div>
|
||||||
|
|
||||||
<header>Analogue Synth Visualiser</header>
|
<header>Analogue Synth Visualiser</header>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user