なんとなく

誰得感満載な記事が多いかも。Mono関係とLinuxのサーバ関係、レビューとか。

LibOpenMetaverseを中心に関連技術とともに音を扱ってみる

セカンドライフ技術系 Advent Calendar 2017 - Adventar向けの記事です。

はじめに

今回はViewer側でLibOpenMetaverseを利用する際の音データについて扱ってみたいと思います。以前(と言ってももうかなり前なんですが)、Parcelに設定してあるストリーミングについてXamarin.Androidで再生する方法を書きました。

takeshich.hatenablog.com

今回は、オブジェクトの音やUIの音を扱おうと思います。ただ、環境はWindowsです。モバイルを含むクロスプラットフォームという視点を取り入れようとしてはいます。一部失敗したため、Windowsとさせてください。

なぜ音を扱おうと思い至ったかというと今年の秋に発売から1年遅れで始めたゲーム(P5)を5.1chのマルチチャンネルでプレイしていたら、バグなのか演出なのか不明ながらも、ものすごく気になる箇所がありました。 それは、戦闘時にリア左に音声がやたら集まるという現象で敵の断末魔の叫びのみならず、主たる戦闘時のナビゲーションボイスまでもがリア左スピーカーから一時的に3音声程度が同時に出るというものでした。

https://www.itu.int/dms_pubrec/itu-r/rec/bs/R-REC-BS.775-1-199407-S!!PDF-E.pdfより

それでマルチチャンネルの音について考えるようになり、以前、眺めていたRadegastのソースではFMODでを使っていて、Listenerの位置設定はカメラじゃなくて、アバターだったなぁと思い出したりていました。 そこから派生して、Radegastは3DsceneはOpenTKで実装していて、OpenTKはOpenGLばかりでなく、OpenALもWrapしているのになぜ音関連はFMODを使っているのだろうとも思ったりしました。

そういう流れで今回LibOpenMetaverseで音を扱うにあたって、OpenALも触ってみました。

また、

ということを目標としました。

SLのマルチチャンネルについて

突然ですが、公式ViewerやFSでは5.1chなどのマルチチャンネルは対応していません。PCでマルチチャンネルな環境の方は少ないのでしょう。 なぜか当方はPCをAVアンプに接続しており、普段はリモートで別の部屋から繋いでいるのですが、テレビから出力させてAVアンプを介し5.1chで再生することも可能です。 Radegastは5.1chにも対応しており、アバターの後ろにあるオブジェクトから出る音は後ろのスピーカーから出力されるようになっています。

実際にはPCでサラウンドの環境構築はあまり需要がないと思われますが、Steam Linkを使うとPCの映像および音声を家庭内のネットワークを通じて送ることができ、もしAVアンプを持っているならば、その環境でマルチチャンネルでPCを扱うことができます。 最近、Steam Linkを入手し、実現性をテストできましたので、後日方法については書きたいと思います

takeshich.hatenablog.com

【国内正規品】 Steamリンク

【国内正規品】 Steamリンク

要は、Steam Link使えば、テレビでSLできますってことです。

個人的には公式ViewerやFSがマルチチャンネルに対応しないということは、見た目の3Dは凝るけど、音は凝らないのね。という感想を持っています。 悲しいかな技術的な風潮としてもそういう流れです。

うしろからとあるアバターが来てタイピング音が後ろから聞こえると楽しくなったりしませんか?私だけか。。。

LibOpenMetaverseのクロスプラットフォームについて

Linux,Windows,Macが対象となっていますので、デスクトップにおいてはクロスプラットフォームです。 モバイル環境を含むクロスプラットフォームとなると、設計が古く、近年ほとんど開発されていないため、十分とは言えません。 しかし、かれこれ5年近くこの時期に書いているように改修可能な設計ではあり、一部を修正すると十分と言えるまでになります。 プロジェクトに反映されないもののモバイル環境のXamarin.iOSやXamarin.Androidで稼働しているのをうかがい知ることができます。

SLでの音の形式とLibOpenMetaverseで音の取扱

さて、SLでの音の形式とそれをLibOpenMetaverseでどう取り扱っているかについて見ていこうと思います。

SLでの音の形式

Sound Clips - Second Life Wikiをみるとデータ仕様としては

PCM WAV format, 16-bit, 44.1kHz, mono.

とありますし、ステレオでもモノラルに変換するよとも書いてあります。

しかしながら、Protocol - Second Life Wikiを見ると Sounds の mime typeがapplication/oggです。サーバサイドにはPCMで保存されていないような気配がします。

これだけではよくわからなかったので、FirestormとRadegastのソースを覗いてみました。 Firestormのソースですとviewer側でPCMをエンコードして、Ogg Vorbisにしてサーバに上げているようでした。

Radegastでも

Decode the Ogg Vorbis buffer. radegast/BufferSound.cs at master · radegastdev/radegast · GitHub

とあってソースを見るとサーバにはOgg Vorbisで格納されているようです。

まとめると

  • サーバからViewerへダウンロードする際はOgg Vorbisで16-bit, 44.1kHzでダウンロードし、再生できる形式(PCM)に変換して再生する。
  • Viewerからサーバへアップロードする際は、WAV 16-bit, 44.1kHz, mono or stereo をOgg Vorbis16-bit, 44.1kHz, monoに変換してサーバにアップロードする

という仕様です。

また、SLにおいてはすでに定義されているサウンドが存在します。

LibOpenMetaverseで音の取扱

Viewer側からのLibOpenMetaverseにおける音の取扱は、概ね以下のようになります。

  • Sound.AttachedSoundイベントで音のあるオブジェクトが得られる。
  • 音のあるオブジェクトの場合、PrimitiveクラスにSoundプロパティがあり、AssetにAsset.TypeがSoundのUUIDがあり、それを取得する。
  • SendSoundTriggerメソッドでそれを鳴らし、SoundTriggerイベントで拾う事もできる。

詳しくは、OpenMetaverse.SoundManagerクラスを参照してください。

LibOpenMetaverseを使った処理の一例として、 SIM内の音のあるオブジェクトを鳴らしたい場合は、

  • Sound.AttachedSoundイベントが発生。 
  • 得られるサウンドのUUIDを使ってAssets.RequestAssetメソッドでデータを取得。それをPCMに変換。
  • 得られるオブジェクトのUUIDを使ってそのオブジェクトの位置を取得する
  • 音量が得られる
  • サウンドフラグが得られる。(loop,stopなど)
  • 上記で得られたデータ、位置、音量、フラグを使って、再生。

という処理になります。

詳しくは以下のリファレンスを参照してください。

音データを取得するコードのイメージとしては、ログインした状態で以下のようになります。なお、あくまでイメージであって、実際にはオブジェクトの位置など他のデータも必要ですから、格納してあげるものを作成して別のタイミングで音データを取得し、再生する形になります。

Client.Sound.AttachedSound += new EventHandler<AttachedSoundEventArgs>(Sound_AttachedSound);

private void Sound_AttachedSound(object sender, AttachedSoundEventArgs e)
 {
        略
Client.Assets.RequestAsset(
                e.SoundID,
                AssetType.Sound,
                false,
                new AssetManager.AssetReceivedCallback(Assets_OnSoundReceived));
        略
}

void Assets_OnSoundReceived(AssetDownload transfer, Asset asset)
{
        if (transfer.Success)
        {
                // Decode the Ogg Vorbis buffer.
                AssetSound s = asset as AssetSound;
                s.Decode();
                byte[] data = s.AssetData;
略    
}

関連技術

Ogg Vorbisについて

Ogg Vorbisって何よ?いうことで、オーディオの圧縮形式のことなのですが、詳細は、以下引用を確認ください。

Ogg

Ogg is the name of Xiph.org's container format for audio, video, and metadata.

Vorbis

Vorbis is the name of a specific audio compression scheme that's designed to be contained in Ogg. Note that other formats are capable of being embedded in Ogg such as FLAC and Speex.

http://vorbis.com/faq/#names

ということで、

  • Oggはメディアを格納するためのコンテナフォーマットです。
  • Vorbisは実際の音声圧縮のフォーマットです。

OggVorbisで圧縮した音声を格納したものをOgg Vorbisといい、今回扱うデータもこのOgg Vorbisです。(なお、VorbisOgg以外にも格納でき、Matroskaでも対応してます。)

Ogg Vorbisのライブラリについて

Ogg Vorbisを扱う場合には、OggVorbisのためのライブラリが必要となり、公式から提供されているのが以下のものです。

今回の使い方としては、以降に説明するOpenALへのデータ提供として、ヘッダレスなPCMデータbyte配列を与えたいので、 Ogg Vorbisなbyte配列を得て、PCMなbyte配列を吐き出したいと考えていました。 また、今後を考えてモバイルを含めたクロスプラットフォームなものをと思い、いろいろ調査した結果、以前紹介した以下で扱っている

takeshich.hatenablog.com

OpenJPEGのように変換させて、実装したいと思っていました。

今回の調査でいろいろとハマったのがここですが、手を出してみたら、ものすごく面倒で時間がかかり間に合いそうもなかったので、とりあえずWindowsで動くライブラリを見つけてそちらにおまかせすることにしました。

github.com

以下のようにして、Ogg Vorbisのbyte配列をstreamで得て、PCMなbyte配列を返すものです。

protected static byte[] LoadOgg(System.IO.Stream stream, out int channels, out int bits, out int rate)
{
        if (stream == null)
                throw new ArgumentNullException("stream");

        byte[] returnbuf;

        using (MemoryStream outMs = new MemoryStream())
        {
                using (var osb = new OggDecodeStream(stream))
                {
                        byte[] readBuffer = new byte[2048];

                        channels = osb.Channels;
                        bits = osb.BitsPerSample;
                        rate = osb.SamplesPerSecond;

                        while (true)
                        {
                                var read_size = osb.Read(readBuffer, 0, readBuffer.Length);

                                if (read_size <= 0) break;
                                        outMs.Write(readBuffer, 0, readBuffer.Length);
                        }

                        outMs.Flush();
                        outMs.Seek(0, SeekOrigin.Begin);
                        returnbuf = outMs.GetBuffer();
                }
        }

        return returnbuf;
}

using (var ms = new MemoryStream(data))
{
        int channels, bits_per_sample, sample_rate;
        var sound_data = LoadOgg(ms, out channels, out bits_per_sample, out sample_rate);
}

しかし、Ogg Vorbisのデコード、エンコードについては、今後LibOpenMetaverseに実装したいものです。もしくはforkした環境だけでも動くようにしたいです。

OpenALについて

OpenAL (Open Audio Library)はクロスプラットフォームのオーディオAPIであるフリーソフトです。マルチチャンネル3次元定位オーディオを効率よく表現するように設計されました。 OpenAL - Wikipedia

C#ではOpenTKOpenALをWrapした実装があり、そちらを使用しました。

使ってみたところ、OpenGLに似せてあり、

takeshich.hatenablog.com

においてOpenTKでWrapされたOpenGLには十分触れており、違和感なく使用することができました。

OpenALでの使用例

ソースを見ていただければ、そのOpenGLっぽさがわかると思います。

例として、listen positionから半径2mの円上を落ち葉の上を歩く音を再生してみます。

まず、音の入手についてですが、

soundeffect-lab.info

より、落ち葉の上を歩くを入手しました。walk-fallen-leaves1.mp3です。 これをsoxでwavにし、モノラルに加工しました。

sox walk-fallen-leaves1.mp3 walk-fallen-leaves1.wav
sox walk-fallen-leaves1.wav walk-fallen-leaves1_mono.wav remix 1,2

ソースの抜粋は以下です。

略

IntPtr device;
OpenTK.ContextHandle context;

private void button1_Click(object sender, EventArgs e)
{
        if (_tokenSource == null) _tokenSource = new CancellationTokenSource();
        var token = _tokenSource.Token;
        
        //初期化
        device = Alc.OpenDevice(null);
        context = Alc.CreateContext(device, (int[])null);
        Alc.MakeContextCurrent(context);
        
        string filename = Path.Combine("wav", "walk-fallen-leaves_mono.wav");
        
        //リッスンポジションなどの設定
        float[] ListenOri = { 0.0f, 0.0f, -1.0f, 0.0f, 1.0f, 0.0f }; // look.x.y.z / up.x.y.z

        
        AL.Listener(ALListener3f.Position, 0.0f, 1.5f, 0.0f);
        AL.Listener(ALListener3f.Velocity, 0.0f, 0.0f, 0.0f);
        AL.Listener(ALListenerfv.Orientation, ref ListenOri);
        AL.Listener(ALListenerf.Gain, 1.0f);
        
        //buffer とsourceのひもづけ
        var buffer = createBuffer(filename);
        var source = AL.GenSource();
        AL.BindBufferToSource(source, buffer);
        
        //ソースの設定
        AL.Source(source[0], ALSourceb.Looping, true);  // 繰り返し
        AL.Source(source[0], ALSourcef.Pitch, 1.0f); //
        AL.Source(source[0], ALSourcef.Gain, 1.0f);     // 音量
        AL.Source(source[0], ALSource3f.Position, 0.0f, 0.0f, 0.0f);
        
        AL.SourcePlay(source);
        
        
        //1周16秒で円を描きたい 2πr
        //2*3*3 18m
        //を16秒は妥当かな?
        int i = 0;
        int n = 160;
        double rate = 0.0d;
        double r = 2.0d;
        float x;
        float z;
        
        Task.Factory.StartNew(() =>
        {
                while (true)
                {
                        //円
                        //座標を計算
                        rate = (double)i / (double)n;
                        x = (float)(r * Math.Cos(2.0 * Math.PI * rate));
                        z = (float)(r * Math.Sin(2.0 * Math.PI * rate));
                        AL.Source(source, ALSource3f.Position, x, 0.0f, z);

                        System.Threading.Thread.Sleep(100);
                        i++;
                        //Console.WriteLine("x:{0},z:{1},i:{2},rate:{3:000.0000}", x, z, i, rate);
                        if (i == n)
                        {
                                i = 0;
                        }

                        if (token.IsCancellationRequested)
                        {
                                // TODO:キャンセル処理
                                break;
                        }

                }
        }, token).ContinueWith(t =>
        {
                // TODO:あとしまつ
                _tokenSource.Dispose();
                _tokenSource = null;

                // TODO:キャンセルされたときの処理
                AL.SourcePause(source);

                //使わなくなったデータをクリーンアップ
                AL.DeleteSource(source);
                AL.DeleteBuffer(buffer);


                Alc.MakeContextCurrent(OpenTK.ContextHandle.Zero);
                Alc.DestroyContext(context);
                Alc.CloseDevice(device);
        });
}

private static int createBuffer(string filename)
{
        int channels, bits_per_sample, sample_rate;
        var buffer = AL.GenBuffer();
        var sound_data = LoadWave(File.Open(filename, FileMode.Open), out channels, out bits_per_sample, out sample_rate);
        AL.BufferData(buffer, GetSoundFormat(channels, bits_per_sample), sound_data, sound_data.Length, sample_rate);
        return buffer;
}

初期化のところとか、GenSourceして、GenBufferして、BufferDataでbyte配列を渡して、さらにBindBufferToSourceで紐付けるところとか。 さらっとOpenALでマルチチャンネルな実装ができることを確かめました。また、WAVをOgg Vorbisに変換し、上述したLoadOggを使用し、OpenALで使用できることを確認しました。

具体的な実装

Ogg VorbisからPCMへ変換し、OpenALにデータを渡せそうですし、OpenALでサラウンドな音の取扱もできそうです。 実際にRadegastをいじってみることにしました。

Radegastで使われているFMODをOpenALに置き換えてみる

モバイルを含めたクロスプラットフォームを念頭に置きながら、調査してみました。 Radegastの音関連はFMODで実装されています。 モバイルを含めたクロスプラットフォームを考えるとXamarin用のが見当たりませんでした。でもAndroid用もiOS用もあるんだからBindingできるんじゃないかと思ったりしました。実現できるかはわかりません。 もし実現可能な場合を考えても、ライセンスについても$500000以下なら商用でも使えるから、OpenALをあえて選択する必要ないと思われます。 Bindingができない場合は、もちろんOpenALを選択するのは十分にありです。 でも、今回は勉強のためにいじってみました。SpeechとStreamを除いては実装できました。

変更部分のソースはgistに置きました。

セカンドライフ技術系 Advent Calendar 2017向け · GitHub

感想

再生部分においては、すでにFMODを使うための実装があって、座標系がFMODとOpenALは同じだったのでとても楽に移植できました。ほぼFMODの部分をOpenALに置き換えるだけで済んだため、特にハマることもなく、実装できました。 確認をしたときにListenerの前を向く方向の指定がRadegastの実装においても正しいようには思えなかった部分は修正しましたが。

ただ、SpeechとStreamを除いての話です。時間がなくすすめることはできませんでしたが、おそらくSpeech部分は楽に実装できると思います。 しかし、Stream部分はmp3とoggのストリームに対応しなければならないのでこれはちょっと時間がかかりそうです。 そこを吸収しているFMODはすばらしいと思いました。負け惜しみを言えば、Streamの部分はクロスプラットフォームを意識せず各Nativeに任せるという方法も取れます。

ちなみにLibOpenMetaverseの座標はLLのを引き継いでいると思われますが、Xが東(前)、Yが北(左)、Zが上です。 右手系はZが東(前)、Xが南(右)、Yが上です。

r.x = -omv.y;
r.y = omv.z;
r.z = omv.x;

です。いつも迷うので。

まとめ

Viewerの中でこんな感じで実装されてるという雰囲気を掴んでもらえたでしょうか。

Ogg Vorbisの変換のところでモバイルまで視野に入れた実装ができなかったのがとても心残りです。SLを楽しんでいる方で、マルチチャンネル環境を構築している方が少なく、音の3Dに関して楽しかったり気持ちよかったりするのになかなか同意してもらえないのも残念です。技術的に実現可能なのに母集団が少ないと実装されないということなのでしょう。

今回、音関連のデバッグが難しいことにも気付かされました。

終わりに

いつもの流れどおり、Xamarin.AndroidでLibOpenMetaverseを使って音を出せたよという風にまとめたかったのですが、実は技術的なところに関係するけど技術ではないところで昨年末から年始にいろいろあり、今年全く触ってませんでしたし、記事も書かないつもりでした。でも、ちょっとOpenALをいじったら、やっぱり楽しいよね的なノリで進めてしまいました。このままその気持ちを維持できたら、それなりに追記するつもりです。