@kuralle-syrinx/browser-client 2.1.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/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@kuralle-syrinx/browser-client",
3
+ "version": "2.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "main": "./src/index.ts",
8
+ "types": "./src/index.ts",
9
+ "dependencies": {
10
+ "@evan/opus": "1.0.3",
11
+ "@kuralle-syrinx/core": "2.1.0"
12
+ },
13
+ "devDependencies": {
14
+ "typescript": "^5.7.0",
15
+ "vite": "^6.0.0",
16
+ "vitest": "^2.1.0"
17
+ },
18
+ "scripts": {
19
+ "dev": "vite --port 5173",
20
+ "build": "vite build",
21
+ "preview": "vite preview",
22
+ "typecheck": "tsc --noEmit",
23
+ "test": "vitest run"
24
+ }
25
+ }
@@ -0,0 +1,130 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { encodeSyrinxAudioEnvelope } from "@kuralle-syrinx/core";
5
+ import {
6
+ decodeBrowserAssistantAudio,
7
+ encodeBrowserAudioEnvelopeFrame,
8
+ encodeBrowserAudioFrame,
9
+ float32ToPcm16,
10
+ pcm16FrameSampleCount,
11
+ resampleFloat32Linear,
12
+ } from "./audio.js";
13
+
14
+ describe("browser audio utilities", () => {
15
+ it("resamples 48 kHz microphone input to 16 kHz PCM without trusting AudioContext rate", () => {
16
+ const input = new Float32Array(480);
17
+ for (let i = 0; i < input.length; i += 1) input[i] = i / input.length;
18
+
19
+ const output = resampleFloat32Linear(input, {
20
+ fromSampleRateHz: 48000,
21
+ toSampleRateHz: 16000,
22
+ });
23
+
24
+ expect(output.length).toBe(160);
25
+ expect(output[0]).toBeCloseTo(0);
26
+ expect(output[1]).toBeCloseTo(input[3]!);
27
+ expect(output.at(-1)).toBeCloseTo(input[477]!);
28
+ });
29
+
30
+ it("resamples 44.1 kHz microphone input to 16 kHz with stable frame sizing", () => {
31
+ const input = new Float32Array(441);
32
+ input.fill(0.25);
33
+
34
+ const output = resampleFloat32Linear(input, {
35
+ fromSampleRateHz: 44100,
36
+ toSampleRateHz: 16000,
37
+ });
38
+
39
+ expect(output.length).toBe(160);
40
+ expect(pcm16FrameSampleCount(16000)).toBe(320);
41
+ expect(output.every((sample) => Math.abs(sample - 0.25) < 0.0001)).toBe(true);
42
+ });
43
+
44
+ it("encodes browser Float32 audio as turn-scoped 16 kHz PCM16 JSON frames", () => {
45
+ const frame = encodeBrowserAudioFrame(new Float32Array([-2, -1, 0, 0.5, 1, 2]), {
46
+ fromSampleRateHz: 48000,
47
+ toSampleRateHz: 16000,
48
+ contextId: "review-turn",
49
+ });
50
+ const bytes = Buffer.from(frame.audio, "base64");
51
+ const pcm = new Int16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 2);
52
+
53
+ expect(frame).toMatchObject({
54
+ type: "audio",
55
+ contextId: "review-turn",
56
+ sequence: undefined,
57
+ sampleRateHz: 16000,
58
+ });
59
+ expect(Array.from(pcm)).toEqual([-32768, 16384]);
60
+ });
61
+
62
+ it("preserves sequence metadata on encoded browser JSON audio frames", () => {
63
+ const frame = encodeBrowserAudioFrame(new Float32Array([0, 0.5, 1]), {
64
+ fromSampleRateHz: 48000,
65
+ toSampleRateHz: 16000,
66
+ contextId: "review-turn",
67
+ sequence: 17,
68
+ });
69
+
70
+ expect(frame).toMatchObject({
71
+ type: "audio",
72
+ contextId: "review-turn",
73
+ sampleRateHz: 16000,
74
+ sequence: 17,
75
+ });
76
+ });
77
+
78
+ it("encodes browser Float32 audio as turn-scoped binary envelope frames", () => {
79
+ const frame = encodeBrowserAudioEnvelopeFrame(new Float32Array([-2, -1, 0, 0.5, 1, 2]), {
80
+ fromSampleRateHz: 48000,
81
+ toSampleRateHz: 16000,
82
+ contextId: "review-turn",
83
+ sequence: 9,
84
+ });
85
+
86
+ const decoded = decodeBrowserAssistantAudio(frame);
87
+ const bytes = new Uint8Array(decoded.data);
88
+ const pcm = new Int16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 2);
89
+
90
+ expect(decoded.metadata).toMatchObject({
91
+ type: "audio",
92
+ contextId: "review-turn",
93
+ sampleRateHz: 16000,
94
+ sequence: 9,
95
+ encoding: "pcm_s16le",
96
+ channels: 1,
97
+ byteLength: 4,
98
+ });
99
+ expect(Array.from(pcm)).toEqual([-32768, 16384]);
100
+ });
101
+
102
+ it("clamps Float32 samples before PCM16 conversion", () => {
103
+ expect(Array.from(float32ToPcm16(new Float32Array([-1.5, -1, 0, 1, 1.5])))).toEqual([
104
+ -32768,
105
+ -32768,
106
+ 0,
107
+ 32767,
108
+ 32767,
109
+ ]);
110
+ });
111
+
112
+ it("decodes enveloped assistant audio before playback", () => {
113
+ const encoded = encodeSyrinxAudioEnvelope({
114
+ type: "audio",
115
+ contextId: "turn-tts",
116
+ sampleRateHz: 16000,
117
+ sequence: 2,
118
+ byteLength: 4,
119
+ }, new Uint8Array([1, 2, 3, 4]));
120
+
121
+ const decoded = decodeBrowserAssistantAudio(encoded);
122
+
123
+ expect(new Uint8Array(decoded.data)).toEqual(new Uint8Array([1, 2, 3, 4]));
124
+ expect(decoded.metadata).toMatchObject({
125
+ contextId: "turn-tts",
126
+ sampleRateHz: 16000,
127
+ sequence: 2,
128
+ });
129
+ });
130
+ });
package/src/audio.ts ADDED
@@ -0,0 +1,308 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import {
4
+ decodeSyrinxAudioEnvelope,
5
+ encodeSyrinxAudioEnvelope,
6
+ hasSyrinxAudioEnvelope,
7
+ type SyrinxAudioEnvelopeHeader,
8
+ } from "@kuralle-syrinx/core";
9
+ import { pcm16SamplesToBytes } from "@kuralle-syrinx/core/audio";
10
+ import type { BrowserOpusCodec } from "./browser-opus.js";
11
+
12
+ export interface ResampleFloat32Options {
13
+ readonly fromSampleRateHz: number;
14
+ readonly toSampleRateHz: number;
15
+ }
16
+
17
+ export interface EncodeBrowserAudioOptions extends ResampleFloat32Options {
18
+ readonly contextId?: string;
19
+ readonly sequence?: number;
20
+ }
21
+
22
+ export interface SyrinxAudioJsonFrame {
23
+ readonly type: "audio";
24
+ readonly audio: string;
25
+ readonly sampleRateHz: number;
26
+ readonly contextId?: string;
27
+ readonly sequence?: number;
28
+ }
29
+
30
+ export interface BrowserAssistantAudio {
31
+ readonly data: ArrayBuffer;
32
+ readonly metadata?: SyrinxAudioEnvelopeHeader;
33
+ }
34
+
35
+ export interface AudioJitterBufferOptions {
36
+ readonly targetBufferMs?: number;
37
+ readonly sampleRateHz: number;
38
+ }
39
+
40
+ interface ScheduledAudioFrame {
41
+ readonly buffer: AudioBuffer;
42
+ readonly scheduledTime: number;
43
+ readonly contextId?: string;
44
+ source: AudioBufferSourceNode | null;
45
+ }
46
+
47
+ export function resampleFloat32Linear(input: Float32Array, options: ResampleFloat32Options): Float32Array {
48
+ const fromRate = readPositiveSampleRate(options.fromSampleRateHz, "fromSampleRateHz");
49
+ const toRate = readPositiveSampleRate(options.toSampleRateHz, "toSampleRateHz");
50
+ if (fromRate === toRate) return input;
51
+
52
+ const outLength = Math.max(1, Math.round((input.length * toRate) / fromRate));
53
+ const output = new Float32Array(outLength);
54
+ const ratio = fromRate / toRate;
55
+
56
+ for (let i = 0; i < outLength; i += 1) {
57
+ const src = i * ratio;
58
+ const lo = Math.floor(src);
59
+ const hi = Math.min(input.length - 1, lo + 1);
60
+ const frac = src - lo;
61
+ output[i] = input[lo]! * (1 - frac) + input[hi]! * frac;
62
+ }
63
+
64
+ return output;
65
+ }
66
+
67
+ export function float32ToPcm16(samples: Float32Array): Int16Array {
68
+ const pcm = new Int16Array(samples.length);
69
+ for (let i = 0; i < samples.length; i += 1) {
70
+ const clamped = Math.max(-1, Math.min(1, samples[i]!));
71
+ pcm[i] = clamped < 0 ? Math.round(clamped * 32768) : Math.round(clamped * 32767);
72
+ }
73
+ return pcm;
74
+ }
75
+
76
+ export function encodeBrowserAudioFrame(input: Float32Array, options: EncodeBrowserAudioOptions): SyrinxAudioJsonFrame {
77
+ const targetRate = readPositiveSampleRate(options.toSampleRateHz, "toSampleRateHz");
78
+ const resampled = resampleFloat32Linear(input, options);
79
+ const pcm = float32ToPcm16(resampled);
80
+ return {
81
+ type: "audio",
82
+ contextId: options.contextId,
83
+ sequence: options.sequence,
84
+ sampleRateHz: targetRate,
85
+ audio: bytesToBase64(new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength)),
86
+ };
87
+ }
88
+
89
+ export function encodeBrowserAudioEnvelopeFrame(input: Float32Array, options: EncodeBrowserAudioOptions): Uint8Array {
90
+ const targetRate = readPositiveSampleRate(options.toSampleRateHz, "toSampleRateHz");
91
+ const resampled = resampleFloat32Linear(input, options);
92
+ const pcm = float32ToPcm16(resampled);
93
+ const audio = new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength);
94
+ return encodeSyrinxAudioEnvelope({
95
+ type: "audio",
96
+ contextId: options.contextId,
97
+ sampleRateHz: targetRate,
98
+ sequence: options.sequence,
99
+ encoding: "pcm_s16le",
100
+ channels: 1,
101
+ byteLength: audio.byteLength,
102
+ durationMs: Math.round((pcm.length / targetRate) * 1000),
103
+ }, audio);
104
+ }
105
+
106
+ export function pcm16FrameSampleCount(sampleRateHz: number, frameDurationMs = 20): number {
107
+ const sampleRate = readPositiveSampleRate(sampleRateHz, "sampleRateHz");
108
+ if (!Number.isFinite(frameDurationMs) || frameDurationMs <= 0) {
109
+ throw new Error("frameDurationMs must be a positive number");
110
+ }
111
+ return Math.max(1, Math.round(sampleRate * (frameDurationMs / 1000)));
112
+ }
113
+
114
+ export function decodeBrowserAssistantAudio(
115
+ input: ArrayBuffer | ArrayBufferView,
116
+ opusCodec: BrowserOpusCodec | null = null,
117
+ ): BrowserAssistantAudio {
118
+ const bytes = ArrayBuffer.isView(input)
119
+ ? new Uint8Array(input.buffer, input.byteOffset, input.byteLength)
120
+ : new Uint8Array(input);
121
+ if (!hasSyrinxAudioEnvelope(bytes)) {
122
+ return { data: copyArrayBuffer(bytes) };
123
+ }
124
+ const envelope = decodeSyrinxAudioEnvelope(bytes);
125
+ if (envelope.header.encoding === "opus") {
126
+ if (!opusCodec) {
127
+ return { data: new ArrayBuffer(0), metadata: envelope.header };
128
+ }
129
+ const wireRate = envelope.header.sampleRateHz;
130
+ const decoded = opusCodec.decodeOpusFrame(envelope.audio);
131
+ const pcm = pcm16SamplesToBytes(decoded);
132
+ return {
133
+ data: copyArrayBuffer(pcm),
134
+ metadata: {
135
+ ...envelope.header,
136
+ encoding: "pcm_s16le",
137
+ sampleRateHz: wireRate,
138
+ byteLength: pcm.byteLength,
139
+ },
140
+ };
141
+ }
142
+ return {
143
+ data: copyArrayBuffer(envelope.audio),
144
+ metadata: envelope.header,
145
+ };
146
+ }
147
+
148
+ export function bytesToBase64(bytes: Uint8Array): string {
149
+ let binary = "";
150
+ for (let i = 0; i < bytes.length; i += 0x8000) {
151
+ binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
152
+ }
153
+ return btoa(binary);
154
+ }
155
+
156
+ function readPositiveSampleRate(value: number, name: string): number {
157
+ if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive sample rate`);
158
+ return value;
159
+ }
160
+
161
+ function copyArrayBuffer(bytes: Uint8Array): ArrayBuffer {
162
+ const copy = new Uint8Array(bytes.byteLength);
163
+ copy.set(bytes);
164
+ return copy.buffer;
165
+ }
166
+
167
+ export class AudioJitterBuffer {
168
+ private readonly context: AudioContext;
169
+ private readonly scheduledFrames = new Set<ScheduledAudioFrame>();
170
+ private nextScheduledTime = 0;
171
+ private readonly targetBufferMs: number;
172
+ private readonly sampleRateHz: number;
173
+ private readonly contextIds = new Set<string>();
174
+ private lastEnqueuedContextId: string | null = null;
175
+
176
+ /** True while assistant audio is scheduled or playing on the playout clock (not the generation clock). */
177
+ get isPlayingOut(): boolean {
178
+ return this.scheduledFrames.size > 0;
179
+ }
180
+
181
+ /** ContextId of the most recently scheduled assistant audio while any audio is still playing out. */
182
+ get activeContextId(): string | null {
183
+ return this.scheduledFrames.size > 0 ? this.lastEnqueuedContextId : null;
184
+ }
185
+
186
+ constructor(context: AudioContext, options: AudioJitterBufferOptions) {
187
+ this.context = context;
188
+ this.sampleRateHz = options.sampleRateHz;
189
+ this.targetBufferMs = options.targetBufferMs ?? 100;
190
+ }
191
+
192
+ enqueue(pcm16Data: ArrayBuffer, contextId?: string): void {
193
+ try {
194
+ const pcm16Array = new Int16Array(pcm16Data);
195
+
196
+ // Skip empty or invalid audio data
197
+ if (pcm16Array.length === 0) {
198
+ return;
199
+ }
200
+
201
+ const float32Array = new Float32Array(pcm16Array.length);
202
+
203
+ // Convert PCM16 to Float32
204
+ for (let i = 0; i < pcm16Array.length; i++) {
205
+ float32Array[i] = pcm16Array[i]! / (pcm16Array[i]! < 0 ? 32768 : 32767);
206
+ }
207
+
208
+ const audioBuffer = this.context.createBuffer(1, float32Array.length, this.sampleRateHz);
209
+ audioBuffer.copyToChannel(float32Array, 0);
210
+
211
+ const now = this.context.currentTime;
212
+
213
+ // If this is the first frame or we've fallen behind, establish baseline
214
+ if (this.nextScheduledTime === 0 || this.nextScheduledTime < now) {
215
+ this.nextScheduledTime = now + (this.targetBufferMs / 1000);
216
+ }
217
+
218
+ const frame: ScheduledAudioFrame = {
219
+ buffer: audioBuffer,
220
+ scheduledTime: this.nextScheduledTime,
221
+ contextId,
222
+ source: null,
223
+ };
224
+
225
+ this.scheduledFrames.add(frame);
226
+ if (contextId) {
227
+ this.contextIds.add(contextId);
228
+ this.lastEnqueuedContextId = contextId;
229
+ }
230
+
231
+ // Schedule the audio
232
+ const source = this.context.createBufferSource();
233
+ source.buffer = audioBuffer;
234
+ source.connect(this.context.destination);
235
+
236
+ frame.source = source;
237
+ source.start(frame.scheduledTime);
238
+
239
+ // Clean up when done
240
+ source.onended = () => {
241
+ this.scheduledFrames.delete(frame);
242
+ frame.source = null;
243
+ };
244
+
245
+ this.nextScheduledTime += audioBuffer.duration;
246
+ } catch (error) {
247
+ console.warn("AudioJitterBuffer: Failed to enqueue audio frame:", error);
248
+ }
249
+ }
250
+
251
+ clear(contextId?: string): void {
252
+ if (contextId) {
253
+ for (const frame of this.scheduledFrames) {
254
+ if (frame.contextId === contextId) {
255
+ if (frame.source) {
256
+ try {
257
+ frame.source.stop();
258
+ } catch {
259
+ // Ignore if already stopped
260
+ }
261
+ }
262
+ this.scheduledFrames.delete(frame);
263
+ }
264
+ }
265
+ this.contextIds.delete(contextId);
266
+ if (this.lastEnqueuedContextId === contextId) this.lastEnqueuedContextId = null;
267
+ this.recomputeNextScheduledTime();
268
+ } else {
269
+ // Clear all frames
270
+ for (const frame of this.scheduledFrames) {
271
+ if (frame.source) {
272
+ try {
273
+ frame.source.stop();
274
+ } catch {
275
+ // Ignore if already stopped
276
+ }
277
+ }
278
+ }
279
+ this.scheduledFrames.clear();
280
+ this.contextIds.clear();
281
+ this.lastEnqueuedContextId = null;
282
+ this.nextScheduledTime = 0;
283
+ }
284
+ }
285
+
286
+ get bufferedDurationMs(): number {
287
+ if (this.scheduledFrames.size === 0) return 0;
288
+ const now = this.context.currentTime;
289
+ return Math.max(0, (this.nextScheduledTime - now) * 1000);
290
+ }
291
+
292
+ get activeContextIds(): readonly string[] {
293
+ return [...this.contextIds];
294
+ }
295
+
296
+ private recomputeNextScheduledTime(): void {
297
+ if (this.scheduledFrames.size === 0) {
298
+ this.nextScheduledTime = 0;
299
+ return;
300
+ }
301
+ let maxEnd = 0;
302
+ for (const frame of this.scheduledFrames) {
303
+ const end = frame.scheduledTime + frame.buffer.duration;
304
+ if (end > maxEnd) maxEnd = end;
305
+ }
306
+ this.nextScheduledTime = maxEnd;
307
+ }
308
+ }
@@ -0,0 +1,118 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { encodeSyrinxAudioEnvelope } from "@kuralle-syrinx/core";
4
+ import { pcm16BytesToSamples, pcm16SamplesToBytes } from "@kuralle-syrinx/core/audio";
5
+
6
+ export const BROWSER_OPUS_SAMPLE_RATE_HZ = 48_000;
7
+ export const BROWSER_OPUS_FRAME_DURATION_MS = 20;
8
+ export const BROWSER_SUPPORTED_INPUT_CODECS = ["pcm_s16le", "opus"] as const;
9
+
10
+ export type BrowserWireCodec = "pcm_s16le" | "opus";
11
+
12
+ type OpusEncoder = { encode(pcm: Uint8Array): Uint8Array };
13
+ type OpusDecoder = { decode(opus: Uint8Array): Uint8Array };
14
+
15
+ type OpusModule = {
16
+ readonly Encoder: new (options: { channels: 1; sample_rate: number; application: "voip" }) => OpusEncoder;
17
+ readonly Decoder: new (options: { channels: 1; sample_rate: number }) => OpusDecoder;
18
+ };
19
+
20
+ export interface BrowserOpusCodec {
21
+ readonly sampleRateHz: number;
22
+ readonly frameSamples: number;
23
+ encodePcm16Frame(samples: Int16Array, flush?: boolean): Uint8Array[];
24
+ decodeOpusFrame(wire: Uint8Array): Int16Array;
25
+ reset(): void;
26
+ }
27
+
28
+ let opusModulePromise: Promise<OpusModule> | null = null;
29
+
30
+ export function loadBrowserOpusModule(): Promise<OpusModule> {
31
+ opusModulePromise ??= import("@evan/opus") as Promise<OpusModule>;
32
+ return opusModulePromise;
33
+ }
34
+
35
+ export async function createBrowserOpusCodec(
36
+ sampleRateHz = BROWSER_OPUS_SAMPLE_RATE_HZ,
37
+ ): Promise<BrowserOpusCodec> {
38
+ const { Encoder, Decoder } = await loadBrowserOpusModule();
39
+ const encoder = new Encoder({ channels: 1, sample_rate: sampleRateHz, application: "voip" });
40
+ const decoder = new Decoder({ channels: 1, sample_rate: sampleRateHz });
41
+ const frameSamples = Math.max(1, Math.round((sampleRateHz * BROWSER_OPUS_FRAME_DURATION_MS) / 1000));
42
+ let encodeRemainder = new Int16Array(0);
43
+
44
+ return {
45
+ sampleRateHz,
46
+ frameSamples,
47
+ reset() {
48
+ encodeRemainder = new Int16Array(0);
49
+ },
50
+ decodeOpusFrame(wire: Uint8Array): Int16Array {
51
+ return pcm16BytesToSamples(decoder.decode(wire));
52
+ },
53
+ encodePcm16Frame(samples: Int16Array, flush = false): Uint8Array[] {
54
+ const pending = new Int16Array(encodeRemainder.length + samples.length);
55
+ pending.set(encodeRemainder);
56
+ pending.set(samples, encodeRemainder.length);
57
+ const completeFrames = Math.floor(pending.length / frameSamples);
58
+ const frames: Uint8Array[] = [];
59
+ for (let index = 0; index < completeFrames; index += 1) {
60
+ const frame = pending.subarray(index * frameSamples, (index + 1) * frameSamples);
61
+ frames.push(encoder.encode(pcm16SamplesToBytes(frame)));
62
+ }
63
+ const consumed = completeFrames * frameSamples;
64
+ const remainder = pending.subarray(consumed);
65
+ if (flush && remainder.length > 0) {
66
+ const padded = new Int16Array(frameSamples);
67
+ padded.set(remainder);
68
+ frames.push(encoder.encode(pcm16SamplesToBytes(padded)));
69
+ encodeRemainder = new Int16Array(0);
70
+ } else {
71
+ encodeRemainder = new Int16Array(remainder);
72
+ }
73
+ return frames;
74
+ },
75
+ };
76
+ }
77
+
78
+ export function pickBrowserWireCodec(
79
+ supportedInputCodecs: readonly string[] | undefined,
80
+ opusAvailable: boolean,
81
+ ): BrowserWireCodec {
82
+ if (opusAvailable && supportedInputCodecs?.includes("opus")) return "opus";
83
+ return "pcm_s16le";
84
+ }
85
+
86
+ export function encodeBrowserOpusEnvelope(
87
+ opusPayload: Uint8Array,
88
+ sampleRateHz: number,
89
+ options: { readonly contextId?: string; readonly sequence?: number },
90
+ ): Uint8Array {
91
+ return encodeSyrinxAudioEnvelope({
92
+ type: "audio",
93
+ contextId: options.contextId,
94
+ sampleRateHz,
95
+ sequence: options.sequence,
96
+ encoding: "opus",
97
+ channels: 1,
98
+ byteLength: opusPayload.byteLength,
99
+ durationMs: BROWSER_OPUS_FRAME_DURATION_MS,
100
+ }, opusPayload);
101
+ }
102
+
103
+ export function encodeBrowserPcmEnvelope(
104
+ audio: Uint8Array,
105
+ sampleRateHz: number,
106
+ options: { readonly contextId?: string; readonly sequence?: number },
107
+ ): Uint8Array {
108
+ return encodeSyrinxAudioEnvelope({
109
+ type: "audio",
110
+ contextId: options.contextId,
111
+ sampleRateHz,
112
+ sequence: options.sequence,
113
+ encoding: "pcm_s16le",
114
+ channels: 1,
115
+ byteLength: audio.byteLength,
116
+ durationMs: Math.round((audio.byteLength / 2 / sampleRateHz) * 1000),
117
+ }, audio);
118
+ }