一、为什么需要动态字幕?

做音视频编辑的朋友,应该都见过卡拉OK字幕。那种随着歌声逐字变色的效果,确实很酷。我在项目中第一次接到这个需求时,心想不就是字幕嘛,用MediaMuxer混流就行了。结果一做才发现,事情没那么简单。

动态字幕,说白了就是让字幕的显示状态随时间变化。普通字幕是整句显示,卡拉OK风格则是逐字或逐词高亮。你想想看,如果只是把字幕轨道塞进视频,那顶多算静态字幕。要实现动态效果,必须精确控制每一帧的显示内容。

我个人习惯把动态字幕分为两类:

  • 时间轴驱动型:每个字或词有独立的开始和结束时间
  • 进度驱动型:根据播放进度计算当前高亮位置

卡拉OK字幕属于后者。它需要你实时计算当前唱到哪个字了,然后动态生成字幕图像。

核心思路:用MediaMuxer把字幕帧作为视频轨道的一部分混入。字幕帧本身是动态生成的Bitmap,每帧都包含当前高亮状态。

二、整体架构设计

先别急着写代码。我们得把流程理清楚。我在做第一个版本时,就是没想清楚架构,结果代码改了三遍。

整个流程分三步:

  1. 解析歌词:把LRC或SRT格式的歌词,转成时间戳+文本的列表
  2. 生成字幕帧:根据当前时间,生成带高亮效果的Bitmap
  3. 混流输出:用MediaMuxer把字幕帧和视频帧合并

下面这张图能帮你快速理解整体逻辑:

步骤1:解析歌词 LRC/SRT → 时间戳+文本 步骤2:生成字幕帧 Bitmap + 高亮计算 步骤3:混流输出 MediaMuxer合并 歌词解析细节 • 读取LRC文件 • 正则提取时间戳 • 按时间排序 • 构建歌词列表 • 计算每个字的时长 • 处理多行歌词 字幕帧生成细节 • 创建空白Bitmap • 绘制背景文字(灰色) • 计算高亮进度 • 裁剪高亮区域 • 绘制高亮文字(彩色) • 转成YUV格式 混流输出细节 • 配置MediaFormat • 添加视频轨道 • 逐帧写入 • 同步时间戳 • 停止&释放 • 输出MP4文件

我的经验:第一次做时,我直接在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排查了半天,最后发现是文件编码问题。

四、字幕帧生成:核心算法

这是整个功能最核心的部分。说白了,就是根据当前播放时间,决定哪些字应该高亮。

算法思路:

  1. 找到当前时间对应的歌词行
  2. 计算在该行内的进度百分比
  3. 根据进度决定高亮到第几个字
  4. 绘制两遍文字:先画灰色底,再画彩色高亮

这里有个关键点——高亮区域的裁剪。卡拉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混流。说起来简单,但每个环节都有细节要注意。

我个人觉得,最难的部分其实是「高亮进度计算」。你要考虑中英文混排、标点符号、空格等特殊情况。比如英文单词,按字母高亮就不太对,应该按单词高亮。这些细节处理好了,效果才会自然。

最后提醒一句:测试时多试试不同语速的歌曲。快歌和慢歌的字幕表现差异很大,需要针对性地调整参数。


公众号:蓝海资料掘金营,微信deep3321