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:
parent
54189ad07a
commit
c404b3f82d
3 changed files with 565 additions and 177 deletions
|
|
@ -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<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 {
|
||||
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 }) => (
|
||||
<div className="synth-module">
|
||||
<span className="module-title">{title}</span>
|
||||
<div className="module-body">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Synth: React.FC = () => {
|
||||
const [powered, setPowered] = React.useState(false);
|
||||
const [params, setParams] = React.useState<Params>({
|
||||
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<Params>(INIT);
|
||||
const [note, setNote] = React.useState<string | null>(null);
|
||||
const [meterDb, setMeterDb] = React.useState(-60);
|
||||
const engine = React.useRef<Engine | null>(null);
|
||||
const baseFreq = React.useRef<number | null>(null);
|
||||
const paramsRef = React.useRef(params);
|
||||
paramsRef.current = params;
|
||||
|
||||
const set = <K extends keyof Params>(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 (
|
||||
<section className="block" id="synth">
|
||||
|
|
@ -170,52 +304,123 @@ export const Synth: React.FC = () => {
|
|||
<div className="section-kicker">Play it</div>
|
||||
<h2 className="section-title">A real synth, all dreamknob controls</h2>
|
||||
<p className="section-sub">
|
||||
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.
|
||||
</p>
|
||||
<div className="console" style={{ flexDirection: 'column', gap: 22 }}>
|
||||
<div style={{ display: 'flex', gap: 26, alignItems: 'center', justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<PushButton
|
||||
pressed={powered}
|
||||
onChange={setPowered}
|
||||
color="#ff4d6b"
|
||||
size={40}
|
||||
aria-label="Power"
|
||||
>
|
||||
<div className="console" style={{ flexDirection: 'column', gap: 16 }}>
|
||||
<div className="synth-rows">
|
||||
<div className="synth-row">
|
||||
<Module title="System">
|
||||
<PushButton pressed={powered} onChange={setPowered} color="#ff4d6b" size={40} aria-label="Power">
|
||||
Power
|
||||
</PushButton>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'center' }}>
|
||||
<AlphaDisplay
|
||||
value={!powered ? 'OFF' : (note ?? 'READY')}
|
||||
chars={6}
|
||||
align="right"
|
||||
height={18}
|
||||
value={!powered ? 'OFF' : (note ?? PRESETS[preset].name)}
|
||||
chars={9}
|
||||
height={16}
|
||||
color={powered ? '#3df2ad' : '#3a3f46'}
|
||||
/>
|
||||
<SegmentDisplay
|
||||
value={shownFreq}
|
||||
digits={5}
|
||||
decimals={1}
|
||||
height={11}
|
||||
color={note ? '#4cc2ff' : '#2a2f36'}
|
||||
ghostOpacity={0.12}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{PRESETS.map((p, i) => (
|
||||
<PushButton
|
||||
key={p.name}
|
||||
pressed={preset === i}
|
||||
onChange={on => on && loadPreset(i)}
|
||||
color="#3df2ad"
|
||||
size={32}
|
||||
aria-label={`Load preset ${p.name}`}
|
||||
>
|
||||
{i + 1}
|
||||
</PushButton>
|
||||
))}
|
||||
</div>
|
||||
</Module>
|
||||
|
||||
<Module title="Oscillator">
|
||||
<SteppedKnob
|
||||
size={76}
|
||||
size={70}
|
||||
positions={['SAW', 'SQR', 'TRI']}
|
||||
value={params.wave}
|
||||
onChange={v => set('wave', v)}
|
||||
color="#ffd23e"
|
||||
label="Osc"
|
||||
label="Wave"
|
||||
aria-label="Oscillator wave"
|
||||
/>
|
||||
<SteppedKnob
|
||||
size={70}
|
||||
min={-2}
|
||||
max={2}
|
||||
steps={5}
|
||||
decimals={0}
|
||||
value={params.octave}
|
||||
onChange={v => set('octave', v)}
|
||||
color="#ffd23e"
|
||||
format={v => (v > 0 ? `+${v}` : `${v}`)}
|
||||
label="Octave"
|
||||
aria-label="Octave"
|
||||
/>
|
||||
<FlatKnob
|
||||
size={70}
|
||||
min={-100}
|
||||
max={100}
|
||||
step={1}
|
||||
origin={0}
|
||||
detents={[0]}
|
||||
arcFrom="center"
|
||||
value={params.detune}
|
||||
onChange={v => set('detune', v)}
|
||||
color="#ffd23e"
|
||||
unit="¢"
|
||||
label="Detune"
|
||||
aria-label="Detune"
|
||||
{...anim}
|
||||
/>
|
||||
<FlatKnob
|
||||
size={70}
|
||||
min={0}
|
||||
max={500}
|
||||
taper={powTaper(2)}
|
||||
decimals={0}
|
||||
value={params.glide}
|
||||
onChange={v => set('glide', v)}
|
||||
color="#ffd23e"
|
||||
unit="ms"
|
||||
label="Glide"
|
||||
aria-label="Glide"
|
||||
{...anim}
|
||||
/>
|
||||
</Module>
|
||||
|
||||
<Module title="Filter">
|
||||
<RubberKnob
|
||||
size={76}
|
||||
min={60}
|
||||
max={12000}
|
||||
size={80}
|
||||
min={CUT_MIN}
|
||||
max={CUT_MAX}
|
||||
taper={logTaper}
|
||||
decimals={0}
|
||||
value={params.cutoff}
|
||||
onChange={v => set('cutoff', v)}
|
||||
showValue
|
||||
format={hz}
|
||||
editable
|
||||
color="#ff9640"
|
||||
label="Cutoff"
|
||||
aria-label="Filter cutoff"
|
||||
{...anim}
|
||||
/>
|
||||
<FlatKnob
|
||||
size={76}
|
||||
size={70}
|
||||
min={0}
|
||||
max={24}
|
||||
step={0.5}
|
||||
|
|
@ -224,35 +429,56 @@ export const Synth: React.FC = () => {
|
|||
color="#ff9640"
|
||||
label="Res"
|
||||
aria-label="Filter resonance"
|
||||
{...anim}
|
||||
/>
|
||||
<XYPad
|
||||
width={150}
|
||||
height={104}
|
||||
x={{ min: 0, max: 100, defaultValue: cutToX(INIT.cutoff) }}
|
||||
y={{ min: 0, max: 24, step: 0.5, defaultValue: INIT.res }}
|
||||
value={{ x: cutToX(params.cutoff), y: params.res }}
|
||||
onChange={v => 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"
|
||||
/>
|
||||
</Module>
|
||||
</div>
|
||||
|
||||
<div className="synth-row">
|
||||
<Module title="Envelope">
|
||||
{(
|
||||
[
|
||||
['attack', 'Atk', 1, 1000],
|
||||
['decay', 'Dec', 1, 2000],
|
||||
['sustain', 'Sus', 0, 100],
|
||||
['release', 'Rel', 10, 3000],
|
||||
] as const
|
||||
).map(([key, label, min, max]) => (
|
||||
<FlatKnob
|
||||
size={76}
|
||||
min={1}
|
||||
max={1000}
|
||||
taper={powTaper(2.5)}
|
||||
key={key}
|
||||
size={62}
|
||||
min={min}
|
||||
max={max}
|
||||
taper={key === 'sustain' ? undefined : powTaper(2.2)}
|
||||
decimals={0}
|
||||
value={params.attack}
|
||||
onChange={v => set('attack', v)}
|
||||
unit="ms"
|
||||
value={params[key]}
|
||||
onChange={v => set(key, v)}
|
||||
color="#4cc2ff"
|
||||
label="Attack"
|
||||
aria-label="Envelope attack"
|
||||
/>
|
||||
<FlatKnob
|
||||
size={76}
|
||||
min={10}
|
||||
max={3000}
|
||||
taper={powTaper(2.5)}
|
||||
decimals={0}
|
||||
value={params.release}
|
||||
onChange={v => set('release', v)}
|
||||
unit="ms"
|
||||
color="#4cc2ff"
|
||||
label="Release"
|
||||
aria-label="Envelope release"
|
||||
unit={key === 'sustain' ? '%' : 'ms'}
|
||||
label={label}
|
||||
aria-label={`Envelope ${key}`}
|
||||
{...anim}
|
||||
/>
|
||||
))}
|
||||
</Module>
|
||||
|
||||
<Module title="LFO">
|
||||
<LEDKnob
|
||||
size={76}
|
||||
size={70}
|
||||
min={0.1}
|
||||
max={20}
|
||||
taper={logTaper}
|
||||
|
|
@ -262,11 +488,11 @@ export const Synth: React.FC = () => {
|
|||
digits={4}
|
||||
displayDecimals={1}
|
||||
color="#3df2ad"
|
||||
label="LFO Hz"
|
||||
label="Rate"
|
||||
aria-label="LFO rate"
|
||||
/>
|
||||
<NeonKnob
|
||||
size={76}
|
||||
size={70}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
|
|
@ -275,7 +501,80 @@ export const Synth: React.FC = () => {
|
|||
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}
|
||||
|
|
@ -304,9 +603,37 @@ export const Synth: React.FC = () => {
|
|||
label="Out"
|
||||
aria-label="Synth output level"
|
||||
/>
|
||||
</Module>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
{NOTES.map(n => (
|
||||
|
||||
<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"
|
||||
|
|
@ -318,10 +645,15 @@ export const Synth: React.FC = () => {
|
|||
>
|
||||
{n.name}
|
||||
</PushButton>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</Module>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export const SteppedKnob = React.forwardRef<HTMLDivElement, SteppedKnobProps>(fu
|
|||
labelColor,
|
||||
label,
|
||||
showValue = true,
|
||||
format,
|
||||
focusRing,
|
||||
className,
|
||||
style,
|
||||
|
|
@ -92,7 +93,11 @@ export const SteppedKnob = React.forwardRef<HTMLDivElement, SteppedKnobProps>(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)}
|
||||
</text>
|
||||
)}
|
||||
{label && (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue