Skip to content
8 changes: 8 additions & 0 deletions src/fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ async function watchProgress(response: Response, progressCallback: (percentage:

const contentLength = Number(response.headers.get('Content-Length')) || 0
let receivedLength = 0
const maxIterations = 100000 // Safety limit to prevent infinite loops
let iterations = 0
Comment on lines +7 to +8
Copy link

Copilot AI Oct 1, 2025

Choose a reason for hiding this comment

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

[nitpick] Magic number for iteration cap. Extract to a named constant (e.g., MAX_STREAM_READ_ITERATIONS) or make it configurable to clarify intent and ease tuning.

Copilot uses AI. Check for mistakes.

// Process the data
const processChunk = async (value: Uint8Array | undefined) => {
Expand All @@ -14,6 +16,12 @@ async function watchProgress(response: Response, progressCallback: (percentage:
}

const read = async () => {
// Safety check to prevent infinite recursion
if (iterations++ > maxIterations) {
Copy link

Copilot AI Oct 1, 2025

Choose a reason for hiding this comment

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

Off-by-one in the max-iteration guard: with iterations++ > maxIterations the loop allows maxIterations + 1 reads. Use >= with post-increment to stop after exactly maxIterations reads.

Suggested change
if (iterations++ > maxIterations) {
if (iterations++ >= maxIterations) {

Copilot uses AI. Check for mistakes.
console.error('Fetcher: Maximum iterations reached, stopping read loop')
Copy link

Copilot AI Oct 1, 2025

Choose a reason for hiding this comment

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

[nitpick] When aborting the read loop early, consider canceling the reader to free underlying resources. For example: await reader.cancel().

Suggested change
console.error('Fetcher: Maximum iterations reached, stopping read loop')
console.error('Fetcher: Maximum iterations reached, stopping read loop')
await reader.cancel()

Copilot uses AI. Check for mistakes.
return
}

let data
try {
data = await reader.read()
Expand Down
18 changes: 17 additions & 1 deletion src/plugins/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ class RecordPlugin extends BasePlugin<RecordPluginEvents, RecordPluginOptions> {
private micStream: MicStream | null = null
private unsubscribeDestroy?: () => void
private unsubscribeRecordEnd?: () => void
private recordedBlobUrl: string | null = null

/** Create an instance of the Record plugin */
constructor(options: RecordPluginOptions) {
Expand Down Expand Up @@ -213,6 +214,11 @@ class RecordPlugin extends BasePlugin<RecordPluginEvents, RecordPluginOptions> {

/** Request access to the microphone and start monitoring incoming audio */
public async startMic(options?: RecordPluginDeviceOptions): Promise<MediaStream> {
// Stop previous mic stream if exists to clean up AudioContext
if (this.micStream) {
this.stopMic()
}

let stream: MediaStream
try {
stream = await navigator.mediaDevices.getUserMedia({
Expand Down Expand Up @@ -272,7 +278,12 @@ class RecordPlugin extends BasePlugin<RecordPluginEvents, RecordPluginOptions> {
this.emit(ev, blob)
if (this.options.renderRecordedAudio) {
this.applyOriginalOptionsIfNeeded()
this.wavesurfer?.load(URL.createObjectURL(blob))
// Revoke previous blob URL before creating a new one
if (this.recordedBlobUrl) {
URL.revokeObjectURL(this.recordedBlobUrl)
}
this.recordedBlobUrl = URL.createObjectURL(blob)
this.wavesurfer?.load(this.recordedBlobUrl)
}
}

Expand Down Expand Up @@ -355,6 +366,11 @@ class RecordPlugin extends BasePlugin<RecordPluginEvents, RecordPluginOptions> {
super.destroy()
this.stopRecording()
this.stopMic()
// Revoke blob URL to free memory
if (this.recordedBlobUrl) {
URL.revokeObjectURL(this.recordedBlobUrl)
this.recordedBlobUrl = null
}
}

private applyOriginalOptionsIfNeeded() {
Expand Down
25 changes: 22 additions & 3 deletions src/plugins/regions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ class SingleRegion extends EventEmitter<RegionEvents> implements Region {
public subscriptions: (() => void)[] = []
public updatingSide?: UpdateSide = undefined
private isRemoved = false
private contentClickListener?: (e: MouseEvent) => void
private contentBlurListener?: () => void

constructor(
params: RegionParams,
Expand Down Expand Up @@ -218,7 +220,7 @@ class SingleRegion extends EventEmitter<RegionEvents> implements Region {
let elementTop = 0
let elementHeight = 100

if (this.channelIdx >= 0 && this.channelIdx < this.numberOfChannels) {
if (this.channelIdx >= 0 && this.numberOfChannels > 0 && this.channelIdx < this.numberOfChannels) {
elementHeight = 100 / this.numberOfChannels
elementTop = elementHeight * this.channelIdx
}
Expand Down Expand Up @@ -284,8 +286,10 @@ class SingleRegion extends EventEmitter<RegionEvents> implements Region {
)

if (this.contentEditable && this.content) {
this.content.addEventListener('click', (e) => this.onContentClick(e))
this.content.addEventListener('blur', () => this.onContentBlur())
this.contentClickListener = (e) => this.onContentClick(e)
this.contentBlurListener = () => this.onContentBlur()
this.content.addEventListener('click', this.contentClickListener)
this.content.addEventListener('blur', this.contentBlurListener)
}
}

Expand Down Expand Up @@ -375,6 +379,16 @@ class SingleRegion extends EventEmitter<RegionEvents> implements Region {
public setContent(content: RegionParams['content']) {
if (!this.element) return

// Remove event listeners from old content before removing it
if (this.content && this.contentEditable) {
if (this.contentClickListener) {
this.content.removeEventListener('click', this.contentClickListener)
}
if (this.contentBlurListener) {
this.content.removeEventListener('blur', this.contentBlurListener)
}
}

this.content?.remove()
if (!content) {
this.content = undefined
Expand All @@ -394,6 +408,11 @@ class SingleRegion extends EventEmitter<RegionEvents> implements Region {
}
if (this.contentEditable) {
this.content.contentEditable = 'true'
// Re-add event listeners to new content
this.contentClickListener = (e) => this.onContentClick(e)
this.contentBlurListener = () => this.onContentBlur()
this.content.addEventListener('click', this.contentClickListener)
this.content.addEventListener('blur', this.contentBlurListener)
}
this.content.setAttribute('part', 'region-content')
this.element.appendChild(this.content)
Expand Down
6 changes: 5 additions & 1 deletion src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,10 @@ class Renderer extends EventEmitter<RendererEvents> {
destroy() {
this.subscriptions.forEach((unsubscribe) => unsubscribe())
this.container.remove()
this.resizeObserver?.disconnect()
if (this.resizeObserver) {
this.resizeObserver.disconnect()
this.resizeObserver = null
}
this.unsubscribeOnScroll?.forEach((unsubscribe) => unsubscribe())
this.unsubscribeOnScroll = []
}
Expand Down Expand Up @@ -329,6 +332,7 @@ class Renderer extends EventEmitter<RendererEvents> {
// Convert array of color values to linear gradient
private convertColorValues(color?: WaveSurferOptions['waveColor']): string | CanvasGradient {
if (!Array.isArray(color)) return color || ''
if (color.length === 0) return '#999' // Return default color for empty array
if (color.length < 2) return color[0] || ''

const canvasElement = document.createElement('canvas')
Expand Down
61 changes: 34 additions & 27 deletions src/wavesurfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,10 @@ class WaveSurfer extends Player<WaveSurferEvents> {
if (initialUrl || (peaks && duration)) {
// Swallow async errors because they cannot be caught from a constructor call.
// Subscribe to the wavesurfer's error event to handle them.
this.load(initialUrl, peaks, duration).catch(() => null)
this.load(initialUrl, peaks, duration).catch((err) => {
// Log error for debugging while still emitting error event
console.error('WaveSurfer initial load error:', err)
Comment on lines +204 to +205
Copy link

Copilot AI Oct 1, 2025

Choose a reason for hiding this comment

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

[nitpick] Direct console.error in a library can be noisy for consumers. Consider gating behind a debug flag or relying solely on the emitted 'error' event so integrators control logging.

Suggested change
// Log error for debugging while still emitting error event
console.error('WaveSurfer initial load error:', err)
// Error is emitted via the 'error' event; consumers can handle logging if desired

Copilot uses AI. Check for mistakes.
})
}
})
}
Expand Down Expand Up @@ -318,34 +321,38 @@ class WaveSurfer extends Player<WaveSurferEvents> {

// 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)
let debounceTime

if (this.isPlaying()) {
debounceTime = 0
} else if (this.options.dragToSeek === true) {
debounceTime = 200
} else if (typeof this.options.dragToSeek === 'object' && this.options.dragToSeek !== undefined) {
debounceTime = this.options.dragToSeek['debounceTime']
}
let debounce: ReturnType<typeof setTimeout> | undefined
const unsubscribeDrag = 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)
let debounceTime

if (this.isPlaying()) {
debounceTime = 0
} else if (this.options.dragToSeek === true) {
debounceTime = 200
} else if (typeof this.options.dragToSeek === 'object' && this.options.dragToSeek !== undefined) {
debounceTime = this.options.dragToSeek['debounceTime']
}

debounce = setTimeout(() => {
this.seekTo(relativeX)
}, debounceTime)
debounce = setTimeout(() => {
this.seekTo(relativeX)
}, debounceTime)

this.emit('interaction', relativeX * this.getDuration())
this.emit('drag', relativeX)
}),
)
this.emit('interaction', relativeX * this.getDuration())
this.emit('drag', relativeX)
})

// Clear debounce timeout on destroy
this.subscriptions.push(() => {
clearTimeout(debounce)
Copy link

Copilot AI Oct 1, 2025

Choose a reason for hiding this comment

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

[nitpick] Guard clearTimeout to avoid passing undefined in some TS/lib combinations and to be explicit: if (debounce) clearTimeout(debounce).

Suggested change
clearTimeout(debounce)
if (debounce) clearTimeout(debounce)

Copilot uses AI. Check for mistakes.
unsubscribeDrag()
})
}
}

Expand Down