音频频谱 + 波形图绘制
我们经常有看到音乐播放器播放界面会有频谱图显示,感觉很炫,今天我就带大家来实现频谱图,顺便将波形图绘制也分享给大家,这里重点讲频谱这块。我们这里的频谱采用8分频fft,这里的8分频指的是什么呢,了解音频的知道,普清的音频数据是44.1K的采样率(每秒采样44.1K个点),安卓的默认输出采样也是44.1K(这也就是说,即时你手机下载的高清音频,实际播放出来,安卓底层已经重采样过了,进行了压缩,并不是真实的高清音频)。了解fft的都知道,数据经过fft算法处理,得出来的新数据会呈线性分步,每个点的间隔都是一样的,就算按最低标准44.1K的音频做频谱绘制,都会有很多低频段的点取不到,所以我们需要降低采样率,8分频可以理解为将采样率降低8倍,这样同样1024个数据,fft运算后的频率密度就会增大,也可以说间隔就会减小,这样就可以顺利取到需要展示的低频段,一般做8分频就足够。这里以1K数据为单位进行绘制,区间是20HZ—20KHZ(标准)。理论不多说,咱先上图:
因为我们绘制的频谱图是需要纯音频数据的,所以我们这里就用wav格式的音频,如果用mp3等其他压缩格式的音频还需要先做解码,那样就偏离重心了。拿到wav格式的音频,我们该从哪里入手,做哪些准备工作,相信大家要做这样的效果出来还是很easy的,重要的是显示的频谱够不够专业。首先我们要做好准备工作,wav的采样率和每一个采样点的编码长度是频谱绘制过程中需要的重要数据,那么我们第一步自然是解析wav音频的标签头;第二步则需要获取声道中的音频数据。wav的标签头的格式网上一大堆,我这里就不分析了,结尾处会提供整个源码给大家。
准备工作做好了,我们就可以开始绘制了,这里分以下几步:
第一步、横坐标的绘制:
刚才说了绘制区间是20HZ—20KHZ(一般都是这个区间,可以根据需求改),我们分成31段来展示,因为UI效果的考虑,横坐标,每隔一段绘制一个坐标值,也就是要绘制16个坐标值,这16个坐标值的数又该怎么取,头和尾不用说,20HZ和20KHZ,中间的呢,怎么取值才能体现总体高低频的电频变动,当然有相应的算法,为了方便理解,用代码展示:
int[] loc = new int[SPECTROGRAM_COUNT];//SPECTROGRAM_COUNT = 31,即段数
double[] sampleratePoint = new double[SPECTROGRAM_COUNT];
for (int i = 0; i < loc.length; i++){
//20000表示的最大频点20KHZ,这里的20-20K之间坐标的数据成对数关系,这是音频标准
double F = Math.pow(20000 / 20, 1.0 / SPECTROGRAM_COUNT);//方法中20为低频起点20HZ,31为段数
sampleratePoint[i] = 20 * Math.pow(F, i);//乘方,30为低频起点,sampleratePoint数组中存的就是坐标值
}
因为坐标是定值,为了方便,我先算出来直接放到数组里了。
/**
* 绘制频率坐标
*/
private void drawSpectrogramAxis() {
//这里对应的值是30HZ-20KHZ,中间的值成对数关系,即sampleratePoint
String[] X_axis = {"20Hz", "46Hz", "69Hz", "105Hz", "160Hz", "244Hz",
"371Hz", "565Hz", "859Hz", "1.30KHz", "1.98KHz", "3.02KHz",
"4.60KHz", "7.00KHz", "10.6KHz", "20KHz"};
float x_step = LineViewWidth / SPECTROGRAM_COUNT;
//这里计算的是格子的宽度
float width = x_step - XINTERVAL;
// 横坐标(Hz)
mPaint.setColor(textColor);
mPaint.setAlpha(getTextAlpha());
mPaint.setTextSize(15f);
for (int i = 0; i < X_axis.length; i++) {
float textWidth = mPaint.measureText(X_axis[i]);
//这里是为了计算格子跟字的宽度差,用来确定字的位置,确保字跟方格中心在一条直线
float xBad = (width - textWidth) / 2;
float x = XINTERVAL + 2 * i * x_step + xBad;
//获取文字上坡度(为负数)和下坡度的高度
Paint.FontMetrics font = mPaint.getFontMetrics();
float y = -(font.ascent + font.descent) / 2;
canvas.drawText(X_axis[i], x, LineViewHeight - YINTERVAL/2 + y, mPaint);
}
}
第二步、8分频fft运算:
1、8分频取点
/**
* 显示频谱时进行FFT计算
*
* @param buf
* @param samplerate 采样率
*/
public void spectrogram(int[] buf, double samplerate) {
first_fft_real[0] = buf[0];
first_fft_imag[0] = 0;
second_fft_real[0] = buf[0];
second_fft_imag[0] = 0;
for (int i = 0; i < FFT_SIZE; i++) {
first_fft_real[i] = buf[i];
first_fft_imag[i] = 0;
// 八分频(相当于降低了8倍采样率),这样1024缓存区中的fft频率密度就越大,有利于取低频
second_fft_real[i] = (buf[i * 8] + buf[i * 8 + 1] + buf[i * 8 + 2]
+ buf[i * 8 + 3] + buf[i * 8 + 4] + buf[i * 8 + 5]
+ buf[i * 8 + 6] + buf[i * 8 + 7]) / 8.0;
second_fft_imag[i] = 0;
}
// 高频部分从原始数据取
fft(first_fft_real, first_fft_imag, FFT_SIZE);
// 八分频后的1024个数据的FFT,频率间隔为5.512Hz(samplerate / 8),取低频部分
fft(second_fft_real, second_fft_imag, FFT_SIZE);
//这里算出的是每一个频点的坐标,对应横坐标的值,因为是定值,所以只需要算一次
if (loc == null) {
loc = new int[SPECTROGRAM_COUNT];
sampleratePoint = new double[SPECTROGRAM_COUNT];
for (int i = 0; i < loc.length; i++) {
//20000表示的最大频点20KHZ,这里的20-20K之间坐标的数据成对数关系,这是音频标准
double F = Math.pow(20000 / 20, 1.0 / SPECTROGRAM_COUNT);//方法中20为低频起点20HZ,31为段数
sampleratePoint[i] = 20 * Math.pow(F, i);//乘方,30为低频起点
//这里的samplerate为采样率(samplerate / (1024 * 8))是8分频后点FFT的点密度
loc[i] = (int) (sampleratePoint[i] / (samplerate / (1024 * 8)));//估算出每一个频点的位置
}
}
//低频部分
for (int j = 0; j < LowFreqDividing; j++) {
int k = loc[j];
// 低频部分:八分频的数据,取31段,以第14段为分界点,小于为低频部分,大于为高频部分
// 这里的14是需要取数后分析确定的,确保低频有足够的数可取
real[j] = second_fft_real[k]; //这里的real和imag对应fft运算的实部和虚部
imag[j] = second_fft_imag[k];
}
// 高频部分,高频部分不需要分频
for (int m = LowFreqDividing; m < loc.length; m++) {
int k = loc[m];
real[m] = first_fft_real[k / 8];
imag[m] = first_fft_imag[k / 8];
}
}
2、fft算法(网上有很多fft算法可以参照,我这里只是其中一种)
/**
* 快速傅立叶变换,将复数 x 变换后仍保存在 x 中(这个算法可以不用理解,直接用),转成频率轴的数(呈线性分步)
* 计算出每一个点的信号强度,即电频强度
*
* @param real 实部
* @param imag 虚部
* @param n 多少个数进行FFT,n必须为2的指数倍数
* @return
*/
private int fft(double real[], double imag[], int n) {
int i, j, l, k, ip;
int M = 0;
int le, le2;
double sR, sI, tR, tI, uR, uI;
M = (int) (Math.log(n) / Math.log(2));
// bit reversal sorting
l = n / 2;
j = l;
// 控制反转,排序,从低频到高频
for (i = 1; i <= n - 2; i++) {
if (i < j) {
tR = real[j];
tI = imag[j];
real[j] = real[i];
imag[j] = imag[i];
real[i] = tR;
imag[i] = tI;
}
k = l;
while (k <= j) {
j = j - k;
k = k / 2;
}
j = j + k;
}
// For Loops
for (l = 1; l <= M; l++) { /* loop for ceil{log2(N)} */
le = (int) Math.pow(2, l);
le2 = (int) (le / 2);
uR = 1;
uI = 0;
sR = Math.cos(PI / le2);// cos和sin消耗大量的时间,可以用定值
sI = -Math.sin(PI / le2);
for (j = 1; j <= le2; j++) { // loop for each sub DFT
// jm1 = j - 1;
for (i = j - 1; i <= n - 1; i += le) {// loop for each butterfly
ip = i + le2;
tR = real[ip] * uR - imag[ip] * uI;
tI = real[ip] * uI + imag[ip] * uR;
real[ip] = real[i] - tR;
imag[ip] = imag[i] - tI;
real[i] += tR;
imag[i] += tI;
} // Next i
tR = uR;
uR = tR * sR - uI * sI;
uI = tR * sI + uI * sR;
} // Next j
} // Next l
return 0;
}
3、绘制频谱(纵坐标方格数表示电频大小):
/**
* 柱形频谱:方格方式显示
*
* @param real 实部
* @param imag 虚部
*/
private void drawGridTypeSpectrogram(double real[], double imag[]) {
double model;
int[] local = new int[ROW_LOCAL_COUNT];
//计算绘制频谱格子的宽度
float x_step = LineViewWidth / SPECTROGRAM_COUNT;
//格子的高度
float y_step = LineViewHeight / ROW_LOCAL_COUNT;
canvas.save();
canvas.translate(0, -10);
//SPECTROGRAM_COUNT = 31(取采样频率点数)
for (int i = 0; i < SPECTROGRAM_COUNT; i++) {
model = 2 * Math.hypot(real[i], imag[i]) / FFT_SIZE;// 计算电频最大值,Math.hypot(x,y)返回sqrt(x2+y2),最高电频
for (int k = 1; k < ROW_LOCAL_COUNT; k++) {
if (model >= row_local_table[k - 1]
&& model < row_local_table[k]) {
local[i] = k - 1;//这里取最高电频所对应的方格数
break;
}
}
//ROW_LOCAL_COUNT = 32(最多可绘制的方格数),这里采用的是逆向思维绘制,y = 0对应最大方格数32
local[i] = ROW_LOCAL_COUNT - local[i];
// 柱形
if (Signaled) {
mPaint.setColor(sepColor);
mPaint.setAlpha(getSepAlpha());
} else {
mPaint.setColor(sepColor);
mPaint.setAlpha(getSepAlpha());
}
float x = XINTERVAL + i * x_step;
//从下往上绘制方格
for (int j = ROW_LOCAL_COUNT; j > local[i]; j--) {
float y = (j - 1) * y_step;
canvas.drawRect(x, y, x + x_step - XINTERVAL, y + y_step - YINTERVAL,
mPaint);// 绘制矩形
}
// 绿点
if (Signaled) {
mPaint.setColor(topColor);
mPaint.setAlpha(getSepAlpha());
} else {
mPaint.setColor(topColor);
mPaint.setAlpha(getSepAlpha());
}
//下面部分是用来显示落差效果的,没有强大的理解能力可能会绕晕,所以我也不做过多注释,看个人天赋吧
//local[i] < top_local[i]说明最高点改变(local[i]越小,点越高,这里需要注意)
if (local[i] < top_local[i]) {
//当进入到这里,则说明需要更新最大电频,这个可能发生在上一次最大电频方格下降的过程中
top_local[i] = local[i];
top_local_count[i] = 0;//清零,如果top值未更新,则循环10次开始下落
} else {
top_local_count[i]++;
//这里top_local_count这个是用来记录达到top值的柱体,然后会循环10次才开始下落
// top_local_count中小于DELAY的top_local都保持不变
if (top_local_count[i] >= DELAY) {
top_local_count[i] = DELAY;
//这里控制下降的速度
top_local[i] = local[i] > (top_local[i] + 1) ? (top_local[i] + 1) : local[i];
}
}
//y增加则最高位方格下降
float y = top_local[i] * y_step;
canvas.drawRect(x, y, x + x_step - XINTERVAL, y + y_step - YINTERVAL, mPaint);// 最高位置的方格
}
canvas.restore();
}
第四步、直接在onDraw方法中调用绘制方法即可;
为了控制篇幅长度,我这里就不讲波形图的绘制了以及如何使用了,大家动动手自己去实现吧