2023/04/14

集成 React Flow 和 Web Audio API

Hayleigh Thompson
软件工程师

今天我们将探讨如何使用 React Flow 和 Web Audio API 创建一个交互式音频游乐场。我们将从头开始,首先了解 Web Audio API,然后看看如何在 React Flow 中处理许多常见场景:状态管理、实现自定义节点以及添加交互性。

A screenshot of bleep.cafe, a visual audio programming environment. In it, there are four nodes connected together: an xy pad, an oscillator node, a volume node, and a master output.
这是 bleep.cafe。我们将学习构建类似项目所需的一切!

前段时间,我在 React Flow Discord 服务器 上分享了一个正在进行的项目。它被称为 bleep.cafe,它是一个小型网络应用程序,用于在浏览器内学习数字合成。许多人对这种东西的制作方式很感兴趣:大多数人甚至不知道 **他们的浏览器内置了完整的合成器引擎!**

本教程将带我们一步一步地构建类似的东西。我们可能会跳过这里和那里的某些部分,但总的来说,如果你对 React Flow **或** Web Audio API 比较陌生,你应该能够跟着做,并最终得到一个可用的东西。

如果你已经是 React Flow 专家,你可能想阅读第一部分,介绍 Web Audio API,然后跳到第三部分,看看它是如何联系在一起的!

但首先...

一个演示!

⚠️

本教程中的这个和其他示例都会 **发出声音**。

为了避免创造出一部先锋派杰作,请记住在继续之前将每个示例静音!

Web Audio API

在我们深入研究 React Flow 和交互式节点编辑器的优点之前,我们需要快速了解一下 Web Audio API。以下是你需要知道的要点

  • Web Audio API 提供了各种不同的音频节点,包括源(例如 OscillatorNodeMediaElementAudioSourceNode)、效果(例如 GainNodeDelayNodeConvolverNode)和输出(例如 AudioDestinationNode)。
  • 音频节点可以连接在一起,形成一个(可能是循环的)图。我们倾向于称之为音频处理图、信号图或信号链。
  • 音频处理由原生代码在单独的线程中处理。这意味着即使主 UI 线程繁忙或阻塞,我们也可以继续生成声音。
  • 一个 AudioContext 充当音频处理图的大脑。我们可以用它来创建新的音频节点,并完全暂停或恢复音频处理。

你好,声音!

让我们看看这些东西在实际应用中的情况,并构建我们第一个 Web Audio 应用程序!我们不会做任何过于疯狂的事情:我们将做一个简单的鼠标 特雷门琴。对于这些示例以及之后的所有示例,我们将使用 React,以及 vite 来处理捆绑和热重载。

如果你更喜欢其他捆绑器,比如 parcel 或 Create React App,那也没问题,它们的功能基本上是一样的。你也可以选择使用 TypeScript 而不是 JavaScript。为了简单起见,我们今天不会使用它,但 React Flow 是完全类型的(并且完全用 TypeScript 编写),所以它非常容易使用!

npm create vite@latest -- --template react

Vite 将为我们搭建一个简单的 React 应用程序,但可以删除资产并直接进入 App.jsx。删除为我们生成的演示组件,并开始创建一个新的 AudioContext,并将我们需要的节点组合在一起。我们想要一个 OscillatorNode 来生成一些音调,以及一个 GainNode 来控制音量。

./src/App.jsx
// Create the brain of our audio-processing graph
const context = new AudioContext();
 
// Create an oscillator node to generate tones
const osc = context.createOscillator();
 
// Create a gain node to control the volume
const amp = context.createGain();
 
// Pass the oscillator's output through the gain node and to our speakers
osc.connect(amp);
amp.connect(context.destination);
 
// Start generating those tones!
osc.start();

振荡器节点需要启动。

不要忘记调用 osc.start。如果没有它,振荡器将不会开始生成音调!

对于我们的应用程序,我们将跟踪鼠标在屏幕上的位置,并用它来设置振荡器节点的音调和增益节点的音量。

./src/App.jsx
import React from 'react';
 
const context = new AudioContext();
const osc = context.createOscillator();
const amp = context.createGain();
 
osc.connect(amp);
amp.connect(context.destination);
 
osc.start();
 
const updateValues = (e) => {
  const freq = (e.clientX / window.innerWidth) * 1000;
  const gain = e.clientY / window.innerHeight;
 
  osc.frequency.value = freq;
  amp.gain.value = gain;
};
 
export default function App() {
  return (
    <div
      style={{ width: '100vw', height: '100vh' }}
      onMouseMove={updateValues}
    />
  );
}

osc.frequency.valueamp.gain.value...

Web Audio API 区分了简单的对象属性和音频节点 **参数**。这种区分以 AudioParam 的形式出现。你可以在 MDN 文档 中了解它们,但现在你只要知道,你需要使用 .value 来设置 AudioParam 的值,而不能直接将值分配给属性。

如果你尝试运行这个示例,你可能会发现什么都没发生。为了避免广告劫持我们的扬声器,AudioContext 通常会从暂停状态开始。我们可以通过在 <div /> 上添加一个点击处理程序来轻松解决这个问题,如果它处于暂停状态,则恢复上下文。

./src/App.jsx
const toggleAudio = () => {
  if (context.state === 'suspended') {
    context.resume();
  } else {
    context.suspend();
  }
};
 
export default function App() {
  return (
    <div ...
      onClick={toggleAudio}
    />
  );
};

这就是我们开始使用 Web Audio API 制作声音所需的一切!以下是我们整理的内容,以防你没有在家跟着做

现在让我们把这些知识放在一边,看看如何从头开始构建一个 React Flow 项目。

已经是 React Flow 专家了吗?如果您已经熟悉 React Flow,您可以跳过下一部分,直接进入 制作一些声音。对于其他所有人,让我们看看如何从头开始构建 React Flow 项目。

搭建 React Flow 项目

稍后我们将利用我们对 Web Audio API、振荡器和增益节点的了解,使用 React Flow 交互式地构建音频处理图。不过现在,我们需要构建一个空的 React Flow 应用程序。

我们已经使用 Vite 设置了 React 应用程序,因此我们将继续使用它。如果您跳过了上一部分,我们运行了 npm create vite@latest -- --template react 来开始。不过,您可以使用您喜欢的任何捆绑器和/或开发服务器。这里没有任何东西是特定于 vite 的。

此项目只需要三个额外的依赖项:@xyflow/react 用于我们的 UI(显然!)、zustand 作为我们简单的状态管理库(这就是我们在 React Flow 背后使用的东西),以及 nanoid 作为轻量级 ID 生成器。

npm install @xyflow/react zustand nanoid

我们将从 Web Audio 速成课程中删除所有内容并从头开始。首先修改 main.jsx 以匹配以下内容

./src/main.jsx
import App from './App';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ReactFlowProvider } from '@xyflow/react';
 
// 👇 Don't forget to import the styles!
import '@xyflow/react/dist/style.css';
import './index.css';
 
const root = document.querySelector('#root');
 
ReactDOM.createRoot(root).render(
  <React.StrictMode>
    {/* React flow needs to be inside an element with a known height and width to work */}
    <div style={{ width: '100vw', height: '100vh' }}>
      <ReactFlowProvider>
        <App />
      </ReactFlowProvider>
    </div>
  </React.StrictMode>,
);

这里有三个需要注意的重要事项

  1. 您需要记住 **导入 React Flow CSS 样式** 以确保一切正常工作。
  2. React Flow 渲染器需要位于具有已知高度和宽度的元素内部,因此我们将包含的 <div /> 设置为占据整个屏幕。
  3. 要使用 React Flow 提供的一些钩子,您的组件需要位于 <ReactFlowProvider /> 内部或 <ReactFlow /> 组件本身内部,因此我们将整个应用程序包装在提供程序中以确保。

接下来,进入 App.jsx 并创建一个空的流程

./src/App.jsx
import React from 'react';
import { ReactFlow, Background } from '@xyflow/react';
 
export default function App() {
  return (
    <ReactFlow>
      <Background />
    </ReactFlow>
  );
}

随着时间的推移,我们将扩展并添加到此组件中。现在,我们添加了 React Flow 的一个插件 - <Background /> - 来检查一切是否已正确设置。继续运行 npm run dev(或者如果您没有选择 vite,则需要做的任何事情来启动开发服务器)并查看您的浏览器。您应该看到一个空的流程

Screenshot of an empty React Flow graph

让开发服务器保持运行状态。随着我们添加新的部件,我们可以继续查看我们的进度。

1. 使用 Zustand 进行状态管理

Zustand 存储将保存我们应用程序的所有 UI 状态。从实际意义上来说,这意味着它将保存 React Flow 图的节点和边、其他一些状态片段以及一些用于更新该状态的 *操作*。

为了启动一个基本的交互式 React Flow 图,我们需要三个操作

  1. onNodesChange 用于处理节点的移动或删除。
  2. onEdgesChange 用于处理 *边* 的移动或删除。
  3. addEdge 用于连接图中的两个节点。

继续创建一个新文件 store.js,并添加以下内容

./src/store.js
import { applyNodeChanges, applyEdgeChanges } from '@xyflow/react';
import { nanoid } from 'nanoid';
import { createWithEqualityFn } from 'zustand/traditional';
 
export const useStore = createWithEqualityFn((set, get) => ({
  nodes: [],
  edges: [],
 
  onNodesChange(changes) {
    set({
      nodes: applyNodeChanges(changes, get().nodes),
    });
  },
 
  onEdgesChange(changes) {
    set({
      edges: applyEdgeChanges(changes, get().edges),
    });
  },
 
  addEdge(data) {
    const id = nanoid(6);
    const edge = { id, ...data };
 
    set({ edges: [edge, ...get().edges] });
  },
}));

Zustand 的使用非常简单。我们创建一个接收 setget 函数的函数,并返回一个对象,该对象包含我们的初始状态以及我们可用于更新该状态的操作。更新是不可变的,我们可以使用 set 函数来完成。get 函数是读取当前状态的方式。就是这样,Zustand 就完成了。

onNodesChangeonEdgesChange 中的 changes 参数表示事件,例如节点或边的移动或删除。幸运的是,React Flow 提供了一些 辅助 函数 来帮我们应用这些更改。我们只需要使用新的节点数组更新存储即可。

addEdge 将在两个节点连接时被调用。data 参数 *几乎* 是一个有效的边,只是缺少一个 ID。在这里,我们使用 nanoid 生成一个 6 个字符的随机 ID,然后将边添加到我们的图形中,没什么特别的。

如果我们回到 <App /> 组件,我们可以将 React Flow 连接到我们的操作并使其正常工作。

./src/App.jsx
import React from 'react';
import { ReactFlow, Background } from '@xyflow/react';
import { shallow } from 'zustand/shallow';
 
import { useStore } from './store';
 
const selector = (store) => ({
  nodes: store.nodes,
  edges: store.edges,
  onNodesChange: store.onNodesChange,
  onEdgesChange: store.onEdgesChange,
  addEdge: store.addEdge,
});
 
export default function App() {
  const store = useStore(selector, shallow);
 
  return (
    <ReactFlow
      nodes={store.nodes}
      edges={store.edges}
      onNodesChange={store.onNodesChange}
      onEdgesChange={store.onEdgesChange}
      onConnect={store.addEdge}
    >
      <Background />
    </ReactFlow>
  );
}

那么这个 selector 是怎么回事?Zustand 允许我们提供一个选择器函数,从存储中提取我们需要的精确状态片段。结合 shallow 等式函数,这意味着当我们不关心的状态发生变化时,通常不会重新渲染。

现在,我们的存储很小,我们实际上需要存储中的所有内容来帮助渲染 React Flow 图,但是随着我们对其进行扩展,这个选择器将确保我们不会一直重新渲染 *所有内容*。

这是我们拥有交互式图形所需的一切:我们可以移动节点、将它们连接在一起并删除它们。为了进行演示,暂时 将一些虚拟节点添加到您的存储中

./store.jsx
const useStore = createWithEqualityFn((set, get) => ({
  nodes: [
    { id: 'a', data: { label: 'oscillator' }, position: { x: 0, y: 0 } },
    { id: 'b', data: { label: 'gain' }, position: { x: 50, y: 50 } },
    { id: 'c', data: { label: 'output' }, position: { x: -50, y: 100 } }
  ],
  ...
}));
export default function App() {
  const data: string = "world"

  return <h1>Hello {data}</h1>
}

只读

2. 自定义节点

好的,我们有一个可以开始使用的交互式 React Flow 实例。我们添加了一些虚拟节点,但它们现在只是默认的无样式节点。在这一步中,我们将添加三个带有交互式控件的自定义节点

  1. 一个振荡器节点,以及用于音高和波形类型的控件。
  2. 一个增益节点,以及用于音量控制。
  3. 一个输出节点,以及一个用于切换音频处理的按钮。

让我们创建一个新文件夹 nodes/,并为我们想要创建的每个自定义节点创建一个文件。从振荡器开始,我们需要两个控件和一个源句柄来连接振荡器的输出到其他节点。

./src/nodes/Osc.jsx
import React from 'react';
import { Handle } from '@xyflow/react';
 
import { useStore } from '../store';
 
export default function Osc({ id, data }) {
  return (
    <div>
      <div>
        <p>Oscillator Node</p>
 
        <label>
          <span>Frequency</span>
          <input
            className="nodrag"
            type="range"
            min="10"
            max="1000"
            value={data.frequency} />
          <span>{data.frequency}Hz</span>
        </label>
 
        <label>
          <span>Waveform</span>
          <select className="nodrag" value={data.type}>
            <option value="sine">sine</option>
            <option value="triangle">triangle</option>
            <option value="sawtooth">sawtooth</option>
            <option value="square">square</option>
          </select>
      </div>
 
      <Handle type="source" position="bottom" />
    </div>
  );
};

“nodrag” 很重要。

请注意 "nodrag" 类已添加到 <input /><select /> 元素中。*非常重要* 的是您要记住添加此类,否则您会发现 React Flow 会拦截鼠标事件,您将永远卡在拖动节点周围!

如果我们尝试渲染此自定义节点,我们会发现输入不起作用。这是因为输入值由 data.frequencydata.type 确定,但我们没有监听更改的事件处理程序,也没有更新节点数据的机制!

为了解决这种情况,我们需要回到我们的存储并添加一个 updateNode 操作

./src/store.js
export const useStore = createWithEqualityFn((set, get) => ({
  ...
 
  updateNode(id, data) {
    set({
      nodes: get().nodes.map(node =>
        node.id === id
          ? { ...node, data: { ...node.data, ...data } }
          : node
      )
    });
  },
 
  ...
}));

此操作将处理部分数据更新,例如,如果我们只想更新节点的 frequency,我们可以简单地调用 updateNode(id, { frequency: 220 }。现在我们只需要将操作引入到 <Osc /> 组件中,并在输入发生变化时调用它即可。

./src/nodes/Osc.jsx
import React from 'react';
import { Handle } from '@xyflow/react';
import { shallow } from 'zustand/shallow';
 
import { useStore } from '../store';
 
const selector = (id) => (store) => ({
  setFrequency: (e) => store.updateNode(id, { frequency: +e.target.value }),
  setType: (e) => store.updateNode(id, { type: e.target.value }),
});
 
export default function Osc({ id, data }) {
  const { setFrequency, setType } = useStore(selector(id), shallow);
 
  return (
    <div>
      <div>
        <p>Oscillator Node</p>
 
        <label>
          <span>Frequency:</span>
          <input
            className="nodrag"
            type="range"
            min="10"
            max="1000"
            value={data.frequency}
            onChange={setFrequency}
          />
          <span>{data.frequency}Hz</span>
        </label>
 
        <label>
          <span>Waveform:</span>
          <select className="nodrag" value={data.type} onChange={setType}>
            <option value="sine">sine</option>
            <option value="triangle">triangle</option>
            <option value="sawtooth">sawtooth</option>
            <option value="square">square</option>
          </select>
        </label>
      </div>
 
      <Handle type="source" position="bottom" />
    </div>
  );
}

嘿,那个 selector 又回来了!请注意,这次我们使用它从通用的 updateNode 操作中派生出两个事件处理程序 setFrequencysetType

最后一个难题是告诉 React Flow 如何渲染我们的自定义节点。为此,我们需要创建一个 nodeTypes 对象:键应该对应于节点的 type,而值将是要渲染的 React 组件。

./src/App.jsx
import React from 'react';
import { ReactFlow } from '@xyflow/react';
import { shallow } from 'zustand/shallow';
 
import { useStore } from './store';
import Osc from './nodes/Osc';
 
const selector = (store) => ({
  nodes: store.nodes,
  edges: store.edges,
  onNodesChange: store.onNodesChange,
  onEdgesChange: store.onEdgesChange,
  addEdge: store.addEdge,
});
 
const nodeTypes = {
  osc: Osc,
};
 
export default function App() {
  const store = useStore(selector, shallow);
 
  return (
    <ReactFlow
      nodes={store.nodes}
      nodeTypes={nodeTypes}
      edges={store.edges}
      onNodesChange={store.onNodesChange}
      onEdgesChange={store.onEdgesChange}
      onConnect={store.addEdge}
    >
      <Background />
    </ReactFlow>
  );
}

避免不必要的渲染。

<App /> 组件之外定义 nodeTypes(或使用 React 的 useMemo)非常重要,以避免在每次渲染时重新计算它。

如果您正在运行开发服务器,请不要惊慌,因为目前还没有变化!我们所有的临时节点都还没有被赋予正确的类型,因此 React Flow 只会回退到渲染默认节点。如果我们将其中一个节点更改为 osc,并为 frequencytype 提供一些初始值,我们应该会看到我们的自定义节点被渲染出来。

const useStore = createWithEqualityFn((set, get) => ({
  nodes: [
    { type: 'osc',
      id: 'a',
      data: { frequency: 220, type: 'square' },
      position: { x: 0, y: 0 }
    },
    ...
  ],
  ...
}));

在样式方面遇到困难吗?

如果您只是按步骤实施本文中的代码,您会发现您的自定义节点与上面的预览中的节点看起来不一样。为了使内容易于理解,我们在代码片段中省略了样式。

要了解如何设置自定义节点的样式,请查看我们关于 主题 的文档或我们使用 Tailwind 的示例。

实现增益节点的过程几乎相同,因此我们将把这个留给您。相反,我们将把注意力转向输出节点。此节点将没有任何参数控制,但我们确实想要切换信号处理的开/关状态。现在,当我们还没有实现任何音频代码时,这有点困难,因此,在此期间,我们将只向我们的存储添加一个标志以及一个用于切换它的操作。

./src/store.js
const useStore = createWithEqualityFn((set, get) => ({
  ...
 
  isRunning: false,
 
  toggleAudio() {
    set({ isRunning: !get().isRunning });
  },
 
  ...
}));

自定义节点本身非常简单

./src/nodes/Out.jsx
import React from 'react';
import { Handle } from '@xyflow/react';
import { shallow } from 'zustand/shallow';
import { useStore } from '../store';
 
const selector = (store) => ({
  isRunning: store.isRunning,
  toggleAudio: store.toggleAudio,
});
 
export default function Out({ id, data }) {
  const { isRunning, toggleAudio } = useStore(selector, shallow);
 
  return (
    <div>
      <Handle type="target" position="top" />
 
      <div>
        <p>Output Node</p>
 
        <button onClick={toggleAudio}>
          {isRunning ? (
            <span role="img" aria-label="mute">
              🔇
            </span>
          ) : (
            <span role="img" aria-label="unmute">
              🔈
            </span>
          )}
        </button>
      </div>
    </div>
  );
}

事情开始顺利地成形了!

那么,下一步是…

进行声音处理

我们有一个交互式图形,并且可以更新节点数据,现在让我们添加我们对 Web Audio API 的了解。首先创建一个新文件 audio.js,并创建一个新的音频上下文和一个空的 Map

./src/audio.js
const context = new AudioContext();
const nodes = new Map();

我们将通过连接到存储中的不同操作来管理我们的音频图形。因此,我们可能会在调用 addEdge 操作时连接两个音频节点,或者在调用 updateNode 操作时更新音频节点的属性,等等。

⚠️

硬编码节点

我们在本文前面硬编码了几个节点,但我们的音频图形对此一无所知!在完成的项目中,我们可以删除所有这些硬编码的部分,但现在,**非常重要** 的是我们还需要对一些音频节点进行硬编码。

以下是我们的做法

./src/audio.js
const context = new AudioContext();
const nodes = new Map();
 
const osc = context.createOscillator();
osc.frequency.value = 220;
osc.type = 'square';
osc.start();
 
const amp = context.createGain();
amp.gain.value = 0.5;
 
const out = context.destination;
 
nodes.set('a', osc);
nodes.set('b', amp);
nodes.set('c', out);

1. 节点更改

现在,我们的图中可能发生两种类型的节点更改,我们需要对此进行响应:更新节点的 data 和从图中删除节点。我们已经为前者定义了操作,所以让我们先处理它。

audio.js 中,我们将定义一个名为 updateAudioNode 的函数,我们将使用节点 ID 和部分 data 对象来调用它,并用它来更新 Map 中的现有节点。

./src/audio.js
export function updateAudioNode(id, data) {
  const node = nodes.get(id);
 
  for (const [key, val] of Object.entries(data)) {
    if (node[key] instanceof AudioParam) {
      node[key].value = val;
    } else {
      node[key] = val;
    }
  }
}

请记住,音频节点上的属性可能是特殊的 AudioParams,必须以与普通对象属性不同的方式更新。

现在,我们需要更新存储中的 updateNode 操作,以将此函数作为更新的一部分进行调用。

./src/store.js
import { updateAudioNode } from './audio';
 
export const useStore = createWithEqualityFn((set, get) => ({
  ...
 
  updateNode(id, data) {
    updateAudioNode(id, data);
    set({ nodes: ... });
  },
 
  ...
}));
 

我们需要处理的下一个更改是从图中删除节点。如果在图中选择一个节点并按退格键,React Flow 将删除它。这由我们连接的 onNodesChange 操作为我们隐式处理,但现在我们需要一些额外的处理,我们需要将新的操作连接到 React Flow 的 onNodesDelete 事件。

这实际上非常简单,所以我将节省您一些阅读时间,并提供接下来的三个代码片段,不作任何评论。

export function removeAudioNode(id) {
  const node = nodes.get(id);
 
  node.disconnect();
  node.stop?.();
 
  nodes.delete(id);
}

需要注意的是,onNodesDelete 使用数组形式的已删除节点调用提供的回调函数,因为可以一次删除多个节点!

2. 边缘更改

我们离真正发出声音越来越近了!剩下的就是处理图的边缘更改。与节点更改一样,我们已经拥有一个操作来处理创建新的边缘,我们还在 onEdgesChange 中隐式处理已删除的边缘。

要处理新连接,我们只需要来自 addEdge 操作中创建的边缘的 sourcetarget ID。然后,我们只需在 Map 中查找这两个节点并将其连接起来。

export function connect(sourceId, targetId) {
  const source = nodes.get(sourceId);
  const target = nodes.get(targetId);
 
  source.connect(target);
}

我们看到 React Flow 接受了一个 onNodesDelete 处理程序,您可能不知道,它也有一个 onEdgesDelete 处理程序!我们用来实现 disconnect 并将其连接到我们的存储和 React Flow 实例的方法与以前几乎相同,所以我们也会把这个留给您来完成!

3. 打开扬声器

您会记得,我们的 AudioContext 可能处于挂起状态,以防止出现潜在的烦人自动播放问题。我们已经在存储中为 <Out /> 组件伪造了所需的数据和操作,现在我们只需要用真实上下文的 state 和 resume/suspend 方法替换它们即可。

./src/audio.js
export function isRunning() {
  return context.state === 'running';
}
 
export function toggleAudio() {
  return isRunning() ? context.suspend() : context.resume();
}

虽然我们到目前为止没有从音频函数中返回任何内容,但我们需要从 toggleAudio 中返回,因为这些方法是异步的,我们不想过早更新存储!

./src/store.js
import { ..., isRunning, toggleAudio } from './audio'
 
export const useStore = createWithEqualityFn((set, get) => ({
  ...
 
  isRunning: isRunning(),
 
  toggleAudio() {
    toggleAudio().then(() => {
      set({ isRunning: isRunning() });
    });
  }
}));

Voilà,我们做到了!我们现在已经完成了足够的工作来真正发出声音!让我们看看实际效果如何。

4. 创建新节点

到目前为止,我们一直在处理图中的硬编码节点集。这对原型设计来说已经足够了,但为了使其真正有用,我们需要一种方法来动态地将新节点添加到图中。我们的最后任务将是添加此功能:我们将从音频代码开始,反向工作,最后创建一个基本工具栏。

实现 createAudioNode 函数将非常简单。我们只需要新节点的 ID、要创建的节点类型及其初始数据即可。

./src/audio.js
export function createAudioNode(id, type, data) {
  switch (type) {
    case 'osc': {
      const node = context.createOscillator();
      node.frequency.value = data.frequency;
      node.type = data.type;
      node.start();
 
      nodes.set(id, node);
      break;
    }
 
    case 'amp': {
      const node = context.createGain();
      node.gain.value = data.gain;
 
      nodes.set(id, node);
      break;
    }
  }
}

接下来,我们需要在存储中创建一个 createNode 函数。节点 ID 将由 nanoid 生成,我们将对每种节点类型进行一些初始数据的硬编码,因此我们只需要传入要创建的节点类型即可。

./src/store.js
import { ..., createAudioNode } from './audio';
 
export const useStore = createWithEqualityFn((set, get) => ({
  ...
 
  createNode(type) {
    const id = nanoid();
 
    switch(type) {
      case 'osc': {
        const data = { frequency: 440, type: 'sine' };
        const position = { x: 0, y: 0 };
 
        createAudioNode(id, type, data);
        set({ nodes: [...get().nodes, { id, type, data, position }] });
 
        break;
      }
 
      case 'amp': {
        const data = { gain: 0.5 };
        const position = { x: 0, y: 0 };
 
        createAudioNode(id, type, data);
        set({ nodes: [...get().nodes, { id, type, data, position }] });
 
        break;
      }
    }
  }
}));

我们可以更聪明地计算新节点的位置,但为了保持简单,我们将暂时将其硬编码为 { x: 0, y: 0 }

难题的最后一块是创建一个工具栏组件,该组件可以触发新的 createNode 操作。为此,我们将跳回到 App.jsx,并使用 <Panel /> 插件组件。

./src/App.jsx
...
import { ReactFlow,  Panel } from '@xyflow/react';
...
 
const selector = (store) => ({
  ...,
  createNode: store.createNode,
});
 
export default function App() {
  const store = useStore(selector, shallow);
 
  return (
    <ReactFlow>
      <Panel position="top-right">
        ...
      </Panel>
      <Background />
    </ReactFlow>
  );
};

我们不需要任何花哨的东西,只需要几个按钮,它们可以使用适当的类型触发 createNode 操作即可。

./src/App.jsx
<Panel position="top-right">
  <button onClick={() => store.createNode('osc')}>osc</button>
  <button onClick={() => store.createNode('amp')}>amp</button>
</Panel>

就这样……完成了!我们现在拥有了一个功能齐全的音频图编辑器,可以

  • 创建新的音频节点
  • 使用一些 UI 控件更新节点数据
  • 将节点连接在一起
  • 删除节点和连接
  • 启动和停止音频处理

以下是从头开始的演示,但这次您可以查看源代码,以确保您没有错过任何内容。

export default function App() {
  const data: string = "world"

  return <h1>Hello {data}</h1>
}

只读

最后的想法

哇,这真是一段漫长的旅程,但我们做到了!通过我们的努力,我们获得了有趣的小型互动式音频游乐场,一路学习了一些关于 Web Audio API 的知识,并且更好地了解了一种“运行”React Flow 图的方法。

如果您坚持到最后,并心想“Hayleigh,我永远不会编写 Web Audio 应用程序。我学到了什么有用的东西?”那么您真幸运,因为您确实学到了!您可以采用我们连接到 Web Audio API 的方法,并将其应用于其他基于图的计算引擎,例如 behave-graph。事实上,有些人已经做到了,并创建了 behave-flow

还有很多方法可以扩展这个项目。如果您想继续进行,这里有一些想法。

  • 添加更多节点类型。
  • 允许节点连接到其他节点上的 AudioParams
  • 使用 AnalyserNode 来可视化节点或信号的输出。
  • 任何你能想到的其他事情!

如果您需要灵感,网络上有很多项目正在使用基于节点的 UI 来处理音频问题。我最喜欢的项目包括 Max/MSPReaktorPure Data。Max 和 Reaktor 是闭源商业软件,但您仍然可以从中窃取一些想法

您可以使用已完成的 源代码 作为起点,或者您也可以在我们今天创建的基础上进行构建。我们很乐意看到您构建的内容,因此请在我们的 Discord 服务器Twitter 上与我们分享。

React Flow 是一家由用户资助的独立公司。如果您想支持我们,可以 在 Github 上赞助我们订阅我们的 Pro 计划之一

使用 React Flow Pro 获取 Pro 示例、优先级错误报告、维护人员的 1:1 支持等等。