iOSのCore Audioを使って、波形を生成するシンセサイザーを作ってみました。あわせてエフェクターもいくつか実装しています。
その作り方を解説します。
できたもの
音のサンプルをSoundCloudにアップしています。アナログシンセのような感じが出ていると思います。
画面はこんな感じです。鍵盤ではなく、スライダーで音程を調整します。
ソースコードはこちらです。複数のサンプルがありますが、今回解説するのは「Synthesizer」になります。
https://github.com/TokyoYoshida/CoreAudioExamples
作り方の概要
Core Audioの構成要素であるAVAudioEngineを使います。波形を生成するレンダー関数を作り、これをソースノードとしてミキサーノードにつなぎ、出力ノードから出力することで、任意の波形を音声出力することができます。
作り方
本稿は「Synthesizer」の解説記事ですが、波形の作り方は「AudioEngeneGenerateWave」の方がシンプルで分かりやすいので、まずはこれをベースに解説していきます。
1. AVAudioEngineを生成する
AVAudioEngineを生成して各種の情報を取得します。
class AudioEngeneWaveGenerator {
var audioEngine: AVAudioEngine = AVAudioEngine()
var sampleRate: Float = 44100.0
var deltaTime: Float = 0
var mainMixer: AVAudioMixerNode?
var outputNode: AVAudioOutputNode?
var format: AVAudioFormat?
func initAudioEngene() {
mainMixer = audioEngine.mainMixerNode
outputNode = audioEngine.outputNode
format = outputNode!.inputFormat(forBus: 0)
sampleRate = Float(format!.sampleRate)
deltaTime = 1 / Float(sampleRate)
}
// 〜略〜
2. レンダー関数を作る
波形をレンダーする関数を作ります。ここではサイン波を生成しています。
sin関数に周波数(currentTone)と、2π(単位時間で1周する)、時間を与えて生成します。currentToneに、440.0を与えればラの音(A)になります。
# class AudioEngeneWaveGeneratorの一部
static let toneA: Float = 440.0
var time: Float = 0
lazy var sourceNode = AVAudioSourceNode { [self] (_, _, frameCount, audioBufferList) -> OSStatus in
let abl = UnsafeMutableAudioBufferListPointer(audioBufferList)
for frame in 0..<Int(frameCount) {
let sampleVal: Float = sin(AudioEngeneWaveGenerator.toneA * 2.0 * Float(Double.pi) * self.time)
self.time += self.deltaTime
for buffer in abl {
let buf: UnsafeMutableBufferPointer<Float> = UnsafeMutableBufferPointer(buffer)
buf[frame] = sampleVal
}
}
return noErr
}
3. 音を鳴らす
実際に音を鳴らします。
AVAudioEngineに、AVAudioSourceNodeやAVAudioOutputNodeをコネクトしていくことで、音の生成から出力までをルーティングすることができます。合わせて、AVAudioSourceNodeのフォーマットを指定したり、AVAudioOutputNodeのボリュームを設定します。
準備ができたら、AVAudioEngineのstartメソッドで再生開始です。
# class AudioEngeneWaveGeneratorの一部
func start() {
refData.frame = 0
let inputFormat = AVAudioFormat(commonFormat: format!.commonFormat, sampleRate: Double(sampleRate), channels: 1, interleaved: format!.isInterleaved)
audioEngine.attach(sourceNode)
audioEngine.connect(sourceNode, to: mainMixer!, format: inputFormat!)
audioEngine.connect(mainMixer!, to: outputNode!, format: nil)
mainMixer?.outputVolume = 0
do {
try audioEngine.start()
} catch {
fatalError("Coud not start engine: \(error.localizedDescription)")
}
}
これで音が鳴るようになりました。
4. 音の再生を止める
音の再生を止めるメソッドも作っておきます。
# class AudioEngeneWaveGeneratorの一部
func stop() {
audioEngine.stop()
}
5. 音を差し替えられるようにする
ここからは、サンプル「Synthesizer」の説明になります。
ここまではAudioEngeneWaveGeneratorクラスを実装していましたが、ここからはSynthesizerクラスになります。実装はほぼ同じなので、追加したところだけを説明します。
まず、音を差し替えたり、エフェクターをつなげたりできるようにします。
これは、波形の生成をする関数をprotocolで抽象化し、それぞれの部品をクラスにすることで実現できます。
今回は次のような構成で作ってみました。
protocol AudioSourceは、音程(tone)と、時間を与えるとその時点の波形を出力するメソッド(signal)を持ちます。
protocol AudioSource {
var tone: Float {get set}
func signal(time: Float) -> Float
}
そして先程説明したレンダー関数の波形生成部分は、AudioSourceに委譲するようにします。
// class Synthesizerの一部
private var audioSource: AudioSource?
lazy var sourceNode = AVAudioSourceNode { [self] (_, _, frameCount, audioBufferList) -> OSStatus in
let abl = UnsafeMutableAudioBufferListPointer(audioBufferList)
guard let oscillator = self.audioSource else {fatalError("Oscillator is nil")}
for frame in 0..<Int(frameCount) {
let sampleVal: Float = oscillator.signal(time: self.time) // この部分
self.time += self.deltaTime
for buffer in abl {
let buf: UnsafeMutableBufferPointer<Float> = UnsafeMutableBufferPointer(buffer)
buf[frame] = sampleVal
}
}
return noErr
}
6. オシレーターを作る
サイン波を発生させるオシレーターを作ります。先ほど紹介したサイン波の数式をclassでくるんでいるだけです。
updateToneメソッドの詳細は割愛しますが、音色を急に変化させてもゆっくりなめらかにcurrentToneを変化させるための処理です。これがなくても動作しますが、音色を変化させたときにプチプチとノイズが入ります。
class SinOscillator: ToneAdjuster { // ToneAdjusterはupdateToneの実体を定義するためのもので、それ以外はAudioSourceプロトコルと同じ
override func signal(time: Float) -> Float {
updateTone()
return sin(currentTone * 2.0 * Float(Double.pi) * time)
}
}
7. ミキサーを作る
ミキサーは、AudioSouceを継承しつつ、エフェクターを加えることができるプロトコルとして宣言します。
protocol Mixer: AudioSource {
func addEffector(index: Int, effector: Effector)
func removeEffector(at index: Int)
}
ミキサーの実装であるAudioMixerは、オシレータを1つ、エフェクターを複数保持しています。
class AudioMixer: Mixer {
private var oscillator: Oscillator
private var effectors: [Int:Effector] = [:]
// 〜略〜
ミキサーのsignalメソッドが、実際にレンダー関数から呼ばれるところです。
オシレーターに時間を渡して波形を生成させ、エフェクターに波形を渡し、その出力をさらに別のエフェクターに渡すということを繰り返しています。
セマフォによる排他制御をしているのは、波形の処理中に画面側でオシレーターやエフェクターを変更したときの競合を避けるためです。
// class AudioMixerの一部
let semaphore = DispatchSemaphore(value: 1)
func signal(time: Float) -> Float {
semaphore.wait()
var waveValue = oscillator.signal(time: time)
for (_,effector) in effectors {
waveValue = effector.signal(waveValue: waveValue, time: time)
}
semaphore.signal()
return waveValue
}
8. エフェクターを作る
エフェクターを作ります。これが一番楽しい作業でした。
エフェクターは、入力した波形に変化を加えて、出力します。
例えばディレイエフェクターは、入力した波形に、バッファにためた過去の波形を少し音量を下げて加えます。そしてその結果をバッファにためます。このようにすると残響効果が現れて、面白い音が作り出せます。
class DelayEffector: Effector {
var delayCount = 22_100
lazy var buffer = RingBuffer<Float>(delayCount + 1)
var index: Int = 0
func signal(waveValue: Float, time: Float) -> Float {
func enqueue(_ value: Float) {
if !buffer.enqueue(value) {
fatalError("Cannot enqueue buffer.")
}
}
if delayCount > 0 {
delayCount -= 1
enqueue(waveValue)
return waveValue
}
if let delayValue = buffer.dequeue() {
let ret = waveValue + delayValue*0.4
enqueue(ret)
return ret
}
fatalError("Cannot dequeue buffer.")
}
}
なお、各エフェクターの厳密な言葉の定義は違っているかもしれないのでご了承下さい。(ある程度のイメージは合っていると思います)
9. 画面を作る
最後に画面を作ります。鍵盤を付けるのもいいですが、アナログシンセっぽく使えるといいかなと思い、音程はスライダーにしてみました。
参考資料
Building a Synthesizer in Swift
https://betterprogramming.pub/building-a-synthesizer-in-swift-866cd15b731
最後に
iOSを使ったARやML、音声処理などの作品やサンプル、技術情報を発信しています。
作品ができたらTwitterで発信していきますのでフォローをお願いします🙏
Twitter
https://twitter.com/jugemjugemjugem
Qiitaは、iOS開発、とくにARや機械学習、グラフィックス処理、音声処理について発信しています。
https://qiita.com/TokyoYoshida
Noteでは、連載記事を書いています。
https://note.com/tokyoyoshida
Zennは機械学習が多めです。
https://zenn.dev/tokyoyoshida