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…

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

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

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

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

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

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

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

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

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

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

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…

arm linux 存储,linux arm的存储分布那些事

原标题:linux arm的存储分布那些事linux arm 内存分布总览上图是linux的arm的虚拟地址分布总览,我们按从低地址到高地址的顺序逐个描述,每项的描述包括如下的内容的组和:地址范围大小,虚拟转物理的接口函数&#xff0c…

linux恢复终端默认配置,以gnome-terminal为例,修改gnome3 的默认配置,

以gnome-terminal为例,修改gnome3 的默认配置,gnome连续几个版本的terminal默认配置文件都是同一个配置文件“b1dcc9dd-5262-4d8d-a863-c897e6d979b9”。这是因为gnome的developers编辑了这个配置文件并作为gnome-terminal的默认配置文件,用来…

com.sec.android.app.smartclipservice,EPR Aerospace News

The World Cup Ball And Its Astonishing Effects Can Be Easily Explained Through TheTheory Of Dynamic Interactions, Which Also Applies To The Flight Of The Boomerang.The official World Cup ball, the so called “Jabulani”, which has been object of a lot of c…

html表格内文字置顶,css如何让table里的字居中?

css如何让table里的字居中?下面本篇文章就来给大家介绍一下使用CSS让table里字居中的方法。有一定的参考价值,有需要的朋友可以参考一下,希望对大家有所帮助。在CSS中,可以通过设置text-align: center;和vertical-align:middle;来…

html5离开网页自动暂停,通过html5代码在网页中实现播放和暂停音乐mp3,mav等文

介绍通过html5代码在网页中实现播放和暂停音乐mp3,mav等文件的具体操作方法。这样对于用户来说,在线可播放功能能大大提高站内效率也可带来一定的流量。希望对有需要的朋友有所帮助。这里我们需要先了解下,在html5中的两个个重要标签。阿里西西web开发网…

android digest 认证,探究 Android 签名机制和原理

背景最近在调研一个测试工具的使用,在使用中发现被测试工具处理过的apk文件经安装后打开就会崩溃,分析崩溃日志后原因是签名不一致导致的。说到Android中的签名,可能大家都知道签名的目的就是为了保护apk文件的安全,如果apk被恶意…

pm2 start 带参数_3款有海景天窗的国产SUV,最适合带女朋友看星星,首付3万拿下...

夏天就这么缓缓地来了,在某一个周末的晚上,约上心爱的女朋友,开上车子,一路上驰骋在无人的大桥上,放上音乐,开到目的地,打开天窗,看看星星,从诗词歌赋谈到人生哲学&#…

html仿京东快速购物导航,jQuery仿京东楼层滑动侧边栏高亮(原创)

插件描述:jQuery模仿京东侧边栏点击滑动到该楼层,同时侧边栏随着页面滚动对应导航高亮。更新时间:2017/9/30 下午2:48:37更新说明:1,添加了查看评论按钮来改变对应区的高度2,将floorList和navList作为匿名函…

华为云大数据存储的冗余方式是三副本_华为TaurusDB技术解读(转载)

近日,华为云自研关系型数据库 Taurus 公开亮相。作为华为云自研的最新一代云原生分布式数据库,Taurus 完全兼容 MySQL 8.0,采用计算与存储分离、日志即数据的架构设计,支持 1 写 15 读,性能达到原生 MySQL 的 7 倍。性…

unity 是厘米还是米_乔丹19岁才1.75米,2年增高近20公分,这个长高方法你能坚持多久...

相信现在有很多的人希望自己在长大之后能去NBA打篮球,但是因为种种原因,最后还是放弃了这个想法, 很多人是因为自己的身体素质达不到要求,比如说天赋上没有达到一个篮球运动员的身高,还有的是因为自己家庭的原因最后放…

win7变成xp风格了怎么改回_微软看了会沉默,把 Windows 10 变成经典 98 风格

众所周知「Windows 10」官方提供的主题都只是换换壁纸,所以喜欢折腾的小伙伴会选择用「UltraUXThemePatcher」来让「Windows 10」支持第三方主题,实现模仿各种风格,如 macOS、Win7、Ubuntu 等。但是由于「Windows 10」版本太多,导…

2021广东高考成绩排名如何查询,2021年广东高考个人排名怎么查询,广东高考成绩排名查询方法...

高考成绩公布后,很多家长和学生咨询我们,广东高考个人成绩排名位次如何查询:广东高考成绩排名,可以通过省招生考试院发布的广东一分一段表来查询,也可以到聚志愿网站直接输入分数查询,一分一段它显示每一个…