CoreAudioのAudio Queue Servicesを使って、録音/再生する方法を記載します。
また録音した音声を波形データとして表示します。
サンプルコードは以下にアップ。
Macアプリで書いてますがUI以外は基本iOSでも使えると思います。
https://github.com/atsushijike/AudioService
- Xcode 9.4.1
- Swift 4.1
CoreAudioの概要(日本語)
https://developer.apple.com/jp/documentation/MusicAudio/Conceptual/CoreAudioOverview/Introduction/Introduction.html
Audio Queue Services Programming Guide(英語)
https://developer.apple.com/library/archive/documentation/MusicAudio/Conceptual/AudioQueueProgrammingGuide/Introduction/Introduction.html#//apple_ref/doc/uid/TP40005343
CoreAudio自体その歴史の古さもあってかMacアプリ開発時代のObj-Cの情報が多く、
今回調査しながら一旦Obj-Cで書いて動くことを確認してから、現代のプロジェクトにも組み込みやすいようswift 4.1でリライトというアプローチで進めた。
定義
オーディオキューオブジェクトとバッファを用いて録音/再生管理を行うクラス AudioService
を定義する。
各変数の概要は次の通り。
class AudioService {
// バッファ
var buffer: UnsafeMutableRawPointer
// オーディオキューオブジェクト
var audioQueueObject: AudioQueueRef?
// 再生時のパケット数
let numPacketsToRead: UInt32 = 1024
// 録音時のパケット数
let numPacketsToWrite: UInt32 = 1024
// 再生/録音時の読み出し/書き込み位置
var startingPacketCount: UInt32
// 最大パケット数。(サンプリングレート x 秒数)
var maxPacketCount: UInt32
// パケットのバイト数
let bytesPerPacket: UInt32 = 2
// 録音時間(=再生時間)
let seconds: UInt32 = 10
// オーディオストリームのフォーマット
var audioFormat: AudioStreamBasicDescription {
return AudioStreamBasicDescription(mSampleRate: 48000.0, // サンプリング周波数
mFormatID: kAudioFormatLinearPCM, // フォーマットID(リニアPCM, MP3, AAC etc)
mFormatFlags: AudioFormatFlags(kLinearPCMFormatFlagIsSignedInteger | kLinearPCMFormatFlagIsPacked), // フォーマットフラグ(エンディアン, 整数or浮動小数点数)
mBytesPerPacket: 2, // 1パケットのバイト数(データ読み書き単位)
mFramesPerPacket: 1, // 1パケットのフレーム数
mBytesPerFrame: 2, // 1フレームのバイト数
mChannelsPerFrame: 1, // 1フレームのチャンネル数
mBitsPerChannel: 16, // 1チャンネルのビット数
mReserved: 0)
}
// 書き出し/読み出し用のデータ
var data: NSData?
...
}
録音
大体の流れは、 オーディオキューオブジェクトとバッファを準備して定義したコールバックが呼ばれたらバッファにデータをコピーして書き込む。いっぱいになったら停止する。
準備
AudioQueueNewInput
でオーディオフォーマットとコールバック関数を指定して オーディオキューオブジェクト AudioQueueRef
を得る。
オーディオキューで管理するバッファを3個にして準備しておく。
録音されたデータは ここで用意している buffers
に書き込まれ、いっぱいになると指定したコールバックが呼ばれ、 変数の buffer
にmemcpyされる仕組みとなっている。
AudioQueueAllocateBuffer
でキューに確保するバッファを指示し、
AudioQueueEnqueueBuffer
でキューの最後にバッファを追加している。
private func prepareForRecord() {
var audioFormat = self.audioFormat
AudioQueueNewInput(&audioFormat,
AQAudioQueueInputCallback,
unsafeBitCast(self, to: UnsafeMutableRawPointer.self),
CFRunLoopGetCurrent(),
CFRunLoopMode.commonModes.rawValue,
0,
&audioQueueObject)
startingPacketCount = 0;
var buffers = Array<AudioQueueBufferRef?>(repeating: nil, count: 3)
let bufferByteSize: UInt32 = numPacketsToWrite * audioFormat.mBytesPerPacket
for bufferIndex in 0 ..< buffers.count {
AudioQueueAllocateBuffer(audioQueueObject!, bufferByteSize, &buffers[bufferIndex])
AudioQueueEnqueueBuffer(audioQueueObject!, buffers[bufferIndex]!, 0, nil)
}
}
録音用コールバック関数の定義。
コールバックで得たバッファを writePackets(inBuffer: )
して用意したバッファにデータをコピーして書き込む。
録音位置が最大パケット数に達したら停止する。
func AQAudioQueueInputCallback(inUserData: UnsafeMutableRawPointer?,
inAQ: AudioQueueRef,
inBuffer: AudioQueueBufferRef,
inStartTime: UnsafePointer<AudioTimeStamp>,
inNumberPacketDescriptions: UInt32,
inPacketDescs: UnsafePointer<AudioStreamPacketDescription>?) {
let audioService = unsafeBitCast(inUserData!, to:AudioService.self)
audioService.writePackets(inBuffer: inBuffer)
AudioQueueEnqueueBuffer(inAQ, inBuffer, 0, nil);
if (audioService.maxPacketCount <= audioService.startingPacketCount) {
audioService.stopRecord()
}
}
バッファを書き込む部分。
inBuffer.pointee.mAudioData
を用意したバッファに書き込んで、書き込み位置 startingPacketCount
を次の位置にインクリメントしておく。
func writePackets(inBuffer: AudioQueueBufferRef) {
var numPackets: UInt32 = (inBuffer.pointee.mAudioDataByteSize / bytesPerPacket)
if ((maxPacketCount - startingPacketCount) < numPackets) {
numPackets = (maxPacketCount - startingPacketCount)
}
if 0 < numPackets {
memcpy(buffer.advanced(by: Int(bytesPerPacket * startingPacketCount)),
inBuffer.pointee.mAudioData,
Int(bytesPerPacket * numPackets))
startingPacketCount += numPackets;:loud_sound:
}
}
開始
prepareForRecord()
で準備して、AudioQueueStart
で録音を開始する。
func startRecord() {
guard audioQueueObject == nil else { return }
prepareForRecord()
let err: OSStatus = AudioQueueStart(audioQueueObject!, nil)
print("err: \(err)")
}
停止
保存用に録音したバッファ内容を data
に格納する。
オーディオキューオブジェクトをStop, Disposeして録音を停止する。
func stopRecord() {
data = NSData(bytesNoCopy: buffer, length: Int(maxPacketCount * bytesPerPacket))
AudioQueueStop(audioQueueObject!, true)
AudioQueueDispose(audioQueueObject!, true)
audioQueueObject = nil
}
再生
大体の流れは録音と同じで、 コールバックが呼ばれたらバッファにデータを読み取り、いっぱいになったら再生位置を戻しておく。
準備
AudioQueueNewOutput
でオーディオフォーマットとコールバック関数を指定して オーディオキューオブジェクトを得る。
録音と同じように3個で確保した buffers
を用意してオーディオキューが管理するバッファに指定、追加する。
private func prepareForPlay() {
print("prepareForPlay")
var audioFormat = self.audioFormat
AudioQueueNewOutput(&audioFormat,
AQAudioQueueOutputCallback,
unsafeBitCast(self, to: UnsafeMutableRawPointer.self),
CFRunLoopGetCurrent(),
CFRunLoopMode.commonModes.rawValue,
0,
&audioQueueObject)
startingPacketCount = 0
var buffers = Array<AudioQueueBufferRef?>(repeating: nil, count: 3)
let bufferByteSize: UInt32 = numPacketsToRead * audioFormat.mBytesPerPacket
for bufferIndex in 0 ..< buffers.count {
AudioQueueAllocateBuffer(audioQueueObject!, bufferByteSize, &buffers[bufferIndex])
AQAudioQueueOutputCallback(inUserData: unsafeBitCast(self, to: UnsafeMutableRawPointer.self),
inAQ: audioQueueObject!,
inBuffer: buffers[bufferIndex]!)
}
}
再生用コールバック関数の定義。
録音とは逆に readPackets(inBuffer: )
してコールバックで得たバッファに保持しているバッファを書き込む。
再生位置が最大パケット数に達したら再生位置を最初に戻す。
func AQAudioQueueOutputCallback(inUserData: UnsafeMutableRawPointer?,
inAQ: AudioQueueRef,
inBuffer: AudioQueueBufferRef) {
let audioService = unsafeBitCast(inUserData!, to:AudioService.self)
audioService.readPackets(inBuffer: inBuffer)
AudioQueueEnqueueBuffer(inAQ, inBuffer, 0, nil);
if (audioService.maxPacketCount <= audioService.startingPacketCount) {
audioService.startingPacketCount = 0;
}
}
バッファを書き込む部分。
保持しているバッファを inBuffer.pointee.mAudioData
に書き込んで、書き込み位置 startingPacketCount
を次の位置にインクリメントしておく。
録音とは逆になっている。
func readPackets(inBuffer: AudioQueueBufferRef) {
var numPackets: UInt32 = maxPacketCount - startingPacketCount
if numPacketsToRead < numPackets {
numPackets = numPacketsToRead
}
if 0 < numPackets {
memcpy(inBuffer.pointee.mAudioData,
buffer.advanced(by: Int(bytesPerPacket * startingPacketCount)),
(Int(bytesPerPacket * numPackets)))
inBuffer.pointee.mAudioDataByteSize = (bytesPerPacket * numPackets)
inBuffer.pointee.mPacketDescriptionCount = numPackets
startingPacketCount += numPackets
}
else {
inBuffer.pointee.mAudioDataByteSize = 0;
inBuffer.pointee.mPacketDescriptionCount = 0;
}
}
開始
prepareForPlay()
で準備して、AudioQueueStart
で再生を開始する。
func play() {
guard audioQueueObject == nil else { return }
prepareForPlay()
let err: OSStatus = AudioQueueStart(audioQueueObject!, nil)
print("err: \(err)")
}
停止
オーディオキューオブジェクトをStop, Disposeして再生を停止する。
func stop() {
AudioQueueStop(audioQueueObject!, true)
AudioQueueDispose(audioQueueObject!, true)
audioQueueObject = nil
}
書き出し
録音停止時に保持した data
をそのまま書き出している。
@IBAction func exportData(_ sender: Any) {
guard let data = audioService.data else { return }
let savePanel = NSSavePanel()
savePanel.canCreateDirectories = true
savePanel.nameFieldStringValue = "sound.raw"
savePanel.begin { (result) in
if result.rawValue == NSFileHandlingPanelOKButton {
guard let url = savePanel.url else { return }
data.write(to: url, atomically: true)
}
}
}
読み出し
AudioService
の data
にセットしている。
@IBAction func importData(_ sender: Any) {
let openPanel = NSOpenPanel()
openPanel.allowsMultipleSelection = false
openPanel.canChooseDirectories = false
openPanel.canCreateDirectories = false
openPanel.canChooseFiles = true
openPanel.allowedFileTypes = ["raw"]
openPanel.begin { [weak self] (result) in
if result.rawValue == NSFileHandlingPanelOKButton {
guard let `self` = self, let url = openPanel.url, let data = NSMutableData(contentsOf: url) else { return }
self.audioService.setData(data)
}
}
}
data
をコピーして、保持している buffer
に書き込んでいる。
func setData(_ data: NSMutableData) {
self.data = data.copy() as? NSData
memcpy(buffer, data.mutableBytes, Int(maxPacketCount * bytesPerPacket))
}
波形
NSData から波形表示するビューを実装する。
縦方向に中心を0として-128 ~ 128までをパスで描画して波形を表現する。
final class WaveView: NSView {
var data: NSData? {
didSet {
needsDisplay = true
}
}
override func draw(_ dirtyRect: NSRect) {
NSColor.white.set()
NSBezierPath(rect: bounds).fill()
NSColor.darkGray.set()
NSBezierPath(rect: bounds).stroke()
guard let data = data else { return }
let bezierPath = NSBezierPath()
let length = data.length
let unit = bounds.width / CGFloat(length)
let mid = bounds.height / 2
let bytes = UnsafeBufferPointer(start: data.bytes.assumingMemoryBound(to: Int8.self), count: length)
bezierPath.move(to: NSPoint(x: 0, y: mid))
for index in 0 ..< length {
let normaliedSample = 100 * CGFloat(bytes[index]) / 128
bezierPath.line(to: NSPoint(x: unit * CGFloat(index), y: (normaliedSample * CGFloat(mid / 128)) + mid))
}
NSColor.black.set()
bezierPath.lineWidth = 1
bezierPath.stroke()
}
}
録音位置や再生位置を縦線で動かしたり、録音してリアルタイムに波形を表現していったら良いかもしれない。