該原創文章首發於微信公衆號字節流動
音頻數據的採集
OpenGL 實現可視化實時音頻的思路比較清晰,可以利用 Java 層的 API AudioRecorder 採集到未編碼的音頻裸數據(PCM 數據),也可以利用 OpenSLES 接口在 Native 層採集,然後將採集到的音頻數據看作一組音頻的強度(Level)值,再根據這組強度值生成網格,最後進行實時繪製。
本文爲方便展示,直接採用 Android 的 API AudioRecorder 採集音頻裸數據,然後通過 JNI 傳入 Native 層,最後生成網格進行繪製。
在使用 AudioRecorder 採集格式爲 ENCODING_PCM_16BIT 音頻數據需要了解:所採集到的音頻數據在內存中字節的存放模式是小端模式(小端序)(Little-Endian),即低地址存放低位、高地址存放高位,所以如果用 2 個字節轉換爲 short 型的數據需要特別注意。另外,大端序與小端序相反,即低地址存放高位、高地址存放低位。
在 Java 中小端序存儲的 byte 數據轉爲 short 型數值可以採用如下方式:
byte firstByte = 0x10, secondByte = 0x01; //0x0110
ByteBuffer bb = ByteBuffer.allocate(2);
bb.order(ByteOrder.LITTLE_ENDIAN);
bb.put(firstByte);
bb.put(secondByte);
short shortVal = bb.getShort(0);
爲了避免數據轉換的麻煩,Android 的 AudioRecorder 類也提供了直接可以輸出 short 型數組音頻數據的 API ,我是踩了坑之後才發現的。
public int read(short[] audioData, int offsetInShorts, int sizeInShorts, int readMode)
Android 使用 AudioRecorder 採集音頻的大致流程,在 Java 層對其進行一個簡單的封裝:
public class AudioCollector implements AudioRecord.OnRecordPositionUpdateListener{
private static final String TAG = "AudioRecorderWrapper";
private static final int RECORDER_SAMPLE_RATE = 44100; //採樣率
private static final int RECORDER_CHANNELS = 1; //通道數
private static final int RECORDER_AUDIO_ENCODING = AudioFormat.ENCODING_PCM_16BIT; //音頻格式
private static final int RECORDER_ENCODING_BIT = 16;
private AudioRecord mAudioRecord;
private Thread mThread;
private short[] mAudioBuffer;
private Handler mHandler;
private int mBufferSize;
private Callback mCallback;
public AudioCollector() {
//計算 buffer 大小
mBufferSize = 2 * AudioRecord.getMinBufferSize(RECORDER_SAMPLE_RATE,
RECORDER_CHANNELS, RECORDER_AUDIO_ENCODING);
}
public void init() {
mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC, RECORDER_SAMPLE_RATE,
RECORDER_CHANNELS, RECORDER_AUDIO_ENCODING, mBufferSize);
mAudioRecord.startRecording();
//在一個新的工作線程裏不停地採集音頻數據
mThread = new Thread("Audio-Recorder") {
@Override
public void run() {
super.run();
mAudioBuffer = new short[mBufferSize];
Looper.prepare();
mHandler = new Handler(Looper.myLooper());
//通過 AudioRecord.OnRecordPositionUpdateListener 不停地採集音頻數據
mAudioRecord.setRecordPositionUpdateListener(AudioCollector.this, mHandler);
int bytePerSample = RECORDER_ENCODING_BIT / 8;
float samplesToDraw = mBufferSize / bytePerSample;
mAudioRecord.setPositionNotificationPeriod((int) samplesToDraw);
mAudioRecord.read(mAudioBuffer, 0, mBufferSize);
Looper.loop();
}
};
mThread.start();
}
public void unInit() {
if(mAudioRecord != null) {
mAudioRecord.stop();
mAudioRecord.release();
mHandler.getLooper().quitSafely();
mHandler = null;
mAudioRecord = null;
}
}
public void addCallback(Callback callback) {
mCallback = callback;
}
@Override
public void onMarkerReached(AudioRecord recorder) {
}
@Override
public void onPeriodicNotification(AudioRecord recorder) {
if (mAudioRecord.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING
&& mAudioRecord.read(mAudioBuffer, 0, mAudioBuffer.length) != -1)
{
if(mCallback != null)
//通過接口回調將音頻數據傳到 Native 層
mCallback.onAudioBufferCallback(mAudioBuffer);
}
}
public interface Callback {
void onAudioBufferCallback(short[] buffer);
}
}
音頻可視化
在 Native 層獲取到 AudioRecorder 所採集的 PCM 音頻數據(short 類型數組),然後根據數組的長度將紋理座標系的 S 軸進行等距離劃分,再以數組中的數值(類似聲音的強度值)爲高度構建條狀圖,生成相應的紋理座標和頂點座標。
由於“一幀”音頻數據對應的數組比較大,繪製出來的音頻條狀圖成了一坨 shi ,要想直觀性地表現時域上的音頻,還需要在繪製之前對數據進行適當的採樣。
float dx = 1.0f / m_RenderDataSize;
for (int i = 0; i < m_RenderDataSize; ++i) {
int index = i * RESAMPLE_LEVEL; //RESAMPLE_LEVEL 表示採樣間隔
float y = m_pAudioData[index] * dy * -1;
y = y < 0 ? y : -y; //表示音頻的數值轉爲正數
//構建條狀矩形的 4 個點
vec2 p1(i * dx, 0 + 1.0f);
vec2 p2(i * dx, y + 1.0f);
vec2 p3((i + 1) * dx, y + 1.0f);
vec2 p4((i + 1) * dx, 0 + 1.0f);
//構建紋理座標
m_pTextureCoords[i * 6 + 0] = p1;
m_pTextureCoords[i * 6 + 1] = p2;
m_pTextureCoords[i * 6 + 2] = p3;
m_pTextureCoords[i * 6 + 3] = p1;
m_pTextureCoords[i * 6 + 4] = p3;
m_pTextureCoords[i * 6 + 5] = p4;
m_pTextureCoords[i * 6 + 2] = p4;
m_pTextureCoords[i * 6 + 3] = p4;
m_pTextureCoords[i * 6 + 4] = p2;
m_pTextureCoords[i * 6 + 5] = p3;
//構建頂點座標,將紋理座標轉爲頂點座標
m_pVerticesCoords[i * 6 + 0] = GLUtils::texCoordToVertexCoord(p1);
m_pVerticesCoords[i * 6 + 1] = GLUtils::texCoordToVertexCoord(p2);
m_pVerticesCoords[i * 6 + 2] = GLUtils::texCoordToVertexCoord(p3);
m_pVerticesCoords[i * 6 + 3] = GLUtils::texCoordToVertexCoord(p1);
m_pVerticesCoords[i * 6 + 4] = GLUtils::texCoordToVertexCoord(p3);
m_pVerticesCoords[i * 6 + 5] = GLUtils::texCoordToVertexCoord(p4);
m_pVerticesCoords[i * 6 + 2] = GLUtils::texCoordToVertexCoord(p4);
m_pVerticesCoords[i * 6 + 3] = GLUtils::texCoordToVertexCoord(p4);
m_pVerticesCoords[i * 6 + 4] = GLUtils::texCoordToVertexCoord(p2);
m_pVerticesCoords[i * 6 + 5] = GLUtils::texCoordToVertexCoord(p3);
}
Java 層輸入“一幀”音頻數據,Native 層繪製一幀:
void VisualizeAudioSample::Draw(int screenW, int screenH) {
LOGCATE("VisualizeAudioSample::Draw()");
if (m_ProgramObj == GL_NONE) return;
//加互斥鎖,保證音頻數據繪製與更新同步
std::unique_lock<std::mutex> lock(m_Mutex);
//根據音頻數據更新紋理座標和頂點座標
UpdateMesh();
UpdateMVPMatrix(m_MVPMatrix, m_AngleX, m_AngleY, (float) screenW / screenH);
// Generate VBO Ids and load the VBOs with data
if(m_VboIds[0] == 0)
{
glGenBuffers(2, m_VboIds);
glBindBuffer(GL_ARRAY_BUFFER, m_VboIds[0]);
glBufferData(GL_ARRAY_BUFFER, sizeof(GLfloat) * m_RenderDataSize * 6 * 3, m_pVerticesCoords, GL_DYNAMIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, m_VboIds[1]);
glBufferData(GL_ARRAY_BUFFER, sizeof(GLfloat) * m_RenderDataSize * 6 * 2, m_pTextureCoords, GL_DYNAMIC_DRAW);
}
else
{
glBindBuffer(GL_ARRAY_BUFFER, m_VboIds[0]);
glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(GLfloat) * m_RenderDataSize * 6 * 3, m_pVerticesCoords);
glBindBuffer(GL_ARRAY_BUFFER, m_VboIds[1]);
glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(GLfloat) * m_RenderDataSize * 6 * 2, m_pTextureCoords);
}
if(m_VaoId == GL_NONE)
{
glGenVertexArrays(1, &m_VaoId);
glBindVertexArray(m_VaoId);
glBindBuffer(GL_ARRAY_BUFFER, m_VboIds[0]);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (const void *) 0);
glBindBuffer(GL_ARRAY_BUFFER, GL_NONE);
glBindBuffer(GL_ARRAY_BUFFER, m_VboIds[1]);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(GLfloat), (const void *) 0);
glBindBuffer(GL_ARRAY_BUFFER, GL_NONE);
glBindVertexArray(GL_NONE);
}
// Use the program object
glUseProgram(m_ProgramObj);
glBindVertexArray(m_VaoId);
glUniformMatrix4fv(m_MVPMatLoc, 1, GL_FALSE, &m_MVPMatrix[0][0]);
GLUtils::setFloat(m_ProgramObj, "drawType", 1.0f);
glDrawArrays(GL_TRIANGLES, 0, m_RenderDataSize * 6);
GLUtils::setFloat(m_ProgramObj, "drawType", 0.0f);
glDrawArrays(GL_LINES, 0, m_RenderDataSize * 6);
}
實時音頻的繪製結果如下:
但是,上面這個實時音頻的繪製效果並不能給人時間流逝的感覺,就是單純地繪製完一組接着繪製另外一組數據,中間沒有任何過渡。
我們是在時域上(也可以通過傅立葉變換轉換成頻域)繪製音頻數據,要想繪製出來的效果有時間流逝的感覺,那就需要在 Buffer 上進行偏移繪製,即逐步丟棄舊的數據,同時逐步添加新的數據,這樣繪製出來的效果就有時間流逝的感覺。
首先我們的 Buffer 要擴大一倍(也可以是幾倍),採集 2 幀音頻數據填滿 Buffer ,這個時候阻塞音頻採集線程,然後通知渲染線程(數據準備好了)進行繪製,然後指向 Buffer 的指針按照特定的步長進行偏移,偏移一次繪製一次。
當指針偏移到上圖所示的邊界,這個時候 Buffer 中的數據都被繪製完畢,渲染線程暫停繪製,通知音頻採集線程解除阻塞,將 Buffer2 中的數據拷貝的 Buffer1 中,並接收新的數據放到 Buffer2 中,這個時候再次阻塞音頻採集線程,通知渲染線程數據更新完畢,可以進行繪製了。
void VisualizeAudioSample::UpdateMesh() {
//設置一個偏移步長
int step = m_AudioDataSize / 64;
//判斷指針是否偏移到邊界
if(m_pAudioBuffer + m_AudioDataSize - m_pCurAudioData >= step)
{
float dy = 0.5f / MAX_AUDIO_LEVEL;
float dx = 1.0f / m_RenderDataSize;
for (int i = 0; i < m_RenderDataSize; ++i) {
int index = i * RESAMPLE_LEVEL;
float y = m_pCurAudioData[index] * dy * -1;
y = y < 0 ? y : -y;
vec2 p1(i * dx, 0 + 1.0f);
vec2 p2(i * dx, y + 1.0f);
vec2 p3((i + 1) * dx, y + 1.0f);
vec2 p4((i + 1) * dx, 0 + 1.0f);
m_pTextureCoords[i * 6 + 0] = p1;
m_pTextureCoords[i * 6 + 1] = p2;
m_pTextureCoords[i * 6 + 2] = p4;
m_pTextureCoords[i * 6 + 3] = p4;
m_pTextureCoords[i * 6 + 4] = p2;
m_pTextureCoords[i * 6 + 5] = p3;
m_pVerticesCoords[i * 6 + 0] = GLUtils::texCoordToVertexCoord(p1);
m_pVerticesCoords[i * 6 + 1] = GLUtils::texCoordToVertexCoord(p2);
m_pVerticesCoords[i * 6 + 2] = GLUtils::texCoordToVertexCoord(p4);
m_pVerticesCoords[i * 6 + 3] = GLUtils::texCoordToVertexCoord(p4);
m_pVerticesCoords[i * 6 + 4] = GLUtils::texCoordToVertexCoord(p2);
m_pVerticesCoords[i * 6 + 5] = GLUtils::texCoordToVertexCoord(p3);
}
m_pCurAudioData += step;
}
else
{
//偏移到邊界時,通知音頻採集線程更新數據
m_bAudioDataReady = false;
m_Cond.notify_all();
return;
}
}
void VisualizeAudioSample::LoadShortArrData(short *const pShortArr, int arrSize) {
if (pShortArr == nullptr || arrSize == 0)
return;
m_FrameIndex++;
std::unique_lock<std::mutex> lock(m_Mutex);
//前兩幀數據直接填充 Buffer
if(m_FrameIndex == 1)
{
m_pAudioBuffer = new short[arrSize * 2];
memcpy(m_pAudioBuffer, pShortArr, sizeof(short) * arrSize);
m_AudioDataSize = arrSize;
return;
}
//前兩幀數據直接填充 Buffer
if(m_FrameIndex == 2)
{
memcpy(m_pAudioBuffer + arrSize, pShortArr, sizeof(short) * arrSize);
m_RenderDataSize = m_AudioDataSize / RESAMPLE_LEVEL;
m_pVerticesCoords = new vec3[m_RenderDataSize * 6]; //(x,y,z) * 6 points
m_pTextureCoords = new vec2[m_RenderDataSize * 6]; //(x,y) * 6 points
}
//將 Buffer2 中的數據拷貝的 Buffer1 中,並接收新的數據放到 Buffer2 中,
if(m_FrameIndex > 2)
{
memcpy(m_pAudioBuffer, m_pAudioBuffer + arrSize, sizeof(short) * arrSize);
memcpy(m_pAudioBuffer + arrSize, pShortArr, sizeof(short) * arrSize);
}
//這個時候阻塞音頻採集線程,通知渲染線程數據更新完畢
m_bAudioDataReady = true;
m_pCurAudioData = m_pAudioBuffer;
m_Cond.wait(lock);
}
實現代碼路徑:
Android_OpenGLES_3_0