Mac
CoreAudio
AudioToolbox
Swift

[macOS][Swift4.1] Audio Queue Servicesを使って録音/再生、波形表示する。

CoreAudioのAudio Queue Servicesを使って、録音/再生する方法を記載します。

また録音した音声を波形データとして表示します。

app.png

サンプルコードは以下にアップ。

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 を定義する。

各変数の概要は次の通り。


AudioService.swift

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?

...

}



:microphone2: 録音

大体の流れは、 オーディオキューオブジェクトとバッファを準備して定義したコールバックが呼ばれたらバッファにデータをコピーして書き込む。いっぱいになったら停止する。


準備

AudioQueueNewInput でオーディオフォーマットとコールバック関数を指定して オーディオキューオブジェクト AudioQueueRef を得る。

オーディオキューで管理するバッファを3個にして準備しておく。

録音されたデータは ここで用意している buffers に書き込まれ、いっぱいになると指定したコールバックが呼ばれ、 変数の buffer にmemcpyされる仕組みとなっている。

AudioQueueAllocateBuffer でキューに確保するバッファを指示し、

AudioQueueEnqueueBuffer でキューの最後にバッファを追加している。


AudioService.swift

    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: ) して用意したバッファにデータをコピーして書き込む。

録音位置が最大パケット数に達したら停止する。


AudioService.swift

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 を次の位置にインクリメントしておく。


AudioService.swift

    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 で録音を開始する。


AudioService.swift

    func startRecord() {

guard audioQueueObject == nil else { return }
prepareForRecord()
let err: OSStatus = AudioQueueStart(audioQueueObject!, nil)
print("err: \(err)")
}


停止

保存用に録音したバッファ内容を data に格納する。

オーディオキューオブジェクトをStop, Disposeして録音を停止する。


AudioService.swift

    func stopRecord() {

data = NSData(bytesNoCopy: buffer, length: Int(maxPacketCount * bytesPerPacket))
AudioQueueStop(audioQueueObject!, true)
AudioQueueDispose(audioQueueObject!, true)
audioQueueObject = nil
}


:loud_sound: 再生

大体の流れは録音と同じで、 コールバックが呼ばれたらバッファにデータを読み取り、いっぱいになったら再生位置を戻しておく。


準備

AudioQueueNewOutput でオーディオフォーマットとコールバック関数を指定して オーディオキューオブジェクトを得る。

録音と同じように3個で確保した buffers を用意してオーディオキューが管理するバッファに指定、追加する。


AudioService.swift

    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: ) してコールバックで得たバッファに保持しているバッファを書き込む。

再生位置が最大パケット数に達したら再生位置を最初に戻す。


AudioService.swift

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 を次の位置にインクリメントしておく。

録音とは逆になっている。


AudioService.swift

    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 で再生を開始する。


AudioService.swift

    func play() {

guard audioQueueObject == nil else { return }
prepareForPlay()
let err: OSStatus = AudioQueueStart(audioQueueObject!, nil)
print("err: \(err)")
}


停止

オーディオキューオブジェクトをStop, Disposeして再生を停止する。


AudioService.swift

    func stop() {

AudioQueueStop(audioQueueObject!, true)
AudioQueueDispose(audioQueueObject!, true)
audioQueueObject = nil
}


書き出し

録音停止時に保持した data をそのまま書き出している。


AppDelegate.swift

    @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)
}
}
}


読み出し

AudioServicedata にセットしている。


AudioService.swift

    @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 に書き込んでいる。


AudioService.swift

    func setData(_ data: NSMutableData) {

self.data = data.copy() as? NSData
memcpy(buffer, data.mutableBytes, Int(maxPacketCount * bytesPerPacket))
}


:wavy_dash: 波形

NSData から波形表示するビューを実装する。

縦方向に中心を0として-128 ~ 128までをパスで描画して波形を表現する。


WaveView.swift

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()
}
}


録音位置や再生位置を縦線で動かしたり、録音してリアルタイムに波形を表現していったら良いかもしれない。