Takazudo Modular Styleguide

AudioDemo/QuantizerDemo

Default

Component Source

quantizer-demo.tsx

'use client';

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 &amp; 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>;
}

Story Source

quantizer-demo.stories.tsx

import QuantizerDemo from './quantizer-demo';

export const meta = {
  title: 'AudioDemo/QuantizerDemo',
};

export const Default = () => <QuantizerDemo />;