WebSocketでかんたんキーボードセッション

2012.05.11

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

クライアント・サーバー間の双方向通信を実現する技術として注目されているWebSocketですが、だいぶ仕様が固まって各ブラウザの実装も進んできたようですので、そろそろ試しておこうと思いサンプルアプリを作ってみました。今回作成したサンプルは、「かんたんキーボードセッション」アプリです。ブラウザ内の鍵盤をマウスで操作すると音が鳴り、なおかつ、複数クライアントで一緒に演奏できるようになっています。キーボードの音はWeb Audio APIを、複数クライアントによるリアルタイム演奏はWebSocketを利用して実現します。

Web Audio APIは、ブラウザでの高度な音声処理を実現するものですが、これについては、以前弊社の中村のエントリで紹介されています。

Web Audio APIをつかった音声処理

今回のサンプルでは、生成したサウンドデータから音声を出力する機能を利用してキーボードの音を鳴らします。

サンプルのソースコードはこちらで公開しています。

開発環境

以下の環境で開発・動作確認をしています。

  • OS:Windows7 64bit
  • JDK:1.6.0u31
  • ブラウザ:Google Chrome 18
  • IDE:Eclipse3.7 (for Java EE) + m2eclipse

WebコンテナはWebSocketをサポートするJetty8を利用します。

プロジェクトの作成と準備

まずはm2eclipseをインストールしたEclipseでMavenプロジェクトを新規作成します。「シンプルなプロジェクトの作成」を選択し、グループIDとアーティファクトIDは「websocket-sample」とします。

pom.xmlをリンク先のように編集します。

サーブレットコンテナのJetty、ロガーのSLF4JとlogBack、JSONライブラリのJSONICを利用するため、pom.xmlに依存関係を定義しています。pom.xmlの編集後、プロジェクトルートを右クリックし、[Maven]-[プロジェクト構成の更新]を選択します。これで必要なライブラリが利用できるようになりました。

Jettyを利用したサーバーの実装

では、サーバーサイドを作成します。まずはアプリケーションのエントリポイントです。

Main.java

Jettyをインメモリで起動して8080ポートを監視しています。静的コンテンツのハンドラを作成し、コンテンツの配置場所をルート直下のwebappフォルダに設定しています。

WebSocketServlet

WebSocketのプロトコルでクライアントと通信を行うサーブレットを作成します。

PlayerWebSocketServlet.java

doWebSocketConnectメソッドの実装です。

@Override
public WebSocket doWebSocketConnect(HttpServletRequest request, String protocol) {
    // サーブレット内の処理で利用するWebSocketのファクトリメソッド
    // WebSocketインターフェースの実装クラスのインスタンスを生成して返す
    
    // クライアントIDとしてセッションIDを利用
    String sessionId = request.getSession().getId();
    LOGGER.info(String.format("doWebSocketConnect - sessionID:%s", sessionId));
    
    // WebSocketインターフェースの実装クラスのインスタンスを生成
    PlayerManager manager = (PlayerManager) getServletContext().getAttribute("playerManager");
    PlayerWebSocket webSocket = new PlayerWebSocket(manager, sessionId);
    
    return webSocket;
}

ここで定義されているdoWebSocketConnectメソッドは、WebSocketFactory.Acceptorインターフェースで宣言されているメソッドの実装です。このメソッドはJavaDocに記述されているように、WebSocketインターフェースを実装するインスタンスを返すファクトリメソッドとなっており、通常のサーブレットのdoGetとは異なり送受信したデータの処理を扱いません。その代わりに、WebSocketのコネクションのオープンやメッセージ受信などのイベントのハンドラインターフェースであるWebSocketインターフェースの実装を渡すことによって通信時の処理を定義しています。

実際には、コネクションコンポーネントのファクトリが、コネクションを作成する際にdoWebSocketConnectメソッドで取得したWebSocketインターフェース実装を渡します。作成されたコネクションは、オープンやクローズなどのイベントがあるとWebSocketインターフェース実装の該当メソッドを呼び出す、という仕組みになっています。

WebSocketインターフェースの実装

WebSocketインターフェースの実装クラスを作成します。先ほども触れましたが、WebSocketインターフェースはWebSocketでの通信時のハンドラの役割を担っています。

PlayerWebSocket.java

簡単に実装メソッドを見ていきます。

@Override
public void onClose(int closeCode, String message) {
    LOGGER.info(String.format("Open a connection. ID:%s", id));
    // プレーヤーを削除
    manager.removePlayer(this);
}

@Override
public void onOpen(Connection connection) {
    LOGGER.info(String.format("Close a connection. ID:%s", id));
    this.connection = connection;
    // プレーヤーを追加
    manager.addPlayer(this);
}

onOpen、onCloseメソッドはWebSocketインターフェースの実装メソッドです。コネクションの接続・切断時に呼び出されます。

@Override
public void onMessage(String data) {
    LOGGER.info(String.format("Receive a message. data: %s", data));
    // データをJSONからデシリアライズ
    PlayerEvent receivedData = JSON.decode(data, PlayerEvent.class);
    // データの受信をマネージャーに通知
    manager.messageReceived(this, receivedData);
}

onMessageメソッドはWebSocket.OnTextMessageインターフェースの実装メソッドです。テキスト形式でのデータ受信時に呼び出されます。このサンプルでは、イベントデータ受信時に他クライアントとの連携処理を受け持つPlayerManagerクラスにデータを渡して、他のクライアントに通知しています。イベントデータを受信したクライアントは、データの内容に応じてサウンドの再生などの処理を行っています。

WebSocketインターフェースには、OnTextMessage以外にもOnBinaryMessageなどのサブインターフェースが定義されており、それらを実装することで任意の処理を行うことができます。元々はonMessageメソッドなどもWebSocketインターフェースに定義されていたようですが、不要なハンドラメソッドを実装しなくてもいいようにサブインターフェースに分割されたようです。

クライアントの実装

音声処理の実装

次に、クライアント側のWeb Audio APIを利用した音声処理部分を作成します。

synth.js

AudioContextの作成

まずAudioContextを作成します。

try {
    context = new webkitAudioContext();
} catch (e) {
    console.error(e.toString());
}

WebKitでAudioContextを利用する場合、ベンダプレフィックスがついたwebkitAudioContextを使用する必要があります。現状、Google Chrome以外ではWeb Audio APIが実装されていないようです。対応していないブラウザでwebkitAudioContextを作成する際にReferenceErrorが発生する可能性がありますので、try-catchで囲んでおきます。

JavaScriptAudioNodeの取得

// AudioContextからJavaScriptAudioNodeを取得
node = context.createJavaScriptNode(BUFFER_SIZE, 1, channelNumber);
// AudioNodeを出力に接続
node.connect(context.destination);

作成したAudioContextのcreateJavaScriptNodeメソッドを呼び出して、JavaScriptで直接音を生成するためのAudioNodeであるJavaScriptAudioNodeを取得します。取得したAudioNodeのconnectメソッドの引数は、出力先を表すAudioDestinationNodeです。AudioContextのdestinationプロパティにセットされているAudioDestinationNodeを渡すと、後述するonaudioprocessで生成された音が出力されます。

サウンドデータのセット

オーディオ処理イベント時のイベントハンドラの実装です。

/**
 * JavaScriptAudioNodeのオーディオ処理イベント時のイベントハンドラです。
 * 
 * @param event AudioProcessingEvent
 */
function nodeAudioProcessHandler(event) {
    // 左右チャネルのオーディオストリームの参照を取得
    var leftChannelData = event.outputBuffer.getChannelData(0);
    var rightChannelData = event.outputBuffer.getChannelData(1);
    
    // サウンドデータを作成
    var s = getSoundStream();
    
    // サウンドデータを各オーディオストリームにセット
    leftChannelData.set(s);
    rightChannelData.set(s);
}

JavaScriptAudioNode#connectメソッドを呼び出すと、onaudioprocessに設定したイベントハンドラが定期的に呼ばれます。このイベントハンドラが呼ばれる頻度は、AudioContext#createJavaScriptNodeの第一引数で与えたサウンドデータのバッファサイズが小さいほど高頻度になります。

イベントハンドラの引数で渡されるAudioProcessingEventオブジェクトのoutputBufferプロパティにAudioBufferが格納されています。AudioBufferのgetChannelDataメソッドでチャネル毎のオーディオストリームの参照が取り出せます。オーディオストリームはFloat32Array型で、32ビット浮動小数点値の型指定された配列です。今回は、JavaScriptAudioNodeを取得する際にチャネル数を2に設定しているので、左チャネルと右チャネルからオーディオストリームの参照をそれぞれ取り出しています。オーディオストリームの参照に生成したサウンドデータをセットすると、セットしたサウンドデータが再生されて音が鳴ります。

サウンドデータの生成

onaudioprocessのイベントハンドラで呼ばれていたgetSoundStreamメソッドです。

/**
 * サウンドデータを作成します。
 * 
 * @return サウンドデータ
 */
function getSoundStream() {
    // サウンドデータを格納する配列を用意
    var stream = new Float32Array(BUFFER_SIZE);
    
    // 各クライアントのオーディオジェネレータからサウンドデータを取得して合成
    for (key in generatorMap) {
        var generator = generatorMap[key];
        if (!generator.isActive) {
            continue;
        }
        var tempStream = generator.next();
        for (var i = 0; i < BUFFER_SIZE; i++) {
            stream[i] += tempStream[i];
        }
    }
    return stream;
}
[/javascript]</p>
<p>先ほどオーディオストリームにセットしたサウンドデータは、getSoundStreamメソッド内で作成されています。ここでは、キーボードセッションに参加しているクライアントそれぞれに割り当てたオーディオストリームジェネレータからサウンドデータを取り出し、それらを合成して返しています。</p>

<p>オーディオストリームジェネレータのサウンドデータを取得するメソッドです。</p>
<p></p>

<p>AudioStreamGeneratorのnextメソッドでは、波を発生させるオシレータからバッファサイズの大きさのデータをサンプリングして返しています。</p>

<p>オシレータの実装です。</p>
<p>
/**
 * 正弦波を生成するオシレータです。
 */
var SineWaveOscillator = function() {
    this.getData = function(offset) {
        return Math.sin(offset);
    }
};

...

オシレータは基本的な4つの波を発生させるものを用意して、音色を切り替えられるようにしています。

UIの作成

以下のようにUIを作成します。

index.html
main.css

divを利用した鍵盤と音色を切り替えるためのラジオボタンを配置しています。

WebSocketによる通信

WebSocketを利用した通信部分を実装します。
main.js

初期化処理

クライアントの初期化処理です。

// WebSocketの宛先URLを作成
var protocol = (location.protocol == "https:") ? "wss" : "ws";
var host = location.host;
var url = protocol + "://" + host + DESTINATION;
// WebSocket操作用のヘルパーオブジェクトを生成
webSocketHelper = new WebSocketHelper();
// WebSocketオブジェクトを生成
var webSocket = webSocketHelper.createWebSocket(url);
if (!webSocket) {
    alert("WebSocketが利用できません。");
    return;
}
// WebSocketオブジェクトにイベントハンドラをセット
webSocket.onopen = webSocketOpenHandler;
webSocket.onclose = webSocketCloseHandler;
webSocket.onmessage = webSocketMessageHandler;
webSocket.onerror = webSocketErrorHandler;

jQueryのonReadyでWebSocketを初期化します。WebSocketは、WebSocketHelperでラップして操作しています。

WebSocketHelperのWebSocket生成部分です。

/**
 * 指定されたURLに接続するWebSocketオブジェクトを生成して返します。
 * 
 * @param url 接続先URL
 * @return WebSocketオブジェクト
 */
this.createWebSocket = function(url) {
    try {
        webSocket = new WebSocket(url);
    } catch (e) {
        console.error(e.toString());
        return null;
    }
    return webSocket;
};

WebSocketを生成する際は、AudioContextと同じく実装されていないブラウザがありますのでtry-catchで囲みます。WebSocketはIE以外の主要ブラウザで実装されており、ずっとベンダプレフィックス付きだったFirefoxもバージョン11でプレフィックスが取れました。

WebSocketによるコネクションのオープン時の処理です。

/**
 * WebSocketのopenイベント時のイベントハンドラです。
 * 
 * @param event Event
 */
function webSocketOpenHandler(event) {
    console.info("connected.");
    
    // Web Audio API操作用のヘルパーオブジェクトを生成
    webAudioHelper = new WebAudioHelper(4096, 1, CLIENT_ID_SELF);
    if (!webAudioHelper.isWebAudioAvailable()) {
        alert("Web Audio APIが利用できません。");
        return;
    }
    
    // イベントハンドラの登録
    $(window).unload(windowUnloadHandler);
    $(".white-key, .black-key").mousedown(keyMouseDownHandler);
    $(".white-key, .black-key").mouseup(keyMouseUpHandler);
    $("input[name='oscillator']").change(oscillatorRadioChangeHandler);
}

WebSocketのコネクションが開くとopenイベントが発生します。このイベントのハンドラ内で先ほど作成したWeb Audio APIを操作するヘルパーを生成し、演奏用キーボードのマウスイベントなどを登録しています。

鍵盤操作によるサウンドの再生・停止

鍵盤操作時の処理です。

/**
 * 演奏用キーボードのmousedownイベント時のイベントハンドラです。
 * 
 * @param event Event
 */
function keyMouseDownHandler(event) {
    // 押されたキーに対応する音を発音
    var note = getNoteFromEvent(event);
    webAudioHelper.noteOn(note, CLIENT_ID_SELF);
    // ノートオンをサーバーに通知
    var data = { type : "noteOn", note : note };
    webSocketHelper.sendData(data);
    // 押されたキーの色を変更
    var target = $(event.target);
    target.addClass("pressed");
    target.bind("mouseout", keyMouseUpHandler);
}

/**
 * 演奏用キーボードのmouseupイベント時のイベントハンドラです。
 * 
 * @param event Event
 */
function keyMouseUpHandler(event) {
    // 自クライアントの音を消す
    webAudioHelper.noteOff(CLIENT_ID_SELF);
    // ノートオフをサーバーに通知
    var data = { type : "noteOff", note : null };
    webSocketHelper.sendData(data);
    // キーの色を戻す
    var target = $(".pressed");
    target.removeClass("pressed");
    target.unbind("mouseout", keyMouseUpHandler);
}

鍵盤上でのマウスイベントのイベントハンドラです。このハンドラ内ではWebAudioHelperにサウンドの再生・停止を指示すると同時に、サウンドイベントの発生をWebSocketでサーバーに通知し、UIを更新しています。音色選択ラジオボタンが変更された際も同様です。

他クライアントのサウンドのコントロール

サーバーからの他クライアントのイベントデータ受信時の処理です。

/**
 * WebSocketのmessageイベント時のイベントハンドラです。
 * 
 * @param event Event
 */
function webSocketMessageHandler(event) {
    console.info("message received:" + event.data);
    
    if (!event.data || event.data == "") {
        return;
    }
    
    // Json形式のデータをデシリアライズ
    var data = JSON.parse(event.data);
    
    var targetElement;
    if (data.type == "noteOn") {
        // 他のクライアントのノートオンイベント
        // 特定のクライアントの音を追加
        webAudioHelper.noteOn(data.note, data.id);
        // キーボードの色を変える
        var noteString = Note.getNoteByValue(data.note);
        var id = noteString + "key";
        targetElement = $("#" + id);
        targetElement.addClass("pressed-by-other");
    } else if (data.type == "noteOff") {
        // 他のクライアントのノートオフイベント
        // 特定のクライアントの音を消す
        webAudioHelper.noteOff(data.id);
        // キーボードの色を戻す
        targetElement = $(".pressed-by-other");
        targetElement.removeClass("pressed-by-other");
    } else if (data.type == "soundTypeChanged") {
        // 特定のクライアントの音色を変更
        var soundType = data.soundType;
        webAudioHelper.changeSound(soundType, data.id);
    } else {
        console.warn("The type of received message is invalid: " + data.type);
    }
}

また、WebSocketでサーバーから通知を受け取った場合は、WebSocketオブジェクトにmessageイベントが発生します。サンプルではmessageイベントのイベントハンドラ内でサウンドの操作とUIの更新を行っています。

アプリケーションの実行

では、作成したアプリケーションを実行します。EclipseのパッケージエクスプローラでMainクラスを選択し、Javaアプリケーションとして実行します。実行するとサーバーが起動しますので、サーバーを起動したマシンの8080ポートにChromeからアクセスしてください。アクセスすると以下のような画面が表示されます。

上の画面の鍵盤上の赤い部分は自クライアントがマウスで押下している部分、青い部分は他のクライアントがマウスで押下している部分です。スクリーンショットだけでは分からないですが、他のクライアントでの演奏もきちんと再生されています。

なお、サンプルではミキシングを固定値で行っているため、同時発音数が5音を超えると音が割れるので注意してください。

まとめ

WebSocketは既存のライブラリを利用すればとても簡単に利用できました。また、ローカルネットワーク上でしか試していませんが、他クライアントで鳴らされた音の発音までのタイムラグを感じることもほとんどありませんでした。今回のアプリケーションでは、むしろWeb Audio APIのサウンドの再生の方が若干遅延があるように感じましたが、その点はサウンドのバッファサイズの調整で改善できそうです。

このサンプルを作成するにあたって、初めはHerokuにデプロイしてネットワーク越しでどの程度のリアルタイム性があるかをテストしてみるつもりでした。しかし、HerokuがWebSocketに対応しておらず、WebSocketプロトコルでリクエストを送ると405が返されてしまうため今回は諦めました。Herokuだけでなく大多数のPaaSがまだWebSocketに対応していないようで、PaaSを諦めてサーバーを立てるか、Pusherなどの外部サービスを利用するか、もしくは従来どおりCometで双方向通信を実現するしかないようです。

WebSocket自体はとても使いやすいので、早くいろいろな環境で利用できるようになるといいですね。

参考サイト

JettyでWebSocketを試してみた。
Jetty8で作るWebSocketチャット(サーバ側編)
サーバを作りながら学ぶWebSocketプロトコル
Web Audio API
JavaScriptでリアルタイムに音を鳴らす方法を3つほど