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

b3e65b2fea04b5144b85289a34a8d389.png

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

前言

在上篇文章中我们已经拿到了频谱数据,也知道了数组每个元素表示的是振幅,那这些数组元素之间有什么关系呢?根据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,可以说频率和音调之间的变化并不是呈线性关系,而是某种对数的关系。因此在实现动画时将数据从等频率间隔划分成对数增长的间隔更合乎人类的听感。

90811a08239fe415ad53563f07515d36.png

图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

}

}

}

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

f8c487c561bac2996529185fdeb9218c.gif

图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

}

8c38e574a2a545cf7c18b46ca68bae87.gif

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

低频部分的能量相比中高频大许多,但实际上低音听上去并没有那么明显,这是为什么呢?这里涉及到响度的概念:响度(loudness又称音响或音量),是与声强相对应的声音大小的知觉量。声强是客观的物理量,响度是主观的心理量。响度不仅跟声强有关,还跟频率有关。不同频率的纯音,在和1000Hz某个声压级纯音等响时,其声压级也不相同。这样的不同声压级,作为频率函数所形成的曲线,称为等响度曲线。改变这个1000Hz纯音的声压级,可以得到一组等响度曲线。最下方的0方曲线表示人类能听到的最小的声音响度,即听阈;最上方是人类能承受的最大的声音响度,即痛阈。

2bed8478d04d5ec247a14ec5fc598614.png

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

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

b333c6edaa47609274c14922b44b4ca0.png

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

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

private func createFrequencyWeights() -> [Float] {

let Δf = 44100.0 / Float(fftSize)

let bins = fftSize / 2 //返回数组的大小

var f = (0..

f = f.map { $0 * $0 }

let c1 = powf(12194.217, 2.0)

let c2 = powf(20.598997, 2.0)

let c3 = powf(107.65265, 2.0)

let c4 = powf(737.86223, 2.0)

let num = f.map { c1 * $0 * $0 }

let den = f.map { ($0 + c2) * sqrtf(($0 + c3) * ($0 + c4)) * ($0 + c1) }

let weights = num.enumerated().map { (index, ele) in

return 1.2589 * ele / den[index]

}

return weights

}

更新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

}

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

5d213d070b6a73828a7ff53b5767c336.gif

图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..

for i in startIndex..

//3: zip作用: zip([a,b,c], [x,y,z]) -> [(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

}

e3b1fadd3df2f2a6406276f36e94c603.gif

图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..

spectrumBuffer.append(Array(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

}

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

27480a83cab70d7323b0f2895d85b0fb.gif

结尾

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

参考资料

[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进行对比^_^,会发现区别。作者:potato04

链接:https://juejin.im/post/5c26d44ae51d45619a4b8b1e

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/533017.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

php删除尾部字符,php如何删除字符串末尾字符

我们知道字符串删除字符的方式有好几种,今天就来介绍三种php删除字符串最后一个字符的函数,有需要的小伙伴可以参考一下。方法一:substr()函数substr()函数返回字符串的一部分。语法如下:substr(string string, int start, int [l…

empinfo Oracle数据库,Oracle数据库---包

--根据员工号或员工姓名获取员工的信息--根据员工号或员工姓名删除员工的信息--创建包规范CREATE OR REPLACE PACKAGE overload_pkgISFUNCTION get_info(eno NUMBER) RETURN emp%ROWTYPE;FUNCTION get_info(name VARCHAR2) RETURN emp%ROWTYPE;PROCEDURE del_emp(eno NUMBER);P…

oracle查看context,oracle context(上下文)

context在计算机领域翻译为上下文context的信息也就是当前会话中的环境变量,如:登录的session_id,用户名,语言等信息查看context中的属性信息。oracle默认的为我们创建了一个context叫userenv(user environment)SYS_CONTEXT(USERE…

oracle标量子查询的优势,标量子查询

--标量子查询select e.empno, e.ename, e.sal, e.deptno,(select d.dname from dept d where e.deptno d.deptno)as dnamefrom emp e--插入一条数据insert into emp(empno,deptno) values(9999,null)--返回结果15条记录--改成left join(hash outer)select e.empno, e.ename, e…

切割照片php上传,php下ajax的文件切割上传

var myForm document.getElementById("myForm");var upfile document.getElementById("upfile");myForm.onsubmit function() {//获取文件对象var file upfile.files[0];//获取文件大小var fileSize file.size;//一次截取的大小(字节)var CutSize 10…

oracle插补缺失日期,Oracle连接 ORA-28001: 口令已经失效解决方法

cmd进入命令行C:UsersAdministrator>sqlplus / as sysdbaSQL*Plus: Release 11.2.0.1.0 Production on 星期四 9月 24 15:19:21 2020Copyright (c) 1982, 2010, Oracle. All rights reserved.连接到:Oracle Database 11g Enterprise Edition Release 11.2.0.1.0 - 64bit Pr…

PHP 蒙太奇马赛克拼图,AndreaMosaic制作一幅马赛克拼图

大家在网上应该都见过用很多幅图片拼成的马赛克图片,今天小编就为大家介绍AndreaMosaic制作一幅马赛克拼图方法,不会的朋友快快来学习吧!软件名称:AndreaMosaic(蒙太奇图片制作软件) V6.1.0.4 中文安装免费版软件大小:…

php mongo 查询count,[PHP] 使用PHP在mongodb中进行count查询

原文:https://www.cnblogs.com/taoshihan/p/12362111.html在php7的mongodb扩展中,当要查询某个集合在某个条件下的数据个数时,可以使用下面的方式来获取。比原生的命令要复杂许多比旧版mongo扩展也复杂许多需要使用到MongoDB\Driver\Command …

oracle字段类型设计,Oracle字段类型设计与实际业务不符引发的问题

在Oracle表的设计过程中,开发人员总是对字段的类型不以为然,下面来演示一个例子,按照应该设计为number的,结果设计成了varcha在Oracle表的设计过程中,开发人员总是对字段的类型不以为然,下面来演示一个例子…

linux下进程监控6,Linux进程监控技术—精通软件性能测试与LoadRunner最佳实战(6)...

8.2.5 Linux操作系统进程监控技术Linux在进程监控方面同样出色,不仅可以通过图形用户界面的管理工具,还可以用命令方式显示进程相关信息。像“Windows的任务管理器”一样,在RedHat 9中可以通过单击“系统工具”→“系统监视器”,…

linux pcie命令,setpci命令_Linux setpci 命令用法详解:查询和配置PCI设备的使用工具...

setpci命令是一个查询和配置PCI设备的使用工具。语法setpci(选项)(参数)选项-v:显示指令执行的细节信息;-f:当没有任何操作需要完成时,不显示任何信息;-D:测试模式,并不真正将配置信息写入寄存器…

linux proc文件 write的原子性,Linux命令之write调用的原子性

linux命令是对Linux系统进行管理的命令。本文介绍的关于linux命令中write调用的原子性的详细描述,具体内容如下所述。UNIX环境高级编程中关于原子操作的介绍,其中有一种情形是在文件尾端添加数据。文中说,如果多个进程都需要将数据添加到某一…

linux 命令行 迅雷替代,Mac/Linux下迅雷替代方案

还记得我两年前写的《DIY了家用NAS》吗?现在又带来新的升级啦。当初的NAS最多能使用Transmission来进行BT下载,那时就在想,如果能下载普通的http资源就好了。再进一步,有什么方案可以通吃所有下载方式呢? 记得那个时候…

linux好用的编译器,推荐几款Linux下比Notepad++好的编辑器软件

Notepad这一段又出风头了,好好的做你软件多好,非得参杂入政治。前两天开源文本编辑器 Notepad 发布了 7.8.1 版本,然后在该版本中作者居然摸黑中国,具体的内容请大家自行百度。而且这已经不是 Notepad 第一次这么干了!…

linux下调用python脚本,Linux下QT调用Python脚本的解决方案,Qt,python,一种,解决办法

最近在做一个深度学习对图片中对象识别效果的检测工具,其主要功能就是将自己标注的图片与识别结果图片进行对比然后计算识别的准确等参数,并提供原图与结果图片的显示功能。脚本主要完成识别与计算功能,QT完成数据的整理显示与图片的显示。我…

linux获取bind返回值信息,v$sql_bind_capture 获取绑定变量信息

截取自v$sql_bind_capture 对于游标中定义的每一个绑定变量都会有视图中的一行对应。主要包含三个部分:指向父游标(hash_value, address)和子游标(hash_value, child_address)的信息,变量类型定义,变量的值(不包含复杂的值:LONG,LOB,和…

linux boost教程,Linux上安装使用Boost入门指导

获得boostboost分布只需要头文件的库使用boost建立一个简单的程序准备使用boost二进制文件库把你的程序链接到boost库1.获得boost解压2.boost分布boost_1_46_1.........................boost根目录boost/.....................................所有boost头文件libs/..........…

vps如何linux内核4.19,Linux kernel 4.19 RC1 发布,一个相当大的版本

原标题:Linux kernel 4.19 RC1 发布,一个相当大的版本Linus Torvalds今天发布了第一个候选版本(RC),正式启动了即将推出的Linux 4.19内核系列的开发周期。自Linux 4.18内核系列推出以来已经过去两周了,因此下一个主要版本Linux ke…

linux下变量名长度,Linux中shell的变量介绍

Linux中shell的变量介绍发布时间:2020-06-24 16:20:39来源:亿速云阅读:112作者:元一这期内容当中的小编将会给大家带来有关Linux中shell的变量介绍,以专业的角度为大家分析和叙述,阅读完这篇文章希望大家可…

0 0/2 * * * ? linux文本含义,Linux基础2.0

1、硬盘使用的步骤识别硬盘 > 分区规划 > 格式化 > 挂载使用2、列出创建ext3、ext4、xfs、fat32文件系统的格式化工具及用法mkfs.ext3 分区设备路径mkfs.ext4 分区设备路径mkfs.xfs 分区设备路径mkfs.vfat -F 32 分区设备路径3、开机自动挂载配置文件及6个字段/etc/fs…