Takazudo Modular Styleguide

AudioDemo/EnvelopeDemo

Default

Component Source

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

interface EnvelopeParams {
  attack: number;
  decay: number;
  sustain: number;
  release: number;
}

/**
 * Compute the SVG path points for an ADSR envelope shape.
 * Returns a polyline-compatible points string.
 */
function computeEnvelopePath(params: EnvelopeParams, width: number, height: number): string {
  const { attack, decay, sustain, release } = params;

  // Total time representation
  const totalTime = attack + decay + 0.5 + release; // 0.5s sustain hold for visual
  const padding = 10;
  const drawWidth = width - padding * 2;
  const drawHeight = height - padding * 2;

  const timeToX = (t: number) => padding + (t / totalTime) * drawWidth;
  const levelToY = (level: number) => padding + (1 - level) * drawHeight;

  const sustainLevel = sustain / 100;

  const points = [
    // Start at zero
    `${timeToX(0)},${levelToY(0)}`,
    // Attack peak
    `${timeToX(attack)},${levelToY(1)}`,
    // Decay to sustain
    `${timeToX(attack + decay)},${levelToY(sustainLevel)}`,
    // Sustain hold
    `${timeToX(attack + decay + 0.5)},${levelToY(sustainLevel)}`,
    // Release to zero
    `${timeToX(attack + decay + 0.5 + release)},${levelToY(0)}`,
  ];

  return points.join(' ');
}

function EnvelopeDemoInner() {
  const { ensureResumed } = useAudioContext();
  const [attack, setAttack] = useState(0.1);
  const [decay, setDecay] = useState(0.3);
  const [sustain, setSustain] = useState(70);
  const [release, setRelease] = useState(0.5);
  const [isGateOn, setIsGateOn] = useState(false);
  const [isAudioActive, setIsAudioActive] = useState(false);
  const [timeScale, setTimeScale] = useState(1);
  const [outputAnalyserNode, setOutputAnalyserNode] = useState<AnalyserNode | null>(null);

  const oscRef = useRef<OscillatorNode | null>(null);
  const filterRef = useRef<BiquadFilterNode | 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 releaseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const isGateOnRef = useRef(false);
  const cvScope = useScopeWorklet();

  const svgWidth = 600;
  const svgHeight = 150;

  const envelopePoints = useMemo(
    () => computeEnvelopePath({ attack, decay, sustain, release }, svgWidth, svgHeight),
    [attack, decay, sustain, release],
  );
  const envelopeWindowMs = useMemo(
    () => (attack + decay + 0.5 + release) * 1000,
    [attack, decay, release],
  );

  useEffect(() => {
    return () => {
      if (releaseTimeoutRef.current) clearTimeout(releaseTimeoutRef.current);
      stopSource(oscRef);
      disconnectNode(filterRef);
      disconnectNode(gainRef);
      disconnectNode(outputAnalyserRef);
      stopSource(cvSourceRef);
      disconnectNode(cvGainRef);
      disconnectNode(cvAnalyserRef);
      cvScope.disconnect();
    };
  }, [cvScope]);

  const handleGateOn = useCallback(async () => {
    if (isGateOnRef.current) return;
    isGateOnRef.current = true;

    // Clear any pending release timeout
    if (releaseTimeoutRef.current) {
      clearTimeout(releaseTimeoutRef.current);
      releaseTimeoutRef.current = null;
    }

    // Stop any existing nodes
    stopSource(oscRef);
    disconnectNode(filterRef);
    disconnectNode(gainRef);
    disconnectNode(outputAnalyserRef);
    stopSource(cvSourceRef);
    disconnectNode(cvGainRef);
    disconnectNode(cvAnalyserRef);

    const ctx = await ensureResumed();

    const oscillator = ctx.createOscillator();
    const filter = ctx.createBiquadFilter();
    const gainNode = ctx.createGain();
    const outputAnalyser = ctx.createAnalyser();
    outputAnalyser.fftSize = DEFAULT_FFT_SIZE;

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

    filter.type = 'lowpass';
    filter.frequency.setValueAtTime(2000, ctx.currentTime);
    filter.Q.setValueAtTime(2, ctx.currentTime);

    // Start at zero, apply attack-decay envelope
    gainNode.gain.setValueAtTime(0, ctx.currentTime);
    // Attack: ramp to full volume
    gainNode.gain.linearRampToValueAtTime(0.6, ctx.currentTime + attack);
    // Decay: ramp to sustain level
    gainNode.gain.linearRampToValueAtTime(0.6 * (sustain / 100), ctx.currentTime + attack + decay);

    oscillator.connect(filter);
    filter.connect(gainNode);
    gainNode.connect(outputAnalyser);
    outputAnalyser.connect(ctx.destination);

    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);
    cvGain.gain.setValueAtTime(0, ctx.currentTime);
    cvGain.gain.linearRampToValueAtTime(1, ctx.currentTime + attack);
    cvGain.gain.linearRampToValueAtTime(sustain / 100, ctx.currentTime + attack + decay);

    cvSource.connect(cvGain);
    cvGain.connect(cvAnalyser);
    cvGain.connect(cvWorklet);
    cvSource.start();

    oscillator.start();

    oscRef.current = oscillator;
    filterRef.current = filter;
    gainRef.current = gainNode;
    outputAnalyserRef.current = outputAnalyser;
    setOutputAnalyserNode(outputAnalyser);
    cvSourceRef.current = cvSource;
    cvGainRef.current = cvGain;
    cvAnalyserRef.current = cvAnalyser;
    setIsGateOn(true);
    setIsAudioActive(true);
  }, [attack, decay, sustain, ensureResumed, cvScope]);

  const handleGateOff = useCallback(() => {
    // Use ref (not stale state) to prevent double-fire from mouseup+mouseleave
    if (!isGateOnRef.current || !gainRef.current || !oscRef.current) {
      return;
    }
    isGateOnRef.current = false;

    const ctx = oscRef.current.context;
    const gainNode = gainRef.current;
    const cvGain = cvGainRef.current;
    const oscillator = oscRef.current;

    // Cancel any scheduled ramps and start release from current value
    gainNode.gain.cancelScheduledValues(ctx.currentTime);
    gainNode.gain.setValueAtTime(gainNode.gain.value, ctx.currentTime);
    gainNode.gain.linearRampToValueAtTime(0, ctx.currentTime + release);

    if (cvGain) {
      cvGain.gain.cancelScheduledValues(ctx.currentTime);
      cvGain.gain.setValueAtTime(cvGain.gain.value, ctx.currentTime);
      cvGain.gain.linearRampToValueAtTime(0, ctx.currentTime + release);
    }

    // Stop the oscillator after release completes
    releaseTimeoutRef.current = setTimeout(
      () => {
        oscillator.stop();
        oscillator.disconnect();
        oscRef.current = null;
        disconnectNode(filterRef);
        disconnectNode(outputAnalyserRef);
        gainNode.disconnect();
        gainRef.current = null;
        stopSource(cvSourceRef);
        disconnectNode(cvGainRef);
        disconnectNode(cvAnalyserRef);
        releaseTimeoutRef.current = null;
        setIsAudioActive(false);
      },
      release * 1000 + 50,
    );

    setIsGateOn(false);
  }, [release]);

  return (
    <div className={adStyles.demoContainer}>
      <div className={adStyles.controlsSection}>
        <div className={adStyles.controlRow}>
          <div className={adStyles.controlGroup}>
            <label htmlFor="envelope-attack" className={adStyles.controlLabel}>
              Attack: <span className={adStyles.valueDisplay}>{attack.toFixed(2)}s</span>
            </label>
            <input
              id="envelope-attack"
              type="range"
              min="0"
              max="2"
              step="0.01"
              value={attack}
              onChange={(e) => setAttack(inputValue(e))}
              className={adStyles.slider}
            />
          </div>

          <div className={adStyles.controlGroup}>
            <label htmlFor="envelope-decay" className={adStyles.controlLabel}>
              Decay: <span className={adStyles.valueDisplay}>{decay.toFixed(2)}s</span>
            </label>
            <input
              id="envelope-decay"
              type="range"
              min="0"
              max="2"
              step="0.01"
              value={decay}
              onChange={(e) => setDecay(inputValue(e))}
              className={adStyles.slider}
            />
          </div>

          <div className={adStyles.controlGroup}>
            <label htmlFor="envelope-sustain" className={adStyles.controlLabel}>
              Sustain: <span className={adStyles.valueDisplay}>{sustain}%</span>
            </label>
            <input
              id="envelope-sustain"
              type="range"
              min="0"
              max="100"
              step="1"
              value={sustain}
              onChange={(e) => setSustain(inputValue(e))}
              className={adStyles.slider}
            />
          </div>

          <div className={adStyles.controlGroup}>
            <label htmlFor="envelope-release" className={adStyles.controlLabel}>
              Release: <span className={adStyles.valueDisplay}>{release.toFixed(2)}s</span>
            </label>
            <input
              id="envelope-release"
              type="range"
              min="0"
              max="3"
              step="0.01"
              value={release}
              onChange={(e) => setRelease(inputValue(e))}
              className={adStyles.slider}
            />
          </div>
        </div>

        <button
          type="button"
          aria-pressed={isGateOn}
          className={`${adStyles.playButton} ${isGateOn ? adStyles.playButtonActive : ''}`}
          onMouseDown={handleGateOn}
          onMouseUp={handleGateOff}
          onMouseLeave={handleGateOff}
          onTouchStart={(e) => {
            e.preventDefault();
            void handleGateOn();
          }}
          onTouchEnd={(e) => {
            e.preventDefault();
            handleGateOff();
          }}
          onKeyDown={(e) => {
            if (e.key === ' ' || e.key === 'Enter') {
              e.preventDefault();
              void handleGateOn();
            }
          }}
          onKeyUp={(e) => {
            if (e.key === ' ' || e.key === 'Enter') {
              e.preventDefault();
              handleGateOff();
            }
          }}
        >
          {isGateOn ? 'Gate ON (release to stop)' : 'Hold to Trigger'}
        </button>

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

      <div className={adStyles.envelopeSvgContainer}>
        <svg
          viewBox={`0 0 ${svgWidth} ${svgHeight}`}
          className={adStyles.envelopeSvg}
          preserveAspectRatio="xMidYMid meet"
        >
          {/* Background grid */}
          <line
            x1="10"
            y1={svgHeight / 2}
            x2={svgWidth - 10}
            y2={svgHeight / 2}
            stroke="rgba(255,255,255,0.1)"
            strokeWidth="1"
          />
          <line
            x1="10"
            y1="10"
            x2="10"
            y2={svgHeight - 10}
            stroke="rgba(255,255,255,0.1)"
            strokeWidth="1"
          />

          {/* ADSR labels */}
          <AdsrLabels
            attack={attack}
            decay={decay}
            release={release}
            width={svgWidth}
            height={svgHeight}
          />

          {/* Envelope shape */}
          <polyline
            points={envelopePoints}
            fill="none"
            stroke={ACCENT_COLOR}
            strokeWidth="2.5"
            strokeLinejoin="round"
          />

          {/* Fill under curve */}
          <polyline
            points={`${envelopePoints} ${svgWidth - 10},${svgHeight - 10} 10,${svgHeight - 10}`}
            fill="rgba(232, 89, 12, 0.15)"
            stroke="none"
          />
        </svg>
      </div>

      <WaveformDisplay
        isPlaying={isAudioActive}
        timeScale={timeScale}
        timeWindowMs={envelopeWindowMs}
        scrollMode
        scope={cvScope}
        triggerEnabled
        triggerMode="auto"
        triggerLevel={0.01}
        triggerEdge="rising"
        triggerPosition={0.05}
        ariaLabel="Envelope CV waveform"
      />
      <WaveformDisplay
        analyserNode={outputAnalyserNode}
        isPlaying={isAudioActive}
        timeScale={1}
        timeWindowMs={envelopeWindowMs}
        ariaLabel="Audio output waveform"
      />
    </div>
  );
}

/**
 * Renders small labels for each ADSR phase on the SVG.
 */
function AdsrLabels({
  attack,
  decay,
  release,
  width,
  height,
}: {
  attack: number;
  decay: number;
  release: number;
  width: number;
  height: number;
}) {
  const totalTime = attack + decay + 0.5 + release;
  const padding = 10;
  const drawWidth = width - padding * 2;

  const timeToX = (t: number) => padding + (t / totalTime) * drawWidth;

  const attackMid = timeToX(attack / 2);
  const decayMid = timeToX(attack + decay / 2);
  const sustainMid = timeToX(attack + decay + 0.25);
  const releaseMid = timeToX(attack + decay + 0.5 + release / 2);

  const labelY = height - 2;

  return (
    <>
      <text x={attackMid} y={labelY} textAnchor="middle" fill="rgba(255,255,255,0.4)" fontSize="11">
        A
      </text>
      <text x={decayMid} y={labelY} textAnchor="middle" fill="rgba(255,255,255,0.4)" fontSize="11">
        D
      </text>
      <text
        x={sustainMid}
        y={labelY}
        textAnchor="middle"
        fill="rgba(255,255,255,0.4)"
        fontSize="11"
      >
        S
      </text>
      <text
        x={releaseMid}
        y={labelY}
        textAnchor="middle"
        fill="rgba(255,255,255,0.4)"
        fontSize="11"
      >
        R
      </text>
    </>
  );
}

export default function EnvelopeDemo() {
  return <BrowserOnly>{() => <EnvelopeDemoInner />}</BrowserOnly>;
}

Story Source

envelope-demo.stories.tsx

import EnvelopeDemo from './envelope-demo';

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

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