navidrome/core/lyrics/sources_ttml_test.go
ranokay c77e0de976
feat: add TTML lyrics support with token-level karaoke and translation/pronunciation layers
Add a full TTML (Timed Text Markup Language) sidecar lyrics parser that extracts
word/syllable-level timing from <span> elements, plus translation and pronunciation
(transliteration) tracks from Apple Music TTML metadata sections.

Backend changes:
- TTML parser (core/lyrics/ttml.go) with support for all TTML time formats,
  nested timing contexts, and bare decimal second offsets
- Translation/pronunciation tracks resolved via key-based metadata linking
- Line timing hydration from token-level start/end values
- 'kind' field added to Lyrics model and StructuredLyric API response
  (main/translation/pronunciation)
- 'tokenLine' array in API response for word-level timing data
- UTF-8 BOM and UTF-16 LE encoding support for TTML files
- Fix for ambiguous time resolution in pronunciation spans (pre-1-minute)

Frontend changes:
- KaraokeLyricsOverlay rewritten with scrollable multi-line layout,
  word-level wipe highlighting with eased alpha transitions,
  rAF-driven playback clock with drift correction
- Inline translation (above) and pronunciation (below) each main line,
  with smart filtering to hide redundant lines (same normalized text)
- TR/PR toggle buttons and layer selection via selectLyricLayers()
- Click-to-seek: click any lyric line to jump to that position
- Customization popover with font-size sliders and color presets
  for each line type (TR/Default/PR), persisted to localStorage
- Smooth font-size transition between active and inactive lines
- Resizable overlay height via drag handle
- lyrics.js: resolveKaraokeTokenWindow, buildSyntheticWordTokens,
  findLayerLineIndexForMain, token sorting, collapsed timing detection

API extension (non-breaking, additive):
- tokenLine[].token[] provides per-word start/end timing (ms)
- tokenLine[].index maps back to the corresponding line[] entry
- kind field: 'main', 'translation', 'pronunciation'
- Clients ignoring tokenLine/kind continue to work unchanged
2026-03-27 07:27:01 +02:00

93 lines
2.6 KiB
Go

package lyrics
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/navidrome/navidrome/model"
)
func TestFromExternalFileTTML(t *testing.T) {
ctx := context.Background()
mf := model.MediaFile{Path: fixturePath("test.mp3")}
lyrics, err := fromExternalFile(ctx, &mf, ".ttml")
if err != nil {
t.Fatalf("fromExternalFile returned error: %v", err)
}
if len(lyrics) != 2 {
t.Fatalf("expected 2 lyric tracks, got %d", len(lyrics))
}
if lyrics[0].Lang != "eng" {
t.Fatalf("expected first language 'eng', got %q", lyrics[0].Lang)
}
if len(lyrics[0].Line) != 2 {
t.Fatalf("expected 2 english lines, got %d", len(lyrics[0].Line))
}
if lyrics[0].Line[0].Start == nil || *lyrics[0].Line[0].Start != 18800 {
t.Fatalf("expected first english line start to be 18800, got %v", lyrics[0].Line[0].Start)
}
}
func TestFromExternalFileTTMLWithUTF8BOM(t *testing.T) {
ctx := context.Background()
mf := model.MediaFile{Path: fixturePath("bom-test.ttml")}
lyrics, err := fromExternalFile(ctx, &mf, ".ttml")
if err != nil {
t.Fatalf("fromExternalFile returned error: %v", err)
}
if len(lyrics) != 1 {
t.Fatalf("expected 1 lyric track, got %d", len(lyrics))
}
if !lyrics[0].Synced {
t.Fatal("expected BOM TTML lyrics to be synced")
}
if len(lyrics[0].Line) != 1 {
t.Fatalf("expected 1 lyric line, got %d", len(lyrics[0].Line))
}
if lyrics[0].Line[0].Start == nil || *lyrics[0].Line[0].Start != 0 {
t.Fatalf("expected first line start 0, got %v", lyrics[0].Line[0].Start)
}
}
func TestFromExternalFileTTMLUTF16(t *testing.T) {
ctx := context.Background()
mf := model.MediaFile{Path: fixturePath("bom-utf16-test.ttml")}
lyrics, err := fromExternalFile(ctx, &mf, ".ttml")
if err != nil {
t.Fatalf("fromExternalFile returned error: %v", err)
}
if len(lyrics) != 1 {
t.Fatalf("expected 1 lyric track, got %d", len(lyrics))
}
if !lyrics[0].Synced {
t.Fatal("expected UTF16 TTML lyrics to be synced")
}
if len(lyrics[0].Line) != 2 {
t.Fatalf("expected 2 lyric lines, got %d", len(lyrics[0].Line))
}
if lyrics[0].Line[0].Start == nil || *lyrics[0].Line[0].Start != 18800 {
t.Fatalf("expected first line start 18800, got %v", lyrics[0].Line[0].Start)
}
if lyrics[0].Line[1].Start == nil || *lyrics[0].Line[1].Start != 22801 {
t.Fatalf("expected second line start 22801, got %v", lyrics[0].Line[1].Start)
}
}
func fixturePath(name string) string {
candidates := []string{
filepath.Join("tests", "fixtures", name),
filepath.Join("..", "..", "tests", "fixtures", name),
}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return filepath.Join("tests", "fixtures", name)
}