Babylon.js の「Playing Sounds and Music」のサンプルを見ていく： その3（Stereo pan・Spatial audio）

## はじめに

以下の 2つの記事の続編になるような内容です。

- [Babylon.js の「Playing Sounds and Music」のサンプルを 2つほど見てみる - Qiita](https://qiita.com/youtoy/items/4f66e9a5f53d9af322ca)
- [続・Babylon.js の「Playing Sounds and Music」のサンプルを見ていく - Qiita](https://qiita.com/youtoy/items/c1ae0d53217f0d3e4e43)

### 今回の内容

[Babylon.js公式の「Playing Sounds and Music」のドキュメント](https://doc.babylonjs.com/features/featuresDeepDive/audio/playingSoundsMusic/)を見てきた上記 2つの記事で、まだとりあげられてないものとして、以下の [Stereo pan](https://doc.babylonjs.com/features/featuresDeepDive/audio/playingSoundsMusic/#stereo-pan)・[Spatial audio](https://doc.babylonjs.com/features/featuresDeepDive/audio/playingSoundsMusic/#spatial-audio) 以降の内容がありました。

- Stereo pan
- Spatial audio
   - Attaching meshes
- Audio buses
   - Main audio buses
   - Intermediate audio buses
- Analyzer
- Sound buffers
- Using browser-specific audio codecs
- Browser autoplay considerations
   - Playing sounds after the audio engine is unlocked
   - Unmute button
- Feature requests and bug fixes

今回、その続きでいくつかサンプルを見ていこうと思います。




## 内容を見ていく

### Stereo pan・Spatial audio

まずは [Stereo pan](https://doc.babylonjs.com/features/featuresDeepDive/audio/playingSoundsMusic/#stereo-pan)・[Spatial audio](https://doc.babylonjs.com/features/featuresDeepDive/audio/playingSoundsMusic/#spatial-audio) を見てみようと思います。

#### 概要

それぞれの概要は、以下となるようです。

- Stereo pan
   - 左右のスピーカー間で音を定位移動（パンニング）する機能
      - stereo.pan プロパティを -1 〜 1 の間で変化させると、-1（左）～ 0（中央）～ +1（右）という移動をさせられる
      - 「[sounds](https://doc.babylonjs.com/typedoc/classes/BABYLON.AbstractSound#stereo)・[buses](https://doc.babylonjs.com/typedoc/classes/BABYLON.AudioBus#stereo)」には適用できるが、「[main buses](https://doc.babylonjs.com/typedoc/classes/BABYLON.MainAudioBus)」には適用不可
- Spatial audio
   - 空間音響を実現するもので、3D空間内での音源の位置・方向を設定し、音を聴く人の位置に応じた定位・減衰・遅延などをシミュレーションするもの
   - 3D空間内に、聞き手の "listener" と音源の "source" をそれぞれ設定する形、両方とも 3D空間内で位置と方向を持つ


ちなみに、上記の「Main Buses（メインバス）」は最上位の出力先（バス）で、全てのサウンド／バスがここに集約されて出力される、というような位置付けのようです。そして、サウンドは単一の音源となり、バスは複数のサウンドをまとめたものとなるようです。


#### サンプルコード： Stereo pan

次にサンプルコードをざっと見ていきます。

Stereo pan のサンプルコードとして、以下が提示されていました。

```javascript
const audioEngine = await BABYLON.CreateAudioEngineAsync();
audioEngine.volume = 0.5;

const gunshot = await BABYLON.CreateSoundAsync("gunshot",
    "sounds/gunshot.wav",
    { stereoEnabled: true }
);

// Wait until audio engine is ready to play sounds.
await audioEngine.unlockAsync();

gunshot.stereo.pan = -1;
gunshot.play()
```

どうやら、サウンドを 1つ用意して、そのサウンドから鳴る音の位置を -1（左）にするサンプルのようです。


#### サンプルコード： Spatial audio その1

Spatial audio のサンプルコードは公式ドキュメントの文章内で 1つ、プレイグラウンド上のサンプルで 2つ示されています。

公式ドキュメントの文章内のサンプルは、シンプルな以下が提示されていました。
これは、音源をオブジェクトに紐付けるという部分のみに内容を絞ったサンプルになっているようです。


```javascript
const bounce = await BABYLON.CreateSoundAsync("bounce",
    "sounds/bounce.wav",
    { spatialEnabled: true }
);

bounce.spatial.attach(mesh);

// Wait until audio engine is ready to play sounds.
await audioEngine.unlockAsync();

bounce.play({ loop: true });
```


#### サンプルコード： Spatial audio その2

Spatial audio のサンプル2つ目、プレイグラウンド上で示されている Spatial audio のサンプルとしては 1つ目となるものを見てみます。


以下の「Attaching meshes」というサンプルは、音の鳴る位置を動的に変化させるサンプルでした。サンプルを実行すると、画面上のオブジェクトが左右を往復するように移動し、音源の位置がそれに連動して変化するというもののようです。

●AudioEngineV2 | Babylon.js Playground
　https://playground.babylonjs.com/#1BZK59#14

```javascript
var createScene = function () {
    // Create a basic scene
    var scene = new BABYLON.Scene(engine);
    scene.clearColor = BABYLON.Color3.BlackReadOnly;
    scene.createDefaultEnvironment({
        createGround: false,
        createSkybox: false,
        createDefaultCameraOrLight: false,
    });
    scene.createDefaultCameraOrLight(true, true, true);
    scene.activeCamera.alpha = Math.PI / 2;
    scene.activeCamera.radius = 0.05;

    // Load the BoomBox mesh and move it back and forth.
    async function initMesh() {
        await BABYLON.AppendSceneAsync("https://playground.babylonjs.com/scenes/BoomBox.glb", scene, { name: "BoomBox" });
        const mesh = scene.getMeshByName("BoomBox");
        mesh.position.x = -0.05;
        mesh.position.z = -0.05;
        mesh.rotation = new BABYLON.Vector3(0, 0, 0);

        const animRotY = new BABYLON.Animation("rotY", "rotation.y", 30, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
        animRotY.setKeys([
            { frame: 0, value: 1.25 * Math.PI },
            { frame: 100, value: 0.75 * Math.PI },
            { frame: 200, value: 1.25 * Math.PI },
        ]);
        mesh.animations.push(animRotY);

        const animPosX = new BABYLON.Animation("posX", "position.x", 30, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
        animPosX.setKeys([
            { frame: 0, value: -0.05 },
            { frame: 100, value: 0.05 },
            { frame: 200, value: -0.05 },
        ]);
        mesh.animations.push(animPosX);

        scene.beginAnimation(mesh, 0, 200, true);

        return mesh;
    }

    // Load the spatial sound, attach it to the boombox and play it when the audio engine is unlocked.
    async function initAudio(mesh) {
        const audioEngine = await BABYLON.CreateAudioEngineAsync({ volume: 0.25 });
        audioEngine.listener.attach(scene.activeCamera);

        const bounce = await BABYLON.CreateSoundAsync("bounce", "https://playground.babylonjs.com/sounds/bounce.wav", { spatialEnabled: true });
        bounce.spatial.maxDistance = 5;
        bounce.spatial.attach(mesh);

        await audioEngine.unlockAsync();

        bounce.play({ loop: true });
    }

    initMesh().then((mesh) => {
        initAudio(mesh);
    });

    return scene;
};
```

オブジェクトの動きの制御について、「animRotY」と「animPosX」でそれぞれ縦方向の回転と横方向の移動を変化させているようです。

また、以下の部分で「音の聞き手」「オブジェクトと音の発生源」を紐付けているようです。

```javascript

。。。
        audioEngine.listener.attach(scene.activeCamera);
。。。
        bounce.spatial.attach(mesh);
```


#### サンプルコード： Spatial audio その3

Spatial audio の 3つ目のサンプル（プレイグラウンド上の Spatial audio のサンプルとしては 2つ目）の「Spatial audio visualizer」も見てみます。

●AudioEngineV2 | Babylon.js Playground
　https://playground.babylonjs.com/#A9NDNJ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/c15257e5-746b-440b-8b23-d520d6d05554.png)


以下のパラメータを変化させた時の聞こえ方の違いを体験でき、その時の Spatial audio の状態を視覚的に確認できるようでした。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/875c2e46-eb20-4563-b2e3-5e7f2924c87b.png)



実装されているコードは、かなり行数が多いので以下に折りたたんで掲載しています。
ボリュームがあるので、内容を細かく見ていくのは大変そうです（今回は省略）。



<details><summary>公式サンプルのコード（プレイグラウンド上の Spatial audio のサンプル 2つ目）</summary>

```javascript
class Playground {
    public static async CreateScene(engine: BABYLON.Engine, canvas: HTMLCanvasElement): Promise<BABYLON.Scene> {
        const scene = new BABYLON.Scene(engine);
        const camera = new Camera(canvas);

        new Environment(scene);
        new Head(scene);

        await BABYLON.InitializeCSG2Async({ manifoldUrl: "https://unpkg.com/manifold-3d@2.5.1" });

        await BABYLON.CreateAudioEngineAsync();

        const sound = await BABYLON.CreateSoundAsync("morse-code-beeps", SoundUrlBase + "samples/effects/morse-code-beeps.mp3", {
            autoplay: true,
            loop: true,
            spatialEnabled: true,
        });
        sound.spatial.coneInnerAngle = Math.PI / 4;
        sound.spatial.coneOuterAngle = Math.PI / 2;
        sound.spatial.distanceModel = "linear";
        sound.spatial.maxDistance = 15;
        sound.spatial.position = new BABYLON.Vector3(0, 0, 10);
        sound.spatial.rotation = new BABYLON.Vector3(0, Math.PI / 2, 0);

        const soundVisualizer = new SoundVisualizer(sound);
        scene.onBeforeRenderObservable.add(soundVisualizer.onBeforeRender);

        new Gui(camera, sound);

        return scene;
    }
}

class Camera extends BABYLON.ArcRotateCamera {
    constructor(canvas: HTMLCanvasElement) {
        super("Camera", d2r(90), d2r(70), 20, BABYLON.Vector3.Zero());
        this.upperRadiusLimit = EnvironmentMaxX;
        this.attachControl(canvas);
    }
}

class Environment {
    constructor(scene: BABYLON.Scene) {
        scene.clearColor = BABYLON.Color3.Black().toColor4();

        new BABYLON.HemisphericLight("Environment.light", new BABYLON.Vector3(0, 1, 0));

        const material = new BABYLON.GridMaterial("Environment.material");
        material.backFaceCulling = false;
        material.lineColor = new BABYLON.Color3(1, 0.5, 1);
        material.majorUnitFrequency = 3;
        material.minorUnitVisibility = 0;

        const boundary = BABYLON.MeshBuilder.CreateSphere("Environment.boundary", { diameter: EnvironmentSize });
        boundary.material = material;
        boundary.visibility = 0.2;
        boundary.flipFaces();
    }
}

class Gui {
    constructor(camera: Camera, sound: BABYLON.AbstractSound) {
        let canvasZone = document.getElementById("canvasZone");
        if (!canvasZone) {
            return;
        }

        const oldGui = document.getElementById("datGui");
        if (oldGui) {
            canvasZone.removeChild(oldGui);
        }
        const gui = new dat.GUI({ autoPlace: false });
        canvasZone.appendChild(gui.domElement);
        gui.domElement.id = "datGui";
        gui.domElement.style.position = "absolute";
        gui.domElement.style.top = "55px";
        gui.domElement.style.right = "0";

        // Make property labels wider for long names.
        const oldGuiStyle = document.getElementById("datGuiStyle");
        if (oldGuiStyle) {
            canvasZone.removeChild(oldGuiStyle);
        }
        const guiStyle = document.createElement("style");
        canvasZone.appendChild(guiStyle);
        guiStyle.id = "datGuiStyle";
        guiStyle.innerHTML = `
            .dg.main { width: 275px !important; }
            .dg .property-name { width: 50%; }
            .dg .c { width: 50%; }
        `;

        const spatial = sound.spatial;

        const spatialAngles = {
            _spatial: spatial,

            get coneInnerAngle() {
                return r2d(this._spatial.coneInnerAngle);
            },
            set coneInnerAngle(value: number) {
                this._spatial.coneInnerAngle = d2r(value);
                this.coneInnerAngleGui.__prev = value;
            },
            get coneOuterAngle() {
                return r2d(this._spatial.coneOuterAngle);
            },
            set coneOuterAngle(value: number) {
                this._spatial.coneOuterAngle = d2r(value);
                this.coneOuterAngleGui.__prev = value;
            },

            coneInnerAngleGui: { __prev: 0 },
            coneOuterAngleGui: { __prev: 0 },
        };

        const spatialPosition = {
            _v: spatial.position.clone(),

            get x() {
                return this._v.x;
            },
            set x(value: number) {
                this._v.x = value;
                spatial.position = this._v;
                this.xGui.__prev = value;
            },
            get y() {
                return this._v.y;
            },
            set y(value: number) {
                this._v.y = value;
                spatial.position = this._v;
                this.yGui.__prev = value;
            },
            get z() {
                return this._v.z;
            },
            set z(value: number) {
                this._v.z = value;
                spatial.position = this._v;
                this.zGui.__prev = value;
            },

            xGui: { __prev: 0 },
            yGui: { __prev: 0 },
            zGui: { __prev: 0 },
        };

        const spatialRotation = {
            _v: spatial.rotation.clone(),

            get x() {
                return r2d(this._v.x);
            },
            set x(value: number) {
                this._v.x = d2r(value);
                spatial.rotation = this._v;
                this.xGui.__prev = value;
            },
            get y() {
                return r2d(this._v.y);
            },
            set y(value: number) {
                this._v.y = d2r(value);
                spatial.rotation = this._v;
                this.yGui.__prev = value;
            },
            get z() {
                return r2d(this._v.z);
            },
            set z(value: number) {
                this._v.z = d2r(value);
                spatial.rotation = this._v;
                this.zGui.__prev = value;
            },

            xGui: { __prev: 0 },
            yGui: { __prev: 0 },
            zGui: { __prev: 0 },
        };

        const soundDefaults = {
            volume: sound.volume,
            coneInnerAngle: spatial.coneInnerAngle,
            coneOuterAngle: spatial.coneOuterAngle,
            coneOuterVolume: spatial.coneOuterVolume,
            distanceModel: spatial.distanceModel,
            maxDistance: spatial.maxDistance,
            minDistance: spatial.minDistance,
            panningModel: spatial.panningModel,
            position: spatial.position.clone(),
            rolloffFactor: spatial.rolloffFactor,
            rotation: spatial.rotation.clone(),
        };

        const resetSound = () => {
            sound.volume = soundDefaults.volume;
            spatial.coneInnerAngle = soundDefaults.coneInnerAngle;
            spatial.coneOuterAngle = soundDefaults.coneOuterAngle;
            spatial.coneOuterVolume = soundDefaults.coneOuterVolume;
            spatial.distanceModel = soundDefaults.distanceModel;
            spatial.maxDistance = soundDefaults.maxDistance;
            spatial.minDistance = soundDefaults.minDistance;
            spatial.panningModel = soundDefaults.panningModel;
            spatial.rolloffFactor = soundDefaults.rolloffFactor;

            spatialPosition.x = soundDefaults.position.x;
            spatialPosition.y = soundDefaults.position.y;
            spatialPosition.z = soundDefaults.position.z;

            spatial.rotation.x = soundDefaults.rotation.x;
            spatial.rotation.y = soundDefaults.rotation.y;
            spatial.rotation.z = soundDefaults.rotation.z;
        };

        const soundGui = gui.addFolder("Sound");
        soundGui.add({ reset: resetSound }, "reset");

        soundGui.add(sound, "volume", 0, 1, GuiIncrement).listen();

        soundGui.add(spatial, "panningModel", ["equalpower", "HRTF"]).listen();
        soundGui.add(spatial, "distanceModel", ["linear", "inverse", "exponential"]).listen();
        soundGui.add(spatial, "maxDistance", 0, EnvironmentSize, GuiIncrement).listen();
        soundGui.add(spatial, "minDistance", 0, 10, GuiIncrement).listen();
        soundGui.add(spatial, "rolloffFactor", 0, 1, GuiIncrement).listen();

        soundGui.add(spatialAngles, "coneInnerAngle", 0, 360, GuiDegreesIncrement).listen();
        soundGui.add(spatialAngles, "coneOuterAngle", 0, 360, GuiDegreesIncrement).listen();
        soundGui.add(spatial, "coneOuterVolume", 0, 1, GuiIncrement).listen();

        spatialPosition.xGui = soundGui.add(spatialPosition, "x", EnvironmentMinX, EnvironmentMaxX, GuiIncrement).name("position.x").listen();
        spatialPosition.yGui = soundGui.add(spatialPosition, "y", EnvironmentMinX, EnvironmentMaxX, GuiIncrement).name("position.y").listen();
        spatialPosition.zGui = soundGui.add(spatialPosition, "z", EnvironmentMinX, EnvironmentMaxX, GuiIncrement).name("position.z").listen();

        spatialRotation.xGui = soundGui.add(spatialRotation, "x", -180, 180, GuiDegreesIncrement).name("rotation.x").listen();
        spatialRotation.yGui = soundGui.add(spatialRotation, "y", -180, 180, GuiDegreesIncrement).name("rotation.y").listen();
        spatialRotation.zGui = soundGui.add(spatialRotation, "z", -180, 180, GuiDegreesIncrement).name("rotation.z").listen();

        soundGui.open();

        const cameraPosition = {
            get x() {
                return camera.position.x;
            },
            set x(value: number) {
                camera.detachControl();
                camera.target.x += value - camera.position.x;
                camera.position.x = value;
                camera.attachControl(canvasZone);
            },
            get y() {
                return camera.position.y;
            },
            set y(value: number) {
                camera.detachControl();
                camera.target.y += value - camera.position.y;
                camera.position.y = value;
                camera.attachControl(canvasZone);
            },
            get z() {
                return camera.position.z;
            },
            set z(value: number) {
                camera.detachControl();
                camera.target.z += value - camera.position.z;
                camera.position.z = value;
                camera.attachControl(canvasZone);
            },
        };

        const cameraRotation = {
            get alpha() {
                return r2d(camera.alpha);
            },
            set alpha(value: number) {
                camera.detachControl();
                camera.alpha = d2r(value);
                camera.attachControl(canvasZone);
            },
            get beta() {
                return r2d(camera.beta);
            },
            set beta(value: number) {
                camera.detachControl();
                camera.beta = d2r(value);
                camera.attachControl(canvasZone);
            },
        };

        const cameraDefaults = {
            alpha: camera.alpha,
            beta: camera.beta,
            radius: camera.radius,
            position: camera.position.clone(),
            target: camera.target.clone(),
        };

        const cameraButtons = {
            reset() {
                camera.alpha = cameraDefaults.alpha;
                camera.beta = cameraDefaults.beta;
                camera.radius = cameraDefaults.radius;
                camera.position = cameraDefaults.position.clone();
                camera.target = cameraDefaults.target.clone();
            },
            top() {
                camera.alpha = d2r(0);
                camera.beta = d2r(0);
            },
            bottom() {
                camera.alpha = d2r(180);
                camera.beta = d2r(180);
            },
            right() {
                camera.alpha = d2r(90);
                camera.beta = d2r(90);
            },
            left() {
                camera.alpha = d2r(-90);
                camera.beta = d2r(90);
            },
            front() {
                camera.alpha = d2r(180);
                camera.beta = d2r(90);
            },
            back() {
                camera.alpha = d2r(0);
                camera.beta = d2r(90);
            },
        };

        const cameraGui = gui.addFolder("Camera");
        cameraGui.add(cameraButtons, "reset");
        cameraGui.add(camera, "radius", camera.lowerRadiusLimit ?? 5, camera.upperRadiusLimit ?? 50, GuiIncrement).listen();

        Object.keys(cameraPosition).forEach((key) => {
            cameraGui.add(cameraPosition, key, EnvironmentMinX / 2, EnvironmentMaxX / 2, GuiIncrement).listen();
        });

        cameraGui
            .add(
                cameraRotation,
                "alpha",
                camera.lowerAlphaLimit ? r2d(camera.lowerAlphaLimit) : -180,
                camera.upperAlphaLimit ? r2d(camera.upperAlphaLimit) : 180,
                GuiDegreesIncrement
            )
            .listen();
        cameraGui.add(cameraRotation, "beta", r2d(camera.lowerBetaLimit!), r2d(camera.upperBetaLimit!), GuiDegreesIncrement).listen();

        Object.keys(cameraButtons).forEach((key) => {
            cameraGui.add(cameraButtons, key);
        });

        camera.onViewMatrixChangedObservable.add(() => {
            while (camera.alpha < -Math.PI) camera.alpha += 2 * Math.PI;
            while (Math.PI < camera.alpha) camera.alpha -= 2 * Math.PI;
            camera.radius = Math.min(Math.max(5, camera.radius), 100);
        });
    }
}

class Head {
    constructor(scene: BABYLON.Scene) {
        BABYLON.ImportMeshAsync("https://assets.babylonjs.com/meshes/Lee-Perry-Smith-Head/head.glb", scene).then((result) => {
            const material = new BABYLON.GridMaterial("Head.material");
            material.majorUnitFrequency = 1;
            material.gridRatio = 0.005;

            const mesh = result.meshes[1];
            mesh.material = material;
            mesh.position.y = -1.39;
            mesh.position.z = -0.3;
            mesh.rotationQuaternion = BABYLON.Quaternion.RotationYawPitchRoll(0, 0, 0);
            mesh.scaling.scaleInPlace(5);
        });
    }
}

class SoundVisualizer {
    private _coneInnerAngleMaterial: BABYLON.StandardMaterial;
    private _coneInnerAngleMesh: BABYLON.Mesh | null = null;
    private _coneOuterAngleMaterial: BABYLON.StandardMaterial;
    private _coneOuterAngleMesh: BABYLON.Mesh | null = null;
    private _sound: BABYLON.AbstractSound;
    private _spatialOptions = {} as BABYLON.ISpatialAudioOptions;
    private _transformNode: BABYLON.TransformNode | null = null;

    constructor(sound: BABYLON.AbstractSound) {
        this._coneInnerAngleMaterial = new BABYLON.StandardMaterial("Sound.innerConeMaterial");
        this._coneInnerAngleMaterial.alpha = 0.15;
        this._coneInnerAngleMaterial.backFaceCulling = false;
        this._coneInnerAngleMaterial.disableLighting = true;
        this._coneInnerAngleMaterial.emissiveColor = BABYLON.Color3.Green();

        this._coneOuterAngleMaterial = new BABYLON.StandardMaterial("Sound.innerConeMaterial");
        this._coneOuterAngleMaterial.alpha = 0.1;
        this._coneOuterAngleMaterial.backFaceCulling = false;
        this._coneOuterAngleMaterial.disableLighting = true;
        this._coneOuterAngleMaterial.emissiveColor = BABYLON.Color3.Red();

        this._sound = sound;

        this._transformNode = new BABYLON.TransformNode("Sound.transformNode");

        const mesh = BABYLON.MeshBuilder.CreateSphere("Sound.mesh", { diameter: 1 });
        mesh.parent = this._transformNode;

        const material = new BABYLON.GridMaterial("Sound.material");
        material.majorUnitFrequency = 1;
        material.gridRatio = 0.05;
        mesh.material = material;

        this._updateSpatialOptions();
        this._updateVisualization(true);
    }

    public onBeforeRender = () => {
        const sourceOptions = this._sound.spatial;
        const options = this._spatialOptions;

        if (sourceOptions.maxDistance !== options.spatialMaxDistance) {
            console.debug(`maxDistance: ${options.spatialMaxDistance}`);

            this._updateConeInnerAngleMesh();
            this._updateConeOuterAngleMesh();
        } else {
            if (sourceOptions.coneInnerAngle !== options.spatialConeInnerAngle) {
                this._updateConeInnerAngleMesh();
            }
            if (sourceOptions.coneOuterAngle !== options.spatialConeOuterAngle) {
                this._updateConeOuterAngleMesh();
            }
        }

        if (!sourceOptions.position.equalsWithEpsilon(options.spatialPosition, 0.001) || !sourceOptions.rotation.equalsWithEpsilon(options.spatialRotation, 0.001)) {
            this._updateTransformNode();
        }

        this._updateSpatialOptions();
    };

    private _createConeMesh(name: string, angleRadians: number) {
        if (0 === angleRadians) {
            return null;
        }

        const angleDegrees = r2d(angleRadians);

        const spatial = this._sound.spatial;

        if (180 === angleDegrees) {
            const sphereMesh = BABYLON.MeshBuilder.CreateSphere(name, { diameter: spatial.maxDistance * 2, segments: ConeMeshSegments / 2, slice: 0.5 });

            const capMesh = BABYLON.MeshBuilder.CreateDisc("", { radius: spatial.maxDistance, tessellation: ConeMeshSegments + 4 });
            capMesh.rotation.x = Math.PI / 2;

            const mesh = BABYLON.Mesh.MergeMeshes([sphereMesh, capMesh])!;
            mesh.parent = this._transformNode;
            mesh.rotation.z = -Math.PI / 2;

            return mesh;
        }

        const sphereMesh = BABYLON.MeshBuilder.CreateSphere(name, { diameter: spatial.maxDistance * 2, segments: ConeMeshSegments });

        if (360 <= angleDegrees) {
            sphereMesh.parent = this._transformNode;
            return sphereMesh;
        }

        let inverted = 180 < angleDegrees;
        const angle = ((inverted ? 360 - angleDegrees : angleDegrees) / 180) * Math.PI;
        const radius = Math.tan(angle / 2) * spatial.maxDistance;

        const coneMesh = BABYLON.MeshBuilder.CreateCylinder("", {
            diameterBottom: radius * 2,
            diameterTop: 0,
            height: spatial.maxDistance,
            tessellation: ConeMeshSegments,
        });

        coneMesh.position.x = (spatial.maxDistance / 2) * (inverted ? -1 : 1);
        coneMesh.rotation.z = (Math.PI / 2) * (inverted ? -1 : 1);

        const sphereCSG = BABYLON.CSG2.FromMesh(sphereMesh);
        const coneCSG = BABYLON.CSG2.FromMesh(coneMesh);

        const csg = inverted ? sphereCSG.subtract(coneCSG) : sphereCSG.intersect(coneCSG);
        const csgMesh = csg.toMesh(name, undefined, { centerMesh: false }) as BABYLON.Mesh;
        csgMesh.parent = this._transformNode;

        sphereMesh.dispose();
        coneMesh.dispose();
        sphereCSG.dispose();
        coneCSG.dispose();

        return csgMesh;
    }

    private _updateConeInnerAngleMesh() {
        console.debug(`coneInnerAngle: ${this._sound.spatial.coneInnerAngle}`);

        this._coneInnerAngleMesh?.dispose();

        this._coneInnerAngleMesh = this._createConeMesh("Sound.coneInnerAngle", this._sound.spatial.coneInnerAngle);

        if (this._coneInnerAngleMesh) {
            this._coneInnerAngleMesh.alphaIndex = 1;
            this._coneInnerAngleMesh.edgesColor = BABYLON.Color3.Green().toColor4();
            this._coneInnerAngleMesh.edgesWidth = 3;
            this._coneInnerAngleMesh.material = this._coneInnerAngleMaterial;

            this._coneInnerAngleMesh.enableEdgesRendering();
        }
    }

    private _updateConeOuterAngleMesh() {
        console.debug(`coneOuterAngle: ${this._sound.spatial.coneOuterAngle}`);

        this._coneOuterAngleMesh?.dispose();

        this._coneOuterAngleMesh = this._createConeMesh("Sound.coneOuterAngle", this._sound.spatial.coneOuterAngle);

        if (this._coneOuterAngleMesh) {
            this._coneOuterAngleMesh.alphaIndex = 2;
            this._coneOuterAngleMesh.edgesColor = BABYLON.Color3.Red().toColor4();
            this._coneOuterAngleMesh.edgesWidth = 3;
            this._coneOuterAngleMesh.material = this._coneOuterAngleMaterial;

            this._coneOuterAngleMesh.enableEdgesRendering();
        }
    }

    private _updateSpatialOptions() {
        const sourceOptions = this._sound.spatial;
        const options = this._spatialOptions;

        options.spatialConeInnerAngle = sourceOptions.coneInnerAngle;
        options.spatialConeOuterAngle = sourceOptions.coneOuterAngle;
        options.spatialConeOuterVolume = sourceOptions.coneOuterVolume;
        options.spatialDistanceModel = sourceOptions.distanceModel;
        options.spatialMaxDistance = sourceOptions.maxDistance;
        options.spatialMinDistance = sourceOptions.minDistance;
        options.spatialPanningModel = sourceOptions.panningModel;
        options.spatialPosition = sourceOptions.position;
        options.spatialRolloffFactor = sourceOptions.rolloffFactor;
        options.spatialRotation = sourceOptions.rotation;
    }

    private _updateTransformNode() {
        if (!this._transformNode) {
            return;
        }

        this._transformNode.position = this._sound.spatial.position;
        this._transformNode.rotation = this._sound.spatial.rotation;
    }

    private _updateVisualization(force: boolean) {
        if (force) {
            this._updateConeInnerAngleMesh();
            this._updateConeOuterAngleMesh();
            this._updateTransformNode();
        }
    }
}

const ConeMeshSegments = 64;
const EnvironmentSize = 100;
const GuiIncrement = 0.01;
const GuiDegreesIncrement = 0.1;
const SoundUrlBase = "https://amf-ms.github.io/AudioAssets/";

const EnvironmentMinX = -EnvironmentSize / 2;
const EnvironmentMaxX = EnvironmentSize / 2;

const r2d = (radians: number) => (radians * 180) / Math.PI;
const d2r = (degrees: number) => (degrees * Math.PI) / 180;

declare var dat: any;
```

</details>



## おわりに

今回の記事は Babylon.js の「Playing Sounds and Music」のサンプルの中で、「Stereo pan・Spatial audio」の 2つを見てみたものでした。

まだ確認できてない以下も、もしかしたら部分的にという感じになるかもしれないですが、別途、内容を見られればと思います。

- Audio buses
   - Main audio buses
   - Intermediate audio buses
- Analyzer
- Sound buffers
- Using browser-specific audio codecs
- Browser autoplay considerations
   - Playing sounds after the audio engine is unlocked
   - Unmute button
- Feature requests and bug fixes