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.
322 lines
20 KiB
JavaScript
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');
|