Spaces:
Running
Running
| 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 */ } | |
| } | |
| } | |