From db229ab5e53f016429bebf227177ecb689f89ba2 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 20 Sep 2026 07:56:01 -0700 Subject: [PATCH] Preserve pieces, music tooling, workshop assets, and privacy plan --- .../campaigns/grokaesthetic/bin/stories.mjs | 187 ++++++ plans/letters-privacy.md | 279 +++++++++ pop/imab/bin/bundle-review.py | 83 +++ pop/imab/bin/melodyproof.html.tpl | 281 +++++++++ pop/imab/bin/melodyproof.py | 517 ++++++++++++++++ pop/imab/bin/notealign.py | 157 +++++ pop/imab/bin/setalign.mjs | 354 +++++++++++ pop/imab/bin/syllsplit.py | 105 ++++ pop/imab/bin/wizard-prep.py | 98 +++ .../boundaries-drawn-7427025009693986079.json | 167 +++++ .../boundaries-dtw-7311185009245818143.json | 167 +++++ .../boundaries-dtw-7335667959647456555.json | 167 +++++ .../boundaries-dtw-7427025009693986079.json | 167 +++++ .../boundaries-dtw-7601670541833014558.json | 167 +++++ .../boundaries-dtw-7656469190517263646.json | 167 +++++ pop/imab/melody.json | 27 + pop/imab/review.html | 573 ++++++++++++++++++ pop/maytrax/README.md | 15 + .../bin/score-video-femrag-plusplus.mjs | 160 +++++ system/backend/auth0-emails/README.md | 35 ++ .../passwordless.before-2026-09-13.json | 28 + system/backend/auth0-emails/passwordless.html | 15 + .../public/aesthetic.computer/disks/aesel.mjs | 3 + .../public/aesthetic.computer/disks/babo.mjs | 9 + .../aesthetic.computer/disks/balozo.mjs | 20 + .../aesthetic.computer/disks/bimero.mjs | 10 + .../public/aesthetic.computer/disks/dafu.mjs | 196 ++++++ .../aesthetic.computer/disks/fazoko.mjs | 288 +++++++++ .../public/aesthetic.computer/disks/kana.mjs | 271 +++++++++ .../aesthetic.computer/disks/kizide.mjs | 200 ++++++ .../aesthetic.computer/disks/kokazo.mjs | 263 ++++++++ .../aesthetic.computer/disks/konuzu.mjs | 10 + .../public/aesthetic.computer/disks/lusu.mjs | 35 ++ .../aesthetic.computer/disks/murafi.mjs | 21 + .../public/aesthetic.computer/disks/neme.mjs | 24 + .../public/aesthetic.computer/disks/pedu.mjs | 14 + .../public/aesthetic.computer/disks/puzi.mjs | 169 ++++++ .../public/aesthetic.computer/disks/ruke.mjs | 113 ++++ .../aesthetic.computer/disks/ruzomo.mjs | 10 + .../aesthetic.computer/disks/zutega.mjs | 9 + toolchain/email/la-workshop-audience.mjs | 118 ++++ 41 files changed, 5699 insertions(+) create mode 100644 marketing/campaigns/grokaesthetic/bin/stories.mjs create mode 100644 plans/letters-privacy.md create mode 100755 pop/imab/bin/bundle-review.py create mode 100644 pop/imab/bin/melodyproof.html.tpl create mode 100644 pop/imab/bin/melodyproof.py create mode 100755 pop/imab/bin/notealign.py create mode 100644 pop/imab/bin/setalign.mjs create mode 100755 pop/imab/bin/syllsplit.py create mode 100755 pop/imab/bin/wizard-prep.py create mode 100644 pop/imab/boundaries-drawn-7427025009693986079.json create mode 100644 pop/imab/boundaries-dtw-7311185009245818143.json create mode 100644 pop/imab/boundaries-dtw-7335667959647456555.json create mode 100644 pop/imab/boundaries-dtw-7427025009693986079.json create mode 100644 pop/imab/boundaries-dtw-7601670541833014558.json create mode 100644 pop/imab/boundaries-dtw-7656469190517263646.json create mode 100644 pop/imab/melody.json create mode 100644 pop/imab/review.html create mode 100644 pop/maytrax/bin/score-video-femrag-plusplus.mjs create mode 100644 system/backend/auth0-emails/README.md create mode 100644 system/backend/auth0-emails/passwordless.before-2026-09-13.json create mode 100644 system/backend/auth0-emails/passwordless.html create mode 100644 system/public/aesthetic.computer/disks/aesel.mjs create mode 100644 system/public/aesthetic.computer/disks/babo.mjs create mode 100644 system/public/aesthetic.computer/disks/balozo.mjs create mode 100644 system/public/aesthetic.computer/disks/bimero.mjs create mode 100644 system/public/aesthetic.computer/disks/dafu.mjs create mode 100644 system/public/aesthetic.computer/disks/fazoko.mjs create mode 100644 system/public/aesthetic.computer/disks/kana.mjs create mode 100644 system/public/aesthetic.computer/disks/kizide.mjs create mode 100644 system/public/aesthetic.computer/disks/kokazo.mjs create mode 100644 system/public/aesthetic.computer/disks/konuzu.mjs create mode 100644 system/public/aesthetic.computer/disks/lusu.mjs create mode 100644 system/public/aesthetic.computer/disks/murafi.mjs create mode 100644 system/public/aesthetic.computer/disks/neme.mjs create mode 100644 system/public/aesthetic.computer/disks/pedu.mjs create mode 100644 system/public/aesthetic.computer/disks/puzi.mjs create mode 100644 system/public/aesthetic.computer/disks/ruke.mjs create mode 100644 system/public/aesthetic.computer/disks/ruzomo.mjs create mode 100644 system/public/aesthetic.computer/disks/zutega.mjs create mode 100644 toolchain/email/la-workshop-audience.mjs diff --git a/marketing/campaigns/grokaesthetic/bin/stories.mjs b/marketing/campaigns/grokaesthetic/bin/stories.mjs new file mode 100644 index 0000000000..e9ba39835b --- /dev/null +++ b/marketing/campaigns/grokaesthetic/bin/stories.mjs @@ -0,0 +1,187 @@ +#!/usr/bin/env node +// stories.mjs — the three Instagram Story cards for the grokaesthetic +// workshop at CultureHub LA. Builds self-contained HTML (fonts + art +// inlined as data URIs, so Chrome never has to reach across file://) +// and screenshots each at 1080x1920 via toolchain/macos/chrome-shot.mjs. +// +// Usage: node marketing/campaigns/grokaesthetic/bin/stories.mjs [out-dir] +// Default out: ~/Desktop/Shelf/culturehub-2026/stories +// +// Art is the existing campaign painting (macneopolitan `program`), the same +// image already running on X and Bluesky. Nothing here is generated. +// +// Instagram paints its own chrome over the top and bottom 250px, so every +// readable thing lives between SAFE_TOP and H - SAFE_BOT. Line breaks are +// hand-placed rather than left to wrapping — an orphaned "PT" on its own +// line is the thing that reads as sloppy at thumb speed. + +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { tmpdir } from "node:os"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(HERE, "../../../.."); +const OUT = resolve((process.argv[2] || `${process.env.HOME}/Desktop/Shelf/culturehub-2026/stories`).replace(/^~/, process.env.HOME)); +const BUILD = join(tmpdir(), "grok-stories"); + +const W = 1080, H = 1920; +const SAFE_TOP = 250, SAFE_BOT = 250; + +// the event. these are the facts — nothing here is decorative. +const ev = { + day: "Sat · Sep 19 · 2026", + time: "2:00 – 3:30 PM PT", + venue: "CultureHub LA · The Reef", + street: "1933 S Broadway, Suite 1268", + city: "Los Angeles, CA 90007", + bring: "Bring a laptop —
loaners available", + skill: "No programming or music
experience needed", + price: "Pay what you can · suggested $10", + encore: "Companion performance Thu Sep 24, 7pm · same venue", + host: "with Jeffrey Alan Scudder · @aesthetic.computer", +}; + +// warm palette pulled straight off the painting's wood floor. +const c = { + paper: "#F6E8C9", + ink: "#191310", + soft: "#6B4E33", + rule: "#C0985F", + pink: "#DC0554", +}; + +const b64 = (p) => readFileSync(p).toString("base64"); +const font = (f) => `data:font/woff2;base64,${b64(join(REPO, "system/public/type/webfonts", f))}`; + +// art comes in pre-scaled so the pages stay small and chrome never resamples +// a 4mb png at paint time. `trim` shaves the wordmark's white margins. +function art(src, geom, trim = false) { + const out = join(BUILD, `${trim ? "t-" : ""}${geom.replace(/\W/g, "")}-${src.split("/").pop()}`); + execFileSync("magick", [join(REPO, src), ...(trim ? ["-fuzz", "6%", "-trim", "+repage"] : []), "-resize", geom, "-strip", out]); + const [w, h] = execFileSync("magick", ["identify", "-format", "%w %h", out]).toString().split(" ").map(Number); + return { url: `data:image/png;base64,${b64(out)}`, w, h }; +} + +mkdirSync(BUILD, { recursive: true }); +mkdirSync(OUT, { recursive: true }); + +const asset = { + bold: font("ywft-processing-bold.woff2"), + reg: font("ywft-processing-regular.woff2"), + mono: font("BerkeleyMonoVariable-Regular.woff2"), + tall: art("marketing/campaigns/macneopolitan/gens/program-portrait.png", "1080x"), + wide: art("marketing/campaigns/macneopolitan/gens/program.png", "1240x"), + mark: art("marketing/campaigns/grokaesthetic/refs/grokaesthetic-wordmark.png", "900x", true), +}; + +// the wordmark ships on white; multiply drops it onto the cream cleanly, +// which beats keying the white out and fringing the pixel letterforms. +const mark = (w, extra = "") => + ``; + +// a window onto the painting — explicit offsets, not object-fit, so the +// figure can be placed exactly where the layout wants it. +const pane = (a, top, h, oy = 0) => ` +
+ +
+
+
`; + +const shell = (body) => ` +
${body}
`; + +// ─ story 1 — the hook ────────────────────────────────────────────────────── +// painting holds the top of the safe zone, the apron underneath carries the +// four things a thumb can absorb in a second: who, what, when, cost of entry. +const story1 = shell(` + ${pane(asset.tall, 250, 820, 0)} +
+ ${mark(860)} +
WORKSHOP
+
+
+ Make art, music & tiny programs
together — no experience needed. +
+
Sat Sep 19 · 2–3:30 PM PT · CultureHub LA
+
+`); + +// ─ story 2 — the details ─────────────────────────────────────────────────── +const row = (k, v, sub = "") => ` +
+
${k}
+
${v}${sub ? `
${sub}
` : ""}
+
`; + +const story2 = shell(` + ${pane(asset.wide, 250, 360, 30)} +
+ ${mark(520)} +
WORKSHOP
+
+ ${row("WHEN", `${ev.day}
${ev.time}`)} + ${row("WHERE", ev.venue, `${ev.street}
${ev.city}`)} + ${row("BRING", ev.bring)} + ${row("SKILL", ev.skill)} + ${row("COST", `${ev.price}`)} +
+
${ev.encore}
${ev.host}
+
+`); + +// ─ story 3 — the rsvp ────────────────────────────────────────────────────── +// y 1140–1440 is deliberately empty cream. that is the link sticker's seat; +// the url is never typed because story text is not tappable. +const story3 = shell(` + ${pane(asset.wide, 250, 550, 30)} +
+ ${mark(620, "margin:0 auto;")} +
TAP TO RSVP
+
use the link sticker below ↓
+
+
+
+
+ ${ev.day} · ${ev.time}
${ev.venue}, Los Angeles +
+
Pay what you can · bring a laptop
+
@aesthetic.computer · @whistlegraph · @culturehub
+
+`); + +const cards = [ + ["story-1-hook", story1], + ["story-2-details", story2], + ["story-3-rsvp", story3], +]; + +for (const [name, html] of cards) { + const page = join(BUILD, `${name}.html`); + writeFileSync(page, html); + const png = join(OUT, `${name}.png`); + execFileSync("node", [ + join(REPO, "toolchain/macos/chrome-shot.mjs"), + pathToFileURL(page).href, png, "--size", `${W}x${H}`, "--budget", "4000", + ], { stdio: ["ignore", "ignore", "ignore"] }); + const dim = execFileSync("magick", ["identify", "-format", "%wx%h", png]).toString(); + if (dim !== `${W}x${H}`) { console.error(`✗ ${name}: ${dim}, wanted ${W}x${H}`); process.exit(1); } + console.log(`✓ ${name}.png ${dim}`); +} + +rmSync(BUILD, { recursive: true, force: true }); +console.log(`\nreading stays inside y ${SAFE_TOP}–${H - SAFE_BOT}. → ${OUT}`); diff --git a/plans/letters-privacy.md b/plans/letters-privacy.md new file mode 100644 index 0000000000..2fbb217e93 --- /dev/null +++ b/plans/letters-privacy.md @@ -0,0 +1,279 @@ +# Letters: privacy design and migration + +2026-09-14 · Proposed architecture; encryption is not implemented by this document. + +First implementation slice prepared in `/Users/jas/ac-amail`: generic letter +notifications (including `/tell` and incoming email), fixed mail diagnostic codes, +sanitized API errors and browser request failures, plus a rebuilt disk worker. +`node --experimental-vm-modules spec/mail-privacy-spec.mjs` exercises these paths +with synthetic content and mocked storage/transports. These are local changes; +publication and production deployment have not been performed. Stage 0 remains +open for disclosure, the broader data-access/retention inventory, and any paths +that inventory identifies. Existing stored messages are still plaintext. + +People send and keep **letters**. The goal is that AC delivers private letters +without possessing the keys to read them. Outside email remains useful, with an +explicitly different protection level. “Letters” is the product concept; this +plan does not rename `/mail`, addresses, APIs, or database collections. + +## Current evidence + +Source baseline: deployed commit `12f2cd64e1239064cb406cf8f164edca4fe8dd8b`, +inspected in `/Users/jas/ac-amail`. The primary checkout was on another branch; +links below identify paths, whose contents may differ until that branch catches up. +No private message bodies were read for this design. + +| Surface | Current behavior | Consequence | +| --- | --- | --- | +| [Mail backend](../system/backend/mail.mjs) | `deliver`, `deliverFromOutside`, and `sendOutside` store `text` and optional `subject` in `tells` | Database administrators can read internal, incoming, and sent letters | +| [Mail API](../system/netlify/functions/mail.mjs) | Authenticated inbox/sent queries return readable content | Authentication separates accounts; it does not exclude the operator | +| [Tell API](../system/netlify/functions/tell.mjs) | Calls the same `deliver` function | Privacy changes must cover this older entry point too | +| [SMTP receiver](../lith/mail-inbound.mjs) | Google Workspace forwards ordinary mail to lith, which parses it | Both services handle readable content during delivery | +| [Push transport](../shared/push.mjs) | Mail supplies subject/body previews to Web Push and APNs | Notification payloads create another content exposure; encrypted Web Push transport does not remove server access | +| [Piece runtime](../system/public/aesthetic.computer/lib/disk.mjs) | Captures console output for piece telemetry | Mail must not emit letters or keys into captured diagnostics | +| [Mail piece](../system/public/aesthetic.computer/disks/mail.mjs) | Runs inside the general AC client | Strong key isolation requires a separate trust boundary | + +SPF/DKIM and SMTP TLS help authenticate or transport outside email. They do not +keep its contents from the mail operator. End-to-end protection is a separate +mechanism. [RFC 9787](https://www.rfc-editor.org/rfc/rfc9787.html) + +## Privacy contract + +| Letter type | Intended protection | What AC can still observe | +| --- | --- | --- | +| Private AC letter | Sender encrypts; authorized recipient and sender devices decrypt | Routing identities, delivery times, approximate sizes, device delivery state | +| Ordinary outside email | Encrypt before persistent mailbox storage once recipient keys exist | Plaintext during receipt or sending; outside addresses and delivery metadata; Google also handles content | +| Encrypted outside email | Standards-based encryption at the endpoints, when both support it | Routing metadata; exact header exposure depends on interoperable header protection | +| Older letter | Existing plaintext, or an explicitly recorded migration to encrypted storage | Earlier plaintext access and historical copies cannot be undone | + +The client derives the displayed protection from the actual verified envelope and +route. It must not trust a server-provided “private” badge. Use one concise status +per letter: “Private letter”, “Outside email”, or “Older letter”. For ordinary +outside email, explain at send time: “Email providers can read this letter.” +Incoming mail encrypted only after receipt must never be labeled end-to-end encrypted. + +Protection excludes a compromised reader's device, a recipient sharing a letter, +and screenshots or exported copies. Availability, traffic analysis, and the social +graph remain separate problems. Do not promise anonymous correspondence. + +## Threat model and release promises + +1. **Current service:** reduce accidental exposure and unnecessary staff/agent + access. Disclose that administrators can access stored letters. +2. **Encrypted AC pilot:** a database leak or ordinary backend access reveals no + private content. This requires keys to remain on approved devices and prevents + account recovery from silently enrolling a decrypting device. +3. **Operator-resistant clients:** protect against key substitution and malicious + client delivery using independently verifiable releases and verified identities. + This is the gate for a stronger claim about a malicious operator. + +Web encryption alone cannot establish the third promise: the operator can serve +modified JavaScript that steals a key or a displayed letter. CSP and a separate +origin reduce attack surface; neither proves the delivered code is honest. An +installed Electron shell that still loads remote mail code has the same problem. + +## Keys, devices, and recovery + +- Generate secret material locally using the selected protocol's implementation; + do not derive it from an Auth0 token or have AC generate users' private keys. + Local generation follows [RFC 9787 §8.2.1.3](https://www.rfc-editor.org/rfc/rfc9787.html#section-8.2.1.3). +- Bind cryptographic identities to a stable account identifier, not a mutable + handle. Specify identity continuity for handle changes, account deletion, + account linking, and reused names. A new owner never inherits old keys. +- Each device has its own identity and revocable authorization. A signed device + list must prevent the server from silently adding itself as a recipient. +- Pair devices through approval on an existing device, with an authenticated QR + or code comparison. Login alone authorizes ciphertext retrieval, not decryption. +- Show identity changes and support out-of-band fingerprint verification. Add a + public-key transparency design with independently witnessed consistency checks; + a log controlled and observed only by AC cannot stop targeted substitution. +- Offer a locally generated, high-entropy recovery code that wraps an optional + encrypted archive backup. AC may store the wrapped backup, never the recovery + secret. Keep the code out of URLs, telemetry, support tools, and server requests. +- Account reset restores account access only. Without an approved device or + recovery material, old private letters are lost. Starting fresh creates a new + identity and alerts correspondents; it must not look like continuity. +- Revocation stops future delivery after clients learn the new device list. It + cannot retract content or keys already obtained by a revoked device. Specify + freshness checks, offline behavior, and conflict resolution before shipping. + +History transfer is an explicit device action. Newly paired devices do not +automatically receive every historical session key. Users may transfer an +encrypted archive or start with future letters only. + +## Protocol decision + +Use a maintained, reviewed implementation of an established protocol. This is a +requirements decision, not permission to invent a wire protocol or combine crypto +primitives ad hoc. + +For AC letters, evaluate asynchronous ratcheting protocols first: offline delivery, +multiple devices, sender history, and identity changes are core requirements. +Signal's [Sesame specification](https://signal.org/docs/specifications/sesame/) +describes asynchronous multi-device session management; its +[Double Ratchet specification](https://signal.org/docs/specifications/doubleratchet/) +describes evolving per-message keys. These are references, not a finding that a +particular library is suitable for AC's browser/runtime/license constraints. + +Keep [MLS, RFC 9420](https://www.rfc-editor.org/rfc/rfc9420.html) as the alternative +if group letters become a concrete requirement. Do not incur group membership +complexity solely for a hypothetical future feature. + +Outside interoperability should use established email formats, such as +[OpenPGP, RFC 9580](https://www.rfc-editor.org/rfc/rfc9580.html), with the composition +and interpretation guidance in RFC 9787. Ordinary OpenPGP mailbox encryption must +not be marketed as providing ratcheting transport's forward secrecy. + +Before choosing a library, record maintained releases, license, security review +scope, browser/native support, key storage options, official test vectors, +offline/reordered delivery behavior, device revocation, upgrade compatibility, +resource use on low-end devices, and the consequences of compromise. An external +review must cover our integration as well as the chosen implementation. + +**Archive tradeoff:** ratcheting transport can discard old session keys, but a +recoverable archive preserves another route to old content. Compromise of an +archive key can expose retained history. Keep live session state out of backups; +document separately the protection of transport, local history, and archive +recovery. Do not promise forward secrecy for all retained history. + +## Storage and API boundary + +Introduce a versioned content representation with exclusive plaintext-legacy and +encrypted variants. The application record should carry a format version, the +allowlisted protocol/version identifier, opaque protocol envelope, migration +provenance, and routing/delivery fields. Cryptographic envelope structure and key +identifiers come from the chosen protocol, not this document. + +Encrypted bodies include subjects, content, and private thread references. Drafts, +sent copies, and any later attachments receive the same treatment. Authenticate +relevant recipient, sender, version, and message identity within the protocol so +that copied or relabeled ciphertext cannot change its meaning. + +Server rules: + +- Encrypted records cannot contain a readable `text`, `subject`, preview, draft, + search index, or attachment filename alongside the envelope. +- Reject unknown protocol versions and oversized/malformed envelopes; never run + plaintext `clean()` filtering over ciphertext. +- Preserve mailbox authorization, quotas, deduplication, idempotent retries, and + abuse controls. Encryption is not permission to write to someone else's box. +- Keep content protection separate from delivery status and SMTP sender checks. + A DKIM pass is not proof of end-to-end identity or confidentiality. +- Search private content locally. Keep unread state separate from content, with + no sender-visible read receipts by default. +- A recipient without private-mail keys is “not ready for private letters”. Do + not silently use the legacy endpoint or SMTP as a fallback. Older clients must + show an upgrade requirement, not unreadable ciphertext or plaintext fallback. + +Inventory all callers of `deliver`, every writer/reader of `tells`, notification +consumers, exports, backups, and support scripts before enforcing the new schema. +The inventory must include `/tell` and any server-originated messages. System +notifications need their own honest provenance; they cannot impersonate private +user letters. + +## Client isolation and operations + +Private keys and decrypted content must live outside the general piece runtime. +Use a dedicated mail origin for the web client, with no user-published pieces, +third-party scripts, session replay, general console capture, or broad extension +bridges. Restrict cross-origin messages to fixed operations with checked origins; +the AC launcher receives navigation/unread information, never keys or bodies. +Audit service workers, caches, browser storage, desktop preload APIs, and exports. + +For the strongest tier, bundle the mail implementation in a client with reproducible +builds, public release hashes, independently checked artifacts, and reviewed update +authorization. Signing alone is insufficient when the operator controls the signing +key. Explicitly address targeted updates and old-version rollback. + +Use generic notifications, “You have a new letter”, with only an opaque routing +identifier where necessary. Keep raw content out of errors, push provider details, +crash reports, mail transport debugging, analytics, and local agent/MCP access. +Operational telemetry should use allowlisted codes and aggregate counts, with +documented retention and tightly controlled metadata access. + +Separate SMTP gateway, mail storage, and other production credentials. Least +privilege limits accidental access today; a root operator can still override it. +Private-mail abuse controls use blocking, sender quotas, delivery limits, and +explicit recipient reports. A report shares only the letters the user selects, +with a preview of the disclosure; it never grants administrators mailbox keys. + +## Outside email + +Ordinary inbound email is readable by Google and our receiving gateway. Once a +recipient has a storage public key, encrypt immediately before persistent filing. +Audit queues, temporary files, SMTP diagnostics, swap/core dumps, and backups so +that “encrypted storage” describes the complete retention path. Public-key lookup +and rotation must be authenticated; otherwise the operator can substitute a key. + +Ordinary outbound email similarly exposes plaintext to the sending gateway and +providers. Store the sender's archive copy encrypted on their device. Do not route +a private AC letter through SMTP when a private delivery fails. + +For a mailbox with no key, retain the disclosed legacy behavior during transition +or defer delivery according to an explicit enrollment policy; never generate a +server-held private key and call it user-owned encryption. Define retry/bounce +behavior before changing the inbound gateway. + +Preserve encrypted outside messages without destructively filtering or truncating +their cryptographic MIME structure. The current text extraction and 2,000-character +limit need a separate reviewed path before OpenPGP interoperability is offered. + +## Migration and release gates + +| Stage | Work | Exit evidence | +| --- | --- | --- | +| 0: Limit exposure | Generic push; accurate disclosure; sanitized diagnostics; inventory data access, backups, exports, and all `tells` consumers | Synthetic content canaries absent from diagnostics/push; all known copies and credentials documented | +| 1: Prepare compatibility | Versioned representation; explicit protection state; safe old-client behavior; API contract | Existing mail works; encrypted variant rejects plaintext sidecars; no silent downgrade | +| 2: Private pilot | Isolated client; selected reviewed protocol; local keys; pairing, recovery, revocation, identity verification | Two consenting test accounts pass lifecycle and adversarial scenarios below | +| 3: Migrate storage | Enroll users; migrate selected old letters; encrypt ordinary inbound storage; retire plaintext copies | Verified migration ledger, recoverable encrypted copies, retention deadlines, restore rehearsal | +| 4: Stronger operator resistance | Independent client verification and witnessed key transparency | External review and tests for targeted code/key substitution before stronger public claims | + +For stage 3, client-side migration should fetch only the user's authorized legacy +letters and create encrypted archive copies, then verify decryption and content +integrity before requesting deletion of that user's plaintext copy. Make batches +idempotent, resumable, and auditable without logging content. Preserve unread state, +dates, and duplicate handling. + +Today one internal `tells` row serves both sender and recipient. Split ownership +into per-account archive copies or equivalent independent grants before deletion. +One person's migration or deletion must not erase the other person's letter. +An unenrolled correspondent's plaintext copy means that content remains +operator-readable; do not describe partial migration as complete confidentiality. + +Inventory replicas, exports, push traces, and backup providers. Assign actual +retention deadlines from that inventory before claiming deletion; those schedules +are currently unknown. Backup restores must reapply deletion/migration tombstones +before serving mail. Do not create a new plaintext backup to make migration easier. +Evidence of historical plaintext access remains a limit even after all copies expire. + +Rollback stops new enrollment and keeps ciphertext readable through the last safe +client. It must never decrypt server-side, recreate plaintext records, or downgrade +new letters. Roll out separately from unrelated piece and SMTP changes. + +## Required verification before a private pilot + +- Offline recipient; reordered, duplicate, delayed, and replayed messages; retry + after interrupted send; sent history available only to approved sender devices. +- Second device pairing; rejected rogue device; revoked device; stale device list; + missing prekeys/session material; explicit history transfer. +- Recovery success; wrong/lost recovery code; all devices lost; Auth0/password + reset cannot decrypt archives or silently preserve cryptographic identity. +- Handle rename, name reuse, account linking/deletion, identity rotation, and + attempted directory substitution or inconsistent transparency views. +- Tampered body, subject, sender, recipient, version, and message ID fail safely; + invalid envelopes cannot trigger plaintext fallback or content-bearing errors. +- Cross-account API access fails; ciphertext-only database snapshot and production + credentials cannot decrypt pilot letters; a separately controlled client detects + the modeled malicious-directory attack. +- Synthetic unique body/subject/key markers do not appear in server logs, piece + telemetry, analytics, push payloads, temporary files, exports, or backups outside + the explicitly disclosed ordinary-SMTP boundary. +- Old client/new record, new client/legacy record, incomplete migration, independent + sender/recipient deletion, backup restore, and rollback all preserve the contract. +- Resource use and accessibility remain acceptable on a small phone; locked keys, + recovery loss, outside delivery, and changed identities have understandable UI. + +Use synthetic letters and consenting pilot accounts. No real users' mail is needed +for these checks. The first implementation slice is stage 0 plus the data-consumer +inventory; protocol selection and an isolated prototype follow. This document does +not authorize stronger privacy claims before their release gates pass. diff --git a/pop/imab/bin/bundle-review.py b/pop/imab/bin/bundle-review.py new file mode 100755 index 0000000000..1186e5533f --- /dev/null +++ b/pop/imab/bin/bundle-review.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""bundle-review.py — a self-contained copy of the imab review pages. + +review.html and out/melodyproof.html reach into the repo (../artist, +../../system) and the cache (~/.cache/ac/imab). This copies every +referenced file into ONE folder and rewrites the references, so the +folder can sit on a Desktop or be zipped for someone else. + + pop/.venv/bin/python pop/imab/bin/bundle-review.py [dest] (default ~/Desktop/imab-review) +""" +import json, os, re, shutil, sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +LANE = HERE.parent +CACHE = Path.home() / ".cache/ac/imab" +DEST = Path(sys.argv[1]).expanduser() if len(sys.argv) > 1 else Path.home() / "Desktop/imab-review" +DEST.mkdir(parents=True, exist_ok=True) +copied = 0 + + +def put(src: Path, rel: str): + global copied + d = DEST / rel + d.parent.mkdir(parents=True, exist_ok=True) + if not d.exists() or d.stat().st_mtime < src.stat().st_mtime: + shutil.copy2(src, d); copied += 1 + return rel + + +def rewrite_ref(ref: str, base: Path, page_dir_rel: str): + """map one src/href to a path inside the bundle, relative to the page.""" + if ref.startswith(("http", "#", "data:")): + return ref + path, _, q = ref.partition("?") + if path.startswith("file://"): + p = Path(path[7:]) + try: + rel = "cache/" + str(p.relative_to(CACHE)) + except ValueError: + rel = "assets/" + p.name + else: + p = (base / path).resolve() + try: + rel = str(p.relative_to(LANE)) # out/…, samples/… keep their shape + except ValueError: + rel = "assets/" + p.name # ../artist, ../../system → assets/ + if not p.exists(): + print(f" ! missing {p}"); return ref + put(p, rel) + up = "../" * (page_dir_rel.count("/") + 1) if page_dir_rel else "" + return up + rel + (("?" + q) if q else "") + + +def bundle_page(page: Path, page_dir_rel: str): + html = page.read_text() + base = page.parent + def sub(m): + return f'{m.group(1)}="{rewrite_ref(m.group(2), base, page_dir_rel)}"' + html = re.sub(r'\b(src|href)="([^"]+)"', sub, html) + return html + + +# review.html at the bundle root +(DEST / "review.html").write_text(bundle_page(LANE / "review.html", "")) + +# the syllawizard pages are self-contained (base64) — copied by the href walk above. +# melodyproof.html: its media paths live inside the embedded JSON +mp = (LANE / "out/melodyproof.html").read_text() +for p in set(re.findall(r'"/Users/[^"]+?\.(?:mp3|png|wav)"', mp)): + src = Path(json.loads(p)) + try: + rel = "cache/" + str(src.relative_to(CACHE)) + except ValueError: + rel = "assets/" + src.name + if src.exists(): + put(src, rel) + mp = mp.replace(p, json.dumps("../" + rel)) +(DEST / "out").mkdir(exist_ok=True) +(DEST / "out/melodyproof.html").write_text(mp) + +size = sum(f.stat().st_size for f in DEST.rglob("*") if f.is_file()) / 1e6 +print(f"✓ {DEST} ({copied} files copied this run, {size:.0f} MB total)") diff --git a/pop/imab/bin/melodyproof.html.tpl b/pop/imab/bin/melodyproof.html.tpl new file mode 100644 index 0000000000..73e2f548dd --- /dev/null +++ b/pop/imab/bin/melodyproof.html.tpl @@ -0,0 +1,281 @@ + + + + +imab · melodyproof + + + +
+

melodyproof

+

Every syllable's sung pitch, measured inside its boundary, against the notepat truth. Each take's + register is fitted once (the tonic it was actually sung in), so a letter that matches is a matched + interval, whatever key the take is in. green = within ±60¢. + gold = right note, wrong octave. red = a different note. + Uppercase = the octave below the tonic; h i j k l m n = notepat's second octave. + Two stages per take: SET is vocalset's note-locked render (proves the render, nothing else). + RAW is the performance itself, boundaries warped back from the set. RAW is the verdict on the melody.

+
+
ground truth · melody.json · tonic C4 · one letter per syllable
+ +

the six takes at a glance

+
takestagesung tonicboundshit / oct / miss / —sung line
+ +
+
+
+ + + + diff --git a/pop/imab/bin/melodyproof.py b/pop/imab/bin/melodyproof.py new file mode 100644 index 0000000000..43388fceec --- /dev/null +++ b/pop/imab/bin/melodyproof.py @@ -0,0 +1,517 @@ +#!/usr/bin/env python3 +"""melodyproof.py — does the take sing the melody we wrote down? + +For every take: measure the sung pitch inside each syllable's boundary, +fit the take's register to the notepat ground truth (melody.json), and +report the miss per syllable — as cents, and as a notepat line you can +read against `c g c c c · c h c c c · g f e d · e e d c c c`. + +Two stages per take. The SET (~/.cache/ac/imab/takes//audio.mp3) is +vocalset's note-locked, tempo-fit output — measuring it proves the render. +The RAW stage measures the performance itself (the demucs vocal stem, or +the raw wav), with the set's boundaries warped back onto it by DTW. The +raw stage is the one that answers "does he sing what we wrote down". + +Boundaries come from three places, all measured and all shown: + drawn processed-boundaries-.json / boundaries-drawn-.json + (@jeffrey's hand, the truth where it exists) + dtw the LEAD take's drawn boundaries warped onto this take by a + DTW of MFCC+RMS (same voice, same words — timbre aligns even + when the register doesn't). Written to boundaries-dtw-.json + so syllawizard can seed from it. + syllnote whisper word spans from toolchain/whistlegraph/downloads, + split into syllables (the source of the old mistakes) + + pop/.venv/bin/python pop/imab/bin/melodyproof.py # all six takes + pop/.venv/bin/python pop/imab/bin/melodyproof.py 7427…079 # one + → prints the tables, writes out/melodyproof.html + out/melodyproof.json +""" +import json, os, re, sys, difflib +from pathlib import Path +import numpy as np +import librosa + +HERE = Path(__file__).resolve().parent +LANE = HERE.parent +REPO = LANE.parent.parent +OUT = LANE / "out" +WORK = Path(os.environ.get("HOME", "")) / ".cache/ac/imab" +DL = REPO / "toolchain/whistlegraph/downloads" + +LEAD = "7311159624588070175" +TAKES_ALL = sorted(p.name for p in (WORK / "takes").iterdir() if (p / "audio.mp3").exists()) + +SR, HOP = 22050, 256 +FMIN, FMAX = 65.0, 900.0 # G#2 registers exist (7335…) — reach down to C2 +PC_TOL, STRICT_TOL = 60, 60 # cents + +NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] +LOW = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] # octave below tonic (our convention) +MID = ["c", "c#", "d", "d#", "e", "f", "f#", "g", "g#", "a", "a#", "b"] # notepat first octave +HIGH = ["h", "h#", "i", "i#", "j", "k", "k#", "l", "l#", "m", "m#", "n"] # notepat second octave + + +def to_midi(name): + m = re.match(r"^([A-G]#?)(-?\d)$", name) + return (int(m.group(2)) + 1) * 12 + NAMES.index(m.group(1)) + + +def nname(m): + m = int(round(m)) + return NAMES[m % 12] + str(m // 12 - 1) + + +def tonic_name_(m): + """C3 +40¢ style — the register a take was really sung in.""" + r = int(round(m)); c = int(round((m - r) * 100)) + return nname(r) + (f" {c:+d}¢" if c else "") + + +def degree_letter(deg): + """semitones above the fitted tonic → notepat letter (uppercase = octave below).""" + if deg is None: + return "?" + if deg < -12 or deg >= 24: + return "!" + if deg < 0: + return LOW[deg % 12] + if deg < 12: + return MID[deg] + return HIGH[deg - 12] + + +MELODY = json.load(open(LANE / "melody.json")) +SYLLS = MELODY["sylls"] +TARGET = np.array([to_midi(s["note"]) for s in SYLLS], dtype=float) +TONIC = to_midi(MELODY["tonic"]) +TEMPLATE_WORDS = ["i'm", "a", "butterfly", "flapping", "for", "you", "guys", + "just", "a", "costume", "i", "put", "on", "in", "my", "room"] +# syllable count per template word, from melody.json +SYL_PER_WORD = [sum(1 for s in SYLLS if s["wi"] == wi) for wi in range(len(TEMPLATE_WORDS))] + + +# ── audio + pitch ──────────────────────────────────────────────────────── + +def load_take(take): + y, _ = librosa.load(WORK / "takes" / take / "audio.mp3", sr=SR, mono=True) + return y + + +def pitch_track(y): + """WORLD harvest + stonemask, 5 ms frames. pyin voices only a third of a + demucs stem's sung frames; harvest voices ~85% (measured on the lead, + 2026-09-13) and is what the rest of the lane's WORLD chain hears.""" + import pyworld as pw + y64 = y.astype(np.float64) + f0, t = pw.harvest(y64, SR, f0_floor=FMIN, f0_ceil=FMAX, frame_period=5.0) + f0 = pw.stonemask(y64, f0, t, SR) + # breath and room: drop frames quieter than a tenth of the sung level + rms = librosa.feature.rms(y=y, frame_length=1024, hop_length=int(SR * 0.005))[0] + rms = np.interp(t, np.arange(len(rms)) * 0.005, rms) + voiced = f0 > 0 + gate = rms > 0.1 * np.median(rms[voiced]) if voiced.any() else voiced + midi = np.full(f0.shape, np.nan) + ok = voiced & gate + midi[ok] = 69 + 12 * np.log2(f0[ok] / 440.0) + return t, midi + + +# ── boundaries ─────────────────────────────────────────────────────────── + +def _read_bounds(name): + p = LANE / name + if not p.exists(): + return None + by = {} + for s in json.load(open(p))["sylls"]: + by.setdefault((s["label"], s["wi"]), s) + return [([b["fromMs"], b["toMs"]] if (b := by.get((s["label"], s["wi"]))) else None) for s in SYLLS] + + +def bounds_drawn(take): + """@jeffrey's hand in the SET timebase (vocalset's stretched, note-locked wav).""" + name = f"processed-boundaries-{take}.json" + b = _read_bounds(name) + return (b, name) if b else (None, None) + + +def bounds_drawn_raw(take): + """@jeffrey's hand in the RAW timebase (the wizard run on the stem itself).""" + name = f"boundaries-drawn-{take}.json" + b = _read_bounds(name) + return (b, name) if b else (None, None) + + +def norm_word(w): + return re.sub(r"[^a-z']", "", w.lower()) + + +def bounds_syllnote(take): + p = DL / f"whistlegraph-{take}.syllnote.json" + if not p.exists(): + return None + words = json.load(open(p))["words"] + got = [norm_word(w["text"]) for w in words] + sm = difflib.SequenceMatcher(a=TEMPLATE_WORDS, b=got, autojunk=False) + wmap = {} + for tag, i1, i2, j1, j2 in sm.get_opcodes(): + if tag == "equal": + for k in range(i2 - i1): + wmap[i1 + k] = j1 + k + elif tag == "replace" and (i2 - i1) == (j2 - j1): + for k in range(i2 - i1): + wmap[i1 + k] = j1 + k + out = [] + for wi, n in enumerate(SYL_PER_WORD): + if wi not in wmap: + out += [None] * n + continue + w = words[wmap[wi]] + a, b = w["fromMs"], w["toMs"] + nuc = w.get("nuclei") or [] + if n == 1: + out.append([a, b]) + elif len(nuc) == n: + edges = [a] + [int(round(x["startSec"] * 1000)) for x in nuc[1:]] + [b] + out += [[edges[i], edges[i + 1]] for i in range(n)] + else: + step = (b - a) / n + out += [[int(a + i * step), int(a + (i + 1) * step)] for i in range(n)] + return out + + +def dtw_features(y): + hop = 512 + mf = librosa.feature.mfcc(y=y, sr=SR, n_mfcc=20, hop_length=hop) + mf = (mf - mf.mean(1, keepdims=True)) / (mf.std(1, keepdims=True) + 1e-6) + rms = librosa.feature.rms(y=y, hop_length=hop) + rms = np.log(rms + 1e-5) + rms = (rms - rms.mean()) / (rms.std() + 1e-6) + return np.vstack([mf, rms * 2.0]), hop + + +def bounds_dtw(lead_y, lead_bounds, y): + """warp the lead's drawn boundaries onto this take (ms → ms).""" + X, hop = dtw_features(lead_y) + Y, _ = dtw_features(y) + _, wp = librosa.sequence.dtw(X=X, Y=Y, metric="euclidean") + wp = wp[::-1] # (lead_frame, take_frame), ascending + lead_f, take_f = wp[:, 0], wp[:, 1] + # for each lead frame, the median take frame it maps to (a monotone function) + n = X.shape[1] + mapped = np.zeros(n) + for i in range(n): + sel = take_f[lead_f == i] + mapped[i] = np.median(sel) if len(sel) else (mapped[i - 1] if i else 0) + mapped = np.maximum.accumulate(mapped) + + def warp_ms(ms): + fr = ms / 1000 * SR / hop + i0 = int(np.clip(np.floor(fr), 0, n - 1)); i1 = int(np.clip(i0 + 1, 0, n - 1)) + frac = fr - i0 + m = mapped[i0] * (1 - frac) + mapped[i1] * frac + return int(round(m * hop / SR * 1000)) + + return [[warp_ms(a), warp_ms(b)] if b else None for a, b in + [(x[0], x[1]) if x else (None, None) for x in lead_bounds]] + + +def bounds_notealign(wav): + """neo's forced alignment: DTW the written note sequence against the f0 + track; every syllable gets a contiguous span. It finds WHERE, given the + melody — so its verdicts lean toward agreement and are shown as such.""" + import subprocess, tempfile + notes = [{"label": s_["label"], "note": s_["note"]} for s_ in SYLLS] + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: + json.dump(notes, f); nf = f.name + try: + r = subprocess.run([sys.executable, str(HERE / "notealign.py"), str(wav), nf], + capture_output=True, text=True, timeout=300) + data = json.loads(r.stdout or "{}") + rows = data.get("syllables", data) if isinstance(data, dict) else data + except Exception as e: # noqa: BLE001 — a proposal, never fatal + print(f" (notealign failed: {e})"); rows = [] + finally: + os.unlink(nf) + if len(rows) != len(SYLLS): + return None + return [[int(round(r_["fromMs"])), int(round(r_["toMs"]))] for r_ in rows] + + +def raw_spec_png(take, y): + """log-frequency spectrogram strip of the raw stem at 260 px/s (the wizard's scale).""" + from PIL import Image + out = WORK / "takes" / take / "raw-spec.png" + if out.exists(): + return str(out) + S = np.abs(librosa.stft(y, n_fft=2048, hop_length=int(SR / 260))) + S = librosa.amplitude_to_db(S, ref=np.max) + # keep 60 Hz … 4 kHz on a log axis, 520 px tall like the wizard + freqs = librosa.fft_frequencies(sr=SR, n_fft=2048) + lo, hi = np.log2(60), np.log2(4000) + rows = np.logspace(lo, hi, 520, base=2) + idx = np.clip(np.searchsorted(freqs, rows), 0, len(freqs) - 1) + img = S[idx][::-1] # high frequencies on top + img = np.clip((img + 70) / 70, 0, 1) # −70 dB … 0 dB + # the wizard's ember palette: black → deep red → orange → white + r = np.clip(img * 2.2, 0, 1); g = np.clip(img * 1.4 - 0.4, 0, 1); b = np.clip(img * 2.0 - 1.4, 0, 1) + rgb = (np.stack([r, g, b], -1) * 255).astype(np.uint8) + Image.fromarray(rgb).save(out) + return str(out) + + +def raw_audio_path(take): + """the performance itself: demucs vocal stem if we have it, else the raw wav.""" + for cand in (WORK / "sep/htdemucs" / f"whistlegraph-{take}" / "vocals.wav", + DL / f"whistlegraph-{take}.wav", DL / f"{take}.wav"): + if cand.exists(): + return cand + return None + + +# ── measurement ────────────────────────────────────────────────────────── + +def measure(t, midi, bounds): + rows = [] + for b in bounds: + if not b: + rows.append(None); continue + t0, t1 = b[0] / 1000 + 0.02, max(b[1] / 1000, b[0] / 1000 + 0.12) + sel = (t >= t0) & (t <= t1) & np.isfinite(midi) + m = midi[sel] + if len(m) < 3: + rows.append({"n": int(len(m)), "median": None}); continue + k = max(1, min(5, len(m) // 4)) + rows.append({ + "n": int(len(m)), "voiced": float(len(m) / max(1, ((t >= t0) & (t <= t1)).sum())), + "median": float(np.median(m)), "p20": float(np.percentile(m, 20)), + "p80": float(np.percentile(m, 80)), + "start": float(np.median(m[:k])), "end": float(np.median(m[-k:])), + }) + return rows + + +def fit_register(rows): + """integer transposition k of the target that best matches the sung medians. + pitch-class first (octave-blind), then the octave the majority actually sang.""" + meas = np.array([r["median"] if r and r["median"] is not None else np.nan for r in rows]) + w = np.array([r["n"] if r and r["median"] is not None else 0 for r in rows], dtype=float) + ok = np.isfinite(meas) + if ok.sum() == 0: + return 0, {} + best = None + for k in range(-36, 13): + diff = meas[ok] - (TARGET[ok] + k) + pc = np.abs(((diff + 6) % 12) - 6) + cost_pc = float((pc * w[ok]).sum()) + cost_strict = float((np.minimum(np.abs(diff), 6.0) * w[ok]).sum()) + cand = (round(cost_pc, 3), cost_strict, k) + if best is None or cand < best: + best = cand + # among k's tied on pitch class (k, k±12 …), the strict cost picked the octave + k = best[2] + # refine to cents: the weighted median of the wrapped residuals is the + # tonic the singer actually held, which is rarely a whole semitone + diff = meas[ok] - (TARGET[ok] + k) + resid = ((diff + 6) % 12) - 6 + order = np.argsort(resid) + cw = np.cumsum(w[ok][order]) + shift = float(resid[order][np.searchsorted(cw, cw[-1] / 2)]) + kf = k + shift + return kf, {"cost_pc": best[0], "cost_strict": best[1], "shift_cents": int(round(shift * 100))} + + +def judge(rows, k): + out = [] + for i, r in enumerate(rows): + tgt = TARGET[i] + k + if not r or r["median"] is None: + out.append({"i": i, "label": SYLLS[i]["label"], "wi": SYLLS[i]["wi"], + "target": float(tgt), "targetName": nname(tgt), + "sung": None, "cents": None, "pcCents": None, + "verdict": "unvoiced", "letter": "?", "truth": degree_letter(int(TARGET[i] - TONIC))}) + continue + diff = r["median"] - tgt + cents = diff * 100 + pc = ((diff + 6) % 12) - 6 + pc_cents = pc * 100 + strict = abs(cents) <= STRICT_TOL + pchit = abs(pc_cents) <= PC_TOL + if strict: + v = "hit" + elif pchit: + v = "octave" # right note, wrong octave + else: + v = "miss" + deg = int(round(r["median"] - (TONIC + k))) # k may be fractional + out.append({"i": i, "label": SYLLS[i]["label"], "wi": SYLLS[i]["wi"], + "target": float(tgt), "targetName": nname(tgt), + "sung": r["median"], "sungName": nname(r["median"]), + "p20": r["p20"], "p80": r["p80"], "start": r["start"], "end": r["end"], + "n": r["n"], "voiced": r["voiced"], + "cents": int(round(cents)), "pcCents": int(round(pc_cents)), + "octaves": int(round((diff - pc) / 12)), + "verdict": v, "letter": degree_letter(deg), + "truth": degree_letter(int(TARGET[i] - TONIC))}) + return out + + +def line_of(js, key="letter"): + parts, cur_wi = [], None + for j in js: + parts.append(j[key]) + # group as the notepat line: 5 · 5 · 4 · 6 + groups = [parts[0:5], parts[5:10], parts[10:14], parts[14:20]] + return " · ".join(" ".join(g) for g in groups) + + +# ── main ───────────────────────────────────────────────────────────────── + +def main(): + if "--html-only" in sys.argv: # re-skin the page from the last run's measurements + write_html(json.load(open(OUT / "melodyproof.json"))) + print(f"✓ {OUT / 'melodyproof.html'} (from melodyproof.json)"); return + takes = [a for a in sys.argv[1:] if not a.startswith("--")] or TAKES_ALL + print(f"melody: {MELODY['notepat']} (tonic {MELODY['tonic']})\n") + + lead_y = load_take(LEAD) + lead_bounds, lead_src = bounds_drawn(LEAD) + if lead_bounds is None: + sys.exit("the lead take has no drawn boundaries — nothing to warp from") + + report = {"melody": MELODY, "generated": __import__("datetime").datetime.now().isoformat(timespec="seconds"), + "lead": LEAD, "takes": []} + for take in takes: + y = lead_y if take == LEAD else load_take(take) + dur = len(y) / SR + t, midi = pitch_track(y) + + sources = {} + drawn, drawn_name = bounds_drawn(take) + if drawn: + sources["drawn"] = {"bounds": drawn, "file": drawn_name} + if take != LEAD: + dtw = bounds_dtw(lead_y, lead_bounds, y) + sources["dtw"] = {"bounds": dtw, "file": f"boundaries-dtw-{take}.json"} + json.dump({"take": take, "derived": report["generated"], + "method": f"DTW (MFCC+RMS) of the lead take's {lead_src} onto this take", + "sylls": [{"label": s["label"], "wi": s["wi"], "fromMs": b[0], "toMs": b[1], "fLo": 0.15, "fHi": 0.9} + for s, b in zip(SYLLS, dtw) if b]}, + open(LANE / f"boundaries-dtw-{take}.json", "w"), indent=1) + sn = bounds_syllnote(take) + if sn: + sources["syllnote"] = {"bounds": sn, "file": f"whistlegraph-{take}.syllnote.json"} + + primary = "drawn" if "drawn" in sources else "dtw" + # register is fitted ONCE per take, on the primary boundaries, then reused + rows_primary = measure(t, midi, sources[primary]["bounds"]) + k, fit = fit_register(rows_primary) + tonic_name = tonic_name_(TONIC + k) + + print(f"── take {take} ({dur:.1f}s) · tonic sung ≈ {tonic_name} (k={k:+.2f})") + for name, src in sources.items(): + rows = measure(t, midi, src["bounds"]) + js = judge(rows, k) + src["judged"] = js + src["hits"] = sum(1 for j in js if j["verdict"] == "hit") + src["octave"] = sum(1 for j in js if j["verdict"] == "octave") + src["miss"] = sum(1 for j in js if j["verdict"] == "miss") + src["unvoiced"] = sum(1 for j in js if j["verdict"] == "unvoiced") + flag = "★" if name == primary else " " + print(f" {flag} {name:<9} hit {src['hits']:>2} octave {src['octave']:>2} miss {src['miss']:>2} unvoiced {src['unvoiced']:>2}") + print(f" truth {line_of(js, 'truth')}") + print(f" sung {line_of(js, 'letter')}") + # per-syllable table for the primary + js = sources[primary]["judged"] + print(f" {'syll':<6}{'target':>7}{'sung':>7}{'cents':>7} verdict") + for j in js: + if j["sung"] is None: + print(f" {j['label']:<6}{j['targetName']:>7}{'—':>7}{'—':>7} unvoiced"); continue + mark = {"hit": "✓", "octave": f"↕ {j['octaves']:+d} oct", "miss": "✗"}[j["verdict"]] + print(f" {j['label']:<6}{j['targetName']:>7}{j['sungName']:>7}{j['cents']:>+6}¢ {mark}") + print() + + def curve_of(tt_, mm_, step=4): + return [[round(float(a), 3), (round(float(b), 2) if np.isfinite(b) else None)] + for a, b in zip(tt_[::step], mm_[::step])] + curve = curve_of(t, midi) + + # ── RAW stage: the performance, boundaries warped back from the set ── + raw = None + rp = raw_audio_path(take) + if rp is not None: + ry, _ = librosa.load(rp, sr=SR, mono=True) + rt, rmidi = pitch_track(ry) + rsources = {} + rdrawn, rdrawn_name = bounds_drawn_raw(take) + if rdrawn: + rsources["drawn"] = {"bounds": rdrawn, "file": rdrawn_name} + rsources["dtw←set"] = {"bounds": bounds_dtw(y, sources[primary]["bounds"], ry), + "file": f"{primary} bounds of the set, DTW'd onto {rp.name}"} + na = bounds_notealign(rp) + if na: + rsources["notealign"] = {"bounds": na, "file": "notealign.py — the WRITTEN melody forced onto the f0 track (a proposal; biased to agree)"} + # the primary is the boundary set that lands on the most sung frames — + # a box on silence is wrong whatever the melody says; ties keep the + # hand first, then the timbre warp, then the forced alignment. + order = {"drawn": 0, "dtw←set": 1, "notealign": 2} + def coverage(name): + rows_ = measure(rt, rmidi, rsources[name]["bounds"]) + return (sum(1 for r_ in rows_ if r_ and r_["median"] is not None), + sum(r_["n"] for r_ in rows_ if r_ and r_["median"] is not None), -order[name]) + rprimary = max(rsources, key=coverage) + rk, rfit = fit_register(measure(rt, rmidi, rsources[rprimary]["bounds"])) + for name, src in rsources.items(): + js_ = judge(measure(rt, rmidi, src["bounds"]), rk) + src.update(judged=js_, + hits=sum(1 for j in js_ if j["verdict"] == "hit"), + octave=sum(1 for j in js_ if j["verdict"] == "octave"), + miss=sum(1 for j in js_ if j["verdict"] == "miss"), + unvoiced=sum(1 for j in js_ if j["verdict"] == "unvoiced")) + raw = {"audio": str(rp), "spec": raw_spec_png(take, ry), + "duration": round(len(ry) / SR, 3), "k": rk, + "tonic": tonic_name_(TONIC + rk), "fit": rfit, "curve": curve_of(rt, rmidi), + "sources": rsources, "primary": rprimary} + print(f" RAW {rp.name} · tonic sung ≈ {raw['tonic']} (k={rk:+.2f})") + for name, R in rsources.items(): + flag = "★" if name == rprimary else " " + print(f" {flag} {name:<9} hit {R['hits']:>2} octave {R['octave']:>2} miss {R['miss']:>2} unvoiced {R['unvoiced']:>2}") + print(f" truth {line_of(R['judged'], 'truth')}") + print(f" sung {line_of(R['judged'], 'letter')}") + rjs = rsources[rprimary]["judged"] + print(f" {'syll':<6}{'target':>7}{'sung':>7}{'cents':>7} verdict") + for j in rjs: + if j["sung"] is None: + print(f" {j['label']:<6}{j['targetName']:>7}{'—':>7}{'—':>7} unvoiced"); continue + mark = {"hit": "✓", "octave": f"↕ {j['octaves']:+d} oct", "miss": "✗"}[j["verdict"]] + print(f" {j['label']:<6}{j['targetName']:>7}{j['sungName']:>7}{j['cents']:>+6}¢ {mark}") + print() + else: + print(" (no raw audio locally — set stage only)\n") + + report["takes"].append({ + "raw": raw, + "take": take, "duration": round(dur, 3), "k": k, "tonic": tonic_name, "fit": fit, + "primary": primary, "isLead": take == LEAD, + "audio": str(WORK / "takes" / take / "audio.mp3"), + "spec": str(WORK / "takes" / take / "spec.png"), + "curve": curve, + "sources": {n: {kk: vv for kk, vv in s.items()} for n, s in sources.items()}, + }) + + OUT.mkdir(exist_ok=True) + json.dump(report, open(OUT / "melodyproof.json", "w")) + write_html(report) + print(f"✓ {OUT / 'melodyproof.html'}") + + +# ── the page ───────────────────────────────────────────────────────────── + +def write_html(report): + tpl = open(HERE / "melodyproof.html.tpl").read() + html = tpl.replace("/*DATA*/", json.dumps(report)) + open(OUT / "melodyproof.html", "w").write(html) + + +if __name__ == "__main__": + main() diff --git a/pop/imab/bin/notealign.py b/pop/imab/bin/notealign.py new file mode 100755 index 0000000000..3e0076f657 --- /dev/null +++ b/pop/imab/bin/notealign.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""notealign.py — align a KNOWN sung melody to a recording, and read the +syllable boundaries off the alignment. + +Whisper cannot judge these takes: it guesses words, and sung melisma +defeats it (it has snapped "guys" onto "just"'s audio and shrunk "room" +to 15ms). But we are not missing the words — we know exactly what is +sung and on which notes. What is unknown is WHERE each syllable sits. +That makes this a forced alignment, and for singing the honest feature +is pitch: DTW the measured f0 track against the written note sequence, +monotonically, and every syllable gets a contiguous span by +construction. A window can then never sit on a note the singer did not +sing there — which is the failure that flattens the flare and makes +cos→tume read as a rise. + + pop/.venv/bin/python notealign.py [--offset auto|N] + notes.json [{label, note}] in sung order (e.g. C4, C5 …) + → stdout [{label, note, fromMs, toMs}] + +The written octave need not match the singer's: the transposition is +searched, which is how a hook written at C4 aligns to a take sung at C3. +""" +import json, sys +import numpy as np +import librosa + +NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] + + +def to_midi(name): + import re + m = re.match(r"^([A-G]#?)(-?\d)$", name) + return (int(m.group(2)) + 1) * 12 + NAMES.index(m.group(1)) + + +wav, notesf = sys.argv[1], sys.argv[2] +notes = json.load(open(notesf)) +targets = np.array([to_midi(n["note"]) for n in notes], dtype=float) +K = len(targets) + +y, sr = librosa.load(wav, sr=22050, mono=True) +HOP = 256 +f0, voiced, _ = librosa.pyin(y, sr=sr, fmin=70, fmax=700, + frame_length=2048, hop_length=HOP) +times = librosa.times_like(f0, sr=sr, hop_length=HOP) * 1000.0 +ok = voiced & np.isfinite(f0) +semi = np.full(f0.shape, np.nan) +semi[ok] = 69 + 12 * np.log2(f0[ok] / 440.0) + +# trim to the sung span so leading/trailing silence cannot absorb notes +vidx = np.flatnonzero(ok) +if vidx.size < K * 2: + print("[]") + sys.exit(0) +lo, hi = vidx[0], vidx[-1] +sl = slice(lo, hi + 1) +s, t, v = semi[sl], times[sl], ok[sl] +T = s.size + +UNVOICED = 0.9 # a rest costs a little, so notes are not parked in silence +CAP = 5.0 # a wildly wrong frame should not dominate the path +MIN_FR = max(2, int(round(0.06 * sr / HOP))) # 60ms floor per syllable + + +def align(offset): + tg = targets + offset + cost = np.empty((T, K)) + d = np.abs(s[:, None] - tg[None, :]) + np.minimum(d, CAP, out=d) + cost[:] = d + cost[~v, :] = UNVOICED + D = np.full((T, K), np.inf) + P = np.zeros((T, K), dtype=np.int8) + D[0, 0] = cost[0, 0] + for i in range(1, T): + stay = D[i - 1] + adv = np.concatenate(([np.inf], D[i - 1, :-1])) + take_adv = adv < stay + D[i] = np.where(take_adv, adv, stay) + cost[i] + P[i] = take_adv + return D[T - 1, K - 1], D, P + + +best = None +for off in range(-24, 25): + total, D, P = align(off) + if best is None or total < best[0]: + best = (total, off, D, P) +_, off, D, P = best + +# backtrack +k = K - 1 +bounds = [T] +for i in range(T - 1, 0, -1): + if P[i, k]: + bounds.append(i) + k -= 1 + if k < 0: + break +bounds.append(0) +bounds = sorted(set(bounds)) +while len(bounds) < K + 1: + bounds.append(T) +bounds = bounds[:K + 1] + +# Pitch cannot separate syllables written on the SAME note — but, ter, +# fly and flap are four C4s in a row, so the path is free to park three +# of them in slivers and let the fourth eat four seconds. Within each run +# of equal targets there is no pitch evidence, so fall back to the +# evidence that does exist there: onsets. Re-cut each run at its +# strongest attacks. +onsets = librosa.onset.onset_strength(y=y, sr=sr, hop_length=HOP)[sl] +i = 0 +while i < K: + j = i + while j + 1 < K and targets[j + 1] == targets[i]: + j += 1 + n = j - i + 1 + if n > 1: + a, b = bounds[i], bounds[j + 1] + if b - a >= n * MIN_FR: + seg = onsets[a:b].copy() + picks = [] + for _ in range(n - 1): + order = np.argsort(seg)[::-1] + for p in order: + ms = a + int(p) + if ms - a < MIN_FR or b - ms < MIN_FR: + continue + if any(abs(ms - q) < MIN_FR for q in picks): + continue + picks.append(ms) + seg[max(0, int(p) - MIN_FR):int(p) + MIN_FR] = -1 + break + else: + break + if len(picks) == n - 1: + for m, ms in enumerate(sorted(picks)): + bounds[i + 1 + m] = ms + i = j + 1 + +# enforce a minimum syllable length so a note cannot collapse to nothing +for i in range(1, K): + if bounds[i] - bounds[i - 1] < MIN_FR: + bounds[i] = min(bounds[i - 1] + MIN_FR, T) +for i in range(K - 1, 0, -1): + if bounds[i + 1] - bounds[i] < MIN_FR: + bounds[i] = max(bounds[i + 1] - MIN_FR, 0) + +out = [] +for i, n in enumerate(notes): + a, b = bounds[i], min(bounds[i + 1], T - 1) + seg = s[a:b + 1][v[a:b + 1]] + out.append({"label": n["label"], "note": n["note"], + "fromMs": round(float(t[a]), 1), "toMs": round(float(t[b]), 1), + "sungMidi": round(float(np.median(seg)), 2) if seg.size else None}) +json.dump({"offset": int(off), "syllables": out}, sys.stdout) diff --git a/pop/imab/bin/setalign.mjs b/pop/imab/bin/setalign.mjs new file mode 100644 index 0000000000..bb5e5d4139 --- /dev/null +++ b/pop/imab/bin/setalign.mjs @@ -0,0 +1,354 @@ +#!/usr/bin/env node +// setalign.mjs — make every vocal set speak in the LEAD's rhythm. +// +// A regulated set gets one global tempo fit, so it keeps the natural +// spacing of its own performance: take 7427 sings the hook in 9.0s while +// the lead, retimed onto the grid, takes 14.5s. Stacked, they smear. +// This warps each set piecewise so its syllable onsets land on the +// lead's — same law as lyrictrack's dictation retime, but the +// destination is the first take instead of a grid address. +// +// node pop/imab/bin/setalign.mjs [--takes a,b] [--no-audition] +// → out/imab-set--aligned.wav one per set, lead-timed +// → out/imab-sets-successive.mp3 lead then each set, in turn, +// over a click — no layering, so every take can be judged alone. +// +// The shift table prints how far each syllable had to travel: a take +// that needs a big, lopsided pull is one whose boundaries are wrong, +// not one that merely sings loose. + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const LANE = resolve(HERE, ".."); +const OUT = `${LANE}/out`; +const WORK = `${process.env.HOME}/.cache/ac/imab`; +mkdirSync(WORK, { recursive: true }); +const argv = process.argv.slice(2); +const flag = (n, d) => { const i = argv.indexOf(`--${n}`); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; }; +const sh = (c, a) => spawnSync(c, a, { stdio: ["ignore", "ignore", "inherit"] }); +const SR = 48000, BPM = 124, BEAT = 60 / BPM, BAR = 4 * BEAT; + +const dur = (f) => parseFloat(spawnSync("ffprobe", + ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", f], + { encoding: "utf8" }).stdout); +const readF32 = (f) => { + const raw = `${WORK}/.sa.f32`; + sh("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-i", f, + "-f", "f32le", "-ac", "1", "-ar", String(SR), raw]); + const b = readFileSync(raw); + return new Float32Array(b.buffer.slice(b.byteOffset, b.byteOffset + Math.floor(b.length / 4) * 4)); +}; + +// ── the lead's syllable clock ───────────────────────────────────────── +const LEADJ = `${OUT}/imab-sacredvox.lyrics.json`; +if (!existsSync(LEADJ)) { console.error("✗ run lyrictrack.mjs first"); process.exit(1); } +const lead = JSON.parse(readFileSync(LEADJ, "utf8")).syllables; +const LEADWAV = ["imab-aesthetivox-retimed.wav", "imab-aesthetivox.wav", "imab-sacredvox.wav"] + .map((n) => `${OUT}/${n}`).find(existsSync); +console.log(`lead ${LEADWAV.split("/").pop()} · ${lead.length} syllables · ` + + `${lead[0].label}@${lead[0].fromMs}ms → ${lead[lead.length - 1].label}@${lead[lead.length - 1].fromMs}ms`); + +const sdoc = JSON.parse(readFileSync(`${LANE}/vocal-sets.json`, "utf8")); +const listed = [...new Set([...(sdoc.sets ?? []).map((s) => s.take), + ...(sdoc.choir?.takes ?? [])])]; +const TAKES = flag("takes") ? flag("takes").split(",") : listed; + +// ── align one set onto the lead ─────────────────────────────────────── +const aligned = []; +for (const take of TAKES) { + const wav = `${OUT}/imab-set-${take}.wav`; + const tj = `${OUT}/imab-set-${take}-targets.json`; + if (!existsSync(wav) || !existsSync(tj)) { + console.log(` (${take.slice(0, 6)}… not regulated — skipped)`); continue; + } + const set = JSON.parse(readFileSync(tj, "utf8")); + // pair by label, walking forward: a set's extra word (both alternate + // takes sing "it's" before "just") simply carries between anchors. + const pairs = []; + let si = 0; + for (const L of lead) { + let k = si; + while (k < set.length && set[k].label !== L.label) k++; + if (k < set.length) { pairs.push({ label: L.label, src: set[k].t * 1000, dst: L.fromMs }); si = k + 1; } + } + if (pairs.length < 8) { console.log(` ✗ ${take.slice(0, 6)}… only ${pairs.length} syllables paired — skipped`); continue; } + const sDur = dur(wav) * 1000; + const anchors = [[0, 0]]; + for (const p of pairs) { + const [ps, pd] = anchors[anchors.length - 1]; + if (p.src > ps + 20 && p.dst > pd + 20) anchors.push([p.src, p.dst]); + } + const [ls, ld] = anchors[anchors.length - 1]; + anchors.push([sDur, ld + Math.max(120, sDur - ls)]); + // render: cut each span, stretch the ones that move, concat + const parts = []; + for (let k = 1; k < anchors.length; k++) { + const [s0, d0] = anchors[k - 1], [s1, d1] = anchors[k]; + const sd = (s1 - s0) / 1000, dd = (d1 - d0) / 1000; + if (sd < 0.002) continue; + const seg = `${WORK}/sa-${take}-${k}.wav`; + sh("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-i", wav, + "-ss", s0 / 1000, "-t", sd, "-ac", "1", "-ar", String(SR), "-c:a", "pcm_s16le", seg]); + const f = dd / sd; + if (Math.abs(f - 1) > 0.02) { + sh("rubberband", ["-t", f.toFixed(4), "-F", "-c", "6", seg, `${WORK}/sa-${take}-${k}s.wav`]); + parts.push(`${WORK}/sa-${take}-${k}s.wav`); + } else parts.push(seg); + } + const list = `${WORK}/sa-${take}.txt`; + writeFileSync(list, parts.map((p) => `file '${p}'`).join("\n")); + const final = `${OUT}/imab-set-${take}-aligned.wav`; + sh("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-f", "concat", + "-safe", "0", "-i", list, "-ac", "1", "-ar", String(SR), final]); + const shifts = pairs.map((p) => p.dst - p.src); + const worst = pairs.reduce((a, p) => Math.abs(p.dst - p.src) > Math.abs(a.dst - a.src) ? p : a, pairs[0]); + const stretch = (pairs[pairs.length - 1].dst - pairs[0].dst) / Math.max(1, pairs[pairs.length - 1].src - pairs[0].src); + console.log(`✓ ${take.slice(0, 6)}… ${pairs.length}/${lead.length} paired · phrase ×${stretch.toFixed(2)} · ` + + `shift ${Math.round(Math.min(...shifts))}…${Math.round(Math.max(...shifts))}ms · worst ${worst.label} ${Math.round(worst.dst - worst.src)}ms`); + aligned.push({ take, wav: final }); +} + +// ── successive audition: no layering, one take at a time, over a floor ─ +// Still one voice at a time — but a dry click tells you nothing about +// whether a take DANCES. The real CC0 kit plays a 124 four-on-the-floor +// under it, and each take gets a whole 16-bar phrase to arrive in +// rather than being butted against the next one. +if (!argv.includes("--no-audition") && aligned.length) { + const phrase = lead[lead.length - 1].fromMs / 1000 + 2.5; + const SLOT_BARS = Math.max(16, Math.ceil(phrase / BAR / 8) * 8); // 8-bar multiples + const INTRO_BARS = 8, OUTRO_BARS = 8; + const SLOT = SLOT_BARS * BAR; + const order = [{ take: "LEAD", wav: LEADWAV }, ...aligned]; + const bars = INTRO_BARS + order.length * SLOT_BARS + OUTRO_BARS; + const total = bars * BAR + 2; + const mix = new Float32Array(Math.ceil(total * SR)); + + const KIT = `${LANE}/samples/real`; + const kit = {}; + for (const n of ["kick", "clap", "hat-closed", "hat-open", "shaker", "snare"]) { + const p = `${KIT}/${n}.wav`; + if (existsSync(p)) kit[n] = readF32(p); + } + const hit = (name, atSec, gain) => { + const x = kit[name]; + if (!x || gain <= 0) return; + const at = Math.floor(atSec * SR); + for (let j = 0; j < x.length && at + j < mix.length; j++) mix[at + j] += x[j] * gain; + }; + // a rising noise swell — the sound of a build going somewhere + const swell = (t0s, dur, gain) => { + const at = Math.floor(t0s * SR), n = Math.floor(dur * SR); + let lp = 0; + for (let i = 0; i < n && at + i < mix.length; i++) { + const k = i / n; + const white = Math.random() * 2 - 1; + lp += (white - lp) * (0.02 + 0.5 * k); // filter opens as it rises + mix[at + i] += lp * gain * k * k; + } + }; + + // Entries and builds: every take arrives at a phrase top, and the two + // bars before it climb — the roll tightens, the swell opens, and the + // kick drops out of the last bar so the downbeat lands. + const entryBars = order.map((_, i) => INTRO_BARS + i * SLOT_BARS); + const buildAt = (b) => (entryBars.includes(b + 1) ? 1 : entryBars.includes(b + 2) ? 0.5 : 0); + + for (let b = 0; b < bars; b++) { + const t0 = b * BAR; + const intro = b < INTRO_BARS; + const lift = b % 8 === 7; // phrase turn + const bl = buildAt(b); + const busy = (b % SLOT_BARS) >= SLOT_BARS / 2; // second half of a slot opens up + const entry = entryBars.includes(b); + if (entry) { // the drop + hit("hat-open", t0, 0.75); + hit("clap", t0, 0.8); + for (let i = 0; i < Math.floor(0.5 * SR); i++) // sub boom + mix[Math.floor(t0 * SR) + i] += Math.sin(2 * Math.PI * 48 * i / SR) * + Math.exp(-i / (0.16 * SR)) * 0.5; + } + for (let beat = 0; beat < 4; beat++) { + const t = t0 + beat * BEAT; + // kick: absent from the first 4 intro bars and from the final build bar + if (!(intro && b < 4) && bl < 1) hit("kick", t, 0.95); + if ((beat === 1 || beat === 3) && !intro && bl === 0) hit("clap", t, 0.55); + if (bl === 0) { + hit("hat-closed", t + BEAT / 2, lift && beat === 3 ? 0 : 0.34); + if (busy) hit("hat-closed", t + BEAT / 4, 0.16); // 16ths in the busy half + } + if (lift && beat === 3 && bl === 0) hit("hat-open", t + BEAT / 2, 0.42); + for (let s = 0; s < 4; s++) hit("shaker", t + (s * BEAT) / 4, s % 2 ? 0.10 : 0.16); + } + if (bl > 0) { // the roll, accelerating + const div = bl === 1 ? 16 : 8; // 16ths, then 32nds + for (let i = 0; i < div; i++) { + const k = i / div; + hit("snare", t0 + (i * BAR) / div, 0.16 + 0.42 * k * bl); + } + if (bl === 1) + for (let i = 0; i < 8; i++) // final beat doubles up + hit("snare", t0 + BAR * 0.75 + (i * BEAT) / 8, 0.3 + 0.4 * (i / 8)); + swell(t0, BAR, 0.10 + 0.14 * bl); + } + } + + // ── chords ────────────────────────────────────────────────────────── + // An 8-bar loop the hook already implies: C under the chant, F where + // "a" sings F4, G where "just" sings G4, Am for the turn. Offbeat + // stabs carry the dance, a pad holds the bar underneath, and both ride + // the pump so the kick keeps the floor. + const HZ = (m) => 440 * Math.pow(2, (m - 69) / 12); + const PROG = [ + { name: "C", bass: 36, notes: [48, 52, 55] }, + { name: "C", bass: 36, notes: [48, 52, 55] }, + { name: "F", bass: 41, notes: [48, 53, 57] }, + { name: "F", bass: 41, notes: [48, 53, 57] }, + { name: "Am", bass: 45, notes: [48, 52, 57] }, + { name: "Am", bass: 45, notes: [48, 52, 57] }, + { name: "G", bass: 43, notes: [50, 55, 59] }, + { name: "G", bass: 43, notes: [50, 55, 59] }, + ]; + // sidechain: everything harmonic breathes under the four-on-the-floor + const pump = (t) => 1 - 0.55 * Math.exp(-(((t / BEAT) % 1) * BEAT) / 0.16); + const tone = (midi, t, dur, gain, harm, tau, attack) => { + const f = HZ(midi), at = Math.floor(t * SR), n = Math.floor(dur * SR); + for (let i = 0; i < n && at + i < mix.length; i++) { + const s = i / SR; + const e = Math.exp(-s / tau) * (1 - Math.exp(-s / attack)); + let v = 0; + for (let h = 1; h <= harm; h++) v += Math.sin(2 * Math.PI * f * h * (i / SR)) / h; + mix[at + i] += (v / harm) * e * gain * pump(t + s); + } + }; + for (let b = 0; b < bars; b++) { + const c = PROG[b % PROG.length], t0 = b * BAR; + const intro = b < 4; + for (const m of c.notes) tone(m, t0, BAR, intro ? 0.09 : 0.13, 5, 1.3, 0.25); // pad + const bl2 = buildAt(b); + if (!intro && bl2 === 0) { + for (let beat = 0; beat < 4; beat++) // offbeat stabs + for (const m of c.notes) tone(m + 12, t0 + beat * BEAT + BEAT / 2, 0.30, 0.085, 7, 0.10, 0.003); + // BASS with somewhere to go: root on the beat, the octave answering + // offbeat, and the fifth pushing into the back half of the bar. + const busy2 = (b % SLOT_BARS) >= SLOT_BARS / 2; + for (let beat = 0; beat < 4; beat++) { + const t = t0 + beat * BEAT; + tone(c.bass, t, BEAT * 0.52, 0.40, 2, 0.16, 0.004); // sub root + tone(c.bass + 12, t + BEAT / 2, BEAT * 0.36, 0.20, 3, 0.09, 0.003); // octave push + if (busy2 && (beat === 1 || beat === 3)) + tone(c.bass + 7, t + BEAT * 0.75, BEAT * 0.22, 0.16, 3, 0.07, 0.003); // fifth + } + } else if (!intro && bl2 === 1) { + for (const m of c.notes) tone(m + 12, t0, BAR, 0.10, 6, 1.6, 0.5); // held under the build + } + } + + console.log("\nsuccessive audition (124 · four-on-the-floor · C C F F Am Am G G · " + + `${SLOT_BARS}-bar slots · ${bars} bars ≈ ${(bars * BAR / 60).toFixed(1)}min):`); + // ── the vocal bus: pushed, not merely placed ──────────────────────── + // Level alone reads as loud, not as POP. The force comes from holding + // the whole phrase up (compression), opening the consonants (presence), + // driving it (saturation), and hanging it in a room big enough to sing + // back (plate). Reverb is a SEND, so the dry stays in front. + const hpf = (x, fc) => { // one-pole, clears the bass + const a = Math.exp(-2 * Math.PI * fc / SR); + const y = new Float32Array(x.length); + let px = 0, py = 0; + for (let i = 0; i < x.length; i++) { y[i] = a * (py + x[i] - px); px = x[i]; py = y[i]; } + return y; + }; + const presence = (x, f0, gainDb, q) => { // peaking EQ + const A = Math.pow(10, gainDb / 40), w = 2 * Math.PI * f0 / SR; + const al = Math.sin(w) / (2 * q), c = Math.cos(w); + const b0 = 1 + al * A, b1 = -2 * c, b2 = 1 - al * A; + const a0 = 1 + al / A, a1 = -2 * c, a2 = 1 - al / A; + const y = new Float32Array(x.length); + let x1 = 0, x2 = 0, y1 = 0, y2 = 0; + for (let i = 0; i < x.length; i++) { + const v = (b0 / a0) * x[i] + (b1 / a0) * x1 + (b2 / a0) * x2 - (a1 / a0) * y1 - (a2 / a0) * y2; + x2 = x1; x1 = x[i]; y2 = y1; y1 = v; y[i] = v; + } + return y; + }; + const compress = (x, thr, ratio, atkMs, relMs) => { + const aA = Math.exp(-1 / (atkMs / 1000 * SR)), aR = Math.exp(-1 / (relMs / 1000 * SR)); + const y = new Float32Array(x.length); + let env = 0; + for (let i = 0; i < x.length; i++) { + const v = Math.abs(x[i]); + env = v > env ? aA * env + (1 - aA) * v : aR * env + (1 - aR) * v; + let g = 1; + if (env > thr) g = (thr + (env - thr) / ratio) / env; + y[i] = x[i] * g; + } + return y; + }; + const saturate = (x, drive) => { + const y = new Float32Array(x.length); + for (let i = 0; i < x.length; i++) y[i] = Math.tanh(x[i] * drive) / Math.tanh(drive); + return y; + }; + // Freeverb-shaped plate: parallel combs into series allpasses + const plate = (x, decay, tailSec) => { + const out = new Float32Array(x.length + Math.floor(tailSec * SR)); + const combs = [1557, 1617, 1491, 1422, 1277, 1356].map((d) => Math.round(d * SR / 44100)); + const g = 0.82 * decay; + for (const d of combs) { + const buf = new Float32Array(d); + let idx = 0, lp = 0; + for (let i = 0; i < out.length; i++) { + const inp = i < x.length ? x[i] : 0; + const y = buf[idx]; + lp += (y - lp) * 0.62; // damping, so it is not brittle + buf[idx] = inp + lp * g; + out[i] += y / combs.length; + idx = (idx + 1) % d; + } + } + for (const d of [225, 556, 441].map((v) => Math.round(v * SR / 44100))) { + const buf = new Float32Array(d); + let idx = 0; + const ag = 0.7; + for (let i = 0; i < out.length; i++) { + const inp = out[i], y = buf[idx]; + out[i] = -ag * inp + y; + buf[idx] = inp + ag * y; + idx = (idx + 1) % d; + } + } + return out; + }; + + order.forEach((o, i) => { + let x = readF32(o.wav); + let peak = 0; + for (let j = 0; j < x.length; j++) peak = Math.max(peak, Math.abs(x[j])); + if (peak > 0) { const n = 0.7 / peak; for (let j = 0; j < x.length; j++) x[j] *= n; } + x = hpf(x, 95); + x = presence(x, 3200, 6.5, 0.9); // consonants forward + x = presence(x, 180, -3.0, 0.8); // clear the mud the plate would smear + x = compress(x, 0.16, 5.0, 4, 90); // hold the phrase up + x = saturate(x, 2.4); // force + for (let j = 0; j < x.length; j++) x[j] *= 1.55; + const wet = plate(x, 0.92, 2.6); + const bar = INTRO_BARS + i * SLOT_BARS; + const at = Math.floor(bar * BAR * SR); + // pre-delay keeps the word intelligible before the room answers + const pre = Math.floor(0.028 * SR); + for (let j = 0; j < x.length && at + j < mix.length; j++) mix[at + j] += x[j] * 0.92; + for (let j = 0; j < wet.length && at + pre + j < mix.length; j++) + mix[at + pre + j] += wet[j] * 0.42; + console.log(` ${(bar * BAR).toFixed(1).padStart(6)}s bar ${String(bar + 1).padStart(3)} ${o.take}`); + }); + const raw = `${WORK}/successive.f32`; + writeFileSync(raw, Buffer.from(mix.buffer)); + const mp3 = `${OUT}/imab-sets-successive.mp3`; + sh("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-f", "f32le", "-ar", String(SR), + "-ac", "1", "-i", raw, "-af", "aresample=192000,alimiter=limit=0.74,aresample=48000", + "-b:a", "192k", mp3]); + console.log(`✓ ${mp3}`); +} diff --git a/pop/imab/bin/syllsplit.py b/pop/imab/bin/syllsplit.py new file mode 100755 index 0000000000..598fd5863c --- /dev/null +++ b/pop/imab/bin/syllsplit.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""syllsplit.py — split a word window into syllables where the SINGING +actually changes note. + +The nuclei in a take's syllnote doc live in the raw timebase. Carrying +them into a stretched, hand-dictated window by proportion drifts, and a +drifted split puts the flap/PING boundary on the wrong side of the +flare — the octave collapses and the choir sings the hook flat. So +measure the stretched audio instead: pyin inside each window, smooth to +a semitone track, and cut at the largest pitch moves that are far enough +apart to be separate syllables. + + pop/.venv/bin/python syllsplit.py + req.json [{i, fromMs, toMs, k}] k = syllables wanted + → stdout {"": [onsetMs, …]} k onsets, first == fromMs + +A window whose pitch never moves has no honest split to report; it is +omitted and the caller falls back. +""" +import json, sys +import numpy as np +import librosa + +wav, reqf = sys.argv[1], sys.argv[2] +req = json.load(open(reqf)) +y, sr = librosa.load(wav, sr=22050, mono=True) +HOP = 256 +MIN_SYL_MS = 90 # a syllable shorter than this is a transient + +f0, voiced, _ = librosa.pyin(y, sr=sr, fmin=70, fmax=700, + frame_length=2048, hop_length=HOP) +times = librosa.times_like(f0, sr=sr, hop_length=HOP) * 1000.0 +semi = np.full(f0.shape, np.nan) +ok = voiced & np.isfinite(f0) +semi[ok] = 69 + 12 * np.log2(f0[ok] / 440.0) + +def flare_run(a, b): + """The highest sustained voiced run in [a,b] — the hook's flare. Returns + (fromMs, toMs, semitones) for the best >=120ms run near the top of the + region's pitch range, or None when the region never lifts.""" + sel = np.flatnonzero((times >= a) & (times <= b) & ok) + if sel.size < 8: + return None + s, t = semi[sel], times[sel] + top = np.percentile(s, 97) + if top - np.median(s) < 2.0: # the region never lifts + return None + hot = s >= top - 1.5 + best, run = None, None + for i, h in enumerate(list(hot) + [False]): + if h and run is None: + run = i + elif not h and run is not None: + if t[i - 1] - t[run] >= 120: + cand = (t[run], t[i - 1], float(np.median(s[run:i]))) + if best is None or cand[2] > best[2]: + best = cand + run = None + return best + + +out = {} +for w in req: + # flare repair: a window that claims the flapping/PING word but does + # not contain the line's high note is mislabeled — the flare landed in + # the neighbour. Report where it really is; the caller re-cuts. + if w.get("flareScanToMs"): + fr = flare_run(w["fromMs"], w["flareScanToMs"]) + if fr: + out["flare" + str(w["i"])] = [float(fr[0]), float(fr[1]), fr[2]] + k = int(w["k"]) + if k < 2: + continue + sel = (times >= w["fromMs"]) & (times <= w["toMs"]) + idx = np.flatnonzero(sel & ok) + if idx.size < 6: + continue + t = times[idx] + s = semi[idx] + # median-smooth so vibrato and octave-hopping pyin frames do not + # read as note changes + win = max(3, int(round(0.05 * sr / HOP)) | 1) + pad = win // 2 + sm = np.median(np.stack([np.pad(s, (pad, pad), mode="edge")[i:i + s.size] + for i in range(win)]), axis=0) + # candidate cuts: frame-to-frame movement in the smoothed track + jump = np.abs(np.diff(sm)) + order = np.argsort(jump)[::-1] + cuts = [] + for j in order: + if jump[j] < 0.6: # < ~a semitone is not a note change + break + ms = t[j + 1] + if ms - w["fromMs"] < MIN_SYL_MS or w["toMs"] - ms < MIN_SYL_MS: + continue + if any(abs(ms - c) < MIN_SYL_MS for c in cuts): + continue + cuts.append(ms) + if len(cuts) == k - 1: + break + if len(cuts) < k - 1: + continue + out[str(w["i"])] = [float(w["fromMs"])] + [float(c) for c in sorted(cuts)] + +json.dump(out, sys.stdout) diff --git a/pop/imab/bin/wizard-prep.py b/pop/imab/bin/wizard-prep.py new file mode 100755 index 0000000000..408006d3fa --- /dev/null +++ b/pop/imab/bin/wizard-prep.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""wizard-prep.py — lay out the table for SyllaWizard on any take. + +The wizard wants two things in ~/.cache/ac/imab: a spectrogram at 260 +px/s and a bounds-.json of machine guesses it can pre-seed as +editable rectangles. This renders both, and it seeds from notealign — +the known melody DTW'd against the measured pitch — so @jeffrey is +correcting a reading that already knows where the notes are, instead of +drawing twenty rectangles from a blank spectrogram. + + pop/.venv/bin/python wizard-prep.py [--from S] [--to S] + → ~/.cache/ac/imab/wizard-spec.png + → ~/.cache/ac/imab/bounds-.json + then: cd sylla-wizard && swift run SyllaWizard + +--from/--to bound the sung phrase; without them the whole stem is +aligned, and anything the singer says around the hook will pull the +seeds off. +""" +import json, os, subprocess, sys +import numpy as np +import librosa +from PIL import Image + +HERE = os.path.dirname(os.path.abspath(__file__)) +LANE = os.path.dirname(HERE) +WORK = os.path.expanduser("~/.cache/ac/imab") +PXS = 260 # px per second — the wizard's fixed scale +SPEC_H = 420 + +take = sys.argv[1] if len(sys.argv) > 1 else "7311159624588070175" +def opt(name, d=None): + return float(sys.argv[sys.argv.index(name) + 1]) if name in sys.argv else d +t_from, t_to = opt("--from"), opt("--to") + +stem = f"{WORK}/sep/htdemucs/whistlegraph-{take}/vocals.wav" +if not os.path.exists(stem): + sys.exit(f"missing stem {stem}") + +y, sr = librosa.load(stem, sr=22050, mono=True) +dur = len(y) / sr +print(f"take {take} · stem {dur:.2f}s") + +# ── spectrogram the wizard draws on ─────────────────────────────────── +n_fft, hop = 2048, max(1, int(round(sr / PXS))) +S = librosa.feature.melspectrogram(y=y, sr=sr, n_fft=n_fft, hop_length=hop, + n_mels=SPEC_H, fmin=60, fmax=6000) +D = librosa.power_to_db(S, ref=np.max) +D = np.clip((D + 70) / 70, 0, 1) +img = np.flipud(D) +rgb = np.zeros((img.shape[0], img.shape[1], 3), dtype=np.uint8) +rgb[..., 0] = (np.clip(img * 1.5, 0, 1) * 255) +rgb[..., 1] = (np.clip(img * 1.05, 0, 1) * 255) +rgb[..., 2] = (np.clip(0.25 + img * 0.8, 0, 1) * 255) +Image.fromarray(rgb).save(f"{WORK}/wizard-spec.png") +print(f"✓ wizard-spec.png {rgb.shape[1]}×{rgb.shape[0]} ({rgb.shape[1] / PXS:.1f}s at {PXS}px/s)") + +# ── seeds: the written hook aligned to this take's pitch ────────────── +# SyllaWizard's own syllable roster, in order (label, word index). +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)] +GT = ["C4", "G4", "C4", "C4", "C4", "C4", "C5", "C4", "C4", "C4", + "G4", "F4", "E4", "D4", "E4", "E4", "D4", "C4", "C4", "C4"] + +clip = stem +if t_from is not None or t_to is not None: + a, b = t_from or 0.0, t_to or dur + clip = f"{WORK}/wizard-align-{take}.wav" + subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", + "-ss", str(a), "-t", str(b - a), "-i", stem, + "-ac", "1", "-ar", "22050", clip], check=True) + print(f" aligning inside {a:.2f}–{b:.2f}s") +off_ms = (t_from or 0.0) * 1000 + +nj = f"{WORK}/wizard-notes-{take}.json" +json.dump([{"label": s[0], "note": n} for s, n in zip(SYLS, GT)], open(nj, "w")) +r = subprocess.run([sys.executable, os.path.join(HERE, "notealign.py"), clip, nj], + capture_output=True, text=True) +try: + al = json.loads(r.stdout) +except Exception: + sys.exit(f"notealign failed: {r.stderr[-400:]}") + +sylls = [] +for s, a in zip(SYLS, al["syllables"]): + sylls.append({"label": s[0], "wi": s[1], + "fromMs": int(round(a["fromMs"] + off_ms)), + "toMs": int(round(a["toMs"] + off_ms)), + "fLo": 0.15, "fHi": 0.9}) +json.dump({"take": take, "source": "notealign", "transpose": al["offset"], + "words": [{"sylls": sylls}]}, open(f"{WORK}/bounds-{take}.json", "w"), indent=1) +print(f"✓ bounds-{take}.json — {len(sylls)} seeded rects (written hook fits at {al['offset']:+d} semitones)") +for s, a in zip(SYLS, al["syllables"]): + sung = a.get("sungMidi") + print(f" {s[0]:<5} {int(a['fromMs'] + off_ms):>6}–{int(a['toMs'] + off_ms):<6}ms " + f"want {a['note']:<3}" + (f" sung {librosa.midi_to_note(sung)}" if sung else " (unvoiced)")) diff --git a/pop/imab/boundaries-drawn-7427025009693986079.json b/pop/imab/boundaries-drawn-7427025009693986079.json new file mode 100644 index 0000000000..8585517cbb --- /dev/null +++ b/pop/imab/boundaries-drawn-7427025009693986079.json @@ -0,0 +1,167 @@ +{ + "drawn" : "2026-09-09T01:19:14Z", + "sylls" : [ + { + "fHi" : 1, + "fLo" : 1, + "fromMs" : 433, + "label" : "i'm", + "toMs" : 1038, + "wi" : 0 + }, + { + "fHi" : 0.89985119047619044, + "fLo" : 0, + "fromMs" : 1430, + "label" : "a", + "toMs" : 2757, + "wi" : 1 + }, + { + "fHi" : 0.80150669642857142, + "fLo" : 0.80020461309523805, + "fromMs" : 2790, + "label" : "but", + "toMs" : 2828, + "wi" : 2 + }, + { + "fHi" : 0.90379464285714284, + "fLo" : 0, + "fromMs" : 2748, + "label" : "ter", + "toMs" : 3817, + "wi" : 2 + }, + { + "fHi" : 0.90000000000000002, + "fLo" : 0.12, + "fromMs" : 3831, + "label" : "fly", + "toMs" : 5132, + "wi" : 2 + }, + { + "fHi" : 0.90000000000000002, + "fLo" : 0.12, + "fromMs" : 5132, + "label" : "flap", + "toMs" : 6362, + "wi" : 3 + }, + { + "fHi" : 0.90000000000000002, + "fLo" : 0.12, + "fromMs" : 6362, + "label" : "ping", + "toMs" : 7695, + "wi" : 3 + }, + { + "fHi" : 0.90000000000000002, + "fLo" : 0.12, + "fromMs" : 7696, + "label" : "for", + "toMs" : 8438, + "wi" : 4 + }, + { + "fHi" : 0.90000000000000002, + "fLo" : 0.12, + "fromMs" : 8447, + "label" : "you", + "toMs" : 8725, + "wi" : 5 + }, + { + "fHi" : 0.90000000000000002, + "fLo" : 0.12, + "fromMs" : 8727, + "label" : "guys", + "toMs" : 9591, + "wi" : 6 + }, + { + "fHi" : 0.90000000000000002, + "fLo" : 0.12, + "fromMs" : 10159, + "label" : "just", + "toMs" : 11192, + "wi" : 7 + }, + { + "fHi" : 0.90000000000000002, + "fLo" : 0.12, + "fromMs" : 11192, + "label" : "a", + "toMs" : 12545, + "wi" : 8 + }, + { + "fHi" : 0.90000000000000002, + "fLo" : 0.12, + "fromMs" : 12589, + "label" : "cos", + "toMs" : 13723, + "wi" : 9 + }, + { + "fHi" : 0.90000000000000002, + "fLo" : 0.12, + "fromMs" : 13723, + "label" : "tume", + "toMs" : 14915, + "wi" : 9 + }, + { + "fHi" : 0.90000000000000002, + "fLo" : 0.12, + "fromMs" : 14918, + "label" : "i", + "toMs" : 15546, + "wi" : 10 + }, + { + "fHi" : 0.90000000000000002, + "fLo" : 0.12, + "fromMs" : 15546, + "label" : "put", + "toMs" : 16201, + "wi" : 11 + }, + { + "fHi" : 0.90000000000000002, + "fLo" : 0.12, + "fromMs" : 16197, + "label" : "on", + "toMs" : 17385, + "wi" : 12 + }, + { + "fHi" : 0.90000000000000002, + "fLo" : 0.12, + "fromMs" : 17389, + "label" : "in", + "toMs" : 17993, + "wi" : 13 + }, + { + "fHi" : 0.90000000000000002, + "fLo" : 0.12, + "fromMs" : 18012, + "label" : "my", + "toMs" : 18628, + "wi" : 14 + }, + { + "fHi" : 0.90000000000000002, + "fLo" : 0.12, + "fromMs" : 18628, + "label" : "room", + "toMs" : 20619, + "wi" : 15 + } + ], + "take" : "7427025009693986079", + "tool" : "SyllaWizard" +} \ No newline at end of file diff --git a/pop/imab/boundaries-dtw-7311185009245818143.json b/pop/imab/boundaries-dtw-7311185009245818143.json new file mode 100644 index 0000000000..2f4140edc8 --- /dev/null +++ b/pop/imab/boundaries-dtw-7311185009245818143.json @@ -0,0 +1,167 @@ +{ + "take": "7311185009245818143", + "derived": "2026-09-13T08:39:48", + "method": "DTW (MFCC+RMS) of the lead take's processed-boundaries-7311159624588070175.json onto this take", + "sylls": [ + { + "label": "i'm", + "wi": 0, + "fromMs": 0, + "toMs": 964, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "a", + "wi": 1, + "fromMs": 965, + "toMs": 1516, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "but", + "wi": 2, + "fromMs": 1950, + "toMs": 2183, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "ter", + "wi": 2, + "fromMs": 2183, + "toMs": 2299, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "fly", + "wi": 2, + "fromMs": 2299, + "toMs": 3218, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "flap", + "wi": 3, + "fromMs": 3227, + "toMs": 4615, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "ping", + "wi": 3, + "fromMs": 4614, + "toMs": 5704, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "for", + "wi": 4, + "fromMs": 5732, + "toMs": 6197, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "you", + "wi": 5, + "fromMs": 6198, + "toMs": 6432, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "guys", + "wi": 6, + "fromMs": 6435, + "toMs": 7304, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "just", + "wi": 7, + "fromMs": 7319, + "toMs": 8046, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "a", + "wi": 8, + "fromMs": 8086, + "toMs": 9654, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "cos", + "wi": 9, + "fromMs": 9729, + "toMs": 10785, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "tume", + "wi": 9, + "fromMs": 10789, + "toMs": 11621, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "i", + "wi": 10, + "fromMs": 11651, + "toMs": 12131, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "put", + "wi": 11, + "fromMs": 12151, + "toMs": 12630, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "on", + "wi": 12, + "fromMs": 12643, + "toMs": 13560, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "in", + "wi": 13, + "fromMs": 13602, + "toMs": 14090, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "my", + "wi": 14, + "fromMs": 14094, + "toMs": 14446, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "room", + "wi": 15, + "fromMs": 14451, + "toMs": 15827, + "fLo": 0.15, + "fHi": 0.9 + } + ] +} \ No newline at end of file diff --git a/pop/imab/boundaries-dtw-7335667959647456555.json b/pop/imab/boundaries-dtw-7335667959647456555.json new file mode 100644 index 0000000000..668245a340 --- /dev/null +++ b/pop/imab/boundaries-dtw-7335667959647456555.json @@ -0,0 +1,167 @@ +{ + "take": "7335667959647456555", + "derived": "2026-09-13T08:39:48", + "method": "DTW (MFCC+RMS) of the lead take's processed-boundaries-7311159624588070175.json onto this take", + "sylls": [ + { + "label": "i'm", + "wi": 0, + "fromMs": 0, + "toMs": 1637, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "a", + "wi": 1, + "fromMs": 1638, + "toMs": 2189, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "but", + "wi": 2, + "fromMs": 2642, + "toMs": 3065, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "ter", + "wi": 2, + "fromMs": 3065, + "toMs": 3251, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "fly", + "wi": 2, + "fromMs": 3251, + "toMs": 3845, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "flap", + "wi": 3, + "fromMs": 3854, + "toMs": 4499, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "ping", + "wi": 3, + "fromMs": 4498, + "toMs": 5681, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "for", + "wi": 4, + "fromMs": 5709, + "toMs": 6339, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "you", + "wi": 5, + "fromMs": 6339, + "toMs": 6594, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "guys", + "wi": 6, + "fromMs": 6597, + "toMs": 7699, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "just", + "wi": 7, + "fromMs": 7714, + "toMs": 8650, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "a", + "wi": 8, + "fromMs": 8690, + "toMs": 9681, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "cos", + "wi": 9, + "fromMs": 9721, + "toMs": 10715, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "tume", + "wi": 9, + "fromMs": 10719, + "toMs": 12097, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "i", + "wi": 10, + "fromMs": 12117, + "toMs": 12376, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "put", + "wi": 11, + "fromMs": 12383, + "toMs": 12862, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "on", + "wi": 12, + "fromMs": 12875, + "toMs": 13839, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "in", + "wi": 13, + "fromMs": 13881, + "toMs": 14299, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "my", + "wi": 14, + "fromMs": 14303, + "toMs": 14725, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "room", + "wi": 15, + "fromMs": 14730, + "toMs": 15827, + "fLo": 0.15, + "fHi": 0.9 + } + ] +} \ No newline at end of file diff --git a/pop/imab/boundaries-dtw-7427025009693986079.json b/pop/imab/boundaries-dtw-7427025009693986079.json new file mode 100644 index 0000000000..0053f3de76 --- /dev/null +++ b/pop/imab/boundaries-dtw-7427025009693986079.json @@ -0,0 +1,167 @@ +{ + "take": "7427025009693986079", + "derived": "2026-09-13T08:39:48", + "method": "DTW (MFCC+RMS) of the lead take's processed-boundaries-7311159624588070175.json onto this take", + "sylls": [ + { + "label": "i'm", + "wi": 0, + "fromMs": 0, + "toMs": 941, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "a", + "wi": 1, + "fromMs": 942, + "toMs": 1516, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "but", + "wi": 2, + "fromMs": 1969, + "toMs": 2541, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "ter", + "wi": 2, + "fromMs": 2541, + "toMs": 2867, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "fly", + "wi": 2, + "fromMs": 2867, + "toMs": 3868, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "flap", + "wi": 3, + "fromMs": 3877, + "toMs": 4801, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "ping", + "wi": 3, + "fromMs": 4800, + "toMs": 5681, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "for", + "wi": 4, + "fromMs": 5709, + "toMs": 6174, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "you", + "wi": 5, + "fromMs": 6175, + "toMs": 6629, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "guys", + "wi": 6, + "fromMs": 6634, + "toMs": 7722, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "just", + "wi": 7, + "fromMs": 7737, + "toMs": 8696, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "a", + "wi": 8, + "fromMs": 8736, + "toMs": 9681, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "cos", + "wi": 9, + "fromMs": 9702, + "toMs": 10669, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "tume", + "wi": 9, + "fromMs": 10673, + "toMs": 11656, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "i", + "wi": 10, + "fromMs": 11676, + "toMs": 12131, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "put", + "wi": 11, + "fromMs": 12151, + "toMs": 12630, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "on", + "wi": 12, + "fromMs": 12643, + "toMs": 13560, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "in", + "wi": 13, + "fromMs": 13602, + "toMs": 14113, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "my", + "wi": 14, + "fromMs": 14117, + "toMs": 14655, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "room", + "wi": 15, + "fromMs": 14660, + "toMs": 15827, + "fLo": 0.15, + "fHi": 0.9 + } + ] +} \ No newline at end of file diff --git a/pop/imab/boundaries-dtw-7601670541833014558.json b/pop/imab/boundaries-dtw-7601670541833014558.json new file mode 100644 index 0000000000..e63749119a --- /dev/null +++ b/pop/imab/boundaries-dtw-7601670541833014558.json @@ -0,0 +1,167 @@ +{ + "take": "7601670541833014558", + "derived": "2026-09-13T08:39:48", + "method": "DTW (MFCC+RMS) of the lead take's processed-boundaries-7311159624588070175.json onto this take", + "sylls": [ + { + "label": "i'm", + "wi": 0, + "fromMs": 0, + "toMs": 964, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "a", + "wi": 1, + "fromMs": 965, + "toMs": 1516, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "but", + "wi": 2, + "fromMs": 1969, + "toMs": 3121, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "ter", + "wi": 2, + "fromMs": 3121, + "toMs": 3378, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "fly", + "wi": 2, + "fromMs": 3378, + "toMs": 4040, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "flap", + "wi": 3, + "fromMs": 4040, + "toMs": 4801, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "ping", + "wi": 3, + "fromMs": 4800, + "toMs": 5875, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "for", + "wi": 4, + "fromMs": 5875, + "toMs": 6174, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "you", + "wi": 5, + "fromMs": 6175, + "toMs": 6548, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "guys", + "wi": 6, + "fromMs": 6551, + "toMs": 7699, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "just", + "wi": 7, + "fromMs": 7714, + "toMs": 8650, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "a", + "wi": 8, + "fromMs": 8690, + "toMs": 9681, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "cos", + "wi": 9, + "fromMs": 9702, + "toMs": 10762, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "tume", + "wi": 9, + "fromMs": 10766, + "toMs": 11842, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "i", + "wi": 10, + "fromMs": 11862, + "toMs": 12294, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "put", + "wi": 11, + "fromMs": 12314, + "toMs": 12630, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "on", + "wi": 12, + "fromMs": 12643, + "toMs": 13560, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "in", + "wi": 13, + "fromMs": 13602, + "toMs": 14183, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "my", + "wi": 14, + "fromMs": 14187, + "toMs": 14771, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "room", + "wi": 15, + "fromMs": 14776, + "toMs": 15873, + "fLo": 0.15, + "fHi": 0.9 + } + ] +} \ No newline at end of file diff --git a/pop/imab/boundaries-dtw-7656469190517263646.json b/pop/imab/boundaries-dtw-7656469190517263646.json new file mode 100644 index 0000000000..bfe85d5b60 --- /dev/null +++ b/pop/imab/boundaries-dtw-7656469190517263646.json @@ -0,0 +1,167 @@ +{ + "take": "7656469190517263646", + "derived": "2026-09-13T08:39:48", + "method": "DTW (MFCC+RMS) of the lead take's processed-boundaries-7311159624588070175.json onto this take", + "sylls": [ + { + "label": "i'm", + "wi": 0, + "fromMs": 0, + "toMs": 1289, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "a", + "wi": 1, + "fromMs": 1290, + "toMs": 1841, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "but", + "wi": 2, + "fromMs": 2178, + "toMs": 2670, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "ter", + "wi": 2, + "fromMs": 2670, + "toMs": 2937, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "fly", + "wi": 2, + "fromMs": 2937, + "toMs": 3868, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "flap", + "wi": 3, + "fromMs": 3877, + "toMs": 4801, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "ping", + "wi": 3, + "fromMs": 4800, + "toMs": 5681, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "for", + "wi": 4, + "fromMs": 5709, + "toMs": 5944, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "you", + "wi": 5, + "fromMs": 5944, + "toMs": 6176, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "guys", + "wi": 6, + "fromMs": 6179, + "toMs": 7258, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "just", + "wi": 7, + "fromMs": 7273, + "toMs": 8209, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "a", + "wi": 8, + "fromMs": 8249, + "toMs": 9669, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "cos", + "wi": 9, + "fromMs": 9719, + "toMs": 10808, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "tume", + "wi": 9, + "fromMs": 10812, + "toMs": 11842, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "i", + "wi": 10, + "fromMs": 11862, + "toMs": 12317, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "put", + "wi": 11, + "fromMs": 12337, + "toMs": 12653, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "on", + "wi": 12, + "fromMs": 12666, + "toMs": 13583, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "in", + "wi": 13, + "fromMs": 13625, + "toMs": 14136, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "my", + "wi": 14, + "fromMs": 14140, + "toMs": 14562, + "fLo": 0.15, + "fHi": 0.9 + }, + { + "label": "room", + "wi": 15, + "fromMs": 14567, + "toMs": 15827, + "fLo": 0.15, + "fHi": 0.9 + } + ] +} \ No newline at end of file diff --git a/pop/imab/melody.json b/pop/imab/melody.json new file mode 100644 index 0000000000..baae728767 --- /dev/null +++ b/pop/imab/melody.json @@ -0,0 +1,27 @@ +{ + "_": "the imab hook, ground truth. @jeffrey's notepat punch-in, 2026-08-31 (see imab.np). tonic C4; h = the high C. one entry per sung syllable, same order and word index (wi) as syllawizard's SYLS. melodyproof.py fits each take's register to this and reports the miss per syllable.", + "tonic": "C4", + "notepat": "c g c c c · c h c c c · g f e d · e e d c c c", + "sylls": [ + { "label": "i'm", "wi": 0, "note": "C4" }, + { "label": "a", "wi": 1, "note": "G4" }, + { "label": "but", "wi": 2, "note": "C4" }, + { "label": "ter", "wi": 2, "note": "C4" }, + { "label": "fly", "wi": 2, "note": "C4" }, + { "label": "flap", "wi": 3, "note": "C4" }, + { "label": "ping", "wi": 3, "note": "C5" }, + { "label": "for", "wi": 4, "note": "C4" }, + { "label": "you", "wi": 5, "note": "C4" }, + { "label": "guys", "wi": 6, "note": "C4" }, + { "label": "just", "wi": 7, "note": "G4" }, + { "label": "a", "wi": 8, "note": "F4" }, + { "label": "cos", "wi": 9, "note": "E4" }, + { "label": "tume", "wi": 9, "note": "D4" }, + { "label": "i", "wi": 10, "note": "E4" }, + { "label": "put", "wi": 11, "note": "E4" }, + { "label": "on", "wi": 12, "note": "D4" }, + { "label": "in", "wi": 13, "note": "C4" }, + { "label": "my", "wi": 14, "note": "C4" }, + { "label": "room", "wi": 15, "note": "C4" } + ] +} diff --git a/pop/imab/review.html b/pop/imab/review.html new file mode 100644 index 0000000000..741754db63 --- /dev/null +++ b/pop/imab/review.html @@ -0,0 +1,573 @@ + + + + + +imab · review + + + + + + +
+ + +
+
+
+ imab glyph +
+
+

i'm a butterfly + imab · Whistlegraph Dot Org · the dance track · 124 BPM · four on the floor +

+

+ The club cousin of flutterbap. Built bottom-up from AC instruments and sung around one + hook. The source is Butterfly Cosplayer (2019), whistlegraph code imab, + 138M views at mint. The minted code promises its holder the first edition and a royalty + split when this record ships. So this is the first release that has to honor that. +

+ +

canonical whistlegraph · 0:19 · whistlegraph.org/imab · the graphic score

+
+
+
+ WDO header · crystal night + WDO header · neon wall +
+

Whistlegraph Dot Org artist headers cut from imab · pop/artist/whistlegraph-dot-org/

+
+ + +
+

01the hook

+

The melody is measured, not composed. Thirty-five performances of the song from + @whistlegraph (2020–2026) were statistically skeletonised, then @jeffrey punched the definitive line + into notepat on Aug 31. Where the stats and the singer disagree, the singer wins.

+ +

I'm a butterfly, flapping for you guys, just a costume, I put on, in my room.

+ +
# ground truth · notepat form · 2026-08-31 · h = the high C
+c g c c c   ·   c h c c c   ·   g f e d e e d c c c
+
+i'm(C4) a(G4) but-ter-fly(C4 C4 C4)
+flap(C4)-PING(C5) for you guys(C4 C4 C4)      ← the one octave flare
+just(G4) a(F4) cos(E4)-tume(D4)                 ← the walk down, major third = MAJOR
+i(E4) put(E4) on(D4) · in my room(C4 C4 C4)      ← home; "room" held longest
+
+# shape: a TONIC CHANT with one flare. source tempo ≈ 115 · the dance cut runs 124.
+# harmony implied by the chant: tonic drone · IV under "just a costume" · home.
+# dance-cut bed: Am | Am | G | G | C | C | F | G (8-bar loop), bass roots A2 A2 G2 G2 C3 C3 F2 G2.
+ +
+

melodyproof · does he sing what we wrote?

new · Sep 13out/melodyproof.html
+

Every syllable's sung pitch in every take, measured against this line. Two stages: the note-locked SET (proves the render) and the RAW stem (the verdict). Click a block to hear that syllable; ▶ melody plays the written notes on the take's timing. open the proof · one take: lead · 7427 · 7656

+
+ +
+

notepat melody proof

out/imab-melody-check.mp30:24
+

The corrected line rendered as notes (holyvox notecheck, all targets hit). Listen to this before anything else.

+
+ +
+
+ + +
+

02the six takes

+

Six TikTok performances pulled into the lane (17.4 s each, spectrogram strips from the + syllawizard). 7311…070175 is the lead: its syllable boundaries were drawn by hand on Sep 9 and + committed Sep 10 as the ground truth the aesthetivox chain reads. Two others have grid addresses in + the vocal-set registry; all five non-leads stack as the choir at Y1. Each syllawizard link opens the + interactive rectangle-drawing instrument for that take.

+ +
+

7311159624588070175

LEADboundaries drawn~/.cache/ac/imab/takes/0:17
+ +
+ +

syllawizard · processed-boundaries-7311159624588070175.json · set-fixes/7311159624588070175.json · lyric-overrides.json (@jeffrey's dictated retiming, Sep 3)

+
+ +
+
+

7427025009693986079

set @ J10:17
+ +
+ +

syllawizard · has a set-fixes overlay

+
+
+

7656469190517263646

set @ Q10:17
+ +
+ +

syllawizard

+
+
+

7311185009245818143

choir only0:17
+ +
+ +

syllawizard

+
+
+

7335667959647456555

choir only0:17
+ +
+ +

syllawizard

+
+
+

7601670541833014558

choir only0:17
+ +
+ +

syllawizard

+
+
+ +
+

takes review · the scrolling set video

Sep 9out/imab-takes-review.mp41:47
+

Every placed vocal set in succession over the real floor mix, the lead's lyric ribbon underneath (setscroll / lyricscroll). Same audio as sets-successive below.

+ +
+
+ + +
+

03the vocal chain, generation by generation

+

Every route we tried to get the hook sung, in order. Same 63 s accompaniment bed under most of + them so they compare fairly. Raw takes never ship: everything here is aesthetivoxed. The + arc goes TTS → WORLD resynthesis → chopped real words → one continuous real phrase → half-time sacred.

+ +
+

0 · the spoken source

Aug 31 08:30out/imab-hook-vocal.mp30:11
+

The full lyric spoken by jeffrey's ElevenLabs voice via spinging say. Cached at hash c181725e2034d814; never re-spend the call. Whisper word sidecar alongside.

+
+ +
+ +
+
+

1 · hookvox

sing-hook.mjs0:16
+

Sung derivative of the TTS: sliced, WORLD-snapped to the composed hook, transposed −12.

+
+ +
+
+

demo 1 · hookvox over the bed

sing-hook.mjs1:03
+
+ +
+
+

demo 2 · resing

resing.mjs1:03
+

One continuous WORLD line through the full spinging engine on the measured melody: guided phoneme alignment, legato bridging, self-choir on vowels, reverb halo. Replaced the sliced route. The bare line is imab-line-vocal.mp3 (0:05).

+
+ + +
+
+

demo 3 · voxcut

voxcut.mjs1:03
+

First use of the real recordings: jeffrey's sung words sliced from a take by whisper timing, quantized to the 124 grid, then WORLD f0-replaced onto his own notes.

+
+ +
+
+

demo 5 · modelvox

modelvox.py1:03
+

The averaged-Hz vocal model: the band tunes to jeffrey's measured pitch centres rather than the reverse.

+
+ +
+
+

demo 6 · sacredvox

the lawsacredvox.mjs1:03
+

The vocal IS the original recording. Demucs-separated, kept as one continuous phrase, no WORLD, no slicing, one formant-preserving tempo-fit to 124, bed transposed to his key. Its ×6.00 gain law became the floor's cap. This is the posture the floor and hitbaker inherited.

+
+ +
+
+ +

demo 7 · coolvox, three takes

+

sacredvox plus the aesthetivox sheen: loner's autotune in NOTE mode, C major, strength 0.92 with scoop. One render per candidate take so the take itself could be chosen by ear.

+
+
+

…070175

lead1:03
+
+ +
+
+

…986079

1:03
+
+ +
+
+

…263646

1:03
+
+ +
+
+ +
+

demo 8 · holyvox

newest · Sep 11holyvox.mjsout/imab-vox-demo8.mp32:07
+

The half-time sacred vocal: note-locked to the notepat truth and verified by notecheck, haloed on the vocal bus. This is the last thing rendered in the lane. It leads imabclub draft 1 and enters the floor at bar 30 as the tissue under the kickless break.

+
+ +
+ +
+

sets, successive

vocalset.mjsSep 8out/imab-sets-successive.mp31:47
+

Every regulated vocal set from vocal-sets.json placed in a row on the floor: the lead on the pass doors, 7427… at J1, 7656… at Q1, the five-take choir at Y1 at half gain. Each sample tunes from its own measured base frequency.

+
+ +
+ +
+

the syllawizard spectrum

~/.cache/ac/imab/wizard-spec.png
+

The 4529 × 520 spectrogram the wizard draws rectangles on, one band per syllable. Scroll it sideways.

+
+
+
+ + +
+

04click · guide · accompaniment

+

For cutting real takes against. Record at 48 kHz with one ear in, drop files in + performances/, log every take in the manifest immediately. No human take has been + recorded yet: the manifest holds only the TTS source.

+
+
+

click 124

2:09
+

2-bar count-in then 64 bars. Beat 1 accented, brighter door tick at every 8-bar phrase start.

+
+ +
+
+

guide 124

2:09
+

Same click with the hook melody on xylophone and the bass roots looping.

+
+ +
+
+

accompaniment

1:03
+

The demo bed under every vox demo (gen-accomp.mjs). A ×3 variant sits beside it.

+
+ + +
+
+
+ + +
+

05the three spines

+

Three full arrangements exist and none has been chosen. Listen to them in the order + they were made. The word-level vocal score is written against hitbaker, which makes it the + de facto lead, but the decision is still yours.

+ +
+

A · imabclub draft 1

Aug 3196 bars · holyvox-ledout/imabclub-draft1.mp33:10
+

Everything the first day learned in one arrangement: the note-locked half-time vocal, a sine choir assembling act by act, kick five four-on-the-floor with a kickless break, offbeat sub in C, eager hats and shaker, click-rush doors, a reversed-vocal riser into the drop. A click-overlaid print sits beside it for checking the grid. Review video generator: imabreview.mjs.

+
+ +

with click:

+ +
+ +
+

B · the floor, demo 1

Sep 572 bars · sacredvox palindromeout/imab-floor-demo1.mp32:22
+

The clubber360 discipline applied: one grid, the floor assembles act by act, reduces to a shaker seed in the kickless break, returns whole at the drop, peels in reverse. Sacredvox placed whole at bars 16 / 40 / 56, holyvox as tissue from 30. Printed at −11.7 LUFS · LRA 13.3 · TP −1.6 via the cut-wax law: measure, one static dB, true-peak limit. Full bar map below.

+
+ +
+ +
+

C · hitbaker, demo 1

de facto leadSep 5104-bar chart formout/imab-hitbaker-demo1.mp31:32
+

The chart-calibrated arrangement, single-study: cold open, verse / pre / chorus form with choruses at bars 24 / 56 / 76, a G-pedal break, terminal-lift finale. Jazz comp keys, bass chords, real CC0 Freesound drum kit, vocal bus with group and a butterfly choir, house master. Drives the word score (vocal-score.json: verse 1 is the Shatner monologue, every word its own event) and the pitch gate, which measures targets and iterates until they hit. Track video: preview-hitbaker.mjs. This render predates the Sep 10 boundaries, so it wants a re-bake.

+
+ +
+
+ + +
+

06floor map · spine B

+

The palindrome from FLOOR-NOTES.md. Doors at 8, 16, 24, 32, 40, 48, 56, 64, 72 get click rushes; + breath rests floor the decoratives in the last half-bar before each door. Kick, sub, bass and both voices are never gated.

+ + + + + + + + + + + + + + + +
barsactwhat plays
0–4kick alonekit kick four on the floor at 0.9 the whole record; one door tick at 0
4–8+ closed hatsoffbeat eighths
8–12+ open / shaker / subopen-hat exhale every 2 bars, shaker 16ths (the seed that ticks through everything until 66), offbeat C1 tanh sub
12–16+ bass / sinesmarimba bass roots soft, sine choir 3 voices
16–24PASS 1sacredvox at the door (dry, ×6.00), bass full, vibraphone chords, wub on, reverse-bell into the door
24–32liftvox out; snare answers on odd bars, sines 4v, shaker gathering into 32
30–40holyvoxhalf-time tissue enters, 2-bar fade-in, haloed; its tail lands exactly at the drop door
32–40KICKLESS BREAKno kick / sub / bass / hats; sines 5v swelling, shaker alone; snare roll 36–40, reverse-kick, biggest click rush
40–48DROP · PASS 2everything: sacredvox, sines 6v, vibes, open hats every offbeat, wub 0.17, side-fold inhale into 40
48–56peakvox out; xylophone chant echo up an octave in C at 48 / 50 / 52 / 54; wub swells free
56–64PASS 3 · farewellsacredvox once more over the peel; vibes / xylo / snares gone at 56, sub + open + bass out at 60, sines fade 60–64
64–68peelclosed offbeats thinning, shaker out by 66
68–72kick aloneplus one last hit at 72, ringing into the fade
+

Act RMS arc: −19.7 → −11.4 → −8.7…−9.2 → break −13.0 → PASS 3 −10.6 → peel −19.8. The palindrome holds.

+
+ + +
+

07samples

+

Tap to audition. kit/ is sample-free, rendered from the AC engines (perc kit, bass-perc, xylophone hook notes, click ticks). + real/ is the CC0 Freesound drum kit hitbaker prefers, provenance in kit-real.json.

+

samples/real · CC0 Freesound

+
+ +
+

samples/kit · AC engines

+
+ + + +
+
+ + +
+

08what we did

+

Twenty-eight commits, all on origin/main. blueberry is clean and level with the knot.

+
+
Aug 31 · bootstrap +
    +
  • Lane bootstrap plus the whistlegraph song-corpus statistical melody tools.
  • +
  • The measured melody: a 35-take skeleton replaces the composed hook; then corrected to the notepat truth (cgccc · chccc · gfedeedccc).
  • +
  • Averaged-Hz vocal model: the band tunes to jeffrey, not the reverse.
  • +
  • holyvox: half-time sacred vocal, note-locked and verified. boundfix and timemap.
  • +
  • imabclub draft 1, the lyricline proof video, grand piano accompaniment, cult-style score video with an events receipt.
  • +
  • aesthetivox-alignment-video, the named scrutiny feature; SYNC_MS 50 measured on synccal.
  • +
  • Hand boundary overrides (ping no longer swallows "for you"); syllabound, shapebound, syllawizard boundary instruments.
  • +
+
+
Sep 5 · hitbaker + floor +
    +
  • hitbaker: the chart-calibrated arrangement bed; real CC0 drum kit; octaves down, bass chords, aesthetivox by default; cold open, jazz comp, vocal bus + group + butterfly choir, house master; no intro, playful keys, angels, textures.
  • +
  • The word score: playable lyric placement, duration, harmony. Stretched butter / fly, other-take words, bassy kick thump.
  • +
  • The pitch gate: measured targets, iterate until hit.
  • +
  • preview-hitbaker track video on the shared preview primitives, synced to the cold-open form.
  • +
  • Vocal sets: every sample tunes from its own base frequency, and the ribbon stops lying.
  • +
  • floor.mjs: the 72-bar sacredvox palindrome, printed and measured (FLOOR-NOTES.md).
  • +
+
+
Sep 7 · pop sweep +
  • The imab choir stacks at one address (Y1); lyricscroll draws choir blocks without ribbon.
+
+
Sep 9–10 · boundaries +
    +
  • syllawizard run on all six takes (the six HTML instruments above), takes-review mp4.
  • +
  • Boundaries drawn for the lead take and committed as ground truth, with the set-fixes overlay.
  • +
+
+
Sep 11 · last render +
  • holyvox pass → vox-demo8. Nothing since.
+
+
+
+ + +
+

09what's open

+
+
+

machines

+

● blueberry: clean, level with origin/main. All demos here are on its disk.

+

● neo: unreachable Sep 13. Tailscale says offline since ~12:00 UTC; LAN pings answer slowly, SSH times out on both routes. Matches the pingable-but-unsshable memory-exhaustion state. Any uncommitted imab work there is unverified.

+
+
+

not started

+

● no human performance recorded (manifest holds only the TTS source)

+

● no cover, no canvas, no release bundle

+

● hitbaker not re-baked against the Sep 10 boundaries

+
+
+ +

the six questions from FLOOR-NOTES

+
    +
  1. Three passes or two? PASS 3 at bar 56 sings the hook over the peel as a farewell. Drop it and the record empties out wordless from 56.
  2. +
  3. The bar-12 step. The marimba bass arrives +8 dB even at 0.35. Stretch the assembly (bass at 12, chords at 14) or keep the two-stage door (harmony at 12, voice at 16)?
  4. +
  5. Snare answers at 24–32 and 48–56 are the closest thing to a backbeat. clubber360 used rims. Keep, thin, or cut?
  6. +
  7. Wub audibility at 0.17 under the drop with the follower pulling it under the voice. Worth a solo listen; may want +2 dB or a second harmonic.
  8. +
  9. LRA 13.3 is wide for a juke print. Kick-alone acts could come up ~3 dB at the cost of the palindrome's hush.
  10. +
  11. Which spine gets draft 2? imabclub (A, 96 bars, holyvox-led), the floor (B, 72 bars, sacredvox), or hitbaker (C, 104-bar chart, word score).
  12. +
+ +
+

suggested next move

+

Pick C · hitbaker as the spine, answer the six questions, re-bake one fresh demo with the Sep 10 boundaries and the vox-demo8 holyvox tissue, and retire the two older spines to the archive. Then record a real take against the guide.

+
+
+ +
pop/imab/review.html · rendered 2026-09-13 on blueberry · waveforms in out/waves/ · take audio + spectrograms read from ~/.cache/ac/imab · open with open pop/imab/review.html
+ +
+ + + + diff --git a/pop/maytrax/README.md b/pop/maytrax/README.md index a721a635dc..6587477d13 100644 --- a/pop/maytrax/README.md +++ b/pop/maytrax/README.md @@ -80,6 +80,21 @@ node pop/maytrax/bin/render-femrag-plusplus.mjs # → out/femrag-plusplus.mp3 ``` +To watch the released track as a scrolling instrument score: + +```bash +# Restore timing receipts if they are missing locally. +node pop/bin/remote-workspace.mjs fetch maytrax/femrag-plusplus.events.json +node pop/bin/remote-workspace.mjs fetch maytrax/femrag-plusplus.struct.json +node pop/maytrax/bin/score-video-femrag-plusplus.mjs --open +# → out/femrag-plusplus-score.mp4 (1280×720, 24 fps) +``` + +Uses `out/femrag-plusplus-release.mp3`; `--audio` selects another matching +render. `--frame 34` exports a still for inspection. Bell and bass height +follows MIDI pitch; reverse bells are wedges, and throat bass is violet. +Percussion widths represent display envelopes where the receipt has no duration. + ## conventions - bpm: 138–142 (prodigy classic; `firestarter`=140, `breathe`=137) diff --git a/pop/maytrax/bin/score-video-femrag-plusplus.mjs b/pop/maytrax/bin/score-video-femrag-plusplus.mjs new file mode 100644 index 0000000000..d227fa36a8 --- /dev/null +++ b/pop/maytrax/bin/score-video-femrag-plusplus.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +// Scrolling performance score, using the audio renderer's literal events. +// Restore the events/struct JSON with pop/bin/remote-workspace.mjs if needed. +// node pop/maytrax/bin/score-video-femrag-plusplus.mjs [--frame 34] [--open] +import { readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn, spawnSync } from "node:child_process"; +import { once } from "node:events"; +import { createCanvas } from "canvas"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, "../../.."); +const dir = resolve(here, "../out"); +const slug = "femrag-plusplus"; +const arg = (flag, fallback) => { + const i = process.argv.indexOf(flag); + return i < 0 ? fallback : process.argv[i + 1]; +}; +const audio = resolve(arg("--audio", `${dir}/${slug}-release.mp3`)); +const output = resolve(arg("--out", `${dir}/${slug}-score.mp4`)); +const score = JSON.parse(readFileSync(`${dir}/${slug}.events.json`, "utf8")); +const { sections } = JSON.parse(readFileSync(`${dir}/${slug}.struct.json`, "utf8")); +const env = { ...process.env, PATH: `${root}/toolchain/shims:${process.env.PATH}` }; +const probe = spawnSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", + "-of", "csv=p=0", audio], { env, encoding: "utf8" }); +if (probe.status !== 0) throw new Error(probe.stderr || "Audio probe failed"); +if (Math.abs(Number(probe.stdout) - score.seconds) > .1) { + throw new Error("Audio duration does not match the score"); +} +const W = 1280, H = 720, FPS = 24; +const canvas = createCanvas(W, H), ctx = canvas.getContext("2d"); +const LEFT = 176, RIGHT = 1240, TOP = 139, HEAD = 470; +const BAR = 240 / score.bpm, SCALE = (RIGHT - LEFT) / (4 * BAR); +const lanes = [ + { name: "BELLS", keys: ["bell", "rbell"], h: 172, color: "#ffce70", range: [36, 112] }, + { name: "BASS", keys: ["sub", "throat"], h: 96, color: "#72e0c0", range: [19, 59] }, + { name: "KICK", keys: ["boom"], h: 44, color: "#ff7c92" }, + { name: "SNARE", keys: ["snare"], h: 44, color: "#ab9aff" }, + { name: "HATS", keys: ["hat"], h: 38, color: "#7fcfff" }, + { name: "DONK", keys: ["donk"], h: 38, color: "#ffa66f" }, + { name: "RISER", keys: ["riser"], h: 30, color: "#a7b7c8" }, + { name: "VOICE", keys: ["voice"], h: 30, color: "#f8efe5" }, +]; +let y = TOP; +for (const lane of lanes) { + lane.y = y; y += lane.h; + lane.events = score.events.filter(e => lane.keys.includes(e.i) && e.t >= 0) + .map(e => ({ ...e, duration: e.dur ?? e.length ?? ({ boom: .16, snare: .10, hat: e.open ? .14 : .035, donk: .12 }[e.i] ?? .1) })) + .sort((a, b) => a.t - b.t); +} +const BOTTOM = y; +const clock = t => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, "0")}`; +const text = (s, x, y, size, color = "#f4eee6", align = "left") => { + ctx.fillStyle = color; ctx.font = `${size}px Menlo, monospace`; + ctx.textAlign = align; ctx.fillText(s, x, y); +}; +const pitchY = (lane, midi) => lane.y + lane.h - 12 + - (midi - lane.range[0]) / (lane.range[1] - lane.range[0]) * (lane.h - 24); + +function draw(now) { + ctx.fillStyle = "#11151c"; ctx.fillRect(0, 0, W, H); + text("Femrag++", 40, 65, 46); + text("Aesthetic Dot Computer", 42, 98, 19, "#a1adbc"); + const section = sections.find(s => now >= s.startSec && now < s.endSec) ?? sections.at(-1); + text(section.name.toUpperCase().replaceAll("-", " "), RIGHT, 60, 28, "#ffce70", "right"); + text(`${score.bpm} BPM ${clock(now)} / ${clock(score.seconds)}`, RIGHT, 97, 20, "#a1adbc", "right"); + const xAt = t => HEAD + (t - now) * SCALE; + const start = now - (HEAD - LEFT) / SCALE; + const end = now + (RIGHT - HEAD) / SCALE; + for (const [i, lane] of lanes.entries()) { + ctx.fillStyle = i % 2 ? "#171d27" : "#141a23"; + ctx.fillRect(LEFT, lane.y, RIGHT - LEFT, lane.h - 1); + text(lane.name, 40, lane.y + lane.h / 2 + 6, 19, lane.color); + if (lane.range) { + for (let midi = Math.ceil(lane.range[0] / 12) * 12; midi < lane.range[1]; midi += 12) { + const yy = pitchY(lane, midi); + ctx.fillStyle = "#27303b"; ctx.fillRect(LEFT, yy, RIGHT - LEFT, 1); + text(`C${midi / 12 - 1}`, LEFT + 6, yy - 3, 11, "#697688"); + } + } + } + for (let beat = Math.max(0, Math.ceil(start / (BAR / 4))); beat * BAR / 4 <= end; beat++) { + const xx = xAt(beat * BAR / 4); + ctx.fillStyle = beat % 4 ? "#232d39" : "#3a4656"; + ctx.fillRect(xx, TOP, 1, BOTTOM - TOP); + if (beat % 4 === 0) text(String(beat / 4 + 1), xx + 5, TOP - 10, 14, "#a1adbc"); + } + ctx.save(); ctx.beginPath(); ctx.rect(LEFT, TOP, RIGHT - LEFT, BOTTOM - TOP); ctx.clip(); + for (const lane of lanes) { + for (const e of lane.events) { + if (e.t > end) break; + if (e.t + e.duration < start) continue; + const active = now >= e.t && now < e.t + e.duration; + const color = e.i === "throat" ? "#d99eff" : lane.color; + const xx = xAt(e.t), ww = Math.max(5, e.duration * SCALE); + const yy = lane.range ? pitchY(lane, e.midi) : lane.y + lane.h / 2; + const hh = lane.range ? 5 : Math.max(5, Math.min(lane.h - 12, 9 + (e.gain ?? .1) * 65)); + ctx.globalAlpha = active ? 1 : e.t < now ? .35 : .78; + ctx.fillStyle = active ? "#ffffff" : color; + if (e.i === "rbell") { + ctx.beginPath(); ctx.moveTo(xx, yy); ctx.lineTo(xx + ww, yy - 4); + ctx.lineTo(xx + ww, yy + 4); ctx.closePath(); ctx.fill(); + } else ctx.fillRect(xx, yy - hh / 2, ww, hh); + if (active) { + ctx.globalAlpha = 1; ctx.fillStyle = color; + ctx.beginPath(); ctx.arc(HEAD, yy, lane.range ? 5 : 7, 0, Math.PI * 2); ctx.fill(); + } + } + } + ctx.restore(); + ctx.fillStyle = "#f4eee6"; ctx.fillRect(HEAD, TOP - 6, 2, BOTTOM - TOP + 6); + // Full arrangement strip: the current section and cursor remain visible. + for (const s of sections) { + const xx = LEFT + s.startSec / score.seconds * (RIGHT - LEFT); + const ww = (s.endSec - s.startSec) / score.seconds * (RIGHT - LEFT); + ctx.fillStyle = s === section ? "#ffce70" : s.endSec <= now ? "#566273" : "#303b49"; + ctx.fillRect(xx, 670, ww - 3, 12); + } + ctx.fillStyle = "#ffffff"; + ctx.fillRect(LEFT + now / score.seconds * (RIGHT - LEFT), 665, 2, 22); + text(`BAR ${Math.floor(now / BAR) + 1}`, 40, 684, 17, "#a1adbc"); +} + +mkdirSync(dirname(output), { recursive: true }); +if (process.argv.includes("--frame")) { + const t = Number(arg("--frame", 34)); + if (!Number.isFinite(t) || t < 0 || t >= score.seconds) throw new Error("Invalid frame time"); + draw(t); + const path = output.replace(/\.mp4$/, `-frame-${t}.png`); + writeFileSync(path, canvas.toBuffer("image/png")); console.log(path); +} else { + const temp = output.replace(/\.mp4$/, ".partial.mp4"); + const encoder = spawn("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", + "-f", "rawvideo", "-pixel_format", "bgra", "-video_size", `${W}x${H}`, + "-framerate", String(FPS), "-i", "pipe:0", "-i", audio, + "-map", "0:v:0", "-map", "1:a:0", "-c:v", "libx264", "-preset", "fast", + "-crf", "19", "-threads", "2", "-filter_threads", "1", "-pix_fmt", "yuv420p", + "-c:a", "aac", "-b:a", "256k", "-t", String(score.seconds), + "-movflags", "+faststart", "-metadata", "title=Femrag++ — score", + "-metadata", "artist=Aesthetic Dot Computer", temp], { env, stdio: ["pipe", "ignore", "inherit"] }); + const finished = once(encoder, "close"); + // Catch process/pipe errors during the frame loop, including an early exit. + let failure; + encoder.on("error", e => { failure = e; }); + encoder.stdin.on("error", e => { failure = e; }); + finished.catch(e => { failure = e; }); + const frames = Math.ceil(score.seconds * FPS); + for (let f = 0; f < frames; f++) { + if (failure || encoder.exitCode !== null) throw failure ?? new Error("Encoder exited early"); + draw(f / FPS); + if (!encoder.stdin.write(canvas.toBuffer("raw"))) await once(encoder.stdin, "drain"); + if (f % (FPS * 10) === 0) console.log(`${Math.round(f / frames * 100)}%`); + } + encoder.stdin.end(); + const [code] = await finished; + if (code !== 0) throw new Error(`ffmpeg exited ${code}`); + renameSync(temp, output); console.log(output); + if (process.argv.includes("--open")) spawnSync("open", [output]); +} diff --git a/system/backend/auth0-emails/README.md b/system/backend/auth0-emails/README.md new file mode 100644 index 0000000000..d3e9637ecc --- /dev/null +++ b/system/backend/auth0-emails/README.md @@ -0,0 +1,35 @@ +# Auth0 passwordless email + +Applied to the `aesthetic` production tenant on 2026-09-13 through the Auth0 +dashboard. Reloaded and verified the saved sender, subject, and exact body. + +Matches the existing signup email recovered from mail@aesthetic.computer's +Sent Mail on 2026-09-13: lowercase text, default browser typography, two line +breaks, and an aesthetic.computer link. No logo, layout tables, or welcome copy. + +In the `aesthetic` tenant, open **Authentication → Passwordless → Email**: + +- **From:** `aesthetic.computer ` +- **Subject:** `verify your email 💌` +- **Message:** contents of [passwordless.html](passwordless.html) + +The previous sender, subject, body, and relevant connection settings are saved in +[passwordless.before-2026-09-13.json](passwordless.before-2026-09-13.json) for +rollback. OTP expiry (180 seconds), length (6), signup and domain flags, and +authentication parameters were verified unchanged after reload. The template handles codes, +magic links, and email-change notifications. It deliberately makes no claim +about code or link expiry. + +This is minimal HTML matching the signup email, not a request to change the +message's MIME type. The existing signup message has both plain-text and HTML +parts. Delivery of the replacement has not yet been tested. Inspect the next +requested code email in the mailbox to +verify sender, subject, both MIME parts, and absence of Auth0 branding. Requesting +a test email requires an explicitly authorized recipient. + +Auth0 configures passwordless messages separately from its regular email +templates: [Passwordless Authentication with Email](https://auth0.com/docs/authenticate/passwordless/authentication-methods/email-otp). + +Access checked on 2026-09-13: the production M2M credential lacks +`read:email_templates`, `read:connections`, and `read:email_provider`. +The authenticated Chrome dashboard was used to apply this change. diff --git a/system/backend/auth0-emails/passwordless.before-2026-09-13.json b/system/backend/auth0-emails/passwordless.before-2026-09-13.json new file mode 100644 index 0000000000..e01e9fd902 --- /dev/null +++ b/system/backend/auth0-emails/passwordless.before-2026-09-13.json @@ -0,0 +1,28 @@ +{ + "from": "{{ application.name }} ", + "subject": "Welcome to {{ application.name }}", + "body": "\n\n \n \n \n \n
\n \n \n \n \n
\n
\n

\n \"Your\n

\n\n \n {% if operation == 'change_email' %}\n\n

Your email address has been updated.

\n\n {% else %}\n\n \n {% if send == 'link' or send == 'link_ios' or send == 'link_android' %}\n\n

Click and confirm that you want to sign in to {{ application.name }}. This link will expire in three minutes.

\n\n \n\n

Or sign in using this link:

\n

{{ link }}

\n\n {% elsif send == 'code' %}\n\n

Your verification code is: {{ code }}

\n\n {% endif %}\n\n {% endif %}\n\n

If you are having any issues with your account, please don't hesitate to contact us by replying to this mail.

\n\n
\n Thanks!\n
\n\n {{ application.name }}\n\n

\n
\n

\n If you did not make this request, please contact us by replying to this mail.\n

\n
\n
\n
\n\n", + "settings": [ + { + "name": "options.totp.time_step", + "value": "180", + "checked": false + }, + { + "name": "options.totp.length", + "value": "6", + "checked": false + }, + { + "name": "options.disable_signup", + "value": "on", + "checked": false + }, + { + "name": "is_domain_connection", + "value": "on", + "checked": false + } + ], + "authParams": "" +} diff --git a/system/backend/auth0-emails/passwordless.html b/system/backend/auth0-emails/passwordless.html new file mode 100644 index 0000000000..bfb61c6384 --- /dev/null +++ b/system/backend/auth0-emails/passwordless.html @@ -0,0 +1,15 @@ + + + + + {% if operation == 'change_email' %} + your email address has been updated + {% elsif send == 'link' or send == 'link_ios' or send == 'link_android' %} + sign in to aesthetic.computer + {% else %} + your verification code is: {{ code | escape }} + {% endif %} +

+ aesthetic.computer + + diff --git a/system/public/aesthetic.computer/disks/aesel.mjs b/system/public/aesthetic.computer/disks/aesel.mjs new file mode 100644 index 0000000000..2f09065408 --- /dev/null +++ b/system/public/aesthetic.computer/disks/aesel.mjs @@ -0,0 +1,3 @@ +// Aesel, 2026.09.16 +// Aesel by Aesthetic Computer: make art and software. +export * from "./easel.mjs"; diff --git a/system/public/aesthetic.computer/disks/babo.mjs b/system/public/aesthetic.computer/disks/babo.mjs new file mode 100644 index 0000000000..5aa115e54e --- /dev/null +++ b/system/public/aesthetic.computer/disks/babo.mjs @@ -0,0 +1,9 @@ +// babo, 26.09.15 +// A blank Aesthetic Computer piece. + +function paint({ wipe }) { + wipe(70, 50, 100); + return false; // Painted once; return true to keep painting. +} + +export { paint }; diff --git a/system/public/aesthetic.computer/disks/balozo.mjs b/system/public/aesthetic.computer/disks/balozo.mjs new file mode 100644 index 0000000000..06e4bff4d8 --- /dev/null +++ b/system/public/aesthetic.computer/disks/balozo.mjs @@ -0,0 +1,20 @@ +// balozo, 26.09.11 +// A pink line that follows the pointer. + +let pointer; // { x, y } — last known pen/mouse position. + +function paint({ wipe, ink, screen, pen }) { + const cx = screen.width / 2; + const cy = screen.height / 2; + const p = pointer || pen || { x: cx, y: cy }; + + wipe(70, 50, 100); + ink(255, 100, 255).line(cx, cy, p.x, p.y); + ink(255, 100, 255).write("balozo", { center: "xy" }); +} + +function act({ event: e }) { + if (e.is("move") || e.is("draw") || e.is("touch")) pointer = { x: e.x, y: e.y }; +} + +export { paint, act }; diff --git a/system/public/aesthetic.computer/disks/bimero.mjs b/system/public/aesthetic.computer/disks/bimero.mjs new file mode 100644 index 0000000000..a5fa3009b0 --- /dev/null +++ b/system/public/aesthetic.computer/disks/bimero.mjs @@ -0,0 +1,10 @@ +// bimero, 26.09.13 +// A blank Aesthetic Computer piece. + +function paint({ wipe, ink, screen }) { + wipe(30, 90, 120); + ink(255, 100, 255).write("bimero", { center: "xy" }); + return false; // Painted once; return true to keep painting. +} + +export { paint }; diff --git a/system/public/aesthetic.computer/disks/dafu.mjs b/system/public/aesthetic.computer/disks/dafu.mjs new file mode 100644 index 0000000000..70e3a57a17 --- /dev/null +++ b/system/public/aesthetic.computer/disks/dafu.mjs @@ -0,0 +1,196 @@ +// dafu, 26.09.11 +// A fountain of particles that rattles off boxes and collects in vessels. + +const particles = []; +const MAX = 300; + +let pointer; // { x, y } — last known pen/mouse position. +let dims = { width: 128, height: 128 }; + +let boxes = []; // Solid obstacles: { x, y, w, h }. +let vessels = []; // Open-topped cups: { x, y, w, h, fill, hue } + wall boxes. +let laidOutFor = ""; // "WxH" the current layout was built for. + +const WALL = 2; // Vessel wall thickness in pixels. +const PER_PARTICLE = 0.6; // How much a settled particle raises a vessel. + +// Rebuild the obstacle course whenever the screen size changes. +function layout(width, height) { + const key = `${width}x${height}`; + if (key === laidOutFor) return; + laidOutFor = key; + + boxes = []; + vessels = []; + + const vh = Math.max(12, Math.round(height * 0.18)); // Vessel height. + const vy = height - vh; + const count = 3; + const gap = Math.round(width * 0.04); + const vw = Math.floor((width - gap * (count + 1)) / count); + + for (let i = 0; i < count; i += 1) { + const x = gap + i * (vw + gap); + vessels.push({ x, y: vy, w: vw, h: vh, fill: 0, hue: 300 }); + // The walls are ordinary obstacles, so rims deflect like everything else. + boxes.push({ x, y: vy, w: WALL, h: vh }); + boxes.push({ x: x + vw - WALL, y: vy, w: WALL, h: vh }); + } + + // A staggered field of bumpers between the fountain and the vessels. + const bw = Math.max(6, Math.round(width * 0.16)); + const bh = Math.max(3, Math.round(height * 0.02)); + for (let row = 0; row < 3; row += 1) { + const y = Math.round(height * (0.42 + row * 0.13)); + const offset = row % 2 ? Math.round(width * 0.25) : 0; + for (let col = 0; col < 2; col += 1) { + const x = offset + Math.round(width * 0.08) + col * Math.round(width * 0.5); + if (x + bw > width) continue; + boxes.push({ x, y, w: bw, h: bh }); + } + } +} + +// Push a particle back out of any solid box it just entered. +function collide(p, px, py) { + for (const b of boxes) { + if (p.x < b.x || p.x > b.x + b.w || p.y < b.y || p.y > b.y + b.h) continue; + + if (py <= b.y) { + p.y = b.y; // Landed on top. + p.yv *= -0.4; + p.xv *= 0.85; + } else if (py >= b.y + b.h) { + p.y = b.y + b.h; // Knocked the underside. + p.yv *= -0.4; + } else if (px <= b.x) { + p.x = b.x; // Came in from the left. + p.xv *= -0.6; + } else { + p.x = b.x + b.w; // Came in from the right. + p.xv *= -0.6; + } + } +} + +// Settle a particle onto a vessel's surface, absorbing it once it stops. +// Returns true if the particle was absorbed. +function pour(p) { + for (const v of vessels) { + if (p.x < v.x + WALL || p.x > v.x + v.w - WALL) continue; + const surface = v.y + v.h - Math.min(v.fill * PER_PARTICLE, v.h - WALL); + if (p.y < surface) continue; + + p.y = surface; + if (Math.abs(p.yv) < 0.6) { + v.fill += 1; + // Nudge the vessel's color toward the particle's, the short way around. + const d = ((p.hue - v.hue + 540) % 360) - 180; + v.hue = (v.hue + d * 0.25 + 360) % 360; + return true; + } + p.yv *= -0.35; // Otherwise it splashes. + p.xv *= 0.7; + } + return false; +} + +function spawn({ num }, x, y, count = 6) { + for (let i = 0; i < count; i += 1) { + const angle = num.randIntRange(0, 360) * (Math.PI / 180); + const speed = num.randInt(20) / 10 + 0.4; + particles.push({ + x, + y, + xv: Math.cos(angle) * speed, + yv: Math.sin(angle) * speed - 1.2, // A little upward kick. + life: 1, + decay: 0.006 + num.randInt(14) / 1000, + hue: num.randIntRange(0, 360), + }); + } + while (particles.length > MAX) particles.shift(); +} + +function sim($) { + const { screen } = $; + if (screen) dims = { width: screen.width, height: screen.height }; + layout(dims.width, dims.height); + + const src = pointer || { x: dims.width / 2, y: dims.height * 0.2 }; + spawn($, src.x, src.y, 2); + + for (const v of vessels) v.fill = Math.max(0, v.fill - 0.03); // Slow seepage. + + for (let i = particles.length - 1; i >= 0; i -= 1) { + const p = particles[i]; + const px = p.x; + const py = p.y; + + p.x += p.xv; + p.y += p.yv; + p.yv += 0.06; // Gravity. + p.xv *= 0.99; + p.life -= p.decay; + + collide(p, px, py); + + if (pour(p)) { + particles.splice(i, 1); + continue; + } + + if (p.y > dims.height) { + // Bounce off the floor, losing most of the energy. + p.y = dims.height; + p.yv *= -0.45; + p.xv *= 0.8; + } + + if (p.life <= 0) particles.splice(i, 1); + } +} + +function paint({ wipe, ink, screen, num }) { + dims = { width: screen.width, height: screen.height }; + layout(screen.width, screen.height); + wipe(70, 50, 100); + + for (const v of vessels) { + const level = Math.min(v.fill * PER_PARTICLE, v.h - WALL); + if (level > 0) { + const [r, g, b] = num.hslToRgb(v.hue, 70, 45); + ink(r, g, b, 200).box( + v.x + WALL, + Math.round(v.y + v.h - level), + v.w - WALL * 2, + Math.round(level), + ); + } + ink(200, 190, 230).box(v.x, v.y, v.w, v.h, "outline"); // The cup itself. + } + + for (const b of boxes) ink(150, 140, 190).box(b.x, b.y, b.w, b.h); + + for (const p of particles) { + const [r, g, b] = num.hslToRgb(p.hue, 80, 40 + p.life * 30); + const size = Math.max(1, Math.round(p.life * 3)); + ink(r, g, b, Math.round(p.life * 255)).box( + Math.round(p.x) - (size >> 1), + Math.round(p.y) - (size >> 1), + size, + ); + } + + ink(255, 100, 255).write("dafu", { x: screen.width / 2, y: 6, center: "x" }); +} + +function act($) { + const e = $.event; + if (e.is("move") || e.is("draw") || e.is("touch")) { + pointer = { x: e.x, y: e.y }; + if (e.is("touch") || e.is("draw")) spawn($, e.x, e.y, 10); // Burst on contact. + } +} + +export { sim, paint, act }; diff --git a/system/public/aesthetic.computer/disks/fazoko.mjs b/system/public/aesthetic.computer/disks/fazoko.mjs new file mode 100644 index 0000000000..7e21c8d44a --- /dev/null +++ b/system/public/aesthetic.computer/disks/fazoko.mjs @@ -0,0 +1,288 @@ +// fazoko, 26.09.11 +// an inchworm, going from twig to twig. + +const L = 44; // body length, as arc +const SPAN = { min: 11, max: 38, reach: 54 }; +const PACE = 0.62; // seconds per half step +const TICK = 1 / 120; + +const twigs = []; // { s0, s1, a, b, dir, thick, shoots } +const worm = { backS: 0, frontS: 12, phase: "pull", t: 1, from: 0, to: 0, bridging: false }; +const cam = { x: 0, y: 0, set: false }; +let crossed = 0; +let time = 0; +let startled = 0; + +const lerp = (a, b, t) => a + (b - a) * t; +const ease = (t) => t * t * (3 - 2 * t); + +// 🌿 the world grows rightward, one twig at a time. +function grow() { + const last = twigs[twigs.length - 1]; + let a, ang, s0; + if (!last) { + a = [0, 0]; + ang = 0; + s0 = 0; + } else { + const gap = 11 + Math.random() * 16; + const away = last.ang + (Math.random() - 0.5) * 0.7; + a = [last.b[0] + Math.cos(away) * gap, last.b[1] + Math.sin(away) * gap]; + ang = last.ang * 0.5 + (Math.random() - 0.5) * 0.6; + s0 = last.s1 + gap; + } + const len = 58 + Math.random() * 110; + const dir = [Math.cos(ang), Math.sin(ang)]; + const shoots = []; + for (let n = 0; n < 1 + Math.floor(Math.random() * 3); n += 1) { + shoots.push({ + u: 0.15 + Math.random() * 0.7, + ang: ang + (Math.random() < 0.5 ? -1 : 1) * (0.5 + Math.random() * 0.7), + len: 6 + Math.random() * 13, + leaf: Math.random() < 0.7, + }); + } + twigs.push({ + s0, + s1: s0 + len, + a, + b: [a[0] + dir[0] * len, a[1] + dir[1] * len], + dir, + ang, + thick: 2 + Math.floor(Math.random() * 2), + shoots, + }); +} + +function ensure(s) { + while (twigs[twigs.length - 1].s1 < s + 200) grow(); + while (twigs.length > 2 && twigs[1].s0 < worm.backS - 900) twigs.shift(); +} + +function at(s) { + ensure(s); + for (let i = 0; i < twigs.length; i += 1) { + const t = twigs[i]; + if (s > t.s1) continue; + if (s >= t.s0) return [t.a[0] + t.dir[0] * (s - t.s0), t.a[1] + t.dir[1] * (s - t.s0)]; + const p = twigs[i - 1] || t; + const u = t.s0 === p.s1 ? 0 : (s - p.s1) / (t.s0 - p.s1); + return [lerp(p.b[0], t.a[0], u), lerp(p.b[1], t.a[1], u)]; + } + const end = twigs[twigs.length - 1]; + return [...end.b]; +} + +function on(s) { + ensure(s); + return twigs.find((t) => s >= t.s0 && s <= t.s1); +} + +function ahead(s) { + ensure(s); + return twigs.find((t) => t.s0 >= s); +} + +function behind(s) { + ensure(s); + let lip = null; + twigs.forEach((t) => { + if (t.s1 <= s) lip = t.s1; + }); + return lip; +} + +// 🐛 alternate: reach the front out, then draw the back up. +function plan(sound) { + const w = worm; + w.t = 0; + w.bridging = false; + if (w.phase === "reach") { + w.phase = "pull"; + w.from = w.backS; + let to = w.frontS - SPAN.min; + if (!on(to)) { + to = on(w.frontS).s0; + w.bridging = true; + } + w.to = Math.max(to, w.from); + } else { + w.phase = "reach"; + w.from = w.frontS; + let to = w.backS + SPAN.max; + if (!on(to)) { + const next = ahead(to); + if (next && next.s0 + 3 - w.backS <= SPAN.reach) { + to = next.s0 + 3; + w.bridging = true; + } else { + to = behind(to) - 1; + } + } + w.to = Math.max(to, w.from); + } + w.dur = PACE * (w.bridging ? 1.7 : 1) * (0.85 + Math.random() * 0.3); + const t = on(w.phase === "reach" ? w.from : w.frontS); + sound?.synth?.({ + tone: 180 + (t ? (t.ang + 0.6) * 90 : 0), + type: "sine", + beats: 0.06, + attack: 0.005, + decay: 0.9, + volume: 0.11, + }); +} + +// 🐛 the two ends, one of them possibly in the air. +function ends() { + const w = worm; + const e = ease(Math.min(1, w.t)); + const air = (p0, p1, lift) => { + const hop = Math.sin(Math.PI * e); + const sway = w.bridging ? Math.sin(e * Math.PI * 5) * 3.5 * hop : 0; + return [lerp(p0[0], p1[0], e) + sway, lerp(p0[1], p1[1], e) - lift * hop]; + }; + if (w.phase === "reach") { + return [at(w.backS), air(at(w.from), at(w.to), w.bridging ? 17 : 9)]; + } + return [air(at(w.from), at(w.to), w.bridging ? 12 : 6), at(w.frontS)]; +} + +function body() { + const [pA, pB] = ends(); + const dx = pB[0] - pA[0]; + const dy = pB[1] - pA[1]; + const d = Math.hypot(dx, dy) || 0.01; + let nx = -dy / d; + let ny = dx / d; + if (ny > 0) { + nx = -nx; + ny = -ny; + } + const rear = startled > 0 ? 9 * Math.min(1, startled * 3) : 0; + const h = Math.sqrt(Math.max(0, L * L - d * d)) * 0.52 + 3 + rear; + const breath = 1 + 0.035 * Math.sin(time * 5); + const pts = []; + for (let i = 0; i <= 16; i += 1) { + const u = i / 16; + const arc = Math.sin(Math.PI * u) * h * breath; + pts.push([pA[0] + dx * u + nx * arc, pA[1] + dy * u + ny * arc]); + } + return pts; +} + +function boot() { + grow(); + for (let n = 0; n < 4; n += 1) grow(); +} + +function sim({ sound }) { + time += TICK; + if (startled > 0) { + startled -= TICK; + return; + } + const w = worm; + w.t += TICK / (w.dur || PACE); + if (w.t >= 1) { + if (w.phase === "reach") { + const was = on(w.frontS); + w.frontS = w.to; + if (was && on(w.frontS) !== was) crossed += 1; + } else w.backS = w.to; + plan(sound); + } +} + +function paint({ wipe, ink, screen }) { + const mid = at((worm.backS + worm.frontS) / 2); + const want = { x: mid[0] - screen.width * 0.38, y: mid[1] - screen.height * 0.58 }; + if (!cam.set) { + cam.x = want.x; + cam.y = want.y; + cam.set = true; + } + cam.x = lerp(cam.x, want.x, 0.07); + cam.y = lerp(cam.y, want.y, 0.04); + + wipe(24, 30, 52); + for (let y = 0; y < screen.height; y += 1) { + const u = y / screen.height; + ink( + lerp(28, 96, u * u), + lerp(36, 78, u), + lerp(62, 74, u), + ).line(0, y, screen.width, y); + } + + // three depths of branch, the far ones hazy and slow. + [ + [0.22, 44, 130, [52, 62, 78]], + [0.45, 26, 74, [64, 66, 68]], + ].forEach(([par, dy, dx, c]) => { + twigs.forEach((t) => { + const ax = t.a[0] - cam.x * par + dx; + const ay = t.a[1] - cam.y * par + dy; + const bx = t.b[0] - cam.x * par + dx; + const by = t.b[1] - cam.y * par + dy; + ink(...c).line(ax, ay, bx, by, t.thick); + }); + }); + + twigs.forEach((t) => { + const ax = t.a[0] - cam.x; + const ay = t.a[1] - cam.y; + const bx = t.b[0] - cam.x; + const by = t.b[1] - cam.y; + ink(38, 26, 22).line(ax, ay + 2, bx, by + 2, t.thick + 1); + ink(104, 70, 44).line(ax, ay, bx, by, t.thick + 1); + ink(148, 108, 68).line(ax, ay - 1, bx, by - 1, 1); + t.shoots.forEach((s) => { + const sx = lerp(ax, bx, s.u); + const sy = lerp(ay, by, s.u); + const ex = sx + Math.cos(s.ang) * s.len; + const ey = sy + Math.sin(s.ang) * s.len; + ink(96, 66, 42).line(sx, sy, ex, ey); + if (s.leaf) ink(86, 150, 72).circle(ex, ey, 2.5, true); + }); + }); + + const pts = body().map((p) => [p[0] - cam.x, p[1] - cam.y]); + pts.forEach((p, i) => { + const u = i / (pts.length - 1); + const taper = Math.min(1, 0.5 + 2.4 * Math.min(u, 1 - u)); + const r = (2.4 + 2.6 * Math.sin(Math.PI * u)) * taper; + ink(30, 44, 28).circle(p[0], p[1] + 1, r, true); + if (i % 2) ink(78, 148, 62).circle(p[0], p[1], r, true); + else ink(132, 206, 96).circle(p[0], p[1], r, true); + }); + + // head, facing where it's going. + const head = pts[pts.length - 1]; + const neck = pts[pts.length - 3]; + const hd = Math.hypot(head[0] - neck[0], head[1] - neck[1]) || 1; + const fx = (head[0] - neck[0]) / hd; + const fy = (head[1] - neck[1]) / hd; + ink(146, 214, 104).circle(head[0] + fx, head[1] + fy, 4, true); + for (const side of [-1, 1]) { + const ex = head[0] + fx * 2.2 - fy * side * 1.8; + const ey = head[1] + fy * 2.2 + fx * side * 1.8; + ink(22, 28, 20).circle(ex, ey, 1.2, true); + ink(60, 90, 50).line(ex, ey, ex + fx * 5 - fy * side * 3, ey + fy * 5 + fx * side * 3); + } + + ink(210, 226, 200, 140).write(`twig ${crossed + 1}`, { + x: 4, + y: screen.height - 10, + font: "MatrixChunky8", + }); +} + +function act({ event: e, sound }) { + if (e.is("touch")) { + startled = 0.7; + sound?.synth?.({ tone: 640, type: "sine", beats: 0.1, attack: 0.01, decay: 0.9, volume: 0.1 }); + } +} + +export { boot, sim, paint, act }; diff --git a/system/public/aesthetic.computer/disks/kana.mjs b/system/public/aesthetic.computer/disks/kana.mjs new file mode 100644 index 0000000000..0ded188d01 --- /dev/null +++ b/system/public/aesthetic.computer/disks/kana.mjs @@ -0,0 +1,271 @@ +// kana, 26.09.13 + +/* #region 📝 notes + A bumblebee. It wanders; hold anywhere and it comes to your finger. +#endregion */ + +const { cos, sin, sqrt, min, max, hypot, PI } = Math; + +const bee = { x: 0, y: 0, vx: 0, vy: 0, a: 0, flap: 0 }; +let flowers = []; +let stars = []; +let pollen = []; // motes the bee knocks loose over a flower. +let target = null; // where it's headed. null until boot sizes the screen. +let called = false; // a finger is down, so the bee chases it. +let wait = 0; // frames until it's bored of this flower. +let buzz; // the held wingbeat voice, alive only while a finger is down. +let tick = 0; + +const petalTints = [ + [235, 120, 180], + [180, 150, 255], + [250, 240, 240], + [255, 160, 90], +]; + +// Bee-space -> screen. px is along the body (nose at +x), py across it. +function at(px, py, a = bee.a, x = bee.x, y = bee.y) { + return [x + px * cos(a) - py * sin(a), y + px * sin(a) + py * cos(a)]; +} + +function ellipse(cx, cy, rx, ry, spin, steps = 14) { + const pts = []; + for (let i = 0; i < steps; i += 1) { + const t = (i / steps) * PI * 2; + const ex = rx * cos(t), + ey = ry * sin(t); + pts.push(at(cx + ex * cos(spin) - ey * sin(spin), cy + ex * sin(spin) + ey * cos(spin))); + } + return pts; +} + +// Flowers on their own stems, rooted low, none under the corner label. +function plant({ width, height }) { + const count = max(3, min(6, Math.round(width / 90))); + flowers = []; + for (let i = 0; i < count; i += 1) { + const x = ((i + 0.5) / count) * width + (Math.random() - 0.5) * (width / count) * 0.6; + flowers.push({ + x: max(14, min(width - 14, x)), + y: max(34, height - 40 - Math.random() * max(8, height * 0.26)), + r: 7 + Math.random() * 6, + tint: petalTints[(Math.random() * petalTints.length) | 0], + sway: Math.random() * PI * 2, + }); + } + + stars = []; + for (let i = 0; i < 40; i += 1) { + stars.push({ + x: Math.random() * width, + y: Math.random() * height * 0.55, + t: Math.random() * PI * 2, + }); + } +} + +function visit() { + const f = flowers[(Math.random() * flowers.length) | 0]; + target = f ? { x: f.x, y: f.y - f.r - 16 } : { x: bee.x, y: bee.y }; + wait = 70 + Math.random() * 140; +} + +function boot({ screen }) { + bee.x = screen.width / 2; + bee.y = screen.height / 3; + plant(screen); + visit(); +} + +function sim({ pen }) { + tick += 1; + if (!target) visit(); + if (called && pen) target = { x: pen.x, y: pen.y }; + else if (--wait < 0) visit(); + + const dx = target.x - bee.x, + dy = target.y - bee.y, + d = hypot(dx, dy) || 1; + + const pull = called ? 0.22 : 0.07; + bee.vx += (dx / d) * pull; + bee.vy += (dy / d) * pull; + bee.vx *= 0.94; + bee.vy *= 0.94; + + bee.x += bee.vx; + bee.y += bee.vy; + + // A bee never holds still, even over a flower. + bee.vx += sin(bee.flap * 0.043) * 0.08; + bee.vy += cos(bee.flap * 0.061) * 0.08; + + const speed = hypot(bee.vx, bee.vy); + if (speed > 0.2) bee.a += ((Math.atan2(bee.vy, bee.vx) - bee.a + PI * 3) % (PI * 2) - PI) * 0.2; + bee.flap += 0.9 + speed * 0.12; + + // Wingbeat rises with effort — but not every frame, that floods the voice. + if (buzz && tick % 6 === 0) buzz.update({ tone: 92 + speed * 16, duration: 0.1 }); + + for (const f of flowers) f.sway += 0.012; + for (const s of stars) s.t += 0.03; + + // Hovering close to a flower shakes pollen off it. + const near = flowers.find((f) => hypot(f.x - bee.x, f.y - bee.y) < f.r + 14); + if (near && pollen.length < 60 && tick % 4 === 0) { + pollen.push({ + x: near.x + (Math.random() - 0.5) * near.r * 2, + y: near.y + (Math.random() - 0.5) * near.r, + vy: -0.15 - Math.random() * 0.25, + life: 1, + }); + } + + for (const p of pollen) { + p.x += sin(p.life * 6) * 0.3; + p.y += p.vy; + p.life -= 0.008; + } + pollen = pollen.filter((p) => p.life > 0); +} + +function paint({ wipe, ink, screen, paintCount }) { + wipe(24, 30, 40); + ink("fade:midnightblue-rebeccapurple-indianred:vertical").box( + 0, + 0, + screen.width, + screen.height, + ); + + for (const s of stars) { + const twinkle = 60 + sin(s.t) * 50; + ink(255, 245, 220, max(0, twinkle)).point(s.x, s.y); + } + + const horizon = screen.height - 12; + ink(34, 24, 34).box(0, horizon, screen.width, screen.height - horizon); + + for (const f of flowers) { + const lean = sin(f.sway) * 4; + ink(60, 120, 80).line(f.x, screen.height, f.x + lean, f.y); + ink(70, 140, 90).line(f.x + lean * 0.5, f.y + f.r * 2.2, f.x + lean - 9, f.y + f.r * 1.6); + for (let p = 0; p < 6; p += 1) { + const a = (p / 6) * PI * 2 + f.sway * 0.3; + ink(f.tint).circle(f.x + lean + cos(a) * f.r, f.y + sin(a) * f.r, f.r * 0.62, true); + } + ink(250, 205, 70).circle(f.x + lean, f.y, f.r * 0.5, true); + } + + for (const p of pollen) ink(255, 225, 120, p.life * 200).point(p.x, p.y); + + const s = max(7, min(screen.width, screen.height) * 0.07); // half a body. + const spread = 0.55 + sin(bee.flap) * 0.45; + + // Wings first, behind the fuzz. + for (const side of [-1, 1]) { + ink(225, 240, 255, 70).shape({ points: wing(s, side, spread), filled: true }); + } + + // Legs. + for (let i = 0; i < 3; i += 1) { + const lx = (0.3 - i * 0.45) * s; + for (const side of [-1, 1]) { + const swing = sin(bee.flap * 0.35 + i) * 0.12 * s; + ink(30, 24, 18).line( + ...at(lx, side * 0.45 * s), + ...at(lx - 0.3 * s + swing, side * 0.95 * s), + ); + } + } + + // Striped abdomen: slices of one ellipse, dark and gold in turn. + const slices = 7; + for (let i = 0; i < slices; i += 1) { + const t0 = -1 + (i / slices) * 1.9, + t1 = -1 + ((i + 1) / slices) * 1.9; + const h0 = 0.62 * s * sqrt(max(0, 1 - t0 * t0)), + h1 = 0.62 * s * sqrt(max(0, 1 - t1 * t1)); + ink(i % 2 ? [250, 202, 48] : [28, 23, 20]).shape({ + points: [ + at(t0 * s, -h0), + at(t1 * s, -h1), + at(t1 * s, h1), + at(t0 * s, h0), + ], + filled: true, + }); + } + + // Stinger. + ink(20, 16, 14).shape({ + points: [at(-0.98 * s, -0.14 * s), at(-1.4 * s, 0), at(-0.98 * s, 0.14 * s)], + filled: true, + }); + + // Head and antennae. + ink(26, 21, 18).shape({ + points: ellipse(1.0 * s, 0, 0.44 * s, 0.4 * s, 0), + filled: true, + }); + for (const side of [-1, 1]) { + ink(26, 21, 18).line( + ...at(1.2 * s, side * 0.2 * s), + ...at(1.7 * s, side * 0.55 * s), + ); + ink(240, 236, 220).circle(...at(1.12 * s, side * 0.26 * s), max(1, s * 0.1), true); + } + + // A hint that shows itself once, then gets out of the way. + const fade = 255 - max(0, paintCount - 260) * 4; + if (fade > 0 && !called) { + ink(255, 240, 210, min(150, fade)).write("hold to call", { + x: screen.width - 78, + y: screen.height - 26, + }); + } +} + +// One wing: an ellipse swung out from the shoulder, `spread` sweeping it back. +function wing(s, side, spread) { + const a = side * (0.35 + spread * 0.7); + const reach = 0.85 * s; + return ellipse( + 0.1 * s + reach * cos(a), + side * 0.18 * s + reach * sin(a), + reach, + 0.26 * s, + a, + 12, + ); +} + +function act({ event: e, screen, sound }) { + if (e.is("reframed")) plant(screen); + + if (e.is("touch")) { + called = true; + buzz ||= sound?.synth?.({ + type: "square", + tone: 100, + attack: 0.08, + decay: 0.6, + duration: "🔁", + volume: 0.12, + }); + } + + if (e.is("lift")) { + called = false; + buzz?.kill(0.4); + buzz = undefined; + visit(); + } +} + +function leave() { + buzz?.kill(0.1); + buzz = undefined; +} + +export { boot, sim, paint, act, leave }; diff --git a/system/public/aesthetic.computer/disks/kizide.mjs b/system/public/aesthetic.computer/disks/kizide.mjs new file mode 100644 index 0000000000..72492b0499 --- /dev/null +++ b/system/public/aesthetic.computer/disks/kizide.mjs @@ -0,0 +1,200 @@ +// kizide, 26.09.11 +// A ring of textured cubes, spun by hand. + +const count = 8; +const reach = 2.4; // a cube's worst-case projected half-extent, in `size`s +const dist = 3; // camera distance, in cube halves +const light = [-0.39, -0.49, -0.78]; // unit, over the viewer's left shoulder + +const corners = []; +for (let i = 0; i < 8; i += 1) + corners.push([i & 1 ? 1 : -1, i & 2 ? 1 : -1, i & 4 ? 1 : -1]); + +// a face is an axis, a side, and the four corners that agree on that bit — +// walked in ring order, so the uv corners below line up with them. +const uvs = [ + [0, 0], + [0, 1], + [1, 1], + [1, 0], +]; + +const faces = []; +for (let a = 0; a < 3; a += 1) + for (const s of [-1, 1]) { + const b = (a + 1) % 3, + c = (a + 2) % 3; + const ring = uvs.map(([u, v]) => { + let i = s > 0 ? 1 << a : 0; + if (u) i |= 1 << b; + if (v) i |= 1 << c; + return i; + }); + faces.push([a, s, ring]); + } + +// the texture: a warm frame, magenta diagonals, a woven indigo ground. the +// frame is why the cubes still read as edged without any wireframe pass. +const texSize = 16; +const tex = new Uint8ClampedArray(texSize * texSize * 3); +for (let y = 0; y < texSize; y += 1) + for (let x = 0; x < texSize; x += 1) { + const framed = x === 0 || y === 0 || x === texSize - 1 || y === texSize - 1; + const c = framed + ? [255, 240, 200] + : (x + y) % 7 < 2 + ? [225, 70, 195] + : (x ^ y) & 2 + ? [54, 40, 92] + : [32, 24, 62]; + const o = (y * texSize + x) * 3; + tex[o] = c[0]; + tex[o + 1] = c[1]; + tex[o + 2] = c[2]; + } + +// one cube gets rasterized into this and pasted. writing straight into +// `screen.pixels` renders nothing here — the writes land in a valid buffer +// and are silently dropped, while `ink` in the same frame draws fine. `paste` +// goes through `graph`, which is the path that actually reaches the screen. +const tile = { width: 0, height: 0, pixels: null }; + +let yaw = 0, + pitch = 0, + orbit = 0; + +function sim() { + yaw += 0.02; + pitch += 0.013; + orbit += 0.003; +} + +function rot(x, y, z, cyw, syw, cp, sp) { + const x1 = x * cyw - z * syw, + z1 = x * syw + z * cyw; + return [x1, y * cp - z1 * sp, y * sp + z1 * cp]; +} + +// one triangle, barycentric, perspective-correct. a vertex is +// [x, y, iw, u * iw, v * iw] — all three interpolate linearly on screen. +function tri(px, W, H, p0, p1, p2, shade) { + const d = + (p1[1] - p2[1]) * (p0[0] - p2[0]) + (p2[0] - p1[0]) * (p0[1] - p2[1]); + if (d === 0) return; + const id = 1 / d; + + const lo = Math.max(0, Math.floor(Math.min(p0[0], p1[0], p2[0]))), + hi = Math.min(W - 1, Math.ceil(Math.max(p0[0], p1[0], p2[0]))), + top = Math.max(0, Math.floor(Math.min(p0[1], p1[1], p2[1]))), + bot = Math.min(H - 1, Math.ceil(Math.max(p0[1], p1[1], p2[1]))); + + for (let y = top; y <= bot; y += 1) + for (let x = lo; x <= hi; x += 1) { + const sx = x + 0.5, + sy = y + 0.5; + const w0 = + ((p1[1] - p2[1]) * (sx - p2[0]) + (p2[0] - p1[0]) * (sy - p2[1])) * + id, + w1 = + ((p2[1] - p0[1]) * (sx - p2[0]) + (p0[0] - p2[0]) * (sy - p2[1])) * id, + w2 = 1 - w0 - w1; + if (w0 < 0 || w1 < 0 || w2 < 0) continue; + + const iw = w0 * p0[2] + w1 * p1[2] + w2 * p2[2]; + const u = (w0 * p0[3] + w1 * p1[3] + w2 * p2[3]) / iw, + v = (w0 * p0[4] + w1 * p1[4] + w2 * p2[4]) / iw; + + const tx = Math.min(texSize - 1, Math.max(0, (u * texSize) | 0)), + ty = Math.min(texSize - 1, Math.max(0, (v * texSize) | 0)); + const t = (ty * texSize + tx) * 3, + o = (y * W + x) * 4; + px[o] = tex[t] * shade; + px[o + 1] = tex[t + 1] * shade; + px[o + 2] = tex[t + 2] * shade; + px[o + 3] = 255; + } +} + +function cube(px, W, H, cx, cy, size, yaw, pitch) { + const cyw = Math.cos(yaw), + syw = Math.sin(yaw), + cp = Math.cos(pitch), + sp = Math.sin(pitch); + + const flat = corners.map(([x, y, z]) => { + const p = rot(x, y, z, cyw, syw, cp, sp); + const iw = dist / (dist + p[2]); + return [cx + p[0] * size * iw, cy + p[1] * size * iw, iw]; + }); + + faces.forEach(([a, s, ring]) => { + const n = rot( + a === 0 ? s : 0, + a === 1 ? s : 0, + a === 2 ? s : 0, + cyw, + syw, + cp, + sp, + ); + if (n[2] >= 0) return; // facing away — a cube is convex, so that's all + // the depth sorting it needs. + + const lam = n[0] * light[0] + n[1] * light[1] + n[2] * light[2]; + const shade = 0.35 + 0.65 * (0.5 + 0.5 * lam); // half lambert; nothing + // goes fully black at this size. + + const q = ring.map((i, k) => { + const f = flat[i]; + return [f[0], f[1], f[2], uvs[k][0] * f[2], uvs[k][1] * f[2]]; + }); + tri(px, W, H, q[0], q[1], q[2], shade); + tri(px, W, H, q[0], q[2], q[3], shade); + }); +} + +function paint({ wipe, paste, screen }) { + wipe(12, 10, 24); + + const W = screen.width, + H = screen.height; + + // the ring wants room for itself plus one cube's reach; neighbours want a + // gap of the same reach. both fall out of one radius. + const room = Math.min(W, H) / 2 - 6, + gap = Math.sin(Math.PI / count), + ring = room / (1 + gap), + size = (ring * gap) / reach; + + const span = Math.ceil(size * reach * 2) + 2; + if (tile.width !== span) { + tile.width = tile.height = span; + tile.pixels = new Uint8ClampedArray(span * span * 4); + } + + for (let i = 0; i < count; i += 1) { + const a = orbit + (i / count) * Math.PI * 2; + const cx = W / 2 + Math.cos(a) * ring, + cy = H / 2 + Math.sin(a) * ring; + const ox = Math.floor(cx - span / 2), + oy = Math.floor(cy - span / 2); + + tile.pixels.fill(0); // transparent — `paste` skips those, so the wipe + // shows between the cubes. + cube( + tile.pixels, + span, + span, + cx - ox, // keep the subpixel remainder, so the orbit stays smooth + cy - oy, + size, + // its own rate, not just its own phase — a cube has 90° symmetry, so + // equal-step offsets keep re-syncing and the ring goes flat-on at once. + yaw * (1 + i * 0.07) + i * 2.39996, + pitch * (1 + i * 0.11) + i * 1.0472, + ); + paste(tile, ox, oy); + } +} + +export { sim, paint }; diff --git a/system/public/aesthetic.computer/disks/kokazo.mjs b/system/public/aesthetic.computer/disks/kokazo.mjs new file mode 100644 index 0000000000..31a7a277fd --- /dev/null +++ b/system/public/aesthetic.computer/disks/kokazo.mjs @@ -0,0 +1,263 @@ +// kokazo, 26.09.11 +// A smiley with a body. It dances, and it runs under falling food to catch it. +// Tap the background to drop a snack. Tap the guy to poke him. + +const { min, max, abs, sin, cos, atan2, hypot, PI } = Math; + +let blink = 0, wait = 80, t = 0; +let grin = 0, hover = 0, poke = 0, chew = 0, mood = 1, lean = { x: 0, y: 0 }; +let food = [], crumbs = [], dropIn = 120, caught = 0, missed = 0, chase = 0; +const head = { cx: 0, cy: 0, r: 1 }; // last painted geometry. (act hit-tests it) +const torso = { cx: 0, cy: 0, rx: 1, ry: 1 }; +const stage = { s: 10, w: 100, groundY: 100 }; // last painted layout. (sim reads it) + +const SNACKS = [ + { k: "berry", c: [240, 70, 90] }, + { k: "donut", c: [250, 180, 70] }, + { k: "pea", c: [140, 220, 110] }, + { k: "berry", c: [200, 110, 240] }, +]; + +function hit(x, y) { + return ( + hypot(x - head.cx, y - head.cy) < head.r || + (abs(x - torso.cx) < torso.rx && abs(y - torso.cy) < torso.ry) + ); +} + +function lowest() { + return food.reduce((a, f) => (!a || f.y > a.y ? f : a), null); +} + +function drop(x) { + const s = SNACKS[(Math.random() * SNACKS.length) | 0]; + food.push({ ...s, x, y: -stage.s * 0.5, vy: 0, spin: Math.random() * PI }); +} + +function burst(f, n) { + for (let i = 0; i < n; i += 1) { + crumbs.push({ + x: f.x, y: f.y, c: f.c, + vx: (Math.random() - 0.5) * stage.s * 0.3, + vy: -Math.random() * stage.s * 0.14, + life: 18 + Math.random() * 16, + }); + } +} + +function sim({ pen, sound }) { + t += 0.07 + grin * 0.09 + poke * 0.22; // the dance picks up when provoked. + if (blink > 0) blink -= 1; + else if (--wait <= 0) { blink = 7; wait = 60 + Math.random() * 180; } + + poke *= 0.88; + chew = max(0, chew - 1); + mood = ease(mood, 1, 0.03); // a miss stings, then passes. + grin = ease(grin, pen?.drawing && poke < 0.05 ? 1 : 0, 0.12); + hover = ease(hover, pen && hit(pen.x, pen.y) ? 1 : 0, 0.2); + + const pull = pen?.drawing ? 0.16 : 0; + lean.x = ease(lean.x, pen ? clamp((pen.x - head.cx) / head.r, -1, 1) * head.r * pull : 0, 0.14); + lean.y = ease(lean.y, pen ? clamp((pen.y - head.cy) / head.r, -1, 1) * head.r * pull : 0, 0.14); + + if (--dropIn <= 0) { // snacks keep coming whether or not you feed him. + drop(stage.s + Math.random() * max(1, stage.w - stage.s * 2)); + dropIn = 150 + Math.random() * 170; + } + + const g = stage.s * 0.013; + // Terminal speed stays under the catch radius, or a fast snack tunnels past his hands. + food.forEach((f) => { f.vy = min(f.vy + g, stage.s * 0.4); f.y += f.vy; f.spin += 0.08; }); + food = food.filter((f) => { + if (f.y < stage.groundY) return true; + missed += 1; + mood = -1; + burst(f, 9); + sound?.synth?.({ type: "square", tone: 90, duration: 0.12, attack: 0.01, decay: 0.9, volume: 0.25 }); + return false; + }); + + crumbs.forEach((c) => { c.vy += g; c.x += c.vx; c.y += c.vy; c.life -= 1; }); + crumbs = crumbs.filter((c) => c.life > 0); + + // He scoots under whatever is closest to landing. + const mark = lowest(); + const room = stage.w / 2 - stage.s * 1.1; // far enough to stand under an edge drop. + chase = ease(chase, mark ? clamp(mark.x - stage.w / 2, -room, room) : 0, 0.07); +} + +function paint({ wipe, ink, write, screen, pen, sound }) { + const { width: w, height: h } = screen; + const s = min(w / 7, h / 16); // zoomed out, but a tall phone shouldn't be all sky. + const beat = sin(t), swing = sin(t / 2); + const lift = abs(beat); + + const x = w / 2 + chase + swing * s * 0.35 + lean.x; + const groundY = h - s * 1.6; // he stands near the bottom, sky above for the food. + const top = max(26, groundY - s * 7.6) - lift * s * 0.3 + lean.y; + const shoulderY = top + s * 2.95; + const hipY = shoulderY + s * 2; + + stage.s = s; stage.w = w; stage.groundY = groundY; + head.r = s * 1.5 * (1 + poke * 0.07); + head.cx = x + swing * s * 0.12; + head.cy = top + s * 1.5 + sin(t * 2) * s * 0.06; + torso.cx = x + swing * s * 0.06; + torso.cy = (shoulderY + hipY) / 2; + torso.rx = s * 0.85; + torso.ry = (hipY - shoulderY) / 2 + s * 0.35; + + wipe(40 - hover * 10, 60 + hover * 10, 110 + hover * 30); + + ink(26, 38, 82).box(0, groundY + s * 0.25, w, h - groundY); // the floor he dances on. + ink(16, 24, 60, 170).oval(x, groundY + s * 0.25, s * (1.2 - lift * 0.25), s * 0.22, true); + + const skin = [255, 210 + poke * 30, 60]; + const limb = [190 + hover * 65, 130 + hover * 60, 20]; + + crumbs.forEach((c) => ink(c.c[0], c.c[1], c.c[2], c.life * 8).circle(c.x, c.y, s * 0.09, true)); + food.forEach((f) => snack(ink, f, s)); + + // Legs kick opposite the arms, one splaying as the other tucks. + [-1, 1].forEach((side) => { + const step = sin(t + (side < 0 ? PI : 0)); + const a1 = PI / 2 - side * (0.3 + 0.3 * step); + const a2 = a1 + side * 0.22 * max(0, step); + const foot = hose(ink, limb, x + side * s * 0.42, hipY + s * 0.2, a1, s * 1.2, a2, s * 1.1, s * 0.42); + ink(...limb).oval(foot[0], foot[1] + s * 0.1, s * 0.3, s * 0.16, true); + }); + + ink(...skin).oval(torso.cx, torso.cy, torso.rx, torso.ry, true); + ink(...limb).oval(torso.cx, torso.cy, torso.rx, torso.ry); + + // Arms throw up on the beat — unless there's food, which they reach for. + const mark = lowest(); + [-1, 1].forEach((side) => { + const sw = sin(t + (side < 0 ? 0 : PI)); + const sx = torso.cx + side * s * 0.8, sy = shoulderY + s * 0.2; + let a1 = PI / 2 - side * (0.9 + 0.95 * sw); + let a2 = a1 - side * (0.1 + 0.6 * max(0, sw)); + if (mark) { + const aim = atan2(mark.y - sy, mark.x - sx); + const reach = clamp(1 - (hypot(mark.x - sx, mark.y - sy) - s * 2) / (s * 2), 0, 1); + a1 = a1 + (aim - a1) * reach; + a2 = a2 + (aim - a2) * reach; + } + const hand = hose(ink, limb, sx, sy, a1, s * 1.15, a2, s * 1.05, s * 0.38); + ink(...skin).circle(hand[0], hand[1], s * 0.26, true); // mitts, so a lowered arm still reads. + + food = food.filter((f) => { + if (hypot(f.x - hand[0], f.y - hand[1]) > s * 0.6) return true; + caught += 1; + chew = 28; + mood = 1; + burst(f, 5); + sound?.synth?.({ type: "sine", tone: 640 + caught * 6, duration: 0.09, attack: 0.01, decay: 0.96, volume: 0.4 }); + return false; + }); + }); + + const { cx, cy } = head; + const R = head.r; + ink(...skin).circle(cx, cy, R, true); + ink(...limb).circle(cx, cy, R); + + if (hover > 0.05) { // blush that fades in under the cursor. + [-1, 1].forEach((side) => { + ink(255, 120, 130, hover * 90).circle(cx + side * R * 0.58, cy + R * 0.12, R * 0.14, true); + }); + } + + // The eyes watch the food if there is any, otherwise the pen. + const watch = mark || pen; + const eyeR = R * (0.16 + poke * 0.06); + const lookX = watch ? clamp((watch.x - cx) / R, -1, 1) : 0; + const lookY = watch ? clamp((watch.y - cy) / R, -1, 1) : 0; + + [-1, 1].forEach((side) => { + const ex = cx + side * R * 0.36, ey = cy - R * 0.22; + if (blink > 0 && poke < 0.2) { + ink(70, 30, 20).line(ex - eyeR, ey, ex + eyeR, ey); + } else { + ink(255, 245, 220).circle(ex, ey, eyeR, true); + const pupil = eyeR * (poke > 0.2 ? 0.34 : 0.5); + ink(30, 20, 10).circle(ex + lookX * eyeR * 0.5, ey + lookY * eyeR * 0.5, pupil, true); + } + }); + + const my = cy + R * 0.08; + if (chew > 0) { // chomping, jaw working. + ink(70, 30, 20).oval(cx, my + R * 0.16, R * 0.26, R * (0.1 + 0.18 * abs(sin(chew * 0.6))), true); + } else if (poke > 0.08) { // a startled O, wider the harder it was poked. + ink(70, 30, 20).circle(cx, my + R * 0.18, R * (0.1 + poke * 0.22), true); + } else { + const mr = R * (0.5 + grin * 0.1); // the smile flips to a frown on a miss. + const spread = PI * (0.22 + grin * 0.14); + let px, py; + for (let i = 0; i <= 24; i += 1) { + const a = PI / 2 - spread + (spread * 2 * i) / 24; + const mx = cx + cos(a) * mr, myy = my + sin(a) * mr * mood; + if (i > 0) ink(70, 30, 20).line(px, py, mx, myy); + px = mx; py = myy; + } + } + + ink(255, 230, 120).write(`caught ${caught}`, { x: 6, y: h - 22 }); + ink(150, 160, 200).write(`missed ${missed}`, { x: 6, y: h - 12 }); +} + +function act({ event: e, sound }) { + if (e.is("touch")) { + if (hit(e.x, e.y)) { + poke = 1; + grin = 0; + sound?.synth?.({ type: "sine", tone: 520, duration: 0.06, attack: 0.01, decay: 0.96, volume: 0.5 }); + } else { + drop(e.x); // anywhere else is a snack. + } + } + if (e.is("lift") && poke > 0.1) { // he springs back with a little chirp. + sound?.synth?.({ type: "sine", tone: 760, duration: 0.08, attack: 0.01, decay: 0.96, volume: 0.4 }); + } +} + +function snack(ink, f, s) { + const r = s * 0.34; + if (f.k === "donut") { + ink(...f.c).circle(f.x, f.y, r, true); + ink(50, 40, 60).circle(f.x, f.y, r * 0.36, true); + } else if (f.k === "pea") { + ink(...f.c).oval(f.x, f.y, r, r * 0.68, true); + } else { + ink(...f.c).circle(f.x, f.y, r, true); + ink(110, 200, 110).line(f.x, f.y - r, f.x + cos(f.spin) * r, f.y - r * 1.7); + } +} + +// A rubber-hose limb: two segments stamped as overlapping dots, so the width +// stays even and the joints round themselves. (pline tapers at the anchor) +function hose(ink, color, x, y, a1, l1, a2, l2, thick) { + const jx = x + cos(a1) * l1, jy = y + sin(a1) * l1; + const ex = jx + cos(a2) * l2, ey = jy + sin(a2) * l2; + const r = thick / 2; + stamp(ink, color, x, y, jx, jy, r); + stamp(ink, color, jx, jy, ex, ey, r); + return [ex, ey]; +} + +function stamp(ink, color, x0, y0, x1, y1, r) { + const n = max(2, Math.round(hypot(x1 - x0, y1 - y0) / (r * 0.6))); + for (let i = 0; i <= n; i += 1) { + ink(...color).circle(x0 + ((x1 - x0) * i) / n, y0 + ((y1 - y0) * i) / n, r, true); + } +} + +function ease(n, to, k) { + return n + (to - n) * k; +} + +function clamp(n, lo, hi) { + return n < lo ? lo : n > hi ? hi : n; +} + +export { sim, paint, act }; diff --git a/system/public/aesthetic.computer/disks/konuzu.mjs b/system/public/aesthetic.computer/disks/konuzu.mjs new file mode 100644 index 0000000000..283c14293d --- /dev/null +++ b/system/public/aesthetic.computer/disks/konuzu.mjs @@ -0,0 +1,10 @@ +// konuzu, 26.09.11 +// A blank Aesthetic Computer piece. + +function paint({ wipe, ink, screen }) { + wipe(70, 50, 100); + ink(255, 100, 255).write("konuzu", { center: "xy" }); + return false; // Painted once; return true to keep painting. +} + +export { paint }; diff --git a/system/public/aesthetic.computer/disks/lusu.mjs b/system/public/aesthetic.computer/disks/lusu.mjs new file mode 100644 index 0000000000..2d18cfb39a --- /dev/null +++ b/system/public/aesthetic.computer/disks/lusu.mjs @@ -0,0 +1,35 @@ +// lusu, 26.09.13 +// Red, with stripes the pointer steers — x drifts them, y sets their width. + +let shift = 0; +let pointer; // last known pen position, null until it moves +let held = false; + +function paint({ wipe, ink, screen }) { + wipe(180, 20, 30); + + const half = screen.width / 2; + const w = pointer ? 4 + (pointer.y / screen.height) * 28 : 12; + const gap = w * 2; + + for (let x = -gap + (((shift % gap) + gap) % gap); x < screen.width; x += gap) { + ink(220, 60, 70).box(x, 0, w, screen.height); + } + + if (held && pointer) { + ink(255, 230, 220, 90).box(pointer.x - 20, 0, 40, screen.height); + } + + ink(255, 220, 210).write("lusu", { center: "xy" }); + + // drift speed rides on how far the pointer sits from center. + shift += pointer ? ((pointer.x - half) / half) * 3 : 0.4; +} + +function act({ event: e }) { + if (e.is("move") || e.is("draw") || e.is("touch")) pointer = { x: e.x, y: e.y }; + if (e.is("touch")) held = true; + if (e.is("lift")) held = false; +} + +export { paint, act }; diff --git a/system/public/aesthetic.computer/disks/murafi.mjs b/system/public/aesthetic.computer/disks/murafi.mjs new file mode 100644 index 0000000000..a28e568860 --- /dev/null +++ b/system/public/aesthetic.computer/disks/murafi.mjs @@ -0,0 +1,21 @@ +// murafi, 26.09.11 +// Random lines, drawn forever. + +function boot({ wipe }) { + wipe(70, 50, 100); +} + +function paint({ ink, screen, num }) { + const { randInt } = num; + + for (let i = 0; i < 4; i += 1) { + ink(randInt(255), randInt(255), randInt(255), 128).line( + randInt(screen.width), + randInt(screen.height), + randInt(screen.width), + randInt(screen.height), + ); + } +} + +export { boot, paint }; diff --git a/system/public/aesthetic.computer/disks/neme.mjs b/system/public/aesthetic.computer/disks/neme.mjs new file mode 100644 index 0000000000..2595ef5154 --- /dev/null +++ b/system/public/aesthetic.computer/disks/neme.mjs @@ -0,0 +1,24 @@ +// neme, 26.09.13 + +const w = 24; // stripe width +let shift = 0; + +function paint({ wipe, ink, screen, pen, num: { hslToRgb } }) { + // the pen slides the pair around the wheel; red & blue are just where it rests. + const hue = pen ? (pen.x / screen.width) * 360 : 0; + const light = pen ? 65 - (pen.y / screen.height) * 30 : 50; + const a = hslToRgb(hue, 75, light); + const b = hslToRgb((hue + 240) % 360, 75, light); + + wipe(16, 16, 24); + for (let x = -w * 2 + (shift % (w * 2)); x < screen.width; x += w * 2) { + ink(a).box(x, 0, w, screen.height); + ink(b).box(x + w, 0, w, screen.height); + } +} + +function sim() { + shift += 1; +} + +export { paint, sim }; diff --git a/system/public/aesthetic.computer/disks/pedu.mjs b/system/public/aesthetic.computer/disks/pedu.mjs new file mode 100644 index 0000000000..f2722a254f --- /dev/null +++ b/system/public/aesthetic.computer/disks/pedu.mjs @@ -0,0 +1,14 @@ +// pedu, 26.09.13 + +function paint({ wipe, ink, screen }) { + const { width: w, height: h } = screen; + const pad = 16; + const thick = max(8, floor(min(w, h) / 12)); + wipe(70, 50, 100); + ink(255, 0, 0).line(pad, h - pad - thick, w - pad, pad + 24, thick); + return false; +} + +const { max, min, floor } = Math; + +export { paint }; diff --git a/system/public/aesthetic.computer/disks/puzi.mjs b/system/public/aesthetic.computer/disks/puzi.mjs new file mode 100644 index 0000000000..f8bfca14aa --- /dev/null +++ b/system/public/aesthetic.computer/disks/puzi.mjs @@ -0,0 +1,169 @@ +// puzi, 26.09.11 +// Notepat with only three keys. Tap the pads or press C, E, G. + +import { getNoteColorWithOctave } from "../lib/note-colors.mjs"; + +const NOTES = ["c", "e", "g"]; // The whole instrument. +const WAVES = ["sine", "triangle", "square", "sawtooth"]; +const KILL_FADE = 0.15; + +let octave = 4; +let waveIndex = 0; +let wave = WAVES[waveIndex]; + +const buttons = {}; // note -> ui.Button +const sounds = {}; // note -> synth handle +const downs = {}; // hardware keys currently held +const glow = {}; // note -> 0..1 afterglow + +let soundContext = null; + +// The system paints its corner label at (6, 6) in a 6x10 font, so the pads +// start below it and the readout lives along the bottom. +const LABEL_BOTTOM = 20; +const READOUT_HEIGHT = 14; + +function setup({ ui, geo, screen }) { + const margin = 3; + const top = LABEL_BOTTOM + margin; + const w = (screen.width - margin * (NOTES.length + 1)) / NOTES.length; + const h = screen.height - top - margin - READOUT_HEIGHT; + + NOTES.forEach((note, i) => { + const geometry = [ + Math.round(margin + i * (w + margin)), + top, + Math.round(w), + h, + ]; + if (!buttons[note]) { + buttons[note] = new ui.Button(...geometry); + buttons[note].id = `pad-${note}`; + } else { + buttons[note].box = new geo.Box(...geometry); + } + }); +} + +function colorFor(note) { + return getNoteColorWithOctave(note, octave); +} + +function startNote(note) { + const synth = soundContext?.synth; + if (!synth) return; + if (sounds[note]) sounds[note].kill(0.02); // Retrigger cleanly. + sounds[note] = synth({ + type: wave, + tone: `${octave}${note.toUpperCase()}`, + duration: "🔁", + attack: 0.005, + volume: 0.8, + }); + glow[note] = 1; +} + +function stopNote(note) { + sounds[note]?.kill(KILL_FADE); + delete sounds[note]; +} + +function boot({ ui, geo, screen, sound }) { + soundContext = sound; + setup({ ui, geo, screen }); +} + +function sim({ sound }) { + soundContext = sound; + NOTES.forEach((note) => { + if (sounds[note]) { + glow[note] = 1; + } else if (glow[note] > 0) { + glow[note] = Math.max(0, glow[note] - 0.06); + } + }); +} + +function paint({ wipe, ink, screen, write }) { + wipe(12, 10, 20); + + NOTES.forEach((note) => { + const btn = buttons[note]; + if (!btn) return; + const { x, y, w, h } = btn.box; + const [r, g, b] = colorFor(note); + const lit = glow[note] || 0; + const held = Boolean(sounds[note]); + + // Dim body, brightening with the afterglow. + const fade = 0.35 + 0.65 * lit; + ink(r * fade, g * fade, b * fade).box(x, y, w, h); + ink(held ? 255 : 90 + 120 * lit).box(x, y, w, h, "outline"); + + ink(held ? [12, 10, 20] : [255, 255, 255]).write( + note.toUpperCase() + octave, + { center: "xy", x: x + w / 2, y: y + h / 2 }, + ); + }); + + // Readout along the bottom, clear of the corner label. + const readoutY = screen.height - READOUT_HEIGHT / 2; + ink(200, 200, 255).write(wave, { center: "y", x: 4, y: readoutY }); + ink(160, 160, 200).write(`oct ${octave}`, { + center: "y", + x: screen.width - 4 - 7 * 6, + y: readoutY, + }); +} + +function act({ event: e, sound, pens, ui, geo, screen }) { + soundContext = sound; + + if (e.is("reframed")) setup({ ui, geo, screen }); + + NOTES.forEach((note) => { + buttons[note]?.act( + e, + { + down: () => startNote(note), + over: (btn) => { + if (btn.up) { + btn.up = false; + startNote(note); + } + }, + out: () => stopNote(note), + push: () => stopNote(note), + cancel: () => stopNote(note), + }, + pens?.(), + ); + }); + + NOTES.forEach((note) => { + if (e.is(`keyboard:down:${note}`) && !downs[note]) { + downs[note] = true; + if (buttons[note]) buttons[note].down = true; + startNote(note); + } + if (e.is(`keyboard:up:${note}`)) { + delete downs[note]; + if (buttons[note]) buttons[note].down = false; + stopNote(note); + } + }); + + // Octave and waveform. + if (e.is("keyboard:down:arrowup")) octave = Math.min(8, octave + 1); + if (e.is("keyboard:down:arrowdown")) octave = Math.max(1, octave - 1); + if (e.is("keyboard:down:w")) { + waveIndex = (waveIndex + 1) % WAVES.length; + wave = WAVES[waveIndex]; + } +} + +function leave() { + NOTES.forEach(stopNote); +} + +export { boot, sim, paint, act, leave }; diff --git a/system/public/aesthetic.computer/disks/ruke.mjs b/system/public/aesthetic.computer/disks/ruke.mjs new file mode 100644 index 0000000000..53d37e8889 --- /dev/null +++ b/system/public/aesthetic.computer/disks/ruke.mjs @@ -0,0 +1,113 @@ +// ruke, 26.09.13 + +/* #region 📝 notes + A single notepat-style pad: neon flash while held, ghost trail on release. +#endregion */ + +const pad = { note: "4G", label: "G", tint: [110, 200, 255] }; + +let btn, voice, trail = 0; + +// Centered pad, sized off the smaller dimension, kept clear of the label. +function layout({ screen }) { + const w = Math.min(120, Math.round(Math.min(screen.width, screen.height) * 0.5)); + const h = Math.round(w * 0.75); + return [ + Math.round((screen.width - w) / 2), + Math.max(24, Math.round((screen.height - h) / 2)), + w, + h, + ]; +} + +function boot({ ui, screen }) { + btn = new ui.Button(...layout({ screen })); +} + +function paint({ wipe, ink, screen, paintCount }) { + wipe(16, 14, 22); + + btn?.paint((b) => { + let c = pad.tint; + + if (b.down) { + // Dayglow cycle — the held pad should be unmistakable across the room. + const phase = (paintCount * 0.15) % 3; + const f = + phase < 1 ? [255, 80, 120] : phase < 2 ? [80, 255, 80] : [120, 80, 255]; + c = c.map((v, i) => Math.min(255, Math.round(v * 0.4 + f[i] * 0.6))); + } + + ink(c, 196).box(b.box); + + if (b.over && !b.down) { + ink(...pad.tint, 40).box(b.box); + ink(...pad.tint, 140).box(b.box, "outline"); + } + + if (trail > 0) { + ink("maroon", Math.max(1, trail * 180)).box( + b.box.x + b.box.w / 2, + b.box.y + b.box.h / 2, + trail * b.box.w, + trail * b.box.h, + "center", + ); + } + + ink(b.down ? "white" : [20, 18, 26]).write( + pad.label, + { x: b.box.x + 3, y: b.box.y + 2 }, + undefined, + undefined, + false, + "MatrixChunky8", + ); + }); + + ink(120, 130, 150).write(pad.note, { x: 4, y: screen.height - 12 }); +} + +function sim() { + if (trail > 0) trail = Math.max(0, trail - 0.06); +} + +function act({ event: e, sound, pens, screen, geo }) { + if (e.is("reframed")) btn.box = new geo.Box(...layout({ screen })); + + const hit = () => { + voice?.kill(0.1); + voice = sound.synth({ + type: "sine", + tone: pad.note, + attack: 0.005, + decay: 0.9, + duration: "🔁", + volume: 0.8, + }); + }; + + const release = (fade = 0.15) => { + voice?.kill(fade); + voice = undefined; + trail = 1; + }; + + btn?.act( + e, + { + down: hit, // also fires when a gesture slides onto the pad + out: () => release(), + push: () => release(), + cancel: () => release(0.02), + }, + pens?.(), + ); +} + +function leave() { + voice?.kill(0.1); + voice = undefined; +} + +export { boot, paint, sim, act, leave }; diff --git a/system/public/aesthetic.computer/disks/ruzomo.mjs b/system/public/aesthetic.computer/disks/ruzomo.mjs new file mode 100644 index 0000000000..e84b70e2cc --- /dev/null +++ b/system/public/aesthetic.computer/disks/ruzomo.mjs @@ -0,0 +1,10 @@ +// ruzomo, 26.09.13 +// A blank Aesthetic Computer piece. + +function paint({ wipe, ink, screen }) { + wipe(70, 50, 100); + ink(255, 100, 255).write("ruzomo", { center: "xy" }); + return false; // Painted once; return true to keep painting. +} + +export { paint }; diff --git a/system/public/aesthetic.computer/disks/zutega.mjs b/system/public/aesthetic.computer/disks/zutega.mjs new file mode 100644 index 0000000000..2c74e8a960 --- /dev/null +++ b/system/public/aesthetic.computer/disks/zutega.mjs @@ -0,0 +1,9 @@ +// zutega, 26.09.15 +// A blank Aesthetic Computer piece. + +function paint({ wipe }) { + wipe(70, 50, 100); + return false; // Painted once; return true to keep painting. +} + +export { paint }; diff --git a/toolchain/email/la-workshop-audience.mjs b/toolchain/email/la-workshop-audience.mjs new file mode 100644 index 0000000000..536cb1b906 --- /dev/null +++ b/toolchain/email/la-workshop-audience.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +// Build a private, read-only LA-area candidate report from Lith. Never sends mail. +// node toolchain/email/la-workshop-audience.mjs --output /absolute/private/report.json +import { spawnSync } from 'node:child_process'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, isAbsolute } from 'node:path'; + +async function collect(allCalifornia = false, neighbors = false) { + const { MongoClient } = await import('mongodb'); + const client = new MongoClient(process.env.MONGODB_CONNECTION_STRING, { + maxPoolSize: 1, serverSelectionTimeoutMS: 10000, + }); + await client.connect(); + try { + const db = client.db(process.env.MONGODB_NAME); + const since = new Date(Date.now() - 90 * 86400000); + const cities = ['Los Angeles', 'East Los Angeles', 'Santa Monica', 'Burbank', + 'Pasadena', 'South Pasadena', 'Glendale', 'Long Beach', 'Inglewood', + 'Culver City', 'West Hollywood', 'Beverly Hills', 'Torrance', 'Alhambra', + 'Monterey Park', 'Monrovia', 'Azusa', 'Pomona', 'Hidden Hills']; + const match = { createdAt: { $gte: since }, 'server.country': 'US', + 'server.region': { $in: ['California', 'CA'] }, 'server.city': { $in: cities } }; + if (allCalifornia || neighbors) delete match['server.city']; + if (neighbors) match['server.region'] = { $in: ['Arizona', 'AZ', 'Nevada', 'NV', 'Oregon', 'OR'] }; + const locationSummary = await db.collection('boots').aggregate([ + { $match: match }, + { $group: { _id: {city: '$server.city', state: '$server.region'}, boots: { $sum: 1 }, + identifiedBoots: { $sum: { $cond: [ + { $ne: [{ $ifNull: ['$meta.user', null] }, null] }, 1, 0] } } } }, + { $sort: { boots: -1 } }, + ], { maxTimeMS: 15000 }).toArray(); + const accounts = await db.collection('boots').aggregate([ + { $match: match }, + { $set: { accountId: { $ifNull: ['$meta.user.sub', '$meta.user'] } } }, + { $match: { accountId: { $type: 'string', $ne: '' } } }, + { $group: { _id: '$accountId', cities: { $addToSet: '$server.city' }, + states: { $addToSet: '$server.region' }, + boots: { $sum: 1 }, lastSeen: { $max: '$createdAt' } } }, + ], { maxTimeMS: 15000 }).toArray(); + const normalize = (email) => String(email || '').trim().toLowerCase(); + const unsubs = new Set((await db.collection('email-blast-unsubscribes') + .find({}, { projection: { email: 1 }, maxTimeMS: 10000 }).toArray()).map(x => normalize(x.email))); + const muted = new Set(); + for (const name of ['chat-system-mutes', 'chat-clock-mutes', 'chat-sotce-mutes']) { + for (const row of await db.collection(name).find({}, { + projection: { user: 1 }, maxTimeMS: 10000, + }).toArray()) muted.add(row.user); + } + const exclusions = { muted: 0, unsubscribed: 0, unverified: 0, + blocked: 0, missingAccount: 0, missingEmail: 0, duplicate: 0, organizer: 0 }; + const candidates = []; + const seen = new Set(); + const organizerEmails = new Set(['mail@aesthetic.computer', 'me@jas.life', + 'mail@whistlegraph.org', 'jeffrey@aesthetic.computer']); + let token; + if (accounts.length) { + const res = await fetch('https://aesthetic.us.auth0.com/oauth/token', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_id: process.env.AUTH0_M2M_CLIENT_ID, + client_secret: process.env.AUTH0_M2M_SECRET, + audience: 'https://aesthetic.us.auth0.com/api/v2/', grant_type: 'client_credentials' }), + signal: AbortSignal.timeout(15000), + }); + if (!res.ok) throw new Error(`Auth0 token request failed: ${res.status}`); + token = (await res.json()).access_token; + } + for (const account of accounts) { + if (muted.has(account._id)) { exclusions.muted++; continue; } + const res = await fetch(`https://aesthetic.us.auth0.com/api/v2/users/${encodeURIComponent(account._id)}?fields=email,email_verified,blocked&include_fields=true`, { + headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(15000), + }); + if (res.status === 404) { exclusions.missingAccount++; continue; } + if (!res.ok) throw new Error(`Auth0 account lookup failed: ${res.status}`); + const user = await res.json(); + const email = normalize(user.email); + if (user.blocked) { exclusions.blocked++; continue; } + if (!user.email_verified) { exclusions.unverified++; continue; } + if (!email || !email.includes('@')) { exclusions.missingEmail++; continue; } + if (unsubs.has(email)) { exclusions.unsubscribed++; continue; } + if (organizerEmails.has(email)) { exclusions.organizer++; continue; } + if (seen.has(email)) { exclusions.duplicate++; continue; } + seen.add(email); + candidates.push({ email, cities: account.cities.sort(), states: account.states.sort(), boots: account.boots, + lastSeen: account.lastSeen, priorInvitation: 'unchecked', + basis: 'signed-in boot with approximate Cloudflare city; not confirmed residence' }); + } + return { generatedAt: new Date(), since, scope: neighbors ? 'Arizona, Nevada, Oregon' : allCalifornia ? 'California' : 'LA area', cities: allCalifornia || neighbors ? null : cities, locationSummary, + identifiedAccounts: accounts.length, exclusions, + note: 'Candidates only. No mail sent. Check prior invitations and recheck suppressions before sending. IPs and account IDs are not exported.', + candidates: candidates.sort((a, b) => a.email.localeCompare(b.email)) }; + } finally { await client.close(); } +} + +const args = process.argv.slice(2); +const allCalifornia = args.includes('--california'); +if (allCalifornia) args.splice(args.indexOf('--california'), 1); +const neighbors = args.includes('--neighbors'); +if (neighbors) args.splice(args.indexOf('--neighbors'), 1); +if (args.length !== 2 || args[0] !== '--output' || !isAbsolute(args[1]) || (allCalifornia && neighbors)) { + console.error('Usage: node toolchain/email/la-workshop-audience.mjs [--california | --neighbors] --output /absolute/private/report.json'); + process.exit(1); +} +const remote = spawnSync('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', + 'root@lith.aesthetic.computer', 'cd /opt/ac/system && node --env-file=.env --input-type=module'], { + input: `const collect = ${collect.toString()};\nconsole.log(JSON.stringify(await collect(${allCalifornia}, ${neighbors})));\n`, + encoding: 'utf8', timeout: 180000, maxBuffer: 4 * 1024 * 1024, +}); +if (remote.error || remote.status !== 0) { + // Do not echo remote errors that could contain credentials or customer data. + console.error(`Audience collection failed (${remote.error?.code || remote.status}). No report written.`); + process.exit(1); +} +const report = JSON.parse(remote.stdout.trim()); +mkdirSync(dirname(args[1]), { recursive: true, mode: 0o700 }); +writeFileSync(args[1], JSON.stringify(report, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); +console.log(JSON.stringify({ output: args[1], identifiedAccounts: report.identifiedAccounts, + candidates: report.candidates.length, exclusions: report.exclusions, + locationSummary: report.locationSummary }, null, 2)); -- 2.51.2