Qt5 + libmpv 手写卡拉OK播放器:ASS逐字高亮与 FFmpeg 字幕烧录实战(附源码)

本文基于个人项目 KaraokePlayer 整理,完整展示如何用 C++/Qt 打造一个支持 ASS 卡拉OK字幕、词级字幕转换与 FFmpeg 字幕烧录的桌面播放器。

一、项目简介

KaraokePlayer 是一个 Windows 桌面端的卡拉OK播放器,主要解决"带逐字高亮(karaoke)效果的字幕播放与烧录导出"这一痛点。与传统播放器相比,它具备以下能力:

  • 视频渲染:基于 libmpv 的 OpenGL 渲染接口,将视频帧直接绘制到 Qt 窗口;
  • 卡拉OK字幕:解析 ASS/SSA 字幕中的 \k/\kf/\ko 标签,还原逐字高亮的时间轴;
  • 词级字幕转换:将 Whisper 等 ASR 产出的 JSON 词级时间戳字幕,一键转换为标准 ASS;
  • 字幕烧录导出:调用 FFmpeg 将字幕硬编码进视频,支持实时进度显示。

二、技术栈

组件 版本/用途
C++ C++17
Qt 5.15.2(MinGW 8.1 64bit)
构建 CMake ≥ 3.16
渲染 libmpv(OpenGL render API)
导出 FFmpeg(外部进程调用)
字幕格式 ASS/SSA + JSON(词级时间戳)

三、整体架构

项目采用分层设计,model / core / ui 三层分离,数据流清晰:

src/
├── main.cpp                 # 入口
├── model/                   # 数据模型(纯结构体,无 Qt 依赖逻辑)
│   ├── KaraokeLine.h        # 一行歌词
│   ├── KaraokeSyllable.h    # 单个音节(含特效类型)
│   └── SubtitleTrack.h      # 整条字幕轨道(样式 + 行集合)
├── core/                    # 核心逻辑
│   ├── PlaybackController   # 播放控制 + mpv 事件循环
│   ├── AssParser            # ASS 解析 + 卡拉OK标签提取
│   ├── JsonSubtitleParser   # JSON 字幕 → ASS 转换
│   └── Exporter             # FFmpeg 字幕烧录导出
└── ui/                      # 界面层
    ├── MainWindow           # 主窗口
    ├── MpvWidget            # QOpenGLWidget + mpv 渲染
    ├── ControlBar           # 底部控制条
    ├── ProgressBar          # 进度条
    ├── SubtitlePanel        # 字幕面板
    └── ExportDialog         # 导出对话框

数据模型只有三个轻量结构体,是整个系统的"通用语言":

// KaraokeSyllable.h —— 音节特效枚举
enum Effect { Instant, Sweep, Outline };  // \k / \kf / \ko

struct KaraokeSyllable {
    QString text;
    double startTime = 0;
    double duration = 0;
    Effect effect = Instant;
};

// KaraokeLine.h —— 一行歌词
struct KaraokeLine {
    QString style;
    double lineStart = 0;
    double lineEnd = 0;
    QString rawText;      // 含标签原文
    QString plainText;    // 去除标签后的纯文本
    QVector<KaraokeSyllable> syllables;
};

四、核心模块详解

4.1 libmpv 视频渲染引擎集成(MpvWidget)

libmpv 提供了 render.h 渲染接口,支持把视频帧渲染到我们提供的 OpenGL FBO 中。这里的关键是让 mpv 和 Qt 的 OpenGL 上下文协作:

// MpvWidget 继承 QOpenGLWidget
void MpvWidget::initializeGL()
{
    initializeOpenGLFunctions();

    m_mpv = mpv_create();
    // 关闭内置 UI,交给 Qt 管理
    mpv_set_option_string(m_mpv, "osc", "no");
    mpv_set_option_string(m_mpv, "idle", "yes");
    mpv_set_option_string(m_mpv, "loop-file", "inf");
    // 指定 OpenGL 渲染后端
    mpv_set_option_string(m_mpv, "vo", "libmpv");
    mpv_set_option_string(m_mpv, "gpu-api", "opengl");
    // 高精度 seek,配合逐字字幕
    mpv_set_option_string(m_mpv, "hr-seek", "yes");

    mpv_initialize(m_mpv);

    // 关键:用 Qt 的 getProcAddress 作为 OpenGL 函数解析器
    mpv_opengl_init_params glInit{};
    glInit.get_proc_address = getProcAddress;

    mpv_render_param params[] = {
        {MPV_RENDER_PARAM_API_TYPE, (void *)MPV_RENDER_API_TYPE_OPENGL},
        {MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &glInit},
        {MPV_RENDER_PARAM_INVALID, nullptr}
    };
    mpv_render_context_create(&m_renderCtx, m_mpv, params);

    // 帧更新回调 → 通过 QueuedConnection 安全触发重绘
    mpv_render_context_set_update_callback(m_renderCtx, onUpdate, this);
}

void MpvWidget::paintGL()
{
    int fbo = static_cast<int>(defaultFramebufferObject());
    mpv_opengl_fbo mpvFbo{};
    mpvFbo.fbo = fbo;
    mpvFbo.w = width();
    mpvFbo.h = height();

    int flip = 1;  // OpenGL 与视频坐标系 Y 轴相反,需要翻转
    mpv_render_param params[] = {
        {MPV_RENDER_PARAM_OPENGL_FBO, &mpvFbo},
        {MPV_RENDER_PARAM_FLIP_Y, &flip},
        {MPV_RENDER_PARAM_INVALID, nullptr}
    };
    mpv_render_context_render(m_renderCtx, params);
}

踩坑点:onUpdate 回调运行在 mpv 内部线程,不能直接调用 Qt 绘图接口,必须用 QMetaObject::invokeMethod(..., Qt::QueuedConnection) 投递到主线程。

4.2 播放控制与事件循环(PlaybackController)

mpv 本身是单线程异步模型,通过 wakeup callback 通知外部有新事件。这里采用"回调唤醒 + 队列投递"的方式把事件接到 Qt 信号槽体系:

static void mpvWakeup(void *ctx)
{
    QMetaObject::invokeMethod(static_cast<PlaybackController *>(ctx),
                              "onMpvEvents", Qtet::QueuedConnection);
}

PlaybackController::PlaybackController(mpv_handle *mpv, QObject *parent)
    : QObject(parent), m_mpv(mpv)
{
    // 观察需要实时同步的属性,变化时触发 PROPERTY_CHANGE 事件
    mpv_observe_property(m_mpv, 0, "time-pos", MPV_FORMAT_DOUBLE);
    mpv_observe_property(m_mpv, 0, "duration", MPV_FORMAT_DOUBLE);
    mpv_observe_property(m_mpv, 0, "pause", MPV_FORMAT_FLAG);
    mpv_observe_property(m_mpv, 0, "media-title", MPV_FORMAT_STRING);
    mpv_observe_property(m_mpv, 0, "idle-active", MPV_FORMAT_FLAG);

    mpv_set_wakeup_callback(m_mpv, mpvWakeup, this);
}

void PlaybackController::onMpvEvents()
{
    while (m_mpv) {
        mpv_event *event = mpv_wait_event(m_mpv, 0);  // 非阻塞
        if (event->event_id == MPV_EVENT_NONE) break;

        switch (event->event_id) {
        case MPV_EVENT_PROPERTY_CHANGE:
            processPropertyChange(...);  // 更新 position/duration/state
            break;
        case MPV_EVENT_FILE_LOADED:
            m_state = Playing; emit stateChanged(m_state); break;
        case MPV_EVENT_END_FILE:
            m_state = Stopped; break;
        // ...
        }
    }
}

通过 mpv_observe_property 订阅 time-pos,播放位置变化时会持续回调,前端据此同步进度条与字幕高亮。

4.3 ASS 卡拉OK标签解析(AssParser)

这是项目的核心难点。ASS 的卡拉OK标签格式为 {\k50}(50 厘秒 = 0.5 秒),三种变体对应不同特效:

标签 含义 枚举值
\k 变色后立即切换 Instant
\kf / \K 平滑扫过(填充) Sweep
\ko 先描边再填充 Outline

解析思路:用正则找出所有 {\k...} 标签,标签之间的文本文本即为一个音节,标签数值累加得到每个音节的起始时间:

QVector<KaraokeSyllable> AssParser::extractSyllables(const QString &textetext, double lineStart)
{
    QVector<KaraokeSyllable> syllables;
    static QRegularExpression kTagRE(R"(\{\\k(f|o)?(\d+)\})");
    double offset = lineStart;

    auto it = kTagRE.globalMatch(text);
    while (it.hasNext()) {
        auto match = it.next();
        QString type = match.captured(1);       // f / o / 空
        double duration = match.captured(2).toInt() / 100.0;  // 厘秒 → 秒

        // 标签之间的文本即当前音节
        int tagEnd = match.capturedEnd();
        int nextTagStart = text.indexOf('{', tagEnd);
        QString syllableText = (nextTagStart >= 0)
            ? text.mid(tagEnd, nextTagStart - tagEnd)
            : text.mid(tagEnd);
        syllableText = stripAssTags(syllableText);

        KaraokeSyllable syl;
        syl.text = syllableText;
        syl.startTime = offset;
        syl.duration = duration;
        syl.effect = (type == "f") ? Sweep
                   : (type == "o") ? Outline : Instant;

        syllables.append(syl);
        offset += duration;  // 累加推进时间轴
    }
    return syllables;
}

同时用正则解析 [V4+ Styles] 段里的样式定义(字体、字号、颜色)和 [Events] 段里的 Dialogue 行(起止时间、样式、文本)。

4.4 JSON 词级字幕转 ASS(JsonSubtitleParser)

现代 ASR(如 Whisper)输出的字幕是词级时间戳 JSON,形如:

{
  "clips": [
    {
      "clip_index": 0,
      "text": " "word": "你", "start": 1.0, "end": 1.5, "probability": 0.98 },
        { "word": "好", "start": 1.5, "end": 1.8, "probability": 0.96 }
      ]
    }
  ]
}

转换逻辑:每个 word 的 (start, end) 直接映射为一个 \kf 音节,整句拼接成一条 Dialogue:

for (const auto &w : clip.words) {
    KaraokeSyllable syl;
    syl.text = w.word;
    syl.startTime = w.start;
    syl.duration = w.end - w.start;
    syl.effect = KaraokeSyllable::Sweep;   // 词级时间戳 → 平滑扫过

    QString tag = QString("{\\kf%1}").arg(static_cast<int>((w.end - w.start) * 100));
    line.rawText += tag + escapeAssText(w.word);
}

最后 toJsonAss() 把整条轨道序列化为标准 ASS 文本(含 Script Info / Styles / Events 三段),并落地保存。这样用户拿 ASR 结果就能直接得到可烧录的卡拉OK字幕。

4.5 FFmpeg 字幕烧录导出(Exporter)

导出通过 QProcess 调用 FFmpeg 完成,核心是 ass 滤镜把字幕硬编码进画面:

QStringList args;
args << "-y" << "-i" << config.inputVideo
     << "-vf" << QString("ass='%1'").arg(assPath)   // 烧录字幕
     << "-c:v" << config.videoCodec << "-preset" << config.preset
     << "-crf" << QString::number(config.crf)
     << "-progress" << "pipe:1" << "-nostats";        // 进度输出到 stdout

进度解析则抓取 FFmpeg 的 -progress 输出里的 out_time_ms:

void Exporter::parseProgress(const QString &output)
{
    static QRegularExpression timeRE("out_time_ms=(\\d+)");
    auto m = timeRE.match(output);
    if (m.hasMatch() && m_totalDuration > 0) {
        double sec = m.captured(1).toDouble() / 1e6;
        double percent = qMin(sec / m_totalDuration * 100.0, 100.0);
        emit progressChanged(percent);
    }
}

Windows 路径转义坑:FFmpeg 的 ass 滤镜参数对路径中的 \、:、' 敏感,必须做转义,否则找不到字幕文件:

#ifdef Q_OS_WIN
assPath.replace('\\', "\\\\");
assPath.replace(':', "\\:");
assPath.replace('\'', "\\'");
#endif

4.6 界面设计

  • MainWindow:QMainWindow 布局,中央 MpvWidget,底部 QToolBar 放控制条,右侧 QDockWidget 放字幕面板;
  • SubtitlePanel:QListWidget 列出所有歌词行,播放时通过 highlightLineAt() 高亮当前行;点击某行可精确 seek 到该句;
  • ControlBar:播放/暂停、停止、打开视频、加载字幕、导出、进度条、音量;
  • ExportDialog:输入视频、字幕、输出路径、preset、CRF、是否拷贝音频、实时进度与日志。

字幕高亮的核心是位置与行的区间匹配:

void SubtitlePanel::highlightLineAt(double position)
{
    for (int i = 0; i < m_track.lines.size(); ++i) {
        const auto &line = m_track.lines[i];
        if (position >= line.lineStart && position <= line.lineEnd) {
            if (m_list->currentRow() != i) {
                m_list->blockSignals(true);   // 防止触发 lineClicked 递归 seek
                m_list->setCurrentRow(i);
                m_list->scrollToItem(m_list->item(i));
                m_list->blockSignals(false);
            }
            return;
        }
    }
}

五、构建与部署

CMake 配置中除了编译,还自动处理了依赖 DLL 的拷贝(Qt 运行时、MinGW 运行时、平台插件、图片格式插件、libmpv):

# 拷贝 libmpv
add_custom_command(TARGET KaraokePlayer POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy_if_different
        "${MPV_DLL}" "$<TARGET_FILE_DIR:KaraokePlayer>/libmpv-2.dll")

# 拷贝 Qt 平台插件(否则程序启动即崩溃)
COMMAND ${CMAKE_COMMAND} -E copy_if_different
    "${QT_PLUGINS_DIR}/platforms/qwindows.dll" "${OUT_DIR}/platforms/"

注意:qwindows.dll 平台插件缺失是 Qt 打包最常见的崩溃原因,windeployqt 有时会因 PATH 里有其它 Qt 版本而拷贝错误版本,这里选择显式指定路径拷贝,更可靠。

六、关键技术难点总结

  1. mpv 与 Qt OpenGL 上下文协作:通过 getProcAddress 桥接,帧更新回调需跨线程投递;
  2. ASS 卡拉OK标签解析:\k/\kf/\ko 三种特效语义的还原,以及厘秒到秒的换算;
  3. 词级字幕 → 卡拉OK字幕:把 ASR 的 word 时间戳映射为 \kf 标签,实现自动逐字高亮;
  4. FFmpeg 进度实时反馈:解析 -progress pipe:1 的 out_time_ms 字段;
  5. Windows 路径转义:ass 滤镜对特殊字符敏感,需统一转义。

七、总结与展望

KaraokePlayer 完整串联了"视频渲染 → 字幕解析 → 逐字高亮 → 字幕烧录"这条链路,是一个非常适合学习 Qt 多媒体开发、libmpv 嵌入式渲染 和 FFmpeg 字幕处理 的实践项目。

后续可以继续扩展的方向:

  • 增加卡拉OK特效渲染(逐字渐变、描边动画);
  • 接入 Whisper 实时转写,实现"边录音边出字幕";
  • 支持多轨字幕切换与歌词编辑;
  • 打包成可分发安装包(NSIS / Inno Setup)。

希望这篇文章能帮到你。如果你也在做类似的多媒体项目,欢迎交流讨论。


本文代码基于个人开源项目 KaraokePlayer 整理,技术栈:Qt 5.15.2 / C++17 / libmpv / FFmpeg。