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 版本而拷贝错误版本,这里选择显式指定路径拷贝,更可靠。
六、关键技术难点总结
- mpv 与 Qt OpenGL 上下文协作:通过
getProcAddress桥接,帧更新回调需跨线程投递; - ASS 卡拉OK标签解析:
\k/\kf/\ko三种特效语义的还原,以及厘秒到秒的换算; - 词级字幕 → 卡拉OK字幕:把 ASR 的 word 时间戳映射为
\kf标签,实现自动逐字高亮; - FFmpeg 进度实时反馈:解析
-progress pipe:1的out_time_ms字段; - Windows 路径转义:
ass滤镜对特殊字符敏感,需统一转义。
七、总结与展望
KaraokePlayer 完整串联了"视频渲染 → 字幕解析 → 逐字高亮 → 字幕烧录"这条链路,是一个非常适合学习 Qt 多媒体开发、libmpv 嵌入式渲染 和 FFmpeg 字幕处理 的实践项目。
后续可以继续扩展的方向:
- 增加卡拉OK特效渲染(逐字渐变、描边动画);
- 接入 Whisper 实时转写,实现"边录音边出字幕";
- 支持多轨字幕切换与歌词编辑;
- 打包成可分发安装包(NSIS / Inno Setup)。
希望这篇文章能帮到你。如果你也在做类似的多媒体项目,欢迎交流讨论。
本文代码基于个人开源项目 KaraokePlayer 整理,技术栈:Qt 5.15.2 / C++17 / libmpv / FFmpeg。