前言
音频拼接是一个实用需求:将多个音频文件按顺序首尾相连,输出为一个连续播放的 WAV 文件。与"声道合并"(多单声道交织成多声道)不同,拼接是时间轴上的顺序连接,所有段共享相同的声道布局。
本文介绍基于 Qt 5.15 + QML + FFmpeg 的实现方案,核心思路:解码 → 格式对齐 → PCM 顺序拼接 → WAV 封装。
效果图:

一、整体架构
ConcatPage.qml(前端 UI:文件选择、列表预览、输出设置)
↓ 调用 Q_INVOKABLE
MediaAnalyzer::concatAudioFiles()(C++ 业务层:解码 + 格式对齐)
↓ 委托
AudioToolProcessor::concatPcmListToWav()(C++ 核心层:PCM 拼接 + WAV 输出)
二、前端 UI:ConcatPage.qml
2.1 文件选择与累加
FileDialog 启用 selectMultiple: true 支持多选,同时支持多次打开对话框累加文件(自动去重):
qml
FileDialog {
id: concatFilesDialog
title: "选择要拼接的音频文件(按住 Ctrl 可多选,可多次打开累加)"
selectExisting: true
selectMultiple: true
nameFilters: [
"Audio files (*.wav *.mp3 *.flac *.aac *.m4a *.ogg *.wma)",
"All files (*)"
]
onAccepted: {
// 累加到已有列表,支持多次选择
var urls = page.concatFileUrls.slice()
for (var i = 0; i < fileUrls.length; ++i) {
var url = fileUrls[i]
var exists = false
for (var j = 0; j < urls.length; ++j) {
if (String(urls[j]) === String(url)) {
exists = true; break
}
}
if (!exists)
urls.push(url)
}
page.concatFileUrls = urls
page.refreshFileList()
}
}
注意 :
fileUrls是 QMLlist<url>类型,直接赋值给property var后绑定可能不刷新,因此需要显式转为 JS 数组。
2.2 文件列表预览
使用 ListView + 编号标签展示已选文件,配合「清空」按钮管理列表:
qml
ListView {
id: fileListPanel
property var modelData: []
anchors.fill: parent
model: modelData
delegate: RowLayout {
Rectangle { // 编号标签
width: 28; height: 22; radius: 4; color: "#0e3a5a"
Text { text: modelData.label; color: "#7edcff" }
}
Text { text: modelData.value; color: "#d7edf6" } // 文件名
}
}
2.3 执行按钮与条件控制
「开始拼接」按钮在文件数 ≥ 2 时启用:
qml
ActionButton {
text: "开始拼接"
accent: true
enabled: !mediaAnalyzer.busy && page.concatFileUrls.length >= 2
onClicked: mediaAnalyzer.concatAudioFiles(
page.concatFileUrls, concatPathField.text.trim())
}
三、C++ 业务层:格式对齐
MediaAnalyzer::concatAudioFiles() 负责解码和格式对齐。以第一段为目标格式基准 ,后续段如果采样率、位深或通道数不一致,自动调用 resamplePcm 对齐:
cpp
bool MediaAnalyzer::concatAudioFiles(const QVariantList &fileUrls,
const QString &filePath)
{
QList<QByteArray> pcmList;
QVariantMap targetInfo;
for (int i = 0; i < fileUrls.size(); ++i) {
const QString localPath = toLocalFile(fileUrls.at(i).toUrl());
// 解码为 PCM
QByteArray pcm;
QVariantMap pcmInfo;
m_audioDecoder.decodeToPcm(localPath, &pcm, &pcmInfo, &errorText);
if (i == 0) {
// 第一段作为目标格式基准
targetInfo = pcmInfo;
} else {
// 检查采样率/位深/通道是否与目标一致
const int targetRate = targetInfo.value("sampleRate").toInt();
const int targetBits = targetInfo.value("bitsPerSample").toInt();
const int targetCh = targetInfo.value("channels").toInt();
if (pcmInfo.value("sampleRate").toInt() != targetRate
|| pcmInfo.value("bitsPerSample").toInt() != targetBits
|| pcmInfo.value("channels").toInt() != targetCh) {
// 不一致则重采样对齐
m_toolProcessor.resamplePcm(pcm, pcmInfo,
targetRate, targetBits, targetCh,
&resampled, &resampledInfo, &errorText);
pcm = resampled;
}
}
pcmList.append(pcm);
}
// 委托核心层完成拼接和 WAV 封装
m_toolProcessor.concatPcmListToWav(outputPath, pcmList,
targetInfo, &concatInfo, &errorText);
}
四、C++ 核心层:PCM 拼接
AudioToolProcessor::concatPcmListToWav() 采用两趟遍历策略:
- 第一趟:校验每段 PCM 字节对齐,累计总 frame 数
- 第二趟 :按顺序
memcpy到连续缓冲区
由于所有段已统一到相同的 interleaved 格式(采样率/位深/通道数一致),直接字节追加即可保持帧对齐,无需额外的交错计算。
cpp
bool AudioToolProcessor::concatPcmListToWav(const QString &filePath,
const QList<QByteArray> &pcmList, const QVariantMap &pcmInfo,
QVariantMap *concatInfo, QString *errorText) const
{
const int frameSize = channels * sampleSpec.bytesPerSample;
// 第一趟:校验 + 累计总 frame 数
qint64 totalFrames = 0;
for (int i = 0; i < pcmList.size(); ++i) {
const QByteArray &pcm = pcmList.at(i);
if (pcm.isEmpty() || pcm.size() % frameSize != 0) {
*errorText = QStringLiteral("第 %1 段 PCM 数据与格式不匹配。").arg(i + 1);
return false;
}
totalFrames += pcm.size() / frameSize;
}
// 第二趟:顺序 memcpy 拼接
QByteArray concatenated;
concatenated.resize(static_cast<int>(totalFrames * frameSize));
char *target = concatenated.data();
int offset = 0;
QVariantList segments;
for (int i = 0; i < pcmList.size(); ++i) {
const QByteArray &pcm = pcmList.at(i);
memcpy(target + offset, pcm.constData(), pcm.size());
offset += pcm.size();
// 记录每段信息供 UI 展示
QVariantMap seg;
seg.insert("index", i + 1);
seg.insert("sizeText", FFmpegUtils::formatBytes(pcm.size()));
seg.insert("durationText",
FFmpegUtils::formatDuration(pcm.size() / frameSize * 1000 / sampleRate));
segments.append(seg);
}
// 复用已有 WAV 封装函数写入文件
savePcmAsWav(filePath, concatenated, pcmInfo, &saveError);
// 返回拼接结果信息(路径、总时长、每段组成等)
QVariantMap info;
info.insert("outputPath", outputPath);
info.insert("fileCount", pcmList.size());
info.insert("frames", totalFrames);
info.insert("durationText", FFmpegUtils::formatDuration(totalFrames * 1000 / sampleRate));
info.insert("segments", segments);
*concatInfo = info;
return true;
}
五、结果展示
拼接完成后,concatInfo 通过 Q_PROPERTY 绑定到 QML,InfoPanel 组件展示以下信息:
| 字段 | 说明 |
|---|---|
| 输出文件 | WAV 保存路径 |
| 文件数量 | 参与拼接的文件数 |
| 采样率 / 通道数 | 统一后的格式参数 |
| 总 Frame 数 | 拼接后的总采样帧数 |
| 总时长 | 所有片段时长之和 |
| 每段详情 | 各片段的时长和大小 |
六、与声道合并的区别
| 对比项 | 音频拼接(Concat) | 声道合并(Merge) |
|---|---|---|
| 目的 | 时间轴首尾相连 | 多单声道交织成多声道 |
| 输入 | N 个任意音频文件 | N 个单声道文件 |
| 输出 | 单文件,通道数 = 基准段 | 单文件,通道数 = 文件数 |
| 时长 | 所有片段时长之和 | 等于单个文件时长 |
| 格式要求 | 自动重采样对齐到第一段 | 要求所有文件时长一致 |