Skip to content

Commit

Permalink
Fix: handle empty src on destroy
Browse files Browse the repository at this point in the history
  • Loading branch information
katspaugh committed Dec 28, 2023
1 parent c83aec7 commit 17ba360
Show file tree
Hide file tree
Showing 4 changed files with 107 additions and 67 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,6 @@
"typescript": "^5.0.4"
},
"dependencies": {
"wavesurfer.js": "^7.5.2"
"wavesurfer.js": "^7.6.0"
}
}
5 changes: 2 additions & 3 deletions src/multitrack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ class MultiTrack extends EventEmitter<MultitrackEvents> {
return Math.max(max, track.startPosition + durations[index])
}, 0)

const placeholderAudio = this.audios[this.audios.length - 1] as WebAudioPlayer
const placeholderAudio = this.audios[this.audios.length - 1] as HTMLAudioElement & { duration: number }
placeholderAudio.duration = this.maxDuration
this.durations[this.durations.length - 1] = this.maxDuration

Expand All @@ -171,8 +171,7 @@ class MultiTrack extends EventEmitter<MultitrackEvents> {

return new Promise<typeof audio>((resolve) => {
if (!audio.src) return resolve(audio)

audio.addEventListener('loadedmetadata', () => resolve(audio), { once: true })
;(audio as HTMLAudioElement).addEventListener('loadedmetadata', () => resolve(audio), { once: true })
})
}

Expand Down
159 changes: 100 additions & 59 deletions src/webaudio.ts
Original file line number Diff line number Diff line change
@@ -1,82 +1,82 @@
import EventEmitter from 'wavesurfer.js/dist/event-emitter.js'

type WebAudioPlayerEvents = {
loadedmetadata: []
canplay: []
play: []
pause: []
seeking: []
timeupdate: []
volumechange: []
emptied: []
ended: []
}

/**
* Web Audio buffer player emulating the behavior of an HTML5 Audio element.
* A Web Audio buffer player emulating the behavior of an HTML5 Audio element.
*/
class WebAudioPlayer {
class WebAudioPlayer extends EventEmitter<WebAudioPlayerEvents> {
private audioContext: AudioContext
private gainNode: GainNode
private bufferNode: AudioBufferSourceNode | null = null
private listeners: Map<string, Set<() => void>> = new Map()
private autoplay = false
private playStartTime = 0
private playedDuration = 0
private _src = ''
private _duration = 0
private _muted = false
private buffer: AudioBuffer | null = null
public currentSrc = ''
public paused = true
public crossOrigin: string | null = null

constructor(audioContext = new AudioContext()) {
super()
this.audioContext = audioContext

this.gainNode = this.audioContext.createGain()
this.gainNode.connect(this.audioContext.destination)
}

addEventListener(event: string, listener: () => void, options?: { once?: boolean }) {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set())
}
this.listeners.get(event)?.add(listener)

if (options?.once) {
const onOnce = () => {
this.removeEventListener(event, onOnce)
this.removeEventListener(event, listener)
}
this.addEventListener(event, onOnce)
}
}
/** Subscribe to an event. Returns an unsubscribe function. */
addEventListener = this.on

removeEventListener(event: string, listener: () => void) {
if (this.listeners.has(event)) {
this.listeners.get(event)?.delete(listener)
}
}
/** Unsubscribe from an event */
removeEventListener = this.un

private emitEvent(event: string) {
this.listeners.get(event)?.forEach((listener) => listener())
async load() {
return
}

get src() {
return this._src
return this.currentSrc
}

set src(value: string) {
this._src = value
this.currentSrc = value

if (!value) {
this.buffer = null
this.emit('emptied')
return
}

fetch(value)
.then((response) => response.arrayBuffer())
.then((arrayBuffer) => this.audioContext.decodeAudioData(arrayBuffer))
.then((arrayBuffer) => {
if (this.currentSrc !== value) return null
return this.audioContext.decodeAudioData(arrayBuffer)
})
.then((audioBuffer) => {
if (this.currentSrc !== value) return

this.buffer = audioBuffer
this._duration = audioBuffer.duration

this.emitEvent('loadedmetadata')
this.emitEvent('canplay')
this.emit('loadedmetadata')
this.emit('canplay')

if (this.autoplay) {
this.play()
}
if (this.autoplay) this.play()
})
}

getChannelData() {
const channelData = this.buffer?.getChannelData(0)
return channelData ? [channelData] : undefined
}

async play() {
private _play() {
if (!this.paused) return
this.paused = false

Expand All @@ -85,22 +85,50 @@ class WebAudioPlayer {
this.bufferNode.buffer = this.buffer
this.bufferNode.connect(this.gainNode)

const offset = this.playedDuration > 0 ? this.playedDuration : 0
const start =
this.playedDuration > 0 ? this.audioContext.currentTime : this.audioContext.currentTime - this.playedDuration
if (this.playedDuration >= this.duration) {
this.playedDuration = 0
}

this.bufferNode.start(start, offset)
this.bufferNode.start(this.audioContext.currentTime, this.playedDuration)
this.playStartTime = this.audioContext.currentTime
this.emitEvent('play')

this.bufferNode.onended = () => {
if (this.currentTime >= this.duration) {
this.pause()
this.emit('ended')
}
}
}

pause() {
private _pause() {
if (this.paused) return
this.paused = true

this.bufferNode?.stop()
this.playedDuration += this.audioContext.currentTime - this.playStartTime
this.emitEvent('pause')
}

async play() {
this._play()
this.emit('play')
}

pause() {
this._pause()
this.emit('pause')
}

stopAt(timeSeconds: number) {
const delay = timeSeconds - this.currentTime
this.bufferNode?.stop(this.audioContext.currentTime + delay)

this.bufferNode?.addEventListener(
'ended',
() => {
this.bufferNode = null
this.pause()
},
{ once: true },
)
}

async setSinkId(deviceId: string) {
Expand All @@ -121,32 +149,29 @@ class WebAudioPlayer {
return this.paused ? this.playedDuration : this.playedDuration + this.audioContext.currentTime - this.playStartTime
}
set currentTime(value) {
this.emitEvent('seeking')
this.emit('seeking')

if (this.paused) {
this.playedDuration = value
} else {
this.pause()
this._pause()
this.playedDuration = value
this.play()
this._play()
}

this.emitEvent('timeupdate')
this.emit('timeupdate')
}

get duration() {
return this._duration
}
set duration(value: number) {
this._duration = value
return this.buffer?.duration || 0
}

get volume() {
return this.gainNode.gain.value
}
set volume(value) {
this.gainNode.gain.value = value
this.emitEvent('volumechange')
this.emit('volumechange')
}

get muted() {
Expand All @@ -162,6 +187,22 @@ class WebAudioPlayer {
this.gainNode.connect(this.audioContext.destination)
}
}

/** Get the GainNode used to play the audio. Can be used to attach filters. */
public getGainNode(): GainNode {
return this.gainNode
}

/** Get decoded audio */
public getChannelData(): Float32Array[] {
const channels: Float32Array[] = []
if (!this.buffer) return channels
const numChannels = this.buffer.numberOfChannels
for (let i = 0; i < numChannels; i++) {
channels.push(this.buffer.getChannelData(i))
}
return channels
}
}

export default WebAudioPlayer
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1240,10 +1240,10 @@ vscode-textmate@^8.0.0:
resolved "https://registry.yarnpkg.com/vscode-textmate/-/vscode-textmate-8.0.0.tgz#2c7a3b1163ef0441097e0b5d6389cd5504b59e5d"
integrity sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==

wavesurfer.js@^7.5.2:
version "7.5.2"
resolved "https://registry.yarnpkg.com/wavesurfer.js/-/wavesurfer.js-7.5.2.tgz#8f03124531ba1e6b022df85a0cc358747aa8317d"
integrity sha512-hgO8p0MXxJ6oLm367jsjujve6QNEqt1B+T7muvXtMWWDn08efZ2DrVw6xaUI9NiX3Lo7BNYu9lPKnE5jubjoOg==
wavesurfer.js@^7.6.0:
version "7.6.0"
resolved "https://registry.yarnpkg.com/wavesurfer.js/-/wavesurfer.js-7.6.0.tgz#19b40f3bd6d05ce53d828d237507bd97f403d973"
integrity sha512-fRGgzdr7QfWuiELWHuaoiXO3WXhBTejBSX6ca41onLmbvEexFWbNRX89aeHDLF+72Gcd5NLqFjxt/0Pc808JBQ==

which@^2.0.1:
version "2.0.2"
Expand Down

0 comments on commit 17ba360

Please sign in to comment.