@kano/stem-daw 0.1.0

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 (67) hide show
  1. package/README.md +253 -0
  2. package/dist/chat-actions-54Z6URC4.js +7 -0
  3. package/dist/chat-actions-54Z6URC4.js.map +1 -0
  4. package/dist/chunk-56PWIP7O.js +1029 -0
  5. package/dist/chunk-56PWIP7O.js.map +1 -0
  6. package/dist/chunk-AAVC7KUW.js +145 -0
  7. package/dist/chunk-AAVC7KUW.js.map +1 -0
  8. package/dist/chunk-KCOOE2OP.js +1764 -0
  9. package/dist/chunk-KCOOE2OP.js.map +1 -0
  10. package/dist/chunk-LO74ZJ4H.js +23923 -0
  11. package/dist/chunk-LO74ZJ4H.js.map +1 -0
  12. package/dist/chunk-OFGZURP6.js +247 -0
  13. package/dist/chunk-OFGZURP6.js.map +1 -0
  14. package/dist/chunk-OYNES5W3.js +3085 -0
  15. package/dist/chunk-OYNES5W3.js.map +1 -0
  16. package/dist/chunk-QQ5NZTHT.js +336 -0
  17. package/dist/chunk-QQ5NZTHT.js.map +1 -0
  18. package/dist/chunk-TBXCZFAY.js +13713 -0
  19. package/dist/chunk-TBXCZFAY.js.map +1 -0
  20. package/dist/chunk-U44X6QP5.js +281 -0
  21. package/dist/chunk-U44X6QP5.js.map +1 -0
  22. package/dist/chunk-UKMELGZL.js +27 -0
  23. package/dist/chunk-UKMELGZL.js.map +1 -0
  24. package/dist/components/DAWView.d.ts +19 -0
  25. package/dist/components/DAWView.js +11 -0
  26. package/dist/components/DAWView.js.map +1 -0
  27. package/dist/daw-controller-BjRWcTol.d.ts +339 -0
  28. package/dist/engine/daw-controller.d.ts +3 -0
  29. package/dist/engine/daw-controller.js +5 -0
  30. package/dist/engine/daw-controller.js.map +1 -0
  31. package/dist/engine/daw-import-stem-fm-config.d.ts +224 -0
  32. package/dist/engine/daw-import-stem-fm-config.js +7 -0
  33. package/dist/engine/daw-import-stem-fm-config.js.map +1 -0
  34. package/dist/fetchStationTracks-SKFT4V3U.js +3 -0
  35. package/dist/fetchStationTracks-SKFT4V3U.js.map +1 -0
  36. package/dist/index.d.ts +308 -0
  37. package/dist/index.js +332 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/interface-DaRj7RkY.d.ts +66 -0
  40. package/dist/interfaces-5ZlG0Y4Y.d.ts +549 -0
  41. package/dist/media-session-XTP6PP7Q.js +3 -0
  42. package/dist/media-session-XTP6PP7Q.js.map +1 -0
  43. package/dist/note-detection-PPLM7R2H.js +148 -0
  44. package/dist/note-detection-PPLM7R2H.js.map +1 -0
  45. package/dist/sampler-audio-B7MBG3YN.js +3 -0
  46. package/dist/sampler-audio-B7MBG3YN.js.map +1 -0
  47. package/dist/sampler-store-QPHANXYP.js +3 -0
  48. package/dist/sampler-store-QPHANXYP.js.map +1 -0
  49. package/dist/services/track-search-api.d.ts +152 -0
  50. package/dist/services/track-search-api.js +4 -0
  51. package/dist/services/track-search-api.js.map +1 -0
  52. package/dist/store/daw-auth-store.d.ts +31 -0
  53. package/dist/store/daw-auth-store.js +3 -0
  54. package/dist/store/daw-auth-store.js.map +1 -0
  55. package/dist/store/daw-session-store.d.ts +255 -0
  56. package/dist/store/daw-session-store.js +3 -0
  57. package/dist/store/daw-session-store.js.map +1 -0
  58. package/dist/vite/index.d.ts +46 -0
  59. package/dist/vite/index.js +94 -0
  60. package/dist/vite/index.js.map +1 -0
  61. package/dist/workers/analysis-worker.js +379 -0
  62. package/dist/workers/buffer-player-processor-202602.lavv8e32-ts.js +1 -0
  63. package/dist/workers/daw-stem-processor.js +228 -0
  64. package/dist/workers/manifest.json +10 -0
  65. package/dist/workers/phase-vocoder3.js +920 -0
  66. package/dist/workers/realtime-pitch-shift-processor.js +2 -0
  67. package/package.json +151 -0
@@ -0,0 +1,3085 @@
1
+ import { AEV3Config, extractTfraData, MP4MoofExtractor, AACAdtsPackager, getWorkletPath_Phaze, getWorkletPath_RubberBand } from './chunk-TBXCZFAY.js';
2
+ import { getCachedAudioBuffer, cacheAudioBuffer, useDAWSessionStore, barToSample, semitonesToPitchFactor, DEFAULT_PITCH_SETTINGS, calculateTempoMatch } from './chunk-KCOOE2OP.js';
3
+ import isMobile from 'is-mobile';
4
+
5
+ // src/daw/engine/event-emitter.ts
6
+ var BrowserEventEmitter = class {
7
+ _listeners = /* @__PURE__ */ new Map();
8
+ on(event, listener) {
9
+ if (!this._listeners.has(event)) {
10
+ this._listeners.set(event, /* @__PURE__ */ new Set());
11
+ }
12
+ this._listeners.get(event).add(listener);
13
+ return this;
14
+ }
15
+ off(event, listener) {
16
+ this._listeners.get(event)?.delete(listener);
17
+ return this;
18
+ }
19
+ once(event, listener) {
20
+ const wrapper = (...args) => {
21
+ this.off(event, wrapper);
22
+ listener(...args);
23
+ };
24
+ return this.on(event, wrapper);
25
+ }
26
+ emit(event, ...args) {
27
+ const listeners = this._listeners.get(event);
28
+ if (!listeners || listeners.size === 0) return false;
29
+ for (const listener of listeners) {
30
+ try {
31
+ listener(...args);
32
+ } catch (e) {
33
+ console.error(`EventEmitter error on "${event}":`, e);
34
+ }
35
+ }
36
+ return true;
37
+ }
38
+ removeAllListeners(event) {
39
+ if (event) {
40
+ this._listeners.delete(event);
41
+ } else {
42
+ this._listeners.clear();
43
+ }
44
+ return this;
45
+ }
46
+ };
47
+
48
+ // src/daw/engine/progressive-track-loader.ts
49
+ var SAMPLE_RATE = 48e3;
50
+ var AAC_ENCODER_DELAY_SAMPLES = 2112;
51
+ var BATCH_SEGMENTS = 8;
52
+ var PLAYABLE_THRESHOLD_SAMPLES = 8 * SAMPLE_RATE;
53
+ var AAC_FRAME_SAMPLES = 1024;
54
+ var STEM_LABELS = ["bass", "drums", "other", "vocals"];
55
+ var TFRA_STEM_MAP = {
56
+ 0: "bass",
57
+ 1: "drums",
58
+ 2: "other",
59
+ 3: "vocals"
60
+ };
61
+ function isProgressiveDecodeSupported() {
62
+ return typeof globalThis.AudioDecoder !== "undefined";
63
+ }
64
+ function deriveTotalSamplesFromTfra(tfraData, expectedDurationSec) {
65
+ const entries = tfraData[0]?.entries ?? [];
66
+ const lastIdx = entries.length - 1;
67
+ const tfraSamples = lastIdx >= 0 ? entries[lastIdx].time : 0;
68
+ const durationSamples = Math.ceil(expectedDurationSec * SAMPLE_RATE);
69
+ return Math.max(tfraSamples, durationSamples) + SAMPLE_RATE;
70
+ }
71
+ function planBatches(tfraData, totalFileBytes) {
72
+ const entryCount = tfraData[0].entries.length;
73
+ if (entryCount < 2) return [];
74
+ const usableEntries = entryCount - 1;
75
+ const batches = [];
76
+ for (let s = 0; s < usableEntries; s += BATCH_SEGMENTS) {
77
+ const startSeg = s;
78
+ const endSeg = Math.min(s + BATCH_SEGMENTS, usableEntries);
79
+ const byteStart = tfraData[0].entries[startSeg].moof_offset;
80
+ const nextSegMoofOffset = endSeg < entryCount ? tfraData[0].entries[endSeg].moof_offset : tfraData[0].entries[endSeg - 1].moof_offset;
81
+ const byteEnd = nextSegMoofOffset;
82
+ batches.push({ startSeg, endSeg, byteStart, byteEnd });
83
+ }
84
+ return batches;
85
+ }
86
+ function getStemSliceWithinSegment(tfraData, segIndex, stemTfraIdx) {
87
+ const moofOffset = tfraData[stemTfraIdx].entries[segIndex].moof_offset;
88
+ let moofEnd;
89
+ if (stemTfraIdx < 3) {
90
+ moofEnd = tfraData[stemTfraIdx + 1].entries[segIndex].moof_offset;
91
+ } else {
92
+ moofEnd = tfraData[0].entries[segIndex + 1].moof_offset;
93
+ }
94
+ return { moofOffsetInFile: moofOffset, moofEndInFile: moofEnd };
95
+ }
96
+ async function fetchRange(url, byteStart, byteEnd, token, signal) {
97
+ const headers = {
98
+ Range: `bytes=${byteStart}-${byteEnd - 1}`
99
+ };
100
+ if (token) headers["Authorization"] = `Bearer ${token}`;
101
+ const res = await fetch(url, { headers, cache: "no-cache", signal });
102
+ if (!res.ok && res.status !== 206) {
103
+ throw new Error(`Range fetch failed: ${res.status} for bytes=${byteStart}-${byteEnd - 1}`);
104
+ }
105
+ return res.arrayBuffer();
106
+ }
107
+ function createStemDecoder(onChunk, onError) {
108
+ const Ctor = globalThis.AudioDecoder;
109
+ const decoder = new Ctor({
110
+ output: (audioData) => {
111
+ const frames = audioData.numberOfFrames;
112
+ const channels = audioData.numberOfChannels;
113
+ const left = new Float32Array(frames);
114
+ const right = new Float32Array(frames);
115
+ try {
116
+ audioData.copyTo(left, { planeIndex: 0, format: "f32-planar" });
117
+ if (channels > 1) {
118
+ audioData.copyTo(right, { planeIndex: 1, format: "f32-planar" });
119
+ } else {
120
+ right.set(left);
121
+ }
122
+ } catch (e) {
123
+ onError(e);
124
+ audioData.close();
125
+ return;
126
+ }
127
+ audioData.close();
128
+ onChunk(left, right, frames);
129
+ },
130
+ error: (e) => onError(e)
131
+ });
132
+ decoder.configure({
133
+ codec: "mp4a.40.2",
134
+ sampleRate: SAMPLE_RATE,
135
+ numberOfChannels: 2
136
+ });
137
+ return decoder;
138
+ }
139
+ function beginProgressiveLoad(mp4Url, expectedDurationSec, options = {}) {
140
+ const totalSamples = options.totalSamples ?? Math.ceil(expectedDurationSec * SAMPLE_RATE) + SAMPLE_RATE;
141
+ const abortController = new AbortController();
142
+ const patchListeners = /* @__PURE__ */ new Set();
143
+ const progressListeners = /* @__PURE__ */ new Set();
144
+ let playableResolve;
145
+ let playableReject;
146
+ const playable = new Promise((resolve, reject) => {
147
+ playableResolve = resolve;
148
+ playableReject = reject;
149
+ });
150
+ let completeResolve;
151
+ let completeReject;
152
+ const complete = new Promise((resolve, reject) => {
153
+ completeResolve = resolve;
154
+ completeReject = reject;
155
+ });
156
+ let playableResolved = false;
157
+ let completeResolved = false;
158
+ let aborted = false;
159
+ const emitPatch = (p) => {
160
+ if (aborted) return;
161
+ for (const cb of patchListeners) {
162
+ try {
163
+ cb(p);
164
+ } catch (e) {
165
+ console.warn("[ProgressiveLoader] patch listener threw", e);
166
+ }
167
+ }
168
+ };
169
+ const emitProgress = (p) => {
170
+ if (aborted) return;
171
+ for (const cb of progressListeners) {
172
+ try {
173
+ cb(p);
174
+ } catch (e) {
175
+ console.warn("[ProgressiveLoader] progress listener threw", e);
176
+ }
177
+ }
178
+ };
179
+ const run = async () => {
180
+ try {
181
+ const token = AEV3Config.getInstance()?.getAccessToken?.() ?? "";
182
+ emitProgress({ phase: "fetching", percent: 0, detail: "Reading MP4 index\u2026" });
183
+ const tfraData = options.tfraData ?? await extractTfraData(mp4Url, token, false);
184
+ if (aborted) return;
185
+ if (!tfraData || tfraData.length < 4) {
186
+ throw new Error("TFRA data missing or incomplete \u2013 expected 4 tracks");
187
+ }
188
+ const batches = planBatches(tfraData, null);
189
+ if (batches.length === 0) {
190
+ throw new Error("No decodable segments found in TFRA index");
191
+ }
192
+ const moofExtractor = new MP4MoofExtractor();
193
+ const adtsPackager = new AACAdtsPackager();
194
+ const stemStates = {};
195
+ for (const label of STEM_LABELS) {
196
+ const state = {
197
+ decoder: null,
198
+ writeOffset: 0,
199
+ samplesToSkip: AAC_ENCODER_DELAY_SAMPLES,
200
+ pendingChunks: [],
201
+ batchSampleStart: 0,
202
+ decodedSamples: 0,
203
+ timestampUs: 0
204
+ };
205
+ state.decoder = createStemDecoder(
206
+ (left, right) => {
207
+ let l = left;
208
+ let r = right;
209
+ if (state.samplesToSkip > 0) {
210
+ const drop = Math.min(state.samplesToSkip, l.length);
211
+ l = l.subarray(drop);
212
+ r = r.subarray(drop);
213
+ state.samplesToSkip -= drop;
214
+ if (l.length === 0) return;
215
+ }
216
+ state.pendingChunks.push([l, r]);
217
+ },
218
+ (e) => {
219
+ console.error(`[ProgressiveLoader] decoder error for ${label}:`, e);
220
+ }
221
+ );
222
+ stemStates[label] = state;
223
+ }
224
+ const checkPlayable = () => {
225
+ if (playableResolved) return;
226
+ let minDecoded = Infinity;
227
+ for (const label of STEM_LABELS) {
228
+ if (stemStates[label].decodedSamples < minDecoded) {
229
+ minDecoded = stemStates[label].decodedSamples;
230
+ }
231
+ }
232
+ if (minDecoded >= PLAYABLE_THRESHOLD_SAMPLES) {
233
+ playableResolved = true;
234
+ playableResolve();
235
+ }
236
+ };
237
+ for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) {
238
+ if (aborted) return;
239
+ const batch = batches[batchIdx];
240
+ emitProgress({
241
+ phase: "fetching",
242
+ percent: Math.round(batchIdx / batches.length * 100),
243
+ detail: `Batch ${batchIdx + 1}/${batches.length}`
244
+ });
245
+ let chunkBuf;
246
+ if (options.sourceBuffer) {
247
+ chunkBuf = options.sourceBuffer.slice(batch.byteStart, batch.byteEnd);
248
+ } else {
249
+ chunkBuf = await fetchRange(mp4Url, batch.byteStart, batch.byteEnd, token, abortController.signal);
250
+ }
251
+ if (aborted) return;
252
+ for (let tfraIdx = 0; tfraIdx < 4; tfraIdx++) {
253
+ const stemLabel = TFRA_STEM_MAP[tfraIdx];
254
+ const state = stemStates[stemLabel];
255
+ state.pendingChunks = [];
256
+ state.batchSampleStart = state.writeOffset;
257
+ const mp4TrackId = tfraIdx + 1;
258
+ for (let segIdx = batch.startSeg; segIdx < batch.endSeg; segIdx++) {
259
+ const { moofOffsetInFile, moofEndInFile } = getStemSliceWithinSegment(tfraData, segIdx, tfraIdx);
260
+ const relStart = moofOffsetInFile - batch.byteStart;
261
+ const relEnd = moofEndInFile - batch.byteStart;
262
+ if (relStart < 0 || relEnd > chunkBuf.byteLength || relEnd <= relStart) {
263
+ continue;
264
+ }
265
+ const segBuf = chunkBuf.slice(relStart, relEnd);
266
+ let adtsFrames;
267
+ try {
268
+ const { trunData, mdatData } = moofExtractor.extractMoofDataForAEV3(
269
+ segBuf,
270
+ mp4TrackId,
271
+ segIdx,
272
+ "",
273
+ "",
274
+ `stem-${stemLabel}`
275
+ );
276
+ const { buffers } = adtsPackager.packageAacFramesForAEV3(
277
+ mdatData,
278
+ trunData,
279
+ `stem-${stemLabel}`
280
+ );
281
+ adtsFrames = buffers;
282
+ } catch (e) {
283
+ console.warn(`[ProgressiveLoader] failed to parse seg ${segIdx} stem ${stemLabel}:`, e);
284
+ continue;
285
+ }
286
+ for (const frame of adtsFrames) {
287
+ if (aborted) return;
288
+ try {
289
+ state.decoder.decode(new EncodedAudioChunk({
290
+ type: "key",
291
+ timestamp: state.timestampUs,
292
+ duration: Math.round(AAC_FRAME_SAMPLES * 1e6 / SAMPLE_RATE),
293
+ data: frame
294
+ }));
295
+ state.timestampUs += Math.round(AAC_FRAME_SAMPLES * 1e6 / SAMPLE_RATE);
296
+ } catch (e) {
297
+ console.warn(`[ProgressiveLoader] decode() threw for ${stemLabel}:`, e);
298
+ }
299
+ }
300
+ }
301
+ }
302
+ await Promise.all(STEM_LABELS.map(async (label) => {
303
+ const state = stemStates[label];
304
+ try {
305
+ await state.decoder.flush();
306
+ } catch (e) {
307
+ console.warn(`[ProgressiveLoader] flush failed for ${label}:`, e);
308
+ }
309
+ }));
310
+ if (aborted) return;
311
+ for (const label of STEM_LABELS) {
312
+ const state = stemStates[label];
313
+ if (state.pendingChunks.length === 0) continue;
314
+ let total = 0;
315
+ for (const [l] of state.pendingChunks) total += l.length;
316
+ if (total === 0) continue;
317
+ const writeStart = state.batchSampleStart;
318
+ const maxWritable = Math.max(0, totalSamples - writeStart);
319
+ const writeLen = Math.min(total, maxWritable);
320
+ if (writeLen <= 0) {
321
+ console.warn(
322
+ `[ProgressiveLoader] buffer cap hit for ${label}: writeStart=${writeStart} >= totalSamples=${totalSamples} (have ${total} decoded samples queued, batch ${batchIdx + 1}/${batches.length})`
323
+ );
324
+ state.pendingChunks = [];
325
+ continue;
326
+ }
327
+ if (writeLen < total) {
328
+ console.warn(
329
+ `[ProgressiveLoader] truncating ${label} batch ${batchIdx + 1}: wrote ${writeLen} of ${total} samples (buffer ends at ${totalSamples})`
330
+ );
331
+ }
332
+ const mergedL = new Float32Array(writeLen);
333
+ const mergedR = new Float32Array(writeLen);
334
+ let off = 0;
335
+ for (const [l, r] of state.pendingChunks) {
336
+ const remaining = writeLen - off;
337
+ if (remaining <= 0) break;
338
+ const take = Math.min(l.length, remaining);
339
+ mergedL.set(l.subarray(0, take), off);
340
+ mergedR.set(r.subarray(0, take), off);
341
+ off += take;
342
+ }
343
+ state.pendingChunks = [];
344
+ state.writeOffset = writeStart + writeLen;
345
+ state.decodedSamples = state.writeOffset;
346
+ emitPatch({
347
+ stem: label,
348
+ sampleOffset: writeStart,
349
+ samples: [mergedL, mergedR]
350
+ });
351
+ }
352
+ checkPlayable();
353
+ emitProgress({
354
+ phase: "decoding",
355
+ percent: Math.round((batchIdx + 1) / batches.length * 100),
356
+ detail: `Decoded batch ${batchIdx + 1}/${batches.length}`
357
+ });
358
+ }
359
+ for (const label of STEM_LABELS) {
360
+ const state = stemStates[label];
361
+ try {
362
+ await state.decoder.flush();
363
+ state.decoder.close();
364
+ } catch {
365
+ }
366
+ }
367
+ if (!playableResolved) {
368
+ playableResolved = true;
369
+ playableResolve();
370
+ }
371
+ if (!completeResolved) {
372
+ completeResolved = true;
373
+ completeResolve();
374
+ }
375
+ emitProgress({ phase: "decoding", percent: 100, detail: "Done" });
376
+ } catch (e) {
377
+ if (aborted) return;
378
+ const err = e;
379
+ console.error("[ProgressiveLoader] run failed:", err);
380
+ if (!playableResolved) {
381
+ playableResolved = true;
382
+ playableReject(err);
383
+ }
384
+ if (!completeResolved) {
385
+ completeResolved = true;
386
+ completeReject(err);
387
+ }
388
+ }
389
+ };
390
+ void run();
391
+ return {
392
+ totalSamples,
393
+ playable,
394
+ complete,
395
+ abort: () => {
396
+ aborted = true;
397
+ abortController.abort();
398
+ },
399
+ onPatch: (cb) => {
400
+ patchListeners.add(cb);
401
+ return () => patchListeners.delete(cb);
402
+ },
403
+ onProgress: (cb) => {
404
+ progressListeners.add(cb);
405
+ return () => progressListeners.delete(cb);
406
+ }
407
+ };
408
+ }
409
+ async function cacheFullFileForOffline(url) {
410
+ try {
411
+ const existing = await getCachedAudioBuffer(url);
412
+ if (existing) return;
413
+ const token = AEV3Config.getInstance()?.getAccessToken?.() ?? "";
414
+ const headers = {};
415
+ if (token) headers["Authorization"] = `Bearer ${token}`;
416
+ const res = await fetch(url, { headers, cache: "force-cache" });
417
+ if (!res.ok) return;
418
+ const ab = await res.arrayBuffer();
419
+ await cacheAudioBuffer(url, ab);
420
+ } catch {
421
+ }
422
+ }
423
+
424
+ // src/daw/engine/daw-track-loader.ts
425
+ var SAMPLE_RATE2 = 48e3;
426
+ var _decodingCtx = null;
427
+ function getDecodingContext() {
428
+ if (!_decodingCtx || _decodingCtx.state === "closed") {
429
+ _decodingCtx = new AudioContext({ sampleRate: SAMPLE_RATE2 });
430
+ }
431
+ return _decodingCtx;
432
+ }
433
+ var TFRA_STEM_MAP2 = {
434
+ 0: "bass",
435
+ 1: "drums",
436
+ 2: "other",
437
+ 3: "vocals"
438
+ };
439
+ async function loadTrackStemsFromMp3Urls(urls, onProgress) {
440
+ const ctx = getDecodingContext();
441
+ const result = {};
442
+ const labels = ["bass", "drums", "other", "vocals"];
443
+ for (let i = 0; i < labels.length; i++) {
444
+ const label = labels[i];
445
+ const url = urls[label];
446
+ const pct = Math.round((i + 1) / 4 * 100);
447
+ onProgress?.({ phase: "fetching", percent: pct, detail: `Loading ${label}\u2026` });
448
+ const res = await fetch(url, { cache: "no-cache" });
449
+ if (!res.ok) throw new Error(`Failed to fetch ${label}: ${res.status}`);
450
+ const ab = await res.arrayBuffer();
451
+ const buf = await ctx.decodeAudioData(ab);
452
+ if (buf.numberOfChannels === 1) {
453
+ const stereo = ctx.createBuffer(2, buf.length, buf.sampleRate);
454
+ const mono = buf.getChannelData(0);
455
+ stereo.copyToChannel(mono, 0);
456
+ stereo.copyToChannel(mono, 1);
457
+ result[label] = stereo;
458
+ } else {
459
+ result[label] = buf;
460
+ }
461
+ }
462
+ onProgress?.({ phase: "decoding", percent: 100, detail: "Done" });
463
+ return result;
464
+ }
465
+ async function loadTrackStems(mp4Url, onProgress) {
466
+ const token = AEV3Config.getInstance()?.getAccessToken?.() ?? "";
467
+ onProgress?.({ phase: "fetching", percent: 0, detail: "Reading MP4 index\u2026" });
468
+ const tfraData = await extractTfraData(mp4Url, token, false);
469
+ if (!tfraData || tfraData.length < 4) {
470
+ throw new Error("TFRA data missing or incomplete \u2013 expected 4 tracks");
471
+ }
472
+ let fullBuffer;
473
+ const cached = await getCachedAudioBuffer(mp4Url);
474
+ if (cached) {
475
+ onProgress?.({ phase: "fetching", percent: 35, detail: "Loaded from cache" });
476
+ fullBuffer = cached;
477
+ } else {
478
+ onProgress?.({ phase: "fetching", percent: 10, detail: "Downloading audio\u2026" });
479
+ const headers = {};
480
+ if (token) headers["Authorization"] = `Bearer ${token}`;
481
+ const response = await fetch(mp4Url, { headers, cache: "no-cache" });
482
+ if (!response.ok) throw new Error(`MP4 fetch failed: ${response.status}`);
483
+ fullBuffer = await response.arrayBuffer();
484
+ cacheAudioBuffer(mp4Url, fullBuffer.slice(0));
485
+ }
486
+ onProgress?.({ phase: "parsing", percent: 40, detail: "Extracting stems\u2026" });
487
+ const moofExtractor = new MP4MoofExtractor();
488
+ const adtsPackager = new AACAdtsPackager();
489
+ const stemFrames = {
490
+ bass: [],
491
+ drums: [],
492
+ other: [],
493
+ vocals: []
494
+ };
495
+ const bufLen = fullBuffer.byteLength;
496
+ tfraData[0].entries.length;
497
+ for (let tfraIdx = 0; tfraIdx < 4; tfraIdx++) {
498
+ const stemLabel = TFRA_STEM_MAP2[tfraIdx];
499
+ const entries = tfraData[tfraIdx].entries;
500
+ const mp4TrackId = tfraIdx + 1;
501
+ for (let i = 0; i < entries.length - 1; i++) {
502
+ const moofOffset = entries[i].moof_offset;
503
+ let moofEnd;
504
+ if (tfraIdx < 3) {
505
+ moofEnd = tfraData[tfraIdx + 1].entries[i].moof_offset;
506
+ } else {
507
+ moofEnd = tfraData[0].entries[i + 1].moof_offset;
508
+ }
509
+ const byteLength = moofEnd - moofOffset;
510
+ if (moofOffset + byteLength > bufLen || byteLength <= 0) {
511
+ continue;
512
+ }
513
+ const trackBuffer = fullBuffer.slice(moofOffset, moofOffset + byteLength);
514
+ try {
515
+ const { trunData, mdatData } = moofExtractor.extractMoofDataForAEV3(
516
+ trackBuffer,
517
+ mp4TrackId,
518
+ i,
519
+ "",
520
+ "",
521
+ `stem-${stemLabel}`
522
+ );
523
+ const { buffers: adtsFrames } = adtsPackager.packageAacFramesForAEV3(
524
+ mdatData,
525
+ trunData,
526
+ `stem-${stemLabel}`
527
+ );
528
+ stemFrames[stemLabel].push(...adtsFrames);
529
+ } catch {
530
+ }
531
+ }
532
+ const pct = 40 + Math.round((tfraIdx + 1) / 4 * 30);
533
+ onProgress?.({ phase: "parsing", percent: pct, detail: `Extracted ${stemLabel}` });
534
+ }
535
+ onProgress?.({ phase: "decoding", percent: 70, detail: "Decoding audio\u2026" });
536
+ const ctx = getDecodingContext();
537
+ const result = {};
538
+ const stemLabels = Object.keys(stemFrames);
539
+ for (let si = 0; si < stemLabels.length; si++) {
540
+ const label = stemLabels[si];
541
+ const frames = stemFrames[label];
542
+ if (frames.length === 0) {
543
+ result[label] = ctx.createBuffer(2, SAMPLE_RATE2, SAMPLE_RATE2);
544
+ continue;
545
+ }
546
+ const merged = mergeFrames(frames);
547
+ let decoded;
548
+ try {
549
+ decoded = await ctx.decodeAudioData(merged.buffer);
550
+ } catch {
551
+ const blob = new Blob([merged], { type: "audio/aac" });
552
+ const ab = await blob.arrayBuffer();
553
+ decoded = await ctx.decodeAudioData(ab);
554
+ }
555
+ result[label] = trimEncoderDelay(decoded, ctx);
556
+ const pct = 70 + Math.round((si + 1) / 4 * 28);
557
+ onProgress?.({ phase: "decoding", percent: pct, detail: `Decoded ${label}` });
558
+ }
559
+ onProgress?.({ phase: "decoding", percent: 100, detail: "Done" });
560
+ return result;
561
+ }
562
+ var AAC_ENCODER_DELAY_SAMPLES2 = 2112;
563
+ function trimEncoderDelay(buf, ctx) {
564
+ if (buf.length <= AAC_ENCODER_DELAY_SAMPLES2) return buf;
565
+ const trimmedLength = buf.length - AAC_ENCODER_DELAY_SAMPLES2;
566
+ const trimmed = ctx.createBuffer(buf.numberOfChannels, trimmedLength, buf.sampleRate);
567
+ for (let ch = 0; ch < buf.numberOfChannels; ch++) {
568
+ const src = buf.getChannelData(ch);
569
+ const dst = trimmed.getChannelData(ch);
570
+ dst.set(src.subarray(AAC_ENCODER_DELAY_SAMPLES2));
571
+ }
572
+ return trimmed;
573
+ }
574
+ function mergeFrames(frames) {
575
+ let totalLength = 0;
576
+ for (const f of frames) totalLength += f.byteLength;
577
+ const out = new Uint8Array(totalLength);
578
+ let offset = 0;
579
+ for (const f of frames) {
580
+ out.set(f, offset);
581
+ offset += f.byteLength;
582
+ }
583
+ return out;
584
+ }
585
+ function allocateEmptyStemBuffers(totalSamples, ctx) {
586
+ const len = Math.max(1, totalSamples);
587
+ return {
588
+ bass: ctx.createBuffer(2, len, SAMPLE_RATE2),
589
+ drums: ctx.createBuffer(2, len, SAMPLE_RATE2),
590
+ other: ctx.createBuffer(2, len, SAMPLE_RATE2),
591
+ vocals: ctx.createBuffer(2, len, SAMPLE_RATE2)
592
+ };
593
+ }
594
+ async function loadTrackStemsProgressively(mp4Url, expectedDurationSec, onProgress) {
595
+ const ctx = getDecodingContext();
596
+ if (!isProgressiveDecodeSupported()) {
597
+ const fallbackTotalSamples = Math.max(1, Math.ceil(expectedDurationSec * SAMPLE_RATE2) + SAMPLE_RATE2);
598
+ const initial2 = allocateEmptyStemBuffers(fallbackTotalSamples, ctx);
599
+ return fallbackToOneShotPatch(mp4Url, initial2, fallbackTotalSamples, onProgress);
600
+ }
601
+ const token = AEV3Config.getInstance()?.getAccessToken?.() ?? "";
602
+ let tfraData;
603
+ try {
604
+ tfraData = await extractTfraData(mp4Url, token, false);
605
+ } catch (err) {
606
+ console.warn("[loadTrackStemsProgressively] TFRA fetch failed, falling back to duration-only sizing:", err);
607
+ tfraData = null;
608
+ }
609
+ const totalSamples = tfraData && tfraData.length >= 4 ? deriveTotalSamplesFromTfra(tfraData, expectedDurationSec) : Math.max(1, Math.ceil(expectedDurationSec * SAMPLE_RATE2) + SAMPLE_RATE2);
610
+ const initial = allocateEmptyStemBuffers(totalSamples, ctx);
611
+ const cached = await getCachedAudioBuffer(mp4Url);
612
+ if (cached && tfraData && tfraData.length >= 4) {
613
+ const handle2 = beginProgressiveLoad(mp4Url, expectedDurationSec, {
614
+ tfraData,
615
+ sourceBuffer: cached,
616
+ totalSamples
617
+ });
618
+ if (onProgress) handle2.onProgress(onProgress);
619
+ return { initial, handle: handle2 };
620
+ }
621
+ const handle = beginProgressiveLoad(mp4Url, expectedDurationSec, {
622
+ tfraData: tfraData ?? void 0,
623
+ totalSamples
624
+ });
625
+ if (onProgress) handle.onProgress(onProgress);
626
+ return { initial, handle };
627
+ }
628
+ async function fallbackToOneShotPatch(mp4Url, initial, totalSamples, onProgress) {
629
+ const patchListeners = /* @__PURE__ */ new Set();
630
+ const progressListeners = /* @__PURE__ */ new Set();
631
+ if (onProgress) progressListeners.add(onProgress);
632
+ let completeResolve;
633
+ let completeReject;
634
+ const complete = new Promise((resolve, reject) => {
635
+ completeResolve = resolve;
636
+ completeReject = reject;
637
+ });
638
+ let aborted = false;
639
+ (async () => {
640
+ try {
641
+ const stems = await loadTrackStems(mp4Url, (p) => {
642
+ for (const cb of progressListeners) {
643
+ try {
644
+ cb(p);
645
+ } catch {
646
+ }
647
+ }
648
+ });
649
+ if (aborted) return;
650
+ const stemKeys = ["bass", "drums", "other", "vocals"];
651
+ for (const k of stemKeys) {
652
+ const buf = stems[k];
653
+ const lenToCopy = Math.min(buf.length, totalSamples);
654
+ const left = new Float32Array(lenToCopy);
655
+ const right = new Float32Array(lenToCopy);
656
+ left.set(buf.getChannelData(0).subarray(0, lenToCopy));
657
+ if (buf.numberOfChannels > 1) {
658
+ right.set(buf.getChannelData(1).subarray(0, lenToCopy));
659
+ } else {
660
+ right.set(left);
661
+ }
662
+ for (const cb of patchListeners) {
663
+ try {
664
+ cb({ stem: k, sampleOffset: 0, samples: [left, right] });
665
+ } catch {
666
+ }
667
+ }
668
+ }
669
+ completeResolve();
670
+ } catch (e) {
671
+ completeReject(e);
672
+ }
673
+ })();
674
+ const handle = {
675
+ totalSamples,
676
+ playable: complete,
677
+ complete,
678
+ abort: () => {
679
+ aborted = true;
680
+ },
681
+ onPatch: (cb) => {
682
+ patchListeners.add(cb);
683
+ return () => patchListeners.delete(cb);
684
+ },
685
+ onProgress: (cb) => {
686
+ progressListeners.add(cb);
687
+ return () => progressListeners.delete(cb);
688
+ }
689
+ };
690
+ return { initial, handle };
691
+ }
692
+
693
+ // src/daw/components/waveform-cache.ts
694
+ var MIPMAP_LEVELS = [64, 256, 1024, 4096, 16384, 65536];
695
+ var mipmapStore = /* @__PURE__ */ new Map();
696
+ var STEM_KEYS = ["other", "vocals", "bass", "drums"];
697
+ var changeListeners = /* @__PURE__ */ new Set();
698
+ function subscribeMipmapChanges(cb) {
699
+ changeListeners.add(cb);
700
+ return () => changeListeners.delete(cb);
701
+ }
702
+ function notifyChange() {
703
+ for (const cb of changeListeners) {
704
+ try {
705
+ cb();
706
+ } catch {
707
+ }
708
+ }
709
+ }
710
+ function makeKey(trackId, stemKey) {
711
+ return `${trackId}:${stemKey}`;
712
+ }
713
+ function initMipmapsForTrack(trackId, stemKey, totalSamples) {
714
+ const key = makeKey(trackId, stemKey);
715
+ const existing = mipmapStore.get(key);
716
+ if (existing && existing.totalSamples === totalSamples) {
717
+ for (const arr of existing.levels.values()) arr.fill(0);
718
+ existing.decodedSampleHigh = 0;
719
+ existing.version++;
720
+ return existing;
721
+ }
722
+ const levels = /* @__PURE__ */ new Map();
723
+ for (const numBins of MIPMAP_LEVELS) {
724
+ if (totalSamples < numBins) continue;
725
+ levels.set(numBins, new Float32Array(numBins));
726
+ }
727
+ const state = {
728
+ levels,
729
+ decodedSampleHigh: 0,
730
+ totalSamples,
731
+ version: 1
732
+ };
733
+ mipmapStore.set(key, state);
734
+ return state;
735
+ }
736
+ function appendPeaksToMipmapCache(trackId, stemKey, sampleOffset, leftChannel, rightChannel) {
737
+ const state = mipmapStore.get(makeKey(trackId, stemKey));
738
+ if (!state) return;
739
+ const total = state.totalSamples;
740
+ const startSample = Math.max(0, sampleOffset);
741
+ const endSample = Math.min(total, sampleOffset + leftChannel.length);
742
+ if (endSample <= startSample) return;
743
+ for (const [numBins, peaks] of state.levels) {
744
+ const firstBin = Math.floor(startSample * numBins / total);
745
+ const lastBinExcl = Math.min(numBins, Math.ceil(endSample * numBins / total));
746
+ for (let b = firstBin; b < lastBinExcl; b++) {
747
+ const binStart = Math.floor(b * total / numBins);
748
+ const binEnd = Math.floor((b + 1) * total / numBins);
749
+ const sFrom = Math.max(binStart, startSample);
750
+ const sToExcl = Math.min(binEnd, endSample);
751
+ if (sToExcl <= sFrom) continue;
752
+ let max = peaks[b];
753
+ for (let i = sFrom; i < sToExcl; i++) {
754
+ const localI = i - sampleOffset;
755
+ const l = leftChannel[localI];
756
+ const r = rightChannel ? rightChannel[localI] : l;
757
+ const al = l < 0 ? -l : l;
758
+ const ar = r < 0 ? -r : r;
759
+ const v = al > ar ? al : ar;
760
+ if (v > max) max = v;
761
+ }
762
+ peaks[b] = max;
763
+ }
764
+ }
765
+ if (endSample > state.decodedSampleHigh) state.decodedSampleHigh = endSample;
766
+ state.version++;
767
+ notifyChange();
768
+ }
769
+ function invalidateWaveformCache(trackId, stemIndex) {
770
+ const key = makeKey(trackId, STEM_KEYS[stemIndex]);
771
+ const had = mipmapStore.has(key);
772
+ mipmapStore.delete(key);
773
+ console.log("[DAW Waveform] invalidateWaveformCache:", key, had ? "(cache cleared)" : "(was not cached)");
774
+ }
775
+ function clearWaveformCacheForTrack(trackId) {
776
+ for (const k of STEM_KEYS) {
777
+ mipmapStore.delete(makeKey(trackId, k));
778
+ }
779
+ }
780
+
781
+ // src/daw/engine/stem-effect-chain.ts
782
+ var DEFAULT_EFFECTS = {
783
+ eq: { enabled: false, lowGain: 0, midGain: 0, highGain: 0, lowFreq: 320, highFreq: 3200 },
784
+ compressor: { enabled: false, threshold: -24, ratio: 4, attack: 3e-3, release: 0.25, knee: 30 },
785
+ reverb: { enabled: false, mix: 0.3, decay: 2 },
786
+ delay: { enabled: false, time: 0.375, feedback: 0.35, mix: 0.25 }
787
+ };
788
+ function hasActiveEffects(fx) {
789
+ return fx.eq.enabled || fx.compressor.enabled || fx.reverb.enabled || fx.delay.enabled;
790
+ }
791
+ var StemEffectChain = class {
792
+ ctx;
793
+ // Input/output anchors — these never change
794
+ input;
795
+ output;
796
+ // EQ: 3-band (lowshelf + peaking + highshelf)
797
+ eqLow;
798
+ eqMid;
799
+ eqHigh;
800
+ // Compressor
801
+ compressor;
802
+ // Reverb (algorithmic via ConvolverNode with generated impulse)
803
+ reverbDry;
804
+ reverbWet;
805
+ convolver;
806
+ // Delay
807
+ delayNode;
808
+ delayFeedback;
809
+ delayDry;
810
+ delayWet;
811
+ // Per-slot bypass routing
812
+ eqBypass;
813
+ compBypass;
814
+ reverbBypass;
815
+ delayBypass;
816
+ // Junction nodes between stages
817
+ postEq;
818
+ postComp;
819
+ postReverb;
820
+ _params;
821
+ constructor(ctx) {
822
+ this.ctx = ctx;
823
+ this._params = structuredClone(DEFAULT_EFFECTS);
824
+ this.input = ctx.createGain();
825
+ this.output = ctx.createGain();
826
+ this.eqLow = ctx.createBiquadFilter();
827
+ this.eqLow.type = "lowshelf";
828
+ this.eqLow.frequency.value = 320;
829
+ this.eqLow.gain.value = 0;
830
+ this.eqMid = ctx.createBiquadFilter();
831
+ this.eqMid.type = "peaking";
832
+ this.eqMid.frequency.value = 1e3;
833
+ this.eqMid.Q.value = 0.7;
834
+ this.eqMid.gain.value = 0;
835
+ this.eqHigh = ctx.createBiquadFilter();
836
+ this.eqHigh.type = "highshelf";
837
+ this.eqHigh.frequency.value = 3200;
838
+ this.eqHigh.gain.value = 0;
839
+ this.eqBypass = ctx.createGain();
840
+ this.compressor = ctx.createDynamicsCompressor();
841
+ this.compressor.threshold.value = -24;
842
+ this.compressor.ratio.value = 4;
843
+ this.compressor.attack.value = 3e-3;
844
+ this.compressor.release.value = 0.25;
845
+ this.compressor.knee.value = 30;
846
+ this.compBypass = ctx.createGain();
847
+ this.convolver = ctx.createConvolver();
848
+ this.convolver.buffer = this.generateImpulse(2);
849
+ this.reverbDry = ctx.createGain();
850
+ this.reverbDry.gain.value = 1;
851
+ this.reverbWet = ctx.createGain();
852
+ this.reverbWet.gain.value = 0;
853
+ this.reverbBypass = ctx.createGain();
854
+ this.delayNode = ctx.createDelay(5);
855
+ this.delayNode.delayTime.value = 0.375;
856
+ this.delayFeedback = ctx.createGain();
857
+ this.delayFeedback.gain.value = 0.35;
858
+ this.delayDry = ctx.createGain();
859
+ this.delayDry.gain.value = 1;
860
+ this.delayWet = ctx.createGain();
861
+ this.delayWet.gain.value = 0;
862
+ this.delayBypass = ctx.createGain();
863
+ this.delayNode.connect(this.delayFeedback);
864
+ this.delayFeedback.connect(this.delayNode);
865
+ this.postEq = ctx.createGain();
866
+ this.postComp = ctx.createGain();
867
+ this.postReverb = ctx.createGain();
868
+ this.rebuildRouting();
869
+ }
870
+ /**
871
+ * Rebuild the full routing graph based on current bypass states.
872
+ * Uses disconnect-all then reconnect to avoid orphaned connections.
873
+ */
874
+ rebuildRouting() {
875
+ const p = this._params;
876
+ this.input.disconnect();
877
+ this.eqLow.disconnect();
878
+ this.eqMid.disconnect();
879
+ this.eqHigh.disconnect();
880
+ this.eqBypass.disconnect();
881
+ this.postEq.disconnect();
882
+ this.compressor.disconnect();
883
+ this.compBypass.disconnect();
884
+ this.postComp.disconnect();
885
+ this.reverbDry.disconnect();
886
+ this.reverbWet.disconnect();
887
+ this.convolver.disconnect();
888
+ this.reverbBypass.disconnect();
889
+ this.postReverb.disconnect();
890
+ this.delayDry.disconnect();
891
+ this.delayWet.disconnect();
892
+ this.delayNode.disconnect();
893
+ this.delayFeedback.disconnect();
894
+ this.delayBypass.disconnect();
895
+ this.delayNode.connect(this.delayFeedback);
896
+ this.delayFeedback.connect(this.delayNode);
897
+ if (p.eq.enabled) {
898
+ this.input.connect(this.eqLow);
899
+ this.eqLow.connect(this.eqMid);
900
+ this.eqMid.connect(this.eqHigh);
901
+ this.eqHigh.connect(this.postEq);
902
+ } else {
903
+ this.input.connect(this.postEq);
904
+ }
905
+ if (p.compressor.enabled) {
906
+ this.postEq.connect(this.compressor);
907
+ this.compressor.connect(this.postComp);
908
+ } else {
909
+ this.postEq.connect(this.postComp);
910
+ }
911
+ if (p.reverb.enabled) {
912
+ this.postComp.connect(this.reverbDry);
913
+ this.postComp.connect(this.convolver);
914
+ this.convolver.connect(this.reverbWet);
915
+ this.reverbDry.connect(this.postReverb);
916
+ this.reverbWet.connect(this.postReverb);
917
+ } else {
918
+ this.postComp.connect(this.postReverb);
919
+ }
920
+ if (p.delay.enabled) {
921
+ this.postReverb.connect(this.delayDry);
922
+ this.postReverb.connect(this.delayNode);
923
+ this.delayNode.connect(this.delayWet);
924
+ this.delayDry.connect(this.output);
925
+ this.delayWet.connect(this.output);
926
+ } else {
927
+ this.postReverb.connect(this.output);
928
+ }
929
+ }
930
+ /** Generate a synthetic impulse response for reverb */
931
+ generateImpulse(decay) {
932
+ const rate = this.ctx.sampleRate;
933
+ const length = Math.floor(rate * Math.min(decay, 6));
934
+ const buf = this.ctx.createBuffer(2, length, rate);
935
+ for (let ch = 0; ch < 2; ch++) {
936
+ const data = buf.getChannelData(ch);
937
+ for (let i = 0; i < length; i++) {
938
+ data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, decay * 0.8);
939
+ }
940
+ }
941
+ return buf;
942
+ }
943
+ // ── Public API ───────────────────────────────────────────────────
944
+ get params() {
945
+ return this._params;
946
+ }
947
+ updateEQ(p) {
948
+ Object.assign(this._params.eq, p);
949
+ const eq = this._params.eq;
950
+ this.eqLow.frequency.value = eq.lowFreq;
951
+ this.eqLow.gain.value = eq.lowGain;
952
+ this.eqMid.gain.value = eq.midGain;
953
+ this.eqHigh.frequency.value = eq.highFreq;
954
+ this.eqHigh.gain.value = eq.highGain;
955
+ if ("enabled" in p) this.rebuildRouting();
956
+ }
957
+ updateCompressor(p) {
958
+ Object.assign(this._params.compressor, p);
959
+ const c = this._params.compressor;
960
+ const t = this.ctx.currentTime;
961
+ this.compressor.threshold.setValueAtTime(c.threshold, t);
962
+ this.compressor.ratio.setValueAtTime(c.ratio, t);
963
+ this.compressor.attack.setValueAtTime(c.attack, t);
964
+ this.compressor.release.setValueAtTime(c.release, t);
965
+ this.compressor.knee.setValueAtTime(c.knee, t);
966
+ if ("enabled" in p) this.rebuildRouting();
967
+ }
968
+ updateReverb(p) {
969
+ Object.assign(this._params.reverb, p);
970
+ const r = this._params.reverb;
971
+ const t = this.ctx.currentTime;
972
+ this.reverbDry.gain.setValueAtTime(1 - r.mix, t);
973
+ this.reverbWet.gain.setValueAtTime(r.mix, t);
974
+ if ("decay" in p) {
975
+ this.convolver.buffer = this.generateImpulse(r.decay);
976
+ }
977
+ if ("enabled" in p) this.rebuildRouting();
978
+ }
979
+ updateDelay(p) {
980
+ Object.assign(this._params.delay, p);
981
+ const d = this._params.delay;
982
+ const t = this.ctx.currentTime;
983
+ this.delayNode.delayTime.setValueAtTime(d.time, t);
984
+ this.delayFeedback.gain.setValueAtTime(d.feedback, t);
985
+ this.delayDry.gain.setValueAtTime(1 - d.mix, t);
986
+ this.delayWet.gain.setValueAtTime(d.mix, t);
987
+ if ("enabled" in p) this.rebuildRouting();
988
+ }
989
+ applyAll(fx) {
990
+ this.updateEQ(fx.eq);
991
+ this.updateCompressor(fx.compressor);
992
+ this.updateReverb(fx.reverb);
993
+ this.updateDelay(fx.delay);
994
+ }
995
+ dispose() {
996
+ try {
997
+ this.input.disconnect();
998
+ this.output.disconnect();
999
+ } catch {
1000
+ }
1001
+ }
1002
+ };
1003
+ var SAMPLE_RATE3 = 48e3;
1004
+ var STEM_LABELS2 = ["other", "vocals", "bass", "drums"];
1005
+ var DAW_WORKLET_PATH = "/workers/daw-stem-processor.js";
1006
+ var USE_PHAZE = isMobile();
1007
+ var PITCH_WORKLET_NAME = USE_PHAZE ? "phase-vocoder-processor" : "realtime-pitch-shift-processor";
1008
+ var METRO_LOOK_AHEAD_SEC = 0.1;
1009
+ var METRO_SCHEDULE_INTERVAL_MS = 25;
1010
+ var DEFAULT_MAX_DECODED_TRACKS = isMobile() ? 6 : 12;
1011
+ function buildBarMap(barMapping, masterBarLength, nativeBarLength, subdivisions = 1) {
1012
+ const tempoMatch = calculateTempoMatch(masterBarLength, nativeBarLength);
1013
+ const targetBarLength = tempoMatch.targetBarLength;
1014
+ const subs = Math.max(1, subdivisions);
1015
+ const parentSpeeds = barMapping.map((bar) => bar.duration / targetBarLength);
1016
+ if (subs <= 1) {
1017
+ return barMapping.map((bar, i) => ({
1018
+ sampleStart: bar.sampleStart,
1019
+ speed: parentSpeeds[i]
1020
+ }));
1021
+ }
1022
+ const totalSubs = barMapping.length * subs;
1023
+ const speeds = new Array(totalSubs);
1024
+ for (let i = 0; i < totalSubs; i++) {
1025
+ const parentPos = (i + 0.5) / subs;
1026
+ const lo = Math.floor(parentPos - 0.5);
1027
+ const hi = lo + 1;
1028
+ const t = parentPos - 0.5 - lo;
1029
+ const loSpeed = parentSpeeds[Math.max(0, Math.min(lo, parentSpeeds.length - 1))];
1030
+ const hiSpeed = parentSpeeds[Math.max(0, Math.min(hi, parentSpeeds.length - 1))];
1031
+ speeds[i] = loSpeed + (hiSpeed - loSpeed) * t;
1032
+ }
1033
+ const result = [];
1034
+ for (let b = 0; b < barMapping.length; b++) {
1035
+ const bar = barMapping[b];
1036
+ const offset = b * subs;
1037
+ let speedSum = 0;
1038
+ for (let s = 0; s < subs; s++) speedSum += speeds[offset + s];
1039
+ let cumSamples = 0;
1040
+ for (let s = 0; s < subs; s++) {
1041
+ result.push({
1042
+ sampleStart: Math.round(bar.sampleStart + cumSamples),
1043
+ speed: speeds[offset + s]
1044
+ });
1045
+ cumSamples += speeds[offset + s] / speedSum * bar.duration;
1046
+ }
1047
+ }
1048
+ return result;
1049
+ }
1050
+ function buildBarPitchMap(barMap) {
1051
+ return barMap.map((entry) => ({
1052
+ pitchCorrectionSemitones: -12 * Math.log2(entry.speed),
1053
+ speed: entry.speed
1054
+ }));
1055
+ }
1056
+ var DAWController = class _DAWController extends BrowserEventEmitter {
1057
+ ctx = null;
1058
+ masterGain = null;
1059
+ tracks = /* @__PURE__ */ new Map();
1060
+ /** URL → shared decode entry. See `SourceCacheEntry`. */
1061
+ sourceCache = /* @__PURE__ */ new Map();
1062
+ isInitialised = false;
1063
+ _pitchWorkletAvailable = false;
1064
+ _playing = false;
1065
+ _playStartCtxTime = 0;
1066
+ _playStartOffset = 0;
1067
+ playheadAnimationId = null;
1068
+ _seekGen = 0;
1069
+ /**
1070
+ * Max number of tracks allowed to hold their decoded PCM concurrently.
1071
+ * Beyond this we evict LRU tracks via `dumpTrackAudio`. See
1072
+ * `DEFAULT_MAX_DECODED_TRACKS` for the rationale.
1073
+ */
1074
+ _maxDecodedTracks = DEFAULT_MAX_DECODED_TRACKS;
1075
+ // Track which stems are currently playing so we can start/stop
1076
+ // them as the playhead crosses their timeline boundaries.
1077
+ _stemActive = /* @__PURE__ */ new Map();
1078
+ // Metronome
1079
+ _metronomeOn = false;
1080
+ _metronomeDownbeatOnly = true;
1081
+ _metronomeGain = null;
1082
+ _metronomeScheduleId = null;
1083
+ _metronomeNextBeat = 0;
1084
+ // CPU monitoring
1085
+ _cpuPollId = null;
1086
+ _cpuUsage = 0;
1087
+ // Native playback mode — disables tempo quantization for a single track
1088
+ // so the beat lab can play audio at original speed (beat markers align 1:1).
1089
+ _nativeModeTrackId = null;
1090
+ _nativeModeOriginalBarMap = null;
1091
+ /** Native-time offset (samples) at the moment play/seek was last called in native mode */
1092
+ _nativeStartSample = 0;
1093
+ static instance = null;
1094
+ static getInstance() {
1095
+ if (!_DAWController.instance) _DAWController.instance = new _DAWController();
1096
+ return _DAWController.instance;
1097
+ }
1098
+ constructor() {
1099
+ super();
1100
+ }
1101
+ // ── Init ──────────────────────────────────────────────────────────
1102
+ async init() {
1103
+ if (this.isInitialised) return;
1104
+ this.ctx = new AudioContext({ sampleRate: SAMPLE_RATE3 });
1105
+ console.log("[DAWController.init] AudioContext created, state:", this.ctx.state);
1106
+ this.ctx.addEventListener("statechange", () => {
1107
+ console.log("[DAWController] AudioContext statechange \u2192", this.ctx.state);
1108
+ if (this.ctx.state === "suspended" && this._playing) {
1109
+ console.warn("[DAWController] Context suspended while playing \u2014 auto-resuming");
1110
+ this.ctx.resume().catch(() => {
1111
+ });
1112
+ }
1113
+ });
1114
+ await this.ctx.audioWorklet.addModule(DAW_WORKLET_PATH);
1115
+ const pitchWorkletPath = USE_PHAZE ? getWorkletPath_Phaze() : getWorkletPath_RubberBand();
1116
+ try {
1117
+ await this.ctx.audioWorklet.addModule(pitchWorkletPath);
1118
+ this._pitchWorkletAvailable = true;
1119
+ } catch (err) {
1120
+ console.warn("[DAWController] Pitch worklet failed to load, transpose unavailable:", err);
1121
+ this._pitchWorkletAvailable = false;
1122
+ }
1123
+ this.masterGain = this.ctx.createGain();
1124
+ this.masterGain.connect(this.ctx.destination);
1125
+ this.isInitialised = true;
1126
+ this.emit("initialised");
1127
+ }
1128
+ isReady() {
1129
+ return this.isInitialised;
1130
+ }
1131
+ /**
1132
+ * Resolve when the named track's audio has fully decoded. If the
1133
+ * decode is already complete (common with the source-cache when
1134
+ * multiple clips share one source), resolve immediately on the next
1135
+ * microtask. Rejects if the track is removed or no such track exists.
1136
+ *
1137
+ * Use this instead of `controller.on('trackLoaded', …)` from
1138
+ * importers / batch loaders — the event fires once and a late
1139
+ * subscriber would miss it. This helper closes that race.
1140
+ */
1141
+ whenTrackLoaded(trackId) {
1142
+ const rt = this.tracks.get(trackId);
1143
+ if (!rt) return Promise.reject(new Error(`Unknown track ${trackId}`));
1144
+ if (rt.loaded) return Promise.resolve();
1145
+ return new Promise((resolve, reject) => {
1146
+ const onLoaded = (loadedId) => {
1147
+ if (loadedId !== trackId) return;
1148
+ cleanup();
1149
+ resolve();
1150
+ };
1151
+ const onRemoved = (removedId) => {
1152
+ if (removedId !== trackId) return;
1153
+ cleanup();
1154
+ reject(new Error(`Track ${trackId} removed before load completed`));
1155
+ };
1156
+ const cleanup = () => {
1157
+ this.off("trackLoaded", onLoaded);
1158
+ this.off("trackRemoved", onRemoved);
1159
+ };
1160
+ this.on("trackLoaded", onLoaded);
1161
+ this.on("trackRemoved", onRemoved);
1162
+ });
1163
+ }
1164
+ getAudioContext() {
1165
+ return this.ctx;
1166
+ }
1167
+ getMasterGainNode() {
1168
+ return this.masterGain;
1169
+ }
1170
+ // ── Track lifecycle ──────────────────────────────────────────────
1171
+ async addTrack(trackData, onProgress) {
1172
+ if (!this.isInitialised) await this.init();
1173
+ this.evictLRUDecodedTracks(this._maxDecodedTracks - 1);
1174
+ let stems;
1175
+ let loadHandle = null;
1176
+ let sourceCacheKey = null;
1177
+ const stemUrls = trackData.stemUrls;
1178
+ try {
1179
+ if (stemUrls) {
1180
+ stems = await loadTrackStemsFromMp3Urls(stemUrls, onProgress);
1181
+ } else {
1182
+ sourceCacheKey = trackData.url;
1183
+ const cached = this.sourceCache.get(sourceCacheKey);
1184
+ if (cached) {
1185
+ cached.refCount++;
1186
+ stems = cached.stems;
1187
+ loadHandle = cached.loadHandle;
1188
+ onProgress?.({ phase: "decoding", percent: 100, detail: "Using shared source decode" });
1189
+ } else {
1190
+ const result = await loadTrackStemsProgressively(
1191
+ trackData.url,
1192
+ trackData.duration,
1193
+ onProgress
1194
+ );
1195
+ stems = result.initial;
1196
+ loadHandle = result.handle;
1197
+ this.sourceCache.set(sourceCacheKey, {
1198
+ url: sourceCacheKey,
1199
+ refCount: 1,
1200
+ stems,
1201
+ loadHandle
1202
+ });
1203
+ }
1204
+ }
1205
+ } catch (err) {
1206
+ console.error("[DAWController] Failed to load stems:", err);
1207
+ return null;
1208
+ }
1209
+ const store = useDAWSessionStore.getState();
1210
+ const dawTrack = store.addTrack(trackData);
1211
+ const trackId = dawTrack.id;
1212
+ const trackGain = this.ctx.createGain();
1213
+ trackGain.connect(this.masterGain);
1214
+ let pitchWorklet = null;
1215
+ if (this._pitchWorkletAvailable) {
1216
+ try {
1217
+ const settings = dawTrack.pitchSettings;
1218
+ if (USE_PHAZE) {
1219
+ pitchWorklet = new AudioWorkletNode(this.ctx, PITCH_WORKLET_NAME, {
1220
+ channelCount: 2,
1221
+ channelCountMode: "explicit",
1222
+ outputChannelCount: [2]
1223
+ });
1224
+ } else {
1225
+ pitchWorklet = new AudioWorkletNode(this.ctx, PITCH_WORKLET_NAME, {
1226
+ numberOfInputs: 1,
1227
+ numberOfOutputs: 1,
1228
+ channelCount: 2,
1229
+ channelCountMode: "explicit",
1230
+ outputChannelCount: [2],
1231
+ processorOptions: {
1232
+ pitchTransitionStep: 1,
1233
+ formantOption: settings.formantOption,
1234
+ pitchOption: settings.pitchOption,
1235
+ engineOption: settings.engineOption,
1236
+ quality: true
1237
+ }
1238
+ });
1239
+ }
1240
+ pitchWorklet.connect(trackGain);
1241
+ this.configurePitchWorklet(pitchWorklet, settings);
1242
+ } catch (err) {
1243
+ console.warn("[DAWController] Failed to create pitch worklet for track:", err);
1244
+ pitchWorklet = null;
1245
+ }
1246
+ }
1247
+ const stemTarget = pitchWorklet ?? trackGain;
1248
+ const worklets = [];
1249
+ const stemGains = [];
1250
+ const stemEffects = [];
1251
+ const freshStore = useDAWSessionStore.getState();
1252
+ const freshTrack = freshStore.getTrackById(trackId);
1253
+ const barMap = buildBarMap(
1254
+ freshTrack.barMapping,
1255
+ freshStore.masterBarLength48000,
1256
+ freshTrack.nativeBarLength,
1257
+ freshStore.barSubdivisions
1258
+ );
1259
+ STEM_LABELS2.forEach((label, stemIdx) => {
1260
+ const stemGain = this.ctx.createGain();
1261
+ stemGain.connect(stemTarget);
1262
+ const fxChain = new StemEffectChain(this.ctx);
1263
+ fxChain.output.connect(stemGain);
1264
+ const worklet = new AudioWorkletNode(this.ctx, "daw-stem-processor", {
1265
+ numberOfInputs: 0,
1266
+ numberOfOutputs: 1,
1267
+ outputChannelCount: [2]
1268
+ });
1269
+ worklet.connect(fxChain.input);
1270
+ const buf = stems[label];
1271
+ if (buf) {
1272
+ const channels = [];
1273
+ for (let ch = 0; ch < buf.numberOfChannels; ch++) {
1274
+ channels.push(new Float32Array(buf.getChannelData(ch)));
1275
+ }
1276
+ worklet.port.postMessage({ type: "load-buffer", channels }, channels.map((c) => c.buffer));
1277
+ }
1278
+ worklet.port.postMessage({ type: "load-bar-map", entries: barMap });
1279
+ worklet.port.postMessage({ type: "set-global-rate", rate: freshStore.globalPlaybackRate });
1280
+ if (stemIdx === 0) {
1281
+ worklet.port.onmessage = (msg) => {
1282
+ const d = msg.data;
1283
+ if (d?.type === "bar-changed") {
1284
+ this.handleWorkletBarChange(trackId, d.barIndex);
1285
+ }
1286
+ };
1287
+ }
1288
+ worklets.push(worklet);
1289
+ stemGains.push(stemGain);
1290
+ stemEffects.push(fxChain);
1291
+ });
1292
+ const barPitchMap = buildBarPitchMap(barMap);
1293
+ const rt = {
1294
+ stems,
1295
+ worklets,
1296
+ stemGains,
1297
+ stemEffects,
1298
+ trackGain,
1299
+ pitchWorklet,
1300
+ barPitchMap,
1301
+ currentPitchBar: -1,
1302
+ autotuneOffset: 0,
1303
+ lastPitchStr: "",
1304
+ loadHandle,
1305
+ sourceCacheKey,
1306
+ unsubscribePatch: null,
1307
+ playable: loadHandle ? false : true,
1308
+ loaded: loadHandle ? false : true,
1309
+ dumped: false,
1310
+ lastAccessAt: performance.now(),
1311
+ trackData
1312
+ };
1313
+ this.tracks.set(trackId, rt);
1314
+ this.syncWorkletPitchMap(trackId);
1315
+ this.syncPitchForTrack(trackId);
1316
+ this.syncGainsForTrack(trackId);
1317
+ if (loadHandle) {
1318
+ const totalSamples = loadHandle.totalSamples;
1319
+ for (const stemKey of ["bass", "drums", "other", "vocals"]) {
1320
+ initMipmapsForTrack(trackId, stemKey, totalSamples);
1321
+ }
1322
+ const unsubscribe = loadHandle.onPatch((patch) => {
1323
+ const rtNow = this.tracks.get(trackId);
1324
+ if (!rtNow) return;
1325
+ if (rtNow.dumped) return;
1326
+ rtNow.lastAccessAt = performance.now();
1327
+ const stemIdx = STEM_LABELS2.indexOf(patch.stem);
1328
+ if (stemIdx < 0) return;
1329
+ const w = rtNow.worklets[stemIdx];
1330
+ const [left, right] = patch.samples;
1331
+ const workletL = new Float32Array(left);
1332
+ const workletR = new Float32Array(right);
1333
+ const mirrorL = new Float32Array(left);
1334
+ const mirrorR = new Float32Array(right);
1335
+ w.port.postMessage(
1336
+ { type: "patch-buffer", sampleOffset: patch.sampleOffset, channels: [workletL, workletR] },
1337
+ [workletL.buffer, workletR.buffer]
1338
+ );
1339
+ const buf = rtNow.stems[patch.stem];
1340
+ if (buf) {
1341
+ const writeStart = patch.sampleOffset;
1342
+ const remaining = buf.length - writeStart;
1343
+ const writeLen = Math.max(0, Math.min(mirrorL.length, remaining));
1344
+ if (writeLen > 0) {
1345
+ const ch0 = buf.getChannelData(0);
1346
+ ch0.set(mirrorL.subarray(0, writeLen), writeStart);
1347
+ if (buf.numberOfChannels > 1) {
1348
+ const ch1 = buf.getChannelData(1);
1349
+ ch1.set(mirrorR.subarray(0, writeLen), writeStart);
1350
+ }
1351
+ }
1352
+ }
1353
+ appendPeaksToMipmapCache(
1354
+ trackId,
1355
+ patch.stem,
1356
+ patch.sampleOffset,
1357
+ mirrorL,
1358
+ mirrorR
1359
+ );
1360
+ });
1361
+ rt.unsubscribePatch = unsubscribe;
1362
+ if (sourceCacheKey && this.sourceCache.get(sourceCacheKey)?.refCount && this.sourceCache.get(sourceCacheKey).refCount > 1) {
1363
+ for (const stemKey of ["bass", "drums", "other", "vocals"]) {
1364
+ const buf = stems[stemKey];
1365
+ if (!buf) continue;
1366
+ const ch0 = new Float32Array(buf.getChannelData(0));
1367
+ const ch1 = buf.numberOfChannels > 1 ? new Float32Array(buf.getChannelData(1)) : ch0;
1368
+ appendPeaksToMipmapCache(trackId, stemKey, 0, ch0, ch1);
1369
+ }
1370
+ }
1371
+ loadHandle.playable.then(() => {
1372
+ const rtNow = this.tracks.get(trackId);
1373
+ if (!rtNow) return;
1374
+ rtNow.playable = true;
1375
+ this.emit("trackPlayable", trackId);
1376
+ }).catch((err) => {
1377
+ console.warn("[DAWController] progressive load failed before playable threshold:", err);
1378
+ });
1379
+ loadHandle.complete.then(() => {
1380
+ const rtNow = this.tracks.get(trackId);
1381
+ if (!rtNow) return;
1382
+ rtNow.loaded = true;
1383
+ this.emit("trackLoaded", trackId);
1384
+ void cacheFullFileForOffline(trackData.url);
1385
+ }).catch((err) => {
1386
+ console.warn("[DAWController] progressive load failed:", err);
1387
+ });
1388
+ }
1389
+ this.emit("trackAdded", trackId);
1390
+ return dawTrack;
1391
+ }
1392
+ // ── Decoded-tracks memory cap (dump / rehydrate) ─────────────────
1393
+ /**
1394
+ * Read the current cap. See `DEFAULT_MAX_DECODED_TRACKS` for context.
1395
+ */
1396
+ get maxDecodedTracks() {
1397
+ return this._maxDecodedTracks;
1398
+ }
1399
+ /**
1400
+ * Update the soft cap on simultaneously-decoded tracks. If the new cap
1401
+ * is lower than the current decoded count, the surplus LRU tracks are
1402
+ * dumped immediately.
1403
+ */
1404
+ setMaxDecodedTracks(n) {
1405
+ const clamped = Math.max(1, Math.floor(n));
1406
+ if (clamped === this._maxDecodedTracks) return;
1407
+ this._maxDecodedTracks = clamped;
1408
+ this.evictLRUDecodedTracks(clamped);
1409
+ this.emit("maxDecodedTracksChanged", clamped);
1410
+ }
1411
+ /** Tracks currently holding their decoded PCM in memory. */
1412
+ decodedTrackCount() {
1413
+ let n = 0;
1414
+ for (const rt of this.tracks.values()) if (!rt.dumped) n++;
1415
+ return n;
1416
+ }
1417
+ /** True if the named track is currently audible (playing into its range). */
1418
+ isTrackAudible(trackId) {
1419
+ const store = useDAWSessionStore.getState();
1420
+ const track = store.getTrackById(trackId);
1421
+ if (!track) return false;
1422
+ const bar = store.playheadBar;
1423
+ for (const ch of track.channels) {
1424
+ const visStart = ch.timelineStartBar + ch.trimStartBar;
1425
+ const visEnd = ch.timelineStartBar + ch.trimEndBar;
1426
+ if (bar >= visStart && bar <= visEnd) return true;
1427
+ }
1428
+ return false;
1429
+ }
1430
+ /**
1431
+ * Free PCM from the least-recently-used undumped tracks until at most
1432
+ * `keep` tracks remain decoded. Audible tracks (containing the playhead)
1433
+ * and currently-transitioning tracks are excluded from eviction so
1434
+ * playback never goes silent unexpectedly.
1435
+ */
1436
+ evictLRUDecodedTracks(keep) {
1437
+ const max = Math.max(0, Math.floor(keep));
1438
+ let decoded = this.decodedTrackCount();
1439
+ if (decoded <= max) return;
1440
+ const candidates = [];
1441
+ for (const [id, rt] of this.tracks) {
1442
+ if (rt.dumped) continue;
1443
+ if (rt.transitioning) continue;
1444
+ if (this.isTrackAudible(id)) continue;
1445
+ candidates.push({ id, rt });
1446
+ }
1447
+ candidates.sort((a, b) => a.rt.lastAccessAt - b.rt.lastAccessAt);
1448
+ while (decoded > max && candidates.length > 0) {
1449
+ const victim = candidates.shift();
1450
+ if (!victim) break;
1451
+ this.dumpTrackAudio(victim.id);
1452
+ decoded--;
1453
+ }
1454
+ if (decoded > max) {
1455
+ console.warn(
1456
+ `[DAWController] decoded-tracks cap exceeded: have ${decoded}, want ${max}, but all extras are audible/transitioning.`
1457
+ );
1458
+ }
1459
+ }
1460
+ /**
1461
+ * Release the decoded PCM held by a track to free memory without
1462
+ * removing the track from the timeline. The worklets, gains, and
1463
+ * effect chain stay wired so rehydration is a single message away.
1464
+ *
1465
+ * - Worklet stem buffers: `dump-buffer` clears the Float32Arrays.
1466
+ * - Main-thread mirror (`rt.stems`): replaced with 1-sample silent
1467
+ * AudioBuffers so legacy consumers (BarSampler, paste-audio) don't
1468
+ * null-deref. Re-allocated to full length on rehydrate.
1469
+ * - Waveform mipmaps: cleared so the timeline shows a flat
1470
+ * centerline until the track is rehydrated.
1471
+ * - In-flight decode: aborted (refcount on shared source decremented).
1472
+ */
1473
+ dumpTrackAudio(trackId) {
1474
+ const rt = this.tracks.get(trackId);
1475
+ if (!rt || rt.dumped) return;
1476
+ rt.transitioning = true;
1477
+ try {
1478
+ try {
1479
+ rt.unsubscribePatch?.();
1480
+ } catch {
1481
+ }
1482
+ rt.unsubscribePatch = null;
1483
+ if (rt.sourceCacheKey) {
1484
+ const entry = this.sourceCache.get(rt.sourceCacheKey);
1485
+ if (entry) {
1486
+ entry.refCount--;
1487
+ if (entry.refCount <= 0) {
1488
+ try {
1489
+ entry.loadHandle?.abort();
1490
+ } catch {
1491
+ }
1492
+ this.sourceCache.delete(rt.sourceCacheKey);
1493
+ }
1494
+ }
1495
+ } else {
1496
+ try {
1497
+ rt.loadHandle?.abort();
1498
+ } catch {
1499
+ }
1500
+ }
1501
+ rt.loadHandle = null;
1502
+ rt.sourceCacheKey = null;
1503
+ for (const w of rt.worklets) {
1504
+ try {
1505
+ w.port.postMessage({ type: "dump-buffer" });
1506
+ } catch {
1507
+ }
1508
+ }
1509
+ for (let i = 0; i < rt.worklets.length; i++) {
1510
+ const key = `${trackId}:${i}`;
1511
+ this._stemActive.set(key, false);
1512
+ }
1513
+ if (this.ctx) {
1514
+ const ctx = this.ctx;
1515
+ const silent = () => ctx.createBuffer(2, 1, SAMPLE_RATE3);
1516
+ rt.stems = {
1517
+ other: silent(),
1518
+ vocals: silent(),
1519
+ bass: silent(),
1520
+ drums: silent()
1521
+ };
1522
+ }
1523
+ clearWaveformCacheForTrack(trackId);
1524
+ rt.dumped = true;
1525
+ rt.playable = false;
1526
+ rt.loaded = false;
1527
+ } finally {
1528
+ rt.transitioning = false;
1529
+ }
1530
+ this.emit("trackDumped", trackId);
1531
+ }
1532
+ /**
1533
+ * Re-decode a previously-dumped track from IndexedDB (the encoded fMP4
1534
+ * stays cached even after dump). Reuses the existing worklet/audio
1535
+ * graph — no node recreation — so rehydration is just a fresh
1536
+ * progressive decode that streams patches back into the same
1537
+ * worklets and mipmap cache.
1538
+ *
1539
+ * Returns true once `loadHandle.playable` resolves (enough audio for
1540
+ * playback to start). Background batches continue filling the buffer.
1541
+ */
1542
+ async rehydrateTrackAudio(trackId) {
1543
+ const rt = this.tracks.get(trackId);
1544
+ if (!rt) return false;
1545
+ if (!rt.dumped || rt.transitioning) return !rt.dumped;
1546
+ if (!this.isInitialised) await this.init();
1547
+ this.evictLRUDecodedTracks(this._maxDecodedTracks - 1);
1548
+ rt.transitioning = true;
1549
+ rt.lastAccessAt = performance.now();
1550
+ try {
1551
+ const stemUrls = rt.trackData.stemUrls;
1552
+ let stems;
1553
+ let loadHandle = null;
1554
+ let sourceCacheKey = null;
1555
+ if (stemUrls) {
1556
+ stems = await loadTrackStemsFromMp3Urls(stemUrls);
1557
+ } else {
1558
+ sourceCacheKey = rt.trackData.url;
1559
+ const cached = this.sourceCache.get(sourceCacheKey);
1560
+ if (cached) {
1561
+ cached.refCount++;
1562
+ stems = cached.stems;
1563
+ loadHandle = cached.loadHandle;
1564
+ } else {
1565
+ const result = await loadTrackStemsProgressively(
1566
+ rt.trackData.url,
1567
+ rt.trackData.duration
1568
+ );
1569
+ stems = result.initial;
1570
+ loadHandle = result.handle;
1571
+ this.sourceCache.set(sourceCacheKey, {
1572
+ url: sourceCacheKey,
1573
+ refCount: 1,
1574
+ stems,
1575
+ loadHandle
1576
+ });
1577
+ }
1578
+ }
1579
+ rt.stems = stems;
1580
+ rt.loadHandle = loadHandle;
1581
+ rt.sourceCacheKey = sourceCacheKey;
1582
+ rt.dumped = false;
1583
+ rt.playable = loadHandle ? false : true;
1584
+ rt.loaded = loadHandle ? false : true;
1585
+ STEM_LABELS2.forEach((label, stemIdx) => {
1586
+ const buf = stems[label];
1587
+ const w = rt.worklets[stemIdx];
1588
+ if (!buf || !w) return;
1589
+ const channels = [];
1590
+ for (let ch = 0; ch < buf.numberOfChannels; ch++) {
1591
+ channels.push(new Float32Array(buf.getChannelData(ch)));
1592
+ }
1593
+ w.port.postMessage({ type: "load-buffer", channels }, channels.map((c) => c.buffer));
1594
+ });
1595
+ if (loadHandle) {
1596
+ const totalSamples = loadHandle.totalSamples;
1597
+ for (const stemKey of ["bass", "drums", "other", "vocals"]) {
1598
+ initMipmapsForTrack(trackId, stemKey, totalSamples);
1599
+ }
1600
+ rt.unsubscribePatch = loadHandle.onPatch((patch) => {
1601
+ const rtNow = this.tracks.get(trackId);
1602
+ if (!rtNow || rtNow.dumped) return;
1603
+ rtNow.lastAccessAt = performance.now();
1604
+ const stemIdx = STEM_LABELS2.indexOf(patch.stem);
1605
+ if (stemIdx < 0) return;
1606
+ const w = rtNow.worklets[stemIdx];
1607
+ const [left, right] = patch.samples;
1608
+ const workletL = new Float32Array(left);
1609
+ const workletR = new Float32Array(right);
1610
+ const mirrorL = new Float32Array(left);
1611
+ const mirrorR = new Float32Array(right);
1612
+ w.port.postMessage(
1613
+ { type: "patch-buffer", sampleOffset: patch.sampleOffset, channels: [workletL, workletR] },
1614
+ [workletL.buffer, workletR.buffer]
1615
+ );
1616
+ const buf = rtNow.stems[patch.stem];
1617
+ if (buf) {
1618
+ const writeStart = patch.sampleOffset;
1619
+ const remaining = buf.length - writeStart;
1620
+ const writeLen = Math.max(0, Math.min(mirrorL.length, remaining));
1621
+ if (writeLen > 0) {
1622
+ const ch0 = buf.getChannelData(0);
1623
+ ch0.set(mirrorL.subarray(0, writeLen), writeStart);
1624
+ if (buf.numberOfChannels > 1) {
1625
+ const ch1 = buf.getChannelData(1);
1626
+ ch1.set(mirrorR.subarray(0, writeLen), writeStart);
1627
+ }
1628
+ }
1629
+ }
1630
+ appendPeaksToMipmapCache(
1631
+ trackId,
1632
+ patch.stem,
1633
+ patch.sampleOffset,
1634
+ mirrorL,
1635
+ mirrorR
1636
+ );
1637
+ });
1638
+ loadHandle.playable.then(() => {
1639
+ const rtNow = this.tracks.get(trackId);
1640
+ if (!rtNow || rtNow.dumped) return;
1641
+ rtNow.playable = true;
1642
+ this.emit("trackPlayable", trackId);
1643
+ }).catch(() => {
1644
+ });
1645
+ loadHandle.complete.then(() => {
1646
+ const rtNow = this.tracks.get(trackId);
1647
+ if (!rtNow || rtNow.dumped) return;
1648
+ rtNow.loaded = true;
1649
+ this.emit("trackLoaded", trackId);
1650
+ }).catch(() => {
1651
+ });
1652
+ }
1653
+ this.emit("trackRehydrated", trackId);
1654
+ return true;
1655
+ } catch (err) {
1656
+ console.error("[DAWController] Failed to rehydrate track:", trackId, err);
1657
+ return false;
1658
+ } finally {
1659
+ rt.transitioning = false;
1660
+ }
1661
+ }
1662
+ /** True if a track's decoded PCM has been freed. */
1663
+ isTrackDumped(trackId) {
1664
+ return !!this.tracks.get(trackId)?.dumped;
1665
+ }
1666
+ removeTrack(trackId) {
1667
+ const rt = this.tracks.get(trackId);
1668
+ if (rt) {
1669
+ try {
1670
+ rt.unsubscribePatch?.();
1671
+ } catch {
1672
+ }
1673
+ if (rt.sourceCacheKey) {
1674
+ const entry = this.sourceCache.get(rt.sourceCacheKey);
1675
+ if (entry) {
1676
+ entry.refCount--;
1677
+ if (entry.refCount <= 0) {
1678
+ try {
1679
+ entry.loadHandle?.abort();
1680
+ } catch {
1681
+ }
1682
+ this.sourceCache.delete(rt.sourceCacheKey);
1683
+ }
1684
+ }
1685
+ } else {
1686
+ try {
1687
+ rt.loadHandle?.abort();
1688
+ } catch {
1689
+ }
1690
+ }
1691
+ rt.worklets.forEach((w) => {
1692
+ w.port.postMessage({ type: "stop" });
1693
+ w.disconnect();
1694
+ });
1695
+ rt.stemEffects.forEach((fx) => fx.dispose());
1696
+ rt.stemGains.forEach((g) => g.disconnect());
1697
+ try {
1698
+ rt.pitchWorklet?.disconnect();
1699
+ } catch {
1700
+ }
1701
+ rt.trackGain.disconnect();
1702
+ }
1703
+ this.tracks.delete(trackId);
1704
+ clearWaveformCacheForTrack(trackId);
1705
+ useDAWSessionStore.getState().removeTrack(trackId);
1706
+ this.emit("trackRemoved", trackId);
1707
+ }
1708
+ getStemBuffers(trackId) {
1709
+ return this.tracks.get(trackId)?.stems;
1710
+ }
1711
+ getStemEffectChain(trackId, stemIndex) {
1712
+ return this.tracks.get(trackId)?.stemEffects[stemIndex] ?? null;
1713
+ }
1714
+ updateStemEffects(trackId, stemIndex, fx) {
1715
+ const chain = this.getStemEffectChain(trackId, stemIndex);
1716
+ if (chain) chain.applyAll(fx);
1717
+ useDAWSessionStore.getState().setChannelEffects(trackId, stemIndex, fx);
1718
+ }
1719
+ // ── Per-bar precise seeking ──────────────────────────────────────
1720
+ /**
1721
+ * Compute the native buffer sample position for a stem at a given
1722
+ * global timeline bar, using the track's barMapping for precision.
1723
+ *
1724
+ * Returns null if the stem is outside its active region.
1725
+ */
1726
+ computeStemSeekSamples(globalBar, channel, track) {
1727
+ const visibleStart = channel.timelineStartBar + channel.trimStartBar;
1728
+ const visibleEnd = channel.timelineStartBar + channel.trimEndBar;
1729
+ if (globalBar < visibleStart || globalBar > visibleEnd) return null;
1730
+ const localBar = globalBar - channel.timelineStartBar;
1731
+ const barMapping = track.barMapping;
1732
+ if (!barMapping || barMapping.length === 0) return null;
1733
+ const barIdx = Math.min(Math.floor(localBar), barMapping.length - 1);
1734
+ const barFrac = localBar - Math.floor(localBar);
1735
+ const bar = barMapping[barIdx];
1736
+ return Math.floor(bar.sampleStart + barFrac * bar.duration);
1737
+ }
1738
+ // ── Transport ────────────────────────────────────────────────────
1739
+ async play() {
1740
+ if (!this.isInitialised) await this.init();
1741
+ if (this.ctx.state !== "running") {
1742
+ console.log("[DAWController.play] AudioContext state:", this.ctx.state, "\u2014 resuming\u2026");
1743
+ await this.ctx.resume();
1744
+ const stateAfterResume = this.ctx.state;
1745
+ if (stateAfterResume !== "running") {
1746
+ await new Promise((resolve) => {
1747
+ const check = () => {
1748
+ if (this.ctx.state === "running") return resolve();
1749
+ this.ctx.addEventListener("statechange", check, { once: true });
1750
+ setTimeout(resolve, 500);
1751
+ };
1752
+ check();
1753
+ });
1754
+ console.log("[DAWController.play] AudioContext state after wait:", this.ctx.state);
1755
+ }
1756
+ }
1757
+ const store = useDAWSessionStore.getState();
1758
+ const globalBar = store.playheadBar;
1759
+ for (const [trackId, rt] of this.tracks) {
1760
+ if (!rt.dumped) continue;
1761
+ if (!this.isTrackAudible(trackId)) continue;
1762
+ rt.lastAccessAt = performance.now();
1763
+ void this.rehydrateTrackAudio(trackId);
1764
+ }
1765
+ let stemsActivated = 0;
1766
+ let stemsPaused = 0;
1767
+ for (const [trackId, rt] of this.tracks) {
1768
+ const track = store.getTrackById(trackId);
1769
+ if (!track) continue;
1770
+ track.channels.forEach((ch, i) => {
1771
+ const key = `${trackId}:${i}`;
1772
+ const seekPos = this.computeStemSeekSamples(globalBar, ch, track);
1773
+ const w = rt.worklets[i];
1774
+ if (seekPos !== null) {
1775
+ w.port.postMessage({ type: "seek", position: seekPos });
1776
+ w.port.postMessage({ type: "play" });
1777
+ this._stemActive.set(key, true);
1778
+ stemsActivated++;
1779
+ rt.lastAccessAt = performance.now();
1780
+ } else {
1781
+ w.port.postMessage({ type: "pause" });
1782
+ this._stemActive.set(key, false);
1783
+ stemsPaused++;
1784
+ }
1785
+ });
1786
+ }
1787
+ if (stemsActivated === 0 && this.tracks.size > 0) {
1788
+ const debugInfo = [];
1789
+ for (const [trackId] of this.tracks) {
1790
+ const t = store.getTrackById(trackId);
1791
+ debugInfo.push({
1792
+ trackId,
1793
+ found: !!t,
1794
+ barMappingLen: t?.barMapping?.length ?? -1,
1795
+ channels: t?.channels.map((ch) => ({
1796
+ timelineStart: ch.timelineStartBar,
1797
+ trimStart: ch.trimStartBar,
1798
+ trimEnd: ch.trimEndBar,
1799
+ visibleStart: ch.timelineStartBar + ch.trimStartBar,
1800
+ visibleEnd: ch.timelineStartBar + ch.trimEndBar
1801
+ }))
1802
+ });
1803
+ }
1804
+ console.warn("[DAWController.play] No stems activated!", {
1805
+ globalBar,
1806
+ trackCount: this.tracks.size,
1807
+ stemsPaused,
1808
+ audioCtxState: this.ctx.state,
1809
+ tracks: debugInfo
1810
+ });
1811
+ }
1812
+ this._playStartCtxTime = this.ctx.currentTime;
1813
+ this._playStartOffset = this.barToSeconds(globalBar);
1814
+ this._playing = true;
1815
+ store.setPlaying(true);
1816
+ if (this._nativeModeTrackId) {
1817
+ const nTrack = store.getTrackById(this._nativeModeTrackId);
1818
+ if (nTrack) {
1819
+ const ch0 = nTrack.channels[0];
1820
+ const seekPos = ch0 ? this.computeStemSeekSamples(globalBar, ch0, nTrack) : null;
1821
+ this._nativeStartSample = seekPos ?? 0;
1822
+ }
1823
+ }
1824
+ for (const [trackId, rt] of this.tracks) {
1825
+ rt.currentPitchBar = -1;
1826
+ rt.lastPitchStr = "";
1827
+ this.sendInitialBarPitch(trackId, globalBar);
1828
+ }
1829
+ this.startPlayheadAnimation();
1830
+ if (this._metronomeOn) this.startMetronomeScheduler();
1831
+ this.emit("playing");
1832
+ }
1833
+ pause() {
1834
+ if (this._playing) {
1835
+ if (this._nativeModeTrackId) {
1836
+ this._nativeStartSample = this.getNativePositionSec() * SAMPLE_RATE3;
1837
+ }
1838
+ const posSec = this.getCurrentPositionSec();
1839
+ useDAWSessionStore.getState().setPlayheadBar(this.secondsToBar(posSec));
1840
+ }
1841
+ for (const [, rt] of this.tracks) {
1842
+ rt.currentPitchBar = -1;
1843
+ rt.lastPitchStr = "";
1844
+ for (const w of rt.worklets) {
1845
+ w.port.postMessage({ type: "pause" });
1846
+ }
1847
+ }
1848
+ this._playing = false;
1849
+ useDAWSessionStore.getState().setPlaying(false);
1850
+ this.stopPlayheadAnimation();
1851
+ this.stopMetronomeScheduler();
1852
+ for (const trackId of this.tracks.keys()) {
1853
+ this.syncPitchForTrack(trackId);
1854
+ }
1855
+ this.emit("paused");
1856
+ }
1857
+ stop() {
1858
+ for (const [, rt] of this.tracks) {
1859
+ for (const w of rt.worklets) {
1860
+ w.port.postMessage({ type: "stop" });
1861
+ }
1862
+ }
1863
+ this._playing = false;
1864
+ const store = useDAWSessionStore.getState();
1865
+ store.setPlaying(false);
1866
+ store.setPlayheadBar(0);
1867
+ this.stopPlayheadAnimation();
1868
+ this.stopMetronomeScheduler();
1869
+ this.emit("stopped");
1870
+ }
1871
+ async seekToBar(bar) {
1872
+ const gen = ++this._seekGen;
1873
+ const wasPlaying = this._playing;
1874
+ const store = useDAWSessionStore.getState();
1875
+ store.setPlayheadBar(bar);
1876
+ if (wasPlaying) {
1877
+ for (const [trackId, rt] of this.tracks) {
1878
+ if (!rt.dumped) continue;
1879
+ if (!this.isTrackAudible(trackId)) continue;
1880
+ rt.lastAccessAt = performance.now();
1881
+ void this.rehydrateTrackAudio(trackId);
1882
+ }
1883
+ }
1884
+ for (const [trackId, rt] of this.tracks) {
1885
+ const track = store.getTrackById(trackId);
1886
+ if (!track) continue;
1887
+ track.channels.forEach((ch, i) => {
1888
+ const key = `${trackId}:${i}`;
1889
+ const seekPos = this.computeStemSeekSamples(bar, ch, track);
1890
+ const w = rt.worklets[i];
1891
+ if (seekPos !== null) {
1892
+ w.port.postMessage({ type: "seek", position: seekPos });
1893
+ if (wasPlaying) w.port.postMessage({ type: "play" });
1894
+ this._stemActive.set(key, wasPlaying);
1895
+ if (wasPlaying) rt.lastAccessAt = performance.now();
1896
+ } else if (wasPlaying) {
1897
+ w.port.postMessage({ type: "pause" });
1898
+ this._stemActive.set(key, false);
1899
+ }
1900
+ });
1901
+ }
1902
+ if (wasPlaying && gen === this._seekGen) {
1903
+ this._playStartCtxTime = this.ctx.currentTime;
1904
+ this._playStartOffset = this.barToSeconds(bar);
1905
+ const seekStep = this._metronomeDownbeatOnly ? 1 : 0.25;
1906
+ this._metronomeNextBeat = Math.ceil(bar / seekStep) * seekStep;
1907
+ }
1908
+ if (this._nativeModeTrackId) {
1909
+ const nTrack = store.getTrackById(this._nativeModeTrackId);
1910
+ if (nTrack) {
1911
+ const ch0 = nTrack.channels[0];
1912
+ const seekPos = ch0 ? this.computeStemSeekSamples(bar, ch0, nTrack) : null;
1913
+ this._nativeStartSample = seekPos ?? 0;
1914
+ }
1915
+ }
1916
+ for (const [trackId, rt] of this.tracks) {
1917
+ rt.currentPitchBar = -1;
1918
+ rt.lastPitchStr = "";
1919
+ this.sendInitialBarPitch(trackId, bar);
1920
+ }
1921
+ }
1922
+ // ── Master tempo / playback rate ────────────────────────────────
1923
+ setMasterTempo(bpm) {
1924
+ const wasPlaying = this._playing;
1925
+ const store = useDAWSessionStore.getState();
1926
+ const currentBar = wasPlaying ? this.secondsToBar(this.getCurrentPositionSec()) : store.playheadBar;
1927
+ store.setMasterTempo(bpm);
1928
+ const newStore = useDAWSessionStore.getState();
1929
+ for (const [trackId, rt] of this.tracks) {
1930
+ const track = newStore.getTrackById(trackId);
1931
+ if (!track) continue;
1932
+ const barMap = buildBarMap(
1933
+ track.barMapping,
1934
+ newStore.masterBarLength48000,
1935
+ track.nativeBarLength,
1936
+ newStore.barSubdivisions
1937
+ );
1938
+ rt.barPitchMap = buildBarPitchMap(barMap);
1939
+ rt.currentPitchBar = -1;
1940
+ this.syncWorkletPitchMap(trackId);
1941
+ track.channels.forEach((ch, i) => {
1942
+ const w = rt.worklets[i];
1943
+ w.port.postMessage({ type: "load-bar-map", entries: barMap });
1944
+ const seekPos = this.computeStemSeekSamples(currentBar, ch, track);
1945
+ if (seekPos !== null) {
1946
+ w.port.postMessage({ type: "seek", position: seekPos });
1947
+ }
1948
+ });
1949
+ }
1950
+ if (wasPlaying) {
1951
+ this._playStartOffset = this.barToSeconds(currentBar);
1952
+ this._playStartCtxTime = this.ctx.currentTime;
1953
+ } else {
1954
+ newStore.setPlayheadBar(currentBar);
1955
+ }
1956
+ this.emit("tempoChanged", bpm);
1957
+ }
1958
+ /**
1959
+ * Reorder a track and, if it becomes the new first track, promote it as master.
1960
+ * Handles worklet bar map / pitch resync the same way setMasterTempo does.
1961
+ */
1962
+ reorderTrack(trackId, newIndex) {
1963
+ const wasPlaying = this._playing;
1964
+ const store = useDAWSessionStore.getState();
1965
+ const currentBar = wasPlaying ? this.secondsToBar(this.getCurrentPositionSec()) : store.playheadBar;
1966
+ const oldMasterId = store.tracks[0]?.id;
1967
+ store.reorderTrack(trackId, newIndex);
1968
+ const newStore = useDAWSessionStore.getState();
1969
+ const newMasterId = newStore.tracks[0]?.id;
1970
+ if (newMasterId !== oldMasterId) {
1971
+ for (const [tid, rt] of this.tracks) {
1972
+ const track = newStore.getTrackById(tid);
1973
+ if (!track) continue;
1974
+ const barMap = buildBarMap(
1975
+ track.barMapping,
1976
+ newStore.masterBarLength48000,
1977
+ track.nativeBarLength,
1978
+ newStore.barSubdivisions
1979
+ );
1980
+ rt.barPitchMap = buildBarPitchMap(barMap);
1981
+ rt.currentPitchBar = -1;
1982
+ this.syncWorkletPitchMap(tid);
1983
+ this.syncPitchForTrack(tid);
1984
+ track.channels.forEach((ch, i) => {
1985
+ const w = rt.worklets[i];
1986
+ w.port.postMessage({ type: "load-bar-map", entries: barMap });
1987
+ const seekPos = this.computeStemSeekSamples(currentBar, ch, track);
1988
+ if (seekPos !== null) {
1989
+ w.port.postMessage({ type: "seek", position: seekPos });
1990
+ }
1991
+ });
1992
+ }
1993
+ if (wasPlaying) {
1994
+ this._playStartOffset = this.barToSeconds(currentBar);
1995
+ this._playStartCtxTime = this.ctx.currentTime;
1996
+ } else {
1997
+ newStore.setPlayheadBar(currentBar);
1998
+ }
1999
+ this.emit("tempoChanged", newStore.masterTempo);
2000
+ }
2001
+ }
2002
+ setGlobalPlaybackRate(rate) {
2003
+ if (this._playing) {
2004
+ const posSec = this.getCurrentPositionSec();
2005
+ useDAWSessionStore.getState().setGlobalPlaybackRate(rate);
2006
+ this._playStartOffset = posSec;
2007
+ this._playStartCtxTime = this.ctx.currentTime;
2008
+ } else {
2009
+ useDAWSessionStore.getState().setGlobalPlaybackRate(rate);
2010
+ }
2011
+ this.broadcastGlobalRate();
2012
+ this.emit("playbackRateChanged", rate);
2013
+ }
2014
+ broadcastGlobalRate() {
2015
+ const rate = useDAWSessionStore.getState().globalPlaybackRate;
2016
+ for (const [, rt] of this.tracks) {
2017
+ for (const w of rt.worklets) {
2018
+ w.port.postMessage({ type: "set-global-rate", rate });
2019
+ }
2020
+ }
2021
+ }
2022
+ // ── Gain / mute / solo sync ──────────────────────────────────────
2023
+ syncGainsForTrack(trackId) {
2024
+ const state = useDAWSessionStore.getState();
2025
+ const track = state.getTrackById(trackId);
2026
+ const rt = this.tracks.get(trackId);
2027
+ if (!track || !rt) return;
2028
+ const hasSoloedTrack = state.tracks.some((t) => t.soloed);
2029
+ const trackAudible = hasSoloedTrack ? track.soloed : !track.muted;
2030
+ rt.trackGain.gain.setValueAtTime(trackAudible ? track.volume : 0, this.ctx.currentTime);
2031
+ const globalSoloedStems = /* @__PURE__ */ new Set();
2032
+ for (const t of state.tracks) {
2033
+ for (const ch of t.channels) {
2034
+ if (ch.soloed && ch.visible) globalSoloedStems.add(ch.stemIndex);
2035
+ }
2036
+ }
2037
+ const hasAnySoloedStem = globalSoloedStems.size > 0;
2038
+ track.channels.forEach((ch, i) => {
2039
+ const audible = ch.visible && (hasAnySoloedStem ? ch.soloed : !ch.muted);
2040
+ rt.stemGains[i]?.gain.setValueAtTime(audible ? ch.volume : 0, this.ctx.currentTime);
2041
+ });
2042
+ }
2043
+ syncAllGains() {
2044
+ for (const trackId of this.tracks.keys()) this.syncGainsForTrack(trackId);
2045
+ }
2046
+ /** Send mute regions for a specific stem to its worklet (bar-based → sample-based) */
2047
+ syncMuteRegions(trackId, stemIndex) {
2048
+ const rt = this.tracks.get(trackId);
2049
+ const track = useDAWSessionStore.getState().getTrackById(trackId);
2050
+ if (!rt || !track) return;
2051
+ const ch = track.channels.find((c) => c.stemIndex === stemIndex);
2052
+ if (!ch) return;
2053
+ const barMapping = track.barMapping;
2054
+ const sampleRegions = ch.muteRegions.map((r) => {
2055
+ const sStart = barToSample(r.startBar, barMapping);
2056
+ const sEnd = barToSample(r.endBar, barMapping);
2057
+ return { sampleStart: sStart, sampleEnd: sEnd };
2058
+ });
2059
+ const w = rt.worklets[stemIndex];
2060
+ if (w) w.port.postMessage({ type: "load-mute-regions", regions: sampleRegions });
2061
+ }
2062
+ /** Sync mute regions for all stems of a track */
2063
+ syncAllMuteRegionsForTrack(trackId) {
2064
+ const track = useDAWSessionStore.getState().getTrackById(trackId);
2065
+ if (!track) return;
2066
+ for (const ch of track.channels) {
2067
+ this.syncMuteRegions(trackId, ch.stemIndex);
2068
+ }
2069
+ }
2070
+ /**
2071
+ * Rebuild and re-send the bar map for a track to all its stem worklets.
2072
+ * Call after the track's barMapping has been modified (e.g. beat grid nudge).
2073
+ */
2074
+ reloadTrackBarMap(trackId) {
2075
+ const rt = this.tracks.get(trackId);
2076
+ if (!rt) return;
2077
+ const store = useDAWSessionStore.getState();
2078
+ const track = store.getTrackById(trackId);
2079
+ if (!track) return;
2080
+ const isNativeTrack = this._nativeModeTrackId === trackId;
2081
+ const barMap = isNativeTrack ? track.barMapping.map((bar) => ({ sampleStart: bar.sampleStart, speed: 1 })) : buildBarMap(
2082
+ track.barMapping,
2083
+ store.masterBarLength48000,
2084
+ track.nativeBarLength,
2085
+ store.barSubdivisions
2086
+ );
2087
+ if (isNativeTrack) {
2088
+ rt.barPitchMap = barMap.map(() => ({ pitchCorrectionSemitones: 0, speed: 1 }));
2089
+ } else {
2090
+ rt.barPitchMap = buildBarPitchMap(barMap);
2091
+ }
2092
+ rt.currentPitchBar = -1;
2093
+ const wasPlaying = this._playing;
2094
+ const currentBar = store.playheadBar;
2095
+ track.channels.forEach((ch, i) => {
2096
+ const w = rt.worklets[i];
2097
+ if (!w) return;
2098
+ w.port.postMessage({ type: "load-bar-map", entries: barMap });
2099
+ const seekPos = this.computeStemSeekSamples(currentBar, ch, track);
2100
+ if (seekPos !== null) {
2101
+ w.port.postMessage({ type: "seek", position: seekPos });
2102
+ if (wasPlaying) w.port.postMessage({ type: "play" });
2103
+ }
2104
+ });
2105
+ if (!isNativeTrack) {
2106
+ this.syncWorkletPitchMap(trackId);
2107
+ this.syncPitchForTrack(trackId);
2108
+ }
2109
+ this.syncAllMuteRegionsForTrack(trackId);
2110
+ if (wasPlaying) {
2111
+ this._playStartOffset = this.barToSeconds(currentBar);
2112
+ this._playStartCtxTime = this.ctx.currentTime;
2113
+ }
2114
+ }
2115
+ // ── Native playback mode (Beat Lab) ────────────────────────────────
2116
+ /**
2117
+ * Enable or disable native playback for a track. When enabled, the track
2118
+ * plays at its original recorded tempo (speed = 1.0 for every bar) with no
2119
+ * pitch compensation. This allows the Beat Lab to display beat markers at
2120
+ * their true sample positions and have them match the audible audio exactly.
2121
+ */
2122
+ setNativePlaybackMode(trackId, enabled) {
2123
+ const rt = this.tracks.get(trackId);
2124
+ if (!rt) return;
2125
+ const store = useDAWSessionStore.getState();
2126
+ const track = store.getTrackById(trackId);
2127
+ if (!track) return;
2128
+ const wasPlaying = this._playing;
2129
+ if (wasPlaying) this.pause();
2130
+ if (enabled) {
2131
+ const quantizedBarMap = buildBarMap(
2132
+ track.barMapping,
2133
+ store.masterBarLength48000,
2134
+ track.nativeBarLength,
2135
+ store.barSubdivisions
2136
+ );
2137
+ this._nativeModeOriginalBarMap = quantizedBarMap;
2138
+ this._nativeModeTrackId = trackId;
2139
+ const nativeBarMap = track.barMapping.map((bar) => ({
2140
+ sampleStart: bar.sampleStart,
2141
+ speed: 1
2142
+ }));
2143
+ for (const w of rt.worklets) {
2144
+ w.port.postMessage({ type: "load-bar-map", entries: nativeBarMap });
2145
+ }
2146
+ rt.barPitchMap = nativeBarMap.map(() => ({ pitchCorrectionSemitones: 0, speed: 1 }));
2147
+ rt.currentPitchBar = -1;
2148
+ if (rt.pitchWorklet) {
2149
+ try {
2150
+ for (const g of rt.stemGains) {
2151
+ g.disconnect();
2152
+ g.connect(rt.trackGain);
2153
+ }
2154
+ } catch (err) {
2155
+ console.warn("[DAWController] Failed to bypass pitch worklet for native mode:", err);
2156
+ }
2157
+ }
2158
+ } else {
2159
+ this._nativeModeTrackId = null;
2160
+ this._nativeModeOriginalBarMap = null;
2161
+ if (rt.pitchWorklet) {
2162
+ try {
2163
+ for (const g of rt.stemGains) {
2164
+ g.disconnect();
2165
+ g.connect(rt.pitchWorklet);
2166
+ }
2167
+ } catch (err) {
2168
+ console.warn("[DAWController] Failed to restore pitch worklet routing:", err);
2169
+ }
2170
+ }
2171
+ const barMap = buildBarMap(
2172
+ track.barMapping,
2173
+ store.masterBarLength48000,
2174
+ track.nativeBarLength,
2175
+ store.barSubdivisions
2176
+ );
2177
+ for (const w of rt.worklets) {
2178
+ w.port.postMessage({ type: "load-bar-map", entries: barMap });
2179
+ }
2180
+ rt.barPitchMap = buildBarPitchMap(barMap);
2181
+ rt.currentPitchBar = -1;
2182
+ this.syncWorkletPitchMap(trackId);
2183
+ this.syncPitchForTrack(trackId);
2184
+ }
2185
+ store.setPlayheadBar(0);
2186
+ }
2187
+ isNativePlaybackMode() {
2188
+ return this._nativeModeTrackId !== null;
2189
+ }
2190
+ get nativeModeTrackId() {
2191
+ return this._nativeModeTrackId;
2192
+ }
2193
+ /**
2194
+ * Get the current playback position in native (unquantized) seconds.
2195
+ * Only meaningful when native playback mode is active.
2196
+ */
2197
+ getNativePositionSec() {
2198
+ if (!this.ctx || !this._nativeModeTrackId) return 0;
2199
+ if (!this._playing) return this._nativeStartSample / SAMPLE_RATE3;
2200
+ const globalRate = useDAWSessionStore.getState().globalPlaybackRate;
2201
+ const elapsedSec = (this.ctx.currentTime - this._playStartCtxTime) * globalRate;
2202
+ return this._nativeStartSample / SAMPLE_RATE3 + elapsedSec;
2203
+ }
2204
+ /**
2205
+ * Seek to a native sample position (used by beat lab scrubbing).
2206
+ * Only works when native playback mode is active.
2207
+ */
2208
+ seekToNativeSample(sample) {
2209
+ if (!this._nativeModeTrackId) return;
2210
+ const rt = this.tracks.get(this._nativeModeTrackId);
2211
+ const store = useDAWSessionStore.getState();
2212
+ const track = store.getTrackById(this._nativeModeTrackId);
2213
+ if (!rt || !track) return;
2214
+ this._nativeStartSample = sample;
2215
+ for (let i = 0; i < rt.worklets.length; i++) {
2216
+ const w = rt.worklets[i];
2217
+ w.port.postMessage({ type: "seek", position: Math.floor(sample) });
2218
+ if (this._playing) w.port.postMessage({ type: "play" });
2219
+ }
2220
+ if (this.ctx) {
2221
+ this._playStartCtxTime = this.ctx.currentTime;
2222
+ this._playStartOffset = sample / SAMPLE_RATE3;
2223
+ }
2224
+ const bm = track.barMapping;
2225
+ if (bm.length > 0) {
2226
+ let barIdx = 0;
2227
+ for (let i = bm.length - 1; i >= 0; i--) {
2228
+ if (bm[i].sampleStart <= sample) {
2229
+ barIdx = i;
2230
+ break;
2231
+ }
2232
+ }
2233
+ const bar = bm[barIdx];
2234
+ const barFrac = bar.duration > 0 ? Math.max(0, Math.min(1, (sample - bar.sampleStart) / bar.duration)) : 0;
2235
+ const tlStart = track.channels[0]?.timelineStartBar ?? 0;
2236
+ store.setPlayheadBar(barIdx + barFrac + tlStart);
2237
+ }
2238
+ }
2239
+ // ── Pitch / transpose ─────────────────────────────────────────────
2240
+ /**
2241
+ * Send the combined pitch factor (correction + transpose) to a track's pitch worklet.
2242
+ * When perBarPitchCompensation is enabled, the per-bar correction is added on top
2243
+ * via updatePerBarPitch() during playback — this method sends the base correction.
2244
+ */
2245
+ syncPitchForTrack(trackId) {
2246
+ const rt = this.tracks.get(trackId);
2247
+ if (!rt?.pitchWorklet) return;
2248
+ const track = useDAWSessionStore.getState().getTrackById(trackId);
2249
+ if (!track) return;
2250
+ if (track.perBarPitchCompensation && this._playing) {
2251
+ return;
2252
+ }
2253
+ const totalSemitones = track.pitchCorrectionSemitones + track.transposeSemitones + rt.autotuneOffset;
2254
+ const factor = semitonesToPitchFactor(totalSemitones);
2255
+ this.sendPitchToWorklet(rt.pitchWorklet, factor);
2256
+ }
2257
+ /**
2258
+ * Check if we've crossed a bar boundary and send an updated pitch factor
2259
+ * that includes the per-bar pitch compensation for that specific bar.
2260
+ */
2261
+ /**
2262
+ * rAF-driven per-bar pitch update (fallback for seeks / playback start).
2263
+ * handleWorkletBarChange is the primary path — this catches missed bars.
2264
+ */
2265
+ updatePerBarPitch(_trackId, _globalBar) {
2266
+ }
2267
+ /** One-shot pitch send for play/seek — sends the bar's pitch and sets
2268
+ * lastPitchStr so the rAF path won't duplicate it. */
2269
+ sendInitialBarPitch(trackId, globalBar) {
2270
+ const rt = this.tracks.get(trackId);
2271
+ if (!rt?.pitchWorklet || !this.ctx) return;
2272
+ const store = useDAWSessionStore.getState();
2273
+ const track = store.getTrackById(trackId);
2274
+ if (!track?.perBarPitchCompensation) return;
2275
+ const ch0 = track.channels[0];
2276
+ if (!ch0) return;
2277
+ const subs = store.barSubdivisions ?? 1;
2278
+ const localBarF = (globalBar - ch0.timelineStartBar) * subs;
2279
+ const localBar = Math.floor(localBarF);
2280
+ if (localBar < 0 || localBar >= rt.barPitchMap.length) return;
2281
+ rt.currentPitchBar = localBar;
2282
+ const barPitch = rt.barPitchMap[localBar];
2283
+ const totalSemitones = barPitch.pitchCorrectionSemitones + track.transposeSemitones + rt.autotuneOffset;
2284
+ const factor = semitonesToPitchFactor(totalSemitones);
2285
+ const pitchStr = factor.toFixed(2);
2286
+ rt.lastPitchStr = pitchStr;
2287
+ this.sendPitchToWorklet(rt.pitchWorklet, factor);
2288
+ }
2289
+ /** Set user transpose in semitones and update the worklet. */
2290
+ setTrackTranspose(trackId, semitones) {
2291
+ useDAWSessionStore.getState().setTrackTranspose(trackId, semitones);
2292
+ this.syncWorkletPitchMap(trackId);
2293
+ this.syncPitchForTrack(trackId);
2294
+ this.emit("transposeChanged", trackId, semitones);
2295
+ }
2296
+ /** Toggle per-bar pitch compensation for a track. */
2297
+ setPerBarPitchCompensation(trackId, enabled) {
2298
+ useDAWSessionStore.getState().setPerBarPitchCompensation(trackId, enabled);
2299
+ const rt = this.tracks.get(trackId);
2300
+ if (rt) {
2301
+ rt.currentPitchBar = -1;
2302
+ rt.lastPitchStr = "";
2303
+ }
2304
+ this.syncWorkletPitchMap(trackId);
2305
+ if (!enabled) {
2306
+ this.syncPitchForTrack(trackId);
2307
+ } else if (this._playing) {
2308
+ const bar = useDAWSessionStore.getState().playheadBar;
2309
+ this.sendInitialBarPitch(trackId, bar);
2310
+ }
2311
+ this.emit("perBarPitchChanged", trackId, enabled);
2312
+ }
2313
+ /**
2314
+ * Send the per-bar pitch map to the first stem worklet so it can apply a
2315
+ * short crossfade at bar boundaries to mask the ~3ms gap between the
2316
+ * speed change (instant, audio-thread) and the pitch change (next render quantum).
2317
+ * Also sends the pitch map so the worklet can forward pitch commands to
2318
+ * Rubberband via the main thread at the exact bar crossing moment.
2319
+ */
2320
+ syncWorkletPitchMap(trackId) {
2321
+ const rt = this.tracks.get(trackId);
2322
+ if (!rt || rt.worklets.length === 0) return;
2323
+ const track = useDAWSessionStore.getState().getTrackById(trackId);
2324
+ if (!track) return;
2325
+ const stemWorklet = rt.worklets[0];
2326
+ if (!track.perBarPitchCompensation || !rt.pitchWorklet) {
2327
+ stemWorklet.port.postMessage({ type: "load-pitch-map", entries: [] });
2328
+ return;
2329
+ }
2330
+ const entries = rt.barPitchMap.map((bp) => ({
2331
+ pitchFactor: semitonesToPitchFactor(
2332
+ bp.pitchCorrectionSemitones + track.transposeSemitones + rt.autotuneOffset
2333
+ )
2334
+ }));
2335
+ stemWorklet.port.postMessage({ type: "load-pitch-map", entries });
2336
+ }
2337
+ /** Update rubberband settings for a track. Requires worklet recreation. */
2338
+ setTrackPitchSettings(trackId, settings) {
2339
+ useDAWSessionStore.getState().setTrackPitchSettings(trackId, settings);
2340
+ const rt = this.tracks.get(trackId);
2341
+ if (!rt?.pitchWorklet || !this.ctx || USE_PHAZE) return;
2342
+ const oldWorklet = rt.pitchWorklet;
2343
+ try {
2344
+ const newWorklet = new AudioWorkletNode(this.ctx, PITCH_WORKLET_NAME, {
2345
+ numberOfInputs: 1,
2346
+ numberOfOutputs: 1,
2347
+ channelCount: 2,
2348
+ channelCountMode: "explicit",
2349
+ outputChannelCount: [2]
2350
+ });
2351
+ this.configurePitchWorklet(newWorklet, settings);
2352
+ newWorklet.connect(rt.trackGain);
2353
+ for (const sg of rt.stemGains) {
2354
+ sg.disconnect();
2355
+ sg.connect(newWorklet);
2356
+ }
2357
+ oldWorklet.disconnect();
2358
+ rt.pitchWorklet = newWorklet;
2359
+ this.syncPitchForTrack(trackId);
2360
+ } catch (err) {
2361
+ console.warn("[DAWController] Failed to recreate pitch worklet:", err);
2362
+ }
2363
+ }
2364
+ /**
2365
+ * Apply an autotune pitch offset on top of the current pitch chain.
2366
+ * Called from the autotune panel at ~60fps during playback.
2367
+ * Set autoSemitones=0 to clear the override.
2368
+ */
2369
+ applyAutotuneCorrection(trackId, autoSemitones) {
2370
+ const rt = this.tracks.get(trackId);
2371
+ if (!rt?.pitchWorklet) return;
2372
+ rt.autotuneOffset = autoSemitones;
2373
+ const track = useDAWSessionStore.getState().getTrackById(trackId);
2374
+ if (!track) return;
2375
+ let baseSemitones = track.transposeSemitones;
2376
+ if (track.perBarPitchCompensation && this._playing && rt.currentPitchBar >= 0 && rt.currentPitchBar < rt.barPitchMap.length) {
2377
+ baseSemitones += rt.barPitchMap[rt.currentPitchBar].pitchCorrectionSemitones;
2378
+ } else {
2379
+ baseSemitones += track.pitchCorrectionSemitones;
2380
+ }
2381
+ const factor = semitonesToPitchFactor(baseSemitones + autoSemitones);
2382
+ this.sendPitchToWorklet(rt.pitchWorklet, factor);
2383
+ }
2384
+ /** Send a pitch factor to the Rubberband pitch worklet. */
2385
+ sendPitchToWorklet(worklet, pitchFactor) {
2386
+ worklet.port.postMessage(JSON.stringify(["pitch", pitchFactor.toFixed(2)]));
2387
+ }
2388
+ /**
2389
+ * Configure a Rubberband pitch worklet via port messages.
2390
+ * The worklet ignores processorOptions — all config must be sent as messages.
2391
+ *
2392
+ * pitchDelayMs controls how many ms the worklet waits after receiving a pitch
2393
+ * command before applying it to Rubberband. This is the single control for
2394
+ * aligning speed changes (instant) with pitch changes (delayed by FFT pipeline).
2395
+ */
2396
+ configurePitchWorklet(worklet, settings) {
2397
+ const delaySamples = Math.round(settings.pitchDelayMs / 1e3 * SAMPLE_RATE3);
2398
+ worklet.port.postMessage(JSON.stringify(["set-pitch-delay", { delaySamples }]));
2399
+ worklet.port.postMessage(JSON.stringify(["pitchTransitionStep", 100]));
2400
+ worklet.port.postMessage(JSON.stringify(["pitchOption", settings.pitchOption]));
2401
+ worklet.port.postMessage(JSON.stringify(["formantOption", settings.formantOption]));
2402
+ worklet.port.postMessage(JSON.stringify(["engineOption", settings.engineOption]));
2403
+ if (!worklet.port.onmessage) {
2404
+ worklet.port.onmessage = (e) => {
2405
+ try {
2406
+ const msg = typeof e.data === "string" ? JSON.parse(e.data) : e.data;
2407
+ if (!msg?.type) return;
2408
+ if (msg.type === "pitch-delayed") {
2409
+ const d = msg.data ?? msg;
2410
+ console.log(
2411
+ `%c[RB Worklet] pitch-delayed target=${d.targetPitch} delay=${d.delaySamples}smp (${(d.delaySamples / SAMPLE_RATE3 * 1e3).toFixed(1)}ms)`,
2412
+ "color: #4fc3f7"
2413
+ );
2414
+ } else if (msg.type === "pitch-applied") {
2415
+ console.log(
2416
+ `%c[RB Worklet] pitch-applied value=${msg.data?.value ?? "?"} @audioTime=${msg.timestamp?.toFixed(3) ?? "?"}`,
2417
+ "color: #4ade80; font-weight: bold"
2418
+ );
2419
+ } else if (msg.type === "pitch-delay-updated" || msg.type === "api-created") {
2420
+ console.log(`%c[RB Worklet] ${msg.type}`, "color: #4fc3f7", msg.data ?? msg);
2421
+ }
2422
+ } catch {
2423
+ }
2424
+ };
2425
+ }
2426
+ console.log(
2427
+ `%c[DAW] Rubberband worklet configured`,
2428
+ "color: #81c784; font-weight: bold",
2429
+ `pitchDelay=${delaySamples} samples (${settings.pitchDelayMs}ms) | ${settings.pitchOption} | ${settings.formantOption} | ${settings.engineOption} | step=100 (instant)`
2430
+ );
2431
+ }
2432
+ get pitchWorkletAvailable() {
2433
+ return this._pitchWorkletAvailable;
2434
+ }
2435
+ get audioContext() {
2436
+ return this.ctx;
2437
+ }
2438
+ /** Get the total semitones currently being applied to a track's pitch worklet. */
2439
+ getCurrentPitchSemitones(trackId) {
2440
+ const rt = this.tracks.get(trackId);
2441
+ if (!rt) return 0;
2442
+ const track = useDAWSessionStore.getState().getTrackById(trackId);
2443
+ if (!track) return 0;
2444
+ if (track.perBarPitchCompensation && rt.currentPitchBar >= 0 && rt.currentPitchBar < rt.barPitchMap.length) {
2445
+ const barPitch = rt.barPitchMap[rt.currentPitchBar];
2446
+ return barPitch.pitchCorrectionSemitones + track.transposeSemitones + rt.autotuneOffset;
2447
+ }
2448
+ return track.pitchCorrectionSemitones + track.transposeSemitones + rt.autotuneOffset;
2449
+ }
2450
+ /**
2451
+ * Get the pitch artifact (semitones) that the current bar's speed change introduces.
2452
+ * This is the inverse of the compensation: if speed > 1, pitch goes up, etc.
2453
+ * Returns 0 if per-bar compensation isn't active or no bar is current.
2454
+ */
2455
+ getCurrentSpeedPitchArtifact(trackId) {
2456
+ const rt = this.tracks.get(trackId);
2457
+ if (!rt || rt.currentPitchBar < 0 || rt.currentPitchBar >= rt.barPitchMap.length) return 0;
2458
+ return -rt.barPitchMap[rt.currentPitchBar].pitchCorrectionSemitones;
2459
+ }
2460
+ /** Update pitchDelayMs for a track and push it to the Rubberband worklet immediately. */
2461
+ setTrackPitchDelay(trackId, ms) {
2462
+ const store = useDAWSessionStore.getState();
2463
+ const track = store.getTrackById(trackId);
2464
+ if (!track) return;
2465
+ const settings = { ...track.pitchSettings ?? DEFAULT_PITCH_SETTINGS, pitchDelayMs: ms };
2466
+ store.setTrackPitchSettings(trackId, settings);
2467
+ const rt = this.tracks.get(trackId);
2468
+ if (rt?.pitchWorklet) {
2469
+ const delaySamples = Math.round(ms / 1e3 * SAMPLE_RATE3);
2470
+ rt.pitchWorklet.port.postMessage(JSON.stringify(["set-pitch-delay", { delaySamples }]));
2471
+ console.log(
2472
+ `%c[DAW] Pitch delay updated`,
2473
+ "color: #81c784; font-weight: bold",
2474
+ `${ms}ms \u2192 ${delaySamples} samples`
2475
+ );
2476
+ }
2477
+ }
2478
+ /** Change bar subdivision and reload all bar maps + pitch maps. */
2479
+ setBarSubdivisions(n) {
2480
+ const store = useDAWSessionStore.getState();
2481
+ store.setBarSubdivisions(n);
2482
+ store.barSubdivisions;
2483
+ for (const [trackId, rt] of this.tracks) {
2484
+ const track = store.getTrackById(trackId);
2485
+ if (!track) continue;
2486
+ const barMap = buildBarMap(
2487
+ track.barMapping,
2488
+ store.masterBarLength48000,
2489
+ track.nativeBarLength,
2490
+ n
2491
+ );
2492
+ rt.barPitchMap = buildBarPitchMap(barMap);
2493
+ rt.currentPitchBar = -1;
2494
+ for (const w of rt.worklets) {
2495
+ w.port.postMessage({ type: "load-bar-map", entries: barMap });
2496
+ }
2497
+ }
2498
+ console.log(
2499
+ `%c[DAW] Bar subdivisions set to ${n}`,
2500
+ "color: #81c784; font-weight: bold",
2501
+ `(${n === 1 ? "bar-level" : n + " sub-bars per bar"})`
2502
+ );
2503
+ }
2504
+ // ── CPU monitoring ──────────────────────────────────────────────
2505
+ /** Start polling AudioContext render capacity (if supported). */
2506
+ startCpuMonitor(intervalMs = 500) {
2507
+ this.stopCpuMonitor();
2508
+ const ac = this.ctx;
2509
+ if (!ac?.renderCapacity) {
2510
+ this._cpuPollId = setInterval(() => {
2511
+ const trackCount = this.tracks.size;
2512
+ const workletCount = trackCount * 4 + (this._pitchWorkletAvailable ? trackCount : 0);
2513
+ this._cpuUsage = Math.min(1, workletCount * 0.03);
2514
+ this.emit("cpuUsage", this._cpuUsage);
2515
+ }, intervalMs);
2516
+ return;
2517
+ }
2518
+ ac.renderCapacity.addEventListener("update", (e) => {
2519
+ this._cpuUsage = e.load ?? e.averageLoad ?? 0;
2520
+ this.emit("cpuUsage", this._cpuUsage);
2521
+ });
2522
+ ac.renderCapacity.start({ updateInterval: intervalMs / 1e3 });
2523
+ }
2524
+ stopCpuMonitor() {
2525
+ if (this._cpuPollId) {
2526
+ clearInterval(this._cpuPollId);
2527
+ this._cpuPollId = null;
2528
+ }
2529
+ const ac = this.ctx;
2530
+ if (ac?.renderCapacity) {
2531
+ try {
2532
+ ac.renderCapacity.stop();
2533
+ } catch {
2534
+ }
2535
+ }
2536
+ }
2537
+ get cpuUsage() {
2538
+ return this._cpuUsage;
2539
+ }
2540
+ // ── Playhead animation ───────────────────────────────────────────
2541
+ startPlayheadAnimation() {
2542
+ this.stopPlayheadAnimation();
2543
+ const animate = () => {
2544
+ if (!this._playing) return;
2545
+ const posSec = this.getCurrentPositionSec();
2546
+ const store = useDAWSessionStore.getState();
2547
+ const currentBar = this.secondsToBar(posSec);
2548
+ store.setPlayheadBar(currentBar);
2549
+ this.updateStemPlayState(currentBar);
2550
+ this.updateAllPerBarPitch(currentBar);
2551
+ if (store.loopRegion && currentBar >= store.loopRegion.endBar) {
2552
+ this.seekToBar(store.loopRegion.startBar);
2553
+ }
2554
+ this.playheadAnimationId = requestAnimationFrame(animate);
2555
+ };
2556
+ this.playheadAnimationId = requestAnimationFrame(animate);
2557
+ }
2558
+ /**
2559
+ * Authoritative pitch update from the worklet's bar-changed message.
2560
+ * Fires at audio-thread timing. The worklet's internal pitchDelayMs
2561
+ * handles the alignment between speed (instant) and pitch (delayed).
2562
+ */
2563
+ handleWorkletBarChange(trackId, localBarIndex) {
2564
+ if (!this._playing) return;
2565
+ const rt = this.tracks.get(trackId);
2566
+ if (!rt?.pitchWorklet) return;
2567
+ const track = useDAWSessionStore.getState().getTrackById(trackId);
2568
+ if (!track?.perBarPitchCompensation) return;
2569
+ if (localBarIndex < 0 || localBarIndex >= rt.barPitchMap.length) return;
2570
+ if (localBarIndex === rt.currentPitchBar) return;
2571
+ rt.currentPitchBar = localBarIndex;
2572
+ const barPitch = rt.barPitchMap[localBarIndex];
2573
+ const totalSemitones = barPitch.pitchCorrectionSemitones + track.transposeSemitones + rt.autotuneOffset;
2574
+ const factor = semitonesToPitchFactor(totalSemitones);
2575
+ const pitchStr = factor.toFixed(2);
2576
+ if (pitchStr === rt.lastPitchStr) return;
2577
+ rt.lastPitchStr = pitchStr;
2578
+ this.sendPitchToWorklet(rt.pitchWorklet, factor);
2579
+ const subs = useDAWSessionStore.getState().barSubdivisions ?? 1;
2580
+ const parentBar = subs > 1 ? Math.floor(localBarIndex / subs) : localBarIndex;
2581
+ const subIdx = subs > 1 ? localBarIndex % subs : 0;
2582
+ const prevSt = rt.lastCorrectionSt ?? barPitch.pitchCorrectionSemitones;
2583
+ const deltaSt = barPitch.pitchCorrectionSemitones - prevSt;
2584
+ rt.lastCorrectionSt = barPitch.pitchCorrectionSemitones;
2585
+ const deltaColor = Math.abs(deltaSt) > 0.5 ? "color: #ef4444; font-weight: bold" : "color: #81c784";
2586
+ console.log(
2587
+ `%c[Bar Pitch] parentBar=${parentBar} sub=${subIdx}/${subs} | idx=${localBarIndex} pitch=${pitchStr} speed=${barPitch.speed.toFixed(4)} | correction=${barPitch.pitchCorrectionSemitones.toFixed(2)}st | \u0394=${deltaSt >= 0 ? "+" : ""}${deltaSt.toFixed(2)}st`,
2588
+ deltaColor
2589
+ );
2590
+ }
2591
+ /** Update per-bar pitch compensation for all tracks that have it enabled. */
2592
+ updateAllPerBarPitch(globalBar) {
2593
+ for (const trackId of this.tracks.keys()) {
2594
+ this.updatePerBarPitch(trackId, globalBar);
2595
+ }
2596
+ }
2597
+ /** Start/stop individual stem worklets as the playhead crosses their timeline boundaries */
2598
+ updateStemPlayState(globalBar) {
2599
+ const store = useDAWSessionStore.getState();
2600
+ for (const [trackId, rt] of this.tracks) {
2601
+ const track = store.getTrackById(trackId);
2602
+ if (!track) continue;
2603
+ let anyShouldBeActive = false;
2604
+ track.channels.forEach((ch, i) => {
2605
+ const key = `${trackId}:${i}`;
2606
+ const seekPos = this.computeStemSeekSamples(globalBar, ch, track);
2607
+ const wasActive = this._stemActive.get(key) ?? false;
2608
+ const shouldBeActive = seekPos !== null;
2609
+ if (shouldBeActive) anyShouldBeActive = true;
2610
+ if (shouldBeActive && !wasActive) {
2611
+ const w = rt.worklets[i];
2612
+ w.port.postMessage({ type: "seek", position: seekPos });
2613
+ w.port.postMessage({ type: "play" });
2614
+ this._stemActive.set(key, true);
2615
+ } else if (!shouldBeActive && wasActive) {
2616
+ const w = rt.worklets[i];
2617
+ w.port.postMessage({ type: "pause" });
2618
+ this._stemActive.set(key, false);
2619
+ }
2620
+ });
2621
+ if (anyShouldBeActive) {
2622
+ rt.lastAccessAt = performance.now();
2623
+ if (rt.dumped && !rt.transitioning) {
2624
+ void this.rehydrateTrackAudio(trackId);
2625
+ }
2626
+ }
2627
+ }
2628
+ }
2629
+ stopPlayheadAnimation() {
2630
+ if (this.playheadAnimationId !== null) {
2631
+ cancelAnimationFrame(this.playheadAnimationId);
2632
+ this.playheadAnimationId = null;
2633
+ }
2634
+ }
2635
+ getCurrentPositionSec() {
2636
+ if (!this.ctx) return 0;
2637
+ const globalRate = useDAWSessionStore.getState().globalPlaybackRate;
2638
+ return this._playStartOffset + (this.ctx.currentTime - this._playStartCtxTime) * globalRate;
2639
+ }
2640
+ // ── Time / bar conversion ────────────────────────────────────────
2641
+ barToSeconds(bar) {
2642
+ const masterBar = useDAWSessionStore.getState().masterBarLength48000;
2643
+ if (!masterBar) return 0;
2644
+ return bar * masterBar / SAMPLE_RATE3;
2645
+ }
2646
+ secondsToBar(sec) {
2647
+ const masterBar = useDAWSessionStore.getState().masterBarLength48000;
2648
+ if (!masterBar) return 0;
2649
+ return sec * SAMPLE_RATE3 / masterBar;
2650
+ }
2651
+ // ── Metronome ─────────────────────────────────────────────────────
2652
+ get metronomeEnabled() {
2653
+ return this._metronomeOn;
2654
+ }
2655
+ get metronomeVolume() {
2656
+ return this._metronomeGain?.gain.value ?? 0.35;
2657
+ }
2658
+ get metronomeDownbeatOnly() {
2659
+ return this._metronomeDownbeatOnly;
2660
+ }
2661
+ setMetronomeDownbeatOnly(downbeatOnly) {
2662
+ this._metronomeDownbeatOnly = downbeatOnly;
2663
+ if (this._metronomeOn && this._playing) {
2664
+ this.startMetronomeScheduler();
2665
+ }
2666
+ }
2667
+ toggleMetronome(on) {
2668
+ this._metronomeOn = on ?? !this._metronomeOn;
2669
+ if (this._metronomeOn && !this._metronomeGain && this.ctx) {
2670
+ this._metronomeGain = this.ctx.createGain();
2671
+ this._metronomeGain.gain.value = 0.35;
2672
+ this._metronomeGain.connect(this.ctx.destination);
2673
+ }
2674
+ if (this._metronomeOn && this._playing) {
2675
+ this.startMetronomeScheduler();
2676
+ } else {
2677
+ this.stopMetronomeScheduler();
2678
+ }
2679
+ this.emit("metronomeChanged", this._metronomeOn);
2680
+ }
2681
+ setMetronomeVolume(volume) {
2682
+ if (this._metronomeGain) {
2683
+ this._metronomeGain.gain.value = Math.max(0, Math.min(1, volume));
2684
+ }
2685
+ this.emit("metronomeChanged", this._metronomeOn);
2686
+ }
2687
+ startMetronomeScheduler() {
2688
+ this.stopMetronomeScheduler();
2689
+ if (!this.ctx || !this._metronomeGain) return;
2690
+ const store = useDAWSessionStore.getState();
2691
+ const currentBar = store.playheadBar;
2692
+ const step = this._metronomeDownbeatOnly ? 1 : 0.25;
2693
+ this._metronomeNextBeat = Math.ceil(currentBar / step) * step;
2694
+ this._metronomeScheduleId = setInterval(() => this.scheduleMetronomeClicks(), METRO_SCHEDULE_INTERVAL_MS);
2695
+ }
2696
+ stopMetronomeScheduler() {
2697
+ if (this._metronomeScheduleId !== null) {
2698
+ clearInterval(this._metronomeScheduleId);
2699
+ this._metronomeScheduleId = null;
2700
+ }
2701
+ }
2702
+ scheduleMetronomeClicks() {
2703
+ if (!this.ctx || !this._metronomeGain || !this._playing) return;
2704
+ const store = useDAWSessionStore.getState();
2705
+ const masterBar = store.masterBarLength48000;
2706
+ if (!masterBar) return;
2707
+ const globalRate = store.globalPlaybackRate;
2708
+ const lookAheadBar = METRO_LOOK_AHEAD_SEC / (masterBar / SAMPLE_RATE3);
2709
+ const currentBar = this.secondsToBar(this.getCurrentPositionSec());
2710
+ const horizonBar = currentBar + lookAheadBar;
2711
+ const step = this._metronomeDownbeatOnly ? 1 : 0.25;
2712
+ while (this._metronomeNextBeat < horizonBar) {
2713
+ const beatBar = this._metronomeNextBeat;
2714
+ if (beatBar >= currentBar) {
2715
+ const deltaSec = (beatBar - currentBar) * masterBar / SAMPLE_RATE3 / globalRate;
2716
+ const when = this.ctx.currentTime + deltaSec;
2717
+ this.playClick(when, Math.abs(beatBar % 1) < 0.01);
2718
+ }
2719
+ this._metronomeNextBeat += step;
2720
+ }
2721
+ }
2722
+ playClick(when, downbeat) {
2723
+ if (!this.ctx || !this._metronomeGain) return;
2724
+ const osc = this.ctx.createOscillator();
2725
+ const env = this.ctx.createGain();
2726
+ osc.type = "sine";
2727
+ osc.frequency.value = downbeat ? 1e3 : 800;
2728
+ env.gain.setValueAtTime(downbeat ? 1 : 0.5, when);
2729
+ env.gain.exponentialRampToValueAtTime(1e-3, when + 0.04);
2730
+ osc.connect(env);
2731
+ env.connect(this._metronomeGain);
2732
+ osc.start(when);
2733
+ osc.stop(when + 0.05);
2734
+ }
2735
+ // ── Copy/paste audio between bar ranges ─────────────────────────
2736
+ /**
2737
+ * Copy audio samples from one bar range and overwrite another bar range
2738
+ * inside a track's stem buffers. Source and destination stems may be the
2739
+ * same (default) or different stems on the same track. After writing, the
2740
+ * modified destination buffer is sent to the worklet so playback reflects
2741
+ * the change immediately.
2742
+ *
2743
+ * Both `sourceStart/End` and `targetStartBar` are in local bars (relative
2744
+ * to the track's barMapping, not the global timeline).
2745
+ *
2746
+ * `targetStemIndex` defaults to `stemIndex` (source) for backwards-compatible
2747
+ * same-stem behaviour.
2748
+ */
2749
+ pasteAudioRegion(trackId, stemIndex, sourceStartBar, sourceEndBar, targetStartBar, targetStemIndex = stemIndex) {
2750
+ const rt = this.tracks.get(trackId);
2751
+ const store = useDAWSessionStore.getState();
2752
+ const track = store.getTrackById(trackId);
2753
+ if (!rt || !track || !this.ctx) {
2754
+ console.warn("[DAW pasteAudio] Missing runtime/track/ctx", { hasRt: !!rt, hasTrack: !!track, hasCtx: !!this.ctx });
2755
+ return;
2756
+ }
2757
+ const stemKeys = ["other", "vocals", "bass", "drums"];
2758
+ const srcKey = stemKeys[stemIndex];
2759
+ const dstKey = stemKeys[targetStemIndex];
2760
+ const srcBuf = rt.stems[srcKey];
2761
+ const dstBuf = rt.stems[dstKey];
2762
+ if (!srcBuf || !dstBuf) {
2763
+ console.warn("[DAW pasteAudio] No buffer for stem", { srcKey, dstKey });
2764
+ return;
2765
+ }
2766
+ const barMapping = track.barMapping;
2767
+ const srcSampleStart = barToSample(sourceStartBar, barMapping);
2768
+ const srcSampleEnd = barToSample(sourceEndBar, barMapping);
2769
+ const srcLen = srcSampleEnd - srcSampleStart;
2770
+ const dstSampleStart = barToSample(targetStartBar, barMapping);
2771
+ const clipLen = sourceEndBar - sourceStartBar;
2772
+ const targetEndBar = Math.min(targetStartBar + clipLen, barMapping.length);
2773
+ const dstSampleEnd = barToSample(targetEndBar, barMapping);
2774
+ const dstLen = dstSampleEnd - dstSampleStart;
2775
+ if (srcLen <= 0) {
2776
+ console.warn("[DAW pasteAudio] srcLen <= 0, aborting");
2777
+ return;
2778
+ }
2779
+ if (dstLen <= 0) {
2780
+ console.warn("[DAW pasteAudio] dstLen <= 0, aborting");
2781
+ return;
2782
+ }
2783
+ const copyLen = Math.min(srcLen, dstLen);
2784
+ const channelCount = Math.min(srcBuf.numberOfChannels, dstBuf.numberOfChannels);
2785
+ for (let ch = 0; ch < channelCount; ch++) {
2786
+ const srcData = srcBuf.getChannelData(ch);
2787
+ const dstData = dstBuf.getChannelData(ch);
2788
+ const srcCopy = srcData.slice(srcSampleStart, srcSampleStart + copyLen);
2789
+ dstData.set(srcCopy, dstSampleStart);
2790
+ if (dstLen > copyLen) {
2791
+ for (let s = dstSampleStart + copyLen; s < dstSampleEnd; s++) {
2792
+ dstData[s] = 0;
2793
+ }
2794
+ }
2795
+ }
2796
+ const w = rt.worklets[targetStemIndex];
2797
+ const channels = [];
2798
+ for (let ch = 0; ch < dstBuf.numberOfChannels; ch++) {
2799
+ channels.push(new Float32Array(dstBuf.getChannelData(ch)));
2800
+ }
2801
+ w.port.postMessage({ type: "load-buffer", channels }, channels.map((c) => c.buffer));
2802
+ const freshStore = useDAWSessionStore.getState();
2803
+ const freshTrack = freshStore.getTrackById(trackId);
2804
+ if (freshTrack) {
2805
+ const barMap = buildBarMap(freshTrack.barMapping, freshStore.masterBarLength48000, freshTrack.nativeBarLength, freshStore.barSubdivisions);
2806
+ w.port.postMessage({ type: "load-bar-map", entries: barMap });
2807
+ rt.barPitchMap = buildBarPitchMap(barMap);
2808
+ rt.currentPitchBar = -1;
2809
+ }
2810
+ this.syncMuteRegions(trackId, targetStemIndex);
2811
+ }
2812
+ // ── Selection-based batch operations ────────────────────────────
2813
+ //
2814
+ // These wrap pasteAudioRegion / mute regions / clipboard with the right
2815
+ // undo/redo semantics so the keyboard handlers and the SelectionActionBar
2816
+ // popover share one implementation.
2817
+ /**
2818
+ * Copy each selection's bar range into the clipboard. No mutation of
2819
+ * the audio buffers — just snapshots the source bar ranges. No undo step
2820
+ * (copy is non-destructive).
2821
+ */
2822
+ copySelections(selections) {
2823
+ const store = useDAWSessionStore.getState();
2824
+ if (selections.length === 0) return;
2825
+ const entries = selections.filter((s) => s.endBar > s.startBar).map((s) => ({
2826
+ trackId: s.trackId,
2827
+ stemIndex: s.stemIndex,
2828
+ sourceStartBar: Math.round(s.startBar),
2829
+ sourceEndBar: Math.round(s.endBar)
2830
+ }));
2831
+ store.setClipboardEntries(entries);
2832
+ }
2833
+ /**
2834
+ * Mute (silence) each selection's bar range. Single undo step covers all
2835
+ * ranges. After muting, clears the selection set.
2836
+ */
2837
+ muteSelections(selections) {
2838
+ if (selections.length === 0) return;
2839
+ const store = useDAWSessionStore.getState();
2840
+ store.pushUndo();
2841
+ const touched = /* @__PURE__ */ new Set();
2842
+ for (const sel of selections) {
2843
+ store.addMuteRegion(sel.trackId, sel.stemIndex, {
2844
+ startBar: Math.round(sel.startBar),
2845
+ endBar: Math.round(sel.endBar)
2846
+ });
2847
+ touched.add(`${sel.trackId}:${sel.stemIndex}`);
2848
+ }
2849
+ for (const key of touched) {
2850
+ const [trackId, idx] = key.split(":");
2851
+ this.syncMuteRegions(trackId, parseInt(idx, 10));
2852
+ }
2853
+ store.clearSelections();
2854
+ }
2855
+ /**
2856
+ * Cut: copy each selection's bar range to clipboard AND mute it. Single
2857
+ * undo step. Clears the selection set after.
2858
+ */
2859
+ cutSelections(selections) {
2860
+ if (selections.length === 0) return;
2861
+ this.copySelections(selections);
2862
+ this.muteSelections(selections);
2863
+ }
2864
+ /**
2865
+ * Paste the current clipboard at a global timeline bar. Each clipboard
2866
+ * entry pastes into its own stemIndex on the focused track. Cross-track
2867
+ * paste is supported as same-stemIndex-different-track when the source
2868
+ * track no longer exists.
2869
+ *
2870
+ * For a SINGLE-entry clipboard, the optional `targetStemIndex` allows
2871
+ * cross-stem paste (e.g. paste bass clip into drums lane).
2872
+ */
2873
+ pasteClipboardAtBar(targetGlobalBar, targetTrackId, targetStemIndex) {
2874
+ const store = useDAWSessionStore.getState();
2875
+ const entries = store.clipboardEntries;
2876
+ if (entries.length === 0) return;
2877
+ const track = store.getTrackById(targetTrackId);
2878
+ if (!track) return;
2879
+ store.pushUndo();
2880
+ const isSingleEntryCrossStem = entries.length === 1 && targetStemIndex !== void 0 && targetStemIndex !== entries[0].stemIndex;
2881
+ for (const cb of entries) {
2882
+ const dstStemIndex = isSingleEntryCrossStem ? targetStemIndex : cb.stemIndex;
2883
+ const ch = store.getChannel(targetTrackId, dstStemIndex);
2884
+ if (!ch) continue;
2885
+ const clipLen = cb.sourceEndBar - cb.sourceStartBar;
2886
+ const maxBar = track.barMapping.length;
2887
+ let localPasteBar = Math.round(targetGlobalBar) - ch.timelineStartBar;
2888
+ if (localPasteBar < 0) {
2889
+ const shift = -localPasteBar;
2890
+ const newTimelineStart = Math.max(0, ch.timelineStartBar - shift);
2891
+ const actualShift = ch.timelineStartBar - newTimelineStart;
2892
+ store.setStemTimelineStart(targetTrackId, dstStemIndex, newTimelineStart);
2893
+ store.setStemTrim(
2894
+ targetTrackId,
2895
+ dstStemIndex,
2896
+ ch.trimStartBar + actualShift,
2897
+ ch.trimEndBar + actualShift
2898
+ );
2899
+ localPasteBar = Math.round(targetGlobalBar) - newTimelineStart;
2900
+ }
2901
+ const pasteEnd = Math.min(localPasteBar + clipLen, maxBar);
2902
+ if (pasteEnd <= localPasteBar || localPasteBar < 0 || localPasteBar >= maxBar) {
2903
+ continue;
2904
+ }
2905
+ const srcTrackId = cb.trackId === targetTrackId ? targetTrackId : targetTrackId;
2906
+ this.pasteAudioRegion(
2907
+ srcTrackId,
2908
+ cb.stemIndex,
2909
+ cb.sourceStartBar,
2910
+ cb.sourceEndBar,
2911
+ localPasteBar,
2912
+ dstStemIndex
2913
+ );
2914
+ const freshCh = store.getChannel(targetTrackId, dstStemIndex);
2915
+ if (!freshCh) continue;
2916
+ const newTrimStart = Math.min(freshCh.trimStartBar, localPasteBar);
2917
+ const newTrimEnd = Math.max(freshCh.trimEndBar, pasteEnd);
2918
+ if (newTrimStart !== freshCh.trimStartBar || newTrimEnd !== freshCh.trimEndBar) {
2919
+ store.setStemTrim(targetTrackId, dstStemIndex, newTrimStart, newTrimEnd);
2920
+ }
2921
+ const updatedCh = store.getChannel(targetTrackId, dstStemIndex);
2922
+ if (updatedCh) {
2923
+ const newMuteRegions = updatedCh.muteRegions.flatMap((r) => {
2924
+ if (r.endBar <= localPasteBar || r.startBar >= pasteEnd) return [r];
2925
+ const parts = [];
2926
+ if (r.startBar < localPasteBar) parts.push({ startBar: r.startBar, endBar: localPasteBar });
2927
+ if (r.endBar > pasteEnd) parts.push({ startBar: pasteEnd, endBar: r.endBar });
2928
+ return parts;
2929
+ });
2930
+ store.setMuteRegionsBatch(targetTrackId, dstStemIndex, newMuteRegions);
2931
+ this.syncMuteRegions(targetTrackId, dstStemIndex);
2932
+ }
2933
+ }
2934
+ const longestClipLen = entries.reduce((m, e) => Math.max(m, e.sourceEndBar - e.sourceStartBar), 0);
2935
+ store.setPlayheadBar(Math.round(targetGlobalBar) + longestClipLen);
2936
+ store.clearSelections();
2937
+ }
2938
+ /**
2939
+ * Duplicate each selection: copy its audio in-buffer to immediately after
2940
+ * the selection end on the same stem. Single undo step.
2941
+ */
2942
+ duplicateSelections(selections) {
2943
+ if (selections.length === 0) return;
2944
+ const store = useDAWSessionStore.getState();
2945
+ store.pushUndo();
2946
+ const newSelections = [];
2947
+ for (const sel of selections) {
2948
+ const track = store.getTrackById(sel.trackId);
2949
+ const ch = store.getChannel(sel.trackId, sel.stemIndex);
2950
+ if (!track || !ch) continue;
2951
+ const start = Math.round(sel.startBar);
2952
+ const end = Math.round(sel.endBar);
2953
+ const len = end - start;
2954
+ if (len <= 0) continue;
2955
+ const maxBar = track.barMapping.length;
2956
+ const dstStart = end;
2957
+ const dstEnd = Math.min(dstStart + len, maxBar);
2958
+ if (dstEnd <= dstStart) continue;
2959
+ this.pasteAudioRegion(sel.trackId, sel.stemIndex, start, end, dstStart, sel.stemIndex);
2960
+ const freshCh = store.getChannel(sel.trackId, sel.stemIndex);
2961
+ if (freshCh && dstEnd > freshCh.trimEndBar) {
2962
+ store.setStemTrim(sel.trackId, sel.stemIndex, freshCh.trimStartBar, dstEnd);
2963
+ }
2964
+ const updatedCh = store.getChannel(sel.trackId, sel.stemIndex);
2965
+ if (updatedCh) {
2966
+ const newMuteRegions = updatedCh.muteRegions.flatMap((r) => {
2967
+ if (r.endBar <= dstStart || r.startBar >= dstEnd) return [r];
2968
+ const parts = [];
2969
+ if (r.startBar < dstStart) parts.push({ startBar: r.startBar, endBar: dstStart });
2970
+ if (r.endBar > dstEnd) parts.push({ startBar: dstEnd, endBar: r.endBar });
2971
+ return parts;
2972
+ });
2973
+ store.setMuteRegionsBatch(sel.trackId, sel.stemIndex, newMuteRegions);
2974
+ this.syncMuteRegions(sel.trackId, sel.stemIndex);
2975
+ }
2976
+ newSelections.push({
2977
+ trackId: sel.trackId,
2978
+ stemIndex: sel.stemIndex,
2979
+ startBar: dstStart,
2980
+ endBar: dstEnd
2981
+ });
2982
+ }
2983
+ if (newSelections.length > 0) {
2984
+ store.setSelections(newSelections);
2985
+ }
2986
+ }
2987
+ // ── Resampled region insertion ──────────────────────────────────
2988
+ /**
2989
+ * Insert a resampled AudioBuffer into a stem, replacing the specified bar range.
2990
+ * Rebuilds the worklet buffer and bar map, and records the resampled region in the store.
2991
+ */
2992
+ insertResampledRegion(trackId, stemIndex, startBar, endBar, resampledBuffer) {
2993
+ const rt = this.tracks.get(trackId);
2994
+ const store = useDAWSessionStore.getState();
2995
+ const track = store.getTrackById(trackId);
2996
+ if (!rt || !track || !this.ctx) return;
2997
+ const stemKeys = ["other", "vocals", "bass", "drums"];
2998
+ const stemKey = stemKeys[stemIndex];
2999
+ const originalBuf = rt.stems[stemKey];
3000
+ if (!originalBuf) return;
3001
+ const barMapping = track.barMapping;
3002
+ const insertSample = barMapping[startBar]?.sampleStart ?? 0;
3003
+ const endSample = endBar < barMapping.length ? barMapping[endBar].sampleStart : (barMapping[barMapping.length - 1]?.sampleStart ?? 0) + (barMapping[barMapping.length - 1]?.duration ?? 0);
3004
+ const replaceLen = endSample - insertSample;
3005
+ const newLen = originalBuf.length - replaceLen + resampledBuffer.length;
3006
+ const newBuf = this.ctx.createBuffer(originalBuf.numberOfChannels, Math.max(newLen, 1), originalBuf.sampleRate);
3007
+ for (let ch = 0; ch < originalBuf.numberOfChannels; ch++) {
3008
+ const src = originalBuf.getChannelData(ch);
3009
+ const dst = newBuf.getChannelData(ch);
3010
+ const rep = resampledBuffer.numberOfChannels > ch ? resampledBuffer.getChannelData(ch) : resampledBuffer.getChannelData(0);
3011
+ dst.set(src.subarray(0, insertSample));
3012
+ dst.set(rep, insertSample);
3013
+ if (endSample < src.length) {
3014
+ dst.set(src.subarray(endSample), insertSample + resampledBuffer.length);
3015
+ }
3016
+ }
3017
+ rt.stems[stemKey] = newBuf;
3018
+ const w = rt.worklets[stemIndex];
3019
+ const channels = [];
3020
+ for (let ch = 0; ch < newBuf.numberOfChannels; ch++) {
3021
+ channels.push(new Float32Array(newBuf.getChannelData(ch)));
3022
+ }
3023
+ w.port.postMessage({ type: "load-buffer", channels }, channels.map((c) => c.buffer));
3024
+ const freshStore = useDAWSessionStore.getState();
3025
+ const freshTrack = freshStore.getTrackById(trackId);
3026
+ if (freshTrack) {
3027
+ const barMap = buildBarMap(freshTrack.barMapping, freshStore.masterBarLength48000, freshTrack.nativeBarLength, freshStore.barSubdivisions);
3028
+ w.port.postMessage({ type: "load-bar-map", entries: barMap });
3029
+ rt.barPitchMap = buildBarPitchMap(barMap);
3030
+ rt.currentPitchBar = -1;
3031
+ }
3032
+ this.syncMuteRegions(trackId, stemIndex);
3033
+ store.addResampledRegion(trackId, stemIndex, {
3034
+ startBar,
3035
+ endBar,
3036
+ sliceCount: 0
3037
+ });
3038
+ this.emit("resampledRegionInserted", trackId, stemIndex);
3039
+ }
3040
+ // ── Cleanup ──────────────────────────────────────────────────────
3041
+ destroy() {
3042
+ this.stopPlayheadAnimation();
3043
+ this.stopMetronomeScheduler();
3044
+ this.stopCpuMonitor();
3045
+ this._metronomeGain?.disconnect();
3046
+ this._metronomeGain = null;
3047
+ for (const [trackId, rt] of this.tracks) {
3048
+ try {
3049
+ rt.unsubscribePatch?.();
3050
+ } catch {
3051
+ }
3052
+ rt.worklets.forEach((w) => {
3053
+ w.port.postMessage({ type: "stop" });
3054
+ w.disconnect();
3055
+ });
3056
+ rt.stemGains.forEach((g) => g.disconnect());
3057
+ try {
3058
+ rt.pitchWorklet?.disconnect();
3059
+ } catch {
3060
+ }
3061
+ rt.trackGain.disconnect();
3062
+ clearWaveformCacheForTrack(trackId);
3063
+ }
3064
+ for (const entry of this.sourceCache.values()) {
3065
+ try {
3066
+ entry.loadHandle?.abort();
3067
+ } catch {
3068
+ }
3069
+ }
3070
+ this.sourceCache.clear();
3071
+ this.tracks.clear();
3072
+ this.masterGain?.disconnect();
3073
+ this.ctx?.close();
3074
+ this.ctx = null;
3075
+ this.masterGain = null;
3076
+ this.isInitialised = false;
3077
+ this._pitchWorkletAvailable = false;
3078
+ this.removeAllListeners();
3079
+ _DAWController.instance = null;
3080
+ }
3081
+ };
3082
+
3083
+ export { DAWController, DEFAULT_EFFECTS, MIPMAP_LEVELS, appendPeaksToMipmapCache, hasActiveEffects, initMipmapsForTrack, invalidateWaveformCache, loadTrackStems, mipmapStore, subscribeMipmapChanges };
3084
+ //# sourceMappingURL=chunk-OYNES5W3.js.map
3085
+ //# sourceMappingURL=chunk-OYNES5W3.js.map