一、为什么不能直接把录音数据保存成 WAV?
在 ALSA 采集过程中,通过 snd_pcm_readi() 读到的数据,本质上是 裸 PCM 数据。
也就是说,采集到的内容只是:
text
[采样点][采样点][采样点]...
这些数据本身并不包含:
- 采样率
- 位深
- 声道数
- 编码格式
- 数据长度
因此,如果直接把 ALSA 采集到的数据保存到文件中,这个文件通常只是一个 .pcm 裸数据文件,而不是播放器可直接识别的 .wav 文件。
WAV 文件之所以能被播放器识别,是因为它在 PCM 数据前面增加了一段文件头信息,用于描述音频参数。
所以,基于 ALSA 实现录音保存为 WAV,本质上就是两件事:
- 使用 ALSA 采集 PCM 数据
- 在文件前面补写一个合法的 WAV 头
二、整体实现思路
整个录音保存为 WAV 的流程可以概括为:
text
打开 ALSA 采集设备
↓
配置采样参数
↓
创建 WAV 文件并预留头部
↓
循环读取 PCM 数据
↓
将采集数据写入文件
↓
录音结束后回填 WAV 头
↓
关闭文件和 ALSA 设备
注意这里有一个关键点:
WAV 头里的数据长度字段,在录音开始前通常是未知的。
因为只有录音结束之后,才能知道一共采集了多少字节的 PCM 数据。
所以最常见的做法是:
- 先写一个"占位头"
- 录音结束后再回到文件开头,把正确的 WAV 头补进去
三、WAV 文件头结构回顾
标准 PCM-WAV 文件通常由三部分组成:
RIFF Header + fmt Chunk + data Chunk + PCM Data
常见头部长度为 44 字节。
下面给出一个典型的 WAV 头结构体:
c
#pragma pack(1)
typedef struct WAVHeader {
char riff_id[4]; // "RIFF"
unsigned int riff_size; // 文件总长度 - 8
char wave_id[4]; // "WAVE"
char fmt_id[4]; // "fmt "
unsigned int fmt_size; // 16
unsigned short audio_format; // 1: PCM
unsigned short num_channels; // 声道数
unsigned int sample_rate; // 采样率
unsigned int byte_rate; // 每秒字节数
unsigned short block_align; // 每帧字节数
unsigned short bits_per_sample;// 位深
char data_id[4]; // "data"
unsigned int data_size; // PCM 数据长度
} WAVHeader;
字段之间的关系如下:
byte_rate = sample_rate × channels × bits_per_sample / 8
block_align = channels × bits_per_sample / 8
riff_size = 36 + data_size
四、ALSA 录音流程分析
在 Linux 用户态中,基于 ALSA 采集音频的一般步骤如下:
- 打开 PCM 采集设备
- 设置硬件参数
- 分配采集缓冲区
- 循环调用
snd_pcm_readi()读取数据 - 将数据写入文件
- 收尾关闭
我们下面直接结合保存 WAV 文件来实现。
五、实现步骤详解
5.1 定义 WAV 头结构与头部写入函数
首先定义 WAV 头结构,并实现一个用于写入头部的函数。
c
#include <stdio.h>
#include <string.h>
#pragma pack(1)
typedef struct WAVHeader {
char riff_id[4];
unsigned int riff_size;
char wave_id[4];
char fmt_id[4];
unsigned int fmt_size;
unsigned short audio_format;
unsigned short num_channels;
unsigned int sample_rate;
unsigned int byte_rate;
unsigned short block_align;
unsigned short bits_per_sample;
char data_id[4];
unsigned int data_size;
} WAVHeader;
void write_wav_header(FILE *fp,
int channels,
int sample_rate,
int bits_per_sample,
unsigned int data_size)
{
WAVHeader header;
memcpy(header.riff_id, "RIFF", 4);
header.riff_size = 36 + data_size;
memcpy(header.wave_id, "WAVE", 4);
memcpy(header.fmt_id, "fmt ", 4);
header.fmt_size = 16;
header.audio_format = 1; // PCM
header.num_channels = channels;
header.sample_rate = sample_rate;
header.bits_per_sample = bits_per_sample;
header.byte_rate = sample_rate * channels * bits_per_sample / 8;
header.block_align = channels * bits_per_sample / 8;
memcpy(header.data_id, "data", 4);
header.data_size = data_size;
fseek(fp, 0, SEEK_SET);
fwrite(&header, sizeof(WAVHeader), 1, fp);
}
5.2 打开 ALSA 采集设备
录音使用的是:
c
SND_PCM_STREAM_CAPTURE
典型打开方式如下:
c
snd_pcm_t *pcm_handle = NULL;
int rc = snd_pcm_open(&pcm_handle, "hw:0,0", SND_PCM_STREAM_CAPTURE, 0);
if (rc < 0) {
printf("unable to open pcm device: %s\n", snd_strerror(rc));
return -1;
}
这里的 "hw:0,0" 表示:
- 第 0 块声卡
- 第 0 个 PCM 设备
如果希望系统自动进行部分格式转换,也可以用:
text
plughw:0,0
5.3 配置采集参数
在录音场景中,最常见的参数组合之一是:
- 单声道
- 16bit
- 16000Hz
例如用于语音识别前端、对讲、语音控制等。
对应 ALSA 配置如下:
c
snd_pcm_hw_params_t *hw_params;
snd_pcm_hw_params_alloca(&hw_params);
snd_pcm_hw_params_any(pcm_handle, hw_params);
snd_pcm_hw_params_set_access(pcm_handle, hw_params, SND_PCM_ACCESS_RW_INTERLEAVED);
snd_pcm_hw_params_set_format(pcm_handle, hw_params, SND_PCM_FORMAT_S16_LE);
snd_pcm_hw_params_set_channels(pcm_handle, hw_params, 1);
unsigned int sample_rate = 16000;
snd_pcm_hw_params_set_rate_near(pcm_handle, hw_params, &sample_rate, 0);
snd_pcm_uframes_t frames = 1024;
snd_pcm_hw_params_set_period_size_near(pcm_handle, hw_params, &frames, 0);
rc = snd_pcm_hw_params(pcm_handle, hw_params);
if (rc < 0) {
printf("unable to set hw parameters: %s\n", snd_strerror(rc));
snd_pcm_close(pcm_handle);
return -1;
}
参数含义如下:
SND_PCM_ACCESS_RW_INTERLEAVED:交错访问方式SND_PCM_FORMAT_S16_LE:16 位有符号小端1:单声道16000:16k 采样率1024:每次读取 1024 帧
5.4 创建 WAV 文件并预留头部
由于 WAV 头要等录音结束后才能确定 data_size,所以一开始通常先写入一个空头占位。
c
FILE *fp = fopen("record.wav", "wb");
if (!fp) {
perror("fopen");
snd_pcm_close(pcm_handle);
return -1;
}
/* 先预留 44 字节头部 */
WAVHeader empty_header;
memset(&empty_header, 0, sizeof(WAVHeader));
fwrite(&empty_header, sizeof(WAVHeader), 1, fp);
这样后续采集到的数据会直接写到头部之后。
5.5 循环采集 PCM 数据并写入文件
采集核心调用是:snd_pcm_readi()
它读取的单位是 frame,不是字节。
如果当前参数是:
- 单声道
- 16bit
那么:
frame_bytes = 1 × 16 / 8 = 2
如果是双声道 16bit,则:
frame_bytes = 2 × 16 / 8 = 4
录音循环如下:
c
int channels = 1;
int bits_per_sample = 16;
int frame_bytes = channels * bits_per_sample / 8;
int buffer_size = frames * frame_bytes;
char *buffer = (char *)malloc(buffer_size);
if (!buffer) {
perror("malloc");
fclose(fp);
snd_pcm_close(pcm_handle);
return -1;
}
unsigned int total_data_size = 0;
int seconds = 5; // 录音 5 秒
int loop_count = sample_rate * seconds / frames;
while (loop_count-- > 0) {
rc = snd_pcm_readi(pcm_handle, buffer, frames);
if (rc == -EPIPE) {
/* overrun */
snd_pcm_prepare(pcm_handle);
continue;
} else if (rc < 0) {
printf("read error: %s\n", snd_strerror(rc));
break;
} else if (rc != (int)frames) {
printf("short read, rc = %d\n", rc);
}
int bytes = rc * frame_bytes;
fwrite(buffer, 1, bytes, fp);
total_data_size += bytes;
}
这里有几个关键点:
1)为什么要统计 total_data_size?
因为 WAV 头中的 data_size 需要在最后回填。
2)为什么会出现 -EPIPE?
在采集场景里,这通常表示 overrun(溢出) 。
说明应用读数据不够及时,ALSA 缓冲区已经被硬件写满。
处理方式通常是:
c
snd_pcm_prepare(pcm_handle);
然后继续采集。
3)为什么可能出现 rc != frames?
说明本次实际读到的帧数比期望值少,这种情况也需要按实际返回值计算写入字节数。
5.6 录音结束后回填 WAV 头
录音结束后,文件中的 PCM 数据长度已经知道了,这时就可以写入正确的 WAV 头。
c
write_wav_header(fp, channels, sample_rate, bits_per_sample, total_data_size);
最后关闭资源:
c
free(buffer);
fclose(fp);
snd_pcm_close(pcm_handle);
六、完整示例代码
下面给出一个可直接用于学习和移植的完整示例。
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <alsa/asoundlib.h>
#pragma pack(1)
typedef struct WAVHeader {
char riff_id[4];
unsigned int riff_size;
char wave_id[4];
char fmt_id[4];
unsigned int fmt_size;
unsigned short audio_format;
unsigned short num_channels;
unsigned int sample_rate;
unsigned int byte_rate;
unsigned short block_align;
unsigned short bits_per_sample;
char data_id[4];
unsigned int data_size;
} WAVHeader;
void write_wav_header(FILE *fp,
int channels,
int sample_rate,
int bits_per_sample,
unsigned int data_size)
{
WAVHeader header;
memcpy(header.riff_id, "RIFF", 4);
header.riff_size = 36 + data_size;
memcpy(header.wave_id, "WAVE", 4);
memcpy(header.fmt_id, "fmt ", 4);
header.fmt_size = 16;
header.audio_format = 1;
header.num_channels = channels;
header.sample_rate = sample_rate;
header.bits_per_sample = bits_per_sample;
header.byte_rate = sample_rate * channels * bits_per_sample / 8;
header.block_align = channels * bits_per_sample / 8;
memcpy(header.data_id, "data", 4);
header.data_size = data_size;
fseek(fp, 0, SEEK_SET);
fwrite(&header, sizeof(WAVHeader), 1, fp);
}
int main()
{
snd_pcm_t *pcm_handle = NULL;
snd_pcm_hw_params_t *hw_params;
FILE *fp = NULL;
int rc;
int channels = 1;
int bits_per_sample = 16;
unsigned int sample_rate = 16000;
snd_pcm_uframes_t frames = 1024;
int seconds = 5;
rc = snd_pcm_open(&pcm_handle, "hw:0,0", SND_PCM_STREAM_CAPTURE, 0);
if (rc < 0) {
printf("unable to open pcm device: %s\n", snd_strerror(rc));
return -1;
}
snd_pcm_hw_params_alloca(&hw_params);
snd_pcm_hw_params_any(pcm_handle, hw_params);
snd_pcm_hw_params_set_access(pcm_handle, hw_params, SND_PCM_ACCESS_RW_INTERLEAVED);
snd_pcm_hw_params_set_format(pcm_handle, hw_params, SND_PCM_FORMAT_S16_LE);
snd_pcm_hw_params_set_channels(pcm_handle, hw_params, channels);
snd_pcm_hw_params_set_rate_near(pcm_handle, hw_params, &sample_rate, 0);
snd_pcm_hw_params_set_period_size_near(pcm_handle, hw_params, &frames, 0);
rc = snd_pcm_hw_params(pcm_handle, hw_params);
if (rc < 0) {
printf("unable to set hw parameters: %s\n", snd_strerror(rc));
snd_pcm_close(pcm_handle);
return -1;
}
fp = fopen("record.wav", "wb");
if (!fp) {
perror("fopen");
snd_pcm_close(pcm_handle);
return -1;
}
WAVHeader empty_header;
memset(&empty_header, 0, sizeof(WAVHeader));
fwrite(&empty_header, sizeof(WAVHeader), 1, fp);
int frame_bytes = channels * bits_per_sample / 8;
int buffer_size = frames * frame_bytes;
char *buffer = (char *)malloc(buffer_size);
if (!buffer) {
perror("malloc");
fclose(fp);
snd_pcm_close(pcm_handle);
return -1;
}
unsigned int total_data_size = 0;
int loop_count = sample_rate * seconds / frames;
while (loop_count-- > 0) {
rc = snd_pcm_readi(pcm_handle, buffer, frames);
if (rc == -EPIPE) {
snd_pcm_prepare(pcm_handle);
continue;
} else if (rc < 0) {
printf("read error: %s\n", snd_strerror(rc));
break;
} else if (rc != (int)frames) {
printf("short read, rc = %d\n", rc);
}
int bytes = rc * frame_bytes;
fwrite(buffer, 1, bytes, fp);
total_data_size += bytes;
}
write_wav_header(fp, channels, sample_rate, bits_per_sample, total_data_size);
free(buffer);
fclose(fp);
snd_pcm_close(pcm_handle);
printf("record finished, data size = %u bytes\n", total_data_size);
return 0;
}
七、编译方法
如果系统已经安装 ALSA 开发库,可以使用:
bash
gcc record_wav.c -o record_wav -lasound
如果是交叉编译环境,需要确认:
- 头文件
alsa/asoundlib.h - 库文件
libasound.so
已经正确放入工具链和目标系统中。
八、运行效果说明
执行程序后,会进行固定时长录音,例如 5 秒,最终生成:record.wav
这个文件可以直接通过以下命令验证播放:
bash
aplay record.wav
如果能正常播放,说明:
- ALSA 采集成功
- WAV 头写入正确
- 录音数据保存无误
九、常见问题分析
9.1 录出来的 WAV 文件无法播放
最常见原因有:
- WAV 头字段填写错误
data_size不正确riff_size不正确- 采样参数和真实采集参数不匹配
- 没有在录音结束后回填头部
建议重点检查:
riff_size = 36 + data_size
byte_rate = sample_rate × channels × bits_per_sample / 8
block_align = channels × bits_per_sample / 8
9.2 录音过程中频繁出现 overrun
说明应用读取速度慢于设备采集速度。
常见解决方法:
- 增大
period_size - 增大
buffer_size - 提高线程优先级
- 减少磁盘写入阻塞
- 采集线程和文件写线程分离
如果是实时性要求较高的项目,通常会采用:
- 一个线程专门从 ALSA 取数据
- 一个线程负责编码/存盘/发送
9.3 生成的声音速度不对
这通常是因为 WAV 头中的采样率与实际采样率不一致。
例如:
- 实际按 16000Hz 录音
- 头部却写成 8000Hz
那么播放时声音就会变慢、变调。
9.4 为什么建议先保存 PCM,再考虑 WAV?
在调试阶段,很多工程会先验证:
- ALSA 是否成功采到 PCM
- 数据长度是否正确
- 声音是否正常
确认 PCM 没问题后,再封装成 WAV。
因为这样可以把"采集问题"和"封装问题"拆开排查。
十、总结
基于 ALSA 实现录音保存为 WAV,本质上就是:
用 ALSA 采集 PCM,再为这些 PCM 数据补上标准 WAV 文件头。
它涉及两个核心知识点:
- ALSA 采集流程
- WAV 文件结构
只要把这两部分打通,录音保存功能就不复杂了。
可以用一句话总结整个流程:
ALSA 负责采集原始 PCM 数据,WAV 负责为 PCM 数据提供标准文件封装。