先看效果图
前言
最近要做一个这样的需求,但苦于没有对应的android参考案例,所以在自己琢磨出来后,打算写一篇文章记着,顺便也分享给大家!!(android没有,但ios有,我也是参考了那位大佬后,自己逐步实现的,思路和他的相似)文章写的多有不足,欢迎指点!!
写在代码前
首先写代码之前,我们得先认识一下一些基本概念!
音频数字化
- 采样: 众所周知,声音是一种压力波,是连续的,然而在计算机中无法表示连续的数据,所以只能通过间隔采样的方式进行离散化,其中采集的频率称为采样率。根据奈奎斯特采样定理 ,当采样率大于信号最高频率的2倍时信号频率不会失真。人类能听到的声音频率范围是20hz到20khz,所以CD等采用了44.1khz采样率能满足大部分需要。
- 量化: 每次采样的信号强度也会有精度的损失,如果用16位的Int(位深度)来表示,它的范围是[-32768,32767],因此位深度越高可表示的范围就越大,音频质量越好。
- 声道数: 为了更好的效果,声音一般采集左右双声道的信号,如何编排呢?一种是采用交错排列(Interleaved):
LRLRLRLR,另一种采用各自排列(non-Interleaved):LLL RRR。
以上将模拟信号数字化的方法称为脉冲编码调制(PCM),而本文中我们就需要对这类数据进行加工处理。
傅里叶变换
现在我们的音频数据是时域,也就是说横轴是时间,纵轴是信号的强度,而动画展现要求的横轴是频率。将信号从时域转换成频域可以使用傅里叶变换实现,信号经过傅里叶变换分解成了不同频率的正弦波,这些信号的频率和振幅就是我们需要实现动画的数据。
实际上计算机中处理的都是离散傅里叶变换(DFT),而快速傅里叶变换(FFT)是快速计算离散傅里叶变换(DFT)或其逆变换的方法,它将DFT的复杂度从O(n²)降低到O(nlogn)。
(以上几则概念摘自juejin.cn/post/684490… 作者:potato04
在安卓中有挺多开源库可以支持快速傅里叶变换的,这里我们只选一个java库来实现就好。
//math3库做快速傅里叶变换
implementation("org.apache.commons:commons-math3:3.6.1")
总体代码实现
一、录音(直接使用安卓自带的库录音即可,这里选AudioRecord)
1.录音权限(除此之外,还得动态获取权限)
<uses-permission android:name="android.permission.RECORD_AUDIO" />
2.录音需要的常量配置和拿录音实例
const val audioEncodingPCM = AudioFormat.ENCODING_PCM_16BIT
const val sampleRateInHz = 44100
const val channel = AudioFormat.CHANNEL_IN_STEREO
//2.根据录音参数构造AudioRecord实体对象
bufferSize = AudioRecord.getMinBufferSize(sampleRateInHz, channel, audioEncodingPCM)
audioRecord = AudioRecord(
MediaRecorder.AudioSource.MIC,
sampleRateInHz, channel, audioEncodingPCM, bufferSize
)
3.录音并拿到Short类型的2048 个 frame 的 buffer
audioRecord.startRecording()
//这里面几乎有8000个数据,太多了,取2048个够了,对于目前傅里叶变换库比较友好
//val shortBuffer = ShortArray(bufferSize)
val shortBuffer = ShortArray(2048)
while (state == RECORDING) {
//3.不断读取录音数据并保存至文件中
val readSize = audioRecord.read(shortBuffer, 0, shortBuffer.size)
if (callback != null) {
callback!!.onCallback(shortBuffer, readSize)
}
}
//4.当执行stop()方法后state != RecordState.RECORDING,终止循环,停止录音
audioRecord.stop()
二、傅里叶变换fft
fun fft(buffer: ShortArray): Triple<Int, Double, DoubleArray> {
val n = buffer.size
val real = DoubleArray(n)
val mHanNing = getHanNingWindow(n)//加汉宁窗,爱加不加,影响不大
for (i in 0 until n) {
real[i] = (buffer[i] * mHanNing[i]) / (Short.MAX_VALUE.toDouble())
}
val imag = DoubleArray(real.size)
val complexData = arrayOfNulls<Complex>(real.size)// 创建复数数组
for (i in real.indices) {
imag[i] = 0.0 // 给虚部赋值
complexData[i] = Complex(real[i], imag[i]) // 创建复数
}
//执行fft,对数据有要求,必须是2^n倍,比如2048,4096,8192
val fft = FastFourierTransformer(DftNormalization.STANDARD)
val fftResult = fft.transform(complexData, TransformType.FORWARD)//正向fft
//获取频谱数据(振幅)
val spectrum = DoubleArray(fftResult.size)
for (i in fftResult.indices) {
spectrum[i] = fftResult[i].abs()//使用abs返回计算的振幅
}
//分段(顾名思义就是根据频率范围(20-20000)划分为指定分数(这里是100份)
val bandsFrequency = onBand(n, spectrum)
return bandsFrequency
}
三、 频带划分
我们已经拿到了频谱数据,也知道了数组每个元素表示的是振幅,那这些数组元素之间有什么关系呢?根据FFT的原理, N个音频信号样本参与计算将产生N/2个数据(2048/2=1024),其频率分辨率△f=Fs/N = 44100/2048≈21.5hz,而相邻数据的频率间隔是一样的,因此这1024个数据分别代表频率在0hz、21.5hz、43.0hz....22050hz下的振幅。
那是不是可以直接将这1024个数据绘制成动画?当然可以,如果你刚好要显示1024个动画物件!但是如果你想可以灵活地调整这个数量,那么需要进行频带划分---即分段。
频带划分更重要的原因其实是这样的:根据心理声学,人耳能容易的分辨出100hz和200hz的音调不同,但是很难分辨出8100hz和8200hz的音调不同,尽管它们各自都是相差100hz,可以说频率和音调之间的变化并不是呈线性关系,而是某种对数的关系。因此在实现动画时将数据从等频率间隔划分成对数增长的间隔更合乎人类的听感。
(摘自juejin.cn/post/684490… 作者:potato04)
分段,这里根据频率范围(20-20000)划分为指定分数(这里是100份), 然后分别找出这一百份频段中各自的最高振幅作为这个该频段的点保存到新的数组中去等待绘制。
// 分段的频率的范围和步长(段数)
val frequencyBands = 100
val startFrequency = 20.0
val endFrequency = 20000.0
```
private fun onBand(
bufferSize: Int,
spectrum: DoubleArray
): Triple<Int, Double, DoubleArray> {
//1: 创建权重数组
val aWeights = createFrequencyWeights(bufferSize)
val bandsMaxFreqAmp = mutableListOf<Pair<Double, Double>>()//存储各分段的最高频率和最高振幅
//1:根据起止频谱、频带数量确定增长的倍数:2^n
val n = log2(endFrequency - startFrequency) / frequencyBands
var lowFrequency = startFrequency
for (i in 1..frequencyBands) {
//2:频带的上频点是下频点的2^n倍
val heightFrequency = lowFrequency * Math.pow(2.0, n)//2^n倍数
var maxSpectrumInBands = Double.MIN_VALUE
var centerFrequency = Double.MIN_VALUE
val fs = RecordHelperShort.sampleRateInHz / bufferSize
val startFrequencyIndex = Math.max(lowFrequency / fs, startFrequency)
val endFrequencyIndex = if (i == frequencyBands) endFrequency else (heightFrequency / fs)
for (j in spectrum.indices) {
// 假设采样率为44100Hz/窗体大小,也就是数据的大小
val frequency = (j * fs).toDouble()
if (frequency in startFrequencyIndex..endFrequencyIndex) {
if (spectrum[j] > maxSpectrumInBands) {
maxSpectrumInBands = spectrum[j]
centerFrequency = frequency
}
}
}
val nextBand = Pair(centerFrequency, maxSpectrumInBands)//每段
bandsMaxFreqAmp.add(nextBand)
lowFrequency = heightFrequency//上一次的高频变成这次的低频
}
val endSpectrum = DoubleArray(bandsMaxFreqAmp.size)//最终去绘制的振幅(频谱)
val endFrequency = DoubleArray(bandsMaxFreqAmp.size)//最终去绘制的振幅的对应频率
// 输出每个频段的中心频率和最大幅度
for (index in 0 until bandsMaxFreqAmp.size) {
//在筛选有效的原始频谱数据后依次与权重相乘(结果*2倍数,避免起伏不多)
val haveWeight = (bandsMaxFreqAmp[index].second * aWeights[index]) * 2
endSpectrum[index] = haveWeight//用去绘制频谱图的
endFrequency[index] = bandsMaxFreqAmp[index].first//频率
}
val mSmoothSpectrum = highlightWaveform(endSpectrum)//加权平均处理(平滑处理)
val mBlinkSpectrum = onBlinkOps(mSmoothSpectrum)//闪动优化处理,使用缓存上一帧的方式做
//这个分贝db数据在滤波去噪后拿
val mCurrentMaxDb = getDecibel1(mBlinkSpectrum).toInt()
//用处理后的数据去拿最高振幅对应最高频率
val mCurrentMaxFrequency = endFrequency[getAfterDealMaxFrequency(mBlinkSpectrum)]
val endTripleSource = Triple(mCurrentMaxDb, mCurrentMaxFrequency, mBlinkSpectrum)//组装三个参数
return endTripleSource
}
private fun getAfterDealMaxFrequency(mBlinkSpectrum: DoubleArray): Int {
//再根据这些振幅中拿到最高振幅对于的频率,代表这所有段里面频率去计算色相之类的
var maxAllBandsAmp = Double.MIN_VALUE
//最终拿到最高频率对应的下标,他们的长度是一致的,所以下标有效
var maxAllBandsIndex = 0
for (index in mBlinkSpectrum.indices) {
//拿最高振幅的对应频率,用于计算色相的数据做筛选
if (mBlinkSpectrum[index] > maxAllBandsAmp) {
maxAllBandsAmp = mBlinkSpectrum[index]
maxAllBandsIndex = index
}
}
return maxAllBandsIndex
}
上面代码可以看出,顶部做了A计权的系数数组-->createFrequencyWeights,那么如果不做这个处理会是怎么样的呢?如图(不包含分段的相关图):
节奏不匹配,低频部分的能量相比中高频大许多,但实际上低音听上去并没有那么明显,这是为什么呢?这里涉及到响度的概念。
响度(loudness又称音响或音量),是与声强相对应的声音大小的知觉量。声强是客观的物理量,响度是主观的心理量。响度不仅跟声强有关,还跟频率有关。不同频率的纯音,在和1000Hz某个声压级纯音等响时,其声压级也不相同。这样的不同声压级,作为频率函数所形成的曲线,称为等响度曲线。改变这个1000Hz纯音的声压级,可以得到一组等响度曲线。最下方的0方曲线表示人类能听到的最小的声音响度,即听阈;最上方是人类能承受的最大的声音响度,即痛阈。
横坐标为频率,纵坐标为声压级,波动的一条条曲线就是等响度曲线(equal-loudness contours),这些曲线代表着声音的频率和声压级在相同响度级中的关联。
原来人耳对不同频率的声音敏感度不同,两个声音即使声压级相同,如果频率不同那感受到的响度也不同。基于这个原因,需要采用某种频率计权来模拟使得像人耳听上去的那样。常用的计权方式有A、B、C、D等,A计权最为常用,对低频部分相比其他计权有着最多的衰减,这里也将采用A计权。
蓝色曲线就是A计权,是根据40 phon的等响曲线模拟出来的反曲线。
-----以上响度概念摘自 juejin.cn/post/684490… 《作者:potato04》-------
四、A计权处理
private fun createFrequencyWeights(bufferSize: Int): DoubleArray {
val deltaF = 44100.0 / bufferSize
val bins: Int = bufferSize / 2 // 返回数组的大小
val f = DoubleArray(bins)
for (i in 0 until bins) {
f[i] = (i.toFloat() * deltaF)
}
val f1 = Math.pow(20.598997, 2.0)
val f2 = Math.pow(107.65265, 2.0)
val f3 = Math.pow(737.86223, 2.0)
val f4 = Math.pow(12194.217, 2.0)
val num = DoubleArray(bins)
val den = DoubleArray(bins)
for (i in 0 until bins) {
num[i] = (f4 * Math.pow(f[i], 2.0))
den[i] = ((f[i] + f1) * Math.sqrt((f[i] + f2) * (f[i] + f3)) * (f[i] + f4))
}
val weights = DoubleArray(bins)
for (i in 0 until bins) {
weights[i] = (1.2589 * num[i] / den[i])
}
return weights
}
处理后如图(包含了分段,A计权):
是不是好看了很多?但依然存在问题,各个相邻的长矩形峰值差距多大,不美观,那么如何可以处理呢? 其实处理办法很多种,我这里只用加权平均的方式处理: 加权公式:
fx = (f0×w0 + f1×w1...fn×wn)/(w0+w1...+wn)
以下是代码:
private fun highlightWaveform(spectrum: DoubleArray): DoubleArray {
// 1. 定义权重数组,数组中间的5表示自己的权重,个数需要奇数
val weights = mutableListOf(1.0, 2.0, 3.0, 5.0, 3.0, 2.0, 1.0)
val totalWeights = weights.reduce { acc, d -> acc + d }//累加
val averagedSpectrum = DoubleArray(spectrum.size)
val startIndex = weights.size / 2//结果是3
// 3. 循环+1渐进重叠式计算加权平均值
for (i in startIndex until spectrum.size - startIndex) {
val currentSpe = mutableListOf<Double>()//不要用DoubleArray,会下标对不上
for (j in i - startIndex..i + startIndex) {
currentSpe.add(spectrum[j])
}
//写法一
//zip作用: zip([a,b,c], [x,y,z]) -> [(a,x), (b,y), (c,z)]
//val zipped = currentSpe.zip(weights)
//val averaged = zipped.map {
// it.first * it.second
//}.reduce { acc, d -> acc + d } / totalWeights
//写法二
var sum = 0.0
for (k in currentSpe.indices) {
sum += currentSpe[k] * weights[k]
}
val averaged = sum / totalWeights
//统计到每项去
averagedSpectrum[i] = averaged
}
return averagedSpectrum
}
处理完后,整体来说,已经可以达到文章开头的模样了,只是还差一点,就是每次都会动画闪动的过快,没有一种寻寻渐进的感觉,不够柔和,那要怎么处理呢?
五、动画柔和化
这里采用缓存上一帧的方式做动画柔和,用什么技术呢?还是加权平均,公式参考上面
以下是代码:
private var spectrumBuffer = DoubleArray(frequencyBands)
private fun onBlinkOps(spectrum: DoubleArray): DoubleArray {
val aWeight = 0.8//权重系数,0-1,越大越柔和
if (spectrumBuffer.isEmpty()) {
for (j in spectrum.indices) {
spectrumBuffer[j] = spectrum[j]
}
}
//(方式一)加权平均做优化
//注意把旧数据的比重*aWeight,新数据的比重*(1 - aWeight),这样才可以最大程度保留旧数据的帧,达到柔和目的
for (i in spectrum.indices) {
val sum = (spectrumBuffer[i] * aWeight) + (spectrum[i] * (1 - aWeight))
spectrumBuffer[i] = sum / (aWeight + (1 - aWeight))//加起来就是个1,除不除都行了其实
}
//(方式二)这种方式的加权平均和上述的方式没啥区别
//注意把旧数据的比重*aWeight,新数据的比重*(1 - aWeight),这样才可以最大程度保留旧数据的帧,达到柔和目的
//val zipped = spectrumBuffer.zip(spectrum)
//spectrumBuffer = zipped.map { (it.first * aWeight) + (it.second * (1 - aWeight)) }.toDoubleArray()
return spectrumBuffer
}
写在末尾
以上就是全部教程了,最终效果图如下:
当前这篇文章很多借鉴了隔壁IOS开发的大佬的,也欢迎各位指出文章的不妥!!
参考资料:
[1].juejin.cn/post/684490… potato04 # 一步一步教你实现iOS音频频谱动画(一、二)
[2].juejin.cn/post/689343… 网易技术热爱者 # Android音频可视化
[3].www.mikeash.com/pyblog/frid… 获取和解释音频数据