AI 应用前端开发技能库 - realtime 实时聊天 & Web Audio

735 阅读11分钟

大家好呀,我是 HOHO,这期咱们继续来讲 AI 应用中一个比较新颖的需求 “实时语音聊天”。

需求 & 效果

这个需求在去年就已经出现了一些 demo,然后在 GPT-4o 发布时引起了一波不小的浪潮。前端方面的效果大致就是下面这样:

动画.gif

启动之后两个实时波形图,边上可能还会有一个消息气泡的列表展示用户与模型的对话。

这个前端实现的难点其实不少:如何获取到用户音频,如何把用户音频转换成波形图,如何把模型的音频数据如何播放,音频数据如何通信等等。对音视频处理不太熟悉的同学可能一时间就有点懵。

所以坐稳扶好,这篇文章我们就来好好探讨一下。

实现思路

我们这篇文章的重点放在如何获取、展示用户及模型的音频以及数据是如何通信上(就是上面 gif 里的效果),至于其他的前端效果,例如消息气泡列表之类的比较简单,这里就不再赘述了。

首先后端的话我们采用 Azure OpenAI GPT4o-realtime 来实现,这个是目前(2024-10-10)来看落地最简单也没有被墙的实时端到端的音频云服务了。后端的服务我推荐按照 如何将函数调用与 Azure OpenAI 服务配合使用 这个 DEMO 把对 openAI 的调用封装成一个 websocket 服务来暴露给前端。因为咱这篇主要讲前端,如果有同学感兴趣的话这个后端服务怎么搞咱们可以后面另开文章讲(其实本篇文章的部分代码也来自于这个 DEMO,所以如果看完本文之后还有些疑惑的话可以去研究一下)。

OK,说回前端。这个需求的几个核心难点如下:

  • 怎么获取用户的音频
  • 怎么播放模型的音频
  • 怎么把音频可视化

我们会分三个小节来具体讲解这三个问题,至于通信方面,我们会采用把音频转成 base64 文本的方式放在 websocket 上传递的方案,具体转换方法我们会在具体小节里进行讲解。

1、怎么获取用户的音频

我们先上代码,然后再一点点分析:

export class Recorder {
  onDataAvailable: (buffer: Iterable<number>) => void;
  private audioContext: AudioContext | null = null;
  private mediaStream: MediaStream | null = null;
  private mediaStreamSource: MediaStreamAudioSourceNode | null = null;
  private workletNode: AudioWorkletNode | null = null;
  public mediaRecorder: MediaRecorder | null = null;

  public constructor(onDataAvailable: (buffer: Iterable<number>) => void) {
    this.onDataAvailable = onDataAvailable;
  }

  async start(stream: MediaStream) {
    try {
      if (this.audioContext) {
        await this.audioContext.close();
      }

      this.audioContext = new AudioContext({ sampleRate: 24000 });

      await this.audioContext.audioWorklet.addModule('./audio-processor-worklet.js');

      this.mediaStream = stream;
      this.mediaStreamSource = this.audioContext.createMediaStreamSource(this.mediaStream);

      this.workletNode = new AudioWorkletNode(this.audioContext, 'audio-processor-worklet');
      this.workletNode.port.onmessage = (event) => {
        this.onDataAvailable(event.data.buffer);
      };

      this.mediaStreamSource.connect(this.workletNode);
      this.workletNode.connect(this.audioContext.destination);

      this.mediaRecorder = new MediaRecorder(this.mediaStream);
      this.mediaRecorder.start();
    } catch (error) {
      this.stop();
    }
  }

  async stop() {
    if (this.mediaStream) {
      this.mediaStream.getTracks().forEach((track) => track.stop());
      this.mediaStream = null;
    }

    if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
      this.mediaRecorder.stop();
      this.mediaRecorder = null;
    }

    if (this.audioContext) {
      await this.audioContext.close();
      this.audioContext = null;
    }

    this.mediaStreamSource = null;
    this.workletNode = null;
  }
}

这里我们把逻辑封装成了一个类,这个类用法很简单,实例化时传入一个回调,这个回调会一次次的接受用户实际的音频数据,然后开放了两个 api startstop 分别用于启动和关闭音频监听。

可以看到这段代码并没有什么依赖,因为它依赖的是 HTML5 中引入的 Web Audio API。这个 API 大家可能会比较陌生(毕竟平时也用不到),接下来我会尽量用大白话给大家解释一下这段代码里用到的一些 api 是干嘛的。

白话 Web Audio

首先能看到的是 AudioContext 这个东西,这个是干嘛的呢?实际上 AudioContext 类似于一个工作台,就像 ps 新建了一个画布,或者 FL Studio 创建了一个音频工程。我们可以用这个东西来配置一些基础的参数,例如上面代码就配置了个采样率。然后就可以用它来大展拳脚啦。

然后是 audioContext.audioWorklet.addModuleAudioWorkletNode 这俩东西,可以看到他们引入了一个额外的 js 文件。这些是干啥的呢?

不知道你之前有没有看过一些搞音乐的视频,专业音乐人都会有一大堆的设备,然后用线缆把一堆设备连在一起,然后就做出来好听的音乐了。这些设备,就相当于我们这段代码中的 XXXNode,我们写音频处理的功能其实也是差不多的,搞出来一堆 Node,然后把这些 Node 连在一起。这些 Node 的种类有很多,比如有产生声音的(生成正弦波,或者那种杂波),或者调整声音的(比如效果器、滤波器)、或者播放声音的(音响、耳机)。

而我们这里做的,就是创建了一个最基础的 Node,音频数据会“流过”这个 Node,然后被我们的代码处理一遍。

然后是各种的 MediaStream,这种其实就是具体的音频数据,是一种 “流”,和文件流类似。我们这里做的操作基本就是获取流,然后把节点连在一起,让音频在这些节点上流来流去。

最后我们创建了一个 MediaRecorder,这个东西是用来录制音视频的,接入一个流之后可以把流转换成文件。很多的音频可视化工具都是对接到了 MediaRecorder 上,我们这里也一样。这个 MediaRecorder 会暴露出去,然后交给波形图工具进行可视化。


下面就是 audio-processor-worklet.js 具体的逻辑:

const MIN_INT16 = -0x8000;
const MAX_INT16 = 0x7fff;

class PCMAudioProcessor extends AudioWorkletProcessor {
  constructor() {
    super();
  }

  process(inputs, outputs, parameters) {
    const input = inputs[0];
    if (input.length > 0) {
      const float32Buffer = input[0];
      const int16Buffer = this.float32ToInt16(float32Buffer);
      this.port.postMessage(int16Buffer);
    }
    return true;
  }

  float32ToInt16(float32Array) {
    const int16Array = new Int16Array(float32Array.length);
    for (let i = 0; i < float32Array.length; i++) {
      let val = Math.floor(float32Array[i] * MAX_INT16);
      val = Math.max(MIN_INT16, Math.min(MAX_INT16, val));
      int16Array[i] = val;
    }
    return int16Array;
  }
}

registerProcessor('audio-processor-worklet', PCMAudioProcessor);

相关的语法和 API 我就不过多介绍了,概括一下就是接受音频数据,然后做一下格式处理,方便后期转换成 base64。

因为音频处理是 CPU 密集型的,所以很多 API 的设计都会用到 worker,这里也不例外。这个文件可以放在 public 目录下,需要就直接引入。

封装为 react hook

ok,基本功能封装好之后,我们来把它对接到 react,代码如下:

import { useRef, useState } from 'react';
import { Recorder } from './recorder';

const BUFFER_SIZE = 4800;

type Parameters = {
  onAudioRecorded: (base64: string) => void;
};

export default function useAudioRecorder({ onAudioRecorded }: Parameters) {
  const audioRecorder = useRef<Recorder>();
  const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null);

  let buffer = new Uint8Array();

  const appendToBuffer = (newData: Uint8Array) => {
    const newBuffer = new Uint8Array(buffer.length + newData.length);
    newBuffer.set(buffer);
    newBuffer.set(newData, buffer.length);
    buffer = newBuffer;
  };

  const handleAudioData = (data: Iterable<number>) => {
    const uint8Array = new Uint8Array(data);
    appendToBuffer(uint8Array);

    if (buffer.length >= BUFFER_SIZE) {
      const toSend = new Uint8Array(buffer.slice(0, BUFFER_SIZE));
      buffer = new Uint8Array(buffer.slice(BUFFER_SIZE));

      const regularArray = String.fromCharCode(...toSend);
      const base64 = btoa(regularArray);

      onAudioRecorded(base64);
    }
  };

  const start = async () => {
    if (!audioRecorder.current) {
      audioRecorder.current = new Recorder(handleAudioData);
    }
    const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
    await audioRecorder.current.start(stream);
    setMediaRecorder(audioRecorder.current.mediaRecorder);
  };

  const stop = async () => {
    await audioRecorder.current?.stop();
    setMediaRecorder(null);
  };

  return { start, stop, mediaRecorder };
}

用法很简单,接受一个回调用来拿到音频的 base64 字符串。然后暴露三个 api:

  • start:开始录音
  • stop:停止录音
  • mediaRecorder:丢给音频可视化工具

核心逻辑也没什么难点,就是陆续接收音频数据,累加、分片并转换成 base64。其中使用了 await navigator.mediaDevices.getUserMedia({ audio: true }) 来获取用户的音频流。

注意这里的 let buffer = new Uint8Array();,之前技术分享的时候有小伙伴觉得这里会有闭包问题,会导致数据丢失。但是实际上不会的,因为回调只会在调用 start 时被绑定到 Recorder 上,也就是说只有调用 start 方法的那个 react 闭包中的 buffer 才会被用到。这个可能有点绕,感兴趣的可以自己研究一下。

2、怎么播放模型的音频

播放模型的音频和录制用户的音频流程上基本是相反的,大致就是:获取 base64 文本 => 处理成音频数据 => 累积到可以播放的时长 => 播放。

所以我们这里也反着来,先来看一下 react 的 hook 封装:

封装为 react hook

import { useRef, useState } from 'react';
import { Player } from './player';

const SAMPLE_RATE = 24000;

export default function useAudioPlayer() {
  const audioPlayer = useRef<Player>();
  const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null);

  const reset = async () => {
    audioPlayer.current = new Player();
    await audioPlayer.current.init(SAMPLE_RATE);
    setMediaRecorder(audioPlayer.current.mediaRecorder);
  };

  const play = (base64Audio: string) => {
    const binary = atob(base64Audio);
    const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
    const pcmData = new Int16Array(bytes.buffer);

    audioPlayer.current?.play(pcmData);
  };

  const stop = () => {
    audioPlayer.current?.stop();
  };

  const clear = () => {
    stop();
    audioPlayer.current = null;

    if (mediaRecorder) mediaRecorder.stop();
    setMediaRecorder(null);
  };

  return { reset, play, stop, clear, mediaRecorder };
}

注意这里有一个和录音器 Recorder 不同的地方,音频播放器 Player 暴露了四个 api,分别是:

  • reset:初始化播放功能
  • play:开始播放音频
  • stop:停止播放音频
  • clear:关闭播放功能

因为录音其实开启了之后就一直存在了,而播放不一样,开启了之后,需要有另一套 api 来控制播放和停止播放功能(上面的 play 和 stop),比如模型在说话时用户突然问了一个问题,那么就需要调用 stop 来打断上次的播放。

这里很重要的一点是 mediaRecorder.stop() 的调用在 clear 方法里,而不是 stop 方法里,不然你可能会发现聊着聊着模型的波形图卡住不动了。

Player 实现

然后我们来看具体的 Player 实现,也是封装成了一个 class:

export class Player {
  private playbackNode: AudioWorkletNode | null = null;
  public mediaRecorder: MediaRecorder | null = null;

  async init(sampleRate: number) {
    const audioContext = new AudioContext({ sampleRate });
    await audioContext.audioWorklet.addModule('audio-playback-worklet.js');

    this.playbackNode = new AudioWorkletNode(audioContext, 'audio-playback-worklet');
    const streamDestiation = audioContext.createMediaStreamDestination();
    this.playbackNode.connect(streamDestiation);
    this.playbackNode.connect(audioContext.destination);

    this.mediaRecorder = new MediaRecorder(streamDestiation.stream);
    this.mediaRecorder.start();
  }

  play(buffer: Int16Array) {
    if (this.playbackNode) {
      this.playbackNode.port.postMessage(buffer);
    }
  }

  stop() {
    if (this.playbackNode) {
      this.playbackNode.port.postMessage(null);
    }
  }
}

逻辑和 Recorder 类似,只不过我们这边是数据先传递给了 playbackNode,然后这个 node 连接到了两个地方,一个地方用来播放声音(audioContext.destination),一个地方用来进行可视化(streamDestiation)。


下面就是 audio-playback-worklet.js 的具体逻辑:

class AudioPlaybackWorklet extends AudioWorkletProcessor {
  constructor() {
    super();
    this.port.onmessage = this.handleMessage.bind(this);
    this.buffer = [];
  }

  handleMessage(event) {
    if (event.data === null) {
      this.buffer = [];
      return;
    }
    this.buffer.push(...event.data);
  }

  process(inputs, outputs, parameters) {
    const output = outputs[0];
    const channel = output[0];

    if (this.buffer.length > channel.length) {
      const toProcess = this.buffer.slice(0, channel.length);
      this.buffer = this.buffer.slice(channel.length);
      channel.set(toProcess.map((v) => v / 32768));
    } else {
      channel.set(this.buffer.map((v) => v / 32768));
      this.buffer = [];
    }

    return true;
  }
}

registerProcessor('audio-playback-worklet', AudioPlaybackWorklet);

逻辑就是收集音频数据,收集够足够时长了再播,说白了就是缓冲一下再播放。

3、音频可视化

我这里使用的音频可视化工具是 samhirtarif/react-audio-visualize,这个插件可以接收一个 mediaRecorder 并将其显示出来。正好我们刚才的播放器 Player 和录音器 Recorder 也都各自暴露了自己的 mediaRecorder。我们直接把他们连接起来就可以了:

import { FC } from 'react';
import useAudioPlayer from './components/audio-player';
import useAudioRecorder from './components/audio-recorder';
import { LiveAudioVisualizer } from './components/live-audio-visualizer';

export const Realtime: FC = () => {
  const audioPlayer = useAudioPlayer();

  const audioRecorder = useAudioRecorder({
    onAudioRecorded: (message) => {
      // 发送到 websocket
    },
  });

  // 点击“开始会话”按钮时调用
  audioPlayer.reset
  audioRecorder.start

  // 点击“结束会话”按钮时调用
  audioPlayer.clear
  audioRecorder.stop

  // webscoket 收到 “传递音频数据” 事件时调用
  audioPlayer.play

  // webscoket 收到 “开始输出模型音频” 事件时调用
  audioPlayer.stop

  return (
    <>
      {audioPlayer.mediaRecorder ? (
        <LiveAudioVisualizer
          mediaRecorder={audioPlayer.mediaRecorder}
          width={200}
          height={200}
          gap={4}
          barWidth={4}
        />
      ) : (
        <div>会话未启动</div>
      )}
      {audioRecorder.mediaRecorder ? (
        <LiveAudioVisualizer
          mediaRecorder={audioRecorder.mediaRecorder}
          width={200}
          height={200}
          gap={4}
          barWidth={4}
        />
      ) : (
        <div>会话未启动</div>
      )}
    </>
  );
};

上面也提到了我们刚才暴露出来的那些 api 应该连接到哪些功能,都是一些胶水代码,这里大家根据自己的情况来处理即可。

可视化初始最小值处理

上面用到的这个插件 LiveAudioVisualizer 有一个小问题,就是它不支持设置音柱的最小高度,也就是说,当你开始聊天但还没有说话时,你会发现波形图实际上是不会显示的,只有当你开始说话的时候波形图才会正常显示出来。这会导致用户以为自己的会话没有正常启动。

那么怎么搞才能让波形图默认显示一排最小的柱子?

其实没什么好办法,我这里是直接把它的源码复制了出来,然后把 react-audio-visualize/src/LiveAudioVisualizer/utils.ts 这一行的代码改成:

const h = dp + 5 || 5;

就行了,5 是你希望的最小高度。如果你感兴趣的话,可以完善一下给这个库提交一个 pr。

总结

OK,到这里我们本文的内容就算结束了。实际上回头看过去,具体的逻辑并不算复杂,就是录音转 base64 提交给 ws,然后从 ws 接受 base64 再转成音频,最后把两种音频可视化出来。但是核心的难点是对 Web Audio API 比较陌生,可能两三行的代码就要研究两个小时才能找出来,所以整理这篇文章就是为了避免大家再重复浪费脑细胞。

如果你对前端 AI 领域的其他需求感兴趣,可以来看一下我写的相关系列文章 AI 应用前端开发技能库 哦~