From a38d6ca3e26769469b2d865b392daea16fc10ae8 Mon Sep 17 00:00:00 2001 From: DiheChen Date: Thu, 5 Nov 2026 01:03:38 +0800 Subject: [PATCH] feat: send word-by-word and translated lyrics to clients --- internal/adapter/in/api/convert.go | 9 ++++++++ internal/adapter/in/api/convert_test.go | 10 +++++++++ internal/app/port/porttest/source.go | 24 ++++++++++++++++------ internal/app/prepare/run_test.go | 14 +++++++++++++ internal/bootstrap/contract_test.go | 1 + internal/bootstrap/testdata/ws/queue.txtar | 2 +- protocol/idl.json | 4 +++- protocol/openapi.gen.json | 10 ++++++++- protocol/types.gen.go | 4 +++- web/src/core/realtime/protocol.gen.ts | 4 +++- 10 files changed, 71 insertions(+), 11 deletions(-) diff --git a/internal/adapter/in/api/convert.go b/internal/adapter/in/api/convert.go index 22e7154..2d7d2e7 100644 --- a/internal/adapter/in/api/convert.go +++ b/internal/adapter/in/api/convert.go @@ -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)) diff --git a/internal/adapter/in/api/convert_test.go b/internal/adapter/in/api/convert_test.go index 374712e..195e650 100644 --- a/internal/adapter/in/api/convert_test.go +++ b/internal/adapter/in/api/convert_test.go @@ -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) diff --git a/internal/app/port/porttest/source.go b/internal/app/port/porttest/source.go index 3cd2467..f691179 100644 --- a/internal/app/port/porttest/source.go +++ b/internal/app/port/porttest/source.go @@ -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) { diff --git a/internal/app/prepare/run_test.go b/internal/app/prepare/run_test.go index 2ed6a75..f12908e 100644 --- a/internal/app/prepare/run_test.go +++ b/internal/app/prepare/run_test.go @@ -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("不是音频")) diff --git a/internal/bootstrap/contract_test.go b/internal/bootstrap/contract_test.go index 436e614..f379036 100644 --- a/internal/bootstrap/contract_test.go +++ b/internal/bootstrap/contract_test.go @@ -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) diff --git a/internal/bootstrap/testdata/ws/queue.txtar b/internal/bootstrap/testdata/ws/queue.txtar index 295aa5d..98962eb 100644 --- a/internal/bootstrap/testdata/ws/queue.txtar +++ b/internal/bootstrap/testdata/ws/queue.txtar @@ -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}} diff --git a/protocol/idl.json b/protocol/idl.json index bf169f5..63024c9 100644 --- a/protocol/idl.json +++ b/protocol/idl.json @@ -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"] }, diff --git a/protocol/openapi.gen.json b/protocol/openapi.gen.json index ff8d3ca..a8012a9 100644 --- a/protocol/openapi.gen.json +++ b/protocol/openapi.gen.json @@ -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": { diff --git a/protocol/types.gen.go b/protocol/types.gen.go index 401cc5a..3db69a0 100644 --- a/protocol/types.gen.go +++ b/protocol/types.gen.go @@ -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"` } diff --git a/web/src/core/realtime/protocol.gen.ts b/web/src/core/realtime/protocol.gen.ts index 0ba44c9..ac9c90e 100644 --- a/web/src/core/realtime/protocol.gen.ts +++ b/web/src/core/realtime/protocol.gen.ts @@ -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 }