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
21 changes: 21 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,27 @@

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 - 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
`sing.js?v=` / `make.js?v=` bust the cache.

Seen live 2026-11-05: a singer typed "Cole Phillips – Roll of Dice", a 2025 song that isn't in any of our data. gen's
Gemini fallback "corrected" it to "Roll of the Dice". The singer tapped "keep what I typed", but the make wizard ran
gen's AI judge again and put the "the" back. gen job `f01c9997` got the wrong title on the right audio (Spotify
"Roll of Dice", score 1.0).

- **Keep what I typed is trusted** (`sing.js` `makeIsKeptTyped`): when the singer turned the correction down and the
form still holds what they typed, the wizard starts `trusted`. Only the formatting tidy runs, not the AI judge.
- **A different-song correction never lands on the old song's audio** (`make.js` `applyFullVerdict`): with good
audio results for the typed title (tier 1–2), a confident `content` verdict is only offered ("Did you mean…?").
Before, it was applied without a new search. With weak results (tier 3) it is applied and the audio is searched
again, as before.
- **Undo of a content correction searches again** for the song now shown (`toggleCorrection`). A formatting tidy is
the same song, so it keeps its results.
- gen side (v0.294.0) and decide (v0.37.0): a live Spotify search confirms songs our catalogue doesn't have, and an AI
correction nothing can confirm is never `confident`. See `SONG-IDENTIFICATION.md` D14.

## 2026-11-06 - Singer page: pipeline translations for the five strings added on 2026-11-05 (v0.149.2)

The strings added mid-show (`identity.switchShort`, `identity.switchConfirm`, `mySongs.phoneCode`,
Expand Down
1 change: 1 addition & 0 deletions docs/SONG-IDENTIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ there, keep it on disk (SQLite), not in memory.
| D11 | **Persistent search log** (`search_log.py` → `search_log.db` on the device): searches, identifications, Gemini answers and every singer choice, tied by a per-search id. Review with `scripts/search_log_report.py`. | Andrew (2026-09-28): collect "what options singers actually choose after the search/auto-correction results land, so after a few live events we can review the results and identify any issues or edge cases". The report flags undone tidies, "not it?", lower candidates picked, edited make-it pre-fills, and make-it/YouTube requests with no identification. |
| D12 | **ListenBrainz** popularity (per-recording listen counts, fortnightly dumps, imported by karaoke-decide's `lb-refresh`) is the fresh popularity signal. **Shipped 2026-09-29:** each stats range is mapped onto Spotify's 0–100 as `offset + 19·log10(listeners)` (offsets: all_time 8, this_year 11, year/half_yearly 12, month 14, this_month 20; artists `2 / 6 + 17·log10`), fitted to the median Spotify popularity of songs with both. A song takes the max over its recordings and ranges; ranges under 3 listeners are ignored. | MusicBrainz has no popularity, and the Spotify snapshot is frozen at July 2025. Per-range offsets mean a release scored on this month's listeners ranks like an older hit with the same share of listeners. Calibration is data-driven, so re-fit if ListenBrainz's user base changes a lot (queries in the 2026-09-29 session record). |
| D13 | **"No karaoke version yet" goes up top, never at the bottom of the list** (v0.130.0). When identification is confident (including `kind: "same"`, which is now kept as a correction instead of dropped) and no karaoke row is that song, the song card becomes "🎵 Title — Artist · No karaoke version yet · Generate it on demand →" (one tap into the make wizard, pre-filled with the identified song). The other rows go under "Other songs with similar names", capped at 3 with "Show N more". If identification isn't confident (`none`, "which one?", or the singer kept their own typing), a slim sticky bar ("Not seeing your song? Generate it") stays above the tab bar; it is never shown for a confident identification. **v0.146.1:** nor when a row's title is exactly what the singer typed, with or without its artist ("love story" over 24 versions of Taylor Swift's Love Story). A picked "Which song?" candidate keeps the index's `karaoke` flag, so the veto above applies to it too. Make-requests off / Simple Mode / change-song → the card says to ask the host, with no bar. Logged as `make_offer_shown` / `make_offer_tap` with `where` = `top_card`, `sticky_bar`, `bottom_link` or `empty_state` (the empty-search make card, since v0.141.0). **v0.142.0:** the card also shows when there are **no** karaoke rows at all (the `karaoke: true` veto only applies when there are rows to mismatch). It replaces the empty-state triage, which moves behind "Other options". Its button opens the one-screen make-it: song, one-tap Google/Apple sign-in or emailed code, audio picked automatically for tier 1/2 matches, and a single "Make it & add me to the list". | Andrew (2026-10-09): singers whose song matched but had no karaoke version had to scroll past a long list of fuzzy matches to find the make-it link at the bottom, and "most singers never find the button to generate on demand". |
| D14 | **Live Spotify before Gemini, and every AI correction grounded** (2026-11-06; gen v0.294.0, decide v0.37.0, kjbox v0.149.3). When the on-device matcher and gen's BigQuery catalogue don't know a song, gen asks **live Spotify**: decide's partner route `GET /api/partner/gen/spotify-search`, a client-credentials app token, cached, with a 429 circuit breaker. An exact hit answers with no Gemini call. Otherwise Gemini gets the Spotify results as candidates. A confident AI `cosmetic`/`content` answer must name a song the catalogue or Spotify has, or it comes back `confident: false` (offered, never applied). In the make wizard, "keep what I typed" starts the wizard `trusted`, and a content correction is applied only on weak audio (tier 3) with a new audio search. | Live 2026-11-05: "Cole Phillips – Roll of Dice" (2025, not in the July 2025 Spotify snapshot, MusicBrainz or KaraokeNerds). Gemini, given only the raw query, "corrected" it to the familiar phrase "Roll of the Dice". The catalogue check found nothing and the guess shipped as confident, twice, including after the singer rejected it. The LLM is weakest exactly where our data is thinnest (new releases, small artists), so silence from the catalogue must not count as support. The Spotify quota is per developer account and possibly shared with flacfetch's searches: keep volume low (fallback only, cached), never hit Spotify from the client, and never use flacfetch's Premium OAuth account. |

## 5. Target UX

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.2"
version = "0.149.3"
description = "Web-based karaoke show management with mpv + VLC playback"
requires-python = ">=3.11"

Expand Down
14 changes: 13 additions & 1 deletion kj-controller/static-sing/make.js
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,15 @@ export function createMakeFlow(deps) {
const s = m();
s.verdict = v;
if (v.kind === "cosmetic" && v.confident) applyCorrection(v.canonical_artist, v.canonical_title, false);
else if (v.kind === "content" && v.confident) applyCorrection(v.canonical_artist, v.canonical_title, tier >= WEAK_TIER);
else if (v.kind === "content" && v.confident) {
// A different song. Only weak audio results (tier 3) back the typo theory, and then the
// audio is searched again for the corrected song. Good results for what was typed mean
// the typed song exists, so the correction is only offered. Live 2026-11-05: Spotify had
// "Cole Phillips — Roll of Dice" at score 1.0, the AI said "Roll of the Dice", and the job
// got the wrong title on the right audio (applied without a new search).
if (tier >= WEAK_TIER) applyCorrection(v.canonical_artist, v.canonical_title, true);
else offerVerdict(v);
}
}

// A correction shown as "Did you mean…?" instead of applied.
Expand Down Expand Up @@ -535,6 +543,10 @@ export function createMakeFlow(deps) {
} else {
s.artist = s.verdict.canonical_artist; s.title = s.verdict.canonical_title; s.correctionActive = true;
}
// A content correction names a different song, so its audio results are for that song.
// Search again for the song now shown (no judge: the singer chose it), so the title and the
// audio can never disagree. A formatting tidy is the same song: its results still apply.
if (s.verdict.kind === "content") return beginSearch({ judge: false });
rerender();
}

Expand Down
11 changes: 10 additions & 1 deletion kj-controller/static-sing/sing.js
Original file line number Diff line number Diff line change
Expand Up @@ -2612,7 +2612,7 @@ function renderSearch() {
edited: (state.makeArtist || "") !== (pre.artist || "") || (state.makeTitle || "") !== (pre.title || ""),
});
endSearchSession();
return makeFlow.start(state.makeArtist, state.makeTitle, { trusted: makeIsIdentified() });
return makeFlow.start(state.makeArtist, state.makeTitle, { trusted: makeIsIdentified() || makeIsKeptTyped() });
};

// The make form still holds exactly the song we identified on the device
Expand All @@ -2623,6 +2623,15 @@ function renderSearch() {
return (state.makeArtist || "") === c.corrected.artist && (state.makeTitle || "") === c.corrected.title;
}

// The singer turned a correction down ("keep what I typed") and the form still holds what
// they typed: the AI judge must not bring the same correction back in the make wizard.
// Live 2026-11-05: "Roll of dice" was kept, and the wizard's judge put "the" back again.
function makeIsKeptTyped() {
const c = correction;
if (!c || !c.corrected || c.active || !c.userTouched || !c.split) return false;
return (state.makeArtist || "") === c.split.artist && (state.makeTitle || "") === c.split.title;
}

const pickYouTube = (url) => {
state.selected = {
source_type: "youtube",
Expand Down
80 changes: 80 additions & 0 deletions kj-controller/tests/e2e/test_sing_make_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -637,3 +637,83 @@ def test_x_closes_it(self, page, live_server, live_token):
note = self._send(page, live_server, live_token)
note.locator(".sent-note-close").click()
expect(note).to_have_count(0)


# --- 2026-11-05 live: "Cole Phillips – Roll of Dice" was made as "Roll of the Dice" ---------

COLE_SPOTIFY = {"index": 0, "provider": "Spotify", "title": "Roll of Dice", "artist": "Cole Phillips",
"is_lossless": False, "year": 2025, "target_file": "Roll of Dice"}
AI_THE_DICE = {"kind": "content", "confident": True, "engine": "ai", "needs_ai": False,
"canonical_artist": "Cole Phillips", "canonical_title": "Roll of the Dice", "alternatives": []}
NEEDS_AI = {"kind": "none", "confident": False, "needs_ai": True, "engine": "catalog"}


class TestContentCorrectionNeverSplitsTitleFromAudio:
def test_strong_results_for_the_typed_song_only_offer_the_correction(self, page, live_server, live_token):
# radiohed/creep typed; the default results match it (tier 1-2), and the AI names another song.
calls = _open_wizard(page, live_server, live_token, email="m@x.co",
fast=NEEDS_AI, full={**AI_THE_DICE, "canonical_artist": "Radiohead",
"canonical_title": "Creep (Acoustic)"})
expect(page.locator('[data-testid="make-didyoumean"]')).to_contain_text("Creep (Acoustic) — Radiohead")
expect(page.locator(".mk-song-title")).to_have_text("creep") # what was typed stays
expect(page.locator('[data-testid="make-correction"]')).to_have_count(0)
assert [c["title"] for c in calls["search"]] == ["creep"] # no second search
# Taking the suggestion searches again for that song.
page.locator('[data-testid="make-didyoumean"] .mk-suggestion').first.click()
expect(page.locator(".mk-song-title")).to_have_text("Creep (Acoustic)")
assert calls["search"][-1]["title"] == "Creep (Acoustic)"

def test_weak_results_apply_the_correction_and_search_again(self, page, live_server, live_token):
calls = _open_wizard(page, live_server, live_token, email="m@x.co", results=[YOUTUBE],
fast=NEEDS_AI, full={**AI_THE_DICE, "canonical_artist": "Radiohead",
"canonical_title": "Creep"})
expect(page.locator('[data-testid="make-correction"]')).to_contain_text("Corrected to Creep — Radiohead")
assert calls["search"][-1] == {**calls["search"][-1], "artist": "Radiohead", "title": "Creep"}
n = len(calls["search"])
# Undo a content correction: back to the typed song, and its own audio search.
page.locator(".mk-undo").click()
expect(page.locator(".mk-song-artist")).to_have_text("radiohed")
expect(page.locator('[data-testid="make-pick"], [data-testid="make-fallback"]').first).to_be_visible()
assert len(calls["search"]) == n + 1 and calls["search"][-1]["artist"] == "radiohed"
assert [c["stage"] for c in calls["check"]].count("full") == 1 # no judge on the re-search


class TestKeepWhatITypedIsTrusted:
def test_kept_typed_song_skips_the_ai_judge_in_the_wizard(self, page, live_server, live_token):
page.add_init_script("window.__SING_ARM_MS = 0;")
_login(page, live_server, live_token)
calls = {"check": [], "search": []}

def check(route):
body = json.loads(route.request.post_data or "{}")
calls["check"].append(body)
_json(route, AI_THE_DICE if body.get("stage") == "full" else NEEDS_AI)

page.route("**/sing/search/identify*", lambda r: _json(r, {"status": "none", "candidates": []}))
page.route("**/sing/search?*", lambda r: _json(r, {"songs": [], "make_requests_enabled": True,
"simple_mode": False}))
page.route("**/sing/search/resolve*", lambda r: _json(r, {
"corrected": {"artist": "Cole Phillips", "title": "Roll of the Dice"}, "kind": "content",
"typed": "Roll of dice Cole Phillips", "songs": [],
"split": {"artist": "Cole Phillips", "title": "Roll of dice"}}))
page.route("**/sing/make/account*", lambda r: _json(r, {"ready": True, "email": "m@x.co"}))
page.route("**/sing/make/check*", check)
page.route("**/sing/make/search*", lambda r: (calls["search"].append(json.loads(r.request.post_data)),
_json(r, {"search_session_id": "ss-1",
"results": [COLE_SPOTIFY]})))
page.evaluate("""
window.__sing_state.makeRequestsEnabled = true;
window.__sing_state.step = 'search';
window.__sing_render();
""")
page.locator('input[type="search"]').fill("Roll of dice Cole Phillips")
expect(page.locator('[data-testid="song-missing"]')).to_contain_text("Roll of the Dice")
page.locator('[data-testid="song-missing"] [data-testid="search-correction-toggle"]').click()
page.locator('[data-testid="make-card"] button').first.click()
expect(page.locator('[data-testid="make-pick"]')).to_be_visible()
expect(page.locator('[data-testid="make-submit"]')).to_be_enabled()
expect(page.locator(".mk-song-title")).to_have_text("Roll of dice")
assert [c["stage"] for c in calls["check"]] == ["fast"] # no AI judge
assert calls["search"][0]["title"] == "Roll of dice"
expect(page.locator('[data-testid="make-correction"]')).to_have_count(0)
expect(page.locator('[data-testid="make-didyoumean"]')).to_have_count(0)
Loading