package model import ( "bytes" "fmt" "strings" "github.com/navidrome/navidrome/utils/str" "gopkg.in/yaml.v3" ) // parseLyricsfile parses a LRCLIB Lyricsfile YAML document // (see https://github.com/tranxuanthang/lrcget/blob/main/LYRICSFILE_CONCEPT.md) // into a model.LyricList containing a single main Lyrics entry. Returns // (nil, nil) when the input parses as YAML but does not declare Lyricsfile // version 1.0. // // When the source contains per-word timing via lines[].words[], each word // becomes a model.Cue with inclusive UTF-8 byte offsets into Line.Value, and // overlapping lines are attributed to synthetic voice agents via lowest-free // voice ID assignment so the OpenSubsonic v2 enhanced response can split // parallel vocals. func parseLyricsfile(lang string, contents []byte) (LyricList, error) { var doc lyricsfileDocument dec := yaml.NewDecoder(bytes.NewReader(contents)) dec.KnownFields(false) if err := dec.Decode(&doc); err != nil { return nil, fmt.Errorf("not a valid Lyricsfile YAML: %w", err) } if strings.TrimSpace(doc.Version) != lyricsfileVersion { return nil, nil } // Fall back to the caller's language when the document omits its own, matching // the SRT/TTML parsers; normalizeLyricLang yields "xxx" only if both are empty. docLang := doc.Metadata.Language if strings.TrimSpace(docLang) == "" { docLang = lang } lyrics := Lyrics{ DisplayArtist: str.SanitizeText(doc.Metadata.Artist), DisplayTitle: str.SanitizeText(doc.Metadata.Title), Lang: normalizeLyricLang(docLang), Kind: LyricKindMain, } if doc.Metadata.OffsetMs != 0 { off := doc.Metadata.OffsetMs lyrics.Offset = &off } if doc.Metadata.Instrumental { return LyricList{normalizeLyrics(lyrics)}, nil } if len(doc.Lines) == 0 { lines := buildPlainLyricsfileLines(doc.Plain) if len(lines) == 0 { return nil, nil } lyrics.Line = lines return LyricList{normalizeLyrics(lyrics)}, nil } lines, agents := buildLyricsfileLines(doc.Lines) lyrics.Line = lines lyrics.Agents = agents lyrics.Synced = true return LyricList{normalizeLyrics(lyrics)}, nil } const lyricsfileVersion = "1.0" type lyricsfileDocument struct { Version string `yaml:"version"` Metadata lyricsfileMetadata `yaml:"metadata"` Lines []lyricsfileLineEntry `yaml:"lines"` Plain string `yaml:"plain"` } type lyricsfileMetadata struct { Title string `yaml:"title"` Artist string `yaml:"artist"` Album string `yaml:"album"` DurationMs int64 `yaml:"duration_ms"` OffsetMs int64 `yaml:"offset_ms"` Language string `yaml:"language"` Instrumental bool `yaml:"instrumental"` } type lyricsfileLineEntry struct { Text string `yaml:"text"` StartMs int64 `yaml:"start_ms"` EndMs *int64 `yaml:"end_ms"` Words []lyricsfileWordEntry `yaml:"words"` } type lyricsfileWordEntry struct { Text string `yaml:"text"` StartMs int64 `yaml:"start_ms"` EndMs *int64 `yaml:"end_ms"` } // buildLyricsfileLines converts YAML line entries to model.Line entries with // per-cue AgentIDs assigned by streaming overlap clustering (lowest-free // voice ID). The Agents slice is emitted only when at least one cue carries // attribution AND more than one voice is used; otherwise AgentIDs are // stripped so the wire shape stays simple per the OpenSubsonic spec rule // "agents should not be emitted without cueLine data". func buildLyricsfileLines(entries []lyricsfileLineEntry) ([]Line, []Agent) { if len(entries) == 0 { return nil, nil } // Resolved end timestamps per entry: explicit end_ms, final word end_ms, // then the next entry's start. The last entry's end stays nil when no // explicit or word-level end is available. ends := make([]*int64, len(entries)) for i := range entries { var nextStart *int64 if i+1 < len(entries) { v := entries[i+1].StartMs nextStart = &v } ends[i] = lyricsfileLineEnd(entries[i], nextStart) } active := map[int]int64{} maxVoice := -1 anyCues := false lines := make([]Line, 0, len(entries)) for i, entry := range entries { for vID, vEnd := range active { if vEnd <= entry.StartMs { delete(active, vID) } } voiceID := 0 for { if _, busy := active[voiceID]; !busy { break } voiceID++ } if voiceID > maxVoice { maxVoice = voiceID } agentID := fmt.Sprintf("voice-%d", voiceID) cues, value := wordsToLineCues(entry, agentID) if len(cues) > 0 { anyCues = true } startMs := entry.StartMs line := Line{ Start: &startMs, End: ends[i], Value: value, Cue: cues, } lines = append(lines, line) var endMs int64 if ends[i] != nil { endMs = *ends[i] } else { endMs = entry.StartMs } active[voiceID] = endMs } // Monophonic source, or attribution that has nowhere to land: emit no // agents and strip per-cue AgentIDs to keep the wire shape simple. if maxVoice <= 0 || !anyCues { for i := range lines { for j := range lines[i].Cue { lines[i].Cue[j].AgentID = "" } } return lines, nil } agents := make([]Agent, 0, maxVoice+1) for v := 0; v <= maxVoice; v++ { role := "voice" if v == 0 { role = "main" } agents = append(agents, Agent{ ID: fmt.Sprintf("voice-%d", v), Role: role, }) } return lines, agents } func lyricsfileLineEnd(entry lyricsfileLineEntry, nextStart *int64) *int64 { if entry.EndMs != nil { v := *entry.EndMs return &v } if len(entry.Words) > 0 { lastWord := entry.Words[len(entry.Words)-1] if lastWord.EndMs != nil { v := *lastWord.EndMs return &v } } if nextStart != nil { v := *nextStart return &v } return nil } func buildPlainLyricsfileLines(plain string) []Line { plain = str.SanitizeText(plain) rawLines := strings.Split(plain, "\n") lines := make([]Line, 0, len(rawLines)) for _, raw := range rawLines { value := strings.TrimSpace(raw) if value == "" { continue } lines = append(lines, Line{Value: value}) } return lines } // wordsToLineCues converts a Lyricsfile line entry's words[] into model.Cue // entries with inclusive UTF-8 byte offsets into the reconstructed line // value. The line value is built from cue text concatenation rather than // trusting entry.Text, because the Lyricsfile spec only requires word.text // to "approximate" line.text - byte offsets must always land inside // Line.Value. func wordsToLineCues(entry lyricsfileLineEntry, agentID string) ([]Cue, string) { if len(entry.Words) == 0 { return nil, str.SanitizeText(entry.Text) } var sb strings.Builder for _, w := range entry.Words { sb.WriteString(w.Text) } lineValue := sb.String() cues := make([]Cue, len(entry.Words)) cursor := 0 for i, w := range entry.Words { valueBytes := len(w.Text) bs := cursor be := bs if valueBytes > 0 { be = bs + valueBytes - 1 cursor = be + 1 } s := w.StartMs cue := Cue{ Start: &s, Value: w.Text, ByteStart: bs, ByteEnd: be, AgentID: agentID, } if w.EndMs != nil { e := *w.EndMs cue.End = &e } cues[i] = cue } for i := 0; i < len(cues)-1; i++ { if cues[i].End == nil && cues[i+1].Start != nil { v := *cues[i+1].Start cues[i].End = &v } } return cues, lineValue }