Spaces:
Sleeping
Sleeping
File size: 5,888 Bytes
c7986a9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 | // 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;
}
}
|