录音与音频可视化

引言

随着AI智能体应用的普及,用户自定义音色的需求日益增长。为了满足这一需求,需要采集用户的语音样本,并将其转化为可使用的数据格式。本文将详细介绍如何使用Web技术实现实时录音及音频波形的可视化。

实现效果展示

实现的效果如图所示,类似于iPhone的语音备忘录功能:

实时录音示例

该功能具有以下特点:

  • 波形实时变化:随着音量的变化,音频波形会实时更新。
  • 从右向左滚动:录音过程中,波形图从右向左滚动展示。
  • 播放回放效果:在播放模式下,波形能够复现录制时的状态。

技术选型

为了实现实时采集用户的声音并进行可视化处理,需要选择合适的技术方案。主要考虑的是如何获取音频数据以及如何实现音频的实时显示。

如何采集声音

浏览器中录音主要有三种方式:MediaRecorder API、Web Audio API 和 Recorder-Core 库。每种技术各有特点:

  1. MediaRecorder API:这是浏览器提供的高层封装接口,可以简单高效地录制音频并生成 Blob 对象。

     const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
     const recorder = new MediaRecorder(stream);
    
     recorder.ondataavailable = (e) => {
         const blob = e.data;
     };
    
     recorder.start();

    该接口虽然简单易用,但只能获取编码后的音频数据(Blob),无法直接获得原始振幅信息。

  2. Web Audio API:这套API提供了处理和分析原始音频数据的能力。然而,它没有封装任何内置功能,需要手动处理缓冲区、通道等问题。

  3. Recorder-Core 库:基于 Web Audio API 实现了录音功能,并简化了许多复杂操作。此库支持实时回调振幅数据以及 WAV 编码,能够很好地兼容各种浏览器。

如何实现音频的实时可视化

为了实现实时更新的波形图显示,需要选择合适的渲染工具来减少性能消耗。DOM 的频繁回流和重绘会带来额外开销,而 Canvas 是理想的解决方案:

  • Recorder-Core:提供实时振幅数据
  • Canvas:用于绘制不断变化的波形

核心流程

录音与音频采集

1️⃣ 初始化录音器

function createRecorder() {
    return RecorderCore({
        type: "wav",
        sampleRate: 44100,
        onProcess(buffers, powerLevel, duration, sampleRate, newBufferIdx) {
            // 处理音频数据
        },
    });
}

const recorder = createRecorder();
  • type: 指定输出文件格式为 wav。
  • onProcess回调函数:用于实时获取振幅数据,以便后续的波形绘制。

2️⃣ 音频数据处理

原始音频数据范围在[-32768, 32767]区间内。为了便于可视化显示,需要将其转换到[0, 1]之间。

const latest = buffers[newBufferIdx] || buffers[buffers.length - 1];

function calcEnergyFromPCM(pcm: Int16Array): number {
    if (!pcm || pcm.length === 0) return 0;

    let sum = 0;
    for (let i = 0; i < pcm.length; i++) {
        sum += Math.abs(pcm[i]);
    }
    const energy = sum / pcm.length;
    // 将能量值归一化到 [0, 1] 区间内
    return energy / (32768 * 1.5);
}

此函数计算音频缓冲区的能量,并将其归一化。

3️⃣ 波形绘制

通过调用requestAnimationFrame来持续更新波形图:

function drawWaveform() {
    requestAnimationFrame(drawWaveform);

    // 清除canvas内容以准备重新渲染
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    const energy = calcEnergyFromPCM(latest);

    // 根据能量值绘制波形图
    drawWave(ctx, latest, canvas.width / numChannels, energy);
}

// 调用drawWaveform开始渲染动画
drawWaveform();

通过上述步骤,可以实现实时录音和音频可视化功能。

4️⃣ 实时数据采集与处理

在实现录音功能中,实时展示和回放播放的数据是两个关键部分。为了确保流畅的用户体验,我们使用了liveEnergyQueue数组来存储当前时间点内的音频能量值(即实时显示所需的数据)。同时,全量的历史数据被保存到energyTimeline数组中,以备后续进行回放操作时使用。

在每次计算出新的音频能量值之后,会将其分别添加到这两个队列或列表中。这样可以确保用户能够即时看到当前录音的动态变化,并且在需要的时候可以从历史记录中恢复播放整个录音过程中的可视化数据。

const energy = calcEnergyFromPCM(latest);

// 实时显示
liveEnergyQueue.push(energy);
// 播放回放
energyTimeline.push(energy);

5️⃣ 录音控制

在开始录音之前,需要确保麦克风权限已被授予。通过调用recorder.open()函数来请求访问用户的麦克风设备,并在此过程中处理可能发生的错误情况。一旦获得授权后,则启动录音过程。

结束录音时,会触发stop()方法并传递进回调函数以获取录制的音频数据(作为Blob对象)以及录音持续时间等信息。

// 开始录音
await recorder.open(
    () => resolve(),
    (msg: string, _isUserNotAllow: boolean) => reject(new Error(msg)),
);

recorder.start();

// 结束录音
recorder.stop((blob, duration) => {
    wavBlob.value = blob;
});

6️⃣ 音频可视化核心数据

当准备对音频信号进行可视化的处理时,我们需要定义一些基本的数据结构。这些包括用于实时展示的liveEnergyQueue队列、全量记录的历史信息存储于energyTimeline数组中以及当前屏幕上的视觉缓冲区即visualBuffer。

const liveEnergyQueue: number[] = []; // 实时队列
const energyTimeline: number[] = [];  // 全量数据(播放用)
const visualBuffer: number[] = [];    // 当前屏幕显示

7️⃣ 动画驱动与滚动窗口

通过requestAnimationFrame()实现动画驱动,从而在每次绘制完一帧后继续请求下一帧的更新。对于录音和回放场景下的数据处理逻辑,则统一由startVisual(mode)函数来执行。

在这段代码中,根据当前模式的不同(记录或播放),我们从不同的队列或数组中获取最新的音频能量值,并将其添加到视觉缓冲区中。为了防止内存溢出以及保持性能稳定,当缓冲区长度超过预设的最大窗口数时,则移除最早的数据以保证只展示最近的若干个数据。

if (mode === "record") {
    energy = liveEnergyQueue.shift();
} else {
    energy = energyTimeline[playIndex++];
}

visualBuffer.push(energy);

if (visualBuffer.length > MAX_COLUMNS) {
    visualBuffer.shift();
}

8️⃣ 绘制柱状图

通过从右至左的方式绘制每一根柱形图,可以保证实时更新的视觉效果。在这里,我们根据当前缓冲区的数据来确定每个柱子的高度,并按照指定的颜色和样式进行填充。

const startX = width - barWidth;

for (let i = visualBuffer.length - 1, col = 0; i >= 0; i--, col++) {
    const x = startX - col * step;
    if (x + barWidth < 0) break;

    const e = visualBuffer[i];
    const barHeight = Math.min(
        maxBarHeight,
        Math.max(minBarHeight, e * height * VISUAL_HEIGHT_GAIN),
    );
    const y = (height - barHeight) / 2;
    ctx.fillStyle = "#ff3b30";
    ctx.fillRect(x, y, barWidth, barHeight);
}

9️⃣ Canvas高清适配

为了确保无论在何种分辨率的设备上,音频波形图都能够保持清晰和锐利。我们需要根据当前屏幕上的devicePixelRatio来调整画布的实际尺寸,并通过设置适当的缩放比例来避免任何可能产生的模糊效果。


const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.max(1, Math.floor(rect.width * dpr));
canvas.height = Math.max(1, Math.floor(rect.height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);