iPhone + Bluetoothイヤホンを使って「OK, Google」のようなHot Word Detectorを実装した。Raspberry Piの時も使った「Snowboy」とiOSの「AVAudioEngin」を使って割と簡単に実装できる。
Xcode設定
- ここから必要なファイル群をダウンロード
common.res
とjarvis.pmdl
をXcodeのBuild Phases->Copy Bundle Resources からBundle Resourcesに追加する- 次に
libsnowboy-detect.a
をLinked Frameworks and Libraries に追加する - 同じくLinked Frameworks and Librarieに
Accelerate.framwork
を追加する - Wrapperクラス(
nowboy-detect.h
、SnowboyWrapper.h
、SnowboyWrapper.mm
)をプロジェクトに追加する。 - 追加時に、Bridging-Headerの作成を確認されるので、存在していない場合は作成する(ファイル名は、環境によって異なるので要確認)。作成したファイルに、
#import "SnowboyWrapper.h"
を記述する - XcodeのBuild SettingsからEnable Bitcode を No に変更する
- Capabilitiesから、Background Modes で「Audio, AirPlay, and Picture in Picture」にチェックを入れる
- Info.plistに「Privacy – Microphone Usage Description」を追加する
Hotword Detectorの実装
ここまで準備ができれば残りは簡単。まずは以下のようにホットワードの認識モデル(.pmdl
もしくは.umdl
)をインポートする。今回は「Jarvis」を使っているが、「Alexa」や「OK, Google」なども使うことができる。他のモデルを作りたい場合は、kitt.aiのサイトからモデルを作成できる。
Bluetoothイヤホンを使う場合は、AVAudioEngineのsetCategoryで、オプションとして.playAndRecord
と[.allowBluetoothA2DP, .allowAirPlay, .allowBluetooth]
を設定する。さらに定期的に音声データを処理する為には-installTap(onBus:bufferSize:format:block:)
をinputNode.installTap
に追加することで、処理を挟むことができる。定期的に呼ばれるブロックには、bufferSizeで指定したサイズ分のデータがAVAudioPCMBufferに渡されるので、それを使ってホットワードの検知を行う。
[追記]
スマートフォンのマイクを使う場合、Hotword Detectorには16000Hzのオーディオデータを渡す必要がある為、データフォーマットを変換しなければならない。Bluetoothマイクの場合は、Inputが16000Hzに設定されているので、installTapのブロック内でも音声データを16000Hzで取得できる。しかし、スマートフォンマイクInputは44100Hzなので、ホットワードがうまく検出されない。そこで、installTapのブロック内で、44100Hzから16000Hzへのダウンサンプリングの処理を行う必要がある。
protocol HotwordDelegate { func didHotwordDetect() } class MyHWDetector: NSObject { private var audioEngine = AVAudioEngine() let RESOURCE = Bundle.main.path(forResource: "common", ofType: "res") let MODEL = Bundle.main.path(forResource: "jarvis", ofType: "pmdl") var wrapper:SnowboyWrapper public var delegate:HotwordDelegate? override init() { wrapper = SnowboyWrapper(resources: RESOURCE, modelStr: MODEL) wrapper.setSensitivity("0.5") wrapper.setAudioGain(1.0) super.init() } deinit{ self.audioEngine.inputNode.removeTap(onBus: 0) self.audioEngine.reset() } public func startSession() throws { // Reset the audio engine self.audioEngine.inputNode.removeTap(onBus: 0) self.audioEngine.reset() // Configure the audio session for the app. let audioSession = AVAudioSession.sharedInstance() try audioSession.setCategory(.playAndRecord, mode: .default, options: [.allowBluetoothA2DP, .allowAirPlay, .allowBluetooth]) try audioSession.setActive(true, options: .notifyOthersOnDeactivation) let inputNode = audioEngine.inputNode let inputFormat = inputNode.inputFormat(forBus: 0) // <1 ch, 16000 Hz, Float32> let hwdFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: 16000, channels: 1, interleaved: true)! inputNode.installTap(onBus: 0, bufferSize: 16384, format: inputFormat) { (buffer: AVAudioPCMBuffer, when: AVAudioTime) in var convertedBuffer:AVAudioPCMBuffer? = buffer // Convert audio format from 44100Hz to 16000Hz for Hotword Detection // https://medium.com/@prianka.kariat/changing-the-format-of-ios-avaudioengine-mic-input-c183459cab63 if buffer.format != hwdFormat { if let converter = AVAudioConverter(from: inputFormat, to: hwdFormat) { convertedBuffer = AVAudioPCMBuffer(pcmFormat: hwdFormat, frameCapacity: AVAudioFrameCount( hwdFormat.sampleRate * 0.4)) let inputBlock : AVAudioConverterInputBlock = { (inNumPackets, outStatus) -> AVAudioBuffer? in outStatus.pointee = AVAudioConverterInputStatus.haveData let audioBuffer : AVAudioBuffer = buffer return audioBuffer } var error : NSError? if let uwConvertedBuffer = convertedBuffer { converter.convert(to: uwConvertedBuffer, error: &error, withInputFrom: inputBlock) } } } if let newbuffer = convertedBuffer{ // Detect the hotword from audio buffer let array = Array(UnsafeBufferPointer(start: newbuffer.floatChannelData?[0], count:Int(newbuffer.frameLength))) let result = self.wrapper.runDetection(array, length: Int32(newbuffer.frameLength)) /// 1 = detected, 0 = other voice or noise, -2 = no voice and noise if result == 1 { self.delegate?.didHotwordDetect() } } } audioEngine.prepare() try audioEngine.start() } public func stopSession(){ self.audioEngine.stop() self.audioEngine.disconnectNodeOutput(audioEngine.inputNode) self.audioEngine.inputNode.removeTap(onBus: 0) self.audioEngine.reset() } }
Hotword Detectorの利用
MyHWDetectorを初期化して、-startSession
でHotword Detectionを開始する。
import AVFoundation class AClass:NSObject, HotwordDelegate { let detector = MyHWDetector() func init(){ do { detector.delegate = self detector.startSession() } catch { print("Error") } } func didHotwordDetect(){ print(#function) } }
あとはマイクに向かって、「jarvis」と言うと、-didHotwordDetect
が呼ばれる。
収集音の同時記録
同時に生の音声を保存したい場合は、AVAudioFileにbufferを書き込む事で記録できる。例えば以下のコードを-installTap(onBus:bufferSize:format:block:)
のブロック内で呼び出すことで、音声を保存できる。
let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) let docsDirect = paths[0] let audioUrl = docsDirect.appendingPathComponent("sample.wav") do{ let outputFormat = audioEngine.inputNode.outputFormat(forBus: 0) let audioFile = try AVAudioFile(forWriting: audioUrl, settings: outputFormat.settings, commonFormat: outputFormat.commonFormat, interleaved: false) try audioFile?.write(from: buffer) }catch{ print("Error: The target audio file does not exit.") }
同様の方法で、iOSのSpeechライブラリ(Siri)を使って、ライブ音声認識も実現できる。GitHubのソースコードはこちら。