2023年 メディアアート・プログラミング 第4回 #スライド #スライド(PDF) スライド(HTML)シェルとパイプ #前回までlsやcdのような基礎コマンドを使ってきましたが、これらはターミナルのテキスト入力を受け取るとターミナルにテキスト出力を返すものでした。Unix系のOSでは、単純な機能を持つコマンドの入出力をパイプと呼ばれる機能を使い、組み合わせて複雑な処理を実行できます。例えばmacOSで使えるテキストを音声で読み上げるコマンドsayを例にしましょう。次のコマンドを実行すると、macOSが音声で”こんにちは”と喋ってくれます。say "こんにちは" ここでは、sayというプログラムが**標準入力(stdin)**から"こんにちは"というテキストを受け取っています。では、入力が全くないとどうなるでしょうか?say このように入力なしで実行すると、sayコマンドはテキスト入力を待機するモードになります。ここで、“こんにちは"と入力してからEnterキーを押すと読み上げが発生します。読み上げ終わると再度テキスト入力を待機します。標準入力は多くの場合、第1引数の形で与えられるか、そうでなければプログラム実行時にユーザーがターミナルから入力するような形をとります。ここでパイプを使うと、別のコマンドの結果を標準入力としてsayに渡すことができます。echo "こんにちは" | say echoは単に任意のテキストを標準出力に書き込むコマンドです。sayは先ほど同様に引数なしで実行しましたが、対話モードにはならずechoから与えられた"こんにちは"を読んで終了します。echoの代わりにcat hogehoge.txtのようなファイルを標準出力に書き出すコマンドを使えば、任意のテキストファイルを読み上げさせることもできます。ファイルとデバイス #ところで、catが開けるものはストレージ上のファイルだけではありません。Unix系のOSはファイルを扱うのと同じようにコンピューター上のハードウェア情報(例えばCPUの温度や、ハードディスクの回転数など)を取ることもできます。こうしたデバイスは/devディレクトリに存在します。このディレクトリはFinderなどから覗くことはできません。ls /dev たくさんのデバイスがありますが、名前から内容を推測することは難しいです。Bluetoothで繋がってるデバイスなどはその片鱗が伺えます。ここでは試しに、コンピューター上に搭載されているハードウェア乱数生成機urandomを使ってみましょう。コンピューター上では乱数をアルゴリズミックな数列として扱うことが多いですが、これは乱数の初期値を知っていると続く乱数列を予測できてしまうことでもあるため、セキュリティ的に重要な乱数の生成は時刻やハードウェア乱数生成機を元に使うことが多いのです。catで開くとものすごい数の乱数が出てターミナルが固まってしまうので、最初の数行だけを取り出すheadコマンドを使ってみましょう。urandomはバイナリとしての乱数を書き出すため、文字列としてエンコードできないものもたくさん含まれてきます。head /dev/urandom そして、現在は使えないものの、昔のLinuxには/dev/dspという、パイプで書き込むと直接オーディオドライバに波形データを書き込める仮想デバイスが存在しました。現在Linuxではaplayというコマンドで同様のことができます。この仕組みを活用して、できるだけ短く単純なプログラムで音を生成するBytebeatという試みがあります。Bytebeat #Bytebeatは2011年にviznutがYoutube上の動画で公開し、自身のブログの解説などで広がっていったものです。Algorithmic symphonies from one line of code – how and why?(2011)その後、Webブラウザ上でも同様のコードを実行できる環境がいくつか誕生しました。https://greggman.com/downloads/examples/html5bytebeat/html5bytebeat.htmlhttps://sarpnt.github.io/bytebeat-composer今回はBytebeatを、実際にバイナリデータを作る昔ながらの(?)やり方でやってみましょう。Bytebeatは元々次のようなC言語のプログラムで作られていました。main(t){for(;;t++)putchar(((t<<1)^((t<<1)+(t>>7)&t>>12))|t>>(4-(1^7&(t>>19)))|t>>7);} このC言語のコードは極限まで圧縮されているのでもうちょっと丁寧に書くとこうなります。int main(int t){ for(;;t++){ putchar(((t<<1)^((t<<1)+(t>>7)&t>>12))|t>>(4-(1^7&(t>>19)))|t>>7); } } C言語のプログラムはmainという関数を定義するとそれがプログラムで実行される入り口になります。 forによる無限ループの中で、tがプログラム開始時には0でスタートし、ループごとに1増えています。これが仮想的な時間になるわけですね。putcharは標準出力に1バイトのデータを書き込む、C言語の中でも最も原始的な関数の1つです。 このtを様々な演算で計算すると、1バイト分のデータ(0~255)がある1サンプルの波形の値(≒電圧、空気圧)になって出力されます。/dev/dspに書き込んだデータは1バイト1サンプル、サンプリングレート8000Hzとして解釈されます。今回は、環境構築が大変なC言語の代わりにNode.jsを使い、Linux以外でも実行できるようにffmpegというプログラムを使用します。ffmpegとは #ffmpegは様々なフォーマットのファイルやデータストリームを変換するためのツールです。 例えばwavファイルをmp3ファイルに変換したり、インターネットラジオを受信してファイルに書き出したり、逆に音声ファイルを再生してインターネットラジオをホストするようなこともできます。非常に多種多様なフォーマットの変換が可能でモジュラーな作りになっているため、世の配信サービスの裏側では大抵ffmpegが動いていると思っても過言ではありません。ffmpegのインストール #ffmpegはHomebrewでインストールできます。依存ライブラリが多いため時間がかかるので注意してください。brew install ffmpeg ffmpegとffplayコマンド #ffmpegをインストールすると、ffplayというコマンドも同時に使えるようになります。ffplayはffmpegの後段をファイル書き出しやストリーミングではなくシステム上で再生するようにした、いわば万能オーディオ/ビデオ再生ツールです。例えば普通のオーディオファイルの再生は次のようなコマンドで可能です。ffplay hoge.wav また、インターネットラジオも聞けます。以下のURLを開くとNHK-FM(東京)を受信できます。1ffplay https://radio-stream.nhk.jp/hls/live/2023507/nhkradiruakfm/master.m3u8 再生中は標準ではスペクトログラムという周波数分布のビューが表示されます。このウィンドウにフォーカスをした状態でwキーを押すと、波形表示のモードと切り替えができます。2Audacityをffmpegで聴く #前回、「AudacityでAudacityを聴く」というのをやりました。あれをもう一度ffplayでもやると次のようなコマンドになります。cat '/Applications/Audacity.app/Contents/MacOS/Audacity' | ffplay -f u8 -i pipe:0 -ar 44k -ac 1 今回は、データを1バイト1サンプル、サンプルレートは44100(44kと省略できます)、オーディオチャンネル数はモノラルとして解釈しましょう。 通常、ffplayは拡張子やファイルのヘッダーからデータのフォーマットを推定しますが、今回は生のデータを直接読むので、オプションとしてフォーマットを指定してあげる必要があります。このオプションは、前回Audacityでやった時の"Rawデータをインポート"のオプションと直接的に対応しています。Javascriptでバイト列を操作しよう #生のバイトデータをffplayにパイプして聴くことはできました。それではいよいよバイトデータを生成するコードを作っていきましょう。Javascriptは本来、数値のバイトサイズなどの区別がありません(全て実数、多くの環境では64bit浮動小数点のフォーマットで扱われます)。唯一、数値データの型を決めて扱う方法として、型を指定した配列、今回の場合はUint8Arrayを使うことで実現できます。この方法だと連続して標準出力に書き込み続けるのが少し難しいため、まず一度ファイルにバイナリデータを書き出して、それを先ほどと同じくcatで読み出してパイプしてみましょう。今回Bytebeatを作る最小のプログラムは次のようなものになります。const fs = require("fs"); const sample_rate = 8000; const seconds = 5; const byte_length = sample_rate*seconds; const bytebeat = t => (t*(1+(5&t>>10))*(3+(t>>17&1?(2^2&t>>14)/3:3&(t>>13)+1))>>(3&t>>9))&(t&4096?(t*(t^t%9)|t>>3)>>1:255); const data = Uint8Array.from({ length: byte_length }, (v, t) => bytebeat(t) ); fs.writeFile("jsbytebeat.hex",data, err => {} ); 順番にみていきましょう。const fs = require("fs"); この行は、最終的にファイル書き込みをするためのライブラリの読み込みです。あまり気にしなくても大丈夫です。const sample_rate = 8000; const seconds = 5; const byte_length = sample_rate*seconds; はじめ2行は、サンプリングレート(1秒間あたり何サンプルの解像度でデータを詰め込むか)の指定、生成する音声波形の長さを何秒にするかを決めています。 この2つの値が決まれば、データを最終的に何バイト生成すればいいかがわかります。それがlengthです。const bytebeat = t => (t*(1+(5&t>>10))*(3+(t>>17&1?(2^2&t>>14)/3:3&(t>>13)+1))>>(3&t>>9))&(t&4096?(t*(t^t%9)|t>>3)>>1:255); この行が最終的に波形を生成するBytebeatのプログラムです。この=>を使う書き方は関数定義の省略形です。function bytebeat(t) { return (t*(1+(5&t>>10))*(3+(t>>17&1?(2^2&t>>14)/3:3&(t>>13)+1))>>(3&t>>9))&(t&4096?(t*(t^t%9)|t>>3)>>1:255); } この定義でも全く同じです。書き方は好みですが、returnを省略できるのは上の書き方の方だけなので注意してください(上の書き方でも、=>の後を中括弧{}で囲む場合は、やはりreturnが必須です)。const data = Uint8Array.from({ length: byte_length }, (v, t) => bytebeat(t) ); ここでunsigned 8bit 整数の配列を作成します。やり方にはいろいろありますが、今回はfromメソッドでlengthと初期化関数を指定する方法を使いましょう。{length:byte_length}では先ほど計算した8000*5=40000サンプル分の配列を生成することを指定しています。(v, t) => bytebeat(t)は、tという配列のインデックスを取得してbytebeat関数に入れて変換したものを配列に順番に収めていく、という初期化の処理です。fs.writeFile("jsbytebeat.hex",data, err => {} ); ここでようやく、出来上がったバイト列を保存します。"jsbytebeat.hex"は好きなファイル名で問題ありませんが、特にフォーマットの決まっていないバイナリファイルなら拡張子は.binや.hexなどを使うことが多いです。3つ目の引数であるerr => {}はエラー処理で何もしないことを指しています。では、これをbytebeat.jsとして、ターミナルで実行しましょう。 この時、ffplayでの-arオプションはソースコード内で指定したサンプリングレートと一致させることを忘れないようにしましょう。node bytebeat.js cat jsbytebeat.hex | ffplay -f u8 -i pipe:0 -ar 8k -ac 1 うまくいけば、5秒分の音声が再生されて停止するはずです。ffplayの代わりにffmpegでwavファイルとして改めて出力することもできます。cat jsbytebeat.hex | ffmpeg -f u8 -ar 8k -i pipe:0 -c:a pcm_u8 -ac 1 -y jsbytebeat.wav 波形を簡易的に観察してみよう #試しにbytebeat関数をtをそのまま返すだけの関数としてみます。const bytebeat = t => t この時、tの数値自体は数十万などまで際限なく上昇し続けますが、最終的にUint8Arrayに書き込まれるときには整数部分下位8bitのみが書き込まれます。どういうことかというと、0~255まで上昇するとまた0に戻るのです。この、0~255を書き込んだjsbytebeat.hexをVSCodeのHex Editorで開くと次のような見た目をしています。00からFF(255)まで順番に数値が上昇して、また00に戻っているのがわかります。しかし、バイナリを直接Hex Editorで見るだけではあまりにどんな波形が生成されてるのかわかりにくいです。ffmpegで書き出してAudacityで見たり、ffplayの波形表示モードを使うこともできるのですが、せっかくなので、簡単な方法でデータをプロットしてみることにしましょう。先ほどのbytebeat.jsの後半に以下の行を追加します。let file = fs.createWriteStream("graph.txt"); for (byte of data){ let txt = ""; for (i = 0; i < byte; i++) { txt += "|"; } txt += "\n"; file.write(txt); } file.end(); 上のコードは、dataの配列を1バイト分読み取り、データの数値の分だけ文字(|)を書いて、改行し、また次の1バイトを読む……というのを繰り返し、graph.txtというファイルを作っています。これでnode bytebeat.jsを改めて実行すると、ディレクトリにgraph.txtが作られます。これをVSCodeで開くとこんな感じになるはず。文字数の大きさや、テキストの折り返し設定によってい表示は異なりますが、1行ごとに1文字ずつ増えることでノコギリ状の波形がプロットできています。連続して実行できるようにしよう #Uint8Arrayの中身を標準出力に書き込むの自体は、process.stdout.write(data)と割と簡単にできますが、それを永続的に続けられるようにするのは意外と面倒です。とりあえず、先ほど配列生成をしたコードを標準出力に書き出せるように変えましょう。 const data = Uint8Array.from({ length: length }, (v, t) => bytebeat(t) ); process.stdout.write(data); このファイルをbytebeat_stream.jsとしましょう。 これでnodeの出力を直接パイプして5秒間は再生できるようになりました。node bytebeat_stream.js | ffplay -f u8 -i pipe:0 -ar 8k -ac 1 最終的にはこれを連続して行えるようにしたいので、こんな感じに無限ループを作ってみます。while(true){ const data = Uint8Array.from({ length: length }, (v, t) => bytebeat(t) ); process.stdout.write(data); } このコードには2つの問題があります。1つ目に、tは配列生成時に作られるインデックスなので、5秒ごとに0にリセットされてしまいます。そのため、tは外側にグローバルな変数として定義して、更新するようにしましょう。let t = 0; while(true){ const data = Uint8Array.from({ length: length }, (v, _t) => { const res = bytebeat(t); t++; return res; } ); process.stdout.write(data); } tは後から書き換える変数として宣言するためにconstではなくletで宣言してください。また元々の配列生成に使っていた引数(v,t)はもう使わないので、変数tと名前が被らないように_tと変えておきましょう。もう1つの問題としては、バイト列を生成する速度が実際にオーディオドライバーで消費されるよりも圧倒的に速いことです。 (これは元のC言語のプログラムでもそういう仕様なのであまり気にしなくても良いのですが、、、)使われることのないデータが無限に生成されてパイプに流されるとメモリを異常に食ったりするので、5秒分の配列を生成したら5秒分休むようなコードに変えましょう。 こうしたコードの実現方法はいくつかあるのですが、簡単な方法は特定の関数を一定周期で実行し続ける、setInterval関数を使うことです。まず、5秒分標準出力に書き込む部分をmainProcessとして関数にしましょう。const mainProcess = ()=> { const data = Uint8Array.from({ length: length }, (v, _t) => { const res = bytebeat(t); t += 1; return res } ); process.stdout.write(data); }; これを、setIntervalに指定します。第2引数に実行間隔を指定しますが、この時の数値の単位はミリ秒です。なので、secondsで指定した秒数を1000で割ってあげましょう。setInterval(mainProcess,seconds / 1000.0); 実際には、mainProcessの実行にも多少なり時間がかかるはずなので、ピッタリ5秒分を生成して5秒待つ、をやっているとオーディオドライバに書き込むデータが不足して落ちるケースがたまにあるようです。そうした場合1000.0の代わりに1010.0とかにして実行感覚を多少速くしてあげるのもいいでしょう。そういうわけで、完成版bytebeat_stream.jsは次のような感じで完成です。const sample_rate = 8000; const seconds = 1; const length = sample_rate * seconds; const bytebeat = t => (((t >> 10 ^ t >> 11) % 5) * t >> 16) * ((t >> 14 & 3 ^ t >> 15 & 1) + 1) * t % 99 + ((3 + (t >> 14 & 3) - (t >> 16 & 1)) / 3 * t % 99 & 64); let t = 0; const mainProcess = () =>{ const data = Uint8Array.from({ length: length }, (v, _t) => { const res = bytebeat(t); t += 1; return res } ); process.stdout.write(data); }; setInterval(mainProcess,seconds / 1000.0); 補足(備忘録) #Javascriptでの整数演算の処理について #JSで最終的に整数の値をUint8Arrayに詰め込むときの数値型の変換がC言語でのBytebeatと同じになるかどうかは実はそんなに自明ではありません。例えばNode.jsのREPLを開いていくつか計算してみると、> 1<<30 1073741824 > 1<<31 -2147483648 > 1<<32 1 > (1<<31)+0.1 -2147483647.9 > 1.1<<31 -2147483648 > 0.9<<31 0 > 2 ** 52 4503599627370496 > 2**52+1 4503599627370497 > 2 ** 53 9007199254740992 > 2 ** 53 +1 9007199254740992 といった感じになっています。ここから、仕様を調べずともおおよそ次のことができます。JSの数値の内部表現は64bit浮動小数点(C言語におけるdouble)である。うち、仮数部分(指数部が0の場合の整数値)signed 53bitである(上位32bitを抜き出すとCのint/int32_tになる)。&、ビット演算を行う際は、一旦整数に切り捨てをしてから、signed 32bit同士の演算として計算する。Uint8Arrayなどに入れるときは下位ビットを切り出してキャストしている。52bitの整数から32bitにキャストされるときにも下位32ビットが使われている。後々調べたら大体この仕様であっているようです。この挙動が結局、C言語でBytebeatをやる際にはtはmain関数の第1引数、つまりintを使い、putcharをするときにchar型にキャストされるケースと同じ結果を導くようです。C言語とJavascriptで簡単に同じ音が鳴るのは結構絶妙な仕様のバランスの上に成立しているように見えます。そこまで見越して作っていたのかどうかはよくわかりませんが・・・。ただ、よく考えるとt++で時刻を更新して行ったときに、JavaScriptでは2^52まで足されたあと、0に戻るのではなく精度が落ちたままインクリメントが続くことになりそうです。例えばサンプルレート44.1kHzのモノラルの場合どのくらいから精度が落ちるのでしょうか。> (2**52 /44100)/(60*60*24*365) 3238.2813460788693 3238年間は心配なさそうです。NHKのWebラジオのURL一覧はここから取得できます。 http://www.nhk.or.jp/radio/config/config_web.xml ↩︎オプションで-showmode 0のようにすると0:ビデオ(音声ファイルの場合非表示)、1:波形、2:スペクトログラム が表示されます。 ↩︎