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;
    }
}