使用 WebRTC + React + WebAudio 创建空间音频

实时音频是我们现代数字生活中至关重要的一部分。它使我们无论身处世界何处都能相互联系。最近实时社交应用的一个大趋势是空间音频(有时也称为定位音频)。

0:00
/0:30

传统的音频应用会在每只耳朵以相同、平衡的音量播放每个参与者的声音——而空间音频应用则会根据他们在虚拟世界中相对于你的方向和距离来修改播放方式。如果某人位于你的左侧,他们的音频在你的左耳中会更大声。如果某人离你很远,他们的声音会比离你近的人更安静。这种基于物理的音频为用户提供了普通“电话通话式”音频无法提供的沉浸感和上下文。

在本教程中,我们将逐步介绍如何在 WebRTC React 应用中实现空间音频。如果你不熟悉 WebRTC,完全没问题!WebRTC 代表 Web Real-Time Communication(网页实时通信)。它是一个开放标准,现代浏览器都实现了它,用于创建实时视频、音频和数据功能。你不需要成为 WebRTC 专家来理解本教程,但熟悉一些概念可能会有帮助。

项目设置

本文将主要关注与空间音频相关的代码——因此我们不会详细介绍项目设置。如果你想深入了解 WebRTC 项目设置,可以查看完整的示例应用

示例应用使用 LiveKit 与 WebRTC 进行交互。LiveKit 是一个开源的 WebRTC SFU 和一套适用于所有主要平台的客户端库。它是成千上万 WebRTC 开发人员的首选库,每天为数百万个 WebRTC 会话提供支持。LiveKit 也与你正在访问的网站同名 😉。

运行示例应用最简单的方法是使用 LiveKit Cloud。我将跳过整个营销宣传,但我们制作了一个迷你教程,展示了如何设置项目。

架构

在深入研究空间音频代码之前,有必要简要介绍一下应用的高级架构。

该应用有一个前端组件和一个后端组件。后端的工作纯粹是为了生成令牌。它是允许谁连接到房间的看门人。前端实现空间音频,并将麦克风和位置数据发送到 LiveKit Cloud。

应用架构图

空间音频

好的,既然我们已经介绍了所有这些内容,我们就可以进入本文的重点了:实现空间音频功能。

设计 API

首先让我们思考一下我们正在解决的问题。空间音频意味着每个参与者,无论是本地还是远程,都有一个位置。在本文中,我们将保持 2D 维度,但这些概念很容易扩展到 3D。

(如果你对查看 3D 空间音频的示例应用感兴趣,请加入我们的 Slack 并告诉我们。)

因此,对于 SpatialAudioController 的输入,我们有本地用户的 (x,y) 和每个远程用户的 (x,y)。在 LiveKit 中,用户被称为参与者(Participants)——所以我们可以说每个参与者都有一个 (x,y)

但我们实际上会使我们的 SpatialAudioController 更加通用一些,因为我们可能不希望参与者的所有音频都在相同的位置进行空间化。例如,如果参与者正在说话*并且*播放音乐,我们可能希望音乐具有全局音频,而麦克风具有空间音频。

因此,我们不会为每个参与者使用 (x,y),而是为每个空间 TrackPublication 使用一个 (x,y),然后使用你自己的位置的 (x,y)

所以这是我们 API 在代码中的样子

type Vector2 = {
  x: number;
  y: number;
}

type TrackPosition = {
  trackPublication: TrackPublication;
  position: Vector2;
};

type SpatialAudioControllerProps = {
  trackPositions: TrackPosition[];
  myPosition: Vector2;
};

使用 PannerNode

好的,我们已经设计了 API——现在让我们让 SpatialAudioController 来空间渲染音频。为此,我们将使用 WebAudio,特别是 WebAudio 中的 PannerNode。

PannerNode 接受一个 (x,y,z) 作为位置以及一个 (x,y,z) 作为方向,并相应地修改音频。由于我们处于 2D 维度,我们不会使用方向。2D 和 3D 之间的另一个区别是坐标系。PannerNode 使用 y 作为垂直分量,x 和 z 作为平面分量。在 2D 中,我们只有平面分量,所以我们仍然将 x 映射到 x,但我们会将 y 映射到 z。

请注意,PannerNode 不接受 2 个位置(本地和远程),它只接受一个位置。因此,我们将使用数学来将我们的两个位置转换为我们可以提供给 PannerNode 的相对位置。幸运的是,数学很简单

const relativePosition = {
  x: remotePosition.x - myPosition.x, 
  y: remotePosition.y - myPosition.y 
}

渲染单个空间音频轨道

因此,有了这个,让我们使用 PannerNode 并创建一个组件来空间渲染单个轨道

type SpatialPublicationRendererProps = {
  trackPublication: TrackPublication;
  position: { x: number; y: number };
  myPosition: { x: number; y: number };
  audioContext: AudioContext;
};

function SpatialPublicationRenderer({
  trackPublication,
  position,
  myPosition,
  audioContext
}: PublicationRendererProps) {
  const audioEl = useRef<HTMLAudioElement | null>(null);
  const sourceNode = useRef<MediaStreamAudioSourceNode | null>(null);
  const panner = useRef<PannerNode | null>(null);
  const [relativePosition, setRelativePosition] = useState<{
    x: number;
    y: number;
  }>({
    x: 1000,
    y: 1000,
  }); // Set as very far away for our initial values

  // Get the media stream from the track publication
  const mediaStream = useMemo(() => {
    if (
      trackPublication instanceof LocalTrackPublication &&
      trackPublication.track
    ) {
      const mediaStreamTrack = trackPublication.track.mediaStreamTrack;
      return new MediaStream([mediaStreamTrack]);
    }

    return trackPublication.track?.mediaStream || null;
  }, [trackPublication]);

  // Cleanup function for all of the WebAudio nodes we made
  const cleanupWebAudio = useCallback(() => {
    if (panner.current) panner.current.disconnect();
    if (sourceNode.current) sourceNode.current.disconnect();

    panner.current = null;
    sourceNode.current = null;
  }, []);

  // Calculate relative position when position changes
  useEffect(() => {
    setRelativePosition((prev) => {
      return {
        x: position.x - myPosition.x,
        y: position.y - myPosition.y,
      };
    });
  }, [myPosition.x, myPosition.y, position.x, position.y]);

  // Setup panner node for desktop
  useEffect(() => {
    // Cleanup any other nodes we may have previously created
    cleanupWebAudio();

    // Early out if we're missing anything
    if (!audioEl.current || !trackPublication.track || !mediaStream)
      return cleanupWebAudio;

    // Create the entry-node into WebAudio. 
    // This turns our mediaStream into a usable WebAudio node.
    sourceNode.current = audioContext.createMediaStreamSource(mediaStream);

    // Initialize the PannerNode and its values
    panner.current = audioContext.createPanner();
    panner.current.coneOuterAngle = 360;
    panner.current.coneInnerAngle = 360;
    panner.current.positionX.setValueAtTime(1000, 0); // set far away initially so we don't hear it at full volume
    panner.current.positionY.setValueAtTime(0, 0);
    panner.current.positionZ.setValueAtTime(0, 0);
    panner.current.distanceModel = "exponential";
    panner.current.coneOuterGain = 1;
    panner.current.refDistance = 100;
    panner.current.maxDistance = 500;
    panner.current.rolloffFactor = 2;
    
    // Connect the nodes to each other 
    sourceNode.current
      .connect(panner.current)
      .connect(audioContext.destination);
    
    // Attach the mediaStream to an AudioElement. This is just a
    // quirky requirement of WebAudio to get the pipeline to play
    // when dealing with MediaStreamAudioSource nodes
    audioEl.current.srcObject = mediaStream;
    audioEl.current.play();

    return cleanupWebAudio;
  }, [
    panner,
    trackPublication.track,
    cleanupWebAudio,
    audioContext,
    trackPublication,
    mediaStream,
  ]);

  // Update the PannerNode's position values to our
  // calculated relative position.
  useEffect(() => {
    if (!audioEl.current || !panner.current) return;
    panner.current.positionX.setTargetAtTime(relativePosition.x, 0, 0.02);
    panner.current.positionZ.setTargetAtTime(relativePosition.y, 0, 0.02);
  }, [relativePosition.x, relativePosition.y, panner]);

  return (<audio muted={true} ref={audioEl} />);
}

就是这样,这就是你用空间音频空间渲染单个 TrackPublication 所需要的一切。

整合所有内容

我们现在可以填充 SpatialAudioController 来空间渲染 TrackPublications 列表

export function SpatialAudioController({
  trackPositions,
  myPosition,
}: SpatialAudioControllerProps) {
  const audioContext = useMemo(() => new AudioContext(), []);
  return (
    <>
      {trackPositions.map((tp) => {
        return (
          <SpatialPublicationRenderer
            key={`${tp.trackPublication.trackSid}`}
            trackPublication={tp.trackPublication}
            position={tp.position}
            myPosition={myPosition}
            audioContext={audioContext}
          />
        );
      })}
    </>
  );
}

示例应用中完整的空间音频渲染代码可以在示例项目仓库的 SpatialAudioController.tsx 文件中找到。

结论

在这篇文章中,我们向你展示了如何将 WebRTC 与 WebAudio 结合使用来创建空间音频。使用这种技术的完整演示托管在 spatial-audio-demo.livekit.io,你可以随意试用。

我们将很快推出有关此示例应用其他方面的教程,例如如何使用数据通道发送播放器位置数据。在此期间,请查看 https://github.com/livekit-examples/spatial-audio 获取完整的示例应用源代码。