File size: 12,930 Bytes
0b9dc2e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
/**

 * Streaming audio playback support for assistant DataBlocks.

 *

 * Omni-style models (e.g. qwen3.5-omni-plus) deliver speech as a stream of

 * DATA_BLOCK_DELTA events carrying base64 audio bytes for one logical

 * DataBlock. This module routes those chunks to two playback paths:

 *

 *   1. **Live chunked playback** β€” only for audio/wav. We parse the RIFF

 *      header from the first chunk and schedule subsequent PCM bytes onto a

 *      Web Audio timeline as they arrive, so the assistant starts speaking

 *      the moment the first chunk arrives β€” without waiting for

 *      DATA_BLOCK_END, which the agent only emits after the *entire* model

 *      stream (including text) finishes.

 *

 *   2. **Buffered final playback** β€” for non-wav formats (mp3/opus/etc.) we

 *      accumulate bytes and, on DATA_BLOCK_END, build a Blob URL that backs

 *      autoplay + an ``<audio controls>`` element for replay/scrubbing.

 *

 * For wav, the controls element built on DATA_BLOCK_END is replay-only β€”

 * we intentionally skip the second autoplay there to avoid colliding with

 * the live player. ``WavStreamPlayer.finalize`` defers AudioContext

 * teardown until queued PCM has finished playing, so live audio is never

 * cut short by the end event arriving mid-playback.

 */

/** Status of a streaming audio block. */
export type StreamingAudioStatus = 'streaming' | 'ready' | 'error';

/** Per-block state surfaced to React. */
export interface StreamingAudioState {
	status: StreamingAudioStatus;
	mediaType: string;
	/** Object URL for ``<audio>`` once playback-ready. */
	url: string | null;
	/** Cause for ``status === 'error'``. */
	error?: string;
	/** Incremented each time a newer reply interrupts this block. */
	interruptCount: number;
}

type Listener = () => void;

type Bytes = Uint8Array<ArrayBufferLike>;

function base64ToBytes(b64: string): Bytes {
	const binary = atob(b64);
	const bytes = new Uint8Array(binary.length);
	for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
	return bytes;
}

function concatBytes(a: Bytes, b: Bytes): Bytes {
	const out = new Uint8Array(a.length + b.length);
	out.set(a, 0);
	out.set(b, a.length);
	return out;
}

interface WavHeader {
	sampleRate: number;
	channels: number;
	bitsPerSample: number;
	/** Byte offset where PCM samples begin (right after the ``data`` chunk header). */
	dataOffset: number;
}

/**

 * Parse a minimal RIFF/WAVE header. Returns ``null`` if the buffer doesn't

 * yet contain enough bytes to locate the ``fmt `` and ``data`` chunks.

 */
function parseWavHeader(bytes: Bytes): WavHeader | null {
	if (bytes.length < 44) return null;
	const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
	// "RIFF"...."WAVE"
	if (view.getUint32(0, false) !== 0x52494646 || view.getUint32(8, false) !== 0x57415645) {
		return null;
	}

	let offset = 12;
	let sampleRate = 0;
	let channels = 0;
	let bitsPerSample = 0;
	let dataOffset = -1;

	while (offset + 8 <= bytes.length) {
		const chunkId = view.getUint32(offset, false);
		const chunkSize = view.getUint32(offset + 4, true);
		const chunkBody = offset + 8;

		if (chunkId === 0x666d7420 /* "fmt " */) {
			if (chunkBody + 16 > bytes.length) return null;
			channels = view.getUint16(chunkBody + 2, true);
			sampleRate = view.getUint32(chunkBody + 4, true);
			bitsPerSample = view.getUint16(chunkBody + 14, true);
		} else if (chunkId === 0x64617461 /* "data" */) {
			dataOffset = chunkBody;
			break;
		}
		offset = chunkBody + chunkSize;
	}

	if (!sampleRate || !channels || !bitsPerSample || dataOffset < 0) return null;
	return { sampleRate, channels, bitsPerSample, dataOffset };
}

/**

 * Convert raw little-endian PCM into a Float32 AudioBuffer.

 * Supports 16-bit and 8-bit; falls back to silence for other depths.

 */
function pcmToAudioBuffer(ctx: AudioContext, pcm: Bytes, header: WavHeader): AudioBuffer | null {
	const { channels, bitsPerSample, sampleRate } = header;
	const bytesPerSample = bitsPerSample / 8;
	const frameSize = bytesPerSample * channels;
	const frameCount = Math.floor(pcm.length / frameSize);
	if (frameCount === 0) return null;

	const buffer = ctx.createBuffer(channels, frameCount, sampleRate);
	const view = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength);

	for (let ch = 0; ch < channels; ch++) {
		const channelData = buffer.getChannelData(ch);
		for (let i = 0; i < frameCount; i++) {
			const sampleOffset = i * frameSize + ch * bytesPerSample;
			if (bitsPerSample === 16) {
				channelData[i] = view.getInt16(sampleOffset, true) / 0x8000;
			} else if (bitsPerSample === 8) {
				channelData[i] = (view.getUint8(sampleOffset) - 128) / 128;
			} else {
				channelData[i] = 0;
			}
		}
	}
	return buffer;
}

/** Live PCM playback for an in-progress WAV stream. */
class WavStreamPlayer {
	private ctx: AudioContext | null = null;
	private header: WavHeader | null = null;
	private pending: Bytes = new Uint8Array(0);
	private nextStartTime = 0;
	private failed = false;
	private deferredCloseTimer: ReturnType<typeof setTimeout> | null = null;

	append(chunk: Bytes): void {
		if (this.failed) return;
		this.pending = concatBytes(this.pending, chunk);

		if (!this.header) {
			const header = parseWavHeader(this.pending);
			if (!header) return;
			this.header = header;
			try {
				this.ctx = new AudioContext({ sampleRate: header.sampleRate });
				if (this.ctx.state === 'suspended') {
					void this.ctx.resume();
				}
				this.nextStartTime = this.ctx.currentTime;
			} catch {
				this.failed = true;
				return;
			}
			this.pending = this.pending.slice(header.dataOffset);
		}

		if (!this.ctx || !this.header) return;

		const frameSize = (this.header.bitsPerSample / 8) * this.header.channels;
		const playableBytes = Math.floor(this.pending.length / frameSize) * frameSize;
		if (playableBytes === 0) return;

		const toPlay = this.pending.slice(0, playableBytes);
		this.pending = this.pending.slice(playableBytes);

		const audioBuffer = pcmToAudioBuffer(this.ctx, toPlay, this.header);
		if (!audioBuffer) return;

		const source = this.ctx.createBufferSource();
		source.buffer = audioBuffer;
		source.connect(this.ctx.destination);
		const startAt = Math.max(this.nextStartTime, this.ctx.currentTime);
		source.start(startAt);
		this.nextStartTime = startAt + audioBuffer.duration;
	}

	/**

	 * Graceful end-of-stream: stop accepting more bytes, then close the

	 * AudioContext only after the last queued buffer has finished playing.

	 * Without this, ``end()`` calling ``dispose()`` would cut live playback

	 * short by closing the context while sources are still scheduled.

	 *

	 * The AudioContext reference is kept on ``this.ctx`` so that

	 * ``dispose()`` can still close it immediately if a newer reply

	 * interrupts playback before the timer fires.

	 */
	finalize(): void {
		if (!this.ctx || this.failed) {
			this.dispose();
			return;
		}
		const remainingMs = Math.max(0, this.nextStartTime - this.ctx.currentTime) * 1000;
		this.deferredCloseTimer = setTimeout(() => {
			this.dispose();
		}, remainingMs + 200);
		this.header = null;
		this.pending = new Uint8Array(0);
	}

	dispose(): void {
		if (this.deferredCloseTimer) {
			clearTimeout(this.deferredCloseTimer);
			this.deferredCloseTimer = null;
		}
		if (this.ctx) {
			void this.ctx.close().catch(() => undefined);
			this.ctx = null;
		}
		this.pending = new Uint8Array(0);
		this.header = null;
	}
}

interface Session {
	mediaType: string;
	chunks: Bytes[];
	totalBytes: number;
	livePlayer: WavStreamPlayer | null;
	/** True if a live player was created at start β€” survives dispose. */
	hadLivePlayer: boolean;
	state: StreamingAudioState;
}

/**

 * Manages the lifecycle of streaming audio DataBlocks for one chat session.

 * React components subscribe via {@link subscribe} to re-render when a block's

 * state changes.

 */
export class StreamingAudioManager {
	private sessions = new Map<string, Session>();
	private listeners = new Map<string, Set<Listener>>();

	private emit(blockId: string): void {
		this.listeners.get(blockId)?.forEach((fn) => fn());
	}

	subscribe(blockId: string, fn: Listener): () => void {
		let set = this.listeners.get(blockId);
		if (!set) {
			set = new Set();
			this.listeners.set(blockId, set);
		}
		set.add(fn);
		return () => {
			set?.delete(fn);
			if (set && set.size === 0) this.listeners.delete(blockId);
		};
	}

	getState(blockId: string): StreamingAudioState | null {
		return this.sessions.get(blockId)?.state ?? null;
	}

	/** Called on DATA_BLOCK_START for an audio block. */
	start(blockId: string, mediaType: string): void {
		if (this.sessions.has(blockId)) return;
		const livePlayer = mediaType === 'audio/wav' ? new WavStreamPlayer() : null;
		this.sessions.set(blockId, {
			mediaType,
			chunks: [],
			totalBytes: 0,
			livePlayer,
			hadLivePlayer: livePlayer !== null,
			state: { status: 'streaming', mediaType, url: null, interruptCount: 0 },
		});
		this.emit(blockId);
	}

	/** Called on DATA_BLOCK_DELTA. ``data`` is the base64 chunk payload. */
	append(blockId: string, data: string): void {
		const session = this.sessions.get(blockId);
		if (!session || !data) return;
		let bytes: Uint8Array;
		try {
			bytes = base64ToBytes(data);
		} catch {
			return;
		}
		session.chunks.push(bytes);
		session.totalBytes += bytes.length;
		session.livePlayer?.append(bytes);
		// No emit β€” partial bytes don't change the rendered UI yet.
	}

	/**

	 * Called on DATA_BLOCK_END. Builds the replay URL and (for non-wav)

	 * autoplays. For wav we skip autoplay because ``WavStreamPlayer`` has

	 * already been playing the bytes as they arrived; a second ``Audio``

	 * playback here would either double up or β€” given ``end`` fires only

	 * after the whole model stream (text included) finishes β€” make the

	 * user perceive audio as starting only after text streaming completes.

	 */
	end(blockId: string): void {
		const session = this.sessions.get(blockId);
		if (!session) return;

		const hadLivePlayback = session.hadLivePlayer;
		if (session.livePlayer) {
			// Let queued PCM finish playing; AudioContext closes via a
			// deferred timer. The livePlayer reference is kept so that
			// stopAllPlayback() can dispose() it immediately if a newer
			// reply starts before the timer fires.
			session.livePlayer.finalize();
		}

		const blob = new Blob(session.chunks as BlobPart[], { type: session.mediaType });
		const url = URL.createObjectURL(blob);

		// Free the per-chunk buffers; the Blob owns the bytes from here on.
		session.chunks = [];

		session.state = {
			status: 'ready',
			mediaType: session.mediaType,
			url,
			interruptCount: session.state.interruptCount,
		};
		this.emit(blockId);

		if (!hadLivePlayback) {
			// Non-wav: no live playback path, so kick off autoplay now. Some
			// browsers gate this behind a user gesture; if blocked, the user
			// can still hit play on the <audio controls> element.
			const el = new Audio(url);
			void el.play().catch(() => undefined);
		}
	}

	/**

	 * Stop live streaming playback only (WavStreamPlayers). Does not

	 * touch replay ``<audio>`` elements or bump ``interruptCount``.

	 * Called by the replay controller so that clicking play on a past

	 * audio block silences any in-progress streaming audio without

	 * interfering with the element that's about to start playing.

	 */
	stopLivePlayback(): void {
		for (const session of this.sessions.values()) {
			if (session.livePlayer) {
				session.livePlayer.dispose();
				session.livePlayer = null;
			}
		}
	}

	/**

	 * Stop all in-progress and replay audio. Called when a new reply

	 * starts so previous audio doesn't overlap with the new one.

	 * Blob URLs are preserved so the user can still manually replay.

	 */
	stopAllPlayback(): void {
		for (const [blockId, session] of this.sessions) {
			if (session.livePlayer) {
				session.livePlayer.dispose();
				session.livePlayer = null;
			}
			session.state = {
				...session.state,
				interruptCount: session.state.interruptCount + 1,
			};
			this.emit(blockId);
		}
	}

	/** Release a single block's resources (Object URL etc.). */
	dispose(blockId: string): void {
		const session = this.sessions.get(blockId);
		if (!session) return;
		session.livePlayer?.dispose();
		if (session.state.url) URL.revokeObjectURL(session.state.url);
		this.sessions.delete(blockId);
		this.listeners.delete(blockId);
	}

	/** Release every block β€” call on unmount or session switch. */
	disposeAll(): void {
		for (const id of [...this.sessions.keys()]) this.dispose(id);
	}
}