php绘制频谱图,一步一步教你实现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中,使得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..
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
}
再次运行项目看看效果,好多了是吗?
图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
}
图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
}
再次运行项目,得到最终效果:
结尾
音频频谱的动画实现到此已经全部完成。本人之前对音频和声学毫无经验,两篇文章涉及的方法理论均参考自互联网,肯定有不少错误,欢迎指正。
参考资料
[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
php绘制频谱图,一步一步教你实现iOS音频频谱动画(二)相关推荐
- 一步一步教你实现iOS音频频谱动画(一)
如果你想先看看最终效果再决定看不看文章 -> bilibili 示例代码下载 第二篇:一步一步教你实现iOS音频频谱动画(二) 基于篇幅考虑,本次教程分为两篇文章,本篇文章主要讲述音频播放和频谱 ...
- 一步一步教你实现iOS音频频谱动画(二)
如果你想先看看最终效果再决定看不看文章 -> bilibili 示例代码下载 第一篇:一步一步教你实现iOS音频频谱动画(一) 本文是系列文章中的第二篇,上篇讲述了音频播放和频谱数据计算,本篇讲 ...
- 音频断句Matlab,一步一步教你实现iOS音频频谱动画(二)
如果你想先看看最终效果再决定看不看文章 -> bilibili 示例代码下载 本文是系列文章中的第二篇,上篇讲述了音频播放和频谱数据计算,本篇讲述数据处理和动画的绘制. 前言 在上篇文章中我们已 ...
- 利用python绘制雪景图_彩铅 · 教程 | 教你画一张唯美雪景小图
准 备 画 材 ★ 彩铅:辉柏嘉48色水溶性彩铅 ★ 纸张:荷兰白卡A5大小 ★ 超级nice的心情 详 细 步 骤 ▶step1:线稿.为了留出好看的白边,先用纸胶带框出画面范围.线稿很简单,整个画 ...
- Processing Arduino 音频频谱显示
文章目录 前言 一.音频频谱简介 二.使用Minim库显示音频频谱 2.1 Minim文档中的例子 2.2 部分代码的解释 2.3 改写对数平均的频谱显示 三.调整优化 3.1 节奏匹配 3.2 锯齿 ...
- 重温傅里叶--深入篇1--傅里叶级数与傅里叶变换的关系以及频谱图的介绍
在读本文前,请先大致浏览一下笔记篇里的东西,下面使用的符号及其意义都跟笔记篇里是一致的.笔记篇里记录的大都是基础的公式,教科书上都可以找到. (抱歉,刚发现有点小错误:在式(6-4)和式(11)里,积 ...
- 理解图像傅里叶变换的频谱图
很多人都不了解图像(二维)频谱中的每一点究竟代表了什么,有什么意义? 一句话解释为: 二维频谱中的每一个点都是一个与之一 一对应的二维正弦/余弦波. 视觉的优势永远大于其他器官对人的作用,所以对标眼睛 ...
- 音频处理相关内容学习——自动编码器——变分自动编码器——频谱图
文章目录 概述 一.Approaches And Challenges 生成的声音是什么类型 训练模型使用的是什么特征 原始音频Raw Audio 频谱图Spectrograms 声音生成模型常用的结 ...
- 【信号与系统】如何得到原始图片的频谱图?(Matlab)
图像处理是指对图像进行分析.加工.和处理,使其满足视觉.心理或其他要求的技术.图像处理是信号处理在图像领域上的一个应用.目前大多数的图像均是以数字形式存储,因而图像处理很多情况下指数字图像处理.此外, ...
最新文章
- 传承德艺之馨——上海文联第八次代表大会侧记
- 需求分析师的工作重点
- Thymeleaf——访问静态资源(static)解决方案
- 【STM32】GPIO功能复用
- Linux下mp3文件的乱码问题
- 【BZOJ1857】【SCOI2010】传送带 [三分]
- oracle数据库pfile文件,Oracle pfile/spfile参数文件详解
- ES6中object对象属性
- 【JAVA SE】第十一章 正则表达式、包装类和BigDecimal
- WebGraph++编译
- 表单防重复提交拦截器
- 极限学习机Python的代码实现
- StretchDIBits 的使用
- Golang-web网站入门-服务器入门
- 神州信息与瀚华金控签署战略协议 共推数字普惠金融
- html边框倾斜,弯曲的边框CSS实现
- Roxe:大涨时毅然销毁99% ROC 专注解决跨境汇款难题
- python做相册_《自拍教程73》Python 自动生成相册文件夹
- 【报告分享】 2021年天猫618商务合作方案-天猫x阿里妈妈(附下载)
- 投影坐标系的shp数据,如何获取到它地理坐标系下的经纬度坐标
热门文章
- JavaScript从入门到放弃 - (一)构造函数和原型
- python加载模型包占用内存多大_如何保持Keras模型加载到内存中并在需要时使用它? - python...
- 设计java application程序_下面哪些步骤是Java Application程序的建立及运行的步骤( )...
- linux上怎么解压zip文件和tar.gz文件
- oracle 批量导出sequence,如何单独导出导入sequence?
- android 5. 蓝牙 mesh,蓝牙mesh组网
- 对象工厂PHP,php – 域对象工厂是什么样的?
- php io流 读取wav,记php中的io流---帮助理解
- 3d打印主要的切片参数类型_3D打印机切片参数详情说明
- java中随机数边界问题,java 简单Dice问题(随机数的运用)