@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,29 @@
|
|
|
1
|
+
Copyright (c) 2011, Google Inc. All rights reserved.
|
|
2
|
+
|
|
3
|
+
Redistribution and use in source and binary forms, with or without
|
|
4
|
+
modification, are permitted provided that the following conditions are
|
|
5
|
+
met:
|
|
6
|
+
|
|
7
|
+
* Redistributions of source code must retain the above copyright
|
|
8
|
+
notice, this list of conditions and the following disclaimer.
|
|
9
|
+
|
|
10
|
+
* Redistributions in binary form must reproduce the above copyright
|
|
11
|
+
notice, this list of conditions and the following disclaimer in
|
|
12
|
+
the documentation and/or other materials provided with the
|
|
13
|
+
distribution.
|
|
14
|
+
|
|
15
|
+
* Neither the name of Google nor the names of its contributors may
|
|
16
|
+
be used to endorse or promote products derived from this software
|
|
17
|
+
without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
20
|
+
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
21
|
+
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
|
22
|
+
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
|
23
|
+
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
|
24
|
+
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
|
25
|
+
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
|
26
|
+
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
|
27
|
+
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
28
|
+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dialt/sdk",
|
|
3
|
+
"version": "0.23.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Browser SDK for the Dialt realtime voice and text API",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"voice",
|
|
9
|
+
"voice-ai",
|
|
10
|
+
"realtime",
|
|
11
|
+
"speech",
|
|
12
|
+
"dialt"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://dialt.com/docs/api/",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/Dialt-AI/dialt.git",
|
|
18
|
+
"directory": "sdk/browser"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"exports": {
|
|
24
|
+
".": "./src/index.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"CHANGELOG.md",
|
|
29
|
+
"LICENSE",
|
|
30
|
+
"NOTICE",
|
|
31
|
+
"THIRD_PARTY_LICENSES"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node --test --test-concurrency=1 test/*.test.mjs",
|
|
35
|
+
"check": "npm test && npm run vendor:check",
|
|
36
|
+
"pack:check": "npm pack --dry-run",
|
|
37
|
+
"vendor": "node scripts/vendor.mjs",
|
|
38
|
+
"vendor:check": "node scripts/vendor.mjs --check"
|
|
39
|
+
},
|
|
40
|
+
"sideEffects": false
|
|
41
|
+
}
|
package/src/aec.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { SAMPLE_RATE } from './audio.js';
|
|
2
|
+
|
|
3
|
+
// SDK-side echo cancellation: libwebrtc APM (AEC3) compiled to WASM, fed the assistant's own
|
|
4
|
+
// playback as the far-end reference. Exists because WebKit (every iOS browser + Mac Safari)
|
|
5
|
+
// runs ONE shared mic pipeline per device, so we cannot both capture AEC-only audio and trust
|
|
6
|
+
// `echoCancellation:true` there: Apple's VPIO welds AEC to a noise suppressor that is harmful
|
|
7
|
+
// to ASR (finding #80, +12.5 WER at -15 dB), and a parallel raw capture strips AEC entirely
|
|
8
|
+
// (2026-07-03 prod echo incident). On WebKit the app captures fully raw and cancels here, with
|
|
9
|
+
// the same ENGINE desktop Chrome applies natively — but NOT the same behavior: desktop runs the
|
|
10
|
+
// browser's own AEC3 at its untunable defaults, and its residual suppressor eats the user's
|
|
11
|
+
// voice during double-talk (2026-07-13 gold replay: 20/20 VAD-barge misses are double-talk;
|
|
12
|
+
// raw mic fires the barge gate, AEC'd mic doesn't). The "unify on this canceller everywhere"
|
|
13
|
+
// plan was SETTLED NEGATIVE 2026-07-14 (ROADMAP improvement #1): measured on the battery's
|
|
14
|
+
// wasm arm, AEC3 v2.1's suppressor config space tops out at 12/22 barge catches vs native
|
|
15
|
+
// Chrome's 16/22 / 1 false fire — so desktop keeps the native canceller, and this module stays
|
|
16
|
+
// WebKit-only at upstream defaults. TUNED_AEC3_CONFIG + startMic({sdkAec:true}) remain the
|
|
17
|
+
// ship path if a future engine/config beats the battery gate.
|
|
18
|
+
|
|
19
|
+
const APM_FRAME = 160; // 10 ms at 16 kHz — the APM's fixed processing quantum
|
|
20
|
+
const RENDER_PUMP_MS = 10; // far-end feed cadence; AEC3's delay estimator absorbs the jitter
|
|
21
|
+
|
|
22
|
+
// The ONE tuned AEC3 config (ROADMAP improvement #1) — comma-separated key=value
|
|
23
|
+
// overrides applied via apm_create_with_config; '' = upstream defaults. Any change here
|
|
24
|
+
// must first beat the front-end battery gate (run_frontend.py, catch rate AND false
|
|
25
|
+
// fires vs the browser-arm baseline) — tune with eval/runners/sweep_wasm_aec.py.
|
|
26
|
+
export const TUNED_AEC3_CONFIG = '';
|
|
27
|
+
|
|
28
|
+
// True where getUserMedia AEC cannot deliver the AEC-only front-end spec: supported iOS browser
|
|
29
|
+
// builds (including branded Chrome/Firefox/Edge) and Mac Safari. The device gate deliberately
|
|
30
|
+
// keeps new iOS engine variants on the conservative SDK path until physically certified. Desktop
|
|
31
|
+
// Chromium keeps its native per-stream AEC3; Firefox desktop keeps Gecko's per-stream canceller.
|
|
32
|
+
export function needsSdkAec(ua = globalThis.navigator?.userAgent || '') {
|
|
33
|
+
const iosDevice = /iPhone|iPad|iPod/.test(ua)
|
|
34
|
+
|| (/Macintosh/.test(ua) && (globalThis.navigator?.maxTouchPoints || 0) > 1); // iPadOS masquerades as Mac
|
|
35
|
+
const macSafari = /Macintosh/.test(ua) && /Safari\//.test(ua)
|
|
36
|
+
&& !/Chrome\/|Chromium\/|CriOS\/|Edg/.test(ua);
|
|
37
|
+
return iosDevice || macSafari;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Streams two 16 kHz mono feeds through the APM:
|
|
41
|
+
// render (far-end) — assistant chunks, tapped from StreamingPlayer at schedule time and fed
|
|
42
|
+
// on the player's own clock so the reference tracks real playout;
|
|
43
|
+
// capture (near-end) — mic frames via processCapture(), echo-cancelled in place of the input.
|
|
44
|
+
// Until init() resolves (or if it fails) processCapture passes frames through unchanged, so a
|
|
45
|
+
// slow/failed WASM load degrades to "no AEC" rather than breaking the uplink.
|
|
46
|
+
export class EchoCanceller {
|
|
47
|
+
constructor({ loadModule, config = TUNED_AEC3_CONFIG } = {}) {
|
|
48
|
+
this._loadModule = loadModule
|
|
49
|
+
|| (() => import('./aec3-wasm.js').then((m) => m.default()));
|
|
50
|
+
this._config = config;
|
|
51
|
+
this._mod = null;
|
|
52
|
+
this._apm = 0;
|
|
53
|
+
this._buf = 0; // wasm-heap scratch for one APM frame
|
|
54
|
+
this._inBuf = new Float32Array(0);
|
|
55
|
+
this._outBuf = new Float32Array(0);
|
|
56
|
+
this._player = null;
|
|
57
|
+
this._chunks = []; // scheduled far-end: {samples, t0} in player-context time
|
|
58
|
+
this._renderClock = null; // context time of the next far-end sample to feed
|
|
59
|
+
this._pumpTimer = null;
|
|
60
|
+
this._closed = false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async init() {
|
|
64
|
+
const mod = await this._loadModule();
|
|
65
|
+
if (this._closed) return;
|
|
66
|
+
this._mod = mod;
|
|
67
|
+
if (this._config) {
|
|
68
|
+
// A non-default config must actually apply: no silent fallback to defaults.
|
|
69
|
+
if (typeof mod._apm_create_with_config !== 'function') {
|
|
70
|
+
throw new Error('aec3-wasm.js build has no apm_create_with_config');
|
|
71
|
+
}
|
|
72
|
+
const ptr = mod.stringToNewUTF8(this._config);
|
|
73
|
+
this._apm = mod._apm_create_with_config(SAMPLE_RATE, ptr);
|
|
74
|
+
mod._free(ptr);
|
|
75
|
+
} else {
|
|
76
|
+
this._apm = mod._apm_create(SAMPLE_RATE);
|
|
77
|
+
}
|
|
78
|
+
if (!this._apm) throw new Error('apm_create failed');
|
|
79
|
+
this._buf = mod._malloc(APM_FRAME * 4);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get ready() { return !!this._apm; }
|
|
83
|
+
|
|
84
|
+
// Tap the player: far-end chunks arrive with their scheduled playout time; a barge/clear
|
|
85
|
+
// drops the not-yet-played tail so the reference mirrors what actually reaches the speaker.
|
|
86
|
+
attachPlayer(player) {
|
|
87
|
+
this._player = player;
|
|
88
|
+
player.onScheduled = (samples, startAt) => {
|
|
89
|
+
this._chunks.push({ samples, t0: startAt });
|
|
90
|
+
};
|
|
91
|
+
player.onCleared = (cutAt) => {
|
|
92
|
+
// Drop chunks that start after the cut and trim the one straddling it: the reference must
|
|
93
|
+
// end where the speaker does, not where the chunk would have.
|
|
94
|
+
this._chunks = this._chunks.filter((c) => c.t0 < cutAt).map((c) => {
|
|
95
|
+
const keep = Math.round((cutAt - c.t0) * SAMPLE_RATE);
|
|
96
|
+
return keep < c.samples.length ? { samples: c.samples.subarray(0, keep), t0: c.t0 } : c;
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
if (this._pumpTimer == null) {
|
|
100
|
+
this._pumpTimer = setInterval(() => this._pumpRender(), RENDER_PUMP_MS);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Feed the APM one 10 ms far-end frame per elapsed 10 ms of player-context time — scheduled
|
|
105
|
+
// audio where a chunk overlaps the window, zeros where nothing was playing. Keeping the render
|
|
106
|
+
// stream continuous in playout time is what lets AEC3's delay estimator lock on.
|
|
107
|
+
_pumpRender() {
|
|
108
|
+
const ctx = this._player?.context;
|
|
109
|
+
if (!ctx || !this._apm) return;
|
|
110
|
+
const now = ctx.currentTime;
|
|
111
|
+
if (this._renderClock == null) this._renderClock = now;
|
|
112
|
+
if (now - this._renderClock > 1) this._renderClock = now - 0.1; // tab was throttled: resync
|
|
113
|
+
const frame = new Float32Array(APM_FRAME);
|
|
114
|
+
while (this._renderClock + APM_FRAME / SAMPLE_RATE <= now) {
|
|
115
|
+
frame.fill(0);
|
|
116
|
+
for (const c of this._chunks) {
|
|
117
|
+
const off = Math.round((this._renderClock - c.t0) * SAMPLE_RATE);
|
|
118
|
+
if (off + APM_FRAME <= 0 || off >= c.samples.length) continue;
|
|
119
|
+
for (let i = 0; i < APM_FRAME; i++) {
|
|
120
|
+
const j = off + i;
|
|
121
|
+
if (j >= 0 && j < c.samples.length) frame[i] = c.samples[j];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
this._process('_apm_process_render', frame);
|
|
125
|
+
this._renderClock += APM_FRAME / SAMPLE_RATE;
|
|
126
|
+
const horizon = this._renderClock - 1; // keep 1 s of history, prune older chunks
|
|
127
|
+
this._chunks = this._chunks.filter((c) => c.t0 + c.samples.length / SAMPLE_RATE > horizon);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Echo-cancel one mic frame (any length). Returns a frame of the same length. Because the APM
|
|
132
|
+
// quantum (160) doesn't divide the mic frame (512), the output buffer can underfill during the
|
|
133
|
+
// first few frames; each underfill is covered with leading zeros (≤128 samples total, start of
|
|
134
|
+
// session only) so no captured audio is ever dropped and alignment then holds for good.
|
|
135
|
+
processCapture(frame) {
|
|
136
|
+
if (!this._apm) return frame;
|
|
137
|
+
const joined = new Float32Array(this._inBuf.length + frame.length);
|
|
138
|
+
joined.set(this._inBuf); joined.set(frame, this._inBuf.length);
|
|
139
|
+
let pos = 0;
|
|
140
|
+
let out = this._outBuf;
|
|
141
|
+
while (joined.length - pos >= APM_FRAME) {
|
|
142
|
+
const processed = this._process('_apm_process_capture', joined.subarray(pos, pos + APM_FRAME));
|
|
143
|
+
const merged = new Float32Array(out.length + APM_FRAME);
|
|
144
|
+
merged.set(out); merged.set(processed, out.length);
|
|
145
|
+
out = merged;
|
|
146
|
+
pos += APM_FRAME;
|
|
147
|
+
}
|
|
148
|
+
this._inBuf = joined.slice(pos);
|
|
149
|
+
if (out.length < frame.length) {
|
|
150
|
+
const padded = new Float32Array(frame.length);
|
|
151
|
+
padded.set(out, frame.length - out.length);
|
|
152
|
+
out = padded;
|
|
153
|
+
}
|
|
154
|
+
const result = out.slice(0, frame.length);
|
|
155
|
+
this._outBuf = out.slice(frame.length);
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
_process(fn, frame) {
|
|
160
|
+
const mod = this._mod;
|
|
161
|
+
mod.HEAPF32.set(frame, this._buf >> 2);
|
|
162
|
+
mod[fn](this._apm, this._buf);
|
|
163
|
+
return mod.HEAPF32.slice(this._buf >> 2, (this._buf >> 2) + APM_FRAME);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
close() {
|
|
167
|
+
this._closed = true;
|
|
168
|
+
if (this._pumpTimer != null) { clearInterval(this._pumpTimer); this._pumpTimer = null; }
|
|
169
|
+
if (this._player) {
|
|
170
|
+
this._player.onScheduled = null;
|
|
171
|
+
this._player.onCleared = null;
|
|
172
|
+
this._player = null;
|
|
173
|
+
}
|
|
174
|
+
if (this._mod) {
|
|
175
|
+
if (this._buf) this._mod._free(this._buf);
|
|
176
|
+
if (this._apm) this._mod._apm_destroy(this._apm);
|
|
177
|
+
this._mod = null; this._apm = 0; this._buf = 0;
|
|
178
|
+
}
|
|
179
|
+
this._chunks = [];
|
|
180
|
+
}
|
|
181
|
+
}
|
package/src/aec3-wasm.js
ADDED
|
Binary file
|