@waveform-playlist/playout 10.2.0 → 10.4.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.
- package/dist/index.d.mts +38 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +211 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +211 -4
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
package/dist/index.d.mts
CHANGED
|
@@ -21,6 +21,12 @@ interface ToneTrackOptions {
|
|
|
21
21
|
effects?: TrackEffectsFunction;
|
|
22
22
|
destination?: ToneAudioNode;
|
|
23
23
|
}
|
|
24
|
+
/** Per-clip scheduling info and audio nodes */
|
|
25
|
+
interface ScheduledClip {
|
|
26
|
+
clipInfo: ClipInfo;
|
|
27
|
+
fadeGainNode: GainNode;
|
|
28
|
+
scheduleId: number;
|
|
29
|
+
}
|
|
24
30
|
declare class ToneTrack {
|
|
25
31
|
private scheduledClips;
|
|
26
32
|
private activeSources;
|
|
@@ -51,6 +57,26 @@ declare class ToneTrack {
|
|
|
51
57
|
* schedule callbacks at exact Transport position (e.g., loopStart).
|
|
52
58
|
*/
|
|
53
59
|
startMidClipSources(transportOffset: number, audioContextTime: number): void;
|
|
60
|
+
/**
|
|
61
|
+
* Add a clip to this track at runtime. Creates a Transport.schedule event
|
|
62
|
+
* and fadeGainNode. If playing, starts the source mid-clip if needed.
|
|
63
|
+
*/
|
|
64
|
+
addClip(clipInfo: ClipInfo): ScheduledClip;
|
|
65
|
+
/**
|
|
66
|
+
* Remove a scheduled clip by index. Clears the Transport event and
|
|
67
|
+
* disconnects the fadeGainNode.
|
|
68
|
+
*/
|
|
69
|
+
removeScheduledClip(index: number): void;
|
|
70
|
+
/**
|
|
71
|
+
* Replace clips on this track. Diffs old vs new by buffer + timing —
|
|
72
|
+
* unchanged clips keep their active sources playing (no audible interruption).
|
|
73
|
+
* Changed/added/removed clips are rescheduled. Disconnecting a removed clip's
|
|
74
|
+
* fadeGainNode silences its source immediately (audio path broken) without
|
|
75
|
+
* needing to explicitly stop it.
|
|
76
|
+
*/
|
|
77
|
+
replaceClips(newClips: ClipInfo[], newStartTime?: number): void;
|
|
78
|
+
/** Compare two clips by reference (buffer), timing, and fade properties */
|
|
79
|
+
private _clipsEqual;
|
|
54
80
|
/**
|
|
55
81
|
* Stop all active AudioBufferSourceNodes and clear the set.
|
|
56
82
|
* Native AudioBufferSourceNodes ignore Transport state changes —
|
|
@@ -425,6 +451,18 @@ declare class TonePlayout {
|
|
|
425
451
|
applyInitialSoloState(): void;
|
|
426
452
|
removeTrack(trackId: string): void;
|
|
427
453
|
getTrack(trackId: string): PlayableTrack | undefined;
|
|
454
|
+
getTrackIds(): string[];
|
|
455
|
+
/**
|
|
456
|
+
* Replace clips on a track, preserving the track's audio graph.
|
|
457
|
+
* Only works for ToneTrack (audio clips), not MidiToneTrack.
|
|
458
|
+
*/
|
|
459
|
+
replaceTrackClips(trackId: string, newClips: ClipInfo[], newStartTime?: number): boolean;
|
|
460
|
+
/**
|
|
461
|
+
* Start mid-clip sources for a specific track at the current Transport position.
|
|
462
|
+
* Call after adding/updating a track during active playback so clips that span
|
|
463
|
+
* the current position produce audio immediately.
|
|
464
|
+
*/
|
|
465
|
+
resumeTrackMidPlayback(trackId: string): void;
|
|
428
466
|
play(when?: number, offset?: number, duration?: number): void;
|
|
429
467
|
pause(): void;
|
|
430
468
|
stop(): void;
|
package/dist/index.d.ts
CHANGED
|
@@ -21,6 +21,12 @@ interface ToneTrackOptions {
|
|
|
21
21
|
effects?: TrackEffectsFunction;
|
|
22
22
|
destination?: ToneAudioNode;
|
|
23
23
|
}
|
|
24
|
+
/** Per-clip scheduling info and audio nodes */
|
|
25
|
+
interface ScheduledClip {
|
|
26
|
+
clipInfo: ClipInfo;
|
|
27
|
+
fadeGainNode: GainNode;
|
|
28
|
+
scheduleId: number;
|
|
29
|
+
}
|
|
24
30
|
declare class ToneTrack {
|
|
25
31
|
private scheduledClips;
|
|
26
32
|
private activeSources;
|
|
@@ -51,6 +57,26 @@ declare class ToneTrack {
|
|
|
51
57
|
* schedule callbacks at exact Transport position (e.g., loopStart).
|
|
52
58
|
*/
|
|
53
59
|
startMidClipSources(transportOffset: number, audioContextTime: number): void;
|
|
60
|
+
/**
|
|
61
|
+
* Add a clip to this track at runtime. Creates a Transport.schedule event
|
|
62
|
+
* and fadeGainNode. If playing, starts the source mid-clip if needed.
|
|
63
|
+
*/
|
|
64
|
+
addClip(clipInfo: ClipInfo): ScheduledClip;
|
|
65
|
+
/**
|
|
66
|
+
* Remove a scheduled clip by index. Clears the Transport event and
|
|
67
|
+
* disconnects the fadeGainNode.
|
|
68
|
+
*/
|
|
69
|
+
removeScheduledClip(index: number): void;
|
|
70
|
+
/**
|
|
71
|
+
* Replace clips on this track. Diffs old vs new by buffer + timing —
|
|
72
|
+
* unchanged clips keep their active sources playing (no audible interruption).
|
|
73
|
+
* Changed/added/removed clips are rescheduled. Disconnecting a removed clip's
|
|
74
|
+
* fadeGainNode silences its source immediately (audio path broken) without
|
|
75
|
+
* needing to explicitly stop it.
|
|
76
|
+
*/
|
|
77
|
+
replaceClips(newClips: ClipInfo[], newStartTime?: number): void;
|
|
78
|
+
/** Compare two clips by reference (buffer), timing, and fade properties */
|
|
79
|
+
private _clipsEqual;
|
|
54
80
|
/**
|
|
55
81
|
* Stop all active AudioBufferSourceNodes and clear the set.
|
|
56
82
|
* Native AudioBufferSourceNodes ignore Transport state changes —
|
|
@@ -425,6 +451,18 @@ declare class TonePlayout {
|
|
|
425
451
|
applyInitialSoloState(): void;
|
|
426
452
|
removeTrack(trackId: string): void;
|
|
427
453
|
getTrack(trackId: string): PlayableTrack | undefined;
|
|
454
|
+
getTrackIds(): string[];
|
|
455
|
+
/**
|
|
456
|
+
* Replace clips on a track, preserving the track's audio graph.
|
|
457
|
+
* Only works for ToneTrack (audio clips), not MidiToneTrack.
|
|
458
|
+
*/
|
|
459
|
+
replaceTrackClips(trackId: string, newClips: ClipInfo[], newStartTime?: number): boolean;
|
|
460
|
+
/**
|
|
461
|
+
* Start mid-clip sources for a specific track at the current Transport position.
|
|
462
|
+
* Call after adding/updating a track during active playback so clips that span
|
|
463
|
+
* the current position produce audio immediately.
|
|
464
|
+
*/
|
|
465
|
+
resumeTrackMidPlayback(trackId: string): void;
|
|
428
466
|
play(when?: number, offset?: number, duration?: number): void;
|
|
429
467
|
pause(): void;
|
|
430
468
|
stop(): void;
|
package/dist/index.js
CHANGED
|
@@ -179,6 +179,112 @@ var ToneTrack = class {
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Add a clip to this track at runtime. Creates a Transport.schedule event
|
|
184
|
+
* and fadeGainNode. If playing, starts the source mid-clip if needed.
|
|
185
|
+
*/
|
|
186
|
+
addClip(clipInfo) {
|
|
187
|
+
const transport = (0, import_tone.getTransport)();
|
|
188
|
+
const rawContext = (0, import_tone.getContext)().rawContext;
|
|
189
|
+
const volumeNativeInput = this.volumeNode.input.input;
|
|
190
|
+
const fadeGainNode = rawContext.createGain();
|
|
191
|
+
fadeGainNode.gain.value = clipInfo.gain;
|
|
192
|
+
fadeGainNode.connect(volumeNativeInput);
|
|
193
|
+
const absTransportTime = this.track.startTime + clipInfo.startTime;
|
|
194
|
+
const scheduleId = transport.schedule((audioContextTime) => {
|
|
195
|
+
if (absTransportTime < this._scheduleGuardOffset) return;
|
|
196
|
+
this.startClipSource(clipInfo, fadeGainNode, audioContextTime);
|
|
197
|
+
}, absTransportTime);
|
|
198
|
+
const scheduled = { clipInfo, fadeGainNode, scheduleId };
|
|
199
|
+
this.scheduledClips.push(scheduled);
|
|
200
|
+
return scheduled;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Remove a scheduled clip by index. Clears the Transport event and
|
|
204
|
+
* disconnects the fadeGainNode.
|
|
205
|
+
*/
|
|
206
|
+
removeScheduledClip(index) {
|
|
207
|
+
const scheduled = this.scheduledClips[index];
|
|
208
|
+
if (!scheduled) return;
|
|
209
|
+
const transport = (0, import_tone.getTransport)();
|
|
210
|
+
try {
|
|
211
|
+
transport.clear(scheduled.scheduleId);
|
|
212
|
+
} catch {
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
scheduled.fadeGainNode.disconnect();
|
|
216
|
+
} catch {
|
|
217
|
+
}
|
|
218
|
+
this.scheduledClips.splice(index, 1);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Replace clips on this track. Diffs old vs new by buffer + timing —
|
|
222
|
+
* unchanged clips keep their active sources playing (no audible interruption).
|
|
223
|
+
* Changed/added/removed clips are rescheduled. Disconnecting a removed clip's
|
|
224
|
+
* fadeGainNode silences its source immediately (audio path broken) without
|
|
225
|
+
* needing to explicitly stop it.
|
|
226
|
+
*/
|
|
227
|
+
replaceClips(newClips, newStartTime) {
|
|
228
|
+
if (newStartTime !== void 0) {
|
|
229
|
+
this.track.startTime = newStartTime;
|
|
230
|
+
}
|
|
231
|
+
const tp = (0, import_tone.getTransport)();
|
|
232
|
+
const kept = [];
|
|
233
|
+
const toAdd = [];
|
|
234
|
+
const matched = /* @__PURE__ */ new Set();
|
|
235
|
+
for (const clipInfo of newClips) {
|
|
236
|
+
const idx = this.scheduledClips.findIndex(
|
|
237
|
+
(s, i) => !matched.has(i) && this._clipsEqual(s.clipInfo, clipInfo)
|
|
238
|
+
);
|
|
239
|
+
if (idx !== -1) {
|
|
240
|
+
kept.push(this.scheduledClips[idx]);
|
|
241
|
+
matched.add(idx);
|
|
242
|
+
} else {
|
|
243
|
+
toAdd.push(clipInfo);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
for (let i = 0; i < this.scheduledClips.length; i++) {
|
|
247
|
+
if (!matched.has(i)) {
|
|
248
|
+
const scheduled = this.scheduledClips[i];
|
|
249
|
+
try {
|
|
250
|
+
tp.clear(scheduled.scheduleId);
|
|
251
|
+
} catch {
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
scheduled.fadeGainNode.disconnect();
|
|
255
|
+
} catch {
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
this.scheduledClips = kept;
|
|
260
|
+
const isPlaying = tp.state === "started";
|
|
261
|
+
for (const clipInfo of toAdd) {
|
|
262
|
+
const scheduled = this.addClip(clipInfo);
|
|
263
|
+
if (isPlaying) {
|
|
264
|
+
const context = (0, import_tone.getContext)();
|
|
265
|
+
const transportOffset = tp.seconds;
|
|
266
|
+
const audioContextTime = context.currentTime;
|
|
267
|
+
const lookAhead = context.lookAhead ?? 0;
|
|
268
|
+
const audibleOffset = Math.max(0, transportOffset - lookAhead);
|
|
269
|
+
const absClipStart = this.track.startTime + clipInfo.startTime;
|
|
270
|
+
const absClipEnd = absClipStart + clipInfo.duration;
|
|
271
|
+
if (absClipStart < transportOffset && absClipEnd > audibleOffset) {
|
|
272
|
+
const elapsed = audibleOffset - absClipStart;
|
|
273
|
+
this.startClipSource(
|
|
274
|
+
clipInfo,
|
|
275
|
+
scheduled.fadeGainNode,
|
|
276
|
+
audioContextTime,
|
|
277
|
+
clipInfo.offset + Math.max(0, elapsed),
|
|
278
|
+
clipInfo.duration - Math.max(0, elapsed)
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/** Compare two clips by reference (buffer), timing, and fade properties */
|
|
285
|
+
_clipsEqual(a, b) {
|
|
286
|
+
return a.buffer === b.buffer && a.startTime === b.startTime && a.duration === b.duration && a.offset === b.offset && a.gain === b.gain && a.fadeIn?.duration === b.fadeIn?.duration && a.fadeIn?.type === b.fadeIn?.type && a.fadeOut?.duration === b.fadeOut?.duration && a.fadeOut?.type === b.fadeOut?.type;
|
|
287
|
+
}
|
|
182
288
|
/**
|
|
183
289
|
* Stop all active AudioBufferSourceNodes and clear the set.
|
|
184
290
|
* Native AudioBufferSourceNodes ignore Transport state changes —
|
|
@@ -1031,6 +1137,34 @@ var TonePlayout = class {
|
|
|
1031
1137
|
getTrack(trackId) {
|
|
1032
1138
|
return this.tracks.get(trackId);
|
|
1033
1139
|
}
|
|
1140
|
+
getTrackIds() {
|
|
1141
|
+
return [...this.tracks.keys()];
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Replace clips on a track, preserving the track's audio graph.
|
|
1145
|
+
* Only works for ToneTrack (audio clips), not MidiToneTrack.
|
|
1146
|
+
*/
|
|
1147
|
+
replaceTrackClips(trackId, newClips, newStartTime) {
|
|
1148
|
+
const track = this.tracks.get(trackId);
|
|
1149
|
+
if (!track || !("replaceClips" in track)) return false;
|
|
1150
|
+
track.replaceClips(newClips, newStartTime);
|
|
1151
|
+
return true;
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Start mid-clip sources for a specific track at the current Transport position.
|
|
1155
|
+
* Call after adding/updating a track during active playback so clips that span
|
|
1156
|
+
* the current position produce audio immediately.
|
|
1157
|
+
*/
|
|
1158
|
+
resumeTrackMidPlayback(trackId) {
|
|
1159
|
+
const track = this.tracks.get(trackId);
|
|
1160
|
+
if (!track) return;
|
|
1161
|
+
const transport = (0, import_tone4.getTransport)();
|
|
1162
|
+
if (transport.state !== "started") return;
|
|
1163
|
+
const context = (0, import_tone4.getContext)();
|
|
1164
|
+
const lookAhead = context.lookAhead ?? 0;
|
|
1165
|
+
const audibleOffset = Math.max(0, transport.seconds - lookAhead);
|
|
1166
|
+
track.startMidClipSources(audibleOffset, context.currentTime);
|
|
1167
|
+
}
|
|
1034
1168
|
play(when, offset, duration) {
|
|
1035
1169
|
if (!this.isInitialized) {
|
|
1036
1170
|
throw new Error("[waveform-playlist] TonePlayout not initialized. Call init() first.");
|
|
@@ -1468,6 +1602,7 @@ function createToneAdapter(options) {
|
|
|
1468
1602
|
let _loopStart = 0;
|
|
1469
1603
|
let _loopEnd = 0;
|
|
1470
1604
|
let _audioInitialized = false;
|
|
1605
|
+
let _pendingInit = null;
|
|
1471
1606
|
function addTrackToPlayout(p, track) {
|
|
1472
1607
|
const audioClips = track.clips.filter((c) => c.audioBuffer && !c.midiNotes);
|
|
1473
1608
|
const midiClips = track.clips.filter((c) => c.midiNotes && c.midiNotes.length > 0);
|
|
@@ -1551,7 +1686,7 @@ function createToneAdapter(options) {
|
|
|
1551
1686
|
try {
|
|
1552
1687
|
playout.dispose();
|
|
1553
1688
|
} catch (err) {
|
|
1554
|
-
console.warn("[waveform-playlist] Error disposing previous playout
|
|
1689
|
+
console.warn("[waveform-playlist] Error disposing previous playout:", err);
|
|
1555
1690
|
}
|
|
1556
1691
|
playout = null;
|
|
1557
1692
|
}
|
|
@@ -1561,9 +1696,9 @@ function createToneAdapter(options) {
|
|
|
1561
1696
|
effects: options?.effects
|
|
1562
1697
|
});
|
|
1563
1698
|
if (_audioInitialized) {
|
|
1564
|
-
playout.init().catch((err) => {
|
|
1699
|
+
_pendingInit = playout.init().catch((err) => {
|
|
1565
1700
|
console.warn(
|
|
1566
|
-
"[waveform-playlist] Failed to
|
|
1701
|
+
"[waveform-playlist] Failed to initialize playout. Audio playback will require another user gesture.",
|
|
1567
1702
|
err
|
|
1568
1703
|
);
|
|
1569
1704
|
_audioInitialized = false;
|
|
@@ -1582,13 +1717,80 @@ function createToneAdapter(options) {
|
|
|
1582
1717
|
}
|
|
1583
1718
|
return {
|
|
1584
1719
|
async init() {
|
|
1720
|
+
if (_pendingInit) {
|
|
1721
|
+
await _pendingInit;
|
|
1722
|
+
_pendingInit = null;
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1585
1725
|
if (playout) {
|
|
1586
1726
|
await playout.init();
|
|
1587
1727
|
_audioInitialized = true;
|
|
1588
1728
|
}
|
|
1589
1729
|
},
|
|
1590
1730
|
setTracks(tracks) {
|
|
1591
|
-
|
|
1731
|
+
if (!playout) {
|
|
1732
|
+
buildPlayout(tracks);
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
const newTrackIds = new Set(tracks.map((t) => t.id));
|
|
1736
|
+
const oldTrackIds = new Set(playout.getTrackIds());
|
|
1737
|
+
for (const id of oldTrackIds) {
|
|
1738
|
+
if (!newTrackIds.has(id)) {
|
|
1739
|
+
playout.removeTrack(id);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
for (const track of tracks) {
|
|
1743
|
+
if (oldTrackIds.has(track.id)) {
|
|
1744
|
+
playout.removeTrack(track.id);
|
|
1745
|
+
playout.removeTrack(track.id + ":midi");
|
|
1746
|
+
}
|
|
1747
|
+
addTrackToPlayout(playout, track);
|
|
1748
|
+
}
|
|
1749
|
+
playout.applyInitialSoloState();
|
|
1750
|
+
if (_isPlaying) {
|
|
1751
|
+
for (const track of tracks) {
|
|
1752
|
+
playout.resumeTrackMidPlayback(track.id);
|
|
1753
|
+
playout.resumeTrackMidPlayback(track.id + ":midi");
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
},
|
|
1757
|
+
updateTrack(trackId, track) {
|
|
1758
|
+
if (!playout) return;
|
|
1759
|
+
const audioClips = track.clips.filter((c) => c.audioBuffer && !c.midiNotes);
|
|
1760
|
+
if (audioClips.length > 0) {
|
|
1761
|
+
const startTime = Math.min(...audioClips.map(import_core2.clipStartTime));
|
|
1762
|
+
const clipInfos = audioClips.map((clip) => ({
|
|
1763
|
+
buffer: clip.audioBuffer,
|
|
1764
|
+
startTime: (0, import_core2.clipStartTime)(clip) - startTime,
|
|
1765
|
+
duration: (0, import_core2.clipDurationTime)(clip),
|
|
1766
|
+
offset: (0, import_core2.clipOffsetTime)(clip),
|
|
1767
|
+
fadeIn: clip.fadeIn,
|
|
1768
|
+
fadeOut: clip.fadeOut,
|
|
1769
|
+
gain: clip.gain
|
|
1770
|
+
}));
|
|
1771
|
+
const audioUpdated = playout.replaceTrackClips(trackId, clipInfos, startTime);
|
|
1772
|
+
const midiClips = track.clips.filter((c) => c.midiNotes && c.midiNotes.length > 0);
|
|
1773
|
+
if (midiClips.length > 0) {
|
|
1774
|
+
const midiTrackId = trackId + ":midi";
|
|
1775
|
+
playout.removeTrack(midiTrackId);
|
|
1776
|
+
addTrackToPlayout(playout, track);
|
|
1777
|
+
if (_isPlaying) {
|
|
1778
|
+
playout.resumeTrackMidPlayback(midiTrackId);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
if (audioUpdated) {
|
|
1782
|
+
playout.applyInitialSoloState();
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
playout.removeTrack(trackId);
|
|
1787
|
+
playout.removeTrack(trackId + ":midi");
|
|
1788
|
+
addTrackToPlayout(playout, track);
|
|
1789
|
+
playout.applyInitialSoloState();
|
|
1790
|
+
if (_isPlaying) {
|
|
1791
|
+
playout.resumeTrackMidPlayback(trackId);
|
|
1792
|
+
playout.resumeTrackMidPlayback(trackId + ":midi");
|
|
1793
|
+
}
|
|
1592
1794
|
},
|
|
1593
1795
|
addTrack(track) {
|
|
1594
1796
|
if (!playout) {
|
|
@@ -1599,6 +1801,11 @@ function createToneAdapter(options) {
|
|
|
1599
1801
|
addTrackToPlayout(playout, track);
|
|
1600
1802
|
playout.applyInitialSoloState();
|
|
1601
1803
|
},
|
|
1804
|
+
removeTrack(trackId) {
|
|
1805
|
+
if (!playout) return;
|
|
1806
|
+
playout.removeTrack(trackId);
|
|
1807
|
+
playout.applyInitialSoloState();
|
|
1808
|
+
},
|
|
1602
1809
|
play(startTime, endTime) {
|
|
1603
1810
|
if (!playout) {
|
|
1604
1811
|
console.warn(
|