Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

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

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

yumemi
みんなが知ってるあのサービス、実はゆめみが作ってます。スマホアプリ/Webサービスの企画・UX/UI設計、開発運用。Swift, Kotlin, PHP, Vue.js, React.js, Node.js, AWS等エンジニア・クリエイターの会社です。Twitterで情報配信中https://twitter.com/yumemiinc
http://www.yumemi.co.jp
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした