@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.
@@ -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
+ }
Binary file