44
Help us understand the problem. What are the problem?

posted at

updated at

Organization

ブラウザで動くアゲアゲなシンセサイザーをWeb Audio APIで作った話

アゲアゲな年末を過ごすために、JavaScriptのちょっとマニアックなAPI「Web Audio API」を用いてシーケンサー機能付きのシンセサイザーを作成しました。

koodori.png

実物はこちらから動かせるので、まずはぜひ遊んでみてください!全体のコードや、操作方法はリポジトリにございます。

はじめに

Web Audio APIはブラウザ上で音声制御を行うことができるJavaScriptのAPIです。やや独特な仕組みを持ち少々とっつきにくい部分もありますが、工夫次第で本格的な音声制御も可能な数多くの機能を持っています。

本記事では、今回の実装でも利用している

  • 基本的な概念「Node」「connect」について
  • 音声のテンポを合わせる方法
  • 音声合成
  • wavファイルの再生
  • 音量変更
  • フィルター
  • エフェクト(ディレイ、リバーブ)の作り方

をテーマに、Web Audio APIの機能や使い方を解説します。本記事の内容を大まかに理解することで、アイデア次第で様々な応用ができると思います。

注意点

本記事に記載しているコードは説明のために省略、簡略化しているため、リポジトリの内容とは異なります。必要に応じて、実際のコードを併せて確認しながら読んでいただければと思います。

開発環境

  • TypeScript 4.5.4
  • webpack 5.65.0
  • HTML
  • CSS
  • Netlify

特別な内容はないため、環境構築手順は割愛します。

基本的な概念「Node」「connect」について

Web Audio APIを触るとすぐにお目にかかる、以下のようなコードがあります。

const audioCtx = new AudioContext();
const oscNode = new OscillatorNode(audioCtx, { type: 'sawtooth', frequency: 440 });
const gainNode = new GainNode(audioCtx, { gain: 0.5 });

oscNode.connect(gainNode);
gainNode.connect(audioCtx.destination);

oscNode.start();

あまり馴染みのない書き方でちょっと面食らいますが、これは文字通り**「音響機材を作成、接続」しているイメージ**でとらえると分かりやすいです。

まず1行目では「AudioContext」というWeb Audio APIを動かすための土台を作成していまます。この「AudioContext」は利用時に一度立ち上げるだけでよいです。

audioAPI.001.png

その後、上記のコードでいうgainNodeoscNodeなどの「機材」を「connect(接続)」 していきます。Nodeにはいろいろな種類がありますが、大きく分けると楽器のように「音を出すもの」とエフェクターのように「音を加工するもの」の2種類に分けられます。

注意点として、Nodeは最終的にdestinationに接続されていないと音が出ません。どれだけオーディオ機器をたくさん繋いでも、スピーカーが接続されていないと音は出ないのと同じイメージですね。

audioAPI.002.png

アプリを作成するときは、作成したい機能に応じて、どのようにルーティングを行うべきかをある程度イメージしておくと実装がしやすいと思います。

音声のテンポを合わせる方法

Web Audio APIは「音のテンポを合わせる」実装がちょっと難しく、工夫が必要です。

JavaScript標準のタイマー系機能は音楽用途で利用できるほど正確ではなく、容易にズレが生じます。そのため、今回のアプリのように「指定したBPMで16分音符ごとに音が鳴る」ような実装は、単純なsetTimeoutsetIntervalだけだと実現することができません。

そこで今回は、テンポ同期の仕組みとしてこちらの記事で解説されているアプローチを採用しました。

setIntervalと、Web Audio APIで取得できるcurrentTimeというパラメータを併用することで、正確なタイミングでの発音を「先読み予約」し、長時間ループ再生を行ってもズレが発生しないようにしています。

上記記事の作例として用意されているメトロノームの実装は非常に参考になるので、ぜひチェックしてみてください。

音声合成(OscillatorNode)

ここからは、Web Audio APIで利用できる代表的なNodeをご紹介します。

Web Audio APIはあらかじめ収録された音をロードして鳴らすだけでなく、内部で音声合成を行う機能を持っています。

OscillatorNodeというNodeを利用することで、4種の波形を任意の周波数で鳴らすことができます。

// 440Hz (A4)の高さで鋸波を発音する
const oscNode = new OscillatorNode(audioCtx, { type: 'sawtooth', frequency: 440 });
oscNode.connect(audioCtx.destination);

oscNode.start();

audioAPI.003.png

音の高さは数値で指定する必要があるため、周波数と音名の対応表を調べてリスト化しておくとスムーズに実装が行えると思います。参考までに、今回は以下のような関数を作成して対応しました。

/**
 * 音名を周波数に変換して返す
 * @param pitchName 音名 (c2 - b5)
 * @returns 周波数
 */
const pitchNameToFrequency = (pitchName: PitchName): number => {
  const table = {
    c2: 65.406,
    'c#2': 69.296,
    d2: 73.416,
    'd#2': 77.782,
    e2: 82.407,
    f2: 87.307,
    'f#2': 92.499,
    g2: 97.999,
    'g#2': 103.826,
    a2: 110,
    'a#2': 116.541,
    b2: 123.471,
    c3: 130.813,
    'c#3': 138.591,
    d3: 146.832,
    'd#3': 155.563,
    e3: 164.814,
    f3: 174.614,
    'f#3': 184.997,
    g3: 195.998,
    'g#3': 207.652,
    a3: 220,
    'a#3': 233.082,
    b3: 246.942,
    // (以下略)
  };

  return table[pitchName];
};

※追記: 上記のように表を作る方法は入力ミスもあるので、コメントいただいたような処理を実装して計算で出すのもよいと思います!

wavファイルの再生(SourceNode)

今回のアプリにはドラムマシン機能があり、キック、スネア、ハイハットの3種の音色を16分のパターンで打ち込むことができます。このドラムの音色は、先述した音声合成ではなくあらかじめ作曲ツールを用いて収録したオーディオファイル(wav)を用いています。

オーディオファイルの再生自体は通常のaudioタグ等でも可能ですが、Web Audio APIではファイルをAudioBufferという形式に変換して読み込むことで、リアルタイムでテンポに同期したり、エフェクトをかけることができるようになっています。

// 指定したオーディオファイルをAudioBufferに変換する関数
const setupSample = async (samplePath: string) => {
  const response = await fetch(samplePath);
  const arrayBuffer = await response.arrayBuffer();
  const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
  return audioBuffer;
};

// バスドラムのwavをAudioBufferに変換
const kickBuffer = await setupSample('assets/audio/kick.wav');

変換したオーディオはSourceNodeというNodeで読み込むことができます。このSourceNodeは、先ほど登場したOscillatorNodeなどのNodeと同じように扱うことができます。

// SourceNodeを用いてwavファイルを再生
let kickNode = audioCtx.createBufferSource();
kickNode.buffer = kickBuffer;
kickNode.connect(audioCtx.destination);
kickNode.start();

audioAPI.004.png

音量変更(GainNode)

先述したOscillatorNodeSourceNodeは「音を出すためのNode」ですが、続いて「音を加工するNode」を見ていきましょう。

中でも最もシンプルなのは「原音の音量を変えて出力する」GainNodeです。

const gainNode = new GainNode(audioCtx, { gain: 0.5 });
const oscNode = new OscillatorNode(audioCtx, { type: 'sawtooth', frequency: 440 });

oscNode.connect(gainNode);
gainNode.connect(audioCtx.destination);

// oscNode は gainNode を通るため、gain(音量)が0.5で出力される
oscNode.start();

audioAPI.005.png

単純ではありますが利用機会も多く、「全体の音量を調節する」「エフェクトの音量だけ変える」など、ルーティング次第で様々な用途で利用することができます。

なお、Nodeのパラメーターは再代入によって変更が可能なので、inputタグから受け取った値を反映させることで、ユーザーがグリグリと動かすことができるようになります。

// ここでは、あらかじめ「masterGainNode」という全体のボリュームを制御するGainNodeを作成しています
const handleChangeMasterVolume = (e: Event) => {
  if (!(e.currentTarget instanceof HTMLInputElement)) return;
  const value = Number(e.currentTarget.value);
  if (value < 0 || value > 50) {
    alert('無効な値です。');
    return;
  }
  masterGainNode.gain.value = value;
};

フィルター(BiquadFilterNode)

ある周波数の音をカットする「フィルター」はサウンドメイクに欠かせませんが、これはBiquadFilterNodeというNodeを利用することで実装できます。

// 1000Hz以上の帯域をカットするローパスフィルター
const filterNode = new BiquadFilterNode(audioCtx, {
    type: 'lowpass',
    frequency: 1000,
    Q: 10,
  });

実際にconnectして使うと以下のようになります。

const oscNode = new OscillatorNode(audioCtx, { type: 'sawtooth', frequency: 440 });
oscNode.connect(filterNode);
filterNode.connect(audioCtx.destination);

// 1000Hz以上がカットされた、A4の鋸波が鳴る
oscNode.start();

audioAPI.006.png

エフェクト(ディレイ、リバーブ)の作り方

ここからは少し複雑になります。

ディレイやリバーブといったエフェクトは一般的に「エフェクトで得られたサウンド(WET)」と「原音(DRY)」を混ぜて鳴らします。そのため、さきほどのフィルターとは異なり、connectが1本道ではなくなります。

ディレイ(DelayNode)

Web Audio APIのディレイはDelayNodeによって実装できるのですが、このDelayNodeは非常にシンプルな仕組みで、

  • 原音と同じ音を、指定した時間だけ遅らせてもう一度鳴らす

という機能だけを持っています。

シンセやエレキギターなどで利用されるディレイといえば、反響音が何度も繰り返されながら減衰していくサウンドを思い浮かべる方が多いと思います。このようなサウンドを得るためには、少しだけ実装に工夫が必要です。

const oscNode = new OscillatorNode(audioCtx, { type: 'sawtooth', frequency: 440 });
const delayNode = new DelayNode(audioCtx, { delayTime: 1 });
const delayFeedBackNode = new GainNode(audioCtx, { gain: 0.75 });

// 原音
oscNode.connect(audioCtx.destination);
// ディレイ
oscNode.connect(delayNode);
delayNode.connect(delayFeedBackNode);
delayFeedBackNode.connect(delayNode);
delayNode.connect(audioCtx.destination);

audioAPI.007.png

delayNodeによって発音されたサウンドが、GainNodeであるdelayFeedBackNodeを通って再びdelayNodeに戻ってくるようなループ状のルーティングになっています。この方法で、少しずつ音が小さくなって減衰するような反響音を表現することができます。

リバーブ(ConvolverNode)

Web Audio APIのリバーブConvolverNodeは「コンボリューションリバーブ」で、残響データ(インパルスレスポンス)を用いて原音を加工する仕組みになっています。

今回は、あらかじめ作曲ツールで作成したインパルスレスポンスを読み込んで利用しています。読み込む際にAudioBufferに変換するのは、先に紹介したドラムのwavファイルと同様です。

const oscNode = new OscillatorNode(audioCtx, { type: 'sawtooth', frequency: 440 });
// setupSample はドラムの解説でも使用した自作の関数です
const impulse = await setupSample('assets/audio/impulse.wav');
const convolverNode = new ConvolverNode(audioCtx, {
    buffer: impulse,
  });

// 原音
oscNode.connect(audioCtx.destination);
// リバーブ
oscNode.connect(convolverNode);
convoloverNode.connect(audioCtx.destination);

audioAPI.008.png

convolverNodeが発音するのは残響音のみなので、原音と混ぜてdestinationに送ることでリバーブエフェクトらしいサウンドを得ることができます。

その他のポイント

その他、今回の実装で工夫したいくつかのポイントを紹介します。

クリック後にAPIの初期化を行う

Web Audio APIは音声を扱うAPIなので、ページを開いたら勝手に再生されるような実装は避け、ユーザーの許可を待つように実装することが望ましいです(多くの環境で、ユーザー操作を待たずにAudioContextを初期化するような処理を書くと警告が出ます)。今回はアクセス時の「START」ボタンを押してから初期化が始まるようにしています。

参考

koodori_start.png

シンセのフィルターと全体のフィルターを2つ用意する

OSCのセクションにあるフィルターとEFFECTのセクションにある「DJ-FILTER」はどちらもBiquadFilterNodeで作成したフィルターですが、ルーティングが異なります。シンセのフィルターはドラムパートのサウンドには影響しない一方で、「DJ-FILTER」は演奏全体に効果を及ぼすようにしています。

前者は主にオシレーターの音作り用として、後者は演奏中の飛び道具エフェクトとして利用するようなイメージで作成しました。

input type="range"のノブ実装

特定範囲の値をコントロールできるinput type="range"はもともとスライダー型のUIなのですが、シンセといえば「丸いツマミ(ノブ)」をグリグリ回す操作がおなじみです。自前実装はけっこう面倒なので、今回はこちらのライブラリを利用しました。

おわりに

今回のアプリはWebアプリケーションの強みである「ブラウザさえあればいつでも音が鳴らせる手軽さ」を活かして、「専門知識がなくてもすぐ遊べる」ように作ってみました。

フレーズを再生しながらツマミをグリグリして、Web Audio APIの音声制御機能を体感していただければ幸いです!

参考記事

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
44
Help us understand the problem. What are the problem?