Skip to content

Song details, lyrics and artist info for every track, in every language - #713

Merged
thcp merged 20 commits into
mainfrom
feat/song-identification
Sep 29, 2026
Merged

thcp merged 20 commits into
mainfrom
feat/song-identification

Conversation

@thcp

@thcp thcp commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

The track that is playing now knows what it is. Each import is identified while it separates, the now-playing card names the song, the show it comes from and the artist, and the Lyrics tab follows the singer karaoke style. It works the same for a Mandarin ballad or a K-pop single as for an English band.

Closes #699
Closes #702
Closes #703
Closes #704
Closes #705
Closes #706
Closes #707
Closes #708
Closes #709
Closes #710
Closes #711
Closes #712
Closes #714
Closes #715
Closes #716
Closes #717
Closes #718

What changes for someone using it

Measured

A benchmark of 45 real YouTube uploads across the ten languages, artist found / lyrics found:

before after
English (4) 4 / 4 4 / 4
Chinese (6) 0 / 1 6 / 6
Japanese (6) 2 / 2 5 / 5
Korean (5) 0 / 0 5 / 4
German (4) 2 / 2 4 / 4
Portuguese (5) 3 / 3 4 / 3
Polish (4) 2 / 2 4 / 4
French, Spanish, Indonesian (4 each) 4 / 4 4 / 4

The two Korean and Portuguese lyrics still missing in that run were LRCLIB answering 503; LRCLIB is now asked again when it is busy, and both are found. The remaining misses are a fan upload shorter than any recording and a TV performance.

Checked with real imports: Wicked "Dancing Through Life", The Greatest Showman "This Is Me", The Phantom of the Opera, Natalia Kukulska "W biegu", and a 59 minute Teresa Teng compilation, whose artist is now found with no click.

Line timing, checked on 57 songs across all ten languages (1,466 lines): where the timing from the vocals and LRCLIB's disagreed by more than half a second, that stretch of the vocals was transcribed again on its own to see which was right. Share of lines starting within half a second of the singing:

de en es fr id ja ko pl pt zh
93% 96% 93% 91% 92% 100% 95% 97% 90% 97%

What is left is mostly repeated filler ("na-na-na") landing on a later repeat, which Sync lines fixes by hand.

Size

About 50k added lines, but most of it is not code:

Lines What
24.3k Tests (unit, e2e, and the 2.4k line acceptance suite)
12.8k Backend, about a third of it comments, docstrings and blank lines
6.0k Frontend
3.3k The OpenCC character table (data)
1.9k UI strings in ten languages

It is one pull request because it was built and tested as one build, and the acceptance run passed on exactly this combination. One real duplication is known: lyrics are matched to a song both on the server and in the browser. #719 tracks moving that to the server alone.

Worth knowing before merging

  • Desktop update. uv.lock changes (openai-whisper, tiktoken, regex, more-itertools), so existing desktop installs get the full download once rather than an in-app update.
  • Privacy. Names (artist, song, album, show) go to MusicBrainz, Wikidata, Wikipedia and LRCLIB, and a fingerprint to AcoustID only when a key is set. Both boxes say so. Lyrics stay on the user's machine.
  • Licence. The traditional to simplified Chinese table is derived from OpenCC (Apache-2.0). Its licence ships in app/_vendor/opencc and is listed in the three notice files. No new dependency comes with it.
  • Platforms. Checked on Windows (source and the NVIDIA desktop bundle), Linux (WSL, including the Linux Rust target) and the Docker image. The macOS check runs on this pull request.

Tests

  • pytest: 2323 passed locally; the 8 others fail on one machine only (click render and transpose export) and pass in CI.
  • Playwright: 406 passed.
  • Acceptance suite on the NVIDIA desktop build: every check passes, including the drags with a real Windows cursor, apart from the two that need an AcoustID key.
  • JS unit suites, i18n coverage (clean in all ten languages), ruff, bandit, cargo fmt, clippy and test all pass.
  • New suites cover failing and hanging services, cancel and delete during a lookup, data written before this change, the benchmark's title patterns, cross-script matching, and rejecting another artist's song of the same name.

Thales added 6 commits September 27, 2026 10:51
The top bar reads as one extraction row with the now-playing card after
it, and the card now names the track from its own tags.

- Composer: Split sits after the options it acts on; the now-playing card
  moves to the end of the row behind a rule, labelled "Now playing", with
  full-height artwork, the favourite on the left and an info button on the
  right. Library rows drop the repeated stem count.
- Audio tags: an upload's artist, title, album and lyrics are read with
  ffprobe at import, a link's from yt-dlp (with the channel accepted when
  the title corroborates it). Older jobs are filled in once through
  POST /api/jobs/{id}/audio-tags.
- Band: looked up on Wikidata during extraction, alongside separation, and
  kept only on a strict name match. The info button opens its history,
  members and studio albums; a band the user saves always wins.
- Lyrics tab: found by itself on LRCLIB from the tags or the saved band,
  kept per track, embedded lyrics used directly. Synced lyrics fill word
  by word, timed against the vocals stem's loudness
  (GET /api/jobs/{id}/vocal-envelope), and a line click seeks there.
- Library Tags section shows only the open track's tags.
- Fixes a stale thumbnail when switching to a track without one.
Identification runs alongside separation and never fails a job:
AcoustID fingerprint (optional user key in Settings) or title parsing,
then MusicBrainz for the recording, release year and performers,
Wikidata for the performer and the musical or film a song comes from,
and LRCLIB for synced lyrics, shifted to the vocal stem when the
timing is off. Songs with no lyrics anywhere can be transcribed with
Whisper after separation (auto on CUDA, off by default on CPU).

Now playing shows the song, the show it comes from and the performer.
The lyrics tab plays karaoke style from the server's lyrics.
- Lyrics are kept only when LRCLIB's song and artist match the track
  (the artist, one credited artist, or the show); otherwise the tab
  says none were found instead of showing another song's words.
- Accent-stripped LRCLIB copies (common for Polish) get their letters
  back from an intact copy, or from a Whisper transcript when none
  exists, keeping LRCLIB's timing.
- Tags written in cp1250/cp1251 or UTF-8 read as Latin-1 are decoded.
- The artist box shows official website, Instagram, Spotify and Apple
  Music links from Wikidata when they exist.
- A band lookup that fails no longer hides the musical or film.
- Settings shows only the last two characters of the AcoustID key.
- Regression tests for failing services, cancel and delete during
  lookups, and data written before identification existed.
- The error banner's rules were on the "error" class, which every small
  status line also takes, so a lyrics or artist-box error floated over
  the top bar as a faint strip nobody could read or close. The banner
  is now styled by its id: a solid card with an icon, a close button
  and Escape to dismiss; status lines stay in their panels.
- The timeline ruler spaced labels a minute apart at most, piling 59
  labels onto an hour-long video. Long tracks now step up to 5, 10, 15
  minutes and beyond, at most about 12 labels across; songs are
  unchanged.
- Mending lyrics that lost their accents now runs on CPU and Mac under
  "auto" too; only "off" stops it. Full transcription stays CUDA-only.
Chinese, Japanese and Korean artists got almost nothing: on a benchmark
of real YouTube uploads, 0 of 6 Chinese and 0 of 5 Korean songs found
an artist, where English found 4 of 4. Now 6 of 6 and 5 of 5.

- Titles: CJK brackets and quotes, _ and full-width separators, MV and
  other noise in each language, names written in two scripts
  ("周杰倫 Jay Chou", "IU(아이유)") kept as alternatives, compilations.
- MusicBrainz: every alternative name is searched, an artist is found by
  name or alias in any script, covers and tributes are never taken, and
  a busy answer (503/429) is asked again. LRCLIB likewise.
- A band from the title alone only when two names in different scripts
  point to one artist, or a compilation names one artist throughout.
- Lyrics match across scripts, traditional and simplified Chinese and
  full-width text, through the artist's MusicBrainz and Wikidata
  aliases; songs filed under a translated title are found through the
  recording's other titles. Another artist's song is still never kept.
  The traditional-to-simplified table is derived from OpenCC
  (Apache-2.0), with its licence in app/_vendor/opencc.
- Karaoke fills Chinese and Japanese a character at a time and Korean
  by syllables; each line carries its language, so Korean wraps between
  words and Han characters take the line's own glyph forms.
- The artist box reads the artist's own Wikipedia when the reader's and
  English have only a stub, shows the native name beside the translated
  one, searches a name in its own script, and gives a zh-Hans reader
  simplified text.
A key AcoustID refuses was saved all the same, and every import then
fell back to the title search without a word. AcoustID gives each
account two keys of the same shape, and the user key on the profile
page, the one people paste, cannot look fingerprints up.

The key is now tried once on save with a fingerprint that is not one:
refused for the key, it is not saved and Settings says to use an
application key from acoustid.org/new-application; refused for the
fingerprint, it is good. Offline it is kept on trust. The key goes only
to AcoustID, in a POST body, and is never logged or echoed.
STEMDECK_ACOUSTID_CHECK_KEY=0 turns the check off, as the browser
tests' server does.
Comment thread app/core/models.py Fixed
Comment thread app/pipeline/identify.py Fixed
Thales added 3 commits September 28, 2026 07:57
Code-quality review on #713: the NaN test on an identity's score is now
math.isfinite, which also turns an infinite score to 0, and the empty
except around killing a finished fingerprint says why it is empty.
…y check

- Lyrics repair matched a stripped word against a regex of optional
  groups, which backtracks exponentially: two LRCLIB uploads with a
  word of forty accented letters froze the server for hours. It is now
  a linear walk over the positions reached.
- MusicBrainz retries on 503/429 ignored a cancelled job on most paths,
  including the wait for its turn, and could run about a minute per
  request. Every lookup hands its cancel check down, and one request
  never runs past MUSICBRAINZ_REQUEST_BUDGET_SEC in all.
- The AcoustID key check on Save waited on a server that sends one byte
  at a time; it now waits at most twice the request timeout and keeps
  the key on trust past that. A refusal with no reason code is not a
  verdict on the key.
- Escape closed the error box and the popup beneath it together; the
  box now takes that Escape alone.
- A dead timeout branch in the artist box is gone.

Tests for what the range added without one: MusicBrainz retries and
cancel, work titles, title aliases, alias lookups and their caches,
mending gates, the key check when AcoustID is down or slow, the ruler
at 30 s to 13 h (its tick logic moved to rulerTicks.js), native Wikipedia
editions, the error box at phone width, and a gate holding the browser's
name matching and Chinese table identical to the server's.
… found

tests/acceptance drives the portable StemDeck.exe over WebView2's
DevTools port against the real services, one test per manual checklist
id, on request only: `npm run test:acceptance`. It never runs in CI
(the default config's testDir is tests/e2e, and it refuses to start
when CI is set), isolates itself from the installed app with a
throwaway LOCALAPPDATA, reads an AcoustID key only from the environment
and never writes one, and reports service outages apart from bugs.

Its first run found two bugs, both older than this branch:

- A dev build (0.18.2.dev0) was offered an update to 0.18.1: the dev
  check needed a word boundary after "dev" (#714).
- Cancel waited out yt-dlp's metadata probe, up to twenty seconds: the
  probe now runs on its own thread and a cancel stops the wait at once,
  as it does the pause between network retries (#715).
Comment thread app/pipeline/download.py
Thales added 10 commits September 28, 2026 21:40
… hand

- LRCLIB's copy of a track's own length was trusted as the track's
  timing and never checked. Green Day "Basket Case" (the video) sings
  16 s after that copy's first line. Exact-length LRCLIB lyrics are now
  checked against the vocals too and moved when the gap is clearly a
  second or more; a transcription and a file's own lyrics are never
  moved (#716).
- The Lyrics tab gets Align: start the lyrics (or a chosen line) at the
  playhead, nudge earlier or later with the offset shown, auto-detect
  from the vocals (saying so when it cannot tell), and reset. The offset
  is kept beside the lyrics (offset_sec in lyrics.json, or the browser's
  store), never rewritten into them, and survives a re-split. New
  endpoints POST /api/jobs/{id}/lyrics/offset and .../lyrics/align
  (#717).
- Acceptance check L4 runs auto-detect on the real Basket Case video.
Every change is saved as it is made, so Done only closes the panel,
as Escape does. Filled in the accent colour, so it reads as the way out.
In all ten languages.
Lyrics kept before a lookup was held to the track's own song and
artist were never looked at again: NIHIL "Barro" kept Renato Vianna's
"Joao de Barro" from a fuzzy search. GET /api/jobs/{id}/lyrics now
checks LRCLIB lyrics by that rule and drops them for good when the
song matches neither the track's name nor its other titles, or the
artist differs in the same script (another script was matched through
aliases and stands). Other versions of another song are no longer
offered. The Lyrics tab drops an entry it kept itself for another
song and looks the track up again; a version the user picked keeps its
artist, since a cover can be their choice.
…settings tab (#718)

- Settings gets a Song details tab after General, holding Song
  identification (the AcoustID key, moved), Discogs (new) and Transcribe
  lyrics (moved).
- A Discogs personal access token, optional, works like the AcoustID
  key: checked with Discogs on save (refused tokens named, offline kept
  on trust, the wait bounded), only its last two characters shown, never
  logged, put in a URL or sent to the browser.
- With a token, the artist box fills from Discogs what Wikipedia lacks:
  a band's profile, members, releases and links, credited "Data provided
  by Discogs". The band is found through its MusicBrainz or Wikidata
  link to Discogs, or a release crediting that exact artist with that
  song; a shared name alone is never enough. Served by
  GET /api/jobs/{id}/artist-extra; without a token nothing is sent.
…718)

- The box names Wikipedia wherever it searched or found nothing. With no
  Discogs token, a note says only Wikipedia was searched and opens
  Settings on Song details; it shows when nothing was found or a band
  has no history, members or albums, never for a band Wikipedia covers.
- With a token, a name Wikipedia does not know lists Discogs artists to
  pick from (a shared name is the user's to settle), and the pick is
  shown from Discogs with its credit. GET /api/discogs/artist?q= and
  /api/discogs/artist/{id}; the token stays on the server.
- The privacy text says typed names go to Discogs with a token set.
…together (#706)

"NataliaKukulska" found nothing on MusicBrainz or LRCLIB, so W biegu lost
its lyrics. The channel's spelling is taken only when it adds accents and
keeps the words apart.
)

"E tak niewiele jest" in W biegu is "Że tak". A one-letter word is still
never repaired on its own, only when both neighbours pair with the
transcription in order, where no sung vowel is heard as "że".
…ied reader (#709)

Wikidata sometimes files a traditional title under zh-hans, which left
11月的蕭邦 beside 周杰伦的床边故事. Any Chinese label is folded now;
folding one already simplified changes nothing.
Code quality flagged it. It is raised again on the job's thread; narrower,
a SystemExit from yt-dlp would end the probe thread quietly and the import
would go on with no answer.
… them by hand (#716, #717)

Lyrics timed to another cut of a song drift line by line, which one offset
cannot fix. The vocals are transcribed with word timestamps in the lyrics'
own language, the words aligned in order to the lyric (per character for
Chinese and Japanese, per syllable for Korean), each line start put on the
voice's onset, and lines the first pass missed heard again on their own.
The result is kept only when enough of the lyric was found; otherwise the
lyrics stay as they were. A held first word no longer loses its line.

Sync lines is the manual side: tap T or Enter as each line starts, drag a
line's marker on the Lyrics lane above the waveform like the beat grid,
nudge, undo and redo, re-time from the vocals, or reset. A timing the user
made always wins over a detected one and is kept with the lyrics.

Also: the name search keeps the artist's own song in another length and
times it from the vocals; deleting a track waits for a running re-time to
let go of the vocals; the acceptance suite drags with the real Windows
cursor when the DevTools mouse does not reach WebView2.
- Opening the Lyrics tab dropped lyrics the lookup had found under another
  of the artist's names (Cat Stevens for Yusuf Islam), and the user's own
  timing with them. The lookup now marks those "by_alias", and lyrics the
  user timed, moved or that were found line by line in the vocals are never
  dropped (#702).
- Escape and the other Sync lines keys were taken from a dialog open over
  the Lyrics panel: the app's dialogs are role="dialog" elements, not
  native <dialog>s, so none was ever seen as open (#717).
- Lyrics with a byte order mark or a line break other than "\n" split into
  different lines in the page and on the server, so saving the user's own
  timing of them was refused. Stored lyrics now carry one kind of break
  (#717).
- Two patterns were quadratic on crafted input: a line of unclosed "<" sent
  to /lyrics/align held the server for seconds, and blank lines in a file's
  lyrics tag for most of one (#716).
- Automatic alignment could write over an offset the user set while it
  worked. It now writes under the Lyrics tab's lock, and only when nothing
  changed meanwhile (#716).
- The Discogs token would have been sent on to wherever a redirect pointed;
  it now goes to Discogs alone (#718).
- A damaged audio_tags read back from disk made the lyrics lookup raise;
  tags are cleaned like the other fields (#706).
- Deleting or cancelling a track while its audio was being fingerprinted
  could fail on Windows with the file still open: the fingerprint is
  stopped first (#706).
@thcp
thcp merged commit 88d31ef into main Sep 29, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment