Zenn
📑

(iOS)Audio Unit v3 Extensionでシンセサイザーアプリを作ろう

2025/01/30に公開
1

はじめに

本記事ではiOS / ipadOSで動作するAudio Unitプラグイン、正式名称はAudio Unit v3 Extensionの作り方、そしてプラグインを読み込んで動作するホストアプリの作り方を説明します。

さて、Audio Unit v3 Extensionとしてシンセサイザーを作るのは簡単です。Xcodeを開いてFile→New→ProjectからAudio Unit Extensionテンプレートを選ぶだけです。あとはテンプレートの実装を参考に各自で調査しながら実装してください。

……そうですね、この説明だけで実装できるなら苦労はしません。本記事にたどりついた方は何から着手すればよいか迷っている状況かと思います。

本記事ではノコギリ波の音色を合成するシンセサイザーアプリ、SawtoothSynthesizerを作成します。USB MIDIキーボードからの入力またはアプリ画面に配置されたボタンのタッチをトリガーにして音声を再生するアプリです。

完成したサンプルアプリ

本記事で実装するシンセサイザアプリの完成版を以下のGitHubリポジトリで公開しています。動作対象のバージョンはXcode 14以上、iOS 15以上です。

https://github.com/moutend/SawtoothSynthesizer

動作環境

本記事で説明する内容は以下の環境で動作確認しました。

  • 開発環境: Xcode 14からXcode 16
  • プラットフォーム: iOS 15からiOS 18

また、以下のiPhone実機で動作確認しました。

  • iPhone 15 Pro / iOS 18.2.1
  • iPhone 13 / iOS 15.7.1
  • iPhone SE(第2世代) / iOS 18.2.1

Lightning端子を搭載した機種についてはApple純正品のCamera Connection Kitアダプターを利用してUSB MIDIキーボードを接続しました。

C++ / Objective-C / Swiftの知識は必要?

本記事はXcodeが生成するテンプレートからなるべく逸脱しないように説明を進めます。テンプレートに加える変更は最小限のため、Objective-Cコードは読み書きしません。

また、本記事で説明する範囲であれば、C++とSwiftどちらかの言語の読み書きができれば片方の言語は見よう見まねで実装できるかと思います。もちろん両方の言語に習熟していると理解が早まるはずです。

なお、今回はObjective-Cコードの読み書きはしませんが、以下のドキュメントに目を通すことをお勧めします。

About Objective-C

Audio Unit v3 Extensionの構造

開発をはじめる前に、まずはiOSにおけるAudio Unit v3 Extensionの構造を把握しましょう。

Audio Unit v3 Extensionは独立したプロセスとして実行される

Audio Unit v3 Extensionは通常のアプリとは異なり、独立したプロセスとして実行されます。以降、Audio Unit v3 Extensionの実行を要求する側のアプリをホストアプリと呼びます。

MIDIの入出力

ホストアプリとAudio Unit v3 Extensionはプロセス間通信を利用してMIDIの入出力を行います。MIDI入力の流れは外部MIDI機器→ホストアプリ→Audio Unit v3 Extensionです。MIDI出力の場合は流れが逆になります。

従って、Audio Unit v3 Extensionが外部のMIDIハードウェアと直接通信することはできません。入出力はホストアプリを経由して行うことになります。

また、信号の流れからご想像のとおり、ホストアプリがMIDIのイベントを生成しても構いません。楽器アプリが外部MIDI機器を接続しなくても、画面上のボタンをタッチすると音声が流れるのはこれが理由です。

音声信号の合成と再生

ホストアプリとAudio Unit v3 Extensionは音声信号のバッファを共有します。ホストアプリは共有されたバッファにAVAudioEngineのAVAudioUnitを経由してアクセスします。

従って、Audio Unit v3 Extensionが直接iPhoneのハードウェアを制御してスピーカーから音声を再生するといったことはできません。MIDIの入出力と同様に外部のハードウェアを直接操作することはできないのです。

Audio Unit v3 Extensionができることは音声信号バッファの読み書きだけです。その音声信号バッファを読み取って再生するのはホストアプリの役割になります。

新規Audio Unit v3 Extensionプロジェクトの作り方

具体的な実装の説明に進む前に、プロジェクトの作り方について説明します。

まずはXcodeを起動して、File→New→Projectを選びます。その後、プラットフォームとしてiOS、テンプレートとしてAudio Unit Extensionを選んでください。

なお、Xcode 16を利用されている方は以下の注意事項を確認してください。

Xcode 16の注意点

Xcode 16のAudio Unit Extensionテンプレートについて

Xcode 16で新規プロジェクトを作成する場合、プラットフォームとしてiOSを選ぶとテンプレート一覧にAudio Unit Extensionが表示されません。不具合ではなく仕様のようです。

Audio Unit Extensionはプラットフォームとしてマルチプラットフォームを選択すると表示されます。

Xcode 16で発生している不具合について

マルチプラットフォームのAudio Unit ExtensionをiPhone実機で実行しようとすると「Failed to verify code signature」エラーが発生してインストールできない事象を確認しています。私個人のXcode設定に不備がありそうですが、現在も調査中です。

もし同じ事象が発生する場合はコメントなどで報告いただけると助かります。

Xcode 15以前のバージョンを使う方法

Xcode 16の不具合が解消できない場合、Xcode 15以前のバージョンを利用して開発を進めてください。手順は次のとおりです。

  1. Apple DeveloperのMore Downloadsページ にアクセスします。
  2. Xcode 15.4を選びzipファイルをダウンロードします。
  3. zipファイルを展開するとXcode.appが現れます。名前をXcode15.appなど既存のXcode.appと重複しないように変更した上で、/Applicationsディレクトリにコピーしてください。
  4. 初回起動時はライセンスに同意する必要があります。その後は既存のXcode 16とXcode 15を併用できるようになります。

なお、最新のmacOS SequoiaでXcode 15起動時に警告が表示される不具合が発生しています。その場合は/Applications/Xcode15.app/Contents/MacOS/XcodeをFinderから開くか、以下のようにターミナルでopenコマンドを実行してください。

$ open /Applications/Xcode15.app/Contents/MacOS/Xcode

参考資料:ios - Xcode 15 is not running in macOS sequoia - Stack Overflow

新規プロジェクト作成時の入力項目について

Audio Unit Extensionテンプレートからプロジェクトを新規作成する場合、Organization Name / Subtype / Manufacturerの入力が求められます。それぞれの値はabcddemoなど自由に設定して構いません。

また、Audio Unit TypeについてはInstrumentを選択してください。Instrumentを選ぶとMIDIイベントを受け取り音声信号を出力するAudio Unit Extensionとしてテンプレートが作成されます。

後で説明しますが、Audio Unit Type / Subtype / Manufacturerの値の組み合わせをキーにしてホストアプリはExtensionを探します。その後、キーに一致するExtensionが見つかると独立したプロセスとしてExtensionを起動します。

プロジェクトの構造

Xcodeで新規プロジェクトとしてAudio Unit Extensionを選択すると、以下のようなディレクトリ構造で初期化されます。

.
├── MySynthesizer
│   ├── MySynthesizer.entitlements
│   ├── MySynthesizerApp.swift
│   ├── Common
│   │   ├── Audio
│   │   │   ├── SimplePlayEngine.swift
│   │   │   └── Synth.aif
│   │   ├── MIDI
│   │   │   └── MIDIManager.swift
│   │   ├── String
│   │   │   └── StringHelpers.swift
│   │   ├── TypeAliases.swift
│   │   └── UI
│   │       └── ViewControllerRepresentable.swift
│   ├── ContentView.swift
│   └── Model
│       ├── AudioUnitHostModel.swift
│       └── AudioUnitViewModel.swift
└── MySynthesizerExtension
    ├── Common
    │   ├── MySynthesizerExtension-Bridging-Header.h
    │   ├── Audio Unit
    │   │   └── MySynthesizerExtensionAudioUnit.swift
    │   ├── DSP
    │   │   └── MySynthesizerExtensionAUProcessHelper.hpp
    │   ├── Parameters
    │   │   └── ParameterSpecBase.swift
    │   ├── UI
    │   │   ├── AudioUnitViewController.swift
    │   │   └── ObservableAUParameter.swift
    │   └── Utility
    │       ├── CrossPlatform.swift
    │       └── String+Utils.swift
    ├── DSP
    │   ├── MySynthesizerExtensionDSPKernel.hpp
    │   └── SinOscillator.h
    ├── Info.plist
    ├── Parameters
    │   ├── MySynthesizerExtensionParameterAddresses.h
    │   └── Parameters.swift
    ├── README.md
    └── UI
        ├── MySynthesizerExtensionMainView.swift
        └── ParameterSlider.swift

テンプレートの構造はXcode 14からほぼ変化がありません。開発に慣れると独自のディレクトリ構造でプロジェクトを管理したくなるかもしれませんが、元の構造から離れるほどトラブルシューティングが面倒になります。可能な限り元の構造を保つことをお勧めします。

プロジェクトを初期化したらまずは動作確認しよう

テンプレートからプロジェクトを新規作成した時点で、すでにサイン波を合成して再生するシンセサイザが実装されています。この段階で一度実機でアプリの動作確認をしてください。手順は以下のとおりです。

  1. Xcode→Product→実機のiPhoneの名前を選ぶ。
  2. Xcode→Product→Scheme→ホストアプリ(例:MySynthesizer)を選ぶ。
  3. MIDIキーボードをiPhoneに接続する。
  4. XcodeでCommand+Rキーを押して実機でアプリを実行する。
  5. アプリが起動したらMIDIキーボードの鍵盤を触ってiPhoneのスピーカーから音が鳴ることを確認する。

注意が必要なのはSchemeの設定です。通常はホストアプリ(例:MySynthesizer)とAudio Unit Extension(例:MySynthesizerExtension)の2つが表示されるはずです。

ただし、どちらか一方しか表示されない場合もあります。この挙動は不具合ではなくXcodeの設定によるものです。

その場合はXcode→Product→Scheme→Manage Schemes を選び、表示されたダイアログの中にある「Autocreate Schemes Now」ボタンを押してください。ダイアログを閉じるとSchemeにホストアプリとExtensionの2つの選択肢が表示されますので、ホストアプリの名前を選んでください。

(補足)既存のアプリにAudio Unit v3 Extensionを追加する方法

Xcodeの新規プロジェクトでAudio Unit Extensionテンプレートを選択すると、ホストアプリとAudio Unit v3 Extensionのセットでプロジェクトが作成されます。

audio Unit v3 Extensionはホストアプリと独立しているため、既存のアプリにFile→New→TargetからAudio Unit Extensionを追加することも可能です。

(補足)ホストアプリのInter-App Audio Capabilityについて

ホストアプリがAudio Unit Extensionプロセスを起動するにはInter-App AudioのCapabilityが必要になります。新規プロジェクトとしてAudio Unit Extensionを選んだ場合は最初から設定された状態でプロジェクトが生成されます。

注意が必要になるのは既存のアプリにAudio Unit Extensionターゲットを追加する場合です。Capabilityは自動的に追加されません。

Inter-App AudioのCapabilityを追加するにはプロジェクト設定でホストアプリのターゲットを選択し、Signing & CapabilitiesからInter-App Audioを追加してください。

コードの解説

お待たせしました、ここから本題です。具体的なコードの解説を進めます。

といっても、以下2つのコードに手を加えるだけでシンセサイザは実装できます。

  1. MySynthesizerExtension/DSP/SinOscillator.h
  2. MySynthesizerExtension/DSP/MySynthesizerExtensionDSPKernel.hpp

1. MySynthesizerExtension/DSP/SinOscillator.h

デジタル信号処理が実装されているコアの部分です。ピュアC++で実装されています。

以下、SinOscillator.hの内容全体を引用します。純粋なデジタル信号処理をしているだけなのでおそらく将来のAudio Unit Extensionテンプレートでも実装は変化しないものと思われます。

#pragma once

#include <numbers>
#include <cmath>

class SinOscillator {
public:
    SinOscillator(double sampleRate = 44100.0) {
        mSampleRate = sampleRate;
    }

    void setFrequency(double frequency) {
        mDeltaOmega = frequency / mSampleRate;
    }

    double process() {
        const double sample = std::sin(mOmega * (std::numbers::pi_v<double> * 2.0));
        mOmega += mDeltaOmega;

        if (mOmega >= 1.0) { mOmega -= 1.0; }
        return sample;
    }

private:
    double mOmega = { 0.0 };
    double mDeltaOmega = { 0.0 };
    double mSampleRate = { 0.0 };
};

上記の実装ではPCMの1サンプルごとにprocess()メソッドが実行されます。その都度ラジアンを更新し三角関数std::sin()の値を返すことでサイン波を合成しています。

上記のクラスをインスタンス化し、呼び出しているのが次に説明するMySynthesizerExtensionDSPKernel.hppです。

MySynthesizerExtension/DSP/MySynthesizerExtensionDSPKernel.hpp

このファイルにはMIDIイベントを受け取りつつ、音声バッファに信号を書き込む処理が実装されています。このファイルはObjective-C++ですが、Objective-C固有のシンタックスは登場しないため、C++を理解されているなら読み進められるはずです。

まずはSinOscillatorをインスタンス化している部分をみてみましょう。

void initialize(int channelCount, double inSampleRate) {
    mSampleRate = inSampleRate;
    mSinOsc = SinOscillator(inSampleRate);
}

続いてMIDIイベントをハンドリングしている箇所をみてみましょう。

void handleMIDI2VoiceMessage(const struct MIDIUniversalMessage& message) {
    const auto& note = message.channelVoice2.note;
    
    switch (message.channelVoice2.status) {
        case kMIDICVStatusNoteOff: {
            mNoteEnvelope = 0.0;
        }
            break;
            
        case kMIDICVStatusNoteOn: {
            const auto velocity = message.channelVoice2.note.velocity;
            const auto freqHertz   = MIDINoteToFrequency(note.number);

            mSinOsc = SinOscillator(mSampleRate);
            
            // Set frequency on per channel oscillator
            mSinOsc.setFrequency(freqHertz);

            // Use velocity to set amp envelope level
            mNoteEnvelope = (double)velocity / (double)std::numeric_limits<std::uint16_t>::max();
        }
            break;
            
        default:
            break;
    }
}

最後は音声バッファに信号を書き込んでいる箇所の実装をみてみましょう。

void process(std::span<float *> outputBuffers, AUEventSampleTime bufferStartTime, AUAudioFrameCount frameCount) {
    if (mBypassed) {
        // Fill the 'outputBuffers' with silence
        for (UInt32 channel = 0; channel < outputBuffers.size(); ++channel) {
            std::fill_n(outputBuffers[channel], frameCount, 0.f);
        }
        return;
    }
    
    // Use this to get Musical context info from the Plugin Host,
    // Replace nullptr with &memberVariable according to the AUHostMusicalContextBlock function signature
    if (mMusicalContextBlock) {
        mMusicalContextBlock(nullptr /* currentTempo */,
                             nullptr /* timeSignatureNumerator */,
                             nullptr /* timeSignatureDenominator */,
                             nullptr /* currentBeatPosition */,
                             nullptr /* sampleOffsetToNextBeat */,
                             nullptr /* currentMeasureDownbeatPosition */);
    }
    
    // Generate per sample dsp before assigning it to out
    for (UInt32 frameIndex = 0; frameIndex < frameCount; ++frameIndex) {
        // Do your frame by frame dsp here...
        const auto sample = mSinOsc.process() * mNoteEnvelope * mGain;

        for (UInt32 channel = 0; channel < outputBuffers.size(); ++channel) {
            outputBuffers[channel][frameIndex] = sample;
        }
    }
}

注目するべき部分は上記の3つだけです。一度initialize()が実行された後、process()メソッドが繰り返し実行されます。そして、MIDIイベントが発生した任意のタイミングでhandleMIDI2VoiceMessage()メソッドが実行されます。これが信号処理のライフサイクルになります。

記事の冒頭で紹介したSawtoothSynthesizerアプリも実装にあたって手を加えたのは上記2つのファイルだけです。プロジェクト新規作成時のテンプレートと比べてみてください。

(補足1)Audio Unit Extensionが読み書きするPCM音声バッファの形式について

process()メソッドが書き込んでいる音声バッファはfloat型の配列になっていることに気がつかれたかもしれません。Audio Unit Extensionが扱う音声信号は浮動小数で表現されます。

音声バッファが期待する値は-1.0から1.0で正規化された値です。それでは、この範囲を越えた値を書き込むと音声はクリッピングされて、音が割れてしまうのでしょうか?答えはNOです。

正規化された範囲を越えてもAVAudioEngineのメインミキサーが音量を調整してくれます。そのため記事冒頭で紹介したSawtoothSynthesizerは自前で音量調整をせず、ただ音を重ねるだけの実装をしています。

(補足2)MIDI 2.0 Universal MIDI Packetについて

Audio Unit ExtensionはMIDI 1.0とMIDI 2.0のUMP(Universal MIDI Packet)を透過的に扱います。メソッド名のhandleMIDI2VoiceMessage()にある数字の2はMIDI 2.0の2を意味します。

これに関連しているかは定かではありませんが、MINIのNote Numberとして127より大きい数値が受信できる事象を実機で確認しています。具体的にはNative InstrumentsのKOMPLETE KONTROL M32でキーのトランスポーズを行うと127より大きいNote Numberが送信できました。

MIDI 2.0の仕様ではNote Numberは引き続き7 bitで表現されるはずですが、Note On / Note Offイベントなどで127より大きい値が渡される可能性も考慮して実装するのが安全です。

ホストアプリの実装

ホストアプリの実装についてはMySynthesizer/Common/Audio/SimplePlayEngine.swiftを起点に処理の流れを追いかけると理解が深まります。

まずはAudio Unit Extensionを探し、独立したプロセスとして起動する処理をみてみましょう。

func initComponent(type: String, subType: String, manufacturer: String, completion: @escaping (Result<Bool, Error>, ViewController?) -> Void) {
    // Reset the engine to remove any configured audio units.
    reset()
    
    guard let component = AVAudioUnit.findComponent(type: type, subType: subType, manufacturer: manufacturer) else {
        fatalError("Failed to find component with type: \(type), subtype: \(subType), manufacturer: \(manufacturer))" )
    }
    
    // Instantiate the audio unit.
    AVAudioUnit.instantiate(with: component.audioComponentDescription,
                            options: AudioComponentInstantiationOptions.loadOutOfProcess) { avAudioUnit, error in
        
        guard let audioUnit = avAudioUnit, error == nil else {
            completion(.failure(error!), nil)
            return
        }
        
        self.avAudioUnit = audioUnit
        
        self.connect(avAudioUnit: audioUnit)
        
        // Load view controller and call completion handler
        audioUnit.loadAudioUnitViewController { viewController in
            completion(.success(true), viewController)
        }
    }
}

次は外部MIDI機器からの入力をホストアプリが受け取り、プロセス間通信を利用してExtensionに送信している実装をみてみましょう。

private func setupMIDI() {
    if !MIDIManager.shared.setupPort(midiProtocol: MIDIProtocolID._2_0, receiveBlock: { [weak self] eventList, _ in
        if let scheduleMIDIEventListBlock = self?.scheduleMIDIEventListBlock {
            _ = scheduleMIDIEventListBlock(AUEventSampleTimeImmediate, 0, eventList)
        }
    }) {
        fatalError("Failed to setup Core MIDI")
    }
}

上記のself?.scheduleMIDIEventListBlockはAUAudioUnitインスタンスのscheduleMIDIEventListBlockを差しています。

ホストアプリはCore MIDIのイベントを受け取ると都度コールバックを実行します。そのコールバックの中でscheduleMIDIEventListBlock()を呼び出すことでMIDIイベントをAudio Unit Extensionに中継します。

本記事ではAudio Unit Extensionの構造を説明する際にホストアプリがExtensionとプロセス間通信をすると説明しました。プロセス間通信といえばUnixソケットなどを思い浮かべるかもしれませんが、実体は隠蔽されています。どのような手段で通信しているのか私たちが知ることはできません。

scheduleMIDIEventListBlock()を呼び出せばホストアプリがAudio Unit v3 Extensionと通信してMIDIイベントを送信してくれる、という構造だけ把握していれば問題ありません。

おわりに

処理の流れの全体像さえ把握できれば実装はそれほど難しくないのですが最初は複雑さに圧倒されてどこから手をつければよいのか迷うはずです。本記事が理解の助けになれば幸いです。

(余談)App Storeレビュー中のみAudio Unit v3 Extensionが見つからない事象について

トラブルシューティングに苦戦したので、参考までに情報を共有します。結論を先に述べると、Xcode 16が原因でした。

本記事を投稿した2025年2月現在、Xcode 16でアーカイブしたアプリをApp Storeに提出するとAudio Unit v3 Extensionが正常に機能しません。この事象を回避するにはXcode 15を利用してアプリをアーカイブし、App Storeにアップロードしてください。


Audio Unit v3 Extensionを含むアプリをApp Storeに提出したところ、クラッシュするとの報告を受けました。調査の結果、AVAudioUnitComponentManager.shared().components(matching: description).firstがnilを返し、後続の処理でnilガードに引っかかりアプリが落ちていました。

この事象の奇妙なところは、macOSのシミュレーター・ローカル開発環境で実機にインストール・TestFlight経由で実機にインストールどの条件でも不具合が再現しない点です。なぜかApp Storeレビュー中にのみ事象が発生するのです。

アプリの実装やビルドの設定に不備が見あたらず途方に暮れていたところ、そもそもXcode 16に不具合があるのではと思いつきXcode 15経由のアーカイブとアップロードを試したら解消した、という流れです。

今回の件と関連があるのか不明ですが、Apple Developer Forumに同様の不具合報告を見つけました。

AUv3 recent "Failed to find compon… | Apple Developer Forums

参考資料

  1. Creating an audio unit extension | Apple Developer Documentation
  2. Core MIDI | Apple Developer Documentation
  3. AUAudioUnit | Apple Developer Documentation
1

Discussion

ログインするとコメントできます