前言
????????前面的章節我們從認識攝像頭開始,逐漸認識的YCbCr,并對其進行了H264的編碼以及MP4封裝。整個過程中,我們大致使用了V4L2和FFmpeg這兩個重量級工具,就像我們前面章節所講,V4L2只是給圖像做服務的,并不參與音頻。
????????在第3章中,我們說過V4L2這位大哥只管視頻,不管音頻。那音頻誰來管?ALSA大佬管!
????????那么這一章,我們重點討論一下ALSA,并且我們列一個目標:從USB攝像頭獲取音頻流,并且編碼成AAC!
一、ALSA介紹
1、ALSA是什么
- ?全稱:?? ?Advanced Linux Sound Architecture? (高級 Linux 聲音架構)
- ?本質:?? ?Linux 內核的音頻子系統和驅動框架。它提供了從底層硬件聲卡驅動到上層用戶空間應用程序接口(API)的一整套解決方案。
- ?目的:?? 管理和驅動計算機的聲卡硬件,允許應用程序播放和錄制聲音。
- ?歷史:?? 在 2.6 內核中正式取代了老舊的 ?OSS (Open Sound System)?,成為 Linux 默認的標準聲音系統。
2、ALSA 的核心組成部分和功能
?內核驅動:??
- 這部分包含在內核源代碼中 (
/sound
?目錄)。 - 直接與物理聲卡硬件(集成、獨立聲卡、USB 聲卡等)通信,處理中斷、DMA、硬件寄存器讀寫等底層操作。
- 為每種支持的聲卡芯片或型號提供特定的驅動程序模塊,等我們有能力后,也可以為一個音頻芯片或驅動模塊編寫驅動程序,現在還是先用起來。
- 這部分包含在內核源代碼中 (
?用戶空間庫 (
libasound.so
?- ALSA library):??- 這是應用程序主要交互的接口。
- 提供了一組豐富、統一的 API (稱為 ?ALSA API? 或 ?alsa-lib API),讓應用程序開發者無需關心底層硬件的細節即可播放或錄制音頻。
- 庫負責將應用程序的請求(如“播放這個 PCM 數據流”)傳遞給內核驅動,并處理緩沖區、格式轉換、插件等高級功能。
- 支持多種音頻格式(采樣率、位深、通道數)、參數設置(緩沖區大小、周期數)。
?設備文件 (
/dev/snd/
?目錄下):??- 內核驅動為用戶空間暴露的接口文件。雖然應用程序通常通過?
libasound
?訪問音頻功能,但理解這些設備文件有助于調試。 - 主要設備:
/dev/snd/controlC#
: 控制設備 (Control device),用于混音器控制(如?alsamixer
?/?amixer
?使用)。/dev/snd/pcmC#D#
: PCM 播放/錄制設備 (Playback/Capture device)。C#
?表示聲卡號 (Card),D#
?表示該聲卡上的設備號 (Device)。我們編程程序的時候,會用到這個。
- 內核驅動為用戶空間暴露的接口文件。雖然應用程序通常通過?
二、ALSA初體驗
????????為了能夠在程序中使用ALSA,需要安裝ALSA開發庫:
sudo apt-get install libasound2-dev
????????下面直接給出通過ALSA獲取USB攝像頭的PCM音頻數據的代碼,并根據這份代碼進行講解:
#include <stdio.h>
#include <stdlib.h>
#include <alsa/asoundlib.h>#define SAMPLE_RATE 22050 // 采樣率
#define CHANNELS 1 // 單聲道
#define PERIOD_SIZE 512 // 周期大小
#define PERIODS 4 // 緩沖區周期數
#define RECORD_SECONDS 5 // 錄音時長(秒)int main() {int rc;snd_pcm_t *capture_handle;snd_pcm_hw_params_t *hw_params;FILE *pcm_file;short *buffer;int dir = 0;// 1. 打開音頻設備rc = snd_pcm_open(&capture_handle, "plughw:1,0", SND_PCM_STREAM_CAPTURE, 0);if (rc < 0) {fprintf(stderr, "無法打開音頻設備: %s\n", snd_strerror(rc));return 1;}// 2. 分配硬件參數結構snd_pcm_hw_params_alloca(&hw_params);// 3. 初始化硬件參數rc = snd_pcm_hw_params_any(capture_handle, hw_params);if (rc < 0) {fprintf(stderr, "無法初始化硬件參數: %s\n", snd_strerror(rc));goto cleanup;}// 4. 設置訪問類型(交錯模式)rc = snd_pcm_hw_params_set_access(capture_handle, hw_params, SND_PCM_ACCESS_RW_INTERLEAVED);if (rc < 0) {fprintf(stderr, "無法設置訪問類型: %s\n", snd_strerror(rc));goto cleanup;}// 5. 設置采樣格式(16位小端)rc = snd_pcm_hw_params_set_format(capture_handle, hw_params, SND_PCM_FORMAT_S16_LE);if (rc < 0) {fprintf(stderr, "無法設置采樣格式: %s\n", snd_strerror(rc));goto cleanup;}// 6. 設置采樣率unsigned int sample_rate = SAMPLE_RATE;rc = snd_pcm_hw_params_set_rate_near(capture_handle, hw_params, &sample_rate, &dir);if (rc < 0) {fprintf(stderr, "無法設置采樣率: %s\n", snd_strerror(rc));goto cleanup;}printf("實際采樣率: %u Hz\n", sample_rate);// 7. 設置聲道數(單聲道)rc = snd_pcm_hw_params_set_channels(capture_handle, hw_params, CHANNELS);if (rc < 0) {fprintf(stderr, "無法設置聲道數: %s\n", snd_strerror(rc));goto cleanup;}// 8. 設置周期大小snd_pcm_uframes_t period_size = PERIOD_SIZE;rc = snd_pcm_hw_params_set_period_size_near(capture_handle, hw_params, &period_size, &dir);if (rc < 0) {fprintf(stderr, "無法設置周期大小: %s\n", snd_strerror(rc));goto cleanup;}printf("實際周期大小: %lu 幀\n", period_size);// 9. 設置周期數(緩沖區大小 = 周期大小 * 周期數)unsigned int periods = PERIODS;rc = snd_pcm_hw_params_set_periods_near(capture_handle, hw_params, &periods, &dir);if (rc < 0) {fprintf(stderr, "無法設置周期數: %s\n", snd_strerror(rc));goto cleanup;}printf("實際周期數: %u\n", periods);// 10. 應用硬件參數rc = snd_pcm_hw_params(capture_handle, hw_params);if (rc < 0) {fprintf(stderr, "無法設置參數: %s\n", snd_strerror(rc));goto cleanup;}// 11. 準備音頻緩沖區buffer = malloc(period_size * sizeof(short));if (!buffer) {fprintf(stderr, "無法分配緩沖區\n");goto cleanup;}// 12. 打開輸出文件pcm_file = fopen("test.pcm", "wb");if (!pcm_file) {fprintf(stderr, "無法創建輸出文件\n");goto cleanup;}printf("開始錄音...\n");// 13. 錄音循環int frames = 0;const int total_frames = (sample_rate * RECORD_SECONDS) / period_size;while (frames < total_frames) {rc = snd_pcm_readi(capture_handle, buffer, period_size);if (rc == -EPIPE) {fprintf(stderr, "緩沖區溢出,正在恢復\n");snd_pcm_prepare(capture_handle);continue;} else if (rc < 0) {fprintf(stderr, "讀取錯誤: %s\n", snd_strerror(rc));break;} else if (rc != period_size) {fprintf(stderr, "短幀讀取,期望 %lu,實際 %d\n", period_size, rc);}// 寫入PCM數據到文件fwrite(buffer, sizeof(short), rc, pcm_file);frames++;printf("\r已錄制 %.1f 秒... ", (float)frames * period_size / sample_rate);fflush(stdout);}printf("\n錄音完成!保存為 test.pcm\n");cleanup:if (capture_handle) {snd_pcm_close(capture_handle);}if (buffer) {free(buffer);}if (pcm_file) {fclose(pcm_file);}return 0;
}
????????代碼整體上還是比較簡單的,邏輯也很清晰,只對新出現的部分做一些補充:
????????1、snd_pcm_xxx是ALSA庫(alsa-lib)接口,編譯的時候,需要鏈接 -lsound
????????2、snd_pcm_open的參數中,有一個“plughw:1,0”,這里的plug指的是插件,比如應用程序想要獲取44100采樣率的數據,但是硬件只支持22050,那么plug就可以自動將音頻數據從22050轉成44100給到應用程序,主要是考慮到兼容性問題。但是在嵌入式中,音頻硬件和驅動是固定的,不考慮兼容性,所以在打開音頻設備時,使用“hw:1,0”即可。畢竟兼容性是要犧牲算力資源和內存資源的。
????????3、“hw:1,0“的命名規則如下:
????????card如果是0,代表是系統默認聲卡。如果是1,一般是外接聲卡,比如USB聲卡。
????????設備號是從0開始的,我們的USB攝像頭設備號只有一個,其他的不清楚。
????????所以“hw:1,0”表示的是:硬件訪問方式,外置聲卡,且聲卡的設備號為0。
????????在Ubuntu中,在插入USB攝像頭之前,/dev/snd里面的設備如下:
by-path controlC0 midiC0D0 pcmC0D0c pcmC0D0p pcmC0D1p seq timer
????????插入USB攝像頭后,/dev/snd里面的設備如下:
by-id by-path controlC0 controlC1 midiC0D0 pcmC0D0c pcmC0D0p pcmC0D1p pcmC1D0c seq timer
????????可以看到多出了“controlC1”和“pcmC1D0c”,后者就是聲卡1,設備0。
????????4、周期是什么?
????????在代碼中,音頻的采樣率(每秒鐘采樣多少個點)和單聲道都好理解,但是PERIODS/PERIODS_SIZE是什么?
????????當ALSA從USB攝像頭獲取到音頻數據后,會循環放在P個buffer中,這個P就是就是周期數,也就是PERIODS。每個音頻幀的大小都是固定的,也就是PERRIODS_SIZE。其中音頻幀又是交錯格式(Interleaved)存儲的。如果是立體聲(左右雙聲道):L R L R... 我手里的攝像頭是單聲道的,就算是交錯模式,存儲方式也是單聲道的:L L L L ...
????????在snd_pcm_readi讀取音頻數據時,有一個錯誤EPIPE處理。
????????在播放音頻的時候,EPIPE代表的是欠載,表示應用程序填充數據太慢,硬件已經消耗完所有的周期,需要加快數據填充用于播放。
????????在錄音的時候,EPIPE代表的是超限,意思是應用程序snd_pcm_readi讀取的不及時,導致buffer中的數據溢出了,需要及時讀取。
????????5、編譯并運行
gcc uvc_voice_streaming.c -o uvc_voice_streaming -lasound -lavcodec -lavutil
????????運行后,就可以生成test.pcm,因為這個是pcm數據,沒有頭部結構,一般的播放器無法進行播放。筆者使用的是GoldWave,在打開的時候,參數選項要正確,否則無法正確播放錄音。
三、使用FFmpeg進行AAC編碼
????????FFmpeg在前面幾章介紹過,雖然一個是視頻,一個是音頻,但是處理方式都差不多,這里就不再重復。
#include <stdio.h>
#include <stdlib.h>
#include <alsa/asoundlib.h>
#include <libavcodec/avcodec.h>
#include <libavutil/opt.h>
#include <time.h>#define SAMPLE_RATE 22050 // 采樣率
#define CHANNELS 1 // 單聲道
#define PERIOD_SIZE 512 // 周期大小
#define PERIODS 4 // 緩沖區周期數
#define RECORD_SECONDS 5 // 錄音時長(秒)// ADTS頭部長度為7個字節
static uint8_t *adts_header = NULL;// 生成ADTS頭
static void add_adts_header(uint8_t *header, int packet_size, int sample_rate_index, int channels) {// Sync Pointheader[0] = 0xFF;header[1] = 0xF1;// Profile(2), Sampling Freq(4), Private(1), Channel Config(1)header[2] = ((2 - 1) << 6) // AAC-LC = 2| (sample_rate_index << 2)| ((channels & 4) >> 2);// Channel Config(2), Original(1), Home(1), Copyright ID(1), Copyright Start(1), Frame Length(2)header[3] = ((channels & 3) << 6)| ((packet_size + 7) >> 11);// Frame Length(8)header[4] = ((packet_size + 7) >> 3) & 0xFF;// Frame Length(3), Buffer Fullness(5)header[5] = (((packet_size + 7) & 0x07) << 5)| 0x1F;// Buffer Fullness(6), Raw Data Blocks(2)header[6] = 0xFC;
}// 獲取采樣率索引
static int get_sample_rate_index(int sample_rate) {int sample_rates[] = {96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000};for (int i = 0; i < 12; i++) {if (sample_rate == sample_rates[i]) {return i;}}return 7; // 默認使用22050Hz的索引
}int main() {int rc;snd_pcm_t *capture_handle;snd_pcm_hw_params_t *hw_params;FILE *aac_file;short *buffer;int dir = 0;// FFmpeg變量AVCodec *codec = NULL;AVCodecContext *codec_ctx = NULL;AVFrame *frame = NULL;AVPacket *pkt = NULL;// 1. 打開音頻設備rc = snd_pcm_open(&capture_handle, "plughw:1,0", SND_PCM_STREAM_CAPTURE, 0);if (rc < 0) {fprintf(stderr, "無法打開音頻設備: %s\n", snd_strerror(rc));return 1;}// 2. 分配硬件參數結構snd_pcm_hw_params_alloca(&hw_params);// 3. 初始化硬件參數rc = snd_pcm_hw_params_any(capture_handle, hw_params);if (rc < 0) {fprintf(stderr, "無法初始化硬件參數: %s\n", snd_strerror(rc));goto cleanup;}// 4. 設置訪問類型(交錯模式)rc = snd_pcm_hw_params_set_access(capture_handle, hw_params, SND_PCM_ACCESS_RW_INTERLEAVED);if (rc < 0) {fprintf(stderr, "無法設置訪問類型: %s\n", snd_strerror(rc));goto cleanup;}// 5. 設置采樣格式(16位小端)rc = snd_pcm_hw_params_set_format(capture_handle, hw_params, SND_PCM_FORMAT_S16_LE);if (rc < 0) {fprintf(stderr, "無法設置采樣格式: %s\n", snd_strerror(rc));goto cleanup;}// 6. 設置采樣率unsigned int sample_rate = SAMPLE_RATE;rc = snd_pcm_hw_params_set_rate_near(capture_handle, hw_params, &sample_rate, &dir);if (rc < 0) {fprintf(stderr, "無法設置采樣率: %s\n", snd_strerror(rc));goto cleanup;}printf("實際采樣率: %u Hz\n", sample_rate);// 7. 設置聲道數(單聲道)rc = snd_pcm_hw_params_set_channels(capture_handle, hw_params, CHANNELS);if (rc < 0) {fprintf(stderr, "無法設置聲道數: %s\n", snd_strerror(rc));goto cleanup;}// 8. 設置周期大小snd_pcm_uframes_t period_size = PERIOD_SIZE;rc = snd_pcm_hw_params_set_period_size_near(capture_handle, hw_params, &period_size, &dir);if (rc < 0) {fprintf(stderr, "無法設置周期大小: %s\n", snd_strerror(rc));goto cleanup;}printf("實際周期大小: %lu 幀\n", period_size);// 9. 設置周期數(緩沖區大小 = 周期大小 * 周期數)unsigned int periods = PERIODS;rc = snd_pcm_hw_params_set_periods_near(capture_handle, hw_params, &periods, &dir);if (rc < 0) {fprintf(stderr, "無法設置周期數: %s\n", snd_strerror(rc));goto cleanup;}printf("實際周期數: %u\n", periods);// 10. 應用硬件參數rc = snd_pcm_hw_params(capture_handle, hw_params);if (rc < 0) {fprintf(stderr, "無法設置參數: %s\n", snd_strerror(rc));goto cleanup;}// 初始化FFmpeg編碼器codec = avcodec_find_encoder(AV_CODEC_ID_AAC);if (!codec) {fprintf(stderr, "找不到AAC編碼器\n");goto cleanup;}codec_ctx = avcodec_alloc_context3(codec);if (!codec_ctx) {fprintf(stderr, "無法分配編碼器上下文\n");goto cleanup;}// 設置AAC編碼器參數codec_ctx->bit_rate = 64000; // 64 kbpscodec_ctx->sample_fmt = AV_SAMPLE_FMT_FLTP; // AAC需要浮點平面格式codec_ctx->sample_rate = SAMPLE_RATE;codec_ctx->channel_layout = AV_CH_LAYOUT_MONO; // 單聲道codec_ctx->channels = CHANNELS;codec_ctx->profile = FF_PROFILE_AAC_LOW; // AAC-LCrc = avcodec_open2(codec_ctx, codec, NULL);if (rc < 0) {char errbuf[AV_ERROR_MAX_STRING_SIZE];av_strerror(rc, errbuf, AV_ERROR_MAX_STRING_SIZE);fprintf(stderr, "無法打開編碼器: %s\n", errbuf);goto cleanup;}// 分配音頻幀frame = av_frame_alloc();if (!frame) {fprintf(stderr, "無法分配音頻幀\n");goto cleanup;}frame->nb_samples = codec_ctx->frame_size;frame->format = codec_ctx->sample_fmt;frame->channel_layout = codec_ctx->channel_layout;frame->sample_rate = codec_ctx->sample_rate;rc = av_frame_get_buffer(frame, 0);if (rc < 0) {fprintf(stderr, "無法分配幀緩沖區\n");goto cleanup;}// 分配數據包pkt = av_packet_alloc();if (!pkt) {fprintf(stderr, "無法分配數據包\n");goto cleanup;}// 分配ADTS頭部緩沖區adts_header = (uint8_t *)malloc(7);if (!adts_header) {fprintf(stderr, "無法分配ADTS頭部緩沖區\n");goto cleanup;}// 準備音頻緩沖區buffer = malloc(period_size * sizeof(short));if (!buffer) {fprintf(stderr, "無法分配緩沖區\n");goto cleanup;}// 打開輸出文件aac_file = fopen("test.aac", "wb");if (!aac_file) {fprintf(stderr, "無法創建輸出文件\n");goto cleanup;}printf("開始錄音...\n");// 錄音循環int frames = 0;const int total_frames = (SAMPLE_RATE * RECORD_SECONDS) / period_size;float *samples = (float *)frame->data[0];int samples_index = 0;while (frames < total_frames) {rc = snd_pcm_readi(capture_handle, buffer, period_size);if (rc == -EPIPE) {fprintf(stderr, "緩沖區溢出,正在恢復\n");snd_pcm_prepare(capture_handle);continue;} else if (rc < 0) {fprintf(stderr, "讀取錯誤: %s\n", snd_strerror(rc));break;}// 將PCM數據轉換為浮點格式并填充到framefor (int i = 0; i < rc; i++) {samples[samples_index++] = buffer[i] / 32768.0f;if (samples_index >= frame->nb_samples) {// 幀滿了,進行編碼rc = avcodec_send_frame(codec_ctx, frame);if (rc < 0) {fprintf(stderr, "發送幀失敗\n");goto cleanup;}while (rc >= 0) {rc = avcodec_receive_packet(codec_ctx, pkt);if (rc == AVERROR(EAGAIN) || rc == AVERROR_EOF) {break;} else if (rc < 0) {fprintf(stderr, "接收包失敗\n");goto cleanup;}// 添加ADTS頭add_adts_header(adts_header, pkt->size, get_sample_rate_index(SAMPLE_RATE), CHANNELS);// 寫入ADTS頭和AAC數據fwrite(adts_header, 1, 7, aac_file);fwrite(pkt->data, 1, pkt->size, aac_file);av_packet_unref(pkt);}samples_index = 0;}}frames++;printf("\r已錄制 %.1f 秒... ", (float)frames * period_size / SAMPLE_RATE);fflush(stdout);}// 刷新編碼器avcodec_send_frame(codec_ctx, NULL);while (1) {rc = avcodec_receive_packet(codec_ctx, pkt);if (rc == AVERROR_EOF) {break;} else if (rc < 0) {fprintf(stderr, "刷新編碼器失敗\n");break;}// 添加ADTS頭并寫入最后的數據add_adts_header(adts_header, pkt->size, get_sample_rate_index(SAMPLE_RATE), CHANNELS);fwrite(adts_header, 1, 7, aac_file);fwrite(pkt->data, 1, pkt->size, aac_file);av_packet_unref(pkt);}printf("\n錄音完成!保存為 test.aac\n");cleanup:if (capture_handle) {snd_pcm_close(capture_handle);}if (buffer) {free(buffer);}if (aac_file) {fclose(aac_file);}if (codec_ctx) {avcodec_free_context(&codec_ctx);}if (frame) {av_frame_free(&frame);}if (pkt) {av_packet_free(&pkt);}if (adts_header) {free(adts_header);}return 0;
}
????????代碼的邏輯關系如下:
????????該代碼是在上一節代碼基礎上修改的。snd_pcm_readi之后,使用FFmpeg處理。這里只講新的知識點。
????????1、重采樣:
????????USB攝像頭傳過來的音頻數據是交錯模式(interleaved),即立體聲的時候排列方式是:L R L R...
????????但是FFmpeg要求的是平面格式(Plannar),即每個聲道單獨存放:LLLL...RRR...
????????因為我們只有單通道,所以存儲格式不需要更改,后面我們見到重采樣就知道怎么回事的。代碼里面只是對音頻數據進行了浮點重采樣,因為FFmpeg是要求浮點的。
????????2、AAC
????????AAC(Advanced Audio Coding)是現代音頻壓縮技術的巔峰之作,代表了心理聲學模型應用的最高水平。作為MPEG-4標準的核心音頻技術,AAC在效率、質量和靈活性方面都超越了前代MP3標準。筆者并沒有對AAC做過多研究,有興趣的道友可以稍微深入一下。
AAC常見容器格式
格式 | 特點 | 使用場景 |
---|---|---|
?.aac | 原始ADTS流 | 簡單存儲 |
?.m4a? | MP4容器 | iTunes標準 |
.mp4? | 視頻容器 | 視頻伴音 |
.3gp? | 移動設備 | 手機錄制 |
?.ts? | 傳輸流 | 數字電視 |
????????3、ADTS頭結構
// ADTS頭示例
uint8_t adts[7] = {0xFF, // Sync byte 10xF1, // Sync byte 2 + 保護位0x50, // 配置信息 (AAC-LC, 44.1kHz)0x80, // 聲道配置 + 幀長度高位0x1F, // 幀長度中位0xFC, // 幀長度低位 + 緩沖區0x70 // 幀計數器
};
????????有了ADTS頭,現代一般的播放器就能識別。
????????4、編譯和運行
gcc -o uvc_voice_streaming uvc_voice_streaming_aac+adts.c -lasound -lavcodec -lavutil -lm
????????運行后,可以得到test.aac文件,使用VLC或者其他播放器一般是可以播放的。
四、總結
????????本章節主要討論了ALSA框架下的音頻獲取的過程,可以看到應用程序還是比較簡單的,不需要對底層有過多的關注。
????????并使用FFmpeg對音頻數據進行了AAC編碼,有了之前的章節,我們對FFmpeg使用基本上已經得心應手了。
????????下一章將面臨關鍵的挑戰:如何精確同步來自V4L2的視頻幀和來自ALSA的音頻包的時間戳,并使用FFmpeg將它們無縫地封裝進MP4文件,實現真正的音視頻錄制。
????????再之后我們就要進入真正運動相機硬件方面的探討了。