如果你想先看看最终效果再决定看不看文章 -> bilibili

示例代码下载

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

前言

在上篇文章中我们已经拿到了频谱数据,也知道了数组每个元素表示的是振幅,那这些数组元素之间有什么关系呢?根据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中,使得AudioSpectrumPlayer和RealtimeAnalyzer两个类的职责更为明确。

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

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

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

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

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

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

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

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

func analyse(with buffer: AVAudioPCMBuffer) -> [[Float]] { let channelsAmplitudes = fft(buffer) var spectra = [[Float]]() for amplitudes in channelsAmplitudes { let spectrum = bands.map { findMaxAmplitude(for: $0, in: amplitudes, with: Float(buffer.format.sampleRate) / Float(self.fftSize)) } spectra.append(spectrum) } return spectra } 复制代码

动画绘制

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

var leftGradientLayer = CAGradientLayer()

var rightGradientLayer = CAGradientLayer()

复制代码

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

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

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

override init(frame: CGRect) {

super.init(frame: frame) setupView() } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) setupView() } 复制代码

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

var spectra:[[Float]]? {

didSet {

if let spectra = spectra { // left channel let leftPath = UIBezierPath() for (i, amplitude) in spectra[0].enumerated() { let x = CGFloat(i) * (barWidth + space) + space let y = translateAmplitudeToYPosition(amplitude: amplitude) let bar = UIBezierPath(rect: CGRect(x: x, y: y, width: barWidth, height: bounds.height - bottomSpace - y)) leftPath.append(bar) } let leftMaskLayer = CAShapeLayer() leftMaskLayer.path = leftPath.cgPath leftGradientLayer.frame = CGRect(x: 0, y: topSpace, width: bounds.width, height: bounds.height - topSpace - bottomSpace) leftGradientLayer.mask = leftMaskLayer // right channel if spectra.count >= 2 { let rightPath = UIBezierPath() for (i, amplitude) in spectra[1].enumerated() { let x = CGFloat(spectra[1].count - 1 - i) * (barWidth + space) + space let y = translateAmplitudeToYPosition(amplitude: amplitude) let bar = UIBezierPath(rect: CGRect(x: x, y: y, width: barWidth, height: bounds.height - bottomSpace - y)) rightPath.append(bar) } let rightMaskLayer = CAShapeLayer() rightMaskLayer.path = rightPath.cgPath rightGradientLayer.frame = CGRect(x: 0, y: topSpace, width: bounds.width, height: bounds.height - topSpace - bottomSpace) rightGradientLayer.mask = rightMaskLayer } } } } 复制代码

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

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

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

// MARK: SpectrumPlayerDelegate

extension ViewController: AudioSpectrumPlayerDelegate { func player(_ player: AudioSpectrumPlayer, didGenerateSpectrum spectra: [[Float]]) { DispatchQueue.main.async { //1: 将数据交给spectrumView self.spectrumView.spectra = spectra } } } 复制代码

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

图2 初始动画效果

调整优化

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

节奏匹配

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

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

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

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

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

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

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

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

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

private func createFrequencyWeights() -> [Float] { let Δf = 44100.0 / Float(fftSize) let bins = fftSize / 2 //返回数组的大小 var f = (0..

更新analyse函数中的代码:

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

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

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

锯齿消除

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

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

analyse函数需要再次更新:

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

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

闪动优化

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

//缓存上一帧的值

private var spectrumBuffer: [[Float]]?

//缓动系数,数值越大动画越"缓" public var spectrumSmooth: Float = 0.5 { didSet { spectrumSmooth = max(0.0, spectrumSmooth) spectrumSmooth = min(1.0, spectrumSmooth) } } 复制代码

接着修改analyse函数:

func analyse(with buffer: AVAudioPCMBuffer) -> [[Float]] { let channelsAmplitudes = fft(buffer) let aWeights = createFrequencyWeights() //1: 初始化spectrumBuffer if spectrumBuffer.count == 0 { for _ in 0..(repeating: 0, count: frequencyBands)) } } //2: index在给spectrumBuffer赋值时需要用到 for (index, amplitudes) in channelsAmplitudes.enumerated() { let weightedAmp = amplitudes.enumerated().map {(index, element) in return element * aWeights[index] } var spectrum = bands.map { findMaxAmplitude(for: $0, in: weightedAmplitudes, with: Float(buffer.format.sampleRate) / Float(self.fftSize)) * 5 } spectrum = highlightWaveform(spectrum: spectrum) //3: zip用法前面已经介绍过了 let zipped = zip(spectrumBuffer[index], spectrum) spectrumBuffer[index] = zipped.map { $0.0 * spectrumSmooth + $0.1 * (1 - spectrumSmooth) } } return spectrumBuffer } 复制代码

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

结尾

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

参考资料

[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] 动画效果:网易云音乐APP、MOO音乐APP。感兴趣的同学可以用卡农钢琴版音乐和这两款APP进行对比^_^,会发现区别。

音频断句Matlab,一步一步教你实现iOS音频频谱动画(二)相关推荐

  1. android 语音自动分句,进行音频断句的自动拆分方法及系统与流程

    本发明涉及语音.字幕处理技术领域,尤其涉及进行音频断句的自动拆分方法及系统. 背景技术: 目前字幕制作领域,主要通过人工进行语音断句,人工语音断句的前提是将语音全部听一遍,在听写的同时通过拍打快捷键来 ...

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

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

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

    如果你想先看看最终效果再决定看不看文章 -> bilibili 示例代码下载 第一篇:一步一步教你实现iOS音频频谱动画(一) 本文是系列文章中的第二篇,上篇讲述了音频播放和频谱数据计算,本篇讲 ...

  4. 音频转文字软件哪个好用?教你轻松实现音频转文字怎么转

    大家在日常的工作中,经常会遇到需要将音频转为文字的情况,这时的你们一般会采取什么方式呢?是不是会选择一边播放音频,一边手动码字记录.整理音频内容?其实这种做法既费时又费力,因为我们手速通常跟不上讲话的 ...

  5. php绘制频谱图,一步一步教你实现iOS音频频谱动画(二)

    本文是系列文章中的第二篇,上篇讲述了音频播放和频谱数据计算,本篇讲述数据处理和动画的绘制. 前言 在上篇文章中我们已经拿到了频谱数据,也知道了数组每个元素表示的是振幅,那这些数组元素之间有什么关系呢? ...

  6. matlab如何使音频文件声音变大_如何用录音软件AU2020给音频消除噪音?只需简单几步你也可以办到...

    最近有好多朋友私信我,最近头条音频频道单价很高,比视频收益还要高,大家纷纷想通过自已录制音频上传的方式,来获取收益.问到如何才能给录好的音频文件降噪,还原出自已真实的声音,得到一个好的音质.而对于平时 ...

  7. 手机语音翻译的小妙招,一步一步教你!一键音频转文字

    原标题:手机语音翻译的小妙招,一步一步教你!一键音频转文字 电话销售是现在销售的一种重要途径,这些电话销售企业呼叫中心每日会产生大量的通话语音文件,很多时候他们为了服务营销挖掘,都会将这些录音文件进行 ...

  8. 音频文件按照正常语句,断句拆分的处理方法

    关于录音文件断句分割的方法 前言 最近看讲座听在线英文电台的时候总有个想法, 讲座能不能自动记笔记? 电台能不能自动配中英文翻译对照字幕? 当然,这些东西,在一些软件里其实已经实现了的. 只是找不到能 ...

  9. MATLAB Robotics System Toolbox学习笔记(一):一步一步建造一个机械臂

    本文参考 MathWorks 中 Help Center 的 Build a Robot Step by Step ,并加以自己的理解 原网址:https://ww2.mathworks.cn/hel ...

最新文章

  1. 行业观察(一)| 从渠道为王到数据为王——浅谈服装零售企业的数字化转型...
  2. 【学习笔记】11、循环语句—while
  3. 期货与期权(part10)--远期与期货合约的比较
  4. 大道五目Flash英文版(Renju Problems)程序分析之禁手判断
  5. Word2Vec学习笔记(二)
  6. jupyter notebook选择conda环境
  7. xposed hook java_[原创]Android Hook 系列教程(一) Xposed Hook 原理分析
  8. iOS开发:icon和启动图尺寸(转)
  9. 4.nslookup
  10. Windows xp 安装的屏幕保护程序
  11. 台服DNF更换Mysql5.6(rpm包安装、二进制安装)
  12. 一阶广义差分模型_计量经济学习题第5章 自相关性
  13. 不用编程,实现ModbusTC方式与AB罗克韦尔的PLC标签方式通讯
  14. flutter无法抓包
  15. 南京互联网IT公司推荐
  16. 怎么给表格加一列序号_excel表格怎么添加序号
  17. 2022下半年软考什么时候开始报名?
  18. 初体验微信小程序记事本
  19. Linux-CentOS上的服务搭建
  20. Browser --- 更换bookmark、homepage及常见问题

热门文章

  1. 福大软工1816 · 第一次团队作业
  2. 从零开始学 Web 之 ES6(三)ES6基础语法一
  3. C++primer 9.3.3节练习
  4. 关于msi格式的程序包的安装
  5. jquery 鼠标事件汇总
  6. [Linux] - xxx 不在 sudoers 文件中。此事将被报告。
  7. sysbench相关
  8. 算法导论-VLSI芯片测试问题
  9. Linux Shell Record
  10. Flutter 实现微信摇一摇的功能 Flutter 加速度感应