音の鳴るブログ

鳴らないこともある

今日からはじめる CLI 音楽入門 2

コマンドラインからピロピロ鳴らせると良い。バックグラウンドにあったりして見えないターミナルの状態を音を聴くことで判断できるかもしれないし、判断できなかったとしても単純に音が鳴ると楽しい。

前回の記事では既存のコマンドの組み合わせでテキストファイルから音を取り出しました。

とはいうものの、この方法ではデータの内容がそのまま音となるため欲しい情報を欲しい音で得ることができません。そこで、今回の記事では自分でプログラムを書いて音を生成する方法について説明します。プログラムは Node.js で書いていますが、特別なパッケージは使わないので他の言語への移植/実行も難しくないはずです。

復習: KEN_ALL.CSV を聴く

前回の復習がてら KEN_ALL.CSV の音を聴いてみましょう。ここでは改行以外の文字を "x" に変換してインパルスを出力しています。("x" を 0、改行を 1 とみなした信号を生成しています。) 長い行が続くと低い音、短い行が続くと高い音に聴こえます。

cat KEN_ALL.CSV | awk '{ gsub(/./,"x"); print $0 }' | play -t u8 -c 1 -r 44100 -

プログラムでやってみる

同じことをプログラムでやってみます。さきほどのコマンドで awk は stdin からの入力を "x" か 改行 のどちらかに変換して stdout に 文字(u8) として出力していました。それと同じように stdin からデータを読み込んでオーディオ信号(f32) に変換して stdout から出力するだけです。

// nl-impulse.js
"use strict";

const NL = "\n".charCodeAt(0);

// (1) stdin からデータを読み込んで
process.stdin.on("data", (chunk) => {
  const output = new Float32Array(chunk.length);

  // (2) Float32Array にオーディオ信号を書き込んで
  for (let i = 0; i < output.length; i++) {
    output[i] = chunk[i] === NL ? 1 : 0;
  }

  // (3) Buffer に変換して
  const buffer = Buffer.from(output.buffer);
  // Buffer.from がエラーになる場合は new Buffer(output.buffer) とする

  // (4) stdout に出力する
  process.stdout.write(buffer);
});

このように実行できます。(注: 出力が float なのでオプションは -t f32 で指定します)

cat KEN_ALL.CSV | node nl-impulse.js | play -t f32 -c 1 -r 44100 -

もし Linux ユーザで ALSA aplay コマンドを使いたいなら、こう書きます。( -t f32 を -f float_le とする )

cat KEN_ALL.CSV | node nl-impulse.js | aplay -f float_le -c 1 -r 44100

オーディオループをつくる

しかしながら、このシンプルな方法は出力が文字からオーディオ信号になったたけで、本質的には入力を変換して出力するだけの構造は変わっておらず、いくらプログラムを改良してもデータに対して欲しい音を得ることができないという問題は解決できません。

f:id:mohayonao:20160512205941p:plain

そこで、オーディオループを導入して入力と出力を分離します。入力と出力を分離することで、入出力の依存関係が弱まり、入力を単なるトリガとして扱えるようになるので、よりデータの特徴を拾いやすい音作りが可能になります。

f:id:mohayonao:20160512205953p:plain

オーディオループは以下のように定義します。基本的な設定、信号処理のための非同期ループ、そして実際に信号処理を行うためのコールバックを持つ関数です。

// audioloop.js
"use strict";

module.exports = (channels, blockSize, sampleRate, callback) => {
  let currentTime = Date.now() * 0.001;

  const loop = () => {
    // 0.1秒以上先はレンダリングしない
    if (currentTime < (Date.now() * 0.001) + 0.1) {
      const output = new Float32Array(channels * blockSize);

      callback(output, currentTime);
      currentTime += blockSize / sampleRate;

      const buffer = Buffer.from(output.buffer);
      // Buffer.from がエラーになる場合は new Buffer(output.buffer) とする

      // 書き込みバッファがいっぱいになったら 'drain' (書き込み再開可能) まで待機
      if (!process.stdout.write(buffer)) {
        return process.stdout.once("drain", loop);
      }
    }
    setImmediate(loop);
  };

  setImmediate(loop);
};

このように使います。簡単のため入力は割愛しています。

// noise.js
"use strict";

const audioloop = require("./audioloop");

// channels, blockSize, sampleRate, callback
audioloop(1, 128, 44100, (output, playbackTime) => {
  // ここで信号処理をする
  for (let i = 0; i < output.length; i++) {
    output[i] = Math.random() * Math.sin(playbackTime * 0.250);
  }
});
node noise.js | play -t f32 -c 1 -r 44100 -

もう一度 KEN_ALL.CSV を聴く

それでは、オーディオループを使用して KEN_ALL.CSV を聴いてみましょう。新しい例では awk で各行の長さを 0.1秒ごとに出力して、それをMIDIノート番号としてサイン波の周波数を変えながら出力します。最初のプログラム例とは逆で長い行は高い音、短い行は低い音になりますが、最初の例と比較すると同じ長さの行が連続している部分など、よりデータの特徴が掴みやすくなっていると思います。

// midi.js
"use strict";

const audioloop = require("./audioloop");

let phase = 0;
let phaseIncr = 0;
let amp = 0;

audioloop(1, 128, 44100, (output) => {
  for (let i = 0; i < output.length; i++) {
    output[i] = Math.sin(phase) * amp;
    phase += phaseIncr;
    amp *= 0.9999;
  }
});

process.stdin.on("data", (chunk) => {
  const freq = 440 * Math.pow(2, (+chunk - 69) / 12);

  phaseIncr = (freq / 44100) * 2 * Math.PI;
  amp = 1;
});
cat KEN_ALL.CSV | awk '{print length($0)-24; system("sleep 0.1")}' | node midi.js | play -t f32 -c 1 -r 44100 -

シーケンサーとしてつかう

ちなみに、入力と出力が独立していることで継続的な音の生成や編集も可能になります。以下の例では tail -f を使って数列ファイルを編集するたびにその音列を再生します。これはデバッグをするときに非常に役立つテクニックです。

79
78
74
69
68
76
80
84
tail -f seq.txt | awk '{print $0; system("sleep 0.15")}' | node midi.js | play -t f32 -c 1 -r 44100 -

テスト失敗のアラートを聴く

最後に実用的な例を紹介します。mocha --watch でファイルに変更があるたびにテストを実行して、失敗した場合 (この例では AssertionError という出力があった場合) に音を出します。notification などを画面に表示することなくテストの失敗を通知してくれてるので、画面を見なくてもテストの失敗が分かりますし、大きな音を出せば離れた場所にいる人にもテストが失敗したことが伝わります。

音しか出力しなくなるとどのテストが失敗したのか分からなくなるので、本来の出力は stdout でなく stderr に出力していますが、カラー表示がなくなってしまうのはどうすれば良いんだろ?

// mocha-alert.js
"use strict";

const audioloop = require("./audioloop");

let phase = 0;
let phaseIncr = 0;
let amp = 0;

audioloop(1, 128, 44100, (output, playbackTime) => {
  for (let i = 0; i < output.length; i++) {
    output[i] = Math.sin(phase) * amp;
    phase += phaseIncr;
    amp *= 0.99975;
  }
  phaseIncr = playbackTime % 0.1 + 0.3;
});

process.stdin.setEncoding("ascii").on("data", (chunk) => {
  if (/AssertionError/.test(chunk)) {
    amp = 1;
  }
  process.stderr.write(chunk);
});
mocha --watch test.js | node mocha-alert.js | play -t f32 -c 1 -r 44100 -

なるほど〜、といった感じですが、もっと複雑な音を出したいときはどうすればよいのでしょうか? もちろん audioloop の中で信号処理のテクニックを駆使してえげつない音を出すことは可能ですが、もっと簡単にえげつない音を出したいという気持ちもあると思います。

というわけで次回はもう少し簡単な方法で今回より複雑な音を出す方法について説明します。