Swift 音频 DIY ,Audio Queue Services 搞缓冲,AVAudioEngine 加声效

3,914 阅读11分钟

播放网络音频,可以先下载好,得到音频文件,简单了

使用 AVAudioPlayer 播放,完

苹果封装下,AVAudioPlayer 处理本地文件,方便

直接拿到一个文件地址 url,播放

简单机械的理解:

便于音频的传输,一般使用音频压缩文件,mp3 等。文件压的体积小,好传输

声卡是播放 PCM 缓冲的

苹果帮开发把压缩格式,转换为未压缩的原始文件 PCM,

还帮开发做播放音频的资源调度,从 PCM 文件中拿出一段段的缓冲buffer,交给声卡消费掉

( 实际不会分两步,过程当然是并行的 )

现在手动

本文介绍,直接搞音频流媒体

接收到网络上的音频数据包,就去播放。


68747470733a2f2f63646e2e666173746c6561726e65722e6d656469612f73747265616d65722d6f766572766965772d6469616772616d2e737667.png

分四步:

1,网络的音频文件 >> 下载到本地的音频 data

下载音频文件的二进制数据

URLSession 的 task, 去获取网络文件

拿到一个数据包 Data,就处理一个

本例子,一个数据包 Data,对应一个音频包 packet, 对应一个音频缓冲 buffer

这一步,比较容易,

建个 URLSessionDataTask ,去下载

要做的,都在网络代理方法里


extension Downloader: URLSessionDataDelegate {
// 开始下载,拿到文件的总体积
   public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
       totalBytesCount = response.expectedContentLength
       completionHandler(.allow)
   }

// 接收数据
   public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
       // 更新,下载到本地的数据总量
       totalBytesReceived += Int64(data.count)
       // 算进度
       progress = Float(totalBytesReceived) / Float(totalBytesCount)
       // data 交给代理,去解析为音频数据包
       delegate?.download(self, didReceiveData: data, progress: progress)
   }
   
   // 下载完成了
   public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
       state = .completed
       delegate?.download(self, completedWithError: error)
   }
}

音频基础了解先:

音频文件,分为封装格式(文件格式),和编码格式

音频数据的三个层级,buffer, packet, frame

数据缓冲 buffer , 装音频包 packet,

音频包 packet,装音频帧 frame

音频按编码格式,一般分为可变码率 ,和固定码率

固定码率 CBR, 平均采样,对应原始文件,pcm ( 未压缩文件 )

可变码率 VBR,对应压缩文件,例如: mp3

Core Audio 支持 VBR,一般通过可变帧率格式 VFR

VFR 是指:每个包 packet 的体积相等, 包 packet 里面的帧 frame 的数量不一, 帧 frame 含有的音频数据有大有小

Core Audio 中数据描述

固定码率用 ASBD 描述,AudioStreamBasicDescription

ASBD 的描述, 就是指一些配置信息,包含通道数、采样率、位深...

可变码率中 VFR,用 ASPD 描述,AudioStreamPacketDescription

压缩音频数据中 VFR,对应 ASPD

每一个包 Packet,都有其 ASPD

ASPD 里面有,包 packet 的位置信息 mStartOffset,包 packet 的帧 frame 的个数,mVariableFramesInPacket


68747470733a2f2f63646e2e666173746c6561726e65722e6d656469612f71756575652d73657276696365732d6469616772616d2e737667.png

2,音频 data >> 音频包 Packet

拿 Audio Queue Services ,处理上一步获取的音频二进制数据 data,解析为音频数据包 packet

2.1 建立音频的处理通道, 注册解析回调方法

public init() throws {
        let context = unsafeBitCast(self, to: UnsafeMutableRawPointer.self)
        // 创建一个活跃的音频文件流解析器,创建解析器 ID
        guard AudioFileStreamOpen(context, ParserPropertyChangeCallback, ParserPacketCallback, kAudioFileMP3Type, &streamID) == noErr else {
            throw ParserError.streamCouldNotOpen
        }
    }

2.2 传递数据进来,开始解析

    public func parse(data: Data) throws {
        let streamID = self.streamID!
        let count = data.count
        _ = try data.withUnsafeBytes({ (rawBufferPointer) in
            let bufferPointer = rawBufferPointer.bindMemory(to: UInt8.self)
            if let address = bufferPointer.baseAddress{
                // 把音频数据,传给解析器
                //  streamID,  指定解析器
                let result = AudioFileStreamParseBytes(streamID, UInt32(count), address, [])
                guard result == noErr else {
                    throw ParserError.failedToParseBytes(result)
                }
            }
        })
    }

2.3 音频信息解析先

func ParserPropertyChangeCallback(_ context: UnsafeMutableRawPointer, _ streamID: AudioFileStreamID, _ propertyID: AudioFileStreamPropertyID, _ flags: UnsafeMutablePointer<AudioFileStreamPropertyFlags>) {
    let parser = Unmanaged<Parser>.fromOpaque(context).takeUnretainedValue()
    // 关心什么信息,取什么
    switch propertyID {
    case kAudioFileStreamProperty_DataFormat:
        // 拿数据格式
        var format = AudioStreamBasicDescription()
        GetPropertyValue(&format, streamID, propertyID)
        parser.dataFormat = AVAudioFormat(streamDescription: &format)

    case kAudioFileStreamProperty_AudioDataPacketCount:
         // 音频流文件,分离出来的音频数据中,的包 packet 个数
        GetPropertyValue(&parser.packetCount, streamID, propertyID)

    default:
         () 
    }
}

// 套路就是,先拿内存大小 propSize, 再拿关心的属性的值 value
func GetPropertyValue<T>(_ value: inout T, _ streamID: AudioFileStreamID, _ propertyID: AudioFileStreamPropertyID) {
    var propSize: UInt32 = 0
    guard AudioFileStreamGetPropertyInfo(streamID, propertyID, &propSize, nil) == noErr else {
        return
    }
    guard AudioFileStreamGetProperty(streamID, propertyID, &propSize, &value) == noErr else {
        return
    }
}

2.4 解析回调,处理数据

func ParserPacketCallback(_ context: UnsafeMutableRawPointer, _ byteCount: UInt32, _ packetCount: UInt32, _ data: UnsafeRawPointer, _ packetDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>) {

    // 拿回了 self ( parser )
    let parser = Unmanaged<Parser>.fromOpaque(context).takeUnretainedValue()
    let packetDescriptionsOrNil: UnsafeMutablePointer<AudioStreamPacketDescription>? = packetDescriptions
    // ASPD 存在,就是压缩的音频包
    // 未压缩的 pcm, 使用 ASBD
    let isCompressed = packetDescriptionsOrNil != nil
    guard let dataFormat = parser.dataFormat else {
        return
    }
    
    // 拿到了数据,遍历,
    // 存储进去 parser.packets, 也就是 self.packets
    if isCompressed {
        for i in 0 ..< Int(packetCount) {
            // 压缩音频数据,每一个包对应一个 ASPD, 逐个计算
            let packetDescription = packetDescriptions[i]
            let packetStart = Int(packetDescription.mStartOffset)
            let packetSize = Int(packetDescription.mDataByteSize)
            let packetData = Data(bytes: data.advanced(by: packetStart), count: packetSize)
            parser.packets.append((packetData, packetDescription))
        }
    } else {
         // 原始音频数据 pcm,文件统一配置,计算比较简单
        let format = dataFormat.streamDescription.pointee
        let bytesPerPacket = Int(format.mBytesPerPacket)
        for i in 0 ..< Int(packetCount) {
            let packetStart = i * bytesPerPacket
            let packetSize = bytesPerPacket
            let packetData = Data(bytes: data.advanced(by: packetStart), count: packetSize)
            parser.packets.append((packetData, nil))
        }
    }
}

3,音频包 packet >> 音频缓冲 buffer

public required init(parser: Parsing, readFormat: AVAudioFormat) throws {
        // 从之前负责解析的,拿音频数据
        self.parser = parser
        
        guard let dataFormat = parser.dataFormat else {
            throw ReaderError.parserMissingDataFormat
        }

        let sourceFormat = dataFormat.streamDescription
        let commonFormat = readFormat.streamDescription
        // 创建音频格式转换器 converter
        // 通过指定输入格式,和输出格式
        // 输入格式是上一步解析出来的,从 paser 里面拿
        // 输出格式,开发指定的
        let result = AudioConverterNew(sourceFormat, commonFormat, &converter)
        guard result == noErr else {
            throw ReaderError.unableToCreateConverter(result)
        }
        self.readFormat = readFormat
    }
    

开发指定的输出格式

public var readFormat: AVAudioFormat {
        return AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: 44100, channels: 2, interleaved: false)!
    }

// 位深,采用 Float32
// 采样率 44100 Hz, 标准 CD 音质
// 分左右声道

上一步解析出音频包 packet,读取音频缓冲 buffer

    
    public func read(_ frames: AVAudioFrameCount) throws -> AVAudioPCMBuffer {
        let framesPerPacket = readFormat.streamDescription.pointee.mFramesPerPacket
        var packets = frames / framesPerPacket
        
       // 创建空白的、指定格式和容量的,音频缓冲 AVAudioPCMBuffer
        guard let buffer = AVAudioPCMBuffer(pcmFormat: readFormat, frameCapacity: frames) else {
            throw ReaderError.failedToCreatePCMBuffer
        }
        buffer.frameLength = frames
        
        // 把解析出的音频包 packet, 转换成 AVAudioPCMBuffer,这样 AVAudioEngine 播放
        try queue.sync {
            let context = unsafeBitCast(self, to: UnsafeMutableRawPointer.self)
            // 设置好的转换器 converter,使用回调方法 ReaderConverterCallback,填充创建的 buffer 中的数据 buffer.mutableAudioBufferList 
            let status = AudioConverterFillComplexBuffer(converter!, ReaderConverterCallback, context, &packets, buffer.mutableAudioBufferList, nil)
            guard status == noErr else {
                switch status {
                case ReaderMissingSourceFormatError:
                    throw ReaderError.parserMissingDataFormat
                case ReaderReachedEndOfDataError:
                    throw ReaderError.reachedEndOfFile
                case ReaderNotEnoughDataError:
                    throw ReaderError.notEnoughData
                default:
                    throw ReaderError.converterFailed(status)
                }
            }
        }
        return buffer
    }

  • AudioConverterFillComplexBuffer 的使用姿势:

AudioConverterFillComplexBuffer(格式转换器,回调函数,自定义参数指针,包的个数指针,接收转换后数据的指针,接收 ASPD 的指针)

AudioConverterFillComplexBuffer(converter!, ReaderConverterCallback, context, &packets, buffer.mutableAudioBufferList, nil)
  • AudioConverterFillComplexBuffer 的回调函数 ReaderConverterCallback, 的使用姿势:

回调函数(格式转换器, 包的个数指针,接收转换后数据的指针, 接收 ASPD 的指针, 自定义参数指针 )

可看出,传递给 AudioConverterFillComplexBuffer 的 6 个参数,

除了其回调参数本身,其他 5 个参数,其回调函数都有用到


转换 buffer 的回调函数,之前创建了空白的音频缓冲 buffer,现往 buffer 里面,填数据

func ReaderConverterCallback(_ converter: AudioConverterRef,
                             _ packetCount: UnsafeMutablePointer<UInt32>,
                             _ ioData: UnsafeMutablePointer<AudioBufferList>,
                             _ outPacketDescriptions: UnsafeMutablePointer<UnsafeMutablePointer<AudioStreamPacketDescription>?>?,
                             _ context: UnsafeMutableRawPointer?) -> OSStatus {

    // 还原出 self ( reader )
    let reader = Unmanaged<Reader>.fromOpaque(context!).takeUnretainedValue()
    
    // 确保输入格式可用
    guard let sourceFormat = reader.parser.dataFormat else {
        return ReaderMissingSourceFormatError
    }
    
    //  这个类 Reader, 里面记录了一个播放到的位置 currentPacket, 
    //  播放相对位置,就是一个 offset
    //   判断播放到包尾的情况
     
    //     播放到包尾,根据下载解析情况,分两种情况
    //     1, 下载解析完成,播放到了结尾
    //     2, 下载没完成,解析好了的,都播放完了
    //     (仅此两种状况,因为解析的时间,远比不上下载的时间。下载完成 = 解析完成 )
    let packetIndex = Int(reader.currentPacket)
    let packets = reader.parser.packets
    let isEndOfData = packetIndex >= packets.count - 1
    if isEndOfData {
        if reader.parser.isParsingComplete {
            packetCount.pointee = 0
            return ReaderReachedEndOfDataError
        } else {
            return ReaderNotEnoughDataError
        }
    }
    
    // 之前的设置,每次只处理一个包 packet 的音频数据
    let packet = packets[packetIndex]
    var data = packet.0
    let dataCount = data.count
    ioData.pointee.mNumberBuffers = 1
    // 音频数据拷贝过来:先分配内存,再拷贝地址的数据
    ioData.pointee.mBuffers.mData = UnsafeMutableRawPointer.allocate(byteCount: dataCount, alignment: 0)

    _ = data.withUnsafeMutableBytes { (rawMutableBufferPointer) in
        let bufferPointer = rawMutableBufferPointer.bindMemory(to: UInt8.self)
        if let address = bufferPointer.baseAddress{
            memcpy((ioData.pointee.mBuffers.mData?.assumingMemoryBound(to: UInt8.self))!, address, dataCount)
        }
    }
    
    ioData.pointee.mBuffers.mDataByteSize = UInt32(dataCount)
    
    // 处理压缩文件 MP3, AAC 的 ASPD
    let sourceFormatDescription = sourceFormat.streamDescription.pointee
    if sourceFormatDescription.mFormatID != kAudioFormatLinearPCM {
        if outPacketDescriptions?.pointee == nil {
            outPacketDescriptions?.pointee = UnsafeMutablePointer<AudioStreamPacketDescription>.allocate(capacity: 1)
        }
        outPacketDescriptions?.pointee?.pointee.mDataByteSize = UInt32(dataCount)
        outPacketDescriptions?.pointee?.pointee.mStartOffset = 0
        outPacketDescriptions?.pointee?.pointee.mVariableFramesInPacket = 0
    }
    packetCount.pointee = 1

    // 更新播放到的位置 currentPacket
    reader.currentPacket = reader.currentPacket + 1
    
    return noErr;
}



68747470733a2f2f63646e2e666173746c6561726e65722e6d656469612f6176617564696f656e67696e652d707573682e737667 (1).png

4, 使用 AVAudioEngine, 播放与实时音效处理

AVAudioEngine 可以做实时的音效处理,用 Effect Unit 加效果

4.1 播放先

设置 AudioEngine,添加节点,连接节点

func setupAudioEngine(){
        // 添加节点
        attachNodes()

        // 连接节点
        connectNodes()

        // 准备 AudioEngine
        engine.prepare()
        
        // AVAudioEngine 的数据流,采用推 push 模型
        // 使用计时器,每隔 0.1 秒左右,调度播放资源

        let interval = 1 / (readFormat.sampleRate / Double(readBufferSize))
        let timer = Timer(timeInterval: interval / 2, repeats: true) {
            [weak self] _ in
            guard self?.state != .stopped else {
                return
            }
            // 分配缓冲 buffer, 调度播放资源
            self?.scheduleNextBuffer()
            self?.handleTimeUpdate()
            self?.notifyTimeUpdated()
        }
        RunLoop.current.add(timer, forMode: .common)
    }

    // 添加播放节点
    open func attachNodes() {
        engine.attach(playerNode)
    }

    // 播放节点,连通到输出
    open func connectNodes() {
        engine.connect(playerNode, to: engine.mainMixerNode, format: readFormat)
    }

调度播放资源,将数据 ( 上步创建的音频缓冲 buffer )交给 AudioEngine 的播放节点 playerNode

func scheduleNextBuffer(){
        guard let reader = reader else {
            return
        }
        //  通过状态记录,管理播放
        // 播放状态,就是一个开关
        guard !isFileSchedulingComplete || repeats else {
            return
        }

        do {
            // 拿到,上步创建音频缓冲 buffer
            let nextScheduledBuffer = try reader.read(readBufferSize)
            // playerNode 播放消费掉
            playerNode.scheduleBuffer(nextScheduledBuffer)
        } catch ReaderError.reachedEndOfFile {
            isFileSchedulingComplete = true
        } catch {  }
    }

开启播放

public func play() {
        // 没播放,才开启
        guard !playerNode.isPlaying else {
            return
        }
        
        if !engine.isRunning {
            do {
                try engine.start()
            } catch { }
        }
        
        // 提升用户体验,播放前,先静音
        let lastVolume = volumeRampTargetValue ?? volume
        volume = 0
        
        //  播放节点播放
        playerNode.play()
        
        // 250 毫秒后,正常音量播放
        swellVolume(to: lastVolume)
        
        // 更新播放状态
        state = .playing
    }

4.2 音效后

添加实时的音高、播放速度效果

   // 使用 AVAudioUnitTimePitch 单元,调节播放速度和音高效果
    let timePitchNode = AVAudioUnitTimePitch()
    

    override func attachNodes() {
        // 添加播放节点
        super.attachNodes()
        // 添加音效节点
        engine.attach(timePitchNode)
    }
    
// 相当于在播放节点和输出节点中间,插入音效节点
    override func connectNodes() {
        engine.connect(playerNode, to: timePitchNode, format: readFormat)
        engine.connect(timePitchNode, to: engine.mainMixerNode, format: readFormat)
    }


补充细节

5,计算出歌曲的时长, duration

先拿到包的个数, 下载的数据,解析完成后,加出来的

1 首 2 分 34 秒的 mp3, 可分为 5925 个包

public var totalPacketCount: AVAudioPacketCount? {
        guard let _ = dataFormat else {
            return nil
        }
        // 本例子,走的是 AVAudioPacketCount(packets.count)
        // 2.4 的解析回调 ParserPacketCallback 中,
        // 拿到步骤 1 下载的数据后,就解析,添加数据到 packets
        return max(AVAudioPacketCount(packetCount), AVAudioPacketCount(packets.count))
    }

去拿音频帧 frame 的总数

public var totalFrameCount: AVAudioFrameCount? {
        guard let framesPerPacket = dataFormat?.streamDescription.pointee.mFramesPerPacket else {
            return nil
        }
        
        guard let totalPacketCount = totalPacketCount else {
            return nil
        }
        // 上一步包的总数 X 每个包里有几个帧
        return AVAudioFrameCount(totalPacketCount) * AVAudioFrameCount(framesPerPacket)
    }

计算出音频持续时间

public var duration: TimeInterval? {
        guard let sampleRate = dataFormat?.sampleRate else {
            return nil
        }
        
        guard let totalFrameCount = totalFrameCount else {
            return nil
        }
        // 上一步的音频帧 frame 的总数 / 采样率
        return TimeInterval(totalFrameCount) / TimeInterval(sampleRate)
    }

6,调节播放的当前位置

6.1 音频播放管理者 streamer 里面
    public func seek(to time: TimeInterval) throws {        
        // 有了 parser 的音频包,和 reader 的音频缓冲,才可播放
        guard let parser = parser, let reader = reader else {
            return
        }
        
        // 拿时间,先算出音频帧的相对位置
        // 拿音频帧的相对位置,算出音频包的相对位置
        guard let frameOffset = parser.frameOffset(forTime: time),
            let packetOffset = parser.packetOffset(forFrame: frameOffset) else {
                return
        }
        // 更新当前状态
        currentTimeOffset = time
        isFileSchedulingComplete = false
        
        // 记录当前状态,一会恢复
        let isPlaying = playerNode.isPlaying
        let lastVolume = volumeRampTargetValue ?? volume
        
        // 优化体验,避免杂声,播放先停下来
        playerNode.stop()
        volume = 0
        
        // 更新 reader 里面的播放资源位置
        do {
            try reader.seek(packetOffset)
        } catch {
            return
        }
        
        // 刚才记录当前状态,恢复
        if isPlaying {
            playerNode.play()
        }
        
        // 更新 UI
        delegate?.streamer(self, updatedCurrentTime: time)
        
        // 恢复原来的音量
        swellVolume(to: lastVolume)
    }

算出当前时间的,帧偏移

   public func frameOffset(forTime time: TimeInterval) -> AVAudioFramePosition? {
        guard let _ = dataFormat?.streamDescription.pointee,
            let frameCount = totalFrameCount,
            let duration = duration else {
                return nil
        }
        //  拿当前时间 / 音频总时长,算出比值
        let ratio = time / duration
        return AVAudioFramePosition(Double(frameCount) * ratio)
    }

算出当前帧,对应的包的位置

    public func packetOffset(forFrame frame: AVAudioFramePosition) -> AVAudioPacketCount? {
        guard let framesPerPacket = dataFormat?.streamDescription.pointee.mFramesPerPacket else {
            return nil
        }
        // 当前是第多少帧 / 一个包里面有几个帧
        return AVAudioPacketCount(frame) / AVAudioPacketCount(framesPerPacket)
    }
6.2 音频资源调度 reader 里面
public func seek(_ packet: AVAudioPacketCount) throws {
        queue.sync {
            // 更改位置偏移
            currentPacket = packet
        }
    }

记录的位置 currentPacket,这样作用

步骤三的回调 ReaderConverterCallback 里

    // ...
    // 本例子中,一个音频包 packet, 对应一个音频缓冲 buffer
    let packet = packets[packetIndex]
    var data = packet.0
    // ...
    _ = data.withUnsafeMutableBytes { (rawMutableBufferPointer) in // ...
   }
   // ...

Screen Shot 2020-02-23 at 11.57.10 PM.png

7 UI 用户体验提升,手动拖拽播放时刻的场景

分三个事件处理:手指按下,手指拖动,手指抬起

//  手指按下, 屏蔽刷新播放进度的代理方法
@IBAction func progressSliderTouchedDown(_ sender: UISlider) {
        isSeeking = true
    }

    //  手指拖动, 屏蔽刷新播放进度的代理方法,采用手势对应的 UI
    @IBAction func progressSliderValueChanged(_ sender: UISlider) {
        let currentTime = TimeInterval(progressSlider.value)
        currentTimeLabel.text = currentTime.toMMSS()
    }

//  手指抬起, 调度播放的资源,恢复刷新播放进度的代理方法
@IBAction func progressSliderTouchedUp(_ sender: UISlider) {
        seek(sender)
        isSeeking = false
    }

相关代理方法,根据播放进度,更新当前事件和进度条的 UI

正在拖动,就屏蔽掉

func streamer(_ streamer: Streaming, updatedCurrentTime currentTime: TimeInterval) {
        if !isSeeking {
            progressSlider.value = Float(currentTime)
            currentTimeLabel.text = currentTime.toMMSS()
        }
    }

8 单曲循环模式

步骤 4 播放中,分发播放资源,是走计时器的

管理下里面的两个方法的逻辑

( 调度音频缓冲,和播放完了改状态 )

let timer = Timer(timeInterval: interval / 2, repeats: true) {
            [weak self] _ in
            // ...
            self?.scheduleNextBuffer()
            self?.handleTimeUpdate()
            // ...
        }

调度音频缓冲 buffer,


func scheduleNextBuffer(){
        guard let reader = reader else {
            return
        }
        // 如果重复 repeats,就继续播放,不用管播放完了一遍没有
        guard !isFileSchedulingComplete || repeats else {
            return
        }
       // ...   下面是,播放节点播放资源
}

根据播放情况,处理相关状态

func handleTimeUpdate(){
        guard let currentTime = currentTime, let duration = duration else {
            return
        }
        // 当前播放的时间,过了音频时长,就认为播放完一遍,去暂停
        if currentTime >= duration {
            try? seek(to: 0)
            // 如果重复,别暂停
            if !repeats{
                pause()
            }
        }
    }

代码见 github