/** * targetAudio.js — Plays the "target" sound for a puzzle level * Builds a temporary Tone.js graph from the level's target config * * Extended to support: * - Envelopes (amplitude shaping) * - LFO (modulation) * - Effects (delay, reverb, distortion) */ import * as Tone from 'tone'; let _activeNodes = []; let _isPlaying = false; let _stopTimeout = null; let _loops = []; // Track Tone.Loop instances for cleanup export function isTargetPlaying() { return _isPlaying; } export async function playTarget(target) { if (_isPlaying) { stopTarget(); return; } await Tone.start(); _isPlaying = true; const nodes = []; const output = new Tone.Gain(0.5).toDestination(); nodes.push(output); // Build effects chain (will connect to this) let effectChain = output; // Effects array (in order: distortion → delay → reverb) if (target.effects && target.effects.length > 0) { const effectNodes = []; for (const effect of target.effects) { if (effect.type === 'distortion') { const distortion = new Tone.Distortion(effect.amount ?? 0.4); effectNodes.push(distortion); } else if (effect.type === 'delay') { const delay = new Tone.Delay(effect.time ?? 0.3); delay.feedback.value = effect.feedback ?? 0.3; delay.wet.value = effect.wet ?? 0.5; effectNodes.push(delay); } else if (effect.type === 'reverb') { const reverb = new Tone.Reverb(effect.decay ?? 2.5); reverb.wet.value = effect.wet ?? 0.5; effectNodes.push(reverb); } } // Chain effects together, then to output if (effectNodes.length > 0) { for (let i = 0; i < effectNodes.length - 1; i++) { effectNodes[i].connect(effectNodes[i + 1]); } effectNodes[effectNodes.length - 1].connect(output); effectChain = effectNodes[0]; nodes.push(...effectNodes); } } // Optional filter in the chain let destination = effectChain; if (target.filter) { const filter = new Tone.Filter({ type: target.filter.type || 'lowpass', frequency: target.filter.frequency || 1000, Q: target.filter.Q || 1, }); filter.connect(effectChain); destination = filter; nodes.push(filter); } // Optional envelope let envelope = null; if (target.envelope) { envelope = new Tone.AmplitudeEnvelope({ attack: target.envelope.attack ?? 0.01, decay: target.envelope.decay ?? 0.1, sustain: target.envelope.sustain ?? 0.3, release: target.envelope.release ?? 0.5, }); envelope.connect(destination); destination = envelope; nodes.push(envelope); } // Optional LFO for modulation let lfo = null; if (target.lfo) { lfo = new Tone.LFO({ frequency: target.lfo.frequency ?? 5, type: target.lfo.type ?? 'sine', min: target.lfo.min ?? 0.5, max: target.lfo.max ?? 1.5, }); // Route LFO to the specified target if (target.lfo.target === 'amplitude' && envelope) { lfo.connect(envelope.gain); } else if (target.lfo.target === 'frequency' && target.build.length > 0) { // LFO will be connected to oscillators below } lfo.start(); nodes.push(lfo); } // Build oscillators / noise from target.build for (const spec of target.build) { if (spec.type === 'oscillator') { const osc = new Tone.Oscillator({ type: spec.params.waveform || 'sine', frequency: spec.params.frequency || 440, detune: spec.params.detune || 0, }); osc.connect(destination); // Connect LFO to frequency if specified if (lfo && target.lfo?.target === 'frequency') { lfo.connect(osc.frequency); } osc.start(); nodes.push(osc); } else if (spec.type === 'noise') { const noise = new Tone.Noise(spec.params.type || 'white'); noise.connect(destination); noise.start(); nodes.push(noise); } } // Handle envelope retriggering with triggerPattern if (envelope && target.triggerPattern) { const pattern = target.triggerPattern; const interval = pattern.interval ?? 0.5; const count = pattern.count ?? Math.ceil((target.duration || 2) / interval); const loop = new Tone.Loop((time) => { envelope.triggerAttackRelease( target.envelope.attack + target.envelope.decay + target.envelope.release, time ); }, interval); loop.start(0); nodes.push(loop); _loops.push(loop); } else if (envelope) { // Single trigger if no pattern envelope.triggerAttack(); } _activeNodes = nodes; // Auto-stop after duration const dur = (target.duration || 2) * 1000; _stopTimeout = setTimeout(() => stopTarget(), dur); } export function stopTarget() { if (_stopTimeout) { clearTimeout(_stopTimeout); _stopTimeout = null; } // Stop and cleanup loops for (const loop of _loops) { try { loop.stop(); loop.dispose(); } catch {} } _loops = []; // Stop and cleanup nodes for (const node of _activeNodes) { try { if (node.stop) node.stop(); if (node.disconnect) node.disconnect(); if (node.dispose) node.dispose(); } catch {} } _activeNodes = []; _isPlaying = false; }