なにをやったの
身の回りの振動デバイス (ゲームのコントローラーなど) を PC から一括制御して、手軽に””シナスタジア””を得られないか試してみました。
おまけで映画などのコンテンツに振動を連動させる実験もしてみました。
手持ちの Joy-Con などで手軽に試せるので、よかったら遊んでみてください。
実装的には、ブラウザから周辺機器に直接コマンドを送る WebUSB, WebHID などの実験的な技術を使ってみたので、そのあたりの話もします。
シナスタジア??
「シナスタジア」は「共感覚」という意味の英単語ですが、ここでは「スペースチャンネル5」などでお馴染みのゲームプロデューサー (アーティスト?) 水口氏 の提唱する「シナスタジア」を指します。
氏はプレーヤーの操作、光の演出、音楽、振動などが絶妙にシンクロすることで得られる独特のキモチ良さ、トリップ感 (意訳) をコンセプトにしたゲームを多く作っていて、この体験を「シナスタジア」と呼んでいます。
音・映像の美しさももちろん大事なのですが、振動を通して「体で感じる」ことも「シナスタジア」の重要な要素です。実際、「シナスタジア」シリーズ初のゲームである「Rez」には、「BGM に合わせて振動するだけ」の超攻めた周辺機器「トランスバイブレータ」が付属していました。一作目でこれはなかなか挑戦的ですよね。
その後も「音を全身で感じる」体験の研究は続き、26個の振動デバイスを内蔵した着る体感デバイス「シナスタジアスーツ」などが開発されました。販売はありませんが、 アートイベント などで体験できることがあります (もちろん行きました)。
当時はキワモノ扱いだった印象もありますが、 (Amazon にアダルトグッズ扱いされる憂き目 に遭ったりしながらも) しっかりコアなファンがついているコンテンツでした。その後、 15 年の時を超えて VR に、ほとんどそのままの内容で移植された「Rez」がバッチリ高評価を獲得したり、「着る振動デバイス」を VR 向けに販売するメーカー が現れたりして、いよいよ時代が追いついてきた感があります。
(実際、氏は 30 年前の時点でゴーグルをかぶって遊ぶゲーム機の特許を出願 していますし、本当に未来を見ていたんだと思います。 いかにもセガっぽいです)
最近では往年の名作「テトリス」に「シナスタジア」を取り入れた作品「テトリスエフェクト」が発売されて話題になったりもしました。えっこれがテトリス??ってなるくらいめっちゃ綺麗なので観てください👇
さて、今回はこの「音・映像・振動が融合するキモチ良さ」をもっと手軽に味わえると楽しいのになーと思って、身の回りの振動デバイスを PC から一括制御する実験をしてみたので紹介します。ぜひ音楽を耳だけじゃなく、全身で浴びる体験をしてみてほしいです。
ちなみに
今年は「Rez」の 20 周年、 RTA in Japan の種目に選ばれ たり、 続編の匂わせ もあったりするので、もし興味を持ったらこれを機にぜひ触れてみてください!
ここで遊べます
※ 最新の Chrome が必要です
「Rez」の PV に合わせて、ブラウザから Joy-Con などの各種ゲームコントローラーや、例の「トランスバイブレータ」を制御できるアプリです。
(おまけで映画や他の音楽・ゲーム音などに連動させる仕組みも作ってみました。よかったらそっちも試してみてください、楽しいです)
一応無保証なのでご注意ください。
デバイス数に上限はないので、ケツにトランスバイブレータを敷いて、腰に DualSense を当てて、両手に Joy-Con を握りしめて、一緒にアチラ側に行きましょう。うっかり同居人に見られるととても恥ずかしいので気をつけてください。
他に連動させると面白そうなデバイスを思いついた人がいたら、ぜひ PR ください!
デバイスの繋ぎ方
トランスバイブレーターは USB で、 Joy-Con / Pro-Con は Bluetooth で PC に接続できます。繋いだらアプリの + CONNECT
ボタンで登録してください。
Joy-Con は PC につなぐとホームボタンが光ってびっくりしますが、ペアリングボタンを1回押すと戻ります。
Windows の場合、そのままだとトランスバイブレータが「不明なデバイス」になってしまって使えないので、汎用の USB ドライバを入れてやる必要があるっぽいです (未検証)。
参考👇
それ以外のコントローラーの繋ぎ方
「アプリを開いた状態で」コントローラーを繋いで、 + CONNECT
ボタンで登録してください。認識しない場合は「アプリを開いた状態で」適当にボタンをポチポチしてからもう一度 + CONNECT
してみるとうまくいくことがあります。
映画や他の音楽、ゲームに連動させる
capture other audio devices / desktop audio
を押すと遊べます。
PC に接続されているデバイス (キャプボ、マイクなど) に連動するか、 Chrome の他のタブで開いている動画 (など) に連動するか選べます。
ゲームなどの音を取り込みたい場合はステミキ、仮想オーディオデバイスなどが要るかもです。
技術的な話
ここからは技術的な話です。
WebHID / Joy-Con, Pro-Con を制御する
HID はキーボード、マウス、ジョイスティックなど、人間がコンピューターとやりとりするためのデバイスの総称です。 WebHID はブラウザから HID デバイスを直接制御できる技術です。
ゲームコントローラーも HID デバイスの一種なので、 WebHID から制御できます。
HID デバイスに接続する
ブラウザが WebHID に対応している場合、 navigator.hid.requestDevice
でデバイスを取得できます。デバイスを取得したら、 .open
で接続できます。
const joyCons = await navigator.hid.requestDevice({
filters: [
{ vendorId: 0x057e },
],
});
joyCons[0].open();
vendorId
はデバイスのメーカーを表す ID で、任天堂は 0x057e
として登録されています。
filters
で vendorId
を指定してあげることで、関係ないデバイスに繋いでしまうことを避けられます。
HID デバイスにコマンドを送る
HID デバイスには .sendReport
でコマンドを送ることができます。
joyCons[0].sendReport(reportId, Uint8Array.from(args));
デバイスがどんなコマンドに対応しているかは各自調べる必要があります。 Switch 関連の解析情報はここにまとまっています。
HD 振動を制御するコマンドのパケットは作り方がちょっとややこしいので、上のページや、アプリのソースコードを見てみてください。
GamePad API / それ以外のゲームパッドを制御する
GamePad API はブラウザでゲームパッドを使うための API です。実は Joy-Con / Pro-Con も GamePad API から使うことができますが、 HD 振動の細かい制御ができないのであちらは WebHID で自前実装しました。
Joy-Con 以外の現代のパッドは手元になくて動作確認できないので、 GamePad API でまとめて対応しました。 PS5 の Dualsense なんかがちゃんと動くっぽいです。
パッドに接続する
ブラウザが GamePad API に対応していれば、 navigator.getGamepads
でゲームパッドを取得できます。
const pads = navigator.getGamepads();
(おそらくセキュリティ上の理由で) 「ページを開いた状態で」接続されたパッドしか取得できないようです。もとからパッドが刺さっていて、後からページを開いた場合は認識されません。
パッドの振動を制御する
getGamepads
で取得したゲームパッドには、そのまますぐにコマンドを送れます。
pads[0].vibrationActuator.playEffect("dual-rumble", {
duration: 1000,
weakMagnitude: value1,
strongMagnitude: value2,
});
.vibrationActuator
がない場合は振動に対応していないっぽいです。たいていのコントローラーは振動モーターが2つ入っていますが、それぞれの出力を独立して設定できます。値は 0~1
です。
WebUSB / トランスバイブレーターを制御する
WebUSB は HID ですらない謎 USB デバイスをブラウザから直接制御できる技術です。
USB デバイスに接続する
接続方法はだいたい WebHID と同じです。
const vib = await navigator.usb.requestDevice({
filters: [
{ vendorId: 0x0b49, productId: 0x064f },
],
});
vib.open();
USB デバイスにコマンドを送る
デバイスがどんなコマンドに対応しているかはやはりデバイスごとに調べる必要があります。
トランスバイブレータの場合は、コントロールコマンドで 0~255
の値を送ることで振動の強さがセットできるようです。
await vib.selectConfiguration(1);
await vib.claimInterface(0);
vib.controlTransferOut({
requestType: "vendor",
recipient: "interface",
request: 1,
value: value,
index: 0,
});
YouTube 動画に連動させる
方針
YouTube の js API を調べてみると、現在の再生位置 (秒) が getCurrentTime
で取れると書いてあります。
「再生位置を受け取って、振動の強さを返す関数 getVibValue
」を実装すれば、こんな感じで連動できそうです:
const syncVib = () => {
const time = youtubePlayer.getCurrentTime();
const vibValue = getVibValue(time);
vibrator.sendVib(vibValue);
}
// syncVib を定期的に呼び出す
setInterval(syncVib, 30);
「いっせーのせ」で動画と振動タイマーを同時にスタートする方法もあるかなと思いましたが、だんだんズレちゃったり、あるいはシークバーに対応できなかったりしそうなのでやめました。持たなくて良い状態は持たない方がいいですね。
再生位置を受け取って、振動の強さを返す関数
は、こんな感じで実装しました。
const withBPM = (bpm, array) => time => array[Math.floor(time * bpm * 4 / 60)] || 0;
// テンポ 140 の曲のパターン
const pattern = withBPM(140, [
// 1 2 3 4
1.00, 0.00, 0.00, 0.00, 1.00, 0.00, 0.00, 0.00, 1.00, 0.00, 0.00, 0.00, 1.00, 0.00, 0.00, 0.00,
...
]);
曲のテンポ (BPM) は「1分間に何拍あるか」なので、 Math.floor(time * (bpm / 60))
で「今何拍目か」が得られます。あとは「1拍目なら振動は 1.00
、2拍目なら 0.50
、…」みたいな配列を用意して、そこから目当ての拍に対応する値を取り出せば OK です。
実際には 16 分音符まで対応したかったので、4倍して Math.floor(time * bpm * 4 / 60)
としました。
テンポの違う複数の曲が入っている場合はこんな感じで対応できます:
const song1 = withBPM(120, [ ... ]);
const song2 = withBPM(160, [ ... ]);
// 2曲目 (song2) が 0:20 から始まる場合
const pattern = time => song1(time) + song2(time - 20);
WebAudio / その他の音楽、映画などに連動させる
WebAudio はブラウザで高度な音声処理を簡単にできる技術です。
YouTube 動画との連動では手でパターンを打ち込みましたが、それ以外の音源に連動させる場合はそうはいきません。
そこで音声処理を使って、音声からいい感じの振動パターンを生成します。
他のタブの音をキャプチャする
他のタブから音をキャプチャするためには、 navigator.mediaDevices.getDisplayMedia
を使用します。
const stream = navigator.mediaDevices.getDisplayMedia();
デバイス (マイク、キャプボなど) の音をキャプチャする
それ以外のデバイスから音をキャプチャしたい場合、まずはユーザーにマイク使用を許可してもらう必要があります。
const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
getUserMedia
は Web カメラなども取得してしまうため、音声デバイスに絞るために { audio: true }
を指定します。
マイクから音をキャプチャする場合は、 getUserMedia
の戻り値をそのまま使用できます。が、それ以外のデバイス (キャプボなど) にも対応したい場合、あらためて接続するデバイスを選ぶ必要があります。
使用可能なデバイスの一覧は navigator.mediaDevices.enumerateDevices
で得られます。マイクの使用許可をもらう前にこのメソッドを叩いても、正しい情報が得られません (おそらくセキュリティ上の理由)。
const devices = await navigator.mediaDevices.enumerateDevices();
// 音声入力デバイスに絞る
const audioDevices = devices.filter(dev => dev.kind === "audioinput");
接続したいデバイスが決まったら、あらためて getUserMedia
で接続します。
const stream = await navigator.mediaDevices.getUserMedia({
audio: { deviceId: audioDevices[i].deviceId },
});
deviceId
を指定することで、特定のデバイスに狙い撃ちで接続できます。
キャプチャした音をゴニョゴニョする
音を取り込む準備ができたら、いよいよ WebAudio らしい処理を書いていきます。
WebAudio は「ノード」と呼ばれる部品を回路のように繋いでいくことで音声信号を処理します。
まずは取り込んだ音を再生するノードを用意します。
const ctx = new AudioContext();
const source = source = new MediaStreamAudioSourceNode(ctx, {
mediaStream: stream,
});
stream
は上の節でキャプチャした音声です。
低音に合わせて振動してくれるとキモチ良さそうなので、低域だけを取り出すフィルタノードを作ります。
const lpf = new BiquadFilterNode(ctx, {
type: "lowpass",
Q: 1,
frequency: 90,
});
このフィルタは 90 Hz より高い音をカットします。これくらいガッツリカットすると、もはや人の声などはほとんど入らなくなり、バスドラムや地鳴りなどのズーンと低い音だけが取り出せます。 Q
はフィルタの Q 値です、詳しくはフィルタの勉強をしてください。
フィルタした音から振動パターンを生成したいのですが、「振動パターン生成ノード」はさすがに組み込まれていないので、自前実装する必要があります。そのためにフィルタした音の生データがほしいので、解析ノード (AnalyserNode
) を作ります。
const analyzer = new AnalyserNode(ctx, {
fftSize: 1024,
});
fftSize
は FFT (高速フーリエ変換) のウィンドウサイズです。解析に使うデータのサンプル数みたいな感じだと思ってください。
最後に処理された音の流れ着く先 (?) を作ります。これがないと音が流れ始めません。
const dest = new MediaStreamAudioDestinationNode(ctx);
必要なノードが揃ったら、これらを接続してやれば音が (ノードの中を) 流れ始めます。
source.connect(lpf).connect(analyzer).connect(dest);
AnalyzerNode
を流れる音は、外から取り出すことができます。
// fftSize と同じ大きさの配列
const buf = new Float32Array(1024);
analyzer.getFloatTimeDomainData(buf);
音がでかいほど振動を強くするのが順当だと思うので、振動の強さを計算する関数はこんな感じになりそうです。
const GAIN = 3.5;
const buf = new Float32Array(1024);
const vibValueFromSound () {
analyzer.getFloatTimeDomainData(buf);
const max = Math.max(...buf);
const min = Math.min(...buf);
return (max - min) * 0.5;
}
音声信号から一番値のでかいところ max
と、ちいさいところ min
を計算して、その差を返します。
音声信号は -1 ~ 1
の波なので、 max - min
は最大で 2
になります。これに 0.5
を掛けることで、 0 ~ 1
の範囲に収めます。
感度が悪い場合は適宜定数倍して調整するといいと思います。
- return (max - min) * 0.5;
+ return Math.min(1, (max - min) * 0.5 * GAIN);
1
を超えてしまわないように Math.min(1, ...)
しておく必要があることに注意してください。
これを定期的に呼び出してあげれば音と振動を同期できます。
setInterval(() => {
const vibValue = vibValueFromSound();
sendVib(vibValue);
}, 30);
おまけ: WebGL でオーディオビジュアライザを書く
見た目もかっこいい方がいいので、 WebGL でオーディオビジュアライザを書きました。
(余力があれば後日詳細書きます)
まとめ
いろいろ新しめなブラウザ技術を試しつつ、音と振動を同期させてみました。
ぜひぜひ遊んでみてください!