import { useState, useRef, useCallback, useEffect, useMemo } from 'preact/hooks';
import BrowserOnly from '../browser-only';
import { useAudioContext } from './use-audio-context';
import WaveformDisplay from './waveform-display';
import TimeScaleControls from './time-scale-controls';
import useScopeWorklet from './use-scope-worklet';
import { voltageToFrequency } from './constants';
import { stopSource, disconnectNode } from './cleanup-nodes';
import ButtonGroup from './button-group';
import { adStyles } from './audio-demo-styles';
type ScaleType = 'major' | 'minor' | 'pentatonic';
type RootNote = 'C' | 'D' | 'E' | 'F' | 'G' | 'A' | 'B';
const ROOT_OFFSETS: Record<RootNote, number> = {
C: 0,
D: 2,
E: 4,
F: 5,
G: 7,
A: 9,
B: 11,
};
const SCALE_INTERVALS: Record<ScaleType, number[]> = {
major: [0, 2, 4, 5, 7, 9, 11],
minor: [0, 2, 3, 5, 7, 8, 10],
pentatonic: [0, 2, 4, 7, 9],
};
const LFO_FREQ = 0.3;
const SH_RATE = 4;
const WHITE_KEYS = [
{ semitone: 0, label: 'C', x: 0 },
{ semitone: 2, label: 'D', x: 50 },
{ semitone: 4, label: 'E', x: 100 },
{ semitone: 5, label: 'F', x: 150 },
{ semitone: 7, label: 'G', x: 200 },
{ semitone: 9, label: 'A', x: 250 },
{ semitone: 11, label: 'B', x: 300 },
];
const BLACK_KEYS = [
{ semitone: 1, x: 33 },
{ semitone: 3, x: 83 },
{ semitone: 6, x: 183 },
{ semitone: 8, x: 233 },
{ semitone: 10, x: 283 },
];
function quantizeVoltage(voltage: number, scale: ScaleType, root: RootNote): number {
const semitoneValue = voltage * 12;
const intervals = SCALE_INTERVALS[scale];
const rootOffset = ROOT_OFFSETS[root];
let bestSemitone = 0;
let minDistance = Number.POSITIVE_INFINITY;
for (let octave = -1; octave <= 4; octave += 1) {
for (const step of intervals) {
const semitone = octave * 12 + step + rootOffset;
const distance = Math.abs(semitoneValue - semitone);
if (distance < minDistance) {
minDistance = distance;
bestSemitone = semitone;
}
}
}
return bestSemitone / 12;
}
interface QuantizerDemoLabels {
sourceCv?: string;
processedCvSH?: string;
processedCvDirect?: string;
}
function QuantizerDemoInner({ labels }: { labels?: QuantizerDemoLabels }) {
const { ensureResumed } = useAudioContext();
const [isPlaying, setIsPlaying] = useState(false);
const [scale, setScale] = useState<ScaleType>('major');
const [root, setRoot] = useState<RootNote>('C');
const [shEnabled, setShEnabled] = useState(true);
const [timeScale, setTimeScale] = useState(1);
const lfoOscRef = useRef<OscillatorNode | null>(null);
const cvNodeRef = useRef<ConstantSourceNode | null>(null);
const audioOscRef = useRef<OscillatorNode | null>(null);
const gainRef = useRef<GainNode | null>(null);
const rafRef = useRef(0);
const startTimeRef = useRef(0);
const lastSampleTimeRef = useRef(0);
const heldVoltageRef = useRef(1);
const paramsRef = useRef({ shEnabled, scale, root });
useEffect(() => {
paramsRef.current = { shEnabled, scale, root };
}, [shEnabled, scale, root]);
const lfoScope = useScopeWorklet();
const cvScope = useScopeWorklet();
const activeNotes = useMemo(
() => new Set(SCALE_INTERVALS[scale].map((i) => (i + ROOT_OFFSETS[root]) % 12)),
[scale, root],
);
const tickRef = useRef<() => void>(() => {});
useEffect(() => {
tickRef.current = () => {
const ctx = audioOscRef.current?.context as AudioContext | undefined;
if (!ctx || !audioOscRef.current || !cvNodeRef.current) return;
const elapsed = ctx.currentTime - startTimeRef.current;
const sineValue = Math.sin(2 * Math.PI * LFO_FREQ * elapsed);
const rawVoltage = sineValue + 1; // 0 to 2V range
const { shEnabled: sh, scale: sc, root: rt } = paramsRef.current;
if (sh) {
if (elapsed - lastSampleTimeRef.current >= 1 / SH_RATE) {
heldVoltageRef.current = quantizeVoltage(rawVoltage, sc, rt);
lastSampleTimeRef.current = elapsed;
}
const v = heldVoltageRef.current;
cvNodeRef.current.offset.setValueAtTime(v - 1, ctx.currentTime);
audioOscRef.current.frequency.setValueAtTime(voltageToFrequency(v), ctx.currentTime);
} else {
cvNodeRef.current.offset.setValueAtTime(sineValue, ctx.currentTime);
audioOscRef.current.frequency.setValueAtTime(
voltageToFrequency(rawVoltage),
ctx.currentTime,
);
}
rafRef.current = requestAnimationFrame(() => tickRef.current());
};
});
const tick = useCallback(() => tickRef.current(), []);
const stop = useCallback(() => {
cancelAnimationFrame(rafRef.current);
stopSource(lfoOscRef);
stopSource(cvNodeRef);
stopSource(audioOscRef);
disconnectNode(gainRef);
lfoScope.disconnect();
cvScope.disconnect();
setIsPlaying(false);
}, [lfoScope, cvScope]);
const handleToggle = useCallback(async () => {
if (isPlaying) {
stop();
return;
}
const ctx = await ensureResumed();
// LFO sine for source CV scope
const lfo = ctx.createOscillator();
lfo.type = 'sine';
lfo.frequency.setValueAtTime(LFO_FREQ, ctx.currentTime);
const lfoWorklet = await lfoScope.ensureWorklet(ctx);
lfo.connect(lfoWorklet);
// ConstantSource for processed CV scope
const cvNode = ctx.createConstantSource();
cvNode.offset.setValueAtTime(0, ctx.currentTime);
const cvWorklet = await cvScope.ensureWorklet(ctx);
cvNode.connect(cvWorklet);
// Audio oscillator
const audioOsc = ctx.createOscillator();
audioOsc.type = 'sawtooth';
audioOsc.frequency.setValueAtTime(voltageToFrequency(1), ctx.currentTime);
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.3, ctx.currentTime);
audioOsc.connect(gain);
gain.connect(ctx.destination);
lfo.start();
cvNode.start();
audioOsc.start();
lfoOscRef.current = lfo;
cvNodeRef.current = cvNode;
audioOscRef.current = audioOsc;
gainRef.current = gain;
startTimeRef.current = ctx.currentTime;
lastSampleTimeRef.current = 0;
heldVoltageRef.current = 1;
setIsPlaying(true);
rafRef.current = requestAnimationFrame(tick);
}, [isPlaying, stop, ensureResumed, lfoScope, cvScope, tick]);
useEffect(() => {
return stop;
}, [stop]);
return (
<div className={adStyles.demoContainer}>
<div className={adStyles.controlsSection}>
{/* Scale selector */}
<ButtonGroup
label="Scale"
options={['major', 'minor', 'pentatonic'] as const}
value={scale}
onChange={setScale}
/>
{/* Piano keyboard scale preview */}
<svg
viewBox="0 0 350 130"
className={adStyles.pianoKeyboard}
role="img"
aria-label={`Piano keyboard showing ${scale} scale from ${root}`}
>
{WHITE_KEYS.map((key) => (
<rect
key={key.semitone}
x={key.x}
y={0}
width={50}
height={120}
fill="#f0f0f0"
stroke="#666"
strokeWidth={1}
/>
))}
{BLACK_KEYS.map((key) => (
<rect
key={key.semitone}
x={key.x}
y={0}
width={30}
height={75}
fill="#222"
stroke="#444"
strokeWidth={1}
/>
))}
{WHITE_KEYS.filter((k) => activeNotes.has(k.semitone)).map((key) => (
<circle key={`w-${key.semitone}`} cx={key.x + 25} cy={95} r={8} fill="#e8590c" />
))}
{BLACK_KEYS.filter((k) => activeNotes.has(k.semitone)).map((key) => (
<circle key={`b-${key.semitone}`} cx={key.x + 15} cy={50} r={8} fill="#e8590c" />
))}
{WHITE_KEYS.map((key) => (
<text
key={`l-${key.semitone}`}
x={key.x + 25}
y={115}
textAnchor="middle"
fontSize={14}
fontWeight="bold"
fill="#444"
>
{key.label}
</text>
))}
</svg>
{/* Root selector */}
<ButtonGroup
label="Root"
options={['C', 'D', 'E', 'F', 'G', 'A', 'B'] as const}
value={root}
onChange={setRoot}
/>
{/* S&H toggle */}
<ButtonGroup
label="Sample & Hold"
options={['ON', 'OFF'] as const}
value={shEnabled ? 'ON' : 'OFF'}
onChange={(v) => setShEnabled(v === 'ON')}
/>
<button
type="button"
aria-pressed={isPlaying}
className={`${adStyles.playButton} ${isPlaying ? adStyles.playButtonActive : ''}`}
onClick={handleToggle}
>
{isPlaying ? 'Stop' : 'Play'}
</button>
<TimeScaleControls value={timeScale} onChange={setTimeScale} />
</div>
<p className={`${adStyles.controlLabel} ${adStyles.scopeLabel}`}>
{labels?.sourceCv ?? 'Source CV (Sine Wave)'}
</p>
<WaveformDisplay
isPlaying={isPlaying}
timeScale={timeScale}
scope={lfoScope}
ariaLabel="Source CV waveform"
/>
<p className={`${adStyles.controlLabel} ${adStyles.scopeLabel}`}>
{shEnabled
? (labels?.processedCvSH ?? 'Processed CV (S&H + Quantized)')
: (labels?.processedCvDirect ?? 'Processed CV (Direct — No S&H)')}
</p>
<WaveformDisplay
isPlaying={isPlaying}
timeScale={timeScale}
scope={cvScope}
ariaLabel="Processed CV waveform"
/>
</div>
);
}
export default function QuantizerDemo({ labels }: { labels?: QuantizerDemoLabels }) {
return <BrowserOnly>{() => <QuantizerDemoInner labels={labels} />}</BrowserOnly>;
}