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

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,474 @@
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
+ // Render regions
113
+ if (track.regions) {
114
+ track.regions.forEach((params) => {
115
+ const region = wsRegions.add(params.startTime || 0, params.endTime, params.label, params.color);
116
+ if (!params.startTime) {
117
+ region.element.firstElementChild?.remove();
118
+ }
119
+ });
120
+ }
121
+ // Render markers
122
+ if (track.markers) {
123
+ track.markers.forEach((marker) => {
124
+ wsRegions.add(marker.time, marker.time, marker.label, marker.color);
125
+ });
126
+ }
127
+ }));
128
+ // Envelope
129
+ const envelope = ws.registerPlugin(EnvelopePlugin, {
130
+ ...this.options.envelopeOptions,
131
+ startTime: track.startCue,
132
+ endTime: track.endCue,
133
+ fadeInEnd: track.fadeInEnd,
134
+ fadeOutStart: track.fadeOutStart,
135
+ volume: track.volume,
136
+ });
137
+ this.subscriptions.push(envelope.on('volume-change', ({ volume }) => {
138
+ this.setIsDragging();
139
+ this.emit('volume-change', { id: track.id, volume });
140
+ }), envelope.on('fade-in-change', ({ time }) => {
141
+ this.setIsDragging();
142
+ this.emit('fade-in-change', { id: track.id, fadeInEnd: time });
143
+ }), envelope.on('fade-out-change', ({ time }) => {
144
+ this.setIsDragging();
145
+ this.emit('fade-out-change', { id: track.id, fadeOutStart: time });
146
+ }));
147
+ return ws;
148
+ }
149
+ initAllWavesurfers() {
150
+ const wavesurfers = this.tracks.map((track, index) => {
151
+ return this.initWavesurfer(track, index);
152
+ });
153
+ this.wavesurfers = wavesurfers;
154
+ this.initTimeline();
155
+ }
156
+ initTimeline() {
157
+ if (this.timeline)
158
+ this.timeline.destroy();
159
+ this.timeline = this.wavesurfers[0].registerPlugin(TimelinePlugin, {
160
+ duration: this.maxDuration,
161
+ container: this.rendering.containers[0].parentElement,
162
+ });
163
+ }
164
+ updatePosition(time, autoCenter = false) {
165
+ const precisionSeconds = 0.3;
166
+ this.currentTime = time;
167
+ this.rendering.updateCursor(time / this.maxDuration, autoCenter);
168
+ const isPaused = !this.isPlaying();
169
+ // Update the current time of each audio
170
+ this.tracks.forEach((track, index) => {
171
+ const audio = this.audios[index];
172
+ const duration = this.durations[index];
173
+ const newTime = time - track.startPosition;
174
+ if (Math.abs(audio.currentTime - newTime) > precisionSeconds) {
175
+ audio.currentTime = newTime;
176
+ }
177
+ // If the position is out of the track bounds, pause it
178
+ if (isPaused || newTime < 0 || newTime > duration) {
179
+ !audio.paused && audio.pause();
180
+ }
181
+ else if (!isPaused) {
182
+ // If the position is in the track bounds, play it
183
+ audio.paused && audio.play();
184
+ }
185
+ // Unmute if cue is reached
186
+ const newVolume = newTime >= (track.startCue || 0) && newTime < (track.endCue || Infinity) ? 1 : 0;
187
+ if (newVolume !== audio.volume)
188
+ audio.volume = newVolume;
189
+ });
190
+ }
191
+ setIsDragging() {
192
+ // Prevent click events when dragging
193
+ this.isDragging = true;
194
+ if (this.timer)
195
+ clearTimeout(this.timer);
196
+ this.timer = setTimeout(() => (this.isDragging = false), 300);
197
+ }
198
+ onDrag(index, delta) {
199
+ this.setIsDragging();
200
+ const newTime = this.tracks[index].startPosition + delta * this.maxDuration;
201
+ this.onMove(index, newTime);
202
+ }
203
+ onMove(index, newStartPosition) {
204
+ const track = this.tracks[index];
205
+ if (!track.draggable)
206
+ return;
207
+ const mainIndex = this.tracks.findIndex((item) => item.url && !item.draggable);
208
+ const mainTrack = this.tracks[mainIndex];
209
+ const minStart = (mainTrack ? mainTrack.startPosition : 0) - this.durations[index];
210
+ const maxStart = mainTrack ? mainTrack.startPosition + this.durations[mainIndex] : this.maxDuration;
211
+ if (newStartPosition >= minStart && newStartPosition <= maxStart) {
212
+ track.startPosition = newStartPosition;
213
+ this.rendering.setContainerOffsets();
214
+ this.updatePosition(this.currentTime);
215
+ this.emit('start-position-change', { id: track.id, startPosition: newStartPosition });
216
+ }
217
+ }
218
+ findCurrentTracks() {
219
+ // Find the audios at the current time
220
+ const indexes = [];
221
+ this.tracks.forEach((track, index) => {
222
+ if (track.url &&
223
+ this.currentTime >= track.startPosition &&
224
+ this.currentTime < track.startPosition + this.durations[index]) {
225
+ indexes.push(index);
226
+ }
227
+ });
228
+ if (indexes.length === 0) {
229
+ const minStartTime = Math.min(...this.tracks.filter((t) => t.url).map((track) => track.startPosition));
230
+ indexes.push(this.tracks.findIndex((track) => track.startPosition === minStartTime));
231
+ }
232
+ return indexes;
233
+ }
234
+ startSync() {
235
+ const onFrame = () => {
236
+ const position = this.audios.reduce((pos, audio, index) => {
237
+ if (!audio.paused) {
238
+ pos = Math.max(pos, audio.currentTime + this.tracks[index].startPosition);
239
+ }
240
+ return pos;
241
+ }, this.currentTime);
242
+ if (position > this.currentTime) {
243
+ this.updatePosition(position, true);
244
+ }
245
+ this.frameRequest = requestAnimationFrame(onFrame);
246
+ };
247
+ onFrame();
248
+ }
249
+ play() {
250
+ this.startSync();
251
+ const indexes = this.findCurrentTracks();
252
+ indexes.forEach((index) => {
253
+ this.audios[index]?.play();
254
+ });
255
+ }
256
+ pause() {
257
+ this.audios.forEach((audio) => audio.pause());
258
+ }
259
+ isPlaying() {
260
+ return this.audios.some((audio) => !audio.paused);
261
+ }
262
+ getCurrentTime() {
263
+ return this.currentTime;
264
+ }
265
+ seekTo(time) {
266
+ const wasPlaying = this.isPlaying();
267
+ this.updatePosition(time);
268
+ if (wasPlaying)
269
+ this.play();
270
+ }
271
+ zoom(pxPerSec) {
272
+ this.options.minPxPerSec = pxPerSec;
273
+ this.wavesurfers.forEach((ws, index) => this.tracks[index].url && ws.zoom(pxPerSec));
274
+ this.rendering.setMainWidth(this.durations, this.maxDuration);
275
+ this.rendering.setContainerOffsets();
276
+ }
277
+ addTrack(track) {
278
+ const index = this.tracks.findIndex((t) => t.id === track.id);
279
+ if (index !== -1) {
280
+ this.tracks[index] = track;
281
+ this.initAudio(track).then((audio) => {
282
+ this.audios[index] = audio;
283
+ this.durations[index] = audio.duration;
284
+ this.initDurations(this.durations);
285
+ const container = this.rendering.containers[index];
286
+ container.innerHTML = '';
287
+ this.wavesurfers[index].destroy();
288
+ this.wavesurfers[index] = this.initWavesurfer(track, index);
289
+ const drag = initDragging(container, (delta) => this.onDrag(index, delta), this.options.rightButtonDrag);
290
+ this.wavesurfers[index].once('destroy', () => drag?.destroy());
291
+ this.initTimeline();
292
+ this.emit('canplay');
293
+ });
294
+ }
295
+ }
296
+ destroy() {
297
+ if (this.frameRequest)
298
+ cancelAnimationFrame(this.frameRequest);
299
+ this.rendering.destroy();
300
+ this.audios.forEach((audio) => {
301
+ audio.pause();
302
+ audio.src = '';
303
+ });
304
+ this.wavesurfers.forEach((ws) => {
305
+ ws.destroy();
306
+ });
307
+ }
308
+ // See https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId
309
+ setSinkId(sinkId) {
310
+ return Promise.all(this.audios.map((item) => {
311
+ const audio = item;
312
+ return audio.setSinkId ? audio.setSinkId(sinkId) : Promise.resolve();
313
+ }));
314
+ }
315
+ }
316
+ function initRendering(tracks, options) {
317
+ let pxPerSec = 0;
318
+ let durations = [];
319
+ let mainWidth = 0;
320
+ // Create a common container for all tracks
321
+ const scroll = document.createElement('div');
322
+ scroll.setAttribute('style', 'width: 100%; overflow-x: scroll; overflow-y: hidden; user-select: none;');
323
+ const wrapper = document.createElement('div');
324
+ wrapper.style.position = 'relative';
325
+ scroll.appendChild(wrapper);
326
+ options.container.appendChild(scroll);
327
+ // Create a common cursor
328
+ const cursor = document.createElement('div');
329
+ cursor.setAttribute('style', 'height: 100%; position: absolute; z-index: 10; top: 0; left: 0');
330
+ cursor.style.backgroundColor = options.cursorColor || '#000';
331
+ cursor.style.width = `${options.cursorWidth ?? 1}px`;
332
+ wrapper.appendChild(cursor);
333
+ const { clientWidth } = wrapper;
334
+ // Create containers for each track
335
+ const containers = tracks.map((track, index) => {
336
+ const container = document.createElement('div');
337
+ container.style.position = 'relative';
338
+ if (options.trackBorderColor && index > 0) {
339
+ const borderDiv = document.createElement('div');
340
+ borderDiv.setAttribute('style', `width: 100%; height: 2px; background-color: ${options.trackBorderColor}`);
341
+ wrapper.appendChild(borderDiv);
342
+ }
343
+ if (options.trackBackground && track.url) {
344
+ container.style.background = options.trackBackground;
345
+ }
346
+ // No audio on this track, so make it droppable
347
+ if (!track.url) {
348
+ const dropArea = document.createElement('div');
349
+ dropArea.setAttribute('style', `position: absolute; z-index: 10; left: 10px; top: 10px; right: 10px; bottom: 10px; border: 2px dashed ${options.trackBorderColor};`);
350
+ dropArea.addEventListener('dragover', (e) => {
351
+ e.preventDefault();
352
+ dropArea.style.background = options.trackBackground || '';
353
+ });
354
+ dropArea.addEventListener('dragleave', (e) => {
355
+ e.preventDefault();
356
+ dropArea.style.background = '';
357
+ });
358
+ dropArea.addEventListener('drop', (e) => {
359
+ e.preventDefault();
360
+ dropArea.style.background = '';
361
+ });
362
+ container.appendChild(dropArea);
363
+ }
364
+ wrapper.appendChild(container);
365
+ return container;
366
+ });
367
+ // Set the positions of each container
368
+ const setContainerOffsets = () => {
369
+ containers.forEach((container, i) => {
370
+ const offset = tracks[i].startPosition * pxPerSec;
371
+ if (durations[i]) {
372
+ container.style.width = `${durations[i] * pxPerSec}px`;
373
+ }
374
+ container.style.transform = `translateX(${offset}px)`;
375
+ });
376
+ };
377
+ return {
378
+ containers,
379
+ // Set the start offset
380
+ setContainerOffsets,
381
+ // Set the container width
382
+ setMainWidth: (trackDurations, maxDuration) => {
383
+ durations = trackDurations;
384
+ pxPerSec = Math.max(options.minPxPerSec || 0, clientWidth / maxDuration);
385
+ mainWidth = pxPerSec * maxDuration;
386
+ wrapper.style.width = `${mainWidth}px`;
387
+ setContainerOffsets();
388
+ },
389
+ // Update cursor position
390
+ updateCursor: (position, autoCenter) => {
391
+ cursor.style.left = `${Math.min(100, position * 100)}%`;
392
+ // Update scroll
393
+ const { clientWidth, scrollLeft } = scroll;
394
+ const center = clientWidth / 2;
395
+ const minScroll = autoCenter ? center : clientWidth;
396
+ const pos = position * mainWidth;
397
+ if (pos > scrollLeft + minScroll || pos < scrollLeft) {
398
+ scroll.scrollLeft = pos - center;
399
+ }
400
+ },
401
+ // Click to seek
402
+ addClickHandler: (onClick) => {
403
+ wrapper.addEventListener('click', (e) => {
404
+ const rect = wrapper.getBoundingClientRect();
405
+ const x = e.clientX - rect.left;
406
+ const position = x / wrapper.offsetWidth;
407
+ onClick(position);
408
+ });
409
+ },
410
+ // Destroy the container
411
+ destroy: () => {
412
+ scroll.remove();
413
+ },
414
+ // Do something on drop
415
+ addDropHandler: (onDrop) => {
416
+ tracks.forEach((track, index) => {
417
+ if (!track.url) {
418
+ const droppable = containers[index].querySelector('div');
419
+ droppable?.addEventListener('drop', (e) => {
420
+ e.preventDefault();
421
+ onDrop(track.id);
422
+ });
423
+ }
424
+ });
425
+ },
426
+ };
427
+ }
428
+ function initDragging(container, onDrag, rightButtonDrag = false) {
429
+ const wrapper = container.parentElement;
430
+ if (!wrapper)
431
+ return;
432
+ // Dragging tracks to set position
433
+ let dragStart = null;
434
+ container.addEventListener('contextmenu', (e) => {
435
+ rightButtonDrag && e.preventDefault();
436
+ });
437
+ // Drag start
438
+ container.addEventListener('mousedown', (e) => {
439
+ if (rightButtonDrag && e.button !== 2)
440
+ return;
441
+ const rect = wrapper.getBoundingClientRect();
442
+ dragStart = e.clientX - rect.left;
443
+ container.style.cursor = 'grabbing';
444
+ });
445
+ // Drag end
446
+ const onMouseUp = (e) => {
447
+ if (dragStart != null) {
448
+ e.stopPropagation();
449
+ dragStart = null;
450
+ container.style.cursor = '';
451
+ }
452
+ };
453
+ // Drag move
454
+ const onMouseMove = (e) => {
455
+ if (dragStart == null)
456
+ return;
457
+ const rect = wrapper.getBoundingClientRect();
458
+ const x = e.clientX - rect.left;
459
+ const diff = x - dragStart;
460
+ if (diff > 1 || diff < -1) {
461
+ dragStart = x;
462
+ onDrag(diff / wrapper.offsetWidth);
463
+ }
464
+ };
465
+ document.body.addEventListener('mouseup', onMouseUp);
466
+ document.body.addEventListener('mousemove', onMouseMove);
467
+ return {
468
+ destroy: () => {
469
+ document.body.removeEventListener('mouseup', onMouseUp);
470
+ document.body.removeEventListener('mousemove', onMouseMove);
471
+ },
472
+ };
473
+ }
474
+ export default MultiTrack;
@@ -1,5 +1,10 @@
1
1
  import BasePlugin from '../base-plugin.js';
2
- import { WaveSurferPluginParams } from '../index.js';
2
+ import type { WaveSurferPluginParams } from '../index.js';
3
+ export type RegionsPluginOptions = {
4
+ dragSelection?: boolean;
5
+ draggable?: boolean;
6
+ resizable?: boolean;
7
+ };
3
8
  type Region = {
4
9
  startTime: number;
5
10
  endTime: number;
@@ -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
  }