export const DEFAULT_CHANNEL_GAIN = Math.pow(10, -6 / 20); // ≈ 0.5012 let sharedCtx = null; export function getAudioContext() { if (!sharedCtx) { sharedCtx = new (window.AudioContext || window.webkitAudioContext)(); } if (sharedCtx.state === 'suspended') { sharedCtx.resume(); } return sharedCtx; } export const IMPULSE_RESPONSES = [ { id: 'hall', name: 'Opera Hall', file: 'Scala Milan Opera Hall.wav' }, { id: 'room', name: 'Drum Room', file: 'Nice Drum Room.wav' }, { id: 'narrow', name: 'Narrow Space', file: 'Narrow Bumpy Space.wav' }, ]; const DEFAULT_IR_ID = 'hall'; const irBufferCache = new Map(); let irLoadPromise = null; async function fetchAndDecodeIR(ctx, file) { const res = await fetch(`/ir/${encodeURIComponent(file)}`); if (!res.ok) throw new Error(`IR fetch failed (${res.status}): ${file}`); const arr = await res.arrayBuffer(); return await ctx.decodeAudioData(arr); } export function loadImpulseResponses(ctx) { if (irLoadPromise) return irLoadPromise; irLoadPromise = Promise.all( IMPULSE_RESPONSES.map(async (ir) => { try { const buf = await fetchAndDecodeIR(ctx, ir.file); irBufferCache.set(ir.id, buf); } catch (e) { console.warn(`[performanceAudio] IR load failed for ${ir.id}:`, e); } }) ).then(() => irBufferCache); return irLoadPromise; } export function getImpulseResponseBuffer(id) { return irBufferCache.get(id); } const EARLY_REFLECTIONS_MS = [ [7, 0.55], [13, -0.42], [19, 0.36], [28, -0.30], [41, 0.26], [56, 0.22], [73, -0.18], [91, 0.15], ]; let sharedImpulse = null; function getImpulse(ctx, duration = 2.8, decaySeconds = 1.6, damping = 0.55) { if (sharedImpulse && sharedImpulse.sampleRate === ctx.sampleRate) { return sharedImpulse; } const sr = ctx.sampleRate; const length = Math.floor(sr * duration); const buf = ctx.createBuffer(2, length, sr); for (let ch = 0; ch < 2; ch++) { const data = buf.getChannelData(ch); const stereoJitter = ch === 0 ? 1.0 : 1.037; for (const [timeMs, amp] of EARLY_REFLECTIONS_MS) { const idx = Math.floor(sr * timeMs * 0.001 * stereoJitter); if (idx < length) data[idx] += amp * 0.8; } let lpState = 0; const predelaySec = 0.012; for (let i = 0; i < length; i++) { const t = i / sr; if (t < predelaySec) continue; const env = Math.exp(-(t - predelaySec) / decaySeconds); const progression = Math.min(1, (t - predelaySec) / decaySeconds); const alpha = 1 - damping * (0.4 + 0.55 * progression); const noise = (Math.random() * 2 - 1) * env; lpState += alpha * (noise - lpState); data[i] += lpState * 0.72; } let peak = 0; for (let i = 0; i < length; i++) { const v = Math.abs(data[i]); if (v > peak) peak = v; } if (peak > 0) { const norm = 0.92 / peak; for (let i = 0; i < length; i++) data[i] *= norm; } } sharedImpulse = buf; return buf; } export class ChannelStrip { constructor(masterBus) { const ctx = getAudioContext(); this.ctx = ctx; this.buffer = null; this.source = null; this.isPlaying = false; this.isLooping = false; this.isMuted = false; this.isSoloed = false; this.filter = ctx.createBiquadFilter(); this.filter.type = 'lowpass'; this.filter.frequency.value = 18000; this.filter.Q.value = 0.7; this.dryGain = ctx.createGain(); this.dryGain.gain.value = 1.0; this.delayNode = ctx.createDelay(2.0); this.delayNode.delayTime.value = 0.25; this.delayFeedback = ctx.createGain(); this.delayFeedback.gain.value = 0.42; this.delayWet = ctx.createGain(); this.delayWet.gain.value = 0.0; this.reverbNode = ctx.createConvolver(); this.reverbNode.buffer = getImpulse(ctx); this.reverbWet = ctx.createGain(); this.reverbWet.gain.value = 0.0; this.channelGain = ctx.createGain(); this.channelGain.gain.value = DEFAULT_CHANNEL_GAIN; this._lastUserGain = DEFAULT_CHANNEL_GAIN; this.compressor = ctx.createDynamicsCompressor(); this.compressor.threshold.value = -16; this.compressor.knee.value = 8; this.compressor.ratio.value = 2.5; this.compressor.attack.value = 0.006; this.compressor.release.value = 0.14; this.pan = ctx.createStereoPanner(); this.pan.pan.value = 0; this.analyser = ctx.createAnalyser(); this.analyser.fftSize = 256; this.analyserData = new Uint8Array(this.analyser.frequencyBinCount); this.filter.connect(this.dryGain); this.dryGain.connect(this.channelGain); this.filter.connect(this.delayNode); this.delayNode.connect(this.delayFeedback); this.delayFeedback.connect(this.delayNode); this.delayNode.connect(this.delayWet); this.delayWet.connect(this.channelGain); this.filter.connect(this.reverbNode); this.reverbNode.connect(this.reverbWet); this.reverbWet.connect(this.channelGain); this.channelGain.connect(this.compressor); this.compressor.connect(this.pan); this.pan.connect(this.analyser); this.analyser.connect(masterBus); } async loadBlob(blob) { this.stop(); const arrayBuffer = await blob.arrayBuffer(); this.buffer = await this.ctx.decodeAudioData(arrayBuffer); } play(loop = this.isLooping, startTime = 0) { if (!this.buffer) return; this.stop(); this.isLooping = loop; const src = this.ctx.createBufferSource(); src.buffer = this.buffer; src.loop = loop; src.connect(this.filter); src.onended = () => { if (this.source === src) { this.source = null; this.isPlaying = false; } }; src.start(Math.max(0, startTime)); this.source = src; this.isPlaying = true; } stop() { if (this.source) { try { this.source.stop(0); } catch (_) { /* already stopped */ } this.source.disconnect(); this.source = null; } this.isPlaying = false; } setGain(value) { this.channelGain.gain.setTargetAtTime(value, this.ctx.currentTime, 0.01); } setFilter(hz) { this.filter.frequency.setTargetAtTime(hz, this.ctx.currentTime, 0.01); } setDelayMix(value) { this.delayWet.gain.setTargetAtTime(value, this.ctx.currentTime, 0.02); } setReverbMix(value) { this.reverbWet.gain.setTargetAtTime(value, this.ctx.currentTime, 0.05); } setPan(value) { this.pan.pan.setTargetAtTime(value, this.ctx.currentTime, 0.01); } setImpulseResponse(buffer) { if (buffer) this.reverbNode.buffer = buffer; } setDelayTimeForBpm(bpm) { const safeBpm = Math.max(1, bpm); const eighthSec = Math.min(30 / safeBpm, 2.0); this.delayNode.delayTime.setTargetAtTime( eighthSec, this.ctx.currentTime, 0.04 ); } setLoop(value) { this.isLooping = value; if (this.source) this.source.loop = value; } applyMuteSolo(anySoloed) { const audible = !this.isMuted && (!anySoloed || this.isSoloed); this.channelGain.gain.setTargetAtTime(audible ? this._lastUserGain ?? 0 : 0, this.ctx.currentTime, 0.01); } setUserGain(value) { this._lastUserGain = value; this.channelGain.gain.setTargetAtTime(value, this.ctx.currentTime, 0.01); } getLevel() { if (!this.isPlaying) return 0; this.analyser.getByteTimeDomainData(this.analyserData); let peak = 0; for (let i = 0; i < this.analyserData.length; i++) { const v = Math.abs(this.analyserData[i] - 128) / 128; if (v > peak) peak = v; } return peak; } drawWaveform(canvas, color) { if (!canvas || !this.buffer) return; const ctx2d = canvas.getContext('2d'); const w = canvas.width; const h = canvas.height; ctx2d.clearRect(0, 0, w, h); const data = this.buffer.getChannelData(0); const step = Math.max(1, Math.floor(data.length / w)); ctx2d.strokeStyle = color || '#35C2D4'; ctx2d.lineWidth = 1; ctx2d.beginPath(); for (let i = 0; i < w; i++) { let min = 1.0, max = -1.0; const start = i * step; const end = Math.min(data.length, start + step); for (let j = start; j < end; j++) { const v = data[j]; if (v < min) min = v; if (v > max) max = v; } const yMin = (1 + min) * 0.5 * h; const yMax = (1 + max) * 0.5 * h; ctx2d.moveTo(i + 0.5, yMin); ctx2d.lineTo(i + 0.5, yMax); } ctx2d.stroke(); } dispose() { this.stop(); try { this.filter.disconnect(); this.dryGain.disconnect(); this.delayNode.disconnect(); this.delayFeedback.disconnect(); this.delayWet.disconnect(); this.reverbNode.disconnect(); this.reverbWet.disconnect(); this.channelGain.disconnect(); this.compressor.disconnect(); this.pan.disconnect(); this.analyser.disconnect(); } catch (_) { /* already disconnected */ } } } export class PerformanceEngine { constructor(channelCount = 8) { const ctx = getAudioContext(); this.ctx = ctx; this.masterBus = ctx.createGain(); this.masterBus.gain.value = 0.9; this.masterLimiter = ctx.createDynamicsCompressor(); this.masterLimiter.threshold.value = -1.0; this.masterLimiter.knee.value = 0; this.masterLimiter.ratio.value = 20; this.masterLimiter.attack.value = 0.002; this.masterLimiter.release.value = 0.1; this.masterAnalyser = ctx.createAnalyser(); this.masterAnalyser.fftSize = 1024; this.masterAnalyserData = new Uint8Array(this.masterAnalyser.frequencyBinCount); this.masterBus.connect(this.masterLimiter); this.masterLimiter.connect(this.masterAnalyser); // Stage 2 multichannel routing. The stereo master bus is split into // two mono lines and merged into a destination sized to the device's // maxChannelCount. The pair selector decides which two merger inputs // the splitter outputs connect to — everything else stays silent. // On stereo-only devices the merger has 2 inputs and only pair 0 is // legal, which is the existing behavior. this.outputSplitter = null; this.outputMerger = null; this.currentMainPair = 0; this._buildOutputGraph(); this.channels = Array.from({ length: channelCount }, () => new ChannelStrip(this.masterBus)); this.linkSnapshot = null; this.launchQuantum = 0; // Internal transport: an always-running beat clock anchored in audio // time. Used for launch quantization when Ableton Link isn't active, // so 'Q' still lines launches up to the bar even with no peer. // BPM changes rebase the anchor (see setBpm) so phase is preserved. this.internalTransport = { originAudioTime: ctx.currentTime, anchorBeat: 0, bpm: 120, }; this.currentImpulseId = DEFAULT_IR_ID; loadImpulseResponses(ctx).then(() => { const buf = getImpulseResponseBuffer(this.currentImpulseId); if (buf) this.channels.forEach(ch => ch.setImpulseResponse(buf)); }); } setImpulseResponse(id) { const buf = getImpulseResponseBuffer(id); if (!buf) return false; this.currentImpulseId = id; this.channels.forEach(ch => ch.setImpulseResponse(buf)); return true; } /** * Route the engine's master output to a specific audio device. Pass `''` * for the system default. Stage 1: only does setSinkId — channel-pair * routing within the device is a follow-up. * * Returns the device's max channel count so the UI can populate * pair selectors (1-2, 3-4, ...). */ async setOutputDevice(deviceId) { if (typeof this.ctx.setSinkId !== 'function') { console.warn('[PerformanceEngine] AudioContext.setSinkId not supported on this build'); return this.ctx.destination.maxChannelCount ?? 2; } try { // Some Chromium versions reject setSinkId on a suspended context. if (this.ctx.state === 'suspended') { await this.ctx.resume(); } await this.ctx.setSinkId(deviceId || ''); // Try to coerce the destination to expose all available channels. // On Chromium/Linux/PipeWire, maxChannelCount is computed at // AudioContext-construction time bound to the original sink, and // setSinkId doesn't re-query it. Setting channelCount to the // current maxChannelCount forces a re-evaluation on some builds // and ensures we claim everything the destination exposes — // important for interfaces with more than 8 channels. try { this.ctx.destination.channelCount = this.ctx.destination.maxChannelCount; } catch { /* destination capped; no-op */ } try { this.ctx.destination.channelInterpretation = 'discrete'; } catch { /* older builds may not allow this — fine */ } const applied = this.ctx.sinkId; const dest = this.ctx.destination; console.log( `[PerformanceEngine] setSinkId requested='${deviceId || '(default)'}' applied='${applied}' ` + `channelCount=${dest.channelCount} maxChannelCount=${dest.maxChannelCount} ` + `interpretation=${dest.channelInterpretation}` ); if (deviceId && applied !== deviceId) { console.warn( `[PerformanceEngine] sinkId did not stick (asked='${deviceId}', got='${applied}'). ` + `Likely a placeholder/un-permissioned device id — grant mic access once to unlock real ids.` ); } // ChannelMergerNode input count is fixed at construction. Since // maxChannelCount may have changed with the new device, rebuild // the splitter→merger→destination tail to size it correctly. this._buildOutputGraph(); } catch (err) { console.error('[PerformanceEngine] setSinkId failed:', err); } return this.ctx.destination.maxChannelCount ?? 2; } /** Current max channel count of the bound output destination. */ getMaxChannelCount() { return this.ctx.destination.maxChannelCount ?? 2; } /** * (Re)build the splitter → merger → destination tail of the master path. * Called from the constructor and again whenever the device changes * (since maxChannelCount may change and ChannelMergerNode's input count * is fixed at construction). Restores the current pair after rebuild. */ _buildOutputGraph() { // Tear down any prior wiring. try { this.masterAnalyser.disconnect(); } catch { /* ok */ } try { this.outputSplitter?.disconnect(); } catch { /* ok */ } try { this.outputMerger?.disconnect(); } catch { /* ok */ } const channels = Math.max(2, this.ctx.destination.maxChannelCount || 2); this.outputSplitter = this.ctx.createChannelSplitter(2); this.outputMerger = this.ctx.createChannelMerger(channels); this.masterAnalyser.connect(this.outputSplitter); this.outputMerger.connect(this.ctx.destination); this._wireMainPair(this.currentMainPair); } /** * Connect the splitter's L/R outputs to a specific pair of merger inputs. * Pair 0 = channels 1-2, pair 1 = 3-4, etc. Clamps to the available * range so callers can pass stale indices safely. */ _wireMainPair(pairIdx) { if (!this.outputSplitter || !this.outputMerger) return; const N = this.ctx.destination.maxChannelCount || 2; const maxPair = Math.max(0, Math.floor(N / 2) - 1); const pair = Math.min(Math.max(0, pairIdx | 0), maxPair); try { this.outputSplitter.disconnect(); } catch { /* ok */ } this.outputSplitter.connect(this.outputMerger, 0, pair * 2); this.outputSplitter.connect(this.outputMerger, 1, pair * 2 + 1); this.currentMainPair = pair; } /** Public: pick which channel pair the master mix routes to. */ setMainOutputPair(pairIdx) { this._wireMainPair(pairIdx); } setChannelImpulseResponse(channelIndex, id) { const buf = getImpulseResponseBuffer(id); if (!buf || !this.channels[channelIndex]) return false; this.channels[channelIndex].setImpulseResponse(buf); return true; } setBpm(bpm) { const safe = Number(bpm); if (Number.isFinite(safe) && safe > 0) { // Rebase the internal transport so its current beat position is // preserved across the BPM change. Without rebasing, switching // 120→140 would jump the next-quantized beat by minutes' worth // of time in the wrong direction. const tr = this.internalTransport; const now = this.ctx.currentTime; const elapsed = now - tr.originAudioTime; tr.anchorBeat += elapsed * tr.bpm / 60; tr.originAudioTime = now; tr.bpm = safe; } this.channels.forEach(ch => ch.setDelayTimeForBpm(bpm)); } setLinkSnapshot(snapshot) { this.linkSnapshot = snapshot; } setLaunchQuantum(beats) { const v = Number(beats); this.launchQuantum = Number.isFinite(v) && v > 0 ? v : 0; } getNextQuantizedAudioTime() { const quantum = this.launchQuantum; if (!quantum) return 0; // First-launch shortcut: if nothing is currently playing, fire // immediately and (re)anchor the internal transport at "now". This // matches Live's Session View — the user pressed Play, they expect // audio, not silence until the next bar. Subsequent launches see at // least one channel playing and quantize as normal. Link's clock is // external so we leave its snapshot alone. const anythingPlaying = this.channels.some(c => c.isPlaying); if (!anythingPlaying) { if (!this.linkSnapshot) { this.internalTransport.originAudioTime = this.ctx.currentTime; this.internalTransport.anchorBeat = 0; } return 0; } // Prefer the Link snapshot when active so the app stays in phase with // external peers. Falls through to the internal transport when Link // isn't running so 'Q' still works standalone. const snap = this.linkSnapshot; if (snap && snap.bpm) { const elapsedSec = (performance.now() - snap.capturedAt) / 1000; const currentBeat = snap.beat + elapsedSec * (snap.bpm / 60); let nextBeat = Math.ceil(currentBeat / quantum) * quantum; if (nextBeat - currentBeat < 1e-6) nextBeat += quantum; const secondsUntil = (nextBeat - currentBeat) * 60 / snap.bpm; return this.ctx.currentTime + secondsUntil; } const tr = this.internalTransport; if (!tr.bpm) return 0; const elapsedSec = this.ctx.currentTime - tr.originAudioTime; const currentBeat = tr.anchorBeat + elapsedSec * (tr.bpm / 60); let nextBeat = Math.ceil(currentBeat / quantum) * quantum; if (nextBeat - currentBeat < 1e-6) nextBeat += quantum; const secondsUntil = (nextBeat - currentBeat) * 60 / tr.bpm; return this.ctx.currentTime + secondsUntil; } playChannel(index, loop) { const ch = this.channels[index]; if (!ch || !ch.buffer) return; ch.play(loop, this.getNextQuantizedAudioTime()); } setMasterGain(value) { this.masterBus.gain.setTargetAtTime(value, this.ctx.currentTime, 0.01); } getMasterPeak() { this.masterAnalyser.getByteTimeDomainData(this.masterAnalyserData); let peak = 0; for (let i = 0; i < this.masterAnalyserData.length; i++) { const v = Math.abs(this.masterAnalyserData[i] - 128) / 128; if (v > peak) peak = v; } return peak; } refreshMuteSolo() { const anySoloed = this.channels.some(ch => ch.isSoloed); this.channels.forEach(ch => ch.applyMuteSolo(anySoloed)); } setMute(index, value) { this.channels[index].isMuted = value; this.refreshMuteSolo(); } setSolo(index, value) { this.channels[index].isSoloed = value; this.refreshMuteSolo(); } playAll(loop = true) { const startTime = this.getNextQuantizedAudioTime(); this.channels.forEach(ch => { if (ch.buffer) ch.play(loop, startTime); }); } stopAll() { this.channels.forEach(ch => ch.stop()); } dispose() { this.channels.forEach(ch => ch.dispose()); try { this.masterBus.disconnect(); } catch (_) { /* already disconnected */ } try { this.masterLimiter.disconnect(); } catch (_) { /* already disconnected */ } try { this.masterAnalyser.disconnect(); } catch (_) { /* already disconnected */ } } }