Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,7 @@ Singers submit song requests from their own phones via a QR code instead of hand
- **Completion:** `GenPoller` links only the `NOMAD-#### - …` master once master-sync pulls it; `complete_gen_job` flips `Being Made (!)` → `Waiting` (never other statuses, never over a KJ's manual link). Lyrics review: the singer gets gen's review email (one-click sign-in link for kjbox jobs) and the KJ has the NEEDS REVIEW badge — first wins; an unreviewed song just stays at the bottom.
- `/sing/my-requests` adds `make` (and drops the queue estimate) until the video is linked: `making` → `review` (awaiting lyrics review) → `review_self` / `review_host` (review opened by the singer / by the KJ — gen `state_data.review_started_by == "admin"`, stored as rotation `gen_status` `in_review` / `host_review` by `map_gen_job`) → `rendering` (rendering + master-sync, "~10 minutes"); `needs_host` = audio pick/trim needed or the job failed. In the review phases My songs shows "**Tap here** to review it yourself": `POST /sing/make/review-link/<request_id>` (`{edit_token, device_id, locale}`) → gen `POST /api/kjbox/jobs/{job_id}/review-link` as the device's gen account → a one-click sign-in URL to that job's review (409 `not_in_review` once done). The KJ badge shows IN REVIEW for both in-review states. Singer cancel doesn't cancel the gen job (they still get their video).
- **Quick version (2026-09-26, v0.122.0):** gen also renders a rough **scrolling-lyrics draft** for kjbox jobs a few minutes after the audio lands (gen `backend/services/quick_version/`, a port of `fastgen/`; `state_data.quick_version`, `file_urls.quick.video_mp4`). `GenPoller._sync_quick` polls every 10 s (`QUICK_FAST_POLL_SECONDS`) while one is on its way (gen `separating`/`rendering`, or a singer make-it still `processing`), then downloads it (`GenClient.download_quick_version`, admin token) → `media.import_upload` → `QUICK` version label → row in the new `gen_quick_versions` table (keyed by gen job id, NOT a rotation column — undo snapshots restore entries wholesale; status `downloading → ready → chosen → upgraded`, or `failed`, max 3 download attempts) → `quick_ready` web push to the singer. My songs shows `quick: ready` as an offer card (▶ Preview via preview source `entry_quick`, resolved server-side; **⚡ Sing it now** → `POST /sing/make/use-quick/<request_id>` with the edit_token → `RotationManager.use_quick_version` links it, `Being Made (!)` → `Waiting`, undoable). `complete_gen_job` **swaps in the NOMAD master** when the linked file is still the chosen draft and the entry isn't Now Singing/Done/Left (→ `upgraded`); otherwise the draft stays. KJ UI: `⚡ QUICK READY` badge (click → `POST /rotation/use-quick`) and `⚡ QUICK` on a linked draft (`entries[].quick` from `_add_quick_versions`). Config `make_quick_version_enabled` (default true) turns off download + offer; `GET /sing/event-info` still reports `make_quick_version`, but since the one-screen make-it (v0.142.0) the singer UI no longer pre-announces the quick version; the My songs offer card is where singers meet it.
- **Quick version with no lyrics (2026-11-06, v0.150.0):** gen's quick render looks lyrics up on LRCLIB by the job's exact artist/title, so a decorated title ("… (Orchestral Version Live From Sound City)") can miss and the draft only shows "(lyrics unavailable)" (`gen_quick_versions.lyrics_tier = 'none'`). The KJ UI then shows a red **⚠ NO LYRICS** badge on the rotation row, plus a **⚠ No lyrics, add them** button in Now Playing when that file is playing. Both open the *Add lyrics to the quick version* modal: `GET /rotation/quick-lyrics/search?id=<entry>[&q=]` searches LRCLIB (default query = artist + title with trailing `(…)`/`[…]`/`- Live …` stripped) and returns the video's duration; synced results whose duration is within 4 s of the video get line timing on by default, others are flagged and default to a steady scroll. The KJ can also paste plain text or LRC. `POST /rotation/quick-lyrics {id, lyrics, use_timing}` (`quick_lyrics.py`) re-renders **only the video track** with fastgen's crawl code (loaded by path from `fastgen/fastgen.py`), stream-copies the existing audio, swaps the file in place (same path and media id, so the entry and QUICK label stay linked) and records the new `lyrics_tier` (`synced` / `constant`). It renders one at a time (409 while busy). mpv keeps playing the old file it has open, so the modal offers **Play it again from the start** when the song is on.

**Simple KJ Mode (2026-05-28):** Persistent `kj_simple_mode` flag in `sing_meta` (default off) shrinks both the singer SPA and the KJ UI to the bare minimum needed for a stand-in operator running a QR-only show. Server-side, `POST /sing/submit` narrows the source allowlist to `{local, divebar, kn}` (400 `simple_mode_disabled_source` on `youtube`/`make`/`kj_pick`) — defence-in-depth against stale singer PWAs. The flag rides on `GET /status` (2s heartbeat poll) so the KJ UI applies `<body class="simple-mode">` automatically; the same flag ships on `GET /sing/` template context (`data-simple-mode` on `#sing-root`) and `GET /sing/search` responses for the singer SPA. KJ-side CSS hides the right column (KN/YT/Divebar/Upload/Songs/Browser), the rotation manual-add controls, the overlay panel, and every System subsection except the Mode toggle itself. Singer SPA suppresses the empty-state triage cards (paste YouTube / ask KJ / DIY-via-gen) — replaced by a single "We don't have that one. Talk to the KJ." message — and removes the `kj_pick` deferral on multi-version songs (the singer must pick a specific version). Toggle UI is a switch in `templates/index.html`'s System → Mode subsection, wired to `toggleSimpleMode()` in `app.js` which POSTs to the existing `/rotation/requests/config` endpoint.

Expand Down
14 changes: 14 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

Device configuration changes. For Pi details, see [archive/NOMADPI-DETAILS.md](archive/NOMADPI-DETAILS.md). For mini PC setup, see [MINIPC-SETUP.md](MINIPC-SETUP.md).

## 2026-11-06 - KJ: add lyrics to a quick version gen made without any (v0.150.0)

Live show, 2026-11-06 01:24: Sombr "My Body Isn't Ready (Orchestral Version Live From Sound City)" played
with audio but nothing on screen. gen's quick version had found no lyrics (its LRCLIB lookup uses the exact
decorated title), so the draft only said "(lyrics unavailable)" (`lyrics_tier = none`). LRCLIB has synced
lyrics for the plain title.

- Rotation rows with a lyric-less quick version show **⚠ NO LYRICS**. Now Playing shows **⚠ No lyrics, add them**
while that file plays.
- The modal auto-searches LRCLIB with the cleaned title, flags whether each synced result's length matches the
video, and accepts pasted plain text or LRC.
- The box re-renders only the video track (fastgen crawl, audio copied) in place in a few seconds, then offers to
play the song again.
- New: `kj-controller/quick_lyrics.py`, `GET /rotation/quick-lyrics/search`, `POST /rotation/quick-lyrics`.
## 2026-11-06 - Make-it keeps the title the singer chose, and title and audio always match (v0.149.3)

**Deploy:** frontend + test `.py` change, so auto-deploy restarts kj-controller (playback continues) and
Expand Down
2 changes: 1 addition & 1 deletion kj-controller/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "kj-controller"
version = "0.149.3"
version = "0.150.0"
description = "Web-based karaoke show management with mpv + VLC playback"
requires-python = ">=3.11"

Expand Down
264 changes: 264 additions & 0 deletions kj-controller/quick_lyrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
"""Add lyrics to a quick (draft) version that gen rendered without any.

gen's quick version (see GenPoller) looks lyrics up on LRCLIB by the job's exact
artist/title. When that misses — e.g. "My Body Isn't Ready (Orchestral Version
Live From Sound City)" — gen still renders the video, with a single
"(lyrics unavailable)" line, and the box records ``lyrics_tier = "none"``. The
singer then gets backing audio with nothing on screen.

This module lets the KJ fix that from the KJ UI in seconds:

* ``search_lrclib`` — a looser LRCLIB search (cleaned title, free-text query).
* ``parse_lyrics`` — pasted text, auto-detecting LRC ``[mm:ss.xx]`` timing.
* ``rerender_quick_video`` — re-renders ONLY the video track (the scrolling
crawl, same look as gen's) and stream-copies the existing audio, so the
instrumental + vocal-guide mix is untouched and no separation is needed. The
file is replaced in place (same path, same media id), so the rotation entry,
QUICK label and ``gen_quick_versions`` row stay linked.

The crawl layout/scroll code is fastgen's (``../fastgen/fastgen.py``, the POC
gen's quick-version renderer was ported from), loaded by path so there's one
copy of it on the box.
"""

from __future__ import annotations

import importlib.util
import logging
import os
import re
import subprocess
import sys
import tempfile
import threading

import requests

logger = logging.getLogger(__name__)

LRCLIB_BASE = "https://lrclib.net/api"
USER_AGENT = "nomadkaraoke-kjbox/1.0 (https://nomadkaraoke.com)"
SEARCH_TIMEOUT = 10
MAX_RESULTS = 15
MAX_LYRICS_CHARS = 20000

# Same look as gen's quick version (backend/services/quick_version/renderer.py).
HEIGHT = 480
FPS = 24
WRAP = 34
READING_FRAC = 0.42
GAP_THRESHOLD = 5.0
FFMPEG_TIMEOUT = 300

FONT_CANDIDATES = (
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", # NomadPC / Pi
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"/System/Library/Fonts/Supplemental/Arial Bold.ttf", # macOS dev
)

_FASTGEN_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"fastgen", "fastgen.py")
_fastgen = None
_render_lock = threading.Lock()

# Trailing "(...)" / "[...]" groups and " - Live at ..." style suffixes that
# stop LRCLIB matching the studio lyrics.
_TRAILING_GROUP_RE = re.compile(r"\s*[\(\[][^\(\)\[\]]*[\)\]]\s*$")
_DASH_SUFFIX_RE = re.compile(
r"\s+-\s+.*\b(live|version|remaster(ed)?|mix|edit|acoustic|orchestral|session|demo|mono|stereo)\b.*$",
re.IGNORECASE)


class RenderBusy(RuntimeError):
"""Another re-render is already running on the box."""


def fastgen():
"""The fastgen module (loaded once, by path)."""
global _fastgen
if _fastgen is None:
spec = importlib.util.spec_from_file_location("kjbox_fastgen", _FASTGEN_PATH)
mod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mod # @dataclass looks its module up here
spec.loader.exec_module(mod)
_fastgen = mod
return _fastgen


def clean_title(title):
"""Strip version/live/remaster decorations: 'Song (Live From X) [HD]' → 'Song'."""
t = (title or "").strip()
while True:
stripped = _TRAILING_GROUP_RE.sub("", t)
if stripped == t or not stripped.strip():
break
t = stripped
t = _DASH_SUFFIX_RE.sub("", t)
return t.strip() or (title or "").strip()


def artist_title_from_path(path):
"""('Sombr', "My Body Isn't Ready (...)") from 'Sombr - My Body ... [up-x].mp4'.

Quick versions are imported as "<gen artist> - <gen title>", so the file
name is the most reliable source (rotation entries hold one free-text song
field)."""
from naming import strip_media_id_token
stem = os.path.splitext(strip_media_id_token(path))[0]
stem = re.sub(r"\s*\(Quick Version\)$", "", stem, flags=re.IGNORECASE).strip()
if " - " in stem:
artist, title = stem.split(" - ", 1)
return artist.strip(), title.strip()
return "", stem


def default_query(artist, title):
return " ".join(p for p in ((artist or "").strip(), clean_title(title)) if p)


def _hit(h):
synced = (h.get("syncedLyrics") or "").strip()
plain = (h.get("plainLyrics") or "").strip()
if not synced and not plain:
return None
return {
"id": h.get("id"),
"artist": h.get("artistName") or "",
"title": h.get("trackName") or "",
"album": h.get("albumName") or "",
"duration": h.get("duration"),
"synced": bool(synced),
"lyrics": synced or plain,
"preview": " / ".join([ln for ln in (plain or fastgen()._strip_lrc_timestamps(synced)).splitlines()
if ln.strip()][:2]),
}


def search_lrclib(query, session=None, timeout=SEARCH_TIMEOUT):
"""LRCLIB free-text search → result dicts (synced first, deduped, with lyrics only).

Raises ``requests.RequestException`` if LRCLIB can't be reached."""
query = (query or "").strip()
if not query:
return []
s = session or requests.Session()
s.headers.update({"User-Agent": USER_AGENT})
r = s.get(f"{LRCLIB_BASE}/search", params={"q": query}, timeout=timeout)
r.raise_for_status()
out, seen = [], set()
for h in r.json() or []:
hit = _hit(h) if isinstance(h, dict) else None
if not hit:
continue
key = hit["id"] if hit["id"] is not None else (hit["artist"], hit["title"], hit["lyrics"][:200])
if key in seen:
continue
seen.add(key)
out.append(hit)
out.sort(key=lambda x: 0 if x["synced"] else 1) # stable: keeps LRCLIB's ranking within each group
return out[:MAX_RESULTS]


def parse_lyrics(text):
"""Pasted/selected lyrics → fastgen ``Lyrics``; LRC timestamps → "synced"."""
fg = fastgen()
text = (text or "").replace("\r\n", "\n").strip()
if not text:
raise ValueError("Lyrics are empty")
if len(text) > MAX_LYRICS_CHARS:
raise ValueError("Lyrics are too long")
if fg._LRC_RE.search(text):
timed = fg._parse_synced(text)
plain = fg._strip_lrc_timestamps(text)
if any(t.strip() for _, t in timed):
return fg.Lyrics("synced", plain, timed)
text = plain
if not text.strip():
raise ValueError("Lyrics are empty")
return fg.Lyrics("plain", text)


def has_timing(text):
return bool(fastgen()._LRC_RE.search(text or ""))


def probe_duration(path):
return fastgen().probe_duration(path)


def _font_path():
for p in FONT_CANDIDATES:
if os.path.exists(p):
return p
raise RuntimeError("No bold TrueType font found for the lyrics crawl")


def _ffmpeg_cmd(src, crawl_png, y_expr, out_path, width, duration):
dur = f"{max(duration, 0.1):.3f}"
return [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", f"color=c=black:s={width}x{HEIGHT}:r={FPS}:d={dur}",
"-loop", "1", "-framerate", str(FPS), "-t", dur, "-i", crawl_png,
"-i", src,
"-filter_complex", f"[0:v][1:v]overlay=x=(W-w)/2:y={y_expr}[v]",
"-map", "[v]", "-map", "2:a:0",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "26", "-pix_fmt", "yuv420p",
"-c:a", "copy",
"-movflags", "+faststart",
"-t", dur,
out_path,
]


def rerender_quick_video(path, lyrics, artist, title, use_timing=True):
"""Re-render ``path`` in place with ``lyrics``; returns {lyrics_tier, line_count, duration}.

Only one render runs at a time (raises ``RenderBusy``). The new file is
written next to the old one and swapped in atomically, so a player that has
the old file open keeps playing it undisturbed."""
if not _render_lock.acquire(blocking=False):
raise RenderBusy("Another lyrics re-render is already running")
try:
return _rerender_locked(path, lyrics, artist, title, use_timing)
finally:
_render_lock.release()


def _rerender_locked(path, lyrics, artist, title, use_timing):
fg = fastgen()
if not os.path.isfile(path):
raise FileNotFoundError(path)
duration = probe_duration(path)
if not use_timing and lyrics.kind == "synced":
lyrics = fg.Lyrics("plain", lyrics.plain)
timed_lines, mode = fg.resolve_timed_lines(lyrics, False, None, "", None)
tier = "synced" if mode == "synced" else "constant"
if tier == "synced":
timed_lines = fg.insert_instrumental_gaps(timed_lines, GAP_THRESHOLD, 1)
line_count = sum(1 for t, _ in timed_lines if t.strip())
if not line_count:
raise ValueError("Lyrics are empty")

width = round(HEIGHT * 16 / 9)
width += width % 2
fontsize = max(18, round(HEIGHT * 0.07))
folder = os.path.dirname(path) or "."
with tempfile.TemporaryDirectory(prefix=".quick_lyrics_") as workdir:
lines = fg.build_visual_lines(artist or "", title or "", timed_lines, WRAP)
crawl_png, img_h, centers = fg.render_crawl_png(lines, width, fontsize, _font_path(), workdir)
y_expr, _ = fg.build_scroll_y_expr(lines, centers, duration, HEIGHT, img_h, READING_FRAC)
fd, staging = tempfile.mkstemp(prefix=".quick_lyrics_", suffix=".mp4", dir=folder)
os.close(fd)
try:
proc = subprocess.run(_ffmpeg_cmd(path, crawl_png, y_expr, staging, width, duration),
capture_output=True, text=True, timeout=FFMPEG_TIMEOUT)
if proc.returncode != 0:
raise RuntimeError(f"ffmpeg failed ({proc.returncode}): {proc.stderr[-1500:]}")
os.chmod(staging, os.stat(path).st_mode & 0o777)
os.replace(staging, path)
except BaseException:
if os.path.exists(staging):
os.remove(staging)
raise
logger.info("Quick version re-rendered with %s lyrics (%d lines): %s", tier, line_count, path)
return {"lyrics_tier": tier, "line_count": line_count, "duration": round(duration, 1)}
Loading
Loading