Skip to content

feat(lyrics): take Kugou's word-level timing instead of discarding it - #40

Open
Zakkaus wants to merge 14 commits into
locez:mainfrom
Zakkaus:feat/kugou-word-timing
Open

feat(lyrics): take Kugou's word-level timing instead of discarding it#40
Zakkaus wants to merge 14 commits into
locez:mainfrom
Zakkaus:feat/kugou-word-timing

Conversation

@Zakkaus

@Zakkaus Zakkaus commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Builds on #23, #24, #26, #32, #33, #34, #36 and #37. Review only feat(lyrics): take Kugou's word-level timing instead of discarding it.

Problem

The karaoke sweep highlights the current word, but only when the lyrics carry per-word timing. That came from exactly two places:

source word timing
netease yes — yrc
Cider yes — TTML
kugou no
lrclib no
local .lrc no

So the same song looks different depending on which source answered, and nothing tells the user why. Kugou is the interesting case, because it has the timing — kugou.py was asking its download endpoint for fmt=lrc and getting plain LRC. fmt=krc returns KRC, which carries it.

KRC, decoded with the standard library

  1. drop the four-byte krc1 magic
  2. XOR the rest with a fixed 16-byte key
  3. zlib.decompress
  4. decode UTF-8

The result looks like:

[33238,6890]<0,350,0>让<350,920,0>我<1270,580,0>掉<1850,440,0>下<2290,310,0>眼

[start_ms,duration_ms] for the line, then <offset,duration,flags> per word, where the offset is relative to the line start. Those are converted to the absolute timings LyricWord uses. That conversion is the part that would look plausible and be wrong if it were missed, so a test pins it with a line that does not start at zero.

No new dependency: zlib and base64 are in the standard library, and the key is data, named and commented rather than obfuscated.

Falls back, never worse

fmt=krc first, fmt=lrc whenever KRC is absent, fails the magic check, fails to decompress, or yields no timed lines. Kugou already worked as a source; this change cannot make it worse than it was.

Validation

  • QT_QPA_PLATFORM=offscreen uv run pytest — 471 passed, 6 skipped
  • uv run ruff check .
  • uv run ty check

Tests build the KRC fixture by running the encoding backwards — zlib-compress, XOR, prepend the magic — so they exercise the real decode rather than a stub. No test makes a network call.

Checked against the live service, which is the point:

kugou artifact: 39 lines, 39 with word timing
  line at 41.00s: '让我依依不舍的 不止你的温柔'
     word  40.998 ->  41.408  '让'
     word  41.408 ->  42.428  '我'

Before this change the same request returned 39 lines with none.

Follow-up: ask Kugou for the title with the performer as well

Fetching word timing is only worth as much as the search that finds the record, and Kugou's lyric search misses a lot. It answers 200 OK with an empty candidate list for a title it holds under a different keyword, and neither keyword form wins consistently — 海阔天空 returns nothing alone and ten records with Beyond appended, while 晴天 is the other way round.

Measured against the live endpoint over twelve tracks: the title alone found candidates for 3, the title with the performer for 4, the two together for 6. End to end, 夜に駆ける went from no lyrics to 70 word-timed lines.

The keyword list already flows into one ranking pass, so the second query costs one request and needs no other change. Kugou's own singer field stays out of matching, as before — it is the query that improves, not the identity check.

Zakkaus and others added 13 commits August 16, 2026 17:53
Searching a provider by title and artist is guesswork, and for a whole class of
players it is unnecessary: NetEase clients publish the platform's own song id in
their MPRIS metadata. If the player tells you which song it is, matching is not a
problem you need to have — and as locez#14 puts it, lyrics are only reliable when they
come from where the audio came from, because the first line's start, whether
silence was trimmed, and which performance it is all differ between sources.

The rules are waylyrics' (`src/sync/interop/mpris/hint.rs`), which locez#14 names as
the reference, copied rather than guessed at:

  ElectronNCM, Qcm, musicfox, NeteaseCloudMusicGtk4  last segment of mpris:trackid
  feeluown                                           fuo://netease|qqmusic/songs/<id>
  YesPlayMusic                                       url /trackid/<id>
  anything with a file:// url                        the local path

`xesam:url` is now parsed; it was not read at all before. A hint that names a
supported, enabled provider fetches that id directly and the result is used
without title, artist or duration scoring. Anything else — an unknown player, a
disabled provider, a failed fetch — falls back to today's search path unchanged.
A hint is an optimisation, never a dead end.

QQ Music hints are represented but not acted on: this repository has no QQ Music
provider, and inventing one is a separate change. `file://` yields only the path;
reading tags needs a dependency that is not mine to add.

Verified against the live NetEase API: given a deliberately wrong title and
artist but the right id, the resolver returns the correct 58-line lyric.
Among candidates that pass the acceptance rules, kotonoha broke ties with a
chain of booleans ending in album equality. Album was exact equality after
normalisation, so a candidate whose album read 安和桥北 against a track's
安和橋北, or "X (Deluxe Edition)" against "X", counted for nothing — it ranked
level with a candidate carrying no album at all.

Ranking now uses the weighting waylyrics arrived at, which upstream recommended:

    artist and album  title*0.4 + artist*0.2 + album*0.4
    artist only       title*0.7 + artist*0.3
    album only        title*0.8 + album*0.2
    neither           title

Two things about it are deliberate. Album carries the same weight as title,
because it identifies the release a recording belongs to. Duration is absent
from the blend and stays the last tiebreak beneath it, because durations are
frequently inaccurate — the same reason it already sorted below album here.

Similarity is the Dice-Sørensen coefficient over character bigrams, matching the
reference. Python needs no decoding step for this: iterating a str already
yields code points, which is what the Rust builds explicitly. Bytes are never
involved, since byte bigrams would split multi-byte characters and make the
score meaningless for most of this library. Two empty strings score 0.0, not
1.0 — the trap SequenceMatcher already sprang here once.

The blend ranks and nothing else. It does not assign confidence and cannot
promote a candidate across a tier, because an instrumental shares its album, its
duration and nearly its title with the vocal version and would blend to an
excellent score. The variant veto still runs first: 甲乙丙丁 (你我怎麼兩清伴奏)
is still refused, 甲乙丙丁 (你我怎麼兩清) still accepted.
locez#32 already derives a local path from a `file://` url; nothing consumed it. A
sidecar `.lrc` is the strongest source there is — it is the lyrics shipped with
that exact audio, so there is no matching, no scoring, and no question of which
recording it belongs to. It wins outright, before any network request.

Only `<stem>.lrc` beside the audio is considered: no directory scan, no guessing
at other names, and a sidecar whose resolved path leaves the audio file's own
directory is refused, because this runs on whatever a player reports.

Decoding tries UTF-8 then GB18030, since Chinese `.lrc` files in the wild are
very often GB18030 or GBK. A file that decodes as neither, is missing, is empty,
or parses to no timed lines is a miss, not an error: the existing search path
runs and the user sees nothing unusual. A missing sidecar is the normal case.

Parsing reuses `lrc_parser`; there is no second LRC parser.

Reading embedded tags, the other half of locez#14, still needs a dependency and is
not here.
A sidecar written against a different rip is commonly a second or two out,
and the format's own shift tag is how the file says so. It was ignored, so
the correction had to be re-entered by hand as a per-track offset.

Per the format's wording a "+" value causes the lyrics to appear sooner, so
it is subtracted from each timestamp. A value beyond a minute is treated as
junk rather than an instruction, and a shifted line never goes negative.
The other half of locez#14's local case. locez#34 reads a `.lrc` beside the audio; this
reads lyrics stored inside the file itself, which needs a tag parser.

`mutagen` is that parser, and it is **optional**, declared under
`[project.optional-dependencies]` as `embedded-lyrics` rather than in the
required list. The import is lazy and an `ImportError` is an ordinary miss, so
the application runs unchanged where it is absent — the feature simply does not
exist there. That is deliberate: mutagen is GPL-2.0 while this project is MIT,
so whether to ship it is the packager's decision and the user's, not a condition
of running kotonoha. Debian gets `Suggests:`, Fedora `Recommends:`.

Tags read, per format: `USLT` for ID3, `LYRICS` and `UNSYNCEDLYRICS` for Vorbis
comments in FLAC/Ogg/Opus, and the `©lyr` atom for MP4.

Almost all embedded lyrics in the wild are an LRC-formatted string sitting in an
*unsynchronised* tag, so the text goes through the existing `lrc_parser` and is
accepted only when it yields timed lines. Plain prose lyrics are a miss, not an
empty result that would suppress the network path — this overlay cannot show
untimed text.

Precedence: a sidecar `.lrc` wins, because a user who put a file there did so
deliberately; embedded tags come next; the network last.

The suite passes with mutagen absent, which is the case that matters here: 460
passed, 6 skipped, and the skips are exactly the tests that need it.
_embedded_texts probed the MP4 key b"\xa9lyr" on whatever tag object the
file carried. A Vorbis comment accepts only printable ASCII keys and
raises ValueError on that one, and the caller turned the exception into a
miss — so every FLAC and Ogg with embedded lyrics came back empty, after
the LYRICS value had already been found.

Isolate each lookup, and cover it with a real VCFLACDict instead of a
plain dict, which tolerates any key and hid the failure.
Every embedded-tag test is guarded by importorskip, and CI installed only
the test extra, so the whole feature was uncovered there — which is how a
FLAC returning no lyrics reached a green build. Install the extra.

ty needs the accompanying rule: the mutagen import requires a suppression
when the extra is absent and has none to suppress when it is present, so
one of the two states would always fail the gate.
locez#32 recognises `fuo://qqmusic/songs/<id>` from feeluown and produced a hint that
nothing could act on, because there was no QQ Music provider. This adds the half
that is actually possible without a login.

Two anonymous calls, both verified from Australia and from a network inside
China with identical results:

  music.pf_song_detail_svr / get_song_detail   numeric song id -> songmid
  fcg_query_lyric_new.fcg                      songmid -> base64 LRC + translation

The id matters: feeluown's URI carries the numeric song id, not the songmid the
lyric endpoint wants — `QQSongSchema.identifier` is `fields.Int(data_key="id")`
with `mid` as a separate field, so the conversion is not optional. The body is
JSONP wrapped in `MusicJsonCallback(...)`, and `lyric` and `trans` are base64.

Keyword search is deliberately absent. That is the half that genuinely needs a
logged-in user's cookies: without them the search endpoint returns `code: 2001`
and zero results, from both vantages. So `qqmusic` joins the known sources but
NOT the default order — enabled, it can only answer when a player hands it an
id, and querying it for an ordinary track would waste a request every time.

No cookies, no login, no configurable API base url, and no self-hosted service.
Adding `qqmusic` to the valid sources without a `src.qqmusic` string left the
settings list showing the raw key. Spotted in the running application, not by a
test — so the fix comes with the test that would have caught it: every entry in
`VALID_LYRICS_SOURCES` must have a display name in all four languages.
The karaoke sweep highlights the current word, but only when the lyrics carry
per-word timing — and that came from exactly two places: netease's `yrc` and
Cider's TTML. A song resolved from Kugou fell back to line-level highlighting
and the user was never told why.

Kugou has the timing. `kugou.py` asked its download endpoint for `fmt=lrc` and
got plain LRC; `fmt=krc` returns KRC, which carries it.

KRC decodes with the standard library alone: drop the four-byte `krc1` magic,
XOR with a fixed 16-byte key, `zlib.decompress`, decode UTF-8. The result is
`[start_ms,duration_ms]<offset,duration,flags>word…`, where word offsets are
relative to the line — converted to the absolute timings `LyricWord` uses, which
is the part that would look plausible and be wrong if it were missed, so a test
pins it with a line that does not start at zero.

The request falls back to `fmt=lrc` whenever KRC is missing, fails the magic
check, fails to decompress, or yields no timed lines. Kugou already worked; this
must not be able to make it worse.

Verified against the live service: 成都 now returns 39 lines, all 39 with word
timing, where before it returned 39 lines with none.
Kugou's lyric search answers 200/OK with an empty candidate list for a
title it holds under a different keyword, and neither keyword form wins
consistently. Measured over twelve tracks against the live endpoint: the
title alone found candidates for 3, the title with the performer for 4,
and the two together for 6.

One extra request per track buys that recall; the ranking already merges
candidates across keywords.
A word-timed Kugou hit is cached as base64 KRC, but parse_payload read only
the LRC key. The cache deletes any row its parser cannot read, so every
lookup of a word-timed track failed to parse, dropped the row, and refetched
— the cache never served the path this feature added.

Verified against a live fetch: 63 lines with word timing survive the round
trip through the stored payload.
@Zakkaus
Zakkaus force-pushed the feat/kugou-word-timing branch from 6315e35 to 880aecd Compare August 16, 2026 07:54
…r TLS

Fetching and parsing shared one try block, so a network error left krc unbound
while lines was set to (). The branch below then read krc: on the first
candidate that raised UnboundLocalError, which no caller catches, and on any
later one it silently reused the previous candidate's bytes. Measured by making
download_krc raise aiohttp.ClientError, the whole lookup crashed rather than
moving to the next candidate.

The lyric endpoint serves the same request over https, so the song id and the
lyrics it returns no longer travel in the clear.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant