Skip to Content
教程教程网络音频 API

2023/04/14

集成 React Flow 和 Web Audio API

Hayleigh Thompson
Software Engineer

今天我们将学习如何使用 React Flow 和 Web Audio API 创建一个互动音频在线运行。我们将从零开始,首先了解 Web Audio API,然后再看如何处理 React Flow 中的许多常见场景:状态管理、实现自定义节点以及添加互动性。

🌐 Today we’ll be looking at how to create an interactive audio playground using React Flow and the Web Audio API. We’ll start from scratch, first learning about the Web Audio API before looking at how to handle many common scenarios in React Flow: state management, implementing custom nodes, and adding interactivity.

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.
This is bleep.cafe. We're going to learn everything we need to know to build something just like it!

前一段时间,我在 React Flow discord 服务器 上分享了我正在进行的一个项目。它叫做bleep.cafe ,是一个小型网页应用,用于在浏览器中学习数字合成。很多人对这样一个项目是如何组建的很感兴趣:大多数人甚至不知道他们的浏览器里内置了一个完整的合成器引擎!

本教程将一步步带我们构建类似的东西。我们可能会在一些地方略过一些内容,但大体上,如果你是 React Flow Web Audio API 的新手,你应该能够跟上进度,并在最后得到一个可用的成果。

🌐 This tutorial will take us step-by-step to build something similar. We may skip over some bits here and there, but for the most part if you’re new to React Flow or the Web Audio API you should be able to follow along and have something working by the end.

如果你已经是 React Flow 大师,你可能想先阅读第一部分讲解 Web Audio API,然后跳到第三部分看看这些内容是如何结合在一起的!

🌐 If you’re already a React Flow wizard you might want to read the first section covering the Web Audio API and then jump to the third to see how things are tied together!

但首先…

🌐 But first…

演示!

🌐 A demo!

本教程中的这个例子和其他例子都会“发声”。为了避免创作出先锋派的杰作,请记得在继续之前将每个例子静音!

Web Audio API

🌐 The Web Audio API

在我们深入了解 React Flow 和交互式节点编辑器的精彩功能之前,我们需要快速学习一下 Web Audio API 。以下是你需要了解的重点内容:

🌐 Before we get stuck in to React Flow and interactive node editor goodness, we need to take a crash course on the Web Audio API . Here are the highlights you need to know:

  • Web Audio API 提供了各种不同的音频节点,包括来源(例如 OscillatorNode MediaElementAudioSourceNode )、效果(例如 GainNode DelayNode ConvolverNode )和输出(例如 AudioDestinationNode )。
  • 音频节点可以相互连接,形成一个(可能是循环的)图。我们通常称其为音频处理图、信号图或信号链。
  • 音频处理由本地代码在单独的线程中处理。这意味着即使主 UI 线程忙碌或阻塞,我们仍然可以继续生成声音。
  • 一个 AudioContext  就像音频处理图的“大脑”。我们可以使用它来创建新的音频节点,并完全暂停或恢复音频处理。

你好,声音!

🌐 Hello, sound!

让我们看看这些东西是如何运作的,并构建我们的第一个 Web Audio 应用吧!我们不会做太疯狂的事情:我们将制作一个简单的鼠标特雷门 。我们将为这些示例以及之后的所有内容使用 React(毕竟我们叫做 React Flow!),并使用 vite 来处理打包和热重载。

🌐 Let’s see some of this stuff in action and build our first Web Audio app! We won’t be doing anything too wild: we’ll make a simple mouse theremin . We’ll use React for these examples and everything else moving forward (we’re called React Flow after all!) and vite to handle bundling and hot reloading.

如果你更喜欢使用其他打包工具,比如 Parcel 或 Create React App,那也没问题,它们的功能大体上是相同的。你也可以选择使用 TypeScript 而不是 JavaScript。为了保持简单,我们今天不会使用它,但 React Flow 完全支持类型(并且完全用 TypeScript 编写),所以使用起来非常轻松!

🌐 If you prefer another bundler like parcel or Create React App that’s cool too, they all do largely the same thing. You could also choose to use TypeScript instead of JavaScript. To keep things simple we won’t use it today, but React Flow is fully typed (and written entirely in TypeScript) so it’s a breeze to use!

npm create vite@latest -- --template react

Vite 会为我们搭建一个简单的 React 应用,但可以删除这些资源,直接进入 App.jsx。移除为我们生成的演示组件,并从创建一个新的 AudioContext 开始,组装我们需要的节点。我们需要一个 OscillatorNode 来生成一些音调,以及一个 GainNode 来控制音量。

🌐 Vite will scaffold out a simple React application for us, but can delete the assets and jump right into App.jsx. Remove the demo component generated for us and start by creating a new AudioContext and putting together the nodes we need. We want an OscillatorNode to generate some tones and a GainNode to control the volume.

./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。振荡器没有它就不会开始生成音调!

🌐 Don’t forget that call to osc.start. The oscillator won’t start generating tones without it!

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

🌐 For our app, we’ll track the mouse’s position on the screen and use that to set the pitch of the oscillator node and the volume of the gain node.

./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 的值,而不是直接给属性赋值。

🌐 The Web Audio API makes a distinction between simple object properties and audio node parameters. That distinction appears in the form of an AudioParam. You can read up on them in the MDN docs  but for now it’s enough to know that you need to use .value to set the value of an AudioParam rather than just assigning a value to the property directly.

如果你按照这个示例去尝试,你可能会发现什么都没有发生。AudioContext 通常会以挂起状态启动,以试图避免广告劫持我们的扬声器。我们可以通过在 <div /> 上添加点击处理程序来轻松解决这个问题,如果它处于挂起状态,就恢复上下文。

🌐 If you try this example as it is, you’ll probably find that nothing happens. An AudioContext often starts in a suspended state in an attempt to avoid ads hijacking our speakers. We can fix that easily by adding a click handler on the <div /> to resume the context if it’s suspended.

./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 发出一些声音所需要的一切!这是我们整理出来的内容,以防你在家里没有跟上:

🌐 And that’s everything we need to start making some sounds with the Web Audio API! Here’s what we put together, in case you weren’t following along at home:

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

🌐 Now let’s put this knowledge to one side and take a look at how to build a React Flow project from scratch.

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

搭建 React Flow 项目脚手架

🌐 Scaffolding a React Flow project

之后,我们将利用所学的关于 Web 音频 API、振荡器和增益节点的知识,并使用 React Flow 交互式地构建音频处理图。不过现在,我们需要先搭建一个空的 React Flow 应用。

🌐 Later on we’ll take what we’ve learned about the Web Audio API, oscillators, and gain nodes and use React Flow to interactively build audio-processing graphs. For now though, we need to put together an empty React Flow app.

我们已经用 Vite 设置好了一个 React 应用,所以我们会继续使用它。如果你跳过了上一节,我们运行了 npm create vite@latest -- --template react 来开始项目。不过,你可以使用任何你喜欢的打包工具和/或开发服务器。这里没有任何内容是仅针对 Vite 的。

🌐 We already have a React app set up with Vite, so we’ll keep using that. If you skipped over the last section, we ran npm create vite@latest -- --template react to get started. You can use whatever bundler and/or dev server you like, though. Nothing here is vite specific.

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

🌐 We only need three additional dependencies for this project: @xyflow/react for our UI (obviously!), zustand as our simple state management library (that’s what we use under the hood at React Flow) and nanoid as a lightweight id generator.

npm install @xyflow/react zustand nanoid

我们将从我们的 Web Audio 崩溃课程中移除所有内容并从头开始。先通过修改 main.jsx 来匹配以下内容:

🌐 We’re going to remove everything from our Web Audio crash course and start from scratch. Start by modifying main.jsx to match the following:

./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> <div style={{ width: '100vw', height: '100vh' }}> <ReactFlowProvider> <App /> </ReactFlowProvider> </div> </React.StrictMode>, );

这里有三件重要的事情需要注意:

🌐 There are three important things to pay attention to here:

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

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

🌐 Next, hop into App.jsx and create an empty flow:

./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,运行你需要的启动开发服务器的命令),然后查看你的浏览器。你应该会看到一个空的流程图:

🌐 We’ll expand and add on to this component over time. For now, we’ve added one of React Flow’s built-in components - <Background /> - to check if everything is setup correctly. Go ahead and run npm run dev (or whatever you need to do to spin up a dev server if you didn’t choose vite) and check out your browser. You should see an empty flow:

Screenshot of an empty React Flow graph

保持开发服务器运行。随着我们添加新的零碎东西,我们可以持续检查我们的进展。

🌐 Leave the dev server running. We can keep checking back on our progress as we add new bits and bobs.

1. 使用 Zustand 进行状态管理

🌐 1. State management with Zustand

一个 Zustand store 将会保存我们应用的所有 UI 状态。实际上,这意味着它会保存我们的 React Flow 图的节点和边,以及其他一些状态,并包含少量用于更新该状态的 actions

🌐 A Zustand store will hold all the UI state for our application. In practical terms that means it’ll hold the nodes and edges of our React Flow graph, a few other pieces of state, and a handful of actions to update that state.

要获得基本的交互式 React Flow 图,我们需要三个操作:

🌐 To get a basic interactive React Flow graph going we need three actions:

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

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

🌐 Go ahead and create a new file, store.js, and add the following:

./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 的内容就是这样。

🌐 Zustand is dead simple to use. We create a function that receives both a set and a get function and returns an object with our initial state along with the actions we can use to update that state. Updates happen immutably and we can use the set function for that. The get function is how we read the current state. And… that’s it for zustand.

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

🌐 The changes argument in both onNodesChange and onEdgesChange represents events like a node or edge being moved or deleted. Fortunately, React Flow provides some helper functions to apply those changes for us. We just need to update the store with the new array of nodes.

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

如果我们跳回到我们的 <App /> 组件,我们可以将 React Flow 连接到我们的操作并让它开始工作。

🌐 If we hop back over to our <App /> component we can hook React Flow up to our actions and get something working.

./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 相等函数,这意味着当我们不关心的状态发生变化时,通常不会触发重新渲染。

🌐 So what’s this selector thing all about? Zustand let’s us supply a selector function to pluck out the exact bits of state we need from the store. Combined with the shallow equality function, this means we typically don’t have re-renders when state we don’t care about changes.

现在,我们的商店很小,我们实际上希望从中获取的一切都能帮助渲染我们的 React Flow 图,但随着我们对其进行扩展,这个选择器将确保我们不会一直重新渲染 所有 内容。

🌐 Right now, our store is small and we actually want everything from it to help render our React Flow graph, but as we expand on it this selector will make sure we’re not re-rendering everything all the time.

这是我们拥有一个互动图所需的一切:我们可以移动节点,连接它们,并删除它们。为了演示,_临时_向你的存储中添加一些虚拟节点:

🌐 This is everything we need to have an interactive graph: we can move nodes around, connect them together, and remove them. To demonstrate, temporarily add some dummy nodes to your store:

./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 } } ], ... }));

2. 自定义节点

🌐 2. Custom nodes

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

🌐 OK great, we have an interactive React Flow instance we can start playing with. We added some dummy nodes but they’re just the default unstyled ones right now. In this step we’ll add three custom nodes with interactive controls:

  1. 振荡器节点和用于音高和波形类型的控件。
  2. 增益节点和音量控制
  3. 输出节点和用于打开和关闭音频处理的按钮。

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

🌐 Let’s create a new folder, nodes/, and create a file for each custom node we want to create. Starting with the oscillator we need two controls and a source handle to connect the output of the oscillator to other 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 会拦截鼠标事件,你将会被卡在不断拖动节点的状态中!

🌐 Pay attention to the "nodrag" class being added to both the <input /> and <select /> elements. It’s super important that you remember to add this class otherwise you’ll find that React Flow intercepts the mouse events and you’ll be stuck dragging the node around forever!

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

🌐 If we try rendering this custom node we’ll find that the inputs don’t do anything. That’s because the input values are fixed by data.frequency and data.type but we have no event handlers listening to changes and no mechanism to update a node’s data!

要解决这种情况,我们需要回到我们的商店并添加一个 updateNode 操作:

🌐 To fix the situation we need to jump back to our store and add an updateNode action:

./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 /> 组件,并在输入发生变化时调用它。

🌐 This action will handle partial data updates, such that if we only want to update a node’s frequency, for example, we could just call updateNode(id, { frequency: 220 }. Now we just need to bring the action into our <Osc /> component and call it whenever an input changes.

./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 的。

🌐 Hey, that selector is back! Notice how this time we’re using it to derive two event handlers, setFrequency and setType, from the general updateNode action.

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

🌐 The last piece of the puzzle is to tell React Flow how to render our custom node. For that we need to create a nodeTypes object: the keys should correspond to a node’s type and the value will be the React component to render.

./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),以避免每次渲染时重新计算它。

🌐 It’s important to define nodeTypes outside of the <App /> component (or use React’s useMemo) to avoid recomputing it every render.

如果你的开发服务器正在运行,如果事情还没有变化,不要惊慌!我们所有的临时节点都还没有被赋予正确的类型,所以 React Flow 只是回退渲染默认节点。如果我们将其中一个节点更改为 osc 并为 frequencytype 设置一些初始值,我们应该能够看到自定义节点被渲染出来。

🌐 If you’ve got the dev server running, don’t panic if things haven’t changed yet! None of our temporary nodes have been given the right type yet, so React Flow just falls back to rendering the default node. If we change one of those nodes to be an osc with some initial values for frequency and type we should see our custom node being rendered.

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

卡在造型上吗?

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

🌐 If you’re just implementing the code from this post as you go along, you’ll see that your custom node doesn’t look like the one in the preview above. To keep things easy to digest, we’ve left out styling in the code snippets.

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

🌐 To learn how to style your custom nodes, check out our docs on theming or our example using Tailwind.

实现增益节点的过程基本相同,所以我们把这个留给你来完成。相反,我们将把注意力转向输出节点。这个节点不会有参数控制,但我们确实希望能够开启和关闭信号处理。由于我们还没有实现任何音频代码,这现在有点困难,因此在此期间,我们将在存储中添加一个标志,并添加一个切换它的操作。

🌐 Implementing a gain node is pretty much the same process, so we’ll leave that one to you. Instead, we’ll turn our attention to the output node. This node will have no parameters control, but we do want to toggle signal processing on and off. That’s a bit difficult right now when we haven’t implemented any audio code yet, so in the meantime we’ll add just a flag to our store and an action to toggle it.

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

自定义节点本身非常简单:

🌐 The custom node itself is then pretty simple:

./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> ); }

事情开始变得相当顺利!

🌐 Things are starting to shape up quite nicely!

那么,下一步是…

🌐 The next step, then, is to…

听听它的声音

🌐 Do sound to it

我们有一个交互式图表,并且可以更新节点数据,现在让我们加入关于 Web Audio API 的知识。首先创建一个新文件 audio.js,然后创建一个新的音频上下文和一个空的 Map

🌐 We have an interactive graph and we’re able to update node data, now let’s add in what we know about the Web Audio API. Start by creating a new file, audio.js, and create a new audio context and an empty Map.

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

我们管理音频图的方式是通过钩子到我们 store 中的不同动作。因此,当调用 addEdge 动作时,我们可能会连接两个音频节点,或者在调用 updateNode 动作时更新音频节点的属性,等等。

🌐 The way we’ll manage our audio graph is by hooking into the different actions in our store. So we might connect two audio nodes when the addEdge action is called, or update an audio node’s properties when updateNode is called, and so on.

硬编码节点

我们在本文前面在商店中硬编码了几个节点,但我们的音频图对它们一无所知!对于完成的项目,我们可以去掉所有这些硬编码的部分,但现在非常重要的是,我们也必须硬编码一些音频节点。

🌐 We hardcoded a couple of nodes in our store earlier on in this post but our audio graph doesn’t know anything about them! For the finished project we can do away with all these hardcoded bits, but for now it’s really important that we also hardcode some audio nodes.

以下是我们的方法:

🌐 Here’s how we did it:

./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. 节点变化

🌐 1. Node changes

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

🌐 Right now, there are two types of node changes that can happen in our graph and that we need to respond to: updating a node’s data, and removing a node from the graph. We already have an action for the former, so let’s handle that first.

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

🌐 In audio.js we’ll define a function, updateAudioNode, that we’ll call with a node’s id and a partial data object and use it to update an existing node in the 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 操作,在更新过程中调用此函数:

🌐 Now we’ll want to update our updateNode action in the store to call this function as part of the update:

./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 事件上。

🌐 The next change we need to handle is removing a node from the graph. If you select a node in the graph and hit backspace, React Flow will remove it. This is implicitly handled for us by the onNodesChange action we hooked up, but now we want some additional handling we’ll need to wire up a new action to React Flow’s onNodesDelete event.

这实际上很简单,所以我帮你省点阅读时间,直接展示接下来的三段代码,没有注释。

🌐 This is actually pretty simple, so I’ll save you some reading and present the next three snippets of code without comment.

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

唯一需要注意的是,onNodesDelete 会使用一个被删除节点的 数组 调用提供的回调,因为有可能一次删除多个节点!

🌐 The only thing to note is that onNodesDelete calls the provided callback with an array of deleted nodes, because it is possible to delete more than one node at once!

2. 边缘变化

🌐 2. Edge changes

我们距离真正发出一些声音已经非常接近了!剩下的就是处理我们图的边的变化。就像节点变化一样,我们已经有一个操作来处理创建新边,我们也在 onEdgesChange 中隐式地处理了删除的边。

🌐 We’re getting super close to actually making some sounds! All that’s left is to handle changes to our graph’s edges. Like with node changes, we already have an action to handle creating new edges and we’re also implicitly handling removed edges in onEdgesChange.

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

🌐 To handle new connections, we just need the source and target ids from the edge created in our addEdge action. Then we can just look up the two nodes in our Map and connect them up.

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 实例的方法基本上和以前一样,所以这个我们也就留给你来做吧!

🌐 We saw React Flow accepted an onNodesDelete handler and wouldn’t you know it, there’s an onEdgesDelete handler too! The approach we’d take to implement disconnect and hook it up to our store and React Flow instance is pretty much the same as before, so we’ll leave that one down to you as well!

3. 打开扬声器

🌐 3. Switching the speakers on

你会记得,我们的 AudioContext 可能会以挂起状态开始,以防止潜在的烦人自动播放问题。我们已经在存储中伪造了 <Out /> 组件所需的数据和操作,现在我们只需要用真实上下文的状态以及恢复/挂起方法来替换它们。

🌐 You’ll remember that our AudioContext probably begins in a suspended state to prevent potentially annoying autoplay issues. We already faked the data and actions we need for our <Out /> component in the store, now we just need to replace them with the real context’s state and resume/suspend methods.

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

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

🌐 Although we haven’t been returning anything from our audio functions up until now, we need to return from toggleAudio because those methods are asynchronous and we don’t want to update the store prematurely!

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

瞧,我们做到了!我们现在已经组装好了足够的东西,实际上可以发出声音了!让我们看看它的实际效果。

🌐 Et voilà, we did it! We’ve now put enough together to actually make sounds! Let’s see what we have in action.

4. 创建新节点

🌐 4. Creating new nodes

直到现在,我们一直在处理图中一个硬编码的节点集。这对于原型设计是可以的,但为了让它真正有用,我们需要一种方法来动态地向图中添加新节点。我们的最终任务将是添加这个功能:我们将从音频代码开始,并最终创建一个基本的工具栏。

🌐 Up until now we have been dealing with a hard-coded set of nodes in our graph. This has been fine for prototyping but for it to actually be useful we’ll want a way to add new nodes to the graph dynamically. Our final task will be adding this functionality: we’ll work backwards starting with the audio code and ending by creating a basic toolbar.

实现一个 createAudioNode 函数将足够简单。我们所需要的只是新节点的 id、要创建的节点类型以及它的初始数据:

🌐 Implementing a createAudioNode function will be simple enough. All we need is an id for the new node, the type of node to create, and its initial data:

./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 生成,我们会为每种节点类型硬编码一些初始数据,所以我们唯一需要传入的是要创建的节点类型:

🌐 Next we’ll need a createNode function in our store. The node id will be generated by nanoid and we’ll hardcode some initial data for each of the node types, so the only thing we need to pass in is the type of node to create:

./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 }

🌐 We could be a bit smarter about calculating the position of the new node, but to keep things simple we’ll just hardcode it to { x: 0, y: 0 } for now.

拼图的最后一块是创建一个可以触发新的 createNode 动作的工具栏组件。为此,我们将回到 App.jsx 并使用 <Panel /> 内置组件。

🌐 The final piece of the puzzle is to create a toolbar component that can trigger the new createNode action. To do that we’ll jump back to App.jsx and make use of the <Panel /> built-in component.

./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 操作即可:

🌐 We don’t need anything fancy here, just a couple of buttons that trigger the createNode action with the appropriate type:

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

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

🌐 And that’s… everything! We’ve now got a fully functional audio graph editor that can:

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

这是从一开始的演示,但这一次你可以看到源代码,以确保你没有遗漏任何东西。

🌐 Here’s the demo from the beginning, but this time you can see the source code to make sure you haven’t missed anything.

最后的想法

🌐 Final thoughts

呼,那真是长篇大论,但我们做到了!通过我们的努力,我们成功完成了一个有趣的小型互动音频在线运行,同时在过程中学到了一些关于 Web Audio API 的知识,并对“运行” React Flow 图的一种方法有了更好的理解。

🌐 Whew that was a long one, but we made it! For our efforts we’ve come out the other side with a fun little interactive audio playground, learned a little bit about the Web Audio API along the way, and have a better idea of one approach to “running” a React Flow graph.

如果你已经看到这里,并且在想“Hayleigh,我永远不会写一个 Web Audio 应用。我学到有用的东西了吗?” 那么你很幸运,因为你确实学到了!你可以将我们连接到 Web Audio API 的方法应用到其他一些基于图的计算引擎,例如 behave-graph 。事实上,有人正是这么做的,并创建了 behave-flow 

🌐 If you’ve made it this far and are thinking “Hayleigh, I’m never going to write a Web Audio app. Did I learn anything useful?” Then you’re in luck, because you did! You could take our approach to connecting to the Web Audio API and apply it to some other graph-based computation engine like behave-graph . In fact, some has done just that and created behave-flow !

还有很多方法可以扩展这个项目。如果你想继续进行,它们有一些建议:

🌐 There are still plenty of ways to expand on this project. If you’d like to keep working on it, here are some ideas:

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

如果你正在寻找灵感,那么在野外有相当多的项目正在使用基于节点的界面来处理音频。一些我最喜欢的有 Max/MSP Reaktor Pure Data 。Max 和 Reaktor 是闭源的商业软件,但你仍然可以从它们那里借鉴一些想法

你可以使用完成的 源代码  作为起点,或者你也可以继续在我们今天所做的基础上进行构建。我们很想看到你所创造的内容,所以请在我们的 Discord 服务器 Twitter  上与我们分享。

🌐 You can use the completed source code  as a starting point, or you can just keep building on top of what we’ve made today. We’d love to see what you build so please share it with us over on our Discord server  or Twitter .

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

🌐 React Flow is an independent company financed by its users. If you want to support us you can sponsor us on Github  or subscribe to one of our Pro plans.

Get Pro examples, prioritized bug reports, 1:1 support from the maintainers, and more with React Flow Pro

Last updated on