mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
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
93 lines
2.6 KiB
Go
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)
|
|
}
|