はなちるのマイノート

Unityをメインとした技術ブログ。自分らしくまったりやっていきたいと思いますー!

【Unity】「OggVorbisEncoder」を用いてWavファイルからOggファイルに変換を行う

はじめに

今回はOggVorbisEncoderというライブラリを用いたWavからOggへのファイル変換の仕方について紹介したいと思います。

github.com

一応Macでの動作確認はできたので、Windowsでもいけると思います。(是非調べてみてください)

概要

A .NET Core Ogg Vorbis audio encoding library written entirely in managed code.

// DeepL翻訳
.NET Core Ogg Vorbis オーディオエンコードライブラリ。すべてマネージドコードで書かれています。

GitHub - SteveLillis/.NET-Ogg-Vorbis-Encoder: Ogg Vorbis audio encoding library written in C#

全てManaged code、つまりはC#により記述されているのでおそらく大抵のプラットフォームで動くはずです。

NAudioなども試してはみたのですが、結局OSの機能を用いていたりでWindowsでしか動作しないものが大半でした。

(マルチプラットフォームに対応していない)ネイティブプラグインがないというだけで、かなり価値はあると思います。

インストール

今回はnuget.orgからdllを拾ってきます。

www.nuget.org

依存先のライブラリを含めて以下を入れれば動作しました。

  • OggVorbisEncoder.dll (v1.2.1)
  • System.Runtime.CompilerServices.Unsafe.dll (6.0.0)

コード

どうやらこのdllを入れただけでは簡単にOggに変換できないっぽいので公式のサンプルコードからコードを引っ張ってきます。

private static readonly int WriteBufferSize = 512;
private static readonly int[] SampleRates = {8000, 11025, 16000, 22050, 32000, 44100};

private static byte[] ConvertRawPCMFile(int outputSampleRate, int outputChannels, byte[] pcmSamples,
    PcmSample pcmSampleSize, int pcmSampleRate, int pcmChannels)
{
    int numPcmSamples = (pcmSamples.Length / (int) pcmSampleSize / pcmChannels);
    float pcmDuraton = numPcmSamples / (float) pcmSampleRate;

    int numOutputSamples = (int) (pcmDuraton * outputSampleRate);
    //Ensure that samble buffer is aligned to write chunk size
    numOutputSamples = (numOutputSamples / WriteBufferSize) * WriteBufferSize;

    float[][] outSamples = new float[outputChannels][];

    for (int ch = 0; ch < outputChannels; ch++)
    {
        outSamples[ch] = new float[numOutputSamples];
    }

    for (int sampleNumber = 0; sampleNumber < numOutputSamples; sampleNumber++)
    {
        float rawSample = 0.0f;

        for (int ch = 0; ch < outputChannels; ch++)
        {
            int sampleIndex = (sampleNumber * pcmChannels) * (int) pcmSampleSize;

            if (ch < pcmChannels) sampleIndex += (ch * (int) pcmSampleSize);

            switch (pcmSampleSize)
            {
                case PcmSample.EightBit:
                    rawSample = ByteToSample(pcmSamples[sampleIndex]);
                    break;
                case PcmSample.SixteenBit:
                    rawSample = ShortToSample((short) (pcmSamples[sampleIndex + 1] << 8 |
                                                       pcmSamples[sampleIndex]));
                    break;
            }

            outSamples[ch][sampleNumber] = rawSample;
        }
    }

    return GenerateFile(outSamples, outputSampleRate, outputChannels);
}

private static byte[] GenerateSineWaveFile(int outputSampleRate, int outputChannels, int frequency,
    float durationSeconds, float volume = 0.2f)
{
    float[][] outSamples = new float[outputChannels][];

    int numOutputSamples = (int) (durationSeconds * outputSampleRate);
    //Ensure that samble buffer is aligned to write chunk size
    numOutputSamples = (numOutputSamples / WriteBufferSize) * WriteBufferSize;

    for (int ch = 0; ch < outputChannels; ch++)
    {
        outSamples[ch] = new float[numOutputSamples];
    }

    for (int i = 0; i < numOutputSamples; i++)
    {
        var sample = volume * SineSample(i, frequency, outputSampleRate);
        for (int ch = 0; ch < outputChannels; ch++)
            outSamples[ch][i] = sample;
    }

    return GenerateFile(outSamples, outputSampleRate, outputChannels);
}

private static byte[] GenerateFile(float[][] floatSamples, int sampleRate, int channels)
{
    using MemoryStream outputData = new MemoryStream();

    // Stores all the static vorbis bitstream settings
    var info = VorbisInfo.InitVariableBitRate(channels, sampleRate, 0.5f);

    // set up our packet->stream encoder
    var serial = new System.Random().Next();
    var oggStream = new OggStream(serial);

    // =========================================================
    // HEADER
    // =========================================================
    // Vorbis streams begin with three headers; the initial header (with
    // most of the codec setup parameters) which is mandated by the Ogg
    // bitstream spec.  The second header holds any comment fields.  The
    // third header holds the bitstream codebook.

    var comments = new Comments();
    comments.AddTag("ARTIST", "TEST");

    var infoPacket = HeaderPacketBuilder.BuildInfoPacket(info);
    var commentsPacket = HeaderPacketBuilder.BuildCommentsPacket(comments);
    var booksPacket = HeaderPacketBuilder.BuildBooksPacket(info);

    oggStream.PacketIn(infoPacket);
    oggStream.PacketIn(commentsPacket);
    oggStream.PacketIn(booksPacket);

    // Flush to force audio data onto its own page per the spec
    FlushPages(oggStream, outputData, true);

    // =========================================================
    // BODY (Audio Data)
    // =========================================================
    var processingState = ProcessingState.Create(info);

    for (int readIndex = 0; readIndex <= floatSamples[0].Length; readIndex += WriteBufferSize)
    {
        if (readIndex == floatSamples[0].Length)
        {
            processingState.WriteEndOfStream();
        }
        else
        {
            processingState.WriteData(floatSamples, WriteBufferSize, readIndex);
        }

        while (!oggStream.Finished && processingState.PacketOut(out OggPacket packet))
        {
            oggStream.PacketIn(packet);

            FlushPages(oggStream, outputData, false);
        }
    }

    FlushPages(oggStream, outputData, true);

    return outputData.ToArray();
}

private static void FlushPages(OggStream oggStream, Stream output, bool force)
{
    while (oggStream.PageOut(out OggPage page, force))
    {
        output.Write(page.Header, 0, page.Header.Length);
        output.Write(page.Body, 0, page.Body.Length);
    }
}

private static float SineSample(int sample, float frequency, int sampleRate)
{
    float sampleT = ((float) sample) / sampleRate;
    return (float) Math.Sin(sampleT * Math.PI * 2.0f * frequency);
}

private static float ByteToSample(short pcmValue)
{
    return pcmValue / 128f;
}

private static float ShortToSample(short pcmValue)
{
    return pcmValue / 32768f;
}

/// <summary>
/// We cheat on the WAV header; we just bypass the header and never
/// verify that it matches 16bit/stereo/44.1kHz.This is just an
/// example, after all.
/// </summary>
private static void StripWavHeader(BinaryReader stdin)
{
    var tempBuffer = new byte[6];
    for (var i = 0; (i < 30) && (stdin.Read(tempBuffer, 0, 2) > 0); i++)
    {
        if ((tempBuffer[0] == 'd') && (tempBuffer[1] == 'a'))
        {
            stdin.Read(tempBuffer, 0, 6);
            break;
        }
    }
}

enum PcmSample : int
{
    EightBit = 1,
    SixteenBit = 2
}

.NET-Ogg-Vorbis-Encoder/Encoder.cs at master · SteveLillis/.NET-Ogg-Vorbis-Encoder · GitHub

一部コードを書き換えましたが、このコードのConvertRawPCMFileメソッドを利用することでWavからOggへの変換ができます。

Wavファイルから情報を抜き取る

先程のConvertRawPCMFileメソッドを利用するには以下の情報が必要になります。

  • outputSampleRate
  • outputChannels
  • byte[] pcmSamples,
  • pcmSampleSize
  • pcmSampleRate
  • pcmChannels

ひとまずoutputSampleRatepcmSampleRateoutputChannelspcmChannelsは同じものを使うとして、これらの情報はWavの先頭に情報が書き込まれています。

WAVEファイルの基本構造

Microsoft WAVE soundfile format

細かいことは昔書いた記事でも言及しているので良ければ見てみてください。
【Unity】WAVファイルからAudioClipを動的に生成する(WAVEのファイル構造解析) - はなちるのマイノート

ここから情報を抜き取れば良いので、コードを書いていきます。

/// <summary>
/// Wavファイルを読み取る
/// </summary>
/// <param name="stream">Stream</param>
/// <returns>Wavファイルに書き込まれているデータ</returns>
private static WavFileFormat ReadWavFile(Stream stream)
{
    // RIFF
    var riffBytes = new byte[4];
    stream.Read(riffBytes, 0, riffBytes.Length);
    if (riffBytes[0] != 0x52 || riffBytes[1] != 0x49 || riffBytes[2] != 0x46 || riffBytes[3] != 0x46)
        throw new ArgumentException("fileBytes is not the correct Wav file format.");

    // chunk size
    var chunkSizeBytes = new byte[4];
    stream.Read(chunkSizeBytes, 0, chunkSizeBytes.Length);
    var chunkSize = BitConverter.ToInt32(chunkSizeBytes, 0);

    // WAVE
    var wavBytes = new byte[4];
    stream.Read(wavBytes, 0, wavBytes.Length);
    if (wavBytes[0] != 0x57 || wavBytes[1] != 0x41 || wavBytes[2] != 0x56 || wavBytes[3] != 0x45)
        throw new ArgumentException("fileBytes is not the correct Wav file format.");

    // fmt
    var fmtBytes = new byte[4];
    stream.Read(fmtBytes, 0, fmtBytes.Length);
    if (fmtBytes[0] != 0x66 || fmtBytes[1] != 0x6d || fmtBytes[2] != 0x74 || fmtBytes[3] != 0x20)
        throw new ArgumentException("fileBytes is not the correct Wav file format.");

    // fmtSize
    var fmtSizeBytes = new byte[4];
    stream.Read(fmtSizeBytes, 0, fmtSizeBytes.Length);
    var fmtSize = BitConverter.ToInt32(fmtSizeBytes, 0);

    // AudioFormat
    var audioFormatBytes = new byte[2];
    stream.Read(audioFormatBytes, 0, audioFormatBytes.Length);
    var isPCM = audioFormatBytes[0] == 0x1 && audioFormatBytes[1] == 0x0;

    // NumChannels   Mono = 1, Stereo = 2
    var numChannelsBytes = new byte[2];
    stream.Read(numChannelsBytes, 0, numChannelsBytes.Length);
    var channels = (int) BitConverter.ToUInt16(numChannelsBytes, 0);

    // SampleRate
    var sampleRateBytes = new byte[4];
    stream.Read(sampleRateBytes, 0, sampleRateBytes.Length);
    var sampleRate = BitConverter.ToInt32(sampleRateBytes, 0);

    // ByteRate (=SampleRate * NumChannels * BitsPerSample/8)
    var byteRateBytes = new byte[4];
    stream.Read(byteRateBytes, 0, byteRateBytes.Length);

    // BlockAlign (=NumChannels * BitsPerSample/8)
    var blockAlignBytes = new byte[2];
    stream.Read(blockAlignBytes, 0, blockAlignBytes.Length);

    // BitsPerSample
    var bitsPerSampleBytes = new byte[2];
    stream.Read(bitsPerSampleBytes, 0, bitsPerSampleBytes.Length);
    var bitPerSample = BitConverter.ToUInt16(bitsPerSampleBytes, 0);

    // Discard Extra Parameters
    if (fmtSize > 16) stream.Seek(fmtSize - 16, SeekOrigin.Current);

    // Data
    var subChunkIDBytes = new byte[4];
    stream.Read(subChunkIDBytes, 0, subChunkIDBytes.Length);

    // If fact exists, discard fact
    if (subChunkIDBytes[0] == 0x66 && subChunkIDBytes[1] == 0x61 && subChunkIDBytes[2] == 0x63 &&
        subChunkIDBytes[3] == 0x74)
    {
        var factSizeBytes = new byte[4];
        stream.Read(factSizeBytes, 0, factSizeBytes.Length);
        var factSize = BitConverter.ToInt32(factSizeBytes, 0);
        stream.Seek(factSize, SeekOrigin.Current);
        stream.Read(subChunkIDBytes, 0, subChunkIDBytes.Length);
    }

    if (subChunkIDBytes[0] != 0x64 || subChunkIDBytes[1] != 0x61 || subChunkIDBytes[2] != 0x74 ||
        subChunkIDBytes[3] != 0x61)
        throw new ArgumentException("fileBytes is not the correct Wav file format.");

    // dataSize (=NumSamples * NumChannels * BitsPerSample/8)
    var dataSizeBytes = new byte[4];
    stream.Read(dataSizeBytes, 0, dataSizeBytes.Length);
    var dataSize = BitConverter.ToInt32(dataSizeBytes, 0);

    var data = new byte[dataSize];
    stream.Read(data, 0, data.Length);

    return new WavFileFormat()
    {
        IsPCM = isPCM,
        NumChannels = channels,
        SampleRate = sampleRate,
        BitPerSample = bitPerSample,
        Data = data
    };
}

/// <summary>
/// Wavファイルに書き込まれているデータ
/// </summary>
public class WavFileFormat
{
    public bool IsPCM;
    public int NumChannels;
    public int SampleRate;
    public ushort BitPerSample;
    public byte[] Data;
}


たまにfactチャンクがあったり、fmtチャンクにExtra parametersがあったりするのでそれに対応する処理が含まれています。

Oggへ変換する

これまでのコード達を使って変換を行っていきます。

/// <summary>
/// WavファイルからOggに変換をする
/// </summary>
/// <param name="wavStream">Wavファイル</param>
/// <returns>Ogg</returns>
public static byte[] ConvertWav2Ogg(Stream wavStream)
{
    var wav = ReadWavFile(wavStream);
    var pcmSample = wav.BitPerSample switch
    {
        8 => PcmSample.EightBit,
        16 => PcmSample.SixteenBit,
        _ => throw new InvalidOperationException()
    };
            
    var ogg = ConvertRawPCMFile(wav.SampleRate, wav.NumChannels, wav.Data, pcmSample, wav.SampleRate, wav.NumChannels);
    return ogg;
}

これで一通り完成ですね。

利用するサンプルコード

実際にこれらのコードを利用するサンプルを書いてみます。

var wavPath = Application.streamingAssetsPath + "/output.wav";
        
// 今回はストレージから直接読み取るサンプル
// メモリから読み取るならMemoryStreamを利用すると良い
using var wav = new FileStream(wavPath, FileMode.Open);

// WavからOggに変換 (OggUtilsは今までのコードが格納されているクラス)
var ogg = OggUtils.ConvertWav2Ogg(wav);
        
// ファイル出力する
var outputPath = Application.streamingAssetsPath + "/output.ogg";
using var writer = new FileStream(outputPath, FileMode.Create);
writer.Write(ogg, 0, ogg.Length);
        
// エディタを更新する
#if UNITY_EDITOR
AssetDatabase.Refresh();
#endif

PathとかOggUtils(今までのコードを格納しているクラス)とかは適宜置き換えてもらって、このコードでいけるはずです。

ストレージから直接読み取る場合はFileStreamを利用した方が効率が良いですが、メモリから読み取る場合はMemoryStreamを使いましょう。

ファイル出力してみた様子