From c404b3f82d5e0a5d06ed78ab2c545981c0be7e4d Mon Sep 17 00:00:00 2001 From: Dreamodus Date: Sun, 12 Jul 2026 19:44:42 -0700 Subject: [PATCH] Synth v2: modular pro layout, HOLD latching pads, fuller voice engine - Modular hardware-style panel: System / Oscillator / Filter / Envelope / LFO / Drive / Echo / Output / Keyboard sections with labeled frames - HOLD switch: pads become latching toggles (switching pads retunes the held note, unlatching or disabling HOLD releases it) - Fuller Web Audio voice: waveshaper drive, full ADSR, feedback delay (time/feedback/mix), LFO target switch (cutoff or pitch via detune), octave/detune/glide with live retuning of held notes - More of the library in service: MetalKnob (drive, with value bubble), VintageKnob (echo time), horizontal LEDFader (echo mix), XYPad two-way-linked with the cutoff/res knobs (exponential x-axis mapping), SegmentDisplay live frequency readout, 4 preset buttons that glide every knob via animateChanges - Disambiguated synth aria-labels from gallery demos; octave readout fixed via decimals/format - SteppedKnob now honors the format prop for numeric readouts --- apps/docs/src/sections/Synth.tsx | 684 ++++++++++++++----- apps/docs/src/styles.css | 51 ++ packages/dreamknob/src/skins/SteppedKnob.tsx | 7 +- 3 files changed, 565 insertions(+), 177 deletions(-) diff --git a/apps/docs/src/sections/Synth.tsx b/apps/docs/src/sections/Synth.tsx index 4d23592..06fd3e8 100644 --- a/apps/docs/src/sections/Synth.tsx +++ b/apps/docs/src/sections/Synth.tsx @@ -3,12 +3,17 @@ import { AlphaDisplay, Fader, FlatKnob, + LEDFader, LEDKnob, + MetalKnob, Meter, NeonKnob, PushButton, RubberKnob, + SegmentDisplay, SteppedKnob, + VintageKnob, + XYPad, logTaper, powTaper, } from 'dreamknob'; @@ -28,55 +33,122 @@ const WAVES: OscillatorType[] = ['sawtooth', 'square', 'triangle']; interface Params { wave: number; + octave: number; // -2..+2 (SteppedKnob index handled via min/max) + detune: number; // cents + glide: number; // ms cutoff: number; res: number; - attack: number; // ms - release: number; // ms + attack: number; + decay: number; + sustain: number; // 0..100 % + release: number; lfoRate: number; lfoDepth: number; + lfoTarget: number; // 0 = cutoff, 1 = pitch + drive: number; // 0..100 + delayTime: number; // ms + feedback: number; // 0..90 % + delayMix: number; // 0..100 % master: number; // dB } +const INIT: Params = { + wave: 0, octave: 0, detune: 0, glide: 30, + cutoff: 1200, res: 6, + attack: 8, decay: 180, sustain: 65, release: 300, + lfoRate: 4, lfoDepth: 12, lfoTarget: 0, + drive: 12, delayTime: 320, feedback: 35, delayMix: 20, + master: -12, +}; + +const PRESETS: { name: string; params: Partial }[] = [ + { name: 'INIT', params: INIT }, + { + name: 'WARM PAD', + params: { wave: 2, cutoff: 640, res: 3, attack: 420, decay: 600, sustain: 80, release: 900, lfoRate: 0.6, lfoDepth: 8, lfoTarget: 0, drive: 4, delayTime: 460, feedback: 45, delayMix: 38, glide: 120 }, + }, + { + name: 'ACID 303', + params: { wave: 0, cutoff: 900, res: 18, attack: 2, decay: 140, sustain: 20, release: 90, lfoRate: 6, lfoDepth: 0, lfoTarget: 0, drive: 55, delayTime: 180, feedback: 30, delayMix: 18, glide: 60 }, + }, + { + name: 'WOBBLE', + params: { wave: 1, cutoff: 500, res: 12, attack: 10, decay: 300, sustain: 90, release: 200, lfoRate: 4.5, lfoDepth: 70, lfoTarget: 0, drive: 30, delayTime: 260, feedback: 25, delayMix: 22, glide: 0 }, + }, +]; + interface Engine { ctx: AudioContext; osc: OscillatorNode; + shaper: WaveShaperNode; env: GainNode; filter: BiquadFilterNode; lfo: OscillatorNode; lfoGain: GainNode; + dry: GainNode; + wet: GainNode; + delay: DelayNode; + fb: GainNode; master: GainNode; analyser: AnalyserNode; } +const driveCurve = (amount: number) => { + const k = amount * 0.6; + const n = 256; + const curve = new Float32Array(n); + for (let i = 0; i < n; i++) { + const x = (i * 2) / (n - 1) - 1; + curve[i] = ((1 + k / 20) * x) / (1 + (k / 20) * Math.abs(x)); + } + return curve; +}; + +// XY pad x-axis (0..100) <-> cutoff Hz, exponential so travel feels musical. +const CUT_MIN = 60; +const CUT_MAX = 12000; +const xToCut = (x: number) => CUT_MIN * Math.pow(CUT_MAX / CUT_MIN, x / 100); +const cutToX = (c: number) => (Math.log(c / CUT_MIN) / Math.log(CUT_MAX / CUT_MIN)) * 100; + +const Module: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => ( +
+ {title} +
{children}
+
+); + export const Synth: React.FC = () => { const [powered, setPowered] = React.useState(false); - const [params, setParams] = React.useState({ - wave: 0, - cutoff: 1200, - res: 6, - attack: 12, - release: 260, - lfoRate: 4, - lfoDepth: 15, - master: -12, - }); + const [hold, setHold] = React.useState(false); + const [preset, setPreset] = React.useState(0); + const [params, setParams] = React.useState(INIT); const [note, setNote] = React.useState(null); const [meterDb, setMeterDb] = React.useState(-60); const engine = React.useRef(null); + const baseFreq = React.useRef(null); const paramsRef = React.useRef(params); paramsRef.current = params; const set = (key: K, v: Params[K]) => setParams(p => ({ ...p, [key]: v })); + const loadPreset = (i: number) => { + setPreset(i); + setParams(p => ({ ...p, ...PRESETS[i].params })); + }; + // Build / tear down the audio graph with power. React.useEffect(() => { if (!powered) return; const ctx = new AudioContext(); const p = paramsRef.current; + const osc = ctx.createOscillator(); osc.type = WAVES[p.wave]; osc.frequency.value = 220; + const shaper = ctx.createWaveShaper(); + shaper.curve = driveCurve(p.drive); + shaper.oversample = '2x'; const env = ctx.createGain(); env.gain.value = 0; const filter = ctx.createBiquadFilter(); @@ -86,24 +158,37 @@ export const Synth: React.FC = () => { const lfo = ctx.createOscillator(); lfo.frequency.value = p.lfoRate; const lfoGain = ctx.createGain(); - lfoGain.gain.value = p.lfoDepth * 12; // depth% -> Hz swing on the cutoff + const dry = ctx.createGain(); + const wet = ctx.createGain(); + const delay = ctx.createDelay(2); + delay.delayTime.value = p.delayTime / 1000; + const fb = ctx.createGain(); + fb.gain.value = p.feedback / 100; + dry.gain.value = 1 - (p.delayMix / 100) * 0.7; + wet.gain.value = p.delayMix / 100; const master = ctx.createGain(); master.gain.value = Math.pow(10, p.master / 20); const analyser = ctx.createAnalyser(); analyser.fftSize = 1024; - osc.connect(env); + osc.connect(shaper); + shaper.connect(env); env.connect(filter); - filter.connect(master); + filter.connect(dry); + filter.connect(delay); + delay.connect(fb); + fb.connect(delay); + delay.connect(wet); + dry.connect(master); + wet.connect(master); master.connect(analyser); analyser.connect(ctx.destination); lfo.connect(lfoGain); - lfoGain.connect(filter.frequency); + // Initial LFO routing happens in the params effect below. osc.start(); lfo.start(); - engine.current = { ctx, osc, env, filter, lfo, lfoGain, master, analyser }; + engine.current = { ctx, osc, shaper, env, filter, lfo, lfoGain, dry, wet, delay, fb, master, analyser }; - // Meter: RMS of the output, throttled to ~25 fps. const buf = new Float32Array(analyser.fftSize); let raf = 0; let last = 0; @@ -122,6 +207,7 @@ export const Synth: React.FC = () => { return () => { cancelAnimationFrame(raf); engine.current = null; + baseFreq.current = null; void ctx.close(); setMeterDb(-60); setNote(null); @@ -129,26 +215,60 @@ export const Synth: React.FC = () => { }, [powered]); // Live parameter updates. + const lfoTargetRef = React.useRef(-1); React.useEffect(() => { const e = engine.current; if (!e) return; const t = e.ctx.currentTime; e.osc.type = WAVES[params.wave]; + e.shaper.curve = driveCurve(params.drive); e.filter.frequency.setTargetAtTime(params.cutoff, t, 0.015); e.filter.Q.setTargetAtTime(params.res, t, 0.015); e.lfo.frequency.setTargetAtTime(params.lfoRate, t, 0.015); - e.lfoGain.gain.setTargetAtTime(params.lfoDepth * 12, t, 0.015); + e.delay.delayTime.setTargetAtTime(params.delayTime / 1000, t, 0.03); + e.fb.gain.setTargetAtTime(Math.min(params.feedback, 90) / 100, t, 0.015); + e.dry.gain.setTargetAtTime(1 - (params.delayMix / 100) * 0.7, t, 0.015); + e.wet.gain.setTargetAtTime(params.delayMix / 100, t, 0.015); e.master.gain.setTargetAtTime(Math.pow(10, params.master / 20), t, 0.015); - }, [params]); + // LFO target: cutoff (Hz swing) or pitch (cents on detune). + if (lfoTargetRef.current !== params.lfoTarget) { + try { + e.lfoGain.disconnect(); + } catch { + /* not connected yet */ + } + if (params.lfoTarget === 0) e.lfoGain.connect(e.filter.frequency); + else e.lfoGain.connect(e.osc.detune); + lfoTargetRef.current = params.lfoTarget; + } + const depth = params.lfoTarget === 0 ? params.lfoDepth * 14 : params.lfoDepth * 0.6; + e.lfoGain.gain.setTargetAtTime(depth, t, 0.015); + // Retune a held note when octave/detune change. + if (baseFreq.current !== null) applyPitch(baseFreq.current); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [params, powered]); + + const applyPitch = (base: number) => { + const e = engine.current; + if (!e) return; + const p = paramsRef.current; + const freq = base * Math.pow(2, p.octave) * Math.pow(2, p.detune / 1200); + const tc = Math.max(0.002, p.glide / 1000 / 3); + e.osc.frequency.setTargetAtTime(freq, e.ctx.currentTime, tc); + }; const noteOn = (freq: number, name: string) => { const e = engine.current; if (!e) return; + baseFreq.current = freq; + applyPitch(freq); + const p = paramsRef.current; const t = e.ctx.currentTime; - e.osc.frequency.setTargetAtTime(freq, t, 0.002); - e.env.gain.cancelScheduledValues(t); - e.env.gain.setValueAtTime(e.env.gain.value, t); - e.env.gain.linearRampToValueAtTime(0.5, t + paramsRef.current.attack / 1000); + const g = e.env.gain; + g.cancelScheduledValues(t); + g.setValueAtTime(g.value, t); + g.linearRampToValueAtTime(0.55, t + p.attack / 1000); + g.linearRampToValueAtTime(0.55 * (p.sustain / 100), t + p.attack / 1000 + p.decay / 1000); setNote(name); }; @@ -156,13 +276,27 @@ export const Synth: React.FC = () => { const e = engine.current; if (!e) return; const t = e.ctx.currentTime; - e.env.gain.cancelScheduledValues(t); - e.env.gain.setValueAtTime(e.env.gain.value, t); - e.env.gain.linearRampToValueAtTime(0, t + paramsRef.current.release / 1000); + const g = e.env.gain; + g.cancelScheduledValues(t); + g.setValueAtTime(g.value, t); + g.linearRampToValueAtTime(0, t + paramsRef.current.release / 1000); + baseFreq.current = null; setNote(null); }; + // Turning HOLD off releases a latched note. + React.useEffect(() => { + if (!hold && note) noteOff(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hold]); + const hz = (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${Math.round(v)}`); + const shownFreq = note + ? (NOTES.find(n => n.name === note)?.freq ?? 0) * + Math.pow(2, params.octave) * + Math.pow(2, params.detune / 1200) + : 0; + const anim = { animateChanges: { duration: 300 } as const }; return (
@@ -170,158 +304,356 @@ export const Synth: React.FC = () => {
Play it

A real synth, all dreamknob controls

- Every control drives an actual Web Audio graph — power it on, hold the pads, and - sweep the filter. The meter reads the analyser; the display shows the note. + Every control drives an actual Web Audio graph. Power on, pick a preset (watch + the knobs glide), hold or latch the pads, and sweep the filter — from the knobs + or the XY pad, they're the same parameters.

-
-
- - Power - - - set('wave', v)} - color="#ffd23e" - label="Osc" - aria-label="Oscillator wave" - /> - set('cutoff', v)} - showValue - format={hz} - color="#ff9640" - label="Cutoff" - aria-label="Filter cutoff" - /> - set('res', v)} - color="#ff9640" - label="Res" - aria-label="Filter resonance" - /> - set('attack', v)} - unit="ms" - color="#4cc2ff" - label="Attack" - aria-label="Envelope attack" - /> - set('release', v)} - unit="ms" - color="#4cc2ff" - label="Release" - aria-label="Envelope release" - /> - set('lfoRate', v)} - digits={4} - displayDecimals={1} - color="#3df2ad" - label="LFO Hz" - aria-label="LFO rate" - /> - set('lfoDepth', v)} - color="#e44cff" - label="Depth" - aria-label="LFO depth" - /> - set('master', v)} - origin={-60} - editable - unit=" dB" - label="Master" - aria-label="Master volume" - /> - -
-
- {NOTES.map(n => ( - (on ? noteOn(n.freq, n.name) : noteOff())} - color="#3df2ad" - size={44} - disabled={!powered} - aria-label={`Play ${n.name}`} - > - {n.name} - - ))} +
+
+
+ + + Power + +
+ + +
+
+ {PRESETS.map((p, i) => ( + on && loadPreset(i)} + color="#3df2ad" + size={32} + aria-label={`Load preset ${p.name}`} + > + {i + 1} + + ))} +
+
+ + + set('wave', v)} + color="#ffd23e" + label="Wave" + aria-label="Oscillator wave" + /> + set('octave', v)} + color="#ffd23e" + format={v => (v > 0 ? `+${v}` : `${v}`)} + label="Octave" + aria-label="Octave" + /> + set('detune', v)} + color="#ffd23e" + unit="¢" + label="Detune" + aria-label="Detune" + {...anim} + /> + set('glide', v)} + color="#ffd23e" + unit="ms" + label="Glide" + aria-label="Glide" + {...anim} + /> + + + + set('cutoff', v)} + showValue + format={hz} + editable + color="#ff9640" + label="Cutoff" + aria-label="Filter cutoff" + {...anim} + /> + set('res', v)} + color="#ff9640" + label="Res" + aria-label="Filter resonance" + {...anim} + /> + setParams(p => ({ ...p, cutoff: Math.round(xToCut(v.x)), res: v.y }))} + formatX={v => hz(xToCut(v))} + formatY={v => v.toFixed(1)} + color="#ff9640" + grid={4} + label="Cut / Res" + aria-label="Filter cutoff and resonance pad" + /> + +
+ +
+ + {( + [ + ['attack', 'Atk', 1, 1000], + ['decay', 'Dec', 1, 2000], + ['sustain', 'Sus', 0, 100], + ['release', 'Rel', 10, 3000], + ] as const + ).map(([key, label, min, max]) => ( + set(key, v)} + color="#4cc2ff" + unit={key === 'sustain' ? '%' : 'ms'} + label={label} + aria-label={`Envelope ${key}`} + {...anim} + /> + ))} + + + + set('lfoRate', v)} + digits={4} + displayDecimals={1} + color="#3df2ad" + label="Rate" + aria-label="LFO rate" + /> + set('lfoDepth', v)} + color="#e44cff" + label="Depth" + aria-label="LFO depth" + {...anim} + /> + set('lfoTarget', v)} + color="#e44cff" + label="Target" + aria-label="LFO target" + /> + + + + set('drive', v)} + color="#ff4d6b" + valueBubble + bubbleFormat={v => `${v} %`} + label="Drive" + aria-label="Drive" + {...anim} + /> + + + + set('delayTime', v)} + color="#ffd23e" + label="Time" + aria-label="Delay time" + /> + set('feedback', v)} + color="#ffd23e" + unit="%" + label="Fdbk" + aria-label="Delay feedback" + {...anim} + /> + set('delayMix', v)} + color="#ffd23e" + displayDecimals={0} + label="Mix" + aria-label="Delay mix" + /> + + + + set('master', v)} + origin={-60} + editable + unit=" dB" + label="Master" + aria-label="Master volume" + /> + + +
+ +
+ +
+ + Hold + + + {NOTES.map(n => + hold ? ( + (on ? noteOn(n.freq, n.name) : noteOff())} + color="#3df2ad" + size={44} + disabled={!powered} + aria-label={`Latch ${n.name}`} + > + {n.name} + + ) : ( + (on ? noteOn(n.freq, n.name) : noteOff())} + color="#3df2ad" + size={44} + disabled={!powered} + aria-label={`Play ${n.name}`} + > + {n.name} + + ), + )} +
+
+
- power on · hold a pad · sweep the cutoff while a note rings + power on · try preset 2 and watch the knobs glide · HOLD latches a pad · + sweep the XY pad while a note rings
diff --git a/apps/docs/src/styles.css b/apps/docs/src/styles.css index 4fba32e..4c0839d 100644 --- a/apps/docs/src/styles.css +++ b/apps/docs/src/styles.css @@ -309,6 +309,57 @@ h2.section-title { text-transform: uppercase; } +/* ---- synth panel ---- */ +.synth-rows { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; +} +.synth-row { + display: flex; + gap: 16px; + justify-content: center; + flex-wrap: wrap; + align-items: stretch; +} +.synth-module { + border: 1px solid var(--line-strong); + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); + padding: 14px 18px 12px; + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; +} +.synth-module > .module-title { + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--text-faint); + align-self: stretch; + text-align: center; + border-bottom: 1px solid var(--line); + padding-bottom: 7px; +} +.synth-module .module-body { + display: flex; + gap: 18px; + align-items: center; + justify-content: center; + flex-wrap: wrap; + flex: 1; +} +.synth-pads { + display: flex; + gap: 8px; + justify-content: center; + flex-wrap: wrap; + align-items: center; +} + /* ---- playground ---- */ .playground { display: grid; diff --git a/packages/dreamknob/src/skins/SteppedKnob.tsx b/packages/dreamknob/src/skins/SteppedKnob.tsx index c6eb227..c8d7dc6 100644 --- a/packages/dreamknob/src/skins/SteppedKnob.tsx +++ b/packages/dreamknob/src/skins/SteppedKnob.tsx @@ -30,6 +30,7 @@ export const SteppedKnob = React.forwardRef(fu labelColor, label, showValue = true, + format, focusRing, className, style, @@ -92,7 +93,11 @@ export const SteppedKnob = React.forwardRef(fu fontWeight={600} style={{ pointerEvents: 'none' }} > - {positions ? positions[index] : ctx.value.toFixed(ctx.decimals)} + {positions + ? positions[index] + : format + ? format(ctx.value) + : ctx.value.toFixed(ctx.decimals)} )} {label && (