Takazudo Modular Styleguide

AudioDemo/GateTriggerDemo

Default
With Labels

Component Source

gate-trigger-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 { DEFAULT_FFT_SIZE, BASE_FREQUENCY_A3 } from './constants';
import { stopSource, disconnectNode } from './cleanup-nodes';
import { adStyles } from './audio-demo-styles';

interface GateTriggerDemoLabels {
  instruction?: string;
}

function GateTriggerDemoInner({ labels }: { labels?: GateTriggerDemoLabels }) {
  const { ensureResumed } = useAudioContext();
  const [gateOn, setGateOn] = useState(false);
  const [isActive, setIsActive] = useState(false);
  const [timeScale, setTimeScale] = useState(1);
  const [outputAnalyserNode, setOutputAnalyserNode] = useState<AnalyserNode | null>(null);

  const oscRef = useRef<OscillatorNode | null>(null);
  const gainRef = useRef<GainNode | null>(null);
  const outputAnalyserRef = useRef<AnalyserNode | null>(null);
  const cvSourceRef = useRef<ConstantSourceNode | null>(null);
  const cvGainRef = useRef<GainNode | null>(null);
  const cvAnalyserRef = useRef<AnalyserNode | null>(null);
  const cvScope = useScopeWorklet();

  const stopGate = useCallback(() => {
    if (!oscRef.current || !gainRef.current) {
      setGateOn(false);
      return;
    }

    const ctx = oscRef.current.context;
    gainRef.current.gain.cancelScheduledValues(ctx.currentTime);
    gainRef.current.gain.setValueAtTime(gainRef.current.gain.value, ctx.currentTime);
    gainRef.current.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.15);

    const osc = oscRef.current;
    const gain = gainRef.current;
    setTimeout(() => {
      osc.stop();
      osc.disconnect();
      gain.disconnect();
    }, 200);

    oscRef.current = null;
    gainRef.current = null;
    disconnectNode(outputAnalyserRef);
    stopSource(cvSourceRef);
    disconnectNode(cvGainRef);
    disconnectNode(cvAnalyserRef);
    cvScope.disconnect();
    setGateOn(false);
    setIsActive(false);
  }, [cvScope]);

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

  const handleToggle = useCallback(async () => {
    if (gateOn) {
      stopGate();
      return;
    }

    const ctx = await ensureResumed();
    const oscillator = ctx.createOscillator();
    const gain = ctx.createGain();
    const analyser = ctx.createAnalyser();
    analyser.fftSize = DEFAULT_FFT_SIZE;
    const cvSource = ctx.createConstantSource();
    const cvGain = ctx.createGain();
    const cvAnalyser = ctx.createAnalyser();
    cvAnalyser.fftSize = DEFAULT_FFT_SIZE;
    const cvWorklet = await cvScope.ensureWorklet(ctx);

    oscillator.type = 'sawtooth';
    oscillator.frequency.setValueAtTime(BASE_FREQUENCY_A3, ctx.currentTime);

    gain.gain.setValueAtTime(0, ctx.currentTime);
    gain.gain.linearRampToValueAtTime(0.5, ctx.currentTime + 0.02);

    oscillator.connect(gain);
    gain.connect(analyser);
    analyser.connect(ctx.destination);

    cvSource.offset.setValueAtTime(1, ctx.currentTime);
    cvGain.gain.setValueAtTime(1, ctx.currentTime);
    cvSource.connect(cvGain);
    cvGain.connect(cvAnalyser);
    cvGain.connect(cvWorklet);

    oscillator.start();
    cvSource.start();

    oscRef.current = oscillator;
    gainRef.current = gain;
    outputAnalyserRef.current = analyser;
    setOutputAnalyserNode(analyser);
    cvSourceRef.current = cvSource;
    cvGainRef.current = cvGain;
    cvAnalyserRef.current = cvAnalyser;
    setGateOn(true);
    setIsActive(true);
  }, [gateOn, ensureResumed, cvScope, stopGate]);

  const handleTrigger = useCallback(async () => {
    const ctx = await ensureResumed();
    setIsActive(true);

    // Disconnect previous trigger nodes before creating new ones
    disconnectNode(outputAnalyserRef);
    disconnectNode(cvAnalyserRef);

    const cvSource = ctx.createConstantSource();
    const cvGain = ctx.createGain();
    const cvAnalyser = ctx.createAnalyser();
    cvAnalyser.fftSize = DEFAULT_FFT_SIZE;
    const cvWorklet = await cvScope.ensureWorklet(ctx);

    cvSource.offset.setValueAtTime(1, ctx.currentTime);
    const pulseWidth = 0.3;
    cvGain.gain.setValueAtTime(0, ctx.currentTime);
    cvGain.gain.setValueAtTime(1, ctx.currentTime + 0.0005);
    cvGain.gain.setValueAtTime(1, ctx.currentTime + pulseWidth);
    cvGain.gain.setValueAtTime(0, ctx.currentTime + pulseWidth + 0.0005);
    cvSource.connect(cvGain);
    cvGain.connect(cvAnalyser);
    cvGain.connect(cvWorklet);

    const oscillator = ctx.createOscillator();
    const gain = ctx.createGain();
    const analyser = ctx.createAnalyser();
    analyser.fftSize = DEFAULT_FFT_SIZE;

    oscillator.type = 'square';
    oscillator.frequency.setValueAtTime(330, ctx.currentTime);

    gain.gain.setValueAtTime(0, ctx.currentTime);
    gain.gain.linearRampToValueAtTime(0.5, ctx.currentTime + 0.005);
    gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.18);

    oscillator.connect(gain);
    gain.connect(analyser);
    analyser.connect(ctx.destination);

    outputAnalyserRef.current = analyser;
    setOutputAnalyserNode(analyser);
    cvAnalyserRef.current = cvAnalyser;
    cvSource.start();
    cvSource.stop(ctx.currentTime + pulseWidth + 0.5);

    oscillator.start();
    oscillator.stop(ctx.currentTime + 0.2);

    oscillator.onended = () => {
      oscillator.disconnect();
      gain.disconnect();
      analyser.disconnect();
      setTimeout(() => setIsActive(false), 50);
    };
    cvSource.onended = () => {
      cvSource.disconnect();
      cvGain.disconnect();
      cvAnalyser.disconnect();
    };
  }, [ensureResumed, cvScope]);

  return (
    <div className={adStyles.demoContainer}>
      <div className={adStyles.controlsSection}>
        <div className={adStyles.controlLabel}>
          {labels?.instruction ??
            'Gate stays high until you toggle it off. Trigger fires a short pulse.'}
        </div>
        <div className={adStyles.buttonGroup}>
          <button
            type="button"
            aria-pressed={gateOn}
            className={`${adStyles.playButton} ${gateOn ? adStyles.playButtonActive : ''}`}
            onClick={handleToggle}
          >
            {gateOn ? 'Gate ON' : 'Gate OFF'}
          </button>
          <button type="button" className={adStyles.waveformButton} onClick={handleTrigger}>
            Trigger Pulse
          </button>
        </div>

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

      <WaveformDisplay
        isPlaying={isActive}
        timeScale={timeScale}
        scrollMode
        scope={cvScope}
        ariaLabel="Gate/trigger CV waveform"
      />
      <WaveformDisplay
        analyserNode={outputAnalyserNode}
        isPlaying={isActive}
        timeScale={timeScale}
        ariaLabel="Audio output waveform"
      />
    </div>
  );
}

export default function GateTriggerDemo({ labels }: { labels?: GateTriggerDemoLabels }) {
  return <BrowserOnly>{() => <GateTriggerDemoInner labels={labels} />}</BrowserOnly>;
}

Story Source

gate-trigger-demo.stories.tsx

import GateTriggerDemo from './gate-trigger-demo';

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

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

export const WithLabels = () => (
  <GateTriggerDemo labels={{ instruction: 'ゲートはオフにするまでハイを維持します。' }} />
);