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