一、为什么需要动态字幕?
做音视频编辑的朋友,应该都见过卡拉OK字幕。那种随着歌声逐字变色的效果,确实很酷。我在项目中第一次接到这个需求时,心想不就是字幕嘛,用MediaMuxer混流就行了。结果一做才发现,事情没那么简单。
动态字幕,说白了就是让字幕的显示状态随时间变化。普通字幕是整句显示,卡拉OK风格则是逐字或逐词高亮。你想想看,如果只是把字幕轨道塞进视频,那顶多算静态字幕。要实现动态效果,必须精确控制每一帧的显示内容。
我个人习惯把动态字幕分为两类:
- 时间轴驱动型:每个字或词有独立的开始和结束时间
- 进度驱动型:根据播放进度计算当前高亮位置
卡拉OK字幕属于后者。它需要你实时计算当前唱到哪个字了,然后动态生成字幕图像。
核心思路:用MediaMuxer把字幕帧作为视频轨道的一部分混入。字幕帧本身是动态生成的Bitmap,每帧都包含当前高亮状态。
二、整体架构设计
先别急着写代码。我们得把流程理清楚。我在做第一个版本时,就是没想清楚架构,结果代码改了三遍。
整个流程分三步:
- 解析歌词:把LRC或SRT格式的歌词,转成时间戳+文本的列表
- 生成字幕帧:根据当前时间,生成带高亮效果的Bitmap
- 混流输出:用MediaMuxer把字幕帧和视频帧合并
下面这张图能帮你快速理解整体逻辑:
我的经验:第一次做时,我直接在MediaCodec的输入缓冲区里画字幕,结果帧率掉得厉害。后来改成在应用层生成Bitmap,再用MediaCodec编码,性能就好多了。
三、歌词解析:从LRC到数据结构
LRC格式很简单,就是时间戳+歌词文本。比如:
[00:12.34] 让我们荡起双桨
[00:16.78] 小船儿推开波浪
解析起来不复杂,但有个坑——每个字的时间怎么算?LRC只给了每句的开始时间,没给每个字的时间。我一开始想当然地平均分配,结果唱到快节奏的歌时,字幕完全对不上。
后来我换了个思路:用整句时长除以字数,得到每个字的平均时长。虽然不完美,但大部分场景够用了。
data class LyricLine(
val startTimeMs: Long, // 这句开始时间
val endTimeMs: Long, // 这句结束时间
val text: String, // 歌词文本
val charDurations: List<Long> // 每个字的时长
)
fun parseLrc(lrcContent: String): List<LyricLine> {
val lines = mutableListOf<LyricLine>()
val pattern = Regex("""\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)""")
// 先解析所有行,获取时间戳
val rawLines = lrcContent.lines().mapNotNull { line ->
pattern.find(line)?.let { match ->
val minutes = match.groupValues[1].toInt()
val seconds = match.groupValues[2].toInt()
val millis = match.groupValues[3].padEnd(3, '0').take(3).toInt()
val text = match.groupValues[4].trim()
val startMs = minutes * 60_000L + seconds * 1000L + millis
startMs to text
}
}
// 计算每句的结束时间(下一句开始时间)
for (i in rawLines.indices) {
val (start, text) = rawLines[i]
val end = if (i + 1 < rawLines.size) rawLines[i + 1].first else start + 5000L
val charCount = text.length.coerceAtLeast(1)
val charDuration = (end - start) / charCount
val charDurations = List(charCount) { charDuration }
lines.add(LyricLine(start, end, text, charDurations))
}
return lines
}
注意:LRC文件可能有BOM头,解析前记得处理。我曾经因为这个bug排查了半天,最后发现是文件编码问题。
四、字幕帧生成:核心算法
这是整个功能最核心的部分。说白了,就是根据当前播放时间,决定哪些字应该高亮。
算法思路:
- 找到当前时间对应的歌词行
- 计算在该行内的进度百分比
- 根据进度决定高亮到第几个字
- 绘制两遍文字:先画灰色底,再画彩色高亮
这里有个关键点——高亮区域的裁剪。卡拉OK效果通常是从左到右渐变的,所以我们需要用clipRect来限制高亮文字的绘制区域。
fun generateSubtitleFrame(
lyrics: List<LyricLine>,
currentTimeMs: Long,
width: Int,
height: Int
): Bitmap {
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
// 找到当前歌词行
val currentLine = lyrics.find {
currentTimeMs in it.startTimeMs until it.endTimeMs
} ?: return bitmap
// 计算进度
val progress = (currentTimeMs - currentLine.startTimeMs).toFloat() /
(currentLine.endTimeMs - currentLine.startTimeMs)
// 计算高亮到的字符索引
val highlightIndex = (progress * currentLine.text.length).toInt()
.coerceIn(0, currentLine.text.length)
// 计算高亮宽度比例
val highlightWidth = progress.coerceIn(0f, 1f)
// 绘制灰色背景文字
val paint = Paint().apply {
color = Color.GRAY
textSize = 48f
isAntiAlias = true
}
val textX = 50f
val textY = height / 2f
canvas.drawText(currentLine.text, textX, textY, paint)
// 裁剪高亮区域
val textWidth = paint.measureText(currentLine.text)
canvas.save()
canvas.clipRect(
textX,
0f,
textX + textWidth * highlightWidth,
height.toFloat()
)
// 绘制彩色高亮文字
paint.color = Color.YELLOW
canvas.drawText(currentLine.text, textX, textY, paint)
canvas.restore()
return bitmap
}
关键点:clipRect的边界计算要精确。如果高亮区域和文字位置对不上,就会出现「高亮错位」的问题。我建议先用measureText测量文字宽度,再计算裁剪区域。
五、MediaMuxer混流:把字幕写进视频
字幕帧生成好了,接下来就是把它和原始视频帧合并。这里我用MediaMuxer同时处理两个轨道:原始视频轨道和字幕轨道。
具体做法是:
- 用MediaExtractor读取原始视频
- 用MediaCodec解码视频帧
- 在解码后的帧上叠加字幕Bitmap
- 用MediaCodec重新编码
- 用MediaMuxer写入输出文件
嗯,这里要注意时间戳的同步。字幕帧的时间戳必须和视频帧对齐,否则会出现字幕和画面不同步的情况。
fun muxWithSubtitle(
inputPath: String,
outputPath: String,
lyrics: List<LyricLine>
) {
val extractor = MediaExtractor()
extractor.setDataSource(inputPath)
// 选择视频轨道
val videoTrackIndex = selectVideoTrack(extractor)
extractor.selectTrack(videoTrackIndex)
// 配置编码器
val format = extractor.getTrackFormat(videoTrackIndex)
val decoder = MediaCodec.createDecoderByType(format.getString(MediaFormat.KEY_MIME)!!)
decoder.configure(format, null, null, 0)
decoder.start()
// 配置输出Muxer
val muxer = MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
val outputFormat = MediaFormat.createVideoFormat(
MediaFormat.MIMETYPE_VIDEO_AVC,
width, height
)
// ... 配置编码参数
val encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC)
encoder.configure(outputFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
encoder.start()
val outputTrack = muxer.addTrack(outputFormat)
muxer.start()
// 循环处理每一帧
val bufferInfo = MediaCodec.BufferInfo()
while (true) {
// 读取原始帧
val inputIndex = decoder.dequeueInputBuffer(10000)
if (inputIndex >= 0) {
val inputBuffer = decoder.getInputBuffer(inputIndex)
val sampleSize = extractor.readSampleData(inputBuffer, 0)
if (sampleSize < 0) break
bufferInfo.set(0, sampleSize, extractor.sampleTime, MediaCodec.BUFFER_FLAG_KEY_FRAME)
decoder.queueInputBuffer(inputIndex, 0, sampleSize, extractor.sampleTime, 0)
extractor.advance()
}
// 解码输出
val outputIndex = decoder.dequeueOutputBuffer(bufferInfo, 10000)
if (outputIndex >= 0) {
val outputBuffer = decoder.getOutputBuffer(outputIndex)
// 在这里叠加字幕
val subtitleBitmap = generateSubtitleFrame(
lyrics,
bufferInfo.presentationTimeUs / 1000,
width, height
)
overlaySubtitle(outputBuffer, subtitleBitmap)
// 送入编码器
val encoderInputIndex = encoder.dequeueInputBuffer(10000)
if (encoderInputIndex >= 0) {
val encoderInputBuffer = encoder.getInputBuffer(encoderInputIndex)
encoderInputBuffer.put(outputBuffer)
encoder.queueInputBuffer(
encoderInputIndex,
0,
bufferInfo.size,
bufferInfo.presentationTimeUs,
bufferInfo.flags
)
}
decoder.releaseOutputBuffer(outputIndex, false)
}
// 编码输出并写入Muxer
val encoderOutputIndex = encoder.dequeueOutputBuffer(bufferInfo, 10000)
if (encoderOutputIndex >= 0) {
val encoderOutputBuffer = encoder.getOutputBuffer(encoderOutputIndex)
muxer.writeSampleData(outputTrack, encoderOutputBuffer, bufferInfo)
encoder.releaseOutputBuffer(encoderOutputIndex, false)
}
}
// 清理资源
decoder.stop()
decoder.release()
encoder.stop()
encoder.release()
extractor.release()
muxer.stop()
muxer.release()
}
性能优化建议:不要每帧都创建新的Bitmap。我习惯维护一个Bitmap池,重复使用。另外,字幕绘制可以放在子线程,用SurfaceView的Callback来触发更新。
六、避坑指南
做这个功能时,我踩过不少坑。挑几个典型的说说:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 字幕和声音不同步 | 时间戳单位搞混了(微秒vs毫秒) | 统一用微秒,转换时注意乘1000 |
| 高亮区域闪烁 | 每帧重新创建Bitmap导致GC | 复用Bitmap,用Canvas.drawBitmap更新 |
| 中文字体显示方块 | 系统字体不支持中文 | 加载自定义字体:Typeface.createFromAsset |
| 输出视频花屏 | YUV格式转换错误 | 用RenderScript或libyuv做格式转换 |
我曾经遇到过一个特别隐蔽的问题:在某些手机上,字幕的YUV数据排列方式不同,导致颜色完全错乱。后来我加了个设备兼容性检测,根据不同芯片做不同的处理。
七、总结
卡拉OK风格字幕的实现,核心就三点:歌词解析、动态帧生成、MediaMuxer混流。说起来简单,但每个环节都有细节要注意。
我个人觉得,最难的部分其实是「高亮进度计算」。你要考虑中英文混排、标点符号、空格等特殊情况。比如英文单词,按字母高亮就不太对,应该按单词高亮。这些细节处理好了,效果才会自然。
最后提醒一句:测试时多试试不同语速的歌曲。快歌和慢歌的字幕表现差异很大,需要针对性地调整参数。