Skip to content

Commit

Permalink
Feat: [Record] continuous waveform mode (#3818)
Browse files Browse the repository at this point in the history
* Feat: [Record] continuous waveform mode

* Fix: set position to the latest sample

* Bar width
  • Loading branch information
katspaugh authored Aug 12, 2024
1 parent b578b68 commit 4673c66
Show file tree
Hide file tree
Showing 2 changed files with 105 additions and 17 deletions.
37 changes: 33 additions & 4 deletions examples/record.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,31 @@ import RecordPlugin from 'wavesurfer.js/dist/plugins/record.esm.js'

let wavesurfer, record
let scrollingWaveform = false
let continuousWaveform = true

const createWaveSurfer = () => {
// Create an instance of WaveSurfer
// Destroy the previous wavesurfer instance
if (wavesurfer) {
wavesurfer.destroy()
}

// Create a new Wavesurfer instance
wavesurfer = WaveSurfer.create({
container: '#mic',
waveColor: 'rgb(200, 0, 200)',
progressColor: 'rgb(100, 0, 100)',
})

// Initialize the Record plugin
record = wavesurfer.registerPlugin(RecordPlugin.create({ scrollingWaveform, renderRecordedAudio: false }))
record = wavesurfer.registerPlugin(
RecordPlugin.create({
renderRecordedAudio: false,
scrollingWaveform,
continuousWaveform,
continuousWaveformDuration: 30, // optional
}),
)

// Render recorded audio
record.on('record-end', (blob) => {
const container = document.querySelector('#recordings')
Expand Down Expand Up @@ -114,8 +125,22 @@ recButton.onclick = () => {
pauseButton.style.display = 'inline'
})
}
document.querySelector('input[type="checkbox"]').onclick = (e) => {

document.querySelector('#scrollingWaveform').onclick = (e) => {
scrollingWaveform = e.target.checked
if (continuousWaveform && scrollingWaveform) {
continuousWaveform = false
document.querySelector('#continuousWaveform').checked = false
}
createWaveSurfer()
}

document.querySelector('#continuousWaveform').onclick = (e) => {
continuousWaveform = e.target.checked
if (continuousWaveform && scrollingWaveform) {
scrollingWaveform = false
document.querySelector('#scrollingWaveform').checked = false
}
createWaveSurfer()
}

Expand All @@ -135,7 +160,11 @@ createWaveSurfer()
<select id="mic-select">
<option value="" hidden>Select mic</option>
</select>
<label style="display:inline-block;"><input type="checkbox" /> Scrolling waveform</label>
<label><input type="checkbox" id="scrollingWaveform" /> Scrolling waveform</label>
<label><input type="checkbox" id="continuousWaveform" checked="checked" /> Continuous waveform</label>
<p id="progress">00:00</p>
<div id="mic" style="border: 1px solid #ddd; border-radius: 4px; margin-top: 1rem"></div>
Expand Down
85 changes: 72 additions & 13 deletions src/plugins/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,23 @@

import BasePlugin, { type BasePluginEvents } from '../base-plugin.js'
import Timer from '../timer.js'
import type { WaveSurferOptions } from '../wavesurfer.js'

export type RecordPluginOptions = {
/** The MIME type to use when recording audio */
mimeType?: MediaRecorderOptions['mimeType']
/** The audio bitrate to use when recording audio, defaults to 128000 to avoid a VBR encoding. */
audioBitsPerSecond?: MediaRecorderOptions['audioBitsPerSecond']
/** Whether to render the recorded audio, true by default */
/** Whether to render the recorded audio at the end, true by default */
renderRecordedAudio?: boolean
/** Whether to render the scrolling waveform, false by default */
scrollingWaveform?: boolean
/** The duration of the scrolling waveform window, defaults to 5 seconds */
scrollingWaveformWindow?: number
/** Accumulate and render the waveform data as the audio is being recorded, false by default */
continuousWaveform?: boolean
/** The duration of the continuous waveform, in seconds */
continuousWaveformDuration?: number
/** The timeslice to use for the media recorder */
mediaRecorderTimeslice?: number
}
Expand All @@ -26,12 +31,17 @@ export type RecordPluginDeviceOptions = {
}

export type RecordPluginEvents = BasePluginEvents & {
/** Fires when the recording starts */
'record-start': []
/** Fires when the recording is paused */
'record-pause': [blob: Blob]
/** Fires when the recording is resumed */
'record-resume': []
/* When the recording stops, either by calling stopRecording or when the media recorder stops */
'record-end': [blob: Blob]
/** Fires continuously while recording */
'record-progress': [duration: number]
/** On every new recorded chunk */
'record-data-available': [blob: Blob]
}

Expand All @@ -42,6 +52,7 @@ type MicStream = {

const DEFAULT_BITS_PER_SECOND = 128000
const DEFAULT_SCROLLING_WAVEFORM_WINDOW = 5
const FPS = 60

const MIME_TYPES = ['audio/webm', 'audio/wav', 'audio/mpeg', 'audio/mp4', 'audio/mp3']
const findSupportedMimeType = () => MIME_TYPES.find((mimeType) => MediaRecorder.isTypeSupported(mimeType))
Expand All @@ -51,7 +62,7 @@ class RecordPlugin extends BasePlugin<RecordPluginEvents, RecordPluginOptions> {
private mediaRecorder: MediaRecorder | null = null
private dataWindow: Float32Array | null = null
private isWaveformPaused = false
private originalOptions: { cursorWidth: number; interact: boolean } | undefined
private originalOptions?: Partial<WaveSurferOptions>
private timer: Timer
private lastStartTime = 0
private lastDuration = 0
Expand All @@ -64,6 +75,7 @@ class RecordPlugin extends BasePlugin<RecordPluginEvents, RecordPluginOptions> {
audioBitsPerSecond: options.audioBitsPerSecond ?? DEFAULT_BITS_PER_SECOND,
scrollingWaveform: options.scrollingWaveform ?? false,
scrollingWaveformWindow: options.scrollingWaveformWindow ?? DEFAULT_SCROLLING_WAVEFORM_WINDOW,
continuousWaveform: options.continuousWaveform ?? false,
renderRecordedAudio: options.renderRecordedAudio ?? true,
mediaRecorderTimeslice: options.mediaRecorderTimeslice ?? undefined,
})
Expand Down Expand Up @@ -94,8 +106,18 @@ class RecordPlugin extends BasePlugin<RecordPluginEvents, RecordPluginOptions> {
const dataArray = new Float32Array(bufferLength)

let animationId: number
let sampleIdx = 0

const windowSize = Math.floor((this.options.scrollingWaveformWindow || 0) * audioContext.sampleRate)
if (this.wavesurfer) {
this.originalOptions ??= {
...this.wavesurfer.options,
}

this.wavesurfer.options.interact = false
if (this.options.scrollingWaveform) {
this.wavesurfer.options.cursorWidth = 0
}
}

const drawWaveform = () => {
if (this.isWaveformPaused) {
Expand All @@ -106,6 +128,7 @@ class RecordPlugin extends BasePlugin<RecordPluginEvents, RecordPluginOptions> {
analyser.getFloatTimeDomainData(dataArray)

if (this.options.scrollingWaveform) {
const windowSize = Math.floor((this.options.scrollingWaveformWindow || 0) * audioContext.sampleRate)
const newLength = Math.min(windowSize, this.dataWindow ? this.dataWindow.length + bufferLength : bufferLength)
const tempArray = new Float32Array(windowSize) // Always make it the size of the window, filling with zeros by default

Expand All @@ -116,20 +139,57 @@ class RecordPlugin extends BasePlugin<RecordPluginEvents, RecordPluginOptions> {

tempArray.set(dataArray, windowSize - bufferLength)
this.dataWindow = tempArray
} else if (this.options.continuousWaveform) {
if (!this.dataWindow) {
const size = this.options.continuousWaveformDuration
? Math.round(this.options.continuousWaveformDuration * FPS)
: (this.wavesurfer?.getWidth() ?? 0) * window.devicePixelRatio
this.dataWindow = new Float32Array(size)
}

const maxValue = Math.max(...dataArray)

// Append the max value to the data window at the right position
if (sampleIdx + 1 > this.dataWindow.length) {
const tempArray = new Float32Array(this.dataWindow.length * 2)
tempArray.set(this.dataWindow, 0)
this.dataWindow = tempArray
}

this.dataWindow.set([maxValue], sampleIdx)
sampleIdx++
} else {
this.dataWindow = dataArray
}

const duration = this.options.scrollingWaveformWindow

// Render the waveform
if (this.wavesurfer) {
this.originalOptions ??= {
cursorWidth: this.wavesurfer.options.cursorWidth,
interact: this.wavesurfer.options.interact,
const totalDuration = (this.dataWindow?.length ?? 0) / FPS
let position = sampleIdx / this.dataWindow.length
if (this.wavesurfer.options.barWidth) {
position += this.wavesurfer.options.barWidth / this.wavesurfer.getWidth()
}
this.wavesurfer.options.cursorWidth = 0
this.wavesurfer.options.interact = false
this.wavesurfer.load('', [this.dataWindow], duration)

this.wavesurfer
.load(
'',
[this.dataWindow],
this.options.scrollingWaveform ? this.options.scrollingWaveformWindow : totalDuration,
)
.then(() => {
if (this.wavesurfer && this.options.continuousWaveform) {
this.wavesurfer.seekTo(position)

if (!this.wavesurfer.options.minPxPerSec) {
this.wavesurfer.setOptions({
minPxPerSec: this.wavesurfer.getWidth() / this.wavesurfer.getDuration(),
})
}
}
})
.catch((err) => {
console.error('Error rendering real-time recording data:', err)
})
}

animationId = requestAnimationFrame(drawWaveform)
Expand Down Expand Up @@ -292,8 +352,7 @@ class RecordPlugin extends BasePlugin<RecordPluginEvents, RecordPluginOptions> {

private applyOriginalOptionsIfNeeded() {
if (this.wavesurfer && this.originalOptions) {
this.wavesurfer.options.cursorWidth = this.originalOptions.cursorWidth
this.wavesurfer.options.interact = this.originalOptions.interact
this.wavesurfer.setOptions(this.originalOptions)
delete this.originalOptions
}
}
Expand Down

0 comments on commit 4673c66

Please sign in to comment.