Takazudo Modular Styleguide

AudioDemo/FilterDemo

Default

Component Source

filter-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 ButtonGroup from './button-group';
import { adStyles } from './audio-demo-styles';
import { inputValue } from './utils';

type FilterType = 'lowpass' | 'highpass' | 'bandpass';

const FILTER_TYPES: FilterType[] = ['lowpass', 'highpass', 'bandpass'];

/**
 * Convert a linear slider value (0-1) to a logarithmic frequency (20-20000 Hz).
 * This gives a more natural feel for frequency controls.
 */
function linearToLog(value: number, min: number, max: number): number {
  const minLog = Math.log10(min);
  const maxLog = Math.log10(max);
  return Math.pow(10, minLog + value * (maxLog - minLog));
}

/**
 * Convert a logarithmic frequency back to a linear slider value (0-1).
 */
function logToLinear(freq: number, min: number, max: number): number {
  const minLog = Math.log10(min);
  const maxLog = Math.log10(max);
  return (Math.log10(freq) - minLog) / (maxLog - minLog);
}

function FilterDemoInner() {
  const { ensureResumed } = useAudioContext();
  const [isPlaying, setIsPlaying] = useState(false);
  const [filterType, setFilterType] = useState<FilterType>('lowpass');
  const [cutoff, setCutoff] = useState(1000);
  const [resonance, setResonance] = useState(1);
  const [timeScale, setTimeScale] = useState(1);

  const oscRef = useRef<OscillatorNode | null>(null);
  const filterRef = useRef<BiquadFilterNode | null>(null);
  const gainRef = useRef<GainNode | null>(null);
  const analyserRef = useRef<AnalyserNode | null>(null);
  const scope = useScopeWorklet();

  const handleToggle = useCallback(async () => {
    if (isPlaying) {
      stopSource(oscRef);
      disconnectNode(filterRef);
      disconnectNode(gainRef);
      disconnectNode(analyserRef);
      scope.disconnect();
      setIsPlaying(false);
      return;
    }

    const ctx = await ensureResumed();

    const oscillator = ctx.createOscillator();
    const filter = ctx.createBiquadFilter();
    const gainNode = ctx.createGain();
    const analyser = ctx.createAnalyser();

    analyser.fftSize = DEFAULT_FFT_SIZE;

    // Fixed sawtooth source at A3
    oscillator.type = 'sawtooth';
    oscillator.frequency.setValueAtTime(BASE_FREQUENCY_A3, ctx.currentTime);

    filter.type = filterType;
    filter.frequency.setValueAtTime(cutoff, ctx.currentTime);
    filter.Q.setValueAtTime(resonance, ctx.currentTime);

    gainNode.gain.setValueAtTime(0.5, ctx.currentTime);

    oscillator.connect(filter);
    filter.connect(gainNode);
    gainNode.connect(analyser);
    const worklet = await scope.ensureWorklet(ctx);
    gainNode.connect(worklet);
    analyser.connect(ctx.destination);

    oscillator.start();

    oscRef.current = oscillator;
    filterRef.current = filter;
    gainRef.current = gainNode;
    analyserRef.current = analyser;
    setIsPlaying(true);
  }, [isPlaying, filterType, cutoff, resonance, ensureResumed, scope]);

  const handleFilterTypeChange = useCallback((type: FilterType) => {
    setFilterType(type);
    if (filterRef.current) {
      filterRef.current.type = type;
    }
  }, []);

  const handleCutoffChange = useCallback((e: Event) => {
    const linearVal = inputValue(e);
    const freq = Math.round(linearToLog(linearVal, 20, 20000));
    setCutoff(freq);
    if (filterRef.current) {
      filterRef.current.frequency.setValueAtTime(freq, filterRef.current.context.currentTime);
    }
  }, []);

  const handleResonanceChange = useCallback((e: Event) => {
    const q = inputValue(e);
    setResonance(q);
    if (filterRef.current) {
      filterRef.current.Q.setValueAtTime(q, filterRef.current.context.currentTime);
    }
  }, []);

  useEffect(() => {
    return () => {
      stopSource(oscRef);
      disconnectNode(filterRef);
      disconnectNode(gainRef);
      disconnectNode(analyserRef);
      scope.disconnect();
    };
  }, [scope]);

  // Compute the linear slider position from the current cutoff
  const cutoffSliderValue = logToLinear(cutoff, 20, 20000);

  return (
    <div className={adStyles.demoContainer}>
      <div className={adStyles.controlsSection}>
        <ButtonGroup
          label="Filter Type"
          options={FILTER_TYPES}
          value={filterType}
          onChange={handleFilterTypeChange}
        />

        <div className={adStyles.controlGroup}>
          <label htmlFor="filter-cutoff" className={adStyles.controlLabel}>
            Cutoff:{' '}
            <span className={adStyles.valueDisplay}>
              {cutoff >= 1000 ? `${(cutoff / 1000).toFixed(1)} kHz` : `${cutoff} Hz`}
            </span>
          </label>
          <input
            id="filter-cutoff"
            type="range"
            min="0"
            max="1"
            step="0.001"
            value={cutoffSliderValue}
            onChange={handleCutoffChange}
            className={adStyles.slider}
          />
        </div>

        <div className={adStyles.controlGroup}>
          <label htmlFor="filter-resonance" className={adStyles.controlLabel}>
            Resonance (Q): <span className={adStyles.valueDisplay}>{resonance.toFixed(1)}</span>
          </label>
          <input
            id="filter-resonance"
            type="range"
            min="0.1"
            max="30"
            step="0.1"
            value={resonance}
            onChange={handleResonanceChange}
            className={adStyles.slider}
          />
        </div>

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

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

      <WaveformDisplay isPlaying={isPlaying} timeScale={timeScale} scope={scope} />
    </div>
  );
}

export default function FilterDemo() {
  return <BrowserOnly>{() => <FilterDemoInner />}</BrowserOnly>;
}

Story Source

filter-demo.stories.tsx

import FilterDemo from './filter-demo';

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

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