zoukankan      html  css  js  c++  java
  • 一步一步教你实现iOS音频频谱动画(一)

    如果你想先看看最终效果再决定看不看文章 -> bilibili
    示例代码下载

    第二篇:一步一步教你实现iOS音频频谱动画(二)

    基于篇幅考虑,本次教程分为两篇文章,本篇文章主要讲述音频播放和频谱数据的获取,下篇将讲述数据处理和动画绘制。

    前言

    很久以前在电脑上听音乐的时候,经常会调出播放器的一个小工具,里面的柱状图会随着音乐节奏而跳动,就感觉自己好专业,尽管后来才知道这个是音频信号在频域下的表现。

    热身知识

    动手写代码之前,让我们先了解几个基础概念吧

    音频数字化

    • 采样: 总所周知,声音是一种压力波,是连续的,然而在计算机中无法表示连续的数据,所以只能通过间隔采样的方式进行离散化,其中采集的频率称为采样率。根据奈奎斯特采样定理 ,当采样率大于信号最高频率的2倍时信号频率不会失真。人类能听到的声音频率范围是20hz到20khz,所以CD等采用了44.1khz采样率能满足大部分需要。

    • 量化: 每次采样的信号强度也会有精度的损失,如果用16位的Int(位深度)来表示,它的范围是[-32768,32767],因此位深度越高可表示的范围就越大,音频质量越好。

    • 声道数: 为了更好的效果,声音一般采集左右双声道的信号,如何编排呢?一种是采用交错排列(Interleaved): LRLRLRLR ,另一种采用各自排列(non-Interleaved): LLL RRR

    以上将模拟信号数字化的方法称为脉冲编码调制(PCM),而本文中我们就需要对这类数据进行加工处理。

    傅里叶变换

    现在我们的音频数据是时域的,也就是说横轴是时间,纵轴是信号的强度,而动画展现要求的横轴是频率。将信号从时域转换成频域可以使用傅里叶变换实现,信号经过傅里叶变换分解成了不同频率的正弦波,这些信号的频率和振幅就是我们需要实现动画的数据。

    图1 (from nti-audio) 傅里叶变换将信号从时域转换成频域

    实际上计算机中处理的都是离散傅里叶变换(DFT),而快速傅里叶变换(FFT)是快速计算离散傅里叶变换(DFT)或其逆变换的方法,它将DFT的复杂度从O(n²)降低到O(nlogn)。 如果你刚才点开前面链接看过其中介绍的FFT算法,那么可能会觉得这FFT代码该怎么写?不用担心,苹果为此提供了Accelerate框架,其中vDSP部分提供了数字信号处理的函数实现,包含FFT。有了vDSP,我们只需几个步骤即可实现FFT,简单便捷,而且性能高效。

    iOS中的音频框架

    现在开始让我们看一下iOS系统中的音频框架, AudioToolbox功能强大,不过提供的API都是基于C语言的,其大多数功能已经可以通过AVFoundation实现,它利用Objective-C/Swift对于底层接口进行了封装。我们本次需求比较简单,只需要播放音频文件并进行实时处理,所以AVFoundation中的AVAudioEngine就能满足本次音频播放和处理的需要。

    图2 (from WWDC16) iOS/MAC OS X 音频技术栈

    AVAudioEngine

    AVAudioEngine 从iOS8加入到AVFoundation框架,它提供了以前需要深入到底层AudioToolbox才实现的功能,比如实时音频处理。它把音频处理的各环节抽象成AVAudioNode并通过AVAudioEngine进行管理,最后将它们连接构成完整的节点图。以下就是本次教程的AVAudioEngine与其节点的连接方式。

    图3 AVAudioEngine和AVAudioNode连接图

    mainMixerNodeoutputNode都是在被访问的时候默认由AVAudioEngine对象创建并连接的单例对象,也就是说我们只需要手动创建engineplayer节点并将他们连接就可以了!最后在mainMixerNode的输出总线上安装分接头将定量输出的AVAudioPCMBuffer数据进行转换和FFT。

    代码实现

    了解了以上相关知识后,我们就可以开始编写代码了。打开项目AudioSpectrum01-starter,首先要实现的是音频播放功能。

    如果你只是想浏览实现代码,打开项目AudioSpectrum01-final即可,已经完成本篇文章的所有代码

    音频播放

    AudioSpectrumPlayer类中创建AVAudioEngineAVAudioPlayerNode两个实例变量:

    private let engine = AVAudioEngine()
    private let player = AVAudioPlayerNode()
    复制代码

    接下来在init()方法中添加如下代码:

    //1
    engine.attach(player)
    engine.connect(player, to: engine.mainMixerNode, format: nil)
    //2
    engine.prepare()
    try! engine.start()
    复制代码

    //1:这里将player挂载到engine上,再把playerenginemainMixerNode连接起来就完成了AVAudioEngine的整个节点图创建(详见图3)。
    //2:在调用enginestrat()方法开始启动engine之前,需要通过prepare()方法提前分配相关资源

    继续完善play(withFileName fileName: String)stop()方法:

    //1
    func play(withFileName fileName: String) {
        guard let audioFileURL = Bundle.main.url(forResource: fileName, withExtension: nil),
            let audioFile = try? AVAudioFile(forReading: audioFileURL) else { return }
        player.stop()
        player.scheduleFile(audioFile, at: nil, completionHandler: nil)
        player.play()
    }
    //2
    func stop() {
        player.stop()
    }
    复制代码

    //1:首先需要确保文件名为fileName的音频文件能正常加载,然后通过stop()方法停止之前的播放,再调用scheduleFile(_:at:completionHandler:)方法编排新的文件,最后通过play()方法开始播放。
    //2:停止播放调用playerstop()方法即可。

    音频播放功能已经完成,运行项目,试试点击音乐右侧的Play按钮进行音频播放吧。

    音频数据获取

    前面提到我们可以在mainMixerNode上安装分接头定量获取AVAudioPCMBuffer数据,现在打开AudioSpectrumPlayer文件,先定义一个属性: fftSize,它是每次获取到的buffer的frame数量。

    private var fftSize: Int = 2048
    复制代码

    将光标定位至init()方法中的engine.connect()语句下方,调用mainMixerNodeinstallTap方法开始安装分接头:

    engine.mainMixerNode.installTap(onBus: 0, bufferSize: AVAudioFrameCount(fftSize), format: nil, block: { [weak self](buffer, when) in
        guard let strongSelf = self else { return }
        if !strongSelf.player.isPlaying { return }
        buffer.frameLength = AVAudioFrameCount(strongSelf.fftSize) //这句的作用是确保每次回调中buffer的frameLength是fftSize大小,详见:https://stackoverflow.com/a/27343266/6192288
        let amplitudes = strongSelf.fft(buffer)
        if strongSelf.delegate != nil {
            strongSelf.delegate?.player(strongSelf, didGenerateSpectrum: amplitudes)
        }
    })
    复制代码

    在分接头的回调 block 中将拿到的 2048 个 frame 的 buffer 交由fft函数进行计算,最后将计算的结果通过delegate进行传递。

    按照 44100hz 采样率和 1 Frame = 1 Packet 来计算(可以参考这里关于channel、sample、frame、packet的概念与关系),那么block将会在一秒中调用44100/2048≈21.5次左右,另外需要注意的是block有可能不在主线程调用。

    FFT实现

    终于到实现FFT的时候了,根据vDSP文档,首先需要定义一个FFT权重数组(fftSetup),它可以在多次FFT中重复使用和提升FFT性能:

    private lazy var fftSetup = vDSP_create_fftsetup(vDSP_Length(Int(round(log2(Double(fftSize))))), FFTRadix(kFFTRadix2))
    复制代码

    不需要时在析构函数(反初始化函数)中销毁:

    deinit {
        vDSP_destroy_fftsetup(fftSetup)
    }
    复制代码

    最后新建fft函数,实现代码如下:

    private func fft(_ buffer: AVAudioPCMBuffer) -> [[Float]] {
        var amplitudes = [[Float]]()
        guard let floatChannelData = buffer.floatChannelData else { return amplitudes }
    
        //1:抽取buffer中的样本数据
        var channels: UnsafePointer<UnsafeMutablePointer<Float>> = floatChannelData
        let channelCount = Int(buffer.format.channelCount)
        let isInterleaved = buffer.format.isInterleaved
        
        if isInterleaved {
            // deinterleave
            let interleavedData = UnsafeBufferPointer(start: floatChannelData[0], count: self.fftSize * channelCount)
            var channelsTemp : [UnsafeMutablePointer<Float>] = []
            for i in 0..<channelCount {
                var channelData = stride(from: i, to: interleavedData.count, by: channelCount).map{ interleavedData[$0]}
                channelsTemp.append(UnsafeMutablePointer(&channelData))
            }
            channels = UnsafePointer(channelsTemp)
        }
        
        for i in 0..<channelCount {
            let channel = channels[i]
            //2: 加汉宁窗
            var window = [Float](repeating: 0, count: Int(fftSize))
            vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM))
            vDSP_vmul(channel, 1, window, 1, channel, 1, vDSP_Length(fftSize))
            
            //3: 将实数包装成FFT要求的复数fftInOut,既是输入也是输出
            var realp = [Float](repeating: 0.0, count: Int(fftSize / 2))
            var imagp = [Float](repeating: 0.0, count: Int(fftSize / 2))
            var fftInOut = DSPSplitComplex(realp: &realp, imagp: &imagp)
            channel.withMemoryRebound(to: DSPComplex.self, capacity: fftSize) { (typeConvertedTransferBuffer) -> Void in
                vDSP_ctoz(typeConvertedTransferBuffer, 2, &fftInOut, 1, vDSP_Length(fftSize / 2))
            }
        
            //4:执行FFT
            vDSP_fft_zrip(fftSetup!, &fftInOut, 1, vDSP_Length(round(log2(Double(fftSize)))), FFTDirection(FFT_FORWARD));
            
            //5:调整FFT结果,计算振幅
            fftInOut.imagp[0] = 0
            let fftNormFactor = Float(1.0 / (Float(fftSize)))
            vDSP_vsmul(fftInOut.realp, 1, [fftNormFactor], fftInOut.realp, 1, vDSP_Length(fftSize / 2));
            vDSP_vsmul(fftInOut.imagp, 1, [fftNormFactor], fftInOut.imagp, 1, vDSP_Length(fftSize / 2));
            var channelAmplitudes = [Float](repeating: 0.0, count: Int(fftSize / 2))
            vDSP_zvabs(&fftInOut, 1, &channelAmplitudes, 1, vDSP_Length(fftSize / 2));
            channelAmplitudes[0] = channelAmplitudes[0] / 2 //直流分量的振幅需要再除以2
            amplitudes.append(channelAmplitudes)
        }
        return amplitudes
    }
    复制代码

    通过代码中的注释,应该能了解如何从buffer获取音频样本数据并进行FFT计算了,不过以下两点是我在完成这一部分内容过程中比较难理解的部分:

    1. 通过buffer对象的方法floatChannelData获取样本数据,如果是多声道并且是interleaved,我们就需要对它进行deinterleave, 通过下图就能比较清楚的知道deinterleave前后的结构,不过在我试验了许多音频文件之后,发现都是non-interleaved的,也就是无需进行转换。┑( ̄Д  ̄)┍
    图4 interleaved的样本数据需要进行deinterleave
    1. vDSP在进行实数FFT计算时利用一种独特的数据格式化方式以达到节省内存的目的,它在FFT计算的前后通过两次转换将FFT的输入和输出的数据结构进行统一成DSPSplitComplex。第一次转换是通过vDSP_ctoz函数将样本数据的实数数组转换成DSPSplitComplex。第二次则是将FFT结果转换成DSPSplitComplex,这个转换的过程是在FFT计算函数vDSP_fft_zrip中自动完成的。

      第二次转换过程如下:n位样本数据(n/2位复数)进行fft计算会得到n/2+1位复数结果:{[DC,0],C[2],...,C[n/2],[NY,0]} (其中DC是直流分量,NY是奈奎斯特频率的值,C是复数数组),其中[DC,0]和[NY,0]的虚部都是0,所以可以将NY放到DC中的虚部中,其结果变成{[DC,NY],C[2],C[3],...,C[n/2]},与输入位数一致。

    图5 第一次转换时,vDSP_ctoz函数将实数数组转换成DSPSplitComplex结构

    再次运行项目,现在除了听到音乐之外还可以在控制台中看到打印输出的频谱数据。

    图6 将结果通过Google Sheets绘制出来的频谱图

    好了,本篇文章内容到这里就结束了,下一篇文章将对计算好的频谱数据进行处理和动画绘制。

    资料参考
    [1] wikipedia,脉冲编码调制, zh.wikipedia.org/wiki/%E8%84…
    [2] Mike Ash,音频数据获取与解析, www.mikeash.com/pyblog/frid…
    [3] 韩 昊, 傅里叶分析之掐死教程, blog.jobbole.com/70549/
    [4] raywenderlich, AVAudioEngine编程入门,www.raywenderlich.com/5154-avaudi…
    [5] Apple, vDSP编程指南, developer.apple.com/library/arc…
    [6] Apple, aurioTouch案例代码,developer.apple.com/library/arc…


    作者:potato04
    链接:https://juejin.im/post/5c1bbec66fb9a049cb18b64c
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
  • 相关阅读:
    序列JSON数据和四种AJAX操作方式
    jquery.validate和jquery.form.js实现表单提交
    JQuery Validate使用总结1:
    HOWTO: Include Base64 Encoded Binary Image Data (data URI scheme) in Inline Cascading Style Sheets (CSS)(转)
    SharePoint 2007 使用4.0 .Net
    动态IP解决方案
    取MS CRM表单的URL
    从Iframe或新开的窗口访问MS CRM 2011(转)
    Toggle or Hidden MS CRM Tab
    Windows 2008下修改域用户密码
  • 原文地址:https://www.cnblogs.com/Free-Thinker/p/10880118.html
Copyright © 2011-2022 走看看