写点什么

带你认识时域、频域与 Android 系统 Visualizer

用户头像
Changing Lin
关注
发布于: 2021 年 05 月 16 日
带你认识时域、频域与Android系统Visualizer

1.项目概要:

本文主要介绍音频信号的时域和频域概念,这两个概念是我学生时代就一直没有好好理解的。希望借此搞懂他们两者之间的关系,以及如何在我们的现实生活中体现和应用,希望对音频开发者有帮助。
复制代码

2.背景和需求

2.1 时域和频域

  • 时域(时间域-time domain)——自变量是时间,即横轴是时间,纵轴是信号的变化。其动态信号 x(t)是描述信号在不同时刻取值的函数。

  • 频域(频率域- frequency domain)——自变量是频率,即横轴是频率,纵轴是该频率信号的幅度,也就是通常说的频谱图。频谱图描述了信号的频率结构及频率与该频率信号幅度的关系。对信号进行时域分析时,有时一些信号的时域参数相同,但并不能说明信号就完全相同。因为信号不仅随时间变化,还与频率、相位等信息有关,这就需要进一步分析信号的频率结构,并在频率域中对信号进行描述。动态信号从时间域变换到频率域主要通过傅立叶级数和傅立叶变换等来实现。很简单时域分析的函数是参数是 t,也就是 y=f(t),频域分析时,参数是 w,也就是 y=F(w)两者之间可以互相转化。

  • 傅立叶变换:将一个表示波的函数从时域(时间与振幅的关系)转化为频域(频率与振幅的关系)的数学操作

2.2 需求

为了更直观感受某个时刻的声音或音频信号,在时域上的表现形式,与在频域上的表现形式的不同,希望开发一个APP来展示两者的不同。
复制代码

3.实现原理

3.1 Android Visualizer(Added in API level 9)

Visualizer类允许应用程序检索当前播放的音频的一部分,以实现可视化目的。它不是音频录制接口,只返回部分和低质量的音频内容。但是,为了保护某些音频数据(例如语音邮件)的隐私,使用可视化工具需要获得android.permission.RECORD_AUDIO的权限。
复制代码

3.2 可以捕捉到两种典型的音频数据

  • Waveform data(水波纹数据): 使用 getWaveForm(byte[])方法连续 8 位(无符号)单声道采样

  • Frequency data(频域数据): 使用 getFft(byte[])方法实现 8 位幅度 FFT

  • 重要代码:


visualizer.setDataCaptureListener(new Visualizer.OnDataCaptureListener() {            @Override            public void onWaveFormDataCapture(Visualizer visualizer, byte[] bytes,                                              int samplingRate) {                BaseVisualizer.this.bytes = bytes;                invalidate();            }
@Override public void onFftDataCapture(Visualizer visualizer, byte[] bytes, int samplingRate) { } }, Visualizer.getMaxCaptureRate() / 2, true, false);
复制代码

3.3 Waveform 数据展示

public class BarVisualizer extends BaseVisualizer {
private float density = 50; private int gap;
public BarVisualizer(Context context) { super(context); }
public BarVisualizer(Context context, @Nullable AttributeSet attrs) { super(context, attrs); }
public BarVisualizer(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); }
@Override protected void init() { this.density = 50; this.gap = 4; paint.setStyle(Paint.Style.FILL); }
/** * Sets the density to the Bar visualizer i.e the number of bars * to be displayed. Density can vary from 10 to 256. * by default the value is set to 50. * * @param density density of the bar visualizer */ public void setDensity(float density) { this.density = density; if (density > 256) { this.density = 256; } else if (density < 10) { this.density = 10; } }
@Override protected void onDraw(Canvas canvas) { if (bytes != null) { float barWidth = getWidth() / density; // 计算每个方块的宽度,等于 当前控件的宽 除以 密度 float div = bytes.length / density; // 计算密度,间隔多少取点 paint.setStrokeWidth(barWidth - gap);
for (int i = 0; i < density; i++) { // 根据密度开始绘制每一个块大小 int bytePosition = (int) Math.ceil(i * div); // 根据当前第几个方块,找到 对应的数据起点 /** * 计算分析: * 1.由于 bytes[bytePosition] 是一个无符号 8bit 数 * 2.因此,其取值范围是:[-128~127] * 3.Math.abs(bytes[bytePosition]) + 128 ==> 归一化为 128~256 * 4.类型转换为 byte ==> 归一化为 -[0~128] * 5.因此,top = h + h*(n/128) // n<0 */ int top = getHeight() + ((byte) (Math.abs(bytes[bytePosition]) + 128)) * getHeight() / 128; // 简化版:top = h + h*(n/128) 其中,n表示当前波形的值且小于0;除以128是因为bytes[bytePosition]是无符号8位数据,因此通过128进行归一化 float barX = (i * barWidth) + (barWidth / 2); // 根据当前第几个方块,计算方块的x轴坐标(方块间隔为 方块宽度的一半) canvas.drawLine(barX, getHeight(), barX, top, paint); } super.onDraw(canvas); } }}
复制代码


  • 如下图所示:


3.4 Frequency 数据展示

  • 设置 Visualizer 的采样回调与采样率,FFT 工作模式


isFFT = true;setDataCaptureListener(new Visualizer.OnDataCaptureListener(), Visualizer.getMaxCaptureRate() / 2, !isFFT, isFFT);
复制代码


  • FFT 数据格式解析


    @Override    protected void onDraw(Canvas canvas) {        if (isFFT) {            byte[] fft = bytes;            int n = fft.length; // 数据长度 与 setCaptureSize采样值大小长度一致            float[] magnitudes = new float[n / 2 + 1];            float[] phases = new float[n / 2 + 1];            magnitudes[0] = (float) Math.abs(fft[0]);      // DC 直流分量            magnitudes[n / 2] = (float) Math.abs(fft[1]);  // Nyquist 奈奎斯特            phases[0] = phases[n / 2] = 0; // 两个频点对应的 相位值为 0w
float barWidth = getWidth() / (float) (magnitudes.length - 1); // 计算每个柱形图的宽度,填充完整个控件的宽度 paint.setStrokeWidth(barWidth);
// Log.i("cclin", String.format("柱形宽度:%s-%d-%d-%d", barWidth, magnitudes.length - 1, getWidth(), n));
canvas.drawLine(0, getHeight(), 0, (1 - magnitudes[0] / 20.0f) * getHeight(), paint); // 绘制第一个点
for (int k = 1; k < n / 2; k++) { int i = k * 2; magnitudes[k] = (float) Math.hypot(fft[i], fft[i + 1]); // 根据实部和虚部计算 点的幅值,sqrt(x2 +y2) phases[k] = (float) Math.atan2(fft[i + 1], fft[i]); // 根据实部和虚部计算 点的相位// Log.i("cclin", String.format("第 %d 个点的值 %f, %f", k, magnitudes[k], phases[k]));
float barX = (k * barWidth) + 0; canvas.drawLine(barX, getHeight(), barX, (1 - magnitudes[k] / 20.0f) * getHeight(), paint); } float barX = (n / 2 * barWidth) + 0; canvas.drawLine(barX, getHeight(), barX, (1 - magnitudes[n / 2] / 20.0f) * getHeight(), paint); }}
复制代码


  • OnDataCaptureListener.onFftDataCapture 方法参数说明:


这里说一下两个回调方法中的第二个参数 byte[] waveform 和 fft,waveform 是波形采样的字节数组,它包含一系列的 8 位(无符号)的 PCM 单声道样本,fft 是经过 FFT 转换后频率采样的字节数组,频率范围为 0(直流)到采样值的一半!返回的数据如上图所示:n 为采样值;Rf 和 lf 分别对应第 k 个频率的实部和虚部;如果 Fs 为采样频率,那么第 k 个频率为(k*Fs)/(n/2);换句话说:频率横坐标的取值范围为[0, Fs]

4.使用方法

4.1 导入 BaseVisualizer

package com.besmart.myvisualizer;
import android.content.Context;import android.graphics.Color;import android.graphics.Paint;import android.media.MediaPlayer;import android.media.audiofx.Visualizer;import android.util.AttributeSet;import android.util.Log;import android.view.View;
import androidx.annotation.Nullable;
import java.util.Arrays;
abstract public class BaseVisualizer extends View { protected byte[] bytes; protected Paint paint; protected Visualizer visualizer; protected int color = Color.BLUE; protected boolean isFFT = true; protected int samplingRate = 0;
public BaseVisualizer(Context context) { super(context); init(null); init(); }
public BaseVisualizer(Context context, @Nullable AttributeSet attrs) { super(context, attrs); init(attrs); init(); }
public BaseVisualizer(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(attrs); init(); }
private void init(AttributeSet attributeSet) { paint = new Paint(); }
/** * Set color to visualizer with color resource id. * * @param color color resource id. */ public void setColor(int color) { this.color = color; this.paint.setColor(this.color); }
/** * @deprecated will be removed in next version use {@link BaseVisualizer#setPlayer(int)} instead * @param mediaPlayer MediaPlayer */ @Deprecated public void setPlayer(MediaPlayer mediaPlayer) { setPlayer(mediaPlayer.getAudioSessionId()); }
public void setPlayer(int audioSessionId) { visualizer = new Visualizer(audioSessionId); visualizer.setEnabled(false); int[] ranges = Visualizer.getCaptureSizeRange(); visualizer.setCaptureSize(Visualizer.getCaptureSizeRange()[0]); Log.i("cclin", String.format("采样率区间:%s-%d-%d", Arrays.toString(ranges), Visualizer.getMaxCaptureRate(), visualizer.getCaptureSize()));
visualizer.setDataCaptureListener(new Visualizer.OnDataCaptureListener() { @Override public void onWaveFormDataCapture(Visualizer visualizer, byte[] bytes, int samplingRate) { BaseVisualizer.this.bytes = bytes; BaseVisualizer.this.samplingRate = samplingRate; invalidate(); }
@Override public void onFftDataCapture(Visualizer visualizer, byte[] fft, int samplingRate) { BaseVisualizer.this.bytes = fft; BaseVisualizer.this.samplingRate = samplingRate; Log.e("cclin", "onFftDataCapture: 采样率 "+samplingRate); invalidate(); } }, Visualizer.getMaxCaptureRate() / 2, !isFFT, isFFT);
visualizer.setEnabled(true); }
public void release() { visualizer.release(); bytes = null; invalidate(); }
public Visualizer getVisualizer() { return visualizer; }
protected abstract void init();}
复制代码

4.2 导入 BarVisualizer

package com.besmart.myvisualizer;
import android.content.Context;import android.graphics.Canvas;import android.graphics.Paint;import android.util.AttributeSet;import android.util.Log;
import androidx.annotation.Nullable;
public class BarVisualizer extends BaseVisualizer {
private float density = 50; private int gap;
public BarVisualizer(Context context) { super(context); }
public BarVisualizer(Context context, @Nullable AttributeSet attrs) { super(context, attrs); }
public BarVisualizer(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); }
@Override protected void init() { this.density = 50; this.gap = 4; paint.setStyle(Paint.Style.FILL); }
/** * Sets the density to the Bar visualizer i.e the number of bars * to be displayed. Density can vary from 10 to 256. * by default the value is set to 50. * * @param density density of the bar visualizer */ public void setDensity(float density) { this.density = density; if (density > 256) { this.density = 256; } else if (density < 10) { this.density = 10; } }
@Override protected void onDraw(Canvas canvas) { if (isFFT) {
byte[] fft = bytes; int n = fft.length; float[] magnitudes = new float[n / 2 + 1]; float[] phases = new float[n / 2 + 1]; magnitudes[0] = (float) Math.abs(fft[0]); // DC magnitudes[n / 2] = (float) Math.abs(fft[1]); // Nyquist phases[0] = phases[n / 2] = 0;
float barWidth = getWidth() / (float) (magnitudes.length - 1); paint.setStrokeWidth(barWidth);
Log.i("cclin", String.format("柱形宽度:%s-%d-%d-%d", barWidth, magnitudes.length - 1, getWidth(), n));
canvas.drawLine(0, getHeight(), 0, (1 - magnitudes[0] / 20.0f) * getHeight(), paint);
for (int k = 1; k < n / 2; k++) { int i = k * 2; magnitudes[k] = (float) Math.hypot(fft[i], fft[i + 1]); phases[k] = (float) Math.atan2(fft[i + 1], fft[i]);// Log.i("cclin", String.format("第 %d 个点的值 %f, %f, %d", k, magnitudes[k], phases[k], k*samplingRate /(n/2)));
float barX = (k * barWidth) + 0; canvas.drawLine(barX, getHeight(), barX, (1 - magnitudes[k] / 20.0f) * getHeight(), paint); } float barX = (n / 2 * barWidth) + 0; canvas.drawLine(barX, getHeight(), barX, (1 - magnitudes[n / 2] / 20.0f) * getHeight(), paint);
} else { if (bytes != null) { float barWidth = getWidth() / density; float div = bytes.length / density; paint.setStrokeWidth(barWidth - gap);
for (int i = 0; i < density; i++) { int bytePosition = (int) Math.ceil(i * div);// Log.e("cclin", String.format("%d %d %d %d", bytes[bytePosition], Math.abs(bytes[bytePosition]),// (Math.abs(bytes[bytePosition]) + 128), ((byte) (Math.abs(bytes[bytePosition]) + 128))));
/** * 计算分析: * 1.由于 bytes[bytePosition] 是一个无符号 8bit 数 * 2.因此,其取值范围是:[-128~127] * 3.Math.abs(bytes[bytePosition]) + 128 ==> 归一化为 128~256 * 4.类型转换为 byte ==> 归一化为 -[0~128] * 5.因此,top = h + h*(n/128) // n<0 */ int top = getHeight() + ((byte) (Math.abs(bytes[bytePosition]) + 128)) * getHeight() / 128; // top = h + h*(n/128) // n<0 float barX = (i * barWidth) + (barWidth / 2); canvas.drawLine(barX, getHeight(), barX, top, paint); } super.onDraw(canvas); } } }}
复制代码

4.3 引用 BarVisualizer

package com.besmart.myvisualizer
import android.Manifestimport android.content.pm.PackageManagerimport android.media.MediaPlayerimport android.os.Buildimport android.os.Bundleimport android.util.Logimport android.view.Viewimport android.widget.ImageButtonimport androidx.appcompat.app.AppCompatActivityimport androidx.core.content.ContextCompat
open class MainActivity : AppCompatActivity() {
private val AUDIO_PERMISSION_REQUEST_CODE = 102
private val WRITE_EXTERNAL_STORAGE_PERMS = arrayOf( Manifest.permission.RECORD_AUDIO )
private var mediaPlayer: MediaPlayer? = null
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED ) { requestPermissions(WRITE_EXTERNAL_STORAGE_PERMS, AUDIO_PERMISSION_REQUEST_CODE) } else { setPlayer() }
}
override fun onDestroy() { super.onDestroy() Log.e("cclin", "页面销毁") barVisualizer.release() }
override fun onRequestPermissionsResult( requestCode: Int, permissions: Array<String?>, grantResults: IntArray ) { when (requestCode) { AUDIO_PERMISSION_REQUEST_CODE -> if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED ) { setPlayer() } else { finish() } } }
private lateinit var barVisualizer: BarVisualizer
private fun setPlayer() { mediaPlayer = MediaPlayer.create(this, R.raw.yedediqizhang) mediaPlayer?.isLooping = false
barVisualizer = findViewById(R.id.visualizer)
// set custom color to the line.
// set custom color to the line. barVisualizer.setColor(ContextCompat.getColor(this, R.color.custom))
// define custom number of bars you want in the visualizer between (10 - 256).
// define custom number of bars you want in the visualizer between (10 - 256). barVisualizer.setDensity(50F)
// Set your media player to the visualizer.
// Set your media player to the visualizer. mediaPlayer?.audioSessionId?.let { barVisualizer.setPlayer(it) }
val playPause: ImageButton = findViewById(R.id.ib_play_pause) val replay: ImageButton = findViewById(R.id.ib_replay) playPause.setOnClickListener(this::onClick) replay.setOnClickListener(this::onClick) }
private fun onClick(view: View) { when(view.id){ R.id.ib_play_pause -> { val btnPlayPause = view as ImageButton if (mediaPlayer != null) { if (mediaPlayer?.isPlaying == true) { mediaPlayer?.pause() btnPlayPause.setImageDrawable( ContextCompat.getDrawable( this, R.drawable.ic_play_red_48dp ) ) } else { mediaPlayer?.start() btnPlayPause.setImageDrawable( ContextCompat.getDrawable( this, R.drawable.ic_pause_red_48dp ) ) } } } R.id.ib_replay -> { mediaPlayer?.seekTo(0) } else ->{
} }
}
}
复制代码

5.对比

  • 下图为波形图数据展示效果



  • 下图为 FFT 频域数据展示效果(横坐标单位为频率 [0HZ, 44100000HZ])



6.总结

本文主要介绍音频信号的时域和频域概念,以及 Android 系统 Visualizer 的使用方法,比较直观的对比同一首音乐周杰伦的《夜的第七章》,在时域和频域下的柱形图显示效果。例程已上传github

7.参考文献

https://github.com/GautamChibde/android-audio-visualizer.git

https://en.wikipedia.org/wiki/Fast_Fourier_transform

发布于: 2021 年 05 月 16 日阅读数: 47
用户头像

Changing Lin

关注

获得机遇的手段远超于固有常规之上~ 2020.04.29 加入

我能做的,就是调整好自己的精神状态,以最佳的面貌去面对那些未曾经历过得事情,对生活充满热情和希望。

评论

发布
暂无评论
带你认识时域、频域与Android系统Visualizer