(iOS)Audio Unit v3 Extensionでシンセサイザを作ろう
はじめに
Audio Unit v3 Extensionでシンセサイザを作るのは簡単です。Xcodeを開いてFile→New→ProjectからAudio Unit Extensionテンプレートを選ぶだけです。あとはテンプレートの実装を参考に各自で調査しながら実装してください。
……そうですね、この説明だけで実装できるなら苦労はしません。おそらく、本記事にたどりついた方は何から着手すればよいか情報が不足している状況かと思います。
本記事ではノコギリ波の音色を合成するシンセサイザアプリ、SawtoothSynthesizer
を作成します。USB MIDIキーボードからの入力またはアプリ画面に配置されたボタンのタッチをトリガーにして音声を再生するアプリです。
動作環境
本記事で説明する内容は以下の環境で動作確認をしました。
- 開発環境: 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コードの読み書きはしませんが、以下のドキュメントに目を通すことをお勧めします。
(補足)Xcode 16のAudio Unit Extensionテンプレートについて
Xcode 16で新規プロジェクトを作成する場合、プラットフォームとしてiOSを選ぶとテンプレート一覧にAudio Unit Extensionが表示されません。Audio Unit Extensionはマルチプラットフォームを選択した場合のみ表示されます。
なお、マルチプラットフォームのAudio Unit ExtensionをiPhone実機で実行しようとすると「Failed to verify code signature」エラーが発生してインストールできない事象を確認しています。私個人のXcode設定に不備がありそうですが、現在も調査中です。もし同じ事象が発生する場合はコメントなどで報告いただけると助かります。
サンプルアプリ
本記事で実装するシンセサイザアプリの完成版を以下のGitHubリポジトリで公開しています。対象のバージョンはXcode 14以上、iOS 15以上です。
Audio Unit Extensionの構造
それではシンセサイザアプリの開発をはじめましょう。まずはiOSにおけるAudio Unit Extensionの構造について全体像を説明します。
Audio Unit Extensionは独立したプロセスとして実行される
Audio Unit Extensionは通常のアプリとは異なり、独立したプロセスとして実行されます。以降、Audio Unit Extensionの実行を要求する側のアプリをホストアプリと呼びます。
Xcodeの新規プロジェクトでAudio Unit Extensionを選択すると、ホストアプリとAudio Unit Extensionのセットでプロジェクトが作成されます。audio Unit Extensionはホストアプリと独立しているため、既存のアプリにFile→New→TargetからAudio Unit Extensionを追加することも可能です。
MIDIの入出力
ホストアプリとAudio Unit Extensionはプロセス間通信を利用してMIDIの入出力を行います。MIDI入力の流れは外部MIDI機器→ホストアプリ→Audio Unit Extensionです。出力の場合は流れが逆になります。
従って、Audio Unit Extensionが外部のMIDI機器と直接通信することはできません。入出力はホストアプリを経由して行うことになります。
また、信号の流れからご想像のとおり、ホストアプリがMIDIのイベントを生成しても構いません。楽器アプリが外部MIDI機器を接続しなくても、画面上のピアノロールをタッチすると音声が流れるのはこれが理由です。
音声信号の合成と再生
ホストアプリとAudio Unit Extensionは音声信号のバッファを共有します。ホストアプリは共有されたバッファにAVAudioEngineのAVAudioUnitを経由してアクセスします。
従って、Audio Unit Extensionが直接iPhoneのハードウェアを制御してスピーカーから音声を再生するといったことはできません。MIDIの入出力と同様に外部のハードウェアを直接操作することはできないのです。
Audio Unit Extensionは音声信号のバッファを読み書きするだけです。その音声信号を再生するのはホストアプリの役割になります。
新規プロジェクトの作り方
具体的な実装の説明に進む前に、プロジェクトの作り方について説明します。今回は新規iOSプロジェクトとしてAudio Unit Extensionテンプレートを選択し、プロジェクト名「MySynthesizer」として開発を進める前提で説明します。
新規プロジェクト作成時の入力項目について
Audio Unit Extensionプロジェクトを新規作成する場合、Organization Name / Subtype / Manufacturerの入力が求められます。それぞれの値はabcd
やdemo
など自由に設定して構いません。
また、Audio Unit TypeについてはInstrumentを選択してください。Instrumentを選ぶとMIDIイベントを受け取り音声信号を出力するAudio Unit Extensionとしてテンプレートが作成されます。
すぐ後に説明しますがAudio Unit Type / Subtype / Manufacturerの値の組み合わせをキーにしてホストアプリはAudio Unit Extensionを探します。その後、キーに一致するAudio Unit Extensionが見つかると独立したプロセスとしてExtensionを起動します。
プロジェクトの構造
Xcodeで新規プロジェクトとしてAudio Unit Extensionを選択すると、以下のようなディレクトリ構造で初期化されます。
.
├── AU01
│ ├── AU01.entitlements
│ ├── AU01App.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
└── AU01Extension
├── Common
│ ├── AU01Extension-Bridging-Header.h
│ ├── Audio Unit
│ │ └── AU01ExtensionAudioUnit.swift
│ ├── DSP
│ │ └── AU01ExtensionAUProcessHelper.hpp
│ ├── Parameters
│ │ └── ParameterSpecBase.swift
│ ├── UI
│ │ ├── AudioUnitViewController.swift
│ │ └── ObservableAUParameter.swift
│ └── Utility
│ ├── CrossPlatform.swift
│ └── String+Utils.swift
├── DSP
│ ├── AU01ExtensionDSPKernel.hpp
│ └── SinOscillator.h
├── Info.plist
├── Parameters
│ ├── AU01ExtensionParameterAddresses.h
│ └── Parameters.swift
├── README.md
└── UI
├── AU01ExtensionMainView.swift
└── ParameterSlider.swift
テンプレートの構造はXcode 14から同じです。開発に慣れると独自のディレクトリ構造でプロジェクトを管理したくなるかもしれませんが、元の構造から離れるほどトラブルシューティングが面倒になります。可能な限り元の構造を保つことをお勧めします。
プロジェクトを初期化したらまずは動作確認
Audio Unit Extensionプロジェクトを新規作成した時点で、すでにサイン波を合成して再生するシンセサイザが実装されています。この段階で一度実機でアプリの動作確認をしてください。手順は以下のとおりです。
- Xcode→Product→実機のiPhoneの名前を選ぶ。
- Xcode→Product→Scheme→ホストアプリ(例:MySynthesizer)を選ぶ。
- MIDIキーボードをiPhoneに接続する。
- Xcodeで
Command+R
キーを押して実機でアプリを実行する。 - アプリが起動したらMIDIキーボードの鍵盤を触ってiPhoneのスピーカーから音が鳴ることを確認する。
注意が必要なのはSchemeの設定です。通常はホストアプリ(例:MySynthesizer)とAudio Unit Extension(例:MySynthesizerExtension)の2つが表示されるはずです。
ただし、どちらか一方しか表示されない場合もあります。この挙動は不具合ではなくXcodeの設定によるものです。
その場合はXcode→Product→Scheme→Manage Schemes を選び、表示されたダイアログの中にある「Autocreate Schemes Now」ボタンを押してください。ダイアログを閉じるとSchemeにホストアプリとExtensionの2つの選択肢が表示されますので、ホストアプリの名前を選んでください。
(補足)ホストアプリの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つの実装に手を加えるだけでシンセサイザは実装できます。
- MySynthesizerExtension/DSP/SinOscillator.h
- MySynthesizerExtension/DSP/MySynthesizerExtensionDSPKernel.hpp
1. MySynthesizerExtension/DSP/SinOscillator.h
デジタル信号処理が実装されているコアの部分です。ピュアC++で実装されています。
以下、SinOscillator.hの内容全体を引用します。純粋なデジタル信号処理をしているだけなのでおそらく将来のXcodeでも実装は変化しないものと思われます。
#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つのファイルだけです。Xcodeが生成したテンプレートと見比べてみてください。
(補足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()
を呼び出せばホストアプリがExtensionと通信してMIDIイベントをExtensionに送信してくれる、という構造だけ把握していれば問題ありません。
おわりに
処理の流れの全体像さえ把握できれば実装はそれほど難しくないのですが最初は圧倒されてどこから手をつければよいのか混乱するはずです。本記事が理解の助けになったら幸いです。
Discussion