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

smoother waveform #589

Merged
merged 5 commits into from
Jun 14, 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
162 changes: 123 additions & 39 deletions spx-gui/src/components/editor/sound/wavesurfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import WaveSurfer from 'wavesurfer.js'
import { useUIVariables } from '@/components/ui'
import { getAudioContext } from '@/utils/audio'

export function useWavesurfer(container: () => HTMLElement | undefined, gain: () => number) {
export function useWavesurfer(
container: () => HTMLElement | undefined,
gain: () => number,
recording?: boolean
ComfyFluffy marked this conversation as resolved.
Show resolved Hide resolved
) {
const uiVariables = useUIVariables()

const wavesurfer = ref<WaveSurfer | null>(null)
Expand Down Expand Up @@ -35,6 +39,17 @@ export function useWavesurfer(container: () => HTMLElement | undefined, gain: ()
gainNode_.gain.value = gain()
gainNode = gainNode_

/**
* Cache for averaged data. Only used for recording.
* As we expect the `WaveSurfer` to be destroyed and recreated on every recording,
* we don't need to reset the cache when the recording stops.
*/
const cachedAveragedData: {
ComfyFluffy marked this conversation as resolved.
Show resolved Hide resolved
cache: { averagedData: number[]; originalLength: number; blockSize: number } | null
} = {
cache: null
}

wavesurfer.value = new WaveSurfer({
interact: false,
container: newContainer,
Expand All @@ -46,54 +61,123 @@ export function useWavesurfer(container: () => HTMLElement | undefined, gain: ()
normalize: true,
media: audioElement,
renderFunction: (peaks: (Float32Array | number[])[], ctx: CanvasRenderingContext2D): void => {
// TODO: Better drawing algorithm to reduce flashing?
const smoothAndDrawChannel = (channel: Float32Array, vScale: number) => {
const { width, height } = ctx.canvas
const halfHeight = height / 2
const numPoints = Math.floor(width / 5)
const blockSize = Math.floor(channel.length / numPoints)
const smoothedData = new Float32Array(numPoints)

// Smooth the data by averaging blocks
for (let i = 0; i < numPoints; i++) {
let sum = 0
for (let j = 0; j < blockSize; j++) {
sum += Math.abs(channel[i * blockSize + j])
try {
Copy link
Collaborator

@nighca nighca Jun 13, 2024

Choose a reason for hiding this comment

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

如语音沟通,这里有两个已知的缺陷:

  1. 录制(recording: true)与文件(recording: false)两种情况下,绘制结果的平滑程度不同;虽然都走了这里的 renderFunction

    这个差异应该是 peaks 的密度差异带来的,即,wavesurfer-recordrenderMicStream 处理与 wavesurfer 对文件处理逻辑的差异带来的

  2. blockSize 不会随着音频长度增加持续增加,这导致在录制过程中,录制越久,波形越不平滑

    这是因为 blockSize 越大,累计单个 block 需要的时间越久,波形更新越不频繁,给人“卡顿”的感觉;可能的处理方式是:不等到累计满一个 block 才去更新波形,不满的 block 也可以被绘制出来,这样在录制越久时,虽然波形变更越慢,但更新频率是固定的,不会显得“卡顿”

可以看下好不好处理,不好处理的话在代码中先注释说明下

Copy link
Contributor Author

Choose a reason for hiding this comment

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

我希望在之后可以重写整个逻辑去掉wavesurfer,这样之后也方便解决渲染逻辑不一样导致的密度不统一的问题。

问题2我看了下,如果说要绘制不满的 block,波形图“向左推”的速度依然会越来越慢,同样会导致视觉上的卡顿。“绘制不满的 block”不能改变“向左推”的速度,而我们预期应该是波形图在视觉上持续地收缩。可能的解决方案是非线性的波形图渲染,但这样可能更加麻烦。我目前倾向于设置一个上限让波形图看起来不至于卡顿。

Copy link
Collaborator

Choose a reason for hiding this comment

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

我希望在之后可以重写整个逻辑去掉wavesurfer,这样之后也方便解决渲染逻辑不一样导致的密度不统一的问题。

这个我没问题

问题2我看了下,如果说要绘制不满的 block,波形图“向左推”的速度依然会越来越慢,同样会导致视觉上的卡顿。

慢跟卡是不一样的,如果更新是高频率的,即使变更很小,也不应该会感觉到卡

“绘制不满的 block”不能改变“向左推”的速度,而我们预期应该是波形图在视觉上持续地收缩。

绘制不满的 block 不是为了改变“向左推”的速度,而是让眼睛看到的“推”更频繁(虽然每次推的距离在缩小)

可能的解决方案是非线性的波形图渲染,但这样可能更加麻烦。

这个必要性不大

我目前倾向于设置一个上限让波形图看起来不至于卡顿。

如上面提到的,卡是问题,但慢不应该是问题

如果是说 blockSize 频繁变更(对应均值计算结果的缓存失效)导致卡顿(大量的点重新计算均值),这个我能理解,不过那是另一个问题了,涉及的应该是 blockSize 的阶梯,而不是上限

Copy link
Contributor Author

Choose a reason for hiding this comment

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

绘制不满的 block 不是为了改变“向左推”的速度,而是让眼睛看到的“推”更频繁(虽然每次推的距离在缩小)

懂了。我想了一下,按目前的实现要做到这种效果会比较麻烦。我先加个注释,之后要再重构的时候一起想办法实现

const halfHeight = ctx.canvas.height / 2

const averageBlock = (data: number[] | Float32Array, blockSize: number): number[] => {
const { cache } = cachedAveragedData

// Check if we can use the cached data
if (
recording &&
cache &&
cache.blockSize === blockSize &&
cache.originalLength <= data.length
) {
const newBlocks =
Math.floor(data.length / blockSize) - Math.floor(cache.originalLength / blockSize)

// If there are new blocks to process
if (newBlocks > 0) {
const newAveragedData = cache.averagedData.slice()
const startIndex = cache.originalLength
for (let i = 0; i < newBlocks; i++) {
let sum = 0
for (let j = 0; j < blockSize; j++) {
const index = startIndex + i * blockSize + j
sum += Math.max(0, data[index])
}
newAveragedData.push(sum / blockSize)
}

cachedAveragedData.cache = {
averagedData: newAveragedData,
originalLength: data.length,
blockSize
}
return newAveragedData
}

// If no new blocks to process, return cached data
return cache.averagedData
}

// Calculate new averaged data if cache is not valid or not present
const averagedData: number[] = new Array(Math.floor(data.length / blockSize))
for (let i = 0; i < averagedData.length; i++) {
let sum = 0
for (let j = 0; j < blockSize; j++) {
const index = i * blockSize + j
sum += Math.max(0, data[index])
}
averagedData[i] = sum / blockSize
}

// Update cache with new averaged data
cachedAveragedData.cache = {
averagedData,
originalLength: data.length,
blockSize
}
smoothedData[i] = sum / blockSize

return averagedData
}

// Draw with bezier curves
ctx.beginPath()
ctx.moveTo(0, halfHeight)
const drawSmoothCurve = (
ctx: CanvasRenderingContext2D,
points: number[],
getPoint: (index: number) => number
) => {
const segmentLength = ctx.canvas.width / (points.length - 1)

ctx.beginPath()
ctx.moveTo(0, halfHeight)

for (let i = 1; i < smoothedData.length; i++) {
const prevX = (i - 1) * (width / numPoints)
const currX = i * (width / numPoints)
const midX = (prevX + currX) / 2
const prevY = halfHeight + smoothedData[i - 1] * halfHeight * vScale
const currY = halfHeight + smoothedData[i] * halfHeight * vScale
for (let i = 0; i < points.length - 2; i++) {
const xc = (i * segmentLength + (i + 1) * segmentLength) / 2
const yc = (getPoint(i) + getPoint(i + 1)) / 2
ctx.quadraticCurveTo(i * segmentLength, getPoint(i), xc, yc)
}

// Use a quadratic bezier curve to the middle of the interval for a smoother line
ctx.quadraticCurveTo(prevX, prevY, midX, (prevY + currY) / 2)
ctx.quadraticCurveTo(midX, (prevY + currY) / 2, currX, currY)
ctx.quadraticCurveTo(
(points.length - 2) * segmentLength,
getPoint(points.length - 2),
ctx.canvas.width,
getPoint(points.length - 1)
)

ctx.lineTo(ctx.canvas.width, halfHeight)

ctx.strokeStyle = uiVariables.color.sound[400]
ctx.lineWidth = 2
ctx.stroke()
ctx.closePath()
ctx.fillStyle = uiVariables.color.sound[400]
ctx.fill()
}

ctx.lineTo(width, halfHeight)
ctx.strokeStyle = uiVariables.color.sound[400]
ctx.stroke()
ctx.closePath()
ctx.fillStyle = uiVariables.color.sound[400]
ctx.fill()
}
const channel = peaks[0]

const scale = gain() * 3000
ComfyFluffy marked this conversation as resolved.
Show resolved Hide resolved

const channel = Array.isArray(peaks[0]) ? new Float32Array(peaks[0] as number[]) : peaks[0]
const blockSize = channel.length > 200000 ? 2000 : channel.length > 100000 ? 1000 : 500
nighca marked this conversation as resolved.
Show resolved Hide resolved

const scale = gain() * 5
const smoothedChannel = averageBlock(channel, blockSize)

// Only one channel is assumed, render it twice (mirrored)
smoothAndDrawChannel(channel, scale) // Upper part
smoothAndDrawChannel(channel, -scale) // Lower part (mirrored)
drawSmoothCurve(
ctx,
smoothedChannel,
(index: number) => smoothedChannel[index] * scale + halfHeight
)
drawSmoothCurve(
ctx,
smoothedChannel,
(index: number) => -smoothedChannel[index] * scale + halfHeight
)
} catch (e) {
// wavesurfer does not log errors so we do it ourselves
console.error(e)
}
}
})

Expand Down
21 changes: 5 additions & 16 deletions spx-gui/src/utils/wavesurfer-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ const findSupportedMimeType = () =>
export class RecordPlugin extends BasePlugin<RecordPluginEvents, RecordPluginOptions> {
private stream: MediaStream | null = null
private mediaRecorder: MediaRecorder | null = null
private dataWindow: Float32Array | null = null
private isWaveformPaused = false
private originalOptions: { cursorWidth: number; interact: boolean } | undefined
private timer: Timer
Expand Down Expand Up @@ -82,10 +81,11 @@ export class RecordPlugin extends BasePlugin<RecordPluginEvents, RecordPluginOpt
const audioContext = new AudioContext()
const source = audioContext.createMediaStreamSource(stream)
const analyser = audioContext.createAnalyser()
analyser.fftSize = 1024
source.connect(analyser)

const bufferLength = analyser.frequencyBinCount
const dataArray = new Float32Array(bufferLength)
const dataArray = new Float32Array(analyser.frequencyBinCount)
const dataWindow: number[] = []

let animationId: number

Expand All @@ -96,17 +96,7 @@ export class RecordPlugin extends BasePlugin<RecordPluginEvents, RecordPluginOpt
}

analyser.getFloatTimeDomainData(dataArray)

const newLength = (this.dataWindow?.length || 0) + bufferLength
const tempArray = new Float32Array(newLength)
// Append the new data to the existing data
if (this.dataWindow) {
tempArray.set(this.dataWindow, 0)
tempArray.set(dataArray, this.dataWindow.length)
} else {
tempArray.set(dataArray, 0)
}
this.dataWindow = tempArray
dataWindow.push(...dataArray)
nighca marked this conversation as resolved.
Show resolved Hide resolved

if (this.wavesurfer) {
this.originalOptions ??= {
Expand All @@ -115,7 +105,7 @@ export class RecordPlugin extends BasePlugin<RecordPluginEvents, RecordPluginOpt
}
this.wavesurfer.options.cursorWidth = 0
this.wavesurfer.options.interact = false
this.wavesurfer.load('', [tempArray], this.duration)
this.wavesurfer.load('', [dataWindow], this.duration)
}

animationId = requestAnimationFrame(drawWaveform)
Expand Down Expand Up @@ -167,7 +157,6 @@ export class RecordPlugin extends BasePlugin<RecordPluginEvents, RecordPluginOpt
/** Start recording audio from the microphone */
public async startRecording(options?: RecordPluginDeviceOptions) {
const stream = this.stream || (await this.startMic(options))
this.dataWindow = null
const mediaRecorder =
this.mediaRecorder ||
new MediaRecorder(stream, {
Expand Down
Loading