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
This commit is contained in:
Dreamodus 2026-07-12 19:44:42 -07:00
parent 54189ad07a
commit c404b3f82d
3 changed files with 565 additions and 177 deletions

View file

@ -3,12 +3,17 @@ import {
AlphaDisplay, AlphaDisplay,
Fader, Fader,
FlatKnob, FlatKnob,
LEDFader,
LEDKnob, LEDKnob,
MetalKnob,
Meter, Meter,
NeonKnob, NeonKnob,
PushButton, PushButton,
RubberKnob, RubberKnob,
SegmentDisplay,
SteppedKnob, SteppedKnob,
VintageKnob,
XYPad,
logTaper, logTaper,
powTaper, powTaper,
} from 'dreamknob'; } from 'dreamknob';
@ -28,55 +33,122 @@ const WAVES: OscillatorType[] = ['sawtooth', 'square', 'triangle'];
interface Params { interface Params {
wave: number; wave: number;
octave: number; // -2..+2 (SteppedKnob index handled via min/max)
detune: number; // cents
glide: number; // ms
cutoff: number; cutoff: number;
res: number; res: number;
attack: number; // ms attack: number;
release: number; // ms decay: number;
sustain: number; // 0..100 %
release: number;
lfoRate: number; lfoRate: number;
lfoDepth: 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 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<Params> }[] = [
{ 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 { interface Engine {
ctx: AudioContext; ctx: AudioContext;
osc: OscillatorNode; osc: OscillatorNode;
shaper: WaveShaperNode;
env: GainNode; env: GainNode;
filter: BiquadFilterNode; filter: BiquadFilterNode;
lfo: OscillatorNode; lfo: OscillatorNode;
lfoGain: GainNode; lfoGain: GainNode;
dry: GainNode;
wet: GainNode;
delay: DelayNode;
fb: GainNode;
master: GainNode; master: GainNode;
analyser: AnalyserNode; 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 }) => (
<div className="synth-module">
<span className="module-title">{title}</span>
<div className="module-body">{children}</div>
</div>
);
export const Synth: React.FC = () => { export const Synth: React.FC = () => {
const [powered, setPowered] = React.useState(false); const [powered, setPowered] = React.useState(false);
const [params, setParams] = React.useState<Params>({ const [hold, setHold] = React.useState(false);
wave: 0, const [preset, setPreset] = React.useState(0);
cutoff: 1200, const [params, setParams] = React.useState<Params>(INIT);
res: 6,
attack: 12,
release: 260,
lfoRate: 4,
lfoDepth: 15,
master: -12,
});
const [note, setNote] = React.useState<string | null>(null); const [note, setNote] = React.useState<string | null>(null);
const [meterDb, setMeterDb] = React.useState(-60); const [meterDb, setMeterDb] = React.useState(-60);
const engine = React.useRef<Engine | null>(null); const engine = React.useRef<Engine | null>(null);
const baseFreq = React.useRef<number | null>(null);
const paramsRef = React.useRef(params); const paramsRef = React.useRef(params);
paramsRef.current = params; paramsRef.current = params;
const set = <K extends keyof Params>(key: K, v: Params[K]) => const set = <K extends keyof Params>(key: K, v: Params[K]) =>
setParams(p => ({ ...p, [key]: v })); 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. // Build / tear down the audio graph with power.
React.useEffect(() => { React.useEffect(() => {
if (!powered) return; if (!powered) return;
const ctx = new AudioContext(); const ctx = new AudioContext();
const p = paramsRef.current; const p = paramsRef.current;
const osc = ctx.createOscillator(); const osc = ctx.createOscillator();
osc.type = WAVES[p.wave]; osc.type = WAVES[p.wave];
osc.frequency.value = 220; osc.frequency.value = 220;
const shaper = ctx.createWaveShaper();
shaper.curve = driveCurve(p.drive);
shaper.oversample = '2x';
const env = ctx.createGain(); const env = ctx.createGain();
env.gain.value = 0; env.gain.value = 0;
const filter = ctx.createBiquadFilter(); const filter = ctx.createBiquadFilter();
@ -86,24 +158,37 @@ export const Synth: React.FC = () => {
const lfo = ctx.createOscillator(); const lfo = ctx.createOscillator();
lfo.frequency.value = p.lfoRate; lfo.frequency.value = p.lfoRate;
const lfoGain = ctx.createGain(); 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(); const master = ctx.createGain();
master.gain.value = Math.pow(10, p.master / 20); master.gain.value = Math.pow(10, p.master / 20);
const analyser = ctx.createAnalyser(); const analyser = ctx.createAnalyser();
analyser.fftSize = 1024; analyser.fftSize = 1024;
osc.connect(env); osc.connect(shaper);
shaper.connect(env);
env.connect(filter); 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); master.connect(analyser);
analyser.connect(ctx.destination); analyser.connect(ctx.destination);
lfo.connect(lfoGain); lfo.connect(lfoGain);
lfoGain.connect(filter.frequency); // Initial LFO routing happens in the params effect below.
osc.start(); osc.start();
lfo.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); const buf = new Float32Array(analyser.fftSize);
let raf = 0; let raf = 0;
let last = 0; let last = 0;
@ -122,6 +207,7 @@ export const Synth: React.FC = () => {
return () => { return () => {
cancelAnimationFrame(raf); cancelAnimationFrame(raf);
engine.current = null; engine.current = null;
baseFreq.current = null;
void ctx.close(); void ctx.close();
setMeterDb(-60); setMeterDb(-60);
setNote(null); setNote(null);
@ -129,26 +215,60 @@ export const Synth: React.FC = () => {
}, [powered]); }, [powered]);
// Live parameter updates. // Live parameter updates.
const lfoTargetRef = React.useRef(-1);
React.useEffect(() => { React.useEffect(() => {
const e = engine.current; const e = engine.current;
if (!e) return; if (!e) return;
const t = e.ctx.currentTime; const t = e.ctx.currentTime;
e.osc.type = WAVES[params.wave]; e.osc.type = WAVES[params.wave];
e.shaper.curve = driveCurve(params.drive);
e.filter.frequency.setTargetAtTime(params.cutoff, t, 0.015); e.filter.frequency.setTargetAtTime(params.cutoff, t, 0.015);
e.filter.Q.setTargetAtTime(params.res, t, 0.015); e.filter.Q.setTargetAtTime(params.res, t, 0.015);
e.lfo.frequency.setTargetAtTime(params.lfoRate, 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); 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 noteOn = (freq: number, name: string) => {
const e = engine.current; const e = engine.current;
if (!e) return; if (!e) return;
baseFreq.current = freq;
applyPitch(freq);
const p = paramsRef.current;
const t = e.ctx.currentTime; const t = e.ctx.currentTime;
e.osc.frequency.setTargetAtTime(freq, t, 0.002); const g = e.env.gain;
e.env.gain.cancelScheduledValues(t); g.cancelScheduledValues(t);
e.env.gain.setValueAtTime(e.env.gain.value, t); g.setValueAtTime(g.value, t);
e.env.gain.linearRampToValueAtTime(0.5, t + paramsRef.current.attack / 1000); g.linearRampToValueAtTime(0.55, t + p.attack / 1000);
g.linearRampToValueAtTime(0.55 * (p.sustain / 100), t + p.attack / 1000 + p.decay / 1000);
setNote(name); setNote(name);
}; };
@ -156,13 +276,27 @@ export const Synth: React.FC = () => {
const e = engine.current; const e = engine.current;
if (!e) return; if (!e) return;
const t = e.ctx.currentTime; const t = e.ctx.currentTime;
e.env.gain.cancelScheduledValues(t); const g = e.env.gain;
e.env.gain.setValueAtTime(e.env.gain.value, t); g.cancelScheduledValues(t);
e.env.gain.linearRampToValueAtTime(0, t + paramsRef.current.release / 1000); g.setValueAtTime(g.value, t);
g.linearRampToValueAtTime(0, t + paramsRef.current.release / 1000);
baseFreq.current = null;
setNote(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 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 ( return (
<section className="block" id="synth"> <section className="block" id="synth">
@ -170,158 +304,356 @@ export const Synth: React.FC = () => {
<div className="section-kicker">Play it</div> <div className="section-kicker">Play it</div>
<h2 className="section-title">A real synth, all dreamknob controls</h2> <h2 className="section-title">A real synth, all dreamknob controls</h2>
<p className="section-sub"> <p className="section-sub">
Every control drives an actual Web Audio graph power it on, hold the pads, and Every control drives an actual Web Audio graph. Power on, pick a preset (watch
sweep the filter. The meter reads the analyser; the display shows the note. the knobs glide), hold or latch the pads, and sweep the filter from the knobs
or the XY pad, they're the same parameters.
</p> </p>
<div className="console" style={{ flexDirection: 'column', gap: 22 }}> <div className="console" style={{ flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', gap: 26, alignItems: 'center', justifyContent: 'center', flexWrap: 'wrap' }}> <div className="synth-rows">
<PushButton <div className="synth-row">
pressed={powered} <Module title="System">
onChange={setPowered} <PushButton pressed={powered} onChange={setPowered} color="#ff4d6b" size={40} aria-label="Power">
color="#ff4d6b" Power
size={40} </PushButton>
aria-label="Power" <div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'center' }}>
> <AlphaDisplay
Power value={!powered ? 'OFF' : (note ?? PRESETS[preset].name)}
</PushButton> chars={9}
<AlphaDisplay height={16}
value={!powered ? 'OFF' : (note ?? 'READY')} color={powered ? '#3df2ad' : '#3a3f46'}
chars={6} />
align="right" <SegmentDisplay
height={18} value={shownFreq}
color={powered ? '#3df2ad' : '#3a3f46'} digits={5}
/> decimals={1}
<SteppedKnob height={11}
size={76} color={note ? '#4cc2ff' : '#2a2f36'}
positions={['SAW', 'SQR', 'TRI']} ghostOpacity={0.12}
value={params.wave} />
onChange={v => set('wave', v)} </div>
color="#ffd23e" <div style={{ display: 'flex', gap: 6 }}>
label="Osc" {PRESETS.map((p, i) => (
aria-label="Oscillator wave" <PushButton
/> key={p.name}
<RubberKnob pressed={preset === i}
size={76} onChange={on => on && loadPreset(i)}
min={60} color="#3df2ad"
max={12000} size={32}
taper={logTaper} aria-label={`Load preset ${p.name}`}
decimals={0} >
value={params.cutoff} {i + 1}
onChange={v => set('cutoff', v)} </PushButton>
showValue ))}
format={hz} </div>
color="#ff9640" </Module>
label="Cutoff"
aria-label="Filter cutoff" <Module title="Oscillator">
/> <SteppedKnob
<FlatKnob size={70}
size={76} positions={['SAW', 'SQR', 'TRI']}
min={0} value={params.wave}
max={24} onChange={v => set('wave', v)}
step={0.5} color="#ffd23e"
value={params.res} label="Wave"
onChange={v => set('res', v)} aria-label="Oscillator wave"
color="#ff9640" />
label="Res" <SteppedKnob
aria-label="Filter resonance" size={70}
/> min={-2}
<FlatKnob max={2}
size={76} steps={5}
min={1} decimals={0}
max={1000} value={params.octave}
taper={powTaper(2.5)} onChange={v => set('octave', v)}
decimals={0} color="#ffd23e"
value={params.attack} format={v => (v > 0 ? `+${v}` : `${v}`)}
onChange={v => set('attack', v)} label="Octave"
unit="ms" aria-label="Octave"
color="#4cc2ff" />
label="Attack" <FlatKnob
aria-label="Envelope attack" size={70}
/> min={-100}
<FlatKnob max={100}
size={76} step={1}
min={10} origin={0}
max={3000} detents={[0]}
taper={powTaper(2.5)} arcFrom="center"
decimals={0} value={params.detune}
value={params.release} onChange={v => set('detune', v)}
onChange={v => set('release', v)} color="#ffd23e"
unit="ms" unit="¢"
color="#4cc2ff" label="Detune"
label="Release" aria-label="Detune"
aria-label="Envelope release" {...anim}
/> />
<LEDKnob <FlatKnob
size={76} size={70}
min={0.1} min={0}
max={20} max={500}
taper={logTaper} taper={powTaper(2)}
decimals={1} decimals={0}
value={params.lfoRate} value={params.glide}
onChange={v => set('lfoRate', v)} onChange={v => set('glide', v)}
digits={4} color="#ffd23e"
displayDecimals={1} unit="ms"
color="#3df2ad" label="Glide"
label="LFO Hz" aria-label="Glide"
aria-label="LFO rate" {...anim}
/> />
<NeonKnob </Module>
size={76}
min={0} <Module title="Filter">
max={100} <RubberKnob
step={1} size={80}
value={params.lfoDepth} min={CUT_MIN}
onChange={v => set('lfoDepth', v)} max={CUT_MAX}
color="#e44cff" taper={logTaper}
label="Depth" decimals={0}
aria-label="LFO depth" value={params.cutoff}
/> onChange={v => set('cutoff', v)}
<Fader showValue
length={120} format={hz}
breadth={40} editable
min={-60} color="#ff9640"
max={0} label="Cutoff"
step={0.5} aria-label="Filter cutoff"
value={params.master} {...anim}
onChange={v => set('master', v)} />
origin={-60} <FlatKnob
editable size={70}
unit=" dB" min={0}
label="Master" max={24}
aria-label="Master volume" step={0.5}
/> value={params.res}
<Meter onChange={v => set('res', v)}
value={meterDb} color="#ff9640"
min={-60} label="Res"
max={0} aria-label="Filter resonance"
length={120} {...anim}
zones={[ />
{ upTo: 0.7, color: '#3df2ad' }, <XYPad
{ upTo: 0.9, color: '#ffd23e' }, width={150}
{ upTo: 1, color: '#ff4d6b' }, height={104}
]} x={{ min: 0, max: 100, defaultValue: cutToX(INIT.cutoff) }}
peakColor="#fff" y={{ min: 0, max: 24, step: 0.5, defaultValue: INIT.res }}
label="Out" value={{ x: cutToX(params.cutoff), y: params.res }}
aria-label="Synth output level" onChange={v => setParams(p => ({ ...p, cutoff: Math.round(xToCut(v.x)), res: v.y }))}
/> formatX={v => hz(xToCut(v))}
</div> formatY={v => v.toFixed(1)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}> color="#ff9640"
{NOTES.map(n => ( grid={4}
<PushButton label="Cut / Res"
key={n.name} aria-label="Filter cutoff and resonance pad"
mode="momentary" />
onChange={on => (on ? noteOn(n.freq, n.name) : noteOff())} </Module>
color="#3df2ad" </div>
size={44}
disabled={!powered} <div className="synth-row">
aria-label={`Play ${n.name}`} <Module title="Envelope">
> {(
{n.name} [
</PushButton> ['attack', 'Atk', 1, 1000],
))} ['decay', 'Dec', 1, 2000],
['sustain', 'Sus', 0, 100],
['release', 'Rel', 10, 3000],
] as const
).map(([key, label, min, max]) => (
<FlatKnob
key={key}
size={62}
min={min}
max={max}
taper={key === 'sustain' ? undefined : powTaper(2.2)}
decimals={0}
value={params[key]}
onChange={v => set(key, v)}
color="#4cc2ff"
unit={key === 'sustain' ? '%' : 'ms'}
label={label}
aria-label={`Envelope ${key}`}
{...anim}
/>
))}
</Module>
<Module title="LFO">
<LEDKnob
size={70}
min={0.1}
max={20}
taper={logTaper}
decimals={1}
value={params.lfoRate}
onChange={v => set('lfoRate', v)}
digits={4}
displayDecimals={1}
color="#3df2ad"
label="Rate"
aria-label="LFO rate"
/>
<NeonKnob
size={70}
min={0}
max={100}
step={1}
value={params.lfoDepth}
onChange={v => set('lfoDepth', v)}
color="#e44cff"
label="Depth"
aria-label="LFO depth"
{...anim}
/>
<SteppedKnob
size={70}
positions={['CUT', 'PIT']}
value={params.lfoTarget}
onChange={v => set('lfoTarget', v)}
color="#e44cff"
label="Target"
aria-label="LFO target"
/>
</Module>
<Module title="Drive">
<MetalKnob
size={80}
tone="dark"
min={0}
max={100}
step={1}
value={params.drive}
onChange={v => set('drive', v)}
color="#ff4d6b"
valueBubble
bubbleFormat={v => `${v} %`}
label="Drive"
aria-label="Drive"
{...anim}
/>
</Module>
<Module title="Echo">
<VintageKnob
size={80}
bodyColor="#26221f"
min={40}
max={1200}
step={10}
value={params.delayTime}
onChange={v => set('delayTime', v)}
color="#ffd23e"
label="Time"
aria-label="Delay time"
/>
<FlatKnob
size={62}
min={0}
max={90}
step={1}
value={params.feedback}
onChange={v => set('feedback', v)}
color="#ffd23e"
unit="%"
label="Fdbk"
aria-label="Delay feedback"
{...anim}
/>
<LEDFader
orientation="horizontal"
length={120}
breadth={26}
min={0}
max={100}
step={1}
value={params.delayMix}
onChange={v => set('delayMix', v)}
color="#ffd23e"
displayDecimals={0}
label="Mix"
aria-label="Delay mix"
/>
</Module>
<Module title="Output">
<Fader
length={120}
breadth={40}
min={-60}
max={0}
step={0.5}
value={params.master}
onChange={v => set('master', v)}
origin={-60}
editable
unit=" dB"
label="Master"
aria-label="Master volume"
/>
<Meter
value={meterDb}
min={-60}
max={0}
length={120}
zones={[
{ upTo: 0.7, color: '#3df2ad' },
{ upTo: 0.9, color: '#ffd23e' },
{ upTo: 1, color: '#ff4d6b' },
]}
peakColor="#fff"
label="Out"
aria-label="Synth output level"
/>
</Module>
</div>
<div className="synth-row">
<Module title="Keyboard">
<div className="synth-pads">
<PushButton
pressed={hold}
onChange={setHold}
color="#4cc2ff"
size={40}
disabled={!powered}
aria-label="Hold notes"
>
Hold
</PushButton>
<span style={{ width: 10 }} />
{NOTES.map(n =>
hold ? (
<PushButton
key={n.name}
pressed={note === n.name}
onChange={on => (on ? noteOn(n.freq, n.name) : noteOff())}
color="#3df2ad"
size={44}
disabled={!powered}
aria-label={`Latch ${n.name}`}
>
{n.name}
</PushButton>
) : (
<PushButton
key={n.name}
mode="momentary"
onChange={on => (on ? noteOn(n.freq, n.name) : noteOff())}
color="#3df2ad"
size={44}
disabled={!powered}
aria-label={`Play ${n.name}`}
>
{n.name}
</PushButton>
),
)}
</div>
</Module>
</div>
</div> </div>
<div className="rack-hint" style={{ marginTop: 0 }}> <div className="rack-hint" style={{ marginTop: 0 }}>
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
</div> </div>
</div> </div>
</div> </div>

View file

@ -309,6 +309,57 @@ h2.section-title {
text-transform: uppercase; 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 ---- */
.playground { .playground {
display: grid; display: grid;

View file

@ -30,6 +30,7 @@ export const SteppedKnob = React.forwardRef<HTMLDivElement, SteppedKnobProps>(fu
labelColor, labelColor,
label, label,
showValue = true, showValue = true,
format,
focusRing, focusRing,
className, className,
style, style,
@ -92,7 +93,11 @@ export const SteppedKnob = React.forwardRef<HTMLDivElement, SteppedKnobProps>(fu
fontWeight={600} fontWeight={600}
style={{ pointerEvents: 'none' }} style={{ pointerEvents: 'none' }}
> >
{positions ? positions[index] : ctx.value.toFixed(ctx.decimals)} {positions
? positions[index]
: format
? format(ctx.value)
: ctx.value.toFixed(ctx.decimals)}
</text> </text>
)} )}
{label && ( {label && (