Files
SoulStream/handoff/rounds/patch-round30.cjs
T
KeelrLeek 9f0601027c android: the handoff — the pending rounds, the pipeline, and what is unverified
Everything written this session that has not been through the device: rounds 43
to 48 (each patch adversarially verified, 43's transient-notice expectation
fixed), a partial round 49, the pipeline scripts, the Android Auto design and
the icon comparison mock. Also the two harness changes that were never A/B'd —
adaptive waits, which may only shorten a declared wait and never lengthen it,
and a ship that runs the mock walk and the deploy at the same time. The A/B and
its 255-step baseline are in handoff/tools; main stays at what the phone has
actually walked.
2026-09-14 07:57:41 -05:00

322 lines
20 KiB
JavaScript

// 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(` <button class="sy-pad"`, ins('ins-r30-syncroom-markup.txt') + NL + ` <button class="sy-pad"`);
rep(` <span class="sy-hint">Tap anywhere as the line begins</span>`,
` <span class="sy-hint">{tapSync.estimated ? "Estimated — tap to correct each line" : "Tap anywhere as the line begins"}</span>`);
// a tap simply overwrites its own row, so the set stays an estimate until
// the last of them has been corrected — which is what the label says
rep(`: "Save timing")}`, `: (tapSync.estimated && done === rows.length ? "Save the estimate" : "Save timing"))}`);
save();
// ── the strip's own rules, between the warning and the pad ─────────────
load('src/mobile/shell.css');
rep(`.sy-pad { flex: 1;`, ins('ins-r30-css.txt') + NL + `.sy-pad { flex: 1;`);
save();
// ── the artist page: three lanes off the same id ───────────────────────
load('src/mobile/screens/Artist.svelte');
rep(`loadExtras, loadPopular, picture } from "../catalog.svelte.js";`,
`loadExtras, loadPopular, loadHeroSeed, loadOverview, picture } from "../catalog.svelte.js";`);
rep(` const shownName = $derived(page?.artist?.name || seed?.name || name);`,
` /* the two fast lanes the page does not wait for: the banner on its own
(a hero chosen on this phone is read off the file, so it is up before
any network answers) and the one-round content stage. Both sit strictly
BELOW the page everywhere they are read — what the exhaustive pass
returns always wins. */
const heroSeed = $derived(artistId != null ? catalog.heroSeeds[artistId]?.banner ?? null : null);
const overview = $derived(artistId != null ? catalog.overviews[artistId] ?? null : null);
const shownName = $derived(page?.artist?.name || overview?.artist?.name || seed?.name || name);`);
rep(` const heroUrl = $derived(page?.banner || picture(page?.artist?.picture) || picture(entry?.seed?.picture) || picture(seed?.picture) || null);`,
` const heroUrl = $derived(page?.banner || heroSeed || picture(page?.artist?.picture) || picture(entry?.seed?.picture) || picture(seed?.picture) || null);`);
rep(` const discoAll = $derived(page?.albums ?? []);`,
` /* the overview's raw first page holds the discography up until the full
page lands with the classified one — never the other way round: the
page's list is a strict upgrade, folded and filed */
const discoAll = $derived(page?.albums ?? overview?.albums ?? []);`);
rep(` $effect(() => { const id = artistId; if (id != null) untrack(() => loadPopular(id)); });`,
` $effect(() => { const id = artistId; if (id != null) untrack(() => { loadPopular(id); loadHeroSeed(id); loadOverview(id); }); });`);
rep(` /* Popular's own lane, asked the moment an id is known — from the
search's own row, or from the identity search — so the section can
paint before the full page is built */`,
` /* three lanes off the same id, all of them ahead of the page: Popular's
ranked rows, the banner on its own, and the first page of releases.
Asked the moment an id is known — from the search's own row, or from
the identity search — so the page can paint before it is built */`);
rep(`{#if page?.artist?.fans || seed?.fans}<span class="s"><Icon name="users" size={12} strokeWidth={3} />{fans(page?.artist?.fans || seed?.fans)}</span>{/if}`,
`{#if page?.artist?.fans || overview?.artist?.fans || seed?.fans}<span class="s"><Icon name="users" size={12} strokeWidth={3} />{fans(page?.artist?.fans || overview?.artist?.fans || seed?.fans)}</span>{/if}`);
save();
// ── the probe handle needs the catalog store ──────────────────────────
load('src/mobile/Mobile.svelte');
rep(`player, lib, art, slsk,`, `player, lib, art, catalog, slsk,`);
save();
// ── the mocks ─────────────────────────────────────────────────────────
load('src/lib/mockLibrary.js');
rep(` if (cmd === "lyrics_clear") return Promise.resolve();`,
ins('ins-r30-mock-activity.txt') + NL + ` if (cmd === "lyrics_clear") return Promise.resolve();`);
rep(`const RELATED = [["Justice", 411], ["Air", 412], ["Kavinsky", 413], ["Chemical Brothers", 414]].map(([name, id], i) => ({ id, name, picture: i % 2 ? null : svgArt(40 + id, name[0]), album_count: 3, fans: 900000 - id * 100 }));`,
`const RELATED = [["Justice", 411], ["Air", 412], ["Kavinsky", 413], ["Chemical Brothers", 414]].map(([name, id], i) => ({ id, name, picture: i % 2 ? null : svgArt(40 + id, name[0]), album_count: 3, fans: 900000 - id * 100 }));
` + ins('ins-r30-mock-slow.txt'));
rep(` const a = id === CAT_ARTIST.id ? CAT_ARTIST : RELATED.find((r) => r.id === id);
if (!a) return Promise.reject(new Error("catalog_artist: unknown artist"));
const own = a === CAT_ARTIST;
return new Promise((r) => setTimeout(() => r({
artist: a, banner: heroes[a.id] ?? null, banners: heroes[a.id] ? [heroes[a.id]] : [], banner_custom: !!heroes[a.id], logos: [],
popular_tracks: own ? CAT_TRACKS.slice(0, 7).map((t, i) => ({ ...t, listeners: 2_400_000 - i * 310_000 })) : [],
albums: own ? CAT_DISCO : [{ id: 900 + id, title: a.name + " Live", artist: a.name, cover: null, release_date: "2015-01-01", record_type: "album", track_count: 9, edition_group: null, is_soundtrack: false }],
top_genre: "Electronic", socials: [],
}), 220));`,
` const a = id === CAT_ARTIST.id ? CAT_ARTIST : id === CAT_SLOW.id ? CAT_SLOW : RELATED.find((r) => r.id === id);
if (!a) return Promise.reject(new Error("catalog_artist: unknown artist"));
const own = a === CAT_ARTIST;
/* the exhaustive page, kept four seconds on purpose: whatever is on
screen before then came from the fast lanes and nowhere else */
const slow = a === CAT_SLOW;
return new Promise((r) => setTimeout(() => r({
artist: a, banner: heroes[a.id] ?? null, banners: heroes[a.id] ? [heroes[a.id]] : [], banner_custom: !!heroes[a.id], logos: [],
popular_tracks: own ? CAT_TRACKS.slice(0, 7).map((t, i) => ({ ...t, listeners: 2_400_000 - i * 310_000 })) : [],
albums: own ? CAT_DISCO : slow ? SLOW_ALBUMS : [{ id: 900 + id, title: a.name + " Live", artist: a.name, cover: null, release_date: "2015-01-01", record_type: "album", track_count: 9, edition_group: null, is_soundtrack: false }],
top_genre: "Electronic", socials: [],
}), slow ? 4000 : 220));`);
rep(` const a = id === CAT_ARTIST.id ? CAT_ARTIST : RELATED.find((r) => r.id === id);
if (!a) return Promise.resolve([]);`,
` const a = id === CAT_ARTIST.id ? CAT_ARTIST : id === CAT_SLOW.id ? CAT_SLOW : RELATED.find((r) => r.id === id);
if (!a) return Promise.resolve([]);`);
rep(` if (cmd === "catalog_art") return Promise.resolve(svgArt(200, "D"));`,
ins('ins-r30-mock-lanes.txt') + NL + ` if (cmd === "catalog_art") return Promise.resolve(svgArt(200, "D"));`);
rep(` const hit = /daft|discovery|one more|punk/.test(q);`,
` const hit = /daft|discovery|one more|punk/.test(q);
/* the slow artist answers the identity search at once — it is only
the exhaustive page that is kept waiting */
if (/halcyon/.test(q)) return new Promise((r) => setTimeout(() => r({ artists: [CAT_SLOW], albums: [], tracks: [] }), 120));`);
save();
// ── the walks ─────────────────────────────────────────────────────────
load('tests/mobile/smoke.mjs');
rep(` contactsLoaded: (function () { try { return __ss.contacts.loaded ? 'yes' : (__ss.contacts.error ? 'err' : 'no'); } catch (e) { return ''; } })(),`,
` contactsLoaded: (function () { try { return __ss.contacts.loaded ? 'yes' : (__ss.contacts.error ? 'err' : 'no'); } catch (e) { return ''; } })(),
` + ins('ins-r30-probe.txt'));
rep(` { do: \`__click('.halfsheet .row', 'Re-time by hand')\`, name: "sync-room", wait: 800 },`,
` { do: \`__click('.halfsheet .row', 'Re-time by hand')\`, name: "sync-room", wait: 800 },
` + ins('ins-r30-mock-steps.txt'));
rep(` { do: \`__click('.halfsheet .row', 'Remove custom hero')\`, name: "artist-hero-cleared", wait: 1400 },`,
` { do: \`__click('.halfsheet .row', 'Remove custom hero')\`, name: "artist-hero-cleared", wait: 1400 },
` + ins('ins-r30-mock-steps-lanes.txt'));
rep(` "sync-room": (i) => i.sync && i.syCount === "0 / 3" && !!i.syNow,`, ins('ins-r30-mock-expect.txt'));
rep(` "artist-hero-cleared": (i) => !i.half.includes("up") && /photos are back/.test(i.notice),`,
` "artist-hero-cleared": (i) => !i.half.includes("up") && /photos are back/.test(i.notice),
` + ins('ins-r30-mock-expect-lanes.txt'));
rep(` { do: \`(function () { var u = document.querySelector('.sy-undo'); if (u) u.click(); })()\`, name: "sync-undone", wait: 700 },`,
` { do: \`(function () { var u = document.querySelector('.sy-undo'); if (u) u.click(); })()\`, name: "sync-undone", wait: 700 },
` + ins('ins-r30-phone-step.txt'));
// the phone's own: the room may not have opened at all (a track with no
// words), but if the button is there it must be in one of its four states,
// and an estimate that ran must have drawn the strip and its ticks
rep(` "artist-cold": (i) => i.screen.includes("has-hero"),`,
` "artist-cold": (i) => i.screen.includes("has-hero"),
"sync-estimated": (i) => !i.toast && (!i.sync
|| (/Clear the estimate/.test(i.syEst)
? i.syTicks > 0 && i.syStrip === 64
: /Estimate the timing|Reading the song|No estimate/.test(i.syEst))),`);
// 4.5 s in, both fast lanes must have ANSWERED on the real core: one still
// "loading", or one that never fired ("-"), is the regression this catches
rep(` artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && i.tabs >= 1,`,
` artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && i.tabs >= 1
&& /^[a-z]+:(ready\\+art|none):(ready|error):\\d+$/.test(i.artFast),`);
save();
// ── the doc ───────────────────────────────────────────────────────────
load('ANDROID.md');
/* the artwork viewer was described nowhere but in "Not done yet", which is
the one place it does not belong — it has been built and walked in both
legs for rounds. The description moves up here before that list is
rewritten without it. */
rep(`transform (the small art flies to the big one). The player's scrubber is the` + NL
+ `lane itself, no knob. Motion is Material 3 Expressive's springs, sampled into` + NL
+ '`linear()`. Reduce motion (You → Appearance, or the OS setting) turns it off.',
`transform (the small art flies to the big one); the big art opens the artwork` + NL
+ `viewer — the cover at full size, pinch to zoom, one finger pans while zoomed,` + NL
+ `a tap on the unzoomed cover or the back gesture closes. The player's scrubber` + NL
+ `is the lane itself, no knob. Motion is Material 3 Expressive's springs,` + NL
+ 'sampled into `linear()`. Reduce motion (You → Appearance, or the OS setting)' + NL
+ 'turns it off.');
rep(`opened it. "Play with reduced vocals" is the desktop's offline DSP`,
`opened it.` + NL + NL + ins('ins-r30-doc-lyrics.txt') + NL + NL + `"Play with reduced vocals" is the desktop's offline DSP`);
rep(`row's identity (\`seed\`), so a namesake can never take the page.`,
`row's identity (\`seed\`), so a namesake can never take the page.` + NL + NL + ins('ins-r30-doc-artist.txt'));
// "Not done yet" becomes the honest map — and loses the artwork-viewer
// bullet, which describes something built, walked in both legs and already
// documented above
s = s.slice(0, once(`## Not done yet`)) + ins('ins-r30-doc-notdone.txt') + NL;
save();
console.log('round 30 wired');