@meframe/core 0.0.30 → 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.
Files changed (98) hide show
  1. package/dist/Meframe.d.ts +2 -2
  2. package/dist/Meframe.d.ts.map +1 -1
  3. package/dist/Meframe.js +3 -3
  4. package/dist/Meframe.js.map +1 -1
  5. package/dist/_virtual/_commonjsHelpers.js +7 -0
  6. package/dist/_virtual/_commonjsHelpers.js.map +1 -0
  7. package/dist/cache/CacheManager.d.ts +12 -17
  8. package/dist/cache/CacheManager.d.ts.map +1 -1
  9. package/dist/cache/CacheManager.js +18 -281
  10. package/dist/cache/CacheManager.js.map +1 -1
  11. package/dist/cache/l1/AudioL1Cache.d.ts +36 -19
  12. package/dist/cache/l1/AudioL1Cache.d.ts.map +1 -1
  13. package/dist/cache/l1/AudioL1Cache.js +182 -282
  14. package/dist/cache/l1/AudioL1Cache.js.map +1 -1
  15. package/dist/controllers/PlaybackController.d.ts +5 -2
  16. package/dist/controllers/PlaybackController.d.ts.map +1 -1
  17. package/dist/controllers/PlaybackController.js +60 -13
  18. package/dist/controllers/PlaybackController.js.map +1 -1
  19. package/dist/medeo-fe/node_modules/.pnpm/mp4-muxer@5.2.2/node_modules/mp4-muxer/build/mp4-muxer.js.map +1 -0
  20. package/dist/{node_modules → medeo-fe/node_modules}/.pnpm/mp4box@0.5.4/node_modules/mp4box/dist/mp4box.all.js +7 -2
  21. package/dist/medeo-fe/node_modules/.pnpm/mp4box@0.5.4/node_modules/mp4box/dist/mp4box.all.js.map +1 -0
  22. package/dist/model/types.d.ts +0 -4
  23. package/dist/model/types.d.ts.map +1 -1
  24. package/dist/model/types.js.map +1 -1
  25. package/dist/orchestrator/ExportScheduler.d.ts +6 -0
  26. package/dist/orchestrator/ExportScheduler.d.ts.map +1 -1
  27. package/dist/orchestrator/ExportScheduler.js +45 -66
  28. package/dist/orchestrator/ExportScheduler.js.map +1 -1
  29. package/dist/orchestrator/GlobalAudioSession.d.ts +35 -28
  30. package/dist/orchestrator/GlobalAudioSession.d.ts.map +1 -1
  31. package/dist/orchestrator/GlobalAudioSession.js +212 -421
  32. package/dist/orchestrator/GlobalAudioSession.js.map +1 -1
  33. package/dist/orchestrator/OnDemandVideoSession.d.ts +3 -3
  34. package/dist/orchestrator/OnDemandVideoSession.d.ts.map +1 -1
  35. package/dist/orchestrator/OnDemandVideoSession.js +4 -4
  36. package/dist/orchestrator/OnDemandVideoSession.js.map +1 -1
  37. package/dist/orchestrator/Orchestrator.d.ts +1 -2
  38. package/dist/orchestrator/Orchestrator.d.ts.map +1 -1
  39. package/dist/orchestrator/Orchestrator.js +34 -48
  40. package/dist/orchestrator/Orchestrator.js.map +1 -1
  41. package/dist/orchestrator/VideoClipSession.d.ts +0 -2
  42. package/dist/orchestrator/VideoClipSession.d.ts.map +1 -1
  43. package/dist/orchestrator/VideoClipSession.js +0 -49
  44. package/dist/orchestrator/VideoClipSession.js.map +1 -1
  45. package/dist/stages/compose/OfflineAudioMixer.d.ts.map +1 -1
  46. package/dist/stages/compose/OfflineAudioMixer.js +13 -18
  47. package/dist/stages/compose/OfflineAudioMixer.js.map +1 -1
  48. package/dist/stages/compose/VideoComposer.d.ts.map +1 -1
  49. package/dist/stages/compose/VideoComposer.js +4 -0
  50. package/dist/stages/compose/VideoComposer.js.map +1 -1
  51. package/dist/stages/decode/AudioChunkDecoder.js +169 -0
  52. package/dist/stages/decode/AudioChunkDecoder.js.map +1 -0
  53. package/dist/stages/demux/MP3FrameParser.js +186 -0
  54. package/dist/stages/demux/MP3FrameParser.js.map +1 -0
  55. package/dist/stages/demux/MP4Demuxer.js +6 -7
  56. package/dist/stages/demux/MP4Demuxer.js.map +1 -1
  57. package/dist/stages/demux/MP4IndexParser.js +3 -4
  58. package/dist/stages/demux/MP4IndexParser.js.map +1 -1
  59. package/dist/stages/load/ResourceLoader.d.ts +20 -10
  60. package/dist/stages/load/ResourceLoader.d.ts.map +1 -1
  61. package/dist/stages/load/ResourceLoader.js +92 -139
  62. package/dist/stages/load/ResourceLoader.js.map +1 -1
  63. package/dist/stages/load/index.d.ts +0 -1
  64. package/dist/stages/load/index.d.ts.map +1 -1
  65. package/dist/stages/load/types.d.ts +1 -0
  66. package/dist/stages/load/types.d.ts.map +1 -1
  67. package/dist/stages/mux/MP4Muxer.js +1 -1
  68. package/dist/utils/audio-data.d.ts +16 -0
  69. package/dist/utils/audio-data.d.ts.map +1 -0
  70. package/dist/utils/audio-data.js +111 -0
  71. package/dist/utils/audio-data.js.map +1 -0
  72. package/dist/utils/mp4box.d.ts +4 -0
  73. package/dist/utils/mp4box.d.ts.map +1 -0
  74. package/dist/utils/mp4box.js +17 -0
  75. package/dist/utils/mp4box.js.map +1 -0
  76. package/dist/workers/{MP4Demuxer.BEa6PLJm.js → MP4Demuxer.DxMpB08B.js} +49 -11
  77. package/dist/workers/MP4Demuxer.DxMpB08B.js.map +1 -0
  78. package/dist/workers/stages/compose/{video-compose.worker.DHQ8B105.js → video-compose.worker.BhpN-lxf.js} +5 -1
  79. package/dist/workers/stages/compose/video-compose.worker.BhpN-lxf.js.map +1 -0
  80. package/dist/workers/stages/demux/{audio-demux.worker._VRQdLdv.js → audio-demux.worker.Fd8sRTYi.js} +2 -2
  81. package/dist/workers/stages/demux/{audio-demux.worker._VRQdLdv.js.map → audio-demux.worker.Fd8sRTYi.js.map} +1 -1
  82. package/dist/workers/stages/demux/{video-demux.worker.CSkxGtmx.js → video-demux.worker.DqFOe12v.js} +2 -2
  83. package/dist/workers/stages/demux/{video-demux.worker.CSkxGtmx.js.map → video-demux.worker.DqFOe12v.js.map} +1 -1
  84. package/dist/workers/worker-manifest.json +3 -3
  85. package/package.json +1 -1
  86. package/dist/cache/resource/ImageBitmapCache.d.ts +0 -65
  87. package/dist/cache/resource/ImageBitmapCache.d.ts.map +0 -1
  88. package/dist/cache/resource/ImageBitmapCache.js +0 -101
  89. package/dist/cache/resource/ImageBitmapCache.js.map +0 -1
  90. package/dist/node_modules/.pnpm/mp4-muxer@5.2.2/node_modules/mp4-muxer/build/mp4-muxer.js.map +0 -1
  91. package/dist/node_modules/.pnpm/mp4box@0.5.4/node_modules/mp4box/dist/mp4box.all.js.map +0 -1
  92. package/dist/stages/load/WindowByteRangeResolver.d.ts +0 -47
  93. package/dist/stages/load/WindowByteRangeResolver.d.ts.map +0 -1
  94. package/dist/stages/load/WindowByteRangeResolver.js +0 -270
  95. package/dist/stages/load/WindowByteRangeResolver.js.map +0 -1
  96. package/dist/workers/MP4Demuxer.BEa6PLJm.js.map +0 -1
  97. package/dist/workers/stages/compose/video-compose.worker.DHQ8B105.js.map +0 -1
  98. /package/dist/{node_modules → medeo-fe/node_modules}/.pnpm/mp4-muxer@5.2.2/node_modules/mp4-muxer/build/mp4-muxer.js +0 -0
@@ -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 { hasResourceId, isAudioClip, hasAudioConfig } from "../model/types.js";
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
- currentPlaybackTimeUs = 0;
18
- ensuringClips = /* @__PURE__ */ new Set();
19
- // Track all decoding attempts (Resource & L2)
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
- this.deps.cacheManager.putClipAudioData(sessionId, audioData, clipDurationUs);
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
- await Promise.all(
42
- activeClips.map(async (clip) => {
43
- if (this.deps.cacheManager.hasClipPCM(clip.id)) return;
44
- if (!hasResourceId(clip)) return;
45
- const resource = model.getResource(clip.resourceId);
46
- if (!resource) return;
47
- if (this.deps.cacheManager.audioSampleCache.has(clip.resourceId)) {
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
- if (this.activeClips.has(clip.id)) {
52
- await this.waitForClipPCM(clip.id, 2e3);
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.startAllActiveClips(timeUs);
117
+ this.resetPlaybackStates();
180
118
  }
181
119
  stopPlayback() {
182
120
  this.isPlaying = false;
183
121
  this.stopAllAudioSources();
184
122
  }
185
- updateTime(timeUs) {
186
- this.currentPlaybackTimeUs = timeUs;
187
- if (!this.isPlaying) {
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.checkAndStartNewClips(timeUs);
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
- for (const source of this.audioSources) {
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.audioSources) {
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
- for (const gainNode of this.audioGainNodes) {
432
- try {
433
- gainNode.disconnect();
434
- } catch {
435
- }
436
- }
437
- this.audioSources = [];
438
- this.audioGainNodes = [];
269
+ this.scheduledSources.clear();
439
270
  }
440
- getActiveAudioClips(timeUs) {
441
- const model = this.model;
442
- if (!model) {
443
- return [];
444
- }
445
- const clips = [];
446
- for (const track of model.tracks) {
447
- if (track.kind !== "audio" && track.kind !== "video") {
448
- continue;
449
- }
450
- const trackClips = model.getClipsAtTime(timeUs, track.id);
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
- return clips;
283
+ await this.decodeAudioWindow(clipId, startUs, endUs);
454
284
  }
455
285
  /**
456
- * Ensure PCM for a clip is available
457
- * Priority: AudioSampleCache (Resource) > L2 > None
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 ensureClipAudio(clipId) {
460
- if (this.deps.cacheManager.hasClipPCM(clipId)) {
289
+ async decodeAudioWindow(clipId, startUs, endUs) {
290
+ const clip = this.model?.findClip(clipId);
291
+ if (!clip || !hasResourceId(clip)) {
461
292
  return;
462
293
  }
463
- if (this.ensuringClips.has(clipId)) {
294
+ const audioRecord = this.deps.cacheManager.audioSampleCache.get(clip.resourceId);
295
+ if (!audioRecord) {
464
296
  return;
465
297
  }
466
- this.ensuringClips.add(clipId);
467
- try {
468
- await this.ensureClipAudioFromResource(clipId);
469
- } finally {
470
- this.ensuringClips.delete(clipId);
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
- const decoder = new AudioDecoder({
508
- output: (audioData) => {
509
- this.deps.cacheManager.putClipAudioData(clipId, audioData, clipDurationUs);
510
- },
511
- error: (error) => {
512
- console.error(`[GlobalAudioSession] Decoder error for clip ${clipId}:`, error);
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
- await decoder.flush();
520
- decoder.close();
521
- }
522
- pcmToAudioBuffer(planes, sampleRate) {
523
- const numberOfChannels = planes.length;
524
- const numberOfFrames = planes[0]?.length ?? 0;
525
- const ctx = new OfflineAudioContext(numberOfChannels, 1, sampleRate);
526
- const buffer = ctx.createBuffer(numberOfChannels, numberOfFrames, sampleRate);
527
- for (let channel = 0; channel < numberOfChannels; channel++) {
528
- const plane = planes[channel];
529
- if (plane) {
530
- const channelData = buffer.getChannelData(channel);
531
- channelData.set(plane);
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