🎵

Unity WebGLでゲーム音声を録音する

に公開

現在開発してるシステムにて Unity WebGL で動かしているゲーム音声を録音する必要があったのでその備忘録を残します

問題点

Unity WebGL は内部で Web Audio API の AudioContext を使用して音声を処理するが、これらの音声ノードは隠蔽されていてアクセスできない

コード

if (!window.isAudioContextPatched) {
    window.unityAudioContext = null;
    window.unityAudioNode = null;

    const audioContextHandler = {
        construct(target, args) {
            if (!window.unityAudioContext) {
                const context = new target(...args);
                
                const audioNode = context.createGain();
                audioNode.gain.value = 1.0;

                const wrapConnectMethod = (createMethod, nodeType) => {
                    const originalCreate = context[createMethod];
                    context[createMethod] = function(...args) {
                        const node = originalCreate.apply(this, args);
                        const originalConnect = node.connect;
                        
                        node.connect = function(destination, ...connectArgs) {
                            if (destination === context.destination) {
                                originalConnect.call(node, audioNode, ...connectArgs);
                                audioNode.connect(context.destination);
                                return audioNode;
                            }
                            return originalConnect.call(node, destination, ...connectArgs);
                        };
                        return node;
                    };
                };

                wrapConnectMethod('createBufferSource', 'BufferSource');
                wrapConnectMethod('createGain', 'GainNode');
                wrapConnectMethod('createOscillator', 'Oscillator');
                // 適宜追加
                
                const originalCreateMediaElementSource = context.createMediaElementSource;
                context.createMediaElementSource = function(element) {element);
                    const source = originalCreateMediaElementSource.call(this, element);
                    source.connect(audioNode);
                    audioNode.connect(context.destination);
                    return source;
                };

                window.unityAudioContext = context;
                window.unityAudioNode = audioNode;
            }
            return window.unityAudioContext;
        }
    };

    window.AudioContext = new Proxy(window.AudioContext, audioContextHandler);
    if (window.webkitAudioContext) {
        window.webkitAudioContext = new Proxy(window.webkitAudioContext, audioContextHandler);
    }
    
    window.isAudioContextPatched = true;

JavaScript の Proxy を利用して AudioContext コンストラクタをラッピングすることで解決できる

Unity 音声の集約

const audioNode = context.createGain();

const wrapConnectMethod = (createMethod, nodeType) => {
    const originalCreate = context[createMethod];
    context[createMethod] = function(...args) {
        const node = originalCreate.apply(this, args);
        node.connect = function(destination, ...connectArgs) {
            if (destination === context.destination) {
                originalConnect.call(node, audioNode, ...connectArgs);
                audioNode.connect(context.destination);
                return audioNode;
            }
            return originalConnect.call(node, destination, ...connectArgs);
        };
        return node;
    };
};

Unity が作成する音声ノードの connect メソッドを置換し、作成した音声ノードを経由するようにしている

ラッピングの適用

wrapConnectMethod('createBufferSource', 'BufferSource');
wrapConnectMethod('createGain', 'GainNode');
wrapConnectMethod('createOscillator', 'Oscillator');
// 適宜追加

必要なメソッドをラップ

context.createMediaElementSource = function(element) {
    console.log("Creating media element source:", element);
    const source = originalCreateMediaElementSource.call(this, element);
    source.connect(audioNode);
    audioNode.connect(context.destination);
    return source;
};

createMediaElementSource は呼び出し時に connect を呼ぶため別の対応が必要

Proxy による置換

window.AudioContext = new Proxy(window.AudioContext, audioContextHandler);

AudioContext(のコンストラクタ)を置換し、動作を変更

使用方法

public にスクリプトを配置する

Next.js での実装例

import Script from 'next/script'

export default function UnityGamePage() {
    ...

    return (
        <>
            <Script 
                src="/audioContextProxy.js" 
                strategy="afterInteractive" 
            />
            
            {/* Unityゲームのコンポーネント */}
            <div id="unity-container">
                {/* Unity WebGLビルドをここに配置 */}
            </div>
            
            {/* 録音制御UI */}
            <button onClick={startRecording}>録音開始</button>
            <button onClick={stopRecording}>録音停止</button>
        </>
    )
}

Unity のビルドファイル(.framework.js)で呼び出される window.AudioContext を先に置換することで、Unity のコードを変更せずに動作を変えることができる

マイク音声の録音を行う必要がある場合は、元の AudioContext / AudioNodeoriginalAudioContext などに退避して使用する必要がある
あるいは Unity のビルドファイルを修正する方法もある

さいごに

Untiy 内部で録音(・録画)を行い、そのデータを JS に伝達する手法もあります(有料)
Recorder WebGL | Utilities Tools | Unity Asset Store

また Unity の AudioListner が受け取る音声を処理できる OnAudioFilterRead は WebGL では使えませんでした

Discussion