feat: send word-by-word and translated lyrics to clients

This commit is contained in:
DiheChen committed 2026-11-05 01:03:38 +08:00
1 parent e2364b171e
commit a38d6ca3e2
10 files changed
+71 -11

No files matched your search

+9
View File
@@ -126,9 +126,18 @@ func (s *server) nowPlaying(p *room.NowPlaying) *protocol.NowPlaying {
DurationMs: p.Entry.Prep.Duration.Milliseconds(),
Gain: p.Entry.Prep.Gain,
Lyric: p.Entry.Prep.Lyric,
LyricWords: optional(p.Entry.Prep.LyricWords),
LyricTrans: optional(p.Entry.Prep.LyricTrans),
}
}
func optional(s string) *string {
if s == "" {
return nil
}
return &s
}
func (s *server) mediaURL(t track.Track) string {
if media.Stored(t.Source) {
return s.StorageURL + "/" + media.AudioObject(media.Key(t.ID))
+10
View File
@@ -71,6 +71,16 @@ func TestSnapshotDTO(t *testing.T) {
}
}
func TestNowPlayingCarriesWordAndTranslatedLyrics(t *testing.T) {
s := &server{}
entry := sunnyEntry
entry.Prep.LyricWords, entry.Prep.LyricTrans = "[1000,500](1000,500,0)故", "[00:01.00]The story"
got := s.nowPlaying(&room.NowPlaying{Entry: entry})
if got.LyricWords == nil || *got.LyricWords != entry.Prep.LyricWords || got.LyricTrans == nil || *got.LyricTrans != entry.Prep.LyricTrans {
t.Errorf("lyricWords = %v, lyricTrans = %v", got.LyricWords, got.LyricTrans)
}
}
func TestEventDTO(t *testing.T) {
s := &server{}
sentAt := time.UnixMilli(1760000000456)
+18 -6
View File
@@ -27,11 +27,13 @@ type Source struct {
}
type sourceSong struct {
id string
meta track.Meta
lyric string
audio []byte
reject string
id string
meta track.Meta
lyric string
lyricWords string
lyricTrans string
audio []byte
reject string
}
type sourcePlaylist struct {
@@ -56,6 +58,16 @@ func (s *Source) Add(id string, m track.Meta, lyric string, audio []byte) {
s.songs = append(s.songs, sourceSong{id: id, meta: m, lyric: lyric, audio: audio})
}
func (s *Source) SetLyrics(id, words, trans string) {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.songs {
if s.songs[i].id == id {
s.songs[i].lyricWords, s.songs[i].lyricTrans = words, trans
}
}
}
func (s *Source) Reject(id, reason string) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -164,7 +176,7 @@ func (s *Source) Resolve(_ context.Context, id string) (port.Resolved, error) {
case song.reject != "":
return port.Resolved{}, &port.RejectedError{Err: errors.New(song.reject)}
}
return port.Resolved{Meta: song.meta, Lyric: song.lyric, Audio: io.NopCloser(bytes.NewReader(song.audio))}, nil
return port.Resolved{Meta: song.meta, Lyric: song.lyric, LyricWords: song.lyricWords, LyricTrans: song.lyricTrans, Audio: io.NopCloser(bytes.NewReader(song.audio))}, nil
}
func (s *Source) PlayURL(_ context.Context, id string) (string, error) {
+14
View File
@@ -69,6 +69,20 @@ func checkDecodeWarning(t *testing.T, logs *bytes.Buffer, tr track.Track, decode
}
}
func TestRunSavesWordAndTranslatedLyrics(t *testing.T) {
source := porttest.NewSource(track.NetEase)
source.Add("186016", meta, "[00:01.00]故事", []byte("不是音频"))
source.SetLyrics("186016", "[1000,500](1000,500,0)故", "[00:01.00]The story")
tracks, err := run(t, source, sunny)
if err != nil {
t.Fatal(err)
}
want := track.Prepared{Duration: meta.Duration, Gain: 1, Lyric: "[00:01.00]故事", LyricWords: "[1000,500](1000,500,0)故", LyricTrans: "[00:01.00]The story"}
if got, _ := tracks.row(sunny); got.Result != want {
t.Errorf("准备结果 = %+v,期望 %+v", got.Result, want)
}
}
func TestRunFallsBackWhenAudioCannotBeAnalyzed(t *testing.T) {
source := porttest.NewSource(track.NetEase)
source.Add("186016", meta, "", []byte("不是音频"))
+1
View File
@@ -156,6 +156,7 @@ func contractSources() sources {
"[00:01.00]故事的小黄花", porttest.SilentWAV(269500*time.Millisecond))
netease.Add("185811", track.Meta{Title: "七里香", Artist: "周杰伦", Album: "七里香", CoverURL: "https://p1.music.126.net/qilixiang.jpg", PageURL: "https://music.163.com/#/song?id=185811", Duration: 299 * time.Second},
"", porttest.SilentWAV(299*time.Second))
netease.SetLyrics("185811", "[1000,500](1000,500,0)窗", "[00:01.00]Window")
netease.Add("1100", track.Meta{Title: "片头", Artist: "无名", PageURL: "https://music.163.com/#/song?id=1100", Duration: 15 * time.Second},
"", porttest.SilentWAV(15*time.Second))
netease.Add("404", track.Meta{Title: "不能播放的歌", Artist: "无名", PageURL: "https://music.163.com/#/song?id=404", Duration: time.Minute}, "", nil)
+1 -1
View File
@@ -126,7 +126,7 @@ Authorization: Bearer $alice
{"kind": "event", "type": "playback.updated", "term": 1, "seq": 13, "data": {"nowPlaying": {
"entry": {"id": "2", "track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000},
"by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 1, "status": "ready"},
"mediaUrl": "/media/netease/185811", "startedAtMs": 1, "durationMs": 299000, "gain": 1, "lyric": ""}}}
"mediaUrl": "/media/netease/185811", "startedAtMs": 1, "durationMs": 299000, "gain": 1, "lyric": "", "lyricWords": "[1000,500](1000,500,0)窗", "lyricTrans": "[00:01.00]Window"}}}
-- receive alice --
{"kind": "response", "type": "playback.voteSkip", "id": "v1", "data": {"votes": 1, "required": 1, "skipped": true}}
+3 -1
View File
@@ -171,7 +171,9 @@
"startedAtMs": {"type": "integer"},
"durationMs": {"type": "integer", "description": "准备结果中的时长"},
"gain": {"type": "number", "description": "线性增益系数"},
"lyric": {"type": "string"}
"lyric": {"type": "string"},
"lyricWords": {"type": "string", "description": "逐字歌词,每行形如 [行起始ms,行时长ms](字起始ms,字时长ms,0)字,时间为绝对毫秒"},
"lyricTrans": {"type": "string", "description": "翻译歌词,行级 LRC,时间戳与原文的行起点对应"}
},
"required": ["entry", "mediaUrl", "startedAtMs", "durationMs", "gain", "lyric"]
},
+9 -1
View File
@@ -631,6 +631,14 @@
"lyric": {
"type": "string"
},
"lyricTrans": {
"description": "翻译歌词,行级 LRC,时间戳与原文的行起点对应",
"type": "string"
},
"lyricWords": {
"description": "逐字歌词,每行形如 [行起始ms,行时长ms](字起始ms,字时长ms,0)字,时间为绝对毫秒",
"type": "string"
},
"mediaUrl": {
"type": "string"
},
@@ -1335,7 +1343,7 @@
},
"info": {
"title": "Alisten",
"version": "09dbf7ea495db969"
"version": "473ee31f5ef912b4"
},
"openapi": "3.1.0",
"paths": {
+3 -1
View File
@@ -4,7 +4,7 @@ package protocol
import "encoding/json"
const Version = "09dbf7ea495db969"
const Version = "473ee31f5ef912b4"
type APIKey struct {
Key string `json:"key"`
@@ -255,6 +255,8 @@ type NowPlaying struct {
Entry Entry `json:"entry"`
Gain float64 `json:"gain"`
Lyric string `json:"lyric"`
LyricTrans *string `json:"lyricTrans,omitempty"`
LyricWords *string `json:"lyricWords,omitempty"`
MediaURL string `json:"mediaUrl"`
StartedAtMs int64 `json:"startedAtMs"`
}
+3 -1
View File
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
export const PROTOCOL_VERSION = '09dbf7ea495db969'
export const PROTOCOL_VERSION = '473ee31f5ef912b4'
export interface APIKey {
key: string
@@ -67,6 +67,8 @@ export interface NowPlaying {
entry: Entry
gain: number
lyric: string
lyricTrans?: string
lyricWords?: string
mediaUrl: string
startedAtMs: number
}