forked from katspaugh/wavesurfer.js
-
Notifications
You must be signed in to change notification settings - Fork 0
/
wavesurfer.ts
391 lines (338 loc) · 11.3 KB
/
wavesurfer.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
import type { GenericPlugin } from './base-plugin.js'
import Decoder from './decoder.js'
import Fetcher from './fetcher.js'
import Player from './player.js'
import Renderer from './renderer.js'
import Timer from './timer.js'
export type WaveSurferOptions = {
/** HTML element or CSS selector */
container: HTMLElement | string
/** The height of the waveform in pixels, or "auto" to fill the container height */
height?: number | 'auto'
/** The color of the waveform */
waveColor?: string | string[] | CanvasGradient
/** The color of the progress mask */
progressColor?: string | string[] | CanvasGradient
/** The color of the playpack cursor */
cursorColor?: string
/** The cursor width */
cursorWidth?: number
/** Render the waveform with bars like this: ▁ ▂ ▇ ▃ ▅ ▂ */
barWidth?: number
/** Spacing between bars in pixels */
barGap?: number
/** Rounded borders for bars */
barRadius?: number
/** A vertical scaling factor for the waveform */
barHeight?: number
/** Vertical bar alignment */
barAlign?: 'top' | 'bottom'
/** Minimum pixels per second of audio (i.e. zoom level) */
minPxPerSec?: number
/** Stretch the waveform to fill the container, true by default */
fillParent?: boolean
/** Audio URL */
url?: string
/** Pre-computed audio data */
peaks?: Array<Float32Array | number[]>
/** Pre-computed duration */
duration?: number
/** Use an existing media element instead of creating one */
media?: HTMLMediaElement
/** Play the audio on load */
autoplay?: boolean
/** Pass false to disable clicks on the waveform */
interact?: boolean
/** Hide the scrollbar */
hideScrollbar?: boolean
/** Audio rate */
audioRate?: number
/** Automatically scroll the container to keep the current position in viewport */
autoScroll?: boolean
/** If autoScroll is enabled, keep the cursor in the center of the waveform during playback */
autoCenter?: boolean
/** Decoding sample rate. Doesn't affect the playback. Defaults to 8000 */
sampleRate?: number
/** Render each audio channel as a separate waveform */
splitChannels?: WaveSurferOptions[]
/** Stretch the waveform to the full height */
normalize?: boolean
/** The list of plugins to initialize on start */
plugins?: GenericPlugin[]
/** Custom render function */
renderFunction?: (peaks: Array<Float32Array | number[]>, ctx: CanvasRenderingContext2D) => void
/** Options to pass to the fetch method */
fetchParams?: RequestInit
}
const defaultOptions = {
waveColor: '#999',
progressColor: '#555',
cursorWidth: 1,
minPxPerSec: 0,
fillParent: true,
interact: true,
autoScroll: true,
autoCenter: true,
sampleRate: 8000,
}
export type WaveSurferEvents = {
/** When audio starts loading */
load: [url: string]
/** When the audio has been decoded */
decode: [duration: number]
/** When the audio is both decoded and can play */
ready: [duration: number]
/** When a waveform is drawn */
redraw: []
/** When the audio starts playing */
play: []
/** When the audio pauses */
pause: []
/** When the audio finishes playing */
finish: []
/** On audio position change, fires continuously during playback */
timeupdate: [currentTime: number]
/** An alias of timeupdate but only when the audio is playing */
audioprocess: [currentTime: number]
/** When the user seeks to a new position */
seeking: [currentTime: number]
/** When the user interacts with the waveform (i.g. clicks or drags on it) */
interaction: [newTime: number]
/** When the user clicks on the waveform */
click: [relativeX: number]
/** When the user drags the cursor */
drag: [relativeX: number]
/** When the waveform is scrolled (panned) */
scroll: [visibleStartTime: number, visibleEndTime: number]
/** When the zoom level changes */
zoom: [minPxPerSec: number]
/** Just before the waveform is destroyed so you can clean up your events */
destroy: []
}
export class WaveSurfer extends Player<WaveSurferEvents> {
public options: WaveSurferOptions & typeof defaultOptions
private renderer: Renderer
private timer: Timer
private plugins: GenericPlugin[] = []
private decodedData: AudioBuffer | null = null
private duration: number | null = null
protected subscriptions: Array<() => void> = []
/** Create a new WaveSurfer instance */
public static create(options: WaveSurferOptions) {
return new WaveSurfer(options)
}
/** Create a new WaveSurfer instance */
constructor(options: WaveSurferOptions) {
super({
media: options.media,
autoplay: options.autoplay,
playbackRate: options.audioRate,
})
this.options = Object.assign({}, defaultOptions, options)
this.timer = new Timer()
this.renderer = new Renderer(this.options)
this.initPlayerEvents()
this.initRendererEvents()
this.initTimerEvents()
this.initPlugins()
const url = this.options.url || this.options.media?.currentSrc || this.options.media?.src
if (url) {
this.load(url, this.options.peaks, this.options.duration)
}
}
public setOptions(options: Partial<WaveSurferOptions>) {
this.options = Object.assign({}, this.options, options)
this.renderer.setOptions(this.options)
if (options.audioRate) {
this.setPlaybackRate(options.audioRate)
}
}
private initTimerEvents() {
// The timer fires every 16ms for a smooth progress animation
this.subscriptions.push(
this.timer.on('tick', () => {
const currentTime = this.getCurrentTime()
this.renderer.renderProgress(currentTime / this.getDuration(), true)
this.emit('timeupdate', currentTime)
this.emit('audioprocess', currentTime)
}),
)
}
private initPlayerEvents() {
this.subscriptions.push(
this.onMediaEvent('timeupdate', () => {
const currentTime = this.getCurrentTime()
this.renderer.renderProgress(currentTime / this.getDuration(), this.isPlaying())
this.emit('timeupdate', currentTime)
}),
this.onMediaEvent('play', () => {
this.emit('play')
this.timer.start()
}),
this.onMediaEvent('pause', () => {
this.emit('pause')
this.timer.stop()
}),
this.onMediaEvent('ended', () => {
this.emit('finish')
}),
this.onMediaEvent('seeking', () => {
this.emit('seeking', this.getCurrentTime())
}),
)
}
private initRendererEvents() {
this.subscriptions.push(
// Seek on click
this.renderer.on('click', (relativeX) => {
if (this.options.interact) {
this.seekTo(relativeX)
this.emit('interaction', this.getCurrentTime())
this.emit('click', relativeX)
}
}),
// Scroll
this.renderer.on('scroll', (startX, endX) => {
const duration = this.getDuration()
this.emit('scroll', startX * duration, endX * duration)
}),
// Redraw
this.renderer.on('render', () => {
this.emit('redraw')
}),
)
// Drag
{
let debounce: ReturnType<typeof setTimeout>
this.subscriptions.push(
this.renderer.on('drag', (relativeX) => {
if (!this.options.interact) return
// Update the visual position
this.renderer.renderProgress(relativeX)
// Set the audio position with a debounce
clearTimeout(debounce)
debounce = setTimeout(
() => {
this.seekTo(relativeX)
},
this.isPlaying() ? 0 : 200,
)
this.emit('interaction', relativeX * this.getDuration())
this.emit('drag', relativeX)
}),
)
}
}
private initPlugins() {
if (!this.options.plugins?.length) return
this.options.plugins.forEach((plugin) => {
this.registerPlugin(plugin)
})
}
/** Register a wavesurfer.js plugin */
public registerPlugin<T extends GenericPlugin>(plugin: T): T {
plugin.init(this)
this.plugins.push(plugin)
return plugin
}
/** For plugins only: get the waveform wrapper div */
public getWrapper(): HTMLElement {
return this.renderer.getWrapper()
}
/** Get the current scroll position in pixels */
public getScroll(): number {
return this.renderer.getScroll()
}
/** Get all registered plugins */
public getActivePlugins() {
return this.plugins
}
/** Load an audio file by URL, with optional pre-decoded audio data */
public async load(url: string, channelData?: WaveSurferOptions['peaks'], duration?: number) {
this.decodedData = null
this.duration = null
this.emit('load', url)
// Fetch the entire audio as a blob if pre-decoded data is not provided
const blob = channelData ? undefined : await Fetcher.fetchBlob(url, this.options.fetchParams)
// Set the mediaelement source to the URL
this.setSrc(url, blob)
// Wait for the audio duration
this.duration =
duration ||
this.getDuration() ||
(await new Promise((resolve) => {
this.onceMediaEvent('loadedmetadata', () => resolve(this.getDuration()))
})) ||
0
// Decode the audio data or use user-provided peaks
if (channelData) {
this.decodedData = Decoder.createBuffer(channelData, this.duration)
} else if (blob) {
const arrayBuffer = await blob.arrayBuffer()
this.decodedData = await Decoder.decode(arrayBuffer, this.options.sampleRate)
// Fall back to the decoded data duration if the media duration is incorrect
if (this.duration === 0 || this.duration === Infinity) {
this.duration = this.decodedData.duration
}
}
this.emit('decode', this.duration)
// Render the waveform
if (this.decodedData) {
this.renderer.render(this.decodedData)
}
this.emit('ready', this.duration)
}
/** Zoom in or out */
public zoom(minPxPerSec: number) {
if (!this.decodedData) {
throw new Error('No audio loaded')
}
this.renderer.zoom(minPxPerSec)
this.emit('zoom', minPxPerSec)
}
/** Get the decoded audio data */
public getDecodedData(): AudioBuffer | null {
return this.decodedData
}
/** Get the duration of the audio in seconds */
public getDuration(): number {
if (this.duration !== null) return this.duration
return super.getDuration()
}
/** Toggle if the waveform should react to clicks */
public toggleInteraction(isInteractive: boolean) {
this.options.interact = isInteractive
}
/** Seeks to a percentage of audio as [0..1] (0 = beginning, 1 = end) */
public seekTo(progress: number) {
const time = this.getDuration() * progress
this.setTime(time)
}
/** Play or pause the audio */
public async playPause(): Promise<void> {
return this.isPlaying() ? this.pause() : this.play()
}
/** Stop the audio and go to the beginning */
public stop() {
this.pause()
this.setTime(0)
}
/** Skip N or -N seconds from the current positions */
public skip(seconds: number) {
this.setTime(this.getCurrentTime() + seconds)
}
/** Empty the waveform by loading a tiny silent audio */
public empty() {
this.load('', [[0]], 0.001)
}
/** Unmount wavesurfer */
public destroy() {
this.emit('destroy')
this.subscriptions.forEach((unsubscribe) => unsubscribe())
this.plugins.forEach((plugin) => plugin.destroy())
this.timer.destroy()
this.renderer.destroy()
super.destroy()
}
}
export default WaveSurfer