@dialt/sdk 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +336 -0
- package/LICENSE +202 -0
- package/NOTICE +12 -0
- package/README.md +319 -0
- package/THIRD_PARTY_LICENSES/README.md +9 -0
- package/THIRD_PARTY_LICENSES/abseil-Apache-2.0.txt +203 -0
- package/THIRD_PARTY_LICENSES/emscripten.txt +102 -0
- package/THIRD_PARTY_LICENSES/fft-Mark-Olesen.txt +25 -0
- package/THIRD_PARTY_LICENSES/libcxxabi-Apache-2.0-WITH-LLVM-exception.txt +311 -0
- package/THIRD_PARTY_LICENSES/musl.txt +193 -0
- package/THIRD_PARTY_LICENSES/ooura.txt +8 -0
- package/THIRD_PARTY_LICENSES/pffft-FFTPACK.txt +45 -0
- package/THIRD_PARTY_LICENSES/rnnoise-BSD-3-Clause.txt +31 -0
- package/THIRD_PARTY_LICENSES/spl-sqrt-floor-public-domain.txt +27 -0
- package/THIRD_PARTY_LICENSES/webrtc-BSD-3-Clause.txt +29 -0
- package/THIRD_PARTY_LICENSES/webrtc-PATENTS.txt +24 -0
- package/THIRD_PARTY_LICENSES/webrtc-audio-processing-BSD-3-Clause.txt +29 -0
- package/package.json +41 -0
- package/src/aec.js +181 -0
- package/src/aec3-wasm.js +0 -0
- package/src/ambience.js +508 -0
- package/src/audio.js +79 -0
- package/src/index.js +1798 -0
- package/src/mic-worklet.js +75 -0
- package/src/mic.js +155 -0
- package/src/player.js +347 -0
- package/src/track-feeder-worklet.js +80 -0
- package/src/webrtc.js +194 -0
- package/src/worklet-url.js +18 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
class VoiceLoopMicProcessor extends AudioWorkletProcessor {
|
|
2
|
+
constructor(options) {
|
|
3
|
+
super();
|
|
4
|
+
this.targetRate = options.processorOptions?.targetRate || 16000;
|
|
5
|
+
this.sourceRate = sampleRate;
|
|
6
|
+
this.frameSize = options.processorOptions?.frameSize || 512;
|
|
7
|
+
this.buffer = [];
|
|
8
|
+
this.sourcePos = 0;
|
|
9
|
+
this.carry = 0; // last sample of the previous block (seamless interpolation across blocks)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
process(inputs) {
|
|
13
|
+
const input = inputs[0];
|
|
14
|
+
if (!input || input.length === 0 || input[0].length === 0) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const mono = this.mixToMono(input);
|
|
19
|
+
if (this.sourceRate === this.targetRate) {
|
|
20
|
+
this.pushSamples(mono);
|
|
21
|
+
} else {
|
|
22
|
+
this.resampleAndPush(mono);
|
|
23
|
+
}
|
|
24
|
+
this.flushFrames();
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
mixToMono(channels) {
|
|
29
|
+
if (channels.length === 1) {
|
|
30
|
+
return channels[0];
|
|
31
|
+
}
|
|
32
|
+
const n = channels[0].length;
|
|
33
|
+
const out = new Float32Array(n);
|
|
34
|
+
for (let ch = 0; ch < channels.length; ch += 1) {
|
|
35
|
+
const data = channels[ch];
|
|
36
|
+
for (let i = 0; i < n; i += 1) out[i] += data[i] / channels.length;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Carrying the previous block's last sample lets read positions near a block seam interpolate
|
|
42
|
+
// across it — without it, non-integer ratios (44.1 kHz mics) clamp the read position at every
|
|
43
|
+
// 128-sample block and drop/repeat samples, roughening the STT/barge-VAD uplink. Branch-indexed
|
|
44
|
+
// (no per-block allocation): this runs on the realtime audio thread every ~3 ms.
|
|
45
|
+
resampleAndPush(input) {
|
|
46
|
+
const ratio = this.sourceRate / this.targetRate;
|
|
47
|
+
const n = input.length;
|
|
48
|
+
while (this.sourcePos < n) {
|
|
49
|
+
// Read position sits between carry⌢input[pos-1] and input[pos] — position 0 is the seam.
|
|
50
|
+
const i = Math.floor(this.sourcePos);
|
|
51
|
+
const frac = this.sourcePos - i;
|
|
52
|
+
const a = i === 0 ? this.carry : input[i - 1];
|
|
53
|
+
const b = input[i];
|
|
54
|
+
this.buffer.push(a + (b - a) * frac);
|
|
55
|
+
this.sourcePos += ratio;
|
|
56
|
+
}
|
|
57
|
+
this.sourcePos -= n;
|
|
58
|
+
this.carry = input[n - 1];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
pushSamples(input) {
|
|
62
|
+
for (let i = 0; i < input.length; i += 1) this.buffer.push(input[i]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
flushFrames() {
|
|
66
|
+
while (this.buffer.length >= this.frameSize) {
|
|
67
|
+
const frame = new Float32Array(this.frameSize);
|
|
68
|
+
for (let i = 0; i < this.frameSize; i += 1) frame[i] = this.buffer.shift();
|
|
69
|
+
this.port.postMessage({ type: "frame", frame }, [frame.buffer]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
registerProcessor("voice-loop-mic", VoiceLoopMicProcessor);
|
|
75
|
+
|
package/src/mic.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { FRAME_SAMPLES, SAMPLE_RATE } from './audio.js';
|
|
2
|
+
import { addWorkletModule, defaultWorkletModuleUrls } from './worklet-url.js';
|
|
3
|
+
|
|
4
|
+
export class CaptureStalledError extends Error {
|
|
5
|
+
constructor(message = 'Microphone capture opened but produced no audio frames') {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'CaptureStalledError';
|
|
8
|
+
this.code = 'capture_stalled';
|
|
9
|
+
this.retryable = true;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class CaptureAbortedError extends Error {
|
|
14
|
+
constructor(message = 'Microphone capture was stopped during startup') {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'AbortError';
|
|
17
|
+
this.code = 'capture_aborted';
|
|
18
|
+
this.retryable = true;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// SDK-owned mic capture. Exists so the AEC-only front-end spec is the default, not a doc the app
|
|
23
|
+
// must re-read: echo cancellation ON, noise suppression + AGC OFF. Browser defaults enable BOTH —
|
|
24
|
+
// NS is neutral-to-harmful for the ASR (finding #80: bundled NS +12.5 WER at −15 dB) and AGC
|
|
25
|
+
// distorts levels — so an app that hand-rolls getUserMedia({audio:true}) silently degrades the
|
|
26
|
+
// loop. Frames come out as 16 kHz 512-sample Float32 via an AudioWorklet resampler.
|
|
27
|
+
// `processing:false` opens the mic fully raw (AEC+NS+AGC off): used for the optional raw ablation
|
|
28
|
+
// track, and by ConverseClient.startMic on WebKit where the SDK's own AEC3 cancels instead.
|
|
29
|
+
export class MicCapture {
|
|
30
|
+
constructor({ onFrame, processing = true, workletUrl, deviceId } = {}) {
|
|
31
|
+
this.onFrame = onFrame;
|
|
32
|
+
this.processing = processing;
|
|
33
|
+
this.deviceId = deviceId || null;
|
|
34
|
+
// The worklet ships with the SDK; apps only override this if their bundler relocates assets.
|
|
35
|
+
// Keep the primary URL literal at this call site: Vite/Rollup/Webpack discover and emit
|
|
36
|
+
// worklet assets from this exact new URL(<literal>, import.meta.url) shape.
|
|
37
|
+
const [defaultWorkletUrl, fallbackWorkletUrl] = defaultWorkletModuleUrls(
|
|
38
|
+
new URL('./mic-worklet.js', import.meta.url), 'mic-worklet.js', import.meta.url,
|
|
39
|
+
);
|
|
40
|
+
this.workletUrl = workletUrl || defaultWorkletUrl;
|
|
41
|
+
this.fallbackWorkletUrl = workletUrl ? null : fallbackWorkletUrl;
|
|
42
|
+
this.context = null;
|
|
43
|
+
this.stream = null;
|
|
44
|
+
this.source = null;
|
|
45
|
+
this.worklet = null;
|
|
46
|
+
this.silentSink = null;
|
|
47
|
+
this._startToken = null;
|
|
48
|
+
this._firstFrameReject = null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async start({ firstFrameTimeoutMs = 2000 } = {}) {
|
|
52
|
+
if (this.context) return;
|
|
53
|
+
if (!Number.isFinite(firstFrameTimeoutMs) || firstFrameTimeoutMs <= 0) {
|
|
54
|
+
throw new RangeError('firstFrameTimeoutMs must be a positive finite number');
|
|
55
|
+
}
|
|
56
|
+
const token = {};
|
|
57
|
+
this._startToken = token;
|
|
58
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
59
|
+
audio: {
|
|
60
|
+
echoCancellation: this.processing, // AEC on for the loop; off for the raw track
|
|
61
|
+
noiseSuppression: false, // AEC-only spec: NS off (neutral-to-harmful for ASR)
|
|
62
|
+
autoGainControl: false, // AEC-only spec: AGC off (distorts levels)
|
|
63
|
+
channelCount: 1,
|
|
64
|
+
sampleRate: SAMPLE_RATE,
|
|
65
|
+
...(this.deviceId ? { deviceId: { exact: this.deviceId } } : {}),
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
if (this._startToken !== token) {
|
|
69
|
+
for (const track of stream.getTracks()) track.stop();
|
|
70
|
+
throw new CaptureAbortedError();
|
|
71
|
+
}
|
|
72
|
+
this.stream = stream;
|
|
73
|
+
try {
|
|
74
|
+
this.context = new AudioContext();
|
|
75
|
+
await addWorkletModule(
|
|
76
|
+
this.context.audioWorklet, this.workletUrl, this.fallbackWorkletUrl,
|
|
77
|
+
);
|
|
78
|
+
if (this._startToken !== token) throw new CaptureAbortedError();
|
|
79
|
+
this.source = this.context.createMediaStreamSource(this.stream);
|
|
80
|
+
this.worklet = new AudioWorkletNode(this.context, 'voice-loop-mic', {
|
|
81
|
+
processorOptions: { targetRate: SAMPLE_RATE, frameSize: FRAME_SAMPLES },
|
|
82
|
+
});
|
|
83
|
+
let firstFrameResolve;
|
|
84
|
+
const firstFrame = new Promise((resolve, reject) => {
|
|
85
|
+
firstFrameResolve = resolve;
|
|
86
|
+
this._firstFrameReject = reject;
|
|
87
|
+
});
|
|
88
|
+
this.worklet.port.onmessage = (event) => {
|
|
89
|
+
if (event.data?.type === 'frame') {
|
|
90
|
+
// Frame arrival, not signal amplitude, proves that the capture graph is operational.
|
|
91
|
+
// An all-zero Float32Array is valid silence and must satisfy the startup health gate.
|
|
92
|
+
firstFrameResolve();
|
|
93
|
+
this._firstFrameReject = null;
|
|
94
|
+
const monotonic = globalThis.performance?.now?.();
|
|
95
|
+
this.onFrame?.(event.data.frame,
|
|
96
|
+
Number.isFinite(monotonic) ? monotonic : Date.now());
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
this.silentSink = this.context.createGain();
|
|
100
|
+
this.silentSink.gain.value = 0;
|
|
101
|
+
this.source.connect(this.worklet);
|
|
102
|
+
this.worklet.connect(this.silentSink);
|
|
103
|
+
this.silentSink.connect(this.context.destination);
|
|
104
|
+
await this.context.resume();
|
|
105
|
+
if (this._startToken !== token) throw new CaptureAbortedError();
|
|
106
|
+
let timer;
|
|
107
|
+
try {
|
|
108
|
+
await Promise.race([
|
|
109
|
+
firstFrame,
|
|
110
|
+
new Promise((_, reject) => {
|
|
111
|
+
timer = setTimeout(() => reject(new CaptureStalledError()), firstFrameTimeoutMs);
|
|
112
|
+
}),
|
|
113
|
+
]);
|
|
114
|
+
} finally {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
this._firstFrameReject = null;
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
// Partial start (e.g. addModule failed): release the live tracks and the AudioContext, or
|
|
120
|
+
// the mic indicator stays lit and the leaked context counts against Chrome's per-page cap.
|
|
121
|
+
// stop() failing here must not mask the original error or leave `context` set (which
|
|
122
|
+
// would make the started-guard block a retry).
|
|
123
|
+
try { await this.stop(); } catch { this.context = null; this.stream = null; }
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async stop() {
|
|
129
|
+
this._startToken = null;
|
|
130
|
+
this._firstFrameReject?.(new CaptureAbortedError());
|
|
131
|
+
this._firstFrameReject = null;
|
|
132
|
+
const worklet = this.worklet;
|
|
133
|
+
const source = this.source;
|
|
134
|
+
const silentSink = this.silentSink;
|
|
135
|
+
const stream = this.stream;
|
|
136
|
+
const context = this.context;
|
|
137
|
+
// Clear ownership synchronously so concurrent stop/restart calls cannot release a resource
|
|
138
|
+
// twice while AudioContext.close() is awaiting its browser task.
|
|
139
|
+
this.context = null;
|
|
140
|
+
this.stream = null;
|
|
141
|
+
this.source = null;
|
|
142
|
+
this.worklet = null;
|
|
143
|
+
this.silentSink = null;
|
|
144
|
+
if (worklet?.port) worklet.port.onmessage = null;
|
|
145
|
+
try { worklet?.disconnect(); } catch { /* already disconnected */ }
|
|
146
|
+
try { source?.disconnect(); } catch { /* already disconnected */ }
|
|
147
|
+
try { silentSink?.disconnect(); } catch { /* already disconnected */ }
|
|
148
|
+
for (const track of stream?.getTracks() || []) {
|
|
149
|
+
try { track.stop(); } catch { /* already stopped */ }
|
|
150
|
+
}
|
|
151
|
+
if (context && context.state !== 'closed') {
|
|
152
|
+
try { await context.close(); } catch { /* release references even if the browser rejects */ }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
package/src/player.js
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { SAMPLE_RATE } from './audio.js';
|
|
2
|
+
|
|
3
|
+
// Initial buffer before playback starts (and on recovery after an underrun) to absorb
|
|
4
|
+
// network jitter from the streamed TTS. Trades a little first-audio latency for gap-free
|
|
5
|
+
// playback. 100 ms suits the binary WebSocket transport.
|
|
6
|
+
const JITTER_LEAD = 0.1;
|
|
7
|
+
|
|
8
|
+
// How far ahead of the playhead we keep audio scheduled. Committing only a small horizon keeps
|
|
9
|
+
// clear() (barge/interrupt) responsive — little audio is locked into already-started sources.
|
|
10
|
+
const SCHEDULE_AHEAD = 0.2;
|
|
11
|
+
// Hidden tabs throttle setInterval to ~1 Hz (and the AudioContext keeps running), so the visible
|
|
12
|
+
// horizon starves playback into 0.2 s bursts with ~0.8 s gaps. When the page is hidden, commit a
|
|
13
|
+
// horizon comfortably past one throttled tick instead. clear() stays sound at any horizon — it
|
|
14
|
+
// stops every tracked source, started or not — so the only cost is barge fade granularity in a
|
|
15
|
+
// tab nobody is looking at.
|
|
16
|
+
const HIDDEN_SCHEDULE_AHEAD = 2.5;
|
|
17
|
+
const TICK_MS = 25;
|
|
18
|
+
|
|
19
|
+
// clear() fades the master bus out over this long before stopping sources — a hard stop() cuts
|
|
20
|
+
// the waveform mid-sample and pops audibly (canceled/reset/reconnect). Must stay well under
|
|
21
|
+
// JITTER_LEAD so audio enqueued right after a clear starts at full gain.
|
|
22
|
+
const CLEAR_FADE = 0.025;
|
|
23
|
+
|
|
24
|
+
// Underlay (ambience.js): while nothing is queued, bed-only chunks of UNDERLAY_CHUNK samples are
|
|
25
|
+
// kept scheduled UNDERLAY_AHEAD past the playhead, enough to ride out a sub-second main-thread
|
|
26
|
+
// stall (GC, a WASM AEC burst, layout). A reply arriving mid-bed is NOT queued behind that lead:
|
|
27
|
+
// the scheduled bed is cut at the reply's start (see _cutUnderlay), so a reply costs the same
|
|
28
|
+
// JITTER_LEAD it always did, with the bed's tail re-rendered into the reply's own first chunks
|
|
29
|
+
// (which is also what makes the thinking sound's fade-out a crossfade).
|
|
30
|
+
const UNDERLAY_CHUNK = SAMPLE_RATE / 10; // 100 ms at 16 kHz
|
|
31
|
+
const UNDERLAY_AHEAD = 0.5;
|
|
32
|
+
|
|
33
|
+
// Streaming PCM player for gapless assistant audio.
|
|
34
|
+
//
|
|
35
|
+
// enqueue(samples) — append assistant audio (16 kHz f32) to the play queue.
|
|
36
|
+
// clear() — discard queued audio (a barge/interrupt) and stop playing now.
|
|
37
|
+
// stop() — clear() + tear down (full stop / disconnect).
|
|
38
|
+
// setUnderlay(bed) — an ambience.js AmbienceBed mixed under everything this player plays and,
|
|
39
|
+
// while nothing is queued, played on its own; the bed's own envelope decides
|
|
40
|
+
// when it is audible, the player only renders it.
|
|
41
|
+
//
|
|
42
|
+
// The queue holds 16 kHz PCM, resampled to the context rate as it is scheduled into short
|
|
43
|
+
// BufferSources (resampling at schedule time is what lets the underlay be mixed in at the exact
|
|
44
|
+
// playout moment). Every scheduled source is one entry in `scheduled`:
|
|
45
|
+
// {source, startAt, endAt, underlay, before}: `underlay` marks bed-only audio and `before` is
|
|
46
|
+
// the bed's state from just before that chunk was rendered, so a cut can hand it back exactly.
|
|
47
|
+
//
|
|
48
|
+
// Far-end tap (SDK AEC): `onScheduled(samples16k, startAt)` fires when a chunk is committed to
|
|
49
|
+
// a BufferSource, with its playout time on this context's clock; `onCleared(cutAt)` fires on a
|
|
50
|
+
// barge/clear (and an underlay cut) so the not-yet-played reference tail is dropped. See aec.js.
|
|
51
|
+
// The samples handed over are the MIX (reply + underlay), exactly what reaches the speaker.
|
|
52
|
+
// Playback intentionally remains a unity-gain AudioContext path. Physical output routing, maximum
|
|
53
|
+
// loudness, and full-duplex attenuation belong to the browser/OS; WebKit device tests found no
|
|
54
|
+
// reliable web override. Do not add software boost, output-route switching, or audio-session
|
|
55
|
+
// manipulation here. Native media integrations are the boundary for route guarantees.
|
|
56
|
+
export class StreamingPlayer {
|
|
57
|
+
constructor() {
|
|
58
|
+
this.onScheduled = null;
|
|
59
|
+
this.onCleared = null;
|
|
60
|
+
this.context = null;
|
|
61
|
+
this.master = null; // master gain bus — lets clear() fade out instead of popping
|
|
62
|
+
this.ratio = 1; // ctxRate / SAMPLE_RATE
|
|
63
|
+
this.phase = 0; // carried fractional read position across chunks (resampler)
|
|
64
|
+
this.carry = 0; // last input sample of the previous chunk (seamless interpolation)
|
|
65
|
+
this.fadeEnd = 0; // ctx time a clear()'s fade completes — new audio never starts inside it
|
|
66
|
+
this.queue = []; // Float32Array chunks at 16 kHz, awaiting scheduling
|
|
67
|
+
this.nextTime = 0; // ctx time of the next sample to schedule
|
|
68
|
+
// Reply-audio starvation telemetry: an underrun is the reply queue draining mid-reply and
|
|
69
|
+
// playback re-buffering. Session totals; takePlaybackStats() reads and resets them, so the
|
|
70
|
+
// client can attribute a window (one reply) and report it upstream. Purely observational.
|
|
71
|
+
this._replyAudioScheduled = false; // a reply chunk was scheduled since markReplyStart()
|
|
72
|
+
this._stats = { underruns: 0, starved_ms: 0, max_gap_ms: 0 };
|
|
73
|
+
this.scheduled = []; // in-flight sources (see module comment)
|
|
74
|
+
this.underlay = null; // AmbienceBed (ambience.js) or null
|
|
75
|
+
this.timer = null;
|
|
76
|
+
this._onVisibility = () => this._schedule();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async ensureContext() {
|
|
80
|
+
if (!this.context || this.context.state === 'closed') {
|
|
81
|
+
this.context = new AudioContext();
|
|
82
|
+
this.master = this.context.createGain();
|
|
83
|
+
this.master.connect(this.context.destination);
|
|
84
|
+
this.ratio = this.context.sampleRate / SAMPLE_RATE;
|
|
85
|
+
this.phase = 0;
|
|
86
|
+
this.carry = 0;
|
|
87
|
+
this.fadeEnd = 0;
|
|
88
|
+
this.nextTime = this.context.currentTime;
|
|
89
|
+
}
|
|
90
|
+
if (this.context.state === 'suspended') await this.context.resume();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Continuous linear resample of a 16 kHz chunk up to the context's native rate, carrying the
|
|
94
|
+
// fractional read position AND the previous chunk's last sample across chunks — read positions
|
|
95
|
+
// near a chunk seam interpolate across it instead of flat-holding (an audible tick per chunk).
|
|
96
|
+
_resample(input) {
|
|
97
|
+
if (this.ratio === 1) return Float32Array.from(input);
|
|
98
|
+
const step = 1 / this.ratio;
|
|
99
|
+
const n = input.length;
|
|
100
|
+
const out = new Float32Array(Math.ceil((n - this.phase) / step) + 1);
|
|
101
|
+
let pos = this.phase;
|
|
102
|
+
let k = 0;
|
|
103
|
+
while (pos < n) {
|
|
104
|
+
// Read position pos sits between carry⌢input[pos-1] and input[pos] — position 0 is the seam.
|
|
105
|
+
const i = Math.floor(pos);
|
|
106
|
+
const frac = pos - i;
|
|
107
|
+
const a = i === 0 ? this.carry : input[i - 1];
|
|
108
|
+
const b = input[i];
|
|
109
|
+
out[k++] = a + (b - a) * frac;
|
|
110
|
+
pos += step;
|
|
111
|
+
}
|
|
112
|
+
this.phase = pos - n; // remainder feeds the start of the next chunk
|
|
113
|
+
this.carry = input[n - 1];
|
|
114
|
+
return k === out.length ? out : out.subarray(0, k);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async enqueue(samples) {
|
|
118
|
+
if (!samples || samples.length === 0) return;
|
|
119
|
+
await this.ensureContext();
|
|
120
|
+
this.queue.push(Float32Array.from(samples));
|
|
121
|
+
this._ensureTimer();
|
|
122
|
+
this._schedule();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Attach (or detach, with null) the ambience bed. Rendering is the bed's job; this player
|
|
126
|
+
* just mixes whatever it renders. */
|
|
127
|
+
setUnderlay(bed) {
|
|
128
|
+
this.underlay = bed;
|
|
129
|
+
if (bed) this.resumeUnderlay();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The bed became audible (its envelope target went up) while nothing may be queued: make sure
|
|
133
|
+
* the scheduler is ticking so bed-only chunks start flowing. Never creates a context: the bed
|
|
134
|
+
* only ever follows reply audio, which already built one, and after stop() (a render landing
|
|
135
|
+
* on a closed client) there must be nothing to resume into. */
|
|
136
|
+
resumeUnderlay() {
|
|
137
|
+
if (!this.underlay || !this.context || this.context.state === 'closed') return;
|
|
138
|
+
this.ensureContext().then(() => {
|
|
139
|
+
if (!this.context || !this.underlay) return; // stopped/detached while the context resumed
|
|
140
|
+
this._ensureTimer();
|
|
141
|
+
this._schedule();
|
|
142
|
+
}).catch(() => {});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A reply boundary (`turn`): underruns before any of this reply's audio belong to no reply. */
|
|
146
|
+
markReplyStart() {
|
|
147
|
+
this._replyAudioScheduled = false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Read and reset the starvation counters (see the constructor note). */
|
|
151
|
+
takePlaybackStats() {
|
|
152
|
+
const out = { underruns: this._stats.underruns,
|
|
153
|
+
starved_ms: Math.round(this._stats.starved_ms),
|
|
154
|
+
max_gap_ms: Math.round(this._stats.max_gap_ms) };
|
|
155
|
+
this._stats = { underruns: 0, starved_ms: 0, max_gap_ms: 0 };
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Schedule queued PCM up to SCHEDULE_AHEAD past the playhead. Each call drains whole chunks
|
|
160
|
+
// off the front of the queue into a BufferSource, then tops up bed-only underlay if idle.
|
|
161
|
+
_schedule() {
|
|
162
|
+
if (!this.context) return;
|
|
163
|
+
const now = this.context.currentTime;
|
|
164
|
+
const hidden = typeof document !== 'undefined' && document.visibilityState === 'hidden';
|
|
165
|
+
// Reply audio arriving while bed-only chunks are scheduled ahead: cut the bed at the reply's
|
|
166
|
+
// start instead of queueing the reply behind it. Never earlier than reply audio already in
|
|
167
|
+
// flight (a mid-reply top-up after a momentary underrun) or the new chunk would overlap it.
|
|
168
|
+
if (this.queue.length) {
|
|
169
|
+
const replyEnd = this.scheduled.reduce((t, e) => (e.underlay ? t : Math.max(t, e.endAt)), 0);
|
|
170
|
+
if (this._cutUnderlay(Math.max(now + JITTER_LEAD, this.fadeEnd, replyEnd))) {
|
|
171
|
+
this.onCleared?.(this.nextTime);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// Re-buffer JITTER_LEAD when the queue had drained (first chunk or underrun) — and never
|
|
175
|
+
// start inside a clear()'s fade, however the two constants are tuned relative to each other.
|
|
176
|
+
if (this.nextTime <= now) {
|
|
177
|
+
if (this.queue.length && this._replyAudioScheduled) {
|
|
178
|
+
// Mid-reply drain: the playhead passed the last scheduled reply sample before the next
|
|
179
|
+
// chunk arrived. The gap is how long the speaker starved (plus the re-buffer lead).
|
|
180
|
+
const gap = (now - this.nextTime) * 1000;
|
|
181
|
+
this._stats.underruns += 1;
|
|
182
|
+
this._stats.starved_ms += gap;
|
|
183
|
+
if (gap > this._stats.max_gap_ms) this._stats.max_gap_ms = gap;
|
|
184
|
+
}
|
|
185
|
+
this.nextTime = Math.max(now + JITTER_LEAD, this.fadeEnd);
|
|
186
|
+
}
|
|
187
|
+
const horizon = hidden ? HIDDEN_SCHEDULE_AHEAD : SCHEDULE_AHEAD;
|
|
188
|
+
while (this.queue.length && this.nextTime < now + horizon) {
|
|
189
|
+
const src = this.queue.shift(); // the player's own copy: mixed in place
|
|
190
|
+
const before = this.underlay?.snapshot();
|
|
191
|
+
this.underlay?.mixInto(src);
|
|
192
|
+
this._scheduleChunk(src, false, before);
|
|
193
|
+
}
|
|
194
|
+
if (!this.queue.length && this.underlay?.audible) {
|
|
195
|
+
const ahead = hidden ? HIDDEN_SCHEDULE_AHEAD : UNDERLAY_AHEAD;
|
|
196
|
+
while (this.nextTime < now + ahead) {
|
|
197
|
+
const before = this.underlay.snapshot();
|
|
198
|
+
const chunk = this.underlay.next(UNDERLAY_CHUNK);
|
|
199
|
+
if (!chunk) break; // the envelope just reached silence
|
|
200
|
+
this._scheduleChunk(chunk, true, before);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
_scheduleChunk(src, underlay, before) {
|
|
206
|
+
if (!underlay) this._replyAudioScheduled = true;
|
|
207
|
+
const now = this.context.currentTime;
|
|
208
|
+
const data = this._resample(src);
|
|
209
|
+
if (!data.length) return;
|
|
210
|
+
const buffer = this.context.createBuffer(1, data.length, this.context.sampleRate);
|
|
211
|
+
buffer.copyToChannel(data, 0);
|
|
212
|
+
const source = this.context.createBufferSource();
|
|
213
|
+
source.buffer = buffer;
|
|
214
|
+
source.connect(this.master);
|
|
215
|
+
const startAt = Math.max(now, this.nextTime);
|
|
216
|
+
source.start(startAt);
|
|
217
|
+
this.onScheduled?.(src, startAt);
|
|
218
|
+
this.nextTime = startAt + buffer.duration;
|
|
219
|
+
const entry = { source, startAt, endAt: this.nextTime, underlay, before };
|
|
220
|
+
this.scheduled.push(entry);
|
|
221
|
+
source.onended = () => { this.scheduled = this.scheduled.filter((e) => e !== entry); };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Stop every bed-only source past `cutAt` and give the bed back what will now not play: restore
|
|
225
|
+
// its state from before the earliest chunk of ANY kind past the cut (reply chunks carry bed
|
|
226
|
+
// samples too, and clear() stops those as well), then advance it over the part of that chunk
|
|
227
|
+
// that does play. The next scheduled audio starts at cutAt. True if anything was cut.
|
|
228
|
+
_cutUnderlay(cutAt) {
|
|
229
|
+
const past = this.scheduled.filter((e) => e.endAt > cutAt);
|
|
230
|
+
const cut = past.filter((e) => e.underlay);
|
|
231
|
+
if (!cut.length && !past.length) return false;
|
|
232
|
+
for (const entry of cut) {
|
|
233
|
+
try { entry.source.stop(cutAt); } catch { /* already stopped */ }
|
|
234
|
+
}
|
|
235
|
+
this.scheduled = this.scheduled.filter((e) => !cut.includes(e));
|
|
236
|
+
const first = past.reduce((a, b) => (a.startAt <= b.startAt ? a : b));
|
|
237
|
+
if (first.before && this.underlay) {
|
|
238
|
+
this.underlay.restore(first.before);
|
|
239
|
+
const played = Math.round(Math.max(0, cutAt - first.startAt) * SAMPLE_RATE);
|
|
240
|
+
if (played > 0) this.underlay.next(played); // advance over what still plays
|
|
241
|
+
}
|
|
242
|
+
if (cut.length) this.nextTime = Math.max(this.context.currentTime, Math.min(this.nextTime, cutAt));
|
|
243
|
+
return cut.length > 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Released-but-unplayed REPLY audio right now, in ms: scheduled-but-unplayed + queued-unscheduled,
|
|
247
|
+
// not counting bed-only underlay (the bed is not the reply and must not count as audio the user
|
|
248
|
+
// "didn't hear"). On a barge this is what a drain would still play, and what a clear() throws
|
|
249
|
+
// away (discarded_ms in the playback_stopped report, which re-truncates the server's heard text).
|
|
250
|
+
pendingMs() {
|
|
251
|
+
if (!this.context) return 0;
|
|
252
|
+
const now = this.context.currentTime;
|
|
253
|
+
let replyEnd = 0;
|
|
254
|
+
let underlayAhead = 0;
|
|
255
|
+
for (const e of this.scheduled) {
|
|
256
|
+
if (e.underlay) underlayAhead += Math.max(0, e.endAt - Math.max(now, e.startAt));
|
|
257
|
+
else replyEnd = Math.max(replyEnd, e.endAt);
|
|
258
|
+
}
|
|
259
|
+
// No reply in flight: whatever is scheduled is bed only, and the lead before it is not reply
|
|
260
|
+
// audio either. Otherwise keep the pre-underlay meaning (lead gap + reply), minus bed-only.
|
|
261
|
+
const scheduled = replyEnd > now ? Math.max(0, this.nextTime - now - underlayAhead) : 0;
|
|
262
|
+
const queued = this.queue.reduce((s, c) => s + c.length, 0) / SAMPLE_RATE;
|
|
263
|
+
return (scheduled + queued) * 1000;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Device output latency (context clock -> speaker), in ms.
|
|
267
|
+
deviceLatencyMs() {
|
|
268
|
+
if (!this.context) return 0;
|
|
269
|
+
return (this.context.outputLatency || this.context.baseLatency || 0) * 1000;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Time to actual silence at the speaker if nothing else happens: pending playout plus the
|
|
273
|
+
// device output latency. This is what the server timestamps to measure the stop half of
|
|
274
|
+
// barge latency.
|
|
275
|
+
remainingMs() {
|
|
276
|
+
return this.pendingMs() + this.deviceLatencyMs();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
_ensureTimer() {
|
|
280
|
+
if (this.timer != null) return;
|
|
281
|
+
this.timer = setInterval(() => this._schedule(), TICK_MS);
|
|
282
|
+
// Top up the schedule the instant the tab hides — the first throttled tick can be a full
|
|
283
|
+
// second away, which would otherwise leave a one-off gap at the visibility transition.
|
|
284
|
+
// (visibilitychange itself fires unthrottled; document is absent under node tests.)
|
|
285
|
+
if (typeof document !== 'undefined') {
|
|
286
|
+
document.addEventListener('visibilitychange', this._onVisibility);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
_clearTimer() {
|
|
291
|
+
if (this.timer != null) {
|
|
292
|
+
clearInterval(this.timer);
|
|
293
|
+
this.timer = null;
|
|
294
|
+
}
|
|
295
|
+
if (typeof document !== 'undefined') {
|
|
296
|
+
document.removeEventListener('visibilitychange', this._onVisibility);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Discard queued audio (barge/interrupt) and stop playing — via a short master-bus fade, so
|
|
301
|
+
// the cut lands on silence instead of popping mid-waveform. `fadeS` overrides the fade for a
|
|
302
|
+
// barge hard-clear (~150 ms reads as a yield, not a glitch); the default stays pop-guard short.
|
|
303
|
+
// The underlay goes with it (same sources) and is handed back its unplayed tail, then resumes
|
|
304
|
+
// from the cut on the next tick if its envelope is still up: a barge dips the bed, it does not
|
|
305
|
+
// end it.
|
|
306
|
+
clear(fadeS = CLEAR_FADE) {
|
|
307
|
+
this.queue = [];
|
|
308
|
+
this.phase = 0;
|
|
309
|
+
this.carry = 0;
|
|
310
|
+
const ctx = this.context;
|
|
311
|
+
if (ctx && this.scheduled.length) {
|
|
312
|
+
const now = ctx.currentTime;
|
|
313
|
+
const stopAt = now + fadeS;
|
|
314
|
+
const gain = this.master.gain;
|
|
315
|
+
gain.cancelScheduledValues(now);
|
|
316
|
+
gain.setValueAtTime(gain.value, now);
|
|
317
|
+
gain.linearRampToValueAtTime(0, stopAt);
|
|
318
|
+
// Restore the instant the sources stop: same-time automation events apply in insertion
|
|
319
|
+
// order, so the bus is back at 1 exactly when nothing is left playing. _schedule() keeps
|
|
320
|
+
// any new audio out of the fade window via fadeEnd.
|
|
321
|
+
gain.setValueAtTime(1, stopAt);
|
|
322
|
+
this._cutUnderlay(stopAt);
|
|
323
|
+
for (const { source } of this.scheduled) {
|
|
324
|
+
try { source.stop(stopAt); } catch { /* already stopped */ }
|
|
325
|
+
}
|
|
326
|
+
this.scheduled = [];
|
|
327
|
+
this.fadeEnd = stopAt;
|
|
328
|
+
this.onCleared?.(stopAt);
|
|
329
|
+
}
|
|
330
|
+
this.nextTime = ctx?.currentTime || 0;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
stop() {
|
|
334
|
+
this.clear(0);
|
|
335
|
+
this._clearTimer();
|
|
336
|
+
// Full teardown must release the AudioContext: browsers cap live contexts per tab (~6 in
|
|
337
|
+
// Chrome), and every session/connect-attempt builds a fresh player — leaking contexts here
|
|
338
|
+
// made audio silently die after a few Start/Stop or provider switches until a page refresh.
|
|
339
|
+
// ensureContext() recreates on next use (it already handles state === 'closed').
|
|
340
|
+
const ctx = this.context;
|
|
341
|
+
this.context = null;
|
|
342
|
+
this.master = null;
|
|
343
|
+
if (ctx && ctx.state !== 'closed' && typeof ctx.close === 'function') {
|
|
344
|
+
ctx.close().catch(() => {});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// AudioWorkletProcessor that re-injects mic frames into a real audio graph so they can be captured
|
|
2
|
+
// back out via a MediaStreamAudioDestinationNode and handed to RTCPeerConnection.addTrack() as the
|
|
3
|
+
// outbound mic track for the webrtc transport. This is what negotiates the offer's audio m-line at
|
|
4
|
+
// signaling time, and stays the ACTIVE uplink only for custom-capture callers (no startMic()) or
|
|
5
|
+
// the SDK's own AEC3-canceller path — see src/webrtc.js's header for the full rationale; the normal
|
|
6
|
+
// startMic() path replaces this feeder's track with the real device track once available, at which
|
|
7
|
+
// point the ~PREBUFFER_FRAMES*32ms latency this file adds no longer applies to the live uplink.
|
|
8
|
+
// Frames arrive one 512-sample/16 kHz Float32Array at a time via postMessage.
|
|
9
|
+
//
|
|
10
|
+
// The feeder's AudioContext is created at 16 kHz (src/webrtc.js), matching this source rate exactly,
|
|
11
|
+
// so process() is a straight queue-fed passthrough: no resampling here. (An earlier version of this
|
|
12
|
+
// file linearly interpolated 16k -> the device's default context rate, typically 48 kHz — a
|
|
13
|
+
// low-quality resampler whose artifacts Opus baked into the encoded stream, diagnosed live as
|
|
14
|
+
// mid-stream ASR degradation. Chrome/Firefox now resample the outbound track to Opus's 48k
|
|
15
|
+
// internally with their own production resampler instead.)
|
|
16
|
+
//
|
|
17
|
+
// Frames arrive via postMessage from the main thread in bursts (batched with UI/network work),
|
|
18
|
+
// not steadily every 32 ms — draining the queue the instant any audio is available reproduces that
|
|
19
|
+
// burstiness as mid-word gaps whenever a burst is late. So draining is gated by a small adaptive
|
|
20
|
+
// prebuffer: process() emits silence until PREBUFFER_FRAMES have queued, and re-arms that wait
|
|
21
|
+
// after any underrun rather than resuming on the very next single frame (which would just re-open
|
|
22
|
+
// the same gap on the next burst gap). This trades ~PREBUFFER_FRAMES * 32ms of extra uplink latency
|
|
23
|
+
// for gap-free audio.
|
|
24
|
+
const PREBUFFER_FRAMES = 3; // 3 * 32 ms/frame = ~96 ms of buffer before (re-)starting playback.
|
|
25
|
+
// 512 samples/frame @ 16 kHz = 32 ms/frame, so 250 frames ~= 8 s of buffered mic audio. Caps
|
|
26
|
+
// memory if the outbound track ever falls behind (e.g. a stalled encoder) — process() consumes
|
|
27
|
+
// oldest-first, so an unbounded queue would otherwise grow forever and the caller would hear
|
|
28
|
+
// increasingly stale audio rather than current audio.
|
|
29
|
+
const MAX_QUEUE_FRAMES = 250;
|
|
30
|
+
|
|
31
|
+
class TrackFeederProcessor extends AudioWorkletProcessor {
|
|
32
|
+
constructor() {
|
|
33
|
+
super();
|
|
34
|
+
this.queue = []; // pending 16 kHz Float32Array frames, oldest first
|
|
35
|
+
this.offset = 0; // read position (samples) into queue[0]
|
|
36
|
+
this.muted = false; // true feeds silence without discarding queued frames
|
|
37
|
+
this.prebuffering = true; // true until PREBUFFER_FRAMES have queued; re-armed on underrun
|
|
38
|
+
this.port.onmessage = (event) => {
|
|
39
|
+
const msg = event.data;
|
|
40
|
+
if (!msg) return;
|
|
41
|
+
if (msg.type === 'frame') {
|
|
42
|
+
if (this.queue.length >= MAX_QUEUE_FRAMES) this.queue.shift(); // drop oldest, never block
|
|
43
|
+
this.queue.push(msg.frame);
|
|
44
|
+
} else if (msg.type === 'mute') this.muted = !!msg.value;
|
|
45
|
+
else if (msg.type === 'reset') { this.queue = []; this.offset = 0; this.prebuffering = true; }
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
process(_inputs, outputs) {
|
|
50
|
+
const output = outputs[0]?.[0];
|
|
51
|
+
if (!output) return true;
|
|
52
|
+
if (this.muted) { output.fill(0); return true; }
|
|
53
|
+
|
|
54
|
+
if (this.prebuffering) {
|
|
55
|
+
if (this.queue.length < PREBUFFER_FRAMES) { output.fill(0); return true; }
|
|
56
|
+
this.prebuffering = false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < output.length; i += 1) {
|
|
60
|
+
if (this.queue.length === 0) {
|
|
61
|
+
// Underrun: fill the rest of this quantum with silence and wait for a fresh prebuffer
|
|
62
|
+
// rather than draining the very next single frame the instant it arrives — that would just
|
|
63
|
+
// reopen the same gap on the next delivery burst.
|
|
64
|
+
output.fill(0, i);
|
|
65
|
+
this.prebuffering = true;
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
const frame = this.queue[0];
|
|
69
|
+
output[i] = frame[this.offset];
|
|
70
|
+
this.offset += 1;
|
|
71
|
+
if (this.offset >= frame.length) {
|
|
72
|
+
this.queue.shift();
|
|
73
|
+
this.offset = 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
registerProcessor('voice-loop-track-feeder', TrackFeederProcessor);
|