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,它是一個在瀏覽器中學習數位合成的小型 Web 應用程式。很多人都很有興趣了解它是如何組合在一起的:大多數人甚至不知道他們的瀏覽器內建了一個完整的合成引擎!

本教學課程將逐步引導我們建立類似的東西。我們可能會跳過一些部分,但大多數情況下,如果您是 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(畢竟我們叫做 React Flow!)和 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 提供的一些 Hook,您的元件需要位於 <ReactFlowProvider /> 內部,或 <ReactFlow /> 元件本身內部,因此我們將整個應用程式包裝在 Provider 中以確保萬無一失。

接下來,跳到 App.jsx 並建立一個空的 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,則執行啟動開發伺服器所需的任何操作)並查看您的瀏覽器。您應該看到一個空的 Flow

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 提供了一些 協助 函式來為我們套用這些變更。我們只需要使用新的節點陣列來更新儲存區即可。

每當兩個節點連接時,都會呼叫 addEdgedata 引數*幾乎*是一個有效的邊緣,它只缺少一個 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” 很重要。

請注意新增到 <input /><select /> 元素的 "nodrag" 類別。記住新增此類別*非常重要*,否則您會發現 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,必須以不同於一般物件屬性的方式更新。

現在,我們會希望更新 store 中的 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 並將其連接到我們的 store 和 React Flow 實例的方法與之前幾乎相同,所以我們也將這個留給您自行處理!

3. 開啟喇叭

您會記得,我們的 AudioContext 可能會以暫停狀態開始,以防止潛在的惱人自動播放問題。我們已經在 store 中為我們的 <Out /> 元件偽造了所需的資料和動作,現在我們只需要將它們替換為真實內容的狀態和恢復/暫停方法。

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

雖然到目前為止我們還沒有從我們的音訊函式中傳回任何內容,但我們需要從 toggleAudio 傳回,因為這些方法是異步的,而且我們不希望過早更新 store!

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

好了,我們做到了!我們現在已經組合足夠的東西來實際「發出聲音」!讓我們看看我們的實際操作。

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;
    }
  }
}

接下來,我們需要在 store 中建立一個 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 支援等等