navidrome/model/lyrics_ttml.go
Deluan Quintão 13e96a0e81
Some checks are pending
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (Windows) (push) Waiting to run
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-10 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
fix(lyrics): correct TTML background-vocal cue timing and whitespace (#5672)
* fix(lyrics): correct TTML background-vocal cue timing and whitespace

Two parsing defects surfaced by Apple Music TTML files that mix a main
vocal with an x-bg (background) span group within the same line:

- Cue end-time normalization ran over the whole line's cue list in
  document order. Background cues are stored after the main cues but
  interleave earlier on the timeline, so the next-cue clamp collapsed the
  last main cue's end down to its own start (start == end). End times are
  now normalized per agent group, matching how the Subsonic serializer
  already groups cues, so parallel layers no longer corrupt each other.

- Whitespace between elements was treated as significant: pretty-printed
  (indented) TTML injected spurious newlines into the line text, turning
  one line into many. Per TTML2 default xml:space handling (linefeeds
  treat-as-space, whitespace-collapse), formatting whitespace now collapses
  to a single space and hard line breaks come only from <br/>.

The line-level value and per-agent cueLine.value remain the full line text,
as required by the OpenSubsonic songLyrics v2 contract; the per-agent text
is carried in each cueLine's cue[] array.

Two existing tests that encoded the buggy newline-as-break behavior are
corrected; new tests cover whitespace collapse, <br/> preservation, and
interleaved background cue timing.

* fix(lyrics): only collapse XML whitespace, preserve other Unicode spaces

Whitespace collapsing used unicode.IsSpace, which matches more than the XML
S production (space, tab, CR, LF): it also folds characters like NBSP and
U+3000 into a regular space, silently altering content. Restrict collapsing
to the four XML whitespace characters so other Unicode spaces pass through
unchanged, and add a regression test. Also clarify the doc comment that
collapsing is applied unconditionally (xml:space="preserve" is not supported).
2026-06-27 10:37:25 -04:00

1296 lines
30 KiB
Go

package model
import (
"bytes"
"encoding/xml"
"errors"
"io"
"math"
"regexp"
"sort"
"strconv"
"strings"
"unicode"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/str"
)
const (
defaultTTMLFrameRate = 30.0
defaultTTMLSubFrameRate = 1.0
defaultTTMLTickRate = 1.0
ttmlBackgroundAgentPrefix = "__nd_bg__|"
)
var offsetTimeRegex = regexp.MustCompile(`^([0-9]+(?:\.[0-9]+)?)(h|m|s|ms|f|t)$`)
var xmlEncodingRegex = regexp.MustCompile(`(?i)<\?xml([^>]*?)encoding\s*=\s*["'][^"']+["']([^>]*)\?>`)
type ttmlTimeKind int
const (
ttmlTimeAbsolute ttmlTimeKind = iota
ttmlTimeOffset
ttmlTimeAmbiguous
)
type ttmlTimingParams struct {
frameRate float64
subFrameRate float64
tickRate float64
}
type ttmlTimingContext struct {
lang string
role string
agentID string
begin int64
hasBegin bool
end int64
hasEnd bool
invalid bool
}
type ttmlLineRef struct {
order int
line Line
}
type ttmlMetadataEntry struct {
key string
line Line
seq int
}
type ttmlResolvedMetadataLine struct {
order int
seq int
line Line
}
type ttmlDefinedAgent struct {
ID string
Type string
Name string
}
type ttmlPiece struct {
raw string
cue *Cue
isBreak bool
}
type ttmlParser struct {
decoder *xml.Decoder
params ttmlTimingParams
mainLangOrder []string
mainLinesByLang map[string][]Line
mainLineRefsByKey map[string]ttmlLineRef
mainLineOrder int
translationLangOrder []string
translationEntriesByLg map[string][]ttmlMetadataEntry
pronunciationLangOrder []string
pronunciationEntriesByLg map[string][]ttmlMetadataEntry
definedAgents map[string]ttmlDefinedAgent
metadataSeq int
}
func isTTMLDocument(contents []byte) bool {
decoder := xml.NewDecoder(bytes.NewReader(bytes.TrimSpace(contents)))
for {
token, err := decoder.Token()
if err != nil {
return false
}
if start, ok := token.(xml.StartElement); ok {
return strings.EqualFold(start.Name.Local, "tt")
}
}
}
func parseTTML(defaultLang string, contents []byte) (LyricList, error) {
contents = xmlEncodingRegex.ReplaceAll(contents, []byte(`<?xml$1encoding="UTF-8"$2?>`))
// Skip non-TTML content so sniffing doesn't run the full TTML parse on plain
// text — isTTMLDocument does a cheap decode that stops at the first element.
// Checked after the encoding fixup so UTF-16-declared documents are recognized.
if !isTTMLDocument(contents) {
return nil, nil
}
p := ttmlParser{
decoder: xml.NewDecoder(bytes.NewReader(contents)),
params: ttmlTimingParams{
frameRate: defaultTTMLFrameRate,
subFrameRate: defaultTTMLSubFrameRate,
tickRate: defaultTTMLTickRate,
},
mainLinesByLang: make(map[string][]Line),
mainLineRefsByKey: make(map[string]ttmlLineRef),
translationEntriesByLg: make(map[string][]ttmlMetadataEntry),
pronunciationEntriesByLg: make(map[string][]ttmlMetadataEntry),
definedAgents: make(map[string]ttmlDefinedAgent),
}
root := ttmlTimingContext{lang: normalizeLyricLang(defaultLang)}
for {
token, err := p.decoder.Token()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
start, ok := token.(xml.StartElement)
if !ok {
continue
}
if err := p.parseElement(start, root); err != nil {
return nil, err
}
}
return p.toLyricList(), nil
}
func (p *ttmlParser) parseElement(start xml.StartElement, parent ttmlTimingContext) error {
local := strings.ToLower(start.Name.Local)
if local == "tt" {
p.updateTimingParams(start.Attr)
}
switch local {
case "translation":
return p.parseMetadataTrack(start, parent, LyricKindTranslation)
case "transliteration":
return p.parseMetadataTrack(start, parent, LyricKindPronunciation)
case "agent":
return p.parseAgentDefinition(start)
}
ctx := p.childContext(start.Attr, parent)
if local == "p" {
lineText, tokens, err := p.parseParagraph(ctx)
if err != nil {
return err
}
if ctx.invalid || lineText == "" {
return nil
}
parsedLine := Line{Value: lineText}
if ctx.hasBegin {
startMs := ctx.begin
parsedLine.Start = &startMs
}
if ctx.hasEnd {
endMs := ctx.end
parsedLine.End = &endMs
}
if len(tokens) > 0 {
parsedLine.Cue = tokens
}
parsedLine = normalizeLineTiming(parsedLine)
lineKey, _ := attrValue(start.Attr, "key")
p.addMainLine(ctx.lang, lineKey, parsedLine)
return nil
}
for {
token, err := p.decoder.Token()
if err != nil {
return err
}
switch t := token.(type) {
case xml.StartElement:
nextParent := ctx
if ctx.invalid {
// Best effort: ignore invalid timing in container elements, and
// continue traversing descendants with parent context.
nextParent = parent
}
if err := p.parseElement(t, nextParent); err != nil {
return err
}
case xml.EndElement:
if strings.EqualFold(t.Name.Local, start.Name.Local) {
return nil
}
}
}
}
func (p *ttmlParser) parseMetadataTrack(start xml.StartElement, parent ttmlTimingContext, kind string) error {
ctx := p.childContext(start.Attr, parent)
lang := normalizeLyricLang(ctx.lang)
for {
token, err := p.decoder.Token()
if err != nil {
return err
}
switch t := token.(type) {
case xml.StartElement:
if strings.EqualFold(t.Name.Local, "text") {
entry, ok, err := p.parseMetadataText(t, ctx)
if err != nil {
return err
}
if ok {
p.addMetadataEntry(kind, lang, entry)
}
continue
}
nextParent := ctx
if ctx.invalid {
nextParent = parent
}
if err := p.parseElement(t, nextParent); err != nil {
return err
}
case xml.EndElement:
if strings.EqualFold(t.Name.Local, start.Name.Local) {
return nil
}
}
}
}
func (p *ttmlParser) parseAgentDefinition(start xml.StartElement) error {
id, ok := attrValue(start.Attr, "id")
id = strings.TrimSpace(id)
if !ok || id == "" {
return p.skipElement(start)
}
agent := ttmlDefinedAgent{
ID: id,
Type: strings.ToLower(strings.TrimSpace(attrOrEmpty(start.Attr, "type"))),
}
for {
token, err := p.decoder.Token()
if err != nil {
return err
}
switch t := token.(type) {
case xml.StartElement:
if strings.EqualFold(t.Name.Local, "name") {
name, err := p.collectElementText(t)
if err != nil {
return err
}
name = sanitizeTTMLText(name)
if name != "" && agent.Name == "" {
agent.Name = name
}
continue
}
if err := p.skipElement(t); err != nil {
return err
}
case xml.EndElement:
if strings.EqualFold(t.Name.Local, start.Name.Local) {
p.definedAgents[agent.ID] = agent
return nil
}
}
}
}
func (p *ttmlParser) parseMetadataText(start xml.StartElement, parent ttmlTimingContext) (ttmlMetadataEntry, bool, error) {
forKey, hasFor := attrValue(start.Attr, "for")
forKey = strings.TrimSpace(forKey)
pieces, err := p.parseInlineElement(start, parent)
if err != nil {
return ttmlMetadataEntry{}, false, err
}
if !hasFor || forKey == "" {
return ttmlMetadataEntry{}, false, nil
}
ctx := p.childContext(start.Attr, parent)
if ctx.invalid {
return ttmlMetadataEntry{}, false, nil
}
value, tokens := buildTTMLLineFromPieces(pieces)
line := Line{Value: value}
if ctx.hasBegin {
startMs := ctx.begin
line.Start = &startMs
}
if ctx.hasEnd {
endMs := ctx.end
line.End = &endMs
}
if len(tokens) > 0 {
line.Cue = tokens
}
line = normalizeLineTiming(line)
if line.Value == "" && len(line.Cue) == 0 {
return ttmlMetadataEntry{}, false, nil
}
return ttmlMetadataEntry{key: forKey, line: line}, true, nil
}
func (p *ttmlParser) parseParagraph(parent ttmlTimingContext) (string, []Cue, error) {
var pieces []ttmlPiece
for {
token, err := p.decoder.Token()
if err != nil {
return "", nil, err
}
switch t := token.(type) {
case xml.StartElement:
inlinePieces, err := p.parseInlineElement(t, parent)
if err != nil {
return "", nil, err
}
pieces = append(pieces, inlinePieces...)
case xml.EndElement:
if strings.EqualFold(t.Name.Local, "p") {
value, tokens := buildTTMLLineFromPieces(pieces)
return value, tokens, nil
}
case xml.CharData:
pieces = append(pieces, ttmlPiece{raw: string(t)})
}
}
}
func (p *ttmlParser) parseInlineElement(start xml.StartElement, parent ttmlTimingContext) ([]ttmlPiece, error) {
local := strings.ToLower(start.Name.Local)
if local == "br" {
return []ttmlPiece{{isBreak: true}}, nil
}
ctx := p.childContext(start.Attr, parent)
_, hasBegin := attrValue(start.Attr, "begin")
_, hasEnd := attrValue(start.Attr, "end")
_, hasDur := attrValue(start.Attr, "dur")
hasOwnTiming := hasBegin || hasEnd || hasDur
var pieces []ttmlPiece
for {
token, err := p.decoder.Token()
if err != nil {
return nil, err
}
switch t := token.(type) {
case xml.StartElement:
inlinePieces, err := p.parseInlineElement(t, ctx)
if err != nil {
return nil, err
}
pieces = append(pieces, inlinePieces...)
case xml.EndElement:
if !strings.EqualFold(t.Name.Local, start.Name.Local) {
continue
}
if local == "span" && hasOwnTiming && !ctx.invalid && !ttmlPiecesContainCue(pieces) {
rawValue := concatTTMLPieceRaw(pieces)
tokenText := sanitizeTTMLText(rawValue)
if tokenText != "" {
parsedToken := Cue{
AgentID: p.resolveCueAgentID(ctx),
}
if ctx.hasBegin {
startMs := ctx.begin
parsedToken.Start = &startMs
}
if ctx.hasEnd {
endMs := ctx.end
parsedToken.End = &endMs
}
return []ttmlPiece{{
raw: rawValue,
cue: &parsedToken,
}}, nil
}
}
return pieces, nil
case xml.CharData:
pieces = append(pieces, ttmlPiece{raw: string(t)})
}
}
}
func buildTTMLLineFromPieces(pieces []ttmlPiece) (string, []Cue) {
finalized := finalizeTTMLLines(splitTTMLPiecesByBreak(pieces))
for len(finalized) > 0 && finalized[0].text == "" && len(finalized[0].cues) == 0 {
finalized = finalized[1:]
}
for len(finalized) > 0 {
last := finalized[len(finalized)-1]
if last.text != "" || len(last.cues) > 0 {
break
}
finalized = finalized[:len(finalized)-1]
}
var value strings.Builder
cues := make([]Cue, 0, 8)
byteOffset := 0
for i, line := range finalized {
if i > 0 {
value.WriteByte('\n')
byteOffset++
}
value.WriteString(line.text)
for _, cue := range line.cues {
cue.ByteStart += byteOffset
cue.ByteEnd += byteOffset
cues = append(cues, cue)
}
byteOffset += len(line.text)
}
return value.String(), cues
}
type ttmlFinalLine struct {
text string
cues []Cue
}
func finalizeTTMLLines(lines [][]ttmlPiece) []ttmlFinalLine {
finalized := make([]ttmlFinalLine, 0, len(lines))
for _, line := range lines {
text, cues := finalizeTTMLLogicalLine(line)
finalized = append(finalized, ttmlFinalLine{text: text, cues: cues})
}
return finalized
}
func splitTTMLPiecesByBreak(pieces []ttmlPiece) [][]ttmlPiece {
lines := [][]ttmlPiece{{}}
prevEndedWithSpace := true // leading whitespace on a fresh line is dropped
for _, piece := range pieces {
if piece.isBreak {
lines = append(lines, []ttmlPiece{})
prevEndedWithSpace = true
continue
}
raw := normalizeTTMLPieceRaw(piece.raw)
// Collapse whitespace across piece boundaries: a piece's leading space is
// redundant when the text emitted so far already ends with one.
if prevEndedWithSpace {
raw = strings.TrimPrefix(raw, " ")
}
if raw == "" {
continue
}
lines[len(lines)-1] = append(lines[len(lines)-1], ttmlPiece{
raw: raw,
cue: gg.Clone(piece.cue),
})
prevEndedWithSpace = strings.HasSuffix(raw, " ")
}
return lines
}
func finalizeTTMLLogicalLine(line []ttmlPiece) (string, []Cue) {
rawLine := concatTTMLPieceRaw(line)
if rawLine == "" {
return "", nil
}
leftTrimBytes := len(rawLine) - len(strings.TrimLeftFunc(rawLine, unicode.IsSpace))
rightTrimBytes := len(rawLine) - len(strings.TrimRightFunc(rawLine, unicode.IsSpace))
trimmedEnd := len(rawLine) - rightTrimBytes
if trimmedEnd < leftTrimBytes {
trimmedEnd = leftTrimBytes
}
trimmed := strings.TrimSpace(rawLine)
cues := make([]Cue, 0, len(line))
cursor := 0
for _, piece := range line {
pieceEnd := cursor + len(piece.raw)
if piece.cue != nil {
byteStart := max(cursor, leftTrimBytes)
byteEnd := min(pieceEnd, trimmedEnd)
if byteStart < byteEnd {
cue := *piece.cue
cue.Value = rawLine[byteStart:byteEnd]
cue.ByteStart = byteStart - leftTrimBytes
cue.ByteEnd = byteEnd - leftTrimBytes - 1
cues = append(cues, cue)
}
}
cursor = pieceEnd
}
return trimmed, cues
}
// normalizeTTMLPieceRaw collapses whitespace following TTML's default mode
// (xml:space="default", the root default per TTML2 §8.1.1): per §8.2.10 that
// means linefeed-treatment="treat-as-space" and white-space-collapse="true", so
// linefeeds and other whitespace runs collapse to a single space. Collapsing is
// applied unconditionally; xml:space="preserve" is not supported (no lyric
// source in practice relies on it). Hard line breaks come only from <br/>
// (§8.1.7), tracked separately via ttmlPiece.isBreak, so pretty-printed
// indentation between elements does not inject spurious newlines.
func normalizeTTMLPieceRaw(raw string) string {
raw = str.SanitizeText(raw)
return collapseTTMLWhitespace(raw)
}
func collapseTTMLWhitespace(raw string) string {
var b strings.Builder
b.Grow(len(raw))
prevSpace := false
for _, r := range raw {
// Only the XML S production (space, tab, CR, LF) is collapsible whitespace.
// Other Unicode spaces (e.g. NBSP, U+3000) are content characters, not
// whitespace, so they pass through unchanged.
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
if !prevSpace {
b.WriteByte(' ')
prevSpace = true
}
continue
}
b.WriteRune(r)
prevSpace = false
}
return b.String()
}
func concatTTMLPieceRaw(pieces []ttmlPiece) string {
var raw strings.Builder
for _, piece := range pieces {
raw.WriteString(normalizeTTMLPieceRaw(piece.raw))
}
return raw.String()
}
func ttmlPiecesContainCue(pieces []ttmlPiece) bool {
for _, piece := range pieces {
if piece.cue != nil {
return true
}
}
return false
}
func (p *ttmlParser) toLyricList() LyricList {
res := make(LyricList, 0, len(p.mainLangOrder)+len(p.translationLangOrder)+len(p.pronunciationLangOrder))
for _, lang := range p.mainLangOrder {
lines := p.mainLinesByLang[lang]
if len(lines) == 0 {
continue
}
res = append(res, p.finalizeLyrics(Lyrics{
Kind: LyricKindMain,
Lang: lang,
Line: lines,
Synced: linesAreSynced(lines),
}))
}
res = append(res, p.buildMetadataLyrics(LyricKindTranslation, p.translationLangOrder, p.translationEntriesByLg)...)
res = append(res, p.buildMetadataLyrics(LyricKindPronunciation, p.pronunciationLangOrder, p.pronunciationEntriesByLg)...)
return res
}
func (p *ttmlParser) buildMetadataLyrics(kind string, langOrder []string, entriesByLang map[string][]ttmlMetadataEntry) LyricList {
res := make(LyricList, 0, len(langOrder))
for _, lang := range langOrder {
entries := entriesByLang[lang]
if len(entries) == 0 {
continue
}
seenKeys := make(map[string]struct{}, len(entries))
resolved := make([]ttmlResolvedMetadataLine, 0, len(entries))
for _, entry := range entries {
if _, exists := seenKeys[entry.key]; exists {
continue
}
seenKeys[entry.key] = struct{}{}
ref, ok := p.mainLineRefsByKey[entry.key]
if !ok {
log.Warn("Skipping TTML metadata line without matching key", "kind", kind, "lang", lang, "key", entry.key)
continue
}
line := entry.line
if line.Start == nil && ref.line.Start != nil {
startMs := *ref.line.Start
line.Start = &startMs
}
if line.End == nil && ref.line.End != nil {
endMs := *ref.line.End
line.End = &endMs
}
line = normalizeLineTiming(line)
if line.Value == "" && len(line.Cue) == 0 {
continue
}
resolved = append(resolved, ttmlResolvedMetadataLine{
order: ref.order,
seq: entry.seq,
line: line,
})
}
if len(resolved) == 0 {
continue
}
sort.SliceStable(resolved, func(i, j int) bool {
if resolved[i].order != resolved[j].order {
return resolved[i].order < resolved[j].order
}
return resolved[i].seq < resolved[j].seq
})
lines := make([]Line, len(resolved))
for i := range resolved {
lines[i] = resolved[i].line
}
res = append(res, p.finalizeLyrics(Lyrics{
Kind: kind,
Lang: lang,
Line: lines,
Synced: linesAreSynced(lines),
}))
}
return res
}
func (p *ttmlParser) finalizeLyrics(lyrics Lyrics) Lyrics {
lyrics.Line, lyrics.Agents = p.resolveAgents(lyrics.Line)
return normalizeLyrics(lyrics)
}
func (p *ttmlParser) resolveAgents(lines []Line) ([]Line, []Agent) {
if len(lines) == 0 {
return lines, nil
}
usedOrder := make([]string, 0, 4)
usedSet := make(map[string]struct{}, 4)
sawEmptyCue := false
for i := range lines {
for j := range lines[i].Cue {
agentID := strings.TrimSpace(lines[i].Cue[j].AgentID)
if agentID == "" {
sawEmptyCue = true
continue
}
if _, exists := usedSet[agentID]; !exists {
usedSet[agentID] = struct{}{}
usedOrder = append(usedOrder, agentID)
}
}
}
if len(usedOrder) == 0 {
return lines, nil
}
mainID := ""
for _, agentID := range usedOrder {
role := p.baseRoleForAgent(agentID)
if role != "bg" && role != "group" {
mainID = agentID
break
}
}
if mainID == "" && sawEmptyCue {
mainID = "main"
}
if mainID == "" {
for _, agentID := range usedOrder {
if p.baseRoleForAgent(agentID) != "bg" {
mainID = agentID
break
}
}
}
if mainID == "" {
mainID = usedOrder[0]
}
if _, exists := usedSet[mainID]; !exists {
usedSet[mainID] = struct{}{}
usedOrder = append([]string{mainID}, usedOrder...)
}
for i := range lines {
for j := range lines[i].Cue {
if strings.TrimSpace(lines[i].Cue[j].AgentID) == "" {
lines[i].Cue[j].AgentID = mainID
}
}
}
agents := make([]Agent, 0, len(usedOrder))
for _, agentID := range usedOrder {
role := p.baseRoleForAgent(agentID)
if agentID == mainID {
role = "main"
}
agent := Agent{
ID: agentID,
Role: role,
Name: p.agentNameForID(agentID),
}
agents = append(agents, agent)
}
return lines, agents
}
func (p *ttmlParser) resolveCueAgentID(ctx ttmlTimingContext) string {
agentID := strings.TrimSpace(ctx.agentID)
if contextHasRole(ctx.role, "x-bg") {
if agentID == "" {
agentID = "main"
}
return backgroundAgentID(agentID)
}
return agentID
}
func (p *ttmlParser) baseRoleForAgent(agentID string) string {
if isBackgroundAgentID(agentID) {
return "bg"
}
if agent, ok := p.definedAgents[agentID]; ok {
switch agent.Type {
case "group":
return "group"
default:
return "voice"
}
}
return "voice"
}
func (p *ttmlParser) agentNameForID(agentID string) string {
if isBackgroundAgentID(agentID) {
baseID := strings.TrimPrefix(agentID, ttmlBackgroundAgentPrefix)
if baseID == "main" {
return ""
}
if agent, ok := p.definedAgents[baseID]; ok {
return agent.Name
}
return ""
}
if agent, ok := p.definedAgents[agentID]; ok {
return agent.Name
}
return ""
}
func backgroundAgentID(agentID string) string {
return ttmlBackgroundAgentPrefix + agentID
}
func isBackgroundAgentID(agentID string) bool {
return strings.HasPrefix(agentID, ttmlBackgroundAgentPrefix)
}
func contextHasRole(roles string, role string) bool {
lowerRole := strings.ToLower(role)
for _, candidate := range strings.Fields(strings.ToLower(roles)) {
if candidate == lowerRole {
return true
}
}
return false
}
func appendTTMLRoles(existing string, roles string) string {
for _, role := range strings.Fields(roles) {
if contextHasRole(existing, role) {
continue
}
if existing == "" {
existing = role
} else {
existing += " " + role
}
}
return existing
}
func (p *ttmlParser) addMainLine(lang string, lineKey string, line Line) {
lang = normalizeLyricLang(lang)
if _, ok := p.mainLinesByLang[lang]; !ok {
p.mainLangOrder = append(p.mainLangOrder, lang)
}
p.mainLinesByLang[lang] = append(p.mainLinesByLang[lang], line)
lineKey = strings.TrimSpace(lineKey)
if lineKey != "" {
if _, exists := p.mainLineRefsByKey[lineKey]; !exists {
p.mainLineRefsByKey[lineKey] = ttmlLineRef{
order: p.mainLineOrder,
line: line,
}
}
}
p.mainLineOrder++
}
func (p *ttmlParser) addMetadataEntry(kind string, lang string, entry ttmlMetadataEntry) {
lang = normalizeLyricLang(lang)
entry.seq = p.metadataSeq
p.metadataSeq++
switch kind {
case LyricKindTranslation:
if _, ok := p.translationEntriesByLg[lang]; !ok {
p.translationLangOrder = append(p.translationLangOrder, lang)
}
p.translationEntriesByLg[lang] = append(p.translationEntriesByLg[lang], entry)
case LyricKindPronunciation:
if _, ok := p.pronunciationEntriesByLg[lang]; !ok {
p.pronunciationLangOrder = append(p.pronunciationLangOrder, lang)
}
p.pronunciationEntriesByLg[lang] = append(p.pronunciationEntriesByLg[lang], entry)
}
}
func (p *ttmlParser) childContext(attrs []xml.Attr, parent ttmlTimingContext) ttmlTimingContext {
ctx := parent
if lang, ok := attrValue(attrs, "lang"); ok {
ctx.lang = normalizeLyricLang(lang)
}
if agentID, ok := attrValue(attrs, "agent"); ok {
ctx.agentID = strings.TrimSpace(agentID)
}
if role, ok := attrValue(attrs, "role"); ok {
role = strings.TrimSpace(role)
if role != "" {
ctx.role = appendTTMLRoles(ctx.role, role)
}
}
beginExpr, hasBegin := attrValue(attrs, "begin")
endExpr, hasEnd := attrValue(attrs, "end")
durExpr, hasDur := attrValue(attrs, "dur")
if hasBegin {
begin, kind, ok := parseTTMLTimeExpression(beginExpr, p.params)
if !ok {
ctx.invalid = true
return ctx
}
base := int64(0)
if parent.hasBegin {
base = parent.begin
}
ctx.begin = resolveTTMLTime(begin, kind, base, parent)
ctx.hasBegin = true
} else {
ctx.begin = parent.begin
ctx.hasBegin = parent.hasBegin
}
var calculatedEnd int64
calculatedHasEnd := false
if hasEnd {
end, kind, ok := parseTTMLTimeExpression(endExpr, p.params)
if !ok {
ctx.invalid = true
return ctx
}
base := ctx.begin
if !ctx.hasBegin {
base = parent.begin
}
calculatedEnd = resolveTTMLTime(end, kind, base, parent)
calculatedHasEnd = true
}
if hasDur {
dur, ok := parseTTMLDurationExpression(durExpr, p.params)
if !ok {
ctx.invalid = true
return ctx
}
if ctx.hasBegin {
durEnd := ctx.begin + dur
if !calculatedHasEnd || durEnd < calculatedEnd {
calculatedEnd = durEnd
calculatedHasEnd = true
}
}
}
if !calculatedHasEnd && parent.hasEnd {
calculatedEnd = parent.end
calculatedHasEnd = true
}
ctx.end = calculatedEnd
ctx.hasEnd = calculatedHasEnd
return ctx
}
func (p *ttmlParser) updateTimingParams(attrs []xml.Attr) {
frameRate := p.params.frameRate
if value, ok := attrValue(attrs, "frameRate"); ok {
if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 {
frameRate = parsed
}
}
if value, ok := attrValue(attrs, "frameRateMultiplier"); ok {
parts := strings.Fields(value)
if len(parts) == 2 {
numerator, errA := strconv.ParseFloat(parts[0], 64)
denominator, errB := strconv.ParseFloat(parts[1], 64)
if errA == nil && errB == nil && denominator > 0 {
frameRate = frameRate * (numerator / denominator)
}
}
}
subFrameRate := p.params.subFrameRate
if value, ok := attrValue(attrs, "subFrameRate"); ok {
if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 {
subFrameRate = parsed
}
}
tickRate := p.params.tickRate
if value, ok := attrValue(attrs, "tickRate"); ok {
if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 {
tickRate = parsed
}
}
p.params.frameRate = gg.If(frameRate > 0, frameRate, defaultTTMLFrameRate)
p.params.subFrameRate = gg.If(subFrameRate > 0, subFrameRate, defaultTTMLSubFrameRate)
p.params.tickRate = gg.If(tickRate > 0, tickRate, defaultTTMLTickRate)
}
func parseTTMLDurationExpression(expr string, params ttmlTimingParams) (int64, bool) {
value, _, ok := parseTTMLTimeExpression(expr, params)
return value, ok
}
func resolveTTMLTime(value int64, kind ttmlTimeKind, base int64, parent ttmlTimingContext) int64 {
switch kind {
case ttmlTimeAbsolute:
return value
case ttmlTimeOffset:
return base + value
case ttmlTimeAmbiguous:
absolute := value
offset := base + value
// No parent timing context → no reference frame for offsets.
// Prefer absolute when offset differs (i.e., base > 0).
if !parent.hasBegin && !parent.hasEnd && base != 0 {
return absolute
}
if parent.hasBegin && parent.hasEnd {
absoluteInParent := absolute >= parent.begin && absolute <= parent.end
offsetInParent := offset >= parent.begin && offset <= parent.end
if absoluteInParent && !offsetInParent {
return absolute
}
if offsetInParent && !absoluteInParent {
return offset
}
}
if parent.hasBegin {
if absolute < parent.begin && offset >= parent.begin {
return offset
}
if absolute >= parent.begin && offset > absolute {
return absolute
}
}
return offset
default:
return base + value
}
}
func parseTTMLTimeExpression(expr string, params ttmlTimingParams) (int64, ttmlTimeKind, bool) {
expr = strings.TrimSpace(expr)
if expr == "" {
return 0, ttmlTimeOffset, false
}
lower := strings.ToLower(expr)
if strings.Contains(lower, "wallclock(") ||
strings.Contains(lower, ".begin") ||
strings.Contains(lower, ".end") {
log.Warn("Unsupported TTML time expression", "value", expr)
return 0, ttmlTimeOffset, false
}
// Best-effort support for non-standard TTML seen in the wild where a
// bare decimal value is used (implicitly seconds), e.g. "0.170".
if value, err := strconv.ParseFloat(lower, 64); err == nil && value >= 0 {
return int64(math.Round(value * 1000)), ttmlTimeAmbiguous, true
}
if matches := offsetTimeRegex.FindStringSubmatch(lower); len(matches) == 3 {
value, err := strconv.ParseFloat(matches[1], 64)
if err != nil {
return 0, ttmlTimeOffset, false
}
unit := matches[2]
seconds := 0.0
switch unit {
case "h":
seconds = value * 60 * 60
case "m":
seconds = value * 60
case "s":
seconds = value
case "ms":
seconds = value / 1000
case "f":
seconds = value / params.frameRate
case "t":
seconds = value / params.tickRate
default:
return 0, ttmlTimeOffset, false
}
return int64(math.Round(seconds * 1000)), ttmlTimeOffset, true
}
colonCount := strings.Count(expr, ":")
switch colonCount {
case 1, 2:
clockMs, ok := parseTTMLClockTime(expr)
if !ok {
return 0, ttmlTimeAbsolute, false
}
return clockMs, ttmlTimeAbsolute, true
case 3:
framesMs, ok := parseTTMLFrameTime(expr, params)
if !ok {
return 0, ttmlTimeAbsolute, false
}
return framesMs, ttmlTimeAbsolute, true
default:
log.Warn("Unsupported TTML time expression", "value", expr)
return 0, ttmlTimeOffset, false
}
}
func parseTTMLClockTime(value string) (int64, bool) {
parts := strings.Split(value, ":")
if len(parts) != 2 && len(parts) != 3 {
return 0, false
}
hours := int64(0)
minutesIdx := 0
if len(parts) == 3 {
h, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return 0, false
}
hours = h
minutesIdx = 1
}
minutes, err := strconv.ParseInt(parts[minutesIdx], 10, 64)
if err != nil {
return 0, false
}
seconds, err := strconv.ParseFloat(parts[minutesIdx+1], 64)
if err != nil {
return 0, false
}
totalSeconds := float64(hours*60*60+minutes*60) + seconds
return int64(math.Round(totalSeconds * 1000)), true
}
func parseTTMLFrameTime(value string, params ttmlTimingParams) (int64, bool) {
parts := strings.Split(value, ":")
if len(parts) != 4 {
return 0, false
}
hours, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return 0, false
}
minutes, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return 0, false
}
seconds, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil {
return 0, false
}
frameParts := strings.SplitN(parts[3], ".", 2)
frames, err := strconv.ParseFloat(frameParts[0], 64)
if err != nil {
return 0, false
}
subFrames := 0.0
if len(frameParts) == 2 {
subFrames, err = strconv.ParseFloat(frameParts[1], 64)
if err != nil {
return 0, false
}
}
totalSeconds := float64(hours*60*60 + minutes*60 + seconds)
totalSeconds += frames / params.frameRate
totalSeconds += subFrames / (params.subFrameRate * params.frameRate)
return int64(math.Round(totalSeconds * 1000)), true
}
func attrValue(attrs []xml.Attr, key string) (string, bool) {
for _, attr := range attrs {
if strings.EqualFold(attr.Name.Local, key) {
return strings.TrimSpace(attr.Value), true
}
}
return "", false
}
func attrOrEmpty(attrs []xml.Attr, key string) string {
value, _ := attrValue(attrs, key)
return value
}
func (p *ttmlParser) collectElementText(start xml.StartElement) (string, error) {
var text strings.Builder
for {
token, err := p.decoder.Token()
if err != nil {
return "", err
}
switch t := token.(type) {
case xml.StartElement:
value, err := p.collectElementText(t)
if err != nil {
return "", err
}
text.WriteString(value)
case xml.EndElement:
if strings.EqualFold(t.Name.Local, start.Name.Local) {
return text.String(), nil
}
case xml.CharData:
text.WriteString(string(t))
}
}
}
func (p *ttmlParser) skipElement(_ xml.StartElement) error {
depth := 1
for depth > 0 {
token, err := p.decoder.Token()
if err != nil {
return err
}
switch token.(type) {
case xml.StartElement:
depth++
case xml.EndElement:
depth--
}
}
return nil
}
func normalizeLyricLang(lang string) string {
lang = strings.ToLower(strings.TrimSpace(lang))
if lang == "" {
return "xxx"
}
return lang
}
func sanitizeTTMLText(raw string) string {
raw = str.SanitizeText(raw)
raw = strings.ReplaceAll(raw, "\r\n", "\n")
raw = strings.ReplaceAll(raw, "\r", "\n")
lines := strings.Split(raw, "\n")
for i := range lines {
lines[i] = strings.TrimSpace(lines[i])
}
return strings.TrimSpace(strings.Join(lines, "\n"))
}
func linesAreSynced(lines []Line) bool {
for i := range lines {
if lines[i].Start != nil {
return true
}
for j := range lines[i].Cue {
if lines[i].Cue[j].Start != nil {
return true
}
}
}
return false
}