[Astro] #188 ブラウザで動くボイスチェンジャー — マイクの声を変え、ギターのエフェクターを並べる
はじめに
きっかけは、Gemini との壁打ちで出た「ヘリウムガスのボイスチェンジャーはどうか」という案でした。実用性のない、お遊びのアプリです。ただ、ブラウザでマイクを扱うのは、私にとって初めてでした。マイクの声を、その場で変えて返すだけなら、重くないはずです。そう考えて、作り始めました。
作ったあとで、「ギターのエフェクターみたいなものがあると面白い」と思いつき、EQ と 8 種のペダルまで足しました。この記事は、そこまでの記録です。
結論から言うと、マイクの声は、ちゃんと変わります。そして、ペダルを並べるのは、想像以上に面白かったです。 実際に声を出して、全部のプリセットとペダルを試しました。大きな問題は、出ていません。ただし、ブラウザは Chrome しか試していません。
スクリーンショット
起動画面
presetとエフェクトを追加して再生
Help
F1キーで操作ヘルプ表示。
動画
作ったもの
ページは /voice-changer です。音声は、このブラウザの中だけで処理して、どこにも送りません。
- マイク: MIC ON を押すと、声が変換されて、ヘッドホンから聞こえます
- ファイル: WAV などの音声ファイルをドロップして、再生や、変換した WAV の保存ができます
- REC: 変換後の音を、そのまま録音して、WAV で保存します
- プリセット 10 個: HELIUM、DEEP、ROBOT、ECHO、PHONE、LO-FI、CAVE、FUNK、DRIVE、NORMAL
- VOICE: PITCH(高さ)、BRIGHT(高音)、VOLUME、PHONE(電話の声)
- EQUALIZER: 5 バンドの縦フェーダー
- ペダル 8 種: WAH、DISTORTION、BITCRUSH、ROBOT、CHORUS、TREMOLO、DELAY、REVERB
普段の画面は、できるだけ少なくしました。細かい説明は、F1 のヘルプに入れています。
音の流れ
声は、次の順に、1 本の道を通ります。
入力 → ピッチ → 電話 → 明るさ → EQ
→ WAH → DISTORTION → BITCRUSH → ROBOT
→ CHORUS → TREMOLO → DELAY → REVERB → 音量 → リミッター → 出力
この道は、マイクでも、ファイルの再生でも、WAV への変換でも、同じものを使います。そのため、聞いている音と、保存される音が、同じになります。
マイクを取り込む
ブラウザでマイクを使うのは、getUserMedia です。ここで、1 つ、設定を変えました。
navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false },
});
ブラウザは、初期状態で、エコー除去・ノイズ抑制・自動の音量調整をかけます。通話には便利ですが、声を変えるアプリでは、邪魔になります。声の形が、先に変えられてしまうからです。そこで、3 つとも OFF にしました。
その代わり、スピーカーで聞くと、ハウリングします。画面にも、ヘルプにも、「ヘッドホン推奨」と書きました。
ピッチシフトの仕組み
ヘリウムの声は、声の高さを上げれば作れます。ただし、再生速度を上げると、高さと一緒に、話す速さも変わってしまいます。速さを変えずに、高さだけを変える必要があります。
使ったのは、遅延線を 2 本使う方法です。
- 音を少しずつ遅らせる読み取り位置を、2 つ用意します
- 読み取り位置は、一定の速さでずれていきます(これが、高さを変える仕組みです)
- 2 つを、窓の形(Hann 窓)で、交互に混ぜて、つなぎ目の音を消します
- 窓の長さは、約 60 ms にしました
- 高さを変えないとき(比が 1)は、素通しにします。2 つを混ぜたままにすると、くしの形の音(コムフィルター)が出るためです
高さの比は、半音の数から、次の式で出します。
ratio = Math.pow(2, semitones / 12);
この処理は、AudioWorklet(音を、別スレッドで処理する仕組み)で書きました。
声質(フォルマント)は、別に動かしていません。 そのため、これは「声の高さを上げる」処理で、本物のヘリウムの声(声の通り道が変わる)とは、少し違います。それでも、HELIUM プリセット(+8 半音と、高音を少し持ち上げる)で、十分に、それらしくなりました。
ヘッドレス Chrome で、440 Hz の音を通して測ると、次のようになりました。
| 設定 | 出てきた周波数 |
|---|---|
| 0 半音 | 440 Hz |
| +12 半音 | 874 Hz |
| -12 半音 | 224 Hz |
| +8 半音 | 696 Hz |
理論値は、+12 で 880 Hz、-12 で 220 Hz、+8 で 698 Hz です。ほぼ合っています。長さも、変わりません。
WAV を変換して保存する
ファイルは、decodeAudioData で読み込みます。WAV に限らず、ブラウザが読める音声なら、通ります。
保存するときは、OfflineAudioContext を使いました。これは、再生せずに、計算だけで音を作る仕組みです。音の流れは、ライブと同じ関数で作っているので、聞いた音と同じ結果になります。そして、再生時間を待たずに、一気に終わります。
書き出しは、16 bit の PCM にして、44 バイトのヘッダーを付けた WAV を、自分で作りました。保存した WAV を読み直して、周波数と長さが同じになることも確認しました。
ディレイやリバーブを使うと、余韻が出ます。変換結果の長さを、そのぶん、延ばしています(ディレイは、時間とフィードバックから計算します。リバーブは、広さに 0.2 秒を足します)。
REC は、変換後の音を、ライブで録音する機能で、録音用の AudioWorklet を、道の最後につないで、一定量ずつ、メインに送っています。
ペダルの作り方
ここからが、あとから足した部分です。
原音の道と、効果音の道
ペダルは、全部、同じ形にしました。原音が通る道(dry)と、効果音が通る道(wet)の 2 本を持ちます。
- 効果音だけにしたいペダル(歪み、ワウ、ロボット、トレモロ、クラッシュ)は、ON にすると、dry を 0、wet を 1 にします
- 原音に足したいペダル(ディレイ、リバーブ、コーラス)は、dry を 1 のままにして、wet の量を、MIX で変えます
- OFF のときは、dry が 1、wet が 0 です
これで、8 種の ON/OFF が、1 つの関数で済みました。
各ペダル
| ペダル | 作り方 |
|---|---|
| WAH | バンドパスフィルターの中心周波数(350〜2500 Hz)を動かします |
| DISTORTION | WaveShaper に、tanh の曲線を入れます。DRIVE は、手前の音量で変えます。4 倍オーバーサンプリングで、折り返しの雑音を抑えます |
| BITCRUSH | ビット数を減らして、サンプルを間引きます。AudioWorklet で書きました |
| ROBOT | リングモジュレーション(音に、低い周波数の波を掛ける)です |
| CHORUS | 揺れる短いディレイを、原音に混ぜます |
| TREMOLO | 音量を、周期的に揺らします |
| DELAY | ディレイ。フィードバックで、繰り返しを作ります |
| REVERB | 減衰するノイズから残響を作って、ConvolverNode に入れます |
EQ は、5 つのバンド(100 / 400 / 1k / 2.5k / 6k Hz)を、±12 dB で動かします。
つまみは、input[type=range] ではなく、上下にドラッグする丸いつまみを自分で作りました。ギターのペダルらしくしたかったからです。Shift で細かく、ダブルクリックで初期値に戻ります。
ワウは、画面のペダルで動かす
ワウは、本物のペダルを踏んで、中心周波数を動かすものです。私の手元には、アナログの入力機器がありません。そこで、画面の縦長のペダルを、マウスでドラッグして動かすことにしました。離すと、元の位置に戻ります。
もう 1 つ、AUTO も付けました。一定の周期で、自動で動かします。
注意点が、1 つあります。ファイルを変換して保存するとき、手動のワウは、半開きの位置で固定されます。マウスの動きは、変換には残らないからです。動きごと残したいときは、REC を使います。
詰まったこと
ペダルを OFF にしても、音が出ていた
ペダルを全部 OFF にしても、テストの音が、元の 4 倍近い大きさで出ていました。原因は、次の 1 行でした。
set(box.wet, 0); // 間違い: GainNode そのもの
set(box.wet.gain, 0); // 正しい: AudioParam
音量の値は、GainNode ではなく、その中の gain(AudioParam)に入れます。前者は、エラーにならず、何も起きません。ペダルを 1 つずつ外して測る方法で、見つけました。この件は、テストで音の大きさを測っていなかったら、気づかなかったはずです。
つまみが、スタイルなしで出た
ペダルの画面を、JavaScript で作ったところ、CSS が全く効かず、つまみが文字だけで並びました。Astro の <style> は、そのページの HTML に書いた要素にしか、効かないためです。あとから JavaScript で作った要素には、付きません。
そこで、ペダルまわりのスタイルを、is:global の別ブロックに分けました。他のページに影響しないように、すべて .board の中だけに限定しています。
テスト用のページでは、スタイルを全体に当てていたので、この差に気づけませんでした。実際に Chrome で開いて、初めて分かりました。
画面の配置を、何度も変えた
最初は、ペダルを中央の下に置き、右にログのパネルを置きました。ところが、PLAY ボタンが左の下の方にあって、スクロールしないと押せませんでした。右のログは、場所を取るだけで、見る場面が、ほとんどありませんでした。
最終的には、次の形にしました。
- 左: INPUT、OUTPUT(PLAY など)、PRESET
- 中央の上: 波形
- 中央の下: VOICE、EQ、ペダルを、2 段で
- ログのパネルは、廃止
幅が狭くなると、操作部が 4 列、2 列と減って、縦に積まれます。この調整は、操作のしやすさを見て、私が決めました。
全力の音で、音が割れる
テスト用の擬似マイクは、最大の音量で鳴ります。HELIUM の高音を足すと、波形が、上限に張りつきました。最後に、リミッター(DynamicsCompressor)を入れて、割れを抑えました。本物の声では、ここまでの音量にならないはずです。
確認できたこと、できていないこと
確認できたこと
- 音の高さは、理論値に近い値になりました(上の表)
- 保存した WAV を読み直して、周波数と長さが同じでした
- EQ は、-12 dB で小さく、+12 dB で大きくなります
- BITCRUSH を 3 bit にすると、音の段数が 40 段に減ります
- TREMOLO と AUTO のワウは、音量が周期的に揺れます
- ディレイとリバーブは、元の音が終わったあとも、音が残ります
- 実機(私の Chrome とマイク)で、全プリセットと、ペダル、EQ を、一通り試しました
確認できていないこと
- Quest、スマホ、Safari、Firefox での動作
- 数分以上の長い WAV の変換時間と、メモリ
- 埋め込みの窓(サイトの中の窓)の中での表示
- ペダルを多数 ON にしたときの、マイクの遅延
- ゲームコントローラーでのワウの操作(未実装)
おわりに
ブラウザの Web Audio API は、フィルター、ディレイ、歪みといった部品が、最初から揃っています。マイクの取り込みも、最初に思ったより、ずっと簡単でした。
詰まったのは、音そのものではなく、GainNode と AudioParam の取り違えや、CSS の効く範囲のような、地味なところでした。これは、聞いてもすぐには分からず、数値で測って、初めて分かりました。