@spatius/avatarkit 1.3.5-beta.1 → 1.3.5

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.
@@ -1,561 +0,0 @@
1
- import { c as logEvent, t as logger } from "./logger-BCrlTIt2.js";
2
- import { n as APP_CONFIG, t as errorToMessage } from "./error-utils-CX5P3vnW.js";
3
- //#region audio/StreamingAudioPlayer.ts
4
- /**
5
- * Streaming Audio Player
6
- * Implements real-time audio playback using Web Audio API
7
- * Supports dynamic PCM chunk addition without Workers
8
- * @internal
9
- */
10
- /**
11
- * @internal
12
- */
13
- var StreamingAudioPlayer = class {
14
- audioContext = null;
15
- sampleRate;
16
- channelCount;
17
- debug;
18
- sessionId;
19
- sessionStartTime = 0;
20
- pausedTimeOffset = 0;
21
- pausedAt = 0;
22
- pausedAudioContextTime = 0;
23
- scheduledTime = 0;
24
- isPlaying = false;
25
- isPaused = false;
26
- autoStartEnabled = true;
27
- autoContinue = false;
28
- audioChunks = [];
29
- scheduledChunks = 0;
30
- activeSources = /* @__PURE__ */ new Set();
31
- lastScheduledChunkEndTime = 0;
32
- lastGetCurrentTimeLog = 0;
33
- scheduledChunkInfo = [];
34
- gainNode = null;
35
- volume = 1;
36
- onEndedCallback;
37
- onAudioStallCallback;
38
- stateChangeHandler;
39
- isResuming = false;
40
- constructor(options) {
41
- this.sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
42
- this.sampleRate = options?.sampleRate ?? APP_CONFIG.audio.sampleRate;
43
- this.channelCount = options?.channelCount ?? 1;
44
- this.debug = options?.debug ?? false;
45
- }
46
- /**
47
- * Initialize audio context (create and ensure it's ready)
48
- */
49
- async initialize() {
50
- if (this.audioContext) return;
51
- try {
52
- this.audioContext = new AudioContext({ sampleRate: this.sampleRate });
53
- this.gainNode = this.audioContext.createGain();
54
- this.gainNode.gain.value = this.volume;
55
- this.gainNode.connect(this.audioContext.destination);
56
- if (this.audioContext.state === "suspended") await this.audioContext.resume();
57
- this.stateChangeHandler = (event) => {
58
- if (event.target.state === "suspended" && this.isPlaying && !this.isPaused) this.ensureAudioContextRunning().catch((err) => {
59
- logger.error("[StreamingAudioPlayer] Failed to auto-resume AudioContext after external suspend:", err);
60
- });
61
- };
62
- this.audioContext.addEventListener("statechange", this.stateChangeHandler);
63
- this.log("AudioContext initialized", {
64
- sessionId: this.sessionId,
65
- sampleRate: this.audioContext.sampleRate,
66
- state: this.audioContext.state
67
- });
68
- } catch (error) {
69
- const message = errorToMessage(error);
70
- logEvent("audio_session_init_failed", "warning", {
71
- session_id: this.sessionId,
72
- reason: message
73
- });
74
- logger.error("Failed to initialize AudioContext:", message);
75
- throw error instanceof Error ? error : new Error(message);
76
- }
77
- }
78
- /**
79
- * Ensure AudioContext is running (auto-resume if suspended)
80
- * Only auto-resume when playing and not paused, avoid interfering with normal pause/resume logic
81
- *
82
- * Optimizations:
83
- * - Fast path: if already in running state, return directly
84
- * - Avoid concurrent resume: use isResuming flag to prevent duplicate resume requests
85
- * - Handle closed state: if AudioContext is closed, cannot resume
86
- * @internal
87
- */
88
- async ensureAudioContextRunning() {
89
- if (!this.audioContext) return;
90
- const state = this.audioContext.state;
91
- if (state === "running") return;
92
- if (state === "closed") {
93
- this.log("AudioContext is closed, cannot resume", {
94
- sessionId: this.sessionId,
95
- state
96
- });
97
- return;
98
- }
99
- if (state === "suspended" && this.isPlaying && !this.isPaused) {
100
- if (this.isResuming) {
101
- this.log("AudioContext resume already in progress, skipping duplicate request", {
102
- sessionId: this.sessionId,
103
- state
104
- });
105
- return;
106
- }
107
- this.isResuming = true;
108
- try {
109
- this.log("AudioContext is suspended during playback, resuming...", {
110
- sessionId: this.sessionId,
111
- state,
112
- isPlaying: this.isPlaying,
113
- isPaused: this.isPaused
114
- });
115
- await this.audioContext.resume();
116
- this.log("AudioContext resumed successfully", {
117
- sessionId: this.sessionId,
118
- state: this.audioContext.state
119
- });
120
- } catch (err) {
121
- logger.error("[StreamingAudioPlayer] Failed to resume AudioContext:", err);
122
- logEvent("audio_context_resume_failed", "error", {
123
- session_id: this.sessionId,
124
- reason: err instanceof Error ? err.message : String(err)
125
- });
126
- } finally {
127
- this.isResuming = false;
128
- }
129
- }
130
- }
131
- /**
132
- * Add audio chunk (16-bit PCM)
133
- */
134
- addChunk(pcmData, isLast = false) {
135
- if (!this.audioContext) {
136
- logger.error("AudioContext not initialized");
137
- return;
138
- }
139
- if (this.isPlaying && !this.isPaused && this.audioContext.state === "suspended") this.ensureAudioContextRunning().catch((err) => {
140
- logger.error("[StreamingAudioPlayer] Failed to ensure AudioContext running in addChunk:", err);
141
- });
142
- this.audioChunks.push({
143
- data: pcmData,
144
- isLast
145
- });
146
- if (this.isPlaying && this.audioChunks.length === this.scheduledChunks) {}
147
- this.log(`Added chunk ${this.audioChunks.length}`, {
148
- size: pcmData.length,
149
- totalChunks: this.audioChunks.length,
150
- isLast,
151
- isPlaying: this.isPlaying,
152
- scheduledChunks: this.scheduledChunks
153
- });
154
- if (this.autoContinue && this.isPaused) {
155
- this.log("[StreamingAudioPlayer] autoContinue=true, auto-resuming playback");
156
- this.autoContinue = false;
157
- this.onAudioStallCallback?.(false);
158
- this.resume().catch((err) => {
159
- logger.error("Failed to auto-resume playback:", err);
160
- });
161
- }
162
- if (!this.isPlaying && this.autoStartEnabled && this.audioChunks.length > 0) {
163
- this.log("[StreamingAudioPlayer] Auto-starting playback from addChunk");
164
- this.startPlayback().catch((err) => {
165
- logger.error("[StreamingAudioPlayer] Failed to start playback from addChunk:", err);
166
- });
167
- } else if (this.isPlaying && !this.isPaused) {
168
- this.log("[StreamingAudioPlayer] Already playing, scheduling next chunk");
169
- this.scheduleNextChunk();
170
- } else this.log("[StreamingAudioPlayer] Not playing and no chunks, waiting for more chunks");
171
- }
172
- /**
173
- * Start new session (stop current and start fresh)
174
- */
175
- async startNewSession(audioChunks, leadingSilenceMs = 0) {
176
- this.stop();
177
- this.sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
178
- this.audioChunks = [];
179
- this.scheduledChunks = 0;
180
- this.pausedTimeOffset = 0;
181
- this.pausedAt = 0;
182
- this.pausedAudioContextTime = 0;
183
- this.autoContinue = false;
184
- this.log("Starting new session", {
185
- chunks: audioChunks.length,
186
- leadingSilenceMs
187
- });
188
- if (leadingSilenceMs > 0) {
189
- const silenceSamples = Math.floor(leadingSilenceMs / 1e3 * this.sampleRate) * this.channelCount;
190
- const silencePcm = new Uint8Array(silenceSamples * 2);
191
- this.addChunk(silencePcm, false);
192
- }
193
- for (const chunk of audioChunks) this.addChunk(chunk.data, chunk.isLast);
194
- }
195
- /**
196
- * Start playback
197
- */
198
- async startPlayback() {
199
- if (!this.audioContext) {
200
- this.log("[StreamingAudioPlayer] Cannot start playback: AudioContext not initialized");
201
- return;
202
- }
203
- if (this.isPlaying) {
204
- this.log("[StreamingAudioPlayer] Cannot start playback: Already playing");
205
- return;
206
- }
207
- this.isPlaying = true;
208
- this.sessionStartTime = this.audioContext.currentTime;
209
- this.scheduledTime = this.sessionStartTime;
210
- this.lastScheduledChunkEndTime = 0;
211
- this.scheduledChunkInfo = [];
212
- this.autoContinue = false;
213
- await this.ensureAudioContextRunning();
214
- this.log("[StreamingAudioPlayer] Starting playback", {
215
- sessionStartTime: this.sessionStartTime,
216
- bufferedChunks: this.audioChunks.length,
217
- scheduledChunks: this.scheduledChunks,
218
- activeSources: this.activeSources.size,
219
- audioContextState: this.audioContext.state
220
- });
221
- this.scheduleAllChunks();
222
- }
223
- /**
224
- * Schedule all pending chunks
225
- */
226
- scheduleAllChunks() {
227
- while (this.scheduledChunks < this.audioChunks.length) this.scheduleNextChunk();
228
- }
229
- /**
230
- * Schedule next audio chunk
231
- */
232
- scheduleNextChunk() {
233
- if (!this.audioContext) {
234
- this.log("[StreamingAudioPlayer] Cannot schedule chunk: AudioContext not initialized");
235
- return;
236
- }
237
- if (!this.isPlaying || this.isPaused) {
238
- this.log("[StreamingAudioPlayer] Cannot schedule chunk: Not playing or paused");
239
- return;
240
- }
241
- if (this.audioContext.state === "suspended") this.ensureAudioContextRunning().catch((err) => {
242
- logger.error("[StreamingAudioPlayer] Failed to ensure AudioContext running in scheduleNextChunk:", err);
243
- });
244
- const chunkIndex = this.scheduledChunks;
245
- if (chunkIndex >= this.audioChunks.length) {
246
- this.log(`[StreamingAudioPlayer] No more chunks to schedule (chunkIndex: ${chunkIndex}, totalChunks: ${this.audioChunks.length})`);
247
- return;
248
- }
249
- const chunk = this.audioChunks[chunkIndex];
250
- if (chunk.data.length === 0 && !chunk.isLast) {
251
- this.scheduledChunks++;
252
- return;
253
- }
254
- const pcmData = chunk.data;
255
- const isLast = chunk.isLast;
256
- const audioBuffer = this.pcmToAudioBuffer(pcmData);
257
- if (!audioBuffer) {
258
- logger.error("Failed to create AudioBuffer from PCM data");
259
- logEvent("audio_buffer_creation_failed", "error", { session_id: this.sessionId });
260
- return;
261
- }
262
- try {
263
- const source = this.audioContext.createBufferSource();
264
- source.buffer = audioBuffer;
265
- source.connect(this.gainNode);
266
- const chunkStartTime = this.scheduledTime;
267
- source.start(chunkStartTime);
268
- const actualStartTime = Math.max(chunkStartTime, this.audioContext.currentTime);
269
- this.scheduledChunkInfo.push({
270
- startTime: actualStartTime,
271
- duration: audioBuffer.duration
272
- });
273
- this.activeSources.add(source);
274
- source.onended = () => {
275
- this.activeSources.delete(source);
276
- if (this.activeSources.size === 0) if (!!!this.audioChunks[this.scheduledChunks - 1]?.isLast) {
277
- this.log("All audio chunks ended but end=false, pausing and setting autoContinue");
278
- this.onAudioStallCallback?.(true);
279
- this.autoContinue = true;
280
- this.pause();
281
- } else {
282
- this.log("Last audio chunk ended, marking playback as ended");
283
- this.markEnded();
284
- }
285
- };
286
- this.scheduledTime += audioBuffer.duration;
287
- this.lastScheduledChunkEndTime = this.scheduledTime - this.sessionStartTime - this.pausedTimeOffset;
288
- this.scheduledChunks++;
289
- this.log(`[StreamingAudioPlayer] Scheduled chunk ${chunkIndex + 1}/${this.audioChunks.length}`, {
290
- startTime: this.scheduledTime - audioBuffer.duration,
291
- duration: audioBuffer.duration,
292
- nextScheduleTime: this.scheduledTime,
293
- isLast,
294
- activeSources: this.activeSources.size
295
- });
296
- } catch (err) {
297
- logger.error("Failed to schedule audio chunk:", err);
298
- logEvent("schedule_chunk_failed", "error", {
299
- session_id: this.sessionId,
300
- reason: err instanceof Error ? err.message : String(err)
301
- });
302
- }
303
- }
304
- /**
305
- * Convert PCM data to AudioBuffer
306
- * Input: 16-bit PCM (int16), Output: AudioBuffer (float32 [-1, 1])
307
- */
308
- pcmToAudioBuffer(pcmData) {
309
- if (!this.audioContext) return null;
310
- if (pcmData.length === 0) {
311
- const numSamples = Math.floor(this.sampleRate * .01);
312
- const audioBuffer = this.audioContext.createBuffer(this.channelCount, numSamples, this.sampleRate);
313
- for (let channel = 0; channel < this.channelCount; channel++) audioBuffer.getChannelData(channel).fill(0);
314
- return audioBuffer;
315
- }
316
- const alignedData = new Uint8Array(pcmData);
317
- const int16Array = new Int16Array(alignedData.buffer, 0, alignedData.length / 2);
318
- const numSamples = int16Array.length / this.channelCount;
319
- const audioBuffer = this.audioContext.createBuffer(this.channelCount, numSamples, this.sampleRate);
320
- for (let channel = 0; channel < this.channelCount; channel++) {
321
- const channelData = audioBuffer.getChannelData(channel);
322
- for (let i = 0; i < numSamples; i++) channelData[i] = int16Array[i * this.channelCount + channel] / 32768;
323
- }
324
- return audioBuffer;
325
- }
326
- /**
327
- * Get current playback time (seconds)
328
- * Returns total actual playback duration
329
- * @internal
330
- */
331
- getCurrentTime() {
332
- if (!this.audioContext || !this.isPlaying) return 0;
333
- if (this.isPaused) return this.pausedAt;
334
- const currentAudioTime = this.audioContext.currentTime;
335
- if (this.activeSources.size === 0 && this.scheduledChunks > 0) return Math.max(0, this.lastScheduledChunkEndTime);
336
- let totalPlayedDuration = 0;
337
- for (let i = 0; i < this.scheduledChunkInfo.length; i++) {
338
- const chunkInfo = this.scheduledChunkInfo[i];
339
- const chunkEndTime = chunkInfo.startTime + chunkInfo.duration;
340
- if (currentAudioTime < chunkInfo.startTime) break;
341
- else if (chunkEndTime <= currentAudioTime) totalPlayedDuration += chunkInfo.duration;
342
- else {
343
- const playedTime = currentAudioTime - chunkInfo.startTime;
344
- totalPlayedDuration += playedTime;
345
- break;
346
- }
347
- }
348
- return Math.max(0, totalPlayedDuration);
349
- }
350
- /**
351
- * Get total duration of buffered audio (seconds)
352
- * Calculate total duration of all buffered chunks
353
- * @internal
354
- */
355
- getBufferedDuration() {
356
- if (!this.audioContext) return 0;
357
- let totalDuration = 0;
358
- for (const chunk of this.audioChunks) {
359
- const chunkDuration = chunk.data.length / (this.sampleRate * this.channelCount * 2);
360
- totalDuration += chunkDuration;
361
- }
362
- return totalDuration;
363
- }
364
- /**
365
- * Get current AudioContext time
366
- * @returns Current AudioContext time in seconds, or 0 if AudioContext is not initialized
367
- */
368
- getAudioContextTime() {
369
- return this.audioContext?.currentTime ?? 0;
370
- }
371
- /**
372
- * Pause playback
373
- */
374
- pause() {
375
- if (!this.isPlaying || this.isPaused || !this.audioContext) return;
376
- this.pausedAt = this.getCurrentTime();
377
- this.pausedAudioContextTime = this.audioContext.currentTime;
378
- this.isPaused = true;
379
- if (this.audioContext.state === "running") this.audioContext.suspend().catch((err) => {
380
- logger.error("Failed to suspend AudioContext:", err);
381
- this.isPaused = false;
382
- });
383
- this.log("Playback paused", {
384
- pausedAt: this.pausedAt,
385
- pausedAudioContextTime: this.pausedAudioContextTime,
386
- audioContextState: this.audioContext.state
387
- });
388
- }
389
- /**
390
- * Resume playback
391
- */
392
- async resume() {
393
- if (!this.isPaused || !this.audioContext || !this.isPlaying) return;
394
- this.autoContinue = false;
395
- if (this.audioContext.state === "suspended") try {
396
- await this.audioContext.resume();
397
- } catch (err) {
398
- logger.error("Failed to resume AudioContext:", err);
399
- throw err;
400
- }
401
- const currentAudioTime = this.audioContext.currentTime;
402
- this.sessionStartTime = this.pausedAudioContextTime - this.pausedAt - this.pausedTimeOffset;
403
- this.isPaused = false;
404
- if (this.scheduledChunks < this.audioChunks.length) this.scheduleAllChunks();
405
- this.log("Playback resumed", {
406
- pausedAt: this.pausedAt,
407
- pausedAudioContextTime: this.pausedAudioContextTime,
408
- currentAudioContextTime: currentAudioTime,
409
- adjustedSessionStartTime: this.sessionStartTime,
410
- audioContextState: this.audioContext.state
411
- });
412
- }
413
- /**
414
- * Stop playback
415
- */
416
- stop() {
417
- if (!this.audioContext) return;
418
- if (this.isPaused && this.audioContext.state === "suspended") {
419
- this.audioContext.resume().catch(() => {});
420
- this.isPaused = false;
421
- }
422
- this.isPlaying = false;
423
- this.isPaused = false;
424
- this.isResuming = false;
425
- this.sessionStartTime = 0;
426
- this.scheduledTime = 0;
427
- for (const source of this.activeSources) {
428
- source.onended = null;
429
- try {
430
- source.stop(0);
431
- } catch {}
432
- try {
433
- source.disconnect();
434
- } catch {}
435
- }
436
- this.activeSources.clear();
437
- this.audioChunks = [];
438
- this.scheduledChunks = 0;
439
- this.autoContinue = false;
440
- this.log("[StreamingAudioPlayer] Playback stopped, state reset");
441
- }
442
- /**
443
- * Enable or disable auto-start (for delayed start scenarios)
444
- */
445
- setAutoStart(enabled) {
446
- this.autoStartEnabled = enabled;
447
- this.log(`Auto-start ${enabled ? "enabled" : "disabled"}`);
448
- }
449
- /**
450
- * Start playback manually (for delayed start scenarios)
451
- * This allows starting playback after transition animation completes
452
- */
453
- async play() {
454
- if (this.isPlaying) return;
455
- this.autoStartEnabled = true;
456
- try {
457
- await this.startPlayback();
458
- } catch (err) {
459
- logger.error("[StreamingAudioPlayer] Failed to start playback from play():", err);
460
- }
461
- }
462
- /**
463
- * Mark playback as ended
464
- */
465
- markEnded() {
466
- this.log("Playback ended");
467
- this.isPlaying = false;
468
- this.onEndedCallback?.();
469
- }
470
- /**
471
- * Set ended callback
472
- */
473
- onEnded(callback) {
474
- this.onEndedCallback = callback;
475
- }
476
- /**
477
- * 设置音频缓冲卡顿回调:缓冲耗尽自动暂停时 stalled=true,有数据自动恢复时 stalled=false。
478
- */
479
- onAudioStall(callback) {
480
- this.onAudioStallCallback = callback;
481
- }
482
- /**
483
- * Check if playing
484
- */
485
- isPlayingNow() {
486
- return this.isPlaying && !this.isPaused;
487
- }
488
- /**
489
- * Dispose and cleanup
490
- */
491
- dispose() {
492
- this.stop();
493
- if (this.audioContext && this.stateChangeHandler) {
494
- this.audioContext.removeEventListener("statechange", this.stateChangeHandler);
495
- this.stateChangeHandler = void 0;
496
- }
497
- if (this.audioContext) {
498
- this.audioContext.close();
499
- this.audioContext = null;
500
- this.gainNode = null;
501
- }
502
- this.audioChunks = [];
503
- this.scheduledChunks = 0;
504
- this.sessionStartTime = 0;
505
- this.pausedTimeOffset = 0;
506
- this.pausedAt = 0;
507
- this.pausedAudioContextTime = 0;
508
- this.scheduledTime = 0;
509
- this.onEndedCallback = void 0;
510
- this.log("StreamingAudioPlayer disposed");
511
- }
512
- /**
513
- * Flush buffered audio
514
- * - hard: stops all playing sources and clears all chunks
515
- * - soft (default): clears UNSCHEDULED chunks only
516
- */
517
- flush(options) {
518
- if (options?.hard === true) {
519
- this.stop();
520
- this.audioChunks = [];
521
- this.scheduledChunks = 0;
522
- this.sessionStartTime = 0;
523
- this.pausedAt = 0;
524
- this.scheduledTime = 0;
525
- this.log("Flushed (hard)");
526
- return;
527
- }
528
- if (this.scheduledChunks < this.audioChunks.length) this.audioChunks.splice(this.scheduledChunks);
529
- this.log("Flushed (soft)", { remainingScheduled: this.scheduledChunks });
530
- }
531
- /**
532
- * Set volume (0.0 - 1.0)
533
- * Note: This only controls avatar audio player volume, does not affect system volume
534
- * @param volume Volume value, range 0.0 to 1.0 (0.0 is mute, 1.0 is max volume)
535
- * @internal
536
- */
537
- setVolume(volume) {
538
- if (volume < 0 || volume > 1) {
539
- logger.warn(`[StreamingAudioPlayer] Volume out of range: ${volume}, clamping to [0, 1]`);
540
- volume = Math.max(0, Math.min(1, volume));
541
- }
542
- this.volume = volume;
543
- if (this.gainNode) this.gainNode.gain.value = volume;
544
- }
545
- /**
546
- * Get current volume
547
- * @returns Current volume value (0.0 - 1.0)
548
- * @internal
549
- */
550
- getVolume() {
551
- return this.volume;
552
- }
553
- /**
554
- * Debug logging
555
- */
556
- log(message, data) {
557
- if (this.debug) logger.log(`[StreamingAudioPlayer] ${message}`, data || "");
558
- }
559
- };
560
- //#endregion
561
- export { StreamingAudioPlayer };