第25章:ASR 结果结构化:分句与词级时间戳
本章分析 internal/pkg/asr 的结果解析族:utterances.go(分句/词结构)、result.go(时长解析)、format.go(格式探测)。核心问题只有一个:如何把豆包的原始 JSON 稳定地翻译成业务结构,并保证「任何脏数据都返回空数组而非崩溃」。
流程
转写完成后,原始 JSON 整体存进 live_material.live_asr(第 10 章)。后续所有消费都经过一道翻译函数:
live_asr 原始 JSON
├─ FormatUtterancesForAPI → []Utterance(分句 + 说话人 + 词级时间戳)
├─ ParseDurationMs → int64 毫秒(audio_info.duration)
└─ (后处理 Worker)→ asr_paragraphs 段落层(第 30 章)
FormatUtterancesForAPI 的防御链条:空串与 "{}"(默认值)直接返回空切片 → Unmarshal 失败返回空切片 → 逐条把 result.utterances[] 翻译成业务结构(豆包把说话人藏在 additions.speaker 嵌套层,翻译函数负责拉平)→ words 数组同样逐条转换。返回值永远是初始化的切片而非 nil,调用方 range 零值安全。
这一层的关键定位:豆包的 JSON 字段命名(additions、result 嵌套、毫秒时间戳)被严格限制在 asr 包内部;业务层(model.ASRParagraph、ClipWord)只面对规整后的结构。换 ASR 供应商时理论上只改这一个包。
实现
实现模式的三个特征:
- 匿名结构体精确建模:
var payload struct{ Result struct{ Utterances []struct{...} } }——只声明用到的路径,供应商加字段不影响解析,缺字段零值降级。 - 双层循环翻译:utterances 层与 words 层各自独立建切片(
make(..., 0, len(...))预分配容量),一个词结构损坏不影响其他词。 - 降级而非报错:解析函数返回
(值)而非(值, error)——因为原始 JSON 已经过客户端成功返回,格式异常属于「数据已坏」场景,报错调用方也无法处理;返回空数组让上层自然走「无转写内容」的既有分支(如字幕导出返回ErrASRSubtitleEmpty,第 21 章)。
Word 结构(text + start/end 毫秒)是整个精度体系的原子:字幕按词对齐(第 27 章)、clips1 的 words 从段落词表截取(第 36 章)、草稿字幕轨时间轴基于它(第 41 章)。所有时间单位全链统一毫秒,与 ffmpeg 交互时才除以 1000(prepare 包 float64(clip.StartTime) / 1000.0)。
format.go 的 DetectFormat:从 URL 后缀(.mp3/.wav/.m4a 等)推断提交参数里的 format 字段;无法识别时报错阻止提交(宁可失败也不猜错格式让远端白跑)。
配套测试覆盖:空 JSON、"{}"、损坏 JSON、正常完整结构四个分支(utterances_test.go / result_test.go / format_test.go)——防御性解析的每个分支都有断言。
📌 设计决策
- 翻译层独立成包:供应商协议被包住,业务结构稳定;换供应商的改动半径 = asr 包 + 配置。
- 降级返回空切片:脏数据不炸链路,上层按「无内容」既有路径处理;代价是「真无内容」与「解析坏了」不可区分——权衡点在于后者概率极低且有 live_asr 原文可人工排查。
- 说话人字段拉平进 Utterance.Speaker:下游(段落聚合同说话人,第 30 章)不必知道豆包的嵌套细节。
代码示例
业务分句结构(全链路的精度原子):
// internal/pkg/asr/utterances.go
// Utterance API 返回的 ASR 分句结构。
type Utterance struct {
Speaker string `json:"speaker"`
EndTime int64 `json:"end_time"`
StartTime int64 `json:"start_time"`
Text string `json:"text"`
Words []Word `json:"words"`
}
// Word API 返回的 ASR 字级时间戳。
type Word struct {
EndTime int64 `json:"end_time"`
StartTime int64 `json:"start_time"`
Text string `json:"text"`
}
防御式翻译函数的骨架:
// FormatUtterancesForAPI 将数据库 live_asr 原始 JSON 转为 API 分句数组。
// 无识别结果或解析失败时返回空数组。
func FormatUtterancesForAPI(liveASRJSON string) []Utterance {
if liveASRJSON == "" || liveASRJSON == "{}" {
return []Utterance{
} // 默认值短路
}
var payload struct {
/* 只声明需要的路径 */ }
if err := json.Unmarshal([]byte(liveASRJSON), &payload); err != nil {
return []Utterance{
} // 解析失败降级
}
utterances := make([]Utterance, 0, len(payload.Result.Utterances))
for _, item := range payload.Result.Utterances {
words := make([]Word, 0, len(item.Words))
for _, w := range item.Words {
words = append(words, Word{
/* 拉平翻译 */})
}
utterances = append(utterances, Utterance{
Speaker: item.Additions.Speaker, // 嵌套拉平
StartTime: item.StartTime, EndTime: item.EndTime,
Text: item.Text, Words: words,
})
}
return utterances
}
小结
- asr 解析族 = 供应商协议翻译层 + 防御式降级 + 拉平嵌套。
- Word(字级毫秒时间戳)是字幕与切片精度的原子单位。
- 换供应商的改动半径被锁定在一个包内。
思考题
- 「解析坏了」与「真无内容」不可区分的场景,如何用一条 Warn 日志低成本地区分?
- 若新 ASR 供应商输出秒级时间戳,应该在哪一层统一换算成毫秒?为什么?
项目信息
- GitHub仓库:Chyona/live-mixer