UnityからiOS(Swift)のAudio機能を使う
UnityからiOS(Swift)のAudio機能を使う方法を試してみた。ちなみにUnityはまったく使ったことがない素人である…。
今回はシンプルにマイクから入力したものをスピーカー(イヤホンなど)にそのまま出力するというもの。
UnityからSwiftのコードを使う方法
ネイティブコードプラグインという方法でSwiftのコードを使えるようだ。
- SwiftでFramework化して利用する
- Assets/Plugins/iOS以下にSwiftファイルを配置する
https://docs.unity3d.com/ja/2023.2/Manual/PluginsForIOS.html
https://qiita.com/fuziki/items/955c2b35514bcfc37969
一番簡単そうなSwiftファイルを直接配置して利用する方法を試してみる。Assets/Plugins/iOS以下に置くとiOSプラットフォームのみ有効となるらしい。
SwiftでAudioを操作する処理を書く
簡単にAudio入力と出力させる処理をAudioManagerというクラスにまとめて書くことにした。ついでに音量としてデシベル(dB)を取得する処理も合わせて書く。
流れは以下のような形になる。
- AudioUnitを初期化
- マイク入力と出力を有効化
- Audioフォーマットの設定
- 入力・出力のコールバックを設定
- コールバックから入力・出力・音量を取得する処理
AudioUnitを初期化
まずは、AudioComponentDescriptionを設定する。
Audioをスタート・ストップのような操作する場合、汎用出力ユニットとして、kAudioUnitType_Outputを設定する。SubTypeには、kAudioUnitSubType_RemoteIOを設定する。これは入力と出力を使用する場合に、バス0と1でI/O処理できるようになるとのこと。あとは、ベンダー識別子として、kAudioUnitManufacturer_Appleを指定。ここらへんはお決まりの設定になるようだ。
続いて、AudioComponentFindNextを使用して、定義したオーディオコンポーネントを取得し、AudioUnitのインスタンスを作成する。
// オーディオコンポーネントの設定
var desc = AudioComponentDescription(
componentType: kAudioUnitType_Output,
componentSubType: kAudioUnitSubType_RemoteIO,
componentManufacturer: kAudioUnitManufacturer_Apple,
componentFlags: 0,
componentFlagsMask: 0
)
guard let component = AudioComponentFindNext(nil, &desc) else {
NSLog("AudioComponent not found")
return
}
var tempAudioUnit: AudioUnit?
if AudioComponentInstanceNew(component, &tempAudioUnit) != noErr {
NSLog("AudioUnit instance creation failed")
return
}
audioUnit = tempAudioUnit
マイク入力と出力を有効化
AudioUnitSetPropertyで入力(マイク)と出力(スピーカー)有効化する。
バス0が出力、バス1が入力となる。1を渡すことで、有効化されるとのこと。
var enable: UInt32 = 1
// 入力 (マイク) を有効化
AudioUnitSetProperty(
audioUnit,
kAudioOutputUnitProperty_EnableIO,
kAudioUnitScope_Input,
1, // バス1 = マイク入力
&enable,
UInt32(MemoryLayout.size(ofValue: enable)))
// 出力 (スピーカー) を有効化
AudioUnitSetProperty(
audioUnit,
kAudioOutputUnitProperty_EnableIO,
kAudioUnitScope_Output,
0, // バス0 = スピーカー出力
&enable,
UInt32(MemoryLayout.size(ofValue: enable)))
Audioフォーマットの設定
次は、入力と出力で使用するAudioフォーマットを設定する必要がある。
今回は、後々デシベルを取得する際にFloatの方が扱いやすかったので、PCM・44.1kHz・32bit・2ch (ステレオ)・Floatで設定してみた。ちなみにiPhoneのマイク入力はモノラルだと思うので、あまり意味ないが、そのまま入力を出力させる場合に楽なのでフォーマットを合わせた。
ステレオの場合、mBytesPerPacket、mBytesPerFrameを2倍にし、mChannelsPerFrameを2にする。
var streamFormat = AudioStreamBasicDescription(
mSampleRate: 44100.0,
mFormatID: kAudioFormatLinearPCM,
mFormatFlags: kAudioFormatFlagIsFloat,
mBytesPerPacket: UInt32(MemoryLayout<Float32>.size * 2),
mFramesPerPacket: 1,
mBytesPerFrame: UInt32(MemoryLayout<Float32>.size * 2),
mChannelsPerFrame: 2,
mBitsPerChannel: UInt32(MemoryLayout<Float32>.size * 8),
mReserved: 0
)
// 出力フォーマット設定
AudioUnitSetProperty(
audioUnit,
kAudioUnitProperty_StreamFormat,
kAudioUnitScope_Input,
0,
&streamFormat,
UInt32(MemoryLayout.size(ofValue: streamFormat)))
// 入力フォーマット設定
AudioUnitSetProperty(
audioUnit,
kAudioUnitProperty_StreamFormat,
kAudioUnitScope_Output,
1,
&streamFormat,
UInt32(MemoryLayout.size(ofValue: streamFormat)))
入力・出力のコールバックを設定
本来は入力コールバックを設定し、入力コールバックで入力データをバッファに貯めてから、出力コールバックで出力バッファに書き込むという流れになると思う。
今回は出力コールバックから入力データを取得して、そのまま出力バッファに書き込む方法で楽をする。なので、出力コールバックのみ設定する。inputProcにコールバックで使用するメソッドを指定する。
var outCallback = AURenderCallbackStruct(
inputProc: renderCallback,
inputProcRefCon: UnsafeMutableRawPointer(
Unmanaged.passUnretained(self).toOpaque())
)
AudioUnitSetProperty(
audioUnit,
kAudioUnitProperty_SetRenderCallback,
kAudioUnitScope_Input,
0, // 出力のバス0 (スピーカー)
&outCallback,
UInt32(MemoryLayout.size(ofValue: outCallback)))
コールバックから入力・出力する処理
まずは、マイク入力のデータを取得するためのAudioバッファを用意する。
- Sample チャンネル毎の音データ(44.1kHzなら44.1kサンプル)
- Frame 各チャンネル1サンプルをまとめたもの
- Packet フレームをまとめたもの
inNumberFramesにフレーム数が渡ってくるので、これにFloat(4バイト)xステレオなので2、8バイトを掛けたものがバッファサイズとなる。
ちなみにこれは出力用のコールバックなので、出力する際のフレーム数などが渡ってくるが、入力と出力のAudioフォーマットを合わせているので、一応これでも大丈夫っぽい。
本当は入力用のコールバックを別途用意するなりして、実際入力されたフレーム数からバッファを確保するのが良さそう。
実際の入力データは、AudioUnitRenderに確保したAudioバッファとバス1を指定して取得する。
あとは取得できた入力データを、ioData(出力用のバッファ)にコピーすることで出力完了。
private let renderCallback: AURenderCallback = {
(
inRefCon: UnsafeMutableRawPointer,
ioActionFlags: UnsafeMutablePointer<AudioUnitRenderActionFlags>,
inTimeStamp: UnsafePointer<AudioTimeStamp>,
inBusNumber: UInt32,
inNumberFrames: UInt32,
ioData: UnsafeMutablePointer<AudioBufferList>?
) -> OSStatus in
let audioUnitManager = Unmanaged<AudioManager>.fromOpaque(inRefCon)
.takeUnretainedValue()
guard let audioUnit = audioUnitManager.audioUnit else { return noErr }
// AudioBuffer
let bufferSize = Int(inNumberFrames) * MemoryLayout<Float32>.size * 2
let audioBuffer = UnsafeMutabtlePointer<Float32>.allocate(capacity: bufferSize / MemoryLayout<Float32>.size)
var bufferList = AudioBufferList(
mNumberBuffers: 1,
mBuffers: AudioBuffer(
mNumberChannels: 2,
mDataByteSize: UInt32(bufferSize),
mData: UnsafeMutableRawPointer(audioBuffer)
)
)
// マイク入力からデータを取得
let status = AudioUnitRender(
audioUnit,
ioActionFlags,
inTimeStamp,
1, // 入力のバス1 (マイク)
inNumberFrames,
&bufferList)
//NSLog("size: %d", Int(bufferList.mBuffers.mDataByteSize))
if status != noErr {
NSLog("AudioUnitRender failed:", status)
return status
}
// 出力バッファにコピー
memcpy(
ioData.pointee.mBuffers.mData, bufferList.mBuffers.mData,
Int(bufferList.mBuffers.mDataByteSize))
audioBuffer.deallocate()
}
コールバックから音量を取得する処理
ついでに音量を取得する処理も書く。音量はデシベルという単位で取得する。よく騒音として工事現場などでも何dBとかで表示されていると思う。
dBは基準値からの相対的な数値なので、0dBを基準として大きい、小さいを表したものらしい。要するに0dBが一番うるさい音であり、それからマイナス方向に-10、-20と小さい音として表現される。
しかし、工事現場などでは80dBなど0より大きい数値が使われていると思うが、これは0dBを人間が聞こえる最小値として基準にしているからだ。
最初にこの0を最大音量とした数値(マイナスにいくほど小さくなる)を取得してみる。
マイク入力から取得したバッファからチャンネル毎のRMSを取得する。RMSとは、root mean square(二乗平均平方根)の略で、ようは音の平均値を取得する。この平均値が高ければ音量が大きいということになる。
今回は入力のAudioフォーマットもステレオになっているため、左右の大きさを計算しているが、実際はモノラル入力なので左右とも同じ値となるはず。
デシベルへの変換は、20 * log10(rms)となるが、かなり数学的な話になるので私も完全には理解ができない。音の強さなど範囲が広いものを、ある基準との比を対数(log)で表した単位とのこと。RMSや音圧の比を表現する際は20を使い、スピーカーなど出力する際の電力費の場合は、10として計算するとのこと。
var leftRMS: Float32 = 0.0
var rightRMS: Float32 = 0.0
let data = bufferList.mBuffers.mData!.assumingMemoryBound(to: Float32.self)
for frame in 0..<Int(inNumberFrames) {
// 左右のチャンネルを個別に処理
let leftValue = data[frame * 2]
let rightValue = data[frame * 2 + 1]
leftRMS += leftValue * leftValue
rightRMS += rightValue * rightValue
}
leftRMS = sqrt(leftRMS / Float32(inNumberFrames))
rightRMS = sqrt(rightRMS / Float32(inNumberFrames))
// RMSをデシベルに変換
let leftdB = 20 * log10(leftRMS)
let rightdB = 20 * log10(rightRMS)
これでとりあえず、デシベルの取得はできた。ただ、0が最大音量なのは分かるが、最小音量が不明なため、音の大きさを表示するには、少し使いづらい。
色々iPhoneのマイク入力を試して見ると、-80〜-90dBくらいが最小音量になりそうだと分かった。なのでこの最小値を基準として、0〜140dBとなるように変換するメソッドを作成する。
static func dbfsToSPL(_ dbfs: Float32) -> Float32 {
let silentDbfs: Float32 = -80.0 // 無音判定しきい値(dBFS)
let loudestSPL: Float32 = 140.0 // 最大音
let silentSPL: Float32 = 0.0 // 無音判定しきい値=0dB
let clamped = max(min(dbfs, 0), silentDbfs) // dBFS範囲を制限
return ((clamped + abs(silentDbfs)) / abs(silentDbfs)) * (loudestSPL - silentSPL) + silentSPL
}
UnityからSwiftのメソッドを呼び出す
ようやくSwift側の実装が終わったので、Unityから呼び出すための実装を追加する。
@_cdecl属性を使用することで、C言語から呼び出せる関数として定義する。こうすることで、Unity(C#)からも呼び出せるようになる。
@_cdecl("audioStart")
public func audioStart() {
AudioManager.shared.startAudioUnit()
}
@_cdecl("audioStop")
public func audioStop() {
AudioManager.shared.stopAudioUnit()
}
あとはUnityから下記のような形でこの関数を呼び出すだけ。
public class ButtonScript : MonoBehaviour
{
[DllImport("__Internal", EntryPoint = "audioStart")]
static extern void iOSAudioStart();
[DllImport("__Internal", EntryPoint = "audioStop")]
static extern void iOSAudioStop();
void Update()
{
}
public void OnStart()
{
Debug.Log("Button START Clicked");
#if !UNITY_EDITOR && UNITY_IOS
iOSAudioStart();
#endif
}
public void OnStop()
{
Debug.Log("Button STOP Clicked");
#if !UNITY_EDITOR && UNITY_IOS
iOSAudioStop();
#endif
}
}
前にFFmpegを使っていたときにオーディオやビデオのエンコード・デコード周りを触っていたが、だいぶ忘れていたので、いい復習になった気がする。
ちなみにiPhoneのシミューレーターでマイク入力が出来たり、違う環境だと出来なかったり謎の現象が起きてだいぶハマった。色々試したが解決策は分からず、実機で動かすのが一番確実かな…。
https://github.com/pontago/unity-audio-example
参考にさせていただいたサイト