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