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に関する最初のセクションを読んでから、3番目のセクションに進んで、ものがどのように結び付けられているかを確認することをお勧めします!

しかし、まず…

デモ!

⚠️

このチュートリアルの他の例と同様に、音が出ます

アバンギャルドな傑作を作成しないように、先に進む前に各例の音声をミュートすることを忘れないでください!

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などの別のバンドラを好む場合でも大丈夫です。それらはすべてほぼ同じことを行います。 JavaScriptの代わりにTypeScriptを使用することもできます。単純にするために、今日は使用しませんが、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固有のものではありません。

このプロジェクトには、UI向けに@xyflow/react(言うまでもなく!)、シンプルな状態管理ライブラリとしてzustand(React Flow内部で使用しているもの)、軽量なIDジェネレーターとしてnanoidの3つの追加依存関係のみが必要です。

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>,
);

ここでは、3つの重要な点に注意する必要があります。

  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のプラグインの1つである<Background />を追加して、すべてが正しく設定されているかどうかを確認しています。npm run dev(またはViteを選択しなかった場合は、開発サーバーを起動するために必要な操作)を実行して、ブラウザーを確認してください。空のフローが表示されるはずです。

Screenshot of an empty React Flow graph

開発サーバーは実行したままにしておきます。新しい部分を追加する際に、進捗状況を確認し続けることができます。

1. Zustandを使用した状態管理

Zustandストアは、アプリケーションのすべてのUI状態を保持します。実際には、React Flowグラフのノードとエッジ、その他の状態のいくつかの部分、そしてその状態を更新するためのいくつかのアクションを保持します。

基本的なインタラクティブなReact Flowグラフを稼働させるには、3つのアクションが必要です。

  1. onNodesChangeは、ノードの移動や削除を処理します。
  2. onEdgesChangeは、エッジの移動や削除を処理します。
  3. addEdgeは、グラフ内の2つのノードを接続します。

新しいファイル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の使用は非常に簡単です。set関数とget関数の両方を取得する関数を作成し、初期状態と、その状態を更新するために使用できるアクションを含むオブジェクトを返します。更新は不変的に行われ、そのためにset関数を使用できます。get関数は、現在の状態を読み取る方法です。そして…Zustandは以上です。

onNodesChangeonEdgesChangeの両方にあるchanges引数は、ノードまたはエッジの移動や削除などのイベントを表します。幸いなことに、React Flowは、それらの変更を適用するためのヘルパー 関数を提供しています。ノードの新しい配列でストアを更新するだけです。

addEdgeは、2つのノードが接続されるたびに呼び出されます。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インスタンスを使用できるようになりました。ダミーノードを追加しましたが、現時点ではデフォルトのスタイルなしのノードです。このステップでは、インタラクティブなコントロールを備えた3つのカスタムノードを追加します。

  1. オシレーターノードと、ピッチと波形の種類のコントロール。
  2. ゲインノードと、ボリュームのコントロール。
  3. 出力ノードと、オーディオ処理のオン/オフを切り替えるボタン。

新しいフォルダnodes/を作成し、作成する各カスタムノードのファイルを作成します。オシレーターから始めると、2つのコントロールと、オシレーターの出力を他のノードに接続するためのソースハンドルが必要です。

./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アクションから、2つのイベントハンドラー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はデフォルトのノードのレンダリングに戻ります。frequencytypeの初期値を指定して、それらのノードの1つをoscに変更すると、カスタムノードがレンダリングされるはずです。

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アクションが呼び出されたときに2つのオーディオノードを接続したり、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. ノードの変更

現在、グラフ内で発生する可能性があり、対応する必要があるノードの変更には2つのタイプがあります。ノードのdataの更新と、グラフからのノードの削除です。前者については既にアクションを用意しているので、まずそちらを処理しましょう。

audio.jsで、ノードのIDと部分的なdataオブジェクトを引数として呼び出し、Map内の既存のノードを更新するために使用するupdateAudioNode関数を作成します。

./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イベントに新しいアクションを接続する必要があります。

これは非常に簡単なので、コメントなしで次の3つのコードスニペットを示します。

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

唯一注意すべき点は、onNodesDeleteは一度に複数のノードを削除できるため、削除されたノードの*配列*を指定されたコールバック関数に渡すことです!

2. エッジの変更

実際に音を出す準備が整ってきました!残りはグラフのエッジの変更を処理することだけです。ノードの変更と同様に、新しいエッジの処理を行うアクションは既に用意されており、onEdgesChangeで削除されたエッジも暗黙的に処理されています。

新しい接続を処理するには、addEdgeアクションで作成されたエッジからsource IDとtarget IDを取得するだけです。その後、Mapで2つのノードを検索し、それらを接続できます。

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 />コンポーネントに必要なデータとアクションをストアで既に偽装しているので、今度はそれらを実際のコンテキストの状態と再開/一時停止メソッドに置き換えるだけです。

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

さあ、できました!これで、実際に*音を出す*ための準備が整いました!動作を確認してみましょう。

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グラフを「実行」するアプローチの1つについてより深く理解できました。

ここまで読んで「Hayleigh、Web Audioアプリは二度と書きません。何か役立つことは学びましたか?」と思っているなら、幸運です。なぜなら、学びました!Web Audio APIへの接続アプローチを、behave-graphのような他のグラフベースの計算エンジンに適用できます。実際、そうした人がいてbehave-flowを作成しました!

このプロジェクトを拡張する方法は他にもたくさんあります。作業を続けたい場合は、いくつかのアイデアを以下に示します。

  • ノードタイプの追加。
  • ノードが他のノードのAudioParamsに接続できるようにする。
  • AnalyserNodeを使用して、ノードまたは信号の出力を視覚化する。
  • 他に思いつくことなら何でも!

そして、インスピレーションを探しているなら、オーディオ関連のノードベースのUIを使用しているプロジェクトがいくつかあります。私のお気に入りはMax/MSPReaktor、そしてPure Dataです。MaxとReaktorはクローズドソースの商用ソフトウェアですが、それでもアイデアを盗むことができます.

完成したソースコードを起点として使用するか、今日作成したものの上に構築し続けることができます。皆さん作成したものが是非見たいので、DiscordサーバーまたはTwitterで共有してください。

React Flowは、ユーザーによって資金提供される独立した企業です。支援したい場合は、Githubでスポンサーになるか、Proプランのいずれかに登録することができます。

React Flow Proでは、Proの例、優先的なバグレポート、メンテナーからの1対1のサポートなどを利用できます。