Takazudo Modular Styleguide

AudioDemo/ClockDemo

Default

Component Source

clock-demo.tsx

'use client';

import { useState, useRef, useCallback, useEffect } 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 { CLOCK_RING_BUFFER_SECONDS } from './constants';
import { stopSource, disconnectNode } from './cleanup-nodes';
import { adStyles } from './audio-demo-styles';
import { inputValue } from './utils';

function ClockDemoInner() {
  const { ensureResumed } = useAudioContext();
  const [isRunning, setIsRunning] = useState(false);
  const [bpm, setBpm] = useState(120);
  const [timeScale, setTimeScale] = useState(1);
  const periodMs = 60000 / bpm;

  const pulseSourceRef = useRef<ConstantSourceNode | null>(null);
  const pulseGainRef = useRef<GainNode | null>(null);
  const monitorGainRef = useRef<GainNode | null>(null);
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
  const bpmRef = useRef(bpm);
  const scope = useScopeWorklet(CLOCK_RING_BUFFER_SECONDS);

  useEffect(() => {
    bpmRef.current = bpm;
  }, [bpm]);

  const handleTrigger = useCallback(async () => {
    const ctx = await ensureResumed();
    const osc = ctx.createOscillator();
    const gain = ctx.createGain();

    osc.type = 'square';
    osc.frequency.setValueAtTime(1200, ctx.currentTime);

    gain.gain.setValueAtTime(0, ctx.currentTime);
    gain.gain.linearRampToValueAtTime(0.4, ctx.currentTime + 0.005);
    gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.08);

    osc.connect(gain);
    gain.connect(ctx.destination);

    osc.start();
    osc.stop(ctx.currentTime + 0.1);

    osc.onended = () => {
      osc.disconnect();
      gain.disconnect();
    };
  }, [ensureResumed]);

  const clearTimer = useCallback(() => {
    if (timerRef.current) {
      clearInterval(timerRef.current);
      timerRef.current = null;
    }
  }, []);

  const schedulePulse = useCallback(() => {
    const pulseGainNode = pulseGainRef.current;
    if (pulseGainNode) {
      const now = pulseGainNode.context.currentTime;
      const pulseWidth = Math.min(0.3, Math.max(0.02, 60000 / bpmRef.current / 1000 / 2));
      pulseGainNode.gain.cancelScheduledValues(now);
      pulseGainNode.gain.setValueAtTime(0, now);
      pulseGainNode.gain.setValueAtTime(1, now + 0.0005);
      pulseGainNode.gain.setValueAtTime(0, now + pulseWidth);
    }
    void handleTrigger();
  }, [handleTrigger]);

  const startClock = useCallback(async () => {
    const ctx = await ensureResumed();

    const pulseSource = ctx.createConstantSource();
    const pulseGain = ctx.createGain();
    const workletNode = await scope.ensureWorklet(ctx);
    const monitorGain = ctx.createGain();

    pulseSource.offset.setValueAtTime(1, ctx.currentTime);
    pulseGain.gain.setValueAtTime(0, ctx.currentTime);
    monitorGain.gain.setValueAtTime(0, ctx.currentTime);

    pulseSource.connect(pulseGain);
    pulseGain.connect(workletNode);
    workletNode.connect(monitorGain);
    monitorGain.connect(ctx.destination);

    pulseSource.start();

    pulseSourceRef.current = pulseSource;
    pulseGainRef.current = pulseGain;
    monitorGainRef.current = monitorGain;

    clearTimer();
    timerRef.current = setInterval(schedulePulse, Math.round(60000 / bpm));
    setIsRunning(true);
  }, [bpm, ensureResumed, clearTimer, schedulePulse, scope]);

  const stopClock = useCallback(() => {
    stopSource(pulseSourceRef);
    disconnectNode(pulseGainRef);
    disconnectNode(monitorGainRef);
    scope.disconnect();
    clearTimer();
    setIsRunning(false);
  }, [clearTimer, scope]);

  const handleToggle = useCallback(() => {
    if (isRunning) {
      stopClock();
    } else {
      void startClock();
    }
  }, [isRunning, startClock, stopClock]);

  const handleBpmChange = useCallback(
    (e: Event) => {
      const nextBpm = inputValue(e);
      setBpm(nextBpm);
      if (timerRef.current) {
        clearTimer();
        timerRef.current = setInterval(schedulePulse, Math.round(60000 / nextBpm));
      }
    },
    [clearTimer, schedulePulse],
  );

  useEffect(() => () => stopClock(), [stopClock]);

  return (
    <div className={adStyles.demoContainer}>
      <div className={adStyles.controlsSection}>
        <div className={adStyles.controlGroup}>
          <label htmlFor="clock-tempo" className={adStyles.controlLabel}>
            Tempo: <span className={adStyles.valueDisplay}>{bpm} BPM</span>
          </label>
          <input
            id="clock-tempo"
            type="range"
            min="40"
            max="200"
            value={bpm}
            onChange={handleBpmChange}
            className={adStyles.slider}
          />
        </div>

        <button
          type="button"
          aria-pressed={isRunning}
          className={`${adStyles.playButton} ${isRunning ? adStyles.playButtonActive : ''}`}
          onClick={handleToggle}
        >
          {isRunning ? 'Stop Clock' : 'Start Clock'}
        </button>

        <TimeScaleControls value={timeScale} onChange={setTimeScale} />
      </div>

      <WaveformDisplay
        isPlaying={isRunning}
        refreshMs={30}
        timeScale={timeScale}
        timeWindowMs={Math.min(30000, Math.max(200, periodMs * 4 * timeScale))}
        scope={scope}
        triggerEnabled
        triggerMode="auto"
        triggerLevel={0.5}
        triggerEdge="rising"
        triggerHoldMs={Math.max(200, periodMs)}
        triggerPosition={0.05}
      />
    </div>
  );
}

export default function ClockDemo() {
  return <BrowserOnly>{() => <ClockDemoInner />}</BrowserOnly>;
}

Story Source

clock-demo.stories.tsx

import ClockDemo from './clock-demo';

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

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