Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor: promise-based chunked rendering #3484

Merged
merged 3 commits into from
Jan 11, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.allSettled([
// 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.allSettled(
Array.from({ length: audioData.numberOfChannels }).map((_, i) => {
const options = { ...this.options, ...this.options.splitChannels?.[i] }
return this.renderChannel([audioData.getChannelData(i)], options, width)
}),
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allSettled will prevent the catch from being invoked in subsequent renders.

This seems like more render calls would be happing than one might expect.

I think Promise.all might be the better option here. But I may be miss understanding something here.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, thank you, we want to reject the entire promise when one of them fail.

} 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