Qt/QML + FFmpeg 实现多音频文件顺序拼接功能

前言

音频拼接是一个实用需求:将多个音频文件按顺序首尾相连,输出为一个连续播放的 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 是 QML list<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 个单声道文件
输出 单文件,通道数 = 基准段 单文件,通道数 = 文件数
时长 所有片段时长之和 等于单个文件时长
格式要求 自动重采样对齐到第一段 要求所有文件时长一致

相关推荐
Strugglingler3 小时前
【Qt,OpenGL, RHI,Wayland 等概念梳理】
qt·opengl·wayland·rhi·x11·egl·glx
小短腿的代码世界7 小时前
Qt对象树析构链与智能指针协同:零泄漏内存管理架构
开发语言·qt·架构
小庞在加油7 小时前
从qmake到CMake+VSCode:Qt项目现代化迁移与AI提效实战指南
vscode·qt·ai·ai工具
小短腿的代码世界8 小时前
Qt定时器高精度架构:从QTimer源码到纳秒级定时调度
数据库·qt·架构
尘中远9 小时前
Qt高性能绘图库QIm——实现二维三维科学绘图
开发语言·qt·信息可视化
wbcuc9 小时前
ffmpeg工具把m4s合并为mp4 powershell脚本
ffmpeg·m4s
人还是要有梦想的11 小时前
QT qml布局讲解
qt·布局·qml
小短腿的代码世界11 小时前
Qt交易系统审计日志与合规追踪引擎:从零构建金融级不可篡改日志架构
qt·金融·架构
sycmancia11 小时前
Qt——自定义模型类
开发语言·qt