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 个单声道文件
输出 单文件,通道数 = 基准段 单文件,通道数 = 文件数
时长 所有片段时长之和 等于单个文件时长
格式要求 自动重采样对齐到第一段 要求所有文件时长一致

相关推荐
誰能久伴不乏1 小时前
QT工业上位机一次「指令静默丢失」的排查
qt·docker·github
智能直播14 小时前
网络RTMP拉流不卡顿、声音不同步,一文讲透缓冲区、时间戳
开发语言·ffmpeg·php·音视频·视频编解码
扶尔魔ocy19 小时前
【qt openGL入门笔记】OSG实现飞机模型在网格图上飞行并进行态势标绘(四)
qt·opengl·osg
橙橙笔记1 天前
QT安卓软件的制作
android·git·qt·软件
芦芭荞1 天前
QT使用xml模版生成word文档
xml·qt·word
mengzhi啊1 天前
qt把结构体数据存入json文件
qt·json
八月的雨季 最後的冰吻1 天前
FFmepg-- 42-ffplay.c 线程安全机制深度分析
c语言·安全·ffmpeg
kaixin_learn_qt_ing1 天前
写入二进制文件(Qt)
qt
尘中远1 天前
QWT7 的科学 Colormap 色彩评估:QwtColorMapPreset 22 种预设的感知均匀性与场景化选
qt·qwt·科学绘图·科学配色
宿州派大星1 天前
Python实战:PyQt5 + OpenCV + Dlib 打造AI桌面应用
人工智能·python·qt