fragmenta / app /frontend /src /utils /cueAudio.js
MazCodes's picture
Upload folder using huggingface_hub
c7986a9 verified
raw
history blame
5.89 kB
// Cue / audition output. A second AudioContext that can be routed to a
// different output device than the main mix, so the performer can preview
// candidates in headphones while the audience hears the live channels.
//
// Stage 2: the cue source now flows through a ChannelSplitter → ChannelMerger
// → destination tail so the user can pick which channel pair of a multichannel
// device the cue goes to (independent from the main mix's pair).
//
// Requires AudioContext.setSinkId (Chromium ≥ 110). On unsupported browsers
// isCueSupported() returns false and callers should disable the UI.
let ctx = null;
let currentSource = null;
let currentEndedHandler = null;
let currentSinkId = '';
let cueSplitter = null;
let cueMerger = null;
let currentCuePair = 0;
export function isCueSupported() {
try {
const AC = window.AudioContext || window.webkitAudioContext;
return AC && typeof AC.prototype.setSinkId === 'function';
} catch {
return false;
}
}
function getContext() {
if (!ctx) {
const AC = window.AudioContext || window.webkitAudioContext;
ctx = new AC();
buildCueGraph();
}
if (ctx.state === 'suspended') ctx.resume();
return ctx;
}
// (Re)build the splitter → merger → destination tail. Called once on first
// context use and again whenever setCueDevice succeeds (since maxChannelCount
// may have changed). Restores currentCuePair after rebuild.
function buildCueGraph() {
if (!ctx) return;
try { cueSplitter?.disconnect(); } catch { /* ok */ }
try { cueMerger?.disconnect(); } catch { /* ok */ }
const channels = Math.max(2, ctx.destination.maxChannelCount || 2);
cueSplitter = ctx.createChannelSplitter(2);
cueMerger = ctx.createChannelMerger(channels);
cueMerger.connect(ctx.destination);
wireCuePair(currentCuePair);
}
function wireCuePair(pairIdx) {
if (!cueSplitter || !cueMerger || !ctx) return;
const N = 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 { cueSplitter.disconnect(); } catch { /* ok */ }
cueSplitter.connect(cueMerger, 0, pair * 2);
cueSplitter.connect(cueMerger, 1, pair * 2 + 1);
currentCuePair = pair;
}
/** Public: pick which channel pair the cue routes to. */
export function setCueOutputPair(pairIdx) {
wireCuePair(pairIdx);
}
// Re-route the cue context to a different output device. Pass '' (or 'default')
// to revert to the system default output. Resolves to the actually-applied
// sinkId so callers can confirm.
export async function setCueDevice(deviceId) {
if (!isCueSupported()) {
currentSinkId = '';
return '';
}
const c = getContext();
const id = deviceId || '';
try {
if (c.state === 'suspended') await c.resume();
await c.setSinkId(id);
currentSinkId = id;
// Same coerce-channels trick as the main engine — try to claim
// all available channels post-swap. Silently clamped if not.
try {
c.destination.channelCount = c.destination.maxChannelCount;
} catch { /* ok */ }
try {
c.destination.channelInterpretation = 'discrete';
} catch { /* ok */ }
// maxChannelCount may have changed — rebuild graph.
buildCueGraph();
} catch (err) {
console.warn('[cueAudio] setSinkId failed', err);
}
return currentSinkId;
}
export function getCueDevice() {
return currentSinkId;
}
// Enumerate output devices. Requires a one-time getUserMedia call to unlock
// device labels (otherwise the label is an empty string); we request a
// short-lived mic stream and immediately stop it. Returns an array of
// MediaDeviceInfo for kind === 'audiooutput'.
export async function listOutputDevices() {
if (!navigator?.mediaDevices?.enumerateDevices) return [];
// First call without permission gets devices with blank labels — try to
// get permission so subsequent calls return meaningful labels.
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach(t => t.stop());
} catch {
// Mic denied is fine; we'll still get devices, just with empty labels.
}
const all = await navigator.mediaDevices.enumerateDevices();
return all.filter(d => d.kind === 'audiooutput');
}
// Play a Blob through the cue context. Returns an async-cancellable handle
// with a stop() method. Any previously-playing cue stops first. The source
// is routed through the splitter so it hits the user-selected channel pair.
export async function playBlob(blob, { onEnded } = {}) {
stopCue();
const c = getContext();
const arr = await blob.arrayBuffer();
const buf = await c.decodeAudioData(arr);
const src = c.createBufferSource();
src.buffer = buf;
// Connect into the splitter, NOT directly to destination — that's how
// the channel-pair routing applies.
src.connect(cueSplitter);
const handler = () => {
if (currentSource === src) {
currentSource = null;
currentEndedHandler = null;
}
onEnded?.();
};
src.addEventListener('ended', handler);
currentSource = src;
currentEndedHandler = handler;
src.start();
return {
stop: () => {
if (currentSource === src) stopCue();
},
};
}
export function stopCue() {
if (currentSource) {
if (currentEndedHandler) {
currentSource.removeEventListener('ended', currentEndedHandler);
}
try { currentSource.stop(); } catch { /* already stopped */ }
try { currentSource.disconnect(); } catch { /* already disconnected */ }
currentSource = null;
currentEndedHandler = null;
}
}