// Round thirty: the tail of the port. Two commands the phone never called // and honestly can — lyrics_vocal_activity, so the tap-to-sync room can draw // the measured vocal band and lay a rough set of times across it through the // desktop's own generator; and catalog_artist_hero_seed + catalog_artist_ // overview, the two fast lanes a cold artist page was waiting on one call // for. Neither needs a line of Rust or a new api.js wrapper. The rest of the // audit's tail is deliberate — the private layer, the tag-import pipeline, // hover previews, the external runtimes, the desktop window set, the updater // and the hero/OCR tooling — so ANDROID.md's "Not done yet" is rewritten to // say so, and loses a bullet describing the artwork viewer, which has been // built and walked for rounds. const fs = require('fs'); const root = process.env.SS_ROOT || 'F:/Coding/SoulStream/'; const scratch = 'C:/Users/Kyle/AppData/Local/Temp/claude/F--Coding-SoulStream/5488514a-11e8-482a-b0a0-20cb511ee110/scratchpad/'; const ins = (name) => fs.readFileSync(scratch + name, 'utf8').replace(/\s+$/, ''); let p, s; const load = (file) => { p = file; s = fs.readFileSync(root + p, 'utf8'); }; const save = () => fs.writeFileSync(root + p, s); const once = (a) => { const i = s.indexOf(a); if (i < 0) throw new Error('missing in ' + p + ': ' + a.slice(0, 90)); if (s.indexOf(a, i + 1) >= 0) throw new Error('ambiguous in ' + p + ': ' + a.slice(0, 90)); return i; }; const rep = (a, b) => { once(a); s = s.replace(a, () => b); }; const NL = String.fromCharCode(10); /* Several anchors below are re-emitted (the sync-room expectations, the mock's catalog_artist block), so a second run would double-insert where `once` cannot see it and only throw further down, leaving half a tree. Refuse one before anything is written. */ load('src/mobile/catalog.svelte.js'); if (s.includes('heroSeeds')) { throw new Error('src/mobile/catalog.svelte.js already has heroSeeds — round 30 has already run against ' + root); } // ── the catalog store: the two fast lanes ────────────────────────────── rep(` /** artist id → { status, tracks } — Popular on its own, ahead of the page */ popular: {},`, ` /** artist id → { status, tracks } — Popular on its own, ahead of the page */ popular: {}, /** artist id → { status, banner, custom } — the banner alone, ahead of * the page: a hero Kyle picked on this phone comes back from the * override with no network at all */ heroSeeds: {}, /** artist id → { status, artist, albums } — the desktop's one-round * content stage, so the discography can paint while the exhaustive * classification pass is still running */ overviews: {},`); rep(` api.catalogArtistPopular(id).then( (tracks) => { catalog.popular[id] = { status: "ready", tracks: tracks ?? [] }; }, () => { catalog.popular[id] = { status: "error", tracks: [] }; }, ); }`, ` api.catalogArtistPopular(id).then( (tracks) => { catalog.popular[id] = { status: "ready", tracks: tracks ?? [] }; }, () => { catalog.popular[id] = { status: "error", tracks: [] }; }, ); } ` + ins('ins-r30-catalog-lanes.txt')); // a hero that has just changed must not be answered from the banner the LAST // visit measured — the lanes are the page's, and they go with it rep(`export function forgetArtist(name) { const k = artistKey(name); const cur = catalog.artists[k]; if (!cur) return; delete catalog.artists[k];`, `export function forgetArtist(name) { const k = artistKey(name); const cur = catalog.artists[k]; if (!cur) return; delete catalog.artists[k]; /* the fast lanes belong to the page, not to the name: a new hero or a cleared one must not come back from what the last visit measured */ if (cur.id != null) { delete catalog.heroSeeds[cur.id]; delete catalog.overviews[cur.id]; }`); save(); // ── the lyrics store: the envelope, and the rough pass over it ───────── load('src/mobile/lyrics.svelte.js'); rep(`import { openTiming, closeTiming } from "./nav.svelte.js";`, `import { openTiming, closeTiming } from "./nav.svelte.js"; import { activeSegments, distributeLines } from "../lib/lyric-sync.js";`); rep(` saving: false, error: "", });`, ` saving: false, error: "", /* the envelope is the desktop's, measured per track on open and thrown away with the room; \`estimated\` says the times came from it rather than from a thumb, and every label in the room reads it */ activity: null, // { frameMs, frames } — the measured vocal envelope reading: "idle", // idle | reading | ready | none estimated: false, });`); rep(` if (tapSync.identity?.key === id.key && tapSync.rows.some((r) => r.time_ms != null)) { openTiming(); return; }`, ` if (tapSync.identity?.key === id.key && tapSync.rows.some((r) => r.time_ms != null)) { /* the envelope went with the last close, or never landed — re-arm it rather than leaving the resumed room with an empty strip */ if (!tapSync.activity && tapSync.reading !== "reading") void loadActivity(id); openTiming(); return; }`); rep(` tapSync.rows = texts.map((text) => ({ time_ms: null, text })); tapSync.cursor = 0; tapSync.identity = id; tapSync.saving = false; tapSync.error = ""; openTiming(); }`, ` tapSync.rows = texts.map((text) => ({ time_ms: null, text })); tapSync.cursor = 0; tapSync.identity = id; tapSync.saving = false; tapSync.error = ""; tapSync.activity = null; tapSync.reading = "idle"; tapSync.estimated = false; openTiming(); void loadActivity(id); }`); rep(`export function closeTapSync() { closeTiming(); tapSync.rows = []; tapSync.cursor = 0; tapSync.identity = null; tapSync.saving = false; tapSync.error = ""; }`, `export function closeTapSync() { closeTiming(); tapSync.rows = []; tapSync.cursor = 0; tapSync.identity = null; tapSync.saving = false; tapSync.error = ""; tapSync.activity = null; tapSync.reading = "idle"; tapSync.estimated = false; }`); s = s.replace(/\s*$/, NL) + NL + ins('ins-r30-lyr-store.txt') + NL; save(); // ── the room: the strip, and the one button that fills and empties it ── load('src/mobile/SyncRoom.svelte'); rep(`{ tapSync, saveTapSync, closeTapSync }`, `{ tapSync, saveTapSync, closeTapSync, estimateTimings, clearEstimate }`); rep(` document; lines never tapped are left out of it, and the button says how many are kept. */`, ` document; lines never tapped are left out of it, and the button says how many are kept. The strip under the head is the recording's own measured vocal band, and the button under it lays every line across the stretches it says are sung — the desktop's generator (src/lib/lyric-sync.js), never an even spread, and never presented as finished timing: the estimate is labelled one wherever it shows, and one more tap drops it. */`); rep(` function stamp() {`, ins('ins-r30-syncroom-script.txt') + NL + NL + ` function stamp() {`); // an estimate is corrected line by line from the top, and the labels have to // stop calling it one when the last of them has been tapped rep(` tapSync.rows[at].time_ms = nowMs(); tapSync.cursor = at + 1; confirmDiscard = false;`, ` tapSync.rows[at].time_ms = nowMs(); tapSync.cursor = at + 1; /* the cursor only ever walks forward from where the estimate put it, so reaching the end means every line has been tapped over — there is none of the estimate left, and nothing may still call it one */ if (tapSync.estimated && tapSync.cursor >= rows.length) tapSync.estimated = false; confirmDiscard = false;`); rep(`