Fasttracker 2の.XMファイルをJavaScriptで再生

player
(訳注:元の記事の上部にて、このプレイヤーを実際に実行できます。)

これは何か?

Play(再生)ボタンを押すと、音楽が再生されます。具体的には、1994年に公開されたFastTracker 2と呼ばれるプログラムで作られた(もしくは、少なくとも互換性のある)音楽です。これは私が懐かしくなって敬意を込めて書いたJavaScriptです。ソースコードはGitHubで確認できます。

Load(読み込み)ボタンを押すと、私のWebホスト上にある他のいくつかの.XMを読み込んだり、コンピュータからプレーヤーのウィンドウに.XMをドラッグアンドドロップしたりできます。

次の画像はFasttracker 2のインターフェースです。

FastTracker II screenshot
元のフォントを使用して上記のFasttracker 2インターフェースを模倣したものをレンダリングしています。

.XMファイル

広く普及している.MOD音楽ファイルフォーマットは、1980年代後半にCommodoreのAmiga上で作り出されました。.MODは4トラックの8ビットPCMチャンネルを同時に再生するAmiga内部に組み込まれたチップ”Paula”用に設計されていました。アーティストがたった4トラックで音楽制作を行うことができるのは、かなりすばらしいことです。Lizardkingの古い4チャンネルの.MODをいくつか.XMフォーマットに変換したので、それらを上記のプレーヤーで再生することができます。上記の読み込みボタンを押して試してみてください。

Fasttracker 2の.XM(eXtended Module)フォーマットは、1990年代に.MODのマルチチャンネル拡張として作成され、PC demoグループのTritonによって書かれたものです。同時期には他にもScream Tracker 3の.S3Mフォーマットや、その後登場したImpulse Trackerの.ITのようなマルチチャンネルMODタイプの音楽トラッカーがありました。1990年代と2000年代前半に誕生した大半のPC demoや多くのゲームは、これらのフォーマットのいずれかで音楽を再生していました。

中身は、様々なピッチや音量でサンプルを別々に再生するいくつか(この記事のデモでのデフォルトの曲の場合は14個)のチャンネルで構成されており、記事冒頭のプレーヤー内でスクロールしているようなパターンで制御されています。

パターンは、「1列の命令というより、むしろスプレッドシートのように配置されている」という点を除き、音楽を再生するためのアセンブリ言語によく似ていて、多くの16進数と曖昧なシンタックスを備えています。各セルには、特定の楽器でのノート(音)の再生/停止を行ったり、必要に応じてエフェクトをかけて、音量やパンニング(音の左右)、ピッチを変更をするための記譜法が含まれています。

再生される音はいずれも全てサンプルの1つです。このページの一番上にあるプレーヤーでは、パターンの下に小さな波形と数字の表としてサンプルが表示されています。これは、ミュージシャンがピアノやベースのような楽器を特定の音で録音し、高速や低速で再生することでピッチを変えるだろうという考えからきています。

理論的には、それぞれのサンプルには”ピアノ”または”ベース”のような名前がありますが、実際には、ミュージシャンはそれらを消去して、代わりにメッセージを書いていました。そのため、その後、楽器リストの誤用を避けるため、各トラッカーに曲のメッセージフィールドが追加されたのでしょう。

Annotated pattern image
注釈(左上から):音、楽器、エフェクト、リリース、音量

上記の例では、1つ目のチャンネルにおいて2番の楽器で、5オクターブ目でEの音を再生しようとしているところです。2つ目のチャンネルでは、音量を0x2C(最大音量は0x40なので、これは最大音量の半分~3/4の間の音量です)に落とし、同じ音を同じ楽器で1オクターブ上げて再生します。また、2つ目のチャンネルはエフェクト0をかけて再生されようとしています。エフェクト0は、0、0x0c(または12)、半音上げた0の間を切り替えるアルペジオという効果で、つまり、E-6、E-7、E-6を連続で高速再生し、それを繰り返します。

トラッカープログラムは音階の情報を持っていないので、常に臨時記号にシャープを使います。例えば、Db4ではなくC#4が使われるといった感じです。

.XMの楽器は、音量やパンニングのエンベロープを含んでいる点、楽器毎に複数の異なるサンプルを持つことができるという点で、.MODのサンプルを上回っています。後者の利点により、様々なピアノの音を記録し、再生している音に最も近い録音を使うことができるため、一つのサンプルを数オクターブに渡って使うことで音を悪くするようなことはしません。上記の表では楽器毎に1つ目のサンプルを単にレンダリングしているだけですけどね。

.MODの派生フォーマットでは、パターンデータの各行は、speedBPMによって制御されている一定のレートで再生されます。皆さんにもなじみがあると思われる項と接線の関係に似ています。speedティックの行速度を制御し、BPMティックの長さを制御します。1ティックは2500/BPMミリ秒と定義されています。

典型的な値はBPM = 125、speed = 6で、1ティックにつき2500/125 = 20ミリ秒、または50ティック/秒に対応しており、1秒あたり50/6 = 8.333行です。強拍が4行毎に出てくると仮定すると、1分あたり125拍になります。しかし、speedが5の場合、1分あたり150拍なので、額面どおりの条件で考えることはできません。

ティック毎にエフェクトの処理を行い、音の音量やピッチを修正します。エフェクトは3つの16進数で表されます。最初の数字が効果のタイプで、次の2つの数字はパラメータです。XMでは、効果のタイプは16進数の桁を超えていて、0~9とA~Zの最後までいきます。

本記事のデモでは、アルペジオ(3つの音を非常に速く切り替える。効果047はメジャー・コードのアルペジオを再生)、ポルタメント(トロンボーンのように1つの音から別の音へ滑らかに移る。効果1xxで上に移り、2xxで下へ、3xxで特定の音へ移動)、ビブラート(ピッチを上下に振動させる効果。4xx)、音量の増減(Axy)が含まれています。曲の再生中に注意して見ていれば、それが分かるでしょう。

エフェクトの仕組みは複雑で、再生している曲全体に影響を与える効果もあれば、行の最初のティックでのみ発生する効果や、行の最初のティック以外の全てのティックでのみ発生する効果もあり、それを説明すると長くてあまり面白くない記事になってしまうので、詳細は省きます。適切でありながらも、互いに矛盾しているリソースがオンラインには存在します。

JavaScriptでのリアルタイム音響合成

webkitAudioContext要素がHTMLに導入されたのは随分前のことですが、現在はAudioContextとして標準装備されています。APIがかなり柔軟で、これを活用してできることの1つにJavaScriptのコールバックの取得があり、程度の差はあるものの出力デバイスに直接サンプルを書き込むことができます。これは以前、DMAでループの割り込みを行っていたのと同じような感じで、createScriptProcessorと呼ばれています。

これによって、ありとあらゆるノイズを瞬間に作成し、起動することができます。

function InitAudio() {
  // AudioContextの生成。上手く行かなければ 
  // webkitAudioContextを生成 (Safariなどの場合)
  var audioContext = window.AudioContext || window.webkitAudioContext;
  var audioctx = new audioContext();

  // 入力される全ての値を定数倍する"gain node"の生成。
  // これによりボリュームコントロールが可能
  gainNode = audioctx.createGain();
  gainNode.gain.value = 0.1;  // マスターボリューム

  // 入力チャンネル0個、出力チャンネル2個、バッファサイズ4096の
  // スクリプトプロセッサ・ノードを生成
  jsNode = audioctx.createScriptProcessor(4096, 0, 2);
  jsNode.onaudioprocess = function() {
    var buflen = e.outputBuffer.length;
    var dataL = e.outputBuffer.getChannelData(0);
    var dataR = e.outputBuffer.getChannelData(1);
    // dataLとdataRはFloat32Arrays。ここにサンプルを埋め込み、
    // フレームワークが再生してくれます!
  };

  // ここでパイプラインを生成。パイプラインでは、
  // スクリプトがサンプルをゲインノードに渡し、ゲインノードは
  // 実際にノイズを鳴らす"destination"にサンプルを書き込む
  jsNode.connect(gainNode);
  gainNode.connect(audioctx.destination);
}

onaudioprocess関数は、出力バッファを埋めるために新しいサンプルが必要となった時、いつでもコールバックされます。44.1kHzのデフォルトサンプリングレートレートでは、92ミリ秒毎に4096のサンプルバッファの失効とコールバック呼び出しが行われます。

ここでの目的は、このバッファを各チャンネルの出力波形の総和で埋めることです。各チャンネルは、一定の周波数や一定の音量で再生するサンプルを出力し、1ティックの間、サンプル周波数と音量は一定です1。ティック間では、チャンネルの周波数や音量、エンベロープを再計算します。現在のspeed値(下の例では3)の偶数倍であるティック上では、音、楽器、エフェクトといったパターンデータの新しい行を読み込みます。

出力は、各サンプルに対する個別チャンネル2の総和です。

Output summed up per tick
上記を行うと、出力は以下のようになります。

Audio buffer layout diagram
この例では、1ティックは20ミリ秒、出力は44.1kHzで再生されています。つまり、1ティックが882サンプルということになります。もちろん、ティックがサンプルバッファを均等に分割することはありませんし、AudioContextは2の累乗数、かつ1024以上であるバッファサイズを使う仕様になっています。ですから、サンプルをバッファにレンダリングする場合、最初と最後ではティックを部分的に再生しなければならないかもしれません。

オーディオのコールバックでは、1ティックにいくつのサンプルがあるのかを明らかにし、それらを出力バッファに適合させます。そして1ティック内で各チャンネルのデータを合計すると、以下のようになります3

var cur_ticksamp = 0;  // Sample index in current tick
var channels = [ ... ];  // Channel state: sample num, offset, freq, etc

function audioCallback(e) {
  var buf_remaining = e.outputBuffer.length;
  var dataL = e.outputBuffer.getChannelData(0);
  var dataR = e.outputBuffer.getChannelData(1);

  dataL.fill(0);  // (see footnote 3)
  dataR.fill(0);

  var f_smp = audioctx.sampleRate;  // our output sample rate
  var ticklen = 0|(f_smp * 2.5 / xm.bpm);  // # samples per tick
  var offset = 0;
  while(buf_remaining > 0) {
    // tickduration is the number of samples we can produce for this
    // tick; either we finish the tick, or we run out of space at the
    // end of the buffer
    var tickduration = Math.min(buf_remaining, ticklen - cur_ticksamp);

    if (cur_ticksamp >= ticklen) {
      // Update our channel structures with new song data
      nextTick();
      cur_ticksamp -= ticklen;
    }
    for (var j = 0; j < xm.num_channels; j++) {
      // Add this channel's samples into the dataL/dataR buffers
      // starting at <offset> and spanning <tickduration> samples
      mixChannelInfoBuf(channels[j], offset, offset + tickduration,
                        dataL, dataR);
    }
    offset += tickduration;
    cur_ticksamp += tickduration;
    buf_remaining -= tickduration;
  }
}

音の周波数

.MODの派生フォーマットでは、C-4の音(オクターブ4のC音)は8363Hzのサンプリングレートレートで再生されます。全てこれが基準となっていています。例えば、音を1オクターブ上げると16726Hzとなり、2倍の周波数になります。半音上げる毎に周波数を2の12乗根で掛けていきます。通常、周波数は8363 * Math.pow(2, (note - 48) / 12.0)で計算することができます。ここでのnoteは、半音のノート番号を指していて、C-0の0から始まり、B-7の95までになります。

XMフォーマットでファインチューニング、ビブラート、スライド、その他をサポートするために、エフェクトは音の”period”を編集します。この場合のperiodは周期ではなく半音の1/16(マイナス4)となります。また、各サンプルには、再生周波数を計算する際に追加されるcoarseチューニングやファインチューニングのオフセットがあります。

再サンプリング

mixChannelIntoBufの役割は、一定の周波数で再生されたサンプルを、通常44.1kHzの出力周波数で再生されるバッファ上に書き込むことです。出力周波数と異なる周波数で録音されたサンプルを再生することは、意外にも重要な問題です。

標本化定理から、オリジナルのサンプルに含まれる周波数は、オリジナルのサンプリングレートの半分以下、もしくは同等ということが分かっているので、新しい周波数で再生した場合でも同じ一連の周波数で再生されるべきです。

しかし実際には、”ブリックウォール”や”sinc”フィルタを使わないと、折り返し雑音という現象が原因でいくつかの高調波スプリアスが発生します。これを避けるには、オリジナルサンプルと同じくらいの長さの畳み込みを必要とします。つまり、計算コストが高くなるということです。

また、オリジナルの.XMプレーヤーは、手の込んだことは一切しないので、こちらも通常は必要ありません。

不必要な高調波雑音を取り除くために高度な再サンプリングテクニックを使うと、ほとんどの曲5は、とてもひどい音になることが分かっています。これらを再生したオリジナルのハードウェアやソフトウェアは補完された時間に近いサンプルを出力する”zero-order hold”、もしくは周辺のサンプルの間で補完する”first-order hold”を使っています。

そこで私は、かなり音が良く、シンプルな実装をすることで妥協しました。使った組み合わせはzero-order holdと、実装があまり難しくない各チャンネルに対する2極ローパスフィルタです。詳細については、次回のブログ記事で紹介したいと思います。

どちらにしても、サンプルを処理する速度で計算することができます。この速度は、出力周波数に対する再生周波数の割合となります。

function UpdateChannelPeriod(ch, period) {
  var freq = 8363 * Math.pow(2, (1152.0 - period) / 192.0);
  ch.doff = freq / f_smp;
}

doff変数は、各サンプルのオフセットの差分です。もし、44100Hzちょうどで再生した場合doffは1となり、単にサンプルを出力に複製しているだけです。

以下は、zero-order-holdの例ですが、多くのサンプルのループやエンディングの詳細は省いています。

function mixChannelIntoBuf(ch, start, end, dataL, dataR) {
  var samp = ch.sample;
  for (var i = start; i < end; i++) {
    // Dumb javascript tricks:
    // we use a bitwise OR with 0 to truncate offset to integer
    var s = samp[0|ch.off];

    ch.off += ch.doff;
    dataL[i] += ch.volL * s;  // multiply by left volume
    dataR[i] += ch.volR * s;  // and right volume
    if (ch.off >= ch.sample.length) {
      if (ch.sample.loop) {
        ch.off -= ch.sample.looplen;
      } else {
        return;
      }
    }
  }
}

実際には、ループをアンロールし、サンプルが終了もしくはループする前にどれくらいのサンプルが生成できるかを計算しています。そうすることで、インナーループをテストする必要がなく、メモリ内で短いサンプルループをアンロールする必要もありません。

結果として、端末上で障害を起こすことなくスムーズに再生できます。

Zero-order holdの主な問題は、サンプル間でハードエッジを生み出すので、多くの高音のヒス雑音が入ってしまいます。そこで、私は再生サンプリング周波数の半分に等しいカットオフと併せて各チャンネルに対して追加のローパスフィルタを加えました。次のブログ記事でこの点については詳細に説明します。

同期された可視化

音が聞こえている間、スクリーンで何が起こっているのかを見せるには、わずかなバッファを使う必要があります。そうすれば、サウンドバッファを計算した時と、それが再生された時のレイテンシが最小化されます。これは合理的なように思えます。しかし問題は、ブラウザには20ミリ秒以内にJavaScriptのオーディオコールバックを呼び出すことよりも他にすることがあって、バッファのレイテンシが低くなり、Webページ上で音が途切れ途切れになってしまうことです。これは、リアルタイムシステムとは程遠い状況です。

しかし、私たちはレイテンシを全く気にしていません。予測できない方法(個別に処理される停止動作以外)でサウンドを変更する外部イベントがないからです。このページのコードは、300ミリ秒以上の16384のコールバックバッファサイズを使用しているにも関わらず、オシロスコープとパターンは、大ざっぱなティックレート(~50Hz)で更新されています。これはどのようにして機能するのでしょうか。

オーディオバッファへのティックのレンダリングが終了したら、サンプル内で現在のオーディオタイムによって入力されたキュー上に可視化ステートをプッシュします。AudioContextは、コンテキストが作成されてからオーディオが再生されている間の秒数を示すcurrentTime属性を持っています。オーディオコールバックではこれをキャプチャし、それぞれのティックの時間にoffset / f_smpを追加しています。そして、各チャンネルの最初のいくつかのサンプル、現在のパターン、現在の行をキューにプッシュします。レンダリングコードは、キューの最初とaudioctx.currentTimeを比較していて、次にくるものをレンダリングするようになっています。

var audio_events = [];

function redrawScreen() {
  var e;
  var t = XMPlayer.audioctx.currentTime;
  while (audio_events.length > 0 && audio_events[0].t < t) {
    e = audio_events.shift();
  }
  if (!e) return;

  // draw VU meters, oscilloscopes, and update pattern position
  // using various 2D canvas calls
}

オリジナルフォント画像の個々の文字に対するdrawImageを、新しいパターンが現れる度にオフスクリーンキャンバスに呼び出すことによって、パターンをレンダリングします。そして、現在再生されている行をハイライトするために、そのキャンバスをスクリーン上に構成します。

最終結果

プレーヤーだけが掲載されたページはこちらからご覧ください。Web上にある任意のXMは、アンカーとリンクを使うことで読み込みが可能です。例えば、http://www.a1k0n.net/code/jsxm/#http://your.host.here/blah.xmといった感じです。ただし、ホストの設定がCORS(Access-Control-Allow-Origin)ヘッダとなっている場合のみです。

.MODや昔使われていた同様のファイルの再生で一番興味深いのは、複雑な曲の各部分がどのように組み立てられているのか、そしてそれがコンピュータ上で演奏されるのを目で見ることができる点です。私はこの体験を再現してみたかったのです。現在、mod.haxor.fiといった他のWebベースの.MODプレーヤーがありますが、今回は興味半分でこのコードを書いてみました。

実際、とても楽しい時間でした。エフェクトとしては完璧ではありませんが、この結果にはとても満足しています。

脚注


  1. ティックの最中にサンプルが終了した場合は例外です。 

  2. ステレオの詳細については省きましたが、左右のチャンネルを個別に合計しています。パンニングは、左右のチャンネルで異なる音量になっています。 

  3. Array.fill()は、全てのブラウザで利用可能というわけではないので、実際には、バッファを消去するのにfor loopを使っています。また、データが渡された時、まだバッファは消去されておらず、前のコールバックのデータを含んでいます。 

  4. 歴史的な理由から、ここでは詳細を述べていません。線形変化のサウンドがより線形的に直接耳に届くように、.MODのエフェクトはサンプル周期や逆周波数を編集します。しかし、それは人間の耳が聞くことのできる対数尺の近似値でしかありません。対数尺は.XMで使われていますので、周期をXM内で増加させる時、1/16半音まで下げています。 

  5. 特に”チップチューン”を指しています。小さな矩形波のループや三角波サンプルといった曲で、シンプルな昔のサウンドハードウェアチップを模倣しています。