@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/src/webrtc.js ADDED
@@ -0,0 +1,194 @@
1
+ // WebRTC transport support for ConverseClient — see serving/broker_webrtc.py's module docstring
2
+ // for the full wire contract this mirrors. Two independent pieces live here:
3
+ //
4
+ // TrackFeeder — turns mic frames (whatever startMic()/pushMicFrame() would otherwise ws.send()
5
+ // as PCM16) into a real outbound MediaStreamTrack, by re-injecting them through an
6
+ // AudioWorkletNode into a MediaStreamAudioDestinationNode. RTCPeerConnection.
7
+ // addTrack() needs a live MediaStreamTrack before the offer/ICE-gather/answer
8
+ // exchange even happens — and that exchange has to complete before ConverseClient
9
+ // knows whether the app will call startMic() at all (connect() resolves first) —
10
+ // so this feeder's track is always what negotiates the offer's audio m-line.
11
+ // Once startMic() runs (the normal path: no forced SDK-side AEC), ConverseClient
12
+ // replaceTrack()s the sender straight onto the real getUserMedia device track —
13
+ // zero added JS hops/latency, no renegotiation needed — and this feeder goes idle.
14
+ // It stays the ACTIVE uplink only for callers with no MicCapture at all (custom
15
+ // capture via pushMicFrame()/appendAudio()) or for the rare case startMic() is
16
+ // forced onto the SDK's own AEC3 canceller (sdkAec:true), where re-injecting the
17
+ // already-canceled frames is required — handing the raw device track over in that
18
+ // case would ship uncancelled audio instead.
19
+ //
20
+ // WebRtcSession — the client's half of the peer-connection lifecycle: creates the "control" data
21
+ // channel and the mic track, builds an offer, waits for ICE gathering to
22
+ // complete (trickle ICE is NOT used — ADR: simpler signaling, one signaling
23
+ // round-trip, acceptable latency for a single-hop broker-terminated call), and
24
+ // later applies the server's answer.
25
+ //
26
+ // Both are deliberately dumb/mockable: ConverseClient owns all protocol semantics (start frame,
27
+ // ready/bye handling, reconnection policy); this module only knows WebRTC plumbing.
28
+
29
+ import { addWorkletModule, defaultWorkletModuleUrls } from './worklet-url.js';
30
+
31
+ const STUN_URL = 'stun:stun.l.google.com:19302';
32
+
33
+ // The SDK's mic pipeline always produces 16 kHz Float32 frames (see mic-worklet.js) — the feeder's
34
+ // AudioContext is created at this same rate so the injection worklet needs no resampling.
35
+ const SOURCE_RATE = 16000;
36
+
37
+ export class TrackFeeder {
38
+ constructor({ workletUrl } = {}) {
39
+ const [defaultWorkletUrl, fallbackWorkletUrl] = defaultWorkletModuleUrls(
40
+ // Keep the primary URL literal here so ordinary bundlers emit the worklet asset.
41
+ new URL('./track-feeder-worklet.js', import.meta.url),
42
+ 'track-feeder-worklet.js',
43
+ import.meta.url,
44
+ );
45
+ this.workletUrl = workletUrl || defaultWorkletUrl;
46
+ this.fallbackWorkletUrl = workletUrl ? null : fallbackWorkletUrl;
47
+ this.context = null;
48
+ this.worklet = null;
49
+ this.destination = null;
50
+ }
51
+
52
+ async start() {
53
+ if (this.context) return;
54
+ try {
55
+ // Run the feeder's AudioContext at the mic's native 16 kHz instead of the device's default
56
+ // (typically 48 kHz). Previously the worklet linearly interpolated 16k -> context rate itself
57
+ // — a low-quality resampler whose artifacts Opus then baked into the encoded stream, found
58
+ // live as mid-stream ASR degradation on the dev box. With context rate == source rate the
59
+ // worklet is a straight passthrough, and Chrome/Firefox resample the outbound track to Opus's
60
+ // 48k internally using their own production resampler instead.
61
+ //
62
+ // No runtime fallback here: AudioContext({sampleRate}) is supported by every Chromium and
63
+ // Firefox build this transport ships to, and WebKit never reaches this path at all (webrtc
64
+ // transport falls back to ws on WebKit — see needsSdkAec() in index.js). If a browser ever
65
+ // silently refuses the requested rate, log loudly rather than silently degrading audio.
66
+ this.context = new AudioContext({ sampleRate: SOURCE_RATE });
67
+ if (this.context.sampleRate !== SOURCE_RATE) {
68
+ console.warn(
69
+ `[voice-loop] AudioContext ignored the requested ${SOURCE_RATE} Hz sampleRate ` +
70
+ `(got ${this.context.sampleRate} Hz); mic audio quality will be degraded.`
71
+ );
72
+ }
73
+ await addWorkletModule(
74
+ this.context.audioWorklet, this.workletUrl, this.fallbackWorkletUrl,
75
+ );
76
+ this.worklet = new AudioWorkletNode(this.context, 'voice-loop-track-feeder', {
77
+ numberOfInputs: 0,
78
+ numberOfOutputs: 1,
79
+ outputChannelCount: [1],
80
+ });
81
+ this.destination = this.context.createMediaStreamDestination();
82
+ this.worklet.connect(this.destination);
83
+ await this.context.resume();
84
+ } catch (err) {
85
+ await this.stop();
86
+ throw err;
87
+ }
88
+ }
89
+
90
+ /** The live outbound MediaStreamTrack to hand RTCPeerConnection.addTrack(). */
91
+ get track() {
92
+ return this.destination?.stream?.getAudioTracks?.()[0] || null;
93
+ }
94
+
95
+ /** Feed one already-processed 16 kHz Float32 mic frame (same data pushMicFrame would ws.send()).
96
+ * Deliberately NOT a transferable postMessage: pushMicFrame()'s `frame` argument may be a
97
+ * buffer a caller (custom capture integrations) still holds a reference to afterward — this
98
+ * copies (512 samples is cheap) rather than risk silently detaching someone else's array. */
99
+ push(frame) {
100
+ this.worklet?.port.postMessage({ type: 'frame', frame });
101
+ }
102
+
103
+ /** setMicEnabled(false) parity: feed silence without tearing the track down. */
104
+ setMuted(muted) {
105
+ this.worklet?.port.postMessage({ type: 'mute', value: !!muted });
106
+ }
107
+
108
+ async stop() {
109
+ this.worklet?.disconnect();
110
+ this.worklet = null;
111
+ this.destination = null;
112
+ if (this.context && this.context.state !== 'closed') await this.context.close();
113
+ this.context = null;
114
+ }
115
+ }
116
+
117
+ // Gather-mostly-complete pattern (trickle ICE is not used): resolve once the offer's own ICE
118
+ // gathering is done — OR after a bounded wait with whatever candidates exist by then. The bound
119
+ // is load-bearing, found live on the dev box: with a TURN server in the config, Chrome can hold
120
+ // iceGatheringState off 'complete' for tens of seconds (slow relay allocation, a TURN URL whose
121
+ // transport doesn't answer), which starved the server's webrtc_offer timeout and the call never
122
+ // connected. Host + srflx + relay candidates all normally land well under this bound, so the SDP
123
+ // sent after it is the same one 'complete' would have carried.
124
+ const ICE_GATHER_TIMEOUT_MS = 3000;
125
+
126
+ function waitForIceGathering(pc, timeoutMs = ICE_GATHER_TIMEOUT_MS) {
127
+ if (pc.iceGatheringState === 'complete') return Promise.resolve();
128
+ return new Promise((resolve) => {
129
+ const timer = setTimeout(() => {
130
+ pc.removeEventListener('icegatheringstatechange', check);
131
+ resolve();
132
+ }, timeoutMs);
133
+ const check = () => {
134
+ if (pc.iceGatheringState === 'complete') {
135
+ clearTimeout(timer);
136
+ pc.removeEventListener('icegatheringstatechange', check);
137
+ resolve();
138
+ }
139
+ };
140
+ pc.addEventListener('icegatheringstatechange', check);
141
+ });
142
+ }
143
+
144
+ export class WebRtcSession {
145
+ constructor({ RTCPeerConnectionImpl = globalThis.RTCPeerConnection, iceServers } = {}) {
146
+ if (!RTCPeerConnectionImpl) throw new Error('RTCPeerConnection is required for transport: "webrtc"');
147
+ // iceServers comes from the server's webrtc_ice frame (STUN + short-lived TURN creds when
148
+ // coturn is configured) — that's why signaling is two-step: the peer connection must be built
149
+ // WITH these before gathering starts, or TURN could never be used. Default STUN is only the
150
+ // fallback for a server that sent none.
151
+ this.pc = new RTCPeerConnectionImpl({ iceServers: iceServers || [{ urls: [STUN_URL] }] });
152
+ }
153
+
154
+ /** Create the client-side "control" data channel the wire contract requires by name. */
155
+ createControlChannel() {
156
+ return this.pc.createDataChannel('control');
157
+ }
158
+
159
+ /** Returns the RTCRtpSender so the caller can later replaceTrack() a real capture device's
160
+ * track in directly (see ConverseClient.startMic) without renegotiating. */
161
+ addAudioTrack(track) {
162
+ return this.pc.addTrack(track);
163
+ }
164
+
165
+ /** Register a callback for the server's outbound audio track (assistant voice). */
166
+ onRemoteTrack(handler) {
167
+ this.pc.addEventListener('track', (ev) => {
168
+ if (ev.track?.kind && ev.track.kind !== 'audio') return;
169
+ const stream = ev.streams?.[0]
170
+ || (typeof MediaStream !== 'undefined' ? new MediaStream([ev.track]) : null);
171
+ handler(stream, ev.track);
172
+ });
173
+ }
174
+
175
+ onConnectionStateChange(handler) {
176
+ this.pc.addEventListener('connectionstatechange', () => handler(this.pc.connectionState));
177
+ }
178
+
179
+ /** Create the offer, set it local, wait for ICE gathering to finish, and return the final SDP. */
180
+ async createOfferWithGatheredIce() {
181
+ const offer = await this.pc.createOffer();
182
+ await this.pc.setLocalDescription(offer);
183
+ await waitForIceGathering(this.pc);
184
+ return this.pc.localDescription.sdp;
185
+ }
186
+
187
+ async applyAnswer(sdp) {
188
+ await this.pc.setRemoteDescription({ type: 'answer', sdp });
189
+ }
190
+
191
+ close() {
192
+ try { this.pc.close(); } catch { /* already closed */ }
193
+ }
194
+ }
@@ -0,0 +1,18 @@
1
+ // AudioWorklet modules are not part of the ordinary JavaScript import graph. Most hosts can load
2
+ // the sibling of the SDK entry, while CDN-transformed entries can still expose the package source
3
+ // tree. Try the normal URL first and use that source-tree path only when the normal module fails.
4
+ export function defaultWorkletModuleUrls(primaryUrl, filename, moduleUrl = import.meta.url) {
5
+ return [
6
+ primaryUrl,
7
+ new URL(`./src/${filename}`, moduleUrl),
8
+ ];
9
+ }
10
+
11
+ export async function addWorkletModule(audioWorklet, primaryUrl, fallbackUrl = null) {
12
+ try {
13
+ await audioWorklet.addModule(primaryUrl);
14
+ } catch (error) {
15
+ if (!fallbackUrl || fallbackUrl.href === primaryUrl.href) throw error;
16
+ await audioWorklet.addModule(fallbackUrl);
17
+ }
18
+ }