@spatius/avatarkit 1.3.1-beta.2 → 1.3.1-beta.4
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/CHANGELOG.md +25 -0
- package/dist/AvatarDownloader-C2JJVqKm.js +525 -0
- package/dist/AvatarSDK-CuejGi0J.js +6797 -0
- package/dist/OpusCodec-Bv4kzdt8.js +41726 -0
- package/dist/OpusDecoderProxy-BTWRku-I.js +129 -0
- package/dist/OpusEncoderProxy-_AWpY7N6.js +135 -0
- package/dist/StreamingAudioPlayer-DmotFnXu.js +561 -0
- package/dist/assets/AvatarDownloader-C-CiIH1p.js +753 -0
- package/dist/assets/AvatarSDK-RVj90oMR.js +6585 -0
- package/dist/assets/OpusDecoderWorker.worker-Bd4svkEs.js +41373 -0
- package/dist/assets/OpusEncoderWorker.worker-BdxYhZZ9.js +41567 -0
- package/dist/assets/avatar_core_wasm-wdep7Ict.js +2536 -0
- package/dist/assets/logger-B-X9jUON.js +15043 -0
- package/dist/assets/rolldown-runtime-B-1-B7_t.js +33 -0
- package/dist/avatar_core_wasm-BvVa8lO4.js +2532 -0
- package/dist/core/AvatarController.d.ts +53 -2
- package/dist/core/AvatarView.d.ts +0 -2
- package/dist/error-utils-PtNzUHiL.js +85 -0
- package/dist/index.js +7186 -24
- package/dist/logger-C3fw-NWP.js +15129 -0
- package/dist/pwa-cache-manager-D3nj4sd5.js +151 -0
- package/dist/rolldown-runtime-B-1-B7_t.js +33 -0
- package/dist/types/character.d.ts +4 -4
- package/dist/types/index.d.ts +60 -0
- package/package.json +8 -3
- package/dist/StreamingAudioPlayer-2bLnaLam.js +0 -656
- package/dist/avatar_core_wasm-BIbE25D3.js +0 -2697
- package/dist/index-CPolDcOo.js +0 -22241
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
import { c as logEvent, t as logger } from "./logger-C3fw-NWP.js";
|
|
2
|
+
import { n as APP_CONFIG, t as errorToMessage } from "./error-utils-PtNzUHiL.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 };
|