Docs accuracy pass, guides, and a playable Web Audio synth demo
Docs/playground accuracy (from a full docs-vs-source audit): - Playground: drag-feel select covers pickup/relative rotary modes, is disabled for faders, and generated code no longer emits props the target component does not accept - API/README claims corrected: Shift-fine wheel scope, wheelStep and decimals defaults, trackInset row, data-part and ref-forwarding scope, VintageKnob readout exception, Meter peakHold tag, stale component lists, aria-valuetext wording, also-exported list API consistency fixes surfaced by the audit and the tutorial: - Keyboard gestures now fire onChangeStart (bracket parity with drag/ wheel - fixes gesture-scoped undo patterns) - name (hidden form input) added to Fader, LEDFader, ImageKnob - PushButton gets a themed keyboard-only focus ring + data-focus-visible New docs content: - Getting Started: 7-step guide with live examples and a gotchas list - Advanced guide: 8-step channel-strip tutorial ending in a live, themed, gesture-undoable strip with meter (plus headless demo) - Synth section: playable Web Audio synth - osc/filter/env/LFO/master all dreamknob controls, momentary-pad keyboard, analyser-driven Meter
This commit is contained in:
parent
e9e52e43ed
commit
54189ad07a
15 changed files with 1069 additions and 53 deletions
|
|
@ -1,9 +1,12 @@
|
|||
import React from 'react';
|
||||
import { AdvancedGuide } from './sections/AdvancedGuide';
|
||||
import { ApiDocs } from './sections/ApiDocs';
|
||||
import { Console } from './sections/Console';
|
||||
import { Gallery } from './sections/Gallery';
|
||||
import { GettingStarted } from './sections/GettingStarted';
|
||||
import { Hero } from './sections/Hero';
|
||||
import { Playground } from './sections/Playground';
|
||||
import { Synth } from './sections/Synth';
|
||||
|
||||
const Logo: React.FC = () => (
|
||||
<svg width="22" height="22" viewBox="0 0 100 100">
|
||||
|
|
@ -21,17 +24,22 @@ export const App: React.FC = () => (
|
|||
dreamknob
|
||||
</a>
|
||||
<div className="nav-links">
|
||||
<a href="#start">Start</a>
|
||||
<a href="#gallery">Gallery</a>
|
||||
<a href="#console">Demo</a>
|
||||
<a href="#synth">Synth</a>
|
||||
<a href="#playground">Playground</a>
|
||||
<a href="#guide">Guide</a>
|
||||
<a href="#docs">Docs</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<Hero />
|
||||
<GettingStarted />
|
||||
<Gallery />
|
||||
<Console />
|
||||
<Synth />
|
||||
<Playground />
|
||||
<AdvancedGuide />
|
||||
<ApiDocs />
|
||||
<footer className="footer">
|
||||
<div className="container">
|
||||
|
|
|
|||
444
apps/docs/src/sections/AdvancedGuide.tsx
Normal file
444
apps/docs/src/sections/AdvancedGuide.tsx
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
AlphaDisplay,
|
||||
Arc,
|
||||
DreamknobProvider,
|
||||
Fader,
|
||||
FlatKnob,
|
||||
Knob,
|
||||
KnobValue,
|
||||
Meter,
|
||||
Pointer,
|
||||
PushButton,
|
||||
useKnob,
|
||||
} from 'dreamknob';
|
||||
import { CodeBlock } from '../components/CodeBlock';
|
||||
|
||||
const Step: React.FC<{ n: number; title: string; children: React.ReactNode }> = ({
|
||||
n,
|
||||
title,
|
||||
children,
|
||||
}) => (
|
||||
<div style={{ marginBottom: 36 }}>
|
||||
<h3 className="api-h" style={{ marginTop: 0 }}>
|
||||
<span style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', marginRight: 10 }}>
|
||||
{String(n).padStart(2, '0')}
|
||||
</span>
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The tutorial's finished product — a live channel strip with undo.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ZONES = [
|
||||
{ upTo: 0.72, color: '#3df2ad' },
|
||||
{ upTo: 0.9, color: '#ffd23e' },
|
||||
{ upTo: 1, color: '#ff4d6b' },
|
||||
];
|
||||
|
||||
interface StripState {
|
||||
gain: number;
|
||||
pan: number;
|
||||
drive: number;
|
||||
level: number;
|
||||
mute: boolean;
|
||||
}
|
||||
|
||||
const INITIAL: StripState = { gain: 0, pan: 0, drive: 25, level: -10, mute: false };
|
||||
|
||||
const LiveStrip: React.FC = () => {
|
||||
const [state, setState] = React.useState<StripState>(INITIAL);
|
||||
const [undoStack, setUndoStack] = React.useState<StripState[]>([]);
|
||||
const gestureStart = React.useRef<StripState | null>(null);
|
||||
|
||||
// Undo pattern: snapshot on gesture start, push it on gesture end.
|
||||
const beginGesture = () => {
|
||||
gestureStart.current = state;
|
||||
};
|
||||
const endGesture = () => {
|
||||
if (gestureStart.current) {
|
||||
const snap = gestureStart.current;
|
||||
setUndoStack(s => [...s.slice(-19), snap]);
|
||||
gestureStart.current = null;
|
||||
}
|
||||
};
|
||||
const undo = () => {
|
||||
setUndoStack(s => {
|
||||
if (s.length === 0) return s;
|
||||
setState(s[s.length - 1]);
|
||||
return s.slice(0, -1);
|
||||
});
|
||||
};
|
||||
|
||||
const setPart = <K extends keyof StripState>(key: K, v: StripState[K]) =>
|
||||
setState(p => ({ ...p, [key]: v }));
|
||||
|
||||
// A fake signal for the meter: level + drive, dancing a little.
|
||||
const [signal, setSignal] = React.useState(-60);
|
||||
React.useEffect(() => {
|
||||
let t = 0;
|
||||
const id = setInterval(() => {
|
||||
t += 0.13;
|
||||
const base = state.mute ? -60 : state.level + state.drive * 0.1;
|
||||
setSignal(base + Math.sin(t) * 2 + Math.random() * 3);
|
||||
}, 90);
|
||||
return () => clearInterval(id);
|
||||
}, [state.level, state.drive, state.mute]);
|
||||
|
||||
const db = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)}`;
|
||||
|
||||
return (
|
||||
<DreamknobProvider theme={{ accent: '#3df2ad' }}>
|
||||
<div
|
||||
className="console"
|
||||
style={{ gap: 26, alignItems: 'center', padding: '26px 30px' }}
|
||||
>
|
||||
<FlatKnob
|
||||
size={72}
|
||||
value={state.gain}
|
||||
onChange={v => setPart('gain', v)}
|
||||
onChangeStart={beginGesture}
|
||||
onChangeEnd={endGesture}
|
||||
min={-24}
|
||||
max={24}
|
||||
step={0.5}
|
||||
origin={0}
|
||||
detents={[0]}
|
||||
arcFrom="center"
|
||||
editable
|
||||
format={db}
|
||||
label="Gain"
|
||||
aria-label="Strip gain"
|
||||
/>
|
||||
<FlatKnob
|
||||
size={72}
|
||||
value={state.pan}
|
||||
onChange={v => setPart('pan', v)}
|
||||
onChangeStart={beginGesture}
|
||||
onChangeEnd={endGesture}
|
||||
min={-50}
|
||||
max={50}
|
||||
step={1}
|
||||
origin={0}
|
||||
detents={[0]}
|
||||
arcFrom="center"
|
||||
color="#4cc2ff"
|
||||
format={v => (v === 0 ? 'C' : v < 0 ? `L${-v}` : `R${v}`)}
|
||||
label="Pan"
|
||||
aria-label="Strip pan"
|
||||
/>
|
||||
<DriveKnob value={state.drive} onChange={v => setPart('drive', v)} onStart={beginGesture} onEnd={endGesture} />
|
||||
<Fader
|
||||
length={140}
|
||||
value={state.level}
|
||||
onChange={v => setPart('level', v)}
|
||||
onChangeStart={beginGesture}
|
||||
onChangeEnd={endGesture}
|
||||
min={-60}
|
||||
max={12}
|
||||
step={0.5}
|
||||
origin={0}
|
||||
editable
|
||||
format={v => `${db(v)} dB`}
|
||||
label="Level"
|
||||
aria-label="Strip level"
|
||||
/>
|
||||
<Meter
|
||||
value={signal}
|
||||
min={-60}
|
||||
max={12}
|
||||
length={140}
|
||||
zones={ZONES}
|
||||
peakColor="#fff"
|
||||
label="Out"
|
||||
aria-label="Strip output level"
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'center' }}>
|
||||
<AlphaDisplay
|
||||
value={state.mute ? 'MUTED' : `PAN ${state.pan === 0 ? 'C' : state.pan}`}
|
||||
chars={7}
|
||||
height={13}
|
||||
color={state.mute ? '#ff4d6b' : '#3df2ad'}
|
||||
/>
|
||||
<PushButton
|
||||
pressed={state.mute}
|
||||
onChange={m => {
|
||||
beginGesture();
|
||||
setPart('mute', m);
|
||||
endGesture();
|
||||
}}
|
||||
color="#ff4d6b"
|
||||
aria-label="Mute"
|
||||
>
|
||||
Mute
|
||||
</PushButton>
|
||||
<PushButton
|
||||
pressed={false}
|
||||
onChange={undo}
|
||||
led={false}
|
||||
disabled={undoStack.length === 0}
|
||||
aria-label="Undo"
|
||||
>
|
||||
Undo ({undoStack.length})
|
||||
</PushButton>
|
||||
</div>
|
||||
</div>
|
||||
</DreamknobProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/** Tutorial step 4's custom composed knob, used in the live strip. */
|
||||
const DriveKnob: React.FC<{
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
onStart: () => void;
|
||||
onEnd: () => void;
|
||||
}> = ({ value, onChange, onStart, onEnd }) => (
|
||||
<Knob
|
||||
size={72}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onChangeStart={onStart}
|
||||
onChangeEnd={onEnd}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
aria-label="Strip drive"
|
||||
>
|
||||
<Arc
|
||||
thickness={5}
|
||||
trackColor="rgba(255,255,255,0.08)"
|
||||
gradient={[
|
||||
{ at: 0, color: '#3df2ad' },
|
||||
{ at: 0.6, color: '#ffd23e' },
|
||||
{ at: 1, color: '#ff4d6b' },
|
||||
]}
|
||||
/>
|
||||
<Pointer type="line" radius={24} length={9} width={3} color="#e8eaf0" />
|
||||
<KnobValue fontSize={13} color="#e8eaf0" />
|
||||
<text
|
||||
x={36}
|
||||
y={64}
|
||||
textAnchor="middle"
|
||||
fontSize={7.5}
|
||||
fill="rgba(255,255,255,0.45)"
|
||||
letterSpacing="0.08em"
|
||||
style={{ pointerEvents: 'none', textTransform: 'uppercase' }}
|
||||
>
|
||||
Drive
|
||||
</text>
|
||||
</Knob>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A tiny headless example rendered live.
|
||||
// ---------------------------------------------------------------------------
|
||||
const HeadlessDemo: React.FC = () => {
|
||||
const knob = useKnob({ min: 0, max: 11, step: 0.5, defaultValue: 11, 'aria-label': 'Volume (headless)' });
|
||||
return (
|
||||
<div
|
||||
{...knob.bind}
|
||||
style={{ ...knob.bind.style, width: 96, height: 96, position: 'relative', borderRadius: 12, background: '#16181f', border: '1px solid rgba(255,255,255,0.1)', display: 'grid', placeItems: 'center' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 30,
|
||||
transform: `rotate(${knob.angle}deg)`,
|
||||
transition: knob.isDragging ? undefined : 'transform 60ms linear',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
🎚️
|
||||
</div>
|
||||
<span style={{ position: 'absolute', bottom: 6, fontFamily: 'var(--font-mono)', fontSize: 12, color: '#3df2ad' }}>
|
||||
{knob.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AdvancedGuide: React.FC = () => (
|
||||
<section className="block" id="guide">
|
||||
<div className="container prose">
|
||||
<div className="section-kicker">Advanced guide</div>
|
||||
<h2 className="section-title">Tutorial: build a channel strip</h2>
|
||||
<p className="section-sub">
|
||||
Eight steps from a blank file to a themed, undoable channel strip with a custom
|
||||
composed knob and a live meter. The finished result is running at the bottom.
|
||||
</p>
|
||||
|
||||
<Step n={1} title="Theme the whole strip once">
|
||||
<p className="api-note" style={{ marginBottom: 10 }}>
|
||||
Wrap the strip in a provider so every control shares one accent, track, text
|
||||
and font set. Individual props still override.
|
||||
</p>
|
||||
<CodeBlock
|
||||
code={`import { DreamknobProvider } from 'dreamknob'
|
||||
|
||||
function ChannelStrip() {
|
||||
return (
|
||||
<DreamknobProvider theme={{ accent: '#3df2ad' }}>
|
||||
{/* controls go here */}
|
||||
</DreamknobProvider>
|
||||
)
|
||||
}`}
|
||||
/>
|
||||
</Step>
|
||||
|
||||
<Step n={2} title="Bipolar gain: origin + detents + type-in">
|
||||
<p className="api-note" style={{ marginBottom: 10 }}>
|
||||
A ±24 dB trim should fill its arc from <em>0 dB</em>, snap magnetically at 0
|
||||
while dragging, and accept a typed value on double-click. Three props.
|
||||
</p>
|
||||
<CodeBlock
|
||||
code={`<FlatKnob
|
||||
value={gain} onChange={setGain}
|
||||
min={-24} max={24} step={0.5}
|
||||
origin={0} // arc fills from 0, not from -24
|
||||
detents={[0]} // drags snap at 0
|
||||
arcFrom="center"
|
||||
editable // double-click → type "-6" or "3.5"
|
||||
format={v => \`\${v > 0 ? '+' : ''}\${v.toFixed(1)}\`}
|
||||
label="Gain" aria-label="Gain"
|
||||
/>`}
|
||||
/>
|
||||
</Step>
|
||||
|
||||
<Step n={3} title="A pan pot that reads like hardware">
|
||||
<p className="api-note" style={{ marginBottom: 10 }}>
|
||||
<code>format</code> owns the readout entirely — emit <code>L20 / C / R20</code>{' '}
|
||||
while the value stays a clean number for your engine.
|
||||
</p>
|
||||
<CodeBlock
|
||||
code={`<FlatKnob
|
||||
value={pan} onChange={setPan}
|
||||
min={-50} max={50} step={1}
|
||||
origin={0} detents={[0]} arcFrom="center"
|
||||
format={v => (v === 0 ? 'C' : v < 0 ? \`L\${-v}\` : \`R\${v}\`)}
|
||||
label="Pan" aria-label="Pan"
|
||||
/>`}
|
||||
/>
|
||||
</Step>
|
||||
|
||||
<Step n={4} title="Compose a custom knob from primitives">
|
||||
<p className="api-note" style={{ marginBottom: 10 }}>
|
||||
When no skin fits, <code><Knob></code> wires the interaction and the
|
||||
primitives draw. This drive knob uses a gradient arc that heats up toward red —
|
||||
the colors stay anchored in place, only the sweep grows.
|
||||
</p>
|
||||
<CodeBlock
|
||||
code={`import { Knob, Arc, Pointer, KnobValue } from 'dreamknob'
|
||||
|
||||
<Knob size={72} value={drive} onChange={setDrive} min={0} max={100} aria-label="Drive">
|
||||
<Arc
|
||||
thickness={5}
|
||||
trackColor="rgba(255,255,255,0.08)"
|
||||
gradient={[
|
||||
{ at: 0, color: '#3df2ad' },
|
||||
{ at: 0.6, color: '#ffd23e' },
|
||||
{ at: 1, color: '#ff4d6b' },
|
||||
]}
|
||||
/>
|
||||
<Pointer type="line" radius={24} length={9} width={3} color="#e8eaf0" />
|
||||
<KnobValue fontSize={13} />
|
||||
</Knob>`}
|
||||
/>
|
||||
</Step>
|
||||
|
||||
<Step n={5} title="Fader + meter, side by side">
|
||||
<p className="api-note" style={{ marginBottom: 10 }}>
|
||||
The fader is the control; the <code>Meter</code> is the display. Feed the meter
|
||||
your engine's level (here we fake one) — <code>peakHold</code> keeps the
|
||||
loudest recent segment lit.
|
||||
</p>
|
||||
<CodeBlock
|
||||
code={`<Fader
|
||||
value={level} onChange={setLevel}
|
||||
min={-60} max={12} step={0.5} origin={0}
|
||||
editable format={v => \`\${v.toFixed(1)} dB\`}
|
||||
label="Level" aria-label="Level"
|
||||
/>
|
||||
<Meter
|
||||
value={engineLevel} // whatever your audio engine reports
|
||||
min={-60} max={12}
|
||||
zones={[
|
||||
{ upTo: 0.72, color: '#3df2ad' },
|
||||
{ upTo: 0.9, color: '#ffd23e' },
|
||||
{ upTo: 1, color: '#ff4d6b' },
|
||||
]}
|
||||
peakColor="#fff"
|
||||
aria-label="Output level"
|
||||
/>`}
|
||||
/>
|
||||
</Step>
|
||||
|
||||
<Step n={6} title="Mute, status display, matching hardware">
|
||||
<CodeBlock
|
||||
code={`<PushButton pressed={mute} onChange={setMute} color="#ff4d6b" aria-label="Mute">
|
||||
Mute
|
||||
</PushButton>
|
||||
<AlphaDisplay value={mute ? 'MUTED' : 'ACTIVE'} chars={7} height={13} />`}
|
||||
/>
|
||||
</Step>
|
||||
|
||||
<Step n={7} title="Undo that actually works">
|
||||
<p className="api-note" style={{ marginBottom: 10 }}>
|
||||
<code>onChangeStart</code>/<code>onChangeEnd</code> bracket every gesture —
|
||||
drag, wheel burst, key press, reset. Snapshot on start, push on end: one undo
|
||||
entry per gesture instead of one per pixel. <code>meta.source</code> tells you
|
||||
which input caused it if you need finer policy.
|
||||
</p>
|
||||
<CodeBlock
|
||||
code={`const gestureStart = useRef(null)
|
||||
|
||||
const beginGesture = () => { gestureStart.current = state }
|
||||
const endGesture = () => {
|
||||
if (gestureStart.current) {
|
||||
pushUndo(gestureStart.current)
|
||||
gestureStart.current = null
|
||||
}
|
||||
}
|
||||
|
||||
<FlatKnob
|
||||
value={state.gain}
|
||||
onChange={v => setState(s => ({ ...s, gain: v }))}
|
||||
onChangeStart={beginGesture}
|
||||
onChangeEnd={endGesture}
|
||||
...
|
||||
/>`}
|
||||
/>
|
||||
</Step>
|
||||
|
||||
<Step n={8} title="Or skip our rendering entirely">
|
||||
<p className="api-note" style={{ marginBottom: 10 }}>
|
||||
<code>useKnob</code> gives you the state and the event bindings; you bring the
|
||||
DOM. Everything — rotary drag, wheel, keyboard, Escape, a11y — still works.
|
||||
This emoji is a fully functional knob:
|
||||
</p>
|
||||
<CodeBlock
|
||||
code={`const knob = useKnob({ min: 0, max: 11, step: 0.5, defaultValue: 11, 'aria-label': 'Volume' })
|
||||
|
||||
<div {...knob.bind} style={{ ...knob.bind.style, width: 96, height: 96 }}>
|
||||
<div style={{ transform: \`rotate(\${knob.angle}deg)\` }}>🎚️</div>
|
||||
<span>{knob.value}</span>
|
||||
</div>`}
|
||||
/>
|
||||
<div className="preview" style={{ minHeight: 140, marginTop: 12 }}>
|
||||
<HeadlessDemo />
|
||||
</div>
|
||||
</Step>
|
||||
|
||||
<h3 className="api-h">The finished strip</h3>
|
||||
<p className="api-note" style={{ marginBottom: 14 }}>
|
||||
Everything above, assembled — themed, undoable (try dragging then hitting Undo),
|
||||
with a live meter. All state lives in one plain React object.
|
||||
</p>
|
||||
<LiveStrip />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
@ -36,8 +36,9 @@ export const ApiDocs: React.FC = () => (
|
|||
<h2 className="section-title">API reference</h2>
|
||||
<p className="section-sub">
|
||||
Everything is built on one interaction core. The props below are shared by{' '}
|
||||
<code>useKnob</code>, <code><Knob></code>, every prebuilt skin and{' '}
|
||||
<code><Fader></code>.
|
||||
<code>useKnob</code>, <code><Knob></code>, every prebuilt knob skin and the
|
||||
faders. Rows marked ◇ are component-level (handled by the wrappers, not by{' '}
|
||||
<code>useKnob</code>).
|
||||
</p>
|
||||
|
||||
<h3 className="api-h">Core value & interaction props</h3>
|
||||
|
|
@ -46,7 +47,7 @@ export const ApiDocs: React.FC = () => (
|
|||
<Row name="defaultValue" type="number" desc="Uncontrolled initial value; also the double-click reset target." />
|
||||
<Row name="min / max" type="number" def="0 / 100" desc="Value range. Any numbers, including negative and fractional." />
|
||||
<Row name="step" type="number" def="0 (continuous)" desc="Snap increment, e.g. 1, 0.5, 0.001. Decimal steps set display precision automatically." />
|
||||
<Row name="decimals" type="number" def="from step" desc="Decimal places of emitted values (float-drift-safe rounding)." />
|
||||
<Row name="decimals" type="number" def="from step (3 if continuous)" desc="Decimal places of emitted values (float-drift-safe rounding)." />
|
||||
<Row name="values" type="number[]" desc="Restrict to a discrete list, e.g. [0.25, 0.5, 1, 2, 4]. Overrides step." />
|
||||
<Row name="steps" type="number" desc="Number of evenly spaced detents across the travel (selector knobs)." />
|
||||
<Row name="taper" type="Taper" def="linearTaper" desc="Travel curve: linearTaper, logTaper (frequencies), powTaper(n) (gain), or your own." />
|
||||
|
|
@ -54,24 +55,25 @@ export const ApiDocs: React.FC = () => (
|
|||
<Row name="detents / detentSize" type="number[] / number" def="— / 0.025" desc="Magnetic snap points: drags stick to these values within the capture radius. Wheel/keyboard unaffected." />
|
||||
<Row name="wrap" type="boolean" def="false" desc="Endless encoder: values roll around min↔max. Pair with angleOffset={0} angleRange={360}." />
|
||||
<Row name="animateChanges" type="boolean | {duration}" def="false" desc="Tween the pointer on programmatic changes (preset loads). Gestures never animate; respects prefers-reduced-motion." />
|
||||
<Row name="valueBubble / bubbleFormat" type="boolean / fn" def="false" desc="Floating readout above the control while dragging." />
|
||||
<Row name="interaction" type="InteractionMode" def="'rotary'" desc="'rotary' (grab & turn), 'vertical', 'horizontal', 'both', or absolute 'track-*' modes used by faders." />
|
||||
<Row name="rotaryMode" type="RotaryMode" def="'absolute'" desc="'absolute' tracks the pointer angle; 'relative' applies angle deltas (no grab jump); 'pickup' engages once the pointer sweeps past the value (MIDI pickup)." />
|
||||
<Row name="dragSensitivity" type="number" def="200" desc="Pixels of relative drag for full travel (vertical/horizontal modes)." />
|
||||
<Row name="trackInset" type="number" def="0" desc="Dead margin (px) at each end in track-* modes so pointer mapping matches a handle's travel (the faders set this for you)." />
|
||||
<Row name="dragAcceleration" type="number" def="0" desc="Velocity gain for relative drags: fast flicks cover more range, slow drags stay precise. Shift bypasses it." />
|
||||
<Row name="hideCursorOnDrag" type="boolean" def="false" desc="Hide the mouse cursor for the duration of a drag." />
|
||||
<Row name="fineMultiplier" type="number" def="0.1" desc="Speed multiplier while Shift is held during drags and wheel." />
|
||||
<Row name="fineMultiplier" type="number" def="0.1" desc="Speed multiplier while Shift is held during relative drags and wheel (continuous values — snapped controls always move whole steps)." />
|
||||
<Row name="angleOffset / angleRange" type="number" def="225 / 270" desc="Where travel starts and how far it sweeps, in degrees clockwise from 12 o'clock." />
|
||||
<Row name="enableWheel" type="boolean" def="true" desc="Scroll-wheel nudging. Trackpad deltas accumulate into notches so a flick can't rocket the value." />
|
||||
<Row name="wheelStep" type="number" def="step" desc="Value change per wheel notch, independent of the snapping step." />
|
||||
<Row name="wheelStep" type="number" def="step (else 1% of range)" desc="Value change per wheel notch, independent of the snapping step." />
|
||||
<Row name="wheelRequiresFocus" type="boolean" def="false" desc="Only respond to the wheel while focused — hovering no longer captures page scroll." />
|
||||
<Row name="doubleClickReset" type="boolean" def="auto" desc="Double-click resets to defaultValue." />
|
||||
<Row name="onChange" type="(v, meta) => void" desc="Fires for every distinct snapped value. meta.source tells you what caused it: 'drag' | 'wheel' | 'keyboard' | 'reset' | 'api'." />
|
||||
<Row name="onChangeStart / onChangeEnd" type="(v, meta) => void" desc="Gesture boundaries (drag, wheel burst, key press, reset) — ideal for undo history or parameter automation." />
|
||||
<Row name="name" type="string" desc="Renders a hidden form input carrying the value — plain <form> posts and form libraries just work." />
|
||||
<Row name="◇ name" type="string" desc="Renders a hidden form input carrying the value — plain <form> posts and form libraries just work (XYPad uses nameX/nameY)." />
|
||||
<Row name="disabled / readOnly" type="boolean" def="false" desc="Disable interaction (readOnly stays focusable)." />
|
||||
<Row name="editable / parseValue" type="boolean / fn" def="false" desc="Type-in editing: double-click a knob (or click a fader readout), type a value, Enter commits, Esc cancels. '1.2k' → 1200." />
|
||||
<Row name="focusRing" type="string | false" def="theme" desc="Keyboard focus ring color. Pointer grabs never show it." />
|
||||
<Row name="◇ editable / parseValue" type="boolean / fn" def="false" desc="Type-in editing: double-click a knob (or click a fader readout), type a value, Enter commits, Esc cancels. '1.2k' → 1200." />
|
||||
<Row name="◇ focusRing" type="string | false" def="theme" desc="Keyboard focus ring color. Pointer grabs never show it." />
|
||||
<Row name="◇ valueBubble / bubbleFormat" type="boolean / fn" def="false" desc="Floating readout above knobs while dragging (knob skins + ImageKnob)." />
|
||||
<Row name="aria-label / getAriaValueText" type="string / fn" desc="Accessibility. Every knob is a proper role='slider' with full keyboard support." />
|
||||
</Table>
|
||||
|
||||
|
|
@ -80,9 +82,9 @@ export const ApiDocs: React.FC = () => (
|
|||
<code>↑/→</code> and <code>↓/←</code> step · <code>Shift</code>+arrows jump 10×
|
||||
· <code>PageUp/PageDown</code> move 10% · <code>Home/End</code> jump to min/max ·
|
||||
<code>Esc</code> cancels a drag and restores the start value · scroll wheel nudges
|
||||
(Shift = fine) · double-click resets (or opens the editor when{' '}
|
||||
<code>editable</code>) · touch works via pointer capture · every control shows a
|
||||
keyboard-only focus ring.
|
||||
(Shift = fine on continuous values; snapped controls always move whole steps) ·
|
||||
double-click resets (or opens the editor when <code>editable</code>) · touch works
|
||||
via pointer capture · every control shows a keyboard-only focus ring.
|
||||
</p>
|
||||
|
||||
<h3 className="api-h">Prebuilt skins</h3>
|
||||
|
|
@ -104,12 +106,16 @@ export const ApiDocs: React.FC = () => (
|
|||
<Row name="<SegmentDisplay />" type="digital" desc="Standalone seven-segment display. digits, decimals, color, glow, skew, ghostOpacity." />
|
||||
</Table>
|
||||
<p className="api-note">
|
||||
All skins also take <code>label</code>, <code>showValue</code>, <code>unit</code>,{' '}
|
||||
<code>format</code>, <code>className</code>, <code>style</code>, <code>name</code>{' '}
|
||||
plus every core prop above, and forward a ref to their root element. Inside the
|
||||
SVG, parts carry <code>data-part</code> attributes (<code>arc</code>,{' '}
|
||||
<code>track</code>, <code>pointer</code>, <code>ticks</code>, <code>face</code>,{' '}
|
||||
<code>value</code>, <code>label</code>) and the root exposes{' '}
|
||||
Every knob skin and fader also takes <code>label</code>, <code>showValue</code>,{' '}
|
||||
<code>unit</code>, <code>format</code>, <code>className</code>,{' '}
|
||||
<code>style</code>, <code>name</code> plus every core prop above, and forwards a
|
||||
ref to its root element (<code>VintageKnob</code> has no readout, so it omits{' '}
|
||||
<code>showValue</code>/<code>unit</code>/<code>format</code>; the display
|
||||
components <code>Meter</code>/<code>SegmentDisplay</code>/<code>AlphaDisplay</code>{' '}
|
||||
have their own smaller prop sets). Primitive-drawn parts carry{' '}
|
||||
<code>data-part</code> attributes (<code>arc</code>, <code>track</code>,{' '}
|
||||
<code>pointer</code>, <code>ticks</code>, <code>face</code>, <code>value</code>,{' '}
|
||||
<code>label</code>) and every interactive root exposes{' '}
|
||||
<code>data-dragging</code>/<code>data-disabled</code>/<code>data-focus-visible</code>{' '}
|
||||
— style any state or part with plain CSS. <code><Arc></code> additionally
|
||||
accepts <code>gradient</code> (position-anchored color stops).
|
||||
|
|
|
|||
|
|
@ -165,9 +165,9 @@ export const Gallery: React.FC = () => (
|
|||
<div className="section-kicker">Gallery</div>
|
||||
<h2 className="section-title">Every style in the studio</h2>
|
||||
<p className="section-sub">
|
||||
Nine prebuilt skins — every color themeable per instance. All of them share the
|
||||
same interaction core: rotary or drag adjustment, scroll-wheel nudge, keyboard,
|
||||
fine control and double-click reset.
|
||||
A full rack of prebuilt controls — every color themeable per instance. All of
|
||||
them share the same interaction core: rotary or drag adjustment, scroll-wheel
|
||||
nudge, keyboard, fine control and double-click reset.
|
||||
</p>
|
||||
|
||||
<div className="grid">
|
||||
|
|
@ -341,7 +341,7 @@ export const Gallery: React.FC = () => (
|
|||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Meter" desc="Read-only LED metering with peak hold — the display sibling of LEDFader." tag="<Meter peakHold />">
|
||||
<Card title="Meter" desc="Read-only LED metering with peak hold — the display sibling of LEDFader." tag="<Meter peakHold={1200} />">
|
||||
<MeterDemo />
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
155
apps/docs/src/sections/GettingStarted.tsx
Normal file
155
apps/docs/src/sections/GettingStarted.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import React from 'react';
|
||||
import { FlatKnob, logTaper } from 'dreamknob';
|
||||
import { CodeBlock } from '../components/CodeBlock';
|
||||
|
||||
const Step: React.FC<{ n: number; title: string; children: React.ReactNode }> = ({
|
||||
n,
|
||||
title,
|
||||
children,
|
||||
}) => (
|
||||
<div style={{ marginBottom: 34 }}>
|
||||
<h3 className="api-h" style={{ marginTop: 0 }}>
|
||||
<span style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', marginRight: 10 }}>
|
||||
{String(n).padStart(2, '0')}
|
||||
</span>
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const GettingStarted: React.FC = () => (
|
||||
<section className="block" id="start">
|
||||
<div className="container prose">
|
||||
<div className="section-kicker">Getting started</div>
|
||||
<h2 className="section-title">Your first knob in five minutes</h2>
|
||||
<p className="section-sub">
|
||||
Everything below is copy-pasteable. The only rule to remember: every control needs
|
||||
an <code>aria-label</code> (or a visible label wired via <code>aria-labelledby</code>).
|
||||
</p>
|
||||
|
||||
<Step n={1} title="Install">
|
||||
<CodeBlock code={`npm install dreamknob\n# peer deps: react >= 18, react-dom >= 18`} />
|
||||
<p className="api-note" style={{ marginTop: 10 }}>
|
||||
Zero runtime dependencies, ESM + CJS, full TypeScript types. No CSS file to
|
||||
import — everything is SVG and inline styles.
|
||||
</p>
|
||||
</Step>
|
||||
|
||||
<Step n={2} title="Drop in a knob (uncontrolled)">
|
||||
<p className="api-note" style={{ marginBottom: 10 }}>
|
||||
Pass <code>defaultValue</code> and the knob manages its own state.{' '}
|
||||
<code>defaultValue</code> is also the double-click reset target.
|
||||
</p>
|
||||
<CodeBlock
|
||||
code={`import { FlatKnob } from 'dreamknob'
|
||||
|
||||
<FlatKnob
|
||||
size={90}
|
||||
min={0} max={100} step={1}
|
||||
defaultValue={50}
|
||||
label="Level" unit="%"
|
||||
aria-label="Level"
|
||||
/>`}
|
||||
/>
|
||||
<div className="preview" style={{ minHeight: 150, marginTop: 12 }}>
|
||||
<FlatKnob size={90} min={0} max={100} step={1} defaultValue={50} label="Level" unit="%" aria-label="Level" />
|
||||
</div>
|
||||
</Step>
|
||||
|
||||
<Step n={3} title="Read the value (controlled)">
|
||||
<p className="api-note" style={{ marginBottom: 10 }}>
|
||||
For app state, pass <code>value</code> + <code>onChange</code>. The second
|
||||
argument tells you what caused the change — useful later for undo/automation.
|
||||
</p>
|
||||
<CodeBlock
|
||||
code={`const [gain, setGain] = useState(-6)
|
||||
|
||||
<FlatKnob
|
||||
value={gain}
|
||||
onChange={(v, meta) => setGain(v)} // meta.source: 'drag' | 'wheel' | 'keyboard' | 'reset' | 'api'
|
||||
min={-60} max={12} step={0.5}
|
||||
unit=" dB"
|
||||
aria-label="Gain"
|
||||
/>`}
|
||||
/>
|
||||
</Step>
|
||||
|
||||
<Step n={4} title="Pick the feel">
|
||||
<p className="api-note" style={{ marginBottom: 10 }}>
|
||||
The default is grab-and-rotate (the pointer angle drives the value). Prefer the
|
||||
up/down plugin feel, or MIDI-style pickup? One prop each. Users always get
|
||||
scroll-wheel, arrow keys, Shift-fine, Escape-cancel and double-click reset for
|
||||
free.
|
||||
</p>
|
||||
<CodeBlock
|
||||
code={`<FlatKnob interaction="vertical" ... /> // drag up/down like a plugin
|
||||
<FlatKnob rotaryMode="pickup" ... /> // never jumps when grabbed off-position
|
||||
<FlatKnob rotaryMode="relative" ... /> // angle deltas, no jump either`}
|
||||
/>
|
||||
</Step>
|
||||
|
||||
<Step n={5} title="Get the numbers right">
|
||||
<p className="api-note" style={{ marginBottom: 10 }}>
|
||||
<code>step</code> snaps and sets display precision (float-safe — no{' '}
|
||||
<code>0.30000000000000004</code>). For audio ranges, use a taper:{' '}
|
||||
<code>logTaper</code> gives equal ratios per turn, so 20 Hz–20 kHz feels right.
|
||||
<code>format</code> controls display without touching the emitted value.
|
||||
</p>
|
||||
<CodeBlock
|
||||
code={`import { FlatKnob, logTaper } from 'dreamknob'
|
||||
|
||||
<FlatKnob
|
||||
min={20} max={20000}
|
||||
taper={logTaper} // min must be > 0 for log
|
||||
defaultValue={440}
|
||||
decimals={0}
|
||||
format={v => v >= 1000 ? \`\${(v / 1000).toFixed(1)}k\` : \`\${Math.round(v)}\`}
|
||||
label="Freq"
|
||||
aria-label="Frequency"
|
||||
/>`}
|
||||
/>
|
||||
<div className="preview" style={{ minHeight: 150, marginTop: 12 }}>
|
||||
<FlatKnob
|
||||
size={90}
|
||||
min={20}
|
||||
max={20000}
|
||||
taper={logTaper}
|
||||
defaultValue={440}
|
||||
decimals={0}
|
||||
color="#3df2ad"
|
||||
format={v => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${Math.round(v)}`)}
|
||||
label="Freq"
|
||||
aria-label="Frequency example"
|
||||
/>
|
||||
</div>
|
||||
</Step>
|
||||
|
||||
<Step n={6} title="Theme once, restyle everything">
|
||||
<CodeBlock
|
||||
code={`import { DreamknobProvider } from 'dreamknob'
|
||||
|
||||
<DreamknobProvider base="dark" theme={{ accent: '#ff4d6b' }}>
|
||||
{/* every knob/fader/meter below inherits the tokens */}
|
||||
</DreamknobProvider>`}
|
||||
/>
|
||||
<p className="api-note" style={{ marginTop: 10 }}>
|
||||
Use <code>base="light"</code> on light backgrounds. Per-instance color props
|
||||
always win over the theme.
|
||||
</p>
|
||||
</Step>
|
||||
|
||||
<Step n={7} title="Gotchas checklist">
|
||||
<p className="api-note">
|
||||
① Always pass <code>aria-label</code>. ② <code>logTaper</code> needs{' '}
|
||||
<code>min > 0</code> (it falls back to linear otherwise). ③ Controlled mode
|
||||
means <em>you</em> own the value — if you don't call <code>setState</code> in{' '}
|
||||
<code>onChange</code>, the knob won't move. ④ Hovering captures the scroll
|
||||
wheel by default; pass <code>wheelRequiresFocus</code> if your UI scrolls. ⑤
|
||||
The knob is a <code>div</code>, not an input — use <code>name="…"</code> if you
|
||||
need it in a plain <code><form></code> post.
|
||||
</p>
|
||||
</Step>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
@ -64,8 +64,8 @@ export const Hero: React.FC = () => {
|
|||
/>
|
||||
</div>
|
||||
<div className="rack-hint">
|
||||
drag to rotate · scroll to nudge · <kbd>⇧</kbd> for fine control · double-click to
|
||||
reset · arrow keys work too
|
||||
drag to rotate · scroll to nudge · <kbd>⇧</kbd>+scroll for fine control ·
|
||||
double-click to reset · arrow keys work too
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,10 +11,22 @@ import {
|
|||
VintageKnob,
|
||||
logTaper,
|
||||
powTaper,
|
||||
type InteractionMode,
|
||||
} from 'dreamknob';
|
||||
import { CodeBlock } from '../components/CodeBlock';
|
||||
|
||||
/** Playground "drag feel" — maps to interaction + rotaryMode core props. */
|
||||
type Feel = 'rotary' | 'pickup' | 'relative' | 'vertical' | 'horizontal' | 'both';
|
||||
|
||||
const feelToProps = (feel: Feel) => ({
|
||||
interaction: (feel === 'vertical' || feel === 'horizontal' || feel === 'both'
|
||||
? feel
|
||||
: 'rotary') as 'rotary' | 'vertical' | 'horizontal' | 'both',
|
||||
rotaryMode: (feel === 'pickup' || feel === 'relative' ? feel : 'absolute') as
|
||||
| 'absolute'
|
||||
| 'pickup'
|
||||
| 'relative',
|
||||
});
|
||||
|
||||
type SkinId =
|
||||
| 'flat'
|
||||
| 'metal'
|
||||
|
|
@ -44,7 +56,7 @@ interface Config {
|
|||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
interaction: InteractionMode;
|
||||
feel: Feel;
|
||||
angleRange: number;
|
||||
taper: 'linear' | 'log' | 'pow2';
|
||||
color: string;
|
||||
|
|
@ -61,7 +73,7 @@ const DEFAULTS: Config = {
|
|||
min: 0,
|
||||
max: 100,
|
||||
step: 0.5,
|
||||
interaction: 'rotary',
|
||||
feel: 'rotary',
|
||||
angleRange: 270,
|
||||
taper: 'linear',
|
||||
color: '#4cc2ff',
|
||||
|
|
@ -73,8 +85,10 @@ const DEFAULTS: Config = {
|
|||
};
|
||||
|
||||
const buildCode = (c: Config, controlled: boolean): string => {
|
||||
const linear = c.skin === 'fader' || c.skin === 'ledfader';
|
||||
const noReadout = c.skin === 'vintage';
|
||||
const props: string[] = [];
|
||||
if (c.skin === 'fader' || c.skin === 'ledfader') props.push(`length={${c.size * 1.4}}`);
|
||||
if (linear) props.push(`length={${c.size * 1.4}}`);
|
||||
else props.push(`size={${c.size}}`);
|
||||
if (controlled) props.push('value={value}', 'onChange={setValue}');
|
||||
else props.push(`defaultValue={${(c.min + c.max) / 2}}`);
|
||||
|
|
@ -83,12 +97,18 @@ const buildCode = (c: Config, controlled: boolean): string => {
|
|||
if (c.step > 0) props.push(`step={${c.step}}`);
|
||||
if (c.taper === 'log') props.push('taper={logTaper}');
|
||||
if (c.taper === 'pow2') props.push('taper={powTaper(2)}');
|
||||
if (c.interaction !== 'rotary') props.push(`interaction="${c.interaction}"`);
|
||||
if (c.angleRange !== 270) props.push(`angleRange={${c.angleRange}}`);
|
||||
// Linear controls own their interaction and geometry — those props don't apply.
|
||||
if (!linear) {
|
||||
const feel = feelToProps(c.feel);
|
||||
if (feel.interaction !== 'rotary') props.push(`interaction="${feel.interaction}"`);
|
||||
if (feel.rotaryMode !== 'absolute') props.push(`rotaryMode="${feel.rotaryMode}"`);
|
||||
if (c.angleRange !== 270) props.push(`angleRange={${c.angleRange}}`);
|
||||
}
|
||||
props.push(`color="${c.color}"`);
|
||||
if (c.label) props.push(`label="${c.label}"`);
|
||||
if (c.unit) props.push(`unit="${c.unit}"`);
|
||||
if (c.editable) props.push('editable');
|
||||
if (c.unit && !noReadout) props.push(`unit="${c.unit}"`);
|
||||
if (!c.showValue && !noReadout) props.push('showValue={false}');
|
||||
if (c.editable && !noReadout) props.push('editable');
|
||||
if (c.skin === 'flat' && c.arcFrom !== 'min') props.push(`arcFrom="center"`);
|
||||
if (c.skin === 'stepped') props.push(`steps={5}`);
|
||||
const name = SKINS[c.skin];
|
||||
|
|
@ -112,6 +132,7 @@ export const Playground: React.FC = () => {
|
|||
|
||||
const taper = cfg.taper === 'log' ? logTaper : cfg.taper === 'pow2' ? powTaper(2) : undefined;
|
||||
const logInvalid = cfg.taper === 'log' && cfg.min <= 0;
|
||||
const linear = cfg.skin === 'fader' || cfg.skin === 'ledfader';
|
||||
|
||||
const common = {
|
||||
value,
|
||||
|
|
@ -121,7 +142,7 @@ export const Playground: React.FC = () => {
|
|||
max: cfg.max,
|
||||
step: cfg.step > 0 ? cfg.step : undefined,
|
||||
taper: logInvalid ? undefined : taper,
|
||||
interaction: cfg.interaction,
|
||||
...feelToProps(cfg.feel),
|
||||
angleRange: cfg.angleRange,
|
||||
color: cfg.color,
|
||||
label: cfg.label || undefined,
|
||||
|
|
@ -218,12 +239,15 @@ export const Playground: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
<div className="control">
|
||||
<label>Drag feel</label>
|
||||
<label>Drag feel {linear && '· n/a for faders'}</label>
|
||||
<select
|
||||
value={cfg.interaction}
|
||||
onChange={e => set('interaction', e.target.value as InteractionMode)}
|
||||
value={cfg.feel}
|
||||
disabled={linear}
|
||||
onChange={e => set('feel', e.target.value as Feel)}
|
||||
>
|
||||
<option value="rotary">rotary (grab & turn)</option>
|
||||
<option value="pickup">rotary · pickup (no jump)</option>
|
||||
<option value="relative">rotary · relative</option>
|
||||
<option value="vertical">vertical drag</option>
|
||||
<option value="horizontal">horizontal drag</option>
|
||||
<option value="both">vertical + horizontal</option>
|
||||
|
|
|
|||
330
apps/docs/src/sections/Synth.tsx
Normal file
330
apps/docs/src/sections/Synth.tsx
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
AlphaDisplay,
|
||||
Fader,
|
||||
FlatKnob,
|
||||
LEDKnob,
|
||||
Meter,
|
||||
NeonKnob,
|
||||
PushButton,
|
||||
RubberKnob,
|
||||
SteppedKnob,
|
||||
logTaper,
|
||||
powTaper,
|
||||
} from 'dreamknob';
|
||||
|
||||
const NOTES = [
|
||||
{ name: 'C3', freq: 130.81 },
|
||||
{ name: 'D3', freq: 146.83 },
|
||||
{ name: 'E3', freq: 164.81 },
|
||||
{ name: 'G3', freq: 196.0 },
|
||||
{ name: 'A3', freq: 220.0 },
|
||||
{ name: 'C4', freq: 261.63 },
|
||||
{ name: 'D4', freq: 293.66 },
|
||||
{ name: 'E4', freq: 329.63 },
|
||||
];
|
||||
|
||||
const WAVES: OscillatorType[] = ['sawtooth', 'square', 'triangle'];
|
||||
|
||||
interface Params {
|
||||
wave: number;
|
||||
cutoff: number;
|
||||
res: number;
|
||||
attack: number; // ms
|
||||
release: number; // ms
|
||||
lfoRate: number;
|
||||
lfoDepth: number;
|
||||
master: number; // dB
|
||||
}
|
||||
|
||||
interface Engine {
|
||||
ctx: AudioContext;
|
||||
osc: OscillatorNode;
|
||||
env: GainNode;
|
||||
filter: BiquadFilterNode;
|
||||
lfo: OscillatorNode;
|
||||
lfoGain: GainNode;
|
||||
master: GainNode;
|
||||
analyser: AnalyserNode;
|
||||
}
|
||||
|
||||
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 [note, setNote] = React.useState<string | null>(null);
|
||||
const [meterDb, setMeterDb] = React.useState(-60);
|
||||
const engine = React.useRef<Engine | 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 }));
|
||||
|
||||
// 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 env = ctx.createGain();
|
||||
env.gain.value = 0;
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.value = p.cutoff;
|
||||
filter.Q.value = p.res;
|
||||
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 master = ctx.createGain();
|
||||
master.gain.value = Math.pow(10, p.master / 20);
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 1024;
|
||||
|
||||
osc.connect(env);
|
||||
env.connect(filter);
|
||||
filter.connect(master);
|
||||
master.connect(analyser);
|
||||
analyser.connect(ctx.destination);
|
||||
lfo.connect(lfoGain);
|
||||
lfoGain.connect(filter.frequency);
|
||||
osc.start();
|
||||
lfo.start();
|
||||
engine.current = { ctx, osc, env, filter, lfo, lfoGain, master, analyser };
|
||||
|
||||
// Meter: RMS of the output, throttled to ~25 fps.
|
||||
const buf = new Float32Array(analyser.fftSize);
|
||||
let raf = 0;
|
||||
let last = 0;
|
||||
const tick = (t: number) => {
|
||||
raf = requestAnimationFrame(tick);
|
||||
if (t - last < 40) return;
|
||||
last = t;
|
||||
analyser.getFloatTimeDomainData(buf);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i];
|
||||
const rms = Math.sqrt(sum / buf.length);
|
||||
setMeterDb(rms > 0 ? Math.max(-60, 20 * Math.log10(rms)) : -60);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
engine.current = null;
|
||||
void ctx.close();
|
||||
setMeterDb(-60);
|
||||
setNote(null);
|
||||
};
|
||||
}, [powered]);
|
||||
|
||||
// Live parameter updates.
|
||||
React.useEffect(() => {
|
||||
const e = engine.current;
|
||||
if (!e) return;
|
||||
const t = e.ctx.currentTime;
|
||||
e.osc.type = WAVES[params.wave];
|
||||
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.master.gain.setTargetAtTime(Math.pow(10, params.master / 20), t, 0.015);
|
||||
}, [params]);
|
||||
|
||||
const noteOn = (freq: number, name: string) => {
|
||||
const e = engine.current;
|
||||
if (!e) return;
|
||||
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);
|
||||
setNote(name);
|
||||
};
|
||||
|
||||
const noteOff = () => {
|
||||
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);
|
||||
setNote(null);
|
||||
};
|
||||
|
||||
const hz = (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${Math.round(v)}`);
|
||||
|
||||
return (
|
||||
<section className="block" id="synth">
|
||||
<div className="container">
|
||||
<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.
|
||||
</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"
|
||||
>
|
||||
Power
|
||||
</PushButton>
|
||||
<AlphaDisplay
|
||||
value={!powered ? 'OFF' : (note ?? 'READY')}
|
||||
chars={6}
|
||||
align="right"
|
||||
height={18}
|
||||
color={powered ? '#3df2ad' : '#3a3f46'}
|
||||
/>
|
||||
<SteppedKnob
|
||||
size={76}
|
||||
positions={['SAW', 'SQR', 'TRI']}
|
||||
value={params.wave}
|
||||
onChange={v => set('wave', v)}
|
||||
color="#ffd23e"
|
||||
label="Osc"
|
||||
aria-label="Oscillator wave"
|
||||
/>
|
||||
<RubberKnob
|
||||
size={76}
|
||||
min={60}
|
||||
max={12000}
|
||||
taper={logTaper}
|
||||
decimals={0}
|
||||
value={params.cutoff}
|
||||
onChange={v => set('cutoff', v)}
|
||||
showValue
|
||||
format={hz}
|
||||
color="#ff9640"
|
||||
label="Cutoff"
|
||||
aria-label="Filter cutoff"
|
||||
/>
|
||||
<FlatKnob
|
||||
size={76}
|
||||
min={0}
|
||||
max={24}
|
||||
step={0.5}
|
||||
value={params.res}
|
||||
onChange={v => set('res', v)}
|
||||
color="#ff9640"
|
||||
label="Res"
|
||||
aria-label="Filter resonance"
|
||||
/>
|
||||
<FlatKnob
|
||||
size={76}
|
||||
min={1}
|
||||
max={1000}
|
||||
taper={powTaper(2.5)}
|
||||
decimals={0}
|
||||
value={params.attack}
|
||||
onChange={v => set('attack', v)}
|
||||
unit="ms"
|
||||
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"
|
||||
/>
|
||||
<LEDKnob
|
||||
size={76}
|
||||
min={0.1}
|
||||
max={20}
|
||||
taper={logTaper}
|
||||
decimals={1}
|
||||
value={params.lfoRate}
|
||||
onChange={v => set('lfoRate', v)}
|
||||
digits={4}
|
||||
displayDecimals={1}
|
||||
color="#3df2ad"
|
||||
label="LFO Hz"
|
||||
aria-label="LFO rate"
|
||||
/>
|
||||
<NeonKnob
|
||||
size={76}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={params.lfoDepth}
|
||||
onChange={v => set('lfoDepth', v)}
|
||||
color="#e44cff"
|
||||
label="Depth"
|
||||
aria-label="LFO depth"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
{NOTES.map(n => (
|
||||
<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>
|
||||
<div className="rack-hint" style={{ marginTop: 0 }}>
|
||||
power on · hold a pad · sweep the cutoff while a note rings
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue