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,
|
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,52 +304,123 @@ 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"
|
|
||||||
size={40}
|
|
||||||
aria-label="Power"
|
|
||||||
>
|
|
||||||
Power
|
Power
|
||||||
</PushButton>
|
</PushButton>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'center' }}>
|
||||||
<AlphaDisplay
|
<AlphaDisplay
|
||||||
value={!powered ? 'OFF' : (note ?? 'READY')}
|
value={!powered ? 'OFF' : (note ?? PRESETS[preset].name)}
|
||||||
chars={6}
|
chars={9}
|
||||||
align="right"
|
height={16}
|
||||||
height={18}
|
|
||||||
color={powered ? '#3df2ad' : '#3a3f46'}
|
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
|
<SteppedKnob
|
||||||
size={76}
|
size={70}
|
||||||
positions={['SAW', 'SQR', 'TRI']}
|
positions={['SAW', 'SQR', 'TRI']}
|
||||||
value={params.wave}
|
value={params.wave}
|
||||||
onChange={v => set('wave', v)}
|
onChange={v => set('wave', v)}
|
||||||
color="#ffd23e"
|
color="#ffd23e"
|
||||||
label="Osc"
|
label="Wave"
|
||||||
aria-label="Oscillator 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
|
<RubberKnob
|
||||||
size={76}
|
size={80}
|
||||||
min={60}
|
min={CUT_MIN}
|
||||||
max={12000}
|
max={CUT_MAX}
|
||||||
taper={logTaper}
|
taper={logTaper}
|
||||||
decimals={0}
|
decimals={0}
|
||||||
value={params.cutoff}
|
value={params.cutoff}
|
||||||
onChange={v => set('cutoff', v)}
|
onChange={v => set('cutoff', v)}
|
||||||
showValue
|
showValue
|
||||||
format={hz}
|
format={hz}
|
||||||
|
editable
|
||||||
color="#ff9640"
|
color="#ff9640"
|
||||||
label="Cutoff"
|
label="Cutoff"
|
||||||
aria-label="Filter cutoff"
|
aria-label="Filter cutoff"
|
||||||
|
{...anim}
|
||||||
/>
|
/>
|
||||||
<FlatKnob
|
<FlatKnob
|
||||||
size={76}
|
size={70}
|
||||||
min={0}
|
min={0}
|
||||||
max={24}
|
max={24}
|
||||||
step={0.5}
|
step={0.5}
|
||||||
|
|
@ -224,35 +429,56 @@ export const Synth: React.FC = () => {
|
||||||
color="#ff9640"
|
color="#ff9640"
|
||||||
label="Res"
|
label="Res"
|
||||||
aria-label="Filter resonance"
|
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
|
<FlatKnob
|
||||||
size={76}
|
key={key}
|
||||||
min={1}
|
size={62}
|
||||||
max={1000}
|
min={min}
|
||||||
taper={powTaper(2.5)}
|
max={max}
|
||||||
|
taper={key === 'sustain' ? undefined : powTaper(2.2)}
|
||||||
decimals={0}
|
decimals={0}
|
||||||
value={params.attack}
|
value={params[key]}
|
||||||
onChange={v => set('attack', v)}
|
onChange={v => set(key, v)}
|
||||||
unit="ms"
|
|
||||||
color="#4cc2ff"
|
color="#4cc2ff"
|
||||||
label="Attack"
|
unit={key === 'sustain' ? '%' : 'ms'}
|
||||||
aria-label="Envelope attack"
|
label={label}
|
||||||
/>
|
aria-label={`Envelope ${key}`}
|
||||||
<FlatKnob
|
{...anim}
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
|
</Module>
|
||||||
|
|
||||||
|
<Module title="LFO">
|
||||||
<LEDKnob
|
<LEDKnob
|
||||||
size={76}
|
size={70}
|
||||||
min={0.1}
|
min={0.1}
|
||||||
max={20}
|
max={20}
|
||||||
taper={logTaper}
|
taper={logTaper}
|
||||||
|
|
@ -262,11 +488,11 @@ export const Synth: React.FC = () => {
|
||||||
digits={4}
|
digits={4}
|
||||||
displayDecimals={1}
|
displayDecimals={1}
|
||||||
color="#3df2ad"
|
color="#3df2ad"
|
||||||
label="LFO Hz"
|
label="Rate"
|
||||||
aria-label="LFO rate"
|
aria-label="LFO rate"
|
||||||
/>
|
/>
|
||||||
<NeonKnob
|
<NeonKnob
|
||||||
size={76}
|
size={70}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={1}
|
step={1}
|
||||||
|
|
@ -275,7 +501,80 @@ export const Synth: React.FC = () => {
|
||||||
color="#e44cff"
|
color="#e44cff"
|
||||||
label="Depth"
|
label="Depth"
|
||||||
aria-label="LFO 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
|
<Fader
|
||||||
length={120}
|
length={120}
|
||||||
breadth={40}
|
breadth={40}
|
||||||
|
|
@ -304,9 +603,37 @@ export const Synth: React.FC = () => {
|
||||||
label="Out"
|
label="Out"
|
||||||
aria-label="Synth output level"
|
aria-label="Synth output level"
|
||||||
/>
|
/>
|
||||||
|
</Module>
|
||||||
</div>
|
</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
|
<PushButton
|
||||||
key={n.name}
|
key={n.name}
|
||||||
mode="momentary"
|
mode="momentary"
|
||||||
|
|
@ -318,10 +645,15 @@ export const Synth: React.FC = () => {
|
||||||
>
|
>
|
||||||
{n.name}
|
{n.name}
|
||||||
</PushButton>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue