LoginSignup
13
9

More than 5 years have passed since last update.

WAVEのdataセクションからPCMを取り出して、WebSocketでブラウザに送信しWebAudioAPIで再生する

Posted at

ふと、WebSocketでaudioファイル投げてWebAudioAPIでそれを再生したらストリーミング実装できるのでは?と思いたち、やってみました。
実装については次のgistを参考にさせていただきました(主にクライアント側)。

WebAudio+WebSocketでブラウザへの音声リアルタイムストリーミングを実装する

また、WebSocketのサーバーはD言語でvibe.d(webフレームワーク)を使って実装したものと、Node.jsで実装したもの(上のgistにかかれていたサンプルをステレオ再生に対応させた)を掲載します。(D言語で実装した方はWAVEの読み込みを自分で実装しましたが、Node.jsで実装した方はpcmというライブラリを使いました。記事中ではD言語での実装に焦点をあてますが、記事の末尾にNode.jsでの実装を掲載します。(基本的にD言語での実装と殆ど変わりませんが...))
また、僕はJavaScriptやNode.js、TypeScriptについては素人というか無知なので、お見苦しい点があるかもしれません...

WAVEの簡単なおさらい

WAVまたはWAVE(ウェーブ、ウェブ) (RIFF waveform Audio Format) は、マイクロソフトとIBMにより開発された音声データ記述のためのフォーマットである。RIFFの一種。主としてWindowsで使われるファイル形式である。ファイルに格納した場合の拡張子は、.wav。
Wikipediaより引用: https://ja.wikipedia.org/wiki/WAV

それで、Wikipediaにも書いてありますがWAVEはコンテナなので格納されるデータの形式は任意となりますが、ここではリニアPCMが格納されているものとします。
また、格納されるリニアPCMはサンプリング周波数は44kHz, チャンネルは2(ステレオ)の16bitとします。
おさらいとは書きましたが、細かい説明は本筋からそれてしまうので割愛します。WAVEのフォーマットについてはすでに多くの方が分かりやすい記事を書かれているのでそちらを参照してください。

D言語で実装&簡単な解説

D言語でWAVを扱う

とりあえず、D言語でWAVEを扱うプログラムは以下の様な感じで実装できます。

import std.stdio;

struct ChunkHead {
  char[4] id;
  uint size;
}

struct RiffChunk {
  ChunkHead head;
  char[4] format;
}

struct WaveFileFormat {
  ushort audioFormat,
         channels;
  uint   samplePerSecond,
         bytesPerSecond;
  ushort blockAlign,
         bitsPerSample;
}

struct WaveFormatChunk {
  ChunkHead chunk;
  WaveFileFormat format;
}

auto min(M, N)(M m, N n) {
  return m > n ? n : m;
}

//This program works as $ ffmpeg -i filename -f s16le -ac 2 -acodec pcm_s16le -ar 44100 filename.pcm
void main() {
  string filename = "filename.wav";

  auto file = File(filename, "rb");
  ubyte[] buf;
  buf.length = file.size;
  file.rawRead(buf);

  size_t b = 0,
         e = RiffChunk.sizeof;

  RiffChunk* riff = cast(RiffChunk*)buf[b..e];

  WaveFileFormat* format;
  ubyte[] pcm;

  if(riff.head.id != "RIFF") {
    writeln("Invalid WAV format was given");
    return;
  } 

  if (riff.format != "WAVE") {
    writeln("Invalid RIFF chunk format. Given file is not WAVE");
    return;
  }

  ChunkHead* chunk;

  b = e;

  while (b < buf.length) {
    e = b + ChunkHead.sizeof;
    chunk = cast(ChunkHead*)buf[b..e];

    if (chunk.size < 0) {
      writeln("ERROR! Invalid chunk size");
      return;
    }

    b = e;

    if (chunk.id == "fmt ") {
      e = b + min(chunk.size, WaveFormatChunk.sizeof);
      format = cast(WaveFileFormat*)buf[b..e];

      b = e;
    } else if (chunk.id == "data") {
      pcm.length = chunk.size;

      e = b + chunk.size;
      pcm = buf[b..e];

      b = e;
    } else {//skip the others chunk
      b = e + chunk.size;
    }
  }

  writeln("FORMAT INFORMATIONS");
  writeln("audioFormat: ", format.audioFormat);
  writeln("channels: ", format.channels);
  writeln("samplePerSecond: ", format.samplePerSecond);
  writeln("bytesPerSecond: ", format.bytesPerSecond);
  writeln("blockAlign: ", format.blockAlign);
  writeln("bitsPerSample: ", format.bitsPerSample);

  //Save raw PCM data to filename.pcm
  File(filename ~ ".pcm", "wb").rawWrite(pcm);
}

上のプログラムのfilenameという変数を書き換えれば、ffmpegを用いて

$ ffmpeg -i filename -f s16le -ac 2 -acodec pcm_s16le -ar 44100 filename.pcm

としたのと同じ用に動き、リニアPCMがfilename.pcmという名前で保存されます。
後ほど掲載するWebSocketサーバーのD言語実装では一部上のコードと異なる処理が書かれていますが、それは後ほど説明します(もったいぶらずに言えば、今回、16bitでステレオなので、1サンプルは4バイトになります。したがって取り出したPCMを2つとばしで見ていって隣接する2バイト*2から1サンプルを計算する事になります。よってその処理を上のdataチャンクに到達したところでやるので、その分の処理が追加されています。)

僕のプログラムがちゃんと動くか不安な人は得られたPCMとffmpegが吐いたPCMをそれぞれshasumでもしてみてください。

さて、とりあえずWAVEからPCMを取り出せたのでWebSocketサーバーを書きす。

WebSocketサーバーを書く

今回、D言語とNode.jsでWebSocketサーバーをそれぞれ書きました。
それでD言語ではvibe.dを用いたのですが、WebSocketのハンドラを別スレッドで動かそうと思ったのですが(単純にハンドラの中身をnew Thread((){ハンドラの中身}).start;で包めばいいと思った)、どうやらそれではうまく動かなくて、PCMをクライアントに送っている間はすべてのサーバー側の処理がブロックされる素敵仕様になってしまいました...(多分うまいこと回避する方法はあるのだと思います... ご存じの方がいらっしゃいましたらご享受ください...)

とりあえず、上で述べたとおり、WebSocketサーバーで使うWAVからPCMを取り出す関数は上のサンプルコードを一部改変する必要があります(必要はないですが、改変したほうがスマートに実装できます)。

次のように実装します。

wav.d
import std.range,
       std.stdio;
//諸々の宣言は上のサンプルと共通のため、割愛

short[] getPCM(string filename) {
  auto file = File(filename, "rb");
  ubyte[] buf;
  buf.length = file.size;
  file.rawRead(buf);

  size_t b = 0,
         e = RiffChunk.sizeof;

  RiffChunk* riff = cast(RiffChunk*)buf[b..e];

  WaveFileFormat* format;
  ubyte[] pcm;
  short[] spcm;

  if(riff.head.id != "RIFF") {
    writeln("Invalid WAV format was given");
    return null; 
  } 

  if (riff.format != "WAVE") {
    writeln("Invalid RIFF chunk format. Given file is not WAVE");
    return null; 
  }

  ChunkHead* chunk;

  b = e;
  size_t cursor;
  while (b < buf.length) {
    e = b + ChunkHead.sizeof;
    chunk = cast(ChunkHead*)buf[b..e];

    if (chunk.size < 0) {
      writeln("ERROR! Invalid chunk size");
      return null; 
    }

    b = e;

    if (chunk.id == "fmt ") {
      e = b + min(chunk.size, WaveFormatChunk.sizeof);
      format = cast(WaveFileFormat*)buf[b..e];

      b = e;
    } else if (chunk.id == "data") {
      e = b + chunk.size;

      pcm = buf[b..e];
      //隣接する2つの要素から1つのサンプルを計算するため必要な配列長はPCMの1/2
      spcm.length = pcm.length / 2;

      ulong realIdx;

      foreach (offset; pcm.length.iota) {
        if (offset % 2 == 0) {
          //convert ubyte to 16 bit little endian integer
          //隣接する2つのubyteから16bitのlittle endianなinteger(D言語ではshort)を計算する。
          //この処理はNode.jsの`pcm`というライブラリと、`Buffer`の`readInt16LE`を参考にしました。
          short val = cast(short)(pcm[offset] | (pcm[offset + 1] << 8));
          spcm[realIdx++] = cast(short)((val & 0x8000) ? val | 0xFFFF0000 : val);
        }
      } 

      b = e;
    } else {//skip the others chunk
      b = e + chunk.size;
    }
  }

  return spcm;
}

で、WebSocketサーバーは次のように実装します

app.d
import vibe.d;
import wav;
import std.range,
       std.stdio,
       std.json;

//送信する1チャンネルのサンプル数. 8192ずつ送信するのはNode.jsのBufferのプール数が8192だったので...
enum BUFSIZE = 1024 * 8;
//WebSocket Handler
void handleConn(scope WebSocket sock) {
  ubyte[] buf;
  ulong idx;
  bool sending;

  void send(D)(D buf) {
    writeln("sending");
    sock.send(buf);
  }

  void sendPcmData(string path) {
    float[BUFSIZE] buf0,
                   buf1;
    size_t idx0,
           idx1;

    short[] pcm = getPCM(path);
    size_t loops = pcm.length / BUFSIZE,
           remid = pcm.length % BUFSIZE;
    ushort channel;

    void proc(ulong b, ulong e) {
      foreach (float sample; pcm[b..e]) {
        //ステレオなのでチャンネルごとにサンプルを分離する
        if (channel == 0) {
          buf0[idx0++] = sample / 32767;
          channel = 1;
        } else if (channel == 1) {
          buf1[idx1++] = sample / 32767;
          channel = 0;

          if (idx0 == buf0.length && idx1 == buf1.length) {
            //JSONでデータを扱いたい
            //クライアント側でJSON.parseしてもらえば良い
            string jv = `{"buf0":` ~ buf0.to!string ~`, "buf1":` ~ buf1.to!string ~ `}`;
            send(jv);

            buf0 = buf0.init;
            buf1 = buf1.init;
            idx0 = 0;
            idx1 = 0;
          }
        }
      }

      channel = 0;
    }

    //8192ずつ処理する
    foreach (i; loops.iota) {
      proc(i * BUFSIZE, (i + 1) * BUFSIZE);
    }
    //あまり分
    proc(pcm.length - remid, pcm.length);

  }

  while (sock.connected) {
    string msg = sock.receiveText();
    JSONValue jv = parseJSON(msg);

    if ("type" in jv.object) {
      if (jv.object["type"].str == "play") {
        writeln("Play! : ", jv.object["path"].str);
        sending = true;
        sendPcmData(jv.object["path"].str);
      }
    }
  }
}


shared static this() {
  auto settings = new HTTPServerSettings;
  auto router = new URLRouter;
  router.get("/", staticRedirect("stream.html"));
  router.get("/ws", handleWebSockets(&handleConn));
  router.get("*", serveStaticFiles("./"));
  settings.port = 3000;
  listenHTTP(settings, router);

  logInfo("Please open http://localhost:3000/ in your browser.");
}

で、クライアント側は次のように実装します(上にリンクを張った先のコードを(多分に)参考にさせていただきました)。

再生のスケジューリング等については上にリンクしたgistを参照してください。

stream.html
<!DOCTYPE html>
<html>
  <head>
    <meta content="text/html" charset="UTF-8">
  </head>
  <body>
    <script type="text/javascript">
      var ws                = new WebSocket('ws://localhost:3000/ws'),//Node.js版のWebSocketサーバー実装を使う場合は、末尾の/wsをとってください
          context           = new (window.AudioContext || window.webkitAudioContext), 
          initial_delay_sec = 0,
          scheduled_time    = 0;

      function playChunk(audio_src, scheduled_time) {
        if (audio_src.start) {
          audio_src.start(scheduled_time);
        } else {
          audio_src.noteOn(scheduled_time);
        }
      }

      function playDualAudioStream(audio_f32_0, audio_f32_1) {
        var audio_buf    = context.createBuffer(2, audio_f32_0.length, 44100),
            audio_src    = context.createBufferSource(),
            current_time = context.currentTime;

        audio_buf.getChannelData(0).set(audio_f32_0);
        audio_buf.getChannelData(1).set(audio_f32_1);
        audio_src.buffer = audio_buf;
        audio_src.connect(context.destination);

        if (current_time < scheduled_time) {
          playChunk(audio_src, scheduled_time);
          scheduled_time += audio_buf.duration;
        } else {
          playChunk(audio_src, current_time);
          scheduled_time = current_time + audio_buf.duration + initial_delay_sec;
        }
      }

      ws.binaryType = 'arraybuffer';

      ws.onopen = function () {
        console.log('open');
      };

      ws.onerror = function (e) {
        console.log(String(e));
      };

      ws.onmessage = function (evt) {
        var data = JSON.parse(evt.data);
        playDualAudioStream(new Float32Array(data.buf0), new Float32Array(data.buf1));
      };
    </script>
  </body>
</html>

ここまでのコードを実際に試したい方はDUBとDMDをインストールした後に次のようにして試すことができます:

# vibe.dを用いたプロジェクトのテンプレートを生成します。いろいろと聞かれますが基本的にそれっぽく答えれば大丈夫です
$ dub init wspcmstream --type vibe.d
$ cd wspcmstream
# 適当なエディタでsource/app.dとsource/wav.dに上のコードをそれぞれ貼り付けてください
# wav.dについては構造体とmin関数テンプレートの宣言を省略してあるのでもう少し上の方の宣言をimportの次に貼り付けて宣言してください。
# 編集が終わった後に、 wspcmstreamのルートディレクトリで
$ dub buid
# これで自動的にライブラリなどの依存関係が解決されビルドされます。
# ビルドが終われば準備完了です。サーバーを起動します。
$ ./wspcmstream
# ここで、localhost:3000 にアクセスした後に、JavaScriptコンソールを開き
# ws.send('{"type":"play", "path":"再生したいwavへのサーバーからの相対パス"}');
# としてサーバーにJSONを送ることでサーバーからのストリーミングが開始され、地頭的に再生されます。

あ、当然ですが再生したいWAVEファイルは適宜用意する必要があります(お手元のmp3等をffmpegでエンコードするなどすれば簡単に手に入るかと)。

問題点は多々有りますが(実装が適当すぎるので仕方ない)、こんな感じでWebSocketでPCMをストリーミングしてWebAudioAPIを用いてブラウザで再生することができます。
これをうまいこと用いれば色々と面白いものが作れそうですよね(やる気があればなにか作るかもしれません)。

それでは、最後に上で約束したNode.jsでのストリーミング用のWebSocketサーバーの実装を掲載しておわります。

ストリーミング用のWebSocketサーバーをNode.jsで実装する

前提として、wspcmをnpmでインストールしておいてください。
次に書くのはTypeScriptなので、実際にNode.jsで実行する時はtscでトランスパイルしてください。(なお、上のstream.htmlにもコメントを書きましたが、これを用いる場合はstream.htmlをちょっとだけ編集する必要があります。)

server.ts
var pcm = require("pcm");

var wss = new (require("ws").Server)({
  server: require("http").createServer((req, res) => {
    res.writeHead(200, {"Content-Type": "text/html"});
    res.end(require("fs").readFileSync("stream.html"));
  }).listen(3000)
});

const BUFSIZE = 8192;

wss.on("connection", (ws) => {
  console.log("connected");

  var buf0: Array<number> = new Array<number>(BUFSIZE);
  var buf1: Array<number> = new Array<number>(BUFSIZE);
  var idx0: number = 0;
  var idx1: number = 0;
  var sending: boolean = false;

  function sendPcmData(fileName) {
    pcm.getPcmData(fileName, {stereo: true, sampleRate: 44100},
      (sample, channel) => {
        if (channel == 0) {
          buf0[idx0++] = sample;
        } else if (channel == 1) {
          buf1[idx1++] = sample;

          if (idx0 == buf0.length && idx1 == buf1.length) {
            if (sending) {
              console.log("sending!");
              var data = JSON.stringify({buf0: buf0, buf1: buf1});
              ws.send(data);
            }
            buf0 = new Array<number>(BUFSIZE);
            buf1 = new Array<number>(BUFSIZE);
            idx0 = 0;
            idx1 = 0;
          }
        }
      }, () => {
        console.log("finished!");
        sending = false;
      }
    );
  }

  ws.onmessage = (message) => {
    var jv = JSON.parse(message.data);
    if (jv.type == "play") {
      sending = true;
      sendPcmData(jv.path);
    } else if (jv.type == "stop") {
      sending = false;
    }
  };

  ws.on("close", () => {
    sending = false;
    console.log("close");
  });
});
13
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
9