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

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

本文是系列文章中的第二篇,上篇讲述了音频播放和频谱数据计算,本篇讲述数据处理和动画的绘制。

前言

在上篇文章中我们已经拿到了频谱数据,也知道了数组每个元素表示的是振幅,那这些数组元素之间有什么关系呢?根据FFT的原理, N个音频信号样本参与计算将产生N/2个数据(2048/2=1024),其频率分辨率△f=Fs/N = 44100/2048≈21.5hz,而相邻数据的频率间隔是一样的,因此这1024个数据分别代表频率在0hz、21.5hz、43.0hz....22050hz下的振幅。

那是不是可以直接将这1024个数据绘制成动画?当然可以,如果你刚好要显示1024个动画物件!但是如果你想可以灵活地调整这个数量,那么需要进行频带划分。

严格来说,结果有1025个,因为在上篇文章的FFT计算中通过fftInOut.imagp[0] = 0,直接把第1025个值舍弃掉了。这第1025个值代表的是奈奎斯特频率值的实部。至于为什么保存在第一个FFT结果的虚部中,请翻看第一篇

频带划分

频带划分更重要的原因其实是这样的:根据心理声学,人耳能容易的分辨出100hz和200hz的音调不同,但是很难分辨出8100hz和8200hz的音调不同,尽管它们各自都是相差100hz,可以说频率和音调之间的变化并不是呈线性关系,而是某种对数的关系。因此在实现动画时将数据从等频率间隔划分成对数增长的间隔更合乎人类的听感。


图1 频带划分方式

打开项目AudioSpectrum02-starter,您会发现跟之前的AudioSpectrum01项目有些许不同,它将FFT相关的计算移到了新增的类RealtimeAnalyzer中,使得AudioSpectrumPlayerRealtimeAnalyzer两个类的职责更为明确。

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

查看RealtimeAnalyzer类的代码,其中已经定义了 frequencyBands、startFrequency、endFrequency 三个属性,它们将决定频带的数量和起止频率范围。

  1. public var frequencyBands: Int = 80 //频带数量
  2. public var startFrequency: Float = 100 //起始频率
  3. public var endFrequency: Float = 18000 //截止频率
  4. 复制代码

现在可以根据这几个属性确定新的频带:

  1. private lazy var bands: [(lowerFrequency: Float, upperFrequency: Float)] = {
  2. var bands = [(lowerFrequency: Float, upperFrequency: Float)]()
  3. //1:根据起止频谱、频带数量确定增长的倍数:2^n
  4. let n = log2(endFrequency/startFrequency) / Float(frequencyBands)
  5. var nextBand: (lowerFrequency: Float, upperFrequency: Float) = (startFrequency, 0)
  6. for i in 1...frequencyBands {
  7. //2:频带的上频点是下频点的2^n倍
  8. let highFrequency = nextBand.lowerFrequency * powf(2, n)
  9. nextBand.upperFrequency = i == frequencyBands ? endFrequency : highFrequency
  10. bands.append(nextBand)
  11. nextBand.lowerFrequency = highFrequency
  12. }
  13. return bands
  14. }()
  15. 复制代码

接着创建函数findMaxAmplitude用来计算新频带的值,采用的方法是找出落在该频带范围内的原始振幅数据的最大值:

  1. private func findMaxAmplitude(for band:(lowerFrequency: Float, upperFrequency: Float), in amplitudes: [Float], with bandWidth: Float) -> Float {
  2. let startIndex = Int(round(band.lowerFrequency / bandWidth))
  3. let endIndex = min(Int(round(band.upperFrequency / bandWidth)), amplitudes.count - 1)
  4. return amplitudes[startIndex...endIndex].max()!
  5. }
  6. 复制代码

这样就可以通过新的analyse函数接收音频原始数据并向外提供加工好的频谱数据:

  1. func analyse(with buffer: AVAudioPCMBuffer) -> [[Float]] {
  2. let channelsAmplitudes = fft(buffer)
  3. var spectra = [[Float]]()
  4. for amplitudes in channelsAmplitudes {
  5. let spectrum = bands.map {
  6. findMaxAmplitude(for: $0, in: amplitudes, with: Float(buffer.format.sampleRate) / Float(self.fftSize))
  7. }
  8. spectra.append(spectrum)
  9. }
  10. return spectra
  11. }
  12. 复制代码

动画绘制

看上去数据都处理好了,让我们捋一捋袖子开始绘制动画了!打开自定义视图SpectrumView文件,首先创建两个CAGradientLayer

  1. var leftGradientLayer = CAGradientLayer()
  2. var rightGradientLayer = CAGradientLayer()
  3. 复制代码

新建函数setupView(),分别设置它们的colorslocations属性,这两个属性分别决定渐变层的颜色和位置,再将它们添加到视图的layer层中,它们将承载左右两个声道的动画。

  1. private func setupView() {
  2. rightGradientLayer.colors = [UIColor.init(red: 52/255, green: 232/255, blue: 158/255, alpha: 1.0).cgColor,
  3. UIColor.init(red: 15/255, green: 52/255, blue: 67/255, alpha: 1.0).cgColor]
  4. rightGradientLayer.locations = [0.6, 1.0]
  5. self.layer.addSublayer(rightGradientLayer)
  6. leftGradientLayer.colors = [UIColor.init(red: 194/255, green: 21/255, blue: 0/255, alpha: 1.0).cgColor,
  7. UIColor.init(red: 255/255, green: 197/255, blue: 0/255, alpha: 1.0).cgColor]
  8. leftGradientLayer.locations = [0.6, 1.0]
  9. self.layer.addSublayer(leftGradientLayer)
  10. }
  11. 复制代码

接着在View的初始化函数init(frame: CGRect)init?(coder aDecoder: NSCoder)中调用它,以便在代码或者Storyboard中创建SpectrumView时都可以正确地进行初始化。

  1. override init(frame: CGRect) {
  2. super.init(frame: frame)
  3. setupView()
  4. }
  5. required init?(coder aDecoder: NSCoder) {
  6. super.init(coder: aDecoder)
  7. setupView()
  8. }
  9. 复制代码

关键的来了,定义一个spectra属性对外接收频谱数据,并通过属性观察didSet创建两个声道的柱状图的UIBezierPath,经过CAShapeLayer包装后应用到各自CAGradientLayermask属性中,就得到了渐变的柱状图效果。

  1. var spectra:[[Float]]? {
  2. didSet {
  3. if let spectra = spectra {
  4. // left channel
  5. let leftPath = UIBezierPath()
  6. for (i, amplitude) in spectra[0].enumerated() {
  7. let x = CGFloat(i) * (barWidth + space) + space
  8. let y = translateAmplitudeToYPosition(amplitude: amplitude)
  9. let bar = UIBezierPath(rect: CGRect(x: x, y: y, width: barWidth, height: bounds.height - bottomSpace - y))
  10. leftPath.append(bar)
  11. }
  12. let leftMaskLayer = CAShapeLayer()
  13. leftMaskLayer.path = leftPath.cgPath
  14. leftGradientLayer.frame = CGRect(x: 0, y: topSpace, width: bounds.width, height: bounds.height - topSpace - bottomSpace)
  15. leftGradientLayer.mask = leftMaskLayer
  16. // right channel
  17. if spectra.count >= 2 {
  18. let rightPath = UIBezierPath()
  19. for (i, amplitude) in spectra[1].enumerated() {
  20. let x = CGFloat(spectra[1].count - 1 - i) * (barWidth + space) + space
  21. let y = translateAmplitudeToYPosition(amplitude: amplitude)
  22. let bar = UIBezierPath(rect: CGRect(x: x, y: y, width: barWidth, height: bounds.height - bottomSpace - y))
  23. rightPath.append(bar)
  24. }
  25. let rightMaskLayer = CAShapeLayer()
  26. rightMaskLayer.path = rightPath.cgPath
  27. rightGradientLayer.frame = CGRect(x: 0, y: topSpace, width: bounds.width, height: bounds.height - topSpace - bottomSpace)
  28. rightGradientLayer.mask = rightMaskLayer
  29. }
  30. }
  31. }
  32. }
  33. 复制代码

其中translateAmplitudeToYPosition函数的作用是将振幅转换成视图坐标系中的Y值:

  1. private func translateAmplitudeToYPosition(amplitude: Float) -> CGFloat {
  2. let barHeight: CGFloat = CGFloat(amplitude) * (bounds.height - bottomSpace - topSpace)
  3. return bounds.height - bottomSpace - barHeight
  4. }
  5. 复制代码

回到ViewController,在SpectrumPlayerDelegate的方法中直接将接收到的数据交给spectrumView:

  1. // MARK: SpectrumPlayerDelegate
  2. extension ViewController: AudioSpectrumPlayerDelegate {
  3. func player(_ player: AudioSpectrumPlayer, didGenerateSpectrum spectra: [[Float]]) {
  4. DispatchQueue.main.async {
  5. //1: 将数据交给spectrumView
  6. self.spectrumView.spectra = spectra
  7. }
  8. }
  9. }
  10. 复制代码

敲了这么多代码,终于可以运行一下看看效果了!额...看上去效果好像不太妙啊。请放心,喝杯咖啡放松一下,待会一个一个来解决。

图2 初始动画效果

调整优化

效果不好主要体现在这三点:1)动画与音乐节奏匹配度不高;2)画面锯齿过多; 3)动画闪动明显。 首先来解决第一个问题:

节奏匹配

匹配度不高的一部分原因是目前的动画幅度太小了,特别是中高频部分。我们先放大个5倍看看效果,修改analyse函数:

  1. func analyse(with buffer: AVAudioPCMBuffer) -> [[Float]] {
  2. let channelsAmplitudes = fft(buffer)
  3. var spectra = [[Float]]()
  4. for amplitudes in channelsAmplitudes {
  5. let spectrum = bands.map {
  6. //1: 直接在此函数调用后乘以5
  7. findMaxAmplitude(for: $0, in: amplitudes, with: Float(buffer.format.sampleRate) / Float(self.fftSize)) * 5
  8. }
  9. spectra.append(spectrum)
  10. }
  11. return spectra
  12. }
  13. 复制代码

图3 幅度放大5倍之后,低频部分都超出画面了

低频部分的能量相比中高频大许多,但实际上低音听上去并没有那么明显,这是为什么呢?这里涉及到响度的概念:

响度(loudness又称音响或音量),是与声强相对应的声音大小的知觉量。声强是客观的物理量,响度是主观的心理量。响度不仅跟声强有关,还跟频率有关。不同频率的纯音,在和1000Hz某个声压级纯音等响时,其声压级也不相同。这样的不同声压级,作为频率函数所形成的曲线,称为等响度曲线。改变这个1000Hz纯音的声压级,可以得到一组等响度曲线。最下方的0方曲线表示人类能听到的最小的声音响度,即听阈;最上方是人类能承受的最大的声音响度,即痛阈。

图4 横坐标为频率,纵坐标为声压级,波动的一条条曲线就是等响度曲线(equal-loudness contours),这些曲线代表着声音的频率和声压级在相同响度级中的关联。

原来人耳对不同频率的声音敏感度不同,两个声音即使声压级相同,如果频率不同那感受到的响度也不同。基于这个原因,需要采用某种频率计权来模拟使得像人耳听上去的那样。常用的计权方式有A、B、C、D等,A计权最为常用,对低频部分相比其他计权有着最多的衰减,这里也将采用A计权。

图5 蓝色曲线就是A计权,是根据40 phon的等响曲线模拟出来的反曲线

RealtimeAnalyzer类中新建函数createFrequencyWeights(),它将返回A计权的系数数组:

  1. private func createFrequencyWeights() -> [Float] {
  2. let Δf = 44100.0 / Float(fftSize)
  3. let bins = fftSize / 2 //返回数组的大小
  4. var f = (0..<bins).map { Float($0) * Δf}
  5. f = f.map { $0 * $0 }
  6. let c1 = powf(12194.217, 2.0)
  7. let c2 = powf(20.598997, 2.0)
  8. let c3 = powf(107.65265, 2.0)
  9. let c4 = powf(737.86223, 2.0)
  10. let num = f.map { c1 * $0 * $0 }
  11. let den = f.map { ($0 + c2) * sqrtf(($0 + c3) * ($0 + c4)) * ($0 + c1) }
  12. let weights = num.enumerated().map { (index, ele) in
  13. return 1.2589 * ele / den[index]
  14. }
  15. return weights
  16. }
  17. 复制代码

更新analyse函数中的代码:

  1. func analyse(with buffer: AVAudioPCMBuffer) -> [[Float]] {
  2. let channelsAmplitudes = fft(buffer)
  3. var spectra = [[Float]]()
  4. //1: 创建权重数组
  5. let aWeights = createFrequencyWeights()
  6. for amplitudes in channelsAmplitudes {
  7. //2:原始频谱数据依次与权重相乘
  8. let weightedAmplitudes = amplitudes.enumerated().map {(index, element) in
  9. return element * aWeights[index]
  10. }
  11. let spectrum = bands.map {
  12. //3: findMaxAmplitude函数将从新的`weightedAmplitudes`中查找最大值
  13. findMaxAmplitude(for: $0, in: weightedAmplitudes, with: Float(buffer.format.sampleRate) / Float(self.fftSize)) * 5
  14. }
  15. spectra.append(spectrum)
  16. }
  17. return spectra
  18. }
  19. 复制代码

再次运行项目看看效果,好多了是吗?

图6 A计权之后的动画表现

锯齿消除

接着是锯齿过多的问题,手段是将相邻较长的拉短较短的拉长,常见的办法是使用加权平均。创建函数highlightWaveform()

  1. private func highlightWaveform(spectrum: [Float]) -> [Float] {
  2. //1: 定义权重数组,数组中间的5表示自己的权重
  3. // 可以随意修改,个数需要奇数
  4. let weights: [Float] = [1, 2, 3, 5, 3, 2, 1]
  5. let totalWeights = Float(weights.reduce(0, +))
  6. let startIndex = weights.count / 2
  7. //2: 开头几个不参与计算
  8. var averagedSpectrum = Array(spectrum[0..<startIndex])
  9. for i in startIndex..<spectrum.count - startIndex {
  10. //3: zip作用: zip([a,b,c], [x,y,z]) -> [(a,x), (b,y), (c,z)]
  11. let zipped = zip(Array(spectrum[i - startIndex...i + startIndex]), weights)
  12. let averaged = zipped.map { $0.0 * $0.1 }.reduce(0, +) / totalWeights
  13. averagedSpectrum.append(averaged)
  14. }
  15. //4:末尾几个不参与计算
  16. averagedSpectrum.append(contentsOf: Array(spectrum.suffix(startIndex)))
  17. return averagedSpectrum
  18. }
  19. 复制代码

analyse函数需要再次更新:

  1. func analyse(with buffer: AVAudioPCMBuffer) -> [[Float]] {
  2. let channelsAmplitudes = fft(buffer)
  3. var spectra = [[Float]]()
  4. for amplitudes in channelsAmplitudes {
  5. let weightedAmplitudes = amplitudes.enumerated().map {(index, element) in
  6. return element * weights[index]
  7. }
  8. let spectrum = bands.map {
  9. findMaxAmplitude(for: $0, in: weightedAmplitudes, with: Float(buffer.format.sampleRate) / Float(self.fftSize)) * 5
  10. }
  11. //1: 添加到数组之前调用highlightWaveform
  12. spectra.append(highlightWaveform(spectrum: spectrum))
  13. }
  14. return spectra
  15. }
  16. 复制代码

图7 锯齿少了,波形变得明显

闪动优化

动画闪动给人的感觉就好像丢帧一样。造成这个问题的原因,是因为频带的值前后两帧变化太大,我们可以将上一帧的值缓存起来,然后跟当前帧的值进行...没错,又是加权平均! (⊙﹏⊙)b 继续开始编写代码,首先需要定义两个属性:

  1. //缓存上一帧的值
  2. private var spectrumBuffer: [[Float]]?
  3. //缓动系数,数值越大动画越"缓"
  4. public var spectrumSmooth: Float = 0.5 {
  5. didSet {
  6. spectrumSmooth = max(0.0, spectrumSmooth)
  7. spectrumSmooth = min(1.0, spectrumSmooth)
  8. }
  9. }
  10. 复制代码

接着修改analyse函数:

  1. func analyse(with buffer: AVAudioPCMBuffer) -> [[Float]] {
  2. let channelsAmplitudes = fft(buffer)
  3. let aWeights = createFrequencyWeights()
  4. //1: 初始化spectrumBuffer
  5. if spectrumBuffer.count == 0 {
  6. for _ in 0..<channelsAmplitudes.count {
  7. spectrumBuffer.append(Array<Float>(repeating: 0, count: frequencyBands))
  8. }
  9. }
  10. //2: index在给spectrumBuffer赋值时需要用到
  11. for (index, amplitudes) in channelsAmplitudes.enumerated() {
  12. let weightedAmp = amplitudes.enumerated().map {(index, element) in
  13. return element * aWeights[index]
  14. }
  15. var spectrum = bands.map {
  16. findMaxAmplitude(for: $0, in: weightedAmplitudes, with: Float(buffer.format.sampleRate) / Float(self.fftSize)) * 5
  17. }
  18. spectrum = highlightWaveform(spectrum: spectrum)
  19. //3: zip用法前面已经介绍过了
  20. let zipped = zip(spectrumBuffer[index], spectrum)
  21. spectrumBuffer[index] = zipped.map { $0.0 * spectrumSmooth + $0.1 * (1 - spectrumSmooth) }
  22. }
  23. return spectrumBuffer
  24. }
  25. 复制代码

再次运行项目,得到最终效果:

结尾

音频频谱的动画实现到此已经全部完成。本人之前对音频和声学毫无经验,两篇文章涉及的方法理论均参考自互联网,肯定有不少错误,欢迎指正。

参考资料
[1] 维基百科, 倍频程频带, en.wikipedia.org/wiki/Octave…
[2] 维基百科, 响度, zh.wikipedia.org/wiki/%E9%9F…
[3] mathworks,A-weighting Filter with Matlab,www.mathworks.com/matlabcentr…
[4] 动画效果:网易云音乐APPMOO音乐APP。感兴趣的同学可以用卡农钢琴版音乐和这两款APP进行对比^_^,会发现区别。

作者:potato04
链接:https://juejin.im/post/5c26d44ae51d45619a4b8b1e
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

一步一步教你实现iOS音频频谱动画(二)的更多相关文章

  1. 一步一步教你实现iOS音频频谱动画(一)

    如果你想先看看最终效果再决定看不看文章 -> bilibili 示例代码下载 第二篇:一步一步教你实现iOS音频频谱动画(二) 基于篇幅考虑,本次教程分为两篇文章,本篇文章主要讲述音频播放和频谱 ...

  2. iOS音频频谱动画,仿QQ录音频谱

    先上效果图: display.gif 有需要的请移步GitHub下载: https://github.com/HuangGY1993/GYSpectrum 用法很简单,示例: SpectrumView ...

  3. iOS音频学习笔记二:iOS SDK中与音频有关的相关框架

      上层:       Media Player Framework: 包含MPMoviePlayerController.MPMoviePlayerViewController.MPMusicPla ...

  4. 通过Dapr实现一个简单的基于.net的微服务电商系统(十)——一步一步教你如何撸Dapr之绑定

    如果说Actor是dapr有状态服务的内部体现的话,那绑定应该是dapr对serverless这部分的体现了.我们可以通过绑定极大的扩展应用的能力,甚至未来会成为serverless的基础.最开始接触 ...

  5. 一步一步教你如何在linux下配置apache+tomcat(转)

    一步一步教你如何在linux下配置apache+tomcat   一.安装前准备. 1.   所有组件都安装到/usr/local/e789目录下 2.   解压缩命令:tar —vxzf 文件名(. ...

  6. 一步一步教你将普通的wifi路由器变为智能广告路由器

    一步一步教你将普通的wifi路由器变为智能广告路由器 相信大家对WiFi智能广告路由器已经不再陌生了,现在很多公共WiFi上网,都需要登录并且验证,这也就是WiFi广告路由器的最重要的功能.大致就是下 ...

  7. 一步一步实现iOS微信自动抢红包

    微信红包 前言:最近笔者在研究iOS逆向工程,顺便拿微信来练手,在非越狱手机上实现了微信自动抢红包的功能.   此教程所需要的工具/文件 yololib class-dump dumpdecrypte ...

  8. 【转载】一步一步搭建自己的iOS网络请求库

    一步一步搭建自己的iOS网络请求库(一) 大家好,我是LastDay,很久没有写博客了,这周会分享一个的HTTP请求库的编写经验. 简单的介绍 介绍一下,NSURLSession是iOS7中新的网络接 ...

  9. 一步一步教你使用Git

    一步一步教你使用Git 互联网给我们带来方便的同时,也时常让我们感到困惑.随便搜搜就出一大堆结果,然而总是有大量的重复和错误.小妖发出的内容,都是自己实测过的,有问题请留言. 现在,你已经安装了Git ...

随机推荐

  1. Qt编写数据导出到Excel及Pdf和打印数据

    一.前言 用Qt开发已经九年了,期间用Qt做过不少的项目,在各种项目中有个功能很常用,尤其是涉及到数据记录存储的项目,那就是需要对查询的数据进行导出到Excel,或者导出到Pdf文件,或者直接打印查询 ...

  2. Jmeter与BlazeMeter使用 录制导出jmx

    本文链接:https://blog.csdn.net/weixin_38250126/article/details/82629876JMeter 的脚本录制,除了自带的HTTP代理服务器以外,被大家 ...

  3. ABAP Memory ID

    转自:https://blog.csdn.net/lyq123333321/article/details/52659114 (一)          Difference Between SAP a ...

  4. LeetCode_121. Best Time to Buy and Sell Stock

    121. Best Time to Buy and Sell Stock Easy Say you have an array for which the ith element is the pri ...

  5. 页面被iframe与无刷新更换url方法

    页面被iframe问题解决方法 if (window.top.location !== window.self.location) { const data = JSON.stringify({ if ...

  6. 机器学习第一章——NFL的个人理解

    第一篇博客,想给自己的学习加深记忆.看到书中第一个公式时,本来想直接看证明结果就好,然鹅...作者在备注上写:这里只用到一些非常基础的数学知识,只准备读第一章且有“数学恐惧”的读者可跳过...嘤嘤嘤, ...

  7. 李宗盛spss罚写2019-12-8

    以上过程即整个假设检验的思想:反证法及小概率原理. 因而假设检验有可能犯两类错误. 第一类错误:原假设正确,而错误地拒绝了它,即“拒真”的错误,其发生的概率为第一类错误的概率. 第二类错误:原假设不正 ...

  8. 【VS开发】【CUDA开发】如何在MFC中调用CUDA

    如何在MFC中调用CUDA 有时候,我们需要在比较大的项目中调用CUDA,这就涉及到MFC+CUDA的环境配置问题,以矩阵相乘为例,在MFC中调用CUDA程序.我们参考罗振东iylzd@163.com ...

  9. DDS工作原理及其性能分析

    DDS工作原理及其性能分析 声明:引用请注明出处http://blog.csdn.net/lg1259156776/ 系列博客说明:此系列博客属于作者在大三大四阶段所储备的关于电子电路设计等硬件方面的 ...

  10. Centos7安装gitlab11 学习笔记之基础概念、部署安装、权限管理、issue管理

    一.基础介绍 1.简介 一个基于GIT的源码托管解决方案 基于rubyonrails开发 集成了nginx postgreSQL redis sidekiq等组件 2.安装要求 2g内存以上,有点占内 ...