トップ «前の日記(10/31/2011) 最新 次の日記(11/26/2011)» 編集

本 日 の h o g e

hogeとはワイルドカードのようなものです。日々起こった、さまざまなこと −すなわちワイルドカード− を取り上げて日記を書く、という意味で名付けたのかというとそうでもありません。適当に決めたらこんな理由が浮かんできました。

更新情報の取得には rdflirs を使ってもらえると嬉しいです.


11/05/2011 ふむ [長年日記]

tDiary 3299日目

[小ネタ][JS] Web Audio API

うんこを流す計画その 3.

ひょんなことから Web Audio API に興味を持ったので,Web Audio API を使った 変なもの を作ってみた (きっと chrome とかでしか動かないと思う).

色々思うところはあるのだけれど,何というかコンテキストが「Web Audio API」というよりもはや「いかにして波形を作り合成するか」になってしまい,もう疲れたので一旦区切りを付けた感じ.

まあ何のことはない,右上部のテキストエリアに書かれた JSON データを 1 つの RIFF WAVE データに変換して Web Audio API で鳴らすというもの. JSON は全て配列で表現されていて,ルールは以下のようになっている. 休符はない.相手は死ぬ.

トップ:
  [旋律1, 旋律2, ...]

旋律:
  [音1, 音2, ...]

音:
  [長さ(秒), 周波数群]

周波数群:
  [周波数1, 周波数2, ...]

複数の周波数は同時に鳴るので,これで和音を表現する. 複数の旋律も同時に鳴る.

MML とかじゃねーのかよ,という件については,僕が MML を知らないという理由以外にも理由があって,そもそもの動機がデータを音にしたいだけだったからです. テキストエリアにデフォルトで入っているデータを見ると,IPv4 アドレスっぽいものが入ってたりする. そう,こういうそこら中にある雑多なデータを数値化して音にするとどういう感じになるのかなーという. まあ結果は「意味わからんww」でしたけど :-P

音を作る

まず,周波数と長さというただの数値を音に変えるにはどうすれば良いか. 音というのは波なので,周波数,サンプリングレート,長さから正弦波なり矩形波なりを生成すれば良い.

なお,ただの正弦波だとだいぶのっぺりした音になる. 今回はこの正弦波に 2 次ベジェ曲線のカーブ具合を係数に加えて,徐々に音が弱まるような感じにしてある (これのせいで結構変な音が出たりするのだけれど...).

コードを抜粋すると,大体以下のような感じ.

this.duration;   // 長さ
this.frequency;  // 周波数
this.maxamp;     // 最大振幅
this.hz;         // サンプリングレート

this.signals = new Array(Math.round(this.duration * this.hz));
                 // サンプリングレート x 長さ分の配列を生成する

var len = this.signals.length;
var f   = this.frequency * 2.0 * Math.PI / this.hz;
var p   = 0;

// 2 次ベジェ曲線用
var pt1 = {x: 0,   y: 0};
var pt2 = {x: len, y: this.maxamp};
var pt3 = {x: len, y: 0};

for (var i = 0; i < len; i++) {
    // ベジェの計算
    var t  = i / len;
    var tp = 1 - t;
    var x  = Math.round(tp * tp * pt1.x + 2 * t * tp * pt2.x + t * t * pt3.x);
    var y  = Math.round(tp * tp * pt1.y + 2 * t * tp * pt2.y + t * t * pt3.y);
    var d  = (len - x) / len * y;

    // sin にカーブ具合を係数として与えて保存
    this.signals[i] = Math.round(Math.sin(p) * d);

    p += f;
}

複数の旋律を同時に鳴らす

音を合成するには,その波同士を足し合わせれば良いらしい (これを加算合成というらしい) のだけれど,問題は 8bit データの加算合成.

16bit の wav データは signed で -32768 〜 32767 を取るのでそうそう溢れるようなことはないのだけれど,8bit の wav データは unsigned で 0 〜 255 を取る. 8bit のデータ同士を単に加算合成してしまうと,すぐに溢れてしまうどころか,無音を示すのが 128 なので,無音同士を合成すると音が鳴るという大変なことになる.

これについてはあまり良い方法を思いつかなかったので,今回は 8bit データも内部的には signed で持っておいて合成をやりやすくしておいた上で,最終的にバイナリに変換する際に +128 した.

RIFF WAVE データ形式に変換する

作った配列をバイナリに変換してヘッダを付加してやる.

どうも JavaScript はバイナリを扱うのが得意ではないようで,例えば Python で言う struct モジュールみたいなものはないようだ.

数値をバイナリにパックするには,String.fromCharCode() を使うことができる.このメソッドは 16bit unsigned 列を受け取って,対応する文字列を返してくれるらしい.

が,ヘッダを 8bit データとして付加する関係上,データを 16bit unsigned にしてパックしてしまうと,後で Web Audio API に食わせる際に障害となるので,8bit unsigned としてパックしてやる. 16bit のデータを 8bit のデータに分解することは,ビットシフト/マスクを駆使すればできるのだけれど,今回は typed array というものを使ってみることにする.

var buf = new ArrayBuffer(2);
var u16 = new Uint16Array(buf);
var u8  = new Uint8Array(buf);
u16[0] = 65535;  // 65535 を入れると,
[u8[0], u8[1]];  // [255, 255] に分解される

後は wav ファイルフォーマット を参考にさせて貰いつつパックしていく.

データを Web Audio API に食わせる

実際に音を鳴らすには webkitAudioContext を使う. webkitAudioContext の使い方は 仕様書 を見るとして,問題は,先ほどまでに作った音データをどうやって食わせるのか.

音データは AudioBuffer というもので表現されるらしく, webkitAudioContext の持つ createBuffer() メソッドで作成することができる. この AudioBuffer を音源ノードに乗せて,webkitAudioContext の destination に繋いでやれば良いというわけ.

ただしこの AudioBuffer には先ほど作ったバイナリを直接乗せることはできないようで,どうも ArrayBuffer として渡してやらないといけないようだ.

つまり以下のような感じになる.

var data;  // 作ったバイナリ

// ArrayBuffer に変換
// ここでバイナリがヘッダ 8bit,データ 16bit だとおかしなことになってしまう
var arrbuf = new ArrayBuffer(data.length);
var bytes  = new Uint8Array(arrbuf);
for (var i = 0; i < data.length; i++)
    bytes[i] = data.charCodeAt(i);

// webkitAudioContext を調整
var audioCtx = new webkitAudioContext();
var gainNode = audioCtx.createGainNode();
gainNode.gain.value = 1.0;
gainNode.connect(audioCtx.destination);

// 音源ノードを作る
var voice = audioCtx.createBufferSource();

// 音データを乗せる
voice.buffer = audioCtx.createBuffer(arrbuf, false);

// 音源ノードを繋ぐ
voice.connect(gainNode);

// 鳴らす!!
voice.noteOn(0);

(追記) ローパスフィルタを通す

Web Audio API には各種フィルタも実装されているようだ. 例えばローパスフィルタを通したい場合は,ノードの繋ぎを変えて,フィルタを間に挟んでやれば良い.

var audioCtx = new webkitAudioContext();

var lowpassFilter = audioCtx.createBiquadFilter();
lowpassFilter.type = lowpassFilter.LOWPASS;        // ローパスに設定
lowpassFilter.frequency.value = 440;               // 440hz より上を遮断
lowpassFilter.connect(audioCtx.destination);

var gainNode = audioCtx.createGainNode();
gainNode.gain.value = 1.0;
gainNode.connect(lowpassFilter);

(追記2) 音色 (を生成する関数) を textarea からいじれるようにした

この文章を書いてからまた少しいじっていたのだけれど,いかんせんとにかく面倒臭いので textarea から動的に関数を生成することにした. textarea の中身を変更すると音色がもにょっと変わります.

正弦波なら↓という感じにすれば良いし,

return Math.round(Math.sin(count * delta) * wmaker.maxamp);

矩形波なら↓という感じにすれば良い.

var sig  = Math.round(Math.sin(count * delta) * wmaker.maxamp);
var base = Math.floor(wmaker.maxamp / 2);
return sig < base ? -base : base;

しかし JavaScript ってすごいね,↓で関数作れるとか... eval 並に邪悪なコードをいくらでも書けそうだぜ!

new Function("引数名1", "引数名2", ..., "関数の中身");

(追記3) アホなミス & 勘違いをしていた

Web Audio API を色々いじって遊んでいた際に playbackrate (再生速度) を 0.5 に設定していたのを忘れていた. 音が正常 (?) に鳴った時に「ウムこれでいいのだ」とか思っていたのだけれど,実際は全然良くなかった.

ヘッダ 8bit, データ 16bit でパックしたものを Uint8Array で ArrayBuffer 化したため,謎のデータになっていたみたい. charCodeAt() での変換時に 16bit unsigned が 8bit unsigned に丸められてたのだと思う (ただこれで倍速とはいえ音程がちゃんとする理由はイマイチよく理解できていない).

なのでそこら辺を修正して,日記の内容も編集した (「RIFF WAVE データ形式に変換する」項). ついでに lowpass を 0 にすることでフィルタを切れるようにもしておいた.

ところでこの辺のデバッグ作業をやっていて思ったのが,やはりどうにもデバッグがやりづらいということ. というのも,生成したデータを wav ファイルに落として別プレイヤーで再生してみる,ということができないんですな. バイナリなので console.log() に出してもダメ.

しょうがないので base64 encode して console.log() に出して,それを保存した上で base64 -d file.txt | aplay とかちまちま打っていたのでした.

雑感

とにかく全てが分からなかったのでもう何が何だか.

音がなんたるかが分からない. そもそも音って何だっけ? 合成って何それおいしいの? という感じ. 音の強さ辺りとか,多分今も理解できてないと思う.

JavaScript が分からない. バイナリの扱いどころか配列の扱いすらおぼつかない. けどこれはまあだいぶ慣れたかな.

Web Audio API が分からない. 概念やその使い方など,いまだに自分の理解が合ってるかどうかすら分からない.

とりあえず,やっぱり自分は物理/数学的な感性を持ち合わせていないんだなということだけは分かりました. 波形を操作するにあたって数式を色々もにょもにょしてた時間が一番長かったと思う...