11
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Unity】Unity6.3の目玉であるAudioPipelineでチップチューンサウンドジェネレーターを作ってみた【Audio】

Posted at

GraffityでUnityエンジニアをしているcovaです。

今回はUnity6.3 の目玉であるAudioPipeline でどういうことができるのかをUnity6.3bを使って検証してみました。

なるべくサウンドプログラム初心者でもわかるように解説するよう心がけていますが、一部音信号処理の基礎知識が必要になるので適宜わからないことは調べてください

検証環境

項目 version
Unity 6.3b2
OS MacOS Tahoe 26

Unity6.3と過去のバージョンでの違い

まずUnity2022.3以前と Unity6.0~Unity6.2.x とUnity6.3+ でAudioSource に大きめな変更があります。

下記のスクリーンショットにAudioSourceのバージョンごとの比較画像を用意しました
Compare.png

一番左から Unity2022LTS以前, Unity6.0~6.2, Unity6.3a~ となります。

  • Unity2022LTS以前ではAudioSourceには再生する AudioClip を設定して再生していました
  • Unity6.0(2023.2~) ではAudioClipを拡張したAudioResource という概念が追加され、AudioClip だけでなく 複数のAudioClip + それらの選択ロジックをパッケージングしたAudioResource (ex. AudioRandomClipContainer) を設定できるようになりました
  • Unity6.3aではさらに拡張された IGeneratorDefinition をセットするようになりました

みんなが知ってるAudioSourceとAudioClip

多分この関係だと思います。
Unity2022LTS.Audio.png

AudioResource

Unity6.0(旧Unity2023Tech stream) からは従来のAudioClip だけでなく、複数のAudioClip とそれらを選択するロジックをまとめたAudioResource というものを設定できるようになりました。

Unity6000.0.Audio.png

しかしながら、AudioResource についてですが、基本的にはUnityEngine.Audio のInternal で定義されており、AudioRandomContainer もロジック自体はゲームエンジンのコアであるC++側のコードを呼び出しすることで実装されていて、Unity開発者にはオープンにされず、実質AudioRandomContainer 専用の拡張となっていました

(筆者個人としては .wav 意外にもLoop音源対応のためにIntroとLoop部分をAudioClipでもたせて、再生位置によって再生するAudioClipを切り替えるみたいなIntroBGMContainerを作りたかったです)

参考

IGeneratorDefinition

Unity6.3a から登場した概念で、既存のAudioResourceをさらに拡張する概念です。

いくつか他にも登場人物がいるのでUML図で登場人物と関係性を整理しました。

Unity6000.3.Audio.png

Audio.Generator とAudio.Processor ですが、内部でInterfaceが定義されているのをわかりやすくるため、本来は名前空間(Package)は区切られてないですが敢えてわかりやすさ重視のために名前空間をUML用に独自で切っているのでご注意ください

登場人物 説明
AudioSource Unityがオーディオを再生するときの再生制御を司るクラス. こいつがないと音は鳴らせられない
AudioClip みなさんお馴染みのAudioClip
.wav, .mp3, .ogg などの音源をUnity上で使える形式に加工したもの (圧縮設定とか)
AudioResource AudioClipを複数持たせたいけど従来のAudioClipと同じ扱いをさせたいために生成された概念(from Unity2023)
CRIさんのADXのCue(キュー) やWwiseのSoundContainerに相当
IGeneratorDefinition UnityのDocでは IAudioGenerator だったり IGeneratorDefinition だったり表記ブレがあるので注意
こいつを継承した独自のScriptableObjectを作ることになる
Generator Processor とControler を管理する概念.
アプリ開発者がGenerator に直接何かすることはないです
Processor 昔でいう OnAudioFilterRead() に相当するAudioBufferを用いて信号処理を実際に担当する概念.
AudioSourceがProcessorのHandleを持っているので、何かデータを更新する場合はこのHandleを使ってProcessorに伝達する流れになる
IControl GeneratorがProcessorを初期化する時に担当する概念.
IControl よりは後述のControlContextの方が大事
ControlContext UMLには引数でしか渡されてない第3者.
こいつ自体が unsafeな処理でProcessorやGeneratorのAllocateをしたり、AudioSouceとProcessorの間を取り持ったりする結構大事な概念

長々説明しましたがまとめると下記の通りです

IGeneratorDefinition(IAudioGenerator) を実装したScriptableObjectにProcessorを作って、ProcessorのProcess()関数で信号処理の内容を作ろう

サンプルを見てみる

公式が公開しているSine波をリアルタイムで生成するSineGenerator を動かしてみました。

まずはこちらをご覧ください。
※音がなるのでヘッドフォンなど推奨です。

InspectorのSliderを動かすと音の高さが変化しているのがわかります。
もちろん周波数を直接指定も可能です。

Generator のサンプル

まずはAudioSourceに代わる IGeneratorDefinition に設定されているScriptableObjectについて見てみましょう。

SineGenerator.cs
using System;
using Unity.Burst;
using Unity.IntegerTime;
using UnityEngine;
using UnityEngine.Audio;

[CreateAssetMenu(fileName = "SineGenerator", menuName = "Sample/Create SineGenerator asset", order = 2)]
public class SineGenerator : ScriptableObject, IGeneratorDefinition
{
    public float initialFrequency;

    public bool isFinite => false;
    public bool isRealtime => true;
    public DiscreteTime? length => null;

    public Generator CreateRuntime(ControlContext context, DSPConfiguration? nestedConfiguration,
        ControlContext.ProcessorCreationParameters creationParameters)
    {
        return Processor.Allocate(context, initialFrequency);
    }

    [BurstCompile(CompileSynchronously = true)]
    internal struct Processor : Generator.IProcessor
    {
        const float k_Tau = Mathf.PI * 2;

        float m_Frequency;
        float m_Phase;

        public static Generator Allocate(ControlContext context, float frequency)
        {
            return context.AllocateGenerator(new Processor(frequency), new Control());
        }

        public bool isFinite => false;
        public bool isRealtime => true;
        public DiscreteTime? length => null;

        Generator.Setup m_Setup;

        Processor(float frequency)
        {
            m_Frequency = frequency;
            m_Phase = 0.0f;
            m_Setup = new Generator.Setup();
        }

        public void Update(UnityEngine.Audio.Processor.UpdatedDataContext context, UnityEngine.Audio.Processor.Pipe pipe)
        {
            var enumerator = pipe.GetAvailableData(context);

			foreach (var element in enumerator)
			{
	            if (element.TryGetData(out FrequencyData data))
    	            m_Frequency = data.Value;
        	    else
            	    Debug.Log("DataAvailable: unknown data."); 
			}
        }

        public Generator.Result Process(in ProcessingContext ctx, UnityEngine.Audio.Processor.Pipe pipe, ChannelBuffer buffer, Generator.Arguments args)
        {
            for (var frame = 0; frame < buffer.frameCount; frame++)
            {
                for (var channel = 0; channel < buffer.channelCount; channel++)
                    buffer[channel, frame] = Mathf.Sin(m_Phase * k_Tau);

                m_Phase += m_Frequency / m_Setup.sampleRate;

                if (m_Phase > 1.0f) m_Phase -= 1.0f;
            }

            return buffer.frameCount;
        }

        struct Control : Generator.IControl<Processor>
        {
            public void Configure(ControlContext context, ref Processor generator, in DSPConfiguration config, out Generator.Setup setup, ref Generator.Properties p)
            {
                generator.m_Setup = new Generator.Setup(AudioSpeakerMode.Mono, config.sampleRate);
                setup = generator.m_Setup;
            }

            public void Dispose(ControlContext context, ref Processor processor) { }

            public void Update(ControlContext context, UnityEngine.Audio.Processor.Pipe pipe) { }

            public UnityEngine.Audio.Processor.MessageStatus OnMessage(ControlContext context, UnityEngine.Audio.Processor.Pipe pipe, UnityEngine.Audio.Processor.Message message)
            {
                return UnityEngine.Audio.Processor.MessageStatus.Unhandled;
            }
        }

        internal struct FrequencyData
        {
            public readonly float Value;

            public FrequencyData(float value)
            {
                Value = value;
            }
        }
    }
}

ポイントは3つです.

  1. このScriptableObject自体が IGeneratorDefinition を実装している
  2. 内部でProcessor とControl, 他のC#Scriptから受け取るデータのFrequencyData を定義していること
  3. 実際の波形の生成は Processor の public Generator.Result Process() で行っている

IGeneratorDefinitionを実装

ここはシンプルで

IgeneratorDefinition.cs
  Generator CreateRuntime(
    ControlContext context,
    DSPConfiguration? nestedConfiguration,
    ControlContext.ProcessorCreationParameters creationParameters);

定義にあるこの関数を実装すれば良いので、先ほどのSineGenerator.csの

    public Generator CreateRuntime(ControlContext context, DSPConfiguration? nestedConfiguration,
        ControlContext.ProcessorCreationParameters creationParameters)
    {
        return Processor.Allocate(context, initialFrequency);
    }

ここが回答します。

また、ここで注目したいのが、Generatorを返すにあたり、このSineGenerator.csで作ったProcessorのstaticメソッドである Processor.Allocate でGenerator インスタンスを作っています

Processor とControl, FrequencyData を定義

順番に見ていくと Processor の中にControlとFrequencyDataは定義されていますね

Processor の重要部分は

Prosessor.cs
public static Generator Allocate(ControlContext context, float frequency)
{
    return context.AllocateGenerator(new Processor(frequency), new Control());
}

まずここのAllocate。ここで自身のProcessorと初期値の周波数、そしてProcessorを制御するためのControl インスタンスを作ってContext に登録(Generator のAlloc)をリクエストしています

次に波形のgenerate処理であるProcess関数を見てみましょう。

Processor.Process.cs
       public Generator.Result Process(in ProcessingContext ctx, UnityEngine.Audio.Processor.Pipe pipe, ChannelBuffer buffer, Generator.Arguments args)
        {
            for (var frame = 0; frame < buffer.frameCount; frame++)
            {
                for (var channel = 0; channel < buffer.channelCount; channel++)
                    buffer[channel, frame] = Mathf.Sin(m_Phase * k_Tau);

                m_Phase += m_Frequency / m_Setup.sampleRate;

                if (m_Phase > 1.0f) m_Phase -= 1.0f;
            }

            return buffer.frameCount;
        }

ここでは

  1. 受け取ったAudioBufferからフレーム数を参照
    • ちなみにFrameはゲームのFrameとは異なり 秒間あたり SampleRate[Hz](大抵は44100[Hz] か 48000[Hz]) と同じ値になります
    • AudioBufferはゲームエンジンと同期するためにゲームエンジンの1Frameに合わせて必要なAudioFrameをまとめてAudioBufferという形でとってきます
  2. BufferにあるChannel数(Mono:1, Stereo:2) を確認
  3. AudioBufferは Frame1.Lch->Frame1.Rch->Frame2.Lch->Frame2.Rch->... という形でステレオ音源のバイナリは作られるため二重のFor文を作っています
    • ちなみにSine波は一般には A*sin(ωt + φ) で表されます
      • ω=2πf
    • k_Tauが2πに相当
    • m_Frequencyがf
    • t 正規化して、波形のどの位置なのか?という現在位相を示すために m_Phaseとしています
      • 信号処理的にはφ が初期位相で Phase という言葉が使われますがDSP実装の慣習で θ(t) の累積変数として phase が用いられることがあるらしいです
    • if (m_Phase > 1.0f) m_Phase -= 1.0f; はなくても動きますが累積されて値が肥大化すると浮動小数点の誤差が気になるためTrimしているようです

次にControlですが

Control.cs
            public void Configure(ControlContext context, ref Processor generator, in DSPConfiguration config, out Generator.Setup setup, ref Generator.Properties p)
            {
                generator.m_Setup = new Generator.Setup(AudioSpeakerMode.Mono, config.sampleRate);
                setup = generator.m_Setup;
            }

ここの初期設定でMonoral or Strereo or SurrroundとSampling周波数を設定しているので初期設定はここで渡しましょう

最後にFrequenctDataですが

FrequenctData.cs
internal struct FrequencyData
        {
            public readonly float Value;

            public FrequencyData(float value)
            {
                Value = value;
            }
        }

中身をreadonlyにするなら readonly struct にしてしまえばコピー発生しなくないか?とも思ったり。(Audio はGC.Allocが大の天敵です)

Inspector の操作部

SineGeneratorControl.cs
using System;
using UnityEngine;
using UnityEngine.Audio;

[RequireComponent(typeof(AudioSource))]
public class SineGeneratorControl : MonoBehaviour
{
    [Range(100f, 5000f)]
    public float frequency = 432;

    AudioSource m_AudioSource;
    float m_PreviousFrequency;

    void Awake()
    {
        m_AudioSource = gameObject.GetComponent<AudioSource>();
    }

    void Update()
    {
        var handle = m_AudioSource.generatorHandle;

        if (!m_AudioSource.isPlaying
            || !ControlContext.builtIn.Exists(handle)
            || Mathf.Approximately(frequency, m_PreviousFrequency))
            return;

        ControlContext.builtIn.SendData(handle, new SineGenerator.Processor.FrequencyData(frequency));
        m_PreviousFrequency = frequency;
    }
}

めちゃくちゃシンプルですね。
SerializeFieldで再生周波数を決めて、UpdateでValidationしつつ、前回と周波数に差分があればControlContext経由でFrequencyData をSendDataして周波数の値を更新するだけです。

自作のサウンドジェネレーターを作ってみよう

ということでSine波を作れるということは三角波や矩形波を作ればチップチューンサウンドをUnityで再現できますね!

ということで早速作っていきましょう。

三角波関数

基本的にはSineGenerator.csとSineGeneratorControl.csをまるパクりしてリネームだけします。

そしてTriangleGenerator.cs を作ってProceessorのSin関数で波形を生成している部分を変えましょう

Processor.cs

 public Generator.Result Process(in ProcessingContext ctx, UnityEngine.Audio.Processor.Pipe pipe, ChannelBuffer buffer, Generator.Arguments args)
        {
            for (var frame = 0; frame < buffer.frameCount; frame++)
            {
                // 位相を [0,1) に正規化
                float phase = m_Phase - Mathf.Floor(m_Phase);

                // 三角波を -1~+1 で生成
                float tri = 4f * Mathf.Abs(phase - 0.5f) - 1f;

                // 各チャンネルに書き込み
                for (var channel = 0; channel < buffer.channelCount; channel++)
                {
                    buffer[channel, frame] = tri;
                }

                // 次のサンプルへ時間を進める
                m_Phase += m_Frequency / m_Setup.sampleRate;
                if (m_Phase > 1.0f) m_Phase = 0.0f;
            }

            return buffer.frameCount;
        }

これでOK

矩形波ジェネレーターを作ろう

矩形波は四角い波ですが、これまでと違ってパラメータがもう一つ増え Duty比 というものがあります。簡単に言うと1周期でどこまでを矩形波にするか?というパラメータです。

そのため下記のように周波数とDuty比を受け取れるように拡張しましょう。

SquareWaveGenerator.cs
using System;
using Unity.Burst;
using Unity.IntegerTime;
using UnityEngine;
using UnityEngine.Audio;

[CreateAssetMenu(fileName = "SquareWaveGenerator", menuName = "Audio/Generator/SquareWaveGenerator")]
public class SquareWaveGenerator : ScriptableObject, IGeneratorDefinition
{
    public float initialFrequency;
    // Duty比の初期値定義用に追加
    public float initRatio;

    public bool isFinite => false;
    public bool isRealtime => true;
    public DiscreteTime? length => null;

    public Generator CreateRuntime(ControlContext context, DSPConfiguration? nestedConfiguration,
        ControlContext.ProcessorCreationParameters creationParameters)
    {
        return Processor.Allocate(context, initialFrequency, Mathf.Clamp01(initRatio));
    }

    [BurstCompile(CompileSynchronously = true)]
    internal struct Processor : Generator.IProcessor
    {
        const float k_Tau = Mathf.PI * 2;

        float m_Frequency;
        // Duty比ように変数追加
        private float m_DutyRatio;
        float m_Phase;

        // 外部から初期値を受け取れるように拡張
        public static Generator Allocate(ControlContext context, float frequency, float dutyRatio)
        {
            return context.AllocateGenerator(new Processor(frequency, dutyRatio), new Control());
        }

        public bool isFinite => false;
        public bool isRealtime => true;
        public DiscreteTime? length => null;

        Generator.Setup m_Setup;

        Processor(float frequency, float dutyRatio)
        {
            m_Frequency = frequency;
            m_Phase = 0.0f;
            // 初期化も忘れずに
            m_DutyRatio = dutyRatio;
            m_Setup = new Generator.Setup();
        }

        public void Update(UnityEngine.Audio.Processor.UpdatedDataContext context, UnityEngine.Audio.Processor.Pipe pipe)
        {
            var enumerator = pipe.GetAvailableData(context);

			foreach (var element in enumerator)
			{
                if (element.TryGetData(out WaveData data))
                {
                    // WaveDataをちゃんと受け取れるように対応
                    m_Frequency = data.Freq;
                    m_DutyRatio = data.Ratio;
                    
                }
        	    else
            	    Debug.Log("DataAvailable: unknown data."); 
			}
        }

        // 矩形波ようにロジックを更新
        public Generator.Result Process(in ProcessingContext ctx, UnityEngine.Audio.Processor.Pipe pipe, ChannelBuffer buffer, Generator.Arguments args)
        {
            for (var frame = 0; frame < buffer.frameCount; frame++)
            {
                // 0..1 の位相
                float phase = m_Phase;

                // Dutyは安全にクランプ(完全0%/100%で無音や直流化を避ける)
                float d = Mathf.Clamp01(m_DutyRatio);
                d = Mathf.Clamp(d, 0.001f, 0.999f);

                // 矩形波(±1)
                float v = (phase < d) ? 1f : -1f;

                // 出力
                for (var channel = 0; channel < buffer.channelCount; channel++)
                {
                    buffer[channel, frame] = v;
                }

                // 位相進行とラップ
                m_Phase += m_Frequency / m_Setup.sampleRate;   // 1周期=1.0
                if (m_Phase >= 1f) m_Phase -= 1f;
            }

            return buffer.frameCount;
        }

        struct Control : Generator.IControl<Processor>
        {
            public void Configure(ControlContext context, ref Processor generator, in DSPConfiguration config, out Generator.Setup setup, ref Generator.Properties p)
            {
                generator.m_Setup = new Generator.Setup(AudioSpeakerMode.Mono, config.sampleRate);
                setup = generator.m_Setup;
            }

            public void Dispose(ControlContext context, ref Processor processor) { }

            public void Update(ControlContext context, UnityEngine.Audio.Processor.Pipe pipe) { }

            public UnityEngine.Audio.Processor.MessageStatus OnMessage(ControlContext context, UnityEngine.Audio.Processor.Pipe pipe, UnityEngine.Audio.Processor.Message message)
            {
                return UnityEngine.Audio.Processor.MessageStatus.Unhandled;
            }
        }

        internal readonly struct WaveData
        {
            // Valueじゃわからないので命名をちゃんとした
            public readonly float Freq;
            // Duty比用のパラメータ追加
            public readonly float Ratio;

            public WaveData(float freq, float ratio)
            {
                Freq = freq;
                Ratio = ratio;
            }
        }
    }
}

コントローラーもDuty比を設定するEnumを用意してあげましょう

SquareWaveGeneratorControl.cs
using System;
using UnityEngine;
using UnityEngine.Audio;

namespace SqrWaveGenerator
{
    public enum DutyRatio : int
    {
        UNDEFINED = 0,
        Eightth = 8,
        Quarter = 4,
        Half = 2,
    }
    [RequireComponent(typeof(AudioSource))]
    public class SquareWaveGeneratorControl : MonoBehaviour
    {
        [SerializeField] AudioSource m_AudioSource;
        [Range(100f, 5000f)] public float frequency = 432;
        public DutyRatio dutyRatio = DutyRatio.Eightth;

        float m_PreviousFrequency;
        DutyRatio m_prevRatio = DutyRatio.UNDEFINED;

        void Reset()
        {
            TryGetComponent(out m_AudioSource);
        }

        private void Awake()
        {
            m_AudioSource ??= GetComponent<AudioSource>();
        }

        void Update()
        {
            var handle = m_AudioSource.generatorHandle;

            if (!m_AudioSource.isPlaying
                || !ControlContext.builtIn.Exists(handle)
                || (Mathf.Approximately(frequency, m_PreviousFrequency) && dutyRatio == m_prevRatio)
                || dutyRatio == DutyRatio.UNDEFINED
                )
                return;

            ControlContext.builtIn.SendData(handle, new SquareWaveGenerator.Processor.WaveData(frequency, 1f/(float)dutyRatio));
            m_PreviousFrequency = frequency;
            m_prevRatio = dutyRatio;
        }
    }

}

実践

ちゃんと動きました!!

まとめ

今回はUnity6.3aから追加されたAudioPipeline の1つであるIGeneratorDefinitionを拡張してチップチューンサウンドジェネレーターを作ってみました。

まだ複雑なことはこれからですが、これができればいわゆる ProcedualAudio と呼ばれる動的にSEなどを生成する というサウンドデザインの幅も広がります。

従来はCPUが顕著にSpikeで処理落ちしてましたが、全然違和感なく再生可能かつ、音の加工ができるのは大きいですね。
今後は既存の音源を加工ができるのか?なども検証してみようと思います。

11
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?