Spaces:
Paused
Paused
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);
}
}
|