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