diff --git a/apple/PROGRESS.md b/apple/PROGRESS.md index 2ce313c07e..56b0f5b5de 100644 --- a/apple/PROGRESS.md +++ b/apple/PROGRESS.md @@ -5,7 +5,7 @@ Append a new section on top for each App Store build. --- -## 1.1 (4) — 2026-09-11 (submitted) +## 1.1 (4) — 2026-09-11 (in progress) Builds 1.1 (2) and 1.1 (3) were uploaded in April and May but never attached to an App Store version, and TestFlight let them expire. This build carries @@ -37,55 +37,6 @@ lane instead of by hand. Xcode 15+ needs the platform even for device archives. Fix is `xcodebuild -downloadPlatform iOS` (~8 GB on disk); the build lane now preflights this. -- **Archive host = blueberry** (Xcode 26.6 + iOS 26.5 runtime already installed, - 39 GiB free) — neo would have to download the 8.5 GB platform first. Sync the - lane files with `rsync --relative apple/fastlane apple/PROGRESS.md - apple/aesthetic-computer-Info.plist apple/aesthetic.computer.xcodeproj/project.pbxproj - blueberry:~/aesthetic-computer/`, then `ssh blueberry bash -c "cd - ~/aesthetic-computer/apple && fastlane ios build"` (login shell is fish). First - run died at CodeSign with `errSecInternalComponent` — the ssh session cannot - reach the login keychain; fix is the blueberry recipe: `security - unlock-keychain` + `security set-key-partition-list -S apple-tool:,apple:,codesign: - -s` (interactive, @jeffrey's password), then rerun. - **Unlock state is per audit session**: an unlock in one `ssh -t` does nothing - for the next ssh connection, so unlock and archive in the SAME session: - `ssh -t blueberry 'security unlock-keychain ~/Library/Keychains/login.keychain-db; cd ~/aesthetic-computer/apple && fastlane ios build'`. -- **Export ran on neo, not blueberry.** Blueberry's archive succeeded but its export - produced nothing; the `.xcarchive` was tar-piped to `apple/build/` on neo and - exported with `xcodebuild -exportArchive … -exportOptionsPlist - ExportOptions-AppStore.plist -allowProvisioningUpdates` + the S4TQKG6U99 key. - Signing is a **Cloud Managed Apple Distribution** certificate (SHA1 D25B0254…, - expires 2027-04-23) — cloud-managed, so it never appears in - `security find-identity`, and `/v1/certificates` does not list it either. - Verified in the ipa: aps-environment=production, get-task-allow=false, - ITSAppUsesNonExemptEncryption=false, app + appex both build 4. -- **deliver gotcha:** it validates `./fastlane/metadata` and `./fastlane/screenshots` - on every call even with `skip_metadata`/`skip_screenshots`, and rejects the - `ios/` platform subfolder as an unknown locale — so `metadata_path` and - `screenshots_path` live in `deliver_defaults`, not per lane. -- **Uploaded 2026-09-11 13:04** via `fastlane ios upload` ("Successfully uploaded - package to App Store Connect"). Poll `asc.mjs status --app aestheticcomputer - --platform IOS` until build 4 (dated today) reads VALID, then `fastlane ios ship`. -- **Build 4 VALID on ASC 2026-09-11 10:05 PT** (id `09702f94-172a-46ac-b4bd-9c55cee7910d`, - usesNonExemptEncryption=false from the plist key, minOS 15.0). Waiting on - `fastlane ios ship`. -- **SUBMITTED 2026-09-11 16:00 ET via `fastlane ios ship`** — version 1.1 and - reviewSubmission `3645eb9f-9254-4d77-9f5c-db9210da77e2` both WAITING_FOR_REVIEW. - Three 409s on the way, all fixed in `slab/menuband/bin/asc.mjs`: - (1) `submit` picked the highest build NUMBER across every train — the 2023 - 1.0 train reached build 5, so it tried to attach an EXPIRED build; now filters - `preReleaseVersion.version` + `expired=false`, sorted by upload date. - (2) Apple's expanded age-rating questionnaire had ten unanswered fields - (advertising, ageAssurance, parentalControls, gunsOrOtherWeapons, - healthOrWellnessTopics, lootBox, messagingAndChat, userGeneratedContent, - socialMedia, socialMediaAgeRestricted). They live on the **editable appInfo** - (`556bc291…`, created with version 1.1); the live 1.0 appInfo's declaration is - locked. Declared honestly per the operator: chat, UGC, social = yes (not - age-restricted); ads, loot boxes, health, parental controls, age assurance = - no; guns = none. Rating stayed 4+. - (3) `submit` opened a fresh reviewSubmission on every retry; it now reuses an - open READY_FOR_REVIEW shell and skips an item already present. One empty - shell (`f60158d8…`) is orphaned — Apple refuses to cancel an empty one. - `CURRENT_PROJECT_VERSION` bumped 3 → 4 on both targets (3 was consumed by the expired upload). - Order: `fastlane ios meta` → `fastlane ios build` → `fastlane ios upload` @@ -96,7 +47,7 @@ lane instead of by hand. --- -## 1.1 (3) — 2026-04-30 (uploaded, never submitted; build expired) +## 1.1 (3) — 2026-04-30 (in progress) Reliability pass before this build ships. Field reports of the app sitting on the boot.mjs animation indefinitely (5,000s+) with no way to recover diff --git a/at/cli.mjs b/at/cli.mjs index 5551827c06..4152dbb216 100755 --- a/at/cli.mjs +++ b/at/cli.mjs @@ -9,8 +9,11 @@ import { config } from "dotenv"; config(); // Load .env from at/ directory const PDS_URL = process.env.PDS_URL || "https://at.aesthetic.computer"; -const BSKY_SERVICE = - process.env.BSKY_SERVICE || "https://public.api.bsky.app"; +// Two endpoints, not one. Reads go to the unauthenticated AppView; writes go +// to the PDS we hold an app password for. BSKY_SERVICE steers writes only — +// vault/at/.env points it at bsky.social, and pointing reads there too makes +// every getProfile 401. +const BSKY_PUBLIC = process.env.BSKY_PUBLIC || "https://public.api.bsky.app"; // --------------------------------------------------------------------------- // Argument parser (same style as memory/cli.mjs) @@ -119,7 +122,7 @@ async function commandResolve(args) { if (!input.startsWith("did:")) { console.log(`Resolving handle: ${input}`); const profile = await fetchJSON( - `${BSKY_SERVICE}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(input)}`, + `${BSKY_PUBLIC}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(input)}`, ); did = profile.did; console.log(` @${input} -> ${did}\n`); @@ -168,7 +171,7 @@ async function commandResolve(args) { async function commandProfile(args) { const actor = requireArg(args, 1, "handle-or-did"); const profile = await fetchJSON( - `${BSKY_SERVICE}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(actor)}`, + `${BSKY_PUBLIC}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(actor)}`, ); const p = profile; @@ -188,7 +191,7 @@ async function commandPosts(args) { const limit = parseInt(args.limit) || 10; const data = await fetchJSON( - `${BSKY_SERVICE}/xrpc/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(actor)}&limit=${limit}`, + `${BSKY_PUBLIC}/xrpc/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(actor)}&limit=${limit}`, ); if (!data.feed?.length) { @@ -246,9 +249,24 @@ async function commandPost(args) { if (args.image) { const { readFileSync } = await import("fs"); const imageData = readFileSync(args.image); - const { data } = await agent.uploadBlob(imageData, { - encoding: "image/png", - }); + // The blob's encoding has to match the bytes, or the post renders broken. + // Bluesky also caps a blob near a megabyte, and its error for going over + // is not obvious, so say so here instead. + const kinds = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", + gif: "image/gif", webp: "image/webp" }; + const encoding = kinds[args.image.split(".").pop().toLowerCase()]; + if (!encoding) { + console.error(`Unsupported image type: ${args.image}`); + process.exit(1); + } + if (imageData.length > 976_560) { + console.error( + `${args.image} is ${Math.round(imageData.length / 1024)}KB; Bluesky ` + + `accepts about 953KB. Downscale or re-encode it as JPEG first.`, + ); + process.exit(1); + } + const { data } = await agent.uploadBlob(imageData, { encoding }); postRecord.embed = { $type: "app.bsky.embed.images", images: [ @@ -388,7 +406,7 @@ async function commandAccountCheck(args) { let did = input; if (!input.startsWith("did:")) { const profile = await fetchJSON( - `${BSKY_SERVICE}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(input)}`, + `${BSKY_PUBLIC}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(input)}`, ); did = profile.did; } @@ -594,7 +612,8 @@ Environment: PDS_ADMIN_PASSWORD Admin password for PDS operations BSKY_IDENTIFIER Bluesky handle for posting BSKY_APP_PASSWORD Bluesky app password for posting - BSKY_SERVICE Bluesky API (default: https://public.api.bsky.app) + BSKY_SERVICE Bluesky PDS for posting (default: https://bsky.social) + BSKY_PUBLIC Bluesky AppView for reads (default: https://public.api.bsky.app) Examples: ac-at health diff --git a/at/package-lock.json b/at/package-lock.json index 9625c1a55f..90e4b25d24 100644 --- a/at/package-lock.json +++ b/at/package-lock.json @@ -19,6 +19,9 @@ }, "devDependencies": { "@types/node": "^22.10.2" + }, + "engines": { + "node": ">=24.18.1 <25" } }, "node_modules/@atproto/api": { diff --git a/easel/src/runtimes.mjs b/easel/src/runtimes.mjs index 76f28a175f..b0e50790a1 100644 --- a/easel/src/runtimes.mjs +++ b/easel/src/runtimes.mjs @@ -1,9 +1,11 @@ // runtimes.mjs — the piece languages Aesthetic Computer can run. // // A piece is JavaScript, KidLisp, or Lua, chosen by file extension. The blank -// templates are the starting point for a session's piece: each one paints -// something on its own so a phone pointed at the QR code has an image -// immediately, before the agent has written anything. +// templates are the starting point for a session's piece: each one paints a +// flat color on its own so a phone pointed at the QR code has an image +// immediately, before the agent has written anything. The blank does not write +// its own name — the system's corner label already shows it, and a name left +// in the source tends to survive the agent's first edit as clutter. export const RUNTIMES = { mjs: { @@ -16,9 +18,8 @@ export const RUNTIMES = { blank: (name) => `// ${name}, ${stamp()} // A blank Aesthetic Computer piece. -function paint({ wipe, ink, screen }) { +function paint({ wipe }) { wipe(70, 50, 100); - ink(255, 100, 255).write("${name}", { center: "xy" }); return false; // Painted once; return true to keep painting. } @@ -33,8 +34,6 @@ export { paint }; routable: true, blank: (name) => `; ${name}, ${stamp()} (wipe "purple") -(ink "pink") -(write "${name}" 6 6) `, }, lua: { @@ -62,8 +61,6 @@ end function draw() background(70, 50, 100) - fill(255, 100, 255) - text("${name}", 6, 16) end `, }, diff --git a/easel/src/tui.mjs b/easel/src/tui.mjs index 8cd9784182..e4593c8f79 100755 --- a/easel/src/tui.mjs +++ b/easel/src/tui.mjs @@ -207,7 +207,8 @@ function developerInstructions() { return [ "You are running inside Easel, a terminal interface for Aesthetic Computer (AC) work.", account, - `This session's piece is ${live.file} (${live.runtime.label}). It already exists as a blank piece. Edit that file unless the user asks for something else.`, + `This session's piece is ${live.file} (${live.runtime.label}). It already exists as a blank piece that paints a flat color and nothing else. Edit that file unless the user asks for something else.`, + "Do not write the piece's name onto the screen: the system already shows it in the corner label. If the file still carries a placeholder that writes its own name, remove it in your first edit.", ...dialect, ...styleInstructions(), "Every save of that file is pushed live to a phone that scanned the interface's QR code, so small frequent edits are better than one big rewrite.", diff --git a/easel/test/live.test.mjs b/easel/test/live.test.mjs index ebf17a7744..72be48d160 100644 --- a/easel/test/live.test.mjs +++ b/easel/test/live.test.mjs @@ -114,11 +114,16 @@ test("the rendered code matches the encoder module for module", async () => { assert.equal(isFinder(last, last), false, "no finder in the bottom-right"); }); -test("every runtime carries a blank that names itself", () => { +// The name lives in the blank's header comment only. The system's corner label +// already shows it on screen, so a blank that also wrote it drew it twice — and +// the agent's first edit tended to keep the centered placeholder around. +test("every runtime carries a blank that names itself in a comment, not on screen", () => { assert.deepEqual(runtimeIds(), ["mjs", "lisp", "lua"]); for (const id of runtimeIds()) { const runtime = runtimeFor(id); - assert.match(runtime.blank("movika"), /movika/); + const blank = runtime.blank("movika"); + assert.match(blank, /movika/); + assert.doesNotMatch(blank, /(?:write|text)\s*\(?\s*"movika"/, `${id} blank writes its name on screen`); assert.equal(runtimeForExtension(runtime.extension), runtime); } assert.equal(runtimeFor(".LISP").id, "lisp"); diff --git a/marketing/podcast/SCORE.md b/marketing/podcast/SCORE.md index 7264be8f6f..cc06fd1383 100644 --- a/marketing/podcast/SCORE.md +++ b/marketing/podcast/SCORE.md @@ -120,6 +120,11 @@ Flags: `--open` (slab-afplay the result), `--force` (bypass say cache), `--stability 0.55 --similarity 0.8 --speed 0.98` (voice tuning), `--base ` on `feed.mjs` (override the asset host). +For an addressed audio reply, use `--frame reply --nobed`. This retains the +voice, mastering, and speech QA, omits podcast framing and jingles, and marks +the audio metadata as a synthesized private reply. Keep its source in the +private paper directory and its slug outside the publish allowlist. + ## Reused from /pop - `/api/say` invocation pattern + content-hash caching — lifted from `pop/bin/say.mjs`. diff --git a/marketing/podcast/bin/produce.mjs b/marketing/podcast/bin/produce.mjs index be0b7dd8c3..53e5c66ee6 100644 --- a/marketing/podcast/bin/produce.mjs +++ b/marketing/podcast/bin/produce.mjs @@ -14,7 +14,7 @@ // node bin/produce.mjs ../../opinion/lotus-notes.md --open // flags: --open --force --stability 0.5 --similarity 0.8 --speed 1.0 // --bedstyle sosoft|lofi|club | --bedfile --bedbpm N [--bedbeats 3] -// --frame reading|daily|log +// --frame reading|daily|log|reply import { writeFileSync, readFileSync, mkdirSync, existsSync, rmSync } from "node:fs"; import { resolve, dirname } from "node:path"; @@ -261,6 +261,7 @@ console.log(`\n${units.length} utterances · body ${bodySec.toFixed(1)}s${BEAT_A // 3. Intro + outro voice-over, with the measured length. Two frames: // "reading" (the liturgical essay framing) and "daily" (the short-update show). const FRAME = flags.frame || "reading"; +const IS_REPLY = FRAME === "reply"; const introText = FRAME === "daily" ? `The daily, from Aesthetic Dot Computer. ${script.title}. Approximately ${lengthText}.` : FRAME === "log" @@ -271,12 +272,13 @@ const outroText = FRAME === "daily" : FRAME === "log" ? `That's the log so far. Letters to mail at aesthetic dot computer.` : `Here ends the reading. Questions and feedback are welcome at mail at aesthetic dot computer. Unless you ask us not to, your letter may be read or mentioned on a future episode.`; -console.log("Narrating frame…"); -const introVo = await say(introText, "intro"); -const outroVo = await say(outroText, "outro"); +if (!IS_REPLY) console.log("Narrating frame…"); +const introVo = IS_REPLY ? null : await say(introText, "intro"); +const outroVo = IS_REPLY ? null : await say(outroText, "outro"); // 4. Jingles. -const { intro: introJingle, outro: outroJingle } = renderJingles(resolve(ROOT, "assets")); +const { intro: introJingle, outro: outroJingle } = IS_REPLY + ? {} : renderJingles(resolve(ROOT, "assets")); // 5. Assemble. console.log("\nAssembling…"); @@ -298,9 +300,11 @@ const add = (src) => { }; const gap = (s) => { if (s <= 0) return; const d = resolve(build, `seg_${String(n++).padStart(3, "0")}.wav`); silence(d, s); seq.push(d); clock += s; }; -add(introJingle); -gap(0.35); -add(introVo); +if (!IS_REPLY) { + add(introJingle); + gap(0.35); + add(introVo); +} // Push the body to the next bar boundary so the first VOICED onset lands on // the downbeat (its lead consonant just ahead of it), and every later // sentence onset lands on a beat. @@ -315,10 +319,12 @@ for (let i = 0; i < units.length; i++) { add(units[i].wav); cues.push({ start: startSec, end: clock, text: units[i].text }); } -gap((BEAT_ALIGN ? snapUp(clock, BAR, 0.5) : clock + 0.8) - clock); -add(outroVo); -gap(0.3); -add(outroJingle); +if (!IS_REPLY) { + gap((BEAT_ALIGN ? snapUp(clock, BAR, 0.5) : clock + 0.8) - clock); + add(outroVo); + gap(0.3); + add(outroJingle); +} // Write an SRT of the reading (sentence-level, exact from the timeline). const srtTime = (s) => { @@ -471,10 +477,10 @@ execFileSync("ffmpeg", [ "-disposition:v", "attached_pic", "-metadata", `title=${script.title}`, "-metadata", `artist=${speaker}`, - "-metadata", `album=Aesthetic Computer — Readings`, + "-metadata", `album=${IS_REPLY ? "Aesthetic Computer — Private Replies" : "Aesthetic Computer — Readings"}`, "-metadata", `genre=Spoken Word`, "-metadata", `date=${script.date}`, - "-metadata", `comment=A reading of the essay "${script.title}". ${lengthText}.`, + "-metadata", `comment=${IS_REPLY ? `Private reply draft. Synthesized with ${VOICE.provider}/${VOICE.voice}.` : `A reading of the essay "${script.title}". ${lengthText}.`}`, outMp3, ], { stdio: "ignore" }); @@ -513,6 +519,7 @@ if (existsSync(sidecarPath)) { } writeFileSync(sidecarPath, JSON.stringify({ slug: script.slug, title: script.title, author: speaker, date: script.date, + frame: FRAME, voice: VOICE, status: hosted(script.slug) ? "publish-cleared" : "draft-unpublished", description: script.paragraphs[0], lengthText, durationSec: Math.round(total), bytes: readFileSync(outMp3).length, diff --git a/pop/imab/README.md b/pop/imab/README.md index 61d444cf65..6810a59724 100644 --- a/pop/imab/README.md +++ b/pop/imab/README.md @@ -54,8 +54,48 @@ pop/.venv/bin/python pop/imab/bin/lyricscroll.py # the scrolling review clip: alignment-video # chassis over the real floor mix, lyric # ribbon underneath (needs lyrictrack first) +pop/.venv/bin/python pop/imab/bin/melodyproof.py [take…] + # DOES HE SING WHAT WE WROTE? every syllable's + # sung pitch vs melody.json, per take, two + # stages: SET (vocalset's note-locked render — + # proves the render) and RAW (the demucs stem — + # the verdict). Register fitted per take in + # cents; boundaries from drawn / dtw / notealign. + # → out/melodyproof.html (?only=) + # also writes boundaries-dtw-.json (the + # lead's hand warped onto each take) for the wizard +node pop/imab/bin/syllawizard.mjs # draw syllable rectangles on THIS take's + # spectrogram (takes// in the cache; seeds + # from processed → drawn → dtw boundaries) +pop/.venv/bin/python pop/imab/bin/notealign.py + # forced alignment: the written melody DTW'd onto + # the f0 track → syllable spans (a proposal) +node pop/imab/bin/setalign.mjs # every set retimed onto the lead's syllable + # onsets → out/imab-set--aligned.wav + + # imab-sets-successive.mp3 (no layering) +open pop/imab/review.html # scroll-through of every demo, take and spine ``` +### boundary files — which timebase + +- `processed-boundaries-.json` — @jeffrey's hand on the **SET** (vocalset's + stretched, note-locked wav; the wizard's `takes//spec.png`). Feeds vocalset. +- `boundaries-drawn-.json` — @jeffrey's hand on the **RAW** stem. The lead's + (Sep 1) carries the a/but mislabel that `lyric-overrides.json` drawnFix corrects; + 7427's (Sep 9, drawn on neo) is clean. +- `boundaries-dtw-.json` — melodyproof's warp of the lead's SET hand onto + another take's SET, by MFCC+RMS DTW. A seed, not a truth. +- `melody.json` — the notepat ground truth, one note per syllable. Edit here, not + in the scripts. + +### where the work lives + +The raw takes (`toolchain/whistlegraph/downloads/whistlegraph-.wav`), +their demucs stems and the full `~/.cache/ac/imab` work dir live on **neo**; +blueberry holds copies of the six stems + wavs as of 2026-09-13. Uncommitted +lane work has stranded on both machines before — `git status pop/imab` on +each before assuming a fix "already happened". + - **out/imab-click-124** — 2-bar count-in, then 64 bars. Beat 1 accented, brighter door tick at every 8-bar phrase start. - **out/imab-guide-124** — the same click with the hook melody diff --git a/pop/imab/bin/lyrictrack.mjs b/pop/imab/bin/lyrictrack.mjs index 4de174fc56..1c738fa97d 100644 --- a/pop/imab/bin/lyrictrack.mjs +++ b/pop/imab/bin/lyrictrack.mjs @@ -194,7 +194,7 @@ writeFileSync(`${OUT}/imab-gt-targets.json`, JSON.stringify(sylls.map((s, i) => console.log("→ aesthetivox note-lock (targets from current boundaries)"); sh(PY, [`${REPO}/pop/bin/autotune.py`, `${OUT}/imab-sacredvox.wav`, AVOX, "--targets", `${OUT}/imab-gt-targets.json`, "--register-fit", - "--strength", "1.0", "--preserve", "0.25", "--glide-ms", "30"]); // flutish: dead-on, little ride + "--strength", "1.0", "--preserve", "0.0", "--glide-ms", "0"]); // EXTREME: stepped, no scoop, no ride let stemFile = "out/imab-sacredvox.wav"; const gridOvr = Object.entries(ovr.overrides ?? {}); if (gridOvr.length && existsSync(AVOX)) { diff --git a/pop/imab/bin/syllawizard.mjs b/pop/imab/bin/syllawizard.mjs index d27a69f581..8c3afeec61 100644 --- a/pop/imab/bin/syllawizard.mjs +++ b/pop/imab/bin/syllawizard.mjs @@ -23,22 +23,45 @@ const WORK = `${process.env.HOME}/.cache/ac/imab`; const TAKE = process.argv[2] || "7311159624588070175"; const PXS = 260; -const spec = readFileSync(`${WORK}/wizard-spec.png`).toString("base64"); -const audio = readFileSync(`${WORK}/wizard-audio.mp3`).toString("base64"); +// per-take inputs (takes//) — the shared wizard-* files are a fallback +// from before the wizard was parametric; reading them for every take put +// one take's audio under five other takes' rectangles. +const TAKE_DIR = `${WORK}/takes/${TAKE}`; +const specPath = existsSync(`${TAKE_DIR}/spec.png`) ? `${TAKE_DIR}/spec.png` : `${WORK}/wizard-spec.png`; +const audioPath = existsSync(`${TAKE_DIR}/audio.mp3`) ? `${TAKE_DIR}/audio.mp3` : `${WORK}/wizard-audio.mp3`; +if (!specPath.startsWith(TAKE_DIR)) console.warn(`! no takes/${TAKE}/ in the cache — falling back to the shared wizard-* files`); +const spec = readFileSync(specPath).toString("base64"); +const audio = readFileSync(audioPath).toString("base64"); const SYLS = [ ["i'm", 0], ["a", 1], ["but", 2], ["ter", 2], ["fly", 2], ["flap", 3], ["ping", 3], ["for", 4], ["you", 5], ["guys", 6], ["just", 7], ["a", 8], ["cos", 9], ["tume", 9], ["i", 10], ["put", 11], ["on", 12], ["in", 13], ["my", 14], ["room", 15]]; -let seed = []; +// seed rectangles: the best boundaries we already have for this take, +// so drawing starts from a proposal instead of a blank strip. +// processed-boundaries / boundaries-drawn = @jeffrey's hand (lane root) +// boundaries-dtw = melodyproof's warp of the lead's hand +// bounds-.json = the old cache seed +let seed = [], seedFrom = null; +for (const name of [`processed-boundaries-${TAKE}.json`, `boundaries-drawn-${TAKE}.json`, `boundaries-dtw-${TAKE}.json`]) { + const p = resolve(HERE, `../${name}`); + if (!existsSync(p)) continue; + const by = new Map(); + for (const s of JSON.parse(readFileSync(p, "utf8")).sylls) if (!by.has(`${s.label}/${s.wi}`)) by.set(`${s.label}/${s.wi}`, s); + seed = SYLS.map(([lab, wi]) => { const s = by.get(`${lab}/${wi}`); + return s ? { fromMs: s.fromMs, toMs: s.toMs, fLo: s.fLo ?? 0.15, fHi: s.fHi ?? 0.9 } : null; }); + seedFrom = name; break; +} const bp = `${WORK}/bounds-${TAKE}.json`; -if (existsSync(bp)) { +if (!seedFrom && existsSync(bp)) { const B = JSON.parse(readFileSync(bp, "utf8")); const flat = B.words.flatMap((w) => w.sylls || []); seed = SYLS.map(([lab], i) => { const s = flat[i]; return s ? { fromMs: s.fromMs, toMs: s.toMs, fLo: 0.15, fHi: 0.9 } : null; }); + seedFrom = `cache bounds-${TAKE}.json`; } +if (seedFrom) console.log(`→ seeded from ${seedFrom}`); const html = `syllawizard · ${TAKE}