はじめに

この記事は、自身の波形生成処理で作り出す無限長のサウンドデータを Web Audio API を使って再生する方法を説明します。

音を出したい!

コンピュータをやっていると、音を出したくなることがしばしばあるものです。もちろん WAV や MP3 のような既存のオーディオファイルを再生しましょうという話ではなく、自分で波形生成して出力したいということです。
でも波形生成処理は適当なスクリプト言語なんかでさくっと書くことができても、それを音として鳴らして聞くのは結構面倒なのです。
こんな感じになるでしょう。

イケてない一日
STEP1
波形生成処理を書く

適当なスクリプト言語で思いのままに処理を書きます。楽しい時間です。

STEP2
WAVファイルのフォーマットを思い出す

直接音を出せるスクリプト言語環境は限られますので、生成結果はWAVファイルとして出力することになるでしょう。でも WAVフォーマットなんて暗記していようもなく、素でコーディングすることなんてできません。
苦行の時間の始まりです。

STEP3
プレーヤーで開く

とりあえず出力されたファイルをプレーヤーで開いてみます。
多分うまく再生できません。

STEP4
WAVファイル出力処理のデバッグ

得てしておかしな WAVファイルが出来上がっていますから、出力処理のデバッグをします。
そして出力の再実行をすると「別のプログラムがこのファイルを開いています」的なエラーで書き込みに失敗するわけです。
波形生成処理を書く数倍の時間を費やすことになります。

STEP5
音は鳴るが

あれこれを手を尽くした末にようやく音が鳴ります。
ここでやっと本題の波形生成処理の調整を行うことができます。
しかしこの調整作業は、出力してはプレーヤーを開きというだるい手順の繰り返しを伴います。

本当にやりたいことはたったこれだけなのに。

クールなひと時
STEP1
波形生成処理を書く

適当なスクリプト言語で思いのままに処理を書きます。楽しい時間です。

STEP2
音が鳴る

実行すれば音が出ます。処理を調整して再実行すればすぐ結果を確かめられます。
至福の時間です。

音、出せます

ネックだったのはスクリプト言語から音を出す方法でしたが、どうやら JavaScript を選べば道が見えてきます。JavaScript の代表的な実行環境と言えば Webブラウザですが、今やほとんどの PC に入っているばかりか、今時の Webブラウザは Web Audio なるオーディオ処理機構をサポートしていて JavaScript から音を出すことができるのです。お手元の PC の OS がどーたらといった事情は Webブラウザが吸収してくれますから、ちょっとしたコードスニペットをライブラリ的に持っているだけで、どこでも同じように波形生成処理を書くことができるわけです。おまけにデバッガまで付いているのですから言うこと無しです。
というわけで、まずはサンプルです。

Sound Tester

http://takebo.html.xdomain.jp/tools/SoundTester.html

Playボタンを押すと計算で作り出された音が鳴ります。
Stopボタンを押すまで発声し続けます。
外部のリソースを参照していませんので、このファイルひとつを保存すればローカル完結で動かすことができます。

以下波形生成処理のコードです。あなたがお好きに実装する部分です。

/**
 * 波形計算処理
 * 呼び出し 1回で 1サンプル作る(-1.0 ~ +1.0 の範囲)
 */
const samplingRate = 44100;
const f1 = 1000.0;
const f2 = 555.0;
const a1 = 1.0;
const a2 = 0.1;
let t = 0;

function MySoundGenerator()
{
    const p1 = 2 * Math.PI * f1 * t / samplingRate;
    const p2 = 2 * Math.PI * f2 * t / samplingRate;
    t++;
    return a1 * Math.sin(p1 + 2.0 * Math.PI * a2 * Math.sin(p2));
}

/**
 * プレーヤーのインスタンス作成と初期ボリューム設定
 */
const player = SoundPlayer(samplingRate);
player.volume(50);

MySoundGenerator() で波形を作ってます。呼び出し毎に 1サンプル返す構造にします。
SoundPlayer(samplingRate) が今回の成果物であるプレーヤーのインスタンス化処理ですが、この中身は次章以降で解説します。

音の再生トリガーは HTML側のボタンに書いています。音量設定、再生開始、停止だけです。再生開始時に波形生成処理 MySoundGenerator を渡しています。

<input type="number" value="50" min="0" max="150" step="5" onchange="player.volume(parseInt(this.value))" title="音量">
<button onclick="player.start(MySoundGenerator)">Play</button>
<button onclick="player.stop()">Stop</button>

いかがでしょう、使い方は簡単です。そしてキモは、最初に再生時間を決めていないというところです。Stop を押すまで波形生成とその再生が継続します。

Web Audio API

私は今回初めて Web Audio に手を出したぺーぺーですから、Web Audio の世界を解説するなんておこがましいことはしません(できません)。サウンド出力ツールとして使う次のポイントだけ説明します。

自身のコードで波形生成を行い、時間を限らず処理を止めるまで再生し続ける方法は?

Web Audio は Webブラウザで音を出す機構ですが、特別なプラグインも要らず特別な設定も不要で使えます。ぐぐれば情報もたくさん出てきますしすぐ動くサンプルも見つかります。ただ私がうまく見つけられなかったのが↑の点です。任意波形を生成して鳴らす話はあっても、ほとんどは有限長のバッファのワンショット再生くらいまでの説明でおしまいです。もう一歩踏み込んだ、途切れることなく無限に再生し続ける方法の具体的な指南がなかなか見当たらないと、こういうわけです。で結局のところ目的に敵う方法を見つけられたので、ここに記しておく次第です。
こういう経緯ですが、まずは基本となる有限長の波形を生成して再生する手順を書いておきます。

有限長の波形生成と再生

STEP1
AudioContext の生成

まず AudioContext を new します。
ただしこの処理は、ユーザーの操作によるイベント(click とか)を契機に行わなければいけません。ページを開いたら勝手に音が鳴るとかは許されていないようです。

const ctx = new AudioContext();
STEP2
AudioBuffer の取得

次に、波形を作り出すバッファとして AudioBuffer を取得します。これはバッファですが、配列ではありませんからこれに直接波形を書き込むことはできません。

const audioBuffer = ctx.createBuffer([チャンネル数], [サンプル数], [サンプリングレート]);
STEP3
チャンネルデータの取得と波形書き込み

取得した AudioBuffer からチャンネルデータを取得します。これは配列(Float32Array)で、ここに波形を作りだします。サンプル値は -1.0 ~ +1.0 の範囲に正規化します。

const buf = audioBuffer.getChannelData([チャンネル番号]);
for (let i = 0; i < buf.length; i++) {
    buf[i] = サンプル値;
}
STEP4
オーディオソースを取得

段階が多くてよく分からなくなってきますが、おまじないだと思ってこうします。

const source = ctx.createBufferSource();
source.buffer = audioBuffer;
STEP5
再生

オーディオソースを出力先に connect(接続)して start すれば再生されます。ctx.destination は PC のスピーカーだと思っておけばよい感じでしょうか。

source.connect(ctx.destination);
source.start();

スピーカー直結以外にも、以下のように音量を変えるなどのフィルターを挟むこともできます。gain がそのフィルターですが、これをスピーカーに繋ぎます。そしてオーディオソースは gain に繋ぎます。するとオーディオソースの出力が gain を経由してスピーカーに至るわけです。

const gain = ctx.createGain();
gain.connect(ctx.destination);
source.connect(gain);
source.start();

無限長の再生

いよいよ本題です。無限長ですから、最初にバッファ上に波形の全てを作り出しておくことはできません。
なのでここでは適当な時間で区切ったバッファを複数作って、それが順に隙間なく再生されるようにします。それを可能にするのがオーディオソースの ended イベントと start メソッドに与える引数 when(開始時刻)です。

ended イベント

ended イベントは、そのオーディオバッファの再生が終了した時に発生します。
onended にイベントハンドラを定義すれば再生終了時に処理を行うことができます。

start(when) メソッド

start メソッドはオーディオバッファの再生を開始しますが、これに when を与えると、その時刻になったら再生が開始されるようになります。ここで言う時刻は AudioContext が持つ時刻を基準とします。AudioContext の現在の時刻は AudioContext.currentTime プロパティで取得することができます。
もし 1秒後に再生されるようにしたいなら次のようにします。

source.start(ctx.currentTime + 1.0);

これらを使って、以下のように構成します。

波形生成チェーン

再生を始める時刻を t、バッファ長を d [秒] としたとき、最初にバッファを 2つ作って1つ目は時刻 t を、2つ目は1つ目の終了時刻 t + d を指定して start しておきます。各バッファの onended にハンドラを設定して、バッファの再生が終了したら新しいバッファを作り、最後のバッファの次に再生されるように時刻を指定して start していきます。これを繰り返すことよって途切れることなく再生が継続します。
以下の generate 関数がバッファ一つ分の生成処理です。

const duration = 1.0; // バッファ長
let tm = 0.0; // バッファ時刻// バッファ生成と再生

function generate()
{
    if (tm < 0.0) return;
    const audioBuffer = ctx.createBuffer(1, duration * samplingRate, samplingRate);

    // 単位時間分生成
    const buf = audioBuffer.getChannelData(0);
    for (let i = 0; i < buf.length; i++) buf[i] = gen();

    // 時刻指定で再生
    const source = ctx.createBufferSource();
    source.buffer = audioBuffer;
    source.onended = generate;
    source.connect(gain);
    source.start(tm);
    tm += duration;
}

上図で言うところの t と d はコードの tm と duration に対応しますが、バッファを生成したらそれを時刻 tm に再生するようにして、tm を duratiuon だけ進めて次のバッファの再生時刻とします。onended にはこの生成関数 generate を指定し、バッファの再生の終了ごとに生成処理が継続するようにします。
なお冒頭の if (tm < 0.0) は、再生終了時にバッファの生成が行われないようにするためのコードです。バッファに仕掛けた onended が後から実行されたとき対策で、再生終了時には tm を -1 にするようにしてイベントで行われる処理を制御します。

再生を始めるときの処理は以下の通りです。

// 時間の進行を止めて再生開始時バッファリング
const promise = new Promise((resolve) => {
    ctx.suspend().then(() => {
        // 初回バッファ生成
        tm = ctx.currentTime;
        generate();
        generate();

        // 再生開始
        ctx.resume().then(resolve);
    });
});

バッファの継ぎ目で音が途切れないようにするためにはバッファは2つ以上必要ですので、generate() を 2回しておきます。この初回バッファを作っている間に AudioContext の時刻が動かないよう、ctx.suspend() で時間の進行を一時停止しておきます。ctx.suspend() は Promise を返しますので、一時停止が完了すると then が実行されます(ここで new している Promise のことではありません)。これを受けて現在時刻 ctx.currentTime を取得して基準時刻とし、初回バッファを必要数生成します。それが済んだら ctx.resume() によって AudioContext の進行を再開します。あとは各バッファのイベントによって続くバッファが生成されていきます。

これら処理をまとめたものが SoundPlayer で、以下がその全容です。

/**
 * 合成波形プレーヤー
 *
 * obj = SoundPlayer(samplingRate);
 * obj.volume(100);
 * obj.start(generatorFunc);
 * obj.stop();
 */
function SoundPlayer(samplingRate)
{
    // 現在再生中のプレーヤーインスタンス
    let instance;

    // 音量 [%]
    let vol = 0;

    // プレーヤーインスタンス生成
    function createInstance(gen)
    {
        const duration = 1.0; // バッファ長
        let tm = 0.0; // バッファ時刻

        const ctx = new AudioContext();
        const gain = ctx.createGain();
        gain.connect(ctx.destination);
        volume();

        // 時間の進行を止めて再生開始時バッファリング
        const promise = new Promise((resolve) => {
            ctx.suspend().then(() => {
                // 初回バッファ生成
                tm = ctx.currentTime;
                generate();
                generate();

                // 再生開始
                ctx.resume().then(resolve);
            });
        });

        // バッファ生成と再生
        function generate()
        {
            if (tm < 0.0) return;
            const audioBuffer = ctx.createBuffer(1, duration * samplingRate, samplingRate);

            // 単位時間分生成
            const buf = audioBuffer.getChannelData(0);
            for (let i = 0; i < buf.length; i++) buf[i] = gen();

            // 時刻指定で再生
            const source = ctx.createBufferSource();
            source.buffer = audioBuffer;
            source.onended = generate;
            source.connect(gain);
            source.start(tm);
            tm += duration;
        }

        // 再生停止
        function stop()
        {
            tm = -1; // 停止マーク
            ctx.close();
        }

        // 音量設定
        function volume()
        {
            gain.gain.setValueAtTime(vol / 100.0, ctx.currentTime);
        }

        return {
            promise: promise,
            stop: stop,
            volume: volume,
        };
    }

    return {
        start: (gen) => {
            if (instance) instance.stop();
            instance = createInstance(gen);
            return instance.promise;
        },
        stop: () => {
            if (instance) instance.stop();
            instance = null;
        },
        volume: (v) => {
            vol = v;
            if (instance) instance.volume();
        }
    };
}

2022/9/23 公開時より少し修整しています。
以前はバッファ長(duration) を 0.5秒にしていたのですが、これだと多分バッファの継ぎ目で微妙にチリチリ聞こえることがあるので 1.0秒にしました。時間の単位が小数なので、端数の丸めが発生しづらいぴったりくる数字の方がいいのかもしれません。バッファ長が伸びたので、バッファ数も 3 から 2 に減らしています。

最後まで読んでくれたあなたに

はい、今回はこんなもんです。ちょっと必要に迫られて Web Audio を試した記録みたいなもので、やってることの割に持って回った記事になりました。でも音を出したいニーズを叶える手段としては有効だと思うので、コピペで使えますから活用いただければ幸いです。
さて。そっ閉じせず記事のこんなところまで付き合ってくれたのであれば、ついでにこんなデモも見て行ってください。

TinyMusic Emulator DEMO

http://takebo.html.xdomain.jp/tools/TinyMusicEmu_Demo.html

※音が出ます
※iPhone7 の Safari でうまく動かないことを確認していますのでそのうち直します

JavaScript で書いた AVR CPU の簡易エミュレータで、AVR ATTiny10 で作る私製ミュージックボックス向けの動作確認ツールです。これなら少しは驚きを感じてくれますかね。ちょっと念を押すと、この HEX は単なる曲データではなく、曲を鳴らす AVR のプログラムコードです。このコードをエミュレータで実行して、それが出力するサウンドデータを再生しています。なのでこのまま ATTiny10 に書き込んで電源だけつなげば PB0 から同じように音が出ます。

Tiny Music

このミュージックボックスついてはそのうち記事にします。