wavesurfer.js 7.0.0-alpha.2 → 7.0.0-alpha.21

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.
@@ -0,0 +1,489 @@
1
+ import WaveSurfer from '../index.js';
2
+ import RegionsPlugin from './regions.js';
3
+ import TimelinePlugin from './timeline.js';
4
+ import EnvelopePlugin from './envelope.js';
5
+ import EventEmitter from '../event-emitter.js';
6
+ class MultiTrack extends EventEmitter {
7
+ tracks;
8
+ options;
9
+ audios = [];
10
+ wavesurfers = [];
11
+ durations = [];
12
+ currentTime = 0;
13
+ maxDuration = 0;
14
+ rendering;
15
+ isDragging = false;
16
+ frameRequest = null;
17
+ timer = null;
18
+ subscriptions = [];
19
+ timeline = null;
20
+ static create(tracks, options) {
21
+ return new MultiTrack(tracks, options);
22
+ }
23
+ constructor(tracks, options) {
24
+ super();
25
+ this.tracks = tracks.map((track) => ({
26
+ ...track,
27
+ startPosition: track.startPosition || 0,
28
+ peaks: track.peaks || (track.url ? undefined : [new Float32Array()]),
29
+ }));
30
+ this.options = options;
31
+ this.rendering = initRendering(this.tracks, this.options);
32
+ this.rendering.addDropHandler((trackId) => {
33
+ this.emit('drop', { id: trackId });
34
+ });
35
+ this.initAllAudios().then((durations) => {
36
+ this.initDurations(durations);
37
+ this.initAllWavesurfers();
38
+ this.rendering.containers.forEach((container, index) => {
39
+ const drag = initDragging(container, (delta) => this.onDrag(index, delta), options.rightButtonDrag);
40
+ this.wavesurfers[index].once('destroy', () => drag?.destroy());
41
+ });
42
+ this.emit('canplay');
43
+ });
44
+ }
45
+ initDurations(durations) {
46
+ this.durations = durations;
47
+ this.maxDuration = this.tracks.reduce((max, track, index) => {
48
+ return Math.max(max, track.startPosition + durations[index]);
49
+ }, 0);
50
+ this.rendering.setMainWidth(durations, this.maxDuration);
51
+ this.rendering.addClickHandler((position) => {
52
+ if (this.isDragging)
53
+ return;
54
+ this.seekTo(position * this.maxDuration);
55
+ });
56
+ }
57
+ initAudio(track) {
58
+ const audio = new Audio(track.url);
59
+ return new Promise((resolve) => {
60
+ if (!audio.src)
61
+ return resolve(audio);
62
+ audio.addEventListener('loadedmetadata', () => resolve(audio), { once: true });
63
+ });
64
+ }
65
+ async initAllAudios() {
66
+ this.audios = await Promise.all(this.tracks.map((track) => this.initAudio(track)));
67
+ return this.audios.map((a) => (a.src ? a.duration : 0));
68
+ }
69
+ initWavesurfer(track, index) {
70
+ const container = this.rendering.containers[index];
71
+ // Create a wavesurfer instance
72
+ const ws = WaveSurfer.create({
73
+ ...track.options,
74
+ container,
75
+ minPxPerSec: 0,
76
+ media: this.audios[index],
77
+ peaks: track.peaks,
78
+ cursorColor: 'transparent',
79
+ cursorWidth: 0,
80
+ interactive: false,
81
+ });
82
+ // Regions and markers
83
+ const wsRegions = ws.registerPlugin(RegionsPlugin, {
84
+ draggable: false,
85
+ resizable: true,
86
+ dragSelection: false,
87
+ });
88
+ this.subscriptions.push(ws.once('decode', () => {
89
+ // Start and end cues
90
+ if (track.startCue != null || track.endCue != null) {
91
+ const { startCue = 0, endCue = this.durations[index] } = track;
92
+ const startCueRegion = wsRegions.add(0, startCue, '', 'rgba(0, 0, 0, 0.7)');
93
+ const endCueRegion = wsRegions.add(endCue, endCue + this.durations[index], '', 'rgba(0, 0, 0, 0.7)');
94
+ // Allow resizing only from one side
95
+ startCueRegion.element.firstElementChild?.remove();
96
+ endCueRegion.element.lastChild?.remove();
97
+ // Update the start and end cues on resize
98
+ this.subscriptions.push(wsRegions.on('region-updated', ({ region }) => {
99
+ this.setIsDragging();
100
+ if (region === startCueRegion || region === endCueRegion) {
101
+ if (region === startCueRegion) {
102
+ track.startCue = region.endTime;
103
+ this.emit('start-cue-change', { id: track.id, startCue: track.startCue });
104
+ }
105
+ else {
106
+ track.endCue = region.startTime;
107
+ this.emit('end-cue-change', { id: track.id, endCue: track.endCue });
108
+ }
109
+ }
110
+ }));
111
+ }
112
+ // Intro
113
+ if (track.intro) {
114
+ const introRegion = wsRegions.add(0, track.intro.endTime, track.intro.label, this.options.trackBackground);
115
+ introRegion.element.firstElementChild?.remove();
116
+ introRegion.element.style.backgroundColor = this.options.trackBackground || 'transparent';
117
+ introRegion.element.parentElement.style.mixBlendMode = 'plus-lighter';
118
+ if (track.intro.color) {
119
+ ;
120
+ introRegion.element.lastElementChild.style.borderColor = track.intro.color;
121
+ }
122
+ this.subscriptions.push(wsRegions.on('region-updated', ({ region }) => {
123
+ if (region === introRegion) {
124
+ this.emit('intro-end-change', { id: track.id, endTime: region.endTime });
125
+ }
126
+ }));
127
+ }
128
+ // Render markers
129
+ if (track.markers) {
130
+ track.markers.forEach((marker) => {
131
+ wsRegions.add(marker.time, marker.time, marker.label, marker.color);
132
+ });
133
+ }
134
+ }));
135
+ // Envelope
136
+ const envelope = ws.registerPlugin(EnvelopePlugin, {
137
+ ...this.options.envelopeOptions,
138
+ startTime: track.startCue,
139
+ endTime: track.endCue,
140
+ fadeInEnd: track.fadeInEnd,
141
+ fadeOutStart: track.fadeOutStart,
142
+ volume: track.volume,
143
+ });
144
+ this.subscriptions.push(envelope.on('volume-change', ({ volume }) => {
145
+ this.setIsDragging();
146
+ this.emit('volume-change', { id: track.id, volume });
147
+ }), envelope.on('fade-in-change', ({ time }) => {
148
+ this.setIsDragging();
149
+ this.emit('fade-in-change', { id: track.id, fadeInEnd: time });
150
+ }), envelope.on('fade-out-change', ({ time }) => {
151
+ this.setIsDragging();
152
+ this.emit('fade-out-change', { id: track.id, fadeOutStart: time });
153
+ }), this.on('start-cue-change', ({ id, startCue }) => {
154
+ if (id === track.id) {
155
+ envelope.setStartTime(startCue);
156
+ }
157
+ }), this.on('end-cue-change', ({ id, endCue }) => {
158
+ if (id === track.id) {
159
+ envelope.setEndTime(endCue);
160
+ }
161
+ }));
162
+ return ws;
163
+ }
164
+ initAllWavesurfers() {
165
+ const wavesurfers = this.tracks.map((track, index) => {
166
+ return this.initWavesurfer(track, index);
167
+ });
168
+ this.wavesurfers = wavesurfers;
169
+ this.initTimeline();
170
+ }
171
+ initTimeline() {
172
+ if (this.timeline)
173
+ this.timeline.destroy();
174
+ this.timeline = this.wavesurfers[0].registerPlugin(TimelinePlugin, {
175
+ duration: this.maxDuration,
176
+ container: this.rendering.containers[0].parentElement,
177
+ });
178
+ }
179
+ updatePosition(time, autoCenter = false) {
180
+ const precisionSeconds = 0.3;
181
+ this.currentTime = time;
182
+ this.rendering.updateCursor(time / this.maxDuration, autoCenter);
183
+ const isPaused = !this.isPlaying();
184
+ // Update the current time of each audio
185
+ this.tracks.forEach((track, index) => {
186
+ const audio = this.audios[index];
187
+ const duration = this.durations[index];
188
+ const newTime = time - track.startPosition;
189
+ if (Math.abs(audio.currentTime - newTime) > precisionSeconds) {
190
+ audio.currentTime = newTime;
191
+ }
192
+ // If the position is out of the track bounds, pause it
193
+ if (isPaused || newTime < 0 || newTime > duration) {
194
+ !audio.paused && audio.pause();
195
+ }
196
+ else if (!isPaused) {
197
+ // If the position is in the track bounds, play it
198
+ audio.paused && audio.play();
199
+ }
200
+ // Unmute if cue is reached
201
+ const newVolume = newTime >= (track.startCue || 0) && newTime < (track.endCue || Infinity) ? 1 : 0;
202
+ if (newVolume !== audio.volume)
203
+ audio.volume = newVolume;
204
+ });
205
+ }
206
+ setIsDragging() {
207
+ // Prevent click events when dragging
208
+ this.isDragging = true;
209
+ if (this.timer)
210
+ clearTimeout(this.timer);
211
+ this.timer = setTimeout(() => (this.isDragging = false), 300);
212
+ }
213
+ onDrag(index, delta) {
214
+ this.setIsDragging();
215
+ const newTime = this.tracks[index].startPosition + delta * this.maxDuration;
216
+ this.onMove(index, newTime);
217
+ }
218
+ onMove(index, newStartPosition) {
219
+ const track = this.tracks[index];
220
+ if (!track.draggable)
221
+ return;
222
+ const mainIndex = this.tracks.findIndex((item) => item.url && !item.draggable);
223
+ const mainTrack = this.tracks[mainIndex];
224
+ const minStart = (mainTrack ? mainTrack.startPosition : 0) - this.durations[index];
225
+ const maxStart = mainTrack ? mainTrack.startPosition + this.durations[mainIndex] : this.maxDuration;
226
+ if (newStartPosition >= minStart && newStartPosition <= maxStart) {
227
+ track.startPosition = newStartPosition;
228
+ this.rendering.setContainerOffsets();
229
+ this.updatePosition(this.currentTime);
230
+ this.emit('start-position-change', { id: track.id, startPosition: newStartPosition });
231
+ }
232
+ }
233
+ findCurrentTracks() {
234
+ // Find the audios at the current time
235
+ const indexes = [];
236
+ this.tracks.forEach((track, index) => {
237
+ if (track.url &&
238
+ this.currentTime >= track.startPosition &&
239
+ this.currentTime < track.startPosition + this.durations[index]) {
240
+ indexes.push(index);
241
+ }
242
+ });
243
+ if (indexes.length === 0) {
244
+ const minStartTime = Math.min(...this.tracks.filter((t) => t.url).map((track) => track.startPosition));
245
+ indexes.push(this.tracks.findIndex((track) => track.startPosition === minStartTime));
246
+ }
247
+ return indexes;
248
+ }
249
+ startSync() {
250
+ const onFrame = () => {
251
+ const position = this.audios.reduce((pos, audio, index) => {
252
+ if (!audio.paused) {
253
+ pos = Math.max(pos, audio.currentTime + this.tracks[index].startPosition);
254
+ }
255
+ return pos;
256
+ }, this.currentTime);
257
+ if (position > this.currentTime) {
258
+ this.updatePosition(position, true);
259
+ }
260
+ this.frameRequest = requestAnimationFrame(onFrame);
261
+ };
262
+ onFrame();
263
+ }
264
+ play() {
265
+ this.startSync();
266
+ const indexes = this.findCurrentTracks();
267
+ indexes.forEach((index) => {
268
+ this.audios[index]?.play();
269
+ });
270
+ }
271
+ pause() {
272
+ this.audios.forEach((audio) => audio.pause());
273
+ }
274
+ isPlaying() {
275
+ return this.audios.some((audio) => !audio.paused);
276
+ }
277
+ getCurrentTime() {
278
+ return this.currentTime;
279
+ }
280
+ seekTo(time) {
281
+ const wasPlaying = this.isPlaying();
282
+ this.updatePosition(time);
283
+ if (wasPlaying)
284
+ this.play();
285
+ }
286
+ zoom(pxPerSec) {
287
+ this.options.minPxPerSec = pxPerSec;
288
+ this.wavesurfers.forEach((ws, index) => this.tracks[index].url && ws.zoom(pxPerSec));
289
+ this.rendering.setMainWidth(this.durations, this.maxDuration);
290
+ this.rendering.setContainerOffsets();
291
+ }
292
+ addTrack(track) {
293
+ const index = this.tracks.findIndex((t) => t.id === track.id);
294
+ if (index !== -1) {
295
+ this.tracks[index] = track;
296
+ this.initAudio(track).then((audio) => {
297
+ this.audios[index] = audio;
298
+ this.durations[index] = audio.duration;
299
+ this.initDurations(this.durations);
300
+ const container = this.rendering.containers[index];
301
+ container.innerHTML = '';
302
+ this.wavesurfers[index].destroy();
303
+ this.wavesurfers[index] = this.initWavesurfer(track, index);
304
+ const drag = initDragging(container, (delta) => this.onDrag(index, delta), this.options.rightButtonDrag);
305
+ this.wavesurfers[index].once('destroy', () => drag?.destroy());
306
+ this.initTimeline();
307
+ this.emit('canplay');
308
+ });
309
+ }
310
+ }
311
+ destroy() {
312
+ if (this.frameRequest)
313
+ cancelAnimationFrame(this.frameRequest);
314
+ this.rendering.destroy();
315
+ this.audios.forEach((audio) => {
316
+ audio.pause();
317
+ audio.src = '';
318
+ });
319
+ this.wavesurfers.forEach((ws) => {
320
+ ws.destroy();
321
+ });
322
+ }
323
+ // See https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId
324
+ setSinkId(sinkId) {
325
+ return Promise.all(this.audios.map((item) => {
326
+ const audio = item;
327
+ return audio.setSinkId ? audio.setSinkId(sinkId) : Promise.resolve();
328
+ }));
329
+ }
330
+ }
331
+ function initRendering(tracks, options) {
332
+ let pxPerSec = 0;
333
+ let durations = [];
334
+ let mainWidth = 0;
335
+ // Create a common container for all tracks
336
+ const scroll = document.createElement('div');
337
+ scroll.setAttribute('style', 'width: 100%; overflow-x: scroll; overflow-y: hidden; user-select: none;');
338
+ const wrapper = document.createElement('div');
339
+ wrapper.style.position = 'relative';
340
+ scroll.appendChild(wrapper);
341
+ options.container.appendChild(scroll);
342
+ // Create a common cursor
343
+ const cursor = document.createElement('div');
344
+ cursor.setAttribute('style', 'height: 100%; position: absolute; z-index: 10; top: 0; left: 0');
345
+ cursor.style.backgroundColor = options.cursorColor || '#000';
346
+ cursor.style.width = `${options.cursorWidth ?? 1}px`;
347
+ wrapper.appendChild(cursor);
348
+ const { clientWidth } = wrapper;
349
+ // Create containers for each track
350
+ const containers = tracks.map((track, index) => {
351
+ const container = document.createElement('div');
352
+ container.style.position = 'relative';
353
+ if (options.trackBorderColor && index > 0) {
354
+ const borderDiv = document.createElement('div');
355
+ borderDiv.setAttribute('style', `width: 100%; height: 2px; background-color: ${options.trackBorderColor}`);
356
+ wrapper.appendChild(borderDiv);
357
+ }
358
+ if (options.trackBackground && track.url) {
359
+ container.style.background = options.trackBackground;
360
+ }
361
+ // No audio on this track, so make it droppable
362
+ if (!track.url) {
363
+ const dropArea = document.createElement('div');
364
+ dropArea.setAttribute('style', `position: absolute; z-index: 10; left: 10px; top: 10px; right: 10px; bottom: 10px; border: 2px dashed ${options.trackBorderColor};`);
365
+ dropArea.addEventListener('dragover', (e) => {
366
+ e.preventDefault();
367
+ dropArea.style.background = options.trackBackground || '';
368
+ });
369
+ dropArea.addEventListener('dragleave', (e) => {
370
+ e.preventDefault();
371
+ dropArea.style.background = '';
372
+ });
373
+ dropArea.addEventListener('drop', (e) => {
374
+ e.preventDefault();
375
+ dropArea.style.background = '';
376
+ });
377
+ container.appendChild(dropArea);
378
+ }
379
+ wrapper.appendChild(container);
380
+ return container;
381
+ });
382
+ // Set the positions of each container
383
+ const setContainerOffsets = () => {
384
+ containers.forEach((container, i) => {
385
+ const offset = tracks[i].startPosition * pxPerSec;
386
+ if (durations[i]) {
387
+ container.style.width = `${durations[i] * pxPerSec}px`;
388
+ }
389
+ container.style.transform = `translateX(${offset}px)`;
390
+ });
391
+ };
392
+ return {
393
+ containers,
394
+ // Set the start offset
395
+ setContainerOffsets,
396
+ // Set the container width
397
+ setMainWidth: (trackDurations, maxDuration) => {
398
+ durations = trackDurations;
399
+ pxPerSec = Math.max(options.minPxPerSec || 0, clientWidth / maxDuration);
400
+ mainWidth = pxPerSec * maxDuration;
401
+ wrapper.style.width = `${mainWidth}px`;
402
+ setContainerOffsets();
403
+ },
404
+ // Update cursor position
405
+ updateCursor: (position, autoCenter) => {
406
+ cursor.style.left = `${Math.min(100, position * 100)}%`;
407
+ // Update scroll
408
+ const { clientWidth, scrollLeft } = scroll;
409
+ const center = clientWidth / 2;
410
+ const minScroll = autoCenter ? center : clientWidth;
411
+ const pos = position * mainWidth;
412
+ if (pos > scrollLeft + minScroll || pos < scrollLeft) {
413
+ scroll.scrollLeft = pos - center;
414
+ }
415
+ },
416
+ // Click to seek
417
+ addClickHandler: (onClick) => {
418
+ wrapper.addEventListener('click', (e) => {
419
+ const rect = wrapper.getBoundingClientRect();
420
+ const x = e.clientX - rect.left;
421
+ const position = x / wrapper.offsetWidth;
422
+ onClick(position);
423
+ });
424
+ },
425
+ // Destroy the container
426
+ destroy: () => {
427
+ scroll.remove();
428
+ },
429
+ // Do something on drop
430
+ addDropHandler: (onDrop) => {
431
+ tracks.forEach((track, index) => {
432
+ if (!track.url) {
433
+ const droppable = containers[index].querySelector('div');
434
+ droppable?.addEventListener('drop', (e) => {
435
+ e.preventDefault();
436
+ onDrop(track.id);
437
+ });
438
+ }
439
+ });
440
+ },
441
+ };
442
+ }
443
+ function initDragging(container, onDrag, rightButtonDrag = false) {
444
+ const wrapper = container.parentElement;
445
+ if (!wrapper)
446
+ return;
447
+ // Dragging tracks to set position
448
+ let dragStart = null;
449
+ container.addEventListener('contextmenu', (e) => {
450
+ rightButtonDrag && e.preventDefault();
451
+ });
452
+ // Drag start
453
+ container.addEventListener('mousedown', (e) => {
454
+ if (rightButtonDrag && e.button !== 2)
455
+ return;
456
+ const rect = wrapper.getBoundingClientRect();
457
+ dragStart = e.clientX - rect.left;
458
+ container.style.cursor = 'grabbing';
459
+ });
460
+ // Drag end
461
+ const onMouseUp = (e) => {
462
+ if (dragStart != null) {
463
+ e.stopPropagation();
464
+ dragStart = null;
465
+ container.style.cursor = '';
466
+ }
467
+ };
468
+ // Drag move
469
+ const onMouseMove = (e) => {
470
+ if (dragStart == null)
471
+ return;
472
+ const rect = wrapper.getBoundingClientRect();
473
+ const x = e.clientX - rect.left;
474
+ const diff = x - dragStart;
475
+ if (diff > 1 || diff < -1) {
476
+ dragStart = x;
477
+ onDrag(diff / wrapper.offsetWidth);
478
+ }
479
+ };
480
+ document.body.addEventListener('mouseup', onMouseUp);
481
+ document.body.addEventListener('mousemove', onMouseMove);
482
+ return {
483
+ destroy: () => {
484
+ document.body.removeEventListener('mouseup', onMouseUp);
485
+ document.body.removeEventListener('mousemove', onMouseMove);
486
+ },
487
+ };
488
+ }
489
+ export default MultiTrack;
@@ -1,6 +1,11 @@
1
1
  import BasePlugin from '../base-plugin.js';
2
- import { WaveSurferPluginParams } from '../index.js';
3
- type Region = {
2
+ import type { WaveSurferPluginParams } from '../index.js';
3
+ export type RegionsPluginOptions = {
4
+ dragSelection?: boolean;
5
+ draggable?: boolean;
6
+ resizable?: boolean;
7
+ };
8
+ export type Region = {
4
9
  startTime: number;
5
10
  endTime: number;
6
11
  title: string;
@@ -8,7 +13,7 @@ type Region = {
8
13
  end: number;
9
14
  element: HTMLElement;
10
15
  };
11
- type RegionsPluginEvents = {
16
+ export type RegionsPluginEvents = {
12
17
  'region-created': {
13
18
  region: Region;
14
19
  };
@@ -19,19 +24,19 @@ type RegionsPluginEvents = {
19
24
  region: Region;
20
25
  };
21
26
  };
22
- declare class RegionsPlugin extends BasePlugin<RegionsPluginEvents> {
27
+ declare class RegionsPlugin extends BasePlugin<RegionsPluginEvents, RegionsPluginOptions> {
23
28
  private dragStart;
24
- private container;
29
+ private regionsContainer;
25
30
  private regions;
26
31
  private createdRegion;
27
32
  private modifiedRegion;
28
33
  private isResizingLeft;
29
34
  private isMoving;
30
35
  /** Create an instance of RegionsPlugin */
31
- constructor(params: WaveSurferPluginParams);
32
- /** Unmounts the regions */
36
+ constructor(params: WaveSurferPluginParams, options: RegionsPluginOptions);
37
+ /** Unmount */
33
38
  destroy(): void;
34
- private initContainer;
39
+ private initRegionsContainer;
35
40
  private handleMouseDown;
36
41
  private handleMouseMove;
37
42
  private handleMouseUp;
@@ -41,7 +46,7 @@ declare class RegionsPlugin extends BasePlugin<RegionsPluginEvents> {
41
46
  private updateRegion;
42
47
  private moveRegion;
43
48
  /** Create a region at a given start and end time, with an optional title */
44
- addRegionAtTime(startTime: number, endTime: number, title?: string): Region;
49
+ add(startTime: number, endTime: number, title?: string, color?: string): Region;
45
50
  /** Set the background color of a region */
46
51
  setRegionColor(region: Region, color: string): void;
47
52
  }