見出し画像

Xcode で iOS 用 AUv3 プラグインを作る ~ リングバッファ/ダブルバッファ方式 FIR フィルター ~ Simulink でメイン処理動作確認


まえがき


なんか去年もそうだった気がするのですが、せっかくの桜満開の時期に天気悪くてがっかりですね。

今朝も桜を見に近所の公園に行ったのですが、雨が降ってきました。

やはり桜は青空バックで見たいです。


なので、部屋に籠もって AUv3 プラグインでも作りましょう!


AUv3 プラグインとは


AUv3
とは、Apple の オーディオ用低レイヤーフレームワーク Audio Unit をアプリ間で共有する Audio Unit Extension の最新規格です。

これに準拠すると、MacOS / iOS のアプリ間で、MIDIやオーディオデータを低遅延でリアルタイムにやり取りすることができます。

Audio Unit Extension 対応アプリであれば、OS にインストールされた AUv3 プラグインを使用可能です。

Apple 専用の VST のようなものですかね。


自作した AUv3 を、ガレージバンド等で使用することができます。


VST は JUCE とかを使って作るのが楽だと思います。

JUCE は(ほぼ)同じソースコードから AUv3 も作れるのですが(Mac 版の MATLAB + Audio Toolbox でも作れます)、Xcode にもテンプレートがあるので、JUCE を使わなくても(簡単なヤツなら)簡単に作れます。


それと今は Xcode Cloud で配信まで含めて(Connect 側の設定・操作は必要ですが)ほぼ自動でできるようになったので、Xcode でやるメリットはあるでしょう。


ちょっと変な書き方をしてしまいました。
JUCE でも Xcode を使うので、この辺は同じですね。

2024/04/13 追記


というわけで今回は、Xcode での作り方を見てみましょう。


プロジェクト生成


Xcode に Gain 調整のみの AUv3 テンプレートが用意されているので、これを使用します。


1.Xcodeの新規プロジェクト作成メニューで Audio Unit Extension App を選びます

プロジェクト作成メニュー


2.Product Name 等を記入します

Audio Unit Extension の Type としては以下があります。

・Instrument - mono sine wave synthesizer
GUIパラメータ:ゲイン
MIDI:note
入力:MIDI、出力:オーディオ

・Generator - sine wave generator
GUIパラメータ:ゲイン
出力:オーディオ

・Effect - pass-through effect
GUIパラメータ:ゲイン
入出力:オーディオ

・Music Effect - MIDI-controlled audio gate
GUIパラメータ:ゲイン
MIDI:note-on
入力:MIDI、オーディオ、出力:オーディオ

・MIDI Processor - MIDI note-on and note-off message generator
入出力:MIDI

もう一つ、Speech Synthesis というのも選べるのですが、イマイチよく分かりませんでした。


ここでは、オーディオ用エフェクターである Effect(aufx) を作ってみます。

プロジェクト設定


Subtype Code:4文字の適当な文字を入れます。

Manufacture Code:こちらも適当な4文字ですが、少なくとも1文字は大文字にする必要があります。

これで20個弱の、Swift/C/C++ が混在したファイル群が生成されます。


最初にファイル自体を見てしまうと関係性が複雑で分かりにくいのですが、[Product Name]Extension フォルダーに README ファイルがあり、ここにプロジェクトの構成や、パラメータの追加の仕方等が記載されています。

README

とりあえずファイル構成自体は気にせずに、README にしたがってコードを書いていくと楽だと思います。


テンプレート確認


それでは README にしたがって、実際にエフェクターを作る手順を確認してみましょう。

[Product Name] に空白があると、ファイル名ではアンダースコアに置き換わります。


1.必要な GUI パラメータの追加

パラメータは普通の C の enum 形式で追加します。

[Product Name] \ [Product Name]Extension\Parameters の
[Product Name]ExtensionParameterAddresses.h に追加します。

```c
typedef NS_ENUM(AUParameterAddress, ASSP_PluginExtensionParameterAddress) {
	sendNote = 0,
	....
	attack
```


2.ParameterSpec の追加

追加したパラメータの ParameterSpec を Parameters.swift に記入します。

```swift
ParameterGroupSpec(identifier: "global", name: "Global") {
	....
	ParameterSpec(
		address: .attack,
		identifier: "attack",
		name: "Attack",
		units: .milliseconds,
		valueRange: 0.0...1000.0,
		defaultValue: 100.0
	)
```


3.set / get への追加

追加したパラメータの set / get を AUExtensionDSPKernel.hpp に追加します。

```cpp
	void setParameter(AUParameterAddress address, AUValue value) {
		switch (address) {
			....
			case ASSP_PluginExtensionExtensionParameterAddress:: attack:
				mAttack = value;
				break;			
			...
	}
	
	AUValue getParameter(AUParameterAddress address) {
		switch (address) {
			....
			case ASSP_PluginExtensionExtensionParameterAddress::attack:
				return (AUValue) mAttack;
			...
	}
	
	// You can now apply attack your DSP algorithm using `mAttack` in the `process` call. 
```


これで、処理部から以下のようにパラメータへアクセスできるようになります。

```Swift
// Access the attack parameters value from SwiftUI
parameterTree.global.attack.value

// Set the attack parameters value from SwiftUI
parameterTree.global.attack.value = 0.5

// Bind the parameter to a slider
struct EqualizerExtensionMainView: View {
	...	
	var body: some View {
		ParameterSlider(param: parameterTree.global.attack)
	}
	...
}
```


4.メインの信号処理部を書く

信号処理は、[Product Name]ExtensionDSPKernel.hpp に 普通の C++ で記載します。

当然リアルタイムで動く(フレームサイズ×サンプリング周波数以内で処理が終わる)ように書く必要があります。

私は C++ の作法をよく知らないのですが、.hpp ファイルにメインの処理を書くのが普通なんですかね?(¬_¬)

やりたきゃ自分で勝手に .cpp ファイル作って書け、ってことなんでしょうか?

C++ 分かりません。

JUCE のサンプルも全部 .h に書かれていて、とても使いにくい。

「配布に便利なように」とか書かれていますが、圧縮ファイルだし何が便利なんだか。


また、VST と同じくフレームサイズは(多分)可変なので、呼び出し毎にフレームサイズが変わっても動作するよう、フレームサイズ非依存で書く必要もあります。

max はメインルーチンで定義されているようです。

    AUAudioFrameCount mMaxFramesToRender = 1024;


これ以上は設定させない仕様なんですかね?

VST とはちょっと違いますね。

フレームサイズが大きくなるとその分レイテンシーが増えるので、iOS の場合、UI が最優先と言うことなのかもしれません。


次回呼び出し時も保持したい変数があれば、static にします。


あとは普通にビルドして実機実行するだけです。

インストールされると、AUv3 対応アプリから見えるようになります。


単体アプリ

単体アプリとして立ち上げると、テンプレート内蔵曲のプレイヤーで効果が確認できます。

初回立ち上げ時は、プラグインのロード、再生曲のロードに少し時間が掛かるようです。

AUv3 テンプレートアプリ


スライダー以外の GUI を出したい場合はどうするんですかね?

スライダーは、ParameterSlider.swift で Swift で出しているだけのようです。
専用の UI フレームワークがあるわけじゃないんですね。

プッシュボタン・トグルボタン、コンボボックスの例も書いといてくれれば良いのに。


Swift でやるので、Audio Unit 関連ドキュメントを見ても簡単な例だけで説明もなく、よく分かりません。Swift なんて知らんがな。


Xcode 15から、設定すればブリッジなしに C++ とのインターフェースが取れるようになったらしいので、もしかしたら簡単にできるのかもしれません。

WWDC23 でなんか言ってたような気がします。

誰か知ってたら教えてください。


LMS フィルター

リングバッファとダブルバッファ方式

今回は例として、 LMS フィルターを作ってみます。

LMS フィルターとは、二つの入力に対し誤差が最小となるようなフィルター係数を求めながらフィルタリングを行うアダプティブフィルターです。LMS(Least Mean Square)自体はシステム同定のための演算手法の一つです。

LR のセパレーション向上のためにも使われることのある、古くから知られたアルゴリズムです。

LMS フィルターにより LR の相関(誤差最小)成分と無相関(残差)成分を分離し、相関成分を M、無相関成分を L / R それぞれに振りわけます。

効果がある場合とかえって悪さをする場合もあり、単独で用いられることはあまりないかと思います。

モノラルに対しては効果がありません。


このような FIR フィルターを効率的に実装する代表的な手法としては、リングバッファを用いる方法と、ダブルバッファを用いる方法があります。

モジュロ・アドレッシング・モードが備わっているデバイスであればリングバッファでも良いですし、ダブルバッファ方式はメモリが倍必要になりますがカウンター以外でモジュロを使う必要がなくなります。

モジュロ演算は要は割り算なので、バッファ長がちょうど 2^n 以外では if 文とかより遅くなったりもします。

広く一般的に使える方法ですので覚えておいて、プラットフォームによって使い分けると良いかと思います。


畳み込みの構造(あるいは畳み込みの定義式)を考えれば分かりますが、データの入力方向と係数の順番は逆方向となることに注意しましょう。

またダブルバッファの場合、初期状態で0番目の係数と掛けるデータは、バンク境界を超えたセカンドバンクの先頭となります。

これもややこしく説明もしづらいので、動作をじっくり考えてみてください。

インデックス確認例

MATLAB でのインデックス確認例を示します。

N = 4;

fprintf('Ring Buffer\n')
for idx=0:N-1
    fprintf('idx = %d\n', idx)
    for k=1:N
        coef = N - k;
        buff = mod((idx + k), N);
        fprintf('coeff=%d\tbuff=%d\n',coef,buff)
    end
end

fprintf('Double Buffer\n')
for idx=0:N-1
    fprintf('idx = %d\n', idx)
    for k=1:N
        coef = N - k;
        buff = idx + k;
        fprintf('coeff=%d\tbuff=%d\n',coef,buff)
    end
end

-実行結果

Ring Buffer
idx = 0
	coeff=3	buff=1
	coeff=2	buff=2
	coeff=1	buff=3
	coeff=0	buff=0
idx = 1
	coeff=3	buff=2
	coeff=2	buff=3
	coeff=1	buff=0
	coeff=0	buff=1
idx = 2
	coeff=3	buff=3
	coeff=2	buff=0
	coeff=1	buff=1
	coeff=0	buff=2
idx = 3
	coeff=3	buff=0
	coeff=2	buff=1
	coeff=1	buff=2
	coeff=0	buff=3


Double Buffer
idx = 0
	coeff=3	buff=1
	coeff=2	buff=2
	coeff=1	buff=3
	coeff=0	buff=4
idx = 1
	coeff=3	buff=2
	coeff=2	buff=3
	coeff=1	buff=4
	coeff=0	buff=5
idx = 2
	coeff=3	buff=3
	coeff=2	buff=4
	coeff=1	buff=5
	coeff=0	buff=6
idx = 3
	coeff=3	buff=4
	coeff=2	buff=5
	coeff=1	buff=6
	coeff=0	buff=7


Simulink で動作確認

メイン処理部動作確認

メイン処理部の動作確認には Simulink が便利です。

LMS フィルターブロックもありますし、C/C++ コードを書ける C Function ブロックもあるので、Simulink ブロックの動作と C コードの動作を直接比較して確認することができます。


C Function ブロック

だいぶ前のこの記事で書いたように C/C++ のファイルを読み込むこともできますし、直接書くこともできます。


ファイルを読み込んだ方がそのまま使えるので確認には適していると思いますが、前回の記事でやったので、今回はあえて直接書いてみましょう。

C/C++ のファイルを別途用意する必要がないので、その面では楽です。


直接書く場合、それぞれのブロックパラメータは以下のような意味を持ちます。

出力コード:各タイム ステップで実行する出力コード
初期化コード:開始時に 1 回実行する初期化コード 永続シンボルを初期化
初期化条件コード:モデルがディセーブルからイネーブルに切り替わるたびに実行


また、math ライブラリをインクルードすることなく、sin/cos/pow などを使用することができます。
R2023a 以降であれば、printf、memcpy、memsetなども使用できます。


それぞれ、以下のコードを「出力コード」に書きます。

リングバッファ方式

LMS_z[idx] = u;

y = 0.0;
for (int k = 1; k <= N; ++k) {
     y += LMS_coeff[N - k] * LMS_z[(idx + k) % N];
}
e = des - y;

// update filter coefficients
for (int k = 1; k <= N; ++k) {
    LMS_coeff[N - k] += step * e * LMS_z[(idx + k) % N];
}

idx = (idx + 1) % N;


ダブルバッファ方式

LMS_z[idx] = u;  LMS_z[idx+N] = u;  // to avoid modulo addressing

y = 0.0;
for (int k = 1; k <= N; ++k) {
     y += LMS_coeff[N - k] * LMS_z[idx + k];
}
e = des - y;

// update filter coefficients
for (int k = 1; k <= N; ++k) {
    LMS_coeff[N - k] += step * e * LMS_z[idx + k];
}

idx = (idx + 1) % N;


C Function ブロックで static 宣言は使えないので、"Ports and Parameters" で Persistent として宣言します。デフォルトで 0 に初期化されますが、他の値やポートで初期化したい場合は、「開始」に初期化コードを書きます。


"Ports and Parameters" は以下のように設定します。

Ports and Parameters(リングバッファ方式)
Ports and Parameters(ダブルバッファ方式)

違いは LMS_z のサイズだけです。


誤差を Scope で確認する場合は、実行後にメニューの で拡大することを忘れないでください。

一応、数値で max(abs()) を表示した方が良いですね。

Simulink モデル(R2024a)例を添付しておきます。

Simulink モデル例


最初、リングバッファで作って、Simulink ブロックとの誤差が e-17オーダー。

「まあこんなもんだろ」、 と思って、ついでにダブルバッファでも作ってみたら誤差0!?

計算式自体は全く同じはずなのになぜ!? と数時間悩んだ結果、フィルター部分の足す順番でした・・。

逆に足したら誤差0。

まあ、その程度の誤差なら問題ないですが。


Xcode でインプリ

コードの動作検証が終わったので、実際に Xcode のテンプレートを元にインプリしてみましょう。


パラメータ追加

そのままでは何なので、一応効果度合いを設定するパラメータ Effect(wetDry)を追加してみます。

追加分と合わせて、その前後も示しています。


-[Product Name]ExtensionParameterAddresses.h

typedef NS_ENUM(AUParameterAddress, Auv3_TestExtensionParameterAddress) {
    gain = 0,
    wetDry = 1
};


-Parameters.swift

    ParameterGroupSpec(identifier: "global", name: "Global") {
        ParameterSpec(
            address: .gain,
            identifier: "gain",
            name: "Input Gain",
            units: .linearGain,
            valueRange: 0.0...1.0,
            defaultValue: 1.0
        )
        ParameterSpec(
            address: .wetDry,
            identifier: "wetDry",
            name: "Effect",
            units: .linearGain,
            valueRange: 0.0...1.0,
            defaultValue: 1.0
        )
    }

Output Gain -> Input Gain、defaultValue: 0.25 -> 1.0 の変更も行っています。


-[Product Name]ExtensionDSPKernel.hpp

    void setParameter(AUParameterAddress address, AUValue value) {
        switch (address) {
            case Auv3_TestExtensionParameterAddress::gain:
                mGain = value;
                break;
            case Auv3_TestExtensionParameterAddress::wetDry:
                mWetDry = value;
                break;
                // Add a case for each parameter in Auv3_TestExtensionParameterAddresses.h
        }
    }
    
    AUValue getParameter(AUParameterAddress address) {
        // Return the goal. It is not thread safe to return the ramping value.
        
        switch (address) {
            case Auv3_TestExtensionParameterAddress::gain:
                return (AUValue)mGain;
            case Auv3_TestExtensionParameterAddress::wetDry:
                return (AUValue)mWetDry;
            default: return 0.f;
        }
    }
    AUAudioFrameCount mMaxFramesToRender = 1024;
    double mWetDry;


-[Product Name]AudioUnitHostModel.swift

Title に subType とかを出してるのは
[Product Name] -> Model -> AudioUnitHostModel.swift の

  auValString = "(type) (subType) (manufacturer)"

部分です。

これを変えればタイトルは自由に変えられます。


auValString = "AudiiSion Demo\n     AUv3 Test"

改行したければ "\n" を入れればOKです。


変化ステップ追加

パラメータ追加だけでも何なので、ついでにスライダーの変化ステップ設定も追加してみましょう。

swift のスライダーUI としては元々 step が指定できるので、そこに入れてあげるだけです。

ParameterSpec 構造体の宣言は 
[Product Name]Extension -> Common -> ParameterSpecBase.swift
にあります。

これを変更すれば良いのでしょうが、変更ファイルが増えてしまうので、今回は ParameterSlider の引数として追加することにします。

もっと追加するオプションが多い場合は、直接構造体を変更した方が良いかもしれません。

-ParameterSlider.swift

struct ParameterSlider: View {
    @ObservedObject var param: ObservableAUParameter
    var step: Float // 変化ステップ
    var body: some View {
        VStack {
            Slider(
                value: $param.value,
                in: param.min...param.max,
                step: step,
                onEditingChanged: param.onEditingChanged,
                minimumValueLabel: Text("\(param.min, specifier: specifier)"),
                maximumValueLabel: Text("\(param.max, specifier: specifier)")

両方とも、追加するのは step の行だけです。


-[Product Name]ExtensionMainView.swift

    var body: some View {
        ParameterSlider(param: parameterTree.global.gain, step: 0.1)
        ParameterSlider(param: parameterTree.global.wetDry, step: 0.01)
    }


メイン処理

-[Product Name]ExtensionDSPKernel.hpp
以下の部分を書き換えます。

        // Perform per sample dsp on the incoming float in before assigning it to out
        for (UInt32 channel = 0; channel < inputBuffers.size(); ++channel) {
            for (UInt32 frameIndex = 0; frameIndex < frameCount; ++frameIndex) {
                // Do your sample by sample dsp here...
                outputBuffers[channel][frameIndex] = inputBuffers[channel][frameIndex] * mGain;
            }
        }


LMS 処理(ダブルバッファ方式)

        int32_t N = 5, k;
        static double LMS_coeff[2][5] = {0.0};
        static double LMS_z[2][10] = {0.0};
        static int32_t idx = 0;
        double step = pow(2.0,-12.0);
        double yL, yR, eL, eR;
        float Lin, Rin;

        for (UInt32 frameIndex = 0; frameIndex < frameCount; ++frameIndex) {
            Lin = inputBuffers[0][frameIndex] * mGain;
            Rin = inputBuffers[1][frameIndex] * mGain;
            
            
            // Lch
            LMS_z[0][idx] = Rin;  LMS_z[0][idx+N] = Rin;  // to avoid modulo addressing
            
            yL = 0.0;
            for (k = 1; k <= N; ++k) {
                yL += LMS_coeff[0][N - k] * LMS_z[0][idx + k];
            }
            
            eL = Lin - yL;
            
            // update filter coefficients
            for (k = 1; k <= N; ++k) {
                LMS_coeff[0][[N - k] += step * eL * LMS_z[0][idx + k];
            }

            
    
            //Rch
            LMS_z[1][idx] = Lin;  LMS_z[1][idx+N] = Lin;
            
            yR = 0.0;
            for (k = 1; k <= N; ++k) {
                yR += LMS_coeff[1][N - k] * LMS_z[1][idx + k];
            }
            
            eR = Rin - yR;
            
            // update filter coefficients
            for (k = 1; k <= N; ++k) {
                LMS_coeff[1][N - k] += step * eR * LMS_z[1][idx + k];
            }
            

            auto M = (yL + yR) / 2.0;
            outputBuffers[0][frameIndex] = (M + eL) * mWetDry + Lin * (1 - mWetDry);
            outputBuffers[1][frameIndex] = (M + eR) * mWetDry + Rin * (1 - mWetDry);
            
            
            // update index for both channels
            idx = (idx + 1) % N;
        }


あまり型を気にして書いていませんが、入出力は float であることに注意してください。auto を使った方が安全かもしれません。

入力は 2ch 限定としています。


メイン UI

プラグインとは関係ありませんが、メイン UI のボタンの見た目もちょっと変えてみます。
-ContentView.swift

Text("Audio Playback")
Button {
     hostModel.isPlaying ? hostModel.stopPlaying() : hostModel.startPlaying()                 
} label: {
     Text(hostModel.isPlaying ? "Stop" : "Play")
}
// 以下追加
.padding(.top, 8)
.padding([.leading, .trailing], 20)
.padding(.bottom, 8)
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)


アプリアイコン設定

必要であれば(パブリッシュ時は必要)[Product Name] -> Assets の AppIcon に、アイコン画像を登録します。今は、1024×1024 解像度の画像1枚あればOKです。

アプリアイコン設定


内蔵曲変更

簡易プレイヤーと内蔵曲(Synth.aif)は Common -> Audio にあるので、ここにでも好きな曲をドロップしてプロジェクトに追加して(.wav ファイルとかでもOK)、SimplePlayEngine.swift の以下のファイル名を変更すればその曲に変更できます。

guard let fileURL = Bundle.main.url(forResource: "Synth", withExtension: "aif")


アプリ名設定

TARGETS -> General -> Display Name に設定します。

アプリ名設定


AUv3 Test アプリ


ガレージバンドで使う

実行

実機デバッグを一回走らせれば、その後は対応アプリで使えるようになります。

インストール後、 Xcode は不要です。


Run" での Debug / Release 切替えは通常通り Edit Scheme で設定します。

TestFlight 設定して ReleaseArchive でやった方が楽かもしれません。

Edit Scheme 設定
Debug / Release 切替え


ガレージバンド設定

以下の手順で適用すれば、普通のプラグイン同様に使えます。

 プラグインとEQ -> 編集 -> + -> プラグインを選択 

プラグインとEQ
編集
+で追加
Audio Unit 機能拡張タブ
選択後
UI 表示


AUMediaPlayer(DOTEC)で使う

DOTEC さんが、AUv3 プラグインを複数読み込んで適用できる、無料の簡易プレイヤーアプリ AUMediaPlayer を出しています。

これを使えば簡単に、AUv3 プラグインを適用しての試聴ができます。

”No Effect" のどれかを選択
プラグイン選択
プラグイン UI 画面
プラグインを適用して曲を再生

トグルボタンでプラグインのON/OFFが、プラグイン部分をタッチするとプラグイン UI 表示に切り替わります。


定位が全体に散らばったような曲やセンター付近に寄っている曲で Effect を0から1に動かすと、何となくセンター付近とサイド付近に分かれ、それぞれがハッキリするように聞こえるのではないかと思います。

効果としては地味なので分かりにくかもしれませんが、一般的な FIR としても使える例として取り上げたのでお許しを。😅


あとがき

まあとりあえず、簡単なのは簡単にできることだけは分かりました。

でも iOS はどうせまた、ちょっと変えようとするとなかなか情報が見つからず大変なんですよね。

みなさん、どこで情報取っているんですかね?

私は Apple の Developer サイトとか見ても全然見つからず、いつも苦労します。

AI さんに聞いても全然本当のこと教えてくれないので、なぜか Web に情報があまりないってことなんですかね??

仕様がコロコロ変わるから?(¬_¬) 


今回も、MediaPicker でメディアライブラリから取ったファイル名を内蔵音源の代わりに入れようとしてみましたが、内蔵音源しか再生できず断念しました。

選択したファイル名は取れているのですが・・。

誰か、非プログラマーの私にも分かるよう、優しく教えてください。
(;´Д`)


やっぱりある程度複雑なのを作るなら、JUCE の方が楽なのかな?

まあ、Xcode 完結で作れるので、ちょっと試すには便利です。



この記事が気に入ったらサポートをしてみませんか?