8:从USB摄像头把声音拿出来--ALSA大佬登场!
前言
前面的章节我们从认识摄像头开始,逐渐认识的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文件,实现真正的音视频录制。
再之后我们就要进入真正运动相机硬件方面的探讨了。