@meframe/core 0.0.31 → 0.0.32
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/dist/Meframe.d.ts +2 -2
- package/dist/Meframe.d.ts.map +1 -1
- package/dist/Meframe.js +3 -2
- package/dist/Meframe.js.map +1 -1
- package/dist/cache/CacheManager.d.ts +12 -17
- package/dist/cache/CacheManager.d.ts.map +1 -1
- package/dist/cache/CacheManager.js +18 -280
- package/dist/cache/CacheManager.js.map +1 -1
- package/dist/cache/l1/AudioL1Cache.d.ts +36 -19
- package/dist/cache/l1/AudioL1Cache.d.ts.map +1 -1
- package/dist/cache/l1/AudioL1Cache.js +182 -282
- package/dist/cache/l1/AudioL1Cache.js.map +1 -1
- package/dist/controllers/PlaybackController.d.ts +4 -2
- package/dist/controllers/PlaybackController.d.ts.map +1 -1
- package/dist/controllers/PlaybackController.js +43 -13
- package/dist/controllers/PlaybackController.js.map +1 -1
- package/dist/model/types.d.ts +0 -4
- package/dist/model/types.d.ts.map +1 -1
- package/dist/model/types.js.map +1 -1
- package/dist/orchestrator/ExportScheduler.d.ts +6 -0
- package/dist/orchestrator/ExportScheduler.d.ts.map +1 -1
- package/dist/orchestrator/ExportScheduler.js +45 -66
- package/dist/orchestrator/ExportScheduler.js.map +1 -1
- package/dist/orchestrator/GlobalAudioSession.d.ts +35 -28
- package/dist/orchestrator/GlobalAudioSession.d.ts.map +1 -1
- package/dist/orchestrator/GlobalAudioSession.js +212 -421
- package/dist/orchestrator/GlobalAudioSession.js.map +1 -1
- package/dist/orchestrator/OnDemandVideoSession.d.ts +3 -3
- package/dist/orchestrator/OnDemandVideoSession.d.ts.map +1 -1
- package/dist/orchestrator/OnDemandVideoSession.js +4 -4
- package/dist/orchestrator/OnDemandVideoSession.js.map +1 -1
- package/dist/orchestrator/Orchestrator.d.ts +1 -2
- package/dist/orchestrator/Orchestrator.d.ts.map +1 -1
- package/dist/orchestrator/Orchestrator.js +34 -48
- package/dist/orchestrator/Orchestrator.js.map +1 -1
- package/dist/orchestrator/VideoClipSession.d.ts +0 -2
- package/dist/orchestrator/VideoClipSession.d.ts.map +1 -1
- package/dist/orchestrator/VideoClipSession.js +0 -49
- package/dist/orchestrator/VideoClipSession.js.map +1 -1
- package/dist/stages/compose/OfflineAudioMixer.d.ts.map +1 -1
- package/dist/stages/compose/OfflineAudioMixer.js +13 -18
- package/dist/stages/compose/OfflineAudioMixer.js.map +1 -1
- package/dist/stages/decode/AudioChunkDecoder.js +169 -0
- package/dist/stages/decode/AudioChunkDecoder.js.map +1 -0
- package/dist/stages/demux/MP3FrameParser.js +186 -0
- package/dist/stages/demux/MP3FrameParser.js.map +1 -0
- package/dist/stages/load/ResourceLoader.d.ts +20 -9
- package/dist/stages/load/ResourceLoader.d.ts.map +1 -1
- package/dist/stages/load/ResourceLoader.js +92 -135
- package/dist/stages/load/ResourceLoader.js.map +1 -1
- package/dist/stages/load/types.d.ts +1 -0
- package/dist/stages/load/types.d.ts.map +1 -1
- package/dist/utils/audio-data.d.ts +16 -0
- package/dist/utils/audio-data.d.ts.map +1 -0
- package/dist/utils/audio-data.js +111 -0
- package/dist/utils/audio-data.js.map +1 -0
- package/package.json +1 -1
- package/dist/cache/resource/ImageBitmapCache.d.ts +0 -65
- package/dist/cache/resource/ImageBitmapCache.d.ts.map +0 -1
- package/dist/cache/resource/ImageBitmapCache.js +0 -101
- package/dist/cache/resource/ImageBitmapCache.js.map +0 -1
|
@@ -1,58 +1,76 @@
|
|
|
1
1
|
import { OfflineAudioMixer } from "../stages/compose/OfflineAudioMixer.js";
|
|
2
2
|
import { MeframeEvent } from "../event/events.js";
|
|
3
3
|
import { AudioChunkEncoder } from "../stages/encode/AudioChunkEncoder.js";
|
|
4
|
-
import {
|
|
4
|
+
import { AudioChunkDecoder } from "../stages/decode/AudioChunkDecoder.js";
|
|
5
|
+
import { hasResourceId, isAudioClip } from "../model/types.js";
|
|
5
6
|
class GlobalAudioSession {
|
|
6
7
|
mixer;
|
|
7
8
|
activeClips = /* @__PURE__ */ new Set();
|
|
8
|
-
streamEndedClips = /* @__PURE__ */ new Set();
|
|
9
9
|
deps;
|
|
10
10
|
model = null;
|
|
11
11
|
audioContext = null;
|
|
12
|
-
audioSources = [];
|
|
13
|
-
audioGainNodes = [];
|
|
14
12
|
volume = 1;
|
|
15
13
|
playbackRate = 1;
|
|
16
14
|
isPlaying = false;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
//
|
|
15
|
+
// Lookahead scheduling state
|
|
16
|
+
nextScheduleTime = 0;
|
|
17
|
+
// Next AudioContext time to schedule
|
|
18
|
+
nextContentTimeUs = 0;
|
|
19
|
+
// Next timeline position (Us)
|
|
20
|
+
scheduledSources = /* @__PURE__ */ new Set();
|
|
21
|
+
LOOKAHEAD_TIME = 0.2;
|
|
22
|
+
// 200ms lookahead
|
|
23
|
+
CHUNK_DURATION = 0.1;
|
|
24
|
+
// 100ms chunks
|
|
20
25
|
constructor(deps) {
|
|
21
26
|
this.deps = deps;
|
|
22
27
|
this.mixer = new OfflineAudioMixer(deps.cacheManager, () => this.model);
|
|
23
28
|
}
|
|
24
29
|
setModel(model) {
|
|
25
30
|
this.model = model;
|
|
26
|
-
this.activateAllAudioClips();
|
|
31
|
+
void this.activateAllAudioClips();
|
|
27
32
|
}
|
|
28
33
|
onAudioData(message) {
|
|
29
|
-
const { sessionId, audioData, clipDurationUs } = message;
|
|
30
|
-
|
|
34
|
+
const { sessionId, audioData, clipStartUs, clipDurationUs } = message;
|
|
35
|
+
const globalTimeUs = clipStartUs + (audioData.timestamp ?? 0);
|
|
36
|
+
this.deps.cacheManager.putClipAudioData(sessionId, audioData, clipDurationUs, globalTimeUs);
|
|
31
37
|
}
|
|
32
|
-
async ensureAudioForTime(timeUs) {
|
|
38
|
+
async ensureAudioForTime(timeUs, options) {
|
|
33
39
|
const model = this.model;
|
|
34
40
|
if (!model) return;
|
|
41
|
+
const immediate = options?.immediate ?? false;
|
|
35
42
|
const activeClips = [];
|
|
36
43
|
for (const track of model.tracks) {
|
|
37
44
|
if (track.kind !== "audio" && track.kind !== "video") continue;
|
|
38
45
|
const clips = model.getClipsAtTime(timeUs, track.id);
|
|
39
46
|
activeClips.push(...clips);
|
|
40
47
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
await this.ensureClipAudio(clip.id);
|
|
48
|
+
const ensureAudioWindowPromises = activeClips.map(async (clip) => {
|
|
49
|
+
if (!hasResourceId(clip)) return;
|
|
50
|
+
const resource = model.getResource(clip.resourceId);
|
|
51
|
+
if (!resource) return;
|
|
52
|
+
if (!this.deps.cacheManager.audioSampleCache.has(clip.resourceId)) {
|
|
53
|
+
const resource2 = model.getResource(clip.resourceId);
|
|
54
|
+
if (resource2?.state === "ready") {
|
|
49
55
|
return;
|
|
50
56
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
57
|
+
await this.deps.resourceLoader.fetch(clip.resourceId, {
|
|
58
|
+
priority: "high",
|
|
59
|
+
clipId: clip.id,
|
|
60
|
+
trackId: clip.trackId
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const clipRelativeTimeUs = timeUs - clip.startUs;
|
|
64
|
+
const WINDOW_DURATION = 3e6;
|
|
65
|
+
const startUs = clipRelativeTimeUs;
|
|
66
|
+
const endUs = Math.min(clip.durationUs, startUs + WINDOW_DURATION);
|
|
67
|
+
await this.ensureAudioWindow(clip.id, startUs, endUs);
|
|
68
|
+
});
|
|
69
|
+
if (immediate) {
|
|
70
|
+
void Promise.all(ensureAudioWindowPromises);
|
|
71
|
+
} else {
|
|
72
|
+
await Promise.all(ensureAudioWindowPromises);
|
|
73
|
+
}
|
|
56
74
|
}
|
|
57
75
|
async activateAllAudioClips() {
|
|
58
76
|
const model = this.model;
|
|
@@ -67,18 +85,16 @@ class GlobalAudioSession {
|
|
|
67
85
|
throw new Error(`Clip ${clip.id} in audio track is not an audio clip`);
|
|
68
86
|
}
|
|
69
87
|
if (this.deps.cacheManager.audioSampleCache.has(clip.resourceId)) {
|
|
70
|
-
await this.ensureClipAudio(clip.id);
|
|
71
88
|
this.activeClips.add(clip.id);
|
|
89
|
+
this.deps.eventBus.emit(MeframeEvent.ClipActivated, { clipId: clip.id });
|
|
72
90
|
continue;
|
|
73
91
|
}
|
|
74
|
-
await this.setupAudioPipeline(clip);
|
|
75
|
-
this.activeClips.add(clip.id);
|
|
76
92
|
await this.deps.resourceLoader.fetch(clip.resourceId, {
|
|
77
93
|
priority: "high",
|
|
78
|
-
sessionId: clip.id,
|
|
79
94
|
clipId: clip.id,
|
|
80
95
|
trackId: track.id
|
|
81
96
|
});
|
|
97
|
+
this.activeClips.add(clip.id);
|
|
82
98
|
this.deps.eventBus.emit(MeframeEvent.ClipActivated, { clipId: clip.id });
|
|
83
99
|
}
|
|
84
100
|
}
|
|
@@ -88,140 +104,95 @@ class GlobalAudioSession {
|
|
|
88
104
|
if (!this.activeClips.has(clipId)) {
|
|
89
105
|
return;
|
|
90
106
|
}
|
|
91
|
-
this.stopClipAudioSources(clipId);
|
|
92
|
-
this.deps.workerPool.terminate("audioDemux", clipId);
|
|
93
|
-
this.deps.workerPool.terminate("audioDecode", clipId);
|
|
94
107
|
this.activeClips.delete(clipId);
|
|
95
108
|
this.deps.cacheManager.clearClipAudioData(clipId);
|
|
96
109
|
}
|
|
97
|
-
restartPlayingClip(clipId, currentTimeUs) {
|
|
98
|
-
if (!this.isPlaying || !this.audioContext) {
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
const timeUs = currentTimeUs ?? this.currentPlaybackTimeUs;
|
|
102
|
-
const model = this.model;
|
|
103
|
-
if (!model) {
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
const clip = model.findClip(clipId);
|
|
107
|
-
if (!clip) {
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
const clipEndUs = clip.startUs + clip.durationUs;
|
|
111
|
-
if (timeUs < clip.startUs || timeUs >= clipEndUs) {
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
this.startClipPlayback(clip, timeUs);
|
|
115
|
-
}
|
|
116
|
-
stopClipAudioSources(clipId) {
|
|
117
|
-
const sourcesToStop = [];
|
|
118
|
-
const gainNodesToDisconnect = [];
|
|
119
|
-
for (let i = this.audioSources.length - 1; i >= 0; i--) {
|
|
120
|
-
const source = this.audioSources[i];
|
|
121
|
-
if (source && source._meframeClipId === clipId) {
|
|
122
|
-
sourcesToStop.push(source);
|
|
123
|
-
this.audioSources.splice(i, 1);
|
|
124
|
-
const gainNode = this.audioGainNodes[i];
|
|
125
|
-
if (gainNode) {
|
|
126
|
-
gainNodesToDisconnect.push(gainNode);
|
|
127
|
-
this.audioGainNodes.splice(i, 1);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
for (const source of sourcesToStop) {
|
|
132
|
-
try {
|
|
133
|
-
source.stop();
|
|
134
|
-
source.disconnect();
|
|
135
|
-
} catch {
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
for (const gainNode of gainNodesToDisconnect) {
|
|
139
|
-
try {
|
|
140
|
-
gainNode.disconnect();
|
|
141
|
-
} catch {
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
handleAudioStream(stream, metadata) {
|
|
146
|
-
const sessionId = metadata.sessionId || "unknown";
|
|
147
|
-
const clipStartUs = metadata.clipStartUs ?? 0;
|
|
148
|
-
const clipDurationUs = metadata.clipDurationUs ?? 0;
|
|
149
|
-
const reader = stream.getReader();
|
|
150
|
-
const pump = async () => {
|
|
151
|
-
try {
|
|
152
|
-
const { done, value } = await reader.read();
|
|
153
|
-
if (done) {
|
|
154
|
-
this.streamEndedClips.add(sessionId);
|
|
155
|
-
reader.releaseLock();
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
this.onAudioData({
|
|
159
|
-
sessionId,
|
|
160
|
-
audioData: value,
|
|
161
|
-
clipStartUs,
|
|
162
|
-
clipDurationUs
|
|
163
|
-
});
|
|
164
|
-
await pump();
|
|
165
|
-
} catch (error) {
|
|
166
|
-
console.error("[GlobalAudioSession] Audio stream error:", error);
|
|
167
|
-
reader.releaseLock();
|
|
168
|
-
}
|
|
169
|
-
};
|
|
170
|
-
pump();
|
|
171
|
-
}
|
|
172
110
|
async startPlayback(timeUs, audioContext) {
|
|
173
111
|
this.audioContext = audioContext;
|
|
174
112
|
if (audioContext.state === "suspended") {
|
|
175
113
|
await audioContext.resume();
|
|
176
114
|
}
|
|
177
|
-
await this.ensureAudioForTime(timeUs);
|
|
115
|
+
await this.ensureAudioForTime(timeUs, { immediate: false });
|
|
178
116
|
this.isPlaying = true;
|
|
179
|
-
this.
|
|
117
|
+
this.resetPlaybackStates();
|
|
180
118
|
}
|
|
181
119
|
stopPlayback() {
|
|
182
120
|
this.isPlaying = false;
|
|
183
121
|
this.stopAllAudioSources();
|
|
184
122
|
}
|
|
185
|
-
updateTime(
|
|
186
|
-
|
|
187
|
-
|
|
123
|
+
updateTime(_timeUs) {
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Schedule audio chunks ahead of playback cursor
|
|
127
|
+
* Uses OfflineAudioMixer for proper mixing, then plays the result
|
|
128
|
+
*/
|
|
129
|
+
async scheduleAudio(currentTimelineUs, audioContext) {
|
|
130
|
+
if (!this.isPlaying || !this.model || !this.audioContext) {
|
|
188
131
|
return;
|
|
189
132
|
}
|
|
190
|
-
this.
|
|
133
|
+
const lookaheadTime = audioContext.currentTime + this.LOOKAHEAD_TIME;
|
|
134
|
+
if (this.nextScheduleTime === 0) {
|
|
135
|
+
this.nextScheduleTime = audioContext.currentTime + 0.01;
|
|
136
|
+
this.nextContentTimeUs = currentTimelineUs;
|
|
137
|
+
}
|
|
138
|
+
while (this.nextScheduleTime < lookaheadTime) {
|
|
139
|
+
const chunkDurationUs = Math.round(this.CHUNK_DURATION * 1e6);
|
|
140
|
+
const startUs = this.nextContentTimeUs;
|
|
141
|
+
const endUs = startUs + chunkDurationUs;
|
|
142
|
+
if (endUs > this.model.durationUs) {
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const mixedBuffer = await this.mixer.mix(startUs, endUs);
|
|
147
|
+
const source = audioContext.createBufferSource();
|
|
148
|
+
source.buffer = mixedBuffer;
|
|
149
|
+
source.playbackRate.value = this.playbackRate;
|
|
150
|
+
const gainNode = audioContext.createGain();
|
|
151
|
+
gainNode.gain.value = this.volume;
|
|
152
|
+
source.connect(gainNode);
|
|
153
|
+
gainNode.connect(audioContext.destination);
|
|
154
|
+
source.start(this.nextScheduleTime);
|
|
155
|
+
this.scheduledSources.add(source);
|
|
156
|
+
source.onended = () => {
|
|
157
|
+
source.disconnect();
|
|
158
|
+
gainNode.disconnect();
|
|
159
|
+
this.scheduledSources.delete(source);
|
|
160
|
+
};
|
|
161
|
+
const actualDuration = mixedBuffer.duration;
|
|
162
|
+
this.nextScheduleTime += actualDuration;
|
|
163
|
+
this.nextContentTimeUs += chunkDurationUs;
|
|
164
|
+
} catch (error) {
|
|
165
|
+
console.warn("[GlobalAudioSession] Mix error, skipping chunk:", error);
|
|
166
|
+
this.nextScheduleTime += this.CHUNK_DURATION;
|
|
167
|
+
this.nextContentTimeUs += chunkDurationUs;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Reset playback states (called on seek)
|
|
173
|
+
*/
|
|
174
|
+
resetPlaybackStates() {
|
|
175
|
+
this.stopAllAudioSources();
|
|
176
|
+
this.nextScheduleTime = 0;
|
|
177
|
+
this.nextContentTimeUs = 0;
|
|
191
178
|
}
|
|
192
179
|
setVolume(volume) {
|
|
193
180
|
this.volume = volume;
|
|
194
|
-
for (let i = 0; i < this.audioGainNodes.length; i++) {
|
|
195
|
-
const gainNode = this.audioGainNodes[i];
|
|
196
|
-
const source = this.audioSources[i];
|
|
197
|
-
const clipId = source._meframeClipId;
|
|
198
|
-
if (clipId && gainNode) {
|
|
199
|
-
const model = this.model;
|
|
200
|
-
const clip = model?.findClip(clipId);
|
|
201
|
-
if (clip && hasAudioConfig(clip)) {
|
|
202
|
-
const clipVolume = clip.audioConfig?.volume ?? 1;
|
|
203
|
-
const muted = clip.audioConfig?.muted ?? false;
|
|
204
|
-
gainNode.gain.value = muted ? 0 : clipVolume * this.volume;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
181
|
}
|
|
209
182
|
setPlaybackRate(rate) {
|
|
210
183
|
this.playbackRate = rate;
|
|
211
|
-
|
|
212
|
-
source.playbackRate.value = this.playbackRate;
|
|
213
|
-
}
|
|
184
|
+
this.resetPlaybackStates();
|
|
214
185
|
}
|
|
215
186
|
reset() {
|
|
216
187
|
this.stopAllAudioSources();
|
|
217
188
|
this.deps.cacheManager.resetAudioCache();
|
|
218
189
|
this.activeClips.clear();
|
|
219
|
-
this.streamEndedClips.clear();
|
|
220
190
|
}
|
|
221
191
|
/**
|
|
222
192
|
* Mix and encode audio for a specific segment (used by ExportScheduler)
|
|
223
193
|
*/
|
|
224
194
|
async mixAndEncodeSegment(startUs, endUs, onChunk) {
|
|
195
|
+
await this.ensureAudioForSegment(startUs, endUs);
|
|
225
196
|
const mixedBuffer = await this.mixer.mix(startUs, endUs);
|
|
226
197
|
const audioData = this.audioBufferToAudioData(mixedBuffer, startUs);
|
|
227
198
|
if (!audioData) return;
|
|
@@ -230,10 +201,38 @@ class GlobalAudioSession {
|
|
|
230
201
|
await this.exportEncoder.initialize();
|
|
231
202
|
this.exportEncoderStream = this.exportEncoder.createStream();
|
|
232
203
|
this.exportEncoderWriter = this.exportEncoderStream.writable.getWriter();
|
|
233
|
-
this.startExportEncoderReader(this.exportEncoderStream.readable, onChunk);
|
|
204
|
+
void this.startExportEncoderReader(this.exportEncoderStream.readable, onChunk);
|
|
205
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
234
206
|
}
|
|
235
207
|
await this.exportEncoderWriter?.write(audioData);
|
|
236
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Ensure audio clips in time range are decoded (for export)
|
|
211
|
+
* Decodes from AudioSampleCache (replaces Worker pipeline)
|
|
212
|
+
*/
|
|
213
|
+
async ensureAudioForSegment(startUs, endUs) {
|
|
214
|
+
const model = this.model;
|
|
215
|
+
if (!model) return;
|
|
216
|
+
const clips = [];
|
|
217
|
+
for (const track of model.tracks) {
|
|
218
|
+
if (track.kind !== "audio" && track.kind !== "video") continue;
|
|
219
|
+
for (const clip of track.clips) {
|
|
220
|
+
const clipEndUs = clip.startUs + clip.durationUs;
|
|
221
|
+
if (clip.startUs < endUs && clipEndUs > startUs) {
|
|
222
|
+
clips.push(clip);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
for (const clip of clips) {
|
|
227
|
+
if (!hasResourceId(clip)) continue;
|
|
228
|
+
if (!this.deps.cacheManager.audioSampleCache.has(clip.resourceId)) {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const clipRelativeStartUs = Math.max(0, startUs - clip.startUs);
|
|
232
|
+
const clipRelativeEndUs = Math.min(clip.durationUs, endUs - clip.startUs);
|
|
233
|
+
await this.ensureAudioWindow(clip.id, clipRelativeStartUs, clipRelativeEndUs);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
237
236
|
exportEncoder = null;
|
|
238
237
|
exportEncoderStream = null;
|
|
239
238
|
exportEncoderWriter = null;
|
|
@@ -259,279 +258,113 @@ class GlobalAudioSession {
|
|
|
259
258
|
this.exportEncoder = null;
|
|
260
259
|
this.exportEncoderStream = null;
|
|
261
260
|
}
|
|
262
|
-
/**
|
|
263
|
-
* Create export audio stream
|
|
264
|
-
*/
|
|
265
|
-
async createExportAudioStream() {
|
|
266
|
-
const model = this.model;
|
|
267
|
-
if (!model) {
|
|
268
|
-
return null;
|
|
269
|
-
}
|
|
270
|
-
const totalDurationUs = model.durationUs;
|
|
271
|
-
await this.activateAllAudioClips();
|
|
272
|
-
await this.waitForAudioClipsReady();
|
|
273
|
-
return new ReadableStream({
|
|
274
|
-
start: async (controller) => {
|
|
275
|
-
const windowSize = 5e6;
|
|
276
|
-
let currentUs = 0;
|
|
277
|
-
while (currentUs < totalDurationUs) {
|
|
278
|
-
const windowEndUs = Math.min(currentUs + windowSize, totalDurationUs);
|
|
279
|
-
const mixedBuffer = await this.mixer.mix(currentUs, windowEndUs);
|
|
280
|
-
const audioData = this.audioBufferToAudioData(mixedBuffer, currentUs);
|
|
281
|
-
if (audioData) {
|
|
282
|
-
controller.enqueue(audioData);
|
|
283
|
-
}
|
|
284
|
-
currentUs = windowEndUs;
|
|
285
|
-
}
|
|
286
|
-
controller.close();
|
|
287
|
-
}
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
async waitForAudioClipsReady() {
|
|
291
|
-
const model = this.model;
|
|
292
|
-
if (!model) return;
|
|
293
|
-
const audioClips = model.tracks.filter((track) => track.kind === "audio").flatMap((track) => track.clips);
|
|
294
|
-
const waitPromises = audioClips.map((clip) => this.waitForClipPCM(clip.id, 1e4));
|
|
295
|
-
await Promise.allSettled(waitPromises);
|
|
296
|
-
}
|
|
297
|
-
waitForClipPCM(clipId, timeoutMs) {
|
|
298
|
-
return new Promise((resolve) => {
|
|
299
|
-
const checkInterval = 100;
|
|
300
|
-
let elapsed = 0;
|
|
301
|
-
let lastFrameCount = 0;
|
|
302
|
-
let stableCount = 0;
|
|
303
|
-
let streamEndDetected = false;
|
|
304
|
-
const check = () => {
|
|
305
|
-
const pcm = this.deps.cacheManager.getClipPCM(clipId, 0, Number.MAX_SAFE_INTEGER);
|
|
306
|
-
if (pcm && pcm.length > 0) {
|
|
307
|
-
const currentFrameCount = pcm[0]?.length ?? 0;
|
|
308
|
-
if (this.streamEndedClips.has(clipId)) {
|
|
309
|
-
streamEndDetected = true;
|
|
310
|
-
}
|
|
311
|
-
if (streamEndDetected) {
|
|
312
|
-
resolve(true);
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
if (currentFrameCount === lastFrameCount) {
|
|
316
|
-
stableCount++;
|
|
317
|
-
if (stableCount >= 5) {
|
|
318
|
-
resolve(true);
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
|
-
} else {
|
|
322
|
-
stableCount = 0;
|
|
323
|
-
lastFrameCount = currentFrameCount;
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
elapsed += checkInterval;
|
|
327
|
-
if (elapsed >= timeoutMs) {
|
|
328
|
-
console.warn("[GlobalAudioSession] Timeout waiting for clip", clipId, {
|
|
329
|
-
frames: lastFrameCount,
|
|
330
|
-
elapsed
|
|
331
|
-
});
|
|
332
|
-
resolve(false);
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
setTimeout(check, checkInterval);
|
|
336
|
-
};
|
|
337
|
-
check();
|
|
338
|
-
});
|
|
339
|
-
}
|
|
340
|
-
startAllActiveClips(timeUs) {
|
|
341
|
-
if (!this.audioContext) {
|
|
342
|
-
return;
|
|
343
|
-
}
|
|
344
|
-
const currentClips = this.getActiveAudioClips(timeUs);
|
|
345
|
-
for (const clip of currentClips) {
|
|
346
|
-
this.startClipPlayback(clip, timeUs);
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
checkAndStartNewClips(timeUs) {
|
|
350
|
-
if (!this.audioContext) {
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
const currentClips = this.getActiveAudioClips(timeUs);
|
|
354
|
-
const activeClipIds = new Set(
|
|
355
|
-
this.audioSources.map((source) => source._meframeClipId).filter(Boolean)
|
|
356
|
-
);
|
|
357
|
-
for (const clip of currentClips) {
|
|
358
|
-
const clipEndUs = clip.startUs + clip.durationUs;
|
|
359
|
-
if (timeUs >= clipEndUs) {
|
|
360
|
-
continue;
|
|
361
|
-
}
|
|
362
|
-
if (timeUs < clip.startUs) {
|
|
363
|
-
continue;
|
|
364
|
-
}
|
|
365
|
-
const MIN_REMAINING_TIME_US = 3e4;
|
|
366
|
-
if (clipEndUs - timeUs < MIN_REMAINING_TIME_US) {
|
|
367
|
-
continue;
|
|
368
|
-
}
|
|
369
|
-
if (!activeClipIds.has(clip.id)) {
|
|
370
|
-
this.startClipPlayback(clip, timeUs);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
startClipPlayback(clip, currentTimeUs) {
|
|
375
|
-
if (!this.audioContext) {
|
|
376
|
-
console.warn("[GlobalAudioSession] No audioContext, cannot start playback");
|
|
377
|
-
return;
|
|
378
|
-
}
|
|
379
|
-
const clipPCMData = this.deps.cacheManager.getClipPCMWithMetadata(
|
|
380
|
-
clip.id,
|
|
381
|
-
0,
|
|
382
|
-
// Start from beginning of clip (0-based)
|
|
383
|
-
clip.durationUs
|
|
384
|
-
// Full clip duration
|
|
385
|
-
);
|
|
386
|
-
if (!clipPCMData || clipPCMData.planes.length === 0) {
|
|
387
|
-
void this.ensureClipAudio(clip.id);
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
const buffer = this.pcmToAudioBuffer(clipPCMData.planes, clipPCMData.sampleRate);
|
|
391
|
-
const offsetUs = Math.max(0, currentTimeUs - clip.startUs);
|
|
392
|
-
const offsetSeconds = offsetUs / 1e6;
|
|
393
|
-
const actualDurationSeconds = buffer.duration - offsetSeconds;
|
|
394
|
-
const MIN_PLAYBACK_DURATION_SECONDS = 0.03;
|
|
395
|
-
if (actualDurationSeconds < MIN_PLAYBACK_DURATION_SECONDS) {
|
|
396
|
-
return;
|
|
397
|
-
}
|
|
398
|
-
const source = this.audioContext.createBufferSource();
|
|
399
|
-
source.buffer = buffer;
|
|
400
|
-
source.playbackRate.value = this.playbackRate;
|
|
401
|
-
source._meframeClipId = clip.id;
|
|
402
|
-
const gainNode = this.audioContext.createGain();
|
|
403
|
-
if (hasAudioConfig(clip)) {
|
|
404
|
-
const volume = clip.audioConfig?.volume ?? 1;
|
|
405
|
-
const muted = clip.audioConfig?.muted ?? false;
|
|
406
|
-
gainNode.gain.value = muted ? 0 : volume * this.volume;
|
|
407
|
-
} else {
|
|
408
|
-
gainNode.gain.value = this.volume;
|
|
409
|
-
}
|
|
410
|
-
source.connect(gainNode);
|
|
411
|
-
gainNode.connect(this.audioContext.destination);
|
|
412
|
-
source.start(0, offsetSeconds, actualDurationSeconds);
|
|
413
|
-
source.onended = () => {
|
|
414
|
-
const index = this.audioSources.indexOf(source);
|
|
415
|
-
if (index >= 0) {
|
|
416
|
-
this.audioSources.splice(index, 1);
|
|
417
|
-
this.audioGainNodes.splice(index, 1);
|
|
418
|
-
}
|
|
419
|
-
};
|
|
420
|
-
this.audioSources.push(source);
|
|
421
|
-
this.audioGainNodes.push(gainNode);
|
|
422
|
-
}
|
|
423
261
|
stopAllAudioSources() {
|
|
424
|
-
for (const source of this.
|
|
262
|
+
for (const source of this.scheduledSources) {
|
|
425
263
|
try {
|
|
426
264
|
source.stop();
|
|
427
265
|
source.disconnect();
|
|
428
266
|
} catch {
|
|
429
267
|
}
|
|
430
268
|
}
|
|
431
|
-
|
|
432
|
-
try {
|
|
433
|
-
gainNode.disconnect();
|
|
434
|
-
} catch {
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
this.audioSources = [];
|
|
438
|
-
this.audioGainNodes = [];
|
|
269
|
+
this.scheduledSources.clear();
|
|
439
270
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
clips.push(...trackClips);
|
|
271
|
+
/**
|
|
272
|
+
* Ensure audio window for a clip (aligned with video architecture)
|
|
273
|
+
*
|
|
274
|
+
* Note: Unlike video getFrame(), this method doesn't need a 'preheat' parameter
|
|
275
|
+
* Why: Audio cache check is window-level (range query) via hasWindowPCM()
|
|
276
|
+
* It verifies the entire window has ≥80% data, not just a single point
|
|
277
|
+
* This naturally prevents premature return during preheating
|
|
278
|
+
*/
|
|
279
|
+
async ensureAudioWindow(clipId, startUs, endUs) {
|
|
280
|
+
if (this.deps.cacheManager.hasWindowPCM(clipId, startUs, endUs)) {
|
|
281
|
+
return;
|
|
452
282
|
}
|
|
453
|
-
|
|
283
|
+
await this.decodeAudioWindow(clipId, startUs, endUs);
|
|
454
284
|
}
|
|
455
285
|
/**
|
|
456
|
-
*
|
|
457
|
-
*
|
|
286
|
+
* Decode audio window for a clip (aligned with video architecture)
|
|
287
|
+
* Simple strategy: decode entire window range, cache handles duplicates
|
|
458
288
|
*/
|
|
459
|
-
async
|
|
460
|
-
|
|
289
|
+
async decodeAudioWindow(clipId, startUs, endUs) {
|
|
290
|
+
const clip = this.model?.findClip(clipId);
|
|
291
|
+
if (!clip || !hasResourceId(clip)) {
|
|
461
292
|
return;
|
|
462
293
|
}
|
|
463
|
-
|
|
294
|
+
const audioRecord = this.deps.cacheManager.audioSampleCache.get(clip.resourceId);
|
|
295
|
+
if (!audioRecord) {
|
|
464
296
|
return;
|
|
465
297
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
}
|
|
473
|
-
/**
|
|
474
|
-
* Ensure PCM from AudioSampleCache (Resource-level)
|
|
475
|
-
* Returns true if successful
|
|
476
|
-
*/
|
|
477
|
-
async ensureClipAudioFromResource(clipId) {
|
|
478
|
-
const model = this.model;
|
|
479
|
-
const clip = model?.findClip(clipId);
|
|
480
|
-
if (!clip) return false;
|
|
481
|
-
const resourceId = clip.resourceId;
|
|
482
|
-
if (!resourceId) return false;
|
|
483
|
-
const audioRecord = this.deps.cacheManager.audioSampleCache.get(resourceId);
|
|
484
|
-
if (!audioRecord) return false;
|
|
485
|
-
try {
|
|
486
|
-
const clipSamples = audioRecord.samples.filter((s) => {
|
|
487
|
-
const sampleEndUs = s.timestamp + (s.duration ?? 0);
|
|
488
|
-
return s.timestamp < clip.durationUs && sampleEndUs > 0;
|
|
489
|
-
});
|
|
490
|
-
if (clipSamples.length === 0) {
|
|
491
|
-
return false;
|
|
492
|
-
}
|
|
493
|
-
await this.decodeAudioSamples(clipId, clipSamples, audioRecord.metadata, clip.durationUs);
|
|
494
|
-
return true;
|
|
495
|
-
} catch (error) {
|
|
496
|
-
console.warn(
|
|
497
|
-
`[GlobalAudioSession] Failed to decode audio from resource ${resourceId}:`,
|
|
498
|
-
error
|
|
499
|
-
);
|
|
500
|
-
return false;
|
|
298
|
+
const windowChunks = audioRecord.samples.filter((s) => {
|
|
299
|
+
const sampleEndUs = s.timestamp + (s.duration ?? 0);
|
|
300
|
+
return s.timestamp < endUs && sampleEndUs > startUs;
|
|
301
|
+
});
|
|
302
|
+
if (windowChunks.length === 0) {
|
|
303
|
+
return;
|
|
501
304
|
}
|
|
305
|
+
await this.decodeAudioSamples(
|
|
306
|
+
clipId,
|
|
307
|
+
windowChunks,
|
|
308
|
+
audioRecord.metadata,
|
|
309
|
+
clip.durationUs,
|
|
310
|
+
clip.startUs
|
|
311
|
+
);
|
|
502
312
|
}
|
|
503
313
|
/**
|
|
504
314
|
* Decode audio samples to PCM and cache
|
|
315
|
+
* Uses AudioChunkDecoder for consistency with project architecture
|
|
316
|
+
* Resamples to AudioContext sample rate if needed for better quality
|
|
505
317
|
*/
|
|
506
|
-
async decodeAudioSamples(clipId, samples, config, clipDurationUs) {
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
318
|
+
async decodeAudioSamples(clipId, samples, config, clipDurationUs, clipStartUs) {
|
|
319
|
+
let description;
|
|
320
|
+
if (config.description) {
|
|
321
|
+
if (config.description instanceof ArrayBuffer) {
|
|
322
|
+
description = config.description;
|
|
323
|
+
} else if (ArrayBuffer.isView(config.description)) {
|
|
324
|
+
const view = config.description;
|
|
325
|
+
const newBuffer = new ArrayBuffer(view.byteLength);
|
|
326
|
+
new Uint8Array(newBuffer).set(
|
|
327
|
+
new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
|
|
328
|
+
);
|
|
329
|
+
description = newBuffer;
|
|
513
330
|
}
|
|
514
|
-
});
|
|
515
|
-
decoder.configure(config);
|
|
516
|
-
for (const sample of samples) {
|
|
517
|
-
decoder.decode(sample);
|
|
518
331
|
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
332
|
+
const decoderConfig = {
|
|
333
|
+
codec: config.codec,
|
|
334
|
+
sampleRate: config.sampleRate,
|
|
335
|
+
numberOfChannels: config.numberOfChannels,
|
|
336
|
+
description
|
|
337
|
+
};
|
|
338
|
+
const decoder = new AudioChunkDecoder(`audio-${clipId}`, decoderConfig);
|
|
339
|
+
try {
|
|
340
|
+
const chunkStream = new ReadableStream({
|
|
341
|
+
start(controller) {
|
|
342
|
+
for (const sample of samples) {
|
|
343
|
+
controller.enqueue(sample);
|
|
344
|
+
}
|
|
345
|
+
controller.close();
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
const audioDataStream = chunkStream.pipeThrough(decoder.createStream());
|
|
349
|
+
const reader = audioDataStream.getReader();
|
|
350
|
+
try {
|
|
351
|
+
while (true) {
|
|
352
|
+
const { done, value } = await reader.read();
|
|
353
|
+
if (done) break;
|
|
354
|
+
if (value) {
|
|
355
|
+
const globalTimeUs = clipStartUs + (value.timestamp ?? 0);
|
|
356
|
+
this.deps.cacheManager.putClipAudioData(clipId, value, clipDurationUs, globalTimeUs);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
} finally {
|
|
360
|
+
reader.releaseLock();
|
|
532
361
|
}
|
|
362
|
+
} catch (error) {
|
|
363
|
+
console.error(`[GlobalAudioSession] Decoder error for clip ${clipId}:`, error);
|
|
364
|
+
throw error;
|
|
365
|
+
} finally {
|
|
366
|
+
await decoder.close();
|
|
533
367
|
}
|
|
534
|
-
return buffer;
|
|
535
368
|
}
|
|
536
369
|
audioBufferToAudioData(buffer, timestampUs) {
|
|
537
370
|
const sampleRate = buffer.sampleRate;
|
|
@@ -563,48 +396,6 @@ class GlobalAudioSession {
|
|
|
563
396
|
}
|
|
564
397
|
return interleaved.buffer;
|
|
565
398
|
}
|
|
566
|
-
async setupAudioPipeline(clip) {
|
|
567
|
-
const { id: clipId, resourceId, startUs, durationUs } = clip;
|
|
568
|
-
const audioDemuxWorker = await this.deps.workerPool.getOrCreate("audioDemux", clipId, {
|
|
569
|
-
lazy: true
|
|
570
|
-
});
|
|
571
|
-
const audioDecodeWorker = await this.deps.workerPool.getOrCreate("audioDecode", clipId, {
|
|
572
|
-
lazy: true
|
|
573
|
-
});
|
|
574
|
-
const demuxToDecodeChannel = new MessageChannel();
|
|
575
|
-
await audioDemuxWorker.send(
|
|
576
|
-
"connect",
|
|
577
|
-
{ direction: "downstream", port: demuxToDecodeChannel.port1, streamType: "audio", clipId },
|
|
578
|
-
{ transfer: [demuxToDecodeChannel.port1] }
|
|
579
|
-
);
|
|
580
|
-
await audioDecodeWorker.send(
|
|
581
|
-
"connect",
|
|
582
|
-
{
|
|
583
|
-
direction: "upstream",
|
|
584
|
-
port: demuxToDecodeChannel.port2,
|
|
585
|
-
streamType: "audio",
|
|
586
|
-
sessionId: clipId,
|
|
587
|
-
clipStartUs: startUs || 0,
|
|
588
|
-
clipDurationUs: durationUs || 0
|
|
589
|
-
},
|
|
590
|
-
{ transfer: [demuxToDecodeChannel.port2] }
|
|
591
|
-
);
|
|
592
|
-
audioDecodeWorker.receiveStream((stream, metadata) => {
|
|
593
|
-
this.handleAudioStream(stream, {
|
|
594
|
-
sessionId: clipId,
|
|
595
|
-
clipStartUs: startUs || 0,
|
|
596
|
-
clipDurationUs: durationUs || 0,
|
|
597
|
-
...metadata
|
|
598
|
-
});
|
|
599
|
-
});
|
|
600
|
-
const demuxConfig = this.deps.buildWorkerConfigs().audioDemux;
|
|
601
|
-
await audioDemuxWorker.send("configure", {
|
|
602
|
-
initial: true,
|
|
603
|
-
resourceId,
|
|
604
|
-
clipId,
|
|
605
|
-
config: demuxConfig
|
|
606
|
-
});
|
|
607
|
-
}
|
|
608
399
|
}
|
|
609
400
|
export {
|
|
610
401
|
GlobalAudioSession
|