本文会带大家从零理解一个基于 Qt (做界面)+ FFmpeg(采集音频)的简易录音程序,核心功能是:点击界面按钮开始 / 结束录音,实时显示录音时长,最终将麦克风采集的音频保存为 WAV 文件。即使你是刚接触 Qt 和 FFmpeg 的小白,也能看懂每一行核心代码的作用~
先搞懂基础概念
- PCM:原始音频数据(无压缩、无封装),可以理解为 "音频裸数据",直接播放不了,需要封装成 WAV/MP3 等格式。
- WAV:一种音频格式,本质是 "WAV 文件头 + PCM 数据",播放器能通过文件头识别音频参数(采样率、声道数等),再播放 PCM 数据。
- 采样率:每秒采集的音频样本数(比如 44100Hz,就是每秒采 44100 次),数值越高音质越好。
- 位深度:每个音频样本的二进制位数(比如 16 位、32 位),位数越高音质越好。
- 声道数:单声道(1)/ 立体声(2)等。
- Qt 线程:避免录音的 "耗时操作" 卡死界面,所以把录音逻辑放到子线程中。
| 文件 | 核心作用 |
|---|---|
ffmpegs.h/ffmpegs.cpp |
定义 WAV 文件头结构、封装 PCM 转 WAV 的工具函数 |
audiothread.h/audiothread.cpp |
录音线程类,用 FFmpeg 采集麦克风音频,写入 WAV 文件 |
mainwindow.h/mainwindow.cpp |
Qt 主窗口(界面),处理 "开始 / 结束录音" 按钮、显示录音时长 |
main.cpp |
程序入口,初始化 FFmpeg 设备、启动 Qt 界面 |
1. 基础封装:WAV 文件头与 PCM 转 WAV(ffmpegs.h/ffmpegs.cpp)
ffmpegs.h:定义 WAV 文件头
WAV 文件必须有固定格式的 44 字节头,里面存着音频的关键参数,先看结构体定义:
cpp
#ifndef FFMPEGS_H
#define FFMPEGS_H
#include <stdint.h>
// 音频格式标记:1=PCM(整数),3=浮点PCM
#define AUDIO_FORMAT_PCM 1
#define AUDIO_FORMAT_FLOAT 3
// WAV文件头(固定44字节)
typedef struct {
// 1. RIFF块:标识文件类型
uint8_t riffChunkId[4] = {'R', 'I', 'F', 'F'}; // 固定值"RIFF"
uint32_t riffChunkDataSize; // 文件总长度-8(RIFF块的总大小)
// 2. 格式标识:固定"WAVE"
uint8_t format[4] = {'W', 'A', 'V', 'E'};
// 3. fmt块:音频参数
uint8_t fmtChunkId[4] = {'f', 'm', 't', ' '}; // 固定"fmt "
uint32_t fmtChunkDataSize = 16; // PCM格式固定16
uint16_t audioFormat = AUDIO_FORMAT_PCM; // 音频编码(1=PCM,3=浮点)
uint16_t numChannels; // 声道数(1=单声道,2=立体声)
uint32_t sampleRate; // 采样率(比如44100)
uint32_t byteRate; // 字节率=采样率×blockAlign
uint16_t blockAlign; // 一个样本的总字节数=位深度×声道数/8
uint16_t bitsPerSample; // 位深度(比如16、32)
// 4. data块:PCM数据区
uint8_t dataChunkId[4] = {'d', 'a', 't', 'a'}; // 固定"data"
uint32_t dataChunkDataSize; // PCM数据的总长度
} WAVHeader;
// 工具类:封装PCM转WAV的静态函数
class FFmpegs {
public:
FFmpegs();
static void pcm2wav(WAVHeader &header, const char *pcmFilename, const char *wavFilename);
};
#endif // FFMPEGS_H
解读 :这个结构体就是 WAV 文件的 "说明书",播放器靠这些参数解析 PCM 数据。比sampleRate告诉播放器 "每秒要播放 44100 个样本",bitsPerSample告诉播放器 "每个样本用 32 位表示"。
ffmpegs.cpp:PCM 转 WAV 的实现
核心逻辑:先写 WAV 文件头,再把 PCM 裸数据追加到文件里,同时计算文件头的关键参数:
cpp
#include "ffmpegs.h"
#include <QFile>
#include <QDebug>
FFmpegs::FFmpegs() {}
void FFmpegs::pcm2wav(WAVHeader &header, const char *pcmFilename, const char *wavFilename) {
// 1. 计算音频参数(字节率、块对齐)
header.blockAlign = header.bitsPerSample * header.numChannels >> 3; // 等价于 /8
header.byteRate = header.sampleRate * header.blockAlign;
// 2. 打开PCM文件(读裸数据)
QFile pcmFile(pcmFilename);
if (!pcmFile.open(QFile::ReadOnly)) {
qDebug() << "文件打开失败" << pcmFilename;
return;
}
// PCM数据总长度 = PCM文件的大小
header.dataChunkDataSize = pcmFile.size();
// RIFF块大小 = PCM数据长度 + WAV头长度 - 8(减去riffChunkId和riffChunkDataSize的长度)
header.riffChunkDataSize = header.dataChunkDataSize + sizeof (WAVHeader) - 8;
// 3. 打开WAV文件(写数据)
QFile wavFile(wavFilename);
if (!wavFile.open(QFile::WriteOnly)) {
qDebug() << "文件打开失败" << wavFilename;
pcmFile.close();
return;
}
// 4. 先写WAV文件头
wavFile.write((const char *) &header, sizeof (WAVHeader));
// 5. 循环读取PCM数据,写入WAV文件
char buf[1024];
int size;
while ((size = pcmFile.read(buf, sizeof (buf))) > 0) {
wavFile.write(buf, size);
}
// 6. 关闭文件
pcmFile.close();
wavFile.close();
}
解读:这个函数的作用是 "给裸的 PCM 数据套上 WAV 的壳",输入一个 PCM 文件,输出一个能直接播放的 WAV 文件。
2. 核心逻辑:录音线程(audiothread.h/audiothread.cpp)
录音操作是 "耗时操作",如果直接在界面线程执行,界面会卡死,所以我们把录音逻辑放到 Qt 的QThread子线程中。
audiothread.h:线程类声明
cpp
#ifndef AUDIOTHREAD_H
#define AUDIOTHREAD_H
#include <QThread>
class AudioThread : public QThread {
Q_OBJECT
private:
void run(); // 线程启动后执行的函数(核心录音逻辑)
bool _stop = false;
public:
explicit AudioThread(QObject *parent = nullptr);
~AudioThread();
void setStop(bool stop);
signals:
void timeChanged(unsigned long long ms); // 发送录音时长的信号(给界面显示)
};
#endif // AUDIOTHREAD_H
解读 :run()是线程的 "入口",线程启动后会自动执行这个函数;timeChanged信号用来给界面传录音时长(比如录了 5 秒,就发 5000ms)。
audiothread.cpp:录音核心逻辑
核心步骤:初始化 FFmpeg 设备 → 打开麦克风 → 循环采集音频数据 → 写入 WAV 文件 → 实时计算录音时长 → 结束后修正 WAV 文件头。
cpp
#include "audiothread.h"
#include <QDebug>
#include <QFile>
#include <QDateTime>
#include "ffmpegs.h"
// 引入FFmpeg的C语言接口(必须extern "C",否则编译报错)
extern "C" {
#include <libavdevice/avdevice.h> // 设备相关
#include <libavformat/avformat.h> // 格式相关
#include <libavutil/avutil.h> // 工具函数(错误处理)
}
// 跨平台配置:Windows用dshow采集麦克风,macOS用avfoundation
#ifdef Q_OS_WIN
#define FMT_NAME "dshow" // Windows音频采集格式
#define DEVICE_NAME "audio=麦克风阵列 (英特尔® 智音技术)" // 你的麦克风设备名(需修改!)
#define FILEPATH "D:/" // WAV文件保存路径
#else
#define FMT_NAME "avfoundation" // macOS/iOS采集格式
#define DEVICE_NAME ":0" // macOS默认麦克风
#define FILEPATH "/Users/mj/Desktop/"
#endif
// 构造函数:线程销毁时自动回收资源
AudioThread::AudioThread(QObject *parent) : QThread(parent) {
connect(this, &AudioThread::finished, this, &AudioThread::deleteLater);
}
// 析构函数:确保线程安全退出
AudioThread::~AudioThread() {
disconnect(); // 断开信号槽
requestInterruption(); // 请求中断线程
quit(); // 退出事件循环
wait(); // 等待线程结束
qDebug() << this << "析构(内存被回收)";
}
// 线程核心逻辑:录音的所有操作都在这里
void AudioThread::run() {
qDebug() << this << "开始录音----------";
// 1. 获取FFmpeg输入格式(Windows=dshow,macOS=avfoundation)
AVInputFormat *fmt = av_find_input_format(FMT_NAME);
if (!fmt) {
qDebug() << "获取输入格式失败" << FMT_NAME;
return;
}
// 2. 初始化FFmpeg格式上下文(操作设备的"句柄")
AVFormatContext *ctx = nullptr;
// 打开麦克风设备
int ret = avformat_open_input(&ctx, DEVICE_NAME, fmt, nullptr);
if (ret < 0) {
char errbuf[1024];
av_strerror(ret, errbuf, sizeof (errbuf)); // 解析FFmpeg错误码
qDebug() << "打开麦克风失败" << errbuf;
return;
}
// 3. 准备WAV文件(文件名=当前时间,避免重复)
QString filename = FILEPATH + QDateTime::currentDateTime().toString("MM_dd_HH_mm_ss") + ".wav";
QFile file(filename);
if (!file.open(QFile::WriteOnly)) {
qDebug() << "打开WAV文件失败" << filename;
avformat_close_input(&ctx); // 失败时关闭设备
return;
}
// 4. 获取音频参数(从FFmpeg设备中读取采样率、声道数等)
AVStream *stream = ctx->streams[0]; // 音频流
AVCodecParameters *params = stream->codecpar; // 音频参数
// 初始化WAV文件头
WAVHeader header;
header.sampleRate = params->sample_rate; // 采样率(比如44100)
header.bitsPerSample = av_get_bits_per_sample(params->codec_id); // 位深度
header.numChannels = params->channels; // 声道数
// 判断音频格式:浮点PCM还是整数PCM
if (params->codec_id >= AV_CODEC_ID_PCM_F32BE) {
header.audioFormat = AUDIO_FORMAT_FLOAT;
}
// 计算字节率、块对齐
header.blockAlign = header.bitsPerSample * header.numChannels >> 3;
header.byteRate = header.sampleRate * header.blockAlign;
// 5. 先写入空的WAV文件头(后续会修正参数)
file.write((char *) &header, sizeof (WAVHeader));
// 6. 循环采集音频数据(核心!)
AVPacket *pkt = av_packet_alloc(); // 音频数据包(存储采集到的PCM数据)
while (!isInterruptionRequested()) { // 没收到"结束录音"指令就一直采集
// 从麦克风读取一帧音频数据
ret = av_read_frame(ctx, pkt);
if (ret == 0) { // 读取成功
// 把采集到的PCM数据写入WAV文件
file.write((const char *) pkt->data, pkt->size);
// 计算录音时长:总字节数 / 字节率 = 秒数 → 转毫秒
header.dataChunkDataSize += pkt->size;
unsigned long long ms = 1000.0 * header.dataChunkDataSize / header.byteRate;
emit timeChanged(ms); // 发送时长信号(给界面显示)
av_packet_unref(pkt); // 释放数据包(必须!否则内存泄漏)
} else if (ret == AVERROR(EAGAIN)) { // 临时没数据,继续等
continue;
} else { // 采集出错,退出循环
char errbuf[1024];
av_strerror(ret, errbuf, sizeof (errbuf));
qDebug() << "采集音频失败" << errbuf;
break;
}
}
// 7. 修正WAV文件头(关键!之前写的是空值,现在补全)
// 移动文件指针到dataChunkDataSize的位置,写入真实的PCM数据长度
file.seek(sizeof (WAVHeader) - sizeof (header.dataChunkDataSize));
file.write((char *) &header.dataChunkDataSize, sizeof (header.dataChunkDataSize));
// 移动文件指针到riffChunkDataSize的位置,写入真实的RIFF块大小
file.seek(sizeof (header.riffChunkId));
header.riffChunkDataSize = file.size() - 8;
file.write((char *) &header.riffChunkDataSize, sizeof (header.riffChunkDataSize));
// 8. 释放资源(必须!否则内存泄漏)
av_packet_free(&pkt); // 释放数据包
file.close(); // 关闭WAV文件
avformat_close_input(&ctx); // 关闭麦克风设备
qDebug() << this << "录音结束----------";
}
void AudioThread::setStop(bool stop) {
_stop = stop;
}
重点解读:
avformat_open_input:打开麦克风设备,FFmpeg 会自动识别设备的音频参数(采样率、位深度等)。av_read_frame:循环读取麦克风的音频数据,每次读一帧,存到AVPacket里。file.write(pkt->data, pkt->size):把采集到的 PCM 数据写入 WAV 文件。timeChanged信号:实时计算录音时长并传给界面,让界面显示 "00:05.1" 这种格式。- 最后修正 WAV 文件头:因为采集前不知道 PCM 数据的总长度,所以先写空值,采集结束后再补真实值。
3. 界面逻辑:Qt 主窗口(mainwindow.h/mainwindow.cpp)
负责显示界面、处理 "开始 / 结束录音" 按钮点击、接收录音时长并显示。
mainwindow.h:界面类声明
cpp
#ifndef MAINWINDOW_H
#define MAINWINDOW_H
#include <QMainWindow>
#include <audiothread.h>
QT_BEGIN_NAMESPACE
namespace Ui { class MainWindow; } // Qt设计师生成的界面类
QT_END_NAMESPACE
class MainWindow : public QMainWindow {
Q_OBJECT
public:
MainWindow(QWidget *parent = nullptr);
~MainWindow();
private slots:
void on_audioButton_clicked(); // 按钮点击槽函数
void onTimeChanged(unsigned long long ms); // 接收时长信号的槽函数
private:
Ui::MainWindow *ui; // 界面控件
AudioThread *_audioThread = nullptr; // 录音线程指针
};
#endif // MAINWINDOW_H
mainwindow.cpp:界面逻辑实现
cpp
#include "mainwindow.h"
#include "ui_mainwindow.h"
#include <QTime>
MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), ui(new Ui::MainWindow) {
ui->setupUi(this);
onTimeChanged(0); // 初始化时长显示(00:00.0)
}
MainWindow::~MainWindow() {
delete ui;
}
// 更新录音时长显示:把毫秒转成"分:秒.毫秒"格式
void MainWindow::onTimeChanged(unsigned long long ms) {
QTime time(0, 0, 0, 0);
QString text = time.addMSecs(ms).toString("mm:ss.z"); // 格式:00:05.1
ui->timeLabel->setText(text.left(7)); // 只显示前7位(比如00:05.123 → 00:05.1)
}
// "开始/结束录音"按钮点击事件
void MainWindow::on_audioButton_clicked() {
if (!_audioThread) { // 点击"开始录音"
// 1. 创建并启动录音线程
_audioThread = new AudioThread(this);
_audioThread->start();
// 2. 连接信号槽:线程的时长信号 → 界面的显示函数
connect(_audioThread, &AudioThread::timeChanged, this, &MainWindow::onTimeChanged);
// 3. 线程结束后重置指针、恢复按钮文字
connect(_audioThread, &AudioThread::finished, [this]() {
_audioThread = nullptr;
ui->audioButton->setText("开始录音");
});
// 4. 修改按钮文字
ui->audioButton->setText("结束录音");
} else { // 点击"结束录音"
// 中断录音线程
_audioThread->requestInterruption();
_audioThread = nullptr;
// 恢复按钮文字
ui->audioButton->setText("开始录音");
}
}
解读:
on_audioButton_clicked:按钮点击后判断是 "开始" 还是 "结束":- 开始:创建录音线程、启动、连接信号槽(线程传时长给界面)。
- 结束:中断线程、重置指针、恢复按钮文字。
onTimeChanged:把线程传来的毫秒数转成 "00:05.1" 这种友好的格式,显示在界面的标签上。
4. 程序入口:main.cpp
cpp
#include "mainwindow.h"
#include <QApplication>
#include "ffmpegs.h"
extern "C" {
#include <libavdevice/avdevice.h> // FFmpeg设备
}
int main(int argc, char *argv[]) {
// 注册FFmpeg所有设备(必须!否则找不到麦克风)
avdevice_register_all();
// 启动Qt应用
QApplication a(argc, argv);
MainWindow w;
w.show(); // 显示主窗口
return a.exec(); // 进入Qt事件循环
}
解读 :avdevice_register_all()是 FFmpeg 的 "设备注册",必须调用,否则 FFmpeg 不认识麦克风设备;QApplication a是 Qt 程序的核心,负责管理界面、事件循环。
运行前必做的配置
-
修改麦克风设备名 :在
audiothread.cpp中,DEVICE_NAME要改成你自己的麦克风名称(Windows 用户):- 打开命令行,执行
ffmpeg -list_devices true -f dshow -i dummy,会列出所有音频设备,复制你的麦克风名称替换即可。
- 打开命令行,执行
-
配置 FFmpeg 环境 :
-
下载对应平台的 FFmpeg 库(包含
libavdevice/libavformat等)。 -
在 Qt 工程文件(.pro)中配置 FFmpeg 的头文件路径、库文件路径。
-
示例.pro 配置(Windows):
cppINCLUDEPATH += D:/ffmpeg/include LIBS += -LD:/ffmpeg/lib -lavdevice -lavformat -lavutil -lavcodec
-
-
Qt 界面设计 :
- 在 Qt Designer 中拖一个按钮(audioButton)、一个标签(timeLabel)。
- 按钮文字默认 "开始录音",标签默认显示 "00:00.0"。
程序运行流程
- 启动程序 → 界面显示 "开始录音" 按钮和 "00:00.0" 时长。
- 点击 "开始录音" → 创建录音线程 → FFmpeg 打开麦克风 → 循环采集音频数据 → 写入 WAV 文件 → 实时发送时长信号给界面。
- 点击 "结束录音" → 中断线程 → 修正 WAV 文件头 → 关闭麦克风和文件 → 线程销毁 → 按钮恢复 "开始录音"。
- 最终在
D:/(Windows)或桌面(macOS)生成以当前时间命名的 WAV 文件,可直接用播放器播放。
总结
这个程序的核心是:
- Qt 线程:避免录音卡死界面。
- FFmpeg:跨平台采集麦克风的 PCM 音频数据。
- WAV 封装:把 PCM 裸数据套上 WAV 文件头,变成可播放的音频文件。
重点理解 "线程为什么要用""WAV 文件头的作用""FFmpeg 采集音频的基本步骤",先跑通代码,再逐行拆解,就能快速掌握 Qt+FFmpeg 音频采集的核心逻辑~