From bc885275c0686984ea60ce918f5ec4f6af510201 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Sun, 27 Sep 2026 10:51:41 +0100
Subject: [PATCH 01/20] feat: artist box, lyrics tab with karaoke, and audio
tags (#699)
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.
---
app/api/jobs.py | 138 ++++++-
app/api/stems.py | 56 +++
app/core/config.py | 33 ++
app/core/models.py | 42 +++
app/core/registry.py | 4 +-
app/main.py | 10 +-
app/pipeline/artist_lookup.py | 286 ++++++++++++++
app/pipeline/audio_stats.py | 42 +++
app/pipeline/audio_tags.py | 214 +++++++++++
app/pipeline/download.py | 36 ++
app/pipeline/runner.py | 32 +-
static/css/daw.css | 414 ++++++++++++++++++--
static/index.html | 55 +++
static/js/artistInfo.js | 406 ++++++++++++++++++++
static/js/artistLookup.js | 358 ++++++++++++++++++
static/js/catalog.js | 231 +++++++++++-
static/js/i18n.js | 426 +++++++++++++++++++++
static/js/job.js | 12 +-
static/js/lyrics.js | 492 ++++++++++++++++++++++++
static/js/lyricsLookup.js | 440 ++++++++++++++++++++++
static/js/main.js | 4 +
static/js/player.js | 17 +
tests/conftest.py | 13 +
tests/e2e/artist-from-server.spec.mjs | 167 +++++++++
tests/e2e/artist-from-tags.spec.mjs | 187 +++++++++
tests/e2e/artist-info.spec.mjs | 304 +++++++++++++++
tests/e2e/audio-tags-backfill.spec.mjs | 292 ++++++++++++++
tests/e2e/helpers.mjs | 25 ++
tests/e2e/library-rows.spec.mjs | 24 +-
tests/e2e/library-tags.spec.mjs | 74 ++++
tests/e2e/lyrics.spec.mjs | 400 ++++++++++++++++++++
tests/e2e/now-playing-art.spec.mjs | 71 ++++
tests/js/artist-lookup.test.mjs | 239 ++++++++++++
tests/js/lyrics-lookup.test.mjs | 258 +++++++++++++
tests/test_artist_lookup.py | 501 +++++++++++++++++++++++++
tests/test_audio_tags.py | 253 +++++++++++++
tests/test_audio_tags_api.py | 308 +++++++++++++++
tests/test_csp.py | 16 +
tests/test_vocal_envelope_api.py | 148 ++++++++
39 files changed, 6953 insertions(+), 75 deletions(-)
create mode 100644 app/pipeline/artist_lookup.py
create mode 100644 app/pipeline/audio_tags.py
create mode 100644 static/js/artistInfo.js
create mode 100644 static/js/artistLookup.js
create mode 100644 static/js/lyrics.js
create mode 100644 static/js/lyricsLookup.js
create mode 100644 tests/e2e/artist-from-server.spec.mjs
create mode 100644 tests/e2e/artist-from-tags.spec.mjs
create mode 100644 tests/e2e/artist-info.spec.mjs
create mode 100644 tests/e2e/audio-tags-backfill.spec.mjs
create mode 100644 tests/e2e/library-tags.spec.mjs
create mode 100644 tests/e2e/lyrics.spec.mjs
create mode 100644 tests/e2e/now-playing-art.spec.mjs
create mode 100644 tests/js/artist-lookup.test.mjs
create mode 100644 tests/js/lyrics-lookup.test.mjs
create mode 100644 tests/test_artist_lookup.py
create mode 100644 tests/test_audio_tags.py
create mode 100644 tests/test_audio_tags_api.py
create mode 100644 tests/test_vocal_envelope_api.py
diff --git a/app/api/jobs.py b/app/api/jobs.py
index 4c1c2a29..cd949666 100644
--- a/app/api/jobs.py
+++ b/app/api/jobs.py
@@ -23,6 +23,8 @@
MAX_PENDING_UPLOAD_JOBS,
MAX_PENDING_URL_JOBS,
STEM_NAMES,
+ TIMEOUT_ARTIST_LOOKUP,
+ TIMEOUT_FETCH_TAGS,
ffprobe_executable,
)
from app.core.models import Job, _set
@@ -38,8 +40,10 @@
from app.core.settings import get_auto_sections, get_max_duration_sec
from app.core.stems_location import is_relocating
from app.pipeline import jobqueue
+from app.pipeline.artist_lookup import find_band, tagged_artist_name
+from app.pipeline.audio_tags import probe_tags
from app.pipeline.collect import merge_stem_peaks, presence_for_split
-from app.pipeline.download import InvalidYouTubeURL, validate_youtube_url
+from app.pipeline.download import InvalidYouTubeURL, fetch_audio_tags, validate_youtube_url
from app.pipeline.errors import classify_failure
from app.pipeline.runner import _pipeline_lock
from app.pipeline.vocal_split import split_vocals
@@ -309,6 +313,10 @@ async def _create_local_job(request: Request) -> dict[str, str]:
shutil.rmtree(job_dir, ignore_errors=True)
raise
+ # Now, while the upload still exists: it is deleted once the pipeline is
+ # done with it, and the stems carry no tags. Never fails the upload.
+ audio_tags = await asyncio.to_thread(probe_tags, source_path)
+
title = _sanitize_title(filename)
local_source_url = f"local:{title}"
job = Job(
@@ -318,6 +326,7 @@ async def _create_local_job(request: Request) -> dict[str, str]:
duration_sec=duration,
source_url=local_source_url,
source_format=ext.removeprefix("."),
+ audio_tags=audio_tags,
auto_sections=get_auto_sections(),
)
if not registry_register_if_capacity(job, MAX_PENDING_UPLOAD_JOBS):
@@ -544,6 +553,9 @@ async def resplit_job(job_id: str, body: ResplitBody) -> dict:
# track it came from instead of replacing it.
source_url=_resplit_source_url(job.source_url, new_id),
source_format=source.suffix.lower().removeprefix("."),
+ # The same recording, so the same tags and the same band.
+ audio_tags=job.audio_tags,
+ artist=job.artist,
auto_sections=get_auto_sections(),
)
if not registry_register_if_capacity(new_job, MAX_PENDING_UPLOAD_JOBS):
@@ -719,6 +731,130 @@ def update_sections(job_id: str, body: SectionsBody) -> dict:
return {"job_id": job_id, "sections": validated, "sections_source": "manual"}
+# Jobs whose tags are being looked up right now. Only touched on the event
+# loop, with no await between the check and the add, so a plain set is enough.
+_TAG_LOOKUPS: set[str] = set()
+
+
+def _lookup_audio_tags(job: Job) -> dict[str, str] | None:
+ """Blocking: the tags a finished job's own source can still give, or None.
+
+ An upload is asked through the source kept beside it, if one was. A link
+ is asked through its metadata, and only once the stored URL has passed the
+ same validator as an import: it came from the registry, not from this
+ request, but nothing that fails that check is ever handed to yt-dlp.
+ Raises when the fetch fails.
+ """
+ source_url = job.source_url or ""
+ if source_url.startswith("local:"):
+ source = _retained_source(job.id)
+ return probe_tags(source) if source is not None else None
+ try:
+ url = validate_youtube_url(source_url)
+ except InvalidYouTubeURL:
+ return None
+ return fetch_audio_tags(url)
+
+
+def _write_metadata_fields(job_id: str, fields: dict[str, object]) -> None:
+ """Put ``fields`` (the tags, the band) into metadata.json, if the job has
+ one, and change nothing else in it: restore() rebuilds a lost registry
+ entry from this file."""
+ meta_path = (JOBS_DIR / job_id / "metadata.json").resolve()
+ if not meta_path.is_relative_to(JOBS_DIR.resolve()):
+ return
+ # The same lock as the sections editor, the other read-modify-write here.
+ with _SECTIONS_WRITE_LOCK:
+ try:
+ if not meta_path.is_file():
+ return
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
+ if not isinstance(meta, dict):
+ return
+ meta.update(fields)
+ _write_json_atomic(meta_path, meta)
+ except (OSError, ValueError):
+ logger.warning("could not record audio tags in metadata for %s", job_id, exc_info=True)
+
+
+@router.post("/{job_id}/audio-tags")
+async def refresh_audio_tags(job_id: str) -> dict:
+ """Fill in a finished track's tags, and the band they name (#699), for
+ tracks imported before the pipeline found them.
+
+ Answers {"audio_tags": {...} | null, "artist": {...} | null}. A lookup
+ that fails or times out is "nothing found", not an error: the page asks
+ once per track and does not retry, and the reason is in the server log.
+ The band is looked up when the tags name an artist and the job has no
+ band yet, whether the tags were found just now or were there already.
+
+ Deliberately outside _pipeline_lock. This is one metadata request, and
+ waiting behind a twenty-minute separation for it would leave the artist
+ box empty for no reason.
+ """
+ if not JOB_ID_RE.match(job_id):
+ raise HTTPException(status_code=404, detail="job not found")
+ job = registry_get(job_id)
+ if job is None:
+ raise HTTPException(status_code=404, detail="job not found")
+ if job.status != "done":
+ raise HTTPException(status_code=409, detail="job is not finished")
+ if job.audio_tags and (job.artist or not tagged_artist_name(job.audio_tags.get("artist"))):
+ return {"audio_tags": job.audio_tags, "artist": job.artist}
+ if job_id in _TAG_LOOKUPS:
+ raise HTTPException(status_code=409, detail="already looking up this track")
+
+ _TAG_LOOKUPS.add(job_id)
+ try:
+ tags = job.audio_tags
+ if not tags:
+ try:
+ # A timeout abandons the wait, not the thread: yt-dlp's own
+ # socket timeout ends that, and its answer is then dropped.
+ tags = await asyncio.wait_for(
+ asyncio.to_thread(_lookup_audio_tags, job), timeout=TIMEOUT_FETCH_TAGS
+ )
+ except asyncio.TimeoutError:
+ logger.warning(
+ "[%s] audio tag lookup timed out after %ds", job_id, TIMEOUT_FETCH_TAGS
+ )
+ tags = None
+ except Exception:
+ logger.warning("[%s] audio tag lookup failed", job_id, exc_info=True)
+ tags = None
+ artist = None
+ if tags and not job.artist:
+ # find_band never raises; the wait is bounded all the same, since
+ # a socket timeout is per read, not per request.
+ try:
+ artist = await asyncio.wait_for(
+ asyncio.to_thread(
+ find_band,
+ tags.get("artist"),
+ cancelled=lambda: registry_get(job_id) is not job,
+ ),
+ timeout=2 * TIMEOUT_ARTIST_LOOKUP + 1,
+ )
+ except asyncio.TimeoutError:
+ logger.info("[%s] band lookup timed out", job_id)
+ finally:
+ _TAG_LOOKUPS.discard(job_id)
+
+ # The track was deleted while the lookup ran.
+ if registry_get(job_id) is not job:
+ return {"audio_tags": None, "artist": None}
+ found: dict[str, object] = {}
+ if tags and not job.audio_tags:
+ found["audio_tags"] = tags
+ if artist and not job.artist:
+ found["artist"] = artist
+ if found:
+ _set(job, **found)
+ registry_persist(JOBS_DIR)
+ await asyncio.to_thread(_write_metadata_fields, job_id, found)
+ return {"audio_tags": job.audio_tags, "artist": job.artist}
+
+
# Upper bound on an edited grid. A 20-minute track at 300 BPM is ~6000 beats;
# 20000 leaves generous headroom while refusing a payload crafted to exhaust
# memory or disk.
diff --git a/app/api/stems.py b/app/api/stems.py
index 95bc4a86..b031ee8f 100644
--- a/app/api/stems.py
+++ b/app/api/stems.py
@@ -29,6 +29,7 @@
)
from app.core.registry import get as registry_get
from app.core.settings import get_export_sample_rate
+from app.pipeline.audio_stats import vocal_envelope
from app.pipeline.click_render import cache_key as click_cache_key
from app.pipeline.click_render import count_in_beats, render_click_wav, render_count_in_wav
@@ -870,6 +871,61 @@ async def get_stem_peaks(job_id: str, request: Request) -> Response:
return _peaks_response(job_id, request)
+# The vocals stem's level every 40ms: fine enough to find the gap between two
+# sung words (a breath is 150ms or more), coarse enough that a ten minute track
+# is about 60 KB. peaks.json cannot serve instead: it is a fixed 3000 points
+# per track, 100ms apart on a five minute song and 200ms on a ten minute one.
+_VOCAL_ENVELOPE_HOP_SEC = 0.04
+_VOCAL_ENVELOPE_FILE = "vocal_envelope.json"
+
+
+def _write_vocal_envelope(src: Path, dest: Path) -> None:
+ hop, levels = vocal_envelope(src, _VOCAL_ENVELOPE_HOP_SEC)
+ # A unique temp name: two first requests for the same track may compute at
+ # once, and each moves a whole file into place.
+ tmp = dest.with_name(f"{dest.name}.{uuid.uuid4().hex}.tmp")
+ try:
+ tmp.write_text(json.dumps({"hop": hop, "db": levels}), encoding="utf-8")
+ os.replace(tmp, dest)
+ finally:
+ tmp.unlink(missing_ok=True)
+
+
+@router.get("/jobs/{job_id}/vocal-envelope")
+async def get_vocal_envelope(job_id: str) -> Response:
+ """How loud the vocals stem is over time, for the lyrics wipe (#699):
+ `{"hop": seconds, "db": [whole dBFS per hop, -90 to 0]}`.
+
+ Lyrics from LRCLIB mostly stamp only when each line starts, so the tab
+ uses this to tell where in a line the singer is actually singing.
+
+ Computed the first time a track asks and kept beside its stems, so every
+ job has it, including those separated before it existed. Recomputed if
+ vocals.wav is newer than what was kept. Deliberately outside
+ _pipeline_lock: it is one streamed read of one stem, under a second for a
+ five minute track, and waiting behind a whole separation would leave the
+ lyrics on the rough estimate for minutes.
+ """
+ if not JOB_ID_RE.match(job_id):
+ raise HTTPException(status_code=404, detail="job not found")
+ job = registry_get(job_id)
+ if job is None or job.status != "done":
+ raise HTTPException(status_code=404, detail="job not ready")
+ stems_dir = (JOBS_DIR / job_id / "stems").resolve()
+ src = stems_dir / "vocals.wav"
+ dest = stems_dir / _VOCAL_ENVELOPE_FILE
+ jobs_root = JOBS_DIR.resolve()
+ if not src.is_file() or not src.is_relative_to(jobs_root) or not dest.is_relative_to(jobs_root):
+ raise HTTPException(status_code=404, detail="no vocals")
+ try:
+ if not dest.is_file() or dest.stat().st_mtime_ns < src.stat().st_mtime_ns:
+ await asyncio.to_thread(_write_vocal_envelope, src, dest)
+ except Exception:
+ logger.exception("could not compute the vocal envelope for %s", job_id)
+ raise HTTPException(status_code=500, detail="vocal envelope unavailable") from None
+ return FileResponse(dest, media_type="application/json", headers={"Cache-Control": "no-cache"})
+
+
@router.get("/jobs/{job_id}/stems/beats.json")
async def get_beat_grid(job_id: str) -> Response:
"""Return the pre-computed beat grid driving the click track.
diff --git a/app/core/config.py b/app/core/config.py
index 0a9f0bc9..8ddd614a 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -319,6 +319,39 @@ def js_solver_available() -> bool:
# so it gets its own, larger budget.
TIMEOUT_BEATGRID = _env_int("STEMDECK_TIMEOUT_BEATGRID", 300)
+# Reading an upload's tags (artist, title, album, lyrics) with ffprobe for the
+# Lyrics tab and the artist box (#699). Tags are a nicety, so a probe that
+# takes longer than this is abandoned and the upload goes ahead without them.
+TIMEOUT_PROBE_TAGS = _env_int("STEMDECK_TIMEOUT_PROBE_TAGS", 15)
+# Looking up a finished link's tags after the fact, for tracks imported before
+# tags were read: one yt-dlp metadata request (two if YouTube's bot check sends
+# it back for cookies), no download. Past this the lookup answers "nothing
+# found" rather than keep the page waiting.
+TIMEOUT_FETCH_TAGS = _env_int("STEMDECK_TIMEOUT_FETCH_TAGS", 45)
+# Caps on what a tag may carry into the job record, which is rewritten whole
+# on every save: a name longer than this is not a name, and a lyrics tag
+# longer than this is not lyrics.
+AUDIO_TAG_MAX_CHARS = 300
+AUDIO_TAG_LYRICS_MAX_CHARS = 20000
+
+# Finding the band a job's artist tag names, on Wikidata, while the job is
+# separated (#699), so the artist box and the Lyrics tab have it the moment the
+# import is done. Two requests, each abandoned after this many seconds. The
+# lookup runs beside separation, which takes far longer, so it costs an import
+# no time unless both requests are slow and the separation was fast.
+TIMEOUT_ARTIST_LOOKUP = _env_int("STEMDECK_TIMEOUT_ARTIST_LOOKUP", 8)
+# How long a finished pipeline waits for a lookup still in flight before it
+# lets the job finish without a band. Kept short: the page finds the band
+# itself when it opens a track that has none, so the wait buys little.
+ARTIST_LOOKUP_GRACE_SEC = _env_int("STEMDECK_ARTIST_LOOKUP_GRACE_SEC", 2)
+# Wikimedia asks every client to name itself and a way to reach its maker.
+ARTIST_LOOKUP_USER_AGENT = (
+ "StemDeck (https://github.com/stemdeckapp/stemdeck; self-hosted stem separation)"
+)
+# A search and one entity fetch for its hits come to well under this; an
+# answer bigger than it is not one worth parsing.
+ARTIST_LOOKUP_MAX_BYTES = 8 * 1024 * 1024
+
# Beat-grid analysis parameters. 22050 Hz is plenty for onset detection (the
# percussive energy that matters lives well under 11 kHz) and keeps the decode
# cheap; hop 512 gives ~23 ms grid resolution, the librosa default pairing.
diff --git a/app/core/models.py b/app/core/models.py
index fc7ccef6..b0c7a3b3 100644
--- a/app/core/models.py
+++ b/app/core/models.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import dataclasses
+import re
import time
from dataclasses import dataclass, field
from typing import Any, Literal
@@ -14,6 +15,32 @@ class JobCancelled(Exception):
"queued", "downloading", "analyzing", "separating", "processing", "done", "error", "cancelled"
]
+# A Wikidata item id. Anything else is not a band this app found.
+_WIKIDATA_ID_RE = re.compile(r"^Q\d{1,12}$")
+# A band's name, as kept on the job. Longer than this is not a name.
+_ARTIST_NAME_MAX_CHARS = 300
+
+
+def clean_artist(value: Any) -> dict[str, str] | None:
+ """A band as kept on a job, {"id", "name", "englishName"}, or None.
+
+ For anything read back from disk (the registry, metadata.json), where a
+ hand-edited or damaged file must not put a malformed band in front of the
+ page: the id has to be a Wikidata item, the names plain strings.
+ """
+ if not isinstance(value, dict):
+ return None
+ band_id = value.get("id")
+ if not isinstance(band_id, str) or not _WIKIDATA_ID_RE.match(band_id):
+ return None
+ names = {}
+ for key in ("name", "englishName"):
+ name = value.get(key)
+ names[key] = name.strip()[:_ARTIST_NAME_MAX_CHARS] if isinstance(name, str) else ""
+ if not names["name"] and not names["englishName"]:
+ return None
+ return {"id": band_id, **names}
+
def _set(job: Job, **fields: object) -> None:
"""Mutate Job fields, then bump job.version so the SSE stream (#289) can
@@ -72,6 +99,18 @@ class Job:
# for a link, and for an upload older than this field until its state is
# first served (see _job_state).
source_format: str | None = None
+ # What the source said about itself (#699): {"artist", "title", "album",
+ # "lyrics"}, each only when present. An upload's container tags, read
+ # before the upload is deleted; a link's music metadata from yt-dlp. The
+ # Lyrics tab and the artist box fill themselves from it. None when the
+ # source had none, and for anything imported before it was read.
+ audio_tags: dict[str, str] | None = None
+ # The band audio_tags' artist names, found on Wikidata while the job ran
+ # (#699): {"id": "Q...", "name", "englishName"}. Only ever an exact match
+ # for the tag, so a wrong band is never saved with nobody looking. None
+ # when there was no artist tag, no such band, or no connection; the page
+ # then looks for it itself, and a band saved there always wins over this.
+ artist: dict[str, str] | None = None
# True when a silent video track (video.mp4) was preserved from an .mp4
# upload, enabling the "Export Mix (with video)" MP4 export.
has_video: bool = False
@@ -162,6 +201,8 @@ def to_state(self) -> dict[str, Any]:
"mix_url": self.mix_url,
"source_url": self.source_url,
"source_format": self.source_format,
+ "audio_tags": self.audio_tags,
+ "artist": self.artist,
"has_video": self.has_video,
"video_status": self.video_status,
"error": self.error,
@@ -201,6 +242,7 @@ def from_record(cls, data: dict[str, Any]) -> Job:
job = cls(id=job_id)
for key, value in fields.items():
setattr(job, key, value)
+ job.artist = clean_artist(job.artist)
job.cancel_requested = False
return job
diff --git a/app/core/registry.py b/app/core/registry.py
index 9cc07c87..902ff013 100644
--- a/app/core/registry.py
+++ b/app/core/registry.py
@@ -10,7 +10,7 @@
from pathlib import Path
from app.core.config import DEMUCS_MODEL, EXTRA_STEM_NAMES, JOB_ID_RE, STEM_NAMES
-from app.core.models import Job
+from app.core.models import Job, clean_artist
logger = logging.getLogger("stemdeck.registry")
@@ -368,6 +368,8 @@ def _recover_done_job(job_dir: Path) -> Job | None:
sections=meta.get("sections"),
sections_source=meta.get("sections_source"),
tags=meta.get("tags"),
+ audio_tags=meta.get("audio_tags"),
+ artist=clean_artist(meta.get("artist")),
vocal_split=vocal_split,
)
diff --git a/app/main.py b/app/main.py
index 942f7447..2eabe119 100644
--- a/app/main.py
+++ b/app/main.py
@@ -849,7 +849,11 @@ def download_logs_zip() -> StreamingResponse:
# script-src has no 'unsafe-inline'/'eval': all JS is same-origin modules and the
# inline scripts/onclick were moved out. 'unsafe-inline' is allowed for *styles*
# only (the UI sets many style attributes). Allowances:
-# connect-src -> same-origin API/SSE, the GitHub update check, Tauri IPC
+# connect-src -> same-origin API/SSE, the GitHub update check, Tauri IPC,
+# Wikidata/Wikipedia for the artist box the now-playing card
+# opens, and LRCLIB for the Lyrics tab (#699). All four are
+# read-only public APIs, asked only about the open track, and
+# sent only an artist and a song name.
# img-src https: -> remote YouTube/SoundCloud thumbnails
# style/font -> the Google Fonts
_CSP = (
@@ -863,7 +867,9 @@ def download_logs_zip() -> StreamingResponse:
# track init); without them Multitrack.create throws and no audio loads
# (#186). They are inline/same-origin schemes, not network endpoints, so
# they add no exfiltration channel — script-src below stays locked.
- "connect-src 'self' https://api.github.com ipc: http://ipc.localhost data: blob:; "
+ "connect-src 'self' https://api.github.com ipc: http://ipc.localhost data: blob: "
+ "https://www.wikidata.org https://query.wikidata.org https://*.wikipedia.org "
+ "https://lrclib.net; "
"object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
)
diff --git a/app/pipeline/artist_lookup.py b/app/pipeline/artist_lookup.py
new file mode 100644
index 00000000..cff5aeae
--- /dev/null
+++ b/app/pipeline/artist_lookup.py
@@ -0,0 +1,286 @@
+"""The band a job's artist tag names, found on Wikidata while the job runs (#699).
+
+The page used to do this itself, once a finished track was opened, so the
+artist box, the Lyrics tab and the now-playing card only learned the band a
+moment after the track was open, and only while the page was. Done here, it
+runs beside separation, which takes minutes, and the band is on the job the
+moment the import is done.
+
+This is a port of the page's own lookup (static/js/artistLookup.js), kept to
+the same rule: the first search hit that has a MusicBrainz artist id is the
+band, and it is kept only when its name is the tag's name once case, Latin
+accents, punctuation and spacing are set aside. Wikidata's search forgives
+spelling, so a near miss still finds some band, and saved with nobody looking
+the name has to agree.
+
+What is sent is the tag's artist name and nothing else about the track.
+
+Everything here is best-effort. No tag, no match, no connection or a slow
+answer all leave the band unset, and the page then looks for it itself when
+the track is opened.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+import ssl
+import threading
+import unicodedata
+import urllib.parse
+import urllib.request
+from collections.abc import Callable
+from typing import Any
+
+from app.core.config import ARTIST_LOOKUP_MAX_BYTES, ARTIST_LOOKUP_USER_AGENT, TIMEOUT_ARTIST_LOOKUP
+from app.core.models import Job, _set, clean_artist
+
+logger = logging.getLogger("stemdeck.artist")
+
+WIKIDATA_API = "https://www.wikidata.org/w/api.php"
+
+# Having a MusicBrainz artist ID is what separates the band from the album
+# named after it, a cinema that shares its name, or a disambiguation page.
+MUSICBRAINZ_ARTIST_ID = "P434"
+_QID_RE = re.compile(r"^Q\d+$")
+_SEARCH_LIMIT = "10"
+
+# "Artist feat. Someone", "Artist (ft. Someone)": no band is called that.
+_FEATURING_RE = re.compile(r"\s+[(\[]?(?:feat\.?|ft\.?|featuring)\s+", re.IGNORECASE)
+
+FetchJson = Callable[[dict[str, str]], Any]
+
+
+def artist_name_key(name: Any) -> str:
+ """A name reduced to what tells it apart: no case, Latin accents,
+ punctuation, symbols or spaces. artistNameKey in artistLookup.js.
+
+ Only the Latin combining marks are dropped: the marks in a Japanese or
+ Korean name are part of it, and folding them could make two names one.
+ """
+ text = unicodedata.normalize("NFKD", str(name or ""))
+ text = "".join(ch for ch in text if not 0x300 <= ord(ch) <= 0x36F).lower()
+ return "".join(
+ ch for ch in text if not ch.isspace() and unicodedata.category(ch)[0] not in ("P", "S")
+ )
+
+
+def tagged_artist_name(tag: Any) -> str:
+ """The name to look up from an artist tag, less any featured artist."""
+ return _FEATURING_RE.split(str(tag or ""), maxsplit=1)[0].strip()
+
+
+def artist_matches_name(artist: dict[str, str] | None, name: str) -> bool:
+ """Whether a looked-up band's name, or its English name, is the tag's."""
+ want = artist_name_key(name)
+ if not want or not artist:
+ return False
+ return any(
+ artist_name_key(candidate) == want
+ for candidate in (artist.get("name"), artist.get("englishName"))
+ )
+
+
+def pick_artist(entities: Any, ranked_ids: list[str]) -> dict[str, Any] | None:
+ """The first search hit, in the order the search ranked them, that is an
+ artist, or None. ``entities`` is wbgetentities' ``entities`` object."""
+ if not isinstance(entities, dict):
+ return None
+ for qid in ranked_ids:
+ entity = entities.get(qid)
+ if not isinstance(entity, dict):
+ continue
+ claims = entity.get("claims")
+ if isinstance(claims, dict) and claims.get(MUSICBRAINZ_ARTIST_ID):
+ return entity
+ return None
+
+
+def search_language(name: str) -> str:
+ """The Wikidata language to search a name in.
+
+ The page searched in the language the app was in. The server has no such
+ thing, so the name's own script stands in for it: a name written in kana,
+ Hangul, Han, Cyrillic or Greek is searched among the labels in that
+ script's language, where Wikidata keeps it, and anything else in English.
+ """
+ for ch in name:
+ if 0x3040 <= ord(ch) <= 0x30FF:
+ return "ja"
+ for ch in name:
+ code = ord(ch)
+ if 0xAC00 <= code <= 0xD7AF or 0x1100 <= code <= 0x11FF:
+ return "ko"
+ if 0x4E00 <= code <= 0x9FFF:
+ return "zh"
+ if 0x0400 <= code <= 0x04FF:
+ return "ru"
+ if 0x0370 <= code <= 0x03FF:
+ return "el"
+ return "en"
+
+
+def _ssl_context() -> ssl.SSLContext:
+ # certifi where it is installed, as for the model downloads in
+ # separate.py: a packaged Python on macOS may have no CA store of its own.
+ try:
+ import certifi
+
+ return ssl.create_default_context(cafile=certifi.where())
+ except (ModuleNotFoundError, OSError):
+ return ssl.create_default_context()
+
+
+def _fetch_json(params: dict[str, str]) -> Any:
+ """One Wikidata API request. Raises on any failure."""
+ url = f"{WIKIDATA_API}?{urllib.parse.urlencode({**params, 'format': 'json'})}"
+ request = urllib.request.Request(
+ url, headers={"User-Agent": ARTIST_LOOKUP_USER_AGENT, "Accept": "application/json"}
+ )
+ # A fixed https URL with only its query built here, never a URL from a
+ # request or a tag, which is what B310 exists to catch.
+ with urllib.request.urlopen( # nosec B310
+ request, timeout=TIMEOUT_ARTIST_LOOKUP, context=_ssl_context()
+ ) as response:
+ body = response.read(ARTIST_LOOKUP_MAX_BYTES + 1)
+ if len(body) > ARTIST_LOOKUP_MAX_BYTES:
+ raise ValueError("Wikidata answer too large")
+ return json.loads(body)
+
+
+def _label(entity: dict[str, Any], *codes: str) -> str:
+ """The entity's label in the first of ``codes`` it has one in, or ""."""
+ labels = entity.get("labels")
+ if not isinstance(labels, dict):
+ return ""
+ for code in codes:
+ label = labels.get(code)
+ if isinstance(label, dict) and isinstance(label.get("value"), str):
+ return label["value"]
+ return ""
+
+
+def lookup_band(
+ name: str,
+ *,
+ fetch_json: FetchJson | None = None,
+ cancelled: Callable[[], bool] = lambda: False,
+) -> dict[str, str] | None:
+ """The band called ``name`` on Wikidata, as {"id", "name", "englishName"},
+ or None when nothing there is an artist by exactly that name. Raises when
+ Wikidata cannot be reached or answers nonsense."""
+ search = name.strip()
+ if not search or cancelled():
+ return None
+ # Resolved here rather than as the default, so a test can stand in for
+ # the network by replacing _fetch_json.
+ fetch_json = fetch_json or _fetch_json
+ lang = search_language(search)
+ found = fetch_json(
+ {
+ "action": "wbsearchentities",
+ "search": search,
+ "language": lang,
+ "uselang": lang,
+ "type": "item",
+ "limit": _SEARCH_LIMIT,
+ }
+ )
+ hits = found.get("search") if isinstance(found, dict) else None
+ ids = [
+ hit["id"]
+ for hit in (hits if isinstance(hits, list) else [])
+ if isinstance(hit, dict) and isinstance(hit.get("id"), str) and _QID_RE.match(hit["id"])
+ ]
+ if not ids or cancelled():
+ return None
+ answer = fetch_json(
+ {
+ "action": "wbgetentities",
+ "ids": "|".join(ids),
+ "props": "claims|labels",
+ "languages": "en" if lang == "en" else f"{lang}|en",
+ "languagefallback": "1",
+ }
+ )
+ entity = pick_artist(answer.get("entities") if isinstance(answer, dict) else None, ids)
+ if entity is None:
+ return None
+ band = clean_artist(
+ {
+ "id": entity.get("id"),
+ "name": _label(entity, lang, "en") or search,
+ # Kept beside the name: LRCLIB lists artists by the name they
+ # release under, which the English label nearly always is.
+ "englishName": _label(entity, "en"),
+ }
+ )
+ return band if band and artist_matches_name(band, search) else None
+
+
+def find_band(
+ artist_tag: Any,
+ *,
+ fetch_json: FetchJson | None = None,
+ cancelled: Callable[[], bool] = lambda: False,
+) -> dict[str, str] | None:
+ """lookup_band for an artist tag, featured artists left off. Never raises:
+ any failure is logged and gives None."""
+ name = tagged_artist_name(artist_tag)
+ if not name:
+ return None
+ try:
+ return lookup_band(name, fetch_json=fetch_json, cancelled=cancelled)
+ except Exception:
+ # Offline, a timeout, Wikidata down or answering something else. The
+ # page tries again itself when the track is opened.
+ logger.info("band lookup failed", exc_info=True)
+ return None
+
+
+class BandLookup:
+ """One job's band lookup, in a thread of its own, so separation never
+ waits for it.
+
+ The thread never touches the job. It leaves its answer here, and finish()
+ puts it on the job from the pipeline's own thread, once the pipeline has
+ got that far without being cancelled. A job cancelled or failed while the
+ lookup is out is therefore never written to, and an answer that arrives
+ after the job has finished goes nowhere.
+ """
+
+ def __init__(self, job: Job, artist_tag: str) -> None:
+ self._result: dict[str, str] | None = None
+ self._thread = threading.Thread(
+ target=self._run,
+ args=(job, artist_tag),
+ name=f"artist-{job.id}",
+ daemon=True,
+ )
+
+ def _run(self, job: Job, artist_tag: str) -> None:
+ # find_band never raises, so there is nothing for the thread to lose.
+ self._result = find_band(artist_tag, cancelled=lambda: job.cancel_requested)
+
+ @classmethod
+ def start(cls, job: Job) -> BandLookup | None:
+ """Start looking, or None when there is nothing to look for: no artist
+ tag, or a band already known (a re-split inherits its source's)."""
+ tags = job.audio_tags or {}
+ if job.artist or not tagged_artist_name(tags.get("artist")):
+ return None
+ lookup = cls(job, tags["artist"])
+ lookup._thread.start()
+ return lookup
+
+ def finish(self, job: Job, timeout: float) -> None:
+ """Wait up to ``timeout`` seconds for the answer, and keep it on the
+ job if there is one and the job was not cancelled meanwhile."""
+ self._thread.join(timeout)
+ if self._thread.is_alive():
+ logger.info("[%s] band lookup still out after %ss; finishing without", job.id, timeout)
+ return
+ if job.cancel_requested or self._result is None:
+ return
+ _set(job, artist=self._result)
diff --git a/app/pipeline/audio_stats.py b/app/pipeline/audio_stats.py
index 432a6dd2..302ea26a 100644
--- a/app/pipeline/audio_stats.py
+++ b/app/pipeline/audio_stats.py
@@ -41,3 +41,45 @@ def scan_stem(path: Path, buckets: int = 1500) -> tuple[list[list[float]], float
rms = math.sqrt(sumsq / n) if n else 0.0
return result[:buckets], rms
+
+
+# The level a frame of pure digital silence reports, and the floor every frame
+# is clamped to. Far below anything a vocals stem carries even as bleed.
+ENVELOPE_FLOOR_DB = -90
+
+
+def vocal_envelope(path: Path, hop_sec: float = 0.04) -> tuple[float, list[int]]:
+ """RMS level of the WAV at `path` every `hop_sec`, in whole dBFS clamped to
+ [ENVELOPE_FLOOR_DB, 0], over all channels. For the lyrics wipe (#699),
+ which follows the vocals stem's energy to tell singing from silence.
+
+ Returns (hop actually used, levels). The hop is a whole number of frames,
+ so it can differ from `hop_sec` by a fraction of a sample. Whole decibels
+ because the wipe needs no finer: a word boundary is a change of ten or
+ more, and integers keep the JSON for a ten minute track near 60 KB.
+
+ One streamed pass in blocks of whole hops, so memory stays constant
+ whatever the track's length, the same way scan_stem does (#286)."""
+ info = sf.info(str(path))
+ if info.frames == 0 or info.samplerate <= 0:
+ return hop_sec, []
+ hop = max(1, round(info.samplerate * hop_sec))
+ # A few seconds per block: large enough that the per-block overhead is
+ # nothing, small enough to stay a few MB for any track.
+ blocksize = hop * 128
+ levels: list[int] = []
+ for block in sf.blocks(str(path), blocksize=blocksize, dtype="float32", always_2d=True):
+ if block.size == 0:
+ continue
+ whole = (len(block) // hop) * hop
+ parts = [block[:whole].reshape(-1, hop * block.shape[1])] if whole else []
+ if whole < len(block):
+ # The last partial hop of the file: its own frame, averaged over
+ # what it has.
+ parts.append(block[whole:].reshape(1, -1))
+ for frames in parts:
+ mean_sq = np.mean(frames.astype(np.float64) ** 2, axis=1)
+ db = 10.0 * np.log10(np.maximum(mean_sq, 1e-12))
+ clipped = np.clip(np.round(db), ENVELOPE_FLOOR_DB, 0).astype(int)
+ levels.extend(clipped.tolist())
+ return hop / info.samplerate, levels
diff --git a/app/pipeline/audio_tags.py b/app/pipeline/audio_tags.py
new file mode 100644
index 00000000..5c4de6f5
--- /dev/null
+++ b/app/pipeline/audio_tags.py
@@ -0,0 +1,214 @@
+"""What a source says about itself: artist, title, album, and lyrics (#699).
+
+Read once at import and kept on the job as ``audio_tags``, for the Lyrics tab
+and the artist box. The library otherwise records only a title, and a title
+names the song as often as the band, so a file's own tags are the best answer
+there is to "who is this and what are the words".
+
+Two sources, one shape:
+
+* an upload's container tags, read with ffprobe before the upload is deleted
+ (the stems carry no tags, and the source goes once the pipeline is done);
+* a link's metadata from yt-dlp, which fills artist, track and album for
+ releases YouTube knows as music, and otherwise the channel, when the video
+ itself corroborates that the channel is the artist's.
+
+Everything here is optional. A probe that fails, times out or finds nothing
+gives ``None`` and the import goes ahead exactly as before.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+import subprocess
+from pathlib import Path
+from typing import Any
+
+from app.core.config import (
+ AUDIO_TAG_LYRICS_MAX_CHARS,
+ AUDIO_TAG_MAX_CHARS,
+ TIMEOUT_PROBE_TAGS,
+ ffprobe_executable,
+)
+
+logger = logging.getLogger(__name__)
+
+# ffprobe reports an MP3's lyrics frame as "lyrics-" ("lyrics-eng"),
+# a FLAC's as "LYRICS" or "UNSYNCEDLYRICS", an M4A's as "lyrics". Keys are
+# compared lower-cased.
+_LYRICS_KEYS = ("lyrics", "unsyncedlyrics", "syncedlyrics")
+
+
+def _clean(value: Any, limit: int) -> str:
+ """A tag value as plain text: control characters other than line breaks
+ dropped, trimmed, and cut at ``limit``. Tags are written by anyone, and
+ this text ends up in the page."""
+ if not isinstance(value, str):
+ return ""
+ text = "".join(ch for ch in value if ch in "\n\t" or ch >= " ")
+ return text.replace("\r\n", "\n").strip()[:limit]
+
+
+def _first(tags: dict[str, str], *keys: str) -> str:
+ for key in keys:
+ value = _clean(tags.get(key), AUDIO_TAG_MAX_CHARS)
+ if value:
+ return value
+ return ""
+
+
+def tags_from_probe(raw: dict[str, Any] | None) -> dict[str, str] | None:
+ """Artist, title, album and lyrics from ffprobe's ``format.tags``, or None
+ when the file names none of them."""
+ if not isinstance(raw, dict):
+ return None
+ tags = {str(k).lower(): v for k, v in raw.items() if isinstance(v, str)}
+ lyrics = ""
+ for key, value in tags.items():
+ if key in _LYRICS_KEYS or key.startswith("lyrics-") or key.startswith("lyrics "):
+ lyrics = _clean(value, AUDIO_TAG_LYRICS_MAX_CHARS)
+ if lyrics:
+ break
+ found = {
+ "artist": _first(tags, "artist", "album_artist", "albumartist", "performer"),
+ "title": _first(tags, "title"),
+ "album": _first(tags, "album"),
+ "lyrics": lyrics,
+ }
+ found = {k: v for k, v in found.items() if v}
+ return found or None
+
+
+# YouTube Music's auto-generated channels are named " - Topic", and
+# only ever carry that artist's releases.
+_TOPIC_SUFFIX = " - Topic"
+# "Artist - Title", with the separators people actually type: a hyphen, an en
+# or em dash or a pipe with space either side, or a colon followed by one.
+_TITLE_SEPARATOR_RE = re.compile(r"\s+[-\N{EN DASH}\N{EM DASH}|]\s+|\s*:\s+")
+_NON_WORD_RE = re.compile(r"[\W_]+")
+# What an artist's own channel tends to append to their name: "NirvanaVEVO",
+# "Nirvana Official". Compared on the squashed key, so spacing is irrelevant.
+_CHANNEL_SUFFIXES = ("vevo", "official")
+
+
+def _name_key(text: str) -> str:
+ """A name reduced for comparison: case-folded, punctuation and spacing gone."""
+ return _NON_WORD_RE.sub("", text.casefold())
+
+
+def _channel_key(channel: str) -> str:
+ key = _name_key(channel)
+ stripped = True
+ while stripped:
+ stripped = False
+ for suffix in _CHANNEL_SUFFIXES:
+ if key.endswith(suffix):
+ key = key.removesuffix(suffix)
+ stripped = True
+ return key
+
+
+def _artist_from_channel(info: dict[str, Any]) -> tuple[str, str] | None:
+ """(artist, title) from the channel, only when something corroborates it.
+
+ A bare channel name is not evidence: labels, compilers and fans upload
+ other people's songs all the time. It counts when the channel is YouTube's
+ auto-generated "Artist - Topic", or when the video's own title starts with
+ the channel's name ("Nirvana - Lithium (Official Music Video)" on the
+ NirvanaVEVO channel). The title keeps whatever noise follows; the page
+ cleans that up.
+ """
+ video_title = _clean(info.get("title"), AUDIO_TAG_MAX_CHARS)
+ channels: list[str] = []
+ for field in ("channel", "uploader"):
+ name = _clean(info.get(field), AUDIO_TAG_MAX_CHARS)
+ if name and name not in channels:
+ channels.append(name)
+ for channel in channels:
+ if channel.endswith(_TOPIC_SUFFIX):
+ artist = channel.removesuffix(_TOPIC_SUFFIX).strip()
+ if artist:
+ return artist, video_title
+ continue
+ key = _channel_key(channel)
+ if not key:
+ continue
+ for match in _TITLE_SEPARATOR_RE.finditer(video_title):
+ prefix = video_title[: match.start()].strip()
+ if _name_key(prefix) == key:
+ rest = video_title[match.end() :].strip()
+ if rest:
+ return prefix, rest
+ break
+ return None
+
+
+def tags_from_ytdlp(info: dict[str, Any] | None) -> dict[str, str] | None:
+ """Artist, track and album from yt-dlp's info, or None.
+
+ The music fields first, and they always win. Only when they name no
+ artist does the channel count, and then only when corroborated (see
+ _artist_from_channel): the channel alone is as often a label ("Roadrunner
+ Records") or a fan's upload, and a wrong artist is worse than none because
+ it gets searched for.
+ """
+ if not isinstance(info, dict):
+ return None
+ artists = info.get("artists")
+ artist = _clean(info.get("artist"), AUDIO_TAG_MAX_CHARS) or (
+ _clean(artists[0], AUDIO_TAG_MAX_CHARS) if isinstance(artists, list) and artists else ""
+ )
+ title = _clean(info.get("track"), AUDIO_TAG_MAX_CHARS)
+ if not artist and (corroborated := _artist_from_channel(info)) is not None:
+ artist = corroborated[0]
+ title = title or corroborated[1]
+ found = {
+ "artist": artist,
+ "title": title,
+ "album": _clean(info.get("album"), AUDIO_TAG_MAX_CHARS),
+ }
+ found = {k: v for k, v in found.items() if v}
+ # An album alone says nothing useful about who or what this is.
+ if not found.get("artist") and not found.get("title"):
+ return None
+ return found
+
+
+def probe_tags(path: Path) -> dict[str, str] | None:
+ """Read an upload's tags. Never raises: tags are not worth failing an
+ upload over, so every failure is logged and gives None."""
+ try:
+ result = subprocess.run(
+ [
+ ffprobe_executable(),
+ "-v",
+ "quiet",
+ "-print_format",
+ "json",
+ "-show_entries",
+ "format_tags",
+ str(path),
+ ],
+ capture_output=True,
+ # The same reason as _probe_duration in api/jobs.py: tags are
+ # arbitrary text, and the Windows locale codec would choke on it.
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ timeout=TIMEOUT_PROBE_TAGS,
+ )
+ except (OSError, subprocess.SubprocessError):
+ logger.warning("could not read tags from %s", path.name, exc_info=True)
+ return None
+ if result.returncode != 0:
+ logger.info("ffprobe found no readable tags in %s", path.name)
+ return None
+ try:
+ data = json.loads(result.stdout or "{}")
+ except ValueError:
+ logger.info("ffprobe tag output for %s was not JSON", path.name)
+ return None
+ fmt = data.get("format") if isinstance(data, dict) else None
+ return tags_from_probe(fmt.get("tags") if isinstance(fmt, dict) else None)
diff --git a/app/pipeline/download.py b/app/pipeline/download.py
index 440a3d34..625c2daa 100644
--- a/app/pipeline/download.py
+++ b/app/pipeline/download.py
@@ -11,6 +11,7 @@
from app.core.config import bundled_js_runtime, ffmpeg_dir, js_solver_available
from app.core.models import Job, JobCancelled, _set
from app.core.settings import get_cookies_file, get_max_duration_sec, get_video_max_height
+from app.pipeline.audio_tags import tags_from_ytdlp
logger = logging.getLogger("stemdeck.download")
@@ -431,6 +432,40 @@ def expand_playlist(url: str, limit: int) -> dict:
}
+def fetch_audio_tags(url: str) -> dict[str, str] | None:
+ """A link's tags from its metadata alone, for a track imported before tags
+ were read (#699). Nothing is downloaded.
+
+ The URL goes through validate_youtube_url here as well as at the caller:
+ this is where the fetch happens, so this is where the SSRF boundary (#173)
+ has to hold. Raises on any failure; the caller decides what that means.
+
+ Same cookie rule as an import (#432): without them first, with them only
+ once YouTube's bot check has turned the request away. _with_cookie_fallback
+ itself is not reused because it reports progress on a job's stage, and the
+ job here is finished.
+ """
+ url = validate_youtube_url(url)
+
+ def probe(use_cookies: bool) -> dict:
+ opts = {
+ **_base_ydl_opts(_ALLOWED_EXTRACTORS, use_cookies=use_cookies),
+ "noplaylist": True,
+ "skip_download": True,
+ }
+ with YoutubeDL(opts) as ydl:
+ return ydl.extract_info(url, download=False) or {}
+
+ try:
+ info = probe(False)
+ except Exception as exc:
+ if not _is_bot_check(exc) or get_cookies_file() is None:
+ raise
+ logger.info("tag lookup hit YouTube's bot check; retrying with cookies")
+ info = probe(True)
+ return tags_from_ytdlp(info if isinstance(info, dict) else None)
+
+
def _download_video_track(job: Job, url: str, job_dir: Path, *, use_cookies: bool = False) -> None:
"""Best-effort: download a video-only H.264/MP4 stream to video.mp4 for the
MP4 export (issue #219). The audio source is downloaded separately as
@@ -575,6 +610,7 @@ def _fetch(use_cookies: bool) -> dict:
seen: set[str] = set()
deduped = [t for t in raw_tags if not (t in seen or seen.add(t))] # type: ignore[func-returns-value]
_set(job, tags=deduped[:8] or None)
+ _set(job, audio_tags=tags_from_ytdlp(info))
# Best-effort: fetch the real video stream for the MP4 export.
# Non-fatal -- on any failure the job proceeds audio-only.
diff --git a/app/pipeline/runner.py b/app/pipeline/runner.py
index d9832d3d..3483b0b5 100644
--- a/app/pipeline/runner.py
+++ b/app/pipeline/runner.py
@@ -10,12 +10,13 @@
from datetime import datetime, timezone
from pathlib import Path
-from app.core.config import DEMUCS_MODEL, TIMEOUT_FFMPEG
+from app.core.config import ARTIST_LOOKUP_GRACE_SEC, DEMUCS_MODEL, TIMEOUT_FFMPEG
from app.core.models import Job, JobCancelled, _set
from app.core.redact import redact
from app.core.registry import is_upload, set_proc
from app.core.registry import persist as persist_registry
from app.pipeline.analyze import analyze
+from app.pipeline.artist_lookup import BandLookup
from app.pipeline.beatgrid import compute_beat_grid
from app.pipeline.collect import (
cleanup_source,
@@ -270,12 +271,35 @@ def _run_common(job: Job, source: Path, job_dir: Path) -> None:
_lap(job, "sections", mark)
+def _run_with_band_lookup(job: Job, source: Path, job_dir: Path) -> None:
+ """_run_common, with the band the artist tag names looked up beside it (#699).
+
+ Started as soon as the tags are known, which is now for both pipelines:
+ a link's come with its download, an upload's were read when it arrived.
+ The lookup is two small web requests and separation takes minutes, so by
+ the time the job is done the answer has long been waiting, and the wait
+ below is for nothing. When it has not arrived, the job waits at most
+ ARTIST_LOOKUP_GRACE_SEC and finishes without it; the page then looks for
+ the band itself when the track is opened.
+
+ Only a pipeline that got to the end keeps the answer: a cancel or a
+ failure raises out of _run_common, past finish(), and nothing is written.
+ """
+ lookup = BandLookup.start(job)
+ _run_common(job, source, job_dir)
+ if lookup is not None:
+ mark = time.monotonic()
+ lookup.finish(job, ARTIST_LOOKUP_GRACE_SEC)
+ # What the lookup cost the import, which should be nothing (#293).
+ _lap(job, "artist_wait", mark)
+
+
def _run_blocking(job: Job, url: str, job_dir: Path) -> None:
_check_cancel(job)
mark = time.monotonic()
source = download(job, url, job_dir)
_lap(job, "download", mark)
- _run_common(job, source, job_dir)
+ _run_with_band_lookup(job, source, job_dir)
def _run_local_blocking(job: Job, source_path: Path, job_dir: Path) -> None:
@@ -283,13 +307,15 @@ def _run_local_blocking(job: Job, source_path: Path, job_dir: Path) -> None:
mark = time.monotonic()
source = _prepare_local_source(job, source_path, job_dir)
_lap(job, "prepare", mark)
- _run_common(job, source, job_dir)
+ _run_with_band_lookup(job, source, job_dir)
def _write_metadata(job: Job, job_dir: Path) -> None:
meta = {
"title": job.title,
"thumbnail": job.thumbnail,
+ "audio_tags": job.audio_tags,
+ "artist": job.artist,
"duration_sec": job.duration_sec,
"bpm": job.bpm,
"key": job.key,
diff --git a/static/css/daw.css b/static/css/daw.css
index e256b5c6..0424a3ce 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -3845,6 +3845,207 @@ input, textarea { font-family: inherit; }
what was pushing the list past the dialog and putting a scrollbar on it. */
.friends-card { width: min(1020px, calc(100vw - 32px)); }
.friends-card .lib-friends-grid { width: 100%; margin-top: 4px; }
+
+/* ── Lyrics tab (#699) ──
+ A view of the sidebar, like Favorites and Trash, but not a list: while it is
+ on, the search box and the library list step aside and the panel takes the
+ whole height, so the lyrics get the room and scroll on their own. */
+.lyrics-panel { display: none; }
+.sidebar.lyrics-view .daw-search,
+.sidebar.lyrics-view .clear-bin-bar,
+.sidebar.lyrics-view .daw-lib-list { display: none !important; }
+.sidebar.lyrics-view .lyrics-panel {
+ display: flex; flex-direction: column; gap: 8px;
+ flex: 1 1 auto; min-height: 0;
+}
+/* The EXTRACT label's type, like every other section head in the app. */
+.lyrics-title {
+ margin: 0; font-size: 10px; font-weight: 600;
+ letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted);
+}
+.lyrics-note { margin: 0; font-size: 10.5px; line-height: 1.4; color: var(--muted); }
+.lyrics-status { margin: 0; font-size: 12px; color: var(--fg-2); }
+.lyrics-status:empty { display: none; }
+.lyrics-status.muted { color: var(--muted); }
+.lyrics-status.error { color: var(--danger); }
+
+.lyrics-versions:empty { display: none; }
+.lyrics-versions { display: flex; flex-direction: column; gap: 4px; padding-top: 4px; border-top: 1px solid var(--border); }
+.lyrics-match { display: flex; flex-direction: column; min-width: 0; }
+.lyrics-match-title { font-size: 13px; font-weight: 600; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.lyrics-match-meta { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.lyrics-tools { display: flex; gap: 12px; }
+.lyrics-link {
+ padding: 0; background: none; border: none; cursor: pointer;
+ color: var(--accent); font-size: 11px; font-weight: 600;
+}
+.lyrics-link:hover { text-decoration: underline; }
+/* display: flex would otherwise outrank the hidden attribute that folds it. */
+.lyrics-version-list[hidden] { display: none; }
+.lyrics-version-list { list-style: none; margin: 2px 0 0; padding: 0; display: flex; flex-direction: column; gap: 2px; max-height: 180px; overflow-y: auto; }
+.lyrics-version {
+ width: 100%; display: flex; flex-direction: column; align-items: flex-start; min-width: 0;
+ padding: 5px 7px; border-radius: 6px; border: 1px solid transparent;
+ background: none; color: var(--fg-2); text-align: left; cursor: pointer;
+}
+.lyrics-version:hover { background: var(--panel); border-color: var(--border); }
+.lyrics-version-title { font-size: 12px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.lyrics-version-meta { font-size: 10.5px; color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+/* The lyrics scroll inside the panel. The bottom padding lets the last lines
+ reach the middle, where the line being sung is kept. position: relative
+ makes this the lines' offsetParent, which lyrics.js measures from. */
+.lyrics-body { position: relative; flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 6px 2px 40%; }
+.lyrics-body:empty { display: none; }
+/* Synced lines are buttons, since clicking one moves the playhead there. */
+.lyrics-line {
+ display: block; width: 100%; margin: 0; padding: 4px 8px;
+ background: none; border: none; border-radius: 6px;
+ color: var(--muted); font: inherit; font-size: 14px; line-height: 1.45; text-align: left;
+ cursor: pointer; transition: color var(--t-fast), background var(--t-fast);
+}
+.lyrics-line:hover { color: var(--fg-2); background: var(--panel); }
+.lyrics-line.current { color: var(--fg); font-weight: 600; box-shadow: inset 2px 0 0 var(--accent); }
+/* The line after it, a step brighter than the rest, so the next words can be
+ read before they are due. */
+.lyrics-line.next { color: var(--fg-2); }
+/* The karaoke wipe, one word at a time: sung words take the accent, and the
+ word being sung is filled left to right up to --fill, set every frame by
+ lyrics.js. Per word rather than across the whole line, so a line that wraps
+ fills its first row before its second instead of both at once. */
+.lyrics-line.current .lw.sung { color: var(--accent); }
+.lyrics-line.current .lw.singing {
+ color: transparent;
+ background: linear-gradient(90deg, var(--accent) var(--fill, 0%), var(--fg) var(--fill, 0%));
+ -webkit-background-clip: text;
+ background-clip: text;
+}
+.lyrics-text { margin: 0; padding: 1px 8px; font-size: 13.5px; line-height: 1.55; color: var(--fg-2); }
+
+/* ── Artist box (#699) ──
+ The About dialog's frame, left-aligned and wider, because it holds prose
+ and lists rather than a logo and two buttons. The body scrolls on its own
+ so the search field stays put above a long history. */
+.artist-card {
+ width: min(640px, calc(100vw - 32px));
+ max-height: calc(100vh - 64px);
+ padding: 22px 22px 18px;
+ text-align: left;
+ align-items: stretch;
+}
+/* Room on the right for the close button, which sits in the same corner. */
+.artist-search { display: flex; gap: 8px; margin-right: 34px; }
+.artist-search input {
+ flex: 1; min-width: 0; height: 34px; padding: 0 10px;
+ background: var(--bg-2); border: 1px solid var(--border-strong); border-radius: 8px;
+ color: var(--fg); font: inherit; font-size: 13px;
+}
+.artist-search input:focus { outline: none; border-color: var(--accent); }
+.artist-search-btn {
+ height: 34px; padding: 0 14px; border-radius: 8px;
+ background: var(--panel); border: 1px solid var(--border);
+ color: var(--fg-2); font-weight: 600; cursor: pointer;
+}
+.artist-search-btn:hover { background: var(--accent); border-color: var(--accent); color: #000; }
+.artist-privacy { margin: 6px 0 14px; font-size: 11px; color: var(--muted); }
+/* A band already known opens without the search row; the privacy line then
+ sits in the close button's corner and takes over its room. */
+.artist-search.hidden { display: none; }
+.artist-search.hidden + .artist-privacy { margin: 2px 34px 14px 0; }
+.artist-other { margin: -6px 0 10px; font-size: 12px; }
+.artist-status + .artist-other { margin-top: -14px; text-align: center; }
+.artist-other-btn {
+ padding: 0; background: none; border: 0;
+ color: var(--accent); font: inherit; cursor: pointer;
+}
+.artist-other-btn:hover { text-decoration: underline; }
+.artist-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-right: 4px; }
+.artist-status { margin: 24px 0; text-align: center; font-size: 13px; color: var(--fg-2); }
+.artist-status.muted { color: var(--muted); }
+.artist-status.error { color: var(--danger); }
+
+.artist-head { display: flex; gap: 14px; align-items: center; margin-bottom: 14px; }
+.artist-photo {
+ width: 72px; height: 72px; flex-shrink: 0;
+ object-fit: cover; border-radius: 10px;
+ border: 1px solid var(--border); background: var(--panel);
+}
+/* Two classes deep so it outranks .about-card h2, which is the About box's
+ monospace wordmark and not a band name. */
+.artist-card .artist-name {
+ margin: 0; font-family: inherit; font-size: 22px; font-weight: 700; line-height: 1.2;
+}
+.artist-desc { margin: 4px 0 0; font-size: 12px; color: var(--muted); }
+.artist-titles { flex: 1 1 auto; min-width: 0; }
+/* Save for this track: the accent until it is done, then a quiet
+ label that says so. It sits by the name because the name is what is saved. */
+.artist-save {
+ flex-shrink: 0; align-self: center;
+ height: 30px; padding: 0 12px; border-radius: 8px;
+ background: var(--accent); border: 1px solid var(--accent);
+ color: #000; font-size: 12px; font-weight: 600; cursor: pointer;
+}
+.artist-save:hover { filter: brightness(1.08); }
+.artist-save.saved {
+ background: var(--panel); border-color: var(--border);
+ color: var(--muted); cursor: default; filter: none;
+}
+
+.artist-section { padding: 12px 0; border-top: 1px solid var(--border); }
+/* The EXTRACT label's type, as the card's NOW PLAYING legend uses. */
+.artist-section-title {
+ margin: 0 0 8px; font-size: 10px; font-weight: 600;
+ letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted);
+}
+.artist-history p { margin: 0 0 8px; font-size: 13px; line-height: 1.55; color: var(--fg-2); }
+.artist-names { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
+.artist-names li {
+ padding: 3px 10px; font-size: 12px;
+ background: var(--panel); border: 1px solid var(--border); border-radius: 999px;
+}
+.artist-subtitle { margin: 10px 0 6px; font-size: 11px; font-weight: 600; color: var(--muted); }
+/* Two columns: a band with twenty albums is a short box, not a long one. */
+.artist-albums { list-style: none; margin: 0; padding: 0; columns: 2; column-gap: 24px; }
+.artist-albums li { display: flex; gap: 10px; padding: 3px 0; font-size: 12.5px; break-inside: avoid; }
+.artist-album-year { min-width: 4ch; color: var(--muted); font-variant-numeric: tabular-nums; }
+.artist-foot {
+ display: flex; justify-content: space-between; align-items: center; gap: 12px;
+ padding-top: 12px; border-top: 1px solid var(--border);
+ font-size: 11px; color: var(--muted);
+}
+.artist-more { color: var(--accent); font-weight: 600; text-decoration: none; }
+.artist-more:hover { text-decoration: underline; }
+@media (max-width: 560px) { .artist-albums { columns: 1; } }
+
+/* The info button that opens the box: the heart's mirror image, a full-height
+ segment on the card's right edge, where the heart used to be. A button of
+ its own rather than the whole card being clickable, which competed with the
+ heart inside it and gave the keyboard nothing real to press. */
+#nowPlayingPanel .track-panel-head .daw-details-btn {
+ position: absolute;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ width: var(--np-fav);
+ margin: 0;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ border-left: 1px solid var(--border);
+ border-radius: 0 7px 7px 0;
+ background: none;
+ color: var(--muted);
+ cursor: pointer;
+}
+#nowPlayingPanel .track-panel-head .daw-details-btn:hover { background: var(--panel-3); color: var(--fg); }
+#nowPlayingPanel .track-panel-head .daw-details-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
+/* Nothing to look up before a track is open. */
+.app.no-track #nowPlayingPanel .daw-details-btn { opacity: 0.4; pointer-events: none; }
+/* The text stops short of the segment. */
+#nowPlayingPanel .track-panel-info,
+#nowPlayingPanel .daw-track-meta-line { padding-right: calc(var(--np-fav) + 10px); }
.lib-friends-grid {
display: grid;
grid-template-columns: 1fr 1px 1fr;
@@ -4279,8 +4480,11 @@ input, textarea { font-family: inherit; }
and are therefore exactly the same width. As a flex row the search grew to
whatever was left over, which is why it ran past the last chip.
- Columns: content | spacer | rule | detect | split. Row one is the import
- gesture, row two is what to extract. */
+ Columns: content | detect | split | rule | card. Row one is the import
+ gesture, row two is what to extract. Split sits straight after the options
+ it acts on, and the loaded track comes after the rule, at the end: in the
+ middle of the row it read as Split's input, when it is only what is
+ playing (#699). */
height: auto;
display: grid;
/* minmax(0, 1fr), not 1fr: a grid track's automatic minimum is its
@@ -4288,7 +4492,7 @@ input, textarea { font-family: inherit; }
it. The card sat at its natural width and pushed Split stems off the right
edge of its own container. Zero as the floor lets the column give way, and
the card's own text clips with an ellipsis rather than forcing the row. */
- grid-template-columns: minmax(0, max-content) minmax(0, 1fr) auto auto auto;
+ grid-template-columns: minmax(0, max-content) auto auto auto minmax(0, 1fr);
align-items: center;
row-gap: 8px;
column-gap: 10px;
@@ -4351,9 +4555,9 @@ input, textarea { font-family: inherit; }
min-width: 0;
}
-/* The rule stays, but it now separates the search from the two actions on the
- same line, which is what it does in the mockup. It used to divide the row
- from the Extract chips, which are on their own line now. */
+/* The rule divides the extraction, search through Split, from the track that
+ is playing (#699). Nothing crosses it: what is left of it is one sentence,
+ what is right of it is not an input to that sentence. */
/* One rule, down both rows.
*
* There were two: this one beside the actions, and the Collapse label's own
@@ -4362,7 +4566,7 @@ input, textarea { font-family: inherit; }
* that reads as broken without being nameable. Spanning the rows makes it one
* line that cannot drift from itself. */
.daw-composer > .daw-composer-sep {
- grid-column: 3;
+ grid-column: 4;
grid-row: 1 / 3;
display: block;
justify-self: center;
@@ -4379,7 +4583,7 @@ input, textarea { font-family: inherit; }
Grid because the switch sits left of the label and spans both the label and
the EXPERIMENTAL note under it. */
.daw-composer > .structure-toggle {
- grid-column: 4;
+ grid-column: 2;
grid-row: 1;
position: relative;
display: grid;
@@ -4439,7 +4643,7 @@ input, textarea { font-family: inherit; }
border-left: none;
}
.daw-composer > .daw-process-btn {
- grid-column: 5;
+ grid-column: 3;
grid-row: 1;
}
@@ -4460,19 +4664,19 @@ input, textarea { font-family: inherit; }
and it would need the strip and the footer inset to match, or something
given the freed space. */
-/* Row two, right-hand end: on the chips' line rather than below it.
+/* Row two, under the two actions: on the chips' line rather than below it.
- Columns 4 to 6, not 3: a spanning item's minimum lands in the first track it
- covers, so starting at the separator's column handed that 1px column the
- toggles' whole width and took it out of the card's. The two action columns
- are already as wide as Detect structure and Split stems, which is more than
- the toggles need, so spanning only those costs the row nothing.
+ Columns 2 to 4, the two action columns only, not the rule's: a spanning
+ item's minimum lands in the first track it covers, and the 1px rule column
+ would have been handed the toggles' whole width. The action columns are
+ already as wide as Detect structure and Split stems, which is more than the
+ toggles need, so spanning only those costs the row nothing.
A grid item rather than absolutely positioned: out of flow it sat on the
right line and would have landed on top of the Extract chips the moment a
window was narrow enough for the two to meet. */
.daw-composer > .daw-panel-toggles {
- grid-column: 4 / 6;
+ grid-column: 2 / 4;
grid-row: 2;
justify-self: end;
align-self: center;
@@ -4540,24 +4744,29 @@ input, textarea { font-family: inherit; }
because that timeline's left edge had to line up with the lane waveforms.
The timeline is gone, and this row had empty space in the middle of it, so
the block moved in the markup rather than being pinned here: it is a cell in
- the grid, column two, spanning both rows. That is the column the search and
- the chips do not use, between them and the actions on the right. */
+ the grid, spanning both rows. It was column two, between the chips and the
+ actions, where the row read as "file, options, this track, Split" and made
+ the track look like what Split would act on (#699). Now it is the last
+ column, after the rule, and labelled as what is playing. */
#nowPlayingPanel {
position: relative;
- grid-column: 2;
+ grid-column: 5;
grid-row: 1 / 3;
- /* As wide as the track it is naming, not as wide as the window.
- It was stretched to fill its column, so a spare 400px of bar became 400px
- of card with the same text sitting in it. It sizes to its content now and
- stops; a long title clips with an ellipsis at the cap rather than dragging
- the card out to meet it. */
- justify-self: start;
+ /* Fills its column up to the cap below. It once sized to its content, so a
+ spare 400px of bar would not become 400px of card; the cap does that job
+ now, and filling the column is what lets the card be a size container for
+ the artwork's container query (see .track-panel-cover below). A long
+ title clips with an ellipsis at the cap rather than dragging the card out
+ to meet it. */
+ justify-self: stretch;
/* A real ceiling, not a percentage of the column: 100% just meant "as wide
as the bar allows", so the card was 422px at 1600 and 460 at 1920 with the
- same text in it. 460 is what the meta line needs at its longest, duration
- through to the extracted date, so the card is the same size at every
- window width and a long title clips rather than pushing it wider. */
- max-width: min(100%, 460px);
+ same text in it. 460 was what the meta line needed at its longest,
+ duration through to the extracted date; 536 is that plus the 76px the
+ full-height artwork now takes from the text column (#699). So the card is
+ the same size at every window width and a long title clips rather than
+ pushing it wider. */
+ max-width: min(100%, 536px);
align-self: stretch;
/* .footer-track-slot is `width: var(--daw-col-w)`, which is the gutter width
it was built to fill in the footer. Here the column decides the width, so
@@ -4565,7 +4774,11 @@ input, textarea { font-family: inherit; }
out of the composer on a narrow window. */
width: auto;
min-width: 0;
- margin: 0 14px;
+ /* No side margins. In the middle of the row it needed air on both sides;
+ at the end, the rule and the grid gap already set it off on the left and
+ the bar's padding on the right, and 28px of margin was 28px taken from
+ the meta line, enough to clip the date in German at 1600 (#698). */
+ margin: 0;
display: flex;
flex-direction: column;
justify-content: center;
@@ -4578,6 +4791,39 @@ input, textarea { font-family: inherit; }
border-radius: 8px;
}
+/* "Now playing", set into the card's top border like a fieldset legend.
+ On the border rather than as a line of its own: the card's height is already
+ spent on the title and a two-row meta line (#698). The background is the
+ bar's, so the label cuts the border instead of sitting on top of it. The type
+ is the EXTRACT label's, so the two labels on this row read as a pair. */
+#nowPlayingPanel .np-legend {
+ position: absolute;
+ top: 0;
+ /* Over the text column, not the artwork: on the art it would put a band of
+ bar colour across the top of the picture. */
+ left: calc(var(--np-fav) + var(--np-art) + 8px);
+ transform: translateY(-50%);
+ padding: 0 5px;
+ background: var(--panel);
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.12em;
+ line-height: 1;
+ text-transform: uppercase;
+ color: var(--muted);
+ white-space: nowrap;
+ pointer-events: none;
+}
+/* .footer-track-slot clips the card, a leftover from the footer gutter it was
+ built for, and that clip cut the legend's top half off. The lines inside
+ already clip themselves (title ellipsis, meta line overflow), so this keeps
+ the same clip on the sides and bottom and opens only the 8px above, which
+ is the composer's own padding and where the legend sits. */
+#nowPlayingPanel {
+ overflow: visible;
+ clip-path: inset(-8px 0 0 0);
+}
+
/* The footer clamps the title to two lines, because its gutter is narrow and
a long name would otherwise be cut after three or four words. This card is
wide and short, so two lines is what makes it look cramped: the title grows
@@ -4606,6 +4852,33 @@ input, textarea { font-family: inherit; }
justify-content: flex-start;
}
#nowPlayingPanel .track-panel-info { flex: 1 1 auto; min-width: 0; }
+/* The artist (#699), after the title on the title's own line, in the meta
+ line's quieter voice. A line of its own would make the card taller than the
+ bar, and in the meta line it would push the extracted date off the end.
+ The artist keeps its whole width up to 45% of the line, clipping past that,
+ and the title takes the rest, clipping as it always has. Same size and line
+ height as the title, so sharing a baseline adds nothing to the line. */
+#nowPlayingPanel .track-panel-info {
+ flex-direction: row;
+ align-items: baseline;
+ gap: 0;
+}
+#nowPlayingPanel .track-panel-title { flex: 0 1 auto; min-width: 0; }
+#nowPlayingPanel .np-artist {
+ flex: 0 0 auto;
+ max-width: 45%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 1.25;
+ color: var(--muted);
+}
+#nowPlayingPanel .np-artist::before {
+ content: "\00b7";
+ padding: 0 6px;
+}
/* A flex item's automatic minimum is its content, all the way down, so one
missing min-width anywhere in this chain gives the card a floor it will not
go below and the row overflows instead. The title and the meta line both
@@ -4623,19 +4896,23 @@ input, textarea { font-family: inherit; }
* So it gets a segment of its own, full height, separated by the same hairline
* the composer uses between its clusters. The icon then has a place rather than
* a position, and the card reads as two parts: what the track is, and the one
- * thing you can do to it from here. */
+ * thing you can do to it from here.
+ *
+ * On the left edge, before the artwork (#699): heart, picture, then the words.
+ * On the right it sat at the far end of the bar, away from the track it acts
+ * on, and the text had to stop 56px short to leave room for it. */
#nowPlayingPanel .track-panel-head .daw-fav-btn {
position: absolute;
- right: 0;
+ left: 0;
top: 0;
bottom: 0;
transform: none;
margin: 0;
- width: 46px;
+ width: var(--np-fav);
height: auto;
border: none;
- border-left: 1px solid var(--border);
- border-radius: 0 8px 8px 0;
+ border-right: 1px solid var(--border);
+ border-radius: 7px 0 0 7px;
background: none;
color: var(--muted);
}
@@ -4647,9 +4924,62 @@ input, textarea { font-family: inherit; }
width: 17px;
height: 17px;
}
-/* The text stops where the segment starts. */
-#nowPlayingPanel .track-panel-info,
-#nowPlayingPanel .daw-track-meta-line { padding-right: 56px; }
+/* The artwork, as a segment like the heart's and right after it: full height,
+ with a hairline beside it, rather than a 40px square squeezed between
+ the label and the meta line. It was the smallest thing in the card while
+ being the quickest way to tell which song is open (#699).
+
+ 76px is the card's inside height (78 less its borders), so the picture is
+ square and edge to edge. Title and meta line both start after it, which
+ also puts them on one left edge. That costs the text 76px, so the card's
+ width cap went up by the same amount; the bar has room to the right.
+
+ Only while the card is wide enough to pay for it. A narrow card, which is a
+ long language in a window just above the breakpoint that hides the card,
+ needs those 76px for the extracted date (#698), so below 380px the art goes
+ back inline at 40px. A container query, not a media query: how wide the
+ card is depends on the language as much as on the window.
+
+ Container queries style descendants only, which is why the room for the art
+ is padding on the two text rows rather than on the card itself. And a size
+ container ignores its content when sizing, so the card fills its column (up
+ to the cap) instead of shrinking to fit its text. */
+#nowPlayingPanel {
+ --np-fav: 34px;
+ --np-art: 76px;
+ container-type: inline-size;
+}
+#nowPlayingPanel .track-panel-cover {
+ position: absolute;
+ left: var(--np-fav);
+ top: 0;
+ bottom: 0;
+ width: var(--np-art) !important;
+ height: auto !important;
+ border-radius: 0;
+ border-right: 1px solid var(--border);
+}
+#nowPlayingPanel .track-panel-head,
+#nowPlayingPanel .daw-track-meta-line { padding-left: calc(var(--np-fav) + var(--np-art)); }
+@container (max-width: 379px) {
+ #nowPlayingPanel .track-panel-cover {
+ /* relative, not static: the thumbnail inside is absolutely positioned,
+ and against a static cover it would fill the whole card. */
+ position: relative;
+ top: auto;
+ bottom: auto;
+ left: auto;
+ width: 40px !important;
+ height: 40px !important;
+ border-radius: 7px;
+ border-right: none;
+ }
+ #nowPlayingPanel .track-panel-head,
+ #nowPlayingPanel .daw-track-meta-line { padding-left: var(--np-fav); }
+ /* Over the title, clear of the heart and the 40px art:
+ heart + card padding 14 + art 40 + 6. */
+ #nowPlayingPanel .np-legend { left: calc(var(--np-fav) + 60px); }
+}
/* Source and quality keep their footer colours, which up here is four accents
in one short line. Muted to one voice; the stems dot keeps its colour,
@@ -4675,7 +5005,10 @@ input, textarea { font-family: inherit; }
flex-wrap: wrap;
row-gap: 0;
line-height: 1.35;
- max-height: 2.7em;
+ /* Two rows at 1.35, plus 2px: at exactly 2.7em the second row's line box
+ rounded a pixel past the cap and was measured as clipped. A third row
+ would need another 1.35em, so the headroom never lets one through. */
+ max-height: calc(2.7em + 2px);
white-space: nowrap;
overflow: hidden;
}
@@ -4782,6 +5115,9 @@ input, textarea { font-family: inherit; }
*/
@media (max-width: 1460px) {
.daw-composer > #nowPlayingPanel { display: none; }
+ /* The rule only divides the extraction from the card. Without the card it
+ would divide the row from nothing. */
+ .daw-composer > .daw-composer-sep { display: none; }
}
/* The click-track options with nothing to act on.
diff --git a/static/index.html b/static/index.html
index 44608e44..53da19ee 100644
--- a/static/index.html
+++ b/static/index.html
@@ -170,6 +170,9 @@
It used to fill the gutter beside the footer timeline. That
timeline is gone, and this row had empty space in the middle. -->
+
+
+
+
+
+
Only an artist's name is sent to Wikipedia and Wikidata: the one you search for here, and the artist in a track's tags when you open the track.
+
+
+
+
diff --git a/static/js/artistInfo.js b/static/js/artistInfo.js
new file mode 100644
index 00000000..a44e76bc
--- /dev/null
+++ b/static/js/artistInfo.js
@@ -0,0 +1,406 @@
+// The artist box: the info button on the now-playing card opens it with the
+// band's history, members and studio albums, from Wikidata and Wikipedia
+// (#699), and "Save for this track" keeps the band on the track so the box
+// opens straight on it next time.
+//
+// The lookup itself is artistLookup.js. This is the box: where the name comes
+// from, what is shown while it loads or when it fails, and the markup. And the
+// one lookup made without it, which saves the band from a file's artist tag
+// when a track is opened (see "The band from the file's tags" below).
+//
+// Everything that came from the network goes in with textContent, never as
+// HTML. Wikipedia text is written by anyone, and this page can reach the
+// desktop app's native commands.
+
+import { t, getLanguage, onLanguageChange } from "./i18n.js";
+import { artistMatchesName, lookupArtist, taggedArtistName } from "./artistLookup.js";
+import {
+ getCurrentTrackArtist,
+ getCurrentTrackInfo,
+ isCurrentTrackTagsPending,
+ setCurrentTrackArtist,
+} from "./catalog.js";
+
+// One answer per language and name, for the life of the page. A band's
+// members do not change between two clicks, and asking again costs the user a
+// second of waiting and Wikimedia a request for nothing. Failures are not
+// kept, so a retry after a dropped connection really does retry.
+const answers = new Map();
+
+let dialog = null;
+let body = null;
+let input = null;
+let form = null;
+let closeButton = null;
+let current = null; // AbortController of the lookup in flight
+let lastQuery = "";
+let lastId = ""; // the saved band's Wikidata id, when the box opened on one
+let returnFocus = null;
+let searchShown = true;
+let waiting = false; // open on a track whose tags or band are still being found
+
+const isOpen = () => dialog && !dialog.classList.contains("hidden");
+
+// The search row is for when there is no band to show: nothing is known about
+// the track, the name found nothing, or "Not this band? Search" asked for it.
+// A band the track is known by opens straight on its details, since a field
+// holding the answer reads as a question.
+function showSearch(on, { focus = false } = {}) {
+ searchShown = on;
+ form?.classList.toggle("hidden", !on);
+ if (on) body.querySelector(".artist-other")?.remove();
+ if (on && focus) {
+ input.focus();
+ input.select();
+ }
+}
+
+function el(tag, className, text) {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text != null) node.textContent = text;
+ return node;
+}
+
+function showStatus(message, kind = "") {
+ body.replaceChildren(el("p", `artist-status${kind ? ` ${kind}` : ""}`, message));
+}
+
+function section(titleKey, ...children) {
+ const box = el("section", "artist-section");
+ box.append(el("h3", "artist-section-title", t(titleKey)), ...children);
+ return box;
+}
+
+function nameList(names) {
+ const list = el("ul", "artist-names");
+ for (const name of names) list.append(el("li", "", name));
+ return list;
+}
+
+// "Save for this track", or "Saved for this track" once it is. Kept as the
+// Wikidata id plus the name, so reopening asks for exactly this band in any
+// language, and a different search on the same track can replace it.
+function saveButton(artist) {
+ const button = el("button", "artist-save");
+ button.type = "button";
+ const paint = () => {
+ const saved = getCurrentTrackArtist()?.id === artist.id;
+ button.textContent = t(saved ? "artist.savedForTrack" : "artist.setForTrack");
+ button.disabled = saved;
+ button.classList.toggle("saved", saved);
+ };
+ button.addEventListener("click", () => {
+ if (setCurrentTrackArtist({ id: artist.id, name: artist.name, englishName: artist.englishName })) paint();
+ });
+ paint();
+ return button;
+}
+
+function render(artist) {
+ const head = el("header", "artist-head");
+ if (artist.image) {
+ const img = el("img", "artist-photo");
+ img.alt = "";
+ img.loading = "lazy";
+ img.referrerPolicy = "no-referrer";
+ // A photo that fails to load leaves no broken-image box behind.
+ img.addEventListener("error", () => img.remove(), { once: true });
+ img.src = artist.image;
+ head.append(img);
+ }
+ const titles = el("div", "artist-titles");
+ titles.append(el("h2", "artist-name", artist.name));
+ if (artist.description) titles.append(el("p", "artist-desc", artist.description));
+ head.append(titles, saveButton(artist));
+
+ const parts = [head];
+
+ // Under the name, where a wrong band is noticed: the way back to the field.
+ if (!searchShown) {
+ const other = el("p", "artist-other");
+ const link = el("button", "artist-other-btn", t("artist.notThisBand"));
+ link.type = "button";
+ link.addEventListener("click", () => showSearch(true, { focus: true }));
+ other.append(link);
+ parts.push(other);
+ }
+
+ if (artist.history.length) {
+ const prose = el("div", "artist-history");
+ for (const paragraph of artist.history) prose.append(el("p", "", paragraph));
+ parts.push(section("artist.history", prose));
+ }
+
+ const { current: now, former } = artist.members;
+ if (now.length || former.length) {
+ const children = [];
+ if (now.length) children.push(nameList(now));
+ if (former.length) {
+ children.push(el("h4", "artist-subtitle", t("artist.formerMembers")), nameList(former));
+ }
+ parts.push(section("artist.members", ...children));
+ }
+
+ if (artist.albums.length) {
+ const list = el("ol", "artist-albums");
+ for (const album of artist.albums) {
+ const row = el("li");
+ row.append(el("span", "artist-album-year num", album.year), el("span", "artist-album-title", album.title));
+ list.append(row);
+ }
+ parts.push(section("artist.albums", list));
+ }
+
+ const foot = el("footer", "artist-foot");
+ if (artist.articleUrl) {
+ // target="_blank" is all it takes: main.js routes these through the
+ // desktop app's open_url, since the webview will not open one itself.
+ const link = el("a", "artist-more", t("artist.readMore"));
+ link.href = artist.articleUrl;
+ link.target = "_blank";
+ link.rel = "noopener noreferrer";
+ foot.append(link);
+ }
+ foot.append(el("span", "artist-source", t("artist.source")));
+ parts.push(foot);
+
+ body.replaceChildren(...parts);
+ body.scrollTop = 0;
+}
+
+async function search(name, { id = "" } = {}) {
+ const query = String(name || "").trim();
+ lastQuery = query;
+ lastId = id;
+ current?.abort();
+ current = null;
+ if (!query && !id) {
+ // Nothing asked yet: the field's placeholder already says what to do.
+ body.replaceChildren();
+ return;
+ }
+
+ const lang = getLanguage();
+ const key = id ? `${lang}|#${id}` : `${lang}|${query.toLowerCase()}`;
+ // Nothing to show means the field is the next step, filled with the name
+ // that found nothing so it can be corrected or tried again.
+ const notFound = () => {
+ showSearch(true);
+ showStatus(t("artist.notFound", { name: query }), "muted");
+ };
+ if (answers.has(key)) {
+ const cached = answers.get(key);
+ if (cached) render(cached);
+ else notFound();
+ return;
+ }
+
+ const controller = new AbortController();
+ current = controller;
+ showStatus(t("artist.loading", { name: query }), "loading");
+ try {
+ const artist = await lookupArtist(query, lang, { id, signal: controller.signal });
+ if (controller.signal.aborted) return;
+ answers.set(key, artist);
+ // Also by id, so a band found by name and then saved opens from the cache.
+ if (artist) answers.set(`${lang}|#${artist.id}`, artist);
+ if (artist) render(artist);
+ else notFound();
+ } catch (err) {
+ // A newer search or closing the box aborts this one; that is not a fault.
+ if (controller.signal.aborted && controller.signal.reason?.message !== "timeout") return;
+ console.warn("artist lookup failed", err);
+ showSearch(true);
+ showStatus(t("artist.offline"), "error");
+ } finally {
+ if (current === controller) current = null;
+ }
+}
+
+// Opens on the band saved on the track if there is one, looked up by its id;
+// then on the artist the file was tagged with, which is the file's own word
+// rather than a guess; otherwise on an empty field. It used to guess from the
+// title, the part before " - ", but titles name the song as often as the band
+// ("Metropolis - Part I" guessed "Metropolis"), and a wrong answer shown with
+// confidence is worse than a question.
+//
+// While the track's tags are still being read, or the band is being found
+// from them, it says so and waits: an empty field there would ask for what is
+// about to arrive. showFromTrack() runs again when the answer lands.
+function showFromTrack({ focus = true } = {}) {
+ const info = getCurrentTrackInfo();
+ const saved = getCurrentTrackArtist();
+ waiting = !saved && Boolean(isCurrentTrackTagsPending() || (autoController && autoTrackId === info?.id));
+ if (waiting) {
+ current?.abort();
+ current = null;
+ lastQuery = "";
+ lastId = "";
+ input.value = "";
+ showSearch(false);
+ showStatus(t("artist.checkingTrack"), "loading");
+ // Reading a video's tags can take the server most of a minute, so the
+ // field stays one click away rather than out of reach until then.
+ const other = el("p", "artist-other");
+ const link = el("button", "artist-other-btn", t("artist.searchInstead"));
+ link.type = "button";
+ link.addEventListener("click", () => {
+ waiting = false;
+ body.replaceChildren();
+ showSearch(true, { focus: true });
+ });
+ other.append(link);
+ body.append(other);
+ if (focus) closeButton?.focus();
+ return;
+ }
+ const name = saved?.name || taggedArtistName(info?.audioTags?.artist);
+ input.value = name;
+ showSearch(!name);
+ // With a name, the answer is what they came for, so focus stays off the
+ // field and Escape closes. Without one, the field is the next step.
+ if (focus) (name ? closeButton : input)?.focus();
+ search(name, { id: saved?.id || "" });
+}
+
+function open() {
+ returnFocus = document.activeElement;
+ dialog.classList.remove("hidden");
+ showFromTrack();
+}
+
+// The wait is over. Focus moves only if it is still where the wait put it.
+function settle() {
+ if (!isOpen() || !waiting) return;
+ const active = document.activeElement;
+ showFromTrack({ focus: active === closeButton || !dialog.contains(active) });
+}
+
+function close() {
+ current?.abort();
+ current = null;
+ waiting = false;
+ dialog.classList.add("hidden");
+ returnFocus?.focus?.();
+ returnFocus = null;
+}
+
+// ─── The band from the file's tags ───
+//
+// A track whose file was tagged with an artist gets its band saved without a
+// click: looked up once when the track is opened, and kept only when the answer
+// carries that same name. The box, the Lyrics tab and the now-playing card then
+// all have it.
+//
+// Once per track per page load. Nothing found, a different name or no
+// connection all leave the track as it was, and the next session asks again.
+// The wait first means clicking down the library asks for nothing until a
+// track is settled on, and the request never competes with that track's load.
+//
+// A track from before tags were read gets them from catalog.js a moment after
+// it opens, announced as "tracktags", and the lookup runs then instead.
+const AUTO_DELAY_MS = 800;
+const autoTried = new Set();
+let autoTimer = 0;
+let autoController = null;
+let autoTrackId = null; // the track autoController is finding the band for
+
+function findBandFromTags(trackId) {
+ clearTimeout(autoTimer);
+ autoController?.abort();
+ autoController = null;
+ autoTimer = setTimeout(() => runFindBandFromTags(trackId), AUTO_DELAY_MS);
+}
+
+async function runFindBandFromTags(trackId) {
+ const info = getCurrentTrackInfo();
+ // The box open means the user is choosing; what they pick is the answer.
+ // Unless it is open waiting for exactly this.
+ if (info?.id !== trackId || autoTried.has(trackId) || getCurrentTrackArtist() || (isOpen() && !waiting)) return;
+ const name = taggedArtistName(info.audioTags?.artist);
+ if (!name) return;
+ autoTried.add(trackId);
+ const controller = new AbortController();
+ autoController = controller;
+ autoTrackId = trackId;
+ const lang = getLanguage();
+ const key = `${lang}|${name.toLowerCase()}`;
+ try {
+ // The box may have asked for this name already.
+ const artist = answers.has(key) ? answers.get(key) : await lookupArtist(name, lang, { signal: controller.signal });
+ // Kept for the box either way, under the keys search() looks for.
+ answers.set(key, artist);
+ if (artist) answers.set(`${lang}|#${artist.id}`, artist);
+ if (!artist || !artistMatchesName(artist, name)) return;
+ // Still the open track, and nobody saved a band on it in the meantime.
+ if (getCurrentTrackInfo()?.id !== trackId || getCurrentTrackArtist()) return;
+ setCurrentTrackArtist({ id: artist.id, name: artist.name, englishName: artist.englishName });
+ } catch (err) {
+ // Aborted because another track was opened: not an attempt, so opening
+ // this one again tries again.
+ if (controller.signal.aborted) {
+ autoTried.delete(trackId);
+ return;
+ }
+ console.warn("artist lookup from tags failed", err);
+ } finally {
+ if (autoController === controller) autoController = null;
+ // The box, if it was waiting on this, now shows what was found.
+ settle();
+ }
+}
+
+// An older track's tags have been read (or found missing). For the open
+// track, the band is looked up from them straight away: it was settled on long
+// enough ago for the server to answer, so there is nothing left to wait for.
+function onTrackTags(trackId) {
+ if (!trackId || getCurrentTrackInfo()?.id !== trackId) return;
+ clearTimeout(autoTimer);
+ runFindBandFromTags(trackId);
+ settle();
+}
+
+export function initArtistInfo() {
+ dialog = document.getElementById("artistDialog");
+ body = document.getElementById("artistBody");
+ input = document.getElementById("artistQuery");
+ form = document.getElementById("artistSearch");
+ closeButton = document.getElementById("artistClose");
+ const button = document.getElementById("np-details-btn");
+ if (!dialog || !body || !input || !button) return;
+
+ button.addEventListener("click", () => {
+ // Nothing to look up before a track is open: the card then says "Ready
+ // to import a track", which is not a band. CSS mutes the button too.
+ if (document.querySelector(".app")?.classList.contains("no-track")) return;
+ open();
+ });
+
+ document.getElementById("artistSearch")?.addEventListener("submit", (e) => {
+ e.preventDefault();
+ // What was typed, by name: the saved band's id only applies to the name
+ // the box opened with.
+ search(input.value);
+ });
+ document.getElementById("artistClose")?.addEventListener("click", close);
+ dialog.addEventListener("mousedown", (e) => { if (e.target === dialog) close(); });
+ dialog.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });
+
+ // Section titles and messages are written by this module, not by
+ // applyTranslations, so a language change while the box is open asks again
+ // in the new language, which also brings the article in that language.
+ onLanguageChange(() => {
+ if (!isOpen()) return;
+ if (waiting) showFromTrack({ focus: false });
+ else search(lastQuery, { id: lastId });
+ });
+
+ // catalog.js says when a track has been opened, after its load is under way,
+ // and when an older track's tags have been read.
+ document.addEventListener("trackopen", (e) => {
+ findBandFromTags(e.detail?.id ?? null);
+ settle();
+ });
+ document.addEventListener("tracktags", (e) => onTrackTags(e.detail?.id ?? null));
+}
diff --git a/static/js/artistLookup.js b/static/js/artistLookup.js
new file mode 100644
index 00000000..21796650
--- /dev/null
+++ b/static/js/artistLookup.js
@@ -0,0 +1,358 @@
+// Who made the song that is open: history, members and studio albums, looked
+// up on Wikidata and Wikipedia for the box the now-playing card opens (#699).
+//
+// A title often leaves the band out ("Metropolis - Part I" has no "Dream
+// Theater" in it), so the name comes from the file's artist tag when it had
+// one, and is typed in the box otherwise. A band found either way can be saved
+// on the track. This module only takes a name, or a saved band's id, and looks
+// it up.
+//
+// It runs when the box is opened, and once in the background when a track
+// whose file carries an artist tag is opened with no band saved on it (see
+// artistInfo.js). What is sent is that one name, the search field's or the
+// tag's, nothing else about the track.
+//
+// Wikidata rather than Wikipedia's own sections for members and albums:
+// section titles and list layouts differ per article and per language, and
+// parsing them is a guess. Wikidata has them as data. Wikipedia supplies only
+// the prose, the History section, which Wikidata does not have.
+//
+// No DOM here, and fetch is passed in, so all of it runs under node for the
+// unit tests (tests/js/artist-lookup.test.mjs).
+
+const WIKIDATA_API = "https://www.wikidata.org/w/api.php";
+const WIKIDATA_SPARQL = "https://query.wikidata.org/sparql";
+
+// Having a MusicBrainz artist ID is what separates the band from the album
+// named after it, a cinema that shares its name, or a disambiguation page. A
+// search for "Dream Theater" returns all of those, band first only sometimes.
+const MUSICBRAINZ_ARTIST_ID = "P434";
+const HAS_PART = "P527";
+const END_TIME = "P582";
+const IMAGE = "P18";
+
+// Studio albums only. Everything credited to a band includes demos, bootlegs
+// and video releases: over sixty rows for Dream Theater, and the query took ten
+// seconds. Studio albums are modelled two ways on Wikidata, as their own class
+// or as an album whose form is "studio album", so the query takes both.
+const STUDIO_ALBUM = "Q208569";
+const ALBUM = "Q482994";
+// A deluxe or remastered edition is its own item, typed as a studio album.
+// It is marked either by pointing back at the original ("edition or
+// translation of") or by naming its edition ("edition/version"); Adele's "19 -
+// deluxe edition" only has the second. Either one leaves it out.
+const EDITION_OF = "P629";
+const EDITION_NAME = "P9767";
+
+// How long an answer may take before the box gives up and says so.
+const TIMEOUT_MS = 12000;
+
+// Caps, so a long article or a large collective cannot make the box a wall.
+const HISTORY_MAX_CHARS = 1400;
+const MEMBERS_MAX = 40;
+
+// The app's language codes to Wikipedia editions. zh-Hans reads the Chinese
+// Wikipedia, which serves simplified script to a zh-Hans reader through its
+// own variant handling; pt-PT reads the one Portuguese Wikipedia.
+const WIKI_LANG = {
+ "en": "en", "pl": "pl", "ja": "ja", "ko": "ko", "zh-Hans": "zh",
+ "de": "de", "fr": "fr", "es": "es", "pt": "pt", "pt-PT": "pt", "id": "id",
+};
+
+// The level-two heading that holds a band's history, per edition, lower case
+// and matched as a prefix. English is always tried as well, because an
+// article the chosen language does not have is read from the English edition.
+const HISTORY_HEADINGS = {
+ en: ["history", "biography", "career"],
+ pl: ["historia", "biografia", "kariera"],
+ ja: ["略歴", "経歴", "来歴", "歴史", "バイオグラフィ"],
+ ko: ["역사", "연혁", "경력", "활동"],
+ zh: ["历史", "歷史", "经历", "經歷", "简历", "簡歷", "樂團歷史", "乐队历史"],
+ de: ["geschichte", "bandgeschichte", "biografie", "biographie", "werdegang"],
+ fr: ["historique", "histoire", "biographie", "carrière"],
+ es: ["historia", "biografía", "trayectoria"],
+ pt: ["história", "biografia", "carreira"],
+ id: ["sejarah", "biografi", "karier"],
+};
+
+/** The Wikipedia edition for an app language code. */
+export function wikiLanguage(appLang) {
+ return WIKI_LANG[appLang] || "en";
+}
+
+/**
+ * The first search result that is an artist, in the order the search ranked
+ * them, or null. `entities` is wbgetentities' `entities` object.
+ */
+export function pickArtist(entities, rankedIds) {
+ for (const id of rankedIds) {
+ const entity = entities?.[id];
+ if (entity?.claims?.[MUSICBRAINZ_ARTIST_ID]?.length) return entity;
+ }
+ return null;
+}
+
+/**
+ * Current and former members from a band's "has part" statements.
+ *
+ * A member who left and came back has two statements, one with an end date
+ * and one without, so anyone with an open statement is current and is not
+ * also listed as former. A solo artist has no such statements and gets two
+ * empty lists, which the box shows as no Members section at all.
+ */
+export function membersFromClaims(claims) {
+ const current = new Set();
+ const former = new Set();
+ for (const statement of claims?.[HAS_PART] || []) {
+ if (statement.rank === "deprecated") continue;
+ const id = statement.mainsnak?.datavalue?.value?.id;
+ if (!id) continue;
+ if (statement.qualifiers?.[END_TIME]?.length) former.add(id);
+ else current.add(id);
+ }
+ for (const id of current) former.delete(id);
+ return {
+ current: [...current].slice(0, MEMBERS_MAX),
+ former: [...former].slice(0, MEMBERS_MAX),
+ };
+}
+
+/** Studio albums from the SPARQL answer, oldest first, one row per album. */
+export function albumsFromSparql(json) {
+ const seen = new Set();
+ const albums = [];
+ for (const row of json?.results?.bindings || []) {
+ const id = row.album?.value;
+ const title = row.albumLabel?.value;
+ // The label service falls back to the bare Q-number when an album has no
+ // label in any language asked for. That is not a title.
+ if (!id || !title || /^Q\d+$/.test(title) || seen.has(id)) continue;
+ seen.add(id);
+ const year = /^(\d{4})/.exec(row.released?.value || "")?.[1] || "";
+ albums.push({ title, year });
+ }
+ // SPARQL sorted already; sorted again so a caller never depends on it, and
+ // an album with no date goes last rather than first.
+ return albums.sort((a, b) => (a.year || "9999").localeCompare(b.year || "9999"));
+}
+
+/**
+ * The History section of a plain-text article extract, as paragraphs, capped
+ * at about HISTORY_MAX_CHARS and cut at a sentence. Falls back to the text
+ * before the first heading, the article's own summary, when no heading in
+ * `headings` is found. Sub-headings inside the section are dropped: the box
+ * shows prose, not the article's outline.
+ */
+export function historyFromExtract(extract, headings) {
+ const text = String(extract || "");
+ const lines = text.split("\n");
+ const isLevel2 = (line) => /^==[^=].*[^=]==\s*$/.test(line.trim());
+ const headingText = (line) => line.trim().replace(/^=+\s*|\s*=+$/g, "").toLowerCase();
+
+ let start = lines.findIndex((line) =>
+ isLevel2(line) && headings.some((h) => headingText(line).startsWith(h)));
+ let body;
+ if (start >= 0) {
+ const rest = lines.slice(start + 1);
+ const end = rest.findIndex(isLevel2);
+ body = end >= 0 ? rest.slice(0, end) : rest;
+ } else {
+ const first = lines.findIndex((line) => /^=+.*=+\s*$/.test(line.trim()));
+ body = first >= 0 ? lines.slice(0, first) : lines;
+ }
+
+ const paragraphs = [];
+ let length = 0;
+ for (const raw of body) {
+ const line = raw.trim();
+ if (!line || /^=+.*=+$/.test(line)) continue;
+ if (length + line.length <= HISTORY_MAX_CHARS) {
+ paragraphs.push(line);
+ length += line.length;
+ continue;
+ }
+ // The paragraph that crosses the cap is cut at its last full sentence
+ // inside it, or left out if not even one sentence fits.
+ const room = HISTORY_MAX_CHARS - length;
+ const cut = line.slice(0, room);
+ const stop = Math.max(cut.lastIndexOf(". "), cut.lastIndexOf("。"), cut.lastIndexOf("! "), cut.lastIndexOf("? "));
+ if (stop > 40) paragraphs.push(`${cut.slice(0, stop + 1)} \u2026`);
+ break;
+ }
+ return paragraphs;
+}
+
+/** Headings to try for an edition: its own, then the English ones. */
+export function historyHeadingsFor(wikiLang) {
+ return [...new Set([...(HISTORY_HEADINGS[wikiLang] || []), ...HISTORY_HEADINGS.en])];
+}
+
+// Latin accents, as the combining marks NFKD leaves them as, so a file tagged
+// "Beyonce" still matches "Beyoncé". Only this block: the marks in a Japanese
+// or Korean name are part of it, and folding them could make two names one.
+const LATIN_ACCENTS = new RegExp(`[${String.fromCodePoint(0x300)}-${String.fromCodePoint(0x36f)}]`, "gu");
+
+/** A name reduced to what tells it apart: no case, accents, punctuation or spaces. */
+export function artistNameKey(name) {
+ return String(name || "")
+ .normalize("NFKD")
+ .replace(LATIN_ACCENTS, "")
+ .toLowerCase()
+ .replace(/[\p{P}\p{S}\s]+/gu, "");
+}
+
+/**
+ * The name to look up from a file's artist tag: the tag, less any featured
+ * artist ("Artist feat. Someone", "Artist (ft. Someone)"), since no band is
+ * called that.
+ */
+export function taggedArtistName(tag) {
+ return String(tag || "")
+ .split(/\s+[([]?(?:feat\.?|ft\.?|featuring)\s+/i)[0]
+ .trim();
+}
+
+/**
+ * Whether a looked-up artist is plausibly the one a file was tagged with: its
+ * name in the app's language, or in English, is the tag's name once case,
+ * accents and punctuation are set aside.
+ *
+ * Wikidata's search forgives spelling and matches aliases and parts of names,
+ * so a near miss still finds some band. That is right in the box, where the
+ * answer is on screen to be judged before anyone saves it. Saved on a track
+ * with nobody looking, the name has to agree.
+ */
+export function artistMatchesName(artist, name) {
+ const want = artistNameKey(name);
+ if (!want || !artist) return false;
+ return [artist.name, artist.englishName].some((candidate) => artistNameKey(candidate) === want);
+}
+
+function withTimeout(signal) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(new Error("timeout")), TIMEOUT_MS);
+ signal?.addEventListener("abort", () => controller.abort(signal.reason), { once: true });
+ return { signal: controller.signal, done: () => clearTimeout(timer) };
+}
+
+async function defaultFetchJson(url, signal) {
+ const res = await fetch(url, { signal, headers: { Accept: "application/json" } });
+ if (!res.ok) throw new Error(`HTTP ${res.status} from ${new URL(url).host}`);
+ return res.json();
+}
+
+function query(base, params) {
+ const url = new URL(base);
+ for (const [key, value] of Object.entries({ ...params, format: "json", origin: "*" })) {
+ url.searchParams.set(key, value);
+ }
+ return url.toString();
+}
+
+function sparqlQuery(qid, lang) {
+ // qid is only ever an id taken from Wikidata's own answer, checked against
+ // /^Q\d+$/ before it gets here, so it cannot carry anything into the query.
+ const sparql = `SELECT ?album ?albumLabel (MIN(?date) AS ?released) WHERE {
+ ?album wdt:P175 wd:${qid} .
+ { ?album wdt:P31 wd:${STUDIO_ALBUM} } UNION { ?album wdt:P31 wd:${ALBUM} ; wdt:P7937 wd:${STUDIO_ALBUM} }
+ FILTER NOT EXISTS { ?album wdt:${EDITION_OF} ?original }
+ FILTER NOT EXISTS { ?album wdt:${EDITION_NAME} ?edition }
+ OPTIONAL { ?album wdt:P577 ?date }
+ SERVICE wikibase:label { bd:serviceParam wikibase:language "${lang},en". }
+} GROUP BY ?album ?albumLabel ORDER BY ?released`;
+ const url = new URL(WIKIDATA_SPARQL);
+ url.searchParams.set("query", sparql);
+ url.searchParams.set("format", "json");
+ return url.toString();
+}
+
+function label(entity, lang) {
+ return entity?.labels?.[lang]?.value || entity?.labels?.en?.value || "";
+}
+
+/**
+ * Look an artist up. Resolves to null when nothing on Wikidata is an artist
+ * by that name, and rejects when Wikidata or Wikipedia cannot be reached, so
+ * the box can tell "no such band" from "no connection".
+ *
+ * `id` skips the search: a band saved on a track is kept as its Wikidata id,
+ * so opening it again asks for exactly that band, in whatever language the app
+ * is in now, rather than searching a name that was a label in another one.
+ *
+ * Members, albums and history are fetched side by side and each is optional:
+ * one of them failing leaves that section out rather than failing the box.
+ */
+export async function lookupArtist(name, appLang, { id = "", fetchJson = defaultFetchJson, signal } = {}) {
+ const search = String(name || "").trim();
+ const known = /^Q\d+$/.test(id) ? id : "";
+ if (!search && !known) return null;
+ const lang = wikiLanguage(appLang);
+ const { signal: sig, done } = withTimeout(signal);
+ const get = (url) => fetchJson(url, sig);
+
+ try {
+ let ids = [known];
+ if (!known) {
+ const found = await get(query(WIKIDATA_API, {
+ action: "wbsearchentities", search, language: lang, uselang: lang,
+ type: "item", limit: "10",
+ }));
+ ids = (found?.search || []).map((s) => s.id).filter((qid) => /^Q\d+$/.test(qid));
+ if (!ids.length) return null;
+ }
+
+ const entities = (await get(query(WIKIDATA_API, {
+ action: "wbgetentities", ids: ids.join("|"),
+ props: "claims|sitelinks/urls|labels|descriptions",
+ languages: lang === "en" ? "en" : `${lang}|en`, languagefallback: "1",
+ sitefilter: lang === "en" ? "enwiki" : `${lang}wiki|enwiki`,
+ })))?.entities;
+ const artist = pickArtist(entities, ids);
+ if (!artist) return null;
+
+ const site = artist.sitelinks?.[`${lang}wiki`] || artist.sitelinks?.enwiki || null;
+ const siteLang = site?.site === `${lang}wiki` ? lang : "en";
+ const members = membersFromClaims(artist.claims);
+ const memberIds = [...members.current, ...members.former];
+ const imageFile = artist.claims?.[IMAGE]?.[0]?.mainsnak?.datavalue?.value;
+
+ const [memberEntities, albumsJson, extractJson] = await Promise.all([
+ memberIds.length
+ ? get(query(WIKIDATA_API, {
+ action: "wbgetentities", ids: memberIds.join("|"), props: "labels",
+ languages: lang === "en" ? "en" : `${lang}|en`, languagefallback: "1",
+ })).catch((err) => { console.warn("artist members lookup failed", err); return null; })
+ : null,
+ get(sparqlQuery(artist.id, lang))
+ .catch((err) => { console.warn("artist albums lookup failed", err); return null; }),
+ site
+ ? get(query(`https://${siteLang}.wikipedia.org/w/api.php`, {
+ action: "query", prop: "extracts", explaintext: "1", exsectionformat: "wiki",
+ titles: site.title, formatversion: "2", redirects: "1",
+ })).catch((err) => { console.warn("artist history lookup failed", err); return null; })
+ : null,
+ ]);
+
+ const names = (list) => list
+ .map((id) => label(memberEntities?.entities?.[id], lang))
+ .filter(Boolean);
+
+ return {
+ id: artist.id,
+ name: label(artist, lang) || search,
+ // Kept beside the name in the app's language: LRCLIB lists artists by
+ // the name they release under, which the English label nearly always is.
+ englishName: artist.labels?.en?.value || "",
+ description: artist.descriptions?.[lang]?.value || artist.descriptions?.en?.value || "",
+ image: typeof imageFile === "string"
+ ? `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(imageFile)}?width=240`
+ : "",
+ history: historyFromExtract(extractJson?.query?.pages?.[0]?.extract, historyHeadingsFor(siteLang)),
+ members: { current: names(members.current), former: names(members.former) },
+ albums: albumsFromSparql(albumsJson),
+ articleUrl: site?.url || "",
+ };
+ } finally {
+ done();
+ }
+}
diff --git a/static/js/catalog.js b/static/js/catalog.js
index 93b7b53b..40518657 100644
--- a/static/js/catalog.js
+++ b/static/js/catalog.js
@@ -207,6 +207,8 @@ let folders = [];
let tracks = {};
let _deletedJobIds = new Set();
let _currentTrackId = null;
+// The open track's tags as the last render() saw them. See setCurrentTrack().
+let _renderedTagsKey = "";
let _loadTrackToken = 0;
let catalogView = "library";
let catalogSearchQuery = "";
@@ -399,6 +401,30 @@ function saveState() {
// ─── Track management ───
+/**
+ * A job's audio_tags as the library keeps them (#699): artist, title and album
+ * as they are, and embedded lyrics reduced to `hasLyrics: true`. The lyrics run
+ * to 20,000 characters and the library store is rewritten whole on every
+ * change to any track, so they stay on the server; the Lyrics tab fetches them
+ * once, into a store entry of its own, the first time it needs them.
+ */
+export function libraryAudioTags(tags) {
+ if (!tags || typeof tags !== "object") return null;
+ const { lyrics, ...rest } = tags;
+ return lyrics ? { ...rest, hasLyrics: true } : rest;
+}
+
+/**
+ * The band a job's state carries (#699), found by the server while the job
+ * ran, as the library keeps a band: { id, name, englishName } with id a
+ * Wikidata item. Null for anything else, so a malformed answer never reaches
+ * the artist box.
+ */
+export function libraryArtist(artist) {
+ if (!artist || typeof artist !== "object" || !/^Q\d+$/.test(String(artist.id || ""))) return null;
+ return { id: artist.id, name: String(artist.name || ""), englishName: String(artist.englishName || "") };
+}
+
export function addTrackToLibrary(track) {
// track: { id, title, thumb, stems, status, sourceUrl }
const existingId = findTrackBySource(track.sourceUrl, track.id);
@@ -425,6 +451,9 @@ export function addTrackToLibrary(track) {
tracks[track.id] = {
...existing,
...track,
+ // A band already on the track, saved in the artist box or found before,
+ // is never replaced by the one the server found (#699).
+ ...(existing.artist ? { artist: existing.artist } : {}),
createdAt: existing.createdAt ?? track.createdAt ?? (Date.now() / 1000),
favorite: existing.favorite ?? false,
};
@@ -442,6 +471,107 @@ export function addTrackToLibrary(track) {
render();
}
+/**
+ * The open track, for the lyrics panel: its id, title and length in seconds,
+ * or null when none is open.
+ */
+export function getCurrentTrackInfo() {
+ const track = tracks[_currentTrackId];
+ if (!track) return null;
+ return {
+ id: _currentTrackId,
+ title: track.title || "",
+ duration: Number(track.duration) || 0,
+ // What the file itself was tagged with at import, when it was: artist,
+ // title, album, and lyrics if it carried them. Null for tracks imported
+ // before tags were read, and for sources that had none.
+ audioTags: track.audioTags || null,
+ };
+}
+
+/**
+ * The band saved on the open track from the artist box (#699), as
+ * { id, name } with id a Wikidata item, or null when none has been saved.
+ */
+export function getCurrentTrackArtist() {
+ const artist = tracks[_currentTrackId]?.artist;
+ if (!artist || !/^Q\d+$/.test(artist.id)) return null;
+ return { id: artist.id, name: String(artist.name || ""), englishName: String(artist.englishName || "") };
+}
+
+/** Save a band on the open track, kept with the rest of the library. */
+export function setCurrentTrackArtist(artist) {
+ const track = tracks[_currentTrackId];
+ if (!track || !/^Q\d+$/.test(artist?.id || "")) return false;
+ track.artist = { id: artist.id, name: String(artist.name || ""), englishName: String(artist.englishName || "") };
+ saveState();
+ paintNowPlayingArtist(track);
+ return true;
+}
+
+// The artist beside the title on the now-playing card (#699): the band saved
+// on the track, else the artist its file was tagged with. Neither known, and
+// the card is exactly what it was before there was an artist to show.
+function paintNowPlayingArtist(track) {
+ const el = document.getElementById("np-artist");
+ if (!el) return;
+ const name = String(track?.artist?.name || track?.audioTags?.artist || "").trim();
+ el.textContent = name;
+ // The whole name on hover, since a long title can leave it clipped.
+ el.title = name;
+ el.hidden = !name;
+}
+
+// ─── Tags for tracks imported before they were read ───
+//
+// A track from before #699 has no audioTags, so the card, the artist box and
+// the Lyrics tab had nothing to go on. Opening one asks the server once to
+// read them now, from the upload it kept or the video's metadata, in the
+// background: the track loads exactly as it would have. The answer is kept on
+// the track with audioTagsChecked, null included, so a file with no tags is
+// asked about once ever. A failure sets nothing, and the next session asks
+// again. A 409 (the job is not finished, or the server is already reading its
+// tags) is "not yet", so the next open of the track asks again. The server
+// can take up to ~45s on a slow video metadata fetch, which is why nothing
+// waits on this. "tracktags" then tells artistInfo.js and lyrics.js, which
+// import this module and so cannot be imported back.
+const _tagsAsked = new Set(); // once per track per page load
+const _tagsPending = new Set();
+
+/** Whether the open track's tags are still being asked for. */
+export function isCurrentTrackTagsPending() {
+ return _tagsPending.has(_currentTrackId);
+}
+
+async function backfillAudioTags(trackId) {
+ const track = tracks[trackId];
+ if (!track || track.audioTags || track.audioTagsChecked || track.status !== "done") return;
+ if (_tagsAsked.has(trackId)) return;
+ _tagsAsked.add(trackId);
+ _tagsPending.add(trackId);
+ try {
+ const res = await fetch(`/api/jobs/${encodeURIComponent(trackId)}/audio-tags`, { method: "POST" });
+ if (res.status === 409) _tagsAsked.delete(trackId);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ // Kept on the track it was asked for, even if another is open by now.
+ const target = tracks[trackId];
+ if (!target) return;
+ target.audioTags = libraryAudioTags(data?.audio_tags);
+ target.audioTagsChecked = true;
+ // The band, when the server found it from those tags, so artistInfo.js
+ // has nothing left to look up when "tracktags" reaches it.
+ if (!target.artist) target.artist = libraryArtist(data?.artist);
+ saveState();
+ if (_currentTrackId === trackId) paintNowPlayingNames(target);
+ } catch (err) {
+ console.warn("[catalog] reading an older track's tags failed:", err);
+ } finally {
+ _tagsPending.delete(trackId);
+ document.dispatchEvent(new CustomEvent("tracktags", { detail: { id: trackId } }));
+ }
+}
+
export function updateTrackStatus(trackId, status) {
if (tracks[trackId]) {
tracks[trackId].status = status;
@@ -492,11 +622,18 @@ function stateMetadataToTrack(state, fallbackTrack) {
sectionsSource: state.sections_source ?? fallbackTrack.sectionsSource ?? null,
sourceUrl: state.source_url || fallbackTrack.sourceUrl,
sourceFormat: state.source_format ?? fallbackTrack.sourceFormat ?? null,
+ // The source's own artist, title, album and lyrics (#699), read at import.
+ audioTags: state.audio_tags ? libraryAudioTags(state.audio_tags) : fallbackTrack.audioTags ?? null,
mixUrl: state.mix_url ?? fallbackTrack.mixUrl ?? null,
hasVideo: state.has_video ?? fallbackTrack.hasVideo ?? false,
videoStatus: state.video_status ?? fallbackTrack.videoStatus ?? null,
createdAt: fallbackTrack.createdAt ?? state.created_at,
favorite: fallbackTrack.favorite ?? false,
+ // The band (#699): the one saved on the track first, since a band chosen
+ // in the artist box always wins, and the server never hears of that one,
+ // so it only survives a rebuild from server state by being carried. Else
+ // the one the server found from the tags while the job ran.
+ artist: fallbackTrack.artist ?? libraryArtist(state.artist),
};
}
@@ -571,8 +708,26 @@ export function applyStemPresenceCards(stemPresence) {
});
}
+function paintNowPlayingNames(track) {
+ // The file's own title tag when it has one: the job title is the filename
+ // or the video's name, which often carries the band, "Official Video" and
+ // the like, and the artist now has a place of its own beside it.
+ titleEl.textContent = String(track.audioTags?.title || "").trim() || track.title || i18nT("track.untitled");
+ paintNowPlayingArtist(track);
+}
+
+/**
+ * Name the song and its artist on the now-playing card for a track that has
+ * just finished importing, the way opening it from the library does. Nothing
+ * if another track is open by now.
+ */
+export function paintFinishedTrackNames(trackId) {
+ const track = tracks[trackId];
+ if (track && trackId === _currentTrackId) paintNowPlayingNames(track);
+}
+
function applyTrackInfoToPanel(track) {
- titleEl.textContent = track.title || i18nT("track.untitled");
+ paintNowPlayingNames(track);
bpmChip.textContent = track.bpm ? `${track.bpm} BPM` : "— BPM";
keyChip.textContent = track.key || "— —";
updateFooterTrack({
@@ -701,12 +856,16 @@ function moveTrackToTrash(trackId) {
}
function setCatalogView(view) {
- catalogView = ["trash", "favorites", "queue"].includes(view) ? view : "library";
- // Switching to Trash, Favourites or Queue is a request to look at the
- // sidebar, so a collapsed one comes back. Through the shared helper, or the
- // collapse button's aria-expanded is left claiming the sidebar is still shut.
+ catalogView = ["trash", "favorites", "queue", "lyrics"].includes(view) ? view : "library";
+ // Switching to Trash, Favourites, Queue or Lyrics is a request to look at
+ // the sidebar, so a collapsed one comes back. Through the shared helper, or
+ // the collapse button's aria-expanded is left claiming the sidebar is still
+ // shut.
if (catalogView !== "library") setSidebarCollapsed(false);
render();
+ // The lyrics panel (lyrics.js) draws itself; this is how it learns it is on
+ // screen, without catalog.js importing it.
+ document.dispatchEvent(new CustomEvent("catalogviewchange", { detail: { view: catalogView } }));
}
function applyStoredStemSelection(track) {
@@ -828,6 +987,12 @@ async function loadTrackIntoStudio(trackId) {
applyTrackInfoToPanel(track);
wireUpAudio(trackId, track.audioStems, track.duration || 0, track.thumb, track.mixUrl ?? null, track.title || "", peaksPromise, track.hasVideo ?? false, track.videoStatus ?? null);
initSections(trackId, track.sections, track.duration || 0);
+ // Not awaited: the track is already loading, and the answer arrives as a
+ // "tracktags" event.
+ backfillAudioTags(trackId);
+ // For artistInfo.js, which finds the band from the file's tags. An event
+ // rather than an import, since that module already imports this one.
+ document.dispatchEvent(new CustomEvent("trackopen", { detail: { id: trackId } }));
}
/**
@@ -864,6 +1029,12 @@ export function setResplitTarget(track) {
export function setCurrentTrack(trackId) {
_currentTrackId = trackId;
+ // The library's Tags section shows the open track's tags, so it is rebuilt
+ // when those are not what the last render() drew. Compared by tags rather
+ // than by id: loadTrackIntoStudio() can bring a track's tags in from the
+ // server while that same track is already the open one. Anything else is
+ // patched in place, since a full render() rebuilds the whole sidebar.
+ if (openTrackTags().join("\n") !== _renderedTagsKey) { render(); return; }
for (const el of document.querySelectorAll(".cat-item.active")) el.classList.remove("active");
for (const el of document.querySelectorAll(`.cat-item[data-id="${trackId}"]`)) el.classList.add("active");
for (const el of document.querySelectorAll(".strip-thumb.active")) el.classList.remove("active");
@@ -1257,6 +1428,13 @@ function wireLibraryDeleteKeys() {
// ─── Rendering helpers ───
+/** The open track's tags, deduplicated, or none when it is in the Trash. */
+function openTrackTags() {
+ const track = tracks[_currentTrackId];
+ if (!track || getTrashFolder()?.items.includes(_currentTrackId)) return [];
+ return [...new Set(track.tags ?? [])];
+}
+
function getAllTags(trashIds) {
const counts = {};
for (const [id, track] of Object.entries(tracks)) {
@@ -1322,13 +1500,11 @@ function trackSublineHtml(track, { inTrash = false } = {}) {
} else if (track.status === "error") {
parts = [i18nT("notifKind.importFailed")];
} else {
- const stems = stemCountOf(track);
- // A done track that knows neither its length nor its stems says nothing
- // rather than "0 stems", which would be a claim and a wrong one.
- parts = [
- track.duration ? fmtTime(track.duration) : "",
- stems ? i18nPlural("footer.stemsCount", stems) : "",
- ];
+ // Just the length. The stem count was here too, but every row carrying
+ // "6 stems" says the same thing forty times over, and the now-playing card
+ // shows it for the track that is open (#699). A done track that does not
+ // know its length says nothing rather than a placeholder.
+ parts = [track.duration ? fmtTime(track.duration) : ""];
}
if (inTrash) parts.push(i18nT("track.removed"));
// Spaced as text as well as by the row's flex gap, so what a screen reader
@@ -1653,6 +1829,8 @@ function render() {
const catalog = document.getElementById("catalogPanel");
const searchInput = document.getElementById("catalogSearch");
if (!list) return;
+ const openTags = openTrackTags();
+ _renderedTagsKey = openTags.join("\n");
list.innerHTML = "";
if (strip) strip.innerHTML = "";
@@ -1662,11 +1840,17 @@ function render() {
const isTrashView = catalogView === "trash";
const isFavoritesView = catalogView === "favorites";
const isQueueView = catalogView === "queue";
- const isLibraryView = !isTrashView && !isFavoritesView && !isQueueView;
+ const isLyricsView = catalogView === "lyrics";
+ const isLibraryView = !isTrashView && !isFavoritesView && !isQueueView && !isLyricsView;
catalog?.classList.toggle("trash-view", isTrashView);
catalog?.classList.toggle("favorites-view", isFavoritesView);
catalog?.classList.toggle("queue-view", isQueueView);
+ // The lyrics panel replaces the list rather than being rendered into it;
+ // daw.css swaps which of the two is shown.
+ catalog?.classList.toggle("lyrics-view", isLyricsView);
+ document.querySelector(".rail-lyrics")?.classList.toggle("active", isLyricsView);
+ document.querySelector(".rail-lyrics")?.setAttribute("aria-pressed", String(isLyricsView));
document.querySelector(".rail-library")?.classList.toggle("active", isLibraryView);
document.querySelector(".rail-library")?.setAttribute("aria-pressed", String(isLibraryView));
@@ -1764,14 +1948,19 @@ function render() {
return;
}
- // Tags section
- const tags = getAllTags(trashIds);
- if (tags.length) {
+ // Tags section: the open track's tags only. Every tag in the library was a
+ // wall of chips with nothing to say which song they came from. The count is
+ // still library-wide, since it is how many tracks the chip's filter finds.
+ // setCurrentTrack() re-renders when the open track's tags change.
+ if (openTags.length) {
+ const counts = new Map(getAllTags(trashIds));
const section = makeSectionEl(i18nT("library.tags"));
+ section.classList.add("lib-tags-section");
const row = document.createElement("div");
row.className = "lib-tags-row";
const activeTag = catalogSearchQuery.startsWith("#") ? catalogSearchQuery.slice(1) : null;
- for (const [tag, count] of tags) {
+ for (const tag of openTags) {
+ const count = counts.get(tag) ?? 1;
const chip = document.createElement("button");
chip.className = `lib-tag-chip${activeTag === tag ? " active" : ""}`;
chip.type = "button";
@@ -2220,6 +2409,7 @@ function wireCatalogRailViews() {
document.querySelector(".rail-library")?.addEventListener("click", () => setCatalogView("library"));
document.querySelector(".rail-favorites")?.addEventListener("click", () => setCatalogView("favorites"));
document.querySelector(".rail-trash")?.addEventListener("click", () => setCatalogView("trash"));
+ document.querySelector(".rail-lyrics")?.addEventListener("click", () => setCatalogView("lyrics"));
document.querySelector(".rail-queue")?.addEventListener("click", () => setCatalogView("queue"));
document.getElementById("clearBinBtn")?.addEventListener("click", async (e) => {
const btn = e.currentTarget;
@@ -3110,6 +3300,13 @@ async function syncWithServer() {
known.sourceFormat = state.source_format;
backfilled = true;
}
+ // Likewise the band the server found while the job ran (#699), for a
+ // track added before its import finished. Never over one already
+ // saved on the track.
+ if (!known.artist && libraryArtist(state.artist)) {
+ known.artist = libraryArtist(state.artist);
+ backfilled = true;
+ }
continue;
}
if (trashIds.has(state.job_id)) continue; // soft-deleted, skip
diff --git a/static/js/i18n.js b/static/js/i18n.js
index 59281217..eda0b808 100644
--- a/static/js/i18n.js
+++ b/static/js/i18n.js
@@ -242,6 +242,47 @@ const en = {
"topbar.uploadFile": "Upload audio file",
"topbar.website": "Open stemdeck.app",
"extract.label": "Extract",
+ "track.nowPlaying": "Now playing",
+ "artist.openTitle": "About this artist",
+ "artist.dialogAria": "About the artist",
+ "artist.close": "Close",
+ "artist.searchPlaceholder": "Search for a band or artist",
+ "artist.searchAria": "Band or artist to look up",
+ "artist.search": "Search",
+ "artist.privacy": "Only an artist's name is sent to Wikipedia and Wikidata: the one you search for here, and the artist in a track's tags when you open the track.",
+ "artist.loading": "Looking up “{name}” on Wikipedia…",
+ "artist.notThisBand": "Not this band? Search",
+ "artist.checkingTrack": "Finding this track's artist…",
+ "artist.searchInstead": "Search by name instead",
+ "artist.notFound": "Nothing on Wikipedia matches “{name}” as a band or artist.",
+ "artist.offline": "Could not reach Wikipedia. Check your connection and try again.",
+ "artist.history": "History",
+ "artist.members": "Members",
+ "artist.formerMembers": "Former members",
+ "artist.albums": "Studio albums",
+ "artist.readMore": "Read more on Wikipedia",
+ "artist.source": "From Wikipedia and Wikidata, CC BY-SA",
+ "artist.setForTrack": "Save for this track",
+ "artist.savedForTrack": "Saved for this track",
+ "nav.lyrics": "Lyrics",
+ "lyrics.title": "Lyrics",
+ "lyrics.fromFile": "From the file",
+ "lyrics.pickVersion": "This song is on LRCLIB, but not in a version the same length as this track. Pick the right one:",
+ "lyrics.privacy": "Found on LRCLIB by the artist and song in the file's tags, or the band saved on this track. Nothing else is sent.",
+ "lyrics.noTrack": "Open a track to find its lyrics.",
+ "lyrics.nothingKnown": "This track has no artist or song tags. Save its band with the ⓘ button on the now-playing card, and its lyrics will be looked up here.",
+ "lyrics.removed": "Lyrics removed for this track.",
+ "lyrics.lookAgain": "Look up again",
+ "lyrics.loading": "Searching LRCLIB for “{song}”…",
+ "lyrics.notFound": "No lyrics found for “{song}”.",
+ "lyrics.offline": "Could not reach LRCLIB. Check your connection and try again.",
+ "lyrics.instrumental": "This version is marked instrumental, so it has no lyrics.",
+ "lyrics.otherVersions": "Other versions ({count})",
+ "lyrics.hideVersions": "Hide other versions",
+ "lyrics.remove": "Remove lyrics",
+ "lyrics.synced": "Synced",
+ "lyrics.plain": "Text only",
+ "lyrics.seekTitle": "Jump to this line",
"extract.all": "All",
"extract.moreAria": "Choose stems",
"process.splitStems": "Split stems",
@@ -859,6 +900,47 @@ const pl = {
"topbar.uploadFile": "Wgraj plik audio",
"topbar.website": "Otwórz stemdeck.app",
"extract.label": "Wyodrębnij",
+ "track.nowPlaying": "Teraz odtwarzane",
+ "artist.openTitle": "O tym wykonawcy",
+ "artist.dialogAria": "O wykonawcy",
+ "artist.close": "Zamknij",
+ "artist.searchPlaceholder": "Wyszukaj zespół lub wykonawcę",
+ "artist.searchAria": "Zespół lub wykonawca do wyszukania",
+ "artist.search": "Szukaj",
+ "artist.privacy": "Do Wikipedii i Wikidanych trafia tylko nazwa wykonawcy: ta, której tu szukasz, oraz wykonawca z tagów utworu, gdy go otwierasz.",
+ "artist.loading": "Szukam „{name}” w Wikipedii…",
+ "artist.notThisBand": "To nie ten zespół? Szukaj",
+ "artist.checkingTrack": "Szukam wykonawcy tego utworu…",
+ "artist.searchInstead": "Wyszukaj po nazwie",
+ "artist.notFound": "W Wikipedii nie ma zespołu ani wykonawcy pasującego do „{name}”.",
+ "artist.offline": "Nie udało się połączyć z Wikipedią. Sprawdź połączenie i spróbuj ponownie.",
+ "artist.history": "Historia",
+ "artist.members": "Skład",
+ "artist.formerMembers": "Byli członkowie",
+ "artist.albums": "Albumy studyjne",
+ "artist.readMore": "Czytaj dalej w Wikipedii",
+ "artist.source": "Z Wikipedii i Wikidanych, CC BY-SA",
+ "artist.setForTrack": "Zapisz dla tego utworu",
+ "artist.savedForTrack": "Zapisano dla tego utworu",
+ "nav.lyrics": "Tekst",
+ "lyrics.title": "Tekst",
+ "lyrics.fromFile": "Z pliku",
+ "lyrics.pickVersion": "Ten utwór jest w LRCLIB, ale nie w wersji o tej samej długości. Wybierz właściwą:",
+ "lyrics.privacy": "Szukane w LRCLIB po wykonawcy i utworze z tagów pliku albo po zespole zapisanym dla tego utworu. Nic więcej nie jest wysyłane.",
+ "lyrics.noTrack": "Otwórz utwór, aby znaleźć jego tekst.",
+ "lyrics.nothingKnown": "Ten utwór nie ma tagów wykonawcy ani tytułu. Zapisz jego zespół przyciskiem ⓘ na karcie odtwarzanego utworu, a tekst zostanie tu wyszukany.",
+ "lyrics.removed": "Usunięto tekst tego utworu.",
+ "lyrics.lookAgain": "Wyszukaj ponownie",
+ "lyrics.loading": "Szukam „{song}” w LRCLIB…",
+ "lyrics.notFound": "Nie znaleziono tekstu dla „{song}”.",
+ "lyrics.offline": "Nie udało się połączyć z LRCLIB. Sprawdź połączenie i spróbuj ponownie.",
+ "lyrics.instrumental": "Ta wersja jest oznaczona jako instrumentalna, więc nie ma tekstu.",
+ "lyrics.otherVersions": "Inne wersje ({count})",
+ "lyrics.hideVersions": "Ukryj inne wersje",
+ "lyrics.remove": "Usuń tekst",
+ "lyrics.synced": "Zsynchronizowany",
+ "lyrics.plain": "Tylko tekst",
+ "lyrics.seekTitle": "Przejdź do tego wersu",
"extract.all": "Wszystko",
"extract.moreAria": "Wybierz ścieżki",
"process.splitStems": "Rozdziel ścieżki",
@@ -1466,6 +1548,47 @@ const ja = {
"topbar.uploadFile": "音声ファイルをアップロード",
"topbar.website": "stemdeck.app を開く",
"extract.label": "抽出",
+ "track.nowPlaying": "再生中",
+ "artist.openTitle": "このアーティストについて",
+ "artist.dialogAria": "アーティスト情報",
+ "artist.close": "閉じる",
+ "artist.searchPlaceholder": "バンド名やアーティスト名で検索",
+ "artist.searchAria": "調べるバンドまたはアーティスト",
+ "artist.search": "検索",
+ "artist.privacy": "Wikipedia と Wikidata に送られるのはアーティスト名だけです。ここで検索した名前と、曲を開いたときのタグのアーティスト名です。",
+ "artist.loading": "Wikipedia で「{name}」を検索中…",
+ "artist.notThisBand": "このバンドではない?検索",
+ "artist.checkingTrack": "この曲のアーティストを調べています…",
+ "artist.searchInstead": "名前で検索する",
+ "artist.notFound": "「{name}」に一致するバンドやアーティストは Wikipedia に見つかりませんでした。",
+ "artist.offline": "Wikipedia に接続できませんでした。接続を確認してもう一度お試しください。",
+ "artist.history": "歴史",
+ "artist.members": "メンバー",
+ "artist.formerMembers": "元メンバー",
+ "artist.albums": "スタジオ・アルバム",
+ "artist.readMore": "Wikipedia で続きを読む",
+ "artist.source": "出典: Wikipedia と Wikidata (CC BY-SA)",
+ "artist.setForTrack": "この曲に保存",
+ "artist.savedForTrack": "この曲に保存済み",
+ "nav.lyrics": "歌詞",
+ "lyrics.title": "歌詞",
+ "lyrics.fromFile": "ファイルから",
+ "lyrics.pickVersion": "この曲は LRCLIB にありますが、この曲と同じ長さのバージョンがありません。正しいものを選んでください:",
+ "lyrics.privacy": "ファイルのタグのアーティストと曲名、またはこの曲に保存したバンドで LRCLIB から探します。ほかには何も送りません。",
+ "lyrics.noTrack": "歌詞を探すには曲を開いてください。",
+ "lyrics.nothingKnown": "この曲にはアーティストや曲名のタグがありません。再生中カードの ⓘ ボタンでバンドを保存すると、ここで歌詞を探します。",
+ "lyrics.removed": "この曲の歌詞を削除しました。",
+ "lyrics.lookAgain": "もう一度探す",
+ "lyrics.loading": "LRCLIB で「{song}」を検索中…",
+ "lyrics.notFound": "「{song}」の歌詞は見つかりませんでした。",
+ "lyrics.offline": "LRCLIB に接続できませんでした。接続を確認してもう一度お試しください。",
+ "lyrics.instrumental": "このバージョンはインストゥルメンタルのため、歌詞はありません。",
+ "lyrics.otherVersions": "ほかのバージョン ({count})",
+ "lyrics.hideVersions": "ほかのバージョンを隠す",
+ "lyrics.remove": "歌詞を削除",
+ "lyrics.synced": "同期",
+ "lyrics.plain": "テキストのみ",
+ "lyrics.seekTitle": "この行へ移動",
"extract.all": "すべて",
"extract.moreAria": "抽出するパートを選ぶ",
"process.splitStems": "パートを分離",
@@ -2048,6 +2171,47 @@ const zhHans = {
"topbar.uploadFile": "上传音频文件",
"topbar.website": "打开 stemdeck.app",
"extract.label": "提取",
+ "track.nowPlaying": "正在播放",
+ "artist.openTitle": "关于这位艺人",
+ "artist.dialogAria": "艺人信息",
+ "artist.close": "关闭",
+ "artist.searchPlaceholder": "搜索乐队或艺人",
+ "artist.searchAria": "要查询的乐队或艺人",
+ "artist.search": "搜索",
+ "artist.privacy": "只有艺人名称会发送给维基百科和维基数据:你在这里搜索的名称,以及打开曲目时其标签中的艺人。",
+ "artist.loading": "正在维基百科中查找“{name}”…",
+ "artist.notThisBand": "不是这个乐队?搜索",
+ "artist.checkingTrack": "正在查找这首曲目的艺人…",
+ "artist.searchInstead": "改为按名称搜索",
+ "artist.notFound": "维基百科中没有与“{name}”匹配的乐队或艺人。",
+ "artist.offline": "无法连接维基百科。请检查网络后重试。",
+ "artist.history": "历史",
+ "artist.members": "成员",
+ "artist.formerMembers": "前成员",
+ "artist.albums": "录音室专辑",
+ "artist.readMore": "在维基百科上阅读更多",
+ "artist.source": "来自维基百科和维基数据,CC BY-SA",
+ "artist.setForTrack": "保存到此曲目",
+ "artist.savedForTrack": "已保存到此曲目",
+ "nav.lyrics": "歌词",
+ "lyrics.title": "歌词",
+ "lyrics.fromFile": "来自文件",
+ "lyrics.pickVersion": "LRCLIB 中有这首歌,但没有与此曲目时长相同的版本。请选择正确的版本:",
+ "lyrics.privacy": "根据文件标签中的艺人和歌曲,或此曲目已保存的乐队,在 LRCLIB 上查找。不会发送其他任何内容。",
+ "lyrics.noTrack": "打开一首曲目来查找歌词。",
+ "lyrics.nothingKnown": "此曲目没有艺人或歌曲标签。用正在播放卡片上的 ⓘ 按钮保存它的乐队,歌词就会在这里查找。",
+ "lyrics.removed": "已移除此曲目的歌词。",
+ "lyrics.lookAgain": "重新查找",
+ "lyrics.loading": "正在 LRCLIB 中搜索“{song}”…",
+ "lyrics.notFound": "未找到“{song}”的歌词。",
+ "lyrics.offline": "无法连接 LRCLIB。请检查网络后重试。",
+ "lyrics.instrumental": "此版本标记为纯音乐,没有歌词。",
+ "lyrics.otherVersions": "其他版本 ({count})",
+ "lyrics.hideVersions": "隐藏其他版本",
+ "lyrics.remove": "移除歌词",
+ "lyrics.synced": "同步",
+ "lyrics.plain": "仅文本",
+ "lyrics.seekTitle": "跳到这一行",
"extract.all": "全部",
"extract.moreAria": "选择要提取的音轨",
"process.splitStems": "分离音轨",
@@ -2630,6 +2794,47 @@ const de = {
"topbar.uploadFile": "Audiodatei hochladen",
"topbar.website": "stemdeck.app öffnen",
"extract.label": "Extrahieren",
+ "track.nowPlaying": "Wird abgespielt",
+ "artist.openTitle": "Künstlerinfo anzeigen",
+ "artist.dialogAria": "Künstlerinfo",
+ "artist.close": "Schließen",
+ "artist.searchPlaceholder": "Nach Band oder Künstler suchen",
+ "artist.searchAria": "Band oder Künstler zum Nachschlagen",
+ "artist.search": "Suchen",
+ "artist.privacy": "Nur ein Künstlername wird an Wikipedia und Wikidata gesendet: der, den du hier suchst, und der Künstler aus den Tags eines Tracks, wenn du ihn öffnest.",
+ "artist.loading": "„{name}“ wird auf Wikipedia gesucht…",
+ "artist.notThisBand": "Nicht diese Band? Suchen",
+ "artist.checkingTrack": "Künstler dieses Tracks wird ermittelt…",
+ "artist.searchInstead": "Stattdessen nach Namen suchen",
+ "artist.notFound": "Auf Wikipedia passt keine Band und kein Künstler zu „{name}“.",
+ "artist.offline": "Wikipedia ist nicht erreichbar. Prüfe deine Verbindung und versuche es erneut.",
+ "artist.history": "Geschichte",
+ "artist.members": "Mitglieder",
+ "artist.formerMembers": "Ehemalige Mitglieder",
+ "artist.albums": "Studioalben",
+ "artist.readMore": "Weiter auf Wikipedia",
+ "artist.source": "Aus Wikipedia und Wikidata, CC BY-SA",
+ "artist.setForTrack": "Für diesen Track speichern",
+ "artist.savedForTrack": "Für diesen Track gespeichert",
+ "nav.lyrics": "Songtext",
+ "lyrics.title": "Songtext",
+ "lyrics.fromFile": "Aus der Datei",
+ "lyrics.pickVersion": "Dieser Song ist auf LRCLIB, aber nicht in einer Version mit derselben Länge. Wähle die richtige:",
+ "lyrics.privacy": "Wird auf LRCLIB über Künstler und Song aus den Tags der Datei gesucht, oder über die für diesen Track gespeicherte Band. Sonst wird nichts gesendet.",
+ "lyrics.noTrack": "Öffne einen Track, um seinen Songtext zu finden.",
+ "lyrics.nothingKnown": "Dieser Track hat keine Tags für Künstler oder Song. Speichere seine Band mit der ⓘ-Taste auf der Karte „Wird abgespielt“, dann wird der Songtext hier gesucht.",
+ "lyrics.removed": "Songtext für diesen Track entfernt.",
+ "lyrics.lookAgain": "Erneut suchen",
+ "lyrics.loading": "„{song}“ wird auf LRCLIB gesucht…",
+ "lyrics.notFound": "Kein Songtext für „{song}“ gefunden.",
+ "lyrics.offline": "LRCLIB ist nicht erreichbar. Prüfe deine Verbindung und versuche es erneut.",
+ "lyrics.instrumental": "Diese Version ist als instrumental markiert und hat keinen Text.",
+ "lyrics.otherVersions": "Andere Versionen ({count})",
+ "lyrics.hideVersions": "Andere Versionen ausblenden",
+ "lyrics.remove": "Songtext entfernen",
+ "lyrics.synced": "Synchron",
+ "lyrics.plain": "Nur Text",
+ "lyrics.seekTitle": "Zu dieser Zeile springen",
"extract.all": "Alle",
"extract.moreAria": "Stems auswählen",
"process.splitStems": "Stems trennen",
@@ -3223,6 +3428,47 @@ const pt = {
"topbar.uploadFile": "Enviar arquivo de áudio",
"topbar.website": "Abrir stemdeck.app",
"extract.label": "Extrair",
+ "track.nowPlaying": "Tocando agora",
+ "artist.openTitle": "Sobre este artista",
+ "artist.dialogAria": "Sobre o artista",
+ "artist.close": "Fechar",
+ "artist.searchPlaceholder": "Pesquise uma banda ou artista",
+ "artist.searchAria": "Banda ou artista a pesquisar",
+ "artist.search": "Pesquisar",
+ "artist.privacy": "Só o nome de um artista é enviado à Wikipédia e ao Wikidata: o que você pesquisa aqui e o artista das tags de uma faixa quando você a abre.",
+ "artist.loading": "Procurando “{name}” na Wikipédia…",
+ "artist.notThisBand": "Não é esta banda? Pesquisar",
+ "artist.checkingTrack": "Procurando o artista desta faixa…",
+ "artist.searchInstead": "Pesquisar pelo nome",
+ "artist.notFound": "Nenhuma banda ou artista na Wikipédia corresponde a “{name}”.",
+ "artist.offline": "Não foi possível acessar a Wikipédia. Verifique sua conexão e tente novamente.",
+ "artist.history": "História",
+ "artist.members": "Integrantes",
+ "artist.formerMembers": "Ex-integrantes",
+ "artist.albums": "Álbuns de estúdio",
+ "artist.readMore": "Leia mais na Wikipédia",
+ "artist.source": "Da Wikipédia e do Wikidata, CC BY-SA",
+ "artist.setForTrack": "Salvar nesta faixa",
+ "artist.savedForTrack": "Salvo nesta faixa",
+ "nav.lyrics": "Letra",
+ "lyrics.title": "Letra",
+ "lyrics.fromFile": "Do arquivo",
+ "lyrics.pickVersion": "Esta música está no LRCLIB, mas não numa versão com a mesma duração desta faixa. Escolha a certa:",
+ "lyrics.privacy": "Buscada no LRCLIB pelo artista e pela música das tags do arquivo, ou pela banda salva nesta faixa. Nada mais é enviado.",
+ "lyrics.noTrack": "Abra uma faixa para encontrar a letra.",
+ "lyrics.nothingKnown": "Esta faixa não tem tags de artista nem de música. Salve a banda com o botão ⓘ no cartão Tocando agora e a letra será buscada aqui.",
+ "lyrics.removed": "Letra removida desta faixa.",
+ "lyrics.lookAgain": "Buscar de novo",
+ "lyrics.loading": "Procurando “{song}” no LRCLIB…",
+ "lyrics.notFound": "Nenhuma letra encontrada para “{song}”.",
+ "lyrics.offline": "Não foi possível acessar o LRCLIB. Verifique sua conexão e tente novamente.",
+ "lyrics.instrumental": "Esta versão está marcada como instrumental, então não tem letra.",
+ "lyrics.otherVersions": "Outras versões ({count})",
+ "lyrics.hideVersions": "Ocultar outras versões",
+ "lyrics.remove": "Remover letra",
+ "lyrics.synced": "Sincronizada",
+ "lyrics.plain": "Só texto",
+ "lyrics.seekTitle": "Ir para esta linha",
"extract.all": "Todos",
"extract.moreAria": "Escolher as faixas",
"process.splitStems": "Separar stems",
@@ -3818,6 +4064,47 @@ const id = {
"topbar.uploadFile": "Unggah file audio",
"topbar.website": "Buka stemdeck.app",
"extract.label": "Ekstrak",
+ "track.nowPlaying": "Sedang diputar",
+ "artist.openTitle": "Tentang artis ini",
+ "artist.dialogAria": "Tentang artis",
+ "artist.close": "Tutup",
+ "artist.searchPlaceholder": "Cari band atau artis",
+ "artist.searchAria": "Band atau artis yang dicari",
+ "artist.search": "Cari",
+ "artist.privacy": "Hanya nama artis yang dikirim ke Wikipedia dan Wikidata: nama yang Anda cari di sini, dan artis dari tag trek saat Anda membukanya.",
+ "artist.loading": "Mencari “{name}” di Wikipedia…",
+ "artist.notThisBand": "Bukan band ini? Cari",
+ "artist.checkingTrack": "Mencari artis trek ini…",
+ "artist.searchInstead": "Cari berdasarkan nama saja",
+ "artist.notFound": "Tidak ada band atau artis di Wikipedia yang cocok dengan “{name}”.",
+ "artist.offline": "Tidak dapat terhubung ke Wikipedia. Periksa koneksi Anda lalu coba lagi.",
+ "artist.history": "Sejarah",
+ "artist.members": "Anggota",
+ "artist.formerMembers": "Mantan anggota",
+ "artist.albums": "Album studio",
+ "artist.readMore": "Baca selengkapnya di Wikipedia",
+ "artist.source": "Dari Wikipedia dan Wikidata, CC BY-SA",
+ "artist.setForTrack": "Simpan untuk trek ini",
+ "artist.savedForTrack": "Tersimpan untuk trek ini",
+ "nav.lyrics": "Lirik",
+ "lyrics.title": "Lirik",
+ "lyrics.fromFile": "Dari file",
+ "lyrics.pickVersion": "Lagu ini ada di LRCLIB, tetapi tidak dalam versi dengan durasi yang sama. Pilih yang benar:",
+ "lyrics.privacy": "Dicari di LRCLIB berdasarkan artis dan lagu dari tag file, atau band yang disimpan untuk trek ini. Tidak ada hal lain yang dikirim.",
+ "lyrics.noTrack": "Buka trek untuk mencari liriknya.",
+ "lyrics.nothingKnown": "Trek ini tidak memiliki tag artis atau lagu. Simpan band-nya dengan tombol ⓘ di kartu Sedang diputar, lalu liriknya akan dicari di sini.",
+ "lyrics.removed": "Lirik untuk trek ini dihapus.",
+ "lyrics.lookAgain": "Cari lagi",
+ "lyrics.loading": "Mencari “{song}” di LRCLIB…",
+ "lyrics.notFound": "Lirik untuk “{song}” tidak ditemukan.",
+ "lyrics.offline": "Tidak dapat terhubung ke LRCLIB. Periksa koneksi Anda lalu coba lagi.",
+ "lyrics.instrumental": "Versi ini ditandai instrumental, jadi tidak ada liriknya.",
+ "lyrics.otherVersions": "Versi lain ({count})",
+ "lyrics.hideVersions": "Sembunyikan versi lain",
+ "lyrics.remove": "Hapus lirik",
+ "lyrics.synced": "Tersinkron",
+ "lyrics.plain": "Hanya teks",
+ "lyrics.seekTitle": "Lompat ke baris ini",
"extract.all": "Semua",
"extract.moreAria": "Pilih stem",
"process.splitStems": "Pisahkan stem",
@@ -4400,6 +4687,47 @@ const fr = {
"topbar.uploadFile": "Importer un fichier audio",
"topbar.website": "Ouvrir stemdeck.app",
"extract.label": "Extraire",
+ "track.nowPlaying": "Lecture en cours",
+ "artist.openTitle": "À propos de cet artiste",
+ "artist.dialogAria": "À propos de l'artiste",
+ "artist.close": "Fermer",
+ "artist.searchPlaceholder": "Rechercher un groupe ou un artiste",
+ "artist.searchAria": "Groupe ou artiste à rechercher",
+ "artist.search": "Rechercher",
+ "artist.privacy": "Seul un nom d'artiste est envoyé à Wikipédia et Wikidata : celui que vous cherchez ici, et l'artiste des tags d'un morceau quand vous l'ouvrez.",
+ "artist.loading": "Recherche de « {name} » sur Wikipédia…",
+ "artist.notThisBand": "Pas ce groupe ? Rechercher",
+ "artist.checkingTrack": "Recherche de l'artiste de ce morceau…",
+ "artist.searchInstead": "Rechercher plutôt par nom",
+ "artist.notFound": "Aucun groupe ni artiste sur Wikipédia ne correspond à « {name} ».",
+ "artist.offline": "Impossible de joindre Wikipédia. Vérifiez votre connexion et réessayez.",
+ "artist.history": "Histoire",
+ "artist.members": "Membres",
+ "artist.formerMembers": "Anciens membres",
+ "artist.albums": "Albums studio",
+ "artist.readMore": "Lire la suite sur Wikipédia",
+ "artist.source": "Depuis Wikipédia et Wikidata, CC BY-SA",
+ "artist.setForTrack": "Enregistrer pour ce morceau",
+ "artist.savedForTrack": "Enregistré pour ce morceau",
+ "nav.lyrics": "Paroles",
+ "lyrics.title": "Paroles",
+ "lyrics.fromFile": "Depuis le fichier",
+ "lyrics.pickVersion": "Cette chanson est sur LRCLIB, mais pas dans une version de la même durée. Choisissez la bonne :",
+ "lyrics.privacy": "Recherchées sur LRCLIB d'après l'artiste et la chanson des tags du fichier, ou le groupe enregistré pour ce morceau. Rien d'autre n'est envoyé.",
+ "lyrics.noTrack": "Ouvrez un morceau pour trouver ses paroles.",
+ "lyrics.nothingKnown": "Ce morceau n'a pas de tags d'artiste ni de chanson. Enregistrez son groupe avec le bouton ⓘ de la carte Lecture en cours, et ses paroles seront recherchées ici.",
+ "lyrics.removed": "Paroles supprimées pour ce morceau.",
+ "lyrics.lookAgain": "Rechercher à nouveau",
+ "lyrics.loading": "Recherche de « {song} » sur LRCLIB…",
+ "lyrics.notFound": "Aucunes paroles trouvées pour « {song} ».",
+ "lyrics.offline": "Impossible de joindre LRCLIB. Vérifiez votre connexion et réessayez.",
+ "lyrics.instrumental": "Cette version est indiquée comme instrumentale : elle n'a pas de paroles.",
+ "lyrics.otherVersions": "Autres versions ({count})",
+ "lyrics.hideVersions": "Masquer les autres versions",
+ "lyrics.remove": "Supprimer les paroles",
+ "lyrics.synced": "Synchronisées",
+ "lyrics.plain": "Texte seul",
+ "lyrics.seekTitle": "Aller à cette ligne",
"extract.all": "Tout",
"extract.moreAria": "Choisir les pistes",
"process.splitStems": "Séparer les pistes",
@@ -5031,6 +5359,22 @@ const ptPT = {
"topbar.removeFile": "Remover ficheiro",
"topbar.uploadFile": "Carregar ficheiro de áudio",
"library.importedFile": "Ficheiro importado",
+ "track.nowPlaying": "A reproduzir",
+ "artist.privacy": "Só o nome de um artista é enviado para a Wikipédia e o Wikidata: o que pesquisa aqui e o artista das etiquetas de uma faixa quando a abre.",
+ "artist.loading": "A procurar “{name}” na Wikipédia…",
+ "artist.checkingTrack": "A procurar o artista desta faixa…",
+ "artist.offline": "Não foi possível aceder à Wikipédia. Verifique a sua ligação e tente novamente.",
+ "artist.members": "Membros",
+ "artist.formerMembers": "Antigos membros",
+ "artist.setForTrack": "Guardar nesta faixa",
+ "artist.savedForTrack": "Guardado nesta faixa",
+ "lyrics.nothingKnown": "Esta faixa não tem etiquetas de artista nem de música. Guarde a banda com o botão ⓘ no cartão A reproduzir e a letra será procurada aqui.",
+ "lyrics.lookAgain": "Procurar de novo",
+ "lyrics.privacy": "Procurada no LRCLIB pelo artista e pela música das etiquetas do ficheiro, ou pela banda guardada nesta faixa. Nada mais é enviado.",
+ "lyrics.loading": "A procurar “{song}” no LRCLIB…",
+ "lyrics.offline": "Não foi possível aceder ao LRCLIB. Verifique a sua ligação e tente novamente.",
+ "lyrics.fromFile": "Do ficheiro",
+ "lyrics.instrumental": "Esta versão está marcada como instrumental, por isso não tem letra.",
"settings.cookies.invalid": "Ficheiro não encontrado ou ilegível.",
"upload.unsupportedFormat": "Apenas ficheiros MP3, WAV, FLAC, MP4, M4A, OGG e Opus são compatíveis.",
"upload.fileTooLarge": "O ficheiro é demasiado grande ({size}). O máximo é {max}.",
@@ -5111,6 +5455,47 @@ const es = {
"topbar.uploadFile": "Subir archivo de audio",
"topbar.website": "Abrir stemdeck.app",
"extract.label": "Extraer",
+ "track.nowPlaying": "Reproduciendo",
+ "artist.openTitle": "Sobre este artista",
+ "artist.dialogAria": "Sobre el artista",
+ "artist.close": "Cerrar",
+ "artist.searchPlaceholder": "Busca un grupo o artista",
+ "artist.searchAria": "Grupo o artista que buscar",
+ "artist.search": "Buscar",
+ "artist.privacy": "Solo se envía a Wikipedia y Wikidata el nombre de un artista: el que buscas aquí y el artista de las etiquetas de una pista cuando la abres.",
+ "artist.loading": "Buscando «{name}» en Wikipedia…",
+ "artist.notThisBand": "¿No es esta banda? Buscar",
+ "artist.checkingTrack": "Buscando el artista de esta pista…",
+ "artist.searchInstead": "Buscar por nombre",
+ "artist.notFound": "Ningún grupo ni artista de Wikipedia coincide con «{name}».",
+ "artist.offline": "No se pudo conectar con Wikipedia. Comprueba la conexión e inténtalo de nuevo.",
+ "artist.history": "Historia",
+ "artist.members": "Miembros",
+ "artist.formerMembers": "Antiguos miembros",
+ "artist.albums": "Álbumes de estudio",
+ "artist.readMore": "Leer más en Wikipedia",
+ "artist.source": "De Wikipedia y Wikidata, CC BY-SA",
+ "artist.setForTrack": "Guardar para esta pista",
+ "artist.savedForTrack": "Guardado para esta pista",
+ "nav.lyrics": "Letra",
+ "lyrics.title": "Letra",
+ "lyrics.fromFile": "Del archivo",
+ "lyrics.pickVersion": "Esta canción está en LRCLIB, pero no en una versión con la misma duración que esta pista. Elige la correcta:",
+ "lyrics.privacy": "Se busca en LRCLIB por el artista y la canción de las etiquetas del archivo, o por el grupo guardado en esta pista. No se envía nada más.",
+ "lyrics.noTrack": "Abre una pista para encontrar su letra.",
+ "lyrics.nothingKnown": "Esta pista no tiene etiquetas de artista ni de canción. Guarda su grupo con el botón ⓘ de la tarjeta Reproduciendo y su letra se buscará aquí.",
+ "lyrics.removed": "Letra quitada de esta pista.",
+ "lyrics.lookAgain": "Buscar de nuevo",
+ "lyrics.loading": "Buscando «{song}» en LRCLIB…",
+ "lyrics.notFound": "No se encontró letra para «{song}».",
+ "lyrics.offline": "No se pudo conectar con LRCLIB. Comprueba la conexión e inténtalo de nuevo.",
+ "lyrics.instrumental": "Esta versión está marcada como instrumental, así que no tiene letra.",
+ "lyrics.otherVersions": "Otras versiones ({count})",
+ "lyrics.hideVersions": "Ocultar otras versiones",
+ "lyrics.remove": "Quitar letra",
+ "lyrics.synced": "Sincronizada",
+ "lyrics.plain": "Solo texto",
+ "lyrics.seekTitle": "Ir a esta línea",
"extract.all": "Todo",
"extract.moreAria": "Elegir las pistas",
"process.splitStems": "Separar stems",
@@ -5727,6 +6112,47 @@ const ko = {
"topbar.uploadFile": "오디오 파일 업로드",
"topbar.website": "stemdeck.app 열기",
"extract.label": "추출",
+ "track.nowPlaying": "재생 중",
+ "artist.openTitle": "이 아티스트 정보",
+ "artist.dialogAria": "아티스트 정보",
+ "artist.close": "닫기",
+ "artist.searchPlaceholder": "밴드나 아티스트 검색",
+ "artist.searchAria": "찾아볼 밴드 또는 아티스트",
+ "artist.search": "검색",
+ "artist.privacy": "위키백과와 위키데이터에는 아티스트 이름만 전송됩니다. 여기서 검색한 이름과, 트랙을 열 때 태그에 있는 아티스트입니다.",
+ "artist.loading": "위키백과에서 '{name}' 검색 중…",
+ "artist.notThisBand": "이 밴드가 아닌가요? 검색",
+ "artist.checkingTrack": "이 트랙의 아티스트를 찾는 중…",
+ "artist.searchInstead": "이름으로 검색하기",
+ "artist.notFound": "위키백과에서 '{name}'와(과) 일치하는 밴드나 아티스트를 찾지 못했습니다.",
+ "artist.offline": "위키백과에 연결할 수 없습니다. 연결을 확인하고 다시 시도하세요.",
+ "artist.history": "역사",
+ "artist.members": "멤버",
+ "artist.formerMembers": "전 멤버",
+ "artist.albums": "정규 앨범",
+ "artist.readMore": "위키백과에서 더 읽기",
+ "artist.source": "출처: 위키백과, 위키데이터 (CC BY-SA)",
+ "artist.setForTrack": "이 트랙에 저장",
+ "artist.savedForTrack": "이 트랙에 저장됨",
+ "nav.lyrics": "가사",
+ "lyrics.title": "가사",
+ "lyrics.fromFile": "파일에서",
+ "lyrics.pickVersion": "LRCLIB에 이 곡이 있지만 이 트랙과 길이가 같은 버전은 없습니다. 맞는 것을 고르세요:",
+ "lyrics.privacy": "파일 태그의 아티스트와 곡, 또는 이 트랙에 저장한 밴드로 LRCLIB에서 찾습니다. 그 밖에는 아무것도 보내지 않습니다.",
+ "lyrics.noTrack": "가사를 찾으려면 트랙을 여세요.",
+ "lyrics.nothingKnown": "이 트랙에는 아티스트나 곡 태그가 없습니다. 재생 중 카드의 ⓘ 버튼으로 밴드를 저장하면 여기서 가사를 찾습니다.",
+ "lyrics.removed": "이 트랙의 가사를 삭제했습니다.",
+ "lyrics.lookAgain": "다시 찾기",
+ "lyrics.loading": "LRCLIB에서 '{song}' 검색 중…",
+ "lyrics.notFound": "'{song}'의 가사를 찾지 못했습니다.",
+ "lyrics.offline": "LRCLIB에 연결할 수 없습니다. 연결을 확인하고 다시 시도하세요.",
+ "lyrics.instrumental": "이 버전은 연주곡으로 표시되어 가사가 없습니다.",
+ "lyrics.otherVersions": "다른 버전 ({count})",
+ "lyrics.hideVersions": "다른 버전 숨기기",
+ "lyrics.remove": "가사 삭제",
+ "lyrics.synced": "동기화",
+ "lyrics.plain": "텍스트만",
+ "lyrics.seekTitle": "이 줄로 이동",
"extract.all": "전체",
"extract.moreAria": "추출할 스템 선택",
"process.splitStems": "스템 분리",
diff --git a/static/js/job.js b/static/js/job.js
index 72c081bc..38ca2069 100644
--- a/static/js/job.js
+++ b/static/js/job.js
@@ -9,7 +9,7 @@ import {
import { destroyPlayer, wireUpAudio, setWaveformLoading, updateFooterTrack } from "./player.js";
import { notifyFailure, dismissFailuresByJobId } from "./notifications.js";
import { getStagePhrases } from "./phrases.js";
-import { addTrackToLibrary, setCurrentTrack, updateTrackStatus, applyStemPresenceCards } from "./catalog.js";
+import { addTrackToLibrary, setCurrentTrack, updateTrackStatus, applyStemPresenceCards, libraryAudioTags, libraryArtist, paintFinishedTrackNames } from "./catalog.js";
import { initSections } from "./sections.js";
import { importPlaylist, looksLikePlaylist } from "./playlist.js";
import { t } from "./i18n.js";
@@ -344,6 +344,9 @@ async function finishDoneJob(state) {
finalState.has_video ?? false,
);
initSections(finalState.job_id, finalState.sections, finalState.duration || 0);
+ // The card names the song and its band now, as it does when the track is
+ // opened from the library, rather than only after it is reopened (#699).
+ paintFinishedTrackNames(finalState.job_id);
}
function applyState(state) {
@@ -377,6 +380,13 @@ function applyState(state) {
sectionsSource: state.sections_source,
sourceUrl: jobSources.get(state.job_id) || (isForeground ? urlInput.value : ""),
createdAt: state.created_at,
+ // So a track that has just finished importing already knows its artist
+ // and title (#699), rather than only once it is reopened from the library.
+ ...(state.audio_tags ? { audioTags: libraryAudioTags(state.audio_tags) } : {}),
+ // And its band, found by the server while the job ran, so the artist box
+ // and the Lyrics tab have it with no lookup of their own. A band already
+ // saved on the track is kept (see addTrackToLibrary).
+ ...(libraryArtist(state.artist) ? { artist: libraryArtist(state.artist) } : {}),
});
}
diff --git a/static/js/lyrics.js b/static/js/lyrics.js
new file mode 100644
index 00000000..1dfa4e51
--- /dev/null
+++ b/static/js/lyrics.js
@@ -0,0 +1,492 @@
+// The Lyrics tab: a view of the sidebar where the open track's lyrics are
+// found, kept, and shown in time with playback, karaoke style (#699).
+//
+// Where they come from, in order:
+// 1. lyrics the file itself carried (an upload's embedded lyrics tag);
+// 2. LRCLIB (lyricsLookup.js), looked up by itself when the tab opens, by
+// the artist and song the file was tagged with, or failing that the band
+// saved from the artist box and the song's name cleaned out of the
+// title. Kept straight away only when a version is the same length as
+// the track; otherwise the versions are offered to pick from.
+// There is no search box: what is looked up is what the track says it is.
+// What was kept belongs to the track and comes back whenever it is open.
+//
+// Synced lyrics follow playback: the line being sung fills with colour word by
+// word, the next line is raised a step, the view keeps the line in the middle,
+// and clicking a line moves the playhead there. Plain lyrics are shown as text.
+//
+// Everything that came from the network or a file's tags goes in with
+// textContent, never as HTML: anyone can edit LRCLIB or a tag, and this page
+// can reach the desktop app's native commands.
+
+import { t } from "./i18n.js";
+import { storeGet, storeSet } from "./utils.js";
+import { getCurrentTrackInfo, getCurrentTrackArtist } from "./catalog.js";
+import { transport, setPlayheadTime } from "./transport.js";
+import {
+ searchLyrics,
+ parseLrc,
+ currentLineIndex,
+ wordTimings,
+ songFromTitle,
+} from "./lyricsLookup.js";
+
+// One store entry per track rather than a field in the library store: a song's
+// lyrics run to a few kilobytes, and the library is rewritten whole on every
+// change to any track.
+const storeKey = (trackId) => `stemdeck.lyrics.${trackId}`;
+
+// Within this many seconds of the track's length, an automatic search keeps a
+// version without asking. LRCLIB lengths come from real releases, so the same
+// recording lands within a second or two; a live cut or a radio edit does not.
+const SAME_RECORDING_SEC = 3;
+
+const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
+
+let statusEl = null;
+let versionsEl = null;
+let bodyEl = null;
+
+let visible = false;
+let shownTrackId = null; // the track whose lyrics are on screen
+let lines = []; // parsed synced lines of what is shown, [] for plain text
+let lineEls = [];
+let lineWords = []; // per line: [{ el, start, end }] for the wipe
+let currentIndex = -1;
+let frame = 0;
+let searchController = null;
+let loadToken = 0; // guards against a slow store read landing after a switch
+// The track the tab said "nothing known" for, so tags read for it after it
+// was opened (catalog.js, "tracktags") can be looked up without a reopen.
+let nothingKnownFor = null;
+// Per track, a lookup this session that kept nothing: { song, matches }, the
+// versions offered or none. Opening the tab again shows it rather than asking
+// LRCLIB the same question.
+const answered = new Map();
+// The vocals stem's level over time for one track, { hop, db }, which times
+// the wipe to the singing (lyricsLookup.js wordTimings). Only the open
+// track's is kept: it is asked for again, cheaply, when a track comes back.
+let envelope = null;
+let envelopeFor = null;
+let envelopeAsked = null; // the track whose envelope is being fetched
+
+function el(tag, className, text) {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text != null) node.textContent = text;
+ return node;
+}
+
+function setStatus(message = "", kind = "") {
+ statusEl.textContent = message;
+ statusEl.className = `lyrics-status${kind ? ` ${kind}` : ""}`;
+}
+
+function fmtLength(seconds) {
+ const s = Math.round(Number(seconds) || 0);
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
+}
+
+function clearLyrics() {
+ lines = [];
+ lineEls = [];
+ lineWords = [];
+ currentIndex = -1;
+ bodyEl.replaceChildren();
+ bodyEl.classList.remove("synced");
+ versionsEl.replaceChildren();
+}
+
+/** A synced line as a button of word spans, which the wipe fills in turn. */
+function lineButton(line, nextTime) {
+ const row = el("button", "lyrics-line");
+ row.type = "button";
+ row.title = t("lyrics.seekTitle");
+ row.addEventListener("click", () => setPlayheadTime(line.time));
+ const words = [];
+ for (const word of wordTimings(line, nextTime, envelopeFor === shownTrackId ? envelope : null)) {
+ const span = el("span", "lw", word.text);
+ row.append(span);
+ words.push({ el: span, start: word.start, end: word.end });
+ }
+ // An empty stamp marks an instrumental gap in LRC; a note keeps its place.
+ if (!words.length) row.textContent = "♪";
+ lineWords.push(words);
+ return row;
+}
+
+/** Draw what is kept for the track: `entry` as saved, `others` to offer. */
+function show(entry, others = []) {
+ clearLyrics();
+ if (!entry) return;
+
+ if (entry.instrumental && !entry.synced && !entry.plain) {
+ setStatus(t("lyrics.instrumental"), "muted");
+ } else {
+ setStatus("");
+ }
+
+ const head = el("div", "lyrics-match");
+ head.append(
+ el("span", "lyrics-match-title", entry.track),
+ el("span", "lyrics-match-meta", [
+ entry.artist,
+ entry.album,
+ entry.source === "file" ? t("lyrics.fromFile") : "",
+ ].filter(Boolean).join(" · ")),
+ );
+ const tools = el("div", "lyrics-tools");
+ versionsEl.append(head, tools);
+ if (others.length) {
+ const toggle = el("button", "lyrics-link", t("lyrics.otherVersions", { count: others.length }));
+ toggle.type = "button";
+ toggle.setAttribute("aria-expanded", "false");
+ const list = versionList(others, [entry, ...others]);
+ list.hidden = true;
+ toggle.addEventListener("click", () => {
+ list.hidden = !list.hidden;
+ toggle.setAttribute("aria-expanded", String(!list.hidden));
+ toggle.textContent = t(list.hidden ? "lyrics.otherVersions" : "lyrics.hideVersions", { count: others.length });
+ });
+ tools.append(toggle);
+ versionsEl.append(list);
+ }
+ const remove = el("button", "lyrics-link", t("lyrics.remove"));
+ remove.type = "button";
+ remove.addEventListener("click", () => removeLyrics());
+ tools.append(remove);
+
+ if (entry.synced) {
+ lines = parseLrc(entry.synced);
+ lineEls = lines.map((line, i) => lineButton(line, lines[i + 1]?.time ?? null));
+ bodyEl.append(...lineEls);
+ bodyEl.classList.add("synced");
+ if (lines.length) loadEnvelope(shownTrackId);
+ } else if (entry.plain) {
+ for (const text of entry.plain.split(/\r?\n/)) bodyEl.append(el("p", "lyrics-text", text || " "));
+ }
+ bodyEl.scrollTop = 0;
+}
+
+/**
+ * Fetch the vocals stem's level for `trackId`, once, and re-time the lines on
+ * screen by it. Until it arrives, and for a track with no vocals stem (a 404),
+ * the wipe keeps the estimate from the time between lines.
+ */
+async function loadEnvelope(trackId) {
+ if (!trackId || envelopeFor === trackId || envelopeAsked === trackId) return;
+ envelopeAsked = trackId;
+ try {
+ const res = await fetch(`/api/jobs/${encodeURIComponent(trackId)}/vocal-envelope`);
+ if (!res.ok) return;
+ const data = await res.json();
+ if (!(Number(data?.hop) > 0) || !Array.isArray(data?.db)) return;
+ envelope = { hop: Number(data.hop), db: Int8Array.from(data.db) };
+ envelopeFor = trackId;
+ if (trackId === shownTrackId) retime();
+ } catch (err) {
+ console.warn("vocal envelope fetch failed", err);
+ } finally {
+ if (envelopeAsked === trackId) envelopeAsked = null;
+ }
+}
+
+/** Give the words on screen the envelope's timings: once, not per frame. */
+function retime() {
+ lines.forEach((line, i) => {
+ const words = lineWords[i];
+ const timed = wordTimings(line, lines[i + 1]?.time ?? null, envelope);
+ if (!words || timed.length !== words.length) return;
+ timed.forEach((word, k) => {
+ words[k].start = word.start;
+ words[k].end = word.end;
+ });
+ });
+}
+
+function versionList(matches, all) {
+ const list = el("ul", "lyrics-version-list");
+ for (const match of matches) {
+ const item = el("li");
+ const pick = el("button", "lyrics-version");
+ pick.type = "button";
+ pick.append(
+ el("span", "lyrics-version-title", match.track),
+ el("span", "lyrics-version-meta", [
+ match.artist,
+ match.album,
+ match.duration ? fmtLength(match.duration) : "",
+ t(match.synced ? "lyrics.synced" : "lyrics.plain"),
+ ].filter(Boolean).join(" · ")),
+ );
+ pick.addEventListener("click", () => keep(match, all.filter((m) => m.id !== match.id)));
+ item.append(pick);
+ list.append(item);
+ }
+ return list;
+}
+
+/** Offer `matches` to choose from without keeping any: LRCLIB has this song,
+ * but not a version the length of this track. */
+function offer(matches) {
+ clearLyrics();
+ setStatus(t("lyrics.pickVersion"), "muted");
+ versionsEl.append(versionList(matches, matches));
+}
+
+/** Save `match` on the open track and show it, with `others` still offered. */
+async function keep(match, others) {
+ const info = getCurrentTrackInfo();
+ if (!info) return;
+ const entry = { v: 1, source: "lrclib", ...match, savedAt: Date.now() };
+ await storeSet(storeKey(info.id), { entry, others });
+ answered.delete(info.id);
+ if (getCurrentTrackInfo()?.id !== info.id) return;
+ show(entry, others);
+}
+
+// Removing is remembered, not just a cleared entry: the lookup runs by itself,
+// and would otherwise bring the same lyrics straight back the next time the
+// tab opened. "Look up again" is the way back.
+async function removeLyrics() {
+ const info = getCurrentTrackInfo();
+ if (!info) return;
+ await storeSet(storeKey(info.id), { dismissed: true });
+ answered.delete(info.id);
+ showRemoved();
+}
+
+function showRemoved() {
+ clearLyrics();
+ setStatus(t("lyrics.removed"), "muted");
+ versionsEl.append(lookAgainButton());
+}
+
+/** "Look up again": forget what is kept for the open track and find it anew. */
+function lookAgainButton() {
+ const again = el("button", "lyrics-link", t("lyrics.lookAgain"));
+ again.type = "button";
+ again.addEventListener("click", async () => {
+ const info = getCurrentTrackInfo();
+ if (!info) return;
+ await storeSet(storeKey(info.id), null);
+ loadForCurrentTrack();
+ });
+ return again;
+}
+
+/**
+ * Look the open track up on LRCLIB by what it is known to be. A version the
+ * same length as the track is kept straight away; otherwise the versions are
+ * offered to pick from; with none, it says so. What it found is remembered for
+ * the session, so opening the tab again does not ask again. A failed
+ * connection is not, so the next opening tries again.
+ */
+async function lookUp(info, { artist, song }) {
+ searchController?.abort();
+ const controller = new AbortController();
+ searchController = controller;
+ setStatus(t("lyrics.loading", { song }), "loading");
+ try {
+ const matches = await searchLyrics({ artist, song, duration: info.duration }, { signal: controller.signal });
+ if (controller.signal.aborted || getCurrentTrackInfo()?.id !== info.id) return;
+ const best = matches[0];
+ const sameLength = Boolean(best) && info.duration > 0 && best.duration > 0
+ && Math.abs(best.duration - info.duration) <= SAME_RECORDING_SEC;
+ if (sameLength) {
+ await keep(best, matches.slice(1, 12));
+ return;
+ }
+ answered.set(info.id, { song, matches: matches.slice(0, 12) });
+ showAnswer(answered.get(info.id));
+ } catch (err) {
+ // Aborted only by another track being opened; a timeout aborts the
+ // lookup's own inner signal and lands below, as a failure.
+ if (controller.signal.aborted) return;
+ console.warn("lyrics lookup failed", err);
+ setStatus(t("lyrics.offline"), "error");
+ // There is no search box to press again, so the retry is offered here.
+ versionsEl.replaceChildren(lookAgainButton());
+ } finally {
+ if (searchController === controller) searchController = null;
+ }
+}
+
+/** A lookup's answer that kept nothing: versions to pick from, or none. */
+function showAnswer({ song, matches }) {
+ if (matches.length) offer(matches);
+ else setStatus(t("lyrics.notFound", { song }), "muted");
+}
+
+/** The lyrics a track's file carried, from the server, or "" if unavailable. */
+async function embeddedLyrics(trackId) {
+ try {
+ const res = await fetch(`/api/jobs/${encodeURIComponent(trackId)}`);
+ if (!res.ok) return "";
+ const state = await res.json();
+ return typeof state?.audio_tags?.lyrics === "string" ? state.audio_tags.lyrics : "";
+ } catch (err) {
+ console.warn("embedded lyrics fetch failed", err);
+ return "";
+ }
+}
+
+/**
+ * The artist and song the track is known to be, best source first: the
+ * file's tags, then the artist (tagged, or the band saved from the artist box)
+ * with the song's name cleaned out of the title. An empty song means nothing
+ * is known well enough to look up.
+ */
+function knownSong(info) {
+ const tags = info.audioTags || {};
+ const band = getCurrentTrackArtist();
+ const artist = tags.artist || band?.englishName || band?.name || "";
+ if (tags.title) return { artist, song: songFromTitle(tags.title, artist) || tags.title };
+ if (!artist) return { artist, song: "" };
+ return { artist, song: songFromTitle(info.title, artist) };
+}
+
+/** Show what is kept for the open track, or find it. */
+async function loadForCurrentTrack() {
+ const info = getCurrentTrackInfo();
+ shownTrackId = info?.id ?? null;
+ nothingKnownFor = null;
+ searchController?.abort();
+ searchController = null;
+ clearLyrics();
+ const token = ++loadToken;
+ if (!info) {
+ setStatus(t("lyrics.noTrack"), "muted");
+ return;
+ }
+ setStatus("");
+ let saved = null;
+ try {
+ saved = await storeGet(storeKey(info.id), null);
+ } catch (err) {
+ console.warn("lyrics store read failed", err);
+ }
+ if (token !== loadToken) return;
+ if (saved?.dismissed) {
+ showRemoved();
+ return;
+ }
+ if (saved?.entry) {
+ show(saved.entry, Array.isArray(saved.others) ? saved.others : []);
+ return;
+ }
+
+ // Lyrics the file came with are this recording's own: nothing to look up.
+ // The library keeps only that there are some (libraryAudioTags); the text is
+ // asked of the server once, then kept in this track's own entry below.
+ const { artist, song } = knownSong(info);
+ const embedded = info.audioTags?.lyrics || (info.audioTags?.hasLyrics ? await embeddedLyrics(info.id) : "");
+ if (token !== loadToken) return;
+ if (embedded) {
+ const synced = parseLrc(embedded).length ? embedded : "";
+ await keep({
+ id: 0,
+ source: "file",
+ track: info.audioTags.title || song || info.title,
+ artist,
+ album: info.audioTags.album || "",
+ duration: info.duration,
+ instrumental: false,
+ synced,
+ plain: synced ? "" : embedded,
+ }, []);
+ return;
+ }
+ if (!song) {
+ setStatus(t("lyrics.nothingKnown"), "muted");
+ nothingKnownFor = info.id;
+ return;
+ }
+ if (answered.has(info.id)) {
+ showAnswer(answered.get(info.id));
+ return;
+ }
+ lookUp(info, { artist, song });
+}
+
+// While the tab is on screen: notice a different track being opened, mark the
+// line being sung and fill its words. One frame loop rather than events,
+// because playback time has no event of its own here and the loop stops the
+// moment the tab is hidden.
+function tick() {
+ frame = 0;
+ if (!visible) return;
+ const id = getCurrentTrackInfo()?.id ?? null;
+ if (id !== shownTrackId) loadForCurrentTrack();
+ if (lines.length) {
+ // 50ms ahead: a click on a line seeks to its exact stamp, and the time read
+ // back can land a hair before it, which would mark the line above.
+ const now = (transport()?.getCurrentTime?.() ?? 0) + 0.05;
+ const index = currentLineIndex(lines, now);
+ if (index !== currentIndex) moveTo(index);
+ fillWords(index, now);
+ }
+ frame = requestAnimationFrame(tick);
+}
+
+/** Make `index` the line being sung: marks, the next line, the scroll. */
+function moveTo(index) {
+ const before = lineEls[currentIndex];
+ if (before) {
+ before.classList.remove("current");
+ for (const word of lineWords[currentIndex] || []) {
+ word.el.classList.remove("sung", "singing");
+ word.el.style.removeProperty("--fill");
+ }
+ }
+ lineEls[currentIndex + 1]?.classList.remove("next");
+ currentIndex = index;
+ lineEls[index + 1]?.classList.add("next");
+ const line = lineEls[index];
+ if (!line) return;
+ line.classList.add("current");
+ // Only the lyrics box scrolls: scrollIntoView would also move the sidebar
+ // and the page if they had anything to give.
+ bodyEl.scrollTo({
+ top: line.offsetTop - bodyEl.clientHeight / 2 + line.offsetHeight / 2,
+ behavior: reducedMotion.matches ? "auto" : "smooth",
+ });
+}
+
+/** The wipe: words already sung in colour, the one being sung part-filled. */
+function fillWords(index, now) {
+ for (const word of lineWords[index] || []) {
+ const sung = now >= word.end;
+ const singing = !sung && now > word.start;
+ word.el.classList.toggle("sung", sung);
+ word.el.classList.toggle("singing", singing);
+ if (singing) {
+ const share = (now - word.start) / (word.end - word.start);
+ word.el.style.setProperty("--fill", `${Math.round(share * 100)}%`);
+ }
+ }
+}
+
+function setVisible(on) {
+ visible = on;
+ if (on) {
+ loadForCurrentTrack();
+ if (!frame) frame = requestAnimationFrame(tick);
+ } else if (frame) {
+ cancelAnimationFrame(frame);
+ frame = 0;
+ }
+}
+
+export function initLyrics() {
+ const panel = document.getElementById("lyricsPanel");
+ statusEl = document.getElementById("lyricsStatus");
+ versionsEl = document.getElementById("lyricsVersions");
+ bodyEl = document.getElementById("lyricsBody");
+ if (!panel || !statusEl || !versionsEl || !bodyEl) return;
+
+ document.addEventListener("catalogviewchange", (e) => setVisible(e.detail?.view === "lyrics"));
+ document.addEventListener("tracktags", (e) => {
+ if (visible && nothingKnownFor && e.detail?.id === nothingKnownFor) loadForCurrentTrack();
+ });
+}
diff --git a/static/js/lyricsLookup.js b/static/js/lyricsLookup.js
new file mode 100644
index 00000000..e13b3f2f
--- /dev/null
+++ b/static/js/lyricsLookup.js
@@ -0,0 +1,440 @@
+// Lyrics for the open track, from LRCLIB (lrclib.net), for the Lyrics tab in
+// the sidebar (#699).
+//
+// LRCLIB because it is free, open and needs no key, and because most of what
+// it holds is time-synced: each line carries the moment it is sung, so the tab
+// can follow playback and a line can be clicked to jump there, which is what
+// practising against the stems wants. The lyrics themselves are copyright of
+// their writers; they are fetched when the Lyrics tab is opened on a track
+// that has none kept, and kept on this machine for that track, never sent on.
+//
+// What is sent is the artist and song the track is known to be (its tags, or
+// the saved band and the song's name from the title), nothing else.
+//
+// No DOM here, and fetch is passed in, so this runs under node for the unit
+// tests (tests/js/lyrics-lookup.test.mjs).
+
+const LRCLIB_SEARCH = "https://lrclib.net/api/search";
+const TIMEOUT_MS = 12000;
+
+// Among versions this far apart or closer, prefer the one with synced lyrics.
+// LRCLIB holds the same song many times over (live cuts, remasters, a rip with
+// a second of silence trimmed), and a synced copy a second off beats a plain
+// one that matches to the frame.
+const SAME_LENGTH_SEC = 3;
+
+/**
+ * One LRCLIB row, reduced to what the tab uses. Rows with neither kind of
+ * lyrics and no instrumental flag say nothing, and are dropped by the caller.
+ */
+function normalise(row) {
+ return {
+ id: Number(row?.id) || 0,
+ track: String(row?.trackName || ""),
+ artist: String(row?.artistName || ""),
+ album: String(row?.albumName || ""),
+ duration: Number(row?.duration) || 0,
+ instrumental: Boolean(row?.instrumental),
+ synced: typeof row?.syncedLyrics === "string" ? row.syncedLyrics : "",
+ plain: typeof row?.plainLyrics === "string" ? row.plainLyrics : "",
+ };
+}
+
+/**
+ * Best first, for a track `duration` seconds long (0 when unknown):
+ * closest in length, then synced over plain among versions within
+ * SAME_LENGTH_SEC of each other, then whatever LRCLIB ranked first.
+ */
+export function rankMatches(rows, duration = 0) {
+ const matches = (Array.isArray(rows) ? rows : [])
+ .map(normalise)
+ .filter((m) => m.id && (m.synced || m.plain || m.instrumental));
+ const off = (m) => (duration && m.duration ? Math.abs(m.duration - duration) : 0);
+ return matches
+ .map((m, rank) => ({ m, rank }))
+ .sort((a, b) => {
+ const da = off(a.m);
+ const db = off(b.m);
+ if (Math.abs(da - db) > SAME_LENGTH_SEC) return da - db;
+ if (Boolean(a.m.synced) !== Boolean(b.m.synced)) return a.m.synced ? -1 : 1;
+ return da - db || a.rank - b.rank;
+ })
+ .map(({ m }) => m);
+}
+
+/**
+ * Timed lines from LRC text, earliest first: [{ time, text }], time in
+ * seconds. A line sung more than once carries several stamps
+ * ("[00:12.00][01:40.00]Chorus") and appears once per stamp. Header tags
+ * ("[ar:...]") are skipped, and "[offset:+250]" shifts every line by that many
+ * milliseconds, earlier for a positive value, as the format defines.
+ *
+ * Enhanced LRC times words too ("[00:12.00]<00:12.00>Hello <00:12.60>world"),
+ * and a line that does gets `words`: [{ time, text }], each piece of text up
+ * to the next stamp. Those are what the karaoke wipe follows exactly; a line
+ * without them has its time shared out by wordTimings().
+ */
+export function parseLrc(text) {
+ const lines = [];
+ let offset = 0;
+ for (const raw of String(text || "").split(/\r?\n/)) {
+ const tag = /^\[offset:\s*([+-]?\d+)\s*\]/i.exec(raw.trim());
+ if (tag) {
+ offset = Number(tag[1]) / 1000;
+ continue;
+ }
+ const stamps = [];
+ let rest = raw;
+ let m;
+ while ((m = /^\s*\[(\d{1,3}):(\d{1,2}(?:[.:]\d{1,3})?)\]/.exec(rest))) {
+ stamps.push(Number(m[1]) * 60 + Number(m[2].replace(":", ".")));
+ rest = rest.slice(m[0].length);
+ }
+ if (!stamps.length) continue;
+ const words = wordStamps(rest);
+ const plain = rest.replace(WORD_STAMP, "").replace(/\s+/g, " ").trim();
+ for (const time of stamps) lines.push(words ? { time, text: plain, words } : { time, text: plain });
+ }
+ const shift = (t) => Math.max(0, t - offset);
+ return lines
+ .map((line) => ({
+ ...line,
+ time: shift(line.time),
+ ...(line.words ? { words: line.words.map((w) => ({ ...w, time: shift(w.time) })) } : {}),
+ }))
+ .sort((a, b) => a.time - b.time);
+}
+
+const WORD_STAMP = /<(\d{1,3}):(\d{1,2}(?:[.:]\d{1,3})?)>/g;
+
+/** Word stamps in a line's text, [{ time, text }], or null when it has none. */
+function wordStamps(text) {
+ const pieces = [];
+ let last = null;
+ let cursor = 0;
+ for (const m of text.matchAll(WORD_STAMP)) {
+ if (last) last.text = text.slice(cursor, m.index);
+ last = { time: Number(m[1]) * 60 + Number(m[2].replace(":", ".")), text: "" };
+ pieces.push(last);
+ cursor = m.index + m[0].length;
+ }
+ if (!last) return null;
+ last.text = text.slice(cursor);
+ return pieces;
+}
+
+// Most LRC stamps only when each line starts, so when its words are sung has
+// to be worked out. With the vocals stem's level over time (the envelope,
+// /api/jobs/{id}/vocal-envelope, a level every 40ms) the words are laid over
+// the stretches of the line where the singer is actually singing; without it,
+// over most of the time until the next line.
+//
+// A sung syllable is about a quarter of a second. Words take that, stretched
+// to fill a phrase up to twice over; what a phrase has left beyond that is a
+// note held on its last word.
+const SYLLABLE_SECONDS = 0.25;
+const STRETCH_MAX = 2;
+
+// Without the envelope: a line is sung over 90% of the time until the next
+// one, but never slower than 0.6s a syllable (so a line before an
+// instrumental break does not crawl across it) and never under 1.2s.
+const FALLBACK_SHARE = 0.9;
+const FALLBACK_SECONDS_PER_SYLLABLE = 0.6;
+const MIN_LINE_SECONDS = 1.2;
+// The last line has no next one to stop at.
+const LAST_LINE_SECONDS = 12;
+
+// With the envelope, what counts as singing within a line: within 20dB of the
+// line's loud parts (its 90th percentile), and never below -50dBFS, where a
+// separated vocals stem holds only what bled from the other instruments. A
+// line whose loud parts are under -45dBFS has no singing to follow, and is
+// timed as if there were no envelope.
+const VOICE_RANGE_DB = 20;
+const VOICE_FLOOR_DB = -50;
+const SILENT_LINE_DB = -45;
+const LOUD_PERCENTILE = 0.9;
+// A dip shorter than this is a consonant or a quick breath, not a pause, so
+// the wipe carries on through it; a burst shorter than this is not a word.
+const BRIDGE_SECONDS = 0.25;
+const MIN_PHRASE_SECONDS = 0.12;
+// How far past its stamp a line's singing is looked for: a second a syllable,
+// at least six, so backing vocals deep in an instrumental break are not taken
+// for the line's last word.
+const WINDOW_SECONDS_PER_SYLLABLE = 1;
+const MIN_WINDOW_SECONDS = 6;
+
+// Scripts written without spaces between words, where the wipe moves a
+// character at a time instead.
+const UNSPACED = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;
+
+/** A line's text as the pieces the wipe fills: words, or characters. */
+function lineTokens(text) {
+ return UNSPACED.test(text) && !/\s/.test(text) ? Array.from(text) : text.match(/\S+\s*/g) || [];
+}
+
+/**
+ * About how many syllables `token` is sung in: vowel groups in Latin
+ * script, less a silent final e ("home", "'cause", but not "table"), and
+ * for other scripts one per two or three letters. At least one.
+ */
+export function syllables(token) {
+ const word = token.trim();
+ if (!word) return 0;
+ // The silent e is looked for before accents are stripped: "cafe" with an
+ // acute is two syllables.
+ const lower = word.normalize("NFC").toLowerCase().replace(/[^\p{L}]+$/u, "");
+ const bare = lower.normalize("NFD").replace(/\p{M}/gu, "");
+ if (/[a-z]/.test(bare)) {
+ let count = (bare.match(/[aeiouy]+/g) || []).length;
+ if (count > 1 && /[^aeiouy]e$/.test(lower) && !/[^aeiouy]le$/.test(lower)) count -= 1;
+ return Math.max(1, count);
+ }
+ const letters = Array.from(word.replace(/[\p{P}\p{S}]/gu, "")).length;
+ return Math.max(1, Math.round(letters / 2.5));
+}
+
+/**
+ * The stretches of [from, to) where the envelope says someone is singing:
+ * [{ start, end }] in seconds, earliest first, [] when the window holds no
+ * singing. `envelope` is { hop, db }: a level in dBFS every `hop` seconds.
+ */
+export function voicedPhrases(envelope, from, to) {
+ const { hop, db } = envelope || {};
+ if (!(hop > 0) || !db?.length) return [];
+ const first = Math.max(0, Math.ceil(from / hop - 1e-9));
+ const last = Math.min(db.length, Math.floor(to / hop + 1e-9));
+ if (last - first < 2) return [];
+ const levels = Array.from(db.slice(first, last));
+ const sorted = levels.slice().sort((a, b) => a - b);
+ const loud = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * LOUD_PERCENTILE))];
+ if (loud < SILENT_LINE_DB) return [];
+ const threshold = Math.max(loud - VOICE_RANGE_DB, VOICE_FLOOR_DB);
+ // Frame by frame; a quiet frame inside a word is closed over by the
+ // bridge below, and a loud one alone is dropped as too short.
+ const n = levels.length;
+ const runs = [];
+ let open = -1;
+ for (let i = 0; i <= n; i++) {
+ const voiced = i < n && levels[i] >= threshold;
+ if (voiced && open < 0) open = i;
+ if (!voiced && open >= 0) {
+ runs.push({ start: (first + open) * hop, end: (first + i) * hop });
+ open = -1;
+ }
+ }
+ const phrases = [];
+ for (const run of runs) {
+ const prev = phrases.at(-1);
+ if (prev && run.start - prev.end < BRIDGE_SECONDS) prev.end = run.end;
+ else phrases.push(run);
+ }
+ return phrases
+ .filter((p) => p.end - p.start >= MIN_PHRASE_SECONDS)
+ .map((p) => ({ start: Math.max(p.start, from), end: Math.min(p.end, to) }));
+}
+
+// How badly `nominal` seconds of words fit a phrase `length` long. Words
+// sung faster than a quarter second a syllable cost four times what the same
+// factor slower does, since a held note explains slow but nothing explains
+// fast. A phrase given no words costs its length: a breath the bridge missed
+// is cheap, a sung phrase skipped is not.
+function phraseCost(length, nominal) {
+ if (!nominal) return length * 1.5;
+ const r = Math.log(nominal / length);
+ return r > 0 ? 4 * r * r : r * r;
+}
+
+/**
+ * Which words go in which phrase, as the index of each phrase's first word:
+ * the contiguous split whose word lengths best fit the phrases' lengths.
+ * A phrase may get none. Lines are short (tens of words, a few phrases), so
+ * trying every split costs nothing.
+ */
+function assignWords(phrases, nominal) {
+ const prefix = [0];
+ for (const n of nominal) prefix.push(prefix.at(-1) + n);
+ const words = nominal.length;
+ const best = phrases.map(() => new Array(words + 1).fill(Infinity));
+ const from = phrases.map(() => new Array(words + 1).fill(0));
+ phrases.forEach((phrase, j) => {
+ const length = phrase.end - phrase.start;
+ for (let k = 0; k <= words; k++) {
+ // The last phrase takes every word left.
+ if (j === phrases.length - 1 && k !== words) continue;
+ for (let a = 0; a <= k; a++) {
+ const before = j === 0 ? (a === 0 ? 0 : Infinity) : best[j - 1][a];
+ const cost = before + phraseCost(length, prefix[k] - prefix[a]);
+ if (cost < best[j][k]) {
+ best[j][k] = cost;
+ from[j][k] = a;
+ }
+ }
+ }
+ });
+ const starts = new Array(phrases.length).fill(0);
+ let k = words;
+ for (let j = phrases.length - 1; j >= 0; j--) {
+ starts[j] = from[j][k];
+ k = starts[j];
+ }
+ return starts;
+}
+
+/** Lay `tokens` over one phrase: their own length, stretched at most
+ * STRETCH_MAX times, and the last word held for whatever is left. */
+function layPhrase(tokens, weights, phrase, out) {
+ const length = phrase.end - phrase.start;
+ const nominal = weights.reduce((sum, w) => sum + w * SYLLABLE_SECONDS, 0);
+ const scale = nominal >= length ? length / nominal : Math.min(STRETCH_MAX, length / nominal);
+ let at = phrase.start;
+ tokens.forEach((text, i) => {
+ const end = i === tokens.length - 1 ? phrase.end : at + weights[i] * SYLLABLE_SECONDS * scale;
+ out.push({ text, start: at, end });
+ at = end;
+ });
+}
+
+/**
+ * When each word of `line` is sung, for the karaoke wipe: [{ text, start,
+ * end }], the texts joined giving back the line. From the line's own word
+ * stamps when it has them. Otherwise from `envelope`, the vocals stem's level
+ * ({ hop, db }), when it shows singing in the line: the words fill only while
+ * the singer sings, wait through pauses, and finish when the voice stops.
+ * Otherwise spread over most of the time until the next line. `nextTime` is
+ * when the next line starts, or null for the last.
+ */
+export function wordTimings(line, nextTime, envelope = null) {
+ const limit = nextTime != null && nextTime > line.time ? nextTime : line.time + LAST_LINE_SECONDS;
+ if (line.words?.length) {
+ const out = [];
+ line.words.forEach((word, i) => {
+ if (!word.text) return;
+ const end = line.words[i + 1]?.time ?? Math.min(limit, word.time + 1.5);
+ out.push({ text: word.text, start: word.time, end: Math.max(end, word.time + 0.05) });
+ });
+ if (out.length) return out;
+ }
+ const tokens = lineTokens(line.text || "");
+ const weights = tokens.map(syllables);
+ const total = weights.reduce((a, b) => a + b, 0);
+ if (!total) return [];
+
+ if (envelope) {
+ const reach = Math.max(MIN_WINDOW_SECONDS, total * WINDOW_SECONDS_PER_SYLLABLE);
+ const phrases = voicedPhrases(envelope, line.time, Math.min(limit, line.time + reach));
+ if (phrases.length) {
+ const starts = assignWords(phrases, weights.map((w) => w * SYLLABLE_SECONDS));
+ const out = [];
+ phrases.forEach((phrase, j) => {
+ const end = starts[j + 1] ?? tokens.length;
+ if (end > starts[j]) layPhrase(tokens.slice(starts[j], end), weights.slice(starts[j], end), phrase, out);
+ });
+ return out;
+ }
+ }
+
+ const sung = Math.min(
+ (limit - line.time) * FALLBACK_SHARE,
+ Math.max(MIN_LINE_SECONDS, total * FALLBACK_SECONDS_PER_SYLLABLE),
+ );
+ let at = line.time;
+ return tokens.map((token, i) => {
+ const length = (weights[i] / total) * sung;
+ const word = { text: token, start: at, end: at + length };
+ at += length;
+ return word;
+ });
+}
+
+// Noise a video title adds around the song's name, dropped when it sits in
+// brackets: "(Official Video)", "[HD]", "(Lyric Video)", "(Remastered 2009)".
+// Lookarounds rather than word boundaries so "hd" inside "(Shadow Remix)"
+// is left alone.
+const TITLE_NOISE = /[([][^)\]]*(?> 1;
+ if (lines[mid].time <= seconds) {
+ found = mid;
+ lo = mid + 1;
+ } else {
+ hi = mid - 1;
+ }
+ }
+ return found;
+}
+
+/** The request for a search, as a URL. The song is required, the artist not. */
+export function searchUrl({ artist = "", song = "" }) {
+ const url = new URL(LRCLIB_SEARCH);
+ const a = String(artist).trim();
+ const s = String(song).trim();
+ if (a) {
+ url.searchParams.set("track_name", s);
+ url.searchParams.set("artist_name", a);
+ } else {
+ // No artist: the free-text search, which matches the song name against
+ // titles and artists both, rather than an empty artist filter.
+ url.searchParams.set("q", s);
+ }
+ return url.toString();
+}
+
+async function defaultFetchJson(url, signal) {
+ const res = await fetch(url, { signal, headers: { Accept: "application/json" } });
+ if (!res.ok) throw new Error(`HTTP ${res.status} from ${new URL(url).host}`);
+ return res.json();
+}
+
+/**
+ * Search LRCLIB and rank what comes back for a track `duration` seconds long.
+ * Resolves to [] when nothing matches, and rejects when LRCLIB cannot be
+ * reached, so the tab can tell the two apart.
+ */
+export async function searchLyrics({ artist = "", song = "", duration = 0 }, { fetchJson = defaultFetchJson, signal } = {}) {
+ if (!String(song).trim()) return [];
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(new Error("timeout")), TIMEOUT_MS);
+ signal?.addEventListener("abort", () => controller.abort(signal.reason), { once: true });
+ try {
+ const rows = await fetchJson(searchUrl({ artist, song }), controller.signal);
+ return rankMatches(rows, duration);
+ } finally {
+ clearTimeout(timer);
+ }
+}
diff --git a/static/js/main.js b/static/js/main.js
index 73c44463..fccb50fd 100644
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -19,6 +19,8 @@ import { initNotifications, notifyFailure, dismissFailuresByJobId } from "./noti
import { runStoreMigrationIfNeeded } from "./utils.js";
import { initI18n, applyTranslations, t, plural, onLanguageChange } from "./i18n.js";
import { initFooterFit, refitFooter } from "./footerFit.js";
+import { initArtistInfo } from "./artistInfo.js";
+import { initLyrics } from "./lyrics.js";
// ─── Stem choice toggles on the import page ───
//
@@ -452,6 +454,8 @@ wireAutoSectionsToggle();
wireFileDrop();
wireAppShellControls();
initFooterFit();
+initArtistInfo();
+initLyrics();
// Re-measure after a language switch. Measured across en/fr/de/pl the strip
// came out the same width in all four, because the group labels are 10px
// uppercase and narrower than the controls under them, so this is not the
diff --git a/static/js/player.js b/static/js/player.js
index c89b0ecb..4cbee674 100644
--- a/static/js/player.js
+++ b/static/js/player.js
@@ -823,6 +823,16 @@ function teardownMetronome() {
setBeatGridAvailable(false);
}
+// The artist catalog.js puts beside the title (#699) goes with the title, or
+// the next import's card names the previous track's band.
+function hideNowPlayingArtist() {
+ const artist = document.getElementById("np-artist");
+ if (!artist) return;
+ artist.textContent = "";
+ artist.title = "";
+ artist.hidden = true;
+}
+
export function destroyPlayer() {
document.querySelector(".app")?.classList.remove("is-import");
document.querySelector(".app")?.classList.remove("engine-waveforms");
@@ -873,6 +883,7 @@ export function destroyPlayer() {
wavesGrid.innerHTML = "";
titleEl.textContent = "";
+ hideNowPlayingArtist();
bpmChip.textContent = "\u2014 BPM";
keyChip.textContent = "\u2014 \u2014";
stemsChip.textContent = t("footer.stemsPlaceholder");
@@ -929,6 +940,7 @@ export function renderEmptyShell() {
requestAnimationFrame(() => _applyLaneHeight(1 + STEM_NAMES.length));
applyStemSelectionFilter(new Set(STEM_NAMES));
titleEl.textContent = t("player.readyToImport");
+ hideNowPlayingArtist();
bpmChip.textContent = "\u2014 BPM";
keyChip.textContent = "\u2014 \u2014";
stemsChip.textContent = t("footer.stemsPlaceholder");
@@ -1210,6 +1222,11 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti
stemsChip.textContent = plural("footer.stemsCount", stems.length);
+ // Cleared on every load, not only in destroyPlayer(): switching tracks from
+ // the library comes straight here, so a track with no thumbnail (an upload)
+ // kept the previous track's picture over its own format icon.
+ npThumb.classList.remove("loaded");
+ npThumb.removeAttribute("src");
if (thumbnail) {
npThumb.onload = () => npThumb.classList.add("loaded");
npThumb.onerror = () => npThumb.classList.remove("loaded");
diff --git a/tests/conftest.py b/tests/conftest.py
index dcefa139..9869dfc7 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -8,6 +8,19 @@
from app.pipeline import jobqueue as _jobqueue
+@pytest.fixture(autouse=True)
+def _no_wikidata(monkeypatch):
+ """No test reaches Wikidata. A job with an artist tag looks its band up
+ while it runs (#699), so any pipeline or endpoint test with tags would
+ otherwise make a real request. Offline is what this answers, which the
+ lookup treats as "no band"; tests that want an answer stub it themselves."""
+
+ def offline(params):
+ raise OSError("no network in tests")
+
+ monkeypatch.setattr("app.pipeline.artist_lookup._fetch_json", offline)
+
+
@pytest.fixture(autouse=True)
def _isolate_jobs_dir(tmp_path, monkeypatch):
"""Point every JOBS_DIR at a temp dir, for every test, no exceptions.
diff --git a/tests/e2e/artist-from-server.spec.mjs b/tests/e2e/artist-from-server.spec.mjs
new file mode 100644
index 00000000..0dafe1db
--- /dev/null
+++ b/tests/e2e/artist-from-server.spec.mjs
@@ -0,0 +1,167 @@
+// The band arrives with the finished import (#699): the server looks it up from
+// the artist tag while the job separates, and the job's state carries it. The
+// page keeps it on the track and shows it with no lookup of its own and no
+// click on the info button.
+//
+// Every Wikimedia request is answered here and recorded, so the tests can
+// assert the page asked Wikimedia nothing at all until the box was opened, and
+// then only for the saved band by its id, never a search.
+import { test, expect } from "@playwright/test";
+import {
+ JOB_ID,
+ SIBLING_JOB_ID,
+ fixtureTrack,
+ readCatalogState,
+ seedCatalogState,
+ stubAudioTags,
+ stubExportEndpoints,
+ stubUpdateCheck,
+} from "./helpers.mjs";
+
+// Wide enough for the now-playing card, which the bar hides below 1460px.
+test.use({ viewport: { width: 1600, height: 900 } });
+
+const WIKIMEDIA = /^https:\/\/([a-z]+\.wikipedia\.org|www\.wikidata\.org|query\.wikidata\.org)\//;
+
+// artistInfo.js waits this long after a track opens before looking the band
+// up itself, so a test that proves it did not has to wait longer than that.
+const PAST_THE_WAIT_MS = 2000;
+
+const BAND = { id: "Q162586", name: "Dream Theater", englishName: "Dream Theater" };
+const TAGS = { artist: "Dream Theater", title: "Pull Me Under" };
+
+const ANSWERS = {
+ search: { search: [{ id: "Q162586" }] },
+ entities: {
+ entities: {
+ Q162586: {
+ id: "Q162586",
+ labels: { en: { value: "Dream Theater" } },
+ descriptions: { en: { value: "American progressive metal band" } },
+ sitelinks: {
+ enwiki: { site: "enwiki", title: "Dream Theater", url: "https://en.wikipedia.org/wiki/Dream_Theater" },
+ },
+ claims: { P434: [{}] },
+ },
+ },
+ },
+ members: { entities: {} },
+ sparql: { results: { bindings: [] } },
+ extract: { query: { pages: [{ extract: "Lead.\n== History ==\nIn 1985, three Berklee students formed a band." }] } },
+};
+
+async function stubWikimedia(page) {
+ const asked = [];
+ await page.route(WIKIMEDIA, async (route) => {
+ const url = new URL(route.request().url());
+ asked.push(url.toString());
+ const kind =
+ url.host === "query.wikidata.org" ? "sparql"
+ : url.host.endsWith("wikipedia.org") ? "extract"
+ : url.searchParams.get("action") === "wbsearchentities" ? "search"
+ : url.searchParams.get("props") === "labels" ? "members"
+ : "entities";
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ headers: { "access-control-allow-origin": "*" },
+ body: JSON.stringify(ANSWERS[kind]),
+ });
+ });
+ return asked;
+}
+
+/** The fixture job's real state, as the server would send it once the
+ * pipeline had found `band` from `tags`. */
+async function stubServerBand(page, id, { band = BAND, tags = TAGS } = {}) {
+ const withBand = async () => {
+ const real = await (await page.request.get(`/api/jobs/${id}`)).json();
+ return { ...real, audio_tags: tags, artist: band };
+ };
+ await page.route(`**/api/jobs/${id}`, async (route) => {
+ if (route.request().method() !== "GET") return route.fallback();
+ await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(await withBand()) });
+ });
+ return withBand;
+}
+
+async function openPage(page, tracks) {
+ await seedCatalogState(page, {
+ folders: [
+ { id: "f-unsorted", name: "Unsorted", items: Object.keys(tracks), color: null },
+ { id: "trash", name: "Trash", items: [], color: null },
+ ],
+ tracks,
+ });
+ await stubExportEndpoints(page);
+ await stubUpdateCheck(page);
+ await stubAudioTags(page, null);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.locator(".cat-item").first().waitFor({ timeout: 20000 });
+}
+
+const savedArtist = async (page, id = JOB_ID) => (await readCatalogState(page))?.tracks?.[id]?.artist ?? null;
+
+test.describe("band found by the server during the import", () => {
+ test("a finished import shows its band at once, with nothing asked of Wikimedia", async ({ page }) => {
+ const asked = await stubWikimedia(page);
+ const doneState = await stubServerBand(page, JOB_ID);
+ // The import: the server accepts it as the fixture job, and its event
+ // stream says it is done, carrying the band the pipeline found.
+ await page.route("**/api/jobs", (route) => {
+ if (route.request().method() !== "POST") return route.fallback();
+ return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ job_id: JOB_ID }) });
+ });
+ await page.route(`**/api/jobs/${JOB_ID}/events`, async (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "text/event-stream",
+ body: `data: ${JSON.stringify(await doneState())}\n\n`,
+ }));
+ // Only the sibling is in the library, so nothing is open and the import
+ // takes the studio.
+ await openPage(page, { [SIBLING_JOB_ID]: fixtureTrack(SIBLING_JOB_ID, "E2E Fixture Track (again)") });
+
+ await page.locator("#url").fill("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
+ await page.locator("#submit").click();
+
+ await expect(page.locator(".app")).not.toHaveClass(/no-track/, { timeout: 15000 });
+ await expect(page.locator("#np-artist")).toHaveText("Dream Theater", { timeout: 15000 });
+ await expect(page.locator("#title")).toHaveText("Pull Me Under");
+ await expect.poll(() => savedArtist(page), { timeout: 15000 }).toEqual(BAND);
+
+ await page.waitForTimeout(PAST_THE_WAIT_MS);
+ expect(asked, "the page looked nothing up itself").toEqual([]);
+
+ // The box opens straight on the band's details, asked for by its id.
+ await page.locator("#np-details-btn").click();
+ await expect(page.locator(".artist-name")).toHaveText("Dream Theater");
+ await expect(page.locator(".artist-history")).toContainText("Berklee");
+ await expect(page.locator(".artist-save")).toHaveText("Saved for this track");
+ expect(asked.some((url) => url.includes("action=wbsearchentities"))).toBe(false);
+ expect(asked.some((url) => url.includes("ids=Q162586"))).toBe(true);
+ });
+
+ test("a library track learns its band from the server, and one saved by hand wins", async ({ page }) => {
+ const asked = await stubWikimedia(page);
+ await stubServerBand(page, JOB_ID);
+ const chosen = { id: "Q1", name: "Chosen Band", englishName: "Chosen Band" };
+ await stubServerBand(page, SIBLING_JOB_ID, { tags: { artist: "Dream Theater", title: "Metropolis" } });
+ await openPage(page, {
+ [JOB_ID]: fixtureTrack(JOB_ID, "E2E Fixture Track"),
+ [SIBLING_JOB_ID]: { ...fixtureTrack(SIBLING_JOB_ID, "E2E Fixture Track (again)"), artist: chosen },
+ });
+
+ await page.locator(`.cat-item[data-id="${JOB_ID}"]`).first().click();
+ await expect(page.locator("#np-artist")).toHaveText("Dream Theater", { timeout: 15000 });
+ await expect.poll(() => savedArtist(page), { timeout: 15000 }).toEqual(BAND);
+
+ await page.locator(`.cat-item[data-id="${SIBLING_JOB_ID}"]`).first().click();
+ await expect(page.locator("#title")).toHaveText("Metropolis", { timeout: 15000 });
+ await expect(page.locator("#np-artist")).toHaveText("Chosen Band");
+ expect(await savedArtist(page, SIBLING_JOB_ID)).toEqual(chosen);
+
+ await page.waitForTimeout(PAST_THE_WAIT_MS);
+ expect(asked).toEqual([]);
+ });
+});
diff --git a/tests/e2e/artist-from-tags.spec.mjs b/tests/e2e/artist-from-tags.spec.mjs
new file mode 100644
index 00000000..d6f501e0
--- /dev/null
+++ b/tests/e2e/artist-from-tags.spec.mjs
@@ -0,0 +1,187 @@
+// A track whose file was tagged with an artist gets its band found and saved
+// on its own when it is opened, and the now-playing card names the artist
+// beside the title (#699). No click on the info button is involved.
+//
+// Every Wikimedia request is answered here, so the tests run offline. The
+// answers are the Dream Theater ones artist-info.spec.mjs uses; what the box
+// does with each shape is covered there and in tests/js/artist-lookup.test.mjs.
+// This is what happens without the box: what is saved, what is not, what is
+// asked, and what the card shows.
+import { test, expect } from "@playwright/test";
+import {
+ JOB_ID,
+ SIBLING_JOB_ID,
+ fixtureTrack,
+ readCatalogState,
+ seedCatalogState,
+ stubAudioTags,
+ stubExportEndpoints,
+ stubUpdateCheck,
+} from "./helpers.mjs";
+
+// Wide enough for the now-playing card, which the bar hides below 1460px.
+test.use({ viewport: { width: 1600, height: 900 } });
+
+const WIKIMEDIA = /^https:\/\/([a-z]+\.wikipedia\.org|www\.wikidata\.org|query\.wikidata\.org)\//;
+
+// artistInfo.js waits this long after a track opens before asking, so a test
+// that proves nothing was asked has to wait longer than that.
+const PAST_THE_WAIT_MS = 2000;
+
+const ANSWERS = {
+ search: { search: [{ id: "Q162586" }] },
+ entities: {
+ entities: {
+ Q162586: {
+ id: "Q162586",
+ labels: { en: { value: "Dream Theater" } },
+ descriptions: { en: { value: "American progressive metal band" } },
+ sitelinks: {
+ enwiki: { site: "enwiki", title: "Dream Theater", url: "https://en.wikipedia.org/wiki/Dream_Theater" },
+ },
+ claims: { P434: [{}] },
+ },
+ },
+ },
+ members: { entities: {} },
+ sparql: { results: { bindings: [] } },
+ extract: { query: { pages: [{ extract: "Lead.\n== History ==\nIn 1985, three Berklee students formed a band." }] } },
+};
+
+/**
+ * Answer Wikimedia from ANSWERS, recording each URL asked for. `hold`, when
+ * given, is awaited before the search is answered, so a test can open another
+ * track while the lookup is still out.
+ */
+async function stubWikimedia(page, { hold = null } = {}) {
+ const asked = [];
+ await page.route(WIKIMEDIA, async (route) => {
+ const url = new URL(route.request().url());
+ asked.push(url.toString());
+ const kind =
+ url.host === "query.wikidata.org" ? "sparql"
+ : url.host.endsWith("wikipedia.org") ? "extract"
+ : url.searchParams.get("action") === "wbsearchentities" ? "search"
+ : url.searchParams.get("props") === "labels" ? "members"
+ : "entities";
+ if (hold && kind === "search") await hold;
+ try {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ headers: { "access-control-allow-origin": "*" },
+ body: JSON.stringify(ANSWERS[kind]),
+ });
+ } catch {
+ // The page aborted the request while it was held, which is the point.
+ }
+ });
+ return asked;
+}
+
+/** Both fixture jobs, the first carrying `tags` (none for the second). */
+async function openStudio(page, tags) {
+ const tracks = {
+ [JOB_ID]: { ...fixtureTrack(JOB_ID, "E2E Fixture Track"), ...(tags ? { audioTags: tags } : {}) },
+ [SIBLING_JOB_ID]: fixtureTrack(SIBLING_JOB_ID, "E2E Fixture Track (again)"),
+ };
+ await seedCatalogState(page, {
+ folders: [
+ { id: "f-unsorted", name: "Unsorted", items: Object.keys(tracks), color: null },
+ { id: "trash", name: "Trash", items: [], color: null },
+ ],
+ tracks,
+ });
+ await stubExportEndpoints(page);
+ await stubUpdateCheck(page);
+ // An untagged track asks the server to read its tags when opened. None
+ // here, so the tags seeded above are all there is.
+ await stubAudioTags(page, null);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.locator(".cat-item").first().waitFor({ timeout: 20000 });
+}
+
+async function openTrack(page, id = JOB_ID) {
+ await page.locator(`.cat-item[data-id="${id}"]`).first().click();
+ await expect(page.locator(".app")).not.toHaveClass(/no-track/, { timeout: 15000 });
+}
+
+const savedArtist = async (page, id = JOB_ID) => (await readCatalogState(page))?.tracks?.[id]?.artist ?? null;
+
+test.describe("band from the file's tags", () => {
+ test("a tagged track gets its band saved without a click, and the card names it", async ({ page }) => {
+ const asked = await stubWikimedia(page);
+ await openStudio(page, { artist: "Dream Theater", title: "Metropolis Pt. 1" });
+ await openTrack(page);
+
+ // The file's own title rather than the job's, with the artist beside it.
+ const artist = page.locator("#np-artist");
+ await expect(page.locator("#title")).toHaveText("Metropolis Pt. 1");
+ await expect(artist).toBeVisible();
+ await expect(artist).toHaveText("Dream Theater");
+
+ await expect.poll(() => savedArtist(page), { timeout: 15000 })
+ .toEqual({ id: "Q162586", name: "Dream Theater", englishName: "Dream Theater" });
+ // The tag's artist, to Wikimedia, and nothing else about the track.
+ expect(asked[0]).toContain("search=Dream+Theater");
+ expect(asked.join(" ")).not.toContain("Metropolis");
+ expect(asked.join(" ")).not.toContain("E2E");
+
+ // On the title's line, which is what keeps the card at its height.
+ const [titleBox, artistBox] = await Promise.all([
+ page.locator("#title").boundingBox(),
+ artist.boundingBox(),
+ ]);
+ expect(Math.abs(titleBox.y - artistBox.y)).toBeLessThan(3);
+ expect(artistBox.x).toBeGreaterThan(titleBox.x);
+
+ // The box opens straight on the saved band, from the answer already had.
+ const before = asked.length;
+ await page.locator("#np-details-btn").click();
+ await expect(page.locator(".artist-name")).toHaveText("Dream Theater");
+ await expect(page.locator(".artist-save")).toHaveText("Saved for this track");
+ expect(asked.length).toBe(before);
+ });
+
+ test("an untagged track shows no artist and asks Wikimedia nothing", async ({ page }) => {
+ const asked = await stubWikimedia(page);
+ await openStudio(page, null);
+ await openTrack(page);
+ await expect(page.locator("#title")).toHaveText("E2E Fixture Track");
+ await expect(page.locator("#np-artist")).toBeHidden();
+ await page.waitForTimeout(PAST_THE_WAIT_MS);
+ expect(asked).toEqual([]);
+ expect(await savedArtist(page)).toBeNull();
+ });
+
+ test("an answer with a different name is not saved", async ({ page }) => {
+ const asked = await stubWikimedia(page);
+ // Wikidata's search forgives the spelling and answers Dream Theater.
+ await openStudio(page, { artist: "Dream Theatre" });
+ await openTrack(page);
+ await expect.poll(() => asked.some((url) => url.includes("action=wbgetentities")), { timeout: 15000 }).toBe(true);
+ await page.waitForTimeout(PAST_THE_WAIT_MS);
+ expect(await savedArtist(page)).toBeNull();
+ // The card still names what the file says.
+ await expect(page.locator("#np-artist")).toHaveText("Dream Theatre");
+ // And the job's title stays, with no title tag to prefer.
+ await expect(page.locator("#title")).toHaveText("E2E Fixture Track");
+ });
+
+ test("an answer landing after another track was opened is saved on neither", async ({ page }) => {
+ let release;
+ const hold = new Promise((resolve) => { release = resolve; });
+ const asked = await stubWikimedia(page, { hold });
+ await openStudio(page, { artist: "Dream Theater" });
+ await openTrack(page);
+ await expect.poll(() => asked.length, { timeout: 15000 }).toBeGreaterThan(0);
+
+ await openTrack(page, SIBLING_JOB_ID);
+ await expect(page.locator("#title")).toHaveText("E2E Fixture Track (again)", { timeout: 15000 });
+ await expect(page.locator("#np-artist")).toBeHidden();
+ release();
+ await page.waitForTimeout(PAST_THE_WAIT_MS);
+ expect(await savedArtist(page, JOB_ID)).toBeNull();
+ expect(await savedArtist(page, SIBLING_JOB_ID)).toBeNull();
+ });
+});
diff --git a/tests/e2e/artist-info.spec.mjs b/tests/e2e/artist-info.spec.mjs
new file mode 100644
index 00000000..4e96f78e
--- /dev/null
+++ b/tests/e2e/artist-info.spec.mjs
@@ -0,0 +1,304 @@
+// The artist box the info button on the now-playing card opens (#699): band name
+// on top, then History, Members and Studio albums, from Wikidata and
+// Wikipedia, and a band saved on the track so it opens straight on it later.
+//
+// Every Wikimedia request is answered here, so the tests run offline and do
+// not depend on what an article says today. The answers are trimmed from the
+// real ones for Dream Theater. What the box does with each shape of answer is
+// covered in tests/js/artist-lookup.test.mjs; this is the page: that the card
+// opens it, what is and is not sent, and what each outcome looks like.
+import { test, expect } from "@playwright/test";
+import {
+ JOB_ID,
+ SIBLING_JOB_ID,
+ fixtureTrack,
+ readCatalogState,
+ seedCatalogState,
+ seedLibrary,
+ stubAudioTags,
+ stubExportEndpoints,
+ stubUpdateCheck,
+} from "./helpers.mjs";
+
+// Wide enough for the now-playing card, which the bar hides below 1460px.
+test.use({ viewport: { width: 1600, height: 900 } });
+
+const WIKIMEDIA = /^https:\/\/([a-z]+\.wikipedia\.org|www\.wikidata\.org|query\.wikidata\.org)\//;
+
+const member = (id, ended = false) => ({
+ rank: "normal",
+ mainsnak: { datavalue: { value: { id } } },
+ qualifiers: ended ? { P582: [{}] } : {},
+});
+
+const ANSWERS = {
+ search: { search: [{ id: "Q162586" }] },
+ entities: {
+ entities: {
+ Q162586: {
+ id: "Q162586",
+ labels: { en: { value: "Dream Theater" } },
+ descriptions: { en: { value: "American progressive metal band" } },
+ sitelinks: {
+ enwiki: { site: "enwiki", title: "Dream Theater", url: "https://en.wikipedia.org/wiki/Dream_Theater" },
+ },
+ claims: { P434: [{}], P527: [member("Q175102"), member("Q296039", true)] },
+ },
+ },
+ },
+ members: {
+ entities: {
+ Q175102: { labels: { en: { value: "John Petrucci" } } },
+ Q296039: { labels: { en: { value: "Kevin Moore" } } },
+ },
+ },
+ sparql: {
+ results: {
+ bindings: [
+ {
+ album: { value: "http://www.wikidata.org/entity/Q1" },
+ albumLabel: { value: "Images and Words" },
+ released: { value: "1992-07-07T00:00:00Z" },
+ },
+ ],
+ },
+ },
+ extract: {
+ query: { pages: [{ extract: "Lead.\n== History ==\nIn 1985, three Berklee students formed a band." }] },
+ },
+};
+
+/** Answer Wikimedia from ANSWERS, recording each URL asked for. */
+async function stubWikimedia(page, { offline = false, nothing = false } = {}) {
+ const asked = [];
+ await page.route(WIKIMEDIA, async (route) => {
+ const url = new URL(route.request().url());
+ asked.push(url.toString());
+ if (offline) return route.abort("internetdisconnected");
+ const kind =
+ url.host === "query.wikidata.org" ? "sparql"
+ : url.host.endsWith("wikipedia.org") ? "extract"
+ : url.searchParams.get("action") === "wbsearchentities" ? "search"
+ : url.searchParams.get("props") === "labels" ? "members"
+ : "entities";
+ const body = nothing && kind === "search" ? { search: [] } : ANSWERS[kind];
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ headers: { "access-control-allow-origin": "*" },
+ body: JSON.stringify(body),
+ });
+ });
+ return asked;
+}
+
+async function openStudio(page) {
+ await seedLibrary(page);
+ await stubExportEndpoints(page);
+ await stubUpdateCheck(page);
+ // The untagged fixture asks for its tags when opened; it has none, so the
+ // box has nothing to go on and opens on the search field.
+ await stubAudioTags(page, null);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.locator(".cat-item").first().waitFor({ timeout: 20000 });
+}
+
+async function openTrack(page) {
+ await page.locator(`.cat-item[data-id="${JOB_ID}"]`).first().click();
+ await expect(page.locator(".app")).not.toHaveClass(/no-track/, { timeout: 15000 });
+}
+
+async function lookUp(page, name) {
+ await page.locator("#artistQuery").fill(name);
+ await page.locator("#artistQuery").press("Enter");
+}
+
+test.describe("artist box", () => {
+ test("the info button opens it, with the name on top and the three sections", async ({ page }) => {
+ const asked = await stubWikimedia(page);
+ await openStudio(page);
+ await openTrack(page);
+ // Opening a track asks Wikimedia nothing: only the info button does.
+ expect(asked).toEqual([]);
+
+ await page.locator("#np-details-btn").click();
+ const dialog = page.locator("#artistDialog");
+ await expect(dialog).toBeVisible();
+ // No band saved on the track: an empty field, focused, with nothing guessed
+ // from the title and nothing searched until a name is typed.
+ await expect(page.locator("#artistSearch")).toBeVisible();
+ await expect(page.locator("#artistQuery")).toHaveValue("");
+ await expect(page.locator("#artistQuery")).toBeFocused();
+ await expect(page.locator("#artistQuery")).toHaveAttribute("placeholder", "Search for a band or artist");
+ await expect(page.locator("#artistBody")).toBeEmpty();
+ expect(asked).toEqual([]);
+
+ await lookUp(page, "Dream Theater");
+ await expect(dialog.locator(".artist-name")).toHaveText("Dream Theater");
+ await expect(dialog.locator(".artist-section-title")).toHaveText(["History", "Members", "Studio albums"]);
+ await expect(dialog.locator(".artist-history")).toContainText("Berklee students");
+ await expect(dialog.locator(".artist-names").first()).toContainText("John Petrucci");
+ await expect(dialog.locator(".artist-subtitle")).toHaveText("Former members");
+ await expect(dialog.locator(".artist-albums")).toContainText("1992");
+ await expect(dialog.locator(".artist-albums")).toContainText("Images and Words");
+ await expect(dialog.locator(".artist-more")).toHaveAttribute("href", "https://en.wikipedia.org/wiki/Dream_Theater");
+ await expect(dialog.locator(".artist-more")).toHaveAttribute("target", "_blank");
+
+ // What went out is the typed name, to Wikimedia, and nothing else.
+ expect(asked.length).toBeGreaterThan(0);
+ expect(asked[0]).toContain("search=Dream+Theater");
+ expect(asked.join(" ")).not.toContain("E2E");
+
+ await page.keyboard.press("Escape");
+ await expect(dialog).toBeHidden();
+ });
+
+ test("the same name again is not asked for twice", async ({ page }) => {
+ const asked = await stubWikimedia(page);
+ await openStudio(page);
+ await openTrack(page);
+ await page.locator("#np-details-btn").click();
+ await lookUp(page, "Dream Theater");
+ await expect(page.locator(".artist-name")).toHaveText("Dream Theater");
+ const first = asked.length;
+
+ await page.keyboard.press("Escape");
+ await page.locator("#np-details-btn").click();
+ await lookUp(page, "dream theater");
+ await expect(page.locator(".artist-name")).toHaveText("Dream Theater");
+ expect(asked.length).toBe(first);
+ });
+
+ test("a name that is no band says so", async ({ page }) => {
+ await stubWikimedia(page, { nothing: true });
+ await openStudio(page);
+ await openTrack(page);
+ await page.locator("#np-details-btn").click();
+ await lookUp(page, "Not A Band At All");
+ await expect(page.locator(".artist-status")).toHaveText(
+ "Nothing on Wikipedia matches “Not A Band At All” as a band or artist.",
+ );
+ });
+
+ test("no connection says so, rather than 'not found'", async ({ page }) => {
+ await stubWikimedia(page, { offline: true });
+ await openStudio(page);
+ await openTrack(page);
+ await page.locator("#np-details-btn").click();
+ await lookUp(page, "Dream Theater");
+ await expect(page.locator(".artist-status")).toHaveText(
+ "Could not reach Wikipedia. Check your connection and try again.",
+ );
+ });
+
+ test("the heart favourites and does not open the box", async ({ page }) => {
+ await stubWikimedia(page);
+ await openStudio(page);
+ await openTrack(page);
+ await page.locator("#fav-btn").click();
+ await expect(page.locator("#artistDialog")).toBeHidden();
+ });
+
+ test("with no track open the info button does nothing", async ({ page }) => {
+ const asked = await stubWikimedia(page);
+ await openStudio(page);
+ await expect(page.locator(".app")).toHaveClass(/no-track/);
+ // CSS takes its pointer events away; dispatched, the handler still refuses.
+ await page.locator("#np-details-btn").dispatchEvent("click");
+ await expect(page.locator("#artistDialog")).toBeHidden();
+ expect(asked).toEqual([]);
+ });
+
+ test("the info button opens it from the keyboard", async ({ page }) => {
+ await stubWikimedia(page);
+ await openStudio(page);
+ await openTrack(page);
+ await page.locator("#np-details-btn").focus();
+ await page.keyboard.press("Enter");
+ await expect(page.locator("#artistDialog")).toBeVisible();
+ });
+
+ test("the artist the file was tagged with opens straight on the band, without the search row", async ({ page }) => {
+ const asked = await stubWikimedia(page);
+ // Already tagged, so its tags are never asked for again.
+ const tagsAsked = await stubAudioTags(page, null);
+ await seedCatalogState(page, {
+ folders: [
+ { id: "f-unsorted", name: "Unsorted", items: [JOB_ID, SIBLING_JOB_ID], color: null },
+ { id: "trash", name: "Trash", items: [], color: null },
+ ],
+ tracks: {
+ [JOB_ID]: { ...fixtureTrack(JOB_ID, "E2E Fixture Track"), audioTags: { artist: "Dream Theater" } },
+ [SIBLING_JOB_ID]: fixtureTrack(SIBLING_JOB_ID, "E2E Fixture Track (again)"),
+ },
+ });
+ await stubExportEndpoints(page);
+ await stubUpdateCheck(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.locator(".cat-item").first().waitFor({ timeout: 20000 });
+ await openTrack(page);
+ await page.locator("#np-details-btn").click();
+ await expect(page.locator(".artist-name")).toHaveText("Dream Theater");
+ await expect(page.locator("#artistSearch")).toBeHidden();
+ await expect(page.locator("#artistClose")).toBeFocused();
+ expect(asked[0]).toContain("search=Dream+Theater");
+ expect(tagsAsked).toEqual([]);
+
+ // The way back to the field, filled with the name and ready to edit.
+ const other = page.locator(".artist-other-btn");
+ await expect(other).toHaveText("Not this band? Search");
+ await other.click();
+ await expect(page.locator("#artistSearch")).toBeVisible();
+ await expect(page.locator("#artistQuery")).toHaveValue("Dream Theater");
+ await expect(page.locator("#artistQuery")).toBeFocused();
+ await expect(other).toHaveCount(0);
+ // The details stay until another name is searched.
+ await expect(page.locator(".artist-name")).toHaveText("Dream Theater");
+ });
+
+ test("the rest of the card does not open it", async ({ page }) => {
+ await stubWikimedia(page);
+ await openStudio(page);
+ await openTrack(page);
+ await page.locator("#nowPlayingPanel .track-panel-info").click();
+ await expect(page.locator("#artistDialog")).toBeHidden();
+ });
+
+ test("a band saved on the track opens straight on it, after a reload too", async ({ page }) => {
+ const asked = await stubWikimedia(page);
+ await openStudio(page);
+ await openTrack(page);
+ await page.locator("#np-details-btn").click();
+ await lookUp(page, "Dream Theater");
+ const save = page.locator(".artist-save");
+ await expect(save).toHaveText("Save for this track");
+ await save.click();
+ await expect(save).toHaveText("Saved for this track");
+ await expect(save).toBeDisabled();
+
+ // Kept on the track in the library store: id, name, and the English name
+ // the Lyrics tab searches LRCLIB with.
+ await expect.poll(async () => (await readCatalogState(page))?.tracks?.[JOB_ID]?.artist)
+ .toEqual({ id: "Q162586", name: "Dream Theater", englishName: "Dream Theater" });
+
+ // seedLibrary writes the store on every load, reloads included, so what
+ // the app saved is written back over it before reloading. Init scripts run
+ // in the order they were added, so this one wins.
+ await seedCatalogState(page, await readCatalogState(page));
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await page.locator(".cat-item").first().waitFor({ timeout: 20000 });
+ await openTrack(page);
+ asked.length = 0;
+
+ await page.locator("#np-details-btn").click();
+ await expect(page.locator(".artist-name")).toHaveText("Dream Theater");
+ await expect(page.locator(".artist-save")).toHaveText("Saved for this track");
+ // A known band needs no field; it is one click away, filled in.
+ await expect(page.locator("#artistSearch")).toBeHidden();
+ await expect(page.locator("#artistQuery")).toHaveValue("Dream Theater");
+ await expect(page.locator(".artist-other-btn")).toBeVisible();
+ // By id, not by searching the name again.
+ expect(asked.some((url) => url.includes("action=wbsearchentities"))).toBe(false);
+ expect(asked.some((url) => url.includes("ids=Q162586"))).toBe(true);
+ });
+});
diff --git a/tests/e2e/audio-tags-backfill.spec.mjs b/tests/e2e/audio-tags-backfill.spec.mjs
new file mode 100644
index 00000000..2a9c1d69
--- /dev/null
+++ b/tests/e2e/audio-tags-backfill.spec.mjs
@@ -0,0 +1,292 @@
+// Tracks imported before tags were read (#699) have none in the library, so
+// opening one asks the server to read them now, once, in the background. What
+// comes back is kept on the track, the card names the artist, the band is found
+// and saved from it, and the artist box opens on that band rather than on an
+// empty search field.
+//
+// The tag read and every Wikimedia and LRCLIB request are answered here, so the
+// tests run offline and do not depend on what the backend finds in the fixture.
+import { test, expect } from "@playwright/test";
+import {
+ JOB_ID,
+ SIBLING_JOB_ID,
+ fixtureTrack,
+ readCatalogState,
+ seedCatalogState,
+ stubAudioTags,
+ stubExportEndpoints,
+ stubUpdateCheck,
+} from "./helpers.mjs";
+
+// Wide enough for the now-playing card, which the bar hides below 1460px.
+test.use({ viewport: { width: 1600, height: 900 } });
+
+const WIKIMEDIA = /^https:\/\/([a-z]+\.wikipedia\.org|www\.wikidata\.org|query\.wikidata\.org)\//;
+const LRCLIB = /^https:\/\/lrclib\.net\//;
+
+// Longer than artistInfo.js's wait after a track opens.
+const PAST_THE_WAIT_MS = 2000;
+
+const TAGS = { artist: "Dream Theater", title: "Metropolis Pt. 1" };
+
+const ANSWERS = {
+ search: { search: [{ id: "Q162586" }] },
+ entities: {
+ entities: {
+ Q162586: {
+ id: "Q162586",
+ labels: { en: { value: "Dream Theater" } },
+ descriptions: { en: { value: "American progressive metal band" } },
+ sitelinks: {
+ enwiki: { site: "enwiki", title: "Dream Theater", url: "https://en.wikipedia.org/wiki/Dream_Theater" },
+ },
+ claims: { P434: [{}] },
+ },
+ },
+ },
+ members: { entities: {} },
+ sparql: { results: { bindings: [] } },
+ extract: { query: { pages: [{ extract: "Lead.\n== History ==\nIn 1985, three Berklee students formed a band." }] } },
+};
+
+async function stubWikimedia(page) {
+ const asked = [];
+ await page.route(WIKIMEDIA, (route) => {
+ const url = new URL(route.request().url());
+ asked.push(url.toString());
+ const kind =
+ url.host === "query.wikidata.org" ? "sparql"
+ : url.host.endsWith("wikipedia.org") ? "extract"
+ : url.searchParams.get("action") === "wbsearchentities" ? "search"
+ : url.searchParams.get("props") === "labels" ? "members"
+ : "entities";
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ headers: { "access-control-allow-origin": "*" },
+ body: JSON.stringify(ANSWERS[kind]),
+ });
+ });
+ return asked;
+}
+
+/** Both fixture tracks, untagged, as a library from before tags were read. */
+async function openStudio(page, state = null) {
+ const tracks = {
+ [JOB_ID]: fixtureTrack(JOB_ID, "E2E Fixture Track"),
+ [SIBLING_JOB_ID]: fixtureTrack(SIBLING_JOB_ID, "E2E Fixture Track (again)"),
+ };
+ await seedCatalogState(page, state ?? {
+ folders: [
+ { id: "f-unsorted", name: "Unsorted", items: Object.keys(tracks), color: null },
+ { id: "trash", name: "Trash", items: [], color: null },
+ ],
+ tracks,
+ });
+ await stubExportEndpoints(page);
+ await stubUpdateCheck(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.locator(".cat-item").first().waitFor({ timeout: 20000 });
+}
+
+/** The library as the app saved it, written back before a reload. */
+async function reloadKeepingLibrary(page) {
+ await seedCatalogState(page, await readCatalogState(page));
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await page.locator(".cat-item").first().waitFor({ timeout: 20000 });
+}
+
+async function openTrack(page, id = JOB_ID) {
+ await page.locator(`.cat-item[data-id="${id}"]`).first().click();
+ await expect(page.locator(".app")).not.toHaveClass(/no-track/, { timeout: 15000 });
+}
+
+const stored = async (page, id = JOB_ID) => (await readCatalogState(page))?.tracks?.[id] ?? {};
+const timesAsked = (asked, id = JOB_ID) => asked.filter((x) => x === id).length;
+
+test.describe("tags for tracks imported before they were read", () => {
+ test("an untagged track gets its tags on open, once, and its band saved from them", async ({ page }) => {
+ const wiki = await stubWikimedia(page);
+ const asked = await stubAudioTags(page, (id) => (id === JOB_ID ? TAGS : null));
+ await openStudio(page);
+ await openTrack(page);
+
+ // The card, from the tags that just arrived.
+ await expect(page.locator("#np-artist")).toHaveText("Dream Theater", { timeout: 15000 });
+ await expect(page.locator("#title")).toHaveText("Metropolis Pt. 1");
+
+ // Kept on the track, and the band found from them saved without a click.
+ await expect.poll(async () => (await stored(page)).audioTags, { timeout: 15000 }).toEqual(TAGS);
+ expect((await stored(page)).audioTagsChecked).toBe(true);
+ await expect.poll(async () => (await stored(page)).artist, { timeout: 15000 })
+ .toEqual({ id: "Q162586", name: "Dream Theater", englishName: "Dream Theater" });
+ expect(wiki[0]).toContain("search=Dream+Theater");
+
+ // Opened again, it is not asked again: not in this session...
+ await openTrack(page, SIBLING_JOB_ID);
+ await openTrack(page);
+ await expect(page.locator("#np-artist")).toHaveText("Dream Theater");
+ // ...and not in the next.
+ await reloadKeepingLibrary(page);
+ await openTrack(page);
+ await page.waitForTimeout(PAST_THE_WAIT_MS);
+ expect(timesAsked(asked)).toBe(1);
+
+ // The box opens on the band, with no search row in the way.
+ await page.locator("#np-details-btn").click();
+ await expect(page.locator(".artist-name")).toHaveText("Dream Theater");
+ await expect(page.locator(".artist-save")).toHaveText("Saved for this track");
+ await expect(page.locator("#artistSearch")).toBeHidden();
+ });
+
+ test("a band the server found with the tags is saved, with no search of the page's own", async ({ page }) => {
+ const wiki = await stubWikimedia(page);
+ const band = { id: "Q162586", name: "Dream Theater", englishName: "Dream Theater" };
+ // The server reads the tags and finds the band from them in the same
+ // request (#699).
+ await page.route("**/api/jobs/*/audio-tags", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ audio_tags: TAGS, artist: band }),
+ }));
+ await openStudio(page);
+ await openTrack(page);
+
+ await expect(page.locator("#np-artist")).toHaveText("Dream Theater", { timeout: 15000 });
+ await expect.poll(async () => (await stored(page)).artist, { timeout: 15000 }).toEqual(band);
+ await page.waitForTimeout(PAST_THE_WAIT_MS);
+ expect(wiki).toEqual([]);
+ });
+
+ test("the box opened while the tags are read waits for them, then shows the band", async ({ page }) => {
+ await stubWikimedia(page);
+ let release;
+ const hold = new Promise((resolve) => { release = resolve; });
+ const asked = await stubAudioTags(page, TAGS, { hold });
+ await openStudio(page);
+ await openTrack(page);
+ await expect.poll(() => timesAsked(asked), { timeout: 15000 }).toBe(1);
+
+ await page.locator("#np-details-btn").click();
+ await expect(page.locator(".artist-status")).toHaveText("Finding this track's artist…");
+ await expect(page.locator("#artistSearch")).toBeHidden();
+ await expect(page.locator(".artist-other-btn")).toHaveText("Search by name instead");
+
+ release();
+ await expect(page.locator(".artist-name")).toHaveText("Dream Theater", { timeout: 15000 });
+ await expect(page.locator(".artist-save")).toHaveText("Saved for this track");
+ await expect(page.locator("#artistSearch")).toBeHidden();
+
+ // Not this band: the field, filled and focused.
+ await page.locator(".artist-other-btn").click();
+ await expect(page.locator("#artistSearch")).toBeVisible();
+ await expect(page.locator("#artistQuery")).toHaveValue("Dream Theater");
+ await expect(page.locator("#artistQuery")).toBeFocused();
+ });
+
+ test("a file with no tags is asked about once ever, and the box asks for a name", async ({ page }) => {
+ const wiki = await stubWikimedia(page);
+ const asked = await stubAudioTags(page, null);
+ await openStudio(page);
+ await openTrack(page);
+ await expect.poll(async () => (await stored(page)).audioTagsChecked, { timeout: 15000 }).toBe(true);
+ expect((await stored(page)).audioTags ?? null).toBeNull();
+ await expect(page.locator("#np-artist")).toBeHidden();
+
+ await page.locator("#np-details-btn").click();
+ await expect(page.locator("#artistSearch")).toBeVisible();
+ await expect(page.locator("#artistQuery")).toHaveValue("");
+ await expect(page.locator("#artistQuery")).toBeFocused();
+ await expect(page.locator(".artist-other-btn")).toHaveCount(0);
+ await page.keyboard.press("Escape");
+
+ await reloadKeepingLibrary(page);
+ await openTrack(page);
+ await page.waitForTimeout(PAST_THE_WAIT_MS);
+ expect(timesAsked(asked)).toBe(1);
+ expect(wiki).toEqual([]);
+ });
+
+ test("a failed read is not remembered, and the next session asks again", async ({ page }) => {
+ await stubWikimedia(page);
+ const asked = await stubAudioTags(page, null, { status: 500 });
+ await openStudio(page);
+ await openTrack(page);
+ await expect.poll(() => timesAsked(asked), { timeout: 15000 }).toBe(1);
+ await page.waitForTimeout(500);
+ expect((await stored(page)).audioTagsChecked).toBeUndefined();
+
+ // Once per session...
+ await openTrack(page, SIBLING_JOB_ID);
+ await openTrack(page);
+ await page.waitForTimeout(500);
+ expect(timesAsked(asked)).toBe(1);
+ // ...and again in the next.
+ await reloadKeepingLibrary(page);
+ await openTrack(page);
+ await expect.poll(() => timesAsked(asked), { timeout: 15000 }).toBe(2);
+ });
+
+ test("a read already under way on the server (409) is asked again on the next open", async ({ page }) => {
+ await stubWikimedia(page);
+ const asked = await stubAudioTags(page, null, { status: 409 });
+ await openStudio(page);
+ await openTrack(page);
+ await expect.poll(() => timesAsked(asked), { timeout: 15000 }).toBe(1);
+ await openTrack(page, SIBLING_JOB_ID);
+ await openTrack(page);
+ await expect.poll(() => timesAsked(asked), { timeout: 15000 }).toBe(2);
+ expect((await stored(page)).audioTagsChecked).toBeUndefined();
+ });
+
+ test("tags landing after another track was opened stay on their own track", async ({ page }) => {
+ const wiki = await stubWikimedia(page);
+ let release;
+ const hold = new Promise((resolve) => { release = resolve; });
+ const asked = await stubAudioTags(page, (id) => (id === JOB_ID ? TAGS : null), { hold });
+ await openStudio(page);
+ await openTrack(page);
+ await expect.poll(() => timesAsked(asked), { timeout: 15000 }).toBe(1);
+
+ await openTrack(page, SIBLING_JOB_ID);
+ await expect(page.locator("#title")).toHaveText("E2E Fixture Track (again)", { timeout: 15000 });
+ release();
+
+ await expect.poll(async () => (await stored(page)).audioTags, { timeout: 15000 }).toEqual(TAGS);
+ await page.waitForTimeout(PAST_THE_WAIT_MS);
+ // The open track's card is untouched, and no band was looked up for a
+ // track that is no longer open.
+ await expect(page.locator("#title")).toHaveText("E2E Fixture Track (again)");
+ await expect(page.locator("#np-artist")).toBeHidden();
+ expect(wiki).toEqual([]);
+ expect((await stored(page)).artist ?? null).toBeNull();
+ expect((await stored(page, SIBLING_JOB_ID)).artist ?? null).toBeNull();
+ });
+
+ test("the Lyrics tab, open while the tags arrive, looks them up", async ({ page }) => {
+ await stubWikimedia(page);
+ const lrclib = [];
+ await page.route(LRCLIB, (route) => {
+ lrclib.push(route.request().url());
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ headers: { "access-control-allow-origin": "*" },
+ body: "[]",
+ });
+ });
+ let release;
+ const hold = new Promise((resolve) => { release = resolve; });
+ await stubAudioTags(page, TAGS, { hold });
+ await openStudio(page);
+ await openTrack(page);
+ await page.locator(".rail-lyrics").click();
+ await expect(page.locator("#lyricsStatus")).toContainText("no artist or song tags");
+ expect(lrclib).toEqual([]);
+
+ release();
+ await expect.poll(() => lrclib.length, { timeout: 15000 }).toBeGreaterThan(0);
+ expect(decodeURIComponent(lrclib[0]).replace(/\+/g, " ")).toContain("Dream Theater");
+ });
+});
diff --git a/tests/e2e/helpers.mjs b/tests/e2e/helpers.mjs
index ee105606..13e19b14 100644
--- a/tests/e2e/helpers.mjs
+++ b/tests/e2e/helpers.mjs
@@ -169,6 +169,31 @@ export async function stubExportEndpoints(page) {
route.fulfill({ status: 200, contentType: "audio/wav", body: Buffer.from("RIFF") }));
}
+/**
+ * Answer the tag read an older track asks for when it is opened (#699), with
+ * `answer` as its audio_tags (null: the file had none; a function: called with
+ * the job id), and record the job id of each request. `hold`, when given, is
+ * awaited before answering, so a test can act while the read is still out.
+ * `status` other than 200 answers with that error instead.
+ */
+export async function stubAudioTags(page, answer = null, { hold = null, status = 200 } = {}) {
+ const asked = [];
+ await page.route("**/api/jobs/*/audio-tags", async (route) => {
+ const id = new URL(route.request().url()).pathname.split("/")[3];
+ asked.push(id);
+ if (hold) await hold;
+ const tags = typeof answer === "function" ? answer(id) : answer;
+ try {
+ await route.fulfill(status === 200
+ ? { status, contentType: "application/json", body: JSON.stringify({ audio_tags: tags }) }
+ : { status, contentType: "application/json", body: JSON.stringify({ detail: "stubbed" }) });
+ } catch {
+ // The page went away while the answer was held.
+ }
+ });
+ return asked;
+}
+
/**
* Answer the update check locally instead of letting it reach GitHub.
*
diff --git a/tests/e2e/library-rows.spec.mjs b/tests/e2e/library-rows.spec.mjs
index e37b93d7..e284cc62 100644
--- a/tests/e2e/library-rows.spec.mjs
+++ b/tests/e2e/library-rows.spec.mjs
@@ -35,13 +35,6 @@ async function open(page) {
await page.locator(".cat-item").first().waitFor({ timeout: 20000 });
}
-/** The stems a job really has, from the server, not counting the mix. */
-async function stemFiles(page, id) {
- const res = await page.request.get(`/api/jobs/${id}`);
- const state = await res.json();
- return (state.stems || []).filter((s) => (s.name ?? s) !== "original").length;
-}
-
// Both fixture jobs, as seedLibrary has them, with the first one changed.
// Seeding only the first would not work: the startup sync adopts the second
// from the server, and as they share a source the catalog's dedup (#542)
@@ -65,12 +58,21 @@ test.describe("library rows", () => {
// No local library at all: both jobs arrive through the startup sync, the
// path that used to leave the line as " · 0 stems".
await open(page);
- const expected = await stemFiles(page, SIBLING_JOB_ID);
- expect(expected).toBeGreaterThan(0);
-
const line = await rowText(page, SIBLING_JOB_ID);
expect(line).not.toMatch(/^·/);
- expect(line).toMatch(new RegExp(`(^|· )${expected} stems$`));
+ expect(line).not.toMatch(/·\s*$/);
+ // The row gives the length only; the stem count lives in the now-playing
+ // card (#699).
+ expect(line).not.toMatch(/stems?/i);
+ });
+
+ test("a finished row gives its length and no stem count", async ({ page }) => {
+ await seedCatalogState(
+ page,
+ withTracks({ ...fixtureTrack(JOB_ID, "Timed"), duration: 357 }),
+ );
+ await open(page);
+ expect(await rowText(page, JOB_ID)).toBe("05:57");
});
test("an old stored label is not shown, and not searched", async ({ page }) => {
diff --git a/tests/e2e/library-tags.spec.mjs b/tests/e2e/library-tags.spec.mjs
new file mode 100644
index 00000000..56976463
--- /dev/null
+++ b/tests/e2e/library-tags.spec.mjs
@@ -0,0 +1,74 @@
+// The library's Tags section shows the open track's tags, not every tag in the
+// library. With every tag from every track it was a wall of chips with nothing
+// to say which song they came from. A chip still filters the library by its
+// #tag, and its count is still how many tracks that filter finds.
+import { test, expect } from "@playwright/test";
+import {
+ JOB_ID,
+ SIBLING_JOB_ID,
+ TRACK_TITLE,
+ SIBLING_TITLE,
+ fixtureTrack,
+ seedCatalogState,
+ stubExportEndpoints,
+ stubUpdateCheck,
+} from "./helpers.mjs";
+
+const FIRST_TAGS = ["rock", "live", "shared"];
+const SIBLING_TAGS = ["jazz", "shared"];
+
+const chips = (page) => page.locator(".lib-tags-section .lib-tag-chip");
+const chipTags = (page) => chips(page).evaluateAll((els) => els.map((el) => el.dataset.tag));
+
+test.beforeEach(async ({ page }) => {
+ // Both fixture jobs, as seedLibrary has them, each with tags of its own.
+ // Seeding only one would not work: the startup sync adopts the other from
+ // the server, and as they share a source the catalog's dedup (#542)
+ // replaces the first with it.
+ await seedCatalogState(page, {
+ folders: [
+ { id: "f-unsorted", name: "Unsorted", items: [JOB_ID, SIBLING_JOB_ID], color: null },
+ { id: "trash", name: "Trash", items: [], color: null },
+ ],
+ tracks: {
+ [JOB_ID]: { ...fixtureTrack(JOB_ID, TRACK_TITLE), tags: FIRST_TAGS },
+ [SIBLING_JOB_ID]: { ...fixtureTrack(SIBLING_JOB_ID, SIBLING_TITLE), tags: SIBLING_TAGS },
+ },
+ });
+ await stubExportEndpoints(page);
+ await stubUpdateCheck(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.locator(`.cat-item[data-id="${JOB_ID}"]`).first().waitFor({ timeout: 20000 });
+});
+
+test("no track open, no Tags section", async ({ page }) => {
+ await expect(page.locator(".lib-tags-section")).toHaveCount(0);
+ await expect(page.locator(".lib-tag-chip")).toHaveCount(0);
+});
+
+test("only the open track's tags show, and they follow the open track", async ({ page }) => {
+ await page.locator(`.cat-item[data-id="${JOB_ID}"]`).first().click();
+ await expect.poll(() => chipTags(page), { timeout: 20000 }).toEqual(FIRST_TAGS);
+ // A tag on both tracks counts both, since that is what its filter finds.
+ await expect(chips(page).nth(2).locator(".lib-tag-count")).toHaveText("2");
+ await expect(chips(page).first().locator(".lib-tag-count")).toHaveText("1");
+
+ await page.locator(`.cat-item[data-id="${SIBLING_JOB_ID}"]`).first().click();
+ await expect.poll(() => chipTags(page), { timeout: 20000 }).toEqual(SIBLING_TAGS);
+});
+
+test("a chip still filters the library by its tag", async ({ page }) => {
+ await page.locator(`.cat-item[data-id="${JOB_ID}"]`).first().click();
+ await expect.poll(() => chipTags(page), { timeout: 20000 }).toEqual(FIRST_TAGS);
+
+ await chips(page).first().click();
+ await expect(page.locator("#catalogSearch")).toHaveValue("#rock");
+ await expect(page.locator(`.cat-item[data-id="${JOB_ID}"]`)).not.toHaveCount(0);
+ await expect(page.locator(`.cat-item[data-id="${SIBLING_JOB_ID}"]`)).toHaveCount(0);
+ await expect(chips(page).first()).toHaveClass(/active/);
+
+ // Clicking the active chip again clears the filter.
+ await chips(page).first().click();
+ await expect(page.locator("#catalogSearch")).toHaveValue("");
+ await expect(page.locator(`.cat-item[data-id="${SIBLING_JOB_ID}"]`)).not.toHaveCount(0);
+});
diff --git a/tests/e2e/lyrics.spec.mjs b/tests/e2e/lyrics.spec.mjs
new file mode 100644
index 00000000..afdcf3cd
--- /dev/null
+++ b/tests/e2e/lyrics.spec.mjs
@@ -0,0 +1,400 @@
+// The Lyrics tab (#699): a view of the sidebar that finds the open track's
+// lyrics by itself, from the file's tags or the band saved on the track, keeps
+// them for the track, and shows them in time with playback, karaoke style.
+// There is no search box.
+//
+// LRCLIB is answered here, so the tests run offline. The fixture track is six
+// seconds long, which is why the synced lines below sit inside it and why the
+// first row below counts as the same recording. Ranking, LRC parsing, word
+// timing and title cleaning are covered in tests/js/lyrics-lookup.test.mjs;
+// this is the page: what is looked up and when, what is kept, what is shown.
+import { test, expect } from "@playwright/test";
+import {
+ JOB_ID,
+ SIBLING_JOB_ID,
+ fixtureTrack,
+ readCatalogState,
+ seedCatalogState,
+ seedLibrary,
+ stubAudioTags,
+ stubExportEndpoints,
+ stubUpdateCheck,
+} from "./helpers.mjs";
+
+const LRCLIB = /^https:\/\/lrclib\.net\//;
+
+const ROWS = [
+ {
+ id: 101,
+ trackName: "Fixture Song",
+ artistName: "Fixture Band",
+ albumName: "Studio",
+ duration: 6,
+ instrumental: false,
+ syncedLyrics: "[00:00.50] First line\n[00:02.00] Second line\n[00:04.00] Third line",
+ plainLyrics: "First line\nSecond line\nThird line",
+ },
+ {
+ id: 102,
+ trackName: "Fixture Song (Live)",
+ artistName: "Fixture Band",
+ albumName: "Live",
+ duration: 300,
+ instrumental: false,
+ syncedLyrics: null,
+ plainLyrics: "Live words\nMore live words",
+ },
+];
+
+const TAGS = { audioTags: { artist: "Fixture Band", title: "Fixture Song" } };
+
+async function stubLrclib(page, { rows = ROWS, offline = false } = {}) {
+ const asked = [];
+ await page.route(LRCLIB, async (route) => {
+ asked.push(route.request().url());
+ if (offline) return route.abort("internetdisconnected");
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ headers: { "access-control-allow-origin": "*" },
+ body: JSON.stringify(rows),
+ });
+ });
+ return asked;
+}
+
+/** Both fixture tracks, the first with `extra` fields (tags, a saved band). */
+async function seedWith(page, extra) {
+ await seedCatalogState(page, {
+ folders: [
+ { id: "f-unsorted", name: "Unsorted", items: [JOB_ID, SIBLING_JOB_ID], color: null },
+ { id: "trash", name: "Trash", items: [], color: null },
+ ],
+ tracks: {
+ [JOB_ID]: { ...fixtureTrack(JOB_ID, "E2E Fixture Track"), ...extra },
+ [SIBLING_JOB_ID]: fixtureTrack(SIBLING_JOB_ID, "E2E Fixture Track (again)"),
+ },
+ });
+}
+
+// A tagged artist also sets off the artist box's own lookup (artistInfo.js).
+// Answered with "no such band", so no test reaches Wikimedia and none gets a
+// band saved behind its back.
+const WIKIMEDIA = /^https:\/\/((www|query)\.wikidata\.org|[a-z-]+\.wikipedia\.org)\//;
+
+async function open(page) {
+ await stubExportEndpoints(page);
+ await stubUpdateCheck(page);
+ await page.route(WIKIMEDIA, (route) => route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ headers: { "access-control-allow-origin": "*" },
+ body: JSON.stringify({ search: [] }),
+ }));
+ // An untagged track asks the server to read its tags when it is opened
+ // (catalog.js). Answered with none, so an untagged fixture stays untagged.
+ await stubAudioTags(page, null);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.locator(".cat-item").first().waitFor({ timeout: 20000 });
+}
+
+async function openTrack(page) {
+ await page.locator(`.cat-item[data-id="${JOB_ID}"]`).first().click();
+ await expect(page.locator(".app")).not.toHaveClass(/no-track/, { timeout: 15000 });
+}
+
+const showLyricsTab = (page) => page.locator(".rail-lyrics").click();
+
+/** A tagged track, opened, with the tab showing its lyrics found. */
+async function lyricsShown(page) {
+ const asked = await stubLrclib(page);
+ await seedWith(page, TAGS);
+ await open(page);
+ await openTrack(page);
+ await showLyricsTab(page);
+ await expect(page.locator(".lyrics-line")).toHaveCount(3);
+ return asked;
+}
+
+const stored = (page) =>
+ page.evaluate((id) => JSON.parse(localStorage.getItem(`stemdeck.lyrics.${id}`) || "null"), JOB_ID);
+
+test.describe("lyrics tab", () => {
+ test("the rail button swaps the library list for the lyrics panel, which has no search box", async ({ page }) => {
+ await stubLrclib(page);
+ await seedLibrary(page);
+ await open(page);
+ await showLyricsTab(page);
+ await expect(page.locator("#lyricsPanel")).toBeVisible();
+ await expect(page.locator("#catalogList")).toBeHidden();
+ await expect(page.locator(".rail-lyrics")).toHaveAttribute("aria-pressed", "true");
+ await expect(page.locator("#lyricsPanel input, #lyricsPanel form")).toHaveCount(0);
+
+ await page.locator(".rail-library").click();
+ await expect(page.locator("#lyricsPanel")).toBeHidden();
+ await expect(page.locator("#catalogList")).toBeVisible();
+ });
+
+ test("with no track open it says to open one", async ({ page }) => {
+ const asked = await stubLrclib(page);
+ await seedLibrary(page);
+ await open(page);
+ await showLyricsTab(page);
+ await expect(page.locator("#lyricsStatus")).toHaveText("Open a track to find its lyrics.");
+ expect(asked).toEqual([]);
+ });
+
+ test("a track with no tags and no saved band says how to give it one, and asks nothing", async ({ page }) => {
+ const asked = await stubLrclib(page);
+ await seedLibrary(page);
+ await open(page);
+ await openTrack(page);
+ await showLyricsTab(page);
+ await expect(page.locator("#lyricsStatus")).toContainText("Save its band with the");
+ expect(asked).toEqual([]);
+ });
+
+ test("the file's tags are looked up, and a version the track's length is kept", async ({ page }) => {
+ const asked = await lyricsShown(page);
+ await expect(page.locator(".lyrics-line")).toHaveText(["First line", "Second line", "Third line"]);
+ await expect(page.locator(".lyrics-match-title")).toHaveText("Fixture Song");
+
+ // What went out: the tagged artist and song, to LRCLIB, nothing else.
+ expect(asked).toHaveLength(1);
+ const url = new URL(asked[0]);
+ expect(url.searchParams.get("artist_name")).toBe("Fixture Band");
+ expect(url.searchParams.get("track_name")).toBe("Fixture Song");
+ expect(asked[0]).not.toContain("E2E");
+
+ // Kept on this machine, for this track.
+ await expect.poll(async () => (await stored(page))?.entry?.id ?? null).toBe(101);
+
+ // The other version is folded away until asked for, then can be taken.
+ const toggle = page.locator(".lyrics-tools .lyrics-link").first();
+ await expect(toggle).toHaveText("Other versions (1)");
+ await expect(page.locator(".lyrics-version-list")).toBeHidden();
+ await toggle.click();
+ await page.locator(".lyrics-version", { hasText: "Fixture Song (Live)" }).click();
+ await expect(page.locator(".lyrics-text").first()).toHaveText("Live words");
+ await expect(page.locator(".lyrics-line")).toHaveCount(0);
+ });
+
+ test("a saved band is looked up with the song's name taken from the title", async ({ page }) => {
+ const asked = await stubLrclib(page);
+ await seedWith(page, { artist: { id: "Q162586", name: "Dream Theater", englishName: "Dream Theater" } });
+ await open(page);
+ await openTrack(page);
+ await showLyricsTab(page);
+ await expect(page.locator(".lyrics-line")).toHaveCount(3);
+ const url = new URL(asked[0]);
+ expect(url.searchParams.get("artist_name")).toBe("Dream Theater");
+ expect(url.searchParams.get("track_name")).toBe("E2E Fixture Track");
+
+ // Kept, so coming back to the tab asks nothing more.
+ await page.locator(".rail-library").click();
+ await showLyricsTab(page);
+ await expect(page.locator(".lyrics-line")).toHaveCount(3);
+ expect(asked).toHaveLength(1);
+ });
+
+ test("the library keeps only that a file has lyrics; the tab fetches them once", async ({ page }) => {
+ const asked = await stubLrclib(page);
+ await seedWith(page, { audioTags: { artist: "Fixture Band", title: "Fixture Song", hasLyrics: true } });
+ // The server's state for the job carries the lyrics, as it does for an
+ // upload whose file had a lyrics tag.
+ await page.route(new RegExp(`/api/jobs/${JOB_ID}$`), async (route) => {
+ const response = await route.fetch();
+ const state = await response.json();
+ state.audio_tags = {
+ artist: "Fixture Band",
+ title: "Fixture Song",
+ lyrics: ["[00:00.50]Served one", "[00:02.00]Served two"].join("\n"),
+ };
+ await route.fulfill({ response, json: state });
+ });
+ await open(page);
+ await openTrack(page);
+ await showLyricsTab(page);
+ await expect(page.locator(".lyrics-line")).toHaveText(["Served one", "Served two"]);
+ expect(asked).toEqual([]);
+
+ // The library store has the flag, never the text.
+ const library = await readCatalogState(page);
+ expect(library.tracks[JOB_ID].audioTags).toEqual({ artist: "Fixture Band", title: "Fixture Song", hasLyrics: true });
+ expect(JSON.stringify(library)).not.toContain("Served one");
+ });
+
+ test("lyrics the file came with are shown, with nothing asked", async ({ page }) => {
+ const asked = await stubLrclib(page);
+ await seedWith(page, {
+ audioTags: {
+ artist: "Fixture Band",
+ title: "Fixture Song",
+ lyrics: ["[00:00.50]Embedded one", "[00:02.00]Embedded two"].join("\n"),
+ },
+ });
+ await open(page);
+ await openTrack(page);
+ await showLyricsTab(page);
+ await expect(page.locator(".lyrics-line")).toHaveText(["Embedded one", "Embedded two"]);
+ await expect(page.locator(".lyrics-match-meta")).toContainText("From the file");
+ expect(asked).toEqual([]);
+ });
+
+ test("with no version the track's length, they are offered and none is kept", async ({ page }) => {
+ const asked = await stubLrclib(page, { rows: [ROWS[1]] });
+ await seedWith(page, TAGS);
+ await open(page);
+ await openTrack(page);
+ await showLyricsTab(page);
+ await expect(page.locator("#lyricsStatus")).toContainText("Pick the right one");
+ await expect(page.locator(".lyrics-version")).toHaveCount(1);
+ await expect(page.locator(".lyrics-line")).toHaveCount(0);
+ expect(await stored(page)).toBeNull();
+
+ // The same offer again on coming back, without asking again.
+ await page.locator(".rail-library").click();
+ await showLyricsTab(page);
+ await expect(page.locator(".lyrics-version")).toHaveCount(1);
+ expect(asked).toHaveLength(1);
+
+ await page.locator(".lyrics-version").click();
+ await expect(page.locator(".lyrics-text").first()).toHaveText("Live words");
+ });
+
+ test("clicking a synced line moves the playhead there and marks it", async ({ page }) => {
+ await lyricsShown(page);
+ await page.locator(".lyrics-line", { hasText: "Third line" }).click();
+ await expect(page.locator("#t-time")).toContainText("00:04");
+ await expect(page.locator(".lyrics-line.current")).toHaveText("Third line");
+ });
+
+ test("the line being sung fills word by word, and the next is raised", async ({ page }) => {
+ await lyricsShown(page);
+
+ // Paused just after "Second line" starts: its first word is being sung.
+ await page.locator(".lyrics-line", { hasText: "Second line" }).click();
+ const current = page.locator(".lyrics-line.current");
+ await expect(current).toHaveText("Second line");
+ await expect(current.locator(".lw")).toHaveCount(2);
+ await expect(current.locator(".lw").first()).toHaveClass(/singing/);
+ await expect(current.locator(".lw").nth(1)).not.toHaveClass(/sung|singing/);
+ await expect(page.locator(".lyrics-line.next")).toHaveText("Third line");
+
+ // Moving on leaves no fill behind on the line before.
+ await page.locator(".lyrics-line", { hasText: "Third line" }).click();
+ await expect(page.locator(".lyrics-line", { hasText: "Second line" }).locator(".lw.sung, .lw.singing"))
+ .toHaveCount(0);
+ });
+
+ test("the wipe waits for the singer: no fill while the vocals stem is silent", async ({ page }) => {
+ // The fixture's vocals are a steady tone, so the real envelope would have
+ // the singing start on the stamp. This one has the voice come in 0.6s
+ // after "Second line" is stamped, as a singer who is late on it would.
+ const hop = 0.04;
+ const db = Array.from({ length: 150 }, (_, i) => (i * hop >= 2.6 && i * hop < 3.6 ? -15 : -90));
+ const envelopeUrl = new RegExp(`/api/jobs/${JOB_ID}/vocal-envelope$`);
+ await page.route(envelopeUrl, (route) => route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ hop, db }),
+ }));
+ const asked = page.waitForRequest(envelopeUrl);
+ await lyricsShown(page);
+ await asked;
+
+ await page.locator(".lyrics-line", { hasText: "Second line" }).click();
+ const current = page.locator(".lyrics-line.current");
+ await expect(current).toHaveText("Second line");
+ // Paused on the stamp, before the voice: the line is current, nothing filled.
+ await expect(current.locator(".lw.sung, .lw.singing")).toHaveCount(0);
+ });
+
+ test("kept lyrics come back after a reload, with nothing asked", async ({ page }) => {
+ const asked = await lyricsShown(page);
+ // The library is re-seeded on every load; the lyrics have a store entry of
+ // their own, so they survive it.
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await page.locator(".cat-item").first().waitFor({ timeout: 20000 });
+ asked.length = 0;
+ await openTrack(page);
+ await showLyricsTab(page);
+ await expect(page.locator(".lyrics-line")).toHaveCount(3);
+ expect(asked).toEqual([]);
+ });
+
+ test("removed lyrics stay removed until looked up again", async ({ page }) => {
+ const asked = await lyricsShown(page);
+ await page.locator(".lyrics-tools .lyrics-link", { hasText: "Remove lyrics" }).click();
+ await expect(page.locator(".lyrics-line")).toHaveCount(0);
+ await expect(page.locator("#lyricsStatus")).toHaveText("Lyrics removed for this track.");
+ await expect.poll(() => stored(page)).toEqual({ dismissed: true });
+
+ // The lookup runs by itself, and must not bring them straight back.
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await page.locator(".cat-item").first().waitFor({ timeout: 20000 });
+ await openTrack(page);
+ await showLyricsTab(page);
+ await expect(page.locator("#lyricsStatus")).toHaveText("Lyrics removed for this track.");
+ expect(asked).toHaveLength(1);
+
+ await page.locator(".lyrics-link", { hasText: "Look up again" }).click();
+ await expect(page.locator(".lyrics-line")).toHaveCount(3);
+ expect(asked).toHaveLength(2);
+ });
+
+ test("nothing found says so", async ({ page }) => {
+ await stubLrclib(page, { rows: [] });
+ await seedWith(page, TAGS);
+ await open(page);
+ await openTrack(page);
+ await showLyricsTab(page);
+ await expect(page.locator("#lyricsStatus")).toHaveText("No lyrics found for “Fixture Song”.");
+ });
+
+ test("no connection says so, and the next opening tries again", async ({ page }) => {
+ const asked = await stubLrclib(page, { offline: true });
+ await seedWith(page, TAGS);
+ await open(page);
+ await openTrack(page);
+ await showLyricsTab(page);
+ await expect(page.locator("#lyricsStatus")).toHaveText(
+ "Could not reach LRCLIB. Check your connection and try again.",
+ );
+
+ await page.unroute(LRCLIB);
+ await stubLrclib(page);
+ await page.locator(".rail-library").click();
+ await showLyricsTab(page);
+ await expect(page.locator(".lyrics-line")).toHaveCount(3);
+ expect(asked).toHaveLength(1);
+ });
+
+ test("no connection offers a way to try again in place", async ({ page }) => {
+ await stubLrclib(page, { offline: true });
+ await seedWith(page, TAGS);
+ await open(page);
+ await openTrack(page);
+ await showLyricsTab(page);
+ const again = page.locator(".lyrics-link", { hasText: "Look up again" });
+ await expect(again).toBeVisible();
+
+ await page.unroute(LRCLIB);
+ const asked = await stubLrclib(page);
+ await again.click();
+ await expect(page.locator(".lyrics-line")).toHaveCount(3);
+ expect(asked).toHaveLength(1);
+ });
+
+ test("a tagged artist with no tagged title is looked up with the song from the title", async ({ page }) => {
+ const asked = await stubLrclib(page);
+ await seedWith(page, { audioTags: { artist: "Fixture Band" } });
+ await open(page);
+ await openTrack(page);
+ await showLyricsTab(page);
+ await expect(page.locator(".lyrics-line")).toHaveCount(3);
+ const url = new URL(asked[0]);
+ expect(url.searchParams.get("artist_name")).toBe("Fixture Band");
+ expect(url.searchParams.get("track_name")).toBe("E2E Fixture Track");
+ });
+});
diff --git a/tests/e2e/now-playing-art.spec.mjs b/tests/e2e/now-playing-art.spec.mjs
new file mode 100644
index 00000000..b52732ae
--- /dev/null
+++ b/tests/e2e/now-playing-art.spec.mjs
@@ -0,0 +1,71 @@
+// The now-playing square shows the open track's own picture, or its format
+// icon when it has none. Switching tracks from the library goes straight to
+// wireUpAudio without the teardown that clears the square, and wireUpAudio
+// only ever set the thumbnail, never cleared it. So opening an upload after a
+// YouTube track left the YouTube cover over the upload's WAV icon (#699).
+import { test, expect } from "@playwright/test";
+import {
+ JOB_ID,
+ SIBLING_JOB_ID,
+ fixtureTrack,
+ seedCatalogState,
+ stubExportEndpoints,
+ stubUpdateCheck,
+} from "./helpers.mjs";
+
+// A 1x1 PNG. data: is in the page's img-src, so it loads with no network.
+const PIXEL =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
+
+// Both fixture jobs, seeded together: seeding one lets the startup sync adopt
+// the other, and the catalog's dedup (#542) would fold them into one row.
+function library() {
+ const tracks = {
+ [JOB_ID]: { ...fixtureTrack(JOB_ID, "With Cover"), thumb: PIXEL },
+ [SIBLING_JOB_ID]: fixtureTrack(SIBLING_JOB_ID, "No Cover"),
+ };
+ return {
+ folders: [
+ { id: "f-unsorted", name: "Unsorted", items: Object.keys(tracks), color: null },
+ { id: "trash", name: "Trash", items: [], color: null },
+ ],
+ tracks,
+ };
+}
+
+const thumbState = (page) =>
+ page.locator("#np-thumb").evaluate((img) => ({
+ src: img.getAttribute("src") || "",
+ loaded: img.classList.contains("loaded"),
+ }));
+
+// The title shown is the server job's, whatever the seed called it.
+const TITLES = {
+ [JOB_ID]: /^E2E Fixture Track$/,
+ [SIBLING_JOB_ID]: /^E2E Fixture Track \(again\)$/,
+};
+
+async function openTrack(page, id) {
+ await page.locator(`.cat-item[data-id="${id}"]`).first().click();
+ await expect(page.locator("#title")).toHaveText(TITLES[id], { timeout: 15000 });
+}
+
+test.describe("now-playing art", () => {
+ test("a track with no thumbnail does not keep the previous track's", async ({ page }) => {
+ await seedCatalogState(page, library());
+ await stubExportEndpoints(page);
+ await stubUpdateCheck(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.locator(".cat-item").first().waitFor({ timeout: 20000 });
+
+ await openTrack(page, JOB_ID);
+ await expect.poll(() => thumbState(page)).toEqual({ src: PIXEL, loaded: true });
+
+ await openTrack(page, SIBLING_JOB_ID);
+ await expect.poll(() => thumbState(page)).toEqual({ src: "", loaded: false });
+
+ // And back again: clearing it must not stop the next one from showing.
+ await openTrack(page, JOB_ID);
+ await expect.poll(() => thumbState(page)).toEqual({ src: PIXEL, loaded: true });
+ });
+});
diff --git a/tests/js/artist-lookup.test.mjs b/tests/js/artist-lookup.test.mjs
new file mode 100644
index 00000000..d9eda08d
--- /dev/null
+++ b/tests/js/artist-lookup.test.mjs
@@ -0,0 +1,239 @@
+// The artist box's lookup (#699): which search result it takes as the
+// artist, a saved band found by its id, how it reads members, albums and the
+// History section out of what Wikidata and Wikipedia return, and how it tells
+// "no such artist" from "no connection".
+//
+// No network. lookupArtist takes its fetch as an argument, and the fake below
+// answers from fixtures shaped like the real responses (trimmed from live ones
+// for Dream Theater, Q162586).
+//
+// Run: node tests/js/artist-lookup.test.mjs
+
+import {
+ pickArtist,
+ membersFromClaims,
+ albumsFromSparql,
+ historyFromExtract,
+ historyHeadingsFor,
+ wikiLanguage,
+ lookupArtist,
+ artistMatchesName,
+ taggedArtistName,
+} from "../../static/js/artistLookup.js";
+
+let pass = 0,
+ fail = 0;
+const check = (name, cond, detail = "") => {
+ if (cond) {
+ pass++;
+ } else {
+ fail++;
+ console.error(`FAIL ${name}${detail ? `\n ${detail}` : ""}`);
+ }
+};
+const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
+
+// ── wikiLanguage ──
+check("zh-Hans reads the Chinese edition", wikiLanguage("zh-Hans") === "zh");
+check("pt-PT reads the Portuguese edition", wikiLanguage("pt-PT") === "pt");
+check("an unknown code reads English", wikiLanguage("xx") === "en");
+
+// ── pickArtist ──
+const band = { id: "Q162586", claims: { P434: [{}], P31: [{}] } };
+const album = { id: "Q13420662", claims: { P31: [{}] } };
+check(
+ "skips the album of the same name for the band",
+ pickArtist({ Q13420662: album, Q162586: band }, ["Q13420662", "Q162586"])?.id === "Q162586",
+);
+check("nothing that is an artist gives null", pickArtist({ Q13420662: album }, ["Q13420662"]) === null);
+
+// ── membersFromClaims ──
+const member = (id, ended = false, rank = "normal") => ({
+ rank,
+ mainsnak: { datavalue: { value: { id } } },
+ qualifiers: ended ? { P582: [{}] } : {},
+});
+const members = membersFromClaims({
+ P527: [
+ member("Q175102"),
+ member("Q221468", true), // Portnoy, left in 2010
+ member("Q221468"), // and back in 2023
+ member("Q296039", true),
+ member("Q999999", false, "deprecated"),
+ ],
+});
+check("a member who came back is current", members.current.includes("Q221468"));
+check("and is not also former", !members.former.includes("Q221468"));
+check("an ended membership is former", same(members.former, ["Q296039"]));
+check("a deprecated statement is ignored", !members.current.includes("Q999999"));
+check("a solo artist has no members", same(membersFromClaims({}), { current: [], former: [] }));
+
+// ── albumsFromSparql ──
+const row = (id, title, date) => ({
+ album: { value: `http://www.wikidata.org/entity/${id}` },
+ albumLabel: { value: title },
+ ...(date ? { released: { value: `${date}-01-01T00:00:00Z` } } : {}),
+});
+const albums = albumsFromSparql({
+ results: {
+ bindings: [
+ row("Q2", "Images and Words", "1992"),
+ row("Q1", "When Dream and Day Unite", "1989"),
+ row("Q2", "Images and Words", "1992"),
+ row("Q3", "Q3"),
+ row("Q4", "Undated"),
+ ],
+ },
+});
+check(
+ "oldest first, one row each, undated last, bare ids dropped",
+ same(albums, [
+ { title: "When Dream and Day Unite", year: "1989" },
+ { title: "Images and Words", year: "1992" },
+ { title: "Undated", year: "" },
+ ]),
+ JSON.stringify(albums),
+);
+check("no answer, no albums", same(albumsFromSparql(null), []));
+
+// ── historyFromExtract ──
+const article = [
+ "Dream Theater is an American progressive metal band.",
+ "",
+ "== History ==",
+ "=== Formation (1985-1986) ===",
+ "In 1985, three students formed a band.",
+ "They called it Majesty.",
+ "== Band members ==",
+ "John Petrucci",
+].join("\n");
+check(
+ "the History section, without its sub-headings or what follows",
+ same(historyFromExtract(article, historyHeadingsFor("en")), [
+ "In 1985, three students formed a band.",
+ "They called it Majesty.",
+ ]),
+);
+const german = "Einleitung.\n== Geschichte ==\nIm September 1985 gegründet.\n== Diskografie ==\nx";
+check("a heading in the edition's language", same(historyFromExtract(german, historyHeadingsFor("de")), ["Im September 1985 gegründet."]));
+check(
+ "no History heading falls back to the summary",
+ same(historyFromExtract("A summary.\n== Discography ==\nx", historyHeadingsFor("en")), ["A summary."]),
+);
+const long = `== History ==\n${"A sentence that goes on. ".repeat(120)}`;
+const capped = historyFromExtract(long, historyHeadingsFor("en"));
+check("a long history is capped at a sentence", capped.join("").length <= 1410 && capped.at(-1).endsWith("…"));
+
+// ── lookupArtist ──
+const fixtures = {
+ wbsearchentities: { search: [{ id: "Q13420662" }, { id: "Q162586" }] },
+ bandEntities: {
+ entities: {
+ Q13420662: album,
+ Q162586: {
+ id: "Q162586",
+ labels: { en: { value: "Dream Theater" } },
+ descriptions: { en: { value: "American progressive metal band" } },
+ sitelinks: { enwiki: { site: "enwiki", title: "Dream Theater", url: "https://en.wikipedia.org/wiki/Dream_Theater" } },
+ claims: {
+ P434: [{}],
+ P18: [{ mainsnak: { datavalue: { value: "Dream theater live.jpg" } } }],
+ P527: [member("Q175102"), member("Q296039", true)],
+ },
+ },
+ },
+ },
+ memberEntities: {
+ entities: {
+ Q175102: { labels: { en: { value: "John Petrucci" } } },
+ Q296039: { labels: { en: { value: "Kevin Moore" } } },
+ },
+ },
+ sparql: { results: { bindings: [row("Q9", "Awake", "1994")] } },
+ extract: { query: { pages: [{ extract: "Lead.\n== History ==\nFormed in 1985." }] } },
+};
+
+function fakeFetch({ fail: failing = [], nothing = false } = {}) {
+ const calls = [];
+ const fetchJson = async (url) => {
+ calls.push(url);
+ const u = new URL(url);
+ const kind =
+ u.host === "query.wikidata.org" ? "sparql"
+ : u.host.endsWith("wikipedia.org") ? "extract"
+ : u.searchParams.get("action") === "wbsearchentities" ? "search"
+ : u.searchParams.get("props") === "labels" ? "members"
+ : "entities";
+ if (failing.includes(kind)) throw new Error(`offline (${kind})`);
+ if (kind === "search") return nothing ? { search: [] } : fixtures.wbsearchentities;
+ if (kind === "entities") return fixtures.bandEntities;
+ if (kind === "members") return fixtures.memberEntities;
+ if (kind === "sparql") return fixtures.sparql;
+ return fixtures.extract;
+ };
+ return { fetchJson, calls };
+}
+
+const { fetchJson, calls } = fakeFetch();
+const artist = await lookupArtist("Dream Theater", "en", { fetchJson });
+check("finds the band, not the album", artist?.id === "Q162586");
+check("name and description", artist?.name === "Dream Theater" && artist?.description === "American progressive metal band");
+check("history from the article", same(artist?.history, ["Formed in 1985."]));
+check("members by name", same(artist?.members, { current: ["John Petrucci"], former: ["Kevin Moore"] }));
+check("albums", same(artist?.albums, [{ title: "Awake", year: "1994" }]));
+check("article link", artist?.articleUrl === "https://en.wikipedia.org/wiki/Dream_Theater");
+check("photo from Commons, name encoded", artist?.image.includes("Special:FilePath/Dream%20theater%20live.jpg"));
+check(
+ "only Wikimedia hosts are asked",
+ calls.every((url) => /^https:\/\/(www\.wikidata\.org|query\.wikidata\.org|[a-z]+\.wikipedia\.org)\//.test(url)),
+);
+check("the search sends the name and nothing else about the track", calls[0].includes("search=Dream+Theater"));
+
+check("a name that is no artist gives null", (await lookupArtist("zzz", "en", { fetchJson: fakeFetch({ nothing: true }).fetchJson })) === null);
+check("an empty name asks nothing", (await lookupArtist(" ", "en", { fetchJson: () => { throw new Error("asked"); } })) === null);
+
+let rejected = false;
+try {
+ await lookupArtist("Dream Theater", "en", { fetchJson: fakeFetch({ fail: ["search"] }).fetchJson });
+} catch {
+ rejected = true;
+}
+check("no connection rejects, rather than reading as not found", rejected);
+
+const partial = await lookupArtist("Dream Theater", "en", { fetchJson: fakeFetch({ fail: ["sparql", "extract"] }).fetchJson });
+check(
+ "one section failing leaves the rest",
+ partial && same(partial.albums, []) && same(partial.history, []) && partial.members.current.length === 1,
+);
+
+// A band saved on a track is looked up by its id, with no search at all.
+const byId = fakeFetch();
+const saved = await lookupArtist("", "en", { id: "Q162586", fetchJson: byId.fetchJson });
+check("a saved id finds the band", saved?.id === "Q162586");
+check(
+ "and asks no name search",
+ !byId.calls.some((url) => new URL(url).searchParams.get("action") === "wbsearchentities"),
+);
+check(
+ "an id that is not a Wikidata item is not used",
+ (await lookupArtist("", "en", { id: "Q1 OR 1=1", fetchJson: () => { throw new Error("asked"); } })) === null,
+);
+
+// ── The band found from a file's tags, saved with nobody looking ──
+const dt = { name: "Dream Theater", englishName: "Dream Theater" };
+check("the same name matches", artistMatchesName(dt, "Dream Theater"));
+check("case and spacing do not matter", artistMatchesName(dt, " dream THEATER "));
+check("punctuation does not matter", artistMatchesName({ name: "AC/DC", englishName: "AC/DC" }, "ACDC"));
+check("Latin accents do not matter", artistMatchesName({ name: "Beyoncé", englishName: "Beyoncé" }, "Beyonce"));
+check("the English name matches too", artistMatchesName({ name: "ドリーム・シアター", englishName: "Dream Theater" }, "Dream Theater"));
+check("a near miss does not", !artistMatchesName(dt, "Dream Theatre"));
+check("part of the name does not", !artistMatchesName(dt, "Theater"));
+check("a name that is only punctuation matches nothing", !artistMatchesName({ name: "!!!", englishName: "" }, "!!!"));
+check("no answer matches nothing", !artistMatchesName(null, "Dream Theater"));
+check("a featured artist is left off", taggedArtistName("Dream Theater feat. Someone") === "Dream Theater");
+check("in brackets too", taggedArtistName("Dream Theater (ft. Someone)") === "Dream Theater");
+check("a plain tag is kept", taggedArtistName(" Dream Theater ") === "Dream Theater");
+check("no tag, no name", taggedArtistName(undefined) === "");
+
+console.log(`${pass} passed, ${fail} failed`);
+process.exit(fail ? 1 : 0);
diff --git a/tests/js/lyrics-lookup.test.mjs b/tests/js/lyrics-lookup.test.mjs
new file mode 100644
index 00000000..e3ca397d
--- /dev/null
+++ b/tests/js/lyrics-lookup.test.mjs
@@ -0,0 +1,258 @@
+// The Lyrics tab's lookup (#699): how LRC text becomes timed lines, which line
+// is being sung at a given moment, which of LRCLIB's many versions of a song
+// is taken as the one, and what is sent to search for it.
+//
+// No network. searchLyrics takes its fetch as an argument.
+//
+// Run: node tests/js/lyrics-lookup.test.mjs
+
+import {
+ parseLrc,
+ currentLineIndex,
+ rankMatches,
+ searchUrl,
+ searchLyrics,
+ wordTimings,
+ voicedPhrases,
+ syllables,
+ songFromTitle,
+} from "../../static/js/lyricsLookup.js";
+
+let pass = 0,
+ fail = 0;
+const check = (name, cond, detail = "") => {
+ if (cond) {
+ pass++;
+ } else {
+ fail++;
+ console.error(`FAIL ${name}${detail ? `\n ${detail}` : ""}`);
+ }
+};
+const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
+
+// ── parseLrc ──
+const lrc = [
+ "[ar:Dream Theater]",
+ "[ti:Metropolis]",
+ "[01:58.63] Arrived early May",
+ "[01:55.10] The smile of dawn",
+ "[00:12.00][01:40.5]Chorus",
+ "[02:01] ",
+ "no stamp, not a line",
+].join("\n");
+const parsed = parseLrc(lrc);
+check(
+ "timed lines, earliest first, one per stamp, header tags and bare text skipped",
+ same(parsed, [
+ { time: 12, text: "Chorus" },
+ { time: 100.5, text: "Chorus" },
+ { time: 115.1, text: "The smile of dawn" },
+ { time: 118.63, text: "Arrived early May" },
+ { time: 121, text: "" },
+ ]),
+ JSON.stringify(parsed),
+);
+check(
+ "a positive offset makes every line earlier",
+ same(parseLrc("[offset:+500]\n[00:10.00]Line").map((l) => l.time), [9.5]),
+);
+check("a stamp before zero after the offset stays at zero", parseLrc("[offset:+2000]\n[00:01.00]x")[0].time === 0);
+check("CRLF line endings", parseLrc("[00:01.00]a\r\n[00:02.00]b").length === 2);
+check("nothing gives nothing", same(parseLrc(""), []) && same(parseLrc(null), []));
+
+// ── enhanced LRC: word stamps ──
+const enhanced = parseLrc("[00:12.00]<00:12.00>Hello <00:12.60>world<00:13.20>");
+check("a line with word stamps keeps its plain text", enhanced[0].text === "Hello world");
+check(
+ "and its words, each with its stamp",
+ same(enhanced[0].words.map((w) => [w.time, w.text]), [[12, "Hello "], [12.6, "world"], [13.2, ""]]),
+);
+check(
+ "word stamps follow the offset too",
+ parseLrc(["[offset:+1000]", "[00:12.00]<00:12.00>a <00:13.00>b"].join("\n"))[0].words[1].time === 12,
+);
+
+// ── wordTimings ──
+check(
+ "stamped words run from their stamp to the next",
+ same(wordTimings(enhanced[0], 20), [
+ { text: "Hello ", start: 12, end: 12.6 },
+ { text: "world", start: 12.6, end: 13.2 },
+ ]),
+);
+const even = wordTimings({ time: 10, text: "The smile of dawn" }, 20);
+check("unstamped words give back the line", even.map((w) => w.text).join("") === "The smile of dawn");
+check("start where the line does, in order", even[0].start === 10 && even.every((w, i) => i === 0 || w.start >= even[i - 1].end - 1e-9));
+const shared = wordTimings({ time: 10, text: "I remember" }, 20);
+check("and are shared by syllables", Math.abs((shared[1].end - shared[1].start) - 3 * (shared[0].end - shared[0].start)) < 1e-9);
+const beforeBreak = wordTimings({ time: 10, text: "Short" }, 60);
+check("a line before a long gap is not stretched across it", beforeBreak.at(-1).end - 10 <= 1.2 + 1e-9);
+const crowded = wordTimings({ time: 10, text: "A long line of many many words sung quickly" }, 10.5);
+check("and never runs into the next line", crowded.at(-1).end <= 10.5 + 1e-9);
+check("unspaced scripts fill a character at a time", wordTimings({ time: 0, text: String.fromCodePoint(0x591c, 0x306b, 0x99c6, 0x3051, 0x308b) }, 5).length === 5);
+check("an empty line has nothing to fill", same(wordTimings({ time: 0, text: "" }, 5), []));
+
+// Without the vocals envelope: most of the time until the next line, which is
+// what a singer takes on ordinary consecutive lines. The old guess, 90ms a
+// character, finished this one at 13.24 and ran ahead of the voice.
+const happy = { time: 10, text: "I'm so happy 'cause today I found my friends" };
+const guessed = wordTimings(happy, 14);
+check("without the envelope, a line fills 90% of the way to the next", Math.abs(guessed.at(-1).end - 13.6) < 1e-9, String(guessed.at(-1).end));
+check("with the words in order and back to back", guessed.every((w, i) => i === 0 || Math.abs(w.start - guessed[i - 1].end) < 1e-9));
+check("the last line, with no next one, still ends", wordTimings({ time: 10, text: "The end" }, null).at(-1).end <= 10 + 1.2 + 1e-9);
+
+// ── wordTimings with the vocals envelope ──
+// A synthetic envelope, 40ms a level: silence (-90dBFS) except where given.
+const HOP = 0.04;
+function envelopeOf(spans, seconds = 70, silence = -90) {
+ const db = new Array(Math.round(seconds / HOP)).fill(silence);
+ for (const [from, to, level] of spans) {
+ for (let i = Math.round(from / HOP); i < Math.round(to / HOP); i++) db[i] = level;
+ }
+ return { hop: HOP, db };
+}
+const near = (a, b, tol = HOP + 1e-9) => Math.abs(a - b) <= tol;
+const inside = (w, spans) => spans.some(([a, b]) => w.start >= a - HOP - 1e-9 && w.end <= b + HOP + 1e-9);
+
+const twoPhrases = [[10.5, 12, -15], [13, 14, -15]];
+const phrased = wordTimings({ time: 10, text: "one two three four five" }, 16, envelopeOf(twoPhrases));
+check("with the envelope, every word is still there, in order", phrased.map((w) => w.text).join("") === "one two three four five");
+check("words land only where the voice is", phrased.every((w) => inside(w, twoPhrases)), JSON.stringify(phrased));
+check("the first waits for the voice, not the stamp", near(phrased[0].start, 10.5));
+check("and the last finishes when the voice stops", near(phrased.at(-1).end, 14));
+const gapAt = phrased.findIndex((w) => w.start >= 12.9);
+check(
+ "a silence mid-line pauses the fill between two words",
+ gapAt > 0 && phrased[gapAt - 1].end <= 12 + HOP && near(phrased[gapAt].start, 13),
+ JSON.stringify(phrased),
+);
+check("no word is filling during the silence", phrased.every((w) => w.end <= 12 + HOP || w.start >= 13 - HOP));
+
+const held = wordTimings({ time: 10, text: "To this mind" }, 20, envelopeOf([[10, 14.5, -15]]));
+const len = (w) => w.end - w.start;
+check("a held note stretches the last word", near(held.at(-1).end, 14.5) && len(held[2]) > 2 * (len(held[0]) + len(held[1])), JSON.stringify(held));
+check("rather than slowing the words before it", len(held[0]) <= 0.5 + 1e-9 && len(held[1]) <= 0.5 + 1e-9);
+
+const quick = wordTimings({ time: 10, text: "A long line of many many words sung quickly" }, 16, envelopeOf([[10, 12, -15]]));
+check("a fast line is compressed into its singing", near(quick[0].start, 10) && near(quick.at(-1).end, 12));
+
+const bled = envelopeOf([[10, 16, -48], [11, 12.5, -12]]);
+const overBleed = wordTimings({ time: 10, text: "Only this" }, 16, bled);
+check("what bled into the vocals stem, 20dB under the voice, is not singing", near(overBleed[0].start, 11) && near(overBleed.at(-1).end, 12.5), JSON.stringify(overBleed));
+
+const breath = wordTimings({ time: 10, text: "Carry on my wayward son" }, 16, envelopeOf([[10, 11.2, -15], [11.36, 13, -15]]));
+check("a breath shorter than a quarter second does not stall the wipe", breath.every((w, i) => i === 0 || near(w.start, breath[i - 1].end, 1e-9)), JSON.stringify(breath));
+check(
+ "and a blip shorter than a word is not taken for one",
+ same(voicedPhrases(envelopeOf([[10, 11, -15], [13, 13.08, -15]]), 10, 16).length, 1),
+);
+
+const breakAfter = { time: 10, text: "Before the solo" };
+const backing = envelopeOf([[10, 11.5, -15], [40, 45, -15]]);
+const beforeSolo = wordTimings(breakAfter, 60, backing);
+check("a long instrumental gap is not filled, even with backing vocals in it", near(beforeSolo.at(-1).end, 11.5), JSON.stringify(beforeSolo));
+// Five syllables at the ceiling of 0.6s each: 3 seconds, not 45.
+check("and not by the estimate either", near(wordTimings(breakAfter, 60).at(-1).end, 13, 1e-9));
+
+check(
+ "a line with no singing in the envelope falls back to the estimate",
+ same(wordTimings(happy, 14, envelopeOf([])), wordTimings(happy, 14)),
+);
+check(
+ "word stamps still win over the envelope",
+ same(wordTimings(enhanced[0], 20, envelopeOf([[15, 19, -15]])), wordTimings(enhanced[0], 20)),
+);
+
+// ── syllables ──
+// "cafe" with a combining acute on the e: two syllables, the e not silent.
+const CAFE = `caf${String.fromCodePoint(0x65, 0x301)}`;
+check(
+ "syllables: vowel groups, a silent final e dropped, at least one",
+ same(["happy", "home", "'cause", "table", "Yeah,", "I", "everyday", "friends", CAFE].map(syllables), [2, 1, 1, 2, 1, 1, 4, 1, 2]),
+ JSON.stringify(["happy", "home", "'cause", "table", "Yeah,", "I", "everyday", "friends", CAFE].map(syllables)),
+);
+
+// ── songFromTitle ──
+const cases = [
+ [["Dream Theater - Pull Me Under (Official Video)", "Dream Theater"], "Pull Me Under"],
+ [[`Bohemian Rhapsody ${String.fromCodePoint(0x2013)} Queen [HD]`, "Queen"], "Bohemian Rhapsody"],
+ [['Metropolis - Part I: "The Miracle and the Sleeper"', "Dream Theater"], "Metropolis - Part I: The Miracle and the Sleeper"],
+ [["Jay-Z ft. Alicia Keys - Empire State of Mind", "Jay-Z"], "Empire State of Mind"],
+ [["Shadow Of The Day (Shadow Remix)", ""], "Shadow Of The Day (Shadow Remix)"],
+ [["Hello (Official Music Video) (Remastered 2015)", ""], "Hello"],
+];
+for (const [[title, artist], want] of cases) {
+ const got = songFromTitle(title, artist);
+ check(`song from "${title}"`, got === want, `got ${JSON.stringify(got)}`);
+}
+
+// ── currentLineIndex ──
+const lines = [{ time: 10 }, { time: 20 }, { time: 30 }];
+check("before the first line", currentLineIndex(lines, 5) === -1);
+check("on a line's stamp", currentLineIndex(lines, 20) === 1);
+check("between lines, the one before", currentLineIndex(lines, 25) === 1);
+check("after the last", currentLineIndex(lines, 99) === 2);
+check("no lines", currentLineIndex([], 5) === -1);
+
+// ── rankMatches ──
+const row = (id, duration, kind) => ({
+ id,
+ trackName: `T${id}`,
+ artistName: "A",
+ albumName: "",
+ duration,
+ instrumental: kind === "instrumental",
+ syncedLyrics: kind === "synced" ? "[00:01.00]x" : null,
+ plainLyrics: kind === "synced" || kind === "plain" ? "x" : null,
+});
+const ranked = rankMatches(
+ [row(1, 769, "synced"), row(2, 571, "plain"), row(3, 573, "synced"), row(4, 572, "none"), row(5, 590, "synced")],
+ 572,
+);
+check(
+ "closest length first, synced preferred within a few seconds, empty rows dropped",
+ same(ranked.map((m) => m.id), [3, 2, 5, 1]),
+ JSON.stringify(ranked.map((m) => m.id)),
+);
+check("an instrumental row is kept", rankMatches([row(9, 100, "instrumental")], 100).length === 1);
+check(
+ "with no length known, LRCLIB's order, synced first",
+ same(rankMatches([row(1, 10, "plain"), row(2, 500, "synced")], 0).map((m) => m.id), [2, 1]),
+);
+check("not an array gives nothing", same(rankMatches(null), []) && same(rankMatches({ error: 1 }), []));
+
+// ── searchUrl ──
+const withArtist = new URL(searchUrl({ artist: " Dream Theater ", song: " Metropolis " }));
+check("LRCLIB's search", withArtist.origin + withArtist.pathname === "https://lrclib.net/api/search");
+check(
+ "artist and song as their own fields, trimmed",
+ withArtist.searchParams.get("artist_name") === "Dream Theater" && withArtist.searchParams.get("track_name") === "Metropolis",
+);
+const songOnly = new URL(searchUrl({ song: "Metropolis" }));
+check(
+ "no artist: the free-text search, not an empty artist filter",
+ songOnly.searchParams.get("q") === "Metropolis" && !songOnly.searchParams.has("artist_name"),
+);
+
+// ── searchLyrics ──
+const asked = [];
+const found = await searchLyrics(
+ { artist: "Dream Theater", song: "Metropolis", duration: 572 },
+ { fetchJson: async (url) => { asked.push(url); return [row(1, 769, "synced"), row(3, 573, "synced")]; } },
+);
+check("ranked for the track's length", same(found.map((m) => m.id), [3, 1]));
+check("one request, to LRCLIB only", asked.length === 1 && asked[0].startsWith("https://lrclib.net/"));
+check(
+ "no song asks nothing",
+ same(await searchLyrics({ artist: "X", song: " " }, { fetchJson: () => { throw new Error("asked"); } }), []),
+);
+let rejected = false;
+try {
+ await searchLyrics({ song: "x" }, { fetchJson: async () => { throw new Error("offline"); } });
+} catch {
+ rejected = true;
+}
+check("no connection rejects, rather than reading as nothing found", rejected);
+
+console.log(`${pass} passed, ${fail} failed`);
+process.exit(fail ? 1 : 0);
diff --git a/tests/test_artist_lookup.py b/tests/test_artist_lookup.py
new file mode 100644
index 00000000..11680789
--- /dev/null
+++ b/tests/test_artist_lookup.py
@@ -0,0 +1,501 @@
+"""The band a job's artist tag names, found on Wikidata while the job runs (#699).
+
+No network: Wikidata is answered by a stub standing in for _fetch_json, and
+conftest makes every other test see it as offline. The matching cases mirror
+tests/js/artist-lookup.test.mjs, since this is a port of that module and the
+two must agree on what counts as the same name.
+"""
+
+from __future__ import annotations
+
+import json
+import threading
+import time
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+import app.pipeline.artist_lookup as al
+from app.core.models import Job, clean_artist
+from app.core.registry import _jobs, _recover_done_job
+from app.pipeline.artist_lookup import (
+ BandLookup,
+ artist_matches_name,
+ find_band,
+ lookup_band,
+ pick_artist,
+ search_language,
+ tagged_artist_name,
+)
+from app.pipeline.runner import run_local_pipeline, run_pipeline
+
+# The real request, kept before conftest swaps it for an offline stand-in.
+_REAL_FETCH_JSON = al._fetch_json
+
+DT = {"id": "Q162586", "name": "Dream Theater", "englishName": "Dream Theater"}
+
+# Trimmed from Wikidata's real answers for "Dream Theater": the album of the
+# same name ranks first and is skipped for having no MusicBrainz artist id.
+SEARCH = {"search": [{"id": "Q13420662"}, {"id": "Q162586"}, {"id": "not-an-id"}]}
+ENTITIES = {
+ "entities": {
+ "Q13420662": {
+ "id": "Q13420662",
+ "labels": {"en": {"value": "Dream Theater"}},
+ "claims": {},
+ },
+ "Q162586": {
+ "id": "Q162586",
+ "labels": {"en": {"value": "Dream Theater"}},
+ "claims": {"P434": [{}], "P31": [{}]},
+ },
+ }
+}
+
+
+class Wikidata:
+ """Stands in for _fetch_json: answers by action, records what was asked."""
+
+ def __init__(self, search=SEARCH, entities=ENTITIES, *, gate=None, fail=None):
+ self.search = search
+ self.entities = entities
+ self.gate = gate
+ self.fail = fail
+ self.asked: list[dict[str, str]] = []
+ self.called = threading.Event()
+
+ def __call__(self, params):
+ self.asked.append(dict(params))
+ self.called.set()
+ if self.gate is not None:
+ self.gate.wait(10)
+ if self.fail is not None:
+ raise self.fail
+ return self.search if params["action"] == "wbsearchentities" else self.entities
+
+
+# ── matching: the cases in tests/js/artist-lookup.test.mjs ──
+
+
+@pytest.mark.parametrize(
+ ("artist", "name"),
+ [
+ (DT, "Dream Theater"),
+ (DT, " dream THEATER "),
+ ({"name": "AC/DC", "englishName": "AC/DC"}, "ACDC"),
+ ({"name": "Beyoncé", "englishName": "Beyoncé"}, "Beyonce"),
+ ({"name": "ドリーム・シアター", "englishName": "Dream Theater"}, "Dream Theater"),
+ ],
+)
+def test_names_that_match(artist, name):
+ assert artist_matches_name(artist, name)
+
+
+@pytest.mark.parametrize(
+ ("artist", "name"),
+ [
+ (DT, "Dream Theatre"),
+ (DT, "Theater"),
+ ({"name": "!!!", "englishName": ""}, "!!!"),
+ (None, "Dream Theater"),
+ ],
+)
+def test_names_that_do_not(artist, name):
+ assert not artist_matches_name(artist, name)
+
+
+def test_the_marks_in_a_japanese_name_are_part_of_it():
+ # バ and ハ differ only by a combining mark once decomposed.
+ assert not artist_matches_name({"name": "バンド", "englishName": ""}, "ハンド")
+
+
+@pytest.mark.parametrize(
+ ("tag", "name"),
+ [
+ ("Dream Theater feat. Someone", "Dream Theater"),
+ ("Dream Theater (ft. Someone)", "Dream Theater"),
+ ("Dream Theater [featuring Someone]", "Dream Theater"),
+ (" Dream Theater ", "Dream Theater"),
+ (None, ""),
+ ],
+)
+def test_a_featured_artist_is_left_off(tag, name):
+ assert tagged_artist_name(tag) == name
+
+
+def test_the_first_hit_that_is_an_artist_is_the_band():
+ entities = ENTITIES["entities"]
+ assert pick_artist(entities, ["Q13420662", "Q162586"])["id"] == "Q162586"
+ assert pick_artist({"Q13420662": entities["Q13420662"]}, ["Q13420662"]) is None
+ assert pick_artist(None, ["Q162586"]) is None
+
+
+@pytest.mark.parametrize(
+ ("name", "lang"),
+ [
+ ("Dream Theater", "en"),
+ ("Beyoncé", "en"),
+ ("ドリーム・シアター", "ja"),
+ ("東京事変", "zh"),
+ ("아이유", "ko"),
+ ("Кино", "ru"),
+ ],
+)
+def test_a_name_is_searched_in_its_own_script(name, lang):
+ assert search_language(name) == lang
+
+
+def test_clean_artist_keeps_only_a_wikidata_band():
+ assert clean_artist(DT) == DT
+ assert clean_artist({**DT, "extra": "x"}) == DT
+ assert clean_artist({**DT, "id": "Q1 OR 1=1"}) is None
+ assert clean_artist({**DT, "id": "P434"}) is None
+ assert clean_artist({"id": "Q1", "name": 3, "englishName": None}) is None
+ assert clean_artist("Q162586") is None
+
+
+# ── the lookup ──
+
+
+def test_a_band_by_exactly_that_name_is_found():
+ wikidata = Wikidata()
+ assert lookup_band("Dream Theater", fetch_json=wikidata) == DT
+ search, entities = wikidata.asked
+ # The name, and nothing else about the track.
+ assert search["action"] == "wbsearchentities"
+ assert search["search"] == "Dream Theater"
+ assert search["language"] == "en"
+ assert entities["action"] == "wbgetentities"
+ assert entities["ids"] == "Q13420662|Q162586", "ids that are not items are dropped"
+ assert "claims" in entities["props"]
+
+
+def test_a_band_by_another_name_is_not_kept():
+ # Wikidata's search forgives the spelling and answers Dream Theater.
+ assert lookup_band("Dream Theatre", fetch_json=Wikidata()) is None
+
+
+def test_a_name_in_another_script_matches_its_own_label():
+ entities = {
+ "entities": {
+ "Q162586": {
+ "id": "Q162586",
+ "labels": {
+ "ja": {"value": "ドリーム・シアター"},
+ "en": {"value": "Dream Theater"},
+ },
+ "claims": {"P434": [{}]},
+ }
+ }
+ }
+ wikidata = Wikidata(search={"search": [{"id": "Q162586"}]}, entities=entities)
+ band = lookup_band("ドリーム・シアター", fetch_json=wikidata)
+ assert band == {"id": "Q162586", "name": "ドリーム・シアター", "englishName": "Dream Theater"}
+ assert wikidata.asked[1]["languages"] == "ja|en"
+
+
+def test_no_artist_among_the_hits_is_no_band():
+ assert lookup_band("Dream Theater", fetch_json=Wikidata(search={"search": []})) is None
+ only_album = {"entities": {"Q13420662": ENTITIES["entities"]["Q13420662"]}}
+ assert lookup_band("Dream Theater", fetch_json=Wikidata(entities=only_album)) is None
+
+
+def test_an_id_that_is_not_an_item_is_never_kept():
+ entities = {
+ "entities": {
+ "Q162586": {**ENTITIES["entities"]["Q162586"], "id": "Q162586",
+ "vbscript:msgbox(1)",
+ "file:///etc/passwd",
+ "ftp://example.com/",
+ "//evil.example/",
+ "evil.example",
+ "https://user:pass@evil.example/",
+ "https://real.example@evil.example/",
+ "https:///nohost",
+ "https://exa mple.com/",
+ "https://example.com/\n", ""),
+ ],
+)
+def test_profile_markup_cases(raw, plain):
+ assert discogs.profile_paragraphs(raw) == [plain]
+
+
+def test_a_long_profile_is_cut_at_a_word():
+ paragraphs = discogs.profile_paragraphs("word " * 1000)
+ assert len("".join(paragraphs)) <= discogs._PROFILE_MAX_CHARS + 1
+ assert paragraphs[-1].endswith(chr(0x2026))
+
+
+def test_the_profile_shape():
+ profile = discogs.artist_profile(ARTIST_NIHIL, ARTIST_NIHIL_RELEASES)
+ assert profile["id"] == 555501
+ assert profile["name"] == "Nihil"
+ assert profile["url"] == "https://www.discogs.com/artist/555501-Nihil-5"
+ assert profile["members"] == {"current": ["Rui Barros", "Ana Lima"], "former": ["Pedro Sá"]}
+ assert profile["links"] == [
+ {"kind": "website", "url": "http://www.nihilband.pt"},
+ {"kind": "bandcamp", "url": "https://nihil.bandcamp.com/"},
+ {"kind": "instagram", "url": "https://www.instagram.com/nihil.porto/"},
+ {"kind": "facebook", "url": "https://www.facebook.com/nihilporto"},
+ {"kind": "youtube", "url": "https://www.youtube.com/@nihilporto"},
+ ]
+ # The master over its own release, the Various appearance left out, the
+ # undated one last.
+ assert profile["releases"] == [
+ {"year": "2019", "title": "Barro"},
+ {"year": "2021", "title": "Lama"},
+ {"year": "", "title": "Untitled"},
+ ]
+ # No images: the box's photos come from Wikimedia.
+ assert "image" not in profile and "images" not in profile
+
+
+def test_a_page_url_that_is_not_discogs_is_rebuilt_from_the_id():
+ artist = {**ARTIST_NIHIL, "uri": "https://evil.example/artist/1"}
+ assert discogs.artist_profile(artist, None)["url"] == "https://www.discogs.com/artist/555501"
+
+
+@pytest.mark.parametrize(
+ ("value", "url"),
+ [
+ ("https://band.example/", "https://band.example/"),
+ ("www.band.example", "https://www.band.example"),
+ ("javascript:alert(1)", ""),
+ ("data:text/html,x", ""),
+ ("https://user:pw@band.example/", ""),
+ ("https:///band.example", ""),
+ ("https://band.example/a b", ""),
+ ("ftp://band.example/", ""),
+ ],
+)
+def test_only_plain_web_links_are_kept(value, url):
+ assert discogs.safe_url(value) == url
+
+
+def test_a_real_name_the_same_as_the_name_is_not_repeated():
+ artist = {"id": 7, "name": "Ana Lima (3)", "realname": "Ana Lima"}
+ assert discogs.artist_profile(artist, None)["real_name"] == ""
+ artist = {"id": 7, "name": "DJ Ana", "realname": "Ana Lima", "groups": [{"name": "Nihil (5)"}]}
+ profile = discogs.artist_profile(artist, None)
+ assert profile["real_name"] == "Ana Lima"
+ assert profile["groups"] == ["Nihil"]
+
+
+# ── names ──
+
+
+@pytest.mark.parametrize(
+ ("name", "shown"),
+ [
+ ("Nihil (5)", "Nihil"),
+ ("Nihil", "Nihil"),
+ ("NIHIL*", "NIHIL"),
+ ("Take (2) Me", "Take (2) Me"),
+ ],
+)
+def test_the_number_discogs_adds_is_disambiguation(name, shown):
+ assert discogs.display_name(name) == shown
+
+
+def test_names_compare_without_case_accents_or_number():
+ assert discogs.name_key("Nihil (5)") == discogs.name_key("NIHIL")
+ assert discogs.name_key("Sigur Rós") == discogs.name_key("Sigur Ros")
+ assert discogs.name_key("周杰倫") == discogs.name_key("周杰伦")
+
+
+# ── finding the artist ──
+
+
+def test_nihil_is_found_by_the_release_that_has_barro(monkeypatch):
+ fake = _fake(monkeypatch, NIHIL_ANSWERS)
+ answer = discogs.artist_for_track(
+ NIHIL_IDENTITY, {"artist": "NIHIL", "title": "Barro"}, None, TOKEN
+ )
+ assert answer["id"] == 555501
+ assert answer["name"] == "Nihil"
+ search = fake.requests[0]
+ query = dict(urllib.parse.parse_qsl(urllib.parse.urlsplit(search.full_url).query))
+ assert query["type"] == "release"
+ assert query["artist"] == "NIHIL"
+ assert query["track"] == "Barro"
+ # One pressing of the master is enough: 20002 is not fetched.
+ assert "releases/20002" not in fake.paths()
+
+
+def test_a_same_name_artist_without_the_track_is_refused(monkeypatch):
+ """The search found Nihil (2)'s demo too, and it has no Barro."""
+ answers = {
+ **NIHIL_ANSWERS,
+ "database/search?track=Barro": {"results": [SEARCH_BARRO["results"][2]]},
+ }
+ fake = _fake(monkeypatch, answers)
+ assert discogs.artist_for_track(NIHIL_IDENTITY, None, None, TOKEN) is None
+ assert not any(p.startswith("artists/") for p in fake.paths())
+
+
+def test_a_bare_name_is_never_enough(monkeypatch):
+ """No song and no album: nothing is searched and nothing is taken."""
+ fake = _fake(monkeypatch, NIHIL_ANSWERS)
+ identity = {**NIHIL_IDENTITY, "title": ""}
+ assert discogs.artist_for_track(identity, {"artist": "NIHIL"}, None, TOKEN) is None
+ assert fake.requests == []
+
+
+def test_a_song_title_that_is_a_band_name_does_not_become_the_band(monkeypatch):
+ """The release has "Barro" by a band called Barro: not NIHIL's."""
+ release = {**RELEASE_LAMA, "artists": [{"name": "Barro", "id": 777}]}
+ _fake(monkeypatch, {**NIHIL_ANSWERS, "releases/20001": release})
+ assert discogs.artist_for_track(NIHIL_IDENTITY, None, None, TOKEN) is None
+
+
+def test_two_artists_with_the_name_and_the_song_are_no_answer(monkeypatch):
+ other = {
+ **RELEASE_DEMO,
+ "tracklist": [{"type_": "track", "title": "Barro"}],
+ }
+ _fake(monkeypatch, {**NIHIL_ANSWERS, "releases/10001": other})
+ assert discogs.artist_for_track(NIHIL_IDENTITY, None, None, TOKEN) is None
+
+
+def test_a_compilation_credits_the_track_artist(monkeypatch):
+ compilation = {
+ "id": 40001,
+ "title": "Porto Sludge Vol. 1",
+ "artists": [{"name": "Various", "id": 194}],
+ "tracklist": [
+ {
+ "type_": "track",
+ "title": "Barro (Demo)",
+ "artists": [{"name": "Nihil (5)", "id": 555501}],
+ },
+ {"type_": "track", "title": "Other", "artists": [{"name": "Someone", "id": 1}]},
+ ],
+ }
+ search = {"results": [{"id": 40001, "master_id": 0, "title": "Various - Porto Sludge Vol. 1"}]}
+ _fake(
+ monkeypatch,
+ {**NIHIL_ANSWERS, "database/search?track=Barro": search, "releases/40001": compilation},
+ )
+ assert discogs.artist_for_track(NIHIL_IDENTITY, None, None, TOKEN)["id"] == 555501
+
+
+def test_the_album_is_used_when_the_song_finds_nothing(monkeypatch):
+ fake = _fake(
+ monkeypatch,
+ {
+ "database/search?track=Barro": {"results": []},
+ "database/search?release_title=Lama": SEARCH_BARRO,
+ "releases/20001": RELEASE_LAMA,
+ "releases/10001": RELEASE_DEMO,
+ "artists/555501": ARTIST_NIHIL,
+ "artists/555501/releases": ARTIST_NIHIL_RELEASES,
+ },
+ )
+ identity = {**NIHIL_IDENTITY, "album": "Lama"}
+ assert discogs.artist_for_track(identity, None, None, TOKEN)["id"] == 555501
+ assert fake.paths().count("database/search") == 2
+
+
+def test_the_musicbrainz_link_is_followed_without_searching(monkeypatch):
+ mbid = "0b6e9d9e-1111-4222-8333-444455556666"
+ asked = []
+
+ def musicbrainz(path, params, *, cancelled=lambda: False):
+ asked.append(path)
+ return {
+ "id": mbid,
+ "name": "Nihil",
+ "aliases": [],
+ "relations": [
+ {"type": "wikipedia", "url": {"resource": "https://pt.wikipedia.org/wiki/X"}},
+ {
+ "type": "discogs",
+ "url": {"resource": "https://www.discogs.com/artist/555501-Nihil-5"},
+ },
+ ],
+ }
+
+ monkeypatch.setattr("app.pipeline.musicbrainz._fetch_json", musicbrainz)
+ fake = _fake(monkeypatch, NIHIL_ANSWERS)
+ identity = {**NIHIL_IDENTITY, "source": "acoustid", "artist_mbids": [mbid]}
+ assert discogs.artist_for_track(identity, None, None, TOKEN)["id"] == 555501
+ assert asked == [f"artist/{mbid}"]
+ assert "database/search" not in fake.paths()
+ # The MusicBrainz answer is cached, so the next lookup asks it nothing.
+ discogs.forget()
+ discogs.artist_for_track(identity, None, None, TOKEN)
+ assert asked == [f"artist/{mbid}"]
+
+
+def test_the_wikidata_band_names_its_discogs_artist(monkeypatch):
+ def wikidata(params):
+ assert params["ids"] == "Q999"
+ return {
+ "entities": {
+ "Q999": {
+ "claims": {
+ "P1953": [
+ {"rank": "normal", "mainsnak": {"datavalue": {"value": "555501"}}}
+ ]
+ }
+ }
+ }
+ }
+
+ monkeypatch.setattr("app.pipeline.artist_lookup._fetch_json", wikidata)
+ fake = _fake(monkeypatch, NIHIL_ANSWERS)
+ band = {"id": "Q999", "name": "Nihil", "englishName": "Nihil"}
+ assert (
+ discogs.artist_for_track({**NIHIL_IDENTITY, "title": ""}, None, band, TOKEN)["id"] == 555501
+ )
+ assert "database/search" not in fake.paths()
+
+
+def test_nothing_found_is_kept_for_a_while_and_a_failure_is_not(monkeypatch):
+ fake = _fake(monkeypatch, {"database/search": {"results": []}})
+ assert discogs.artist_for_track(NIHIL_IDENTITY, None, None, TOKEN) is None
+ assert discogs.artist_for_track(NIHIL_IDENTITY, None, None, TOKEN) is None
+ assert len(fake.requests) == 1
+
+ discogs.forget()
+ for path in list(discogs.CACHE_DIR.glob("*.json")):
+ path.unlink()
+ fake = _fake(monkeypatch, {"database/search": OSError("offline")})
+ discogs.artist_for_track(NIHIL_IDENTITY, None, None, TOKEN)
+ discogs.artist_for_track(NIHIL_IDENTITY, None, None, TOKEN)
+ assert len(fake.requests) == 2
+
+
+def test_no_token_asks_nothing(monkeypatch):
+ fake = _fake(monkeypatch, NIHIL_ANSWERS)
+ assert discogs.artist_for_track(NIHIL_IDENTITY, None, None, "") is None
+ assert fake.requests == []
diff --git a/tests/test_discogs_token.py b/tests/test_discogs_token.py
new file mode 100644
index 00000000..26f522c1
--- /dev/null
+++ b/tests/test_discogs_token.py
@@ -0,0 +1,245 @@
+"""The Discogs token setting (GET/POST /api/settings, discogs_auth).
+
+The token is the user's: the API says only whether one is set and its last
+two characters, a rejected one is never echoed back, and none of it reaches
+the log or a URL. It is tried once against Discogs when it is saved: a 401
+refuses it, anything that is not an answer keeps it on trust, and the Save
+is never held past a bound. Nothing reaches api.discogs.com: conftest answers
+every check as offline, and these tests stub the request or urlopen.
+"""
+
+from __future__ import annotations
+
+import logging
+import threading
+import time
+import urllib.error
+from unittest.mock import patch
+
+import pytest
+from fastapi.testclient import TestClient
+
+import app.pipeline.discogs_auth as da
+from app.core import settings as _settings
+
+# The real request, kept before conftest swaps it for an offline stand-in.
+_REAL_ASK = da._ask_identity
+
+TOKEN = "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789XyQz" # 40, the real length
+OTHER = "Zz9Yy8Xx7Ww6Vv5Uu4Tt3Ss2Rr1Qq0PpOoNnMmLl"
+
+
+@pytest.fixture
+def client():
+ with patch("app.api.jobs.jobqueue.enqueue", lambda job_id: None):
+ from app.main import app
+
+ with TestClient(app) as c:
+ yield c
+
+
+def _discogs_answers(monkeypatch, status):
+ sent = []
+
+ def answer(token):
+ sent.append(token)
+ return status
+
+ monkeypatch.setattr(da, "_ask_identity", answer)
+ return sent
+
+
+# ── the setting ──
+
+
+def test_unset_by_default(client):
+ data = client.get("/api/settings").json()
+ assert data["discogs_token_set"] is False
+ assert data["discogs_token_tail"] is None
+ assert "discogs_token" not in data
+
+
+def test_the_token_is_saved_and_never_handed_back(client, caplog):
+ caplog.set_level(logging.DEBUG)
+ r = client.post("/api/settings", json={"discogs_token": f" {TOKEN} "})
+ assert r.status_code == 200
+ assert r.json()["discogs_token_set"] is True
+ assert r.json()["discogs_token_tail"] == "Qz"
+ assert TOKEN not in r.text
+ got = client.get("/api/settings")
+ assert TOKEN not in got.text
+ assert got.json()["discogs_token_tail"] == TOKEN[-2:]
+ assert _settings.get_discogs_token() == TOKEN
+ assert TOKEN not in caplog.text
+
+
+def test_the_token_survives_a_restart():
+ _settings.set_discogs_token(TOKEN)
+ _settings._state = None # the next read comes from settings.json
+ assert _settings.get_discogs_token() == TOKEN
+
+
+def test_the_acoustid_key_is_untouched_by_the_token(client):
+ _settings.set_acoustid_api_key("Zx9Yw8Vu7T")
+ client.post("/api/settings", json={"discogs_token": TOKEN})
+ client.post("/api/settings", json={"discogs_token": ""})
+ assert _settings.get_acoustid_api_key() == "Zx9Yw8Vu7T"
+
+
+@pytest.mark.parametrize("value", ["", " ", None])
+def test_clearing_the_token(client, value):
+ _settings.set_discogs_token(TOKEN)
+ r = client.post("/api/settings", json={"discogs_token": value})
+ assert r.status_code == 200
+ assert r.json()["discogs_token_set"] is False
+ assert _settings.get_discogs_token() is None
+
+
+@pytest.mark.parametrize(
+ "value",
+ [
+ "tooShort123",
+ "x" * 81,
+ "has a space in the middle of it abcdefgh",
+ "dash-is-not-in-a-discogs-token-abcdefgh",
+ "abcdefghijklmn",
+ 12345,
+ ["a"],
+ {"t": TOKEN},
+ ],
+)
+def test_a_token_that_cannot_be_one_is_refused_without_echoing_it(client, monkeypatch, value):
+ sent = _discogs_answers(monkeypatch, 200)
+ _settings.set_discogs_token(TOKEN)
+ r = client.post("/api/settings", json={"discogs_token": value})
+ assert r.status_code == 422
+ assert r.json()["detail"] == "invalid Discogs token"
+ if isinstance(value, str):
+ assert value not in r.text
+ assert _settings.get_discogs_token() == TOKEN, "the old token stays"
+ assert sent == [], "a malformed token is never sent to Discogs"
+
+
+# ── the token is tried when it is saved ──
+
+
+def test_a_token_discogs_refuses_is_not_saved(client, monkeypatch, caplog):
+ caplog.set_level(logging.DEBUG)
+ _settings.set_discogs_token(TOKEN)
+ sent = _discogs_answers(monkeypatch, 401)
+ r = client.post("/api/settings", json={"discogs_token": OTHER})
+ assert r.status_code == 422
+ assert r.json()["detail"] == "Discogs does not accept this token"
+ assert OTHER not in r.text and OTHER not in caplog.text
+ assert _settings.get_discogs_token() == TOKEN, "the old token stays"
+ assert sent == [OTHER], "asked once"
+
+
+def test_a_token_discogs_knows_is_saved(client, monkeypatch):
+ _discogs_answers(monkeypatch, 200)
+ r = client.post("/api/settings", json={"discogs_token": TOKEN})
+ assert r.status_code == 200
+ assert _settings.get_discogs_token() == TOKEN
+
+
+@pytest.mark.parametrize("status", [429, 500, 502, 503])
+def test_discogs_down_or_busy_keeps_the_token_on_trust(client, monkeypatch, status):
+ _discogs_answers(monkeypatch, status)
+ r = client.post("/api/settings", json={"discogs_token": TOKEN})
+ assert r.status_code == 200
+ assert _settings.get_discogs_token() == TOKEN
+
+
+def test_a_token_that_cannot_be_checked_is_kept_on_trust(client):
+ # conftest answers every Discogs request as offline.
+ r = client.post("/api/settings", json={"discogs_token": TOKEN})
+ assert r.status_code == 200
+ assert _settings.get_discogs_token() == TOKEN
+
+
+def test_a_slow_discogs_does_not_hold_the_save(client, monkeypatch, caplog):
+ caplog.set_level(logging.DEBUG)
+ release = threading.Event()
+
+ def hang(token):
+ release.wait(30)
+ return 401
+
+ monkeypatch.setattr(da, "_ask_identity", hang)
+ monkeypatch.setattr(da, "TIMEOUT_DISCOGS_CHECK", 0.3)
+ started = time.monotonic()
+ try:
+ r = client.post("/api/settings", json={"discogs_token": TOKEN})
+ finally:
+ release.set()
+ elapsed = time.monotonic() - started
+ assert r.status_code == 200, "saved on trust"
+ assert elapsed < 5, f"the Save waited {elapsed:.1f}s"
+ assert _settings.get_discogs_token() == TOKEN
+ assert TOKEN not in caplog.text
+
+
+def test_the_check_can_be_turned_off(client, monkeypatch):
+ sent = _discogs_answers(monkeypatch, 401)
+ monkeypatch.setattr("app.main.DISCOGS_CHECK_TOKEN", False)
+ r = client.post("/api/settings", json={"discogs_token": TOKEN})
+ assert r.status_code == 200 and sent == []
+ assert _settings.get_discogs_token() == TOKEN
+
+
+# ── the request itself ──
+
+
+class _Response:
+ status = 200
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *exc):
+ return False
+
+ def read(self, n=-1):
+ return b'{"id": 1, "username": "someone"}'
+
+
+def test_the_token_goes_only_in_the_authorization_header(monkeypatch):
+ monkeypatch.setattr(da, "_ask_identity", _REAL_ASK)
+ seen = []
+
+ def urlopen(request, timeout, context):
+ seen.append(request)
+ return _Response()
+
+ monkeypatch.setattr(da.urllib.request, "urlopen", urlopen)
+ assert da.discogs_token_works(TOKEN) is True
+ (request,) = seen
+ assert request.full_url == "https://api.discogs.com/oauth/identity"
+ assert TOKEN not in request.full_url
+ assert request.get_header("Authorization") == f"Discogs token={TOKEN}"
+ assert request.get_header("User-agent", "").startswith("StemDeck/")
+
+
+def test_a_401_from_the_real_request_is_a_refusal(monkeypatch, caplog):
+ caplog.set_level(logging.DEBUG)
+ monkeypatch.setattr(da, "_ask_identity", _REAL_ASK)
+
+ def urlopen(request, timeout, context):
+ raise urllib.error.HTTPError(request.full_url, 401, "Unauthorized", {}, None)
+
+ monkeypatch.setattr(da.urllib.request, "urlopen", urlopen)
+ assert da.discogs_token_works(TOKEN) is False
+ assert TOKEN not in caplog.text
+
+
+@pytest.mark.parametrize("error", [TimeoutError("timed out"), urllib.error.URLError("offline")])
+def test_no_answer_from_the_real_request_is_trust(monkeypatch, caplog, error):
+ caplog.set_level(logging.DEBUG)
+ monkeypatch.setattr(da, "_ask_identity", _REAL_ASK)
+
+ def urlopen(request, timeout, context):
+ raise error
+
+ monkeypatch.setattr(da.urllib.request, "urlopen", urlopen)
+ assert da.discogs_token_works(TOKEN) is None
+ assert TOKEN not in caplog.text
From 9c6eefe6848ab62860a3290d3bc5a0e1640d3e8c Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Mon, 28 Sep 2026 23:31:00 +0100
Subject: [PATCH 14/20] feat: say where the artist box searched, and search
Discogs by name (#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.
---
app/api/discogs.py | 93 +++++++++++++
app/api/router.py | 2 +
app/core/config.py | 5 +
app/pipeline/discogs.py | 108 ++++++++++++++-
static/css/daw.css | 28 ++++
static/index.html | 2 +-
static/js/artistDiscogs.js | 21 +++
static/js/artistInfo.js | 182 ++++++++++++++++++++++++--
static/js/catalog.js | 2 +-
static/js/i18n.js | 118 +++++++++++++----
tests/e2e/artist-discogs.spec.mjs | 146 ++++++++++++++++++++-
tests/e2e/discogs-setting.spec.mjs | 2 +-
tests/js/artist-discogs.test.mjs | 34 ++++-
tests/test_discogs_search_api.py | 202 +++++++++++++++++++++++++++++
14 files changed, 908 insertions(+), 37 deletions(-)
create mode 100644 app/api/discogs.py
create mode 100644 tests/test_discogs_search_api.py
diff --git a/app/api/discogs.py b/app/api/discogs.py
new file mode 100644
index 00000000..9593602b
--- /dev/null
+++ b/app/api/discogs.py
@@ -0,0 +1,93 @@
+"""Discogs artists by name, for a band typed in the artist box.
+
+GET /api/discogs/artist?q= lists the artists Discogs finds for a name
+Wikipedia did not know, and GET /api/discogs/artist/{id} is the one the user
+picked, in the shape GET /api/jobs/{id}/artist-extra answers in, so the box
+draws it the same way (static/js/artistDiscogs.js).
+
+Nothing is taken on a name alone here: the user picks. Both answer 404 at once
+when no Discogs token is saved, and ask Discogs nothing. The token is the
+server's (settings.get_discogs_token): it goes in the Authorization header of
+the requests app/pipeline/discogs.py makes and never into an answer. Each is
+bounded by DISCOGS_SEARCH_BUDGET_SEC, and shares the process-wide limiter and
+the on-disk cache with every other Discogs request.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import re
+import time
+from typing import Any
+
+from fastapi import APIRouter, HTTPException
+
+from app.core.config import DISCOGS_SEARCH_BUDGET_SEC
+from app.core.settings import get_discogs_token
+from app.pipeline import discogs
+
+logger = logging.getLogger("stemdeck.artist")
+
+router = APIRouter()
+
+_ARTIST_ID_RE = re.compile(r"^[1-9]\d{0,11}$")
+
+
+async def _bounded(func: Any, *args: Any) -> Any:
+ """``func(*args, cancelled=...)`` in a thread, given up on after
+ DISCOGS_SEARCH_BUDGET_SEC; the thread stops at its next request."""
+ deadline = time.monotonic() + DISCOGS_SEARCH_BUDGET_SEC
+
+ def cancelled() -> bool:
+ return time.monotonic() > deadline
+
+ try:
+ return await asyncio.wait_for(
+ asyncio.to_thread(func, *args, cancelled=cancelled), DISCOGS_SEARCH_BUDGET_SEC + 1
+ )
+ except asyncio.TimeoutError:
+ logger.info("Discogs artist request timed out")
+ return None
+
+
+@router.get("/artist")
+async def search_discogs_artists(q: str = "") -> dict[str, list[dict[str, Any]]]:
+ """{"candidates": [{"id", "name", "profile"}]} for the name ``q``, empty
+ when Discogs has none. 422 for a name that is empty, over
+ discogs.QUERY_MAX_CHARS or holds control characters; 404 when no token
+ is saved; 503 when Discogs could not be asked."""
+ query = discogs.valid_query(q)
+ if not query:
+ raise HTTPException(status_code=422, detail="invalid name")
+ token = get_discogs_token()
+ if not token:
+ raise HTTPException(status_code=404, detail="no Discogs token")
+ try:
+ found = await _bounded(discogs.search_artists, query, token)
+ except Exception:
+ logger.exception("Discogs artist search failed")
+ found = None
+ if found is None:
+ raise HTTPException(status_code=503, detail="Discogs could not be reached")
+ return {"candidates": found}
+
+
+@router.get("/artist/{artist_id}")
+async def get_discogs_artist(artist_id: str) -> dict[str, Any]:
+ """The Discogs artist ``artist_id``, as GET /api/jobs/{id}/artist-extra
+ answers, or 404 when the id is not one, no token is saved, or Discogs has
+ no such artist or could not be asked."""
+ if not _ARTIST_ID_RE.match(artist_id):
+ raise HTTPException(status_code=404, detail="no artist details")
+ token = get_discogs_token()
+ if not token:
+ raise HTTPException(status_code=404, detail="no artist details")
+ try:
+ answer = await _bounded(discogs.artist_by_id, int(artist_id), token)
+ except Exception:
+ logger.exception("Discogs artist failed")
+ answer = None
+ if answer is None:
+ raise HTTPException(status_code=404, detail="no artist details")
+ return answer
diff --git a/app/api/router.py b/app/api/router.py
index 3232a5a6..f44d7639 100644
--- a/app/api/router.py
+++ b/app/api/router.py
@@ -3,6 +3,7 @@
from fastapi import APIRouter
from app.api.config import router as config_router
+from app.api.discogs import router as discogs_router
from app.api.events import router as events_router
from app.api.jobs import router as jobs_router
from app.api.playlist import router as playlist_router
@@ -20,3 +21,4 @@
router.include_router(queue_router, prefix="/queue", tags=["queue"])
router.include_router(playlist_router, prefix="/playlist", tags=["playlist"])
router.include_router(search_router, prefix="/search", tags=["search"])
+router.include_router(discogs_router, prefix="/discogs", tags=["discogs"])
diff --git a/app/core/config.py b/app/core/config.py
index b0f43493..29095315 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -494,6 +494,11 @@ def _musicbrainz_user_agent() -> str:
DISCOGS_RETRY_MAX_WAIT_SEC = 10.0
DISCOGS_REQUEST_BUDGET_SEC = 20.0
DISCOGS_LOOKUP_BUDGET_SEC = _env_int("STEMDECK_DISCOGS_LOOKUP_BUDGET_SEC", 45)
+# A name typed in the artist box, searched on Discogs (GET /api/discogs/artist):
+# the search and the profiles of its first few artists, one a second, so the
+# user can tell "Nihil (2)" from "Nihil (5)". What is not in by then is left
+# out rather than waited for.
+DISCOGS_SEARCH_BUDGET_SEC = _env_int("STEMDECK_DISCOGS_SEARCH_BUDGET_SEC", 15)
# An artist's release list at 100 a page is well under a megabyte.
DISCOGS_MAX_BYTES = 2 * 1024 * 1024
# Answers kept on disk, one file per request, as MusicBrainz's are.
diff --git a/app/pipeline/discogs.py b/app/pipeline/discogs.py
index d0e9d263..5b772da0 100644
--- a/app/pipeline/discogs.py
+++ b/app/pipeline/discogs.py
@@ -27,7 +27,8 @@
callers: a failure is "nothing found".
What is sent: the track's artist name, its song and album titles, and
-Discogs ids.
+Discogs ids; and a band name typed in the artist box that Wikipedia did not
+find (search_artists), whose candidates the user picks from.
"""
from __future__ import annotations
@@ -776,6 +777,111 @@ def artist_for_track(
return answer
+# ── a name typed in the box ──
+#
+# Here the user chooses, so nothing is taken on a name alone as above: the
+# artists Discogs finds are offered, each with the start of its profile so
+# "Nihil (2)" can be told from "Nihil (5)", and the one picked is fetched by
+# its id.
+
+_CANDIDATES_MAX = 8
+# Profiles fetched for the snippet, at most: each is a request, one a second.
+_CANDIDATES_PROFILED = 5
+_SNIPPET_MAX_CHARS = 200
+QUERY_MAX_CHARS = 100
+
+
+def valid_query(value: Any) -> str:
+ """A typed name, trimmed, or "" when it is empty, too long, or holds a
+ control or format character (a bidi override, a NUL)."""
+ if not isinstance(value, str):
+ return ""
+ text = value.strip()
+ if not text or len(text) > QUERY_MAX_CHARS:
+ return ""
+ if any(unicodedata.category(ch) in ("Cc", "Cf") and ch != _ZWJ for ch in text):
+ return ""
+ return text
+
+
+def _snippet(profile: Any) -> str:
+ paragraphs = profile_paragraphs(profile)
+ if not paragraphs:
+ return ""
+ first = paragraphs[0]
+ if len(first) <= _SNIPPET_MAX_CHARS:
+ return first
+ return first[:_SNIPPET_MAX_CHARS].rsplit(" ", 1)[0].rstrip(",;: ") + _ELLIPSIS
+
+
+def search_artists(
+ name: str, token: str, *, cancelled: Cancelled = _never
+) -> list[dict[str, Any]] | None:
+ """The Discogs artists a search for ``name`` finds, as [{"id", "name",
+ "profile"}], those named exactly ``name`` first, at most
+ _CANDIDATES_MAX. ``name`` keeps Discogs' number ("Nihil (5)"), since that
+ is how two artists of one name are told apart; ``profile`` is the start of
+ the artist's profile, or "" when it has none or was not in in time. None
+ when the search itself failed. Never raises."""
+ query = valid_query(name)
+ if not token or not query:
+ return None
+ session = _Session(token=token, cancelled=cancelled)
+ try:
+ answer = session.get(
+ "database/search", {"type": "artist", "q": query, "per_page": _SEARCH_PER_PAGE}
+ )
+ if answer is None:
+ return None
+ hits = answer.get("results")
+ want = name_key(query)
+ found: list[dict[str, Any]] = []
+ for hit in hits if isinstance(hits, list) else []:
+ if not isinstance(hit, dict) or hit.get("type", "artist") != "artist":
+ continue
+ artist_id = _artist_id(hit.get("id"))
+ title = hit.get("title")
+ shown = _plain(title).strip()[:_NAME_MAX_CHARS] if isinstance(title, str) else ""
+ if not artist_id or not shown or any(c["id"] == artist_id for c in found):
+ continue
+ found.append({"id": artist_id, "name": shown, "profile": ""})
+ # Stable: Discogs' own order within each half.
+ found.sort(key=lambda c: name_key(c["name"]) != want)
+ found = found[:_CANDIDATES_MAX]
+ for candidate in found[:_CANDIDATES_PROFILED]:
+ if cancelled():
+ break
+ artist = session.get(f"artists/{candidate['id']}")
+ if isinstance(artist, dict):
+ candidate["profile"] = _snippet(artist.get("profile"))
+ return found
+ except Exception:
+ logger.warning("Discogs artist search failed", exc_info=True)
+ return None
+
+
+def artist_by_id(
+ artist_id: int, token: str, *, cancelled: Cancelled = _never
+) -> dict[str, Any] | None:
+ """The profile of the Discogs artist ``artist_id`` (artist_profile's
+ shape), or None when there is none or it could not be had. Never raises."""
+ if not token or not isinstance(artist_id, int) or artist_id <= 0:
+ return None
+ session = _Session(token=token, cancelled=cancelled)
+ try:
+ artist = session.get(f"artists/{artist_id}")
+ if artist is None or cancelled():
+ return None
+ releases = session.get(
+ f"artists/{artist_id}/releases",
+ {"sort": "year", "sort_order": "asc", "per_page": "100"},
+ )
+ return artist_profile(artist, releases)
+ except Exception:
+ logger.warning("Discogs artist failed", exc_info=True)
+ return None
+
+
def forget() -> None:
"""Drop every answer kept in memory. For tests."""
with _KEPT_LOCK:
diff --git a/static/css/daw.css b/static/css/daw.css
index 6e915957..9d1c1587 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -4146,6 +4146,34 @@ input, textarea { font-family: inherit; }
share the row, so it wraps rather than squeezing them. */
.artist-foot { flex-wrap: wrap; }
.artist-discogs-credit { font-weight: 500; }
+/* "Only Wikipedia was searched": a quiet aside, not a warning, with the way
+ to the setting as a small secondary button. */
+.artist-discogs-note {
+ margin: 0 0 12px; padding: 10px 12px;
+ background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
+ font-size: 12px; line-height: 1.5; color: var(--fg-2);
+}
+.artist-discogs-note p { margin: 0 0 8px; }
+.artist-discogs-open {
+ height: 28px; padding: 0 12px; border-radius: 8px;
+ background: var(--bg-2); border: 1px solid var(--border-strong);
+ color: var(--fg); font: inherit; font-size: 12px; font-weight: 600; cursor: pointer;
+}
+.artist-discogs-open:hover { border-color: var(--accent); color: var(--accent); }
+.artist-status + .artist-discogs-note { margin-top: -8px; }
+/* The artists Discogs has for a name Wikipedia does not know, to pick from:
+ each its name, with Discogs' number, and the start of its profile. */
+.artist-candidates { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
+.artist-candidate {
+ display: flex; flex-direction: column; gap: 2px; width: 100%;
+ padding: 8px 10px; text-align: start;
+ background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
+ color: var(--fg); font: inherit; cursor: pointer;
+}
+.artist-candidate:hover, .artist-candidate:focus-visible { border-color: var(--accent); }
+.artist-candidate-name { font-size: 13px; font-weight: 600; overflow-wrap: anywhere; }
+.artist-candidate-profile { font-size: 12px; line-height: 1.45; color: var(--muted); }
+.artist-discogs-pick + .artist-foot { justify-content: flex-end; }
@media (max-width: 560px) { .artist-albums { columns: 1; } }
/* The musical or film a song is from: a section below the band, or the whole
diff --git a/static/index.html b/static/index.html
index 4fe8ddc1..77b592d0 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1155,7 +1155,7 @@
We Recommend
diff --git a/static/js/artistDiscogs.js b/static/js/artistDiscogs.js
index 29a35c16..9e7a155b 100644
--- a/static/js/artistDiscogs.js
+++ b/static/js/artistDiscogs.js
@@ -66,6 +66,27 @@ export function discogsExtraFromJson(json) {
};
}
+const CANDIDATES_MAX = 8;
+
+/**
+ * The artists GET /api/discogs/artist?q= offers for a typed name, checked
+ * as discogsExtraFromJson checks a profile: [{id, name, profile}], at most
+ * CANDIDATES_MAX, each with a whole positive id and a name. The name keeps
+ * Discogs' number ("Nihil (5)"), which is how two of one name are told apart.
+ */
+export function discogsCandidatesFromJson(json) {
+ const list = Array.isArray(json?.candidates) ? json.candidates : [];
+ const found = [];
+ for (const item of list) {
+ const id = Number.isInteger(item?.id) && item.id > 0 && item.id < 1e12 ? item.id : 0;
+ const name = text(item?.name, 300);
+ if (!id || !name || found.some((c) => c.id === id)) continue;
+ found.push({ id, name, profile: text(item.profile, 400) });
+ if (found.length >= CANDIDATES_MAX) break;
+ }
+ return found;
+}
+
/**
* A band known only to Discogs, in the shape the box draws a Wikidata band
* in (artistLookup.js lookupArtist). It has no Wikidata id, so it cannot be
diff --git a/static/js/artistInfo.js b/static/js/artistInfo.js
index 19be0ffc..550af6f4 100644
--- a/static/js/artistInfo.js
+++ b/static/js/artistInfo.js
@@ -20,7 +20,13 @@
// Discogs when the user has set a Discogs token: the server asks Discogs and
// answers GET /api/jobs/{id}/artist-extra (artistDiscogs.js), so the token
// never reaches the page. Wikipedia always comes first; what Discogs added is
-// labelled, and credited as Discogs' terms ask.
+// labelled, and credited as Discogs' terms ask. A name Wikipedia does not know
+// is looked for on Discogs too (GET /api/discogs/artist), and the artists it
+// finds are offered to pick from: a name alone is not enough to be sure.
+//
+// With no token saved, the box says where only Wikipedia was searched (a name
+// it found nothing for, a band it has little on) and offers the way to the
+// setting. Never under a band Wikipedia covers in full.
//
// Everything that came from the network goes in with textContent, never as
// HTML. Wikipedia text is written by anyone, and this page can reach the
@@ -36,7 +42,7 @@ import {
taggedArtistName,
wikiLanguage,
} from "./artistLookup.js";
-import { discogsExtraFromJson, needsDiscogs, withDiscogs } from "./artistDiscogs.js";
+import { discogsCandidatesFromJson, discogsExtraFromJson, needsDiscogs, withDiscogs } from "./artistDiscogs.js";
import {
getCurrentTrackArtist,
getCurrentTrackInfo,
@@ -64,6 +70,7 @@ let returnFocus = null;
let searchShown = true;
let waiting = false; // open on a track whose tags or band are still being found
let searchGen = 0; // bumped by every search and by closing: a late answer checks it
+let tokenAsked = null; // whether a Discogs token is saved, asked once per opening
const isOpen = () => dialog && !dialog.classList.contains("hidden");
@@ -489,6 +496,163 @@ function render(artist, work = null) {
if (artist || work) parts.push(foot);
body.replaceChildren(...parts);
body.scrollTop = 0;
+ // A band with no history, members or albums here is the gap Discogs fills.
+ if (artist && !soundtrack && !artist.discogs && needsDiscogs(artist)) offerDiscogsSetup(foot);
+}
+
+// ─── Discogs, when Wikipedia has nothing or little ───
+
+// Whether a Discogs token is saved: true, false, or null when Settings could
+// not be read. Asked once each time the box opens, so a token saved in
+// Settings counts the next time. Never the token: only whether there is one.
+function discogsTokenSet() {
+ tokenAsked ??= fetch("/api/settings", { headers: { Accept: "application/json" } })
+ .then((res) => (res.ok ? res.json() : null))
+ .then((json) => (typeof json?.discogs_token_set === "boolean" ? json.discogs_token_set : null))
+ .catch((err) => {
+ console.warn("settings unavailable for the Discogs note", err);
+ return null;
+ });
+ return tokenAsked;
+}
+
+// Settings, on the Song details tab, with the token field focused. The box
+// closes first: Settings is a dialog of its own.
+function openDiscogsSettings() {
+ close();
+ document.getElementById("settingsBtn")?.click();
+ const settings = document.querySelector(".library-editor-backdrop");
+ settings?.querySelector('.settings-tab[data-tab="details"]')?.click();
+ settings?.querySelector(".set-discogs-token")?.focus();
+}
+
+// "Only Wikipedia was searched", and the button to the setting.
+function discogsSetupNote() {
+ const note = el("div", "artist-discogs-note");
+ const button = el("button", "artist-discogs-open", t("artist.discogsOpenSettings"));
+ button.type = "button";
+ button.addEventListener("click", openDiscogsSettings);
+ note.append(el("p", "", t("artist.discogsNote")), button);
+ return note;
+}
+
+// The note before `anchor`, once it is known that no token is saved, and only
+// if what it was for is still shown.
+function offerDiscogsSetup(anchor) {
+ discogsTokenSet().then((set) => {
+ if (set !== false || !anchor.isConnected || body.querySelector(".artist-discogs-note")) return;
+ anchor.before(discogsSetupNote());
+ });
+}
+
+const NO_TOKEN = "no-token";
+
+// The artists Discogs finds for a name: a list (maybe empty), NO_TOKEN when no
+// token is saved (the server answers 404 and asks Discogs nothing), or null
+// when Discogs could not be asked. Only a list is kept.
+async function searchDiscogs(query, signal) {
+ const key = `discogs?${query.toLowerCase()}`;
+ if (answers.has(key)) return answers.get(key);
+ try {
+ const res = await fetch(`/api/discogs/artist?q=${encodeURIComponent(query)}`, { signal, headers: { Accept: "application/json" } });
+ if (res.status === 404) return NO_TOKEN;
+ if (!res.ok) return null;
+ const found = discogsCandidatesFromJson(await res.json());
+ answers.set(key, found);
+ return found;
+ } catch (err) {
+ if (!signal.aborted) console.warn("Discogs artist search failed", err);
+ return null;
+ }
+}
+
+// Wikipedia found nothing for `query`. With a token saved, Discogs is asked,
+// and what it finds is offered to pick from; without one, the box says only
+// Wikipedia was searched and how to add Discogs. The field stays open either
+// way, holding the name so it can be corrected.
+async function nothingOnWikipedia(query, signal) {
+ showSearch(true);
+ const shown = () => !signal.aborted && isOpen();
+ const notFound = () => showStatus(t("artist.notFound", { name: query }), "muted");
+ const token = await discogsTokenSet();
+ if (!shown()) return;
+ if (!query || token === false) {
+ notFound();
+ if (token === false) body.append(discogsSetupNote());
+ return;
+ }
+ const kept = answers.get(`discogs?${query.toLowerCase()}`);
+ if (!kept) showStatus(t("artist.loadingDiscogs", { name: query }), "loading");
+ const found = kept || (await searchDiscogs(query, signal));
+ if (!shown()) return;
+ if (Array.isArray(found) && found.length) {
+ showCandidates(query, found);
+ } else if (Array.isArray(found)) {
+ showStatus(t("artist.notFoundAnywhere", { name: query }), "muted");
+ } else {
+ notFound();
+ if (found === NO_TOKEN) body.append(discogsSetupNote());
+ else body.append(el("p", "artist-status error", t("artist.discogsOffline")));
+ }
+}
+
+// The artists Discogs has for a name Wikipedia does not know, each with its
+// Discogs number and the start of its profile, to pick the one meant.
+function showCandidates(query, candidates) {
+ const box = el("section", "artist-section artist-discogs-pick");
+ box.append(el("h3", "artist-section-title", t("artist.discogsPick")), el("p", "artist-edition", t("artist.discogsPickHint")));
+ const list = el("ul", "artist-candidates");
+ for (const candidate of candidates) {
+ const button = el("button", "artist-candidate");
+ button.type = "button";
+ button.dataset.id = String(candidate.id);
+ button.append(el("span", "artist-candidate-name", candidate.name));
+ if (candidate.profile) button.append(el("span", "artist-candidate-profile", candidate.profile));
+ button.addEventListener("click", () => pickDiscogs(candidate));
+ const item = el("li");
+ item.append(button);
+ list.append(item);
+ }
+ box.append(list);
+ const foot = el("footer", "artist-foot");
+ foot.append(discogsCredit(""));
+ body.replaceChildren(...songParts(), el("p", "artist-status muted", t("artist.notFound", { name: query })), box, foot);
+ body.scrollTop = 0;
+}
+
+// The artist picked from Discogs' list, drawn as any band known only to
+// Discogs. It has no Wikidata id, so it is not saved on the track.
+async function pickDiscogs(candidate) {
+ current?.abort();
+ const controller = new AbortController();
+ current = controller;
+ const gen = ++searchGen;
+ const key = `discogs#${candidate.id}`;
+ if (!answers.has(key)) showStatus(t("artist.loadingDiscogs", { name: candidate.name }), "loading");
+ try {
+ let extra = answers.get(key) || null;
+ if (!extra) {
+ const res = await fetch(`/api/discogs/artist/${encodeURIComponent(candidate.id)}`, {
+ signal: controller.signal,
+ headers: { Accept: "application/json" },
+ });
+ extra = res.ok ? discogsExtraFromJson(await res.json()) : null;
+ if (extra) answers.set(key, extra);
+ }
+ if (controller.signal.aborted || gen !== searchGen || !isOpen()) return;
+ if (!extra) {
+ showStatus(t("artist.discogsOffline"), "error");
+ return;
+ }
+ showSearch(false);
+ render(withDiscogs(null, extra));
+ } catch (err) {
+ if (controller.signal.aborted) return;
+ console.warn("Discogs artist failed", err);
+ showStatus(t("artist.discogsOffline"), "error");
+ } finally {
+ if (current === controller) current = null;
+ }
}
// "Data provided by Discogs", linked to the band's page there.
@@ -629,12 +793,6 @@ async function search(name, { id = "", typed = false } = {}) {
}
const lang = getLanguage();
- // Nothing to show means the field is the next step, filled with the name
- // that found nothing so it can be corrected or tried again.
- const notFound = () => {
- showSearch(true);
- showStatus(t("artist.notFound", { name: query }), "muted");
- };
const controller = new AbortController();
current = controller;
@@ -675,7 +833,12 @@ async function search(name, { id = "", typed = false } = {}) {
if (bandError && !(work && !typed)) throw bandError;
if (artist) render(artist, work);
else if (work && !typed) render(null, work);
- else notFound();
+ else {
+ // Nothing to show means the field is the next step, filled with the
+ // name that found nothing so it can be corrected or tried again.
+ await nothingOnWikipedia(query, controller.signal);
+ return;
+ }
if (typed || !(artist || work) || !needsDiscogs(artist)) return;
// Drawn already; filled from Discogs when its answer is in, if it has
// what Wikipedia lacks.
@@ -746,6 +909,7 @@ function showFromTrack({ focus = true } = {}) {
function open() {
returnFocus = document.activeElement;
+ tokenAsked = null;
dialog.classList.remove("hidden");
showFromTrack();
}
diff --git a/static/js/catalog.js b/static/js/catalog.js
index 26eb5bc4..61c896f5 100644
--- a/static/js/catalog.js
+++ b/static/js/catalog.js
@@ -4597,7 +4597,7 @@ function openLibraryEditor() {
Discogs
Band profiles, members and releases for bands Wikipedia does not cover, from Discogs. Optional.
-
Band names are sent to Discogs only when a token is saved and Wikipedia has no history, members or albums for the band.
+
Band names are sent to Discogs only when a token is saved, and only for a band Wikipedia has no history, members or albums for, or a name you search for that Wikipedia does not have.
diff --git a/static/js/i18n.js b/static/js/i18n.js
index f8146909..930cc4ce 100644
--- a/static/js/i18n.js
+++ b/static/js/i18n.js
@@ -249,7 +249,7 @@ const en = {
"artist.searchPlaceholder": "Search for a band or artist",
"artist.searchAria": "Band or artist to look up",
"artist.search": "Search",
- "artist.privacy": "When a track is imported or opened, names from it (artist, song, album or show) are sent to MusicBrainz, Wikidata, Wikipedia and LRCLIB, and a name you search for here to Wikidata and Wikipedia. With an AcoustID key set, an audio fingerprint also goes to AcoustID. With a Discogs token set, the artist, song and album names also go to Discogs when this box opens. Nothing else is sent.",
+ "artist.privacy": "When a track is imported or opened, names from it (artist, song, album or show) are sent to MusicBrainz, Wikidata, Wikipedia and LRCLIB, and a name you search for here to Wikidata and Wikipedia. With an AcoustID key set, an audio fingerprint also goes to AcoustID. With a Discogs token set, the artist, song and album names also go to Discogs when this box opens, and so does a name you search for here that Wikipedia does not have. Nothing else is sent.",
"artist.loading": "Looking up “{name}” on Wikipedia…",
"artist.notThisBand": "Not this band? Search",
"artist.notThisPerformer": "Not this performer? Search",
@@ -276,6 +276,13 @@ const en = {
"artist.source": "From Wikipedia and Wikidata, CC BY-SA",
"artist.fromDiscogs": "From Discogs",
"artist.discogsCredit": "Data provided by Discogs",
+ "artist.loadingDiscogs": "Looking up “{name}” on Discogs…",
+ "artist.notFoundAnywhere": "Nothing on Wikipedia or Discogs matches “{name}” as a band or artist.",
+ "artist.discogsPick": "Found on Discogs",
+ "artist.discogsPickHint": "Not on Wikipedia, but Discogs has these. Pick the one you mean.",
+ "artist.discogsOffline": "Could not reach Discogs. Check your connection and try again.",
+ "artist.discogsNote": "Only Wikipedia was searched. Add a free Discogs token in Settings, Song details, to also search Discogs, which covers many smaller and independent bands.",
+ "artist.discogsOpenSettings": "Open Song details settings",
"artist.realName": "Real name: {name}",
"artist.memberOf": "Member of",
"artist.releases": "Releases",
@@ -656,7 +663,7 @@ const en = {
"settings.acoustid.refused": "AcoustID does not accept this key. Use the key of an application you register at acoustid.org, not the user key on your profile.",
"settings.discogs.title": "Discogs",
"settings.discogs.desc": "Band profiles, members and releases for bands Wikipedia does not cover, from Discogs. Optional.",
- "settings.discogs.privacy": "Band names are sent to Discogs only when a token is saved and Wikipedia has no history, members or albums for the band.",
+ "settings.discogs.privacy": "Band names are sent to Discogs only when a token is saved, and only for a band Wikipedia has no history, members or albums for, or a name you search for that Wikipedia does not have.",
"settings.discogs.placeholder": "Discogs personal access token",
"settings.discogs.save": "Save",
"settings.discogs.clear": "Clear",
@@ -987,7 +994,7 @@ const pl = {
"artist.searchPlaceholder": "Wyszukaj zespół lub wykonawcę",
"artist.searchAria": "Zespół lub wykonawca do wyszukania",
"artist.search": "Szukaj",
- "artist.privacy": "Gdy utwór jest importowany lub otwierany, nazwy z niego (wykonawca, utwór, album lub spektakl) trafiają do MusicBrainz, Wikidanych, Wikipedii i LRCLIB, a nazwa, której tu szukasz, do Wikidanych i Wikipedii. Gdy ustawiony jest klucz AcoustID, do AcoustID trafia też odcisk dźwięku. Gdy ustawiony jest token Discogs, nazwy wykonawcy, utworu i albumu trafiają też do Discogs po otwarciu tego okna. Nic więcej nie jest wysyłane.",
+ "artist.privacy": "Gdy utwór jest importowany lub otwierany, nazwy z niego (wykonawca, utwór, album lub spektakl) trafiają do MusicBrainz, Wikidanych, Wikipedii i LRCLIB, a nazwa, której tu szukasz, do Wikidanych i Wikipedii. Gdy ustawiony jest klucz AcoustID, do AcoustID trafia też odcisk dźwięku. Gdy ustawiony jest token Discogs, nazwy wykonawcy, utworu i albumu trafiają też do Discogs po otwarciu tego okna, podobnie jak szukana tu nazwa, której nie ma w Wikipedii. Nic więcej nie jest wysyłane.",
"artist.loading": "Szukam „{name}” w Wikipedii…",
"artist.notThisBand": "To nie ten zespół? Szukaj",
"artist.notThisPerformer": "To nie ten wykonawca? Szukaj",
@@ -1014,6 +1021,13 @@ const pl = {
"artist.source": "Z Wikipedii i Wikidanych, CC BY-SA",
"artist.fromDiscogs": "Z Discogs",
"artist.discogsCredit": "Dane dostarczone przez Discogs",
+ "artist.loadingDiscogs": "Szukam „{name}” w Discogs…",
+ "artist.notFoundAnywhere": "Ani w Wikipedii, ani w Discogs nie ma zespołu ani wykonawcy pasującego do „{name}”.",
+ "artist.discogsPick": "Znalezione w Discogs",
+ "artist.discogsPickHint": "Nie ma tego w Wikipedii, ale Discogs ma te pozycje. Wybierz właściwą.",
+ "artist.discogsOffline": "Nie udało się połączyć z Discogs. Sprawdź połączenie i spróbuj ponownie.",
+ "artist.discogsNote": "Przeszukano tylko Wikipedię. Dodaj darmowy token Discogs w Ustawieniach, w karcie Szczegóły utworu, aby przeszukiwać też Discogs, który obejmuje wiele mniejszych i niezależnych zespołów.",
+ "artist.discogsOpenSettings": "Otwórz kartę Szczegóły utworu",
"artist.realName": "Prawdziwe imię i nazwisko: {name}",
"artist.memberOf": "Członek zespołów",
"artist.releases": "Wydawnictwa",
@@ -1391,7 +1405,7 @@ const pl = {
"settings.acoustid.refused": "AcoustID nie akceptuje tego klucza. Użyj klucza aplikacji zarejestrowanej na acoustid.org, a nie klucza użytkownika z profilu.",
"settings.discogs.title": "Discogs",
"settings.discogs.desc": "Profile zespołów, składy i wydawnictwa z Discogs dla zespołów, których nie opisuje Wikipedia. Opcjonalne.",
- "settings.discogs.privacy": "Nazwy zespołów są wysyłane do Discogs tylko wtedy, gdy zapisano token, a Wikipedia nie ma historii, składu ani albumów zespołu.",
+ "settings.discogs.privacy": "Nazwy zespołów są wysyłane do Discogs tylko wtedy, gdy zapisano token, i tylko dla zespołu, o którym Wikipedia nie ma historii, składu ani albumów, lub dla szukanej nazwy, której nie ma w Wikipedii.",
"settings.discogs.placeholder": "Osobisty token dostępu Discogs",
"settings.discogs.save": "Zapisz",
"settings.discogs.clear": "Wyczyść",
@@ -1715,7 +1729,7 @@ const ja = {
"artist.searchPlaceholder": "バンド名やアーティスト名で検索",
"artist.searchAria": "調べるバンドまたはアーティスト",
"artist.search": "検索",
- "artist.privacy": "曲を取り込んだときや開いたときに、その曲の名前(アーティスト、曲名、アルバム、作品)が MusicBrainz、Wikidata、Wikipedia、LRCLIB に送られます。ここで検索した名前は Wikidata と Wikipedia に送られます。AcoustID キーを設定している場合は、音声のフィンガープリントも AcoustID に送られます。Discogs トークンを設定している場合は、このボックスを開いたときにアーティスト名、曲名、アルバム名も Discogs に送られます。それ以外は何も送られません。",
+ "artist.privacy": "曲を取り込んだときや開いたときに、その曲の名前(アーティスト、曲名、アルバム、作品)が MusicBrainz、Wikidata、Wikipedia、LRCLIB に送られます。ここで検索した名前は Wikidata と Wikipedia に送られます。AcoustID キーを設定している場合は、音声のフィンガープリントも AcoustID に送られます。Discogs トークンを設定している場合は、このボックスを開いたときにアーティスト名、曲名、アルバム名も Discogs に送られます。ここで検索した名前が Wikipedia にない場合は、その名前も Discogs に送られます。それ以外は何も送られません。",
"artist.loading": "Wikipedia で「{name}」を検索中…",
"artist.notThisBand": "このバンドではない?検索",
"artist.notThisPerformer": "この出演者ではない?検索",
@@ -1742,6 +1756,13 @@ const ja = {
"artist.source": "出典: Wikipedia と Wikidata (CC BY-SA)",
"artist.fromDiscogs": "出典: Discogs",
"artist.discogsCredit": "データ提供: Discogs",
+ "artist.loadingDiscogs": "Discogs で「{name}」を検索中…",
+ "artist.notFoundAnywhere": "「{name}」に一致するバンドやアーティストは Wikipedia にも Discogs にも見つかりませんでした。",
+ "artist.discogsPick": "Discogs で見つかりました",
+ "artist.discogsPickHint": "Wikipedia にはありませんが、Discogs に次の候補があります。該当するものを選んでください。",
+ "artist.discogsOffline": "Discogs に接続できませんでした。接続を確認してもう一度お試しください。",
+ "artist.discogsNote": "Wikipedia だけを検索しました。設定の「曲の詳細」で無料の Discogs トークンを追加すると、小規模なバンドやインディーズのバンドを多く収録している Discogs も検索できます。",
+ "artist.discogsOpenSettings": "「曲の詳細」設定を開く",
"artist.realName": "本名: {name}",
"artist.memberOf": "所属グループ",
"artist.releases": "リリース",
@@ -2118,7 +2139,7 @@ const ja = {
"settings.acoustid.refused": "AcoustID はこのキーを受け付けません。プロフィールのユーザーキーではなく、acoustid.org で登録したアプリケーションのキーを使ってください。",
"settings.discogs.title": "Discogs",
"settings.discogs.desc": "任意。Wikipedia に記事のないバンドのプロフィール、メンバー、リリースを Discogs から取得します。",
- "settings.discogs.privacy": "バンド名が Discogs に送られるのは、トークンが保存されていて、Wikipedia にそのバンドの経歴・メンバー・アルバムがない場合だけです。",
+ "settings.discogs.privacy": "バンド名が Discogs に送られるのは、トークンが保存されていて、Wikipedia にそのバンドの経歴・メンバー・アルバムがない場合か、検索した名前が Wikipedia にない場合だけです。",
"settings.discogs.placeholder": "Discogs 個人アクセストークン",
"settings.discogs.save": "保存",
"settings.discogs.clear": "消去",
@@ -2418,7 +2439,7 @@ const zhHans = {
"artist.searchPlaceholder": "搜索乐队或艺人",
"artist.searchAria": "要查询的乐队或艺人",
"artist.search": "搜索",
- "artist.privacy": "导入或打开曲目时,其中的名称(艺人、歌曲、专辑或剧目)会发送给 MusicBrainz、维基数据、维基百科和 LRCLIB;你在这里搜索的名称会发送给维基数据和维基百科。设置了 AcoustID 密钥时,音频指纹也会发送给 AcoustID。设置了 Discogs 令牌时,打开此框会把艺人、歌曲和专辑名称也发送给 Discogs。除此之外不会发送任何内容。",
+ "artist.privacy": "导入或打开曲目时,其中的名称(艺人、歌曲、专辑或剧目)会发送给 MusicBrainz、维基数据、维基百科和 LRCLIB;你在这里搜索的名称会发送给维基数据和维基百科。设置了 AcoustID 密钥时,音频指纹也会发送给 AcoustID。设置了 Discogs 令牌时,打开此框会把艺人、歌曲和专辑名称也发送给 Discogs;你在这里搜索而维基百科没有的名称也会发送给 Discogs。除此之外不会发送任何内容。",
"artist.loading": "正在维基百科中查找“{name}”…",
"artist.notThisBand": "不是这个乐队?搜索",
"artist.notThisPerformer": "不是这位演出者?搜索",
@@ -2445,6 +2466,13 @@ const zhHans = {
"artist.source": "来自维基百科和维基数据,CC BY-SA",
"artist.fromDiscogs": "来自 Discogs",
"artist.discogsCredit": "数据由 Discogs 提供",
+ "artist.loadingDiscogs": "正在 Discogs 中查找“{name}”…",
+ "artist.notFoundAnywhere": "维基百科和 Discogs 中都没有与“{name}”匹配的乐队或艺人。",
+ "artist.discogsPick": "在 Discogs 上找到",
+ "artist.discogsPickHint": "维基百科中没有,但 Discogs 上有以下结果。请选择你要找的那个。",
+ "artist.discogsOffline": "无法连接 Discogs。请检查网络后重试。",
+ "artist.discogsNote": "只搜索了维基百科。在“设置”的“歌曲详情”中添加免费的 Discogs 令牌,即可同时搜索 Discogs,它收录了许多小型和独立乐队。",
+ "artist.discogsOpenSettings": "打开“歌曲详情”设置",
"artist.realName": "本名:{name}",
"artist.memberOf": "所属团体",
"artist.releases": "发行作品",
@@ -2821,7 +2849,7 @@ const zhHans = {
"settings.acoustid.refused": "AcoustID 不接受此密钥。请使用在 acoustid.org 注册的应用程序的密钥,而不是个人资料中的用户密钥。",
"settings.discogs.title": "Discogs",
"settings.discogs.desc": "可选。从 Discogs 获取维基百科未收录的乐队的简介、成员和发行作品。",
- "settings.discogs.privacy": "只有在已保存令牌且维基百科没有该乐队的历史、成员或专辑时,才会把乐队名称发送给 Discogs。",
+ "settings.discogs.privacy": "只有在已保存令牌,且维基百科没有该乐队的历史、成员或专辑,或维基百科没有你搜索的名称时,才会把乐队名称发送给 Discogs。",
"settings.discogs.placeholder": "Discogs 个人访问令牌",
"settings.discogs.save": "保存",
"settings.discogs.clear": "清除",
@@ -3121,7 +3149,7 @@ const de = {
"artist.searchPlaceholder": "Nach Band oder Künstler suchen",
"artist.searchAria": "Band oder Künstler zum Nachschlagen",
"artist.search": "Suchen",
- "artist.privacy": "Wenn ein Track importiert oder geöffnet wird, werden Namen daraus (Künstler, Song, Album oder Show) an MusicBrainz, Wikidata, Wikipedia und LRCLIB gesendet, und ein Name, den du hier suchst, an Wikidata und Wikipedia. Ist ein AcoustID-Schlüssel gesetzt, geht außerdem ein Audio-Fingerabdruck an AcoustID. Ist ein Discogs-Token gesetzt, gehen beim Öffnen dieses Fensters auch Künstler-, Song- und Albumname an Discogs. Sonst wird nichts gesendet.",
+ "artist.privacy": "Wenn ein Track importiert oder geöffnet wird, werden Namen daraus (Künstler, Song, Album oder Show) an MusicBrainz, Wikidata, Wikipedia und LRCLIB gesendet, und ein Name, den du hier suchst, an Wikidata und Wikipedia. Ist ein AcoustID-Schlüssel gesetzt, geht außerdem ein Audio-Fingerabdruck an AcoustID. Ist ein Discogs-Token gesetzt, gehen beim Öffnen dieses Fensters auch Künstler-, Song- und Albumname an Discogs, ebenso ein hier gesuchter Name, den Wikipedia nicht kennt. Sonst wird nichts gesendet.",
"artist.loading": "„{name}“ wird auf Wikipedia gesucht…",
"artist.notThisBand": "Nicht diese Band? Suchen",
"artist.notThisPerformer": "Nicht diese Person? Suchen",
@@ -3148,6 +3176,13 @@ const de = {
"artist.source": "Aus Wikipedia und Wikidata, CC BY-SA",
"artist.fromDiscogs": "Von Discogs",
"artist.discogsCredit": "Daten bereitgestellt von Discogs",
+ "artist.loadingDiscogs": "„{name}“ wird auf Discogs gesucht…",
+ "artist.notFoundAnywhere": "Weder auf Wikipedia noch auf Discogs passt eine Band oder ein Künstler zu „{name}“.",
+ "artist.discogsPick": "Auf Discogs gefunden",
+ "artist.discogsPickHint": "Nicht auf Wikipedia, aber Discogs hat diese Treffer. Wähle den passenden.",
+ "artist.discogsOffline": "Discogs ist nicht erreichbar. Prüfe deine Verbindung und versuche es erneut.",
+ "artist.discogsNote": "Es wurde nur Wikipedia durchsucht. Füge in den Einstellungen unter Songdetails einen kostenlosen Discogs-Token hinzu, um auch Discogs zu durchsuchen, wo viele kleinere und unabhängige Bands zu finden sind.",
+ "artist.discogsOpenSettings": "Songdetails-Einstellungen öffnen",
"artist.realName": "Bürgerlicher Name: {name}",
"artist.memberOf": "Mitglied von",
"artist.releases": "Veröffentlichungen",
@@ -3525,7 +3560,7 @@ const de = {
"settings.acoustid.refused": "AcoustID akzeptiert diesen Schlüssel nicht. Verwende den Schlüssel einer Anwendung, die du auf acoustid.org registrierst, nicht den Benutzerschlüssel aus deinem Profil.",
"settings.discogs.title": "Discogs",
"settings.discogs.desc": "Bandprofile, Mitglieder und Veröffentlichungen von Discogs für Bands, die Wikipedia nicht abdeckt. Optional.",
- "settings.discogs.privacy": "Bandnamen werden nur an Discogs gesendet, wenn ein Token gespeichert ist und Wikipedia keine Geschichte, Mitglieder oder Alben der Band hat.",
+ "settings.discogs.privacy": "Bandnamen werden nur an Discogs gesendet, wenn ein Token gespeichert ist, und nur für eine Band, zu der Wikipedia keine Geschichte, Mitglieder oder Alben hat, oder für einen gesuchten Namen, den Wikipedia nicht kennt.",
"settings.discogs.placeholder": "Persönlicher Discogs-Zugangstoken",
"settings.discogs.save": "Speichern",
"settings.discogs.clear": "Löschen",
@@ -3835,7 +3870,7 @@ const pt = {
"artist.searchPlaceholder": "Pesquise uma banda ou artista",
"artist.searchAria": "Banda ou artista a pesquisar",
"artist.search": "Pesquisar",
- "artist.privacy": "Quando uma faixa é importada ou aberta, nomes dela (artista, música, álbum ou espetáculo) são enviados ao MusicBrainz, ao Wikidata, à Wikipédia e ao LRCLIB, e um nome que você pesquisa aqui, ao Wikidata e à Wikipédia. Com uma chave do AcoustID definida, uma impressão digital do áudio também vai para o AcoustID. Com um token do Discogs definido, os nomes do artista, da música e do álbum também vão para o Discogs quando esta janela é aberta. Nada mais é enviado.",
+ "artist.privacy": "Quando uma faixa é importada ou aberta, nomes dela (artista, música, álbum ou espetáculo) são enviados ao MusicBrainz, ao Wikidata, à Wikipédia e ao LRCLIB, e um nome que você pesquisa aqui, ao Wikidata e à Wikipédia. Com uma chave do AcoustID definida, uma impressão digital do áudio também vai para o AcoustID. Com um token do Discogs definido, os nomes do artista, da música e do álbum também vão para o Discogs quando esta janela é aberta, assim como um nome que você pesquisa aqui e que a Wikipédia não tem. Nada mais é enviado.",
"artist.loading": "Procurando “{name}” na Wikipédia…",
"artist.notThisBand": "Não é esta banda? Pesquisar",
"artist.notThisPerformer": "Não é este intérprete? Pesquisar",
@@ -3862,6 +3897,13 @@ const pt = {
"artist.source": "Da Wikipédia e do Wikidata, CC BY-SA",
"artist.fromDiscogs": "Do Discogs",
"artist.discogsCredit": "Dados fornecidos pelo Discogs",
+ "artist.loadingDiscogs": "Procurando “{name}” no Discogs…",
+ "artist.notFoundAnywhere": "Nenhuma banda ou artista na Wikipédia ou no Discogs corresponde a “{name}”.",
+ "artist.discogsPick": "Encontrado no Discogs",
+ "artist.discogsPickHint": "Não está na Wikipédia, mas o Discogs tem estes. Escolha o certo.",
+ "artist.discogsOffline": "Não foi possível acessar o Discogs. Verifique sua conexão e tente novamente.",
+ "artist.discogsNote": "Só a Wikipédia foi pesquisada. Adicione um token gratuito do Discogs em Configurações, Detalhes da música, para pesquisar também no Discogs, que cobre muitas bandas menores e independentes.",
+ "artist.discogsOpenSettings": "Abrir as configurações de Detalhes da música",
"artist.realName": "Nome real: {name}",
"artist.memberOf": "Membro de",
"artist.releases": "Lançamentos",
@@ -4239,7 +4281,7 @@ const pt = {
"settings.acoustid.refused": "O AcoustID não aceita esta chave. Use a chave de um aplicativo registrado em acoustid.org, não a chave de usuário do seu perfil.",
"settings.discogs.title": "Discogs",
"settings.discogs.desc": "Perfis, integrantes e lançamentos do Discogs para bandas que a Wikipédia não cobre. Opcional.",
- "settings.discogs.privacy": "Os nomes das bandas só são enviados ao Discogs quando há um token salvo e a Wikipédia não tem a história, os integrantes ou os álbuns da banda.",
+ "settings.discogs.privacy": "Os nomes das bandas só são enviados ao Discogs quando há um token salvo, e só para uma banda de que a Wikipédia não tem a história, os integrantes ou os álbuns, ou para um nome pesquisado que a Wikipédia não tem.",
"settings.discogs.placeholder": "Token de acesso pessoal do Discogs",
"settings.discogs.save": "Salvar",
"settings.discogs.clear": "Limpar",
@@ -4551,7 +4593,7 @@ const id = {
"artist.searchPlaceholder": "Cari band atau artis",
"artist.searchAria": "Band atau artis yang dicari",
"artist.search": "Cari",
- "artist.privacy": "Saat trek diimpor atau dibuka, nama-nama darinya (artis, lagu, album, atau pertunjukan) dikirim ke MusicBrainz, Wikidata, Wikipedia, dan LRCLIB, dan nama yang Anda cari di sini ke Wikidata dan Wikipedia. Jika kunci AcoustID diatur, sidik jari audio juga dikirim ke AcoustID. Jika token Discogs diatur, nama artis, lagu, dan album juga dikirim ke Discogs saat kotak ini dibuka. Tidak ada lagi yang dikirim.",
+ "artist.privacy": "Saat trek diimpor atau dibuka, nama-nama darinya (artis, lagu, album, atau pertunjukan) dikirim ke MusicBrainz, Wikidata, Wikipedia, dan LRCLIB, dan nama yang Anda cari di sini ke Wikidata dan Wikipedia. Jika kunci AcoustID diatur, sidik jari audio juga dikirim ke AcoustID. Jika token Discogs diatur, nama artis, lagu, dan album juga dikirim ke Discogs saat kotak ini dibuka, begitu pula nama yang Anda cari di sini yang tidak ada di Wikipedia. Tidak ada lagi yang dikirim.",
"artist.loading": "Mencari “{name}” di Wikipedia…",
"artist.notThisBand": "Bukan band ini? Cari",
"artist.notThisPerformer": "Bukan penampil ini? Cari",
@@ -4578,6 +4620,13 @@ const id = {
"artist.source": "Dari Wikipedia dan Wikidata, CC BY-SA",
"artist.fromDiscogs": "Dari Discogs",
"artist.discogsCredit": "Data disediakan oleh Discogs",
+ "artist.loadingDiscogs": "Mencari “{name}” di Discogs…",
+ "artist.notFoundAnywhere": "Tidak ada band atau artis di Wikipedia maupun Discogs yang cocok dengan “{name}”.",
+ "artist.discogsPick": "Ditemukan di Discogs",
+ "artist.discogsPickHint": "Tidak ada di Wikipedia, tetapi Discogs punya ini. Pilih yang Anda maksud.",
+ "artist.discogsOffline": "Tidak dapat terhubung ke Discogs. Periksa koneksi Anda lalu coba lagi.",
+ "artist.discogsNote": "Hanya Wikipedia yang dicari. Tambahkan token Discogs gratis di Pengaturan, Detail lagu, agar Discogs juga dicari, yang mencakup banyak band kecil dan independen.",
+ "artist.discogsOpenSettings": "Buka pengaturan Detail lagu",
"artist.realName": "Nama asli: {name}",
"artist.memberOf": "Anggota dari",
"artist.releases": "Rilisan",
@@ -4954,7 +5003,7 @@ const id = {
"settings.acoustid.refused": "AcoustID tidak menerima kunci ini. Gunakan kunci aplikasi yang didaftarkan di acoustid.org, bukan kunci pengguna di profil.",
"settings.discogs.title": "Discogs",
"settings.discogs.desc": "Profil band, anggota, dan rilisan dari Discogs untuk band yang tidak dibahas Wikipedia. Opsional.",
- "settings.discogs.privacy": "Nama band dikirim ke Discogs hanya jika token tersimpan dan Wikipedia tidak punya sejarah, anggota, atau album band tersebut.",
+ "settings.discogs.privacy": "Nama band dikirim ke Discogs hanya jika token tersimpan, dan hanya untuk band yang sejarah, anggota, atau albumnya tidak ada di Wikipedia, atau nama yang dicari yang tidak ada di Wikipedia.",
"settings.discogs.placeholder": "Token akses pribadi Discogs",
"settings.discogs.save": "Simpan",
"settings.discogs.clear": "Hapus",
@@ -5254,7 +5303,7 @@ const fr = {
"artist.searchPlaceholder": "Rechercher un groupe ou un artiste",
"artist.searchAria": "Groupe ou artiste à rechercher",
"artist.search": "Rechercher",
- "artist.privacy": "Quand un morceau est importé ou ouvert, des noms qui en viennent (artiste, titre, album ou spectacle) sont envoyés à MusicBrainz, Wikidata, Wikipédia et LRCLIB, et un nom que vous cherchez ici, à Wikidata et Wikipédia. Si une clé AcoustID est définie, une empreinte audio est aussi envoyée à AcoustID. Si un jeton Discogs est défini, les noms de l'artiste, du morceau et de l'album sont aussi envoyés à Discogs à l'ouverture de cette fenêtre. Rien d'autre n'est envoyé.",
+ "artist.privacy": "Quand un morceau est importé ou ouvert, des noms qui en viennent (artiste, titre, album ou spectacle) sont envoyés à MusicBrainz, Wikidata, Wikipédia et LRCLIB, et un nom que vous cherchez ici, à Wikidata et Wikipédia. Si une clé AcoustID est définie, une empreinte audio est aussi envoyée à AcoustID. Si un jeton Discogs est défini, les noms de l'artiste, du morceau et de l'album sont aussi envoyés à Discogs à l'ouverture de cette fenêtre, de même qu'un nom cherché ici que Wikipédia n'a pas. Rien d'autre n'est envoyé.",
"artist.loading": "Recherche de « {name} » sur Wikipédia…",
"artist.notThisBand": "Pas ce groupe ? Rechercher",
"artist.notThisPerformer": "Pas cet interprète ? Rechercher",
@@ -5281,6 +5330,13 @@ const fr = {
"artist.source": "Depuis Wikipédia et Wikidata, CC BY-SA",
"artist.fromDiscogs": "Depuis Discogs",
"artist.discogsCredit": "Données fournies par Discogs",
+ "artist.loadingDiscogs": "Recherche de « {name} » sur Discogs…",
+ "artist.notFoundAnywhere": "Aucun groupe ni artiste sur Wikipédia ou Discogs ne correspond à « {name} ».",
+ "artist.discogsPick": "Trouvé sur Discogs",
+ "artist.discogsPickHint": "Absent de Wikipédia, mais Discogs a ces résultats. Choisissez le bon.",
+ "artist.discogsOffline": "Impossible de joindre Discogs. Vérifiez votre connexion et réessayez.",
+ "artist.discogsNote": "Seul Wikipédia a été consulté. Ajoutez un jeton Discogs gratuit dans Paramètres, Détails du morceau, pour chercher aussi sur Discogs, qui couvre beaucoup de petits groupes et de groupes indépendants.",
+ "artist.discogsOpenSettings": "Ouvrir les paramètres Détails du morceau",
"artist.realName": "Vrai nom : {name}",
"artist.memberOf": "Membre de",
"artist.releases": "Sorties",
@@ -5659,7 +5715,7 @@ const fr = {
"settings.acoustid.refused": "AcoustID n'accepte pas cette clé. Utilisez la clé d'une application enregistrée sur acoustid.org, pas la clé utilisateur de votre profil.",
"settings.discogs.title": "Discogs",
"settings.discogs.desc": "Profils, membres et parutions des groupes que Wikipédia ne couvre pas, depuis Discogs. Facultatif.",
- "settings.discogs.privacy": "Les noms des groupes ne sont envoyés à Discogs que si un jeton est enregistré et que Wikipédia n'a ni historique, ni membres, ni albums du groupe.",
+ "settings.discogs.privacy": "Les noms des groupes ne sont envoyés à Discogs que si un jeton est enregistré, et seulement pour un groupe dont Wikipédia n'a ni historique, ni membres, ni albums, ou pour un nom cherché que Wikipédia n'a pas.",
"settings.discogs.placeholder": "Jeton d'accès personnel Discogs",
"settings.discogs.save": "Enregistrer",
"settings.discogs.clear": "Effacer",
@@ -6008,10 +6064,14 @@ const ptPT = {
"topbar.uploadFile": "Carregar ficheiro de áudio",
"library.importedFile": "Ficheiro importado",
"track.nowPlaying": "A reproduzir",
- "artist.privacy": "Quando uma faixa é importada ou aberta, nomes dela (artista, música, álbum ou espetáculo) são enviados para o MusicBrainz, o Wikidata, a Wikipédia e o LRCLIB, e um nome que pesquisa aqui, para o Wikidata e a Wikipédia. Com uma chave do AcoustID definida, uma impressão digital do áudio também segue para o AcoustID. Com um token do Discogs definido, os nomes do artista, da música e do álbum também seguem para o Discogs quando esta janela é aberta. Nada mais é enviado.",
+ "artist.privacy": "Quando uma faixa é importada ou aberta, nomes dela (artista, música, álbum ou espetáculo) são enviados para o MusicBrainz, o Wikidata, a Wikipédia e o LRCLIB, e um nome que pesquisa aqui, para o Wikidata e a Wikipédia. Com uma chave do AcoustID definida, uma impressão digital do áudio também segue para o AcoustID. Com um token do Discogs definido, os nomes do artista, da música e do álbum também seguem para o Discogs quando esta janela é aberta, tal como um nome que pesquisa aqui e que a Wikipédia não tem. Nada mais é enviado.",
"artist.loading": "A procurar “{name}” na Wikipédia…",
"artist.checkingTrack": "A procurar o artista desta faixa…",
"artist.offline": "Não foi possível aceder à Wikipédia. Verifique a sua ligação e tente novamente.",
+ "artist.loadingDiscogs": "A procurar “{name}” no Discogs…",
+ "artist.discogsOffline": "Não foi possível aceder ao Discogs. Verifique a sua ligação e tente novamente.",
+ "artist.discogsNote": "Só a Wikipédia foi pesquisada. Adicione um token gratuito do Discogs em Definições, Detalhes da música, para pesquisar também no Discogs, que cobre muitas bandas mais pequenas e independentes.",
+ "artist.discogsOpenSettings": "Abrir as definições de Detalhes da música",
"artist.members": "Membros",
"artist.formerMembers": "Antigos membros",
"artist.setForTrack": "Guardar nesta faixa",
@@ -6030,7 +6090,7 @@ const ptPT = {
"settings.acoustid.none": "Nenhuma chave guardada.",
"settings.acoustid.failed": "Não foi possível guardar a chave. Verifique a sua ligação e tente novamente.",
"settings.acoustid.refused": "O AcoustID não aceita esta chave. Utilize a chave de uma aplicação registada em acoustid.org, não a chave de utilizador do seu perfil.",
- "settings.discogs.privacy": "Os nomes das bandas só são enviados para o Discogs quando há um token guardado e a Wikipédia não tem a história, os membros ou os álbuns da banda.",
+ "settings.discogs.privacy": "Os nomes das bandas só são enviados para o Discogs quando há um token guardado, e só para uma banda de que a Wikipédia não tem a história, os membros ou os álbuns, ou para um nome pesquisado que a Wikipédia não tem.",
"settings.discogs.save": "Guardar",
"settings.discogs.saved": "Está guardado um token terminado em {tail}.",
"settings.discogs.none": "Nenhum token guardado.",
@@ -6123,7 +6183,7 @@ const es = {
"artist.searchPlaceholder": "Busca un grupo o artista",
"artist.searchAria": "Grupo o artista que buscar",
"artist.search": "Buscar",
- "artist.privacy": "Cuando se importa o se abre una pista, sus nombres (artista, canción, álbum u obra) se envían a MusicBrainz, Wikidata, Wikipedia y LRCLIB, y un nombre que buscas aquí, a Wikidata y Wikipedia. Si hay una clave de AcoustID configurada, también se envía a AcoustID una huella de audio. Si hay un token de Discogs configurado, los nombres del artista, la canción y el álbum también se envían a Discogs al abrir este cuadro. No se envía nada más.",
+ "artist.privacy": "Cuando se importa o se abre una pista, sus nombres (artista, canción, álbum u obra) se envían a MusicBrainz, Wikidata, Wikipedia y LRCLIB, y un nombre que buscas aquí, a Wikidata y Wikipedia. Si hay una clave de AcoustID configurada, también se envía a AcoustID una huella de audio. Si hay un token de Discogs configurado, los nombres del artista, la canción y el álbum también se envían a Discogs al abrir este cuadro, igual que un nombre que buscas aquí y que Wikipedia no tiene. No se envía nada más.",
"artist.loading": "Buscando «{name}» en Wikipedia…",
"artist.notThisBand": "¿No es esta banda? Buscar",
"artist.notThisPerformer": "¿No es este intérprete? Buscar",
@@ -6150,6 +6210,13 @@ const es = {
"artist.source": "De Wikipedia y Wikidata, CC BY-SA",
"artist.fromDiscogs": "De Discogs",
"artist.discogsCredit": "Datos proporcionados por Discogs",
+ "artist.loadingDiscogs": "Buscando «{name}» en Discogs…",
+ "artist.notFoundAnywhere": "Ningún grupo ni artista de Wikipedia ni de Discogs coincide con «{name}».",
+ "artist.discogsPick": "Encontrado en Discogs",
+ "artist.discogsPickHint": "No está en Wikipedia, pero Discogs tiene estos. Elige el que buscas.",
+ "artist.discogsOffline": "No se pudo conectar con Discogs. Comprueba la conexión e inténtalo de nuevo.",
+ "artist.discogsNote": "Solo se buscó en Wikipedia. Añade un token gratuito de Discogs en Ajustes, Detalles de la canción, para buscar también en Discogs, que recoge muchas bandas pequeñas e independientes.",
+ "artist.discogsOpenSettings": "Abrir los ajustes de Detalles de la canción",
"artist.realName": "Nombre real: {name}",
"artist.memberOf": "Miembro de",
"artist.releases": "Lanzamientos",
@@ -6528,7 +6595,7 @@ const es = {
"settings.acoustid.refused": "AcoustID no acepta esta clave. Usa la clave de una aplicación registrada en acoustid.org, no la clave de usuario de tu perfil.",
"settings.discogs.title": "Discogs",
"settings.discogs.desc": "Perfiles, miembros y lanzamientos de Discogs para las bandas que Wikipedia no cubre. Opcional.",
- "settings.discogs.privacy": "Los nombres de las bandas solo se envían a Discogs cuando hay un token guardado y Wikipedia no tiene la historia, los miembros o los álbumes de la banda.",
+ "settings.discogs.privacy": "Los nombres de las bandas solo se envían a Discogs cuando hay un token guardado, y solo para una banda de la que Wikipedia no tiene la historia, los miembros o los álbumes, o para un nombre buscado que Wikipedia no tiene.",
"settings.discogs.placeholder": "Token de acceso personal de Discogs",
"settings.discogs.save": "Guardar",
"settings.discogs.clear": "Borrar",
@@ -6860,7 +6927,7 @@ const ko = {
"artist.searchPlaceholder": "밴드나 아티스트 검색",
"artist.searchAria": "찾아볼 밴드 또는 아티스트",
"artist.search": "검색",
- "artist.privacy": "트랙을 가져오거나 열 때 트랙의 이름(아티스트, 곡, 앨범 또는 작품)이 MusicBrainz, 위키데이터, 위키백과, LRCLIB에 전송되고, 여기서 검색한 이름은 위키데이터와 위키백과에 전송됩니다. AcoustID 키를 설정하면 오디오 지문도 AcoustID에 전송됩니다. Discogs 토큰을 설정하면 이 창을 열 때 아티스트, 곡, 앨범 이름도 Discogs에 전송됩니다. 그 밖에는 아무것도 전송되지 않습니다.",
+ "artist.privacy": "트랙을 가져오거나 열 때 트랙의 이름(아티스트, 곡, 앨범 또는 작품)이 MusicBrainz, 위키데이터, 위키백과, LRCLIB에 전송되고, 여기서 검색한 이름은 위키데이터와 위키백과에 전송됩니다. AcoustID 키를 설정하면 오디오 지문도 AcoustID에 전송됩니다. Discogs 토큰을 설정하면 이 창을 열 때 아티스트, 곡, 앨범 이름도 Discogs에 전송되고, 여기서 검색한 이름이 위키백과에 없으면 그 이름도 전송됩니다. 그 밖에는 아무것도 전송되지 않습니다.",
"artist.loading": "위키백과에서 '{name}' 검색 중…",
"artist.notThisBand": "이 밴드가 아닌가요? 검색",
"artist.notThisPerformer": "이 출연자가 아닌가요? 검색",
@@ -6887,6 +6954,13 @@ const ko = {
"artist.source": "출처: 위키백과, 위키데이터 (CC BY-SA)",
"artist.fromDiscogs": "출처: Discogs",
"artist.discogsCredit": "데이터 제공: Discogs",
+ "artist.loadingDiscogs": "Discogs에서 '{name}' 검색 중…",
+ "artist.notFoundAnywhere": "위키백과와 Discogs에서 '{name}'와(과) 일치하는 밴드나 아티스트를 찾지 못했습니다.",
+ "artist.discogsPick": "Discogs에서 찾음",
+ "artist.discogsPickHint": "위키백과에는 없지만 Discogs에 다음 결과가 있습니다. 찾는 항목을 고르세요.",
+ "artist.discogsOffline": "Discogs에 연결할 수 없습니다. 연결을 확인하고 다시 시도하세요.",
+ "artist.discogsNote": "위키백과만 검색했습니다. 설정의 곡 정보에서 무료 Discogs 토큰을 추가하면 작은 밴드와 인디 밴드를 많이 다루는 Discogs도 함께 검색합니다.",
+ "artist.discogsOpenSettings": "곡 정보 설정 열기",
"artist.realName": "본명: {name}",
"artist.memberOf": "소속 그룹",
"artist.releases": "발매작",
@@ -7263,7 +7337,7 @@ const ko = {
"settings.acoustid.refused": "AcoustID가 이 키를 받아들이지 않아요. 프로필의 사용자 키가 아니라 acoustid.org에서 등록한 애플리케이션의 키를 사용하세요.",
"settings.discogs.title": "Discogs",
"settings.discogs.desc": "선택 사항이에요. 위키백과에 없는 밴드의 프로필, 멤버, 발매작을 Discogs에서 가져와요.",
- "settings.discogs.privacy": "토큰이 저장되어 있고 위키백과에 그 밴드의 역사, 멤버, 앨범이 없을 때만 밴드 이름을 Discogs로 보내요.",
+ "settings.discogs.privacy": "토큰이 저장되어 있을 때, 위키백과에 그 밴드의 역사, 멤버, 앨범이 없거나 검색한 이름이 위키백과에 없을 때만 밴드 이름을 Discogs로 보내요.",
"settings.discogs.placeholder": "Discogs 개인 액세스 토큰",
"settings.discogs.save": "저장",
"settings.discogs.clear": "지우기",
diff --git a/tests/e2e/artist-discogs.spec.mjs b/tests/e2e/artist-discogs.spec.mjs
index 18e0358d..c3e88573 100644
--- a/tests/e2e/artist-discogs.spec.mjs
+++ b/tests/e2e/artist-discogs.spec.mjs
@@ -182,7 +182,8 @@ test.describe("artist box from Discogs", () => {
const asked = await stubExtra(page, null);
await openTagged(page);
await expect(page.locator("#artistDialog .artist-status")).toContainText("NIHIL");
- expect(asked).toContain(JOB_ID);
+ // Waited for, not read at once: the request can go out just after the text.
+ await expect.poll(() => asked).toContain(JOB_ID);
await expect(page.locator(".artist-discogs-credit")).toHaveCount(0);
});
@@ -223,3 +224,146 @@ test.describe("artist box from Discogs", () => {
await expect(dialog.locator(".artist-from-discogs")).toHaveCount(0);
});
});
+
+// ── only Wikipedia searched, and a name typed with a token saved ──
+
+/** Answer GET /api/settings as the server does, with discogs_token_set as given. */
+async function stubTokenSet(page, set) {
+ await page.route("**/api/settings", async (route) => {
+ if (route.request().method() !== "GET") return route.continue();
+ const response = await route.fetch();
+ const json = await response.json();
+ return route.fulfill({ response, json: { ...json, discogs_token_set: set } });
+ });
+}
+
+const CANDIDATES = {
+ candidates: [
+ { id: 555501, name: "Nihil (5)", profile: "Portuguese sludge band from Porto, formed in 2016." },
+ { id: 12, name: "Nihil", profile: "German industrial project." },
+ ],
+};
+
+/** Answer the name search and the picked artist, recording what was asked. */
+async function stubDiscogsSearch(page, answer = CANDIDATES) {
+ const asked = { names: [], ids: [] };
+ await page.route(/\/api\/discogs\/artist\?/, (route) => {
+ asked.names.push(new URL(route.request().url()).searchParams.get("q"));
+ return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(answer) });
+ });
+ await page.route(/\/api\/discogs\/artist\/\d+$/, (route) => {
+ asked.ids.push(new URL(route.request().url()).pathname.split("/").pop());
+ return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(EXTRA) });
+ });
+ return asked;
+}
+
+const NOTE =
+ "Only Wikipedia was searched. Add a free Discogs token in Settings, Song details, to also search Discogs, which covers many smaller and independent bands.";
+
+test.describe("artist box: only Wikipedia, or Discogs too", () => {
+ test("with no token, a name Wikipedia does not know says so and opens Song details", async ({ page }) => {
+ await stubTokenSet(page, false);
+ await stubWikimedia(page, null);
+ await stubExtra(page, null);
+ const asked = await stubDiscogsSearch(page);
+ await openTagged(page);
+ const dialog = page.locator("#artistDialog");
+
+ await expect(dialog.locator(".artist-status")).toHaveText("Nothing on Wikipedia matches “NIHIL” as a band or artist.");
+ const note = dialog.locator(".artist-discogs-note");
+ await expect(note).toContainText(NOTE);
+ const open = note.locator(".artist-discogs-open");
+ await expect(open).toHaveText("Open Song details settings");
+ // No token: Discogs is not searched, not even by the server.
+ expect(asked.names).toEqual([]);
+
+ await open.click();
+ await expect(dialog).toBeHidden();
+ const settings = page.locator(".library-editor-backdrop");
+ await expect(settings).toBeVisible();
+ await expect(settings.locator('.settings-tab[data-tab="details"]')).toHaveClass(/active/);
+ await expect(settings.locator('.settings-pane[data-pane="details"]')).toBeVisible();
+ await expect(settings.locator(".set-discogs-token")).toBeFocused();
+ });
+
+ test("with no token, a band Wikipedia has little on gets the note too", async ({ page }) => {
+ await stubTokenSet(page, false);
+ await stubWikimedia(page, wikidataNihil({ article: false }));
+ await stubExtra(page, null);
+ await openTagged(page);
+ const dialog = page.locator("#artistDialog");
+ await expect(dialog.locator(".artist-name")).toHaveText("Nihil");
+ await expect(dialog.locator(".artist-discogs-note")).toContainText(NOTE);
+ await expect(dialog.locator(".artist-source")).toHaveText("From Wikipedia and Wikidata, CC BY-SA");
+ });
+
+ test("never under a band Wikipedia covers in full", async ({ page }) => {
+ await stubTokenSet(page, false);
+ await stubWikimedia(page, wikidataNihil({ article: true }));
+ await stubExtra(page, null);
+ await openTagged(page);
+ const dialog = page.locator("#artistDialog");
+ await expect(dialog.locator(".artist-history")).toContainText("From the Wikipedia article.");
+ await page.waitForTimeout(500);
+ await expect(dialog.locator(".artist-discogs-note")).toHaveCount(0);
+ });
+
+ test("with a token saved, no note, even where Discogs had nothing", async ({ page }) => {
+ await stubTokenSet(page, true);
+ await stubWikimedia(page, wikidataNihil({ article: false }));
+ await stubExtra(page, null);
+ await openTagged(page);
+ const dialog = page.locator("#artistDialog");
+ await expect(dialog.locator(".artist-name")).toHaveText("Nihil");
+ await page.waitForTimeout(500);
+ await expect(dialog.locator(".artist-discogs-note")).toHaveCount(0);
+ });
+
+ test("with a token saved, a typed name Wikipedia does not know is found on Discogs and picked", async ({ page }) => {
+ await stubTokenSet(page, true);
+ await stubWikimedia(page, null);
+ await stubExtra(page, null);
+ const asked = await stubDiscogsSearch(page);
+ const discogsAsked = await openTagged(page);
+ const dialog = page.locator("#artistDialog");
+
+ // The box's own name, found on neither: the field is open for another.
+ await expect(dialog.locator(".artist-candidate")).toHaveCount(2);
+ await dialog.locator("#artistQuery").fill("Nihil Porto");
+ await dialog.locator(".artist-search-btn").click();
+ await expect.poll(() => asked.names).toContain("Nihil Porto");
+
+ await expect(dialog.locator(".artist-status")).toHaveText("Nothing on Wikipedia matches “Nihil Porto” as a band or artist.");
+ await expect(dialog.locator(".artist-discogs-pick .artist-section-title")).toHaveText("Found on Discogs");
+ const candidates = dialog.locator(".artist-candidate");
+ await expect(candidates.locator(".artist-candidate-name")).toHaveText(["Nihil (5)", "Nihil"]);
+ await expect(candidates.first()).toContainText("Portuguese sludge band from Porto");
+ await expect(dialog.locator(".artist-discogs-credit")).toHaveText("Data provided by Discogs");
+ await expect(dialog.locator(".artist-discogs-note")).toHaveCount(0);
+
+ await candidates.first().click();
+ await expect(dialog.locator(".artist-name")).toHaveText("Nihil");
+ expect(asked.ids).toEqual(["555501"]);
+ await expect(dialog.locator(".artist-history")).toContainText("Portuguese sludge band from Porto");
+ await expect(dialog.locator(".artist-source")).toHaveText("From Discogs");
+ const credit = dialog.locator(".artist-discogs-credit");
+ await expect(credit).toHaveText("Data provided by Discogs");
+ await expect(credit).toHaveAttribute("href", DISCOGS_PAGE);
+ await expect(dialog.locator(".artist-save")).toHaveCount(0);
+ await expect(dialog.locator(".artist-other-btn")).toHaveText("Not this band? Search");
+ expect(discogsAsked).toEqual([]);
+ });
+
+ test("with a token saved and nothing on Discogs either, it says both were searched", async ({ page }) => {
+ await stubTokenSet(page, true);
+ await stubWikimedia(page, null);
+ await stubExtra(page, null);
+ await stubDiscogsSearch(page, { candidates: [] });
+ await openTagged(page);
+ const dialog = page.locator("#artistDialog");
+ await expect(dialog.locator(".artist-status")).toHaveText("Nothing on Wikipedia or Discogs matches “NIHIL” as a band or artist.");
+ await expect(dialog.locator(".artist-discogs-note")).toHaveCount(0);
+ await expect(dialog.locator("#artistQuery")).toBeVisible();
+ });
+});
diff --git a/tests/e2e/discogs-setting.spec.mjs b/tests/e2e/discogs-setting.spec.mjs
index dce8b101..9a07c478 100644
--- a/tests/e2e/discogs-setting.spec.mjs
+++ b/tests/e2e/discogs-setting.spec.mjs
@@ -70,7 +70,7 @@ test.describe("Discogs token", () => {
"Band profiles, members and releases for bands Wikipedia does not cover, from Discogs. Optional.",
);
await expect(page.locator(".discogs-privacy")).toHaveText(
- "Band names are sent to Discogs only when a token is saved and Wikipedia has no history, members or albums for the band.",
+ "Band names are sent to Discogs only when a token is saved, and only for a band Wikipedia has no history, members or albums for, or a name you search for that Wikipedia does not have.",
);
const link = page.locator(".discogs-register");
await expect(link).toHaveText("Get a free token at discogs.com");
diff --git a/tests/js/artist-discogs.test.mjs b/tests/js/artist-discogs.test.mjs
index 5cdf418b..f5fb4d18 100644
--- a/tests/js/artist-discogs.test.mjs
+++ b/tests/js/artist-discogs.test.mjs
@@ -8,7 +8,14 @@
//
// Run: node tests/js/artist-discogs.test.mjs
-import { discogsBand, discogsExtraFromJson, needsDiscogs, sameArtist, withDiscogs } from "../../static/js/artistDiscogs.js";
+import {
+ discogsBand,
+ discogsCandidatesFromJson,
+ discogsExtraFromJson,
+ needsDiscogs,
+ sameArtist,
+ withDiscogs,
+} from "../../static/js/artistDiscogs.js";
import { discogsArtistId } from "../../static/js/artistLookup.js";
let pass = 0,
@@ -120,5 +127,30 @@ check("two different ones are no answer", discogsArtistId({ P1953: [claim("1"),
check("a deprecated one does not count", discogsArtistId({ P1953: [claim("1", "deprecated"), claim("2")] }) === "2");
check("a value that is not an id does not count", discogsArtistId({ P1953: [claim("abc")] }) === "");
+// ── the artists a typed name finds (GET /api/discogs/artist?q=) ──
+
+const candidates = discogsCandidatesFromJson({
+ candidates: [
+ { id: 555501, name: "Nihil (5)", profile: "Portuguese sludge band." },
+ { id: 12, name: "Nihil", profile: "" },
+ { id: 12, name: "Nihil again", profile: "" },
+ { id: "13", name: "String id", profile: "" },
+ { id: 14, name: "", profile: "" },
+ { id: -1, name: "Negative", profile: "" },
+ { id: 1.5, name: "Fraction", profile: "" },
+ { id: 15, name: 42, profile: "" },
+ { id: 16, name: "Nihil (2)", profile: { html: "x" } },
+ ],
+});
+check("candidates keep the number that tells them apart", candidates[0].name === "Nihil (5)" && candidates[0].profile === "Portuguese sludge band.");
+check("only whole positive ids with a name, each once", JSON.stringify(candidates.map((c) => c.id)) === "[555501,12,16]", candidates);
+check("a profile that is not text is dropped", candidates[2].profile === "");
+check("no candidates is an empty list", discogsCandidatesFromJson({ candidates: [] }).length === 0);
+check("an answer of the wrong shape is an empty list", discogsCandidatesFromJson(null).length === 0 && discogsCandidatesFromJson({ candidates: "x" }).length === 0);
+check(
+ "at most eight",
+ discogsCandidatesFromJson({ candidates: Array.from({ length: 20 }, (_, i) => ({ id: i + 1, name: `A${i}` })) }).length === 8,
+);
+
console.log(`${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
diff --git a/tests/test_discogs_search_api.py b/tests/test_discogs_search_api.py
new file mode 100644
index 00000000..df048c12
--- /dev/null
+++ b/tests/test_discogs_search_api.py
@@ -0,0 +1,202 @@
+"""GET /api/discogs/artist?q= and GET /api/discogs/artist/{id}: a band typed in
+the artist box, looked for on Discogs, and the one the user picked.
+
+No network: conftest keeps api.discogs.com offline and the cache in a
+temporary directory; the answers are recorded shapes. The token is made up
+and set through the real settings store.
+"""
+
+from __future__ import annotations
+
+import time
+import urllib.parse
+
+import pytest
+from fastapi.testclient import TestClient
+
+import app.api.discogs as discogs_api
+from app.core import settings
+from app.pipeline import discogs, ratelimit
+from tests.test_discogs import ARTIST_NIHIL, ARTIST_NIHIL_RELEASES, TOKEN, FakeDiscogs
+
+SEARCH_NIHIL = {
+ "pagination": {"page": 1, "pages": 1, "per_page": 25, "items": 4, "urls": {}},
+ "results": [
+ {"id": 777, "type": "artist", "title": "Nihilist", "uri": "/artist/777-Nihilist"},
+ {"id": 555501, "type": "artist", "title": "Nihil (5)", "uri": "/artist/555501-Nihil-5"},
+ {"id": 12, "type": "artist", "title": "Nihil", "uri": "/artist/12-Nihil"},
+ {"id": 12, "type": "artist", "title": "Nihil", "uri": "/artist/12-Nihil"},
+ {"id": 99, "type": "label", "title": "Nihil Records"},
+ {"id": "x", "type": "artist", "title": "Broken"},
+ ],
+}
+ARTIST_NIHIL_PLAIN = {"id": 12, "name": "Nihil", "profile": "German industrial project."}
+
+ANSWERS = {
+ "database/search?type=artist": SEARCH_NIHIL,
+ "artists/555501/releases": ARTIST_NIHIL_RELEASES,
+ "artists/555501": ARTIST_NIHIL,
+ "artists/12": ARTIST_NIHIL_PLAIN,
+ "artists/777": {"id": 777, "name": "Nihilist", "profile": ""},
+}
+
+
+@pytest.fixture(autouse=True)
+def _limiter(monkeypatch):
+ ratelimit.DISCOGS.reset()
+ monkeypatch.setattr(ratelimit.DISCOGS, "interval", 0.0)
+ monkeypatch.setattr(discogs, "_sleep", lambda s: None)
+ yield
+ ratelimit.DISCOGS.reset()
+
+
+@pytest.fixture
+def client():
+ from app.main import app
+
+ with TestClient(app) as c:
+ yield c
+
+
+@pytest.fixture
+def fake(monkeypatch):
+ answers = FakeDiscogs(ANSWERS)
+ monkeypatch.setattr(discogs, "_send", answers)
+ return answers
+
+
+def test_search_lists_candidates_exact_names_first(client, fake):
+ settings.set_discogs_token(TOKEN)
+ r = client.get("/api/discogs/artist", params={"q": "NIHIL"})
+ assert r.status_code == 200
+ candidates = r.json()["candidates"]
+ # Named exactly "Nihil" (the number set aside) first, in Discogs' order;
+ # the label, the duplicate and the broken id are gone.
+ assert [c["id"] for c in candidates] == [555501, 12, 777]
+ assert candidates[0]["name"] == "Nihil (5)"
+ assert candidates[0]["profile"].startswith("Portuguese sludge band from Porto")
+ assert candidates[1]["profile"] == "German industrial project."
+ assert candidates[2]["profile"] == ""
+ assert all(set(c) == {"id", "name", "profile"} for c in candidates)
+ assert TOKEN not in r.text
+ search = next(q for q in fake.requests if "database/search" in q.full_url)
+ query = dict(urllib.parse.parse_qsl(urllib.parse.urlsplit(search.full_url).query))
+ assert query["type"] == "artist"
+ assert query["q"] == "NIHIL"
+ # The token rides in the header only, never in a URL.
+ assert all(TOKEN not in q.full_url for q in fake.requests)
+ assert all(q.get_header("Authorization") == f"Discogs token={TOKEN}" for q in fake.requests)
+
+
+def test_search_with_nothing_found_is_an_empty_list(client, monkeypatch):
+ settings.set_discogs_token(TOKEN)
+ monkeypatch.setattr(discogs, "_send", FakeDiscogs({"database/search": {"results": []}}))
+ r = client.get("/api/discogs/artist", params={"q": "Nobody At All"})
+ assert r.status_code == 200
+ assert r.json() == {"candidates": []}
+
+
+def test_search_without_a_token_is_404_and_asks_nothing(client, fake):
+ r = client.get("/api/discogs/artist", params={"q": "Nihil"})
+ assert r.status_code == 404
+ assert fake.requests == []
+
+
+@pytest.mark.parametrize(
+ "q",
+ ["", " ", "x" * 101, "Nihil\x00", "Nihil", "a\nb"],
+)
+def test_search_rejects_a_bad_name(client, fake, q):
+ settings.set_discogs_token(TOKEN)
+ r = client.get("/api/discogs/artist", params={"q": q})
+ assert r.status_code == 422
+ assert r.json() == {"detail": "invalid name"}
+ assert fake.requests == []
+
+
+def test_search_with_discogs_offline_is_503_without_the_token(client):
+ settings.set_discogs_token(TOKEN)
+ r = client.get("/api/discogs/artist", params={"q": "Nihil"})
+ assert r.status_code == 503
+ assert TOKEN not in r.text
+
+
+def test_search_is_bounded(client, monkeypatch):
+ settings.set_discogs_token(TOKEN)
+ monkeypatch.setattr(discogs_api, "DISCOGS_SEARCH_BUDGET_SEC", 0)
+
+ def slow(*args, cancelled, **kwargs):
+ while not cancelled():
+ time.sleep(0.01)
+ return None
+
+ monkeypatch.setattr(discogs, "search_artists", slow)
+ started = time.monotonic()
+ r = client.get("/api/discogs/artist", params={"q": "Nihil"})
+ assert r.status_code == 503
+ assert time.monotonic() - started < 5
+
+
+def test_the_picked_artist_is_served_in_the_artist_extra_shape(client, fake):
+ settings.set_discogs_token(TOKEN)
+ r = client.get("/api/discogs/artist/555501")
+ assert r.status_code == 200
+ body = r.json()
+ assert set(body) == {
+ "id",
+ "name",
+ "real_name",
+ "profile",
+ "members",
+ "groups",
+ "links",
+ "releases",
+ "url",
+ }
+ assert body["id"] == 555501
+ assert body["name"] == "Nihil"
+ assert body["url"] == "https://www.discogs.com/artist/555501-Nihil-5"
+ assert body["releases"][0] == {"year": "2019", "title": "Barro"}
+ assert TOKEN not in r.text
+
+
+def test_the_picked_artist_without_a_token_is_404(client, fake):
+ assert client.get("/api/discogs/artist/555501").status_code == 404
+ assert fake.requests == []
+
+
+def test_an_unknown_artist_is_404(client, fake):
+ settings.set_discogs_token(TOKEN)
+ r = client.get("/api/discogs/artist/424242")
+ assert r.status_code == 404
+ assert r.json() == {"detail": "no artist details"}
+
+
+@pytest.mark.parametrize("artist_id", ["0", "abc", "-5", "1" * 13, "12.5", "%2e%2e"])
+def test_a_malformed_artist_id_is_404_and_asks_nothing(client, fake, artist_id):
+ settings.set_discogs_token(TOKEN)
+ assert client.get(f"/api/discogs/artist/{artist_id}").status_code == 404
+ assert fake.requests == []
+
+
+@pytest.mark.parametrize("path", ["../../etc/passwd", "..%2F..%2Fetc", "..%2F12"])
+def test_a_traversing_id_never_reaches_discogs(client, fake, path):
+ settings.set_discogs_token(TOKEN)
+ r = client.get(f"/api/discogs/artist/{path}")
+ assert r.status_code in (404, 405)
+ assert fake.requests == []
+
+
+def test_the_picked_artist_is_bounded(client, monkeypatch):
+ settings.set_discogs_token(TOKEN)
+ monkeypatch.setattr(discogs_api, "DISCOGS_SEARCH_BUDGET_SEC", 0)
+
+ def slow(*args, cancelled, **kwargs):
+ while not cancelled():
+ time.sleep(0.01)
+ return None
+
+ monkeypatch.setattr(discogs, "artist_by_id", slow)
+ started = time.monotonic()
+ assert client.get("/api/discogs/artist/555501").status_code == 404
+ assert time.monotonic() - started < 5
From 9e0cda3a6e28e2ccf51a553672c6d604c2b36228 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 29 Sep 2026 09:59:32 +0100
Subject: [PATCH 15/20] fix: keep the title's spacing when an artist's channel
runs the name 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.
---
app/pipeline/audio_tags.py | 24 ++++++++++++++++++++----
tests/test_audio_tags.py | 9 +++++++++
2 files changed, 29 insertions(+), 4 deletions(-)
diff --git a/app/pipeline/audio_tags.py b/app/pipeline/audio_tags.py
index 7ce2ad44..66f9ab7c 100644
--- a/app/pipeline/audio_tags.py
+++ b/app/pipeline/audio_tags.py
@@ -333,6 +333,22 @@ def _channel_keys(channel: str) -> list[str]:
return keys
+def _better_spelling(title_name: str, channel: str) -> str:
+ """The better of two spellings of one name: the channel's when it has the
+ accents a title typed without them lost ("Beyonce" on the channel
+ "Beyoncé") and keeps its words apart; the title's otherwise. A channel
+ handle runs the words together ("NataliaKukulska" for the title's
+ "Natalia Kukulska"), and a name with its spaces gone finds nothing on
+ MusicBrainz or LRCLIB."""
+
+ def accents(text: str) -> int:
+ return sum(1 for ch in text if not ch.isascii())
+
+ if accents(channel) > accents(title_name) and len(channel.split()) >= len(title_name.split()):
+ return channel
+ return title_name
+
+
def _artist_from_channel(info: dict[str, Any]) -> tuple[str, str] | None:
"""(artist, title) from the channel, only when something corroborates it.
@@ -363,10 +379,10 @@ def _artist_from_channel(info: dict[str, Any]) -> tuple[str, str] | None:
if prefix and _name_key(prefix) in keys:
rest = video_title[match.end() :].strip()
if rest:
- # The channel's own spelling when it is the name itself
- # (a title typed without its accents), else the title's
- # ("Nirvana", not "NirvanaVEVO").
- return (channel if _name_key(prefix) == keys[0] else prefix), rest
+ # The title's spelling ("Nirvana", not "NirvanaVEVO"),
+ # unless the channel is the name itself spelt better.
+ same = _name_key(prefix) == keys[0]
+ return (_better_spelling(prefix, channel) if same else prefix), rest
break
return None
diff --git a/tests/test_audio_tags.py b/tests/test_audio_tags.py
index 700c7a4f..7d9251e4 100644
--- a/tests/test_audio_tags.py
+++ b/tests/test_audio_tags.py
@@ -163,6 +163,15 @@ def test_a_label_channel_is_not_the_artist_of_what_it_uploads():
"Dawid Podsiadlo - Małomiasteczkowy",
{"artist": "Dawid Podsiadło", "title": "Małomiasteczkowy"},
),
+ # A handle that runs the words together keeps the title's spacing:
+ # "NataliaKukulska" found nothing on MusicBrainz or LRCLIB.
+ (
+ "NataliaKukulska",
+ "Natalia Kukulska - W Biegu",
+ {"artist": "Natalia Kukulska", "title": "W Biegu"},
+ ),
+ # Accents without the spaces: the readable name wins.
+ ("DawidPodsiadło", "Dawid Podsiadlo - Małomiasteczkowy", {"artist": "Dawid Podsiadlo"}),
# A suffix that is part of the name is not lost.
("Roxy Music", "Roxy Music - More Than This", {"artist": "Roxy Music"}),
],
From f72527fe6e97e37b0c59f7961cb872d20dd1e240 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 29 Sep 2026 09:59:35 +0100
Subject: [PATCH 16/20] fix: give a lone letter its accents back between two
matched words (#703)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
"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".
---
app/pipeline/lyrics_repair.py | 22 ++++++++++++++++++++--
tests/test_lyrics_repair.py | 11 +++++++++++
2 files changed, 31 insertions(+), 2 deletions(-)
diff --git a/app/pipeline/lyrics_repair.py b/app/pipeline/lyrics_repair.py
index 6d10acf0..be2cbd37 100644
--- a/app/pipeline/lyrics_repair.py
+++ b/app/pipeline/lyrics_repair.py
@@ -228,8 +228,23 @@ def restores(self, reference: str, word: str) -> bool:
return True
return word == "".join(ch if ch.isascii() else _base(ch) for ch in reference)
+ def restores_short(self, reference: str, word: str) -> bool:
+ """Whether ``word`` is shorter than restores() allows yet strips
+ down from ``reference``: "e" from "że". Taken only between two
+ neighbours paired in order, where no sung vowel is heard as "że"."""
+ return (
+ reference != word
+ and not reference.isascii()
+ and _strips_to(reference, word)
+ and not self.restores(reference, word)
+ )
+
def matches(self, reference: str, word: str) -> bool:
- return reference == word or self.restores(reference, word)
+ return (
+ reference == word
+ or self.restores(reference, word)
+ or self.restores_short(reference, word)
+ )
def _align(words: list[str], reference: list[str], stripper: _Stripper) -> list[tuple[int, int]]:
@@ -320,7 +335,10 @@ def restore_letters(text: str, reference: list[str]) -> Restoration:
anchored = paired.get(i - 1) == j - 1 or paired.get(i + 1) == j + 1
if anchored or ref[j] == lower[i]:
spelled.setdefault(lower[i], Counter())[ref[j]] += 1
- if anchored and stripper.restores(ref[j], lower[i]):
+ both = paired.get(i - 1) == j - 1 and paired.get(i + 1) == j + 1
+ if (anchored and stripper.restores(ref[j], lower[i])) or (
+ both and stripper.restores_short(ref[j], lower[i])
+ ):
replaced[i] = ref[j]
for i, word in enumerate(lower):
if i in replaced or not word.isascii():
diff --git a/tests/test_lyrics_repair.py b/tests/test_lyrics_repair.py
index e09b0a32..e086cb30 100644
--- a/tests/test_lyrics_repair.py
+++ b/tests/test_lyrics_repair.py
@@ -101,6 +101,17 @@ def test_a_lone_letter_is_not_given_any():
assert restore_letters(text, ["Że", "tak", "niewiele", "jest"]).text == text
+def test_a_lone_letter_between_two_paired_neighbours_gets_them_back():
+ # Found in W biegu: "Ciągle się dziwię / E tak niewiele jest".
+ text = "[00:01.00]Ciagle sie dziwie\n[00:04.00]E tak niewiele jest\n"
+ heard = ["ciągle", "się", "dziwię", "że", "tak", "niewiele", "jest"]
+ mended = restore_letters(text, heard).text
+ assert "[00:04.00]Że tak niewiele jest" in mended
+ # With only one neighbour paired it stays as it is.
+ lone = "[00:01.00]Ciagle sie dziwie\n[00:04.00]E niewiele jest\n"
+ assert "E niewiele" in restore_letters(lone, ["dziwię", "że", "tak", "jest"]).text
+
+
def test_a_pair_with_no_neighbour_paired_does_not_count_on_its_own():
# Two words that merely strip down alike, far apart and alone, are not
# the same word sung.
From d6337e075318f9ec5acadd98caad1a6073c19fac Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 29 Sep 2026 09:59:37 +0100
Subject: [PATCH 17/20] fix: show every Chinese album title in simplified
script to a simplified reader (#709)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
static/js/artistLookup.js | 11 ++++++-----
tests/js/artist-lookup.test.mjs | 4 ++++
2 files changed, 10 insertions(+), 5 deletions(-)
diff --git a/static/js/artistLookup.js b/static/js/artistLookup.js
index 8d776e45..39c91087 100644
--- a/static/js/artistLookup.js
+++ b/static/js/artistLookup.js
@@ -796,18 +796,19 @@ function sparqlQuery(qid, languages) {
return url.toString();
}
-// Chinese in traditional script, or in the plain "zh" that mixes both.
-const TRADITIONAL = /^zh(-(hant|tw|hk|mo))?$/;
+// Any Chinese: a "zh-hans" label is sometimes typed in traditional script,
+// and folding one already simplified changes nothing.
+const CHINESE = /^zh(-|$)/;
/**
* A term in the reader's script: for a simplified-Chinese reader
- * (`simplify`), one Wikidata only has in traditional or mixed Chinese is
- * folded to simplified, so Mayday's first album is 五月天第一张创作专辑 and
+ * (`simplify`), one Wikidata has in traditional or mixed Chinese, under any
+ * Chinese code, is folded to simplified, so Mayday's first album is 五月天第一张创作专辑 and
* not 五月天第一張創作專輯. Only Chinese is folded: the same characters in a
* Japanese title are Japanese, and stay as they are.
*/
function inScript(value, language, simplify) {
- return simplify && TRADITIONAL.test(String(language || "").toLowerCase()) ? toSimplified(value) : value;
+ return simplify && CHINESE.test(String(language || "").toLowerCase()) ? toSimplified(value) : value;
}
/** The first of `chain` an entity has a label in, or "". */
diff --git a/tests/js/artist-lookup.test.mjs b/tests/js/artist-lookup.test.mjs
index 87921c89..0770c3b1 100644
--- a/tests/js/artist-lookup.test.mjs
+++ b/tests/js/artist-lookup.test.mjs
@@ -381,6 +381,10 @@ check(
same(folded.albums.map((a) => a.title), ["五月天第一张创作专辑", "裏町酒場", "范特西"]),
JSON.stringify(folded.albums),
);
+check(
+ "a simplified label typed in traditional script is folded too",
+ albumList({ results: { bindings: [zhRow("Q1", "11月的蕭邦", "zh-hans")] } }, { simplify: true }).albums[0].title === "11月的萧邦",
+);
check("nobody else has titles folded", albumList({ results: { bindings: [zhRow("Q1", "五月天第一張創作專輯", "zh")] } }).albums[0].title === "五月天第一張創作專輯");
check("an answer with no studio column is all studio albums", albumList({ results: { bindings: [row("Q9", "Awake", "1994")] } }).albums.length === 1);
From 7e74dd2306db5280a483040bcb6e9a604410d971 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 29 Sep 2026 09:59:41 +0100
Subject: [PATCH 18/20] docs: say why the link probe catches BaseException
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.
---
app/pipeline/download.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/app/pipeline/download.py b/app/pipeline/download.py
index e7e8c9cc..27ce23be 100644
--- a/app/pipeline/download.py
+++ b/app/pipeline/download.py
@@ -113,6 +113,9 @@ def _cancellable(job: Job, fn):
def run() -> None:
try:
result["value"] = fn()
+ # BaseException on purpose: it is raised again on the job's thread
+ # below. Narrower, a SystemExit from yt-dlp would end this thread
+ # quietly and the import would go on with no answer.
except BaseException as exc:
result["error"] = exc
From b4b132e8fe164c3d2ba08d5556034f000a8fdc9e Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 29 Sep 2026 10:15:48 +0100
Subject: [PATCH 19/20] feat: time lyrics line by line from the vocals, and let
the user sync 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.
---
app/api/jobs.py | 201 ++-
app/core/config.py | 24 +
app/pipeline/lyrics_lookup.py | 46 +-
app/pipeline/lyrics_retime.py | 2115 +++++++++++++++++++++++++
app/pipeline/runner.py | 7 +
app/pipeline/transcribe.py | 46 +-
app/pipeline/transcribe_worker.py | 79 +-
static/css/daw.css | 90 ++
static/index.html | 17 +
static/js/i18n.js | 532 +++++++
static/js/lyrics.js | 749 ++++++++-
static/js/lyricsLane.js | 220 +++
static/js/lyricsLookup.js | 36 +-
static/js/lyricsSync.js | 302 ++++
static/js/transport.js | 3 +
tests/acceptance/README.md | 35 +-
tests/acceptance/acceptance.spec.mjs | 425 ++++-
tests/acceptance/checks.mjs | 4 +
tests/acceptance/harness.mjs | 53 +
tests/acceptance/helpers.mjs | 65 +
tests/acceptance/report.mjs | 2 +
tests/e2e/lyrics-from-server.spec.mjs | 2 +-
tests/e2e/lyrics-sync.spec.mjs | 512 ++++++
tests/js/lyrics-sync.test.mjs | 187 +++
tests/test_lyrics_lookup.py | 34 +-
tests/test_lyrics_retime.py | 849 ++++++++++
tests/test_lyrics_retime_api.py | 347 ++++
27 files changed, 6898 insertions(+), 84 deletions(-)
create mode 100644 app/pipeline/lyrics_retime.py
create mode 100644 static/js/lyricsLane.js
create mode 100644 static/js/lyricsSync.js
create mode 100644 tests/e2e/lyrics-sync.spec.mjs
create mode 100644 tests/js/lyrics-sync.test.mjs
create mode 100644 tests/test_lyrics_retime.py
create mode 100644 tests/test_lyrics_retime_api.py
diff --git a/app/api/jobs.py b/app/api/jobs.py
index bc36ff4a..d107644f 100644
--- a/app/api/jobs.py
+++ b/app/api/jobs.py
@@ -24,8 +24,10 @@
JOBS_DIR,
LYRICS_LOOKUP_BUDGET_SEC,
LYRICS_OFFSET_MAX_SEC,
+ LYRICS_TIMED_MAX_CHARS,
MAX_PENDING_UPLOAD_JOBS,
MAX_PENDING_URL_JOBS,
+ RETIME_DELETE_WAIT_SEC,
STEM_NAMES,
TIMEOUT_FETCH_TAGS,
TIMEOUT_IDENTIFY_BACKFILL,
@@ -33,7 +35,7 @@
TIMEOUT_WORK_BACKFILL,
ffprobe_executable,
)
-from app.core.models import Job, _set
+from app.core.models import Job, JobCancelled, _set
from app.core.registry import all_jobs as registry_all_jobs
from app.core.registry import get as registry_get
from app.core.registry import get_proc as registry_get_proc
@@ -60,6 +62,7 @@
from app.pipeline.lyrics_align import detect_offset
from app.pipeline.lyrics_lookup import (
_TEXT_MAX_CHARS,
+ NotTheseLyrics,
build_query,
candidates_path,
copy_lyrics,
@@ -71,7 +74,20 @@
read_lyrics,
saved_lyrics_belong,
set_lyrics_offset,
+ set_user_synced,
)
+from app.pipeline.lyrics_retime import (
+ RetimeFailed,
+ RetimeRun,
+ cancel_run,
+ claim_run,
+ current_run,
+ forget_run,
+ lyric_pairs,
+ retime_job,
+ wait_run_ended,
+)
+from app.pipeline.lyrics_retime import saved_view as saved_retime_view
from app.pipeline.runner import _pipeline_lock
from app.pipeline.vocal_split import split_vocals
from app.pipeline.work_lookup import find_work, might_have_work
@@ -433,6 +449,13 @@ def cancel_job(job_id: str) -> dict:
proc = registry_get_proc(job_id)
if proc is not None and proc.poll() is None:
proc.terminate()
+ # A line-by-line timing of its lyrics (POST .../lyrics/retime) stops
+ # the same way: the run sees its own flag, never the job's, and
+ # leaves the lyrics as they were.
+ elif cancel_run(job_id):
+ proc = registry_get_proc(job_id)
+ if proc is not None and proc.poll() is None:
+ proc.terminate()
return job.to_state()
job.cancel_requested = True
@@ -1193,16 +1216,25 @@ def _synced_lyrics(job_dir: Path) -> dict:
_LYRICS_BODY_MAX_BYTES = 4 * _TEXT_MAX_CHARS + 1024
-async def _lyrics_body(request: Request, model: type[BaseModel], empty_ok: bool = False):
+# A manual timing's body: LYRICS_TIMED_MAX_CHARS of LRC, as UTF-8.
+_TIMED_BODY_MAX_BYTES = 4 * LYRICS_TIMED_MAX_CHARS + 1024
+
+
+async def _lyrics_body(
+ request: Request,
+ model: type[BaseModel],
+ empty_ok: bool = False,
+ max_bytes: int = _LYRICS_BODY_MAX_BYTES,
+):
"""A lyrics edit's JSON body as ``model``: None for no body at all when
``empty_ok``. 413 past _LYRICS_BODY_MAX_BYTES, else 422 for anything that
is not the model, with NaN and Infinity refused while parsing (see
_reject_non_finite) and nothing sent back of what was submitted."""
declared = request.headers.get("content-length", "")
- if declared.isdigit() and int(declared) > _LYRICS_BODY_MAX_BYTES:
+ if declared.isdigit() and int(declared) > max_bytes:
raise HTTPException(status_code=413, detail="body too large")
raw = await request.body()
- if len(raw) > _LYRICS_BODY_MAX_BYTES:
+ if len(raw) > max_bytes:
raise HTTPException(status_code=413, detail="body too large")
if not raw.strip() and empty_ok:
return None
@@ -1274,6 +1306,157 @@ async def align_lyrics_route(job_id: str, request: Request) -> dict:
return {"confident": True, "offset_sec": shift}
+# Line-by-line timing runs in flight, held so the loop does not drop them.
+_retime_tasks: set[asyncio.Task] = set()
+
+
+def _retime_ready(job_dir: Path, lyrics: dict | None) -> None:
+ """Blocking: 404 without lyrics (kept, or given), 409 when they have no
+ lines to time (an instrumental) or there is no vocals stem to time them
+ to."""
+ if lyrics is None:
+ lyrics = read_lyrics(job_dir) if lyrics_path(job_dir).is_file() else None
+ if lyrics is None:
+ raise HTTPException(status_code=404, detail="no lyrics")
+ if not lyric_pairs(lyrics):
+ raise HTTPException(status_code=409, detail="lyrics have no lines")
+ vocals = (job_dir / "stems" / "vocals.wav").resolve()
+ if not vocals.is_relative_to(JOBS_DIR.resolve()) or not vocals.is_file():
+ raise HTTPException(status_code=409, detail="no vocals stem")
+
+
+async def _retime_task(job: Job, job_dir: Path, run: RetimeRun, lyrics: dict | None) -> None:
+ """One on-demand run: behind _pipeline_lock, so it never shares the GPU
+ with a separation, and stopped by a cancel or the job's deletion."""
+
+ def cancelled() -> bool:
+ return run.cancel.is_set() or registry_get(job.id) is not job
+
+ def report(share: float) -> None:
+ run.progress = share
+
+ try:
+ async with _pipeline_lock:
+ run.started = True
+ if cancelled():
+ raise JobCancelled()
+ outcome = await asyncio.to_thread(
+ retime_job, job, job_dir, cancelled=cancelled, report=report, lyrics=lyrics
+ )
+ run.outcome = outcome
+ run.state = outcome.state
+ except JobCancelled:
+ run.state = "idle"
+ except RetimeFailed as exc:
+ logger.info("[%s] lyrics not timed: %s", job.id, exc)
+ run.state = "failed"
+ except Exception:
+ logger.exception("[%s] lyrics timing failed", job.id)
+ run.state = "failed"
+ finally:
+ run.ended.set()
+
+
+class LyricsRetimeBody(BaseModel):
+ """Lyrics the browser keeps (found by the tab's own LRCLIB lookup), to
+ time without saving anything. Omitted for the lyrics the server keeps."""
+
+ synced: str | None = Field(default=None, max_length=_TEXT_MAX_CHARS)
+ plain: str | None = Field(default=None, max_length=_TEXT_MAX_CHARS)
+
+
+@router.post("/{job_id}/lyrics/retime")
+async def start_lyrics_retime(job_id: str, request: Request) -> JSONResponse:
+ """Time the track's lyrics line by line to its vocals
+ (app/pipeline/lyrics_retime.py), in the background: 202 with the run's
+ state, which GET .../lyrics/retime then follows.
+
+ Without a body, the lyrics the server keeps: the result is kept in
+ lyrics.json as "aligned" when it is good enough, and nothing changes
+ when it is not ("unsure"). With {"synced", "plain"}, lyrics the browser
+ keeps: the result comes back as "aligned" in GET's "done" answer and
+ nothing is saved but the transcript.
+
+ Runs behind the pipeline lock, after any import in progress, and takes a
+ transcription of the vocals the first time (about 30 s on a GPU, a few
+ minutes on a CPU; asked for here, it runs on either). One run at a time
+ per track: 409 while one is running. POST .../cancel stops it."""
+ job, job_dir = _lyrics_job(job_id)
+ body = await _lyrics_body(request, LyricsRetimeBody, empty_ok=True)
+ if job.status != "done":
+ raise HTTPException(status_code=409, detail="job not ready")
+ lyrics = None
+ if body is not None and (body.synced or body.plain):
+ lyrics = {"synced": body.synced or "", "plain": body.plain or ""}
+ await asyncio.to_thread(_retime_ready, job_dir, lyrics)
+ run = claim_run(job_id)
+ if run is None:
+ raise HTTPException(status_code=409, detail="lyrics timing already running")
+ task = asyncio.create_task(_retime_task(job, job_dir, run, lyrics))
+ _retime_tasks.add(task)
+ task.add_done_callback(_retime_tasks.discard)
+ return JSONResponse(run.view(), status_code=202)
+
+
+@router.get("/{job_id}/lyrics/retime")
+def get_lyrics_retime(job_id: str) -> dict:
+ """Where the line-by-line timing of the track's lyrics stands:
+ {"state": "idle" | "running" | "done" | "failed" | "unsure"}, with
+ "progress" (0 to 1) while running and "matched" (the share of the
+ lyrics heard), "lines_matched" and "lines" once there is a result; for
+ lyrics the browser sent, "done" carries the timing itself, "aligned"."""
+ job, job_dir = _lyrics_job(job_id)
+ run = current_run(job_id)
+ if run is not None and (
+ run.state not in ("done", "idle")
+ or (run.outcome is not None and run.outcome.aligned is not None)
+ ):
+ return run.view()
+ entry = read_lyrics(job_dir) if lyrics_path(job_dir).is_file() else None
+ saved = saved_retime_view(entry)
+ if run is not None and run.state == "idle" and saved["state"] != "done":
+ return run.view()
+ return saved
+
+
+class UserSyncedBody(BaseModel):
+ """The user's own timing of the lyrics, from the sync editor: LRC whose
+ lines are the lyrics' own, one to one."""
+
+ synced: str = Field(min_length=1, max_length=LYRICS_TIMED_MAX_CHARS)
+
+
+@router.put("/{job_id}/lyrics/user-synced")
+async def put_user_synced(job_id: str, request: Request) -> dict:
+ """Keep the user's own timing of the track's lyrics, shown before any
+ other (see app/pipeline/lyrics_retime.py). 422 when it is not LRC, its
+ stamps go back in time, or its lines are not exactly the lyrics' own:
+ it can time them, never change them."""
+ job, job_dir = _lyrics_job(job_id)
+ body = await _lyrics_body(request, UserSyncedBody, max_bytes=_TIMED_BODY_MAX_BYTES)
+ try:
+ entry = await asyncio.to_thread(set_user_synced, job, job_dir, body.synced)
+ except NotTheseLyrics as exc:
+ raise HTTPException(status_code=422, detail="not a timing of these lyrics") from exc
+ if entry is None:
+ if not lyrics_path(job_dir).is_file():
+ raise HTTPException(status_code=404, detail="no lyrics")
+ raise HTTPException(status_code=500, detail="could not save lyrics")
+ return {"user_synced": entry["user_synced"]}
+
+
+@router.delete("/{job_id}/lyrics/user-synced")
+async def delete_user_synced(job_id: str) -> dict:
+ """Drop the user's own timing of the track's lyrics. Idempotent."""
+ job, job_dir = _lyrics_job(job_id)
+ if not lyrics_path(job_dir).is_file():
+ raise HTTPException(status_code=404, detail="no lyrics")
+ entry = await asyncio.to_thread(set_user_synced, job, job_dir, None)
+ if entry is None:
+ raise HTTPException(status_code=500, detail="could not save lyrics")
+ return {"user_synced": None}
+
+
# Upper bound on an edited grid. A 20-minute track at 300 BPM is ~6000 beats;
# 20000 leaves generous headroom while refusing a payload crafted to exhaust
# memory or disk.
@@ -1517,6 +1700,16 @@ def delete_job(job_id: str) -> dict[str, str]:
raise HTTPException(status_code=404, detail="job not found")
if job.status not in ("done", "error", "cancelled"):
raise HTTPException(status_code=409, detail="job is still running")
+ # A timing of its lyrics in flight stops rather than write into a
+ # directory being removed (it also checks the job is still registered).
+ if cancel_run(job_id) and job.vocal_split != "running":
+ proc = registry_get_proc(job_id)
+ if proc is not None and proc.poll() is None:
+ proc.terminate()
+ # The worker may still have the vocals open while it stops.
+ if not wait_run_ended(job_id, RETIME_DELETE_WAIT_SEC):
+ logger.warning("[%s] lyrics timing still stopping; removing files anyway", job_id)
+ forget_run(job_id)
removed = _rmtree_job(job_id)
# Recorded whether or not the files went away. The user asked for this job
# to be gone; without the record, a directory that outlived the delete is
diff --git a/app/core/config.py b/app/core/config.py
index 29095315..104f67cb 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -579,10 +579,34 @@ def _musicbrainz_user_agent() -> str:
# later or earlier: a copy of the right length can still be timed to another
# cut (Green Day "Basket Case": the video's lines start 16 s after the copy's).
LYRICS_ALIGN_EXACT_MIN_SHIFT_SEC = 1.0
+# How long deleting a job waits for its lyrics timing to stop and let go of
+# the vocals stem (the worker is terminated, then killed after 5 s).
+RETIME_DELETE_WAIT_SEC = 15
# How far the Lyrics tab's Align panel may move a track's lyrics, either way
# ("offset_sec" in lyrics.json, POST /api/jobs/{id}/lyrics/offset): a long
# intro or a medley's later verse, and no further.
LYRICS_OFFSET_MAX_SEC = 600
+# Lyrics timed line by line to the singing (app/pipeline/lyrics_retime.py).
+# The words Whisper heard in the vocals are kept beside lyrics.json in this
+# file, so timing them again costs no second pass.
+LYRICS_TRANSCRIPT_FILE = "lyrics_transcript.json"
+# The result is kept only when at least this share of the lyrics' syllables
+# was matched to heard words, and this share of the lines anchored where they
+# are sung. Measured on 58 songs in ten languages: every one timed well
+# matched 0.48 or more and anchored 0.52 or more of its lines; the timings
+# that must not be kept (a romanised Japanese copy, a remix's lyrics, German
+# transcribed as English, a transcription led astray by a prompt of the
+# lyrics) matched 0.00 to 0.26 and anchored 0.00 to 0.32.
+LYRICS_RETIME_MIN_MATCHED = 0.35
+LYRICS_RETIME_MIN_LINES_SHARE = 0.45
+# The alignment's size cap, lyric tokens times heard tokens: its back
+# pointers take two bytes a cell. A long Chinese song is about 1500 x 1500.
+LYRICS_RETIME_MAX_CELLS = 12_000_000
+# A manual timing (PUT .../lyrics/user-synced) has at most this many lines,
+# and it or a line-by-line timing at most this many characters: word stamps
+# add ten a word to lyrics of up to 100000.
+LYRICS_USER_SYNCED_MAX_LINES = 2000
+LYRICS_TIMED_MAX_CHARS = 400_000
# Lyrics that lost their letters outside ASCII, mended from a reference that
# has them (app/pipeline/lyrics_repair.py). A copy with no intact twin on
# LRCLIB is checked against the audio only when it has at least this many
diff --git a/app/pipeline/lyrics_lookup.py b/app/pipeline/lyrics_lookup.py
index 7c4c5fa8..85f9060c 100644
--- a/app/pipeline/lyrics_lookup.py
+++ b/app/pipeline/lyrics_lookup.py
@@ -94,6 +94,7 @@
from app.pipeline.artist_lookup import _ssl_context, artist_name_key
from app.pipeline.lyrics_align import align_lyrics
from app.pipeline.lyrics_repair import restore_letters, words_of
+from app.pipeline.lyrics_retime import clean_aligned, clean_user_synced, lyric_line_texts
from app.pipeline.name_aliases import artist_aliases, fold, has_cjk, name_key, name_weight
from app.pipeline.title_parse import TitleReading, coverage, resolvable, song_key, work_hint
@@ -919,8 +920,14 @@ def held(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
break
if chosen is None and duration and names:
# (c) The name alone, which answers with anybody's song of that
- # name: only one by a name the track is known by is kept.
- rows = held(search({"q": query.track}))
+ # name: only one by a name the track is known by is kept. A
+ # version of another length is still this artist's song, kept to
+ # be timed from the vocals as one the artist search found would be
+ # (LRCLIB's artist search can miss a name it holds under the name
+ # search).
+ rows = search({"q": query.track})
+ song.extend(rows)
+ rows = held(rows)
chosen = rows[0] if rows else None
if chosen is None and query.artist:
# (d) The artist's other names. First what the searches above
@@ -1093,6 +1100,17 @@ def clean_lyrics(value: Any) -> dict[str, Any] | None:
offset = clean_offset(value.get("offset_sec"))
if offset is not None:
entry["offset_sec"] = offset
+ # Timing made for these lines by lyrics_retime.py, and the user's own
+ # from the sync editor: each dropped when it is malformed or is not of
+ # these lines any more (another version, letters mended since).
+ if "aligned" in value or "user_synced" in value:
+ lines = lyric_line_texts(entry)
+ aligned = clean_aligned(value.get("aligned"), lines)
+ if aligned is not None:
+ entry["aligned"] = aligned
+ user = clean_user_synced(value.get("user_synced"), lines)
+ if user is not None:
+ entry["user_synced"] = user
others = value.get("others")
cleaned = [_clean_version(o) for o in (others if isinstance(others, list) else [])]
entry["others"] = [o for o in cleaned if o is not None][:LYRICS_OTHERS_MAX]
@@ -1221,6 +1239,30 @@ def set_lyrics_offset(job: Job, job_dir: Path, offset: float) -> dict[str, Any]
return entry if write_lyrics(job, job_dir, entry) else None
+class NotTheseLyrics(ValueError):
+ """A manual timing whose lines are not the lyrics' own, or not LRC."""
+
+
+def set_user_synced(job: Job, job_dir: Path, synced: str | None) -> dict[str, Any] | None:
+ """Keep ``synced`` as the user's own timing of the job's lyrics (the sync
+ editor), or drop it with None, and return the entry as written. None when
+ the job has no lyrics or they could not be written. Raises
+ NotTheseLyrics when ``synced`` is not a timing of exactly their lines
+ (lyrics_retime.clean_user_synced): nothing else can be put in front of
+ the page this way."""
+ with _OFFSET_LOCK:
+ entry = read_lyrics(job_dir)
+ if entry is None:
+ return None
+ entry = {k: v for k, v in entry.items() if k != "user_synced"}
+ if synced is not None:
+ cleaned = clean_user_synced(synced, lyric_line_texts(entry))
+ if cleaned is None:
+ raise NotTheseLyrics("not a timing of these lyrics")
+ entry["user_synced"] = cleaned
+ return entry if write_lyrics(job, job_dir, entry) else None
+
+
def copy_lyrics(src_dir: Path, dest_dir: Path) -> bool:
"""Copy a job's lyrics.json and candidates file to another job of the
same recording (a re-split), with any alignment the user gave the lyrics
diff --git a/app/pipeline/lyrics_retime.py b/app/pipeline/lyrics_retime.py
new file mode 100644
index 00000000..b10471d4
--- /dev/null
+++ b/app/pipeline/lyrics_retime.py
@@ -0,0 +1,2115 @@
+"""Synced lyrics timed line by line to the track's own singing.
+
+lyrics_align.py moves a whole LRC by one amount, which is enough when the
+copy is the same recording with more or less lead-in. It is not enough when
+LRCLIB's copy was timed to another cut or arrangement: a verse the track
+lacks, a chorus sung once more, a live take at another pace, or a copy whose
+own stamps were never right. Here each line is found where it is sung.
+
+How, for a vocals stem and the lyrics' lines:
+
+1. Whisper hears the vocals with word timestamps, in the worker process that
+ transcribe.py runs (transcribe_worker.py), in the lyrics' language when
+ their text shows it (script_language, text_language). Its transcript is
+ kept beside the lyrics (LYRICS_TRANSCRIPT_FILE), so timing them again, or
+ after another version is picked, costs no second pass; a transcript
+ mend_lyrics already made is used the same way. The lyrics are not given
+ to Whisper as a prompt: measured on the benchmark, a prompt made it copy
+ the lyrics' own misspellings and lose whole lines (Dawid Podsiadlo
+ "Malomiasteczkowy": 99% of the lyrics matched without, 27% with), and it
+ ran three to four times as long.
+2. The lyrics' words and the heard words become tokens, script by script:
+ a Latin (or Cyrillic, Greek...) word folded for case and accents, one
+ token per Chinese character or kana (katakana as hiragana, traditional as
+ simplified, zh_variants.py), one per Hangul syllable. Words in brackets
+ in a line with words outside them are a backing vocal sung over the lead
+ and count half: they take the echo Whisper hears after the lead's words,
+ never the lead's own. Credits at the top of a copy ("作词 : ...") and
+ pause marks ("♪") are not sung and are not timed.
+3. A monotonic alignment (a dynamic program with free gaps on both sides and
+ a bonus for runs of consecutive matches) pairs lyric tokens with heard
+ ones: exact tokens score 1, Latin words close by edit distance and Hangul
+ syllables one letter off (a misheard word) a little less. Free gaps are
+ what let a verse the track lacks, or a chorus sung once more than the text
+ has, go unmatched rather than pull the rest out of place; monotonic order
+ keeps a repeated chorus with its own occurrence, and a small cost for
+ each heard word skipped between matches keeps it with its first one.
+4. A line is anchored where its first matched word starts, when enough of it
+ matched in a run and the voice is really there (the vocal envelope,
+ lyrics_align._envelope): a word heard in a silence anchors nothing. Whisper
+ often starts the first word of a line on the held end of the line before;
+ the start is moved to where the voice comes back after a pause, or rises
+ (or, for a word held long, rises again twice as sharply), within that
+ word. When Whisper missed the first words of a phrase and heard nothing
+ else in it, the line starts where the phrase does.
+5. Anchors the LRC's own stamps show are out of place (a phrase matched where
+ it is sung again) are dropped, and when the stamps follow the anchors, the
+ alignment runs again with each line's words looked for only near where
+ the stamps and the anchors around it put it.
+6. The lines between anchors are placed with the song's own timing: by the
+ LRC's stamps mapped between the two anchors when those agree with them,
+ else spread over the singing between (not linearly across an instrumental
+ gap), each start put on the nearest onset of the voice. A line stamped the
+ same as the one before it (a translation) is shown with that one.
+7. A run of lines still not found where there is singing (a chorus sung
+ once more at the end, which Whisper skips) is heard again on its own
+ (the worker's --clips), and the lines it then finds are kept when they
+ sit where the copy's stamps and the lines found around them say.
+8. Words inside a line are stamped where they were heard, and the rest
+ spread between those by their syllables.
+
+Measured on the benchmark and left out: the lead vocal
+stem of the lead/backing split (better on 4 of 16 songs, worse on 7, and 2
+to 3 minutes of CPU a song), a band-pass and levelled stem (within noise,
+and fewer words matched), and a spectral-flux onset cue for legato line
+starts (worse in 5 of 10 languages).
+
+The result is kept in lyrics.json as ``aligned``, only when it passes the
+quality gate (retime_gate, no_worse): {"synced": enhanced LRC, "method":
+"whisper", "matched": the share of the lyrics' syllables matched to heard
+words, "lines_matched", "lines", "at": unix time}. The source ``synced`` is
+never rewritten here.
+
+What the page shows, first present wins:
+
+1. ``user_synced``: a full manual timing from the sync editor (PUT
+ .../lyrics/user-synced, validated by clean_user_synced: its lines are the
+ lyrics' own, one to one).
+2. ``aligned.synced``: this module's result.
+3. ``synced`` moved by ``offset_sec``: the source, with the Align panel's
+ whole-song offset.
+
+When it runs: after separation for LRCLIB lyrics whose timing the voice
+does not confirm line for line (a copy of another length, shifted or not,
+or one of the track's length no whole-song shift confirms), under the transcribe_lyrics setting's terms (auto
+means a CUDA job only), inside the pipeline's lock (retime_after_separation);
+and on demand, from POST .../lyrics/retime (app/api/jobs.py), behind the same
+lock.
+"""
+
+from __future__ import annotations
+
+import bisect
+import json
+import logging
+import math
+import re
+import threading
+import time
+import unicodedata
+from collections.abc import Callable
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+
+from app.core.config import (
+ LYRICS_RETIME_MAX_CELLS,
+ LYRICS_RETIME_MIN_LINES_SHARE,
+ LYRICS_RETIME_MIN_MATCHED,
+ LYRICS_TIMED_MAX_CHARS,
+ LYRICS_TRANSCRIPT_FILE,
+ LYRICS_USER_SYNCED_MAX_LINES,
+)
+from app.pipeline.lyrics_align import (
+ _HIT_RISE_DB,
+ _LOUD_PERCENTILE,
+ _RISE_FRAMES,
+ _SILENT_DB,
+ _VOICE_FLOOR_DB,
+ _VOICE_RANGE_DB,
+ _offset,
+ onset_strength,
+)
+from app.pipeline.zh_variants import to_simplified
+
+logger = logging.getLogger("stemdeck.lyrics")
+
+# ── tokens ──
+
+_APOSTROPHES = frozenset("'’`ʼ")
+# Latin letters Unicode does not build from a base letter and an accent.
+_PLAIN_LETTERS = str.maketrans(
+ {"ł": "l", "ø": "o", "đ": "d", "ð": "d", "ß": "ss", "æ": "ae", "œ": "oe", "ı": "i", "þ": "th"}
+)
+_VOWELS = re.compile(r"[aeiouy]+")
+# A small kana or the long-vowel mark belongs to the syllable before it.
+_SMALL_KANA = frozenset("ゃゅょャュョっッーぁぃぅぇぉァィゥェォ")
+
+
+def _han_or_kana(ch: str) -> bool:
+ cp = ord(ch)
+ return (
+ 0x3400 <= cp <= 0x4DBF
+ or 0x4E00 <= cp <= 0x9FFF
+ or 0xF900 <= cp <= 0xFAFF
+ or 0x20000 <= cp <= 0x3FFFF
+ or 0x3040 <= cp <= 0x30FF
+ or 0x31F0 <= cp <= 0x31FF
+ or 0xFF66 <= cp <= 0xFF9F
+ )
+
+
+def _hangul(ch: str) -> bool:
+ return 0xAC00 <= ord(ch) <= 0xD7AF
+
+
+def _cjk(ch: str) -> bool:
+ return _han_or_kana(ch) or _hangul(ch)
+
+
+def _word_char(ch: str) -> bool:
+ return ch.isalnum() and not _cjk(ch)
+
+
+def _latin_key(word: str) -> str:
+ text = unicodedata.normalize("NFKC", word).casefold().translate(_PLAIN_LETTERS)
+ text = unicodedata.normalize("NFKD", text)
+ return "".join(ch for ch in text if unicodedata.category(ch) != "Mn")
+
+
+def _char_key(ch: str) -> str:
+ ch = to_simplified(unicodedata.normalize("NFKC", ch))
+ cp = ord(ch[0]) if ch else 0
+ # Katakana as hiragana: the same sound written either way.
+ if 0x30A1 <= cp <= 0x30F6:
+ return chr(cp - 0x60)
+ return ch
+
+
+def tokens_of(text: str) -> list[tuple[str, int, int, float]]:
+ """The tokens of ``text`` as (key, start, end, syllables), in order, with
+ ``start`` and ``end`` its character offsets: a word of letters and digits
+ (apostrophes inside it dropped, "don't" is "dont"), else one Chinese
+ character, kana or Hangul syllable each."""
+ out: list[tuple[str, int, int, float]] = []
+ i, n = 0, len(text)
+ while i < n:
+ ch = text[i]
+ if _cjk(ch):
+ out.append((_char_key(ch), i, i + 1, 0.3 if ch in _SMALL_KANA else 1.0))
+ i += 1
+ continue
+ if _word_char(ch):
+ j = i
+ while j < n and (
+ _word_char(text[j])
+ or (text[j] in _APOSTROPHES and j + 1 < n and _word_char(text[j + 1]))
+ ):
+ j += 1
+ key = _latin_key("".join(c for c in text[i:j] if c not in _APOSTROPHES))
+ if key:
+ syllables = len(_VOWELS.findall(key)) + sum(c.isdigit() for c in key)
+ out.append((key, i, j, float(max(1, syllables))))
+ i = j
+ continue
+ i += 1
+ return out
+
+
+def _has_word(text: str) -> bool:
+ return any(_word_char(c) or _cjk(c) for c in text)
+
+
+def pieces_of(text: str) -> list[tuple[int, int]]:
+ """Where the word stamps of a line go: (start, end) character offsets of
+ each piece, in order. A piece is a word as the text spaces it, except
+ that each Chinese character or kana is one of its own (those lines do not
+ space their words), and a Latin word after one starts another."""
+ pieces: list[tuple[int, int]] = []
+ start: int | None = None
+ prev_cjk = False
+ for i, ch in enumerate(text):
+ if ch.isspace():
+ if start is not None:
+ pieces.append((start, i))
+ start = None
+ prev_cjk = False
+ continue
+ cjk = _han_or_kana(ch)
+ if start is None:
+ start = i
+ elif cjk or (prev_cjk and _word_char(ch)):
+ pieces.append((start, i))
+ start = i
+ if cjk or _word_char(ch) or _hangul(ch):
+ prev_cjk = cjk
+ if start is not None:
+ pieces.append((start, len(text)))
+ # A piece of punctuation alone joins the one before it (or after, first).
+ merged: list[tuple[int, int]] = []
+ for a, b in pieces:
+ if merged and not _has_word(text[a:b]):
+ merged[-1] = (merged[-1][0], b)
+ continue
+ merged.append((a, b))
+ if len(merged) > 1 and not _has_word(text[merged[0][0] : merged[0][1]]):
+ merged[1] = (merged[0][0], merged[1][1])
+ merged.pop(0)
+ return merged
+
+
+# ── lines ──
+
+_LINE_STAMPS = re.compile(r"^(?:\s*\[\d{1,3}:\d{1,2}(?:[.:]\d{1,3})?\])+")
+_LINE_STAMP = re.compile(r"\[(\d{1,3}):(\d{1,2}(?:[.:]\d{1,3})?)\]")
+_WORD_STAMP = re.compile(r"<\d{1,3}:\d{1,2}(?:[.:]\d{1,3})?>")
+_OPEN, _CLOSE = "([(【", ")])】"
+
+
+def line_text(raw: str) -> str:
+ """A line's text as the page shows it: word stamps out, spaces collapsed,
+ composed (parseLrc in static/js/lyricsLookup.js)."""
+ return " ".join(unicodedata.normalize("NFC", _WORD_STAMP.sub("", raw)).split())
+
+
+@dataclass
+class Line:
+ text: str
+ prior: float | None
+ pieces: list[tuple[int, int]] = field(default_factory=list)
+ # Indices into the lyric's token list.
+ tokens: list[int] = field(default_factory=list)
+
+
+def synced_lines(synced: str) -> list[tuple[float, str]]:
+ """Every sung line of an LRC as (seconds, text), earliest first: a line
+ stamped more than once (a chorus written once) is each of its times, the
+ [offset:] tag is applied, and lines with no words are left out."""
+ offset = _offset(synced)
+ out: list[tuple[float, str]] = []
+ for raw in synced.splitlines():
+ stamps = _LINE_STAMPS.match(raw)
+ if not stamps:
+ continue
+ text = line_text(raw[stamps.end() :])
+ if not text:
+ continue
+ for m in _LINE_STAMP.finditer(stamps.group(0)):
+ seconds = int(m.group(1)) * 60 + float(m.group(2).replace(":", "."))
+ out.append((max(0.0, seconds - offset), text))
+ out.sort(key=lambda pair: pair[0])
+ return out
+
+
+def plain_lines(plain: str) -> list[str]:
+ return [t for t in (line_text(raw) for raw in plain.splitlines()) if t]
+
+
+@dataclass
+class Lyric:
+ lines: list[Line]
+ keys: list[str]
+ weights: list[float]
+ token_line: list[int]
+ token_piece: list[int]
+ # Tokens in brackets in a line with words outside them: a backing vocal
+ # sung over the lead, which Whisper hears little of.
+ aside: list[bool]
+
+
+def _bracketed(text: str) -> list[bool]:
+ """For each character of ``text``, whether it is inside brackets."""
+ depth = 0
+ out: list[bool] = []
+ for ch in text:
+ if ch in _OPEN:
+ depth += 1
+ out.append(depth > 0)
+ if ch in _CLOSE and depth:
+ depth -= 1
+ return out
+
+
+def build_lyric(pairs: list[tuple[float | None, str]]) -> Lyric:
+ """The lines (stamp or None, text) as tokens."""
+ lyric = Lyric([], [], [], [], [], [])
+ for prior, text in pairs:
+ line = Line(text=text, prior=prior, pieces=pieces_of(text))
+ starts = [a for a, _b in line.pieces]
+ toks = tokens_of(text)
+ bracketed = _bracketed(text)
+ inside = [bracketed[a] for _k, a, _b, _s in toks]
+ if all(inside):
+ inside = [False] * len(toks)
+ for (key, a, _b, syllables), side in zip(toks, inside, strict=True):
+ line.tokens.append(len(lyric.keys))
+ lyric.keys.append(key)
+ lyric.weights.append(syllables)
+ lyric.token_line.append(len(lyric.lines))
+ lyric.token_piece.append(max(0, bisect.bisect_right(starts, a) - 1))
+ lyric.aside.append(side)
+ lyric.lines.append(line)
+ return lyric
+
+
+# ── the voice ──
+
+
+@dataclass
+class Voice:
+ """The vocals stem's envelope: where it is singing and where it starts."""
+
+ hop: float
+ voiced: np.ndarray
+ strength: np.ndarray
+ cumulative: np.ndarray
+
+ @classmethod
+ def of(cls, hop: float, db: Any) -> Voice:
+ """From an envelope, a level in dB every ``hop`` seconds. Singing is
+ within _VOICE_RANGE_DB of the stem's loud parts, as the page's wipe
+ has it (lyrics_align)."""
+ levels = np.asarray(db, dtype=float)
+ voiced = np.zeros(levels.size, dtype=bool)
+ if levels.size:
+ loud = float(np.percentile(levels, _LOUD_PERCENTILE))
+ if loud >= _SILENT_DB:
+ voiced = levels >= max(loud - _VOICE_RANGE_DB, _VOICE_FLOOR_DB)
+ cumulative = np.concatenate(([0.0], np.cumsum(voiced) * hop))
+ return cls(hop, voiced, onset_strength(levels), cumulative)
+
+ def frame(self, t: float) -> int:
+ return int(min(max(0, round(t / self.hop)), max(0, self.voiced.size - 1)))
+
+ def voiced_near(self, t: float, within: float) -> bool:
+ if not self.voiced.size:
+ return True
+ return bool(self.voiced[self.frame(t - within) : self.frame(t + within) + 1].any())
+
+ def voiced_between(self, a: float, b: float) -> float:
+ if not self.voiced.size or b <= a:
+ return 0.0
+ return float(self.cumulative[self.frame(b)] - self.cumulative[self.frame(a)])
+
+ def at_voiced(self, a: float, amount: float) -> float:
+ """The sung moment by which ``amount`` seconds of singing follow
+ ``a``: with 0, where the singing next starts."""
+ target = self.cumulative[self.frame(a)] + amount
+ k = int(np.searchsorted(self.cumulative, target, side="right")) - 1
+ if k >= self.voiced.size:
+ return a
+ return max(a, k * self.hop)
+
+ def end_of_singing(self, t: float, within: float) -> float:
+ """Where the singing going on at ``t`` stops, at most ``within``
+ seconds on: the held end of a line belongs to it."""
+ k, last = self.frame(t), self.frame(t + within)
+ while k < last and self.voiced[k]:
+ k += 1
+ return max(t, k * self.hop)
+
+ def onset_near(self, t: float, within: float) -> float | None:
+ """Where the voice rises most within ``within`` seconds of ``t``,
+ as the time the singing starts, or None when it does not clearly."""
+ if not self.strength.size:
+ return None
+ lo, hi = self.frame(t - within), self.frame(t + within)
+ window = self.strength[lo : hi + 1]
+ if not window.size or window.max() < _HIT_RISE_DB:
+ return None
+ return self._rise_time(lo + int(np.argmax(window)))
+
+ def _rise_time(self, peak: int) -> float:
+ # The rise is measured over _RISE_FRAMES of the level smoothed over
+ # three: it peaks a frame after a step in the level.
+ return max(0.0, (peak - (_RISE_FRAMES - 1) / 2) * self.hop)
+
+ def rise_start(self, start: float, end: float) -> float:
+ """When a heard word that starts a line really starts. Whisper often
+ starts it on the held end of the line before: when the voice does not
+ rise at ``start`` but does within the word (or just past its end),
+ the word starts at that rise."""
+ if not self.strength.size:
+ return start
+ at = self.frame(start)
+ here = float(self.strength[max(0, at - 2) : at + 3].max(initial=0))
+ if here >= _HIT_RISE_DB:
+ return self._stronger_rise(start, end, here)
+ span = min(
+ _RISE_SEARCH_MAX_SEC, max(_RISE_SEARCH_MIN_SEC, end - start + _RISE_PAST_END_SEC)
+ )
+ window = self.strength[at : self.frame(start + span) + 1]
+ above = np.flatnonzero(window >= _HIT_RISE_DB)
+ if not above.size:
+ return start
+ k = int(above[0])
+ while k + 1 < window.size and window[k + 1] >= window[k]:
+ k += 1
+ return max(start, self._rise_time(at + k))
+
+ def _stronger_rise(self, start: float, end: float, here: float) -> float:
+ """A word held longer than _HELD_WORD_SEC whose voice rises again,
+ _STRONGER times as sharply as at its start, before its end: Whisper
+ started it on the tail of the note before, and it starts at the
+ stronger rise."""
+ if end - start < _HELD_WORD_SEC:
+ return start
+ lo, hi = self.frame(start + 0.15), self.frame(end - 0.05)
+ window = self.strength[lo : hi + 1]
+ if not window.size:
+ return start
+ k = int(np.argmax(window))
+ if window[k] < max(_STRONGER * here, 2 * _HIT_RISE_DB):
+ return start
+ return self._rise_time(lo + k)
+
+ def phrase_start(self, t: float, gap: float) -> float | None:
+ """Where the singing going on at ``t`` started: back through the
+ voice until a silence of ``gap`` seconds. None when there is no
+ singing at ``t``."""
+ if not self.voiced.size:
+ return None
+ k = self.frame(t)
+ # The word may start a frame before the voice is loud enough.
+ while k < self.voiced.size - 1 and not self.voiced[k] and k < self.frame(t + 0.1):
+ k += 1
+ if not self.voiced[k]:
+ return None
+ need = max(1, round(gap / self.hop))
+ quiet = 0
+ while k > 0:
+ if self.voiced[k - 1]:
+ quiet = 0
+ else:
+ quiet += 1
+ if quiet >= need:
+ return (k - 1 + quiet) * self.hop
+ k -= 1
+ return 0.0
+
+ def first_voiced(self) -> float | None:
+ idx = np.flatnonzero(self.voiced)
+ return float(idx[0] * self.hop) if idx.size else None
+
+ def last_voiced(self) -> float | None:
+ idx = np.flatnonzero(self.voiced)
+ return float((idx[-1] + 1) * self.hop) if idx.size else None
+
+
+# How far into a line's first heard word its start is looked for.
+_RISE_SEARCH_MIN_SEC = 0.4
+_RISE_SEARCH_MAX_SEC = 1.5
+# Whisper can end that word where the rise is: looked for a little past it.
+_RISE_PAST_END_SEC = 0.1
+# A first word held this long, whose voice rises again this many times as
+# sharply as at its start, starts at that rise.
+_HELD_WORD_SEC = 0.7
+_STRONGER = 2.0
+
+
+# ── what was heard ──
+
+
+@dataclass
+class Heard:
+ keys: list[str]
+ starts: np.ndarray
+ ends: np.ndarray
+ # The end of the whole heard word each token is part of.
+ word_ends: np.ndarray
+
+
+# A pause in the voice at least this long inside a heard word is before it...
+_PAUSE_SEC = 0.15
+# ...when it starts in this first share of the word.
+_PAUSE_WITHIN = 0.8
+
+
+def voiced_start(start: float, end: float, voice: Voice | None) -> float:
+ """When a heard word really starts. Whisper often starts the first word
+ after a pause at the end of the word before, a second or so early, and
+ stretches it over the pause: when the voice pauses for _PAUSE_SEC or more
+ in the first _PAUSE_WITHIN of the word, it starts where the voice comes
+ back after the last such pause."""
+ if voice is None or not voice.voiced.size or end - start <= _PAUSE_SEC:
+ return start
+ lo, hi = voice.frame(start), voice.frame(end)
+ latest = voice.frame(start + (end - start) * _PAUSE_WITHIN)
+ quiet = ~voice.voiced[lo:hi]
+ need = max(1, round(_PAUSE_SEC / voice.hop))
+ back = None
+ k = 0
+ while k < quiet.size:
+ if not quiet[k]:
+ k += 1
+ continue
+ first = k
+ while k < quiet.size and quiet[k]:
+ k += 1
+ if k - first >= need and lo + first <= latest and k < quiet.size:
+ back = k
+ return (lo + back) * voice.hop if back is not None else start
+
+
+def heard_tokens(words: list[dict[str, Any]], voice: Voice | None = None) -> Heard:
+ """Whisper's words as tokens, each word's time split between its tokens
+ by their syllables, each word's start moved past a pause in the voice
+ it begins with (voiced_start)."""
+ keys: list[str] = []
+ starts: list[float] = []
+ ends: list[float] = []
+ word_ends: list[float] = []
+ for word in words:
+ toks = tokens_of(str(word.get("text", "")))
+ if not toks:
+ continue
+ start, end = float(word["start"]), max(float(word["end"]), float(word["start"]))
+ start = voiced_start(start, end, voice)
+ total = sum(t[3] for t in toks)
+ at = start
+ for key, _a, _b, syllables in toks:
+ span = (end - start) * syllables / total if total else 0.0
+ keys.append(key)
+ starts.append(at)
+ ends.append(at + span)
+ word_ends.append(end)
+ at += span
+ return Heard(
+ keys,
+ np.asarray(starts, dtype=float),
+ np.asarray(ends, dtype=float),
+ np.asarray(word_ends, dtype=float),
+ )
+
+
+# ── matching ──
+
+# What a match of a backing vocal's word (an aside) is worth: it takes the
+# echo Whisper hears after the lead's words, never the lead's own.
+_ASIDE_SCALE = 0.5
+# A misheard Latin word still counts, a little less, when it is this close.
+_FUZZY_MIN = 0.67
+_FUZZY_SCALE = 0.9
+# A word of two letters or fewer only counts as itself.
+_FUZZY_MIN_LEN = 3
+# A Hangul syllable that shares two of its three letters with the heard one.
+_HANGUL_NEAR = 0.6
+# What a match right after a match adds: runs anchor, stray words do not.
+_RUN_BONUS = 0.5
+# What each heard token skipped between two matches costs.
+_GAP_COST = 0.1
+_NEG = -1e9
+
+
+def _levenshtein(a: str, b: str) -> int:
+ if len(a) < len(b):
+ a, b = b, a
+ previous = list(range(len(b) + 1))
+ for i, ca in enumerate(a, 1):
+ current = [i]
+ for j, cb in enumerate(b, 1):
+ current.append(min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (ca != cb)))
+ previous = current
+ return previous[-1]
+
+
+def similarity(a: str, b: str) -> float:
+ """How alike two Latin tokens are, 0 to 1: 1 - edit distance / length."""
+ if a == b:
+ return 1.0
+ if len(a) < _FUZZY_MIN_LEN or len(b) < _FUZZY_MIN_LEN:
+ return 0.0
+ longest = max(len(a), len(b))
+ if abs(len(a) - len(b)) > longest * (1 - _FUZZY_MIN):
+ return 0.0
+ return 1.0 - _levenshtein(a, b) / longest
+
+
+def _jamo(ch: str) -> tuple[int, int, int]:
+ """A Hangul syllable's lead consonant, vowel and final consonant."""
+ code = ord(ch) - 0xAC00
+ return code // 588, (code % 588) // 28, code % 28
+
+
+def _jamo_alike(a: str, b: str) -> bool:
+ return sum(x == y for x, y in zip(_jamo(a), _jamo(b), strict=True)) >= 2
+
+
+def score_table(lyric_keys: list[str], heard_keys: list[str]) -> tuple:
+ """(table, lyric ids, heard ids): table[u, v] is the score of lyric key
+ u against heard key v, _NEG when they do not match at all."""
+ u_keys = sorted(set(lyric_keys))
+ v_keys = sorted(set(heard_keys))
+ u_index = {k: i for i, k in enumerate(u_keys)}
+ v_index = {k: i for i, k in enumerate(v_keys)}
+ table = np.full((len(u_keys), len(v_keys)), _NEG, dtype=np.float64)
+ for k, i in u_index.items():
+ j = v_index.get(k)
+ if j is not None:
+ table[i, j] = 1.0
+ latin_v = [(k, j) for k, j in v_index.items() if not _cjk(k[0])]
+ hangul_v = [(k, j) for k, j in v_index.items() if _hangul(k[0])]
+ for k, i in u_index.items():
+ if _hangul(k[0]):
+ for hk, j in hangul_v:
+ if hk != k and _jamo_alike(k, hk):
+ table[i, j] = _HANGUL_NEAR
+ continue
+ if _cjk(k[0]) or len(k) < _FUZZY_MIN_LEN:
+ continue
+ for hk, j in latin_v:
+ if hk == k or abs(len(hk) - len(k)) > 3:
+ continue
+ sim = similarity(k, hk)
+ if sim >= _FUZZY_MIN:
+ table[i, j] = sim * _FUZZY_SCALE
+ lyric_ids = np.asarray([u_index[k] for k in lyric_keys], dtype=np.int64)
+ heard_ids = np.asarray([v_index[k] for k in heard_keys], dtype=np.int64)
+ return table, lyric_ids, heard_ids
+
+
+def align_tokens(
+ lyric_keys: list[str],
+ heard_keys: list[str],
+ heard_starts: np.ndarray | None = None,
+ windows: tuple[np.ndarray, np.ndarray] | None = None,
+ scale: np.ndarray | None = None,
+) -> list[tuple[int, int, float]]:
+ """The best monotonic pairing of lyric tokens with heard tokens, as
+ (lyric index, heard index, score), in order. Lyric tokens unheard are
+ free to skip, and heard tokens before the first match and after the
+ last; each match scores its similarity, plus _RUN_BONUS when it follows
+ the match of the tokens just before on both sides, less _GAP_COST for
+ each heard token skipped between matches. One row of the
+ dynamic program at a time, in numpy; only the back pointers are kept, two
+ bytes a cell. Empty when there is nothing to pair or the two are too
+ long to pair (LYRICS_RETIME_MAX_CELLS). With ``windows`` (per lyric
+ token, the earliest and latest start) and ``heard_starts``, a token only
+ pairs with heard tokens starting within its window."""
+ n, m = len(lyric_keys), len(heard_keys)
+ if not n or not m or n * m > LYRICS_RETIME_MAX_CELLS:
+ return []
+ table, lyric_ids, heard_ids = score_table(lyric_keys, heard_keys)
+ d_prev = np.zeros(m + 1)
+ m_prev = np.full(m + 1, _NEG)
+ # from_run[i, j]: the match at (i, j) follows a match. how[i, j]: what
+ # the best score up to (i, j) ends with: 0 a match here, 1 a lyric token
+ # skipped, 2 a heard token skipped.
+ from_run = np.zeros((n + 1, m + 1), dtype=np.bool_)
+ how = np.zeros((n + 1, m + 1), dtype=np.int8)
+ how[1:, 0] = 1
+ cost = _GAP_COST * np.arange(m + 1)
+ for i in range(1, n + 1):
+ scores = table[lyric_ids[i - 1]][heard_ids]
+ if scale is not None:
+ scores = np.where(scores > 0, scores * scale[i - 1], scores)
+ if windows is not None and heard_starts is not None:
+ lo, hi = windows[0][i - 1], windows[1][i - 1]
+ scores = np.where((heard_starts >= lo) & (heard_starts <= hi), scores, _NEG)
+ run = m_prev[:-1] + _RUN_BONUS
+ chain = run > d_prev[:-1]
+ m_row = np.full(m + 1, _NEG)
+ m_row[1:] = np.where(scores > 0, scores + np.where(chain, run, d_prev[:-1]), _NEG)
+ from_run[i, 1:] = chain
+ here = np.maximum(m_row, d_prev)
+ # Heard tokens skipped between matches cost _GAP_COST each: of two
+ # pairings that hear the lines about as well, the one that sings them
+ # without a long wait between (a chorus written once is its first
+ # time, not one a minute on). Kept in the running maximum as
+ # here[k] + cost * k.
+ lifted = here + cost
+ best = np.maximum.accumulate(lifted)
+ d_row = best - cost
+ earlier = np.concatenate(([False], best[1:] == best[:-1]))
+ how[i] = np.where((best > lifted) | earlier, 2, np.where(m_row >= d_prev, 0, 1)).astype(
+ np.int8
+ )
+ how[i, 0] = 1
+ d_prev, m_prev = d_row, m_row
+ pairs: list[tuple[int, int, float]] = []
+ # Heard tokens after the last match cost nothing: the best end, earliest.
+ i, j, in_match = n, int(np.argmax(d_prev)), False
+ while i > 0 and j > 0:
+ if not in_match:
+ step = how[i, j]
+ if step == 0:
+ in_match = True
+ elif step == 1:
+ i -= 1
+ else:
+ j -= 1
+ continue
+ pairs.append((i - 1, j - 1, float(table[lyric_ids[i - 1], heard_ids[j - 1]])))
+ in_match = bool(from_run[i, j])
+ i, j = i - 1, j - 1
+ pairs.reverse()
+ return pairs
+
+
+# ── retiming ──
+
+# A line is anchored when at least this share of its syllables matched...
+_ANCHOR_MIN_SHARE = 0.34
+# ...with at least two of its tokens matched in a run (one-token lines: one).
+_ANCHOR_MIN_RUN = 2
+# The seconds a syllable takes when nothing better is known.
+_DEFAULT_RATE = 0.28
+_MIN_RATE, _MAX_RATE = 0.08, 0.9
+# An anchor with no singing this close is a word heard in a silence.
+_VOICE_CHECK_SEC = 0.35
+# The singing going on when a line's last word ends is that line's for at
+# most this long.
+_HELD_SEC = 1.0
+# A placed line starts on an onset of the voice this close to its estimate.
+_SNAP_SEC = 0.6
+# LRC stamps between two anchors are trusted when their spacing agrees with
+# the anchors' within this ratio.
+_PRIOR_AGREE = 0.12
+# Lines this close apart are one after the other, not at once.
+_MIN_GAP = 0.05
+# Two matched words of a line are sung together when no more than this many
+# seconds apart, plus this many a syllable between them.
+_CLUSTER_GAP_SEC = 1.5
+_CLUSTER_SEC_PER_SYLLABLE = 0.8
+# ...or any distance apart, when next to each other in what was heard and
+# the voice sounds for this share of the time between them: a held note.
+_HELD_VOICED = 0.8
+# An anchor this many seconds off its neighbours' shift from the stamps, on
+# both sides, while those agree among themselves within _AGREE_SEC, is a
+# phrase matched where it is sung again, not this line.
+_OUTLIER_SEC = 3.0
+_AGREE_SEC = 1.0
+# A second pass looks for each line's words within this many seconds of
+# where its neighbours' shifts from the stamps put it, given at least this
+# many anchors, and is kept when it matches at least this share of what the
+# first matched.
+_WINDOW_SEC = 8.0
+_WINDOW_MIN_ANCHORS = 4
+_WINDOW_KEEP = 0.9
+
+Anchor = tuple[float, float, float] # (start, end, seconds a syllable or 0)
+
+
+@dataclass
+class Timed:
+ start: float
+ end: float
+ words: list[float]
+ anchored: bool
+
+
+@dataclass
+class Retimed:
+ lines: list[Line]
+ timed: list[Timed]
+ matched: float
+ lines_matched: int
+ # The lines that were timed: all but those shown with another
+ # (retime_lines), which have nothing of their own to be found by.
+ counted: int = -1
+
+ def __post_init__(self) -> None:
+ if self.counted < 0:
+ self.counted = len(self.lines)
+
+
+def _line_rate(points: list[tuple[float, float]]) -> float | None:
+ """Seconds a syllable from (cumulative syllables, time) points."""
+ if len(points) < 2:
+ return None
+ (s0, t0), (s1, t1) = points[0], points[-1]
+ if s1 <= s0 or t1 <= t0:
+ return None
+ return min(_MAX_RATE, max(_MIN_RATE, (t1 - t0) / (s1 - s0)))
+
+
+def _companions(pairs: list[tuple[float | None, str]]) -> list[int | None]:
+ """For each line, the line it is shown with, or None: a line stamped the
+ same as the one before it is its translation or romanisation (bilingual
+ LRC), never sung apart from it."""
+ out: list[int | None] = [None] * len(pairs)
+ for i in range(1, len(pairs)):
+ here, before = pairs[i][0], pairs[i - 1][0]
+ if here is not None and before is not None and abs(here - before) < 0.005:
+ leader = out[i - 1]
+ out[i] = leader if leader is not None else i - 1
+ return out
+
+
+# A credit a copy lists before the singing: "作词 : 米津玄師", "Lyrics: ...".
+_CREDIT = re.compile(
+ r"^\s*(?:作?[词詞]|作?曲|[编編]曲|制作人?|製作人?|[监監]製|[监監]制|演唱|原唱|和声|和聲|"
+ r"混音|母带|母帶|吉他|贝斯|貝斯|鼓|键盘|鍵盤|弦乐|弦樂|出品|发行|發行|"
+ r"lyrics?|music|composer|composed|written|arrange[dr]?|producer|produced)"
+ r"\s*(?:by)?\s*[::]",
+ re.IGNORECASE,
+)
+
+
+def _credits(pairs: list[tuple[float | None, str]]) -> set[int]:
+ """The lines at the top of a copy that are its credits, not sung: those
+ matching _CREDIT, and a "Title - Artist" line just before them."""
+ out: set[int] = set()
+ for i, (_t, text) in enumerate(pairs):
+ if _CREDIT.match(text):
+ out.add(i)
+ elif i > len(out) + 1:
+ break
+ if out and min(out) == 1 and " - " in pairs[0][1]:
+ out.add(0)
+ return out
+
+
+def retime_lines(
+ pairs: list[tuple[float | None, str]],
+ words: list[dict[str, Any]],
+ voice: Voice | None,
+ duration: float,
+) -> Retimed:
+ """The lyrics' lines as (stamp or None, text) timed to the heard words:
+ retime() of all but the lines shown with another (_companions), which
+ take that one's timing, and the credits at the top (_credits), which
+ are shown before the singing."""
+ companions = _companions(pairs)
+ credits = _credits(pairs)
+ # A line with no word ("♪", "...") marks a pause: it keeps its place in
+ # the copy's own timing, moved with the line before it.
+ marks = {i for i, (_t, text) in enumerate(pairs) if not tokens_of(text)}
+ main = [
+ i for i, c in enumerate(companions) if c is None and i not in credits and i not in marks
+ ]
+ heard = heard_tokens(words, voice)
+ result = retime(build_lyric([pairs[i] for i in main]), heard, voice, duration)
+ if len(main) == len(pairs):
+ return result
+ full = build_lyric(pairs)
+ placed = dict(zip(main, result.timed, strict=True))
+ first = min((t.start for t in result.timed), default=0.0)
+ timed: list[Timed] = []
+ for i, line in enumerate(full.lines):
+ if i in placed:
+ timed.append(placed[i])
+ continue
+ if i in marks and i not in credits and companions[i] is None:
+ timed.append(_mark_timing(i, pairs, placed, line))
+ continue
+ if i in credits or companions[i] not in placed:
+ # Shown before the singing, as briefly as the copy has them.
+ at = max(0.0, min(pairs[i][0] or 0.0, first - _MIN_GAP * (len(credits) - i)))
+ timed.append(Timed(at, at, [at] * len(line.pieces), anchored=True))
+ continue
+ leader = placed[companions[i]] # type: ignore[index]
+ step = (leader.end - leader.start) / max(1, len(line.pieces))
+ words_at = [leader.start + step * k for k in range(len(line.pieces))]
+ timed.append(Timed(leader.start, leader.end, words_at, anchored=leader.anchored))
+ return Retimed(full.lines, timed, result.matched, result.lines_matched, len(main))
+
+
+def _mark_timing(
+ i: int, pairs: list[tuple[float | None, str]], placed: dict[int, Timed], line: Line
+) -> Timed:
+ """A pause mark's timing: its stamp moved as the line before it was
+ (after it, when it leads), or just after that line's end without one."""
+ before = [k for k in placed if k < i]
+ after = [k for k in placed if k > i]
+ prior = pairs[i][0]
+ at = 0.0
+ if before:
+ lead = placed[max(before)]
+ at = lead.end + _MIN_GAP
+ mark_prior, lead_prior = prior, pairs[max(before)][0]
+ if mark_prior is not None and lead_prior is not None:
+ at = max(at, lead.start + (mark_prior - lead_prior))
+ elif after and prior is not None and pairs[min(after)][0] is not None:
+ at = max(0.0, placed[min(after)].start - (pairs[min(after)][0] - prior)) # type: ignore[operator]
+ if after:
+ at = min(at, placed[min(after)].start - _MIN_GAP)
+ at = max(0.0, at)
+ return Timed(at, at, [at] * len(line.pieces), anchored=True)
+
+
+def retime(lyric: Lyric, heard: Heard, voice: Voice | None, duration: float) -> Retimed:
+ """Each line's start and end, and each piece's start, on the track."""
+ lines = lyric.lines
+ # An aside's words count for less: they are sung over the lead's.
+ dp_keys = lyric.keys
+ scale = np.where(np.asarray(lyric.aside, dtype=bool), _ASIDE_SCALE, 1.0)
+ total_w = sum(w for w, side in zip(lyric.weights, lyric.aside, strict=True) if not side)
+ pairs = align_tokens(dp_keys, heard.keys, scale=scale)
+ matched_at = _compact(lyric, heard, voice, {i: j for i, j, _s in pairs})
+ anchors, rates = _anchor(lyric, heard, voice, matched_at)
+ anchors = _drop_outliers(lines, anchors)
+ # Again, each line's words looked for only near where its stamp and the
+ # anchors around it put it, when those agree: a phrase sung again
+ # elsewhere then cannot take a line away from its place.
+ windows = _windows(lyric, anchors)
+ if windows is not None:
+ pairs = align_tokens(dp_keys, heard.keys, heard.starts, windows, scale)
+ near = _compact(lyric, heard, voice, {i: j for i, j, _s in pairs})
+ # Kept unless it loses much of what the first pass heard: stamps
+ # that say nothing about where lines are sung would.
+ if _weight(lyric, near) >= _WINDOW_KEEP * _weight(lyric, matched_at):
+ matched_at = near
+ anchors, rates = _anchor(lyric, heard, voice, matched_at)
+ anchors = _drop_outliers(lines, anchors)
+ matched_w = _weight(lyric, matched_at)
+ default_rate = float(np.median(rates)) if rates else _DEFAULT_RATE
+ if voice is not None:
+ anchors = _phrase_starts(heard, voice, anchors)
+
+ # Anchors must run forward; the alignment makes them, but the back-off
+ # for unmatched first words can cross the line before.
+ kept: dict[int, Anchor] = {}
+ last_start = -1.0
+ for li in sorted(anchors):
+ s, e, r = anchors[li]
+ if s <= last_start:
+ s = last_start + _MIN_GAP
+ kept[li] = (max(0.0, s), max(e, s), r)
+ last_start = kept[li][0]
+ anchors = kept
+
+ starts: list[float | None] = [
+ anchors[li][0] if li in anchors else None for li in range(len(lines))
+ ]
+ song_end = duration
+ if voice is not None:
+ last = voice.last_voiced()
+ if last is not None:
+ song_end = min(duration, last + 1.0) if duration else last + 1.0
+ _place_unanchored(lines, lyric, starts, anchors, voice, song_end, default_rate)
+ for li in range(1, len(starts)):
+ starts[li] = max(starts[li] or 0.0, starts[li - 1] or 0.0)
+
+ timed: list[Timed] = []
+ for li, line in enumerate(lines):
+ start = float(starts[li] or 0.0)
+ following = next((s for s in starts[li + 1 :] if s is not None and s > start), None)
+ limit = following - _MIN_GAP if following is not None else max(song_end, start + 1.0)
+ timed.append(
+ _words_of_line(
+ line, lyric, heard, matched_at, start, limit, anchors.get(li), default_rate
+ )
+ )
+ return Retimed(lines, timed, matched_w / (total_w or 1.0), len(anchors))
+
+
+# A line starts where its phrase does, up to this long before its first heard
+# word, when nothing else was heard in between.
+_PHRASE_BACK_SEC = 2.5
+# Silence this long ends a phrase; a gap shorter than this is within one.
+_PHRASE_GAP_SEC = 0.25
+
+
+def _phrase_starts(
+ heard: Heard,
+ voice: Voice,
+ anchors: dict[int, Anchor],
+) -> dict[int, Anchor]:
+ """The anchors, each moved back to the start of the phrase its first
+ heard word is sung in, when Whisper heard nothing of that phrase before
+ the word. Whisper drops the first words of a phrase often, a repeated
+ word most of all ("Bailando, bailando": it hears the second), and the
+ line then starts where the voice does, after a silence. Never back past
+ the end of the line before, nor further than _PHRASE_BACK_SEC."""
+ out: dict[int, Anchor] = {}
+ floor = 0.0
+ everything = np.sort(heard.starts)
+ for li in sorted(anchors):
+ start, end, rate = anchors[li]
+ phrase = voice.phrase_start(start, _PHRASE_GAP_SEC)
+ if (
+ phrase is not None
+ and floor <= phrase < start - _PHRASE_GAP_SEC
+ and start - phrase <= _PHRASE_BACK_SEC
+ ):
+ between = np.searchsorted(everything, [phrase, start - 0.05])
+ if between[1] == between[0]:
+ start = phrase
+ out[li] = (start, end, rate)
+ floor = voice.end_of_singing(end, _HELD_SEC)
+ return out
+
+
+def _weight(lyric: Lyric, matched_at: dict[int, int]) -> float:
+ return sum(lyric.weights[i] for i in matched_at if not lyric.aside[i])
+
+
+def _compact(
+ lyric: Lyric, heard: Heard, voice: Voice | None, matched_at: dict[int, int]
+) -> dict[int, int]:
+ """The matches, less those of a line's words heard far from the rest of
+ it: a line whose last words were matched to a chorus sung again later
+ keeps only the group of its matches that weighs most. Two words heard
+ one after the other with the voice going on between them are together
+ however long the first is held: Whisper ended Piaf's long "Non" two
+ seconds before "rien", and the line lost its first word."""
+ by_line: dict[int, list[int]] = {}
+ for t in sorted(matched_at):
+ by_line.setdefault(lyric.token_line[t], []).append(t)
+ kept: dict[int, int] = {}
+ for toks in by_line.values():
+ groups: list[list[int]] = [[toks[0]]]
+ for a, b in zip(toks, toks[1:], strict=False):
+ between = sum(lyric.weights[x] for x in range(a + 1, b))
+ gap = float(heard.starts[matched_at[b]] - heard.ends[matched_at[a]])
+ held = (
+ voice is not None
+ and matched_at[b] == matched_at[a] + 1
+ and voice.voiced_between(
+ float(heard.ends[matched_at[a]]), float(heard.starts[matched_at[b]])
+ )
+ >= _HELD_VOICED * gap
+ )
+ if not held and gap > _CLUSTER_GAP_SEC + _CLUSTER_SEC_PER_SYLLABLE * between:
+ groups.append([])
+ groups[-1].append(b)
+ best = max(groups, key=lambda g: sum(lyric.weights[t] for t in g))
+ kept.update({t: matched_at[t] for t in best})
+ return kept
+
+
+def _anchor(
+ lyric: Lyric, heard: Heard, voice: Voice | None, matched_at: dict[int, int]
+) -> tuple[dict[int, Anchor], list[float]]:
+ """The lines found where they are sung, {line: (start, end, pace)}, and
+ each one's pace (seconds a syllable)."""
+ anchors: dict[int, Anchor] = {}
+ rates: list[float] = []
+ for li, line in enumerate(lyric.lines):
+ toks = [t for t in line.tokens if not lyric.aside[t]]
+ if not toks:
+ continue
+ cum = 0.0
+ points: list[tuple[float, float, int]] = [] # (syllables before, time, token)
+ best_run = run = 0
+ prev_j: int | None = None
+ for t in toks:
+ j = matched_at.get(t)
+ if j is not None:
+ points.append((cum, float(heard.starts[j]), t))
+ run = run + 1 if prev_j is not None and j - prev_j <= 2 else 1
+ best_run = max(best_run, run)
+ prev_j = j
+ else:
+ run, prev_j = 0, None
+ cum += lyric.weights[t]
+ share = sum(lyric.weights[p[2]] for p in points) / cum if cum else 0.0
+ if share < _ANCHOR_MIN_SHARE or best_run < min(_ANCHOR_MIN_RUN, len(toks)):
+ continue
+ first_t = points[0][1]
+ if voice is not None:
+ if not voice.voiced_near(first_t, _VOICE_CHECK_SEC):
+ continue
+ first_t = voice.rise_start(first_t, float(heard.word_ends[matched_at[points[0][2]]]))
+ rate = _line_rate([(p[0], p[1]) for p in points])
+ if rate is not None:
+ rates.append(rate)
+ end = float(heard.ends[matched_at[points[-1][2]]])
+ start = _line_start(lyric, heard, matched_at, toks, points, first_t, rate, voice)
+ anchors[li] = (start, end, rate or 0.0)
+ return anchors, rates
+
+
+def _line_start(
+ lyric: Lyric,
+ heard: Heard,
+ matched_at: dict[int, int],
+ toks: list[int],
+ points: list[tuple[float, float, int]],
+ first_t: float,
+ rate: float | None,
+ voice: Voice | None,
+) -> float:
+ """When a line starts whose first matched token is not its first: as
+ many heard tokens before that one as the line has unmatched tokens
+ before it, when those heard tokens matched nothing and are sung with it
+ (misheard words, one for one), else back from the first match by the
+ line's pace."""
+ before = toks.index(points[0][2])
+ if before == 0:
+ return first_t
+ j = matched_at[points[0][2]]
+ used = set(matched_at.values())
+ k = j - before
+ near = _CLUSTER_GAP_SEC + _CLUSTER_SEC_PER_SYLLABLE * points[0][0]
+ if (
+ k >= 0
+ and not any(x in used for x in range(k, j))
+ and first_t - float(heard.starts[k]) <= near
+ ):
+ start = float(heard.starts[k])
+ if voice is not None:
+ start = voice.rise_start(start, float(heard.word_ends[k]))
+ return min(start, first_t)
+ return first_t - points[0][0] * (rate or _DEFAULT_RATE)
+
+
+def _shifts(lines: list[Line], anchors: dict[int, Anchor]) -> tuple[list[int], list[float]]:
+ """The anchored lines with stamps, in order, and how far each moved."""
+ order = [li for li in sorted(anchors) if lines[li].prior is not None]
+ return order, [anchors[li][0] - lines[li].prior for li in order] # type: ignore[operator]
+
+
+def _drop_outliers(lines: list[Line], anchors: dict[int, Anchor]) -> dict[int, Anchor]:
+ """The anchors, less those the LRC's own stamps show are out of place.
+ A song timed to another cut moves by a step where the cut is, and each
+ side of the step agrees with itself; a line matched to a repeat of its
+ words elsewhere agrees with neither side. Stamps that agree with nothing
+ (a copy never timed right) drop nothing."""
+ order, shift = _shifts(lines, anchors)
+ dropped: set[int] = set()
+ for k, li in enumerate(order):
+ sides = [s for s in (shift[max(0, k - 2) : k], shift[k + 1 : k + 3]) if len(s) == 2]
+ if not sides or any(max(s) - min(s) > _AGREE_SEC for s in sides):
+ continue
+ if all(abs(shift[k] - float(np.median(s))) > _OUTLIER_SEC for s in sides):
+ dropped.add(li)
+ return {li: v for li, v in anchors.items() if li not in dropped}
+
+
+def _windows(lyric: Lyric, anchors: dict[int, Anchor]) -> tuple[np.ndarray, np.ndarray] | None:
+ """For each lyric token, the earliest and latest time its heard match may
+ start: its line's stamp moved by the shifts of the anchors either side
+ (as far as the nearer of them disagree), _WINDOW_SEC wider. None when
+ there are too few anchors with stamps to tell."""
+ lines = lyric.lines
+ order, shift = _shifts(lines, anchors)
+ if len(order) < _WINDOW_MIN_ANCHORS:
+ return None
+ lo = np.full(len(lyric.keys), -np.inf)
+ hi = np.full(len(lyric.keys), np.inf)
+ for li, line in enumerate(lines):
+ if line.prior is None:
+ continue
+ k = bisect.bisect_left(order, li)
+ near = shift[max(0, k - 2) : k + 2]
+ for t in line.tokens:
+ lo[t] = line.prior + min(near) - _WINDOW_SEC
+ hi[t] = line.prior + max(near) + _WINDOW_SEC
+ return lo, hi
+
+
+def _syllables(lyric: Lyric, line: Line) -> float:
+ return sum(lyric.weights[t] for t in line.tokens) or 1.0
+
+
+def _place_unanchored(
+ lines: list[Line],
+ lyric: Lyric,
+ starts: list[float | None],
+ anchors: dict[int, Anchor],
+ voice: Voice | None,
+ song_end: float,
+ rate: float,
+) -> None:
+ """Fill ``starts`` for the lines between anchors, in place."""
+ n = len(lines)
+ li = 0
+ while li < n:
+ if starts[li] is not None:
+ li += 1
+ continue
+ a = li - 1 # the anchored line before the run, or -1
+ b = li
+ while b < n and starts[b] is None:
+ b += 1
+ run = list(range(li, b))
+ lo = anchors[a][1] if a >= 0 else 0.0
+ if a >= 0 and voice is not None and voice.voiced.size:
+ lo = voice.end_of_singing(lo, _HELD_SEC)
+ hi = float(starts[b]) if b < n else song_end # type: ignore[arg-type]
+ if a < 0 and voice is not None:
+ first = voice.first_voiced()
+ if first is not None and first < hi:
+ lo = max(lo, first - 0.2)
+ lo = min(lo, hi)
+ placed = _by_prior(lines, run, a, b, starts, lo, hi)
+ if placed is None:
+ placed = _by_voice(lines, lyric, run, lo, hi, voice, rate)
+ prev = starts[a] if a >= 0 else -_MIN_GAP
+ for k, t in zip(run, placed, strict=True):
+ if voice is not None:
+ snapped = voice.onset_near(t, _SNAP_SEC)
+ if snapped is not None and lo - _MIN_GAP <= snapped <= hi - _MIN_GAP:
+ t = snapped
+ t = max(t, (prev or 0.0) + _MIN_GAP)
+ if b < n:
+ # Never past the line after the run: the order is the text's.
+ t = min(t, hi)
+ starts[k] = t
+ prev = t
+ li = b
+
+
+def _by_prior(
+ lines: list[Line],
+ run: list[int],
+ a: int,
+ b: int,
+ starts: list[float | None],
+ lo: float,
+ hi: float,
+) -> list[float] | None:
+ """The run's starts from the LRC's own stamps, mapped onto the anchors
+ around it, when those stamps agree with the anchors: None when they do
+ not, or there are no stamps."""
+ if any(lines[k].prior is None for k in run):
+ return None
+ pa = lines[a].prior if a >= 0 else None
+ pb = lines[b].prior if b < len(lines) else None
+ sa = starts[a] if a >= 0 else None
+ sb = starts[b] if b < len(lines) else None
+ if pa is not None and pb is not None and sa is not None and sb is not None:
+ if pb <= pa or sb <= sa:
+ return None
+ ratio = (sb - sa) / (pb - pa)
+ if abs(ratio - 1) > _PRIOR_AGREE:
+ return None
+ return [sa + (lines[k].prior - pa) * ratio for k in run] # type: ignore[operator]
+ # One anchor only (the run leads or ends the song): moved with it, when
+ # that keeps the whole run inside the time it has.
+ if pa is not None and sa is not None:
+ placed = [sa + (lines[k].prior - pa) for k in run] # type: ignore[operator]
+ elif pb is not None and sb is not None:
+ placed = [sb - (pb - lines[k].prior) for k in run] # type: ignore[operator]
+ else:
+ return None
+ if placed[0] < lo - 1.0 or placed[-1] > hi:
+ return None
+ return placed
+
+
+def _by_voice(
+ lines: list[Line],
+ lyric: Lyric,
+ run: list[int],
+ lo: float,
+ hi: float,
+ voice: Voice | None,
+ rate: float,
+) -> list[float]:
+ """The run's starts spread over the singing between ``lo`` and ``hi``
+ by their syllables; evenly over the time when there is no singing there
+ (lines the track does not sing: they pass quickly)."""
+ weights = [_syllables(lyric, lines[k]) for k in run]
+ total = sum(weights)
+ sung = voice.voiced_between(lo, hi) if voice is not None else 0.0
+ out: list[float] = []
+ before = 0.0
+ if voice is not None and sung >= 0.4 * len(run):
+ # No more singing than the lines need at the usual pace: the rest of
+ # the window is somebody else's (a chorus the text does not repeat).
+ need = min(sung, total * rate * 1.5)
+ for w in weights:
+ out.append(voice.at_voiced(lo, need * before / total))
+ before += w
+ return out
+ span = max(0.0, hi - lo)
+ for w in weights:
+ out.append(lo + span * before / total)
+ before += w
+ return out
+
+
+def _words_of_line(
+ line: Line,
+ lyric: Lyric,
+ heard: Heard,
+ matched_at: dict[int, int],
+ start: float,
+ limit: float,
+ anchor: Anchor | None,
+ default_rate: float,
+) -> Timed:
+ """Each piece's start in a line starting at ``start`` and over by
+ ``limit``, and the line's end: where its words were heard, the rest
+ spread between those by syllables."""
+ n_pieces = len(line.pieces)
+ limit = max(limit, start)
+ rate = (anchor[2] if anchor and anchor[2] else 0.0) or default_rate
+ piece_w = [0.0] * n_pieces
+ known: dict[int, float] = {}
+ for t in line.tokens:
+ p = lyric.token_piece[t]
+ j = matched_at.get(t) if anchor is not None else None
+ if j is not None and p not in known:
+ # The piece starts when its first matched token is heard, less
+ # the syllables of the piece before that token.
+ known[p] = float(heard.starts[j]) - piece_w[p] * rate
+ piece_w[p] += lyric.weights[t]
+ piece_w = [w or 0.5 for w in piece_w]
+ cum = [0.0]
+ for w in piece_w:
+ cum.append(cum[-1] + w)
+ points = [(0, start)]
+ for p in sorted(known):
+ if p == 0:
+ continue
+ points.append((p, min(max(known[p], points[-1][1]), limit)))
+ if anchor is not None:
+ last_known = points[-1][0]
+ own = cum[last_known + 1] - cum[last_known] if last_known < n_pieces else 0.0
+ tail = cum[n_pieces] - cum[last_known + 1] if last_known + 1 <= n_pieces else 0.0
+ end = max(anchor[1], points[-1][1] + own * rate) + tail * rate
+ else:
+ end = start + cum[n_pieces] * rate
+ end = max(min(end, limit), points[-1][1])
+ points.append((n_pieces, end))
+ words: list[float] = []
+ k = 0
+ for p in range(n_pieces):
+ while k + 1 < len(points) and points[k + 1][0] <= p:
+ k += 1
+ p0, t0 = points[k]
+ if p0 == p:
+ words.append(t0)
+ continue
+ p1, t1 = points[k + 1] if k + 1 < len(points) else (n_pieces, end)
+ span_w = cum[p1] - cum[p0]
+ words.append(t0 + (t1 - t0) * ((cum[p] - cum[p0]) / span_w if span_w > 0 else 0.0))
+ for p in range(1, n_pieces):
+ words[p] = max(words[p], words[p - 1])
+ if words:
+ words[0] = start
+ return Timed(start=start, end=max(end, start), words=words, anchored=anchor is not None)
+
+
+# ── LRC out ──
+
+# A pause this long after a line clears it (an empty stamped line).
+_CLEAR_AFTER_SEC = 4.0
+
+
+def _stamp(seconds: float) -> str:
+ centis = max(0, round(seconds * 100))
+ minutes, rest = divmod(centis, 6000)
+ return f"{minutes:02d}:{rest // 100:02d}.{rest % 100:02d}"
+
+
+def to_lrc(result: Retimed) -> str:
+ """The retimed lines as enhanced LRC: each line its start, each piece its
+ word stamp, the last its end; an empty line where a long pause follows."""
+ rows = sorted(zip(result.lines, result.timed, strict=True), key=lambda r: r[1].start)
+ out: list[str] = []
+ for k, (line, timed) in enumerate(rows):
+ text = line.text
+ parts = [f"[{_stamp(timed.start)}]"]
+ cursor = 0
+ for (a, _b), t in zip(line.pieces, timed.words, strict=True):
+ parts.append(text[cursor:a])
+ parts.append(f"<{_stamp(t)}>")
+ cursor = a
+ parts.append(text[cursor:])
+ if line.pieces:
+ parts.append(f"<{_stamp(timed.end)}>")
+ out.append("".join(parts))
+ following = rows[k + 1][1].start if k + 1 < len(rows) else None
+ if following is None or following - timed.end >= _CLEAR_AFTER_SEC:
+ out.append(f"[{_stamp(timed.end + 0.5)}]")
+ return "\n".join(out)
+
+
+# ── the gate ──
+
+
+def onset_fit(starts: list[float], voice: Voice) -> float:
+ """The share of line starts with the voice rising within 0.25 s."""
+ if not starts or not voice.strength.size:
+ return 0.0
+ return sum(voice.onset_near(t, 0.25) is not None for t in starts) / len(starts)
+
+
+def retime_gate(result: Retimed) -> bool:
+ """Whether a result is good enough to show: enough of the lyrics heard,
+ and enough lines found where they are sung."""
+ lines = result.counted
+ return (
+ lines > 0
+ and result.matched >= LYRICS_RETIME_MIN_MATCHED
+ and result.lines_matched / lines >= LYRICS_RETIME_MIN_LINES_SHARE
+ )
+
+
+# ── the transcript ──
+
+
+def transcript_words(result: dict[str, Any]) -> list[dict[str, Any]]:
+ """The timed words of a worker answer: [{"text", "start", "end"}].
+
+ Looser than the segments transcribe.py keeps as lyrics: a chorus sung
+ four times compresses as well as a loop Whisper made up, so the
+ compression ratio drops nothing here. Only a window Whisper itself took
+ for silence goes; a word made up over an instrumental anchors nothing,
+ since an anchor needs the voice (Voice.voiced_near)."""
+ from app.pipeline.transcribe import _LOGPROB_FLOOR, _NO_SPEECH_PROB, _number, _words
+
+ words: list[dict[str, Any]] = []
+ segments = result.get("segments")
+ for segment in segments if isinstance(segments, list) else []:
+ if not isinstance(segment, dict):
+ continue
+ no_speech = _number(segment.get("no_speech_prob")) or 0.0
+ logprob = _number(segment.get("avg_logprob")) or 0.0
+ if no_speech > _NO_SPEECH_PROB and logprob < _LOGPROB_FLOOR:
+ continue
+ words.extend(_words(segment, words[-1]["start"] if words else 0.0))
+ return words
+
+
+def transcript_path(job_dir: Path) -> Path:
+ return job_dir / LYRICS_TRANSCRIPT_FILE
+
+
+def _vocals_stamp(job_dir: Path) -> list[int] | None:
+ """The vocals stem's size and modification time: what a kept transcript
+ was heard from. A stem made again (a re-separation writing it in place)
+ no longer matches, and its transcript is heard afresh."""
+ try:
+ st = (job_dir / "stems" / "vocals.wav").stat()
+ except OSError:
+ return None
+ return [st.st_size, st.st_mtime_ns]
+
+
+def save_transcript(
+ job_dir: Path,
+ result: dict[str, Any] | None = None,
+ *,
+ words: list[dict[str, Any]] | None = None,
+ language: str | None = None,
+ refined: bool = False,
+) -> None:
+ """Keep heard words beside the lyrics, for timing them again without a
+ second pass: a worker answer's, or ``words`` already taken from one
+ (``refined``: with the lines a first timing missed heard again). Best
+ effort."""
+ from app.pipeline.lyrics_lookup import _write_json_atomic
+
+ if result is not None:
+ words = transcript_words(result)
+ language = result.get("language") if isinstance(result.get("language"), str) else None
+ data = {
+ "v": 1,
+ "vocals": _vocals_stamp(job_dir),
+ "language": language,
+ "refined": refined,
+ "words": [[round(w["start"], 3), round(w["end"], 3), w["text"]] for w in words or []],
+ }
+ try:
+ _write_json_atomic(transcript_path(job_dir), data)
+ except OSError:
+ logger.info("could not keep the transcript in %s", job_dir.name, exc_info=True)
+
+
+def read_transcript(job_dir: Path) -> dict[str, Any] | None:
+ """The kept transcript: {"language", "refined", "words": [{"text",
+ "start", "end"}]}, or None when there is none or it is damaged."""
+ try:
+ data = json.loads(transcript_path(job_dir).read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ return None
+ if not isinstance(data, dict) or data.get("v") != 1 or not isinstance(data.get("words"), list):
+ return None
+ # Heard from another vocals stem than the one there now. A transcript kept
+ # before this was recorded has no stamp and is taken as it is.
+ kept = data.get("vocals")
+ if kept is not None and kept != _vocals_stamp(job_dir):
+ return None
+ words = []
+ for row in data["words"]:
+ if (
+ isinstance(row, list)
+ and len(row) == 3
+ and all(isinstance(x, (int, float)) and not isinstance(x, bool) for x in row[:2])
+ and isinstance(row[2], str)
+ and math.isfinite(row[0])
+ and math.isfinite(row[1])
+ ):
+ words.append({"start": float(row[0]), "end": float(row[1]), "text": row[2]})
+ language = data.get("language")
+ return {
+ "language": language if isinstance(language, str) else None,
+ "refined": data.get("refined") is True,
+ "words": words,
+ }
+
+
+# ── hearing the missed lines again ──
+
+# A run of at least this many lines not found is heard again when there is
+# at least this much singing where it goes, heard this much wider on each
+# side (only the words inside the run's own time replace the first ones).
+_CLIP_MIN_LINES = 2
+_CLIP_MIN_SUNG_SEC = 3.0
+_CLIP_PAD_SEC = 0.4
+
+
+def missed_clips(
+ result: Retimed, voice: Voice | None, duration: float
+) -> list[tuple[float, float]]:
+ """Where the runs of lines not found go, as (start, end) seconds of the
+ stem, when there is singing there: between the end of the line found
+ before and the start of the one found after."""
+ if voice is None:
+ return []
+ timed = sorted(result.timed, key=lambda t: t.start)
+ clips: list[tuple[float, float]] = []
+ k = 0
+ while k < len(timed):
+ if timed[k].anchored:
+ k += 1
+ continue
+ j = k
+ while j < len(timed) and not timed[j].anchored:
+ j += 1
+ lo = timed[k - 1].end if k > 0 else 0.0
+ hi = timed[j].start if j < len(timed) else (duration or voice.hop * voice.voiced.size)
+ if j - k >= _CLIP_MIN_LINES and voice.voiced_between(lo, hi) >= _CLIP_MIN_SUNG_SEC:
+ clips.append((lo, hi))
+ k = j
+ return clips
+
+
+def merge_words(
+ words: list[dict[str, Any]], extra: list[dict[str, Any]], clips: list[tuple[float, float]]
+) -> list[dict[str, Any]]:
+ """``words`` with those inside ``clips`` replaced by ``extra``, heard
+ again there, in time order."""
+
+ def inside(w: dict[str, Any]) -> bool:
+ return any(a <= w["start"] < b for a, b in clips)
+
+ kept = [w for w in words if not inside(w)]
+ return sorted(kept + [w for w in extra if inside(w)], key=lambda w: w["start"])
+
+
+# A line found only when heard again is kept where the copy's own stamps,
+# moved as the lines found around it were, put it within this much.
+_CLIP_AGREE_SEC = 1.5
+
+
+def better(new: Retimed, old: Retimed, pairs: list[tuple[float | None, str]]) -> bool:
+ """Whether a timing with missed lines heard again is the better one: it
+ finds more lines, and every line it finds that the first did not sits
+ where the copy's stamps and the lines both found in the same place
+ around it say. A chorus
+ sung more often than the copy has it is heard again too, and its later
+ times must not take the copy's lines."""
+ if (new.lines_matched, new.matched) <= (old.lines_matched, old.matched):
+ return False
+ # The lines both found in the same place: what the others go by.
+ shifts = [
+ (k, t.start - p)
+ for k, (t, n, (p, _x)) in enumerate(zip(old.timed, new.timed, pairs, strict=True))
+ if t.anchored and n.anchored and abs(t.start - n.start) < 0.3 and p is not None
+ ]
+ for k, (t, (prior, _x)) in enumerate(zip(new.timed, pairs, strict=True)):
+ if not t.anchored or old.timed[k].anchored:
+ continue
+ if prior is None or not shifts:
+ return False
+ near = sorted(shifts, key=lambda ks: abs(ks[0] - k))[:4]
+ expected = float(np.median([sh for _k, sh in near]))
+ if abs(t.start - prior - expected) > _CLIP_AGREE_SEC:
+ return False
+ return True
+
+
+# ── the language ──
+
+# The commonest words of each language in Latin letters StemDeck's lyrics
+# come in, only those no other of them shares: a text in one of them uses a
+# handful a line. Whisper decides the language from 30 s of the vocals, and
+# on a song with an English-sounding hook it can decide wrong (Nena
+# "Leuchtturm": English, a transcription that matched 15% of the lyrics; in
+# German, 89%).
+_COMMON_WORDS = {
+ "en": "the you and i'm don't your that with what this love know just can't",
+ "de": "ich und nicht ist die der das mich dich mir dir wir auch noch wenn",
+ "fr": "je tu et le les est pas qui une moi toi dans pour c'est mon ne j'ai",
+ "es": "yo y el los las pero más como está eres tú mí porque",
+ "pt": "eu você e não é meu minha uma com mais tem sou quando",
+ "pl": "nie się że jak mnie już tylko jest ci dla czy",
+ "id": "aku kau kamu dan yang ini itu untuk dengan cinta hati akan",
+}
+_COMMON = {lang: frozenset(words.split()) for lang, words in _COMMON_WORDS.items()}
+# At least this many common words, and this many times the next language's.
+_LANGUAGE_MIN_HITS = 8
+_LANGUAGE_MIN_LEAD = 1.5
+_WORDS = re.compile(r"[^\W\d_]+(?:'[^\W\d_]+)?")
+
+
+def text_language(text: str) -> str | None:
+ """The language of lyrics in Latin letters, from their commonest words,
+ or None when they do not show it clearly."""
+ words = _WORDS.findall(text.casefold())
+ scores = sorted(
+ ((sum(w in common for w in words), lang) for lang, common in _COMMON.items()),
+ reverse=True,
+ )
+ (best, lang), (second, _other) = scores[0], scores[1]
+ if best >= _LANGUAGE_MIN_HITS and best >= _LANGUAGE_MIN_LEAD * second:
+ return lang
+ return None
+
+
+def script_language(text: str) -> str | None:
+ """The language a text's script alone shows: Korean for Hangul,
+ Japanese for kana, Chinese for Chinese characters with neither. None for
+ anything else."""
+ hangul = kana = han = 0
+ for ch in text:
+ cp = ord(ch)
+ if 0xAC00 <= cp <= 0xD7AF:
+ hangul += 1
+ elif 0x3040 <= cp <= 0x30FF:
+ kana += 1
+ elif _han_or_kana(ch):
+ han += 1
+ letters = sum(ch.isalpha() for ch in text) or 1
+ if hangul / letters > 0.3:
+ return "ko"
+ if kana / letters > 0.1:
+ return "ja"
+ if han / letters > 0.3:
+ return "zh"
+ return None
+
+
+# ── the formats ──
+
+
+def lyric_pairs(entry: dict[str, Any]) -> list[tuple[float | None, str]]:
+ """The lyrics' lines as (stamp, text), in time order, one per stamp;
+ (None, text) for text-only lyrics."""
+ if entry.get("synced"):
+ return synced_lines(entry["synced"])
+ return [(None, text) for text in plain_lines(entry.get("plain") or "")]
+
+
+def lyric_line_texts(entry: dict[str, Any]) -> list[str]:
+ """The lyrics' own lines, as any timing of them must give them."""
+ return [text for _t, text in lyric_pairs(entry)]
+
+
+_USER_ROW = re.compile(r"\s*\[(\d{1,3}):(\d{2}(?:\.\d{1,3})?)\](.*)")
+_USER_WORD = re.compile(r"<(\d{1,3}):(\d{2}(?:\.\d{1,3})?)>")
+
+
+def clean_user_synced(value: Any, lines: list[str]) -> str | None:
+ """A manual timing as kept, or None when it is not one of these lyrics:
+ LRC whose every non-empty line is one [mm:ss.xx] stamp and text (enhanced
+ word stamps allowed, no other tag), the texts exactly
+ ``lines`` in order (lyric_line_texts), and the line stamps never going
+ back in time. Empty stamped lines (a pause) are allowed anywhere."""
+ if not isinstance(value, str) or not lines or len(value) > LYRICS_TIMED_MAX_CHARS:
+ return None
+ rows = [raw for raw in value.splitlines() if raw.strip()]
+ if len(rows) > LYRICS_USER_SYNCED_MAX_LINES:
+ return None
+ texts: list[str] = []
+ last = -1.0
+ for raw in rows:
+ m = _USER_ROW.fullmatch(raw)
+ if not m or float(m.group(2)) >= 60:
+ return None
+ seconds = int(m.group(1)) * 60 + float(m.group(2))
+ if seconds < last:
+ return None
+ last = seconds
+ rest = m.group(3)
+ if any(float(w.group(2)) >= 60 for w in _USER_WORD.finditer(rest)):
+ return None
+ # Word stamps only: no other tag, and no stamp of another shape.
+ stripped = _WORD_STAMP.sub("", rest)
+ if re.search(r"\[\d|<\d", stripped):
+ return None
+ text = line_text(rest)
+ if text:
+ texts.append(text)
+ if texts != lines:
+ return None
+ return "\n".join(raw.strip() for raw in rows)
+
+
+ALIGN_METHODS = frozenset(("whisper",))
+
+
+def _count(value: Any) -> int | None:
+ return value if type(value) is int and value >= 0 else None
+
+
+def clean_aligned(value: Any, lines: list[str]) -> dict[str, Any] | None:
+ """The "aligned" of lyrics.json as kept, or None when it is malformed or
+ not a timing of ``lines`` (the lyrics' own, lyric_line_texts)."""
+ if not isinstance(value, dict) or value.get("method") not in ALIGN_METHODS:
+ return None
+ synced = value.get("synced")
+ if not isinstance(synced, str) or not synced or len(synced) > LYRICS_TIMED_MAX_CHARS:
+ return None
+ matched, at = value.get("matched"), value.get("at")
+ for number in (matched, at):
+ if isinstance(number, bool) or not isinstance(number, (int, float)):
+ return None
+ if not (math.isfinite(matched) and 0 <= matched <= 1 and math.isfinite(at) and at >= 0):
+ return None
+ lines_matched, count = _count(value.get("lines_matched")), _count(value.get("lines"))
+ if lines_matched is None or count is None or lines_matched > count:
+ return None
+ if not lines or [text for _t, text in synced_lines(synced)] != lines:
+ return None
+ return {
+ "synced": synced,
+ "method": value["method"],
+ "matched": float(matched),
+ "lines_matched": lines_matched,
+ "lines": count,
+ "at": float(at),
+ }
+
+
+def aligned_entry(result: Retimed) -> dict[str, Any]:
+ return {
+ "synced": to_lrc(result),
+ "method": "whisper",
+ "matched": round(result.matched, 3),
+ "lines_matched": result.lines_matched,
+ "lines": len(result.lines),
+ "at": round(time.time(), 3),
+ }
+
+
+# ── running it ──
+
+_STAGE = "Timing lyrics to the vocals..."
+# The share of a run's progress the transcription takes; the rest is quick.
+_TRANSCRIBE_SHARE = 0.9
+# A new timing is kept over the current one only when it puts at most this
+# much smaller a share of its line starts on a rise of the voice.
+_NO_WORSE_MARGIN = 0.05
+# A current timing counts as confirmed when a whole-song shift of at most
+# this much fits it.
+_CONFIRMED_SHIFT_SEC = 0.3
+
+
+class RetimeFailed(Exception):
+ """There is nothing to time, or the transcription failed."""
+
+
+@dataclass
+class Outcome:
+ state: str # "done" or "unsure"
+ matched: float
+ lines_matched: int
+ lines: int
+ # The timing itself, for lyrics the browser keeps (nothing is written).
+ aligned: dict[str, Any] | None = None
+
+
+def _language(entry: dict[str, Any], text: str) -> str:
+ """The language to transcribe in: the lyrics' own when known, else what
+ their script or their words show, else "" for Whisper to decide."""
+ language = entry.get("language")
+ if isinstance(language, str) and re.fullmatch(r"[a-z]{2,3}", language):
+ return language
+ return script_language(text) or text_language(text) or ""
+
+
+def _hear(
+ job: Any,
+ job_dir: Path,
+ language: str,
+ cancelled: Callable[[], bool],
+ report: Callable[[str, int | None], None] | None,
+ clips: list[tuple[float, float]] | None = None,
+) -> dict[str, Any] | None:
+ """A worker answer for the job's vocals, or only ``clips`` of them; None
+ when the worker failed. Raises RetimeFailed without vocals, JobCancelled."""
+ from app.pipeline.transcribe import _device, _run_worker, _spawn_worker_cmd
+
+ vocals = job_dir / "stems" / "vocals.wav"
+ if not vocals.is_file():
+ raise RetimeFailed("no vocals stem")
+ cmd = _spawn_worker_cmd(vocals, _device(job))
+ if language:
+ cmd += ["--language", language]
+ if clips:
+ # Heard a little wider than the run, for the words at its edges.
+ padded = [(max(0.0, a - _CLIP_PAD_SEC), b + _CLIP_PAD_SEC) for a, b in clips]
+ cmd += ["--clips", ",".join(f"{a:.2f}:{b:.2f}" for a, b in padded)]
+ return _run_worker(job, cmd, cancelled=cancelled, report=report)
+
+
+def _transcribe(
+ job: Any,
+ job_dir: Path,
+ language: str,
+ cancelled: Callable[[], bool],
+ report: Callable[[str, int | None], None],
+) -> tuple[list[dict[str, Any]], bool]:
+ """The heard words, and whether missed lines were heard again already:
+ the kept transcript, else a new one (kept too). Raises RetimeFailed, or
+ JobCancelled."""
+ kept = read_transcript(job_dir)
+ if kept is not None and kept["words"]:
+ return kept["words"], kept["refined"]
+ result = _hear(job, job_dir, language, cancelled, report)
+ if result is None:
+ raise RetimeFailed("transcription failed")
+ save_transcript(job_dir, result)
+ return transcript_words(result), False
+
+
+def _current_starts(entry: dict[str, Any], voice: Voice) -> list[float] | None:
+ """The line starts the lyrics show now, when the voice confirms them:
+ their own stamps with the user's offset, moved by a whole-song shift of
+ at most _CONFIRMED_SHIFT_SEC. None when nothing confirms them."""
+ from app.pipeline.lyrics_align import estimate_offset
+
+ if not entry.get("synced"):
+ return None
+ offset = float(entry.get("offset_sec") or 0.0)
+ starts = sorted(t + offset for t, _text in synced_lines(entry["synced"]))
+ shift = estimate_offset(starts, voice.strength, voice.hop)
+ if shift is None or abs(shift) > _CONFIRMED_SHIFT_SEC:
+ return None
+ return [t + shift for t in starts]
+
+
+def no_worse(entry: dict[str, Any], result: Retimed, voice: Voice | None) -> bool:
+ """Whether ``result`` is at least as good as the timing the lyrics have,
+ when the voice confirms that timing: judged by the share of line starts
+ on a rise of the voice. True when nothing confirms the current one."""
+ if voice is None:
+ return True
+ current = _current_starts(entry, voice)
+ if current is None:
+ return True
+ new = onset_fit([t.start for t in result.timed], voice)
+ return new >= onset_fit(current, voice) - _NO_WORSE_MARGIN
+
+
+def retime_job(
+ job: Any,
+ job_dir: Path,
+ *,
+ cancelled: Callable[[], bool],
+ report: Callable[[float], None] | None = None,
+ lyrics: dict[str, Any] | None = None,
+) -> Outcome:
+ """Time the job's lyrics to its vocals and keep the result as their
+ "aligned" when it passes the gate (retime_gate, no_worse). Blocking:
+ may run a transcription of minutes. Raises RetimeFailed when there is
+ nothing to time or the transcription failed, JobCancelled when
+ ``cancelled()`` turned true; the lyrics are then left as they were.
+
+ ``lyrics``, {"synced", "plain"}, are lyrics the browser keeps rather
+ than lyrics.json: timed the same way, and the result is only answered
+ (Outcome.aligned), never written."""
+ from app.core.models import JobCancelled
+ from app.pipeline.lyrics_align import _envelope
+ from app.pipeline.lyrics_lookup import _OFFSET_LOCK, read_lyrics, write_lyrics
+
+ entry = read_lyrics(job_dir) if lyrics is None else lyrics
+ if entry is None:
+ raise RetimeFailed("no lyrics")
+ pairs = lyric_pairs(entry)
+ if not pairs:
+ raise RetimeFailed("no lines")
+
+ def progress(phase: str, pct: int | None) -> None:
+ if report is not None and phase == "transcribe" and pct is not None:
+ report(_TRANSCRIBE_SHARE * pct / 100)
+
+ language = _language(entry, " ".join(text for _t, text in pairs))
+ words, refined = _transcribe(job, job_dir, language, cancelled, progress)
+ if cancelled():
+ raise JobCancelled()
+ envelope = _envelope(job_dir / "stems")
+ voice = Voice.of(*envelope) if envelope is not None else None
+ duration = float(getattr(job, "duration_sec", 0) or entry.get("duration") or 0)
+ result = retime_lines(pairs, words, voice, duration)
+ clips = [] if refined else missed_clips(result, voice, duration)
+ if clips:
+ # The lines not found, heard again on their own: Whisper often
+ # skips a chorus sung once more at the end, and hears it alone.
+ extra = _hear(job, job_dir, language, cancelled, None, clips)
+ if extra is not None:
+ merged = merge_words(words, transcript_words(extra), clips)
+ again = retime_lines(pairs, merged, voice, duration)
+ if better(again, result, pairs):
+ result, words = again, merged
+ save_transcript(job_dir, words=words, language=language or None, refined=True)
+ if cancelled():
+ raise JobCancelled()
+ outcome = Outcome("unsure", round(result.matched, 3), result.lines_matched, len(result.lines))
+ if not retime_gate(result) or not no_worse(entry, result, voice):
+ logger.info(
+ "[%s] lyrics timing unsure: %.0f%% heard, %d of %d lines found",
+ job.id,
+ result.matched * 100,
+ result.lines_matched,
+ result.counted,
+ )
+ return outcome
+ aligned = aligned_entry(result)
+ if lyrics is not None:
+ outcome.state, outcome.aligned = "done", aligned
+ return outcome
+ with _OFFSET_LOCK:
+ if cancelled():
+ raise JobCancelled()
+ now = read_lyrics(job_dir)
+ # Other lyrics since (another version, letters mended): this timing
+ # is not theirs.
+ if now is None or lyric_line_texts(now) != [text for _t, text in pairs]:
+ raise RetimeFailed("the lyrics changed meanwhile")
+ if not write_lyrics(job, job_dir, {**now, "aligned": aligned}):
+ raise RetimeFailed("could not write lyrics")
+ logger.info(
+ "[%s] lyrics timed line by line: %.0f%% heard, %d of %d lines found",
+ job.id,
+ result.matched * 100,
+ result.lines_matched,
+ result.counted,
+ )
+ outcome.state = "done"
+ return outcome
+
+
+def wants_retime(job: Any, job_dir: Path) -> bool:
+ """Whether a job just separated gets its lyrics timed line by line: LRCLIB
+ lyrics that nothing has confirmed the timing of line by line (a version
+ of another length, moved by a whole-song shift or not, or one the
+ track's length the voice does not confirm), not aligned by hand nor
+ timed already, under the transcribe_lyrics setting (auto: a CUDA job
+ only)."""
+ from app.core.settings import transcribe_lyrics_enabled
+ from app.pipeline.lyrics_align import detect_offset
+ from app.pipeline.lyrics_lookup import read_lyrics
+
+ if not transcribe_lyrics_enabled(getattr(job, "compute_device", None)):
+ return False
+ entry = read_lyrics(job_dir)
+ if entry is None or entry["source"] != "lrclib" or not entry["synced"]:
+ return False
+ if any(key in entry for key in ("aligned", "user_synced", "offset_sec")):
+ return False
+ # "shifted": a copy of another cut moved by one amount, which a cut
+ # inside the song (a verse more or less) leaves wrong from there on.
+ if entry["timing"] in ("unverified", "shifted"):
+ return True
+ return detect_offset(entry["synced"], job_dir / "stems") is None
+
+
+def retime_after_separation(job: Any, job_dir: Path) -> bool:
+ """The pipeline's last step: time the lyrics line by line when
+ wants_retime. True when "aligned" was written. Never raises but
+ JobCancelled: lyrics are optional, and a failure here must not cost the
+ user a separation that has already succeeded."""
+ from app.core.models import JobCancelled, _set
+
+ try:
+ if job.cancel_requested:
+ raise JobCancelled()
+ if not wants_retime(job, job_dir):
+ return False
+ _set(job, stage=_STAGE)
+ outcome = retime_job(job, job_dir, cancelled=lambda: job.cancel_requested)
+ return outcome.state == "done"
+ except JobCancelled:
+ raise
+ except RetimeFailed as exc:
+ logger.info("[%s] lyrics not timed: %s", job.id, exc)
+ return False
+ except Exception:
+ logger.exception("[%s] lyrics timing failed", job.id)
+ return False
+
+
+# ── on demand ──
+
+
+@dataclass
+class RetimeRun:
+ """One on-demand run of a job, for GET .../lyrics/retime: "running"
+ until it ends "done", "unsure", "failed" or, cancelled, "idle"."""
+
+ state: str = "running"
+ progress: float = 0.0
+ outcome: Outcome | None = None
+ cancel: threading.Event = field(default_factory=threading.Event)
+ # Set once it holds the pipeline lock: a run cancelled while waiting for
+ # it is over at once.
+ started: bool = False
+ # Set when its task has finished, whatever the outcome: from then on it
+ # holds no file of the job open (wait_run_ended).
+ ended: threading.Event = field(default_factory=threading.Event)
+
+ def view(self) -> dict[str, Any]:
+ out: dict[str, Any] = {"state": self.state}
+ if self.state == "running":
+ out["progress"] = round(self.progress, 3)
+ if self.outcome is not None:
+ out["matched"] = self.outcome.matched
+ out["lines_matched"] = self.outcome.lines_matched
+ out["lines"] = self.outcome.lines
+ if self.outcome.aligned is not None:
+ out["aligned"] = self.outcome.aligned
+ return out
+
+
+_RUNS: dict[str, RetimeRun] = {}
+_RUNS_LOCK = threading.Lock()
+
+
+def claim_run(job_id: str) -> RetimeRun | None:
+ """A new run for ``job_id``, or None while one is running already."""
+ with _RUNS_LOCK:
+ current = _RUNS.get(job_id)
+ if current is not None and current.state == "running":
+ return None
+ run = RetimeRun()
+ _RUNS[job_id] = run
+ return run
+
+
+def current_run(job_id: str) -> RetimeRun | None:
+ with _RUNS_LOCK:
+ return _RUNS.get(job_id)
+
+
+def cancel_run(job_id: str) -> bool:
+ """Ask a running run of ``job_id`` to stop. True when one was running."""
+ with _RUNS_LOCK:
+ run = _RUNS.get(job_id)
+ if run is None or run.state != "running":
+ return False
+ run.cancel.set()
+ if not run.started:
+ run.state = "idle"
+ return True
+
+
+def wait_run_ended(job_id: str, timeout: float) -> bool:
+ """Wait up to ``timeout`` seconds for a run of ``job_id`` that got as far
+ as the vocals to let go of them. True when none holds them now. Deleting
+ a job waits on this: on Windows a file the transcription still has open
+ cannot be removed, and the delete would fail and leave the folder."""
+ with _RUNS_LOCK:
+ run = _RUNS.get(job_id)
+ if run is None or not run.started:
+ return True
+ return run.ended.wait(timeout)
+
+
+def forget_run(job_id: str) -> None:
+ with _RUNS_LOCK:
+ _RUNS.pop(job_id, None)
+
+
+def saved_view(entry: dict[str, Any] | None) -> dict[str, Any]:
+ """The state of a job with no run since the server started: "done" when
+ its lyrics carry a line-by-line timing, else "idle"."""
+ aligned = (entry or {}).get("aligned")
+ if not aligned:
+ return {"state": "idle"}
+ return {
+ "state": "done",
+ "matched": aligned["matched"],
+ "lines_matched": aligned["lines_matched"],
+ "lines": aligned["lines"],
+ }
diff --git a/app/pipeline/runner.py b/app/pipeline/runner.py
index 906fb6cc..bfd3ab73 100644
--- a/app/pipeline/runner.py
+++ b/app/pipeline/runner.py
@@ -34,6 +34,7 @@
from app.pipeline.errors import classify_failure
from app.pipeline.identify import IdentifyLookup, release_source
from app.pipeline.lyrics_lookup import LyricsLookup
+from app.pipeline.lyrics_retime import retime_after_separation
from app.pipeline.sections import detect_sections
from app.pipeline.separate import separate
from app.pipeline.transcribe import transcribe_lyrics
@@ -325,6 +326,12 @@ def _run_with_band_lookup(job: Job, source: Path, job_dir: Path) -> None:
mark = time.monotonic()
transcribe_lyrics(job, job_dir)
_lap(job, "transcribe", mark)
+ # LRCLIB lyrics whose timing nothing confirmed, timed line by line to the
+ # vocals (lyrics_retime.py): after the above, whose transcript it reuses.
+ # Never raises but for a cancel.
+ mark = time.monotonic()
+ retime_after_separation(job, job_dir)
+ _lap(job, "retime", mark)
def _run_blocking(job: Job, url: str, job_dir: Path) -> None:
diff --git a/app/pipeline/transcribe.py b/app/pipeline/transcribe.py
index 7cde86a0..054cecfd 100644
--- a/app/pipeline/transcribe.py
+++ b/app/pipeline/transcribe.py
@@ -38,6 +38,7 @@
import threading
import time
from collections import deque
+from collections.abc import Callable
from pathlib import Path
from typing import Any
@@ -63,6 +64,7 @@
from app.core.settings import lyrics_mending_enabled, transcribe_lyrics_enabled
from app.pipeline.lyrics_lookup import lyrics_path, read_lyrics, write_lyrics
from app.pipeline.lyrics_repair import looks_stripped, mend_from_transcript, words_of
+from app.pipeline.lyrics_retime import save_transcript
logger = logging.getLogger("stemdeck.transcribe")
@@ -176,10 +178,26 @@ def _stage_for(phase: str, pct: int | None) -> str:
return _STAGE
-def _run_worker(job: Job, cmd: list[str]) -> dict[str, Any] | None:
+def _run_worker(
+ job: Job,
+ cmd: list[str],
+ *,
+ cancelled: Callable[[], bool] | None = None,
+ report: Callable[[str, int | None], None] | None = None,
+) -> dict[str, Any] | None:
"""Run the worker with cancellation, a total timeout and stall detection.
Returns its JSON answer, or None when it failed, timed out or stalled.
- Raises JobCancelled when the job was cancelled meanwhile."""
+ Raises JobCancelled when the job was cancelled meanwhile.
+
+ ``cancelled`` says when to stop (by default, the job's own cancel), and
+ ``report`` hears each phase and percentage (by default, the job's stage
+ text shows them): a run on a finished job (lyrics_retime.py) keeps its own
+ cancel and progress and leaves the job's stage alone."""
+ if cancelled is None:
+
+ def cancelled() -> bool:
+ return job.cancel_requested
+
env = os.environ.copy()
# The worker hard-exits when this process disappears, so a Force Quit
# cannot leave it holding the GPU (#519).
@@ -235,21 +253,27 @@ def read_stderr() -> None:
reader.start()
started = time.monotonic()
- shown = _STAGE
+ shown: Any = _STAGE if report is None else None
failed = None
set_proc(job.id, proc)
try:
while proc.poll() is None:
- if job.cancel_requested:
+ if cancelled():
_terminate(proc)
raise JobCancelled()
now = time.monotonic()
with lock:
silent_for = now - last_output[0]
- stage = _stage_for(progress[0], progress[1])
- if stage != shown:
- shown = stage
- _set(job, stage=stage)
+ phase, pct = progress[0], progress[1]
+ if report is not None:
+ if (phase, pct) != shown:
+ shown = (phase, pct)
+ report(phase, pct)
+ else:
+ stage = _stage_for(phase, pct)
+ if stage != shown:
+ shown = stage
+ _set(job, stage=stage)
if now - started > TIMEOUT_TRANSCRIBE:
failed = f"timed out after {TIMEOUT_TRANSCRIBE}s"
_terminate(proc)
@@ -264,7 +288,7 @@ def read_stderr() -> None:
for reader in readers:
reader.join(timeout=2)
- if job.cancel_requested:
+ if cancelled():
raise JobCancelled()
if failed is None and proc.returncode != 0:
failed = f"exit {proc.returncode}"
@@ -551,6 +575,10 @@ def mend_lyrics(job: Job, job_dir: Path) -> bool:
result = _run_worker(job, _spawn_worker_cmd(vocals, _device(job)) + _language_gate())
if result is None:
return False
+ if not result.get("skipped"):
+ # The same words time the lyrics line by line (lyrics_retime.py)
+ # without a second pass.
+ save_transcript(job_dir, result)
language = result.get("language")
probability = _number(result.get("language_probability")) or 0.0
if (
diff --git a/app/pipeline/transcribe_worker.py b/app/pipeline/transcribe_worker.py
index 11c574a1..3e079fb2 100644
--- a/app/pipeline/transcribe_worker.py
+++ b/app/pipeline/transcribe_worker.py
@@ -49,9 +49,35 @@ def _parser() -> argparse.ArgumentParser:
# accents (transcribe.py), which only a language written with them can.
parser.add_argument("--languages", default="")
parser.add_argument("--min-language-probability", type=float, default=0.0)
+ # Known lyrics being timed (lyrics_retime.py): the language they are
+ # in, which skips detection.
+ parser.add_argument("--language", default="")
+ # Only these stretches of the stem ("start:end,start:end", seconds), each
+ # heard on its own: the lines a first pass did not find, heard again
+ # without the rest of the song around them. Times stay the stem's.
+ parser.add_argument("--clips", default="")
return parser
+# At most this many clips, each at most this long: a clip is a few lines.
+_MAX_CLIPS = 32
+_MAX_CLIP_SEC = 120.0
+
+
+def parse_clips(text: str) -> list[tuple[float, float]]:
+ """The --clips argument as (start, end) seconds, in order, each within
+ _MAX_CLIP_SEC; malformed pieces are left out."""
+ clips: list[tuple[float, float]] = []
+ for piece in text.split(",")[:_MAX_CLIPS]:
+ try:
+ a, b = (float(x) for x in piece.split(":"))
+ except ValueError:
+ continue
+ if math.isfinite(a) and math.isfinite(b) and 0 <= a < b:
+ clips.append((a, min(b, a + _MAX_CLIP_SEC)))
+ return sorted(clips)
+
+
def _phase(name: str) -> None:
print(f"@@PHASE@@{name}", file=sys.stderr, flush=True)
@@ -156,7 +182,10 @@ def transcribe(args: argparse.Namespace) -> dict:
_phase("load")
model = load_model(whisper, torch, model_name, device, args.download_root)
- if model.is_multilingual:
+ forced = args.language if model.is_multilingual else ""
+ if forced:
+ language, probs = forced, {forced: 1.0}
+ elif model.is_multilingual:
start, end = loudest_window(audio)
mel = whisper.log_mel_spectrogram(
whisper.pad_or_trim(audio[start:end]), n_mels=model.dims.n_mels
@@ -183,28 +212,40 @@ def transcribe(args: argparse.Namespace) -> dict:
}
_phase("transcribe")
- result = model.transcribe(
- audio,
- language=language,
- word_timestamps=True,
- fp16=device == "cuda",
- # Progress bars (to stderr), but not every decoded line.
- verbose=False,
- # Carrying the previous window's text forward is what makes Whisper
- # repeat a line forever over a long instrumental; lyrics lose little
- # without it.
- condition_on_previous_text=False,
- hallucination_silence_threshold=args.silence_sec,
- )
+
+ def hear(samples):
+ return model.transcribe(
+ samples,
+ language=language,
+ word_timestamps=True,
+ fp16=device == "cuda",
+ # Progress bars (to stderr), but not every decoded line.
+ verbose=False,
+ # Carrying the previous window's text forward is what makes
+ # Whisper repeat a line forever over a long instrumental; lyrics
+ # lose little without it.
+ condition_on_previous_text=False,
+ hallucination_silence_threshold=args.silence_sec,
+ )
+
+ clips = parse_clips(args.clips)
+ heard: list[tuple[float, dict]] = []
+ if clips:
+ for a, b in clips:
+ piece = audio[int(a * _SAMPLE_RATE) : int(b * _SAMPLE_RATE)]
+ if piece.size >= _SAMPLE_RATE // 2:
+ heard.extend((a, seg) for seg in hear(piece).get("segments", []))
+ else:
+ heard = [(0.0, seg) for seg in hear(audio).get("segments", [])]
peak = None
if device == "cuda":
peak = int(torch.cuda.max_memory_allocated() // (1024 * 1024))
segments = []
- for seg in result.get("segments", []):
+ for offset, seg in heard:
words = [
{
- "start": float(w["start"]),
- "end": float(w["end"]),
+ "start": float(w["start"]) + offset,
+ "end": float(w["end"]) + offset,
"word": str(w["word"]),
"probability": float(w.get("probability", 0.0)),
}
@@ -212,8 +253,8 @@ def transcribe(args: argparse.Namespace) -> dict:
]
segments.append(
{
- "start": float(seg["start"]),
- "end": float(seg["end"]),
+ "start": float(seg["start"]) + offset,
+ "end": float(seg["end"]) + offset,
"text": str(seg.get("text", "")),
"words": words,
"no_speech_prob": float(seg.get("no_speech_prob", 0.0)),
diff --git a/static/css/daw.css b/static/css/daw.css
index 9d1c1587..64695e49 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -4023,6 +4023,96 @@ input, textarea { font-family: inherit; }
}
.lyrics-text { margin: 0; padding: 1px 8px; font-size: 13.5px; line-height: 1.55; color: var(--fg-2); }
+/* The Align panel's note when the lines are timed one by one, so the offset
+ is not what is in effect. */
+.lyrics-align-note { margin: 0; font-size: 11px; line-height: 1.4; color: var(--fg-2); }
+.lyrics-align-note:empty { display: none; }
+.lyrics-align-btn:disabled { opacity: 0.45; cursor: default; }
+.lyrics-align-btn:disabled:hover { background: var(--panel-2); color: var(--fg-2); }
+
+/* ── Sync lines (lyrics.js) ──
+ The panel under the tools row while the mode is on: which line is next,
+ one big Tap, the options, the resets. Built for a glance mid-song. */
+.lyrics-sync {
+ display: flex; flex-direction: column; gap: 7px;
+ padding: 8px; border: 1px solid var(--accent); border-radius: 8px; background: var(--panel);
+}
+.lyrics-sync-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
+.lyrics-sync-title { margin: 0; font-size: 12px; font-weight: 700; color: var(--fg); }
+.lyrics-sync-kind { font-size: 10.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.lyrics-sync-next {
+ margin: 0; padding: 6px 8px; border-radius: 6px; background: var(--bg-2);
+ font-size: 13px; line-height: 1.35; color: var(--fg);
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.lyrics-sync-next-label { display: block; font-size: 9.5px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); }
+.lyrics-sync-tap {
+ min-height: 44px; font-size: 15px; letter-spacing: 0.04em;
+ background: var(--accent); border-color: var(--accent); color: var(--bg);
+}
+.lyrics-sync-tap:hover { background: var(--accent); color: var(--bg); filter: brightness(1.08); }
+.lyrics-sync-tap:disabled, .lyrics-sync-tap:disabled:hover { background: var(--panel-2); border-color: var(--border-strong); color: var(--muted); filter: none; }
+.lyrics-sync-tap kbd { font-family: var(--font-mono); font-size: 10px; font-weight: 600; opacity: 0.75; margin-left: 6px; }
+/* A tap lands: the button flashes, so a tap can be seen as well as heard. */
+.lyrics-sync-tap.tapped { animation: lyrics-tap 180ms ease-out; }
+@keyframes lyrics-tap { from { filter: brightness(1.6); } to { filter: none; } }
+@media (prefers-reduced-motion: reduce) { .lyrics-sync-tap.tapped { animation: none; } }
+.lyrics-sync-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
+.lyrics-sync-row .lyrics-align-btn { flex: 1 1 auto; }
+.lyrics-sync-opts { display: flex; flex-wrap: wrap; gap: 4px 12px; }
+.lyrics-sync-opt { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--fg-2); cursor: pointer; }
+.lyrics-sync-opt input { margin: 0; accent-color: var(--accent); }
+.lyrics-sync-keys { margin: 0; font-size: 10.5px; line-height: 1.5; color: var(--muted); }
+.lyrics-sync-keys kbd {
+ font-family: var(--font-mono); font-size: 10px; padding: 0 4px; border-radius: 3px;
+ border: 1px solid var(--border-strong); background: var(--panel-2); color: var(--fg-2);
+}
+/* In the mode each line shows when it starts, and the line in hand is framed
+ and carries its own nudges. The time is drawn by CSS from data-time, so it
+ is not part of the line's text. */
+.lyrics-body.syncing .lyrics-line::before {
+ content: attr(data-time);
+ display: inline-block; min-width: 4.6em; margin-right: 6px;
+ font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; color: var(--muted);
+ font-variant-numeric: tabular-nums;
+}
+.lyrics-body.syncing .lyrics-line.selected { background: var(--panel); outline: 1px solid var(--accent); outline-offset: -1px; }
+.lyrics-line-tools { display: flex; align-items: center; gap: 4px; padding: 2px 8px 6px; }
+.lyrics-line-tools .lyrics-align-btn { min-height: 22px; padding: 0 6px; font-size: 10.5px; }
+.lyrics-line-tools .lyrics-align-nudge { font-family: var(--font-mono); }
+.lyrics-line-tools .lyrics-line-here { margin-left: auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--accent); border-color: var(--accent); }
+
+/* The lyrics lane over the waveform (lyricsLane.js), shaped like the
+ Sections ribbon above it: markers at percentages of a track that is the
+ zoomed waveform's width. */
+.daw-lyrics-ribbon { display: flex; flex-shrink: 0; border-bottom: 1px solid var(--border); }
+.daw-lyrics-ribbon.hidden { display: none; }
+.daw-lyrics-header { justify-content: space-between; gap: 8px; }
+.daw-lyrics-title-group { display: inline-flex; align-items: baseline; gap: 8px; min-width: 0; overflow: hidden; }
+.daw-lyrics-title-group .daw-label-sub { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.lyrics-lane-done {
+ flex-shrink: 0; min-height: 22px; padding: 0 10px; border-radius: 5px;
+ background: var(--accent); border: 1px solid var(--accent); color: var(--bg);
+ font: inherit; font-size: 11px; font-weight: 700; cursor: pointer;
+}
+.lyrics-lane-done:hover { filter: brightness(1.08); }
+.daw-lyrics-area { flex: 1; position: relative; height: 34px; background: var(--bg-2); overflow: clip; user-select: none; }
+.daw-lyrics-track { position: absolute; inset: 0; right: auto; width: calc(100% * var(--zoom, 1)); }
+.lyr-mark {
+ position: absolute; top: 4px; bottom: 4px; min-width: 6px;
+ display: flex; align-items: center; padding: 0 6px 0 7px; margin: 0;
+ background: none; border: none; border-left: 2px solid var(--fg-2); border-radius: 0;
+ color: var(--fg-2); font: inherit; font-size: 10.5px; text-align: left;
+ cursor: grab; overflow: hidden;
+}
+.lyr-mark:hover { background: rgba(255, 255, 255, 0.05); color: var(--fg); }
+.lyr-mark-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none; }
+.lyr-mark.current { color: var(--fg); border-left-color: var(--fg); background: rgba(255, 255, 255, 0.06); }
+.lyr-mark.selected { color: var(--accent); border-left-color: var(--accent); background: rgba(244, 183, 64, 0.12); z-index: 2; }
+.lyr-mark.dragging { cursor: grabbing; z-index: 3; background: rgba(244, 183, 64, 0.2); }
+.lyr-lane-playhead { position: absolute; inset: 0; pointer-events: none; will-change: transform; z-index: 4; }
+.lyr-lane-playhead i { position: absolute; top: 0; bottom: 0; left: 0; width: 1px; background: var(--accent); opacity: 0.8; }
+
/* ── Artist box (#699) ──
The About dialog's frame, left-aligned and wider, because it holds prose
and lists rather than a logo and two buttons. The body scrolls on its own
diff --git a/static/index.html b/static/index.html
index 77b592d0..517587e0 100644
--- a/static/index.html
+++ b/static/index.html
@@ -579,6 +579,23 @@
Lyrics
+
+
+
+
+ Lyrics
+ Drag a line · ← → nudge
+
+
+
+
+
+
+
+
diff --git a/static/js/i18n.js b/static/js/i18n.js
index 930cc4ce..6900054a 100644
--- a/static/js/i18n.js
+++ b/static/js/i18n.js
@@ -340,6 +340,58 @@ const en = {
"lyrics.align.done": "Done",
"lyrics.align.doneTitle": "Close the panel. Changes are already saved.",
"lyrics.align.saveFailed": "Could not save the alignment. It still applies until the track is closed.",
+ "lyrics.align.perLineUser": "These lines are timed one by one, by you, so the offset here does not apply. Sync lines can change them or reset them.",
+ "lyrics.align.perLineAligned": "These lines are timed one by one from the vocals, so the offset here does not apply. Sync lines can change them.",
+ "lyrics.sync.button": "Sync lines",
+ "lyrics.sync.title": "Sync lines",
+ "lyrics.sync.open": "Sync line by line",
+ "lyrics.sync.openTitle": "Set when each line starts: tap along with the song, drag the lines on the timeline, or nudge them",
+ "lyrics.sync.syncedOnly": "Only lyrics with timing can be synced line by line. These are text only.",
+ "lyrics.sync.kind.user": "Timed by you",
+ "lyrics.sync.kind.aligned": "Timed from the vocals",
+ "lyrics.sync.kind.offset": "Their own timing",
+ "lyrics.sync.saving": "Saving…",
+ "lyrics.sync.saved": "Saved",
+ "lyrics.sync.saveFailed": "Could not save the timing. It still applies until the track is closed.",
+ "lyrics.sync.next": "Next to tap · line {n} of {count}",
+ "lyrics.sync.allSetLabel": "All lines tapped",
+ "lyrics.sync.allSet": "Undo, or click a line to go on from it.",
+ "lyrics.sync.tap": "Tap",
+ "lyrics.sync.tapKey": "T / Enter",
+ "lyrics.sync.tapTitle": "Press as the line starts: it starts at the playhead, and the next line is up.",
+ "lyrics.sync.undo": "Undo",
+ "lyrics.sync.undoTitle": "Undo the last change (Backspace)",
+ "lyrics.sync.leadIn": "Lead-in",
+ "lyrics.sync.leadInTitle": "Clicking a line puts the playhead 2 s before it, so you hear it coming",
+ "lyrics.sync.snap": "Snap to voice",
+ "lyrics.sync.snapTitle": "Taps and drags land where the singing starts, when it is that close. Hold Alt while dragging to place a line freely.",
+ "lyrics.sync.ripple": "Move later lines too",
+ "lyrics.sync.rippleTitle": "A line moved takes the lines after it along, up to the next line you set",
+ "lyrics.sync.retime": "Re-time from vocals",
+ "lyrics.sync.retimeTitle": "Fit every line to the vocals stem again",
+ "lyrics.sync.retiming": "Listening to the vocals…",
+ "lyrics.sync.retimingProgress": "Listening to the vocals… {percent}%",
+ "lyrics.sync.retimed": "Timed from the vocals: {matched} of {count} lines matched.",
+ "lyrics.sync.retimedPlain": "Timed from the vocals.",
+ "lyrics.sync.retimeUnsure": "The vocals did not match the lines well enough. The timing is as it was.",
+ "lyrics.sync.retimeFailed": "Re-timing from the vocals is not available for this track right now.",
+ "lyrics.sync.resetDetected": "Reset to detected",
+ "lyrics.sync.resetDetectedTitle": "Drop your timing and go back to the timing found for these lyrics",
+ "lyrics.sync.nothingDetected": "There is no detected timing to go back to. Try Re-time from vocals.",
+ "lyrics.sync.resetOriginal": "Reset to original",
+ "lyrics.sync.resetOriginalTitle": "Every line back to the lyrics' own timing, as they came, with no offset",
+ "lyrics.sync.backTo.aligned": "Back to the timing from the vocals.",
+ "lyrics.sync.backTo.offset": "Back to the lyrics' own timing, with the Align offset.",
+ "lyrics.sync.backTo.original": "Back to the lyrics' own timing, as they came.",
+ "lyrics.sync.keys": "T or Enter: tap · Backspace: undo · ← →: nudge (Shift: more) · ↑ ↓: line · Esc: done",
+ "lyrics.sync.done": "Done",
+ "lyrics.sync.doneTitle": "Leave Sync lines. Changes are saved as you make them.",
+ "lyrics.sync.here": "Start at playhead",
+ "lyrics.sync.hereTitle": "This line starts where the playhead is",
+ "lyrics.sync.lineToolsAria": "Timing of the line in hand",
+ "lyrics.sync.markAria": "{line}, at {time}",
+ "lyrics.sync.laneTitle": "Lyrics",
+ "lyrics.sync.laneHint": "Drag a line · ← → nudge",
"extract.all": "All",
"extract.moreAria": "Choose stems",
"process.splitStems": "Split stems",
@@ -1085,6 +1137,58 @@ const pl = {
"lyrics.align.done": "Gotowe",
"lyrics.align.doneTitle": "Zamknij panel. Zmiany są już zapisane.",
"lyrics.align.saveFailed": "Nie udało się zapisać dopasowania. Działa do zamknięcia utworu.",
+ "lyrics.align.perLineUser": "Te wersy mają czasy ustawione pojedynczo, przez Ciebie, więc to przesunięcie ich nie dotyczy. Synchronizacja wersów pozwala je zmienić lub zresetować.",
+ "lyrics.align.perLineAligned": "Te wersy mają czasy dopasowane pojedynczo do wokalu, więc to przesunięcie ich nie dotyczy. Synchronizacja wersów pozwala je zmienić.",
+ "lyrics.sync.button": "Synchronizuj wersy",
+ "lyrics.sync.title": "Synchronizuj wersy",
+ "lyrics.sync.open": "Synchronizuj wers po wersie",
+ "lyrics.sync.openTitle": "Ustaw, kiedy zaczyna się każdy wers: stukaj w rytm utworu, przeciągaj wersy na osi czasu lub przesuwaj je",
+ "lyrics.sync.syncedOnly": "Wers po wersie da się zsynchronizować tylko tekst z czasami. Ten jest samym tekstem.",
+ "lyrics.sync.kind.user": "Czasy ustawione przez Ciebie",
+ "lyrics.sync.kind.aligned": "Czasy z wokalu",
+ "lyrics.sync.kind.offset": "Własne czasy tekstu",
+ "lyrics.sync.saving": "Zapisuję…",
+ "lyrics.sync.saved": "Zapisano",
+ "lyrics.sync.saveFailed": "Nie udało się zapisać czasów. Działają do zamknięcia utworu.",
+ "lyrics.sync.next": "Następny do stuknięcia · wers {n} z {count}",
+ "lyrics.sync.allSetLabel": "Wszystkie wersy ustawione",
+ "lyrics.sync.allSet": "Cofnij albo kliknij wers, aby od niego kontynuować.",
+ "lyrics.sync.tap": "Stuknij",
+ "lyrics.sync.tapKey": "T / Enter",
+ "lyrics.sync.tapTitle": "Naciśnij, gdy wers się zaczyna: zacznie się w miejscu głowicy, a kolejny będzie następny.",
+ "lyrics.sync.undo": "Cofnij",
+ "lyrics.sync.undoTitle": "Cofnij ostatnią zmianę (Backspace)",
+ "lyrics.sync.leadIn": "Rozbieg",
+ "lyrics.sync.leadInTitle": "Kliknięcie wersu ustawia głowicę 2 s przed nim, żeby było słychać, jak nadchodzi",
+ "lyrics.sync.snap": "Przyciągaj do głosu",
+ "lyrics.sync.snapTitle": "Stuknięcia i przeciągnięcia trafiają tam, gdzie zaczyna się śpiew, jeśli jest tak blisko. Przytrzymaj Alt podczas przeciągania, aby ustawić wers dowolnie.",
+ "lyrics.sync.ripple": "Przesuwaj też dalsze wersy",
+ "lyrics.sync.rippleTitle": "Przesunięty wers zabiera ze sobą kolejne, aż do następnego ustawionego przez Ciebie",
+ "lyrics.sync.retime": "Dopasuj ponownie do wokalu",
+ "lyrics.sync.retimeTitle": "Dopasuj każdy wers do ścieżki wokalu od nowa",
+ "lyrics.sync.retiming": "Słucham wokalu…",
+ "lyrics.sync.retimingProgress": "Słucham wokalu… {percent}%",
+ "lyrics.sync.retimed": "Dopasowano do wokalu: {matched} z {count} wersów.",
+ "lyrics.sync.retimedPlain": "Dopasowano do wokalu.",
+ "lyrics.sync.retimeUnsure": "Wokal nie pasował do wersów wystarczająco dobrze. Czasy są bez zmian.",
+ "lyrics.sync.retimeFailed": "Ponowne dopasowanie do wokalu jest teraz niedostępne dla tego utworu.",
+ "lyrics.sync.resetDetected": "Resetuj do wykrytych",
+ "lyrics.sync.resetDetectedTitle": "Porzuć swoje czasy i wróć do czasów znalezionych dla tego tekstu",
+ "lyrics.sync.nothingDetected": "Nie ma wykrytych czasów, do których można wrócić. Spróbuj: Dopasuj ponownie do wokalu.",
+ "lyrics.sync.resetOriginal": "Resetuj do oryginału",
+ "lyrics.sync.resetOriginalTitle": "Każdy wers z powrotem do własnych czasów tekstu, bez przesunięcia",
+ "lyrics.sync.backTo.aligned": "Z powrotem do czasów z wokalu.",
+ "lyrics.sync.backTo.offset": "Z powrotem do własnych czasów tekstu, z przesunięciem z Dopasuj.",
+ "lyrics.sync.backTo.original": "Z powrotem do własnych czasów tekstu.",
+ "lyrics.sync.keys": "T lub Enter: stuknij · Backspace: cofnij · ← →: przesuń (Shift: więcej) · ↑ ↓: wers · Esc: gotowe",
+ "lyrics.sync.done": "Gotowe",
+ "lyrics.sync.doneTitle": "Zakończ synchronizację wersów. Zmiany zapisują się na bieżąco.",
+ "lyrics.sync.here": "Zacznij w miejscu głowicy",
+ "lyrics.sync.hereTitle": "Ten wers zaczyna się tam, gdzie jest głowica",
+ "lyrics.sync.lineToolsAria": "Czas wybranego wersu",
+ "lyrics.sync.markAria": "{line}, o {time}",
+ "lyrics.sync.laneTitle": "Tekst",
+ "lyrics.sync.laneHint": "Przeciągnij wers · ← → przesuń",
"extract.all": "Wszystko",
"extract.moreAria": "Wybierz ścieżki",
"process.splitStems": "Rozdziel ścieżki",
@@ -1820,6 +1924,58 @@ const ja = {
"lyrics.align.done": "完了",
"lyrics.align.doneTitle": "パネルを閉じます。変更はすでに保存されています。",
"lyrics.align.saveFailed": "位置合わせを保存できませんでした。曲を閉じるまでは適用されたままです。",
+ "lyrics.align.perLineUser": "この歌詞は行ごとにあなたがタイミングを設定しているため、ここでのずらしは適用されません。「行を同期」で変更やリセットができます。",
+ "lyrics.align.perLineAligned": "この歌詞はボーカルに合わせて行ごとにタイミングが設定されているため、ここでのずらしは適用されません。「行を同期」で変更できます。",
+ "lyrics.sync.button": "行を同期",
+ "lyrics.sync.title": "行を同期",
+ "lyrics.sync.open": "行ごとに同期",
+ "lyrics.sync.openTitle": "各行の開始位置を設定します:曲に合わせてタップする、タイムライン上で行をドラッグする、または少しずつ動かします",
+ "lyrics.sync.syncedOnly": "行ごとに同期できるのはタイミング付きの歌詞だけです。この歌詞はテキストのみです。",
+ "lyrics.sync.kind.user": "あなたが設定したタイミング",
+ "lyrics.sync.kind.aligned": "ボーカルから合わせたタイミング",
+ "lyrics.sync.kind.offset": "歌詞本来のタイミング",
+ "lyrics.sync.saving": "保存中…",
+ "lyrics.sync.saved": "保存しました",
+ "lyrics.sync.saveFailed": "タイミングを保存できませんでした。曲を閉じるまでは適用されたままです。",
+ "lyrics.sync.next": "次にタップ · {count} 行中 {n} 行目",
+ "lyrics.sync.allSetLabel": "すべての行を設定しました",
+ "lyrics.sync.allSet": "元に戻すか、行をクリックしてそこから続けてください。",
+ "lyrics.sync.tap": "タップ",
+ "lyrics.sync.tapKey": "T / Enter",
+ "lyrics.sync.tapTitle": "行が始まる瞬間に押すと、その行が再生位置から始まり、次の行に進みます。",
+ "lyrics.sync.undo": "元に戻す",
+ "lyrics.sync.undoTitle": "直前の変更を元に戻す (Backspace)",
+ "lyrics.sync.leadIn": "助走",
+ "lyrics.sync.leadInTitle": "行をクリックすると、その 2 秒前に再生位置を置いて、来るのが聞こえるようにします",
+ "lyrics.sync.snap": "声にスナップ",
+ "lyrics.sync.snapTitle": "タップとドラッグを、近くにあれば歌い出しの位置に合わせます。ドラッグ中に Alt を押すと自由に置けます。",
+ "lyrics.sync.ripple": "後の行も動かす",
+ "lyrics.sync.rippleTitle": "動かした行と一緒に、次にあなたが設定した行の手前までの行も動かします",
+ "lyrics.sync.retime": "ボーカルから再調整",
+ "lyrics.sync.retimeTitle": "すべての行をボーカルステムに合わせ直す",
+ "lyrics.sync.retiming": "ボーカルを解析中…",
+ "lyrics.sync.retimingProgress": "ボーカルを解析中… {percent}%",
+ "lyrics.sync.retimed": "ボーカルに合わせました:{count} 行中 {matched} 行が一致しました。",
+ "lyrics.sync.retimedPlain": "ボーカルに合わせました。",
+ "lyrics.sync.retimeUnsure": "ボーカルと行が十分に一致しませんでした。タイミングは元のままです。",
+ "lyrics.sync.retimeFailed": "現在、この曲ではボーカルからの再調整を利用できません。",
+ "lyrics.sync.resetDetected": "検出値に戻す",
+ "lyrics.sync.resetDetectedTitle": "あなたのタイミングを破棄して、この歌詞で見つかったタイミングに戻す",
+ "lyrics.sync.nothingDetected": "戻れる検出済みのタイミングがありません。「ボーカルから再調整」を試してください。",
+ "lyrics.sync.resetOriginal": "元に戻す (オリジナル)",
+ "lyrics.sync.resetOriginalTitle": "すべての行を歌詞本来のタイミングに戻す (ずらしなし)",
+ "lyrics.sync.backTo.aligned": "ボーカルから合わせたタイミングに戻しました。",
+ "lyrics.sync.backTo.offset": "歌詞本来のタイミングに、位置合わせのずらしを加えた状態に戻しました。",
+ "lyrics.sync.backTo.original": "歌詞本来のタイミングに戻しました。",
+ "lyrics.sync.keys": "T または Enter:タップ · Backspace:元に戻す · ← →:微調整 (Shift で大きく) · ↑ ↓:行 · Esc:完了",
+ "lyrics.sync.done": "完了",
+ "lyrics.sync.doneTitle": "行の同期を終了します。変更はその都度保存されています。",
+ "lyrics.sync.here": "再生位置から開始",
+ "lyrics.sync.hereTitle": "この行を再生位置から始める",
+ "lyrics.sync.lineToolsAria": "選択中の行のタイミング",
+ "lyrics.sync.markAria": "{line}、{time}",
+ "lyrics.sync.laneTitle": "歌詞",
+ "lyrics.sync.laneHint": "行をドラッグ · ← → で微調整",
"extract.all": "すべて",
"extract.moreAria": "抽出するパートを選ぶ",
"process.splitStems": "パートを分離",
@@ -2530,6 +2686,58 @@ const zhHans = {
"lyrics.align.done": "完成",
"lyrics.align.doneTitle": "关闭面板。更改已自动保存。",
"lyrics.align.saveFailed": "无法保存对齐。在关闭曲目之前仍然有效。",
+ "lyrics.align.perLineUser": "这些歌词行由你逐行设置了时间,因此这里的偏移不适用。可以在“逐行同步”中修改或重置。",
+ "lyrics.align.perLineAligned": "这些歌词行已按人声逐行设置了时间,因此这里的偏移不适用。可以在“逐行同步”中修改。",
+ "lyrics.sync.button": "逐行同步",
+ "lyrics.sync.title": "逐行同步",
+ "lyrics.sync.open": "一行一行同步",
+ "lyrics.sync.openTitle": "设置每一行的开始时间:跟着歌曲点按、在时间轴上拖动歌词行,或微调它们",
+ "lyrics.sync.syncedOnly": "只有带时间的歌词才能逐行同步。这些只是文本。",
+ "lyrics.sync.kind.user": "由你设置的时间",
+ "lyrics.sync.kind.aligned": "按人声设置的时间",
+ "lyrics.sync.kind.offset": "歌词自带的时间",
+ "lyrics.sync.saving": "正在保存…",
+ "lyrics.sync.saved": "已保存",
+ "lyrics.sync.saveFailed": "无法保存时间。在关闭曲目之前仍然有效。",
+ "lyrics.sync.next": "下一个点按 · 第 {n} 行,共 {count} 行",
+ "lyrics.sync.allSetLabel": "所有行都已设置",
+ "lyrics.sync.allSet": "撤销,或点击某一行从那里继续。",
+ "lyrics.sync.tap": "点按",
+ "lyrics.sync.tapKey": "T / Enter",
+ "lyrics.sync.tapTitle": "在这一行开始时按下:它将从播放头处开始,并轮到下一行。",
+ "lyrics.sync.undo": "撤销",
+ "lyrics.sync.undoTitle": "撤销上一次更改 (Backspace)",
+ "lyrics.sync.leadIn": "预备",
+ "lyrics.sync.leadInTitle": "点击一行时,播放头会放在它之前 2 秒,让你听到它的到来",
+ "lyrics.sync.snap": "吸附到人声",
+ "lyrics.sync.snapTitle": "如果足够接近,点按和拖动会落在歌声开始的位置。拖动时按住 Alt 可自由放置。",
+ "lyrics.sync.ripple": "同时移动后面的行",
+ "lyrics.sync.rippleTitle": "移动一行时,后面的行一起移动,直到你设置过的下一行",
+ "lyrics.sync.retime": "按人声重新定时",
+ "lyrics.sync.retimeTitle": "重新让每一行对齐人声分轨",
+ "lyrics.sync.retiming": "正在分析人声…",
+ "lyrics.sync.retimingProgress": "正在分析人声… {percent}%",
+ "lyrics.sync.retimed": "已按人声定时:{count} 行中有 {matched} 行匹配。",
+ "lyrics.sync.retimedPlain": "已按人声定时。",
+ "lyrics.sync.retimeUnsure": "人声与歌词行匹配得不够好。时间保持不变。",
+ "lyrics.sync.retimeFailed": "此曲目目前无法按人声重新定时。",
+ "lyrics.sync.resetDetected": "重置为检测结果",
+ "lyrics.sync.resetDetectedTitle": "放弃你的时间,回到为这些歌词找到的时间",
+ "lyrics.sync.nothingDetected": "没有可恢复的检测时间。请尝试“按人声重新定时”。",
+ "lyrics.sync.resetOriginal": "重置为原始",
+ "lyrics.sync.resetOriginalTitle": "每一行回到歌词自带的时间,不加偏移",
+ "lyrics.sync.backTo.aligned": "已回到按人声设置的时间。",
+ "lyrics.sync.backTo.offset": "已回到歌词自带的时间,并加上对齐偏移。",
+ "lyrics.sync.backTo.original": "已回到歌词自带的原始时间。",
+ "lyrics.sync.keys": "T 或 Enter:点按 · Backspace:撤销 · ← →:微调 (Shift:更多) · ↑ ↓:选择行 · Esc:完成",
+ "lyrics.sync.done": "完成",
+ "lyrics.sync.doneTitle": "退出逐行同步。更改会随时保存。",
+ "lyrics.sync.here": "从播放头开始",
+ "lyrics.sync.hereTitle": "这一行从播放头所在位置开始",
+ "lyrics.sync.lineToolsAria": "当前行的时间",
+ "lyrics.sync.markAria": "{line},{time}",
+ "lyrics.sync.laneTitle": "歌词",
+ "lyrics.sync.laneHint": "拖动歌词行 · ← → 微调",
"extract.all": "全部",
"extract.moreAria": "选择要提取的音轨",
"process.splitStems": "分离音轨",
@@ -3240,6 +3448,58 @@ const de = {
"lyrics.align.done": "Fertig",
"lyrics.align.doneTitle": "Bereich schließen. Änderungen sind bereits gespeichert.",
"lyrics.align.saveFailed": "Die Ausrichtung konnte nicht gespeichert werden. Sie gilt, bis der Track geschlossen wird.",
+ "lyrics.align.perLineUser": "Diese Zeilen sind einzeln von dir getimt, deshalb gilt der Versatz hier nicht. Unter Zeilen synchronisieren lassen sie sich ändern oder zurücksetzen.",
+ "lyrics.align.perLineAligned": "Diese Zeilen sind einzeln am Gesang ausgerichtet, deshalb gilt der Versatz hier nicht. Unter Zeilen synchronisieren lassen sie sich ändern.",
+ "lyrics.sync.button": "Zeilen synchronisieren",
+ "lyrics.sync.title": "Zeilen synchronisieren",
+ "lyrics.sync.open": "Zeile für Zeile synchronisieren",
+ "lyrics.sync.openTitle": "Festlegen, wann jede Zeile beginnt: im Takt mittippen, Zeilen auf der Zeitleiste ziehen oder schrittweise verschieben",
+ "lyrics.sync.syncedOnly": "Nur Songtexte mit Zeitangaben lassen sich Zeile für Zeile synchronisieren. Dieser ist reiner Text.",
+ "lyrics.sync.kind.user": "Von dir getimt",
+ "lyrics.sync.kind.aligned": "Am Gesang ausgerichtet",
+ "lyrics.sync.kind.offset": "Eigenes Timing",
+ "lyrics.sync.saving": "Wird gespeichert…",
+ "lyrics.sync.saved": "Gespeichert",
+ "lyrics.sync.saveFailed": "Das Timing konnte nicht gespeichert werden. Es gilt, bis der Track geschlossen wird.",
+ "lyrics.sync.next": "Als Nächstes tippen · Zeile {n} von {count}",
+ "lyrics.sync.allSetLabel": "Alle Zeilen getippt",
+ "lyrics.sync.allSet": "Rückgängig machen oder auf eine Zeile klicken, um dort weiterzumachen.",
+ "lyrics.sync.tap": "Tippen",
+ "lyrics.sync.tapKey": "T / Enter",
+ "lyrics.sync.tapTitle": "Drücken, wenn die Zeile beginnt: Sie beginnt dann an der Abspielposition, und die nächste Zeile ist dran.",
+ "lyrics.sync.undo": "Rückgängig",
+ "lyrics.sync.undoTitle": "Letzte Änderung rückgängig machen (Rücktaste)",
+ "lyrics.sync.leadIn": "Vorlauf",
+ "lyrics.sync.leadInTitle": "Ein Klick auf eine Zeile setzt die Abspielposition 2 s davor, damit du sie kommen hörst",
+ "lyrics.sync.snap": "An Stimme ausrichten",
+ "lyrics.sync.snapTitle": "Tippen und Ziehen landen dort, wo der Gesang beginnt, wenn er so nah ist. Beim Ziehen Alt halten, um frei zu platzieren.",
+ "lyrics.sync.ripple": "Spätere Zeilen mitziehen",
+ "lyrics.sync.rippleTitle": "Eine verschobene Zeile nimmt die folgenden mit, bis zur nächsten Zeile, die du gesetzt hast",
+ "lyrics.sync.retime": "Neu am Gesang ausrichten",
+ "lyrics.sync.retimeTitle": "Jede Zeile erneut an die Gesangsspur anpassen",
+ "lyrics.sync.retiming": "Gesang wird analysiert…",
+ "lyrics.sync.retimingProgress": "Gesang wird analysiert… {percent} %",
+ "lyrics.sync.retimed": "Am Gesang ausgerichtet: {matched} von {count} Zeilen erkannt.",
+ "lyrics.sync.retimedPlain": "Am Gesang ausgerichtet.",
+ "lyrics.sync.retimeUnsure": "Der Gesang passte nicht gut genug zu den Zeilen. Das Timing ist unverändert.",
+ "lyrics.sync.retimeFailed": "Die Ausrichtung am Gesang ist für diesen Track gerade nicht verfügbar.",
+ "lyrics.sync.resetDetected": "Auf Erkanntes zurücksetzen",
+ "lyrics.sync.resetDetectedTitle": "Dein Timing verwerfen und zum für diesen Text gefundenen Timing zurückkehren",
+ "lyrics.sync.nothingDetected": "Es gibt kein erkanntes Timing, zu dem zurückgekehrt werden kann. Versuche „Neu am Gesang ausrichten“.",
+ "lyrics.sync.resetOriginal": "Auf Original zurücksetzen",
+ "lyrics.sync.resetOriginalTitle": "Jede Zeile zurück zum eigenen Timing des Textes, ohne Versatz",
+ "lyrics.sync.backTo.aligned": "Zurück zum Timing aus dem Gesang.",
+ "lyrics.sync.backTo.offset": "Zurück zum eigenen Timing des Textes, mit dem Versatz aus Ausrichten.",
+ "lyrics.sync.backTo.original": "Zurück zum ursprünglichen Timing des Textes.",
+ "lyrics.sync.keys": "T oder Enter: tippen · Rücktaste: rückgängig · ← →: verschieben (Umschalt: mehr) · ↑ ↓: Zeile · Esc: fertig",
+ "lyrics.sync.done": "Fertig",
+ "lyrics.sync.doneTitle": "Zeilen synchronisieren verlassen. Änderungen werden laufend gespeichert.",
+ "lyrics.sync.here": "An Abspielposition beginnen",
+ "lyrics.sync.hereTitle": "Diese Zeile beginnt an der Abspielposition",
+ "lyrics.sync.lineToolsAria": "Timing der gewählten Zeile",
+ "lyrics.sync.markAria": "{line}, bei {time}",
+ "lyrics.sync.laneTitle": "Songtext",
+ "lyrics.sync.laneHint": "Zeile ziehen · ← → verschieben",
"extract.all": "Alle",
"extract.moreAria": "Stems auswählen",
"process.splitStems": "Stems trennen",
@@ -3961,6 +4221,58 @@ const pt = {
"lyrics.align.done": "Concluir",
"lyrics.align.doneTitle": "Fechar o painel. As alterações já estão salvas.",
"lyrics.align.saveFailed": "Não foi possível salvar o alinhamento. Ele continua valendo até a faixa ser fechada.",
+ "lyrics.align.perLineUser": "Estas linhas têm tempos definidos uma a uma, por você, então o deslocamento aqui não se aplica. Em Sincronizar linhas dá para mudá-las ou redefini-las.",
+ "lyrics.align.perLineAligned": "Estas linhas têm tempos ajustados uma a uma pelos vocais, então o deslocamento aqui não se aplica. Em Sincronizar linhas dá para mudá-las.",
+ "lyrics.sync.button": "Sincronizar linhas",
+ "lyrics.sync.title": "Sincronizar linhas",
+ "lyrics.sync.open": "Sincronizar linha a linha",
+ "lyrics.sync.openTitle": "Defina quando cada linha começa: toque junto com a música, arraste as linhas na linha do tempo ou ajuste-as aos poucos",
+ "lyrics.sync.syncedOnly": "Só letras com tempos podem ser sincronizadas linha a linha. Esta é só texto.",
+ "lyrics.sync.kind.user": "Tempos definidos por você",
+ "lyrics.sync.kind.aligned": "Tempos dos vocais",
+ "lyrics.sync.kind.offset": "Tempos próprios",
+ "lyrics.sync.saving": "Salvando…",
+ "lyrics.sync.saved": "Salvo",
+ "lyrics.sync.saveFailed": "Não foi possível salvar os tempos. Eles continuam valendo até a faixa ser fechada.",
+ "lyrics.sync.next": "Próxima a tocar · linha {n} de {count}",
+ "lyrics.sync.allSetLabel": "Todas as linhas marcadas",
+ "lyrics.sync.allSet": "Desfaça ou clique numa linha para continuar a partir dela.",
+ "lyrics.sync.tap": "Tocar",
+ "lyrics.sync.tapKey": "T / Enter",
+ "lyrics.sync.tapTitle": "Pressione quando a linha começar: ela passa a começar no cursor de reprodução, e a próxima linha é a da vez.",
+ "lyrics.sync.undo": "Desfazer",
+ "lyrics.sync.undoTitle": "Desfazer a última alteração (Backspace)",
+ "lyrics.sync.leadIn": "Preparação",
+ "lyrics.sync.leadInTitle": "Clicar numa linha põe o cursor de reprodução 2 s antes dela, para ouvir chegando",
+ "lyrics.sync.snap": "Encaixar na voz",
+ "lyrics.sync.snapTitle": "Toques e arrastos caem onde o canto começa, se estiver perto. Segure Alt ao arrastar para posicionar livremente.",
+ "lyrics.sync.ripple": "Mover as linhas seguintes",
+ "lyrics.sync.rippleTitle": "Uma linha movida leva as seguintes junto, até a próxima linha que você definiu",
+ "lyrics.sync.retime": "Reajustar pelos vocais",
+ "lyrics.sync.retimeTitle": "Ajustar cada linha à faixa de vocais outra vez",
+ "lyrics.sync.retiming": "Ouvindo os vocais…",
+ "lyrics.sync.retimingProgress": "Ouvindo os vocais… {percent}%",
+ "lyrics.sync.retimed": "Ajustado pelos vocais: {matched} de {count} linhas encontradas.",
+ "lyrics.sync.retimedPlain": "Ajustado pelos vocais.",
+ "lyrics.sync.retimeUnsure": "Os vocais não corresponderam bem o bastante às linhas. Os tempos continuam como estavam.",
+ "lyrics.sync.retimeFailed": "O reajuste pelos vocais não está disponível para esta faixa agora.",
+ "lyrics.sync.resetDetected": "Voltar ao detectado",
+ "lyrics.sync.resetDetectedTitle": "Descartar seus tempos e voltar aos tempos encontrados para esta letra",
+ "lyrics.sync.nothingDetected": "Não há sincronização detectada para voltar. Tente “Reajustar pelos vocais”.",
+ "lyrics.sync.resetOriginal": "Voltar ao original",
+ "lyrics.sync.resetOriginalTitle": "Cada linha de volta aos tempos próprios da letra, sem deslocamento",
+ "lyrics.sync.backTo.aligned": "De volta aos tempos dos vocais.",
+ "lyrics.sync.backTo.offset": "De volta aos tempos próprios da letra, com o deslocamento de Alinhar.",
+ "lyrics.sync.backTo.original": "De volta aos tempos originais da letra.",
+ "lyrics.sync.keys": "T ou Enter: tocar · Backspace: desfazer · ← →: ajustar (Shift: mais) · ↑ ↓: linha · Esc: concluir",
+ "lyrics.sync.done": "Concluir",
+ "lyrics.sync.doneTitle": "Sair de Sincronizar linhas. As alterações são salvas conforme você as faz.",
+ "lyrics.sync.here": "Começar no cursor",
+ "lyrics.sync.hereTitle": "Esta linha começa onde está o cursor de reprodução",
+ "lyrics.sync.lineToolsAria": "Tempo da linha selecionada",
+ "lyrics.sync.markAria": "{line}, em {time}",
+ "lyrics.sync.laneTitle": "Letra",
+ "lyrics.sync.laneHint": "Arraste uma linha · ← → ajustar",
"extract.all": "Todos",
"extract.moreAria": "Escolher as faixas",
"process.splitStems": "Separar stems",
@@ -4684,6 +4996,58 @@ const id = {
"lyrics.align.done": "Selesai",
"lyrics.align.doneTitle": "Tutup panel. Perubahan sudah tersimpan.",
"lyrics.align.saveFailed": "Penyelarasan tidak dapat disimpan. Tetap berlaku sampai trek ditutup.",
+ "lyrics.align.perLineUser": "Baris-baris ini diatur waktunya satu per satu oleh Anda, jadi geseran di sini tidak berlaku. Sinkronkan baris bisa mengubah atau mengatur ulangnya.",
+ "lyrics.align.perLineAligned": "Baris-baris ini diatur waktunya satu per satu dari vokal, jadi geseran di sini tidak berlaku. Sinkronkan baris bisa mengubahnya.",
+ "lyrics.sync.button": "Sinkronkan baris",
+ "lyrics.sync.title": "Sinkronkan baris",
+ "lyrics.sync.open": "Sinkronkan baris demi baris",
+ "lyrics.sync.openTitle": "Atur kapan setiap baris dimulai: ketuk mengikuti lagu, seret baris di linimasa, atau geser sedikit demi sedikit",
+ "lyrics.sync.syncedOnly": "Hanya lirik yang memiliki waktu yang bisa disinkronkan baris demi baris. Ini hanya teks.",
+ "lyrics.sync.kind.user": "Waktu dari Anda",
+ "lyrics.sync.kind.aligned": "Waktu dari vokal",
+ "lyrics.sync.kind.offset": "Waktu bawaan",
+ "lyrics.sync.saving": "Menyimpan…",
+ "lyrics.sync.saved": "Tersimpan",
+ "lyrics.sync.saveFailed": "Waktu tidak dapat disimpan. Tetap berlaku sampai trek ditutup.",
+ "lyrics.sync.next": "Berikutnya diketuk · baris {n} dari {count}",
+ "lyrics.sync.allSetLabel": "Semua baris sudah diketuk",
+ "lyrics.sync.allSet": "Urungkan, atau klik baris untuk melanjutkan dari sana.",
+ "lyrics.sync.tap": "Ketuk",
+ "lyrics.sync.tapKey": "T / Enter",
+ "lyrics.sync.tapTitle": "Tekan saat baris dimulai: baris itu dimulai di posisi putar, lalu giliran baris berikutnya.",
+ "lyrics.sync.undo": "Urungkan",
+ "lyrics.sync.undoTitle": "Urungkan perubahan terakhir (Backspace)",
+ "lyrics.sync.leadIn": "Awalan",
+ "lyrics.sync.leadInTitle": "Mengklik baris menaruh posisi putar 2 detik sebelumnya, agar terdengar saat mendekat",
+ "lyrics.sync.snap": "Tempel ke suara",
+ "lyrics.sync.snapTitle": "Ketukan dan seretan jatuh di awal nyanyian jika sedekat itu. Tahan Alt saat menyeret untuk menaruh bebas.",
+ "lyrics.sync.ripple": "Geser baris berikutnya juga",
+ "lyrics.sync.rippleTitle": "Baris yang digeser membawa baris setelahnya, sampai baris berikutnya yang sudah Anda atur",
+ "lyrics.sync.retime": "Atur ulang dari vokal",
+ "lyrics.sync.retimeTitle": "Cocokkan lagi setiap baris dengan stem vokal",
+ "lyrics.sync.retiming": "Mendengarkan vokal…",
+ "lyrics.sync.retimingProgress": "Mendengarkan vokal… {percent}%",
+ "lyrics.sync.retimed": "Diatur dari vokal: {matched} dari {count} baris cocok.",
+ "lyrics.sync.retimedPlain": "Diatur dari vokal.",
+ "lyrics.sync.retimeUnsure": "Vokal tidak cukup cocok dengan barisnya. Waktunya tetap seperti semula.",
+ "lyrics.sync.retimeFailed": "Pengaturan ulang dari vokal sedang tidak tersedia untuk trek ini.",
+ "lyrics.sync.resetDetected": "Kembali ke hasil deteksi",
+ "lyrics.sync.resetDetectedTitle": "Buang waktu Anda dan kembali ke waktu yang ditemukan untuk lirik ini",
+ "lyrics.sync.nothingDetected": "Tidak ada waktu terdeteksi untuk dikembalikan. Coba “Atur ulang dari vokal”.",
+ "lyrics.sync.resetOriginal": "Kembali ke asli",
+ "lyrics.sync.resetOriginalTitle": "Setiap baris kembali ke waktu bawaan lirik, tanpa geseran",
+ "lyrics.sync.backTo.aligned": "Kembali ke waktu dari vokal.",
+ "lyrics.sync.backTo.offset": "Kembali ke waktu bawaan lirik, dengan geseran dari Selaraskan.",
+ "lyrics.sync.backTo.original": "Kembali ke waktu asli lirik.",
+ "lyrics.sync.keys": "T atau Enter: ketuk · Backspace: urungkan · ← →: geser (Shift: lebih) · ↑ ↓: baris · Esc: selesai",
+ "lyrics.sync.done": "Selesai",
+ "lyrics.sync.doneTitle": "Keluar dari Sinkronkan baris. Perubahan disimpan saat Anda membuatnya.",
+ "lyrics.sync.here": "Mulai di posisi putar",
+ "lyrics.sync.hereTitle": "Baris ini dimulai di posisi putar",
+ "lyrics.sync.lineToolsAria": "Waktu baris terpilih",
+ "lyrics.sync.markAria": "{line}, pada {time}",
+ "lyrics.sync.laneTitle": "Lirik",
+ "lyrics.sync.laneHint": "Seret baris · ← → geser",
"extract.all": "Semua",
"extract.moreAria": "Pilih stem",
"process.splitStems": "Pisahkan stem",
@@ -5394,6 +5758,58 @@ const fr = {
"lyrics.align.done": "Terminé",
"lyrics.align.doneTitle": "Fermer le panneau. Les modifications sont déjà enregistrées.",
"lyrics.align.saveFailed": "Impossible d'enregistrer l'alignement. Il reste appliqué jusqu'à la fermeture du morceau.",
+ "lyrics.align.perLineUser": "Ces lignes sont calées une par une, par vous, donc le décalage ici ne s'applique pas. Synchroniser les lignes permet de les modifier ou de les réinitialiser.",
+ "lyrics.align.perLineAligned": "Ces lignes sont calées une par une sur le chant, donc le décalage ici ne s'applique pas. Synchroniser les lignes permet de les modifier.",
+ "lyrics.sync.button": "Synchroniser les lignes",
+ "lyrics.sync.title": "Synchroniser les lignes",
+ "lyrics.sync.open": "Synchroniser ligne par ligne",
+ "lyrics.sync.openTitle": "Réglez le début de chaque ligne : tapez en rythme avec le morceau, faites glisser les lignes sur la frise ou ajustez-les pas à pas",
+ "lyrics.sync.syncedOnly": "Seules les paroles minutées peuvent être synchronisées ligne par ligne. Celles-ci ne sont que du texte.",
+ "lyrics.sync.kind.user": "Calées par vous",
+ "lyrics.sync.kind.aligned": "Calées sur le chant",
+ "lyrics.sync.kind.offset": "Minutage d'origine",
+ "lyrics.sync.saving": "Enregistrement…",
+ "lyrics.sync.saved": "Enregistré",
+ "lyrics.sync.saveFailed": "Impossible d'enregistrer le minutage. Il reste appliqué jusqu'à la fermeture du morceau.",
+ "lyrics.sync.next": "À taper ensuite · ligne {n} sur {count}",
+ "lyrics.sync.allSetLabel": "Toutes les lignes sont calées",
+ "lyrics.sync.allSet": "Annulez, ou cliquez sur une ligne pour reprendre à partir d'elle.",
+ "lyrics.sync.tap": "Taper",
+ "lyrics.sync.tapKey": "T / Entrée",
+ "lyrics.sync.tapTitle": "Appuyez quand la ligne commence : elle commence alors à la tête de lecture, et c'est au tour de la suivante.",
+ "lyrics.sync.undo": "Annuler",
+ "lyrics.sync.undoTitle": "Annuler la dernière modification (Retour arrière)",
+ "lyrics.sync.leadIn": "Pré-roll",
+ "lyrics.sync.leadInTitle": "Cliquer sur une ligne place la tête de lecture 2 s avant, pour l'entendre arriver",
+ "lyrics.sync.snap": "Caler sur la voix",
+ "lyrics.sync.snapTitle": "Les tapes et les glissements tombent là où le chant commence, s'il est aussi proche. Maintenez Alt en faisant glisser pour placer librement.",
+ "lyrics.sync.ripple": "Déplacer aussi les lignes suivantes",
+ "lyrics.sync.rippleTitle": "Une ligne déplacée emmène les suivantes, jusqu'à la prochaine ligne que vous avez calée",
+ "lyrics.sync.retime": "Recaler sur le chant",
+ "lyrics.sync.retimeTitle": "Recaler chaque ligne sur la piste de chant",
+ "lyrics.sync.retiming": "Écoute du chant…",
+ "lyrics.sync.retimingProgress": "Écoute du chant… {percent} %",
+ "lyrics.sync.retimed": "Calé sur le chant : {matched} lignes sur {count} retrouvées.",
+ "lyrics.sync.retimedPlain": "Calé sur le chant.",
+ "lyrics.sync.retimeUnsure": "Le chant ne correspondait pas assez bien aux lignes. Le minutage est inchangé.",
+ "lyrics.sync.retimeFailed": "Le recalage sur le chant n'est pas disponible pour ce morceau pour le moment.",
+ "lyrics.sync.resetDetected": "Revenir au détecté",
+ "lyrics.sync.resetDetectedTitle": "Abandonner votre minutage et revenir à celui trouvé pour ces paroles",
+ "lyrics.sync.nothingDetected": "Aucun calage détecté vers lequel revenir. Essayez « Recaler sur le chant ».",
+ "lyrics.sync.resetOriginal": "Revenir à l'original",
+ "lyrics.sync.resetOriginalTitle": "Chaque ligne revient au minutage d'origine des paroles, sans décalage",
+ "lyrics.sync.backTo.aligned": "Retour au minutage calé sur le chant.",
+ "lyrics.sync.backTo.offset": "Retour au minutage d'origine, avec le décalage d'Aligner.",
+ "lyrics.sync.backTo.original": "Retour au minutage d'origine des paroles.",
+ "lyrics.sync.keys": "T ou Entrée : taper · Retour arrière : annuler · ← → : ajuster (Maj : plus) · ↑ ↓ : ligne · Échap : terminé",
+ "lyrics.sync.done": "Terminé",
+ "lyrics.sync.doneTitle": "Quitter Synchroniser les lignes. Les modifications sont enregistrées au fur et à mesure.",
+ "lyrics.sync.here": "Commencer à la tête de lecture",
+ "lyrics.sync.hereTitle": "Cette ligne commence là où se trouve la tête de lecture",
+ "lyrics.sync.lineToolsAria": "Minutage de la ligne choisie",
+ "lyrics.sync.markAria": "{line}, à {time}",
+ "lyrics.sync.laneTitle": "Paroles",
+ "lyrics.sync.laneHint": "Glissez une ligne · ← → ajuster",
"extract.all": "Tout",
"extract.moreAria": "Choisir les pistes",
"process.splitStems": "Séparer les pistes",
@@ -6004,6 +6420,18 @@ const ptPT = {
"lyrics.align.detect": "Detetar automaticamente",
"lyrics.align.detectFailed": "A deteção automática não está disponível para esta faixa neste momento.",
"lyrics.align.saveFailed": "Não foi possível guardar o alinhamento. Continua aplicado até a faixa ser fechada.",
+ "lyrics.align.perLineUser": "Estas linhas têm tempos definidos uma a uma, por si, pelo que o desfasamento aqui não se aplica. Em Sincronizar linhas pode alterá-las ou repô-las.",
+ "lyrics.align.perLineAligned": "Estas linhas têm tempos ajustados uma a uma pela voz, pelo que o desfasamento aqui não se aplica. Em Sincronizar linhas pode alterá-las.",
+ "lyrics.sync.kind.user": "Tempos definidos por si",
+ "lyrics.sync.saving": "A guardar…",
+ "lyrics.sync.saved": "Guardado",
+ "lyrics.sync.saveFailed": "Não foi possível guardar os tempos. Continuam aplicados até a faixa ser fechada.",
+ "lyrics.sync.retiming": "A ouvir a voz…",
+ "lyrics.sync.retimingProgress": "A ouvir a voz… {percent}%",
+ "lyrics.sync.resetDetectedTitle": "Descartar os seus tempos e voltar aos tempos encontrados para esta letra",
+ "lyrics.sync.rippleTitle": "Uma linha movida leva as seguintes consigo, até à próxima linha que definiu",
+ "lyrics.sync.doneTitle": "Sair de Sincronizar linhas. As alterações são guardadas à medida que as faz.",
+ "lyrics.sync.snapTitle": "Os toques e arrastos ficam onde o canto começa, se estiver perto. Mantenha Alt ao arrastar para posicionar livremente.",
"lyrics.align.doneTitle": "Fechar o painel. As alterações já estão guardadas.",
"settings.transcribe.desc": "Quando não é encontrada nenhuma letra para uma faixa, esta é transcrita localmente a partir das vozes com o Whisper. No automático, isto só acontece numa GPU NVIDIA. Da primeira vez, é transferido um modelo de fala (1,6 GB para GPU NVIDIA, 0,5 GB nos restantes casos). Aplica-se à próxima faixa.",
"pitch.insecureOrigin": "A transposição não está disponível através de uma ligação de rede",
@@ -6274,6 +6702,58 @@ const es = {
"lyrics.align.done": "Listo",
"lyrics.align.doneTitle": "Cerrar el panel. Los cambios ya están guardados.",
"lyrics.align.saveFailed": "No se pudo guardar la alineación. Sigue aplicada hasta que se cierre la pista.",
+ "lyrics.align.perLineUser": "Estas líneas tienen tiempos puestos una a una, por ti, así que el desfase de aquí no se aplica. En Sincronizar líneas puedes cambiarlas o restablecerlas.",
+ "lyrics.align.perLineAligned": "Estas líneas tienen tiempos ajustados una a una a la voz, así que el desfase de aquí no se aplica. En Sincronizar líneas puedes cambiarlas.",
+ "lyrics.sync.button": "Sincronizar líneas",
+ "lyrics.sync.title": "Sincronizar líneas",
+ "lyrics.sync.open": "Sincronizar línea a línea",
+ "lyrics.sync.openTitle": "Fija cuándo empieza cada línea: marca al ritmo de la canción, arrastra las líneas en la línea de tiempo o muévelas poco a poco",
+ "lyrics.sync.syncedOnly": "Solo las letras con tiempos se pueden sincronizar línea a línea. Esta es solo texto.",
+ "lyrics.sync.kind.user": "Tiempos puestos por ti",
+ "lyrics.sync.kind.aligned": "Tiempos de la voz",
+ "lyrics.sync.kind.offset": "Tiempos propios",
+ "lyrics.sync.saving": "Guardando…",
+ "lyrics.sync.saved": "Guardado",
+ "lyrics.sync.saveFailed": "No se pudieron guardar los tiempos. Siguen aplicados hasta que se cierre la pista.",
+ "lyrics.sync.next": "Siguiente a marcar · línea {n} de {count}",
+ "lyrics.sync.allSetLabel": "Todas las líneas marcadas",
+ "lyrics.sync.allSet": "Deshaz, o haz clic en una línea para seguir desde ella.",
+ "lyrics.sync.tap": "Marcar",
+ "lyrics.sync.tapKey": "T / Intro",
+ "lyrics.sync.tapTitle": "Pulsa cuando empiece la línea: empezará en el cabezal y pasará a la siguiente.",
+ "lyrics.sync.undo": "Deshacer",
+ "lyrics.sync.undoTitle": "Deshacer el último cambio (Retroceso)",
+ "lyrics.sync.leadIn": "Preparación",
+ "lyrics.sync.leadInTitle": "Al hacer clic en una línea, el cabezal se pone 2 s antes, para oírla llegar",
+ "lyrics.sync.snap": "Ajustar a la voz",
+ "lyrics.sync.snapTitle": "Las marcas y los arrastres caen donde empieza el canto, si está así de cerca. Mantén Alt al arrastrar para colocar libremente.",
+ "lyrics.sync.ripple": "Mover también las siguientes",
+ "lyrics.sync.rippleTitle": "Una línea movida arrastra las siguientes, hasta la próxima línea que hayas fijado",
+ "lyrics.sync.retime": "Reajustar con la voz",
+ "lyrics.sync.retimeTitle": "Volver a ajustar cada línea a la pista de voz",
+ "lyrics.sync.retiming": "Escuchando la voz…",
+ "lyrics.sync.retimingProgress": "Escuchando la voz… {percent} %",
+ "lyrics.sync.retimed": "Ajustado a la voz: {matched} de {count} líneas encontradas.",
+ "lyrics.sync.retimedPlain": "Ajustado a la voz.",
+ "lyrics.sync.retimeUnsure": "La voz no coincidió lo bastante con las líneas. Los tiempos siguen como estaban.",
+ "lyrics.sync.retimeFailed": "El reajuste con la voz no está disponible para esta pista ahora mismo.",
+ "lyrics.sync.resetDetected": "Volver a lo detectado",
+ "lyrics.sync.resetDetectedTitle": "Descartar tus tiempos y volver a los encontrados para esta letra",
+ "lyrics.sync.nothingDetected": "No hay una sincronización detectada a la que volver. Prueba «Reajustar con la voz».",
+ "lyrics.sync.resetOriginal": "Volver al original",
+ "lyrics.sync.resetOriginalTitle": "Cada línea vuelve a los tiempos propios de la letra, sin desfase",
+ "lyrics.sync.backTo.aligned": "De vuelta a los tiempos de la voz.",
+ "lyrics.sync.backTo.offset": "De vuelta a los tiempos propios de la letra, con el desfase de Alinear.",
+ "lyrics.sync.backTo.original": "De vuelta a los tiempos originales de la letra.",
+ "lyrics.sync.keys": "T o Intro: marcar · Retroceso: deshacer · ← →: mover (Mayús: más) · ↑ ↓: línea · Esc: listo",
+ "lyrics.sync.done": "Listo",
+ "lyrics.sync.doneTitle": "Salir de Sincronizar líneas. Los cambios se guardan a medida que los haces.",
+ "lyrics.sync.here": "Empezar en el cabezal",
+ "lyrics.sync.hereTitle": "Esta línea empieza donde está el cabezal",
+ "lyrics.sync.lineToolsAria": "Tiempo de la línea elegida",
+ "lyrics.sync.markAria": "{line}, en {time}",
+ "lyrics.sync.laneTitle": "Letra",
+ "lyrics.sync.laneHint": "Arrastra una línea · ← → mover",
"extract.all": "Todo",
"extract.moreAria": "Elegir las pistas",
"process.splitStems": "Separar stems",
@@ -7018,6 +7498,58 @@ const ko = {
"lyrics.align.done": "완료",
"lyrics.align.doneTitle": "패널을 닫아요. 변경 사항은 이미 저장되어 있어요.",
"lyrics.align.saveFailed": "맞춤을 저장할 수 없었습니다. 트랙을 닫을 때까지는 적용됩니다.",
+ "lyrics.align.perLineUser": "이 줄들은 직접 한 줄씩 타이밍을 맞췄기 때문에 여기의 오프셋은 적용되지 않습니다. 줄 싱크에서 바꾸거나 초기화할 수 있어요.",
+ "lyrics.align.perLineAligned": "이 줄들은 보컬에 맞춰 한 줄씩 타이밍이 정해졌기 때문에 여기의 오프셋은 적용되지 않습니다. 줄 싱크에서 바꿀 수 있어요.",
+ "lyrics.sync.button": "줄 싱크",
+ "lyrics.sync.title": "줄 싱크",
+ "lyrics.sync.open": "한 줄씩 싱크",
+ "lyrics.sync.openTitle": "각 줄이 시작하는 때를 정하세요: 곡에 맞춰 탭하거나, 타임라인에서 줄을 끌거나, 조금씩 옮기세요",
+ "lyrics.sync.syncedOnly": "타이밍이 있는 가사만 한 줄씩 싱크할 수 있습니다. 이 가사는 텍스트뿐입니다.",
+ "lyrics.sync.kind.user": "직접 맞춘 타이밍",
+ "lyrics.sync.kind.aligned": "보컬에서 맞춘 타이밍",
+ "lyrics.sync.kind.offset": "원래 타이밍",
+ "lyrics.sync.saving": "저장 중…",
+ "lyrics.sync.saved": "저장됨",
+ "lyrics.sync.saveFailed": "타이밍을 저장할 수 없었습니다. 트랙을 닫을 때까지는 적용됩니다.",
+ "lyrics.sync.next": "다음 탭 · {count}줄 중 {n}번째 줄",
+ "lyrics.sync.allSetLabel": "모든 줄을 탭했습니다",
+ "lyrics.sync.allSet": "실행 취소하거나, 줄을 클릭해 거기서부터 이어가세요.",
+ "lyrics.sync.tap": "탭",
+ "lyrics.sync.tapKey": "T / Enter",
+ "lyrics.sync.tapTitle": "줄이 시작할 때 누르세요: 그 줄이 재생 위치에서 시작하고 다음 줄로 넘어갑니다.",
+ "lyrics.sync.undo": "실행 취소",
+ "lyrics.sync.undoTitle": "마지막 변경 실행 취소 (Backspace)",
+ "lyrics.sync.leadIn": "예비 재생",
+ "lyrics.sync.leadInTitle": "줄을 클릭하면 재생 위치를 2초 앞에 두어 다가오는 것을 들을 수 있어요",
+ "lyrics.sync.snap": "목소리에 맞추기",
+ "lyrics.sync.snapTitle": "탭과 끌기가 가까우면 노래가 시작하는 곳에 맞춰집니다. 끌 때 Alt를 누르면 자유롭게 놓을 수 있어요.",
+ "lyrics.sync.ripple": "뒤의 줄도 함께 옮기기",
+ "lyrics.sync.rippleTitle": "옮긴 줄이 뒤의 줄을 함께 옮깁니다. 다음에 직접 맞춘 줄 앞까지요",
+ "lyrics.sync.retime": "보컬로 다시 맞추기",
+ "lyrics.sync.retimeTitle": "모든 줄을 보컬 스템에 다시 맞추기",
+ "lyrics.sync.retiming": "보컬 분석 중…",
+ "lyrics.sync.retimingProgress": "보컬 분석 중… {percent}%",
+ "lyrics.sync.retimed": "보컬에 맞췄습니다: {count}줄 중 {matched}줄 일치.",
+ "lyrics.sync.retimedPlain": "보컬에 맞췄습니다.",
+ "lyrics.sync.retimeUnsure": "보컬이 줄과 충분히 맞지 않았습니다. 타이밍은 그대로입니다.",
+ "lyrics.sync.retimeFailed": "지금은 이 트랙에서 보컬로 다시 맞추기를 사용할 수 없습니다.",
+ "lyrics.sync.resetDetected": "감지된 값으로 초기화",
+ "lyrics.sync.resetDetectedTitle": "직접 맞춘 타이밍을 버리고 이 가사에서 찾은 타이밍으로 돌아가기",
+ "lyrics.sync.nothingDetected": "되돌아갈 감지된 타이밍이 없어요. '보컬로 다시 맞추기'를 사용해 보세요.",
+ "lyrics.sync.resetOriginal": "원본으로 초기화",
+ "lyrics.sync.resetOriginalTitle": "모든 줄을 가사의 원래 타이밍으로, 오프셋 없이 되돌리기",
+ "lyrics.sync.backTo.aligned": "보컬에서 맞춘 타이밍으로 돌아갔습니다.",
+ "lyrics.sync.backTo.offset": "가사의 원래 타이밍에 맞춤 오프셋을 더한 상태로 돌아갔습니다.",
+ "lyrics.sync.backTo.original": "가사의 원래 타이밍으로 돌아갔습니다.",
+ "lyrics.sync.keys": "T 또는 Enter: 탭 · Backspace: 실행 취소 · ← →: 옮기기 (Shift: 크게) · ↑ ↓: 줄 · Esc: 완료",
+ "lyrics.sync.done": "완료",
+ "lyrics.sync.doneTitle": "줄 싱크를 끝냅니다. 변경 사항은 바로바로 저장돼요.",
+ "lyrics.sync.here": "재생 위치에서 시작",
+ "lyrics.sync.hereTitle": "이 줄을 재생 위치에서 시작",
+ "lyrics.sync.lineToolsAria": "선택한 줄의 타이밍",
+ "lyrics.sync.markAria": "{line}, {time}",
+ "lyrics.sync.laneTitle": "가사",
+ "lyrics.sync.laneHint": "줄 끌기 · ← → 옮기기",
"extract.all": "전체",
"extract.moreAria": "추출할 스템 선택",
"process.splitStems": "스템 분리",
diff --git a/static/js/lyrics.js b/static/js/lyrics.js
index a9f28420..44020ebf 100644
--- a/static/js/lyrics.js
+++ b/static/js/lyrics.js
@@ -26,6 +26,13 @@
// beside the synced text, never the text: on the server for lyrics it keeps
// (POST .../lyrics/offset), in the track's store entry for lyrics kept here.
//
+// Sync lines times the lines one by one: tap along with playback, drag a
+// line's marker in the lane over the waveform (lyricsLane.js), nudge the line
+// in hand, or re-time every line from the vocals. The user's timing takes
+// precedence over the server's fitted to the vocals, which takes precedence
+// over the lyrics' own at the Align offset (lyricsSync.js timingOf); the
+// offset applies to the last only, and the Align panel says so.
+//
// Everything that came from the network or a file's tags goes in with
// textContent, never as HTML: anyone can edit LRCLIB or a tag, and this page
// can reach the desktop app's native commands.
@@ -34,6 +41,7 @@ import { t, getLanguage } from "./i18n.js";
import { storeGet, storeSet } from "./utils.js";
import { getCurrentTrackInfo, getCurrentTrackArtist } from "./catalog.js";
import { transport, setPlayheadTime } from "./transport.js";
+import { totalDuration } from "./state.js";
import {
searchLyrics,
rankVersions,
@@ -49,7 +57,25 @@ import {
shiftLines,
firstSungIndex,
offsetToStart,
+ alignedFrom,
} from "./lyricsLookup.js";
+import {
+ timingOf,
+ serializeLrc,
+ snapToVoice,
+ applyTime,
+ SyncSession,
+} from "./lyricsSync.js";
+import {
+ showLane,
+ hideLane,
+ updateLane,
+ setLaneSelected,
+ setLaneCurrent,
+ setLanePlayhead,
+ revealLaneSelected,
+ formatClock,
+} from "./lyricsLane.js";
// One store entry per track rather than a field in the library store: a song's
// lyrics run to a few kilobytes, and the library is rewritten whole on every
@@ -69,15 +95,30 @@ let bodyEl = null;
let visible = false;
let shownTrackId = null; // the track whose lyrics are on screen
-let lines = []; // synced lines of what is shown, at the offset, [] for plain text
-let baseLines = []; // the same lines at their own timing
+let lines = []; // timed lines of what is shown, in the timing in effect, [] for plain text
+let baseLines = []; // the lyrics' own lines at their own timing
let offset = 0; // seconds the lines are moved later (earlier when negative)
+// Which timing is in effect (lyricsSync.js timingOf): "user" (Sync lines),
+// "aligned" (fitted to the vocals by the server) or "offset" (the lyrics' own,
+// moved by the Align panel's offset, which applies to this one only).
+let timingKind = "offset";
// What is on screen and where it is kept: "server" for lyrics.json, "store"
// for the track's entry here. The Align panel saves to the same place.
let shownEntry = null;
let shownOthers = [];
let shownFrom = "store";
let pickedIndex = -1; // the line last clicked, which Start lyrics here moves
+// Sync lines (lyricsSync.js, lyricsLane.js): the mode, kept on while the
+// track is; its panel, session and elements while it is on screen.
+let syncOpen = false;
+let sync = null;
+let syncToggleEl = null; // the Sync lines link in the tools row
+let userSave = null; // the user's timing waiting to be saved, or its removal
+let userSaveTimer = 0;
+let userSaveChain = Promise.resolve();
+let retimeToken = 0; // guards a re-time's polling against a switch or a close
+let leadIn = false; // a line taken in hand puts the playhead a little before it
+let snapOn = true; // taps and drops snap to where the singing starts
let alignOpen = false; // the Align panel, kept open while the track is
let align = null; // the Align panel's elements while it is on screen
let alignToggleEl = null; // the Align link in the tools row
@@ -122,8 +163,10 @@ function fmtLength(seconds) {
}
function clearLyrics() {
+ closeSync(false, { keepOpen: true });
flushSave();
lines = [];
+ timingKind = "offset";
baseLines = [];
offset = 0;
shownEntry = null;
@@ -136,7 +179,7 @@ function clearLyrics() {
lineWords = [];
currentIndex = -1;
bodyEl.replaceChildren();
- bodyEl.classList.remove("synced", "aligning");
+ bodyEl.classList.remove("synced", "aligning", "syncing");
versionsEl.replaceChildren();
}
@@ -167,7 +210,14 @@ function lineButton(line, nextTime, index) {
row.type = "button";
row.title = t("lyrics.seekTitle");
row.addEventListener("click", (e) => {
- // Its time at the offset now, not when the button was made.
+ if (sync) {
+ // In Sync lines a line is taken in hand, and the playhead goes to it
+ // (a little before, with the lead-in, to hear into it).
+ selectLine(index, { seek: true });
+ if (e.detail > 0) row.blur();
+ return;
+ }
+ // Its time in the timing in effect now, not when the button was made.
setPlayheadTime(Math.max(0, lines[index]?.time ?? line.time));
pickedIndex = index;
showAlignTarget();
@@ -176,6 +226,13 @@ function lineButton(line, nextTime, index) {
// key press (detail 0) keeps focus there for whoever is using the keys.
if (e.detail > 0) row.blur();
});
+ lineWords.push(fillLine(row, line, nextTime));
+ return row;
+}
+
+/** A line's words as spans in `row`, timed for the wipe: [{ el, start, end }]. */
+function fillLine(row, line, nextTime) {
+ row.replaceChildren();
const words = [];
for (const word of wordTimings(line, nextTime, envelopeFor === shownTrackId ? envelope : null)) {
const span = el("span", "lw", word.text);
@@ -184,8 +241,7 @@ function lineButton(line, nextTime, index) {
}
// An empty stamp marks an instrumental gap in LRC; a note keeps its place.
if (!words.length) row.textContent = "♪";
- lineWords.push(words);
- return row;
+ return words;
}
/**
@@ -218,11 +274,9 @@ function show(entry, others = [], from = "store") {
);
const tools = el("div", "lyrics-tools");
versionsEl.append(head, tools);
- if (entry.synced) {
- baseLines = parseLrc(entry.synced);
- offset = clampOffset(entry.offsetSec);
- lines = shiftLines(baseLines, offset);
- }
+ baseLines = entry.synced ? parseLrc(entry.synced) : [];
+ offset = clampOffset(entry.offsetSec);
+ applyTiming();
if (others.length) {
const toggle = el("button", "lyrics-link", t("lyrics.otherVersions", { count: others.length }));
toggle.type = "button";
@@ -239,23 +293,65 @@ function show(entry, others = [], from = "store") {
}
// Words to align, or to say why they cannot be: not for an instrumental.
// The panel goes under the tools row, above the versions list.
- if (entry.synced || entry.plain) tools.append(alignToggle());
- if (lines.length && alignOpen) tools.after(alignPanel());
+ if (entry.synced || entry.plain) tools.append(syncToggle(), alignToggle());
+ if (baseLines.length && alignOpen && !syncOpen) tools.after(alignPanel());
const remove = el("button", "lyrics-link", t("lyrics.remove"));
remove.type = "button";
remove.addEventListener("click", () => removeLyrics());
tools.append(remove);
- if (entry.synced) {
- lineEls = lines.map((line, i) => lineButton(line, lines[i + 1]?.time ?? null, i));
- bodyEl.append(...lineEls);
- bodyEl.classList.add("synced");
- if (lines.length) loadEnvelope(shownTrackId);
+ if (lines.length) {
+ renderLines();
+ loadEnvelope(shownTrackId);
} else if (entry.plain) {
// Composed, as parseLrc does for synced lines.
for (const text of entry.plain.normalize("NFC").split(/\r?\n/)) bodyEl.append(tagLang(el("p", "lyrics-text", text || " "), text));
}
bodyEl.scrollTop = 0;
+ if (syncOpen && lines.length) openSync();
+}
+
+/** The timing in effect for what is shown, into `lines`: the user's own, the
+ * server's fitted to the vocals, or the lyrics' own at the offset. */
+function applyTiming() {
+ const timing = shownEntry ? timingOf({ ...shownEntry, offsetSec: offset }) : { kind: "offset", lines: [] };
+ timingKind = timing.kind;
+ lines = timing.lines;
+}
+
+/** Draw `lines` as the synced list, afresh. */
+function renderLines() {
+ lineWords = [];
+ currentIndex = -1;
+ lineEls = lines.map((line, i) => lineButton(line, lines[i + 1]?.time ?? null, i));
+ bodyEl.replaceChildren(...lineEls);
+ bodyEl.classList.add("synced");
+ showLineTimes();
+}
+
+/** Show `next` as the lines' timing at once: the list, the wipe, a line
+ * click and the lane all follow. The list is drawn again only when its lines
+ * are not the same ones (a reset to a timing with other lines). */
+function setDisplayLines(next) {
+ const same = next.length === lines.length && next.every((line, i) => line.text === lines[i].text);
+ lines = next;
+ if (!same) {
+ renderLines();
+ if (sync) placeLineTools();
+ return;
+ }
+ retime();
+ showLineTimes();
+}
+
+/** In Sync lines, each line says when it starts (drawn by CSS from
+ * data-time, so it is not part of the line's text). */
+function showLineTimes() {
+ if (!sync) return;
+ lineEls.forEach((row, i) => {
+ const time = formatClock(lines[i]?.time ?? 0);
+ if (row.dataset.time !== time) row.dataset.time = time;
+ });
}
/**
@@ -288,7 +384,12 @@ function retime() {
lines.forEach((line, i) => {
const words = lineWords[i];
const timed = wordTimings(line, lines[i + 1]?.time ?? null, levels);
- if (!words || timed.length !== words.length) return;
+ if (!words) return;
+ if (timed.length !== words.length) {
+ // Timing with word stamps where there were none, or the other way.
+ if (lineEls[i]) lineWords[i] = fillLine(lineEls[i], line, lines[i + 1]?.time ?? null);
+ return;
+ }
timed.forEach((word, k) => {
words[k].start = word.start;
words[k].end = word.end;
@@ -305,16 +406,16 @@ const NUDGES = [-0.5, -0.1, 0.1, 0.5];
const SAVE_DELAY_MS = 400;
/** Seconds to a tenth, in the app's language: "+15.9", "-0.5", "0.0". */
-function formatSeconds(seconds, signDisplay = "exceptZero") {
+function formatSeconds(seconds, signDisplay = "exceptZero", digits = 1) {
try {
return new Intl.NumberFormat(getLanguage(), {
- minimumFractionDigits: 1,
- maximumFractionDigits: 1,
+ minimumFractionDigits: digits,
+ maximumFractionDigits: digits,
signDisplay,
}).format(seconds);
} catch (err) {
console.warn("number format failed", err);
- return (signDisplay !== "never" && seconds > 0 ? "+" : "") + seconds.toFixed(1);
+ return (signDisplay !== "never" && seconds > 0 ? "+" : "") + seconds.toFixed(digits);
}
}
@@ -325,7 +426,7 @@ function alignToggle() {
const toggle = el("button", "lyrics-link lyrics-align-toggle", t("lyrics.align.button"));
toggle.type = "button";
alignToggleEl = toggle;
- if (!lines.length) {
+ if (!baseLines.length) {
toggle.setAttribute("aria-disabled", "true");
toggle.title = t("lyrics.align.syncedOnly");
return toggle;
@@ -341,7 +442,8 @@ function alignToggle() {
}
function openAlign() {
- if (!lines.length || align) return;
+ if (!baseLines.length || align) return;
+ if (sync) closeSync(false);
alignOpen = true;
alignToggleEl?.setAttribute("aria-expanded", "true");
alignToggleEl?.closest(".lyrics-tools")?.after(alignPanel());
@@ -405,19 +507,38 @@ function alignPanel() {
const message = el("p", "lyrics-align-message");
message.setAttribute("aria-live", "polite");
- panel.append(el("p", "lyrics-align-hint", t("lyrics.align.hint")), start, target, nudges, actions, message);
+ // The offset moves the lyrics' own timing only. Lines timed one by one (by
+ // hand, or fitted to the vocals) take precedence, and the panel says so
+ // rather than moving nothing.
+ const note = el("p", "lyrics-align-note");
+ note.setAttribute("aria-live", "polite");
+ const syncBtn = alignButton(t("lyrics.sync.open"), () => openSync({ focus: true }));
+ syncBtn.title = t("lyrics.sync.openTitle");
+ const offsetControls = [start, ...steps, detect, reset];
+
+ panel.append(el("p", "lyrics-align-hint", t("lyrics.align.hint")), note, start, target, nudges, actions, syncBtn, message);
panel.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
e.stopPropagation();
closeAlign(true);
});
- align = { panel, target, readout, detect, message };
+ align = { panel, target, readout, detect, message, note, offsetControls };
bodyEl.classList.add("aligning");
+ showAlignApplies();
showOffset();
showAlignTarget();
return panel;
}
+/** Whether the offset is what times the lines now, and the panel's controls
+ * and note to match. */
+function showAlignApplies() {
+ if (!align) return;
+ const applies = timingKind === "offset";
+ for (const btn of align.offsetControls) btn.disabled = !applies;
+ align.note.textContent = applies ? "" : t(timingKind === "user" ? "lyrics.align.perLineUser" : "lyrics.align.perLineAligned");
+}
+
function showOffset() {
if (align) align.readout.textContent = t("lyrics.align.offset", { offset: formatSeconds(offset) });
}
@@ -464,9 +585,13 @@ function startHere() {
function applyOffset(seconds, { save = true } = {}) {
if (!baseLines.length) return;
offset = clampOffset(seconds);
- lines = shiftLines(baseLines, offset);
if (shownEntry) shownEntry.offsetSec = offset;
- retime();
+ // Only the lyrics' own timing moves with the offset; lines timed one by one
+ // keep their times.
+ if (timingKind === "offset") {
+ lines = shiftLines(baseLines, offset);
+ retime();
+ }
showOffset();
setAlignMessage();
if (save) scheduleSave();
@@ -484,7 +609,7 @@ function flushSave() {
saveTimer = 0;
const save = pendingSave;
pendingSave = null;
- return save ? saveOffset(save) : Promise.resolve();
+ return Promise.all([save ? saveOffset(save) : null, flushUserSave()]);
}
/** Forget an offset waiting to be saved in the store: the entry it belongs
@@ -496,6 +621,11 @@ function dropStoreSave() {
saveTimer = 0;
pendingSave = null;
}
+ if (userSave?.from === "store") {
+ clearTimeout(userSaveTimer);
+ userSaveTimer = 0;
+ userSave = null;
+ }
return flushSave();
}
@@ -560,6 +690,542 @@ async function autoDetect() {
}
}
+// ── Sync lines ──
+//
+// Timing the lines one by one, for lyrics whose own timing is off in places
+// rather than as a whole. Tap along with playback (T or Enter at the start of
+// each line), or drag a line's marker in the lane over the waveform, or nudge
+// the line in hand. What is set is the user's timing, which takes precedence
+// over the server's fitted to the vocals and over the Align offset
+// (lyricsSync.js timingOf). It is kept where the lyrics are: PUT
+// .../lyrics/user-synced for lyrics.json, the track's store entry for lyrics
+// kept here.
+
+// The arrows' steps, in seconds, and Shift's.
+const NUDGE_FINE = 0.05;
+const NUDGE_COARSE = 0.25;
+// The lead-in: how far before a line the playhead goes when it is taken in hand.
+const LEAD_IN_SEC = 2;
+// How often a re-time from the vocals is asked how it is getting on, and how
+// many answers of "idle" are taken for "not started yet" rather than lost.
+const RETIME_POLL_MS = 1000;
+const RETIME_IDLE_POLLS = 3;
+
+const now = () => transport()?.getCurrentTime?.() ?? 0;
+const levels = () => (envelopeFor === shownTrackId ? envelope : null);
+const laneLength = () => totalDuration || (lines.at(-1)?.time ?? 0) + 10;
+
+/** The Sync lines link in the tools row. Disabled, and saying why, for
+ * lyrics with no timing at all. */
+function syncToggle() {
+ const toggle = el("button", "lyrics-link lyrics-sync-toggle", t("lyrics.sync.button"));
+ toggle.type = "button";
+ syncToggleEl = toggle;
+ if (!lines.length) {
+ toggle.setAttribute("aria-disabled", "true");
+ toggle.title = t("lyrics.sync.syncedOnly");
+ return toggle;
+ }
+ toggle.title = t("lyrics.sync.openTitle");
+ toggle.setAttribute("aria-controls", "lyricsSync");
+ toggle.setAttribute("aria-expanded", String(Boolean(sync)));
+ toggle.addEventListener("click", (e) => {
+ if (sync) closeSync(false);
+ else openSync({ focus: e.detail === 0 });
+ if (e.detail > 0) toggle.blur();
+ });
+ return toggle;
+}
+
+function openSync({ focus = false } = {}) {
+ if (!lines.length || sync) return;
+ if (align) closeAlign(false);
+ syncOpen = true;
+ const session = new SyncSession(lines, {
+ // The lyrics' own timing is usually right line to line and off as a
+ // whole, so a tap carries the lines after it along; timing already fitted
+ // line by line is left where it is.
+ ripple: timingKind === "offset",
+ max: totalDuration || Infinity,
+ meta: { kind: timingKind },
+ });
+ sync = buildSyncPanel(session);
+ syncToggleEl?.closest(".lyrics-tools")?.after(sync.panel);
+ syncToggleEl?.setAttribute("aria-expanded", "true");
+ bodyEl.classList.add("syncing");
+ showLineTimes();
+ showLane(lines, laneLength(), {
+ select: (i) => selectLine(i),
+ click: (i) => selectLine(i, { seek: true }),
+ drag: (i, time, { free }) => applyTime(sync.session.lines, i, snapped(time, free), {
+ mode: sync.session.ripple ? "ripple" : "clamp",
+ fixed: sync.session.touched,
+ max: sync.session.max,
+ }),
+ drop: (i, time, { free, cancelled }) => {
+ if (!cancelled && sync.session.move(i, snapped(time, free))) edited();
+ else updateLane(sync.session.lines, sync.session.cursor);
+ },
+ done: () => closeSync(true),
+ });
+ onCursor();
+ showSyncState();
+ if (focus) sync.tap.focus();
+}
+
+/** Leave Sync lines: what is waiting to be saved is saved now. `keepOpen`
+ * leaves the mode on for when the same track's lyrics are drawn again. */
+function closeSync(focusToggle, { keepOpen = false } = {}) {
+ if (!keepOpen) syncOpen = false;
+ if (!sync) return;
+ retimeToken++;
+ flushUserSave();
+ sync.panel.remove();
+ sync.lineTools.remove();
+ for (const row of lineEls) {
+ row.classList.remove("selected");
+ delete row.dataset.time;
+ }
+ bodyEl.classList.remove("syncing");
+ hideLane();
+ sync = null;
+ syncToggleEl?.setAttribute("aria-expanded", "false");
+ if (focusToggle) syncToggleEl?.focus();
+}
+
+/** One of the panel's options, a checkbox that lets go of focus once
+ * changed, so Space goes back to play and pause. */
+function syncOption(key, checked, onChange, titleKey) {
+ const label = el("label", "lyrics-sync-opt");
+ const box = el("input");
+ box.type = "checkbox";
+ box.checked = checked;
+ box.addEventListener("change", () => {
+ onChange(box.checked);
+ box.blur();
+ });
+ label.title = t(titleKey);
+ label.append(box, el("span", "", t(key)));
+ return label;
+}
+
+function buildSyncPanel(session) {
+ const panel = el("div", "lyrics-sync");
+ panel.id = "lyricsSync";
+ panel.setAttribute("role", "group");
+ panel.setAttribute("aria-label", t("lyrics.sync.title"));
+
+ const head = el("div", "lyrics-sync-head");
+ const kind = el("span", "lyrics-sync-kind");
+ const saveState = el("span", "lyrics-sync-kind lyrics-sync-save");
+ saveState.setAttribute("aria-live", "polite");
+ head.append(el("h3", "lyrics-sync-title", t("lyrics.sync.title")), kind, saveState);
+
+ const next = el("p", "lyrics-sync-next");
+ const nextLabel = el("span", "lyrics-sync-next-label");
+ const nextText = el("span", "lyrics-sync-next-text");
+ next.append(nextLabel, nextText);
+ next.setAttribute("aria-live", "polite");
+
+ const tap = alignButton(t("lyrics.sync.tap"), tapNow, "lyrics-sync-tap");
+ tap.append(el("kbd", "", t("lyrics.sync.tapKey")));
+ tap.title = t("lyrics.sync.tapTitle");
+
+ const undo = alignButton(t("lyrics.sync.undo"), undoSync);
+ undo.title = t("lyrics.sync.undoTitle");
+ const edits = el("div", "lyrics-sync-row");
+ edits.append(undo);
+
+ const opts = el("div", "lyrics-sync-opts");
+ opts.append(
+ syncOption("lyrics.sync.leadIn", leadIn, (on) => { leadIn = on; }, "lyrics.sync.leadInTitle"),
+ syncOption("lyrics.sync.snap", snapOn, (on) => { snapOn = on; }, "lyrics.sync.snapTitle"),
+ syncOption("lyrics.sync.ripple", session.ripple, (on) => { session.ripple = on; }, "lyrics.sync.rippleTitle"),
+ );
+
+ const retimeBtn = alignButton(t("lyrics.sync.retime"), retimeFromVocals);
+ retimeBtn.title = t("lyrics.sync.retimeTitle");
+ const detected = alignButton(t("lyrics.sync.resetDetected"), resetToDetected);
+ detected.title = t("lyrics.sync.resetDetectedTitle");
+ const original = alignButton(t("lyrics.sync.resetOriginal"), resetToOriginal);
+ original.title = t("lyrics.sync.resetOriginalTitle");
+ // No timing of their own (plain lyrics fitted to the vocals): nothing to
+ // go back to.
+ original.disabled = !baseLines.length;
+ const resets = el("div", "lyrics-sync-row");
+ resets.append(retimeBtn, detected, original);
+
+ const message = el("p", "lyrics-align-message");
+ message.setAttribute("aria-live", "polite");
+ const keys = el("p", "lyrics-sync-keys", t("lyrics.sync.keys"));
+ const done = alignButton(t("lyrics.sync.done"), () => closeSync(true), "lyrics-align-done");
+ done.title = t("lyrics.sync.doneTitle");
+
+ panel.append(head, next, tap, edits, opts, resets, message, keys, done);
+
+ // The line in hand's own controls, shown under it in the list.
+ const lineTools = el("div", "lyrics-line-tools");
+ lineTools.setAttribute("role", "group");
+ lineTools.setAttribute("aria-label", t("lyrics.sync.lineToolsAria"));
+ const lineStep = (step) => {
+ // Hundredths: the steps are 0.05 and 0.25 s.
+ const btn = alignButton(formatSeconds(step, "always", 2), () => nudgeLine(step), "lyrics-align-nudge");
+ const label = t(step < 0 ? "lyrics.align.earlier" : "lyrics.align.later", { seconds: formatSeconds(Math.abs(step), "never", 2) });
+ btn.setAttribute("aria-label", label);
+ btn.title = label;
+ return btn;
+ };
+ const here = alignButton(t("lyrics.sync.here"), lineToPlayhead, "lyrics-line-here");
+ here.title = t("lyrics.sync.hereTitle");
+ lineTools.append(lineStep(-NUDGE_COARSE), lineStep(-NUDGE_FINE), lineStep(NUDGE_FINE), lineStep(NUDGE_COARSE), here);
+
+ return { panel, session, kind, saveState, nextLabel, nextText, tap, undo, retimeBtn, message, lineTools };
+}
+
+/** Where the singing starts near `time`, with Snap on and Alt not held. */
+function snapped(time, free = false) {
+ return snapOn && !free ? snapToVoice(levels(), time) : time;
+}
+
+/** Take line `index` in hand; with `seek`, the playhead goes to it. */
+function selectLine(index, { seek = false } = {}) {
+ if (!sync || index < 0 || index >= lines.length) return;
+ sync.session.select(index);
+ onCursor({ reveal: true });
+ if (seek) setPlayheadTime(Math.max(0, lines[index].time - (leadIn ? LEAD_IN_SEC : 0)));
+}
+
+/** Show which line is in hand: framed in the list with its controls under
+ * it, lit in the lane, named as the next to tap. */
+function onCursor({ reveal = false } = {}) {
+ if (!sync) return;
+ const index = sync.session.cursor;
+ lineEls.forEach((row, i) => row.classList.toggle("selected", i === index));
+ setLaneSelected(index);
+ placeLineTools();
+ const line = lines[index];
+ sync.tap.disabled = !line;
+ sync.nextText.removeAttribute("lang");
+ if (line) {
+ sync.nextLabel.textContent = t("lyrics.sync.next", { n: index + 1, count: lines.length });
+ sync.nextText.textContent = line.text || "♪";
+ tagLang(sync.nextText, line.text);
+ } else {
+ sync.nextLabel.textContent = t("lyrics.sync.allSetLabel");
+ sync.nextText.textContent = t("lyrics.sync.allSet");
+ }
+ if (!reveal) return;
+ const row = lineEls[index];
+ if (row && (row.offsetTop < bodyEl.scrollTop || row.offsetTop + row.offsetHeight > bodyEl.scrollTop + bodyEl.clientHeight)) {
+ bodyEl.scrollTo({ top: row.offsetTop - bodyEl.clientHeight / 3, behavior: "auto" });
+ }
+ revealLaneSelected();
+}
+
+function placeLineTools() {
+ if (!sync) return;
+ const row = lineEls[sync.session.cursor];
+ if (row) row.after(sync.lineTools);
+ else sync.lineTools.remove();
+}
+
+/** What Sync lines says about the timing in effect. */
+function showSyncState() {
+ if (!sync) return;
+ sync.kind.textContent = t(`lyrics.sync.kind.${timingKind}`);
+ sync.undo.disabled = !sync.session.canUndo;
+}
+
+function setSyncMessage(text = "", kind = "") {
+ if (!sync) return;
+ sync.message.textContent = text;
+ sync.message.className = `lyrics-align-message${kind ? ` ${kind}` : ""}`;
+}
+
+/**
+ * The session changed: show its lines as the timing in effect everywhere,
+ * and keep them. Which timing they are is the session's (meta.kind): the
+ * user's own after an edit, or what a reset or an undo went back to, in
+ * which case the user's timing is dropped rather than saved.
+ */
+function syncChanged({ reveal = false } = {}) {
+ if (!sync) return;
+ const { session } = sync;
+ setDisplayLines(session.lines);
+ updateLane(lines, session.cursor);
+ onCursor({ reveal });
+ if (session.meta.kind === "user") {
+ saveUserTiming(serializeLrc(lines));
+ } else if (shownEntry?.userSynced) {
+ saveUserTiming(null);
+ }
+ timingKind = session.meta.kind;
+ showSyncState();
+ showAlignApplies();
+}
+
+/** An edit by hand: the lines are now the user's own timing. */
+function edited(options) {
+ sync.session.meta.kind = "user";
+ setSyncMessage();
+ syncChanged(options);
+}
+
+function tapNow() {
+ if (!sync || sync.session.done) return;
+ sync.session.tap(snapped(now()));
+ // The tap is seen as well as heard: the button flashes.
+ sync.tap.classList.remove("tapped");
+ void sync.tap.offsetWidth;
+ sync.tap.classList.add("tapped");
+ edited({ reveal: true });
+}
+
+function nudgeLine(delta) {
+ if (!sync || sync.session.done) return;
+ if (sync.session.nudge(sync.session.cursor, delta)) edited({ reveal: true });
+}
+
+/** The line in hand starts at the playhead, exactly; it stays in hand. */
+function lineToPlayhead() {
+ if (!sync || sync.session.done) return;
+ if (sync.session.setTime(sync.session.cursor, now(), "push")) edited();
+}
+
+function undoSync() {
+ if (sync?.session.undo()) {
+ setSyncMessage();
+ syncChanged({ reveal: true });
+ }
+}
+
+function redoSync() {
+ if (sync?.session.redo()) {
+ setSyncMessage();
+ syncChanged({ reveal: true });
+ }
+}
+
+/** Reset to detected: the user's timing goes, and what was there before it
+ * shows again, the server's fitted to the vocals or the lyrics' own at the
+ * Align offset. Undo brings it back. */
+function resetToDetected() {
+ if (!sync || !shownEntry) return;
+ const detected = timingOf({ ...shownEntry, userSynced: "", offsetSec: offset });
+ if (!detected.lines.length) {
+ setSyncMessage(t("lyrics.sync.nothingDetected"), "muted");
+ return;
+ }
+ sync.session.replace(detected.lines);
+ sync.session.meta.kind = detected.kind;
+ syncChanged();
+ setSyncMessage(t(`lyrics.sync.backTo.${detected.kind}`));
+}
+
+/** Reset to original: the lyrics' own timing, as they came, with no offset,
+ * kept as the user's timing so it takes precedence over any other. */
+function resetToOriginal() {
+ if (!sync || !baseLines.length) return;
+ sync.session.replace(baseLines);
+ edited();
+ setSyncMessage(t("lyrics.sync.backTo.original"));
+}
+
+/** Keep the user's timing (`lrc`), or drop it (null), a moment after the last
+ * change: a run of taps or nudges is one request. */
+function saveUserTiming(lrc) {
+ if (!shownEntry) return;
+ if (lrc == null) delete shownEntry.userSynced;
+ else shownEntry.userSynced = lrc;
+ userSave = { trackId: shownTrackId, entry: shownEntry, others: shownOthers, from: shownFrom, lrc };
+ if (sync) sync.saveState.textContent = t("lyrics.sync.saving");
+ clearTimeout(userSaveTimer);
+ userSaveTimer = setTimeout(flushUserSave, SAVE_DELAY_MS);
+}
+
+function flushUserSave() {
+ clearTimeout(userSaveTimer);
+ userSaveTimer = 0;
+ const save = userSave;
+ userSave = null;
+ if (!save) return userSaveChain;
+ // In order: a removal sent after a save must land after it.
+ userSaveChain = userSaveChain.then(() => sendUserTiming(save));
+ return userSaveChain;
+}
+
+async function sendUserTiming({ trackId, entry, others, from, lrc }) {
+ try {
+ if (from === "server") {
+ const url = `/api/jobs/${encodeURIComponent(trackId)}/lyrics/user-synced`;
+ const res = lrc == null
+ ? await fetch(url, { method: "DELETE", keepalive: true })
+ : await fetch(url, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ synced: lrc }),
+ keepalive: true,
+ });
+ // Nothing to remove is what a removal wanted.
+ if (!res.ok && !(lrc == null && res.status === 404)) throw new Error(`HTTP ${res.status}`);
+ } else {
+ const kept = { ...entry };
+ if (lrc == null) delete kept.userSynced;
+ else kept.userSynced = lrc;
+ await storeSet(storeKey(trackId), { entry: kept, others });
+ }
+ if (trackId === shownTrackId && sync && !userSave) sync.saveState.textContent = t("lyrics.sync.saved");
+ } catch (err) {
+ console.warn("lyrics timing save failed", err);
+ if (trackId === shownTrackId && sync) {
+ sync.saveState.textContent = "";
+ setSyncMessage(t("lyrics.sync.saveFailed"), "error");
+ }
+ }
+}
+
+/**
+ * Re-time from vocals: the server fits every line to the vocals stem (POST
+ * .../lyrics/retime), which takes a while, so it is asked how it is getting
+ * on until it is done. Done, its timing replaces what is shown and the user's
+ * is dropped, which Undo brings back. Unsure or failed, nothing changes and
+ * the panel says so.
+ */
+async function retimeFromVocals() {
+ if (!sync || sync.retimeBtn.getAttribute("aria-busy") === "true") return;
+ const trackId = shownTrackId;
+ const from = shownFrom;
+ const entry = shownEntry;
+ const token = ++retimeToken;
+ const base = `/api/jobs/${encodeURIComponent(trackId)}/lyrics/retime`;
+ sync.retimeBtn.setAttribute("aria-busy", "true");
+ setSyncMessage(t("lyrics.sync.retiming"), "loading");
+ const live = () => token === retimeToken && trackId === shownTrackId && sync;
+ const finish = (text, kind) => {
+ if (!live()) return;
+ sync.retimeBtn.removeAttribute("aria-busy");
+ setSyncMessage(text, kind);
+ };
+ let started = false;
+ try {
+ const res = await fetch(base, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ // Lyrics kept here are not in lyrics.json: they go along.
+ body: JSON.stringify(from === "server" ? {} : { synced: entry?.synced || "", plain: entry?.plain || "" }),
+ });
+ started = res.ok;
+ if (!started) console.warn("lyrics re-time refused", res.status);
+ } catch (err) {
+ console.warn("lyrics re-time failed", err);
+ }
+ if (!started) return finish(t("lyrics.sync.retimeFailed"), "error");
+
+ let idle = 0;
+ for (;;) {
+ await new Promise((resolve) => setTimeout(resolve, RETIME_POLL_MS));
+ if (!live()) return;
+ let status = null;
+ try {
+ const res = await fetch(base);
+ if (res.ok) status = await res.json();
+ } catch (err) {
+ console.warn("lyrics re-time status failed", err);
+ }
+ if (!live()) return;
+ const state = status?.state;
+ if (state === "running" || (state === "idle" && ++idle <= RETIME_IDLE_POLLS)) {
+ const p = Number(status?.progress);
+ const percent = Number.isFinite(p) && p > 0 ? Math.round(p <= 1 ? p * 100 : p) : 0;
+ setSyncMessage(percent ? t("lyrics.sync.retimingProgress", { percent }) : t("lyrics.sync.retiming"), "loading");
+ continue;
+ }
+ if (state === "unsure") return finish(t("lyrics.sync.retimeUnsure"), "muted");
+ if (state !== "done") return finish(t("lyrics.sync.retimeFailed"), "error");
+ // Lyrics.json holds the new timing; lyrics kept here get it in the answer.
+ const aligned = from === "server"
+ ? (await serverLyrics(trackId))?.entry?.aligned ?? null
+ : alignedFrom(status.aligned);
+ if (!live()) return;
+ const next = aligned ? parseLrc(aligned.synced) : [];
+ if (!next.length) return finish(t("lyrics.sync.retimeFailed"), "error");
+ entry.aligned = aligned;
+ sync.session.replace(next);
+ sync.session.meta.kind = "aligned";
+ syncChanged();
+ // Kept here: the new timing is saved with the entry (the user's own
+ // timing went with the change above).
+ if (from === "store") {
+ storeSet(storeKey(trackId), { entry: { ...entry }, others: shownOthers })
+ .catch((err) => console.warn("lyrics timing save failed", err));
+ }
+ return finish(aligned.lines
+ ? t("lyrics.sync.retimed", { matched: aligned.linesMatched, count: aligned.lines })
+ : t("lyrics.sync.retimedPlain"));
+ }
+}
+
+/** Whether a key press is typing into a field, which Sync lines leaves be. */
+function isTyping(target) {
+ if (target instanceof HTMLInputElement) return !["checkbox", "radio", "button"].includes(target.type);
+ return target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || Boolean(target?.isContentEditable);
+}
+
+/** Whether an editor, dialog or popover other than the Lyrics panel's own is
+ * open, and so owns Escape and undo. */
+function anotherEditorOpen() {
+ if (document.querySelector('#t-metro-edit[aria-pressed="true"], dialog[open]')) return true;
+ const panel = document.getElementById("lyricsPanel");
+ // Popups only: a button that opens a menu or panel and has it open. The
+ // sidebar's collapse button is aria-expanded whenever the sidebar shows.
+ return [...document.querySelectorAll('[aria-haspopup][aria-expanded="true"]')].some((el) => !panel?.contains(el));
+}
+
+/** The Sync lines keys, while the mode is on. On the window, ahead of every
+ * other handler: Backspace also moves the open track to the Trash, and the
+ * arrows would scroll the list. */
+function onSyncKey(e) {
+ if (!sync || !visible || isTyping(e.target)) return;
+ const mod = e.ctrlKey || e.metaKey;
+ // The keyboard belongs to whatever else is open on top: the beat-grid
+ // editor, a dialog, a menu or panel outside the Lyrics panel. Heard first
+ // here (capture), Escape would close Sync lines and leave that stuck open,
+ // undo would undo the lyrics instead of the grid, and the arrows would move
+ // the lyrics instead of the menu.
+ if (anotherEditorOpen()) return;
+ let handled = true;
+ if (mod && e.code === "KeyZ") {
+ if (e.shiftKey) redoSync();
+ else undoSync();
+ } else if (mod && e.code === "KeyY") {
+ redoSync();
+ } else if (mod || e.altKey) {
+ handled = false;
+ } else if (e.code === "KeyT") {
+ tapNow();
+ } else if (e.key === "Enter") {
+ // Enter on another button presses that button.
+ const control = e.target?.closest?.("button, a, [role='button']");
+ if (control && control !== sync.tap) handled = false;
+ else tapNow();
+ } else if (e.key === "Backspace") {
+ undoSync();
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
+ nudgeLine((e.key === "ArrowLeft" ? -1 : 1) * (e.shiftKey ? NUDGE_COARSE : NUDGE_FINE));
+ } else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
+ const at = Math.min(sync.session.cursor, lines.length);
+ selectLine(Math.max(0, Math.min(lines.length - 1, at + (e.key === "ArrowUp" ? -1 : 1))));
+ } else if (e.key === "Escape") {
+ closeSync(true);
+ } else {
+ handled = false;
+ }
+ if (handled) {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ }
+}
+
function versionList(matches, all) {
const list = el("ul", "lyrics-version-list");
for (const match of matches) {
@@ -595,8 +1261,11 @@ async function keep(match, others) {
const info = getCurrentTrackInfo();
if (!info) return;
const entry = { v: 1, source: "lrclib", ...match, savedAt: Date.now() };
- // Another version, at its own timing: the offset was the old one's.
+ // Another version, at its own timing: the offset and the timing line by
+ // line were the old one's.
delete entry.offsetSec;
+ delete entry.userSynced;
+ delete entry.aligned;
await dropStoreSave();
await storeSet(storeKey(info.id), { entry, others });
answered.delete(info.id);
@@ -768,7 +1437,10 @@ function keptForAnotherSong(entry, info) {
async function loadForCurrentTrack() {
const info = getCurrentTrackInfo();
// The Align panel stays open while the same track is shown again.
- if ((info?.id ?? null) !== shownTrackId) alignOpen = false;
+ if ((info?.id ?? null) !== shownTrackId) {
+ alignOpen = false;
+ syncOpen = false;
+ }
shownTrackId = info?.id ?? null;
nothingKnownFor = null;
searchController?.abort();
@@ -861,10 +1533,13 @@ function tick() {
if (lines.length) {
// 50ms ahead: a click on a line seeks to its exact stamp, and the time read
// back can land a hair before it, which would mark the line above.
- const now = (transport()?.getCurrentTime?.() ?? 0) + 0.05;
+ const time = transport()?.getCurrentTime?.() ?? 0;
+ const now = time + 0.05;
const index = currentLineIndex(lines, now);
if (index !== currentIndex) moveTo(index);
fillWords(index, now);
+ // One transform, and only when playback has moved.
+ if (sync) setLanePlayhead(time);
}
frame = requestAnimationFrame(tick);
}
@@ -881,6 +1556,7 @@ function moveTo(index) {
}
lineEls[currentIndex + 1]?.classList.remove("next");
currentIndex = index;
+ if (sync) setLaneCurrent(index);
lineEls[index + 1]?.classList.add("next");
const line = lineEls[index];
if (!line) return;
@@ -909,6 +1585,9 @@ function fillWords(index, now) {
function setVisible(on) {
visible = on;
+ // Sync lines works with the list in view; another view of the sidebar
+ // leaves it, and what it set is saved.
+ if (!on) closeSync(false);
if (on) {
loadForCurrentTrack();
if (!frame) frame = requestAnimationFrame(tick);
@@ -925,10 +1604,12 @@ export function initLyrics() {
bodyEl = document.getElementById("lyricsBody");
if (!panel || !statusEl || !versionsEl || !bodyEl) return;
- // An offset still waiting to be saved goes before the page does.
+ // An offset or a timing still waiting to be saved goes before the page does.
window.addEventListener("pagehide", () => {
flushSave();
});
+ // Capture, on the window: ahead of the app's own keys (see onSyncKey).
+ window.addEventListener("keydown", onSyncKey, true);
document.addEventListener("catalogviewchange", (e) => setVisible(e.detail?.view === "lyrics"));
document.addEventListener("tracktags", (e) => {
if (visible && nothingKnownFor && e.detail?.id === nothingKnownFor) loadForCurrentTrack();
diff --git a/static/js/lyricsLane.js b/static/js/lyricsLane.js
new file mode 100644
index 00000000..eebe0999
--- /dev/null
+++ b/static/js/lyricsLane.js
@@ -0,0 +1,220 @@
+// The lyrics lane: a strip over the waveform lanes, shown while the Lyrics
+// tab's Sync lines mode is on, with one marker per line at the moment it
+// starts, labelled with its first words. A marker is dragged to move its line,
+// clicked to take it in hand; the line being sung is lit as playback goes.
+//
+// Built like the Sections ribbon (sections.js): the markers sit at percentages
+// of an inner track that is the zoomed waveform's width, and transport.js
+// slides that track by the waveform's scroll (syncRulerScroll), so zooming and
+// scrolling need nothing from here. The markers are only redrawn when a line
+// moves, never per frame; the playhead is one transform.
+//
+// It holds no lyrics state of its own: lyrics.js hands it the lines and gets
+// back what the pointer did.
+
+import { t } from "./i18n.js";
+
+let laneEl = null;
+let trackEl = null;
+let playheadEl = null;
+let marks = [];
+let duration = 0;
+let lines = [];
+let selected = -1;
+let current = -1;
+let handlers = {};
+let lastPlayhead = -1;
+
+/** A pixel of pointer travel that still counts as a click, not a drag. */
+const CLICK_SLOP_PX = 3;
+
+function el(tag, className, text) {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text != null) node.textContent = text;
+ return node;
+}
+
+/** What a marker says: the line's first words, or a note for a gap. */
+function labelOf(line) {
+ const text = String(line?.text || "").trim();
+ return text || "♪";
+}
+
+/**
+ * Show the lane for `nextLines` over a track `trackDuration` seconds long.
+ * `on` holds the callbacks: select(index), drag(index, seconds, { free })
+ * while a marker moves (returns the lines to draw meanwhile), drop(index,
+ * seconds, { free }) when it is let go, and done() for the lane's Done.
+ */
+export function showLane(nextLines, trackDuration, on = {}) {
+ laneEl = document.getElementById("lyricsLane");
+ trackEl = document.getElementById("daw-lyrics-track");
+ if (!laneEl || !trackEl) return false;
+ handlers = on;
+ duration = Math.max(0.001, Number(trackDuration) || 0);
+ laneEl.classList.remove("hidden");
+ // Where the waveform is scrolled to now; transport.js keeps it in step
+ // from here on.
+ const wave = document.getElementById("wave-scroll");
+ trackEl.style.transform = `translateX(${-(wave?.scrollLeft || 0)}px)`;
+ const done = document.getElementById("lyricsLaneDone");
+ if (done && !done.dataset.wired) {
+ done.dataset.wired = "1";
+ done.addEventListener("click", (e) => {
+ handlers.done?.();
+ if (e.detail > 0) done.blur();
+ });
+ }
+ marks = [];
+ trackEl.replaceChildren();
+ playheadEl = el("div", "lyr-lane-playhead");
+ playheadEl.setAttribute("aria-hidden", "true");
+ playheadEl.append(el("i"));
+ trackEl.append(playheadEl);
+ lastPlayhead = -1;
+ current = -1;
+ updateLane(nextLines, selected);
+ return true;
+}
+
+export function hideLane() {
+ laneEl?.classList.add("hidden");
+ trackEl?.replaceChildren();
+ marks = [];
+ lines = [];
+ selected = -1;
+ current = -1;
+ handlers = {};
+}
+
+/**
+ * Draw `nextLines` with line `nextSelected` in hand. Markers are made once per
+ * line and after that only moved, so a drag rewrites a few styles.
+ */
+export function updateLane(nextLines, nextSelected = selected) {
+ if (!trackEl) return;
+ lines = nextLines;
+ if (marks.length !== lines.length) {
+ for (const mark of marks) mark.remove();
+ marks = lines.map((line, i) => makeMark(i));
+ trackEl.append(...marks);
+ }
+ lines.forEach((line, i) => {
+ const mark = marks[i];
+ const start = Math.max(0, Math.min(duration, line.time));
+ const end = Math.max(start, Math.min(duration, lines[i + 1]?.time ?? duration));
+ const label = labelOf(line);
+ // Only what moved is written: a drag touches two markers, not all of them.
+ const drawn = mark._drawn;
+ if (drawn && drawn.start === start && drawn.end === end && drawn.label === label) return;
+ mark._drawn = { start, end, label };
+ mark.style.left = `${((start / duration) * 100).toFixed(4)}%`;
+ mark.style.width = `${(((end - start) / duration) * 100).toFixed(4)}%`;
+ const text = mark.firstChild;
+ if (text.textContent !== label) text.textContent = label;
+ mark.setAttribute("aria-label", t("lyrics.sync.markAria", { line: label, time: formatClock(line.time) }));
+ });
+ setLaneSelected(nextSelected);
+}
+
+export function setLaneSelected(index) {
+ marks[selected]?.classList.remove("selected");
+ selected = index;
+ marks[selected]?.classList.add("selected");
+}
+
+export function setLaneCurrent(index) {
+ if (index === current) return;
+ marks[current]?.classList.remove("current");
+ current = index;
+ marks[current]?.classList.add("current");
+}
+
+/** Where playback is, as one transform: no layout. Skipped when unchanged. */
+export function setLanePlayhead(seconds) {
+ if (!playheadEl || seconds === lastPlayhead) return;
+ lastPlayhead = seconds;
+ const pct = Math.max(0, Math.min(100, (seconds / duration) * 100));
+ playheadEl.style.transform = `translateX(${pct}%)`;
+}
+
+/** Scroll the waveform so the marker in hand is on screen, when it is not. */
+export function revealLaneSelected() {
+ const mark = marks[selected];
+ const wave = document.getElementById("wave-scroll");
+ if (!mark || !wave || wave.scrollWidth <= wave.clientWidth) return;
+ const at = (lines[selected].time / duration) * wave.scrollWidth;
+ if (at >= wave.scrollLeft + 24 && at <= wave.scrollLeft + wave.clientWidth - 24) return;
+ wave.scrollLeft = Math.max(0, Math.min(at - wave.clientWidth / 3, wave.scrollWidth - wave.clientWidth));
+}
+
+/** "1:04.25": the lane's own readout, minutes, seconds, hundredths. */
+export function formatClock(seconds) {
+ const cs = Math.max(0, Math.round((Number(seconds) || 0) * 100));
+ const m = Math.floor(cs / 6000);
+ const s = Math.floor((cs % 6000) / 100);
+ return `${m}:${String(s).padStart(2, "0")}.${String(cs % 100).padStart(2, "0")}`;
+}
+
+function makeMark(index) {
+ const mark = el("button", "lyr-mark");
+ mark.type = "button";
+ // The keys reach the lines through the Lyrics tab; a Tab stop per line here
+ // would be dozens of stops on the way to the mixer.
+ mark.tabIndex = -1;
+ mark.dataset.index = String(index);
+ mark.append(el("span", "lyr-mark-label"));
+ wireDrag(mark, index);
+ return mark;
+}
+
+function wireDrag(mark, index) {
+ let active = false;
+ let startX = 0;
+ let origin = 0;
+ let moved = false;
+ let at = 0;
+
+ const timeAt = (clientX) => {
+ const width = trackEl.getBoundingClientRect().width;
+ if (!width) return origin;
+ return origin + ((clientX - startX) / width) * duration;
+ };
+
+ mark.addEventListener("pointerdown", (e) => {
+ if (e.button !== 0) return;
+ active = true;
+ moved = false;
+ startX = e.clientX;
+ origin = lines[index]?.time ?? 0;
+ at = origin;
+ handlers.select?.(index);
+ mark.setPointerCapture(e.pointerId);
+ mark.classList.add("dragging");
+ e.preventDefault();
+ });
+
+ mark.addEventListener("pointermove", (e) => {
+ if (!active) return;
+ if (!moved && Math.abs(e.clientX - startX) < CLICK_SLOP_PX) return;
+ moved = true;
+ at = timeAt(e.clientX);
+ const preview = handlers.drag?.(index, at, { free: e.altKey });
+ if (preview) updateLane(preview, index);
+ });
+
+ const finish = (e, cancelled) => {
+ if (!active) return;
+ active = false;
+ mark.classList.remove("dragging");
+ if (cancelled) {
+ handlers.drop?.(index, origin, { free: true, cancelled: true });
+ return;
+ }
+ if (moved) handlers.drop?.(index, timeAt(e.clientX), { free: e.altKey });
+ else handlers.click?.(index);
+ };
+ mark.addEventListener("pointerup", (e) => finish(e, false));
+ mark.addEventListener("pointercancel", (e) => finish(e, true));
+}
diff --git a/static/js/lyricsLookup.js b/static/js/lyricsLookup.js
index 12bbdb74..9f1a71c9 100644
--- a/static/js/lyricsLookup.js
+++ b/static/js/lyricsLookup.js
@@ -63,6 +63,23 @@ function fromServerVersion(v) {
return version.synced || version.plain || version.instrumental ? version : null;
}
+/**
+ * The server's timing of the lines fitted to the vocals, its "aligned" field
+ * ({ synced, method, matched, lines_matched, lines, at }), in the tab's shape;
+ * null when it holds no timed lines.
+ */
+export function alignedFrom(aligned) {
+ if (!aligned || typeof aligned.synced !== "string" || !aligned.synced.trim()) return null;
+ return {
+ synced: aligned.synced,
+ method: typeof aligned.method === "string" ? aligned.method : "",
+ matched: typeof aligned.matched === "number" ? aligned.matched : null,
+ linesMatched: Number(aligned.lines_matched) || 0,
+ lines: Number(aligned.lines) || 0,
+ at: aligned.at ?? null,
+ };
+}
+
/**
* The lyrics the server found for a track (GET /api/jobs/{id}/lyrics) as the
* tab keeps them: { entry, others }, `others` being the LRCLIB versions to
@@ -75,9 +92,24 @@ export function fromServerLyrics(data) {
const others = (Array.isArray(data.others) ? data.others : [])
.map(fromServerVersion)
.filter((m) => m?.id && m.id !== found?.id);
- // The user's alignment (the Align panel), on the kept version only.
+ // The user's alignment (the Align panel), on the kept version only, and its
+ // timing line by line: the user's own (Sync lines), and the server's fitted
+ // to the vocals. lyricsSync.js timingOf() says which is in effect.
const offsetSec = clampOffset(data.offset_sec);
- if (found) return { entry: { v: 1, ...found, ...(offsetSec ? { offsetSec } : {}) }, others };
+ const userSynced = typeof data.user_synced === "string" && data.user_synced.trim() ? data.user_synced : "";
+ const aligned = alignedFrom(data.aligned);
+ if (found) {
+ return {
+ entry: {
+ v: 1,
+ ...found,
+ ...(offsetSec ? { offsetSec } : {}),
+ ...(userSynced ? { userSynced } : {}),
+ ...(aligned ? { aligned } : {}),
+ },
+ others,
+ };
+ }
return others.length ? { entry: null, others } : null;
}
diff --git a/static/js/lyricsSync.js b/static/js/lyricsSync.js
new file mode 100644
index 00000000..a92119f6
--- /dev/null
+++ b/static/js/lyricsSync.js
@@ -0,0 +1,302 @@
+// Timing synced lyrics line by line, for the Lyrics tab's Sync lines mode.
+//
+// A track's lyrics can be timed three ways, and the one in effect is, first
+// found:
+// 1. the user's own, set line by line here ("user_synced", or userSynced
+// in the store entry of lyrics kept in the browser);
+// 2. the server's, fitted line by line to the vocals stem ("aligned");
+// 3. the lyrics' own timing, moved as a whole by the Align panel's offset.
+// timingOf() picks it. Everything that times a line or a word (the list, the
+// karaoke wipe, a line click, the lane over the waveform) reads its lines.
+//
+// SyncSession is the editor: one line in hand (the cursor), taps that set it
+// to the playhead and move on, drags and nudges that move one line, and an
+// undo history over all of them. Lines never cross: a line dragged is held
+// between its neighbours, a line tapped pushes the lines it would pass.
+//
+// No DOM here, so node runs it (tests/js/lyrics-sync.test.mjs).
+
+import { parseLrc, shiftLines, clampOffset, voicedPhrases } from "./lyricsLookup.js";
+
+// Two lines are never closer than this, so a line dragged onto its neighbour
+// still starts after it and both stay reachable. More than the 50ms the
+// Lyrics tab looks ahead to mark the line being sung (lyrics.js tick), or a
+// click on the line before would mark this one.
+export const MIN_GAP_SEC = 0.1;
+// A line's last word keeps at least this long before the next line starts
+// when the line's words are squeezed to fit.
+const MIN_LAST_WORD_SEC = 0.15;
+// How far a tap or a drop looks for the singing's start to snap to.
+export const SNAP_REACH_SEC = 0.3;
+// Undo keeps this many steps.
+const HISTORY_MAX = 300;
+
+/**
+ * The timing in effect for a kept entry: { kind, lines, offset }. `kind` is
+ * "user" (set here by hand), "aligned" (fitted to the vocals by the server) or
+ * "offset" (the lyrics' own, moved by `offset` seconds). `lines` is [] when
+ * there is no timing at all (plain lyrics nothing has timed).
+ */
+export function timingOf(entry) {
+ const user = typeof entry?.userSynced === "string" ? parseLrc(entry.userSynced) : [];
+ if (user.length) return { kind: "user", lines: user, offset: 0 };
+ const aligned = typeof entry?.aligned?.synced === "string" ? parseLrc(entry.aligned.synced) : [];
+ if (aligned.length) return { kind: "aligned", lines: aligned, offset: 0 };
+ const offset = clampOffset(entry?.offsetSec);
+ return { kind: "offset", lines: shiftLines(parseLrc(entry?.synced || ""), offset), offset };
+}
+
+/** Seconds as an LRC stamp's inside, "mm:ss.xx"; never negative. */
+export function formatStamp(seconds) {
+ const cs = Math.max(0, Math.round((Number(seconds) || 0) * 100));
+ const m = Math.floor(cs / 6000);
+ const s = Math.floor((cs % 6000) / 100);
+ return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(cs % 100).padStart(2, "0")}`;
+}
+
+/**
+ * Lines as LRC, one stamp per line, earliest first, and enhanced LRC's word
+ * stamps for a line that has them: what PUT .../lyrics/user-synced keeps, and
+ * what parseLrc reads back to the same lines.
+ */
+export function serializeLrc(lines) {
+ return lines
+ .map((line) => {
+ const head = `[${formatStamp(line.time)}]`;
+ if (line.words?.length) return head + line.words.map((w) => `<${formatStamp(w.time ?? line.time)}>${w.text}`).join("").trimEnd();
+ return line.text ? `${head} ${line.text}` : head;
+ })
+ .join("\n");
+}
+
+/**
+ * A line's word stamps squeezed, in proportion, to end before `limit` (the
+ * next line's start): a line moved closer to the next one keeps its words in
+ * the same shape, only faster. Lines without word stamps, or with room, are
+ * returned as they are.
+ */
+export function fitWords(line, limit) {
+ if (!line.words?.length || limit == null) return line;
+ const rel = line.words.map((w) => Math.max(0, (w.time ?? line.time) - line.time));
+ const reach = Math.max(...rel);
+ const room = Math.max(0, limit - line.time - MIN_LAST_WORD_SEC);
+ if (reach <= room || reach <= 0) return line;
+ const k = room / reach;
+ return { ...line, words: line.words.map((w, i) => ({ ...w, time: line.time + rel[i] * k })) };
+}
+
+/** `line` at `time`, its words moved with it. */
+function atTime(line, time) {
+ const delta = time - line.time;
+ if (!delta) return line;
+ return {
+ ...line,
+ time,
+ ...(line.words ? { words: line.words.map((w) => ({ ...w, time: (w.time ?? line.time) + delta })) } : {}),
+ };
+}
+
+/**
+ * `lines` with line `index` at `time`, as a new array; `lines` is left alone.
+ *
+ * `mode` says what the other lines do:
+ * "clamp" - nothing; the line is held between its neighbours (a drag, a
+ * nudge);
+ * "push" - the lines it would pass are pushed ahead of it, later ones
+ * later and earlier ones earlier (a tap);
+ * "ripple" - the lines after it move by as much, up to the first of
+ * `fixed` (indexes already set this session, which stay), then
+ * as "push".
+ * `max` is the track's length, past which no line goes. The words of every
+ * line that moved move with it, and are squeezed to fit where a line's next
+ * one came closer.
+ */
+export function applyTime(lines, index, time, { mode = "clamp", fixed = null, max = Infinity } = {}) {
+ const line = lines[index];
+ if (!line || !Number.isFinite(time)) return lines;
+ const out = lines.slice();
+ const top = Number.isFinite(max) && max > 0 ? max : Infinity;
+ let t = Math.min(top, Math.max(0, time));
+ if (mode === "clamp") {
+ const lo = index > 0 ? lines[index - 1].time + MIN_GAP_SEC : 0;
+ const hi = index < lines.length - 1 ? lines[index + 1].time - MIN_GAP_SEC : top;
+ t = Math.max(lo, Math.min(hi, t));
+ // Neighbours closer than the gap already: stay where it was.
+ if (lo > hi) t = line.time;
+ out[index] = atTime(line, t);
+ } else {
+ const delta = t - line.time;
+ out[index] = atTime(line, t);
+ if (mode === "ripple" && delta) {
+ for (let j = index + 1; j < lines.length; j++) {
+ if (fixed?.has?.(j)) break;
+ out[j] = atTime(lines[j], Math.min(top, Math.max(0, lines[j].time + delta)));
+ }
+ }
+ // Nothing crosses the line set: later lines pushed, earlier ones pulled.
+ for (let j = index + 1; j < out.length; j++) {
+ const floor = out[j - 1].time + MIN_GAP_SEC;
+ // No early way out: lines carried by a ripple can cross a line past them.
+ if (out[j].time >= floor) continue;
+ // Order wins over the track's end: a stamp past it is simply never reached.
+ out[j] = atTime(out[j], floor);
+ }
+ for (let j = index - 1; j >= 0; j--) {
+ const ceiling = out[j + 1].time - MIN_GAP_SEC;
+ if (out[j].time <= ceiling) break;
+ out[j] = atTime(out[j], Math.max(0, ceiling));
+ }
+ }
+ // Words of a line whose next line came closer are squeezed to fit.
+ for (let j = 0; j < out.length; j++) {
+ const moved = out[j] !== lines[j] || out[j + 1]?.time !== lines[j + 1]?.time;
+ if (moved && out[j].words) out[j] = fitWords(out[j], out[j + 1]?.time ?? null);
+ }
+ return out;
+}
+
+/**
+ * Where the singing nearest `time` starts, within `reach` seconds, from the
+ * vocals stem's level ({ hop, db }); `time` itself when there is none that
+ * close, or no envelope.
+ */
+export function snapToVoice(envelope, time, reach = SNAP_REACH_SEC) {
+ if (!envelope) return time;
+ // A window wider than the reach, so a phrase that starts at its edge is a
+ // real start and not where the window cut it.
+ const from = Math.max(0, time - reach - 1.5);
+ const phrases = voicedPhrases(envelope, from, time + reach + 1.5);
+ let best = time;
+ let bestOff = Infinity;
+ for (const { start } of phrases) {
+ if (start <= from + envelope.hop) continue;
+ const off = Math.abs(start - time);
+ if (off <= reach && off < bestOff) {
+ best = start;
+ bestOff = off;
+ }
+ }
+ return best;
+}
+
+/** The first line after `index` with words, or lines.length when none. */
+export function nextSungIndex(lines, index) {
+ for (let j = index + 1; j < lines.length; j++) if (lines[j].text.trim()) return j;
+ return lines.length;
+}
+
+/**
+ * The editor's state: the lines being timed, the line in hand (`cursor`,
+ * which a tap sets and the arrows move; lines.length once every line is
+ * tapped), the lines set this session, and undo and redo over all of it.
+ * Every change goes through here, so every change can be undone.
+ */
+export class SyncSession {
+ constructor(lines, { ripple = false, max = Infinity, meta = {} } = {}) {
+ this.lines = lines.slice();
+ // The caller's own notes on each state (lyrics.js keeps which timing it
+ // is), undone and redone with it.
+ this.meta = { ...meta };
+ this.ripple = ripple;
+ this.max = max;
+ this.touched = new Set();
+ this.cursor = Math.max(0, nextSungIndex(this.lines, -1));
+ if (this.cursor >= this.lines.length) this.cursor = 0;
+ this.undoStack = [];
+ this.redoStack = [];
+ }
+
+ get canUndo() { return this.undoStack.length > 0; }
+ get canRedo() { return this.redoStack.length > 0; }
+ get done() { return this.cursor >= this.lines.length; }
+
+ snapshot() {
+ return { lines: this.lines, cursor: this.cursor, touched: new Set(this.touched), meta: { ...this.meta } };
+ }
+
+ restore(snap) {
+ this.lines = snap.lines;
+ this.cursor = snap.cursor;
+ this.touched = snap.touched;
+ this.meta = snap.meta;
+ }
+
+ record() {
+ this.undoStack.push(this.snapshot());
+ if (this.undoStack.length > HISTORY_MAX) this.undoStack.shift();
+ this.redoStack = [];
+ }
+
+ /** Put line `index` in hand. Not a change: nothing to undo. */
+ select(index) {
+ if (index >= 0 && index < this.lines.length) this.cursor = index;
+ }
+
+ /** Set line `index` to `time`, the lines around it as `mode` says (see
+ * applyTime). False when nothing changed. */
+ setTime(index, time, mode) {
+ const next = applyTime(this.lines, index, time, { mode, fixed: this.touched, max: this.max });
+ if (next === this.lines || next.every((line, j) => line.time === this.lines[j].time)) {
+ // Still the line in hand, and still a step for undo when it was tapped.
+ return false;
+ }
+ this.record();
+ this.lines = next;
+ this.touched.add(index);
+ return true;
+ }
+
+ /** A tap: the line in hand starts at `time`, and the next sung line is in
+ * hand. Taps push later lines ahead of them, or carry them along with
+ * `ripple`, so a whole song running late follows the first tap. */
+ tap(time) {
+ if (this.done) return false;
+ const index = this.cursor;
+ const before = this.snapshot();
+ const changed = this.setTime(index, time, this.ripple ? "ripple" : "push");
+ // A tap on the time a line already has still moves on, and undo takes
+ // the cursor back to it.
+ if (!changed) {
+ this.undoStack.push(before);
+ this.redoStack = [];
+ this.touched.add(index);
+ }
+ this.cursor = nextSungIndex(this.lines, index);
+ return true;
+ }
+
+ /** Move line `index` to `time` without passing its neighbours (a drag), or
+ * with the lines after it when `ripple` is on. */
+ move(index, time) {
+ return this.setTime(index, time, this.ripple ? "ripple" : "clamp");
+ }
+
+ nudge(index, delta) {
+ const line = this.lines[index];
+ return line ? this.move(index, line.time + delta) : false;
+ }
+
+ /** Replace every line at once (a reset, or new timing from the vocals). */
+ replace(lines) {
+ this.record();
+ this.lines = lines.slice();
+ this.touched = new Set();
+ if (this.cursor > this.lines.length) this.cursor = this.lines.length;
+ }
+
+ undo() {
+ const snap = this.undoStack.pop();
+ if (!snap) return false;
+ this.redoStack.push(this.snapshot());
+ this.restore(snap);
+ return true;
+ }
+
+ redo() {
+ const snap = this.redoStack.pop();
+ if (!snap) return false;
+ this.undoStack.push(this.snapshot());
+ this.restore(snap);
+ return true;
+ }
+}
diff --git a/static/js/transport.js b/static/js/transport.js
index 3c2fd023..aa5b18c2 100644
--- a/static/js/transport.js
+++ b/static/js/transport.js
@@ -767,6 +767,9 @@ export function syncRulerScroll() {
// document at module scope and that test installs its stub afterwards.
const sectionsTrack = document.getElementById("daw-sections-track");
if (sectionsTrack) sectionsTrack.style.transform = shift;
+ // The lyrics lane (lyricsLane.js), the same kind of strip again.
+ const lyricsTrack = document.getElementById("daw-lyrics-track");
+ if (lyricsTrack) lyricsTrack.style.transform = shift;
}
export function applyWaveZoom() {
diff --git a/tests/acceptance/README.md b/tests/acceptance/README.md
index a66b57af..65a226de 100644
--- a/tests/acceptance/README.md
+++ b/tests/acceptance/README.md
@@ -28,12 +28,43 @@ It opens the app's window and plays a few seconds of several songs out loud.
- First run of a fresh package: 5 to 10 minutes more, for the NVIDIA build of
PyTorch (2.5 GB), FFmpeg, the models and, later, the Whisper model (1.6 GB).
These are kept between runs.
-- A run on an RTX 3080 with those already downloaded: about 20 minutes, most
- of it separating the twelve imports while the checks run. YouTube refuses a
+- A run on an RTX 3080 with those already downloaded: about 25 minutes, most
+ of it separating the twelve imports while the checks run. An import whose
+ LRCLIB lyrics are timed to another cut is also timed line by line to its
+ vocals (a Whisper pass, about 30 s on the GPU), and L7 and L8 add two or
+ three minutes; L8 waits for the queue to empty first. YouTube refuses a
download now and then (a 403); that import is split once more, at the back
of the queue, which can add 10 minutes. The 59 minute compilation (C4, T1)
is the longest import; `STEMDECK_ACCEPTANCE_SKIP_LONG=1` leaves it out.
+## The checks
+
+Titles and ids in `checks.mjs`; steps and expected results above each test
+in `acceptance.spec.mjs`, which runs them in the order they can, not by id.
+
+| Area | Ids |
+|---|---|
+| Setup | S1 to S3 |
+| Settings: song identification | A1 to A3 |
+| Now playing and song details | N1 to N6 |
+| Lyrics | L1 to L4; line by line timing: L5 to L8 |
+| Other languages | C1 to C5 |
+| Timeline | T1 |
+| Errors and cancel | E1 to E6 |
+| Fixes since 0.18.1 | V1 to V4 |
+
+The line by line timing checks:
+
+- L5: W biegu's lyrics, timed to another arrangement, are timed to the
+ vocals on import: 85% of the line starts within 0.5 s of a rise of the
+ voice in the vocals stem's envelope, and "Znowu to samo" near 0:24.
+- L6: Basket Case's lines are timed to the video, the first near 0:16,
+ with word stamps in order inside each line.
+- L7: Sync lines on Wicked: three taps at known playhead times, a marker
+ dragged in the lane, Undo, Done (kept as `user_synced`), Reset to detected.
+- L8: Re-time from vocals on Wicked ends done or unsure within 10 minutes,
+ and the panel says which.
+
## Environment
| Variable | What it does |
diff --git a/tests/acceptance/acceptance.spec.mjs b/tests/acceptance/acceptance.spec.mjs
index 7ad5626e..628ed8f2 100644
--- a/tests/acceptance/acceptance.spec.mjs
+++ b/tests/acceptance/acceptance.spec.mjs
@@ -22,6 +22,7 @@ import {
PACKAGE_DIR,
connect,
launchApp,
+ osDrag,
readState,
stopRecordedApp,
writeState,
@@ -33,10 +34,12 @@ import {
aboutOutline,
api,
closeAbout,
+ clockSeconds,
closeSettings,
importFile,
importLink,
job,
+ lrcLines,
lyricsState,
makeAudio,
note,
@@ -57,6 +60,7 @@ import {
songJob,
spyOnInvoke,
tidy,
+ voiceOnsets,
waitForJob,
watchNetwork,
wideWindow,
@@ -795,6 +799,87 @@ test(title("L2"), async ({ app }, testInfo) => {
partly(testInfo, "Whether any correct word was broken by the repair needs a Polish reader: the program only counted the five words.");
});
+// ─── Lyrics timed line by line ──────────────────────────────────────────────
+
+/**
+ * A track's lyrics as the server keeps them, and the timing in effect, first
+ * present wins (lyricsSync.js timingOf): "user" (Sync lines), "aligned"
+ * (fitted line by line to the vocals) or "offset" (their own at the Align
+ * offset). `sung` is the lines with words.
+ */
+async function keptTiming(id) {
+ const entry = await api(`/api/jobs/${id}/lyrics`);
+ let kind = "offset";
+ let lines = lrcLines(entry.synced).map((l) => ({ ...l, time: l.time + (entry.offset_sec || 0) }));
+ if (entry.user_synced) {
+ kind = "user";
+ lines = lrcLines(entry.user_synced);
+ } else if (entry.aligned?.synced) {
+ kind = "aligned";
+ lines = lrcLines(entry.aligned.synced);
+ }
+ return { entry, kind, lines, sung: lines.filter((l) => l.text) };
+}
+
+/** The first sung line whose words include `words`, folded for case and accents. */
+function lineWith(lines, words) {
+ const fold = (s) => s.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/\s+/g, " ");
+ return lines.find((l) => fold(l.text).includes(fold(words)));
+}
+
+/** Click the list's line holding `words` and read where the playhead went. */
+async function clickLineAt(page, words) {
+ const row = page.locator("#lyricsBody .lyrics-line", { hasText: words }).first();
+ await row.scrollIntoViewIfNeeded();
+ await row.click();
+ await page.waitForTimeout(400);
+ return seconds(await page.locator("#t-time").textContent());
+}
+
+// Steps: import Natalia Kukulska, W biegu; wait for the import to finish
+// (it times LRCLIB's lyrics to the vocals line by line); open Lyrics, click
+// "Znowu to samo". Expect: the lines start where she sings them; "Znowu to
+// samo" starts near 0:24, not at LRCLIB's 0:50.
+//
+// Measured against the vocals stem's own envelope (GET .../vocal-envelope),
+// with the onset rule written again in helpers.mjs voiceOnsets, not the
+// app's: at least 85% of the sung lines start within 0.5 s of a rise of the
+// voice.
+test(title("L5"), async ({ app }, testInfo) => {
+ const { page } = app;
+ await wideWindow(page);
+ const id = await trackFor(page, "polish");
+ const { entry, kind, lines, sung } = await keptTiming(id);
+ const aligned = entry.aligned;
+ note(testInfo, `Lyrics from ${entry.source}, timing "${entry.timing}"; in effect: ${kind}`
+ + (aligned ? `; fitted to the vocals: ${Math.round(aligned.matched * 100)}% heard, ${aligned.lines_matched} of ${aligned.lines} lines found.` : "; nothing fitted to the vocals."));
+ expect(kind, `the lyrics carry a timing line by line from the vocals (source ${entry.source}, timing ${entry.timing})`).toBe("aligned");
+ expect(sung.length, "sung lines").toBeGreaterThan(8);
+
+ const voice = await voiceOnsets(id);
+ expect(voice.frames, "a vocals envelope").toBeGreaterThan(100);
+ const off = sung.filter((l) => !voice.near(l.time, 0.5));
+ const share = 1 - off.length / sung.length;
+ note(testInfo, `${sung.length - off.length} of ${sung.length} line starts (${Math.round(share * 100)}%) within 0.5 s of a rise of the voice`
+ + (off.length ? `; not: ${off.slice(0, 6).map((l) => `${l.time.toFixed(2)} s "${l.text.slice(0, 24)}"`).join(", ")}` : "") + ".");
+ expect(share, "line starts on the singing").toBeGreaterThanOrEqual(0.85);
+
+ const own = lineWith(lrcLines(entry.synced), "Znowu to samo");
+ const znowu = lineWith(lines, "Znowu to samo");
+ expect(znowu, `a line "Znowu to samo" (first lines: ${sung.slice(0, 4).map((l) => l.text).join(" / ")})`).toBeTruthy();
+ note(testInfo, `"Znowu to samo" starts at ${znowu.time.toFixed(2)} s; LRCLIB's copy has it at ${own ? `${own.time.toFixed(2)} s` : "no such line"}.`);
+ expect(Math.abs(znowu.time - 24), "Znowu to samo near 0:24").toBeLessThanOrEqual(1.5);
+
+ // The page shows the same timing: a click on the line plays from there.
+ await openTrack(page, id);
+ await openLyrics(page);
+ const at = await clickLineAt(page, "Znowu to samo");
+ note(testInfo, `A click on the line put the playhead at ${at} s.`);
+ expect(Math.abs(at - Math.floor(znowu.time)), "the playhead went to the line's start").toBeLessThanOrEqual(1);
+ await shot(page, testInfo, "lyrics");
+ partly(testInfo, "Whether each line lands with her voice is for ears: the program measured the line starts against the vocals' rises and one line's place.");
+});
+
// ─── Local files ────────────────────────────────────────────────────────────
// Steps: upload a local MP3 and open it; narrow the window until the card's
@@ -891,7 +976,19 @@ test(title("V2"), async ({ app }, testInfo) => {
await page.mouse.up();
await page.waitForTimeout(500);
let now = await order();
+ let how = "the DevTools mouse";
+ if (JSON.stringify(now) === JSON.stringify(was)) {
+ // WebView2 leaves a DevTools mouse drag undelivered: the real cursor.
+ const dpr = await page.evaluate(() => devicePixelRatio);
+ const from = [grip.x + grip.width / 2, grip.y + grip.height / 2];
+ if (osDrag([from, [from[0], from[1] + 6], [to.x, to.y - 12], [to.x, to.y]], dpr)) {
+ await page.waitForTimeout(800);
+ now = await order();
+ how = "the Windows cursor";
+ }
+ }
if (JSON.stringify(now) === JSON.stringify(was)) {
+ how = "DOM drag events";
// WebView2 did not deliver the mouse drag over the DevTools protocol, so
// the same gesture is replayed as the drag events the library listens
// to, at the same point on the Unsorted row.
@@ -910,7 +1007,7 @@ test(title("V2"), async ({ app }, testInfo) => {
now = await order();
partly(testInfo, "The drag was replayed as DOM drag events (the WebView2 does not take a mouse drag over the DevTools protocol); a hand drag in the window is the remaining check.");
}
- note(testInfo, `Top-level folders before ${was.join(", ")}; after ${now.join(", ")}.`);
+ note(testInfo, `Dragged with ${how}. Top-level folders before ${was.join(", ")}; after ${now.join(", ")}.`);
const inside = await page.evaluate((id) => Boolean(document.querySelector('.folder[data-id="f-unsorted"] .folder[data-id="' + id + '"]')), mineId);
expect(inside, "the folder did not go inside Unsorted").toBe(false);
expect(now.indexOf(mineId), "it moved to just before Unsorted").toBe(now.indexOf("f-unsorted") - 1);
@@ -975,6 +1072,56 @@ test(title("L3"), async ({ app }, testInfo) => {
await shot(page, testInfo, "lyrics");
});
+// Steps: import Green Day, Basket Case (the video); wait for the import to
+// finish; open Lyrics and play from the first line. Expect: the lines are
+// timed one by one to the video, "Do you have the time" near 0:16, and each
+// word lights as it is sung.
+//
+// Runs before L4, whose Auto-detect would move the lyrics' own timing: this
+// reads what the import left.
+test(title("L6"), async ({ app }, testInfo) => {
+ const { page } = app;
+ await wideWindow(page);
+ const id = await trackFor(page, "basketCase");
+ const { entry, kind, sung } = await keptTiming(id);
+ const aligned = entry.aligned;
+ note(testInfo, `Lyrics from ${entry.source}, timing "${entry.timing}"; in effect: ${kind}`
+ + (aligned ? `; fitted to the vocals: ${Math.round(aligned.matched * 100)}% heard, ${aligned.lines_matched} of ${aligned.lines} lines found.` : "; nothing fitted to the vocals."));
+ expect(kind, `the lyrics carry a timing line by line from the vocals (source ${entry.source}, timing ${entry.timing})`).toBe("aligned");
+ expect(sung.length, "sung lines").toBeGreaterThan(8);
+
+ const first = sung[0];
+ note(testInfo, `First line "${first.text}" at ${first.time.toFixed(2)} s.`);
+ expect(first.time, "the first line starts where the video's singing does").toBeGreaterThan(14.5);
+ expect(first.time).toBeLessThan(17.5);
+
+ // Word stamps: on most lines, in order, and inside their own line. The
+ // last stamp of a line is where it ends (lyrics_retime.to_lrc), which may
+ // run a moment into the next line's lead-in; the words' own may not.
+ const next = (i) => sung[i + 1]?.time ?? Infinity;
+ const stamped = sung.filter((l) => l.words.length > 1);
+ const bad = sung.filter((l, i) => l.words.some((w, k) => {
+ const last = k === l.words.length - 1;
+ return w < l.time - 0.01 || w > next(i) + (last ? 0.5 : 0.01) || (k && w < l.words[k - 1]);
+ }));
+ note(testInfo, `${stamped.length} of ${sung.length} lines carry word stamps; ${bad.length} out of order or outside their line.`);
+ expect(stamped.length / sung.length, "word stamps on most lines").toBeGreaterThanOrEqual(0.8);
+ expect(bad.map((l) => `${l.time.toFixed(2)} ${l.text}`), "word stamps in order, inside their line").toEqual([]);
+
+ // The page follows: the first line plays from there, and the words fill.
+ await openTrack(page, id);
+ await openLyrics(page);
+ const at = await clickLineAt(page, first.text.slice(0, 12));
+ note(testInfo, `A click on the first line put the playhead at ${at} s.`);
+ expect(at, "the playhead went to the first line").toBeGreaterThanOrEqual(14);
+ expect(at).toBeLessThanOrEqual(17);
+ const samples = await playAndWatch(page, 6000);
+ expect(Math.max(...samples.map((x) => x.sung)), "words were marked as sung").toBeGreaterThan(0);
+ expect(samples.some((x) => x.singing > 0), "a word was part-filled while it was sung").toBe(true);
+ await shot(page, testInfo, "karaoke");
+ partly(testInfo, "Whether each word lights with the voice is for ears: the program checked the line and word stamps and saw the fill advance.");
+});
+
// Steps: import Green Day, Basket Case (the video); Lyrics, Align, Auto-detect.
// Expect: the first line, "Do you have the time", starts near 16 s, where
// the video's singing does, not at LRCLIB's 0:00; a click on it plays from
@@ -994,6 +1141,24 @@ test(title("L4"), async ({ app }, testInfo) => {
const toggle = page.locator("#lyricsVersions .lyrics-align-toggle");
if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click();
await expect(page.locator("#lyricsAlign")).toBeVisible();
+
+ // Timed line by line on import (L6): a whole-song offset no longer applies,
+ // and the Align panel says so instead of offering Auto-detect.
+ if (saved.aligned?.synced || saved.user_synced) {
+ const key = saved.user_synced ? "lyrics.align.perLineUser" : "lyrics.align.perLineAligned";
+ await expect(page.locator("#lyricsAlign")).toContainText(await tr(page, "en", key));
+ await expect(page.locator("#lyricsAlign .lyrics-align-btn", { hasText: await tr(page, "en", "lyrics.align.detect") })).toBeDisabled();
+ const timed = (saved.user_synced || saved.aligned.synced).split(/\r?\n/)
+ .map((line) => /^\[(\d+):(\d+(?:\.\d+)?)\]\s*(?:<[^>]*>)?\s*(\S.*)$/.exec(line.trim()))
+ .find(Boolean);
+ const start = Number(timed[1]) * 60 + Number(timed[2]);
+ note(testInfo, `Timed line by line; the offset is off, as it should be. First line "${timed[3].replace(/<[^>]*>/g, "")}" at ${start.toFixed(2)} s.`);
+ expect(start, "the first line starts where the video's singing does").toBeGreaterThan(14.5);
+ expect(start).toBeLessThan(17.5);
+ await shot(page, testInfo, "per-line");
+ return;
+ }
+
const message = page.locator("#lyricsAlign .lyrics-align-message");
await page.locator("#lyricsAlign .lyrics-align-btn", { hasText: await tr(page, "en", "lyrics.align.detect") }).click();
const checking = await tr(page, "en", "lyrics.align.detecting");
@@ -1026,6 +1191,213 @@ test(title("L4"), async ({ app }, testInfo) => {
partly(testInfo, "Whether the words now land with the voice is for ears: the program saw the first line move to the video's first sung line.");
});
+// ─── Sync lines ─────────────────────────────────────────────────────────────
+
+/** Open the Lyrics tab's Sync lines mode: its panel and the lane over the waveform. */
+async function openSync(page) {
+ const toggle = page.locator("#lyricsVersions .lyrics-sync-toggle");
+ await expect(toggle, "a Sync lines link").toBeVisible();
+ expect(await toggle.getAttribute("aria-disabled"), "Sync lines is enabled for these lyrics").not.toBe("true");
+ if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click();
+ const panel = page.locator("#lyricsSync");
+ await expect(panel).toBeVisible();
+ await expect(page.locator("#lyricsLane")).toBeVisible();
+ return panel;
+}
+
+/** A button of the Sync lines panel, by its English label. */
+async function syncButton(page, key) {
+ return page.locator("#lyricsSync .lyrics-align-btn", { hasText: await tr(page, "en", key) }).first();
+}
+
+/** Set one of the panel's options (Snap to voice, Move later lines too). */
+async function syncOption(page, key, on) {
+ const box = page.locator("#lyricsSync .lyrics-sync-opt", { hasText: await tr(page, "en", key) }).locator("input");
+ if ((await box.isChecked()) !== on) await box.click();
+ await expect(box).toBeChecked({ checked: on });
+}
+
+/** Each line's start as the list shows it in Sync lines (data-time), in seconds. */
+function listTimes(page) {
+ return page.locator("#lyricsBody .lyrics-line").evaluateAll((rows) => rows.map((r) => r.dataset.time || ""))
+ .then((texts) => texts.map(clockSeconds));
+}
+
+/** The playhead now, from the app's own transport. */
+function playhead(page) {
+ return page.evaluate(async () => (await import("/js/transport.js")).transport()?.getCurrentTime?.() ?? NaN);
+}
+
+/** Wait for the panel to say the timing is saved. */
+async function waitSaved(page) {
+ const saved = await tr(page, "en", "lyrics.sync.saved");
+ await expect(page.locator("#lyricsSync .lyrics-sync-save")).toHaveText(saved, { timeout: 15_000 });
+}
+
+// Steps: open Wicked, Dancing Through Life; Lyrics, Sync lines; play; tap T
+// at the start of three lines; drag a line's marker in the lane over the
+// waveform; Undo; Done; open Sync lines again and Reset to detected.
+// Expect: each tapped line starts where the playhead was, the list says so;
+// the drag moves one line and Undo puts it back; Done keeps the timing
+// (GET .../lyrics shows user_synced); Reset to detected drops it and the
+// lines are as they were.
+//
+// Snap to voice is turned off so a tap lands exactly on the playhead, and
+// Move later lines too so a drag moves one line only.
+test(title("L7"), async ({ app }, testInfo) => {
+ const { page } = app;
+ await wideWindow(page);
+ const id = await trackFor(page, "wicked");
+ const before = await keptTiming(id);
+ note(testInfo, `Timing in effect before: ${before.kind}${before.entry.user_synced ? " (a timing by hand was already kept)" : ""}.`);
+ await openTrack(page, id);
+ await openLyrics(page);
+ const s = await lyricsState(page);
+ expect(s.synced, `synced lyrics (status: "${s.status}")`).toBeGreaterThan(8);
+ await openSync(page);
+ await syncOption(page, "lyrics.sync.snap", false);
+ await syncOption(page, "lyrics.sync.ripple", false);
+ const initial = await listTimes(page);
+ expect(initial.every(Number.isFinite), "every line shows its start").toBe(true);
+ await shot(page, testInfo, "sync-open");
+
+ // Tap three lines while playing, each after putting the playhead 0.6 s
+ // into it: the tap is read between the playhead just before and just after.
+ const rows = page.locator("#lyricsBody .lyrics-line");
+ const selected = () => rows.evaluateAll((els) => els.findIndex((r) => r.classList.contains("selected")));
+ const play = page.locator("#t-play");
+ await play.click();
+ const taps = [];
+ try {
+ for (let k = 0; k < 3; k++) {
+ const index = await selected();
+ expect(index, "a line in hand").toBeGreaterThanOrEqual(0);
+ const target = (await listTimes(page))[index] + 0.6;
+ await page.evaluate(async (t) => (await import("/js/transport.js")).setPlayheadTime(t), target);
+ await page.waitForTimeout(300);
+ const from = await playhead(page);
+ await page.keyboard.press("t");
+ const to = await playhead(page);
+ await expect.poll(selected, { timeout: 5000 }).not.toBe(index);
+ taps.push({ index, target, from, to, shown: (await listTimes(page))[index] });
+ }
+ } finally {
+ await play.click();
+ }
+ note(testInfo, `Taps: ${taps.map((x) => `line ${x.index + 1} at ${x.shown.toFixed(2)} s (playhead ${x.from.toFixed(2)} to ${x.to.toFixed(2)} s)`).join("; ")}.`);
+ for (const x of taps) {
+ expect(x.shown, `line ${x.index + 1} starts where the playhead was`).toBeGreaterThanOrEqual(x.from - 0.03);
+ expect(x.shown).toBeLessThanOrEqual(x.to + 0.03);
+ expect(Math.abs(x.shown - x.target), `line ${x.index + 1} near the seek target`).toBeLessThan(1);
+ }
+ await expect(page.locator("#lyricsSync .lyrics-sync-kind").first()).toHaveText(await tr(page, "en", "lyrics.sync.kind.user"));
+ await waitSaved(page);
+ await shot(page, testInfo, "tapped");
+
+ // Drag: the marker of a later line with room on both sides, moved right
+ // by half its room (at most 3 s).
+ const tapped = await listTimes(page);
+ const last = taps.at(-1).index;
+ let j = -1;
+ let room = 0;
+ for (let i = last + 1; i < tapped.length - 1; i++) {
+ const gap = Math.min(tapped[i + 1] - tapped[i], tapped[i] - tapped[i - 1]);
+ if (gap > room) { room = gap; j = i; }
+ if (room >= 4) break;
+ }
+ expect(j, "a line with room to move").toBeGreaterThan(last);
+ const shift = Math.min(3, (tapped[j + 1] - tapped[j]) / 2);
+ // Taking the line in hand scrolls the waveform to its marker.
+ await rows.nth(j).scrollIntoViewIfNeeded();
+ await rows.nth(j).click();
+ const mark = page.locator(`#daw-lyrics-track .lyr-mark[data-index="${j}"]`);
+ await expect(mark).toBeVisible();
+ const trackWidth = await page.locator("#daw-lyrics-track").evaluate((el) => el.getBoundingClientRect().width);
+ // The lane spans the track's length as the studio has it (lyrics.js laneLength).
+ const duration = await page.evaluate(async () => (await import("/js/state.js")).totalDuration);
+ expect(duration, "the track's length").toBeGreaterThan(0);
+ const box = await mark.boundingBox();
+ const dx = (shift / duration) * trackWidth;
+ const x0 = box.x + Math.min(4, box.width / 2);
+ const y0 = box.y + box.height / 2;
+ let how = "mouse";
+ await page.mouse.move(x0, y0);
+ await page.mouse.down();
+ await page.mouse.move(x0 + dx / 2, y0, { steps: 4 });
+ await page.mouse.move(x0 + dx, y0, { steps: 4 });
+ await page.mouse.up();
+ let dragged = (await listTimes(page))[j];
+ if (Math.abs(dragged - tapped[j]) < 0.01) {
+ // WebView2 leaves a DevTools mouse drag undelivered: the real cursor.
+ const dpr = await page.evaluate(() => devicePixelRatio);
+ if (osDrag([[x0, y0], [x0 + dx / 2, y0], [x0 + dx, y0]], dpr)) {
+ await page.waitForTimeout(500);
+ dragged = (await listTimes(page))[j];
+ how = "the Windows cursor";
+ }
+ }
+ if (Math.abs(dragged - tapped[j]) < 0.01) {
+ // The pointer did not reach the marker through WebView2: the same
+ // gesture as pointer events on the marker itself.
+ how = "pointer events";
+ await mark.evaluate((el, [x, y, d]) => {
+ const fire = (type, cx) => el.dispatchEvent(new PointerEvent(type, {
+ bubbles: true, cancelable: true, pointerId: 1, pointerType: "mouse", isPrimary: true, button: 0, buttons: type === "pointerup" ? 0 : 1, clientX: cx, clientY: y,
+ }));
+ fire("pointerdown", x);
+ fire("pointermove", x + d / 2);
+ fire("pointermove", x + d);
+ fire("pointerup", x + d);
+ }, [x0, y0, dx]);
+ dragged = (await listTimes(page))[j];
+ }
+ const px = duration / trackWidth;
+ note(testInfo, `Dragged line ${j + 1}'s marker by ${dx.toFixed(0)} px (${shift.toFixed(2)} s) with ${how}: ${tapped[j].toFixed(2)} s to ${dragged.toFixed(2)} s.`);
+ expect(Math.abs(dragged - (tapped[j] + shift)), "the dragged line moved by the drag").toBeLessThanOrEqual(Math.max(0.15, 3 * px));
+ const afterDrag = await listTimes(page);
+ expect(afterDrag.filter((t, i) => i !== j && Math.abs(t - tapped[i]) > 0.005), "no other line moved").toEqual([]);
+ if (how === "pointer events") partly(testInfo, "The drag was made with pointer events on the marker, because the mouse over CDP did not move it: dragging with a real mouse is for a person.");
+ await shot(page, testInfo, "dragged");
+
+ // Undo takes the drag back.
+ await (await syncButton(page, "lyrics.sync.undo")).click();
+ await expect.poll(async () => (await listTimes(page))[j], { timeout: 5000 }).toBeCloseTo(tapped[j], 2);
+ expect(await listTimes(page), "every line as before the drag").toEqual(tapped);
+ await waitSaved(page);
+
+ // Done: the timing is kept on the server.
+ await page.locator("#lyricsSync .lyrics-align-done").click();
+ await expect(page.locator("#lyricsSync")).toHaveCount(0);
+ await expect(page.locator("#lyricsLane")).toBeHidden();
+ let kept = null;
+ await expect.poll(async () => {
+ kept = await keptTiming(id);
+ return kept.kind;
+ }, { timeout: 15_000 }).toBe("user");
+ for (const x of taps) {
+ expect(kept.lines[x.index]?.time, `line ${x.index + 1} kept as tapped`).toBeCloseTo(x.shown, 1);
+ }
+ expect(kept.lines[j]?.time, `line ${j + 1} kept as before the drag`).toBeCloseTo(tapped[j], 1);
+ note(testInfo, `Kept: user_synced with ${kept.lines.length} lines.`);
+
+ // Reset to detected: the timing by hand goes, and the lines are as they were.
+ await openSync(page);
+ await (await syncButton(page, "lyrics.sync.resetDetected")).click();
+ const back = await page.locator("#lyricsSync .lyrics-align-message").textContent();
+ note(testInfo, `Reset to detected said "${back.trim()}".`);
+ expect(back.trim()).toBe(await tr(page, "en", `lyrics.sync.backTo.${before.entry.aligned ? "aligned" : "offset"}`));
+ await waitSaved(page);
+ const reset = await listTimes(page);
+ if (before.kind !== "user") {
+ expect(reset.map((t, i) => Math.abs(t - initial[i]) <= 0.011).every(Boolean), "every line back where it was").toBe(true);
+ }
+ await page.locator("#lyricsSync .lyrics-align-done").click();
+ await expect.poll(async () => (await keptTiming(id)).kind, { timeout: 15_000 }).not.toBe("user");
+ expect((await api(`/api/jobs/${id}/lyrics`)).user_synced, "no user_synced kept").toBeUndefined();
+ await shot(page, testInfo, "reset");
+ partly(testInfo, "Whether tapping feels in time while listening is for a person: the program tapped at known playhead times.");
+});
+
// ─── The hour-long compilation ──────────────────────────────────────────────
// Steps: import 邓丽君经典金曲 (59 min); open About this song. Expect: the box
@@ -1083,6 +1455,57 @@ test(title("T1"), async ({ app }, testInfo) => {
if (SKIP_LONG) partly(testInfo, "The hour-long half was skipped (STEMDECK_ACCEPTANCE_SKIP_LONG=1); only the normal song's 0:30 steps were checked.");
});
+// Steps: open Wicked, Dancing Through Life; Lyrics, Sync lines, Re-time from
+// vocals; wait. Expect: the panel says it is listening, then either that the
+// lines were timed from the vocals (how many matched) or that the vocals did
+// not match well enough and nothing changed; never stuck, never an error.
+//
+// The run waits behind any import in progress (the pipeline lock), so this
+// check waits for an idle queue first and then gives the run 10 minutes.
+test(title("L8"), async ({ app }, testInfo) => {
+ const { page } = app;
+ await waitForIdleQueue();
+ await wideWindow(page);
+ const id = await trackFor(page, "wicked");
+ const before = await keptTiming(id);
+ await openTrack(page, id);
+ await openLyrics(page);
+ await openSync(page);
+ const retime = await syncButton(page, "lyrics.sync.retime");
+ const message = page.locator("#lyricsSync .lyrics-align-message");
+ const t0 = Date.now();
+ await retime.click();
+ await expect(retime).toHaveAttribute("aria-busy", "true", { timeout: 5000 });
+ const listening = await tr(page, "en", "lyrics.sync.retiming");
+ await expect.poll(async () => (await message.textContent()).trim(), { timeout: 10_000 }).toContain(listening.replace(/…$/, ""));
+ await shot(page, testInfo, "listening");
+ await expect(retime).not.toHaveAttribute("aria-busy", "true", { timeout: 10 * 60_000 });
+ const took = (Date.now() - t0) / 1000;
+ const said = (await message.textContent()).trim();
+ const state = await api(`/api/jobs/${id}/lyrics/retime`);
+ note(testInfo, `Re-time took ${took.toFixed(0)} s and said "${said}"; the server: ${JSON.stringify(state)}.`);
+ expect(said, "not a failure").not.toBe(await tr(page, "en", "lyrics.sync.retimeFailed"));
+ expect(["done", "unsure"], "the server's answer").toContain(state.state);
+ if (state.state === "done") {
+ const want = (await tr(page, "en", "lyrics.sync.retimed"))
+ .replace("{matched}", String(state.lines_matched)).replace("{count}", String(state.lines));
+ expect(said, "the panel reports the result").toBe(want);
+ await expect(page.locator("#lyricsSync .lyrics-sync-kind").first()).toHaveText(await tr(page, "en", "lyrics.sync.kind.aligned"));
+ const after = await keptTiming(id);
+ expect(after.kind, "the new timing is the one in effect").toBe("aligned");
+ const shown = await listTimes(page);
+ expect(shown.map((t, i) => Math.abs(t - (after.lines[i]?.time ?? NaN)) <= 0.011).every(Boolean), "the list shows the new timing").toBe(true);
+ } else {
+ expect(said, "the panel says the vocals did not match").toBe(await tr(page, "en", "lyrics.sync.retimeUnsure"));
+ const after = await keptTiming(id);
+ expect(after.kind, "the timing is as it was").toBe(before.kind);
+ note(testInfo, "The vocals did not match the lines well enough, so the timing was left as it was: an allowed answer.");
+ }
+ await shot(page, testInfo, "result");
+ await page.locator("#lyricsSync .lyrics-align-done").click();
+ await expect(page.locator("#lyricsSync")).toHaveCount(0);
+});
+
// ─── Errors and cancel, on an idle queue ────────────────────────────────────
/** A fresh page with nothing open, so an import holds the foreground. */
diff --git a/tests/acceptance/checks.mjs b/tests/acceptance/checks.mjs
index 9b0d7f68..7f23ed30 100644
--- a/tests/acceptance/checks.mjs
+++ b/tests/acceptance/checks.mjs
@@ -22,6 +22,10 @@ export const CHECKS = [
{ id: "L2", area: "Lyrics", title: "Polish lyrics get their letters back", refs: [703] },
{ id: "L3", area: "Lyrics", title: "A track with nothing to go on shows no lyrics rather than a guess", refs: [702] },
{ id: "L4", area: "Lyrics", title: "Auto-detect moves lyrics timed to another cut onto the video", refs: [] },
+ { id: "L5", area: "Lyrics", title: "Lyrics timed to another arrangement follow the singer line by line", refs: [] },
+ { id: "L6", area: "Lyrics", title: "Basket Case lines and words follow the video", refs: [] },
+ { id: "L7", area: "Lyrics", title: "Tap to sync, drag and undo in the real app", refs: [] },
+ { id: "L8", area: "Lyrics", title: "Re-time from vocals on demand", refs: [] },
{ id: "C1", area: "Other languages", title: "A Chinese song finds its artist, native name and character-by-character karaoke", refs: [705, 709] },
{ id: "C2", area: "Other languages", title: "A Japanese song finds its artist and lyrics", refs: [705, 709] },
{ id: "C3", area: "Other languages", title: "A Korean song filed under its English title still gets lyrics", refs: [705, 709] },
diff --git a/tests/acceptance/harness.mjs b/tests/acceptance/harness.mjs
index d253f93f..e63ec671 100644
--- a/tests/acceptance/harness.mjs
+++ b/tests/acceptance/harness.mjs
@@ -361,3 +361,56 @@ if ($h -eq [IntPtr]::Zero) { exit 3 }
const out = spawnSync("powershell.exe", ["-NoProfile", "-Command", script], { encoding: "utf8" });
return out.status === 0;
}
+
+/**
+ * Drag with the real Windows cursor (desktop only): SendInput through the
+ * app's window, so WebView2 gets the same input a hand gives. `points` are
+ * page (CSS) pixels, the first where the button goes down, the last where it
+ * comes up. The cursor moves on the tester's screen while this runs. False
+ * when there is no app window to drag in.
+ */
+export function osDrag(points, dpr = 1) {
+ const state = readState();
+ if (state?.mode !== "desktop" || !state.pid || points.length < 2) return false;
+ const path = points.map(([x, y]) => `@(${Math.round(x * dpr)},${Math.round(y * dpr)})`).join(",");
+ const script = `
+Add-Type @"
+using System;
+using System.Runtime.InteropServices;
+public static class Mouse {
+ [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; }
+ [DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
+ [DllImport("user32.dll")] public static extern bool ClientToScreen(IntPtr h, ref POINT p);
+ [DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
+ [DllImport("user32.dll")] public static extern void mouse_event(uint f, uint dx, uint dy, uint d, UIntPtr e);
+ [DllImport("user32.dll")] public static extern void keybd_event(byte k, byte s, uint f, UIntPtr e);
+}
+"@
+[Mouse]::SetProcessDPIAware() | Out-Null
+$h = (Get-Process -Id ${state.pid}).MainWindowHandle
+if ($h -eq [IntPtr]::Zero) { exit 3 }
+# An Alt tap lets this process bring the window to the front.
+[Mouse]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero); [Mouse]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero)
+[Mouse]::SetForegroundWindow($h) | Out-Null
+Start-Sleep -Milliseconds 300
+$o = New-Object Mouse+POINT
+[Mouse]::ClientToScreen($h, [ref]$o) | Out-Null
+$pts = @(${path})
+[Mouse]::SetCursorPos($o.X + $pts[0][0], $o.Y + $pts[0][1]) | Out-Null
+Start-Sleep -Milliseconds 150
+[Mouse]::mouse_event(2, 0, 0, 0, [UIntPtr]::Zero)
+Start-Sleep -Milliseconds 150
+for ($i = 1; $i -lt $pts.Count; $i++) {
+ $a = $pts[$i - 1]; $b = $pts[$i]
+ for ($s = 1; $s -le 8; $s++) {
+ [Mouse]::SetCursorPos($o.X + $a[0] + ($b[0] - $a[0]) * $s / 8, $o.Y + $a[1] + ($b[1] - $a[1]) * $s / 8) | Out-Null
+ Start-Sleep -Milliseconds 25
+ }
+}
+Start-Sleep -Milliseconds 150
+[Mouse]::mouse_event(4, 0, 0, 0, [UIntPtr]::Zero)
+`;
+ const out = spawnSync("powershell.exe", ["-NoProfile", "-Command", script], { encoding: "utf8" });
+ return out.status === 0;
+}
diff --git a/tests/acceptance/helpers.mjs b/tests/acceptance/helpers.mjs
index edb399fd..bda75cf7 100644
--- a/tests/acceptance/helpers.mjs
+++ b/tests/acceptance/helpers.mjs
@@ -588,3 +588,68 @@ export async function spyOnInvoke(page) {
return window.__TAURI__.core.invoke !== window.__acceptance.original;
}, passThrough);
}
+
+// ─── Lyrics timing ──────────────────────────────────────────────────────────
+
+/**
+ * An LRC's lines as { time, text, words }: `text` without its word stamps,
+ * `words` the times of its enhanced-LRC word stamps (), in order.
+ * Lines with no words (a stamp alone marks a pause) come back with text "".
+ */
+export function lrcLines(lrc) {
+ const out = [];
+ for (const raw of String(lrc || "").split(/\r?\n/)) {
+ const m = /^\s*\[(\d+):(\d+(?:\.\d+)?)\](.*)$/.exec(raw);
+ if (!m) continue;
+ const rest = m[3];
+ const words = [...rest.matchAll(/<(\d+):(\d+(?:\.\d+)?)>/g)].map((w) => Number(w[1]) * 60 + Number(w[2]));
+ const text = rest.replace(/<\d+:\d+(?:\.\d+)?>/g, "").trim();
+ out.push({ time: Number(m[1]) * 60 + Number(m[2]), text, words });
+ }
+ return out;
+}
+
+/** Seconds from the Sync lines readout, "m:ss.cc" (lyricsLane.js formatClock). */
+export function clockSeconds(text) {
+ const m = /^(\d+):(\d{2})\.(\d{2})$/.exec(String(text || "").trim());
+ return m ? Number(m[1]) * 60 + Number(m[2]) + Number(m[3]) / 100 : NaN;
+}
+
+/**
+ * Where the voice starts in the vocals stem, from its envelope as the app
+ * serves it (GET /api/jobs/{id}/vocal-envelope: { hop, db }): the rise of the
+ * level over 120 ms, where the voice is singing, as in
+ * app/pipeline/lyrics_align.py onset_strength. Written again here, not
+ * called, so the check does not grade the code with itself: an onset is a
+ * rise of 6 dB or more (_HIT_RISE_DB) within 20 dB of the stem's loud parts.
+ *
+ * Returns near(t, within): whether the voice rises within `within` s of t.
+ */
+export async function voiceOnsets(jobId) {
+ const { hop, db } = await api(`/api/jobs/${encodeURIComponent(jobId)}/vocal-envelope`);
+ const levels = (db || []).map(Number);
+ const n = levels.length;
+ const strength = new Float64Array(n);
+ if (n > 3 && hop > 0) {
+ // numpy's percentile, linear between neighbours.
+ const sorted = [...levels].sort((a, b) => a - b);
+ const pos = (n - 1) * 0.95;
+ const lo = Math.floor(pos);
+ const loud = sorted[lo] + (sorted[Math.min(n - 1, lo + 1)] - sorted[lo]) * (pos - lo);
+ if (loud >= -45) {
+ const floor = Math.max(loud - 20, -50);
+ // np.convolve(levels, ones(3)/3, "same"): zeros past the ends.
+ const smooth = levels.map((_, i) => ((levels[i - 1] ?? 0) + levels[i] + (levels[i + 1] ?? 0)) / 3);
+ for (let i = 3; i < n; i++) {
+ if (smooth[i] < floor) continue;
+ strength[i] = Math.max(0, smooth[i] - smooth[i - 3]);
+ }
+ }
+ }
+ const frame = (t) => Math.min(Math.max(0, Math.round(t / hop)), Math.max(0, n - 1));
+ const near = (t, within) => {
+ for (let i = frame(t - within); i <= frame(t + within); i++) if (strength[i] >= 6) return true;
+ return false;
+ };
+ return { hop, frames: n, near };
+}
diff --git a/tests/acceptance/report.mjs b/tests/acceptance/report.mjs
index 6610ec0e..72aa5a29 100644
--- a/tests/acceptance/report.mjs
+++ b/tests/acceptance/report.mjs
@@ -71,6 +71,8 @@ export default class AcceptanceReporter {
}
onEnd(fullResult) {
+ // A dry listing (--list) runs nothing: the last run's report stays.
+ if (process.argv.includes("--list")) return;
fs.mkdirSync(RESULTS_DIR, { recursive: true });
const state = readState() || {};
const rows = CHECKS.map((c) => ({
diff --git a/tests/e2e/lyrics-from-server.spec.mjs b/tests/e2e/lyrics-from-server.spec.mjs
index 2b817463..2c902c9e 100644
--- a/tests/e2e/lyrics-from-server.spec.mjs
+++ b/tests/e2e/lyrics-from-server.spec.mjs
@@ -163,7 +163,7 @@ test.describe("lyrics from the server", () => {
await setUp(page, { server: whisper });
await expect(page.locator(".lyrics-line")).toHaveText(["Heard words"]);
await expect(page.locator(".lyrics-match-meta")).toHaveText("Fixture Band · Transcribed");
- await expect(page.locator(".lyrics-tools .lyrics-link")).toHaveText(["Align", "Remove lyrics"]);
+ await expect(page.locator(".lyrics-tools .lyrics-link")).toHaveText(["Sync lines", "Align", "Remove lyrics"]);
});
test("Polish is shown letter for letter, a word to a span, even when it came decomposed", async ({ page }) => {
diff --git a/tests/e2e/lyrics-sync.spec.mjs b/tests/e2e/lyrics-sync.spec.mjs
new file mode 100644
index 00000000..dcb5adf1
--- /dev/null
+++ b/tests/e2e/lyrics-sync.spec.mjs
@@ -0,0 +1,512 @@
+// The Lyrics tab's Sync lines mode: timing lyrics line by line. Tap along
+// with playback (T or Enter), undo a tap (Backspace), drag a line's marker in
+// the lane over the waveform, nudge the line in hand with the arrows, re-time
+// every line from the vocals, reset to the detected or the original timing.
+// And the order the timings take effect in: the user's own, then the
+// server's fitted to the vocals, then the lyrics' own at the Align offset.
+//
+// The server's endpoints are stood in for here (the timing engine and its
+// checks are the server's own tests). The fixture track is 6 seconds, so
+// every time below is inside it.
+import { test, expect } from "@playwright/test";
+import {
+ JOB_ID,
+ SIBLING_JOB_ID,
+ fixtureTrack,
+ readCatalogState,
+ seedCatalogState,
+ stubAudioTags,
+ stubExportEndpoints,
+ stubUpdateCheck,
+} from "./helpers.mjs";
+
+const LRCLIB = /^https:\/\/lrclib\.net\//;
+const WIKIMEDIA = /^https:\/\/((www|query)\.wikidata\.org|[a-z-]+\.wikipedia\.org)\//;
+// An empty stamp first, as LRCLIB often has: an instrumental gap, not a line.
+const SYNCED = "[00:00.00]\n[00:00.50] Server one\n[00:02.00] Server two\n[00:04.00] Server three";
+const ALIGNED = "[00:00.00]\n[00:00.80]<00:00.80>Server <00:01.20>one\n[00:02.40] Server two\n[00:04.60] Server three";
+const USER = "[00:00.00]\n[00:01.10] Server one\n[00:03.10] Server two\n[00:05.10] Server three";
+
+const version = (extra = {}) => ({
+ v: 1,
+ source: "lrclib",
+ track: "Fixture Song",
+ artist: "Fixture Band",
+ album: "Studio",
+ duration: 6,
+ synced: SYNCED,
+ plain: "Server one\nServer two\nServer three",
+ instrumental: false,
+ lrclib_id: 201,
+ timing: "exact",
+ others: [],
+ ...extra,
+});
+
+/** Timed lines from LRC, as [text, seconds] with the empty stamps left out. */
+function stamps(lrc) {
+ const out = [];
+ for (const raw of String(lrc || "").split("\n")) {
+ const m = /^\[(\d+):(\d+(?:\.\d+)?)\](.*)$/.exec(raw);
+ if (!m) continue;
+ const text = m[3].replace(/<[^>]*>/g, "").trim();
+ if (text) out.push([text, Math.round((Number(m[1]) * 60 + Number(m[2])) * 100) / 100]);
+ }
+ return out;
+}
+
+/**
+ * Open the fixture track's Lyrics tab with `server` as its lyrics.json (null
+ * for none) and `saved` as its store entry. `retime` is the list of answers
+ * GET .../lyrics/retime gives in turn (the last repeats), and `retimed` what
+ * lyrics.json holds once it is done. `envelope` is the vocal envelope, or
+ * null for a track without one (so nothing snaps).
+ */
+async function setUp(page, { server = version(), saved = null, retime = [], retimed = null, envelope = null } = {}) {
+ const kept = { lyrics: server };
+ const puts = [];
+ const deletes = [];
+ const retimes = [];
+ const envelopes = [];
+ let polls = 0;
+ await page.route(LRCLIB, (route) => route.abort("internetdisconnected"));
+ await page.route(`**/api/jobs/${JOB_ID}/lyrics`, (route) => {
+ if (!kept.lyrics) return route.fulfill({ status: 404, contentType: "application/json", body: '{"detail":"no lyrics"}' });
+ return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(kept.lyrics) });
+ });
+ await page.route(`**/api/jobs/${JOB_ID}/lyrics/user-synced`, (route) => {
+ const method = route.request().method();
+ if (method === "PUT") {
+ const { synced } = route.request().postDataJSON();
+ puts.push(synced);
+ kept.lyrics = { ...kept.lyrics, user_synced: synced };
+ return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ user_synced: synced }) });
+ }
+ deletes.push(method);
+ const { user_synced: _, ...rest } = kept.lyrics || {};
+ kept.lyrics = rest;
+ return route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
+ });
+ await page.route(`**/api/jobs/${JOB_ID}/lyrics/retime`, (route) => {
+ if (route.request().method() === "POST") {
+ retimes.push(route.request().postDataJSON());
+ polls = 0;
+ return route.fulfill({ status: 202, contentType: "application/json", body: '{"state":"running"}' });
+ }
+ const answer = retime[Math.min(polls, retime.length - 1)] || { state: "idle" };
+ polls += 1;
+ if (answer.state === "done" && retimed && kept.lyrics) kept.lyrics = { ...kept.lyrics, aligned: retimed };
+ return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(answer) });
+ });
+ await page.route(`**/api/jobs/${JOB_ID}/vocal-envelope`, (route) => (envelopes.push(1), envelope
+ ? route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope) })
+ : route.fulfill({ status: 404, contentType: "application/json", body: '{"detail":"no vocals"}' })));
+ await page.route(WIKIMEDIA, (route) => route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ headers: { "access-control-allow-origin": "*" },
+ body: JSON.stringify({ search: [] }),
+ }));
+ await stubExportEndpoints(page);
+ await stubUpdateCheck(page);
+ await stubAudioTags(page, null);
+ await seedCatalogState(page, {
+ folders: [
+ { id: "f-unsorted", name: "Unsorted", items: [JOB_ID, SIBLING_JOB_ID], color: null },
+ { id: "trash", name: "Trash", items: [], color: null },
+ ],
+ tracks: {
+ [JOB_ID]: {
+ ...fixtureTrack(JOB_ID, "E2E Fixture Track"),
+ audioTags: { artist: "Fixture Band", title: "Fixture Song" },
+ },
+ [SIBLING_JOB_ID]: fixtureTrack(SIBLING_JOB_ID, "E2E Fixture Track (again)"),
+ },
+ });
+ if (saved) {
+ await page.addInitScript(
+ ([key, value]) => {
+ if (!sessionStorage.getItem("e2e-seeded-lyrics")) {
+ window.localStorage.setItem(key, JSON.stringify(value));
+ sessionStorage.setItem("e2e-seeded-lyrics", "1");
+ }
+ },
+ [`stemdeck.lyrics.${JOB_ID}`, saved],
+ );
+ }
+ await page.setViewportSize({ width: 1600, height: 900 });
+ await openLyrics(page);
+ return { kept, puts, deletes, retimes, envelopes };
+}
+
+async function openLyrics(page) {
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.locator(".cat-item").first().waitFor({ timeout: 20000 });
+ await page.locator(`.cat-item[data-id="${JOB_ID}"]`).first().click();
+ await expect(page.locator(".app")).not.toHaveClass(/no-track/, { timeout: 15000 });
+ await page.locator(".rail-lyrics").click();
+ await expect(page.locator(".lyrics-line, .lyrics-text").first()).toBeVisible();
+}
+
+const syncLink = (page) => page.locator(".lyrics-tools .lyrics-sync-toggle");
+const panel = (page) => page.locator("#lyricsSync");
+const lane = (page) => page.locator("#lyricsLane");
+const marker = (page, text) => page.locator(".lyr-mark", { hasText: text });
+const line = (page, text) => page.locator(".lyrics-line", { hasText: text });
+const current = (page) => page.locator(".lyrics-line.current");
+const message = (page) => page.locator("#lyricsSync .lyrics-align-message");
+const button = (page, name) => page.getByRole("button", { name, exact: true });
+
+async function openSync(page) {
+ await syncLink(page).click();
+ await expect(panel(page)).toBeVisible();
+ await expect(lane(page)).toBeVisible();
+ await expect(syncLink(page)).toHaveAttribute("aria-expanded", "true");
+}
+
+const seek = (page, seconds) =>
+ page.evaluate(async (s) => (await import("/js/transport.js")).setPlayheadTime(s), seconds);
+const playhead = (page) =>
+ page.evaluate(async () => (await import("/js/transport.js")).transport()?.getCurrentTime?.() ?? 0);
+const stored = (page) =>
+ page.evaluate((id) => JSON.parse(localStorage.getItem(`stemdeck.lyrics.${id}`) || "null"), JOB_ID);
+
+/** Press `key` as a tap, returning the playhead just before and just after. */
+async function tapAt(page, key) {
+ const before = await playhead(page);
+ await page.keyboard.press(key);
+ const after = await playhead(page);
+ return [before, after];
+}
+
+test.describe("syncing lyrics line by line", () => {
+ test("tapping along while the song plays sets each line where it was tapped, and that is saved", async ({ page }) => {
+ const { puts } = await setUp(page);
+ await openSync(page);
+ await expect(page.locator(".lyrics-sync-next-text")).toHaveText("Server one");
+ await expect(page.locator(".lyrics-sync-kind").first()).toHaveText("Their own timing");
+
+ await seek(page, 0);
+ await page.keyboard.press("Space");
+ await expect.poll(() => playhead(page), { timeout: 8000 }).toBeGreaterThan(0.9);
+ const one = await tapAt(page, "t");
+ await expect(page.locator(".lyrics-sync-next-text")).toHaveText("Server two");
+ await expect.poll(() => playhead(page), { timeout: 8000 }).toBeGreaterThan(2.6);
+ const two = await tapAt(page, "Enter");
+ await expect(page.locator(".lyrics-sync-next-text")).toHaveText("Server three");
+ await expect.poll(() => playhead(page), { timeout: 8000 }).toBeGreaterThan(3.8);
+ const three = await tapAt(page, "t");
+ await page.keyboard.press("Space");
+ await expect(page.locator(".lyrics-sync-next-label")).toHaveText("All lines tapped");
+ await expect(page.locator(".lyrics-sync-tap")).toBeDisabled();
+
+ // Saved a moment after the last tap: a run of taps is not one request each.
+ await expect.poll(() => stamps(puts.at(-1))[2]?.[1]).toBeLessThanOrEqual(three[1] + 0.02);
+ const saved = stamps(puts.at(-1));
+ expect(saved.map(([text]) => text)).toEqual(["Server one", "Server two", "Server three"]);
+ for (const [i, [lo, hi]] of [one, two, three].entries()) {
+ expect(saved[i][1]).toBeGreaterThanOrEqual(Math.floor(lo * 100) / 100 - 0.01);
+ expect(saved[i][1]).toBeLessThanOrEqual(hi + 0.02);
+ }
+ // The empty stamp before the first line is kept, so the lines still
+ // match the lyrics' own one to one.
+ expect(puts.at(-1).split("\n")[0]).toBe("[00:00.00]");
+ await expect(page.locator(".lyrics-sync-kind").first()).toHaveText("Timed by you");
+ });
+
+ test("Backspace undoes the last tap, not the track: it stays out of the Trash", async ({ page }) => {
+ const { puts } = await setUp(page);
+ await openSync(page);
+ await seek(page, 1);
+ await page.keyboard.press("t");
+ await seek(page, 2.6);
+ await page.keyboard.press("t");
+ await expect(page.locator(".lyrics-sync-next-text")).toHaveText("Server three");
+ await expect(line(page, "Server two")).toHaveAttribute("data-time", "0:02.60");
+
+ await page.keyboard.press("Backspace");
+ await expect(page.locator(".lyrics-sync-next-text")).toHaveText("Server two");
+ // Back where the first tap's ripple had carried it.
+ await expect(line(page, "Server two")).toHaveAttribute("data-time", "0:02.50");
+ await expect(line(page, "Server two")).toHaveClass(/selected/);
+ await expect(marker(page, "Server two")).toHaveClass(/selected/);
+ await expect.poll(() => stamps(puts.at(-1))).toEqual([["Server one", 1], ["Server two", 2.5], ["Server three", 4.5]]);
+
+ // Ctrl+Z undoes too; with nothing left, the tap button's line is the first.
+ await page.keyboard.press("Control+z");
+ await expect(page.locator(".lyrics-sync-next-text")).toHaveText("Server one");
+ await expect(line(page, "Server one")).toHaveAttribute("data-time", "0:00.50");
+ await page.keyboard.press("Backspace");
+ const state = await readCatalogState(page);
+ expect(state.folders.find((f) => f.id === "trash").items).not.toContain(JOB_ID);
+ await expect(page.locator(".app")).not.toHaveClass(/no-track/);
+ });
+
+ test("dragging a line's marker in the lane moves the line, and the list and a line click follow", async ({ page }) => {
+ const { puts } = await setUp(page);
+ await openSync(page);
+ await page.getByLabel("Move later lines too").uncheck();
+ const track = await page.locator("#daw-lyrics-track").boundingBox();
+ const box = await marker(page, "Server two").boundingBox();
+ const perSecond = track.width / 6;
+ // Grab it near its start and move it a second later.
+ const y = box.y + box.height / 2;
+ await page.mouse.move(box.x + 4, y);
+ await page.mouse.down();
+ await page.mouse.move(box.x + 4 + perSecond * 0.5, y, { steps: 4 });
+ await page.mouse.move(box.x + 4 + perSecond, y, { steps: 4 });
+ await page.mouse.up();
+
+ await expect.poll(() => stamps(puts.at(-1))[1]?.[1] ?? 0).toBeGreaterThan(2.9);
+ const moved = stamps(puts.at(-1))[1][1];
+ expect(moved).toBeLessThan(3.1);
+ // Only that line moved: dragging holds a line between its neighbours.
+ expect(stamps(puts.at(-1))[0][1]).toBe(0.5);
+ expect(stamps(puts.at(-1))[2][1]).toBe(4);
+ await expect(line(page, "Server two")).toHaveAttribute("data-time", /^0:0(2\.9|3\.0)/);
+
+ // Dragged past its neighbour, it stops short of it.
+ const again = await marker(page, "Server two").boundingBox();
+ await page.mouse.move(again.x + 4, y);
+ await page.mouse.down();
+ await page.mouse.move(again.x + 4 + perSecond * 2.5, y, { steps: 6 });
+ await page.mouse.up();
+ await expect.poll(() => stamps(puts.at(-1))[1][1]).toBe(3.9);
+
+ await button(page, "Done").first().click();
+ await expect(panel(page)).toHaveCount(0);
+ await expect(lane(page)).toBeHidden();
+ await line(page, "Server two").click();
+ await expect.poll(() => playhead(page)).toBeCloseTo(3.9, 1);
+ await expect(current(page)).toHaveText("Server two");
+ });
+
+ test("the arrows nudge the line in hand, up and down pick another, and the lead-in plays into it", async ({ page }) => {
+ const { puts } = await setUp(page);
+ await openSync(page);
+ await page.getByLabel("Move later lines too").uncheck();
+ await line(page, "Server two").click();
+ await expect(line(page, "Server two")).toHaveClass(/selected/);
+ await expect(page.locator("#t-time")).toContainText("00:02");
+ await page.keyboard.press("ArrowRight");
+ await page.keyboard.press("ArrowRight");
+ await expect(line(page, "Server two")).toHaveAttribute("data-time", "0:02.10");
+ await page.keyboard.press("Shift+ArrowLeft");
+ await expect(line(page, "Server two")).toHaveAttribute("data-time", "0:01.85");
+ await expect.poll(() => stamps(puts.at(-1))[1]?.[1]).toBe(1.85);
+
+ await page.keyboard.press("ArrowUp");
+ await expect(marker(page, "Server one")).toHaveClass(/selected/);
+ await expect(page.locator(".lyrics-sync-next-text")).toHaveText("Server one");
+ // The line's own controls sit under the line in hand.
+ await expect(page.locator(".lyrics-line.selected + .lyrics-line-tools")).toBeVisible();
+ await page.locator(".lyrics-line-tools").getByRole("button", { name: "Later by 0.25 s" }).click();
+ await expect(line(page, "Server one")).toHaveAttribute("data-time", "0:00.75");
+
+ // Set to the playhead, exactly.
+ await seek(page, 1.3);
+ await button(page, "Start at playhead").click();
+ await expect(line(page, "Server one")).toHaveAttribute("data-time", "0:01.30");
+
+ await page.getByLabel("Lead-in").check();
+ await line(page, "Server three").click();
+ await expect(page.locator("#t-time")).toContainText("00:02");
+ await page.keyboard.press("Escape");
+ await expect(panel(page)).toHaveCount(0);
+ await expect(syncLink(page)).toBeFocused();
+ });
+
+ test("a tap snaps to where the singing starts, unless Snap to voice is off", async ({ page }) => {
+ // 40ms frames: singing from 2.4 s to 3.0 s.
+ const db = new Array(150).fill(-80);
+ for (let i = 60; i < 75; i++) db[i] = -12;
+ const { puts, envelopes } = await setUp(page, { envelope: { hop: 0.04, db } });
+ await expect.poll(() => envelopes.length).toBeGreaterThan(0);
+ await openSync(page);
+ await line(page, "Server two").click();
+ await seek(page, 2.55);
+ await page.keyboard.press("t");
+ await expect(line(page, "Server two")).toHaveAttribute("data-time", "0:02.40");
+ await page.getByLabel("Snap to voice").uncheck();
+ await page.keyboard.press("Backspace");
+ await expect(line(page, "Server two")).toHaveAttribute("data-time", "0:02.00");
+ await page.keyboard.press("t");
+ await expect(line(page, "Server two")).toHaveAttribute("data-time", "0:02.55");
+ await expect.poll(() => stamps(puts.at(-1))[1]?.[1]).toBe(2.55);
+ });
+
+ test("Re-time from vocals shows its progress, then the new timing, and drops the user's", async ({ page }) => {
+ const { puts, deletes, retimes } = await setUp(page, {
+ server: version({ user_synced: USER }),
+ retime: [{ state: "running", progress: 0.4 }, { state: "running", progress: 0.4 }, { state: "done", matched: 0.75 }],
+ retimed: { synced: ALIGNED, method: "dtw", matched: 0.75, lines_matched: 3, lines: 4, at: 1 },
+ });
+ await openSync(page);
+ await expect(page.locator(".lyrics-sync-kind").first()).toHaveText("Timed by you");
+ await expect(line(page, "Server two")).toHaveAttribute("data-time", "0:03.10");
+ await button(page, "Re-time from vocals").click();
+ await expect(message(page)).toHaveText("Listening to the vocals… 40%");
+ await expect(message(page)).toHaveText("Timed from the vocals: 3 of 4 lines matched.", { timeout: 8000 });
+ expect(retimes).toEqual([{}]);
+ await expect(line(page, "Server two")).toHaveAttribute("data-time", "0:02.40");
+ await expect(page.locator(".lyrics-sync-kind").first()).toHaveText("Timed from the vocals");
+ await expect.poll(() => deletes.length).toBe(1);
+ expect(puts).toEqual([]);
+
+ // Undo brings the user's own back, and keeps it again.
+ await page.keyboard.press("Backspace");
+ await expect(line(page, "Server two")).toHaveAttribute("data-time", "0:03.10");
+ await expect.poll(() => stamps(puts.at(-1))).toEqual(stamps(USER));
+ });
+
+ test("Re-time that is unsure says so and changes nothing", async ({ page }) => {
+ const { puts, deletes } = await setUp(page, { retime: [{ state: "running" }, { state: "unsure" }] });
+ await openSync(page);
+ await button(page, "Re-time from vocals").click();
+ await expect(message(page)).toHaveText("The vocals did not match the lines well enough. The timing is as it was.", { timeout: 8000 });
+ await expect(line(page, "Server two")).toHaveAttribute("data-time", "0:02.00");
+ expect(puts).toEqual([]);
+ expect(deletes).toEqual([]);
+ });
+
+ test("Re-time that fails says so", async ({ page }) => {
+ await setUp(page, { retime: [{ state: "failed" }] });
+ await openSync(page);
+ await button(page, "Re-time from vocals").click();
+ await expect(message(page)).toHaveText("Re-timing from the vocals is not available for this track right now.", { timeout: 8000 });
+ });
+
+ test("Reset to detected drops the user's timing, back to the vocals' timing", async ({ page }) => {
+ const { deletes, puts } = await setUp(page, { server: version({ user_synced: USER, aligned: { synced: ALIGNED, lines_matched: 3, lines: 4 } }) });
+ await openSync(page);
+ await expect(line(page, "Server two")).toHaveAttribute("data-time", "0:03.10");
+ await button(page, "Reset to detected").click();
+ await expect(line(page, "Server two")).toHaveAttribute("data-time", "0:02.40");
+ await expect(message(page)).toHaveText("Back to the timing from the vocals.");
+ await expect.poll(() => deletes.length).toBe(1);
+ expect(puts).toEqual([]);
+ await button(page, "Done").first().click();
+ await line(page, "Server three").click();
+ await expect(page.locator("#t-time")).toContainText("00:04");
+ });
+
+ test("Reset to original puts every line back at the lyrics' own timing, over any other", async ({ page }) => {
+ const { puts } = await setUp(page, { server: version({ offset_sec: 1, aligned: { synced: ALIGNED } }) });
+ await openSync(page);
+ await expect(line(page, "Server two")).toHaveAttribute("data-time", "0:02.40");
+ await button(page, "Reset to original").click();
+ await expect(line(page, "Server two")).toHaveAttribute("data-time", "0:02.00");
+ await expect.poll(() => stamps(puts.at(-1))).toEqual(stamps(SYNCED));
+ await expect(page.locator(".lyrics-sync-kind").first()).toHaveText("Timed by you");
+ });
+
+ test("the user's timing wins over the vocals', which wins over the offset, and Align says so", async ({ page }) => {
+ const { kept } = await setUp(page, { server: version({ offset_sec: 1, aligned: { synced: ALIGNED }, user_synced: USER }) });
+ await line(page, "Server two").click();
+ await expect(page.locator("#t-time")).toContainText("00:03");
+ await seek(page, 3.2);
+ await expect(current(page)).toHaveText("Server two");
+ await page.locator(".lyrics-align-toggle").click();
+ await expect(page.locator(".lyrics-align-note")).toHaveText(/timed one by one, by you/);
+ await expect(button(page, "Start lyrics here")).toBeDisabled();
+ await expect(button(page, "Later by 0.5 s")).toBeDisabled();
+
+ // Without the user's: the vocals'.
+ kept.lyrics = version({ offset_sec: 1, aligned: { synced: ALIGNED } });
+ await openLyrics(page);
+ await page.locator(".lyrics-align-toggle").click();
+ await seek(page, 2.5);
+ await expect(current(page)).toHaveText("Server two");
+ await seek(page, 2.3);
+ await expect(current(page)).toHaveText("Server one");
+ await expect(page.locator(".lyrics-align-note")).toHaveText(/timed one by one from the vocals/);
+
+ // Without either: the lyrics' own, at the offset, which Align moves.
+ kept.lyrics = version({ offset_sec: 1 });
+ await openLyrics(page);
+ await page.locator(".lyrics-align-toggle").click();
+ await seek(page, 2.9);
+ await expect(current(page)).toHaveText("Server one");
+ await seek(page, 3.1);
+ await expect(current(page)).toHaveText("Server two");
+ await expect(page.locator(".lyrics-align-note")).toHaveText("");
+ await expect(button(page, "Start lyrics here")).toBeEnabled();
+ });
+
+ test("lyrics kept in the browser keep their line timing in their store entry", async ({ page }) => {
+ const saved = { entry: { ...version(), id: 77, album: "Chosen", savedAt: 1 }, others: [] };
+ const { puts } = await setUp(page, { server: null, saved });
+ await openSync(page);
+ await line(page, "Server three").click();
+ await seek(page, 4.7);
+ await page.keyboard.press("t");
+ await expect.poll(async () => stamps((await stored(page))?.entry?.userSynced)).toEqual([["Server one", 0.5], ["Server two", 2], ["Server three", 4.7]]);
+ expect(puts).toEqual([]);
+ expect((await stored(page)).entry.album).toBe("Chosen");
+
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await page.locator(`.cat-item[data-id="${JOB_ID}"]`).first().click();
+ await page.locator(".rail-lyrics").click();
+ await line(page, "Server three").click();
+ await expect(page.locator("#t-time")).toContainText("00:04");
+ await seek(page, 4.5);
+ await expect(current(page)).toHaveText("Server two");
+ });
+
+ test("the lane lines up with the waveform when zoomed and scrolled", async ({ page }) => {
+ await setUp(page);
+ await openSync(page);
+ await page.evaluate(async () => (await import("/js/transport.js")).setWaveZoomLevel(4));
+ await page.evaluate(() => {
+ const wave = document.getElementById("wave-scroll");
+ wave.scrollLeft = wave.scrollWidth / 4;
+ wave.dispatchEvent(new Event("scroll"));
+ });
+ const at = await page.evaluate(() => {
+ const wave = document.getElementById("wave-scroll");
+ const canvas = document.getElementById("wave-canvas").getBoundingClientRect();
+ // Where 2 s is on the waveform, on screen.
+ return canvas.left + (2 / 6) * canvas.width;
+ });
+ await expect.poll(async () => Math.round((await marker(page, "Server two").boundingBox()).x)).toBeGreaterThan(Math.round(at) - 3);
+ const box = await marker(page, "Server two").boundingBox();
+ expect(Math.abs(box.x - at)).toBeLessThan(3);
+ });
+
+ test("text-only lyrics show Sync lines disabled, saying why", async ({ page }) => {
+ await setUp(page, { server: version({ synced: "", plain: "Server one\nServer two" }) });
+ await expect(page.locator(".lyrics-text").first()).toHaveText("Server one");
+ await expect(syncLink(page)).toHaveAttribute("aria-disabled", "true");
+ await expect(syncLink(page)).toHaveAttribute("title", /Only lyrics with timing can be synced/);
+ await syncLink(page).click({ force: true });
+ await expect(panel(page)).toHaveCount(0);
+ await expect(lane(page)).toBeHidden();
+ });
+
+ test("leaving the Lyrics tab leaves Sync lines, and hides the lane", async ({ page }) => {
+ await setUp(page);
+ await openSync(page);
+ await page.locator(".rail-library").click();
+ await expect(lane(page)).toBeHidden();
+ });
+
+ test("Escape goes to a menu open over Sync lines, and only then to Sync lines", async ({ page }) => {
+ await setUp(page);
+ await openSync(page);
+ const exportBtn = page.locator("#t-export-btn");
+ await exportBtn.click();
+ await expect(exportBtn).toHaveAttribute("aria-expanded", "true");
+
+ // The menu's own keys: the arrow moves into it rather than to another line.
+ await page.keyboard.press("ArrowDown");
+ await expect(page.locator("#t-export-panel :focus")).toHaveCount(1);
+ await page.keyboard.press("Escape");
+ // The menu closed; Sync lines stayed open.
+ await expect(exportBtn).toHaveAttribute("aria-expanded", "false");
+ await expect(panel(page)).toBeVisible();
+
+ // With nothing else open, Escape is Sync lines' again.
+ await page.keyboard.press("Escape");
+ await expect(panel(page)).toBeHidden();
+ });
+});
diff --git a/tests/js/lyrics-sync.test.mjs b/tests/js/lyrics-sync.test.mjs
new file mode 100644
index 00000000..558219cf
--- /dev/null
+++ b/tests/js/lyrics-sync.test.mjs
@@ -0,0 +1,187 @@
+// Sync lines (lyricsSync.js): which timing of a track's lyrics is in effect,
+// how a line moved or tapped moves its words and never crosses its
+// neighbours, the tap sequence and its undo, snapping to the singing, and the
+// LRC that is kept.
+//
+// Run: node tests/js/lyrics-sync.test.mjs
+
+import {
+ timingOf,
+ formatStamp,
+ serializeLrc,
+ fitWords,
+ applyTime,
+ snapToVoice,
+ nextSungIndex,
+ SyncSession,
+ MIN_GAP_SEC,
+} from "../../static/js/lyricsSync.js";
+import { parseLrc, fromServerLyrics } from "../../static/js/lyricsLookup.js";
+
+let pass = 0;
+let fail = 0;
+const check = (name, condition, detail = "") => {
+ if (condition) {
+ pass++;
+ console.log(`PASS ${name}`);
+ } else {
+ fail++;
+ console.log(`FAIL ${name}${detail ? `\n ${detail}` : ""}`);
+ }
+};
+const near = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
+const times = (lines) => lines.map((l) => Math.round(l.time * 100) / 100);
+const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
+
+const OWN = "[00:00.00]\n[00:01.00] One line\n[00:03.00] Two line\n[00:05.00] Three line";
+const ALIGNED = "[00:01.20]<00:01.20>One <00:01.60>line\n[00:03.10] Two line\n[00:05.40] Three line\n[00:00.00]";
+const USER = "[00:00.00]\n[00:01.50] One line\n[00:03.50] Two line\n[00:05.50] Three line";
+
+// ── precedence ──
+{
+ const entry = { synced: OWN, offsetSec: 0.5 };
+ const own = timingOf(entry);
+ check("the lyrics' own timing, at the offset, when nothing else is kept", own.kind === "offset" && same(times(own.lines), [0.5, 1.5, 3.5, 5.5]) && own.offset === 0.5);
+ const aligned = timingOf({ ...entry, aligned: { synced: ALIGNED } });
+ check("the server's timing from the vocals over the offset", aligned.kind === "aligned" && same(times(aligned.lines), [0, 1.2, 3.1, 5.4]));
+ const user = timingOf({ ...entry, aligned: { synced: ALIGNED }, userSynced: USER });
+ check("the user's own timing over both", user.kind === "user" && same(times(user.lines), [0, 1.5, 3.5, 5.5]));
+ const broken = timingOf({ ...entry, userSynced: "not lrc at all" });
+ check("user timing with no timed lines falls through", broken.kind === "offset");
+ const plain = timingOf({ plain: "Just words" });
+ check("plain lyrics have no timing at all", plain.lines.length === 0);
+ const plainAligned = timingOf({ plain: "One line", aligned: { synced: "[00:02.00] One line" } });
+ check("plain lyrics the server fitted to the vocals are timed", plainAligned.kind === "aligned" && plainAligned.lines.length === 1);
+}
+
+// ── lyrics.json's new fields ──
+{
+ const found = fromServerLyrics({
+ source: "lrclib",
+ track: "S",
+ synced: OWN,
+ offset_sec: 1,
+ user_synced: USER,
+ aligned: { synced: ALIGNED, method: "dtw", matched: 0.9, lines_matched: 3, lines: 4, at: 1700000000 },
+ });
+ check("user_synced is carried as userSynced", found.entry.userSynced === USER);
+ check("aligned is carried with its counts", found.entry.aligned?.synced === ALIGNED && found.entry.aligned.linesMatched === 3 && found.entry.aligned.lines === 4 && found.entry.aligned.method === "dtw");
+ const bare = fromServerLyrics({ source: "lrclib", track: "S", synced: OWN, user_synced: "", aligned: { synced: "" } });
+ check("empty timing fields are left out", !("userSynced" in bare.entry) && !("aligned" in bare.entry));
+}
+
+// ── LRC out ──
+{
+ check("stamps are mm:ss.xx", formatStamp(65.237) === "01:05.24" && formatStamp(0) === "00:00.00" && formatStamp(-2) === "00:00.00");
+ check("an hour and more still reads back", parseLrc(`[${formatStamp(3725.5)}] Late`)[0].time === 3725.5);
+ const lines = parseLrc(ALIGNED);
+ const lrc = serializeLrc(lines);
+ const back = parseLrc(lrc);
+ check("LRC written reads back to the same lines", same(back.map((l) => [l.time, l.text]), lines.map((l) => [l.time, l.text])), lrc);
+ check("word stamps survive the round trip", same(back[1].words?.map((w) => w.time), [1.2, 1.6]) && back[1].words.map((w) => w.text).join("") === "One line");
+ check("an empty line stays a bare stamp", lrc.split("\n")[0] === "[00:00.00]");
+ check("one line per stamp", lrc.split("\n").length === lines.length);
+}
+
+// ── words ──
+{
+ const line = { time: 10, text: "a b c", words: [{ time: 10, text: "a " }, { time: 11, text: "b " }, { time: 12, text: "c" }] };
+ check("words with room are left alone", fitWords(line, 20) === line);
+ const squeezed = fitWords(line, 11.15);
+ check("words squeezed in proportion to fit before the next line", same(squeezed.words.map((w) => Math.round(w.time * 1000) / 1000), [10, 10.5, 11]));
+ check("a line without word stamps is left alone", fitWords({ time: 1, text: "x" }, 1.1).words === undefined);
+
+ const lines = parseLrc("[00:01.00]<00:01.00>One <00:01.50>two\n[00:04.00]<00:04.00>Three <00:04.50>four\n[00:08.00] End");
+ const moved = applyTime(lines, 1, 5);
+ check("a moved line's words move with it", same(moved[1].words.map((w) => w.time), [5, 5.5]));
+ const early = applyTime(lines, 1, 1.4);
+ check("the line before one moved closer has its words squeezed", early[0].words.every((w) => w.time < 1.4) && near(early[0].words[1].time, 1 + 0.5 * (0.25 / 0.5)));
+ check("the lines given are left as they were", lines[1].time === 4 && lines[1].words[0].time === 4);
+}
+
+// ── moving one line ──
+{
+ const lines = parseLrc("[00:01.00] A\n[00:03.00] B\n[00:05.00] C");
+ check("clamp: a line moves between its neighbours", same(times(applyTime(lines, 1, 4)), [1, 4, 5]));
+ check("clamp: never past the next line", same(times(applyTime(lines, 1, 9)), [1, 5 - MIN_GAP_SEC, 5]));
+ check("clamp: never before the line above", same(times(applyTime(lines, 1, 0)), [1, 1 + MIN_GAP_SEC, 5]));
+ check("never before the track starts", applyTime(lines, 0, -3)[0].time === 0);
+ check("push: the lines passed are pushed ahead", same(times(applyTime(lines, 0, 4, { mode: "push" })), [4, 4.1, 5]));
+ check("push: earlier lines are pulled back", same(times(applyTime(lines, 2, 0.5, { mode: "push" })), [0.3, 0.4, 0.5]));
+ check("ripple: the lines after move by as much", same(times(applyTime(lines, 0, 2, { mode: "ripple" })), [2, 4, 6]));
+ check("ripple stops at a line already set", same(times(applyTime(lines, 0, 2, { mode: "ripple", fixed: new Set([2]) })), [2, 4, 5]));
+ check("ripple then pushes what it would cross", same(times(applyTime(lines, 0, 4.5, { mode: "ripple", fixed: new Set([2]) })), [4.5, 6.5, 6.6]));
+ check("the track's length caps a line", applyTime(lines, 2, 99, { max: 6 })[2].time === 6);
+ const ordered = (ls) => ls.every((l, i) => i === 0 || l.time >= ls[i - 1].time);
+ let fuzz = true;
+ for (let k = 0; k < 200; k++) {
+ const i = k % 3;
+ const t = (k * 7.31) % 9 - 1;
+ for (const mode of ["clamp", "push", "ripple"]) fuzz &&= ordered(applyTime(lines, i, t, { mode }));
+ }
+ check("no move ever puts lines out of order", fuzz);
+}
+
+// ── tapping, and undo ──
+{
+ const lines = parseLrc("[00:00.00]\n[00:01.00] A\n[00:03.00] B\n[00:05.00] C");
+ const session = new SyncSession(lines, { ripple: true, max: 6 });
+ check("the first sung line is in hand to begin with", session.cursor === 1);
+ session.tap(1.4);
+ check("a tap sets the line in hand and carries the rest along", same(times(session.lines), [0, 1.4, 3.4, 5.4]) && session.cursor === 2);
+ session.tap(3.2);
+ check("the next tap sets the next line", same(times(session.lines), [0, 1.4, 3.2, 5.2]) && session.cursor === 3);
+ session.undo();
+ check("undo takes the last tap back, and the line is in hand again", same(times(session.lines), [0, 1.4, 3.4, 5.4]) && session.cursor === 2);
+ session.redo();
+ check("redo puts it back", same(times(session.lines), [0, 1.4, 3.2, 5.2]) && session.cursor === 3);
+ session.tap(5.9);
+ check("after the last line every line is set", session.done && session.tap(6) === false);
+ session.undo();
+ session.undo();
+ session.undo();
+ check("undo all the way goes back to the start", same(times(session.lines), [0, 1, 3, 5]) && session.cursor === 1 && !session.canUndo);
+
+ const from = new SyncSession(lines, { ripple: true });
+ from.select(3);
+ from.tap(5.5);
+ check("tapping can start from any line", same(times(from.lines), [0, 1, 3, 5.5]) && from.done);
+ from.select(1);
+ from.tap(2);
+ check("a tap does not carry lines already set", same(times(from.lines), [0, 2, 4, 5.5]));
+
+ const still = new SyncSession(lines, { ripple: false });
+ still.tap(2);
+ check("without ripple a tap moves only its line, if nothing is crossed", same(times(still.lines), [0, 2, 3, 5]));
+ still.tap(4);
+ check("and pushes a line it would cross", same(times(still.lines), [0, 2, 4, 5]) && still.cursor === 3);
+ const twice = new SyncSession(lines, { ripple: false });
+ twice.tap(1);
+ check("a tap on the time a line already has still moves on, and undoes", twice.cursor === 2 && twice.undo() && twice.cursor === 1);
+
+ const meta = new SyncSession(lines, { meta: { kind: "aligned" } });
+ meta.move(1, 1.5);
+ meta.meta.kind = "user";
+ meta.undo();
+ check("undo brings back the caller's note of which timing it was", meta.meta.kind === "aligned");
+ check("a nudge moves the line in hand", meta.nudge(1, 0.05) && near(meta.lines[1].time, 1.05));
+ meta.replace(parseLrc(OWN));
+ check("a reset is one step to undo", meta.undo() && near(meta.lines[1].time, 1.05));
+ check("next sung line skips empty stamps", nextSungIndex(lines, -1) === 1 && nextSungIndex(lines, 3) === 4);
+}
+
+// ── snapping to the singing ──
+{
+ // 40ms frames: silence, then singing from 2.0 s to 3.0 s.
+ const hop = 0.04;
+ const db = new Int8Array(150).fill(-80);
+ for (let i = 50; i < 75; i++) db[i] = -12;
+ const envelope = { hop, db };
+ check("a tap just after the singing starts snaps back to it", near(snapToVoice(envelope, 2.2), 2.0, 1e-9));
+ check("a tap just before snaps forward", near(snapToVoice(envelope, 1.85), 2.0, 1e-9));
+ check("too far away, the tap stays", snapToVoice(envelope, 1.2) === 1.2);
+ check("no envelope, no snap", snapToVoice(null, 2.2) === 2.2);
+}
+
+console.log(`\n${pass} passed, ${fail} failed`);
+if (fail) process.exit(1);
diff --git a/tests/test_lyrics_lookup.py b/tests/test_lyrics_lookup.py
index 7f2b36ac..c233f5c5 100644
--- a/tests/test_lyrics_lookup.py
+++ b/tests/test_lyrics_lookup.py
@@ -299,13 +299,15 @@ def test_the_shows_name_is_held_to_the_tracks_length():
assert 20 in ids(found["others"])
-def test_the_name_alone_is_kept_only_within_three_seconds():
+def test_the_name_alone_keeps_the_artists_song_timed_by_its_length():
+ """Only the artist's own song is taken from the name search: within three
+ seconds its timing is the track's, else it is timed from the vocals."""
lrclib = Lrclib(searches={"q:Metropolis": [row(40, 575.5), row(41, 600)]})
- answer = lookup_lyrics(query(), fetch_json=lrclib)
- assert answer.lyrics is None
- assert ids(answer.others) == [40, 41], "kept to offer instead"
+ found = kept(query(), fetch_json=lrclib)
+ assert (found["lrclib_id"], found["timing"]) == (40, "unverified")
lrclib = Lrclib(searches={"q:Metropolis": [row(40, 574.9), row(41, 600)]})
- assert kept(query(), fetch_json=lrclib)["lrclib_id"] == 40
+ found = kept(query(), fetch_json=lrclib)
+ assert (found["lrclib_id"], found["timing"]) == (40, "exact")
def test_the_name_alone_is_never_asked_without_the_tracks_length():
@@ -431,10 +433,18 @@ def test_the_shows_song_in_another_length_is_kept_too():
assert "Wicked (Original Broadway Cast Recording)" not in lrclib.searched
-def test_the_name_alone_in_another_length_is_never_kept():
+def test_the_artists_song_the_name_alone_finds_in_another_length_is_timed_from_the_vocals():
+ """LRCLIB's artist search can miss a name its name search holds: Natalia
+ Kukulska's "W biegu", asked for as "NataliaKukulska"."""
lrclib = Lrclib(searches={"q:Metropolis": [row(40, 600)]})
+ found = kept(query(), fetch_json=lrclib)
+ assert (found["lrclib_id"], found["timing"]) == (40, "unverified")
+
+
+def test_another_artists_song_the_name_alone_finds_is_never_kept():
+ lrclib = Lrclib(searches={"q:Metropolis": [row(40, 572, artist="Someone Else")]})
answer = lookup_lyrics(query(), fetch_json=lrclib)
- assert answer.lyrics is None and ids(answer.others) == [40]
+ assert answer.lyrics is None and answer.others == []
def test_an_exact_match_of_another_length_is_not_preferred():
@@ -582,7 +592,9 @@ def test_a_damaged_file_is_not_lyrics(damage):
def test_nothing_kept_leaves_the_versions_and_when_lrclib_was_asked(tmp_path: Path):
job = Job(id="abcdefabc312")
- answer = lookup_lyrics(query(), fetch_json=Lrclib(searches={"q:Metropolis": [row(1, 769)]}))
+ # Versions to offer and nothing kept, as a lookup that ran out of time
+ # with only those in hand answers.
+ answer = ll.LookupAnswer(None, ll.rank_matches([row(1, 769)], 572))
before = time.time()
assert keep_answer(job, tmp_path, answer) is False
data = json.loads((tmp_path / "lyrics_candidates.json").read_text(encoding="utf-8"))
@@ -693,11 +705,13 @@ def test_an_answer_too_late_is_dropped(tmp_path: Path, monkeypatch):
def test_a_lookup_that_keeps_nothing_leaves_the_candidates(tmp_path: Path, monkeypatch):
- monkeypatch.setattr(ll, "_fetch_json", Lrclib(searches={"q:Metropolis": [row(1, 769)]}))
+ # LRCLIB answers only with another artist's song of that name.
+ other = row(1, 769, artist="Someone Else")
+ monkeypatch.setattr(ll, "_fetch_json", Lrclib(searches={"q:Metropolis": [other]}))
job = _tagged_job("abcdefabc328")
LyricsLookup.start(job, tmp_path).finish(job, tmp_path, 5)
assert not (tmp_path / "lyrics.json").exists() and job.has_lyrics is False
- assert ids(read_candidates(tmp_path)["others"]) == [1]
+ assert read_candidates(tmp_path)["others"] == []
def test_nothing_to_look_for_starts_nothing(tmp_path: Path, monkeypatch):
diff --git a/tests/test_lyrics_retime.py b/tests/test_lyrics_retime.py
new file mode 100644
index 00000000..eb976d1b
--- /dev/null
+++ b/tests/test_lyrics_retime.py
@@ -0,0 +1,849 @@
+"""Lyrics timed line by line to the vocals (app/pipeline/lyrics_retime.py).
+
+No model runs here: what Whisper heard is written out as words, the vocals
+stem's envelope as levels, and the worker, where one runs, is a stub script
+behind transcribe._spawn_worker_cmd (conftest's _no_whisper keeps every other
+test away from the real one)."""
+
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+import pytest
+
+from app.core import settings as settings_mod
+from app.core.models import Job, JobCancelled
+from app.pipeline import lyrics_retime as rt
+from app.pipeline.lyrics_lookup import clean_lyrics, lyrics_path, read_lyrics, write_lyrics
+
+HOP = 0.04
+
+# ── tokens ──
+
+
+def keys(text: str) -> list[str]:
+ return [t[0] for t in rt.tokens_of(text)]
+
+
+def test_latin_words_fold_case_accents_and_apostrophes():
+ assert keys("Don't STOP me, Łódź café") == ["dont", "stop", "me", "lodz", "cafe"]
+
+
+def test_polish_and_german_letters_without_a_base_letter_fold():
+ assert keys("Straße Łza") == ["strasse", "lza"]
+
+
+def test_chinese_is_one_token_a_character_traditional_as_simplified():
+ assert keys("紅豆 红豆") == ["红", "豆", "红", "豆"]
+
+
+def test_kana_is_one_token_a_character_katakana_as_hiragana():
+ assert keys("カタ かた") == ["か", "た", "か", "た"]
+
+
+def test_hangul_is_one_token_a_syllable():
+ assert keys("좋은 날") == ["좋", "은", "날"]
+
+
+def test_mixed_scripts_split_where_the_script_changes():
+ assert keys("愛してるyou") == ["爱", "し", "て", "る", "you"]
+
+
+def pieces(text: str) -> list[str]:
+ return [text[a:b] for a, b in rt.pieces_of(text)]
+
+
+def test_pieces_are_spaced_words_and_each_chinese_character():
+ assert pieces("(Don't) stop!") == ["(Don't)", "stop!"]
+ assert pieces("月亮代表") == ["月", "亮", "代", "表"]
+ assert pieces("좋은 날") == ["좋은", "날"]
+ assert pieces("愛してるyou") == ["愛", "し", "て", "る", "you"]
+
+
+def test_punctuation_alone_joins_a_word():
+ assert pieces("... hello , world") == ["... hello ,", "world"]
+
+
+def test_script_language():
+ assert rt.script_language("좋은 날 you") == "ko"
+ assert rt.script_language("夜に駆ける") == "ja"
+ assert rt.script_language("月亮代表我的心") == "zh"
+ assert rt.script_language("W biegu") is None
+
+
+# ── the alignment ──
+
+
+def test_alignment_pairs_in_order_and_skips_what_does_not_match():
+ pairs = rt.align_tokens(list("abcxdef"), list("zzabcdqqdef"))
+ assert [(i, j) for i, j, _s in pairs] == [(0, 2), (1, 3), (2, 4), (4, 8), (5, 9), (6, 10)]
+
+
+def test_a_misheard_word_still_matches_a_little_less():
+ pairs = rt.align_tokens(["zatrzymaj", "mnie"], ["zatrzymać", "mnie"])
+ assert [(i, j) for i, j, _s in pairs] == [(0, 0), (1, 1)]
+ assert 0 < pairs[0][2] < 1
+
+
+def test_a_short_word_only_matches_itself():
+ assert rt.similarity("to", "ta") == 0.0
+ assert rt.similarity("do", "do") == 1.0
+
+
+def test_a_hangul_syllable_one_letter_off_matches():
+ pairs = rt.align_tokens(["적", "막"], ["정", "맥"])
+ assert len(pairs) == 2
+
+
+def test_a_chorus_sung_twice_but_written_once_keeps_one_whole_run():
+ chorus = ["to", "ja", "zatrzymaj", "mnie"]
+ heard = chorus + ["la", "la"] + chorus
+ pairs = rt.align_tokens(chorus, heard)
+ js = [j for _i, j, _s in pairs]
+ # One occurrence, whole: the run bonus keeps the words together.
+ assert js in ([0, 1, 2, 3], [6, 7, 8, 9])
+
+
+def test_repeated_choruses_each_take_their_own_occurrence():
+ chorus = ["to", "ja", "zatrzymaj", "mnie"]
+ lyric = chorus + ["verse"] + chorus
+ heard = chorus + ["verse"] + chorus
+ pairs = rt.align_tokens(lyric, heard)
+ assert [(i, j) for i, j, _s in pairs] == [(k, k) for k in range(9)]
+
+
+def test_nothing_to_pair_or_too_much_is_empty(monkeypatch):
+ assert rt.align_tokens([], ["a"]) == []
+ monkeypatch.setattr(rt, "LYRICS_RETIME_MAX_CELLS", 3)
+ assert rt.align_tokens(["a", "b"], ["a", "b"]) == []
+
+
+# ── the voice ──
+
+
+def voice_of(spans: list[tuple[float, float]], length: float | None = None) -> rt.Voice:
+ """An envelope singing at -15 dB over ``spans`` and silent elsewhere, to
+ a little after the last: the voice's loud parts are its 95th percentile,
+ and a stem silent 95% of the time has none."""
+ length = length or max(b for _a, b in spans) + 3
+ db = [-80] * int(length / HOP)
+ for a, b in spans:
+ for f in range(int(a / HOP), int(b / HOP)):
+ db[f] = -15
+ return rt.Voice.of(HOP, db)
+
+
+def test_a_word_stretched_over_a_pause_starts_after_it():
+ voice = voice_of([(9.0, 10.0), (11.0, 12.0)])
+ # Whisper starts the word at 10 (the end of the note before) and ends it
+ # at 11.6: it is sung from 11.
+ assert rt.voiced_start(10.0, 11.6, voice) == pytest.approx(11.0, abs=HOP)
+ # One sung straight through keeps its start.
+ assert rt.voiced_start(11.0, 11.8, voice) == 11.0
+
+
+def test_a_line_start_on_a_held_note_moves_to_the_rise():
+ voice = voice_of([(9.0, 10.8), (11.0, 12.0)])
+ assert rt.Voice.rise_start(voice, 10.0, 11.5) == pytest.approx(11.0, abs=0.1)
+ assert rt.Voice.rise_start(voice, 11.0, 11.5) == 11.0
+
+
+# ── retiming ──
+
+
+def words(*timed: tuple[str, float, float]) -> list[dict]:
+ return [{"text": t, "start": a, "end": b} for t, a, b in timed]
+
+
+def sing(line: str, start: float, pace: float = 0.4) -> list[dict]:
+ """A line heard word by word from ``start``, ``pace`` seconds a word."""
+ out = []
+ for k, w in enumerate(line.split()):
+ out.append({"text": w, "start": start + k * pace, "end": start + (k + 1) * pace - 0.05})
+ return out
+
+
+LINES = [
+ "znowu to samo znowu biegnę",
+ "chciałabym bardzo choć na chwilę",
+ "zobaczyć cię biegnę za szybko",
+ "nic się nie stało mówią",
+]
+
+
+def test_lines_start_where_they_are_heard_whatever_their_stamps_say():
+ heard = sing(LINES[0], 20) + sing(LINES[1], 25) + sing(LINES[2], 31) + sing(LINES[3], 36)
+ voice = voice_of([(20, 22), (25, 27.5), (31, 33.5), (36, 38)])
+ pairs = [(50.0 + 3 * k, text) for k, text in enumerate(LINES)]
+ result = rt.retime_lines(pairs, heard, voice, 60)
+ assert [round(t.start, 1) for t in result.timed] == [20.0, 25.0, 31.0, 36.0]
+ assert result.lines_matched == 4
+ assert result.matched == pytest.approx(1.0)
+ assert rt.retime_gate(result)
+
+
+def test_word_stamps_follow_the_heard_words():
+ heard = sing(LINES[0], 20, pace=0.5)
+ result = rt.retime_lines([(0.0, LINES[0])], heard, voice_of([(20, 23)]), 60)
+ assert result.timed[0].words == pytest.approx([20.0, 20.5, 21.0, 21.5, 22.0])
+
+
+def test_a_line_the_track_does_not_sing_is_passed_quickly_and_the_rest_kept():
+ extra = "tego wiersza nikt nie śpiewa"
+ heard = sing(LINES[0], 20) + sing(LINES[1], 25)
+ pairs = [(10.0, LINES[0]), (13.0, extra), (16.0, LINES[1])]
+ result = rt.retime_lines(pairs, heard, voice_of([(20, 22), (25, 27.5)]), 60)
+ first, skipped, second = (t.start for t in result.timed)
+ assert (round(first, 1), round(second, 1)) == (20.0, 25.0)
+ assert first < skipped < second
+ assert not result.timed[1].anchored
+
+
+def test_an_unheard_line_between_anchors_lands_on_the_singing_not_midway():
+ # The line between is sung at 40, after a long instrumental: not at the
+ # midpoint (32.5) the stamps would give on a straight line.
+ mumble = "la la la"
+ heard = sing(LINES[0], 20) + sing(LINES[1], 45)
+ voice = voice_of([(20, 22), (40, 41.5), (45, 47.5)])
+ pairs = [(0.0, LINES[0]), (5.0, mumble), (10.0, LINES[1])]
+ result = rt.retime_lines(pairs, heard, voice, 60)
+ assert result.timed[1].start == pytest.approx(40.0, abs=0.2)
+
+
+def test_stamps_between_anchors_that_agree_with_them_are_kept():
+ heard = sing(LINES[0], 20) + sing(LINES[3], 32)
+ voice = voice_of([(20, 22), (26, 28), (29, 31), (32, 34)])
+ pairs = [(10.0, LINES[0]), (16.0, "one mumbled"), (19.0, "two mumbled"), (22.0, LINES[3])]
+ result = rt.retime_lines(pairs, heard, voice, 60)
+ assert [round(t.start, 1) for t in result.timed] == [20.0, 26.0, 29.0, 32.0]
+
+
+def test_a_translation_stamped_with_its_line_is_shown_with_it():
+ heard = sing(LINES[0], 20) + sing(LINES[1], 25)
+ pairs = [(1.0, LINES[0]), (1.0, "Again the same"), (4.0, LINES[1])]
+ result = rt.retime_lines(pairs, heard, voice_of([(20, 22), (25, 27.5)]), 60)
+ assert result.timed[1].start == result.timed[0].start
+ assert [text for _t, text in rt.synced_lines(rt.to_lrc(result))] == [p[1] for p in pairs]
+
+
+def test_words_heard_in_a_silence_anchor_nothing():
+ heard = sing(LINES[0], 20)
+ result = rt.retime_lines([(0.0, LINES[0])], heard, voice_of([(40, 42)]), 60)
+ assert result.lines_matched == 0
+ assert not rt.retime_gate(result)
+
+
+def test_a_line_matched_to_a_later_repeat_keeps_its_main_group():
+ # "niż wcale" is heard again 10 s later: the line keeps its first group.
+ heard = sing("lepiej późno niż wcale", 70) + sing("niż wcale", 82)
+ voice = voice_of([(70, 72), (82, 83)])
+ result = rt.retime_lines([(0.0, "lepiej późno niż wcale")], heard, voice, 100)
+ assert result.timed[0].words[-1] < 72
+ assert result.timed[0].end < 73
+
+
+def test_a_first_word_held_long_stays_with_its_line():
+ # Piaf's "Non" is held two seconds; Whisper ends it long before "rien",
+ # but the voice goes on between them, so the line starts at "Non".
+ heard = words(
+ ("non", 11.3, 11.7), ("rien", 13.8, 14.0), ("de", 14.0, 14.2), ("rien", 14.2, 14.8)
+ )
+ heard += sing("non je ne regrette rien", 16.5)
+ voice = voice_of([(11.3, 15.0), (16.5, 18.5)])
+ lines = [(11.0, "Non, rien de rien"), (16.0, "Non, je ne regrette rien")]
+ result = rt.retime_lines(lines, heard, voice, 30)
+ assert result.timed[0].start == pytest.approx(11.3, abs=0.1)
+ # With the voice silent in between it is a word heard apart, as before.
+ silent = voice_of([(11.3, 11.7), (13.8, 15.0), (16.5, 18.5)])
+ result = rt.retime_lines(lines, heard, silent, 30)
+ assert result.timed[0].start > 13
+
+
+def test_lyrics_nothing_like_the_singing_fail_the_gate():
+ heard = sing("completely other words here", 10)
+ result = rt.retime_lines([(0.0, LINES[0]), (3.0, LINES[1])], heard, voice_of([(10, 12)]), 60)
+ assert not rt.retime_gate(result)
+
+
+def test_plain_lyrics_get_timed_too():
+ heard = sing(LINES[0], 20) + sing(LINES[1], 25)
+ pairs = [(None, LINES[0]), (None, LINES[1])]
+ result = rt.retime_lines(pairs, heard, voice_of([(20, 22), (25, 27.5)]), 60)
+ assert [round(t.start, 1) for t in result.timed] == [20.0, 25.0]
+
+
+# ── LRC out ──
+
+
+def test_the_result_is_enhanced_lrc_of_the_same_lines():
+ heard = sing(LINES[0], 20) + sing(LINES[1], 30)
+ pairs = [(0.0, LINES[0]), (5.0, LINES[1])]
+ lrc = rt.to_lrc(rt.retime_lines(pairs, heard, voice_of([(20, 22), (30, 33)]), 60))
+ rows = lrc.splitlines()
+ assert rows[0].startswith("[00:20.00]<00:20.00>znowu <00:20.40>to ")
+ assert rows[0].endswith(">")
+ # A long pause after a line clears it.
+ assert rows[1].startswith("[00:2") and rows[1].endswith("]")
+ assert [text for _t, text in rt.synced_lines(lrc)] == [p[1] for p in pairs]
+
+
+# ── keeping it ──
+
+
+def entry(**fields) -> dict:
+ base = {
+ "v": 1,
+ "source": "lrclib",
+ "track": "W biegu",
+ "artist": "Natalia Kukulska",
+ "album": "",
+ "duration": 238.0,
+ "synced": "[00:50.00]znowu to samo\n[00:55.00]chciałabym bardzo",
+ "plain": "znowu to samo\nchciałabym bardzo",
+ "instrumental": False,
+ "lrclib_id": 1,
+ "timing": "unverified",
+ "others": [],
+ }
+ return {**base, **fields}
+
+
+ALIGNED = {
+ "synced": "[00:20.00]<00:20.00>znowu <00:20.40>to <00:20.80>samo<00:21.20>\n"
+ "[00:25.00]<00:25.00>chciałabym <00:25.60>bardzo<00:26.00>",
+ "method": "whisper",
+ "matched": 1.0,
+ "lines_matched": 2,
+ "lines": 2,
+ "at": 1700000000.0,
+}
+
+
+def test_old_lyrics_read_back_unchanged():
+ old = entry()
+ cleaned = clean_lyrics(old)
+ assert "aligned" not in cleaned and "user_synced" not in cleaned
+ assert cleaned["synced"] == old["synced"]
+
+
+def test_a_timing_of_these_lines_is_kept():
+ cleaned = clean_lyrics(entry(aligned=ALIGNED))
+ assert cleaned["aligned"] == ALIGNED
+
+
+@pytest.mark.parametrize(
+ "broken",
+ [
+ {**ALIGNED, "method": "guess"},
+ {**ALIGNED, "matched": 1.5},
+ {**ALIGNED, "matched": float("nan")},
+ {**ALIGNED, "lines_matched": 3},
+ {**ALIGNED, "lines": True},
+ {**ALIGNED, "synced": "[00:20.00]another song"},
+ {**ALIGNED, "synced": 5},
+ "aligned",
+ ],
+)
+def test_a_malformed_or_foreign_timing_is_dropped_not_the_lyrics(broken):
+ cleaned = clean_lyrics(entry(aligned=broken))
+ assert cleaned is not None and "aligned" not in cleaned
+
+
+LINES2 = ["znowu to samo", "chciałabym bardzo"]
+
+
+@pytest.mark.parametrize(
+ "lrc",
+ [
+ "[00:20.00]znowu to samo\n[00:25.00]chciałabym bardzo",
+ "[00:20.00]<00:20.00>znowu to samo\n\n[00:22.00]\n[00:25.00]chciałabym bardzo",
+ "[00:20.00]znowu to samo\n[00:20.00]chciałabym bardzo",
+ ],
+)
+def test_a_manual_timing_of_these_lines_is_accepted(lrc):
+ assert rt.clean_user_synced(lrc, LINES2) is not None
+
+
+@pytest.mark.parametrize(
+ "lrc",
+ [
+ # Another text, a line more or less, another order.
+ "[00:20.00]znowu to samo\n[00:25.00]",
+ "[00:20.00]znowu to samo",
+ "[00:20.00]znowu to samo\n[00:25.00]chciałabym bardzo\n[00:30.00]i jeszcze",
+ "[00:20.00]chciałabym bardzo\n[00:25.00]znowu to samo",
+ # Stamps back in time, malformed, or a tag that is not a stamp.
+ "[00:25.00]znowu to samo\n[00:20.00]chciałabym bardzo",
+ "[00:75.00]znowu to samo\n[01:20.00]chciałabym bardzo",
+ "znowu to samo\n[00:25.00]chciałabym bardzo",
+ "[ar:someone]\n[00:20.00]znowu to samo\n[00:25.00]chciałabym bardzo",
+ "[00:20.00][00:30.00]znowu to samo\n[00:25.00]chciałabym bardzo",
+ "[00:20.00]znowu to samo <00:99.00>\n[00:25.00]chciałabym bardzo",
+ "",
+ None,
+ 5,
+ ],
+)
+def test_anything_else_is_refused(lrc):
+ assert rt.clean_user_synced(lrc, LINES2) is None
+
+
+def test_a_manual_timing_too_long_is_refused(monkeypatch):
+ monkeypatch.setattr(rt, "LYRICS_USER_SYNCED_MAX_LINES", 1)
+ assert (
+ rt.clean_user_synced("[00:20.00]znowu to samo\n[00:25.00]chciałabym bardzo", LINES2) is None
+ )
+
+
+def test_the_lines_a_timing_must_give_are_one_a_stamp_in_time_order():
+ lrc = "[00:30.00][00:10.00]refren\n[00:20.00]zwrotka\n[00:40.00]"
+ assert rt.lyric_line_texts({"synced": lrc, "plain": ""}) == ["refren", "zwrotka", "refren"]
+ assert rt.lyric_line_texts({"synced": "", "plain": "a\n\nb"}) == ["a", "b"]
+
+
+# ── the transcript ──
+
+
+def test_a_transcript_is_kept_and_read_back(tmp_path):
+ answer = {
+ "language": "pl",
+ "model": "turbo",
+ "segments": [
+ {"start": 1, "end": 2, "words": [{"word": " znowu", "start": 1.0, "end": 1.5}]},
+ # Taken for silence by Whisper: not kept.
+ {
+ "start": 5,
+ "end": 6,
+ "no_speech_prob": 0.9,
+ "avg_logprob": -2.0,
+ "words": [{"word": " dzięki", "start": 5.0, "end": 5.5}],
+ },
+ # A chorus compresses like a loop and is kept all the same.
+ {
+ "start": 7,
+ "end": 8,
+ "compression_ratio": 3.0,
+ "words": [{"word": " to", "start": 7.0, "end": 7.2}],
+ },
+ ],
+ }
+ rt.save_transcript(tmp_path, answer)
+ kept = rt.read_transcript(tmp_path)
+ assert kept["language"] == "pl"
+ assert [w["text"] for w in kept["words"]] == ["znowu", "to"]
+
+
+def test_a_transcript_of_other_vocals_is_heard_afresh(tmp_path):
+ stems = tmp_path / "stems"
+ stems.mkdir()
+ (stems / "vocals.wav").write_bytes(b"RIFF1")
+ rt.save_transcript(tmp_path, words=[{"start": 1.0, "end": 1.5, "text": "znowu"}], language="pl")
+ assert rt.read_transcript(tmp_path) is not None
+ # The stem made again, in place: the transcript is of other audio.
+ (stems / "vocals.wav").write_bytes(b"RIFF22")
+ assert rt.read_transcript(tmp_path) is None
+
+
+def test_a_transcript_kept_before_the_stamp_is_taken_as_it_is(tmp_path):
+ rt.transcript_path(tmp_path).write_text(
+ json.dumps({"v": 1, "language": "pl", "words": [[1.0, 1.5, "znowu"]]}), encoding="utf-8"
+ )
+ assert [w["text"] for w in rt.read_transcript(tmp_path)["words"]] == ["znowu"]
+
+
+@pytest.mark.parametrize("content", ["{", '{"v": 2, "words": []}', '{"v": 1, "words": 3}'])
+def test_a_damaged_transcript_is_none(tmp_path, content):
+ rt.transcript_path(tmp_path).write_text(content, encoding="utf-8")
+ assert rt.read_transcript(tmp_path) is None
+
+
+def test_bad_rows_of_a_transcript_are_skipped(tmp_path):
+ data = {"v": 1, "words": [[1, 2, "a"], [1, "x", "b"], [float("inf"), 2, "c"], [1, 2]]}
+ rt.transcript_path(tmp_path).write_text(json.dumps(data), encoding="utf-8")
+ assert [w["text"] for w in rt.read_transcript(tmp_path)["words"]] == ["a"]
+
+
+# ── running it on a job ──
+
+
+def job_dir_with(tmp_path: Path, lyrics: dict, voiced: list[tuple[float, float]]) -> Path:
+ job_dir = tmp_path / "abcdef123456"
+ (job_dir / "stems").mkdir(parents=True)
+ (job_dir / "stems" / "vocals.wav").write_bytes(b"RIFF")
+ db = [-80] * int(max(40, max(b for _a, b in voiced) + 3) / HOP)
+ for a, b in voiced:
+ for f in range(int(a / HOP), int(b / HOP)):
+ db[f] = -15
+ # Kept envelope newer than the stem: read instead of the stem.
+ (job_dir / "stems" / "vocal_envelope.json").write_text(
+ json.dumps({"hop": HOP, "db": db}), encoding="utf-8"
+ )
+ lyrics_path(job_dir).write_text(json.dumps(lyrics), encoding="utf-8")
+ return job_dir
+
+
+def make_job(**fields) -> Job:
+ job = Job(id="abcdef123456", status="done", title="W biegu", duration_sec=60.0)
+ job.compute_device = "cuda"
+ for k, v in fields.items():
+ setattr(job, k, v)
+ return job
+
+
+def stub_worker(monkeypatch, tmp_path: Path, heard: list[dict], log: list | None = None):
+ """Point the worker at a script that answers ``heard`` as one segment."""
+ answer = {
+ "language": "pl",
+ "language_probability": 1.0,
+ "model": "stub",
+ "device": "cpu",
+ "segments": [
+ {
+ "start": heard[0]["start"],
+ "end": heard[-1]["end"],
+ "text": " ".join(w["text"] for w in heard),
+ "words": [
+ {"word": " " + w["text"], "start": w["start"], "end": w["end"]} for w in heard
+ ],
+ }
+ ],
+ "peak_vram_mb": None,
+ }
+ out = tmp_path / "answer.json"
+ out.write_text(json.dumps(answer), encoding="utf-8")
+ script = f"import sys; sys.stdout.write(open({str(out)!r}, encoding='utf-8').read())"
+
+ def cmd(vocals, device):
+ if log is not None:
+ log.append(device)
+ return [sys.executable, "-c", script]
+
+ monkeypatch.setattr("app.pipeline.transcribe._spawn_worker_cmd", cmd)
+
+
+HEARD = sing("znowu to samo", 20) + sing("chciałabym bardzo", 25)
+VOICED = [(20, 22), (25, 27)]
+
+
+def test_a_job_gets_its_lyrics_timed_and_the_source_is_kept(monkeypatch, tmp_path):
+ job_dir = job_dir_with(tmp_path, entry(), VOICED)
+ stub_worker(monkeypatch, tmp_path, HEARD)
+ outcome = rt.retime_job(make_job(), job_dir, cancelled=lambda: False)
+ assert outcome.state == "done"
+ kept = read_lyrics(job_dir)
+ assert kept["synced"] == entry()["synced"]
+ assert kept["aligned"]["lines_matched"] == 2
+ assert kept["aligned"]["synced"].startswith("[00:20.00]")
+ assert rt.transcript_path(job_dir).is_file()
+
+
+def test_a_second_run_reuses_the_transcript(monkeypatch, tmp_path):
+ job_dir = job_dir_with(tmp_path, entry(), VOICED)
+ calls: list[str] = []
+ stub_worker(monkeypatch, tmp_path, HEARD, calls)
+ rt.retime_job(make_job(), job_dir, cancelled=lambda: False)
+ rt.retime_job(make_job(), job_dir, cancelled=lambda: False)
+ assert calls == ["cuda"]
+
+
+def test_an_unsure_result_leaves_the_lyrics_alone(monkeypatch, tmp_path):
+ job_dir = job_dir_with(tmp_path, entry(), VOICED)
+ stub_worker(monkeypatch, tmp_path, sing("nothing like it at all", 20))
+ before = lyrics_path(job_dir).read_bytes()
+ assert rt.retime_job(make_job(), job_dir, cancelled=lambda: False).state == "unsure"
+ assert lyrics_path(job_dir).read_bytes() == before
+
+
+def test_a_cancel_leaves_the_lyrics_alone(monkeypatch, tmp_path):
+ job_dir = job_dir_with(tmp_path, entry(), VOICED)
+ stub_worker(monkeypatch, tmp_path, HEARD)
+ before = lyrics_path(job_dir).read_bytes()
+ with pytest.raises(JobCancelled):
+ rt.retime_job(make_job(), job_dir, cancelled=lambda: True)
+ assert lyrics_path(job_dir).read_bytes() == before
+
+
+def test_a_failed_transcription_is_a_failure(tmp_path):
+ job_dir = job_dir_with(tmp_path, entry(), VOICED)
+ with pytest.raises(rt.RetimeFailed):
+ rt.retime_job(make_job(), job_dir, cancelled=lambda: False)
+
+
+def test_no_lyrics_is_a_failure(tmp_path):
+ job_dir = job_dir_with(tmp_path, entry(), VOICED)
+ lyrics_path(job_dir).unlink()
+ with pytest.raises(rt.RetimeFailed):
+ rt.retime_job(make_job(), job_dir, cancelled=lambda: False)
+
+
+STARTS = [5.0, 8.5, 14.0, 17.0, 22.5, 26.0, 31.5, 35.0]
+
+
+def test_a_timing_the_voice_confirms_is_not_replaced_by_a_worse_one():
+ # The copy's own stamps sit on the voice's rises, and a whole-song shift
+ # of 0 fits them; a timing a second later, where the voice does not
+ # rise, is worse.
+ synced = chr(10).join(f"[00:{t:05.2f}]zwrotka numer {k}" for k, t in enumerate(STARTS))
+ voice = voice_of([(t, t + 2) for t in STARTS])
+ pairs = rt.synced_lines(synced)
+ later = rt.Retimed(
+ rt.build_lyric(pairs).lines,
+ [rt.Timed(t + 1.0, t + 1.8, [t + 1.0] * 3, True) for t in STARTS],
+ 1.0,
+ len(STARTS),
+ )
+ assert not rt.no_worse(entry(synced=synced, timing="exact"), later, voice)
+ same = rt.retime_lines(
+ pairs, [w for t in STARTS for w in sing("zwrotka numer x", t)], voice, 40
+ )
+ assert rt.no_worse(entry(synced=synced, timing="exact"), same, voice)
+
+
+def test_a_line_whose_first_words_whisper_missed_starts_with_its_phrase(tmp_path):
+ # Heard from its third word, a second into a phrase nothing else was
+ # heard in: the line starts where the phrase does.
+ heard = sing("samo znowu biegnę", 21.0)
+ result = rt.retime_lines([(0.0, LINES[0])], heard, voice_of([(20.0, 23.0)]), 40)
+ assert result.timed[0].start == pytest.approx(20.0, abs=0.1)
+
+
+# ── after separation ──
+
+
+@pytest.mark.parametrize(
+ ("fields", "setting", "device", "wanted"),
+ [
+ ({}, "auto", "cuda", True),
+ ({}, "auto", "cpu", False),
+ ({}, "on", "cpu", True),
+ ({}, "off", "cuda", False),
+ ({"offset_sec": 1.0}, "auto", "cuda", False),
+ ({"aligned": ALIGNED}, "auto", "cuda", False),
+ ({"timing": "shifted"}, "auto", "cuda", True),
+ ({"source": "whisper"}, "auto", "cuda", False),
+ ],
+)
+def test_who_gets_timed_after_separation(tmp_path, fields, setting, device, wanted):
+ settings_mod.set_transcribe_lyrics(setting)
+ job_dir = job_dir_with(tmp_path, entry(**fields), VOICED)
+ assert rt.wants_retime(make_job(compute_device=device), job_dir) is wanted
+
+
+def test_after_separation_it_never_raises_but_a_cancel(monkeypatch, tmp_path):
+ job_dir = job_dir_with(tmp_path, entry(), VOICED)
+ # The worker fails (conftest): no lyrics timed, no error.
+ assert rt.retime_after_separation(make_job(), job_dir) is False
+ with pytest.raises(JobCancelled):
+ rt.retime_after_separation(make_job(cancel_requested=True), job_dir)
+
+
+def test_after_separation_it_times_the_lyrics(monkeypatch, tmp_path):
+ job_dir = job_dir_with(tmp_path, entry(), VOICED)
+ stub_worker(monkeypatch, tmp_path, HEARD)
+ assert rt.retime_after_separation(make_job(), job_dir) is True
+ assert "aligned" in read_lyrics(job_dir)
+
+
+def test_writing_lyrics_again_keeps_a_timing_of_the_same_lines(tmp_path):
+ job_dir = tmp_path / "j"
+ job_dir.mkdir()
+ job = make_job()
+ assert write_lyrics(job, job_dir, entry(aligned=ALIGNED))
+ assert read_lyrics(job_dir)["aligned"] == ALIGNED
+
+
+def test_the_language_of_latin_lyrics_comes_from_their_commonest_words():
+ german = "Ich geh mit dir wohin du willst und nicht mit mir, das ist die Nacht " * 2
+ assert rt.text_language(german) == "de"
+ assert rt.text_language("Eu não sei o que você quer, é meu e com mais " * 2) == "pt"
+ assert rt.text_language("la la la") is None
+
+
+def test_words_in_brackets_are_a_backing_vocal_and_match_nothing():
+ lyric = rt.build_lyric([(0.0, "(you're gonna wish) the scars of your love")])
+ assert lyric.aside[:3] == [True, True, True] and not any(lyric.aside[3:])
+ # A line all in brackets is the line itself.
+ assert not any(rt.build_lyric([(0.0, "(whoa oh oh)")]).aside)
+ heard = sing("you're gonna wish", 18) + sing("the scars of your love", 20)
+ result = rt.retime_lines(
+ [(0.0, "(you're gonna wish) the scars of your love")], heard, voice_of([(18, 22)]), 30
+ )
+ assert result.timed[0].start == pytest.approx(20.0, abs=0.05)
+
+
+def test_a_line_sung_again_much_later_is_taken_where_it_follows_the_one_before():
+ # The last line is heard right after the line before and again a minute
+ # later, a little more clearly; it is the first time.
+ heard = sing(LINES[0], 20) + sing("chciałabym xyz choć na", 24)
+ heard += [w for k in range(30) for w in sing(f"inne{k}", 30 + k * 2)]
+ heard += sing(LINES[1], 95)
+ voice = voice_of([(20, 22), (24, 26), (30, 90), (95, 98)])
+ result = rt.retime_lines([(0.0, LINES[0]), (4.0, LINES[1])], heard, voice, 100)
+ assert result.timed[1].start == pytest.approx(24.0, abs=0.3)
+
+
+# ── second round: credits, pause marks, missed lines heard again ──
+
+
+def test_credits_at_the_top_are_not_timed_and_come_before_the_singing():
+ pairs = [
+ (0.5, "晴天 - 周傑倫"),
+ (1.0, "詞:周傑倫"),
+ (1.5, "曲:周傑倫"),
+ (30.0, LINES[0]),
+ (35.0, LINES[1]),
+ ]
+ assert rt._credits(pairs) == {0, 1, 2}
+ heard = sing(LINES[0], 20) + sing(LINES[1], 25)
+ result = rt.retime_lines(pairs, heard, voice_of([(20, 22), (25, 27.5)]), 40)
+ starts = [t.start for t in result.timed]
+ assert starts[3] == pytest.approx(20.0, abs=0.05)
+ assert starts[0] <= starts[1] <= starts[2] < starts[3]
+ assert result.counted == 2
+ assert [text for _t, text in rt.synced_lines(rt.to_lrc(result))] == [p[1] for p in pairs]
+
+
+def test_a_line_that_only_sings_the_word_music_is_not_a_credit():
+ assert rt._credits([(1.0, "Music is my life"), (3.0, "la la")]) == set()
+
+
+def test_a_pause_mark_keeps_its_place_after_the_line_before():
+ pairs = [(10.0, LINES[0]), (14.0, "♪"), (20.0, LINES[1])]
+ heard = sing(LINES[0], 30) + sing(LINES[1], 40)
+ result = rt.retime_lines(pairs, heard, voice_of([(30, 32), (40, 42.5)]), 60)
+ mark = result.timed[1]
+ assert mark.start == pytest.approx(34.0, abs=0.1)
+ assert result.counted == 2
+
+
+def test_a_first_word_held_over_the_note_before_starts_at_the_stronger_rise():
+ # Singing from 9 s with a mild rise; the line's word, heard from 10 s,
+ # is sung from the sharp rise at 11 s after a dip.
+ db = [-80] * int(20 / HOP)
+ for f in range(int(9 / HOP), int(13 / HOP)):
+ db[f] = -24
+ for f in range(int(10.0 / HOP), int(10.9 / HOP)):
+ db[f] = -21
+ for f in range(int(11.0 / HOP), int(13 / HOP)):
+ db[f] = -10
+ voice = rt.Voice.of(HOP, db)
+ assert voice.rise_start(10.0, 12.0) == pytest.approx(11.0, abs=0.1)
+ # A short word keeps its start.
+ assert voice.rise_start(10.0, 10.5) == 10.0
+
+
+def test_the_worker_clips_argument():
+ from app.pipeline.transcribe_worker import parse_clips
+
+ assert parse_clips("1:2,x,5:400,-1:3,3:2") == [(1.0, 2.0), (5.0, 125.0)]
+ assert parse_clips("") == []
+
+
+def test_missed_runs_become_clips_and_their_words_replace_the_first_ones():
+ lines = [rt.Line("a", 0.0), rt.Line("b", 4.0), rt.Line("c", 8.0), rt.Line("d", 12.0)]
+ timed = [
+ rt.Timed(10.0, 11.0, [], True),
+ rt.Timed(14.0, 14.5, [], False),
+ rt.Timed(18.0, 18.5, [], False),
+ rt.Timed(22.0, 23.0, [], True),
+ ]
+ result = rt.Retimed(lines, timed, 0.5, 2)
+ voice = voice_of([(10, 23)])
+ assert rt.missed_clips(result, voice, 30) == [(11.0, 22.0)]
+ # Too little singing where the run goes: nothing to hear again.
+ assert rt.missed_clips(result, voice_of([(10, 11), (22, 23)]), 30) == []
+ words = [{"text": "x", "start": 5.0, "end": 5.5}, {"text": "y", "start": 15.0, "end": 15.5}]
+ extra = [{"text": "z", "start": 16.0, "end": 16.5}, {"text": "w", "start": 40.0, "end": 41}]
+ merged = rt.merge_words(words, extra, [(10.6, 22.4)])
+ assert [w["text"] for w in merged] == ["x", "z"]
+
+
+FOUR = [
+ "pierwsza linia tutaj",
+ "druga linia śpiewana",
+ "trzecia linia słyszana",
+ "czwarta już ostatnia",
+]
+
+
+def stub_two_answers(monkeypatch, tmp_path, first: list[dict], clips: list[dict], log: list):
+ """A worker answering ``first`` for the whole stem and ``clips`` when it
+ is asked for --clips."""
+
+ def answer(heard: list[dict]) -> dict:
+ return {
+ "language": "pl",
+ "language_probability": 1.0,
+ "model": "stub",
+ "device": "cpu",
+ "segments": [
+ {
+ "start": heard[0]["start"],
+ "end": heard[-1]["end"],
+ "text": "",
+ "words": [
+ {"word": " " + w["text"], "start": w["start"], "end": w["end"]}
+ for w in heard
+ ],
+ }
+ ],
+ "peak_vram_mb": None,
+ }
+
+ (tmp_path / "full.json").write_text(json.dumps(answer(first)), encoding="utf-8")
+ (tmp_path / "clips.json").write_text(json.dumps(answer(clips)), encoding="utf-8")
+ script = (
+ "import sys; name = 'clips.json' if '--clips' in sys.argv else 'full.json'; "
+ f"sys.stdout.write(open({str(tmp_path)!r} + '/' + name, encoding='utf-8').read())"
+ )
+
+ def cmd(vocals, device):
+ log.append(device)
+ return [sys.executable, "-c", script]
+
+ monkeypatch.setattr("app.pipeline.transcribe._spawn_worker_cmd", cmd)
+
+
+def test_lines_missed_by_the_first_pass_are_heard_again_and_found(monkeypatch, tmp_path):
+ stamps = [2.0, 6.0, 10.0, 14.0]
+ synced = chr(10).join(f"[00:{t:05.2f}]{x}" for t, x in zip(stamps, FOUR, strict=True))
+ voiced = [(t + 10, t + 12) for t in stamps]
+ job_dir = job_dir_with(tmp_path, entry(synced=synced, plain=""), voiced)
+ first = sing(FOUR[0], 12.0) + sing(FOUR[3], 24.0)
+ again = sing(FOUR[1], 16.0) + sing(FOUR[2], 20.0)
+ log: list = []
+ stub_two_answers(monkeypatch, tmp_path, first, again, log)
+ outcome = rt.retime_job(make_job(), job_dir, cancelled=lambda: False)
+ assert outcome.state == "done" and outcome.lines_matched == 4
+ assert len(log) == 2
+ aligned = read_lyrics(job_dir)["aligned"]["synced"]
+ assert [round(t) for t, _x in rt.synced_lines(aligned)] == [12, 16, 20, 24]
+ kept = rt.read_transcript(job_dir)
+ assert kept["refined"] is True
+ # Timed again: the kept transcript, no worker at all.
+ rt.retime_job(make_job(), job_dir, cancelled=lambda: False)
+ assert len(log) == 2
+
+
+SEVEN = FOUR + ["piąta dalej śpiewa", "szósta prawie koniec", "siódma na sam koniec"]
+
+
+def test_lines_heard_again_where_the_copy_does_not_put_them_are_not_taken(monkeypatch, tmp_path):
+ # Heard again, three missed lines turn up 9 s later than the copy and
+ # the lines found around them put them: a chorus sung once more after a
+ # break, not these. They keep the copy's own place.
+ stamps = [2.0, 6.0, 10.0, 14.0, 18.0, 40.0, 44.0]
+ synced = chr(10).join(f"[00:{t:05.2f}]{x}" for t, x in zip(stamps, SEVEN, strict=True))
+ voiced = [(t + 10, t + 12) for t in stamps] + [(37, 39), (41, 43), (45, 47)]
+ job_dir = job_dir_with(tmp_path, entry(synced=synced, plain=""), voiced)
+ first = sing(SEVEN[0], 12.0) + sing(SEVEN[1], 16.0) + sing(SEVEN[5], 50.0)
+ first += sing(SEVEN[6], 54.0)
+ later = sing(SEVEN[2], 37.0) + sing(SEVEN[3], 41.0) + sing(SEVEN[4], 45.0)
+ log: list = []
+ stub_two_answers(monkeypatch, tmp_path, first, later, log)
+ rt.retime_job(make_job(), job_dir, cancelled=lambda: False)
+ assert len(log) == 2
+ starts = [t for t, _x in rt.synced_lines(read_lyrics(job_dir)["aligned"]["synced"])]
+ assert starts[2:5] == pytest.approx([20.0, 24.0, 28.0], abs=0.3)
diff --git a/tests/test_lyrics_retime_api.py b/tests/test_lyrics_retime_api.py
new file mode 100644
index 00000000..15855098
--- /dev/null
+++ b/tests/test_lyrics_retime_api.py
@@ -0,0 +1,347 @@
+"""POST/GET /api/jobs/{id}/lyrics/retime and PUT/DELETE .../lyrics/user-synced.
+
+The worker is a stub script (tests/test_lyrics_retime.py's stub_worker), so
+no model runs; the run itself goes through the real endpoint, background task
+and pipeline lock."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import sys
+import time
+from unittest.mock import patch
+
+import pytest
+from fastapi.testclient import TestClient
+
+import app.api.jobs as jobs_mod
+from app.core.models import Job
+from app.core.registry import _jobs
+from app.pipeline import lyrics_retime as rt
+from tests.test_lyrics_retime import HEARD, HOP, VOICED, entry, stub_worker
+
+JOB_ID = "abcdef123456"
+
+
+@pytest.fixture(autouse=True)
+def _isolate():
+ _jobs.clear()
+ rt._RUNS.clear()
+ yield
+ _jobs.clear()
+ rt._RUNS.clear()
+
+
+@pytest.fixture
+def client():
+ with patch("app.api.jobs.jobqueue.enqueue", lambda job_id: None):
+ from app.main import app
+
+ with TestClient(app) as c:
+ yield c
+
+
+def done_job(lyrics: dict | None = None, vocals: bool = True, **fields) -> Job:
+ job = Job(id=JOB_ID, status="done", title="W biegu", duration_sec=40.0, **fields)
+ job.compute_device = "cuda"
+ _jobs[job.id] = job
+ job_dir = jobs_mod.JOBS_DIR / job.id
+ (job_dir / "stems").mkdir(parents=True)
+ if vocals:
+ (job_dir / "stems" / "vocals.wav").write_bytes(b"RIFF")
+ db = [-80] * int(40 / HOP)
+ for a, b in VOICED:
+ for f in range(int(a / HOP), int(b / HOP)):
+ db[f] = -15
+ (job_dir / "stems" / "vocal_envelope.json").write_text(
+ json.dumps({"hop": HOP, "db": db}), encoding="utf-8"
+ )
+ if lyrics is not None:
+ (job_dir / "lyrics.json").write_text(json.dumps(lyrics), encoding="utf-8")
+ return job
+
+
+def lyrics_file():
+ return jobs_mod.JOBS_DIR / JOB_ID / "lyrics.json"
+
+
+def wait_for(client, url: str, until=lambda body: body["state"] != "running") -> dict:
+ deadline = time.monotonic() + 20
+ while True:
+ body = client.get(url).json()
+ if until(body) or time.monotonic() > deadline:
+ return body
+ time.sleep(0.05)
+
+
+RETIME = f"/api/jobs/{JOB_ID}/lyrics/retime"
+USER = f"/api/jobs/{JOB_ID}/lyrics/user-synced"
+
+
+# ── retime ──
+
+
+def test_a_run_times_the_kept_lyrics(client, monkeypatch, tmp_path):
+ done_job(entry())
+ stub_worker(monkeypatch, tmp_path, HEARD)
+ assert client.get(RETIME).json() == {"state": "idle"}
+ r = client.post(RETIME)
+ assert r.status_code == 202
+ assert r.json()["state"] == "running"
+ body = wait_for(client, RETIME)
+ assert body["state"] == "done"
+ assert body["lines_matched"] == 2 and body["lines"] == 2
+ lyrics = client.get(f"/api/jobs/{JOB_ID}/lyrics").json()
+ assert lyrics["aligned"]["synced"].startswith("[00:20.00]")
+ assert lyrics["synced"] == entry()["synced"]
+
+
+def test_the_state_after_a_restart_comes_from_the_file(client, monkeypatch, tmp_path):
+ done_job(entry())
+ stub_worker(monkeypatch, tmp_path, HEARD)
+ client.post(RETIME)
+ wait_for(client, RETIME)
+ rt._RUNS.clear()
+ body = client.get(RETIME).json()
+ assert body["state"] == "done" and body["lines_matched"] == 2
+
+
+def test_lyrics_the_browser_keeps_are_answered_not_saved(client, monkeypatch, tmp_path):
+ done_job(None)
+ stub_worker(monkeypatch, tmp_path, HEARD)
+ lrc = entry()["synced"]
+ r = client.post(RETIME, json={"synced": lrc, "plain": ""})
+ assert r.status_code == 202
+ body = wait_for(client, RETIME)
+ assert body["state"] == "done"
+ assert rt.clean_aligned(body["aligned"], rt.lyric_line_texts({"synced": lrc})) is not None
+ assert not lyrics_file().exists()
+
+
+def test_an_unsure_result_says_so_and_changes_nothing(client, monkeypatch, tmp_path):
+ done_job(entry())
+ stub_worker(monkeypatch, tmp_path, [{"text": "nothing", "start": 20, "end": 21}])
+ before = lyrics_file().read_bytes()
+ client.post(RETIME)
+ body = wait_for(client, RETIME)
+ assert body["state"] == "unsure"
+ assert "matched" in body
+ assert lyrics_file().read_bytes() == before
+
+
+def test_a_failed_transcription_is_failed(client):
+ done_job(entry())
+ client.post(RETIME)
+ assert wait_for(client, RETIME)["state"] == "failed"
+
+
+def test_no_lyrics_is_404_and_no_vocals_or_lines_is_409(client):
+ done_job(None)
+ assert client.post(RETIME).status_code == 404
+ _jobs.clear()
+ import shutil
+
+ shutil.rmtree(jobs_mod.JOBS_DIR / JOB_ID)
+ done_job(entry(), vocals=False)
+ assert client.post(RETIME).status_code == 409
+ lyrics_file().write_text(
+ json.dumps(entry(synced="", plain="", instrumental=True)), encoding="utf-8"
+ )
+ assert client.post(RETIME).status_code == 409
+
+
+def test_a_job_not_done_is_409(client):
+ job = done_job(entry())
+ job.status = "running"
+ assert client.post(RETIME).status_code == 409
+
+
+def test_a_body_that_is_not_lyrics_is_422(client):
+ done_job(entry())
+ assert client.post(RETIME, json={"synced": 5}).status_code == 422
+ assert client.post(RETIME, content=b"[1]").status_code == 422
+ assert client.post(RETIME, json={"synced": "x" * 100_001}).status_code == 422
+
+
+@pytest.mark.parametrize("route", [RETIME, USER])
+@pytest.mark.parametrize("job_id", ["nope", "ABCDEF123456", "..%2F..%2Fetc", "000000000000"])
+def test_unknown_or_malformed_jobs_are_404(client, route, job_id):
+ url = route.replace(JOB_ID, job_id)
+ assert client.get(url).status_code in (404, 405)
+ assert client.post(url).status_code in (404, 405)
+ assert client.put(url, json={"synced": "[00:01.00]a"}).status_code in (404, 405)
+ assert client.delete(url).status_code in (404, 405)
+
+
+def test_a_traversing_id_never_reaches_the_disk(client):
+ for method in ("get", "post", "put", "delete"):
+ r = getattr(client, method)("/api/jobs/../../etc/passwd/lyrics/retime")
+ assert r.status_code in (404, 405)
+
+
+def hold_the_lock(client, monkeypatch) -> asyncio.Semaphore:
+ """The pipeline lock as busy: a separation is running."""
+ lock = client.portal.call(lambda: _make_semaphore())
+ monkeypatch.setattr(jobs_mod, "_pipeline_lock", lock)
+ return lock
+
+
+async def _make_semaphore() -> asyncio.Semaphore:
+ return asyncio.Semaphore(0)
+
+
+def test_a_run_waits_for_the_pipeline_lock(client, monkeypatch, tmp_path):
+ done_job(entry())
+ stub_worker(monkeypatch, tmp_path, HEARD)
+ lock = hold_the_lock(client, monkeypatch)
+ assert client.post(RETIME).status_code == 202
+ time.sleep(0.3)
+ body = client.get(RETIME).json()
+ assert body == {"state": "running", "progress": 0.0}
+ assert "aligned" not in json.loads(lyrics_file().read_text(encoding="utf-8"))
+ # One at a time per track.
+ assert client.post(RETIME).status_code == 409
+ client.portal.call(_release, lock)
+ assert wait_for(client, RETIME)["state"] == "done"
+
+
+async def _release(lock: asyncio.Semaphore) -> None:
+ lock.release()
+
+
+def test_a_run_waiting_for_the_lock_is_cancelled_at_once(client, monkeypatch, tmp_path):
+ done_job(entry())
+ stub_worker(monkeypatch, tmp_path, HEARD)
+ lock = hold_the_lock(client, monkeypatch)
+ client.post(RETIME)
+ client.post(f"/api/jobs/{JOB_ID}/cancel")
+ assert client.get(RETIME).json()["state"] == "idle"
+ # It never runs, even once the lock is free, and the job is still done.
+ client.portal.call(_release, lock)
+ time.sleep(0.3)
+ assert client.get(RETIME).json()["state"] == "idle"
+ assert "aligned" not in json.loads(lyrics_file().read_text(encoding="utf-8"))
+ assert client.get(f"/api/jobs/{JOB_ID}").json()["status"] == "done"
+
+
+def test_a_running_transcription_is_cancelled(client, monkeypatch):
+ done_job(entry())
+ monkeypatch.setattr(
+ "app.pipeline.transcribe._spawn_worker_cmd",
+ lambda vocals, device: [sys.executable, "-c", "import time; time.sleep(30)"],
+ )
+ before = lyrics_file().read_bytes()
+ client.post(RETIME)
+ deadline = time.monotonic() + 10
+ while rt.current_run(JOB_ID).started is False and time.monotonic() < deadline:
+ time.sleep(0.05)
+ time.sleep(0.5)
+ started = time.monotonic()
+ client.post(f"/api/jobs/{JOB_ID}/cancel")
+ assert wait_for(client, RETIME)["state"] == "idle"
+ assert time.monotonic() - started < 10
+ assert lyrics_file().read_bytes() == before
+
+
+def test_deleting_the_job_stops_its_run(client, monkeypatch):
+ done_job(entry())
+ monkeypatch.setattr(
+ "app.pipeline.transcribe._spawn_worker_cmd",
+ lambda vocals, device: [sys.executable, "-c", "import time; time.sleep(30)"],
+ )
+ client.post(RETIME)
+ time.sleep(0.5)
+ assert client.delete(f"/api/jobs/{JOB_ID}").status_code == 200
+ assert rt.current_run(JOB_ID) is None
+
+
+def test_deleting_waits_for_the_run_to_let_go_of_the_vocals(client, monkeypatch):
+ """On Windows a file the transcription still has open cannot be removed:
+ the folder goes only once the run has ended."""
+ done_job(entry())
+ monkeypatch.setattr(
+ "app.pipeline.transcribe._spawn_worker_cmd",
+ lambda vocals, device: [sys.executable, "-c", "import time; time.sleep(30)"],
+ )
+ client.post(RETIME)
+ deadline = time.monotonic() + 10
+ while not rt.current_run(JOB_ID).started and time.monotonic() < deadline:
+ time.sleep(0.05)
+ run = rt.current_run(JOB_ID)
+ seen = {}
+ real_rmtree = jobs_mod._rmtree_job
+
+ def rmtree(job_id):
+ seen["ended"] = run.ended.is_set()
+ return real_rmtree(job_id)
+
+ monkeypatch.setattr(jobs_mod, "_rmtree_job", rmtree)
+ assert client.delete(f"/api/jobs/{JOB_ID}").status_code == 200
+ assert seen == {"ended": True}
+
+
+# ── user-synced ──
+
+GOOD = "[00:20.00]znowu to samo\n[00:24.00]\n[00:25.00]<00:25.00>chciałabym <00:25.60>bardzo"
+
+
+def test_a_manual_timing_is_kept_and_served(client):
+ done_job(entry())
+ r = client.put(USER, json={"synced": GOOD})
+ assert r.status_code == 200
+ assert r.json()["user_synced"] == GOOD
+ lyrics = client.get(f"/api/jobs/{JOB_ID}/lyrics").json()
+ assert lyrics["user_synced"] == GOOD
+ assert lyrics["synced"] == entry()["synced"]
+
+
+def test_a_manual_timing_is_dropped(client):
+ done_job(entry(user_synced=GOOD))
+ assert client.get(f"/api/jobs/{JOB_ID}/lyrics").json()["user_synced"] == GOOD
+ r = client.delete(USER)
+ assert r.status_code == 200
+ assert "user_synced" not in client.get(f"/api/jobs/{JOB_ID}/lyrics").json()
+ assert client.delete(USER).status_code == 200
+
+
+@pytest.mark.parametrize(
+ "body",
+ [
+ {"synced": "[00:20.00]znowu to samo\n[00:25.00]other text"},
+ {"synced": "[00:25.00]znowu to samo\n[00:20.00]chciałabym bardzo"},
+ {"synced": "not lrc at all"},
+ {"synced": ""},
+ {"synced": 5},
+ {},
+ [GOOD],
+ ],
+)
+def test_anything_but_a_timing_of_these_lines_is_422(client, body):
+ done_job(entry())
+ before = lyrics_file().read_bytes()
+ r = client.put(USER, json=body)
+ assert r.status_code == 422
+ assert "other text" not in r.text
+ assert lyrics_file().read_bytes() == before
+
+
+def test_a_manual_timing_too_large_is_413(client):
+ done_job(entry())
+ r = client.put(USER, content=b'{"synced": "' + b"a" * 2_000_000 + b'"}')
+ assert r.status_code == 413
+
+
+def test_a_manual_timing_without_lyrics_is_404(client):
+ done_job(None)
+ assert client.put(USER, json={"synced": GOOD}).status_code == 404
+ assert client.delete(USER).status_code == 404
+
+
+def test_a_timing_run_keeps_the_manual_timing(client, monkeypatch, tmp_path):
+ done_job(entry(user_synced=GOOD))
+ stub_worker(monkeypatch, tmp_path, HEARD)
+ client.post(RETIME)
+ assert wait_for(client, RETIME)["state"] == "done"
+ lyrics = client.get(f"/api/jobs/{JOB_ID}/lyrics").json()
+ assert lyrics["user_synced"] == GOOD and "aligned" in lyrics
From c20ca422b8e40f73aaaab44f8f2dbe9adc0a3dcd Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 29 Sep 2026 11:16:21 +0100
Subject: [PATCH 20/20] fix: what a review of #713 found (#702, #706, #716,
#717, #718)
- 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