1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【React × Tone.js 入門】Webで音を鳴らすための実装チュートリアルと設計について

Posted at

【1章】Tone.jsとWeb Audio APIの関係

Web上で音を鳴らすためにはWeb Audio APIというWebが標準で提供しているAPIを使う必要があります。
Web Audio APIは音声コンテキストを操作して音声ノードと呼ばれるノードを接続して音声ルーティングラフを作成することで音声操作を実現できます。
このAPIを利用することで立体音響や複雑なデジタル音声の再生を行うことができます。

基本的な流れとしては以下の通りです

  1. 音声コンテキストを作成する
  2. コンテキスト中で音源を作成する
  3. リバーブやフィルター、コンプレッサーといったエフェクトノードを作成する
  4. 音声の出力先を選ぶ(スピーカーとか)
  5. 作成した音源をエフェクトに繋げて、エフェクトを出力先に繋げることで音声を鳴らす

このAPIについての詳しい説明やチュートリアルが豊富にそろっているので詳しくAPIについて確認したい場合は閲覧してみてください。

さて、本題として今回使用するTone.jsとの関わりについて触れたいと思います。

今回使用する Tone.js は、この Web Audio API を抽象化したフレームワークで、拍やテンポ、シーケンスといった高レベルな音楽的概念を提供します。Tone.js を使うことで、音を鳴らすタイミングの管理やイベント同期、Transport 機能などが簡単に実装できます。

基本的な機能に関してはTone.js公式のサイトにサンプルコードと実行環境がありますので閲覧してみてください。

ReactでTone.jsを組み合わせた音声処理の実装を行う上で注意しなければならない箇所があります。

React で Web Audio を扱う場合には注意が必要です。React は state や props が更新されるたびに関数コンポーネントを再実行(再レンダリング)します。一方で、AudioContext や Synth は生成後に保持して使い回すことが前提です。もしコンポーネント内で毎回生成すると、古いインスタンスは参照されなくなり、不要なリソースが残ることでパフォーマンスやメモリ効率が悪くなります。これを防ぐために、React では useRef を使って AudioContext や Synth を保持するのが推奨です。

また、ブラウザの仕様上、音声再生は必ずユーザ操作後に開始する必要があります。このため useEffect 内で自動的に音を再生しようとしてもブロックされることがあります。

さらに、React の state を音声再生のトリガーにする場合も注意が必要です。setState は非同期で実行されるため、state 更新に依存して音声再生を行うと、UI 更新のタイミングと音声再生のタイミングがずれることがあります。正確なタイミングが求められる場合は、ハンドラ内で直接音声再生処理を行い、state は UI 管理だけに使う方が安全です。

ReactとTone.jsの責務の切り分け

  • Stateをトリガーにして音を鳴らす場合
const [isPlaying, setIsPlaying] = useState(false);
useEffect(() => {
  if (isPlaying) {
    synth.triggerAttackRelease("C4", "8n");
  }
}, [isPlaying]);
const handleClick = () => setIsPlaying(true);
  • ハンドラ内で直接音を鳴らす場合(←推奨)
const handleClick = async () => {
  await Tone.start();
  synth.triggerAttackRelease("C4", "8n");
  setIsPlaying(true); // UI 用の state 更新は別
};

本記事では、この構成を踏まえてReactとTone.jsを組み合わせた低遅延な音声再生処理の実装についてコード例とともに解説します。

【2章】Tone.jsの基礎

まずは、ReactではなくTone.jsの機能について基礎的なものを解説していきます。

本記事では Tone.js v15.1.22 の機能をもとに以下のサイトを参考に解説します。

Tone.jsの説明(再掲)

まずTone.jsとは何か?
一章でも触れましたがTone.js はWeb Audio APIをラップしているフレームワークでWeb Audio APIの低レベルな操作を隠蔽し、拍やシーケンス・Synthなどを高レベルAPIで扱えるようにしています。

ノート名

まず、Tone.jsで音を扱う上で必須になってくるのがノートです。Tone.jsでは音階は文字列で指定します。
MIDI標準の音名形式を採用し、オクターブ指定で高さを調整します。

ノート 音名
C4(中央ド) ド4
D4 レ4
E4 ミ4
F4 ファ4
G4 ソ4
A4 ラ4
B4 シ4
C5 ド5

Synth関数一覧

合成音を鳴らす基本的な音源

関数

関数 説明
triggerAttack(note, time?, velocity?) 音を鳴らす(自分でstopする必要がある)
triggerRelease(time?) 音を止める
triggerAttackRelease(note, duration, time?, velocity?) 音指定時間だけ鳴らす
toDestination() 最終出力に繋ぐ
const synth = new Tone.Synth().toDestination();
synth.triggerAttackRelease("C4","8n"); // C4を8分音符の長さで鳴らす

音源を作成し最終音源に接続した上で8分音符の長さでC4を鳴らすという処理

Player

録画済みの音声ファイルを再生

関数 説明
start(time?, offset?, duration?) 再生開始
stop(time?) 停止
toDestination() スピーカー出力
const kick = new Tone.Player("kick.wav").toDestination();
kick.start();

音源はファイルの長さに依存し,指定したファイルの音源を再生する

Transport

全体のBPMやループ、時間を管
Sequence/partと連動して複数音源を同期できる

関数 説明
start 再生開始
stop 停止
scheduleRepeat 一定間隔で音を鳴らす
bpm.value BPMの設定
Tone.getTransport().bpm.value = 120;
Tone.getTransport().scheduleRepeat((time) => {
	synth.triggerAttackRelease("C4","8n",time);
},"4n");
Tone.getTransport().start();

Sequenceのインターバルで次のnoteの開始タイミングを決定し,そのタイミングでcallbackが呼ばれ、callback内のtriggerAttackreleaseで音の長さを指定できます。

Effects

音に加工を加える。一般的なエフェクトのこと

関数 説明
connect(destination, outputNum?, inputNum?) 他のノードやエフェクトに接続
toDestination() 最終出力に繋ぐ
const reverb = new Tone.Reverb().toDestination();
synth.connect(reverb);
synth.triggerAttackRelease("C4","8n");

リバーブエフェクトに接続してC4を8分音符の長さで演奏する

Sequence/part

音の並びを楽譜的に管理することができる
Tone.Transportと連動している

関数 説明
start(time?, offset?) 再生開始
stop(time?) 停止
const seq = new Tone.Sequence(
 	(time, note) =>    synth.triggerAttackRelease(note,"8n",time),
	["C4","E4","G4","B4"],
	"4n"
);
seq.start();
Tone.getTransport().start();

4分音符ごとにC4 > E4 > G4 > B4のノートを再生する
ここでノート自体は8分音符の長さを持っているため8分音符分なってから4分音符分待機してまた8分音符の長さの音が鳴るという処理になっています。

seq.start()でTransportの時間軸上にseqオブジェクトを登録しています.Tone.Transport.start()Transportに登録した音を再生している.音を再生したタイミングでseq(Sequence)のイベントが呼ばれることでseqの音がなることになります。

Group 7 (2).png

Transport.schedule系の基本

関数 説明
Tone.Transport.schedule(callback,time) 1度だけ指定時間にcallbackを呼ぶ
Tone.Transport.scheduleRepeat(callback,interval,startTime?) 指定間隔で繰り返す
Tone.Transport.scheduleOnce(callback,time) 一度だけ実行(scheduleの別名)

callbackにはtimeが渡され、timeはTransport上の時間で秒単位や音符単位などで指定可能です。

  • 一度だけスケジュールする場合
Tone.getTransport().schedule((time) => {
	synth.triggerAttackRelease("C4","8n",time);
},"1:0");
Tone.getTransport().start();

"1:0"はTone.jsのBars:Beats:Sixteenths形式で時間表現をコロン区切りで16分音符の形式で表します。

0からスタートするため"0:0"なら1小節目の1拍目、"1:2"なら2小節目の3拍目に鳴らす感じになります。

  • 繰り返しスケジュールする場合
Tone.getTransport().scheduleRepeat(
(time) =>{
synth.triggerAttackRelease("C4","8n",time);
},
"4n",
"0:0"
);
Tone.getTransport().start();

4分音符の間隔で1小節目の1拍目から繰り返していきます。

Sequencescheduleの違い

  • Sequence
    • 配列で音をまとめて音列を作る
    • 自動でTransportにスケジュールされる
    • 音符の間隔と長さを管理しやすい
  • schedule
    • 単発または任意の間隔でcallbackを登録し発火する
    • Sequenceより処理が柔軟に記述できる

【3章】ReactでTone.jsを扱う

基本のセットアップ

Reactでの使用方法について

React+viteを利用し

npm install tone

また、toneライブラリを使うにあたりimportの制約があるので先に紹介しておきます。

使用するモジュールを個別でimportしていくのだはなくて一括でimportするようにしてください。これは公式ドキュメントにも載っています、

import * as Tone from tone

Tone.jsをReactで使う上での注意点として

  1. 一章で触れていたようにReactでSynthPlayerといったオブジェクトを利用する時はuseStateではなくuseRefを用いてレンダリング毎に再生成が行われないようにする必要があります
  2. Reactのライフサイクルや状態によって意図していないタイミングで音声が流れる恐れがあるので Transport/schedule/Sequence はReactのレンダリングに依存させないようにします

実装パターン①:クリックで音を鳴らす

ボタンをクリックすると、C4(ドの音)が8分音符の長さで一度だけ鳴ります。

import { useEffect, useRef } from "react"
import * as Tone from "tone"

export const SimpleButtonClick = () => {
	const synthRef = useRef<Tone.Synth | null>(null)

	// 初回レンダリング時にシンセサイザーを初期化
	useEffect(() => {
		synthRef.current = new Tone.Synth().toDestination()
	}, [])
	const playSound = async () => {
		await Tone.start();
		synthRef.current?.triggerAttackRelease("C4", "8n")
	}

	return (
		<button onClick={playSound}>
			Play Sound
		</button>
	)
}

実装

  • useRefの活用:再レンダリング時にSynthが再生成されるのを防ぎ、インスタンスを維持する
  • Tone.start()の待機:ブラウザの自動再生ポリシーにより、ユーザ操作内でAudioContextを再開する必要がある

処理フロー

  1. ボタンクリック
  2. Tone.start()AudioContextを準備
  3. 準備完了後にtriggerAttackReleaseで音声を出力

実装パターン②:複数音を一度に鳴らす

PolySynth を使用して、Cメジャーコード(C4, E4, G4)を和音として同時に鳴らす実装です。

import { useRef } from "react";
import * as Tone from "tone";

export const PolySynthExample = () => {
	const synthRef = useRef<Tone.PolySynth | null>(null);
	const playChord = async () => {
		await Tone.start();
		if (!synthRef.current) {
		synthRef.current = new Tone.PolySynth(Tone.Synth).toDestination();
		}
	
	synthRef.current.triggerAttackRelease(["C4", "E4", "G4"], "2n");
	};
	return <button onClick={playChord}>Play Chord</button>;
};

実装

  • PolySynthの利用:短音のSynthではなく、和音を扱えるPolySynthを利用
  • 配列での指定:triggerAttackReleaseの第一引数に音名の配列を渡すことでコードを演奏する

処理フロー

  1. ボタンクリック
  2. synthRef.currentが空の場合のみ、新しいPolySynthを生成
  3. 配列で渡された音を同時に鳴らす

実装パターン③:Sequenceを使って音を鳴らす

「ド・ミ・ソ・シ」の順番で音が鳴るシーケンスを作成し、ループ再生させます。

import { useRef, useEffect } from "react";
import * as Tone from "tone";
  
export const SimpleSequenceButtonClick = () => {
	const synthRef = useRef<Tone.Synth | null>(null);
	const seqRef = useRef<Tone.Sequence | null>(null);
	// 初回マウント時に Synth を生成
	useEffect(() => {
		synthRef.current = new Tone.Synth().toDestination();
	}, []);
	
	const startSequence = async () => {
		await Tone.start();
		// Sequence がまだ生成されていなければ生成
		if (!seqRef.current) {
			seqRef.current = new Tone.Sequence(
				(time, note) => 
					synthRef.current?.triggerAttackRelease(note, "8n", time),
					["C4", "E4", "G4", "B4"],
					"4n"
				);
			}
		seqRef.current.start(0);
		Tone.getTransport().start();
	};  
	
	return(
		<button onClick={startSequence}>
			Start Sequence
		</button>;
	);
}

実装

  • 初期化の担保:startSequenceawait Tone.start()の後に実行されるため確実にAudioContextが有効な状態でシーケンスを登録する
  • シーケンスの存在を確認することでシーケンスの重複を防ぐ

処理フロー

  1. 初回マウント時:useEffectSynthのみ生成
  2. ボタンクリック時:AudioContext を開始(Tone.start())
  3. シーケンス生成: まだ存在しない場合のみnew Tone.Sequenceを作成
  4. 再生開始:Transportを走らせて音を鳴らす

実装パターン④:BPMと同期した音声の再生

useEffectでBPMを初期化し、Start/Stopボタンでシーケンスの再生および停止を制御します。

import { useEffect, useRef, useState } from "react";
import * as Tone from "tone";

export const SimpleSyncroBpmButtonClick = () => {
	const synthRef = useRef<Tone.Synth | null>(null);
	const seqRef = useRef<Tone.Sequence | null>(null);
	const [isPlaying, setIsPlaying] = useState(false); 
	
	useEffect(() => {
		synthRef.current = new Tone.Synth().toDestination();
		
		Tone.getTransport().bpm.value = 120;
	}, []);
	const startSequence = async () => {
		await Tone.start();
		if (!seqRef.current) {
		seqRef.current = new Tone.Sequence(
		(time, note) => synthRef.current!.triggerAttackRelease(note, "8n", time),
		["C4", "E4", "G4", "B4"],
		"4n"
		);
		}
		seqRef.current.start(0);
		Tone.getTransport().start();
		setIsPlaying(true);
	};
	
	const stopSequence = () => {
		Tone.getTransport().stop();
		seqRef.current?.stop();
		setIsPlaying(false);
	};
	return (
	<>
		<button onClick={startSequence} disabled={isPlaying}>
			Start Sequence
		</button>
		<button onClick={stopSequence} disabled={!isPlaying}>
			Stop Sequence
		</button>
	</>
	);
};

実装

  • BPMの初期設定:useEffect内でTone.getTransport().bpm.value = 120を実行しコンポーネントのマウント時にテンポを確定
  • 再生状態の管理:useStateisPlayingを使ってボタンのdisable属性を制御し、二重再生や矛盾した操作を防ぐ
  • 完全な停止:Transport.stop()seq.stop()をすることで時間の停止とシーケンスの停止を確実に行い再生を再開した時に意図しない位置から再生されるのを防ぐ

処理フロー

  • マウント時:シンセサイザーの生成とBPMの初期設定
  • Startボタン:Tone.start()→シーケンス生成→Transport開始→isPlayingtrue更新
  • Stopボタン:Transport停止→シーケンス停止→isPlayingfalseに更新

ReactState運用のベストプラクティス

stateを使うべきタイミング

import { useEffect, useRef, useState } from "react";
import * as Tone from "tone";

export const ShouldUseUseState = () => {
  const synthRef = useRef<Tone.Synth | null>(null);
  const [isPlaying, setIsPlaying] = useState(false);

  useEffect(() => {
    synthRef.current = new Tone.Synth().toDestination();
  }, []);

  const toggleNote = async () => {
    await Tone.start();

    if (isPlaying) {
      synthRef.current?.triggerRelease();
    } else {
      synthRef.current?.triggerAttack("C4");
    }

    setIsPlaying(!isPlaying);
  };

  return (
    <button onClick={toggleNote}>
      {isPlaying ? "Stop Note" : "Play Note"}
    </button>
  );
};

stateTone.jsの使い分け
この例では、isPlayingというstateで音が鳴っているかどうか(UIの状態)を管理しています。

  • stateを使うべき:UIの切り替え(PlayからStopへラベルを切り替える)
  • stateを使うべきではない:音を鳴らすトリガーそのもの(再レンダリングのタイミングと発音がズレる原因になりうる)

stateを使ってはいけないタイミング

import { useState } from "react";
import * as Tone from "tone";

export const BadUseState = () => {
  const [synth, setSynth] = useState<Tone.Synth | null>(null);
  const [isPlaying, setIsPlaying] = useState(false);

  const handleClick = async () => {
    await Tone.start();

    if (!synth) {
      setSynth(new Tone.Synth().toDestination());
      return;
    }

    if (isPlaying) {
      synth.triggerRelease();
    } else {
      synth.triggerAttack("C4");
    }

    setIsPlaying(!isPlaying);
  };

  return (
    <button onClick={handleClick}>
      {isPlaying ? "Stop" : "Play"}
    </button>
  );
};

なぜこれがダメなのか
このコードではsetState のたびに Synth が再生成されてしまいます。音が途切れるだけではなく、メモリリークの原因にもなるアンチパターンです。

【4章】状態管理と音声の連携

サンプルコード

このコードはBPM,Volumeの変更スライダーを用いてリアルタイム音声のテンポ・音量を変更する実装

import { useEffect, useRef, useState } from "react";
import * as Tone from "tone";

export const SequencerControls = () => {
  const synthRef = useRef<Tone.Synth | null>(null);
  const seqRef = useRef<Tone.Sequence | null>(null);

  const [isPlaying, setIsPlaying] = useState(false);
  const [bpm, setBpm] = useState(120);
  const [volume, setVolume] = useState(-6);

  // 1. 初回マウント時に Synth を生成、アンマウントで dispose
  useEffect(() => {
    synthRef.current = new Tone.Synth().toDestination();

    return () => {
      // アンマウント時にリソースを解放
      synthRef.current?.dispose();
      seqRef.current?.dispose();
    };
  }, []);

  // 2. BPM の更新(Transport に同期)
  useEffect(() => {
    Tone.getTransport().bpm.rampTo(bpm, 0.1);
  }, [bpm]);

  // 3. Volume の更新(Synth に同期)
  useEffect(() => {
    if (synthRef.current) {
      synthRef.current.volume.value = volume;
    }
  }, [volume]);

  // 4. シーケンス開始
  const startSequence = async () => {
    await Tone.start(); // ユーザー操作で AudioContext を開始

    if (!seqRef.current && synthRef.current) {
      seqRef.current = new Tone.Sequence(
        (time, note) => {
          synthRef.current!.triggerAttackRelease(note, "8n", time);
        },
        ["C4", "E4", "G4", "B4"],
        "4n"
      );
    }

    seqRef.current?.start(0); // Transport 上の 0 から開始
    Tone.getTransport().start();
    setIsPlaying(true);
  };

  // 5. シーケンス停止
  const stopSequence = () => {
    Tone.getTransport().stop();
    seqRef.current?.stop();
    setIsPlaying(false);
  };

  return (
    <div>
      {/* 再生/停止ボタン */}
      <div>
        <button onClick={startSequence} disabled={isPlaying}>
          Start
        </button>
        <button onClick={stopSequence} disabled={!isPlaying}>
          Stop
        </button>
      </div>

      {/* BPM スライダー */}
      <div>
        <label>
          BPM: {bpm}
          <input
            type="range"
            min={60}
            max={180}
            value={bpm}
            onChange={(e) => setBpm(Number(e.target.value))}
          />
        </label>
      </div>

      {/* 音量スライダー */}
      <div>
        <label>
          Volume: {volume} dB
          <input
            type="range"
            min={-30}
            max={0}
            value={volume}
            onChange={(e) => setVolume(Number(e.target.value))}
          />
        </label>
      </div>
    </div>
  );
};

以下は上記コードの解説です

useRefSynth,Sequenceの音声オブジェクトを管理する

  const synthRef = useRef<Tone.Synth | null>(null);
  const seqRef = useRef<Tone.Sequence | null>(null);

stateは再レンダリング時に値が保持されない場合があるため、SynthSequenceのようなオブジェクトはrefで保持することで再生成を防ぎ、Audio Contextを安全に使い回すことができます。

初回マウント時にSynthを生成しアンマウント時にdisposeをする

useEffect(() => {
  synthRef.current = new Tone.Synth().toDestination();

  return () => {
    synthRef.current?.dispose();
    seqRef.current?.dispose();
  };
}, []);

アンマウント時にdispose()を呼ぶことAudioNodeSequenceのリソースを解放し、メモリリークを防ぐことができます。

BPMの同期

useEffect(() => {
	Tone.getTransport().bpm.rampTo(bpm, 0.1);
}, [bpm]);

state(bpm)が変更されるたびに、TransportのBPMを更新しています。

rampTo()とは指定した時間でBPMを目標値に滑らかに変化させるための関数で、今回の場合だと0.1秒かけて指定のBPMに持っていくという意味です.。rampToではなくbpm.value = 120`のような指定方法をすると瞬間的にテンポが変わるため不自然な音になってしまいます。

Volumeの同期

useEffect(() => {
  if (synthRef.current) {
    synthRef.current.volume.value = volume;
  }
}, [volume]);

Synthの音量をstateと同期させることで、再レンダリングが発生してもrefに保持されたSynthは変わらないため、既存のSynthのvolumeを直接更新することで音量の即時反映が可能になります。

シーケンスの開始

const startSequence = async () => {
  await Tone.start();
  if (!seqRef.current && synthRef.current) {
    seqRef.current = new Tone.Sequence(
      (time, note) => synthRef.current!.triggerAttackRelease(note, "8n", time),
      ["C4", "E4", "G4", "B4"],
      "4n"
    );
  }
  seqRef.current?.start(0);
  Tone.getTransport().start();
  setIsPlaying(true);
};

Tone.start()でユーザ操作内でAudioContextを開始し、初回実行のみSequenceを作成することで2回目以降は作成したSequenceを使い回して二重生成を防ぎます。

ハンドラではなくuseEffectを使う理由

ボタンクリック時に直接Tone.jsの値を書き換えることも可能ですが、本記事ではuseEffectの使用を推奨しています。

ReactのstateはsetStateを行っても即座に更新されないですが,Tone.jsは即時反映されます。このズレを防ぐためにstateをトリガーにしてuseEffectでTone.jsを更新しています。こうすることでデータの流れが一方向になり管理しやすくなります。

【5章】パフォーマンス観点から見た設計のポイント

UI+Audioを連携させたアプリでは、音が途切れないこととUIが重くならないことの両立が重要になります。

本記事で扱ったようなシンプルな構成であれば問題にならなくても、今後よりリッチなAudio Webアプリを作ろうとすると次のような拡張が考えられます。

  • 音階を6音から12音に増やす
  • ステップの数を8から16,32個に増やす
  • 画面に表示するグリッドを巨大化し情報を増やす

このような拡張を行う場合に注意すべきなのがUIと状態管理の負荷が増えてしまう点です。
特に、,ステップ情報を2次元配列のStateとして管理しインタラクティブなUIを作成する場合

  • ステップの更新ごとにグリッド全体が再レンダリングされる
  • 配列コピーのコストが増え、更新処理が重くなる
    といった問題が発生しやすくなります。

こうなると最初に挙げていた音が途切れないこととUIが重くならないという条件を満たせなくなる可能性が出てきます。

考慮すべき設計の方向性

ここからは個人的に考慮する必要がある設計について記述します。

例えば、

  • 再生に直接関係しないUIは分離する
  • ステップ・セル単位で再レンダリングを抑える設計にする
  • すべてをimmutableなstateで管理せず、用途に応じてrefを併用する

Tone.jsはAudioContext上で正確なタイミング制御を行うライブラリでありReactはUIを宣言的に管理するためのライブラリです。それぞれの役割で処理を切り分ける意識を持つとパフォーマンスが気にならない設計にすることができるとおもいます。

音の再生はTone.js / ReactはUI(見た目)と操作

【6章】まとめ

本記事ではTone.jsとReactを組み合わせた基本的な構成と実装例を紹介しました。

ReactとTone.jsを使う時は参考にしてください。

【付録】サンプルコード集

今回作成・提示したコードは全てGitHubにあげているので確認してみてください。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?