Skip to content

Commit

Permalink
Refactor: promise-based chunked rendering (katspaugh#3484)
Browse files Browse the repository at this point in the history
  • Loading branch information
katspaugh committed Jan 11, 2024
1 parent ac41841 commit e59ac35
Show file tree
Hide file tree
Showing 3 changed files with 128 additions and 87 deletions.
19 changes: 19 additions & 0 deletions cypress/e2e/basic.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,25 @@ describe('WaveSurfer basic tests', () => {
cy.window().its('wavesurfer').should('be.an', 'object')
})

it('should emit a redrawcomplete event', () => {
cy.window().then((win) => {
const { wavesurfer } = win
expect(wavesurfer.getDuration().toFixed(2)).to.equal('21.77')

wavesurfer.options.minPxPerSec = 200
wavesurfer.load('../../examples/audio/audio.wav')

return new Promise((resolve) => {
wavesurfer.once('redrawcomplete', () => {
wavesurfer.zoom(100)
wavesurfer.once('redrawcomplete', () => {
resolve()
})
})
})
})
})

it('should load an audio file without errors', () => {
cy.window().then((win) => {
expect(win.wavesurfer.getDuration().toFixed(2)).to.equal('21.77')
Expand Down
11 changes: 8 additions & 3 deletions examples/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,14 @@ wavesurfer.on('ready', (duration) => {
console.log('Ready', duration + 's')
})

/** When a waveform is drawn */
wavesurfer.on('redraw', () => {
console.log('Redraw')
/** When visible waveform is drawn */
wavesurfer.on('redrawcomplete', () => {
console.log('Redraw began')
})

/** When all audio channel chunks of the waveform have drawn */
wavesurfer.on('redrawcomplete', () => {
console.log('Redraw complete')
})

/** When the audio starts playing */
Expand Down
185 changes: 101 additions & 84 deletions src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class Renderer extends EventEmitter<RendererEvents> {
private canvasWrapper: HTMLElement
private progressWrapper: HTMLElement
private cursor: HTMLElement
private timeouts: Array<{ timeout?: ReturnType<typeof setTimeout> }> = []
private timeouts: Array<() => void> = []
private isScrollable = false
private audioData: AudioBuffer | null = null
private resizeObserver: ResizeObserver | null = null
Expand Down Expand Up @@ -105,7 +105,9 @@ class Renderer extends EventEmitter<RendererEvents> {
// Re-render the waveform on container resize
const delay = this.createDelay(100)
this.resizeObserver = new ResizeObserver(() => {
delay(() => this.onContainerResize())
delay()
.then(() => this.onContainerResize())
.catch(() => undefined)
})
this.resizeObserver.observe(this.scrollContainer)
}
Expand Down Expand Up @@ -252,12 +254,27 @@ class Renderer extends EventEmitter<RendererEvents> {
this.resizeObserver?.disconnect()
}

private createDelay(delayMs = 10): (fn: () => void) => void {
const context: { timeout?: ReturnType<typeof setTimeout> } = {}
this.timeouts.push(context)
return (callback: () => void) => {
context.timeout && clearTimeout(context.timeout)
context.timeout = setTimeout(callback, delayMs)
private createDelay(delayMs = 10): () => Promise<void> {
let timeout: ReturnType<typeof setTimeout> | undefined
let reject: (() => void) | undefined

const onClear = () => {
if (timeout) clearTimeout(timeout)
if (reject) reject()
}

this.timeouts.push(onClear)

return () => {
return new Promise((resolveFn, rejectFn) => {
onClear()
reject = rejectFn
timeout = setTimeout(() => {
timeout = undefined
reject = undefined
resolveFn()
}, delayMs)
})
}
}

Expand Down Expand Up @@ -455,7 +472,11 @@ class Renderer extends EventEmitter<RendererEvents> {
}
}

private renderChannel(channelData: Array<Float32Array | number[]>, options: WaveSurferOptions, width: number, done: () => void) {
private async renderChannel(
channelData: Array<Float32Array | number[]>,
options: WaveSurferOptions,
width: number,
): Promise<void> {
// A container for canvases
const canvasContainer = document.createElement('div')
const height = this.getHeight(options.height)
Expand All @@ -467,26 +488,7 @@ class Renderer extends EventEmitter<RendererEvents> {
const progressContainer = canvasContainer.cloneNode() as HTMLElement
this.progressWrapper.appendChild(progressContainer)

// Determine the currently visible part of the waveform
const { scrollLeft, scrollWidth, clientWidth } = this.scrollContainer
const len = channelData[0].length
const scale = len / scrollWidth

let viewportWidth = Math.min(Renderer.MAX_CANVAS_WIDTH, clientWidth)

// Adjust width to avoid gaps between canvases when using bars
if (options.barWidth || options.barGap) {
const barWidth = options.barWidth || 0.5
const barGap = options.barGap || barWidth / 2
const totalBarWidth = barWidth + barGap
if (viewportWidth % totalBarWidth !== 0) {
viewportWidth = Math.floor(viewportWidth / totalBarWidth) * totalBarWidth
}
}

const start = Math.floor(Math.abs(scrollLeft) * scale)
const end = Math.floor(start + viewportWidth * scale)
const viewportLen = end - start
const dataLength = channelData[0].length

// Draw a portion of the waveform from start peak to end peak
const draw = (start: number, end: number) => {
Expand All @@ -496,53 +498,67 @@ class Renderer extends EventEmitter<RendererEvents> {
width,
height,
Math.max(0, start),
Math.min(end, len),
Math.min(end, dataLength),
canvasContainer,
progressContainer,
)
}

const status: { [k:string]: boolean } = { head: false, tail: end >= len }
const complete = (type: string) => {
status[type] = true
if (status.head && status.tail) {
done()
}
// Draw the entire waveform
if (!this.isScrollable) {
draw(0, dataLength)
return
}

// Draw the waveform in viewport chunks, each with a delay
const headDelay = this.createDelay()
const tailDelay = this.createDelay()
const renderHead = (fromIndex: number, toIndex: number) => {
draw(fromIndex, toIndex)
if (fromIndex > 0) {
headDelay(() => {
renderHead(fromIndex - viewportLen, toIndex - viewportLen)
})
} else {
complete('head')
}
}
const renderTail = (fromIndex: number, toIndex: number) => {
draw(fromIndex, toIndex)
if (toIndex < len) {
tailDelay(() => {
renderTail(fromIndex + viewportLen, toIndex + viewportLen)
})
} else {
complete('tail')
// Determine the currently visible part of the waveform
const { scrollLeft, scrollWidth, clientWidth } = this.scrollContainer
const scale = dataLength / scrollWidth

let viewportWidth = Math.min(Renderer.MAX_CANVAS_WIDTH, clientWidth)

// Adjust width to avoid gaps between canvases when using bars
if (options.barWidth || options.barGap) {
const barWidth = options.barWidth || 0.5
const barGap = options.barGap || barWidth / 2
const totalBarWidth = barWidth + barGap
if (viewportWidth % totalBarWidth !== 0) {
viewportWidth = Math.floor(viewportWidth / totalBarWidth) * totalBarWidth
}
}

renderHead(start, end)
if (end < len) {
renderTail(end, end + viewportLen)
}
const start = Math.floor(Math.abs(scrollLeft) * scale)
const end = Math.floor(start + viewportWidth * scale)
const viewportLen = end - start

// Draw the visible part of the waveform
draw(start, end)

// Draw the waveform in chunks equal to the size of the viewport, starting from the position of the viewport
await Promise.all([
// Draw the chunks to the left of the viewport
(async () => {
if (start === 0) return
const delay = this.createDelay()
for (let i = start; i >= 0; i -= viewportLen) {
await delay()
draw(Math.max(0, i - viewportLen), i)
}
})(),
// Draw the chunks to the right of the viewport
(async () => {
if (end === dataLength) return
const delay = this.createDelay()
for (let i = end; i < dataLength; i += viewportLen) {
await delay()
draw(i, Math.min(dataLength, i + viewportLen))
}
})(),
])
}

render(audioData: AudioBuffer) {
async render(audioData: AudioBuffer) {
// Clear previous timeouts
this.timeouts.forEach((context) => context.timeout && clearTimeout(context.timeout))
this.timeouts.forEach((clear) => clear())
this.timeouts = []

// Clear the canvases
Expand Down Expand Up @@ -575,31 +591,32 @@ class Renderer extends EventEmitter<RendererEvents> {
this.cursor.style.backgroundColor = `${this.options.cursorColor || this.options.progressColor}`
this.cursor.style.width = `${this.options.cursorWidth}px`

// Render the waveform
if (this.options.splitChannels) {
let counter = 0
const done = () => {
counter++
if (counter === audioData.numberOfChannels) {
this.emit('rendered')
}
}
this.audioData = audioData

this.emit('render')

// Render a waveform for each channel
for (let i = 0; i < audioData.numberOfChannels; i++) {
const options = { ...this.options, ...this.options.splitChannels[i] }
this.renderChannel([audioData.getChannelData(i)], options, width, done)
// Render the waveform
try {
if (this.options.splitChannels) {
// Render a waveform for each channel
await Promise.all(
Array.from({ length: audioData.numberOfChannels }).map((_, i) => {
const options = { ...this.options, ...this.options.splitChannels?.[i] }
return this.renderChannel([audioData.getChannelData(i)], options, width)
}),
)
} else {
// Render a single waveform for the first two channels (left and right)
const channels = [audioData.getChannelData(0)]
if (audioData.numberOfChannels > 1) channels.push(audioData.getChannelData(1))
await this.renderChannel(channels, this.options, width)
}
} else {
// Render a single waveform for the first two channels (left and right)
const channels = [audioData.getChannelData(0)]
if (audioData.numberOfChannels > 1) channels.push(audioData.getChannelData(1))
this.renderChannel(channels, this.options, width, () => this.emit('rendered'))
} catch {
// Render cancelled due to another render
return
}

this.audioData = audioData

this.emit('render')
this.emit('rendered')
}

reRender() {
Expand Down

0 comments on commit e59ac35

Please sign in to comment.