diff --git a/ac-electron/main.js b/ac-electron/main.js --- a/ac-electron/main.js +++ b/ac-electron/main.js @@ -1038,7 +1038,7 @@ // Recolor + embolden the single-fill pals vector at runtime (MenuBand-style // live tint). Adding a same-color stroke to the path thickens the line art // in vector space, so it stays crisp at every size. const baseFill = accentHex || '#cd5c9b'; - const STROKE_W = 1.0; // extra outline weight, in the SVG's 24-unit viewBox + const STROKE_W = 0.5; // extra outline weight, in the SVG's 24-unit viewBox let svg = fs.readFileSync(svgPath, 'utf8'); svg = svg.replace( /fill="#[0-9a-fA-F]{3,8}"/g, @@ -1049,7 +1049,7 @@ // Rasterize the vector once at high resolution, trimmed to the figures. const master = await sharp(svgBuf, { density: 1200 }).trim().png().toBuffer(); - const SHRINK = 0.96; // logo height relative to the full menubar slot + const SHRINK = 0.84; // logo height relative to the full menubar slot const SHADOW_ALPHA = 0.7; // hard drop-shadow opacity const out = nativeImage.createEmpty(); diff --git a/grants/SCORE.md b/grants/SCORE.md --- a/grants/SCORE.md +++ b/grants/SCORE.md @@ -6,6 +6,10 @@ ## Open / In Flight | Application | Deadline | Amount | Status | Folder | |---|---|---|---|---| +| **transmediale Lattice Labs 2027** (Berlin residency) | **2026-06-07 (23:59 CEST)** | travel + Berlin-week lodging + production budget; FREE to apply (no personal stipend confirmed) | DRAFT v0 ready · pitched to "Reclaiming the Interface" lab (Olia Lialina). **Confirm if 2 letters required — 6-day window** | `transmediale-lattice-labs-2027/` | +| **TOKAS Int'l Creator Residency 2027** (Tokyo, 3 mo) | **2026-06-16 (18:00 JST)** | airfare + housing + studio + ¥4,200/day + ¥300k fee (per-diem & fee taxed 20.42%) | DRAFT v0 ready · exact form fields pulled. **Need: 2 referee letters + pick period (①/②/③)** | `tokas-2027/` | +| IDFA DocLab 2026 (festival selection) | 2026-06-16 (final tier) | exposure only (€45 entry / €25 student); no cash | DRAFT v0 · **borderline NO-GO** — enter only if a Whistlegraph perf was completed/first-shown after 2025-07-04 | `idfa-doclab-2026/` | +| **NLnet — NGI Zero / Restack** (FLOSS commons) | **2026-08-01 (12:00 CEST)** | €5k–50k (asking €48k, 6 milestones) | DRAFT v0 ready · spine = KidLisp open language + URL-piece protocol. **Commons Fund's final call was 06-01 → rerouted to Restack; confirm fund + GenAI-disclosure path** | `nlnet-ngi-zero-2026/` | | Error 406 [Netstalgia] Not Acceptable | 2026-05-04 (23:59 CEST) | €1,500–7,000 (asking €5.5k) | DRAFT v0 ready | `error-406-netstalgia/` | | NY Art Book Fair — Project Space | 2026-05-08 | $3,500 stipend | DRAFT v0 ready | `printed-matter-project-space-2026/` | | Oracle Egg — BROILER Residency (Fall 2026) | 2026-05-15 (23:59 PST) | TBD | DRAFT v0 ready | `oracle-egg-broiler-2026/` | @@ -33,6 +37,32 @@ | SSRC Just Tech Fellowship | 2026-06-28 (23:59 EST) | up to $60,000 / yr | Tech-and-society creative practice — strong AC fit. Promote to `grants/ssrc-just-tech-2026/` + RESEARCH. Source: Fia link 2026-05-14. | | Pollock-Krasner Foundation | rolling | up to $50,000 | Visual artists w/ exhibition record + financial need; Jeffrey qualifies (KADIST/SMK). Could fund SF / PAX East travel. Source: Fia 2026-05-15 ("got it easily before"). | | Critical Playground (pitch) | unconfirmed | unknown | criticalplayground.org publication; Smarthistory-style pitch-first. Verify the call exists. Source: Fia 2026-04-15. | | CAC Individual Artist Fellowship — **Legacy** (via LAPP, LA County) | 2026-06-06 (23:59 PT) | $50,000 unrestricted | DRAFT v0 ready · 5 narrative answers (Legacy tier) + samples plan + letter-of-support plan + checklist in `cac-iaf-2026/DRAFT.md`. Local guideline PDF in `cac-iaf-2026/reference/`. Next: ask Casey Reas for the colleague letter (required, uploaded by him). | + +## New Media / Funding — calendar (closed now, big money, reopens 2027) + +From the 2026-06-01 aggregator sweep (Rhizome, Colossal, Eyebeam, Ars, +e-flux, NLnet, etc.). All closed for their current cycle — these are +the ones worth a reminder because they pay real money AND a US-based +independent (or Aesthetic Inc) is eligible. Set reminders ~4 weeks +before each reopen. + +| Opportunity | Amount | Reopens (est.) | Eligible? | Fit | +|---|---|---|---|---| +| **STARTS Prize** (Ars Electronica / EU S+T+ARTS) | **2 × €20,000** | call ~Jan–Mar 2027 | Yes — globally open | Biggest single purse; art×tech. `ars-electronica-2026/` has prior material | +| **Lumen Prize** | **$30k pool** ($15k Gold + 3×$5k) | ~Jan–May 2027 | Yes — global | Generative / creative-coding named explicitly | +| **LACMA Art + Technology Lab** | up to **$50,000** | RFP ~Feb 2027 | Yes — individuals, global | Art × emerging tech; prior app in `lacma-2026/` | +| **Prix Ars Electronica** | €10,000 Golden Nica | call opens Jan 2027 | Yes — international | Interactive Art+ / Digital Humanity | +| **Pioneer Works** residencies (Music + Visual/Tech) | $1k–2.5k + studios | **2027 calls reopen Summer 2026 — watch NOW** | Yes | Brooklyn; `arxiv-pioneer-works/` research exists | +| **Rhizome** microgrants / Create Change | $500–1,500 / stipend | ~fall 2026 | Yes — individuals | Net-art home; `arxiv-rhizome/` exists | +| **Akademie Schloss Solitude** Web Residencies | ~€1,200 (4 wks) | periodic short calls | Yes — non-EU accommodated | Digital/web strand | + +**Ruled OUT (don't waste effort):** NEA Grants for Arts Projects +(Jul 9) — **501(c)(3) nonprofit-only**; individuals AND for-profit +Aesthetic Inc both ineligible. Gary Marsden Travel Award — SIGCHI-only, +touches none of the current `papers/SCORE.md` conference targets. Also +out: Sloan (institutional), Astera (science + relocation), Sovereign +Tech (infra-only), Ethereum ESP (no art), EMAP / Creative Europe +(EU-residency-gated), VH Award (Asia-identity-locked). ## Submitted / Decided diff --git a/grants/idfa-doclab-2026/DRAFT.md b/grants/idfa-doclab-2026/DRAFT.md new file mode 100644 --- /dev/null +++ b/grants/idfa-doclab-2026/DRAFT.md @@ -0,0 +1,150 @@ +# IDFA DocLab 2026 — Festival Entry Draft (Whistlegraph) + +> **RECOMMENDATION: BORDERLINE — enter only if X.** +> X = there is a specific **Whistlegraph performance** completed or first +> made publicly available **after 2025-07-04** that you can point a curator +> to as the entered work. The platform (Aesthetic Computer, ~2021) is *not* +> eligible as a whole; only a dated, recent piece/performance qualifies. +> +> See `RESEARCH.md` for the full fit assessment. Short version: IDFA DocLab +> is **documentary / non-fiction**; AC is **generative / instrumental**. This +> is the thinnest fit of the batch. Whistlegraph is the only AC work with a +> defensible "documents something real" reading (a graphic score is a +> real-time trace of a real voice and hand). Confidence: **low**. Selection +> is exposure only (no cash). €45 final fee, deadline **2026-06-16**. +> +> *This draft is the best-case entry, ready to fill against the live portal +> fields. Do not submit unless the X above is true and you'd defend the +> documentary framing to a documentary curator.* + +--- + +## Entry track + +- **Format:** Performance (route to **IDFA DocLab Live Events Program**; + secondary consideration: DocLab Competition for Digital Storytelling). +- **Why performance, not web:** the non-fiction reading lives in the *live + act* — a real person's voice and hand traced into a score in real time. + Entered as a web tool, the documentary claim collapses; entered as a + performance, it holds. + +--- + +## Form fields (fill against live portal — labels are best-guess; confirm) + +### Project title +Whistlegraph + +### Logline *(one line)* +A live graphic-score performance where a real voice and hand are drawn, in +real time, into a notation you can read back — the recording *is* the score. + +### Short synopsis *(~50–80 words; trim to portal limit)* +Whistlegraph is a performance form built inside Aesthetic Computer. A +performer draws and vocalizes; the system traces the gesture and the sound +into a moving graphic score on screen. What's left is a notation that is also +a record — a non-fiction trace of exactly what happened in the room, readable +and re-runnable afterward. The performance documents its own making: a real +voice, a real hand, in real time. + +### Full description *(~150–250 words; trim to portal limit)* +Whistlegraph treats notation as documentation. A performer whistles, hums, +or speaks while drawing; Aesthetic Computer renders the voice and the gesture +together into a graphic score that scrolls in real time. The score is not a +plan made in advance — it is a trace made *during*, a recording in the form +of notation. When the performance ends, the score remains as the document of +it: this voice, this hand, this minute. Anyone can read it back, and because +it lives in Aesthetic Computer it can be re-run as the source that produced it. + +The non-fiction claim is narrow and honest: Whistlegraph documents the real, +physical act of a single performer. It does not report on the world; it +records a person making sound and marks. That record — voice as line, gesture +as notation — is what I'd bring to DocLab as a live event: a short performance +followed by the score it leaves behind, shown as the document. + +Whistlegraph is open-source and URL-addressable inside Aesthetic Computer, a +public creative-computing platform I've built and maintained since 2021. The +performance entered here is **[TODO: the specific dated performance — date, +venue, running time, completed/first-shown after 2025-07-04]**. + +### Format / category +Live performance (hybrid: live performance + generative graphic notation / +audio-visual). Web-based runtime, performed. + +### Project stage +**[TODO]** — DocLab requires "advanced stage, if not fully completed." State +the concrete performance instance and its date here. + +### Duration / running time +**[TODO]** — performance length (and whether a fixed-length cut exists). + +### Country +United States. + +### Creator / credits +Jeffrey Alan Scudder (@jeffrey) — concept, software, performance. +ORCID 0009-0007-4460-4913. +Built on Aesthetic Computer (open-source, by @jeffrey). + +### Technical requirements +- Performer's machine running Aesthetic Computer in a browser (or AC Native OS). +- Projection / large screen for the live score; audio out (PA). +- Microphone for voice input; pointer/pen/touch surface for the drawn gesture. +- Internet for the AC runtime (or a local build). +- **[TODO: confirm exact A/V + space needs for a live presentation in + Amsterdam — projector resolution, sound, stage footprint, network.]** + +### Viewing materials (required at entry) +- **Live link:** the Whistlegraph piece running in Aesthetic Computer + **[TODO: exact URL, e.g. aesthetic.computer/whistlegraph — confirm path]**. +- **Documentation video:** a recorded Whistlegraph performance showing the + voice/hand → score in real time, plus the final score. **[TODO: pull or + cut a clip of the specific dated performance; password if private.]** +- **Stills:** score frames + performer. **[TODO]** + +### Non-fiction / documentary rationale *(the gate — be honest)* +Whistlegraph documents a real performative act: a specific person's voice and +hand, captured as graphic notation in real time. The score is a record, not a +fiction — it could not exist without the real event it traces. This is a +*narrow* non-fiction reading: the work documents *its own making*, not an +external subject or social reality. I'm presenting it on those honest terms — +as performance-as-record — and routing it to the Live Events program where +that reading is strongest. **[Internal note: if a curator needs the work to +document the world rather than the performer, this entry does not fit, and +that's fine — see RESEARCH.md go/no-go.]** + +### Links +- Aesthetic Computer: https://aesthetic.computer +- Whistlegraph piece: **[TODO confirm exact URL]** +- (Context, not the entered work) Collections: KADIST (San Francisco), SMK + (Copenhagen) — *work in the collections of*; collectors, not collaborators. + +### If selected — assets to prepare (don't make now) +Hi-res stills (JPEG ≥5MB), EPK, hi-res trailer/teaser, presentation assets +tailored to a live event. + +--- + +## Submission checklist + +- [ ] **Decision gate:** confirm a Whistlegraph performance completed / first + public **after 2025-07-04** exists to enter. If none → **NO-GO**, stop. +- [ ] Decide GO / NO-GO with the RESEARCH.md assessment in front of you. +- [ ] If GO: open the live entry portal, capture the **real** form fields + + character limits, fill against this draft. +- [ ] Confirm exact Whistlegraph URL + pull/cut the documentation video. +- [ ] Pay final-tier fee (€45) before **2026-06-16**. +- [ ] Do **not** touch the Film Fund DocLab Interactive Grant — Dutch-only + + deadline passed. + +--- + +## Internal notes + +- **Single spine:** if entered, lead with Whistlegraph alone — *score as + record*. Do not bundle notepat / No Paint / KidLisp; a multi-piece "here's + my whole platform" entry reads as unfocused and re-exposes the + documentary-fit weakness. +- This is **exposure only**, not a grant. The honest expected value is low + this cycle. Stronger play: enter IDFA in a future year with a piece built + to *document something real*, if that ever becomes a direction. diff --git a/grants/idfa-doclab-2026/RESEARCH.md b/grants/idfa-doclab-2026/RESEARCH.md new file mode 100644 --- /dev/null +++ b/grants/idfa-doclab-2026/RESEARCH.md @@ -0,0 +1,186 @@ +# IDFA DocLab 2026 — Call for Festival Entries (Interactive / Immersive Projects & Performances) + +> **Type:** Festival *selection* (curatorial). Not a grant. No cash to the +> artist — selection is exposure (Amsterdam, Nov 2026, the premier +> documentary festival's new-media wing). +> **Status:** open for entry. +> **Final deadline:** **2026-06-16** (final). Early deadline was 2026-04-14 +> (already passed). Today is 2026-06-01 — so the **final** fee tier applies. +> **Fee:** **€45** standard / **€25** student, final tier. +> (Early tier was €35 / €25.) + +--- + +## Call facts (fetched live 2026-06-01) + +**Sources:** +- Call page: https://professionals.idfa.nl/new-media/call-for-festival-entries-interactive-immersive-projects-and-performances/ +- FAQ (new media & performance entries): https://professionals.idfa.nl/en/program/festival-entries/faq-interactive-immersive-project-entries/ +- Regulations: https://professionals.idfa.nl/program/festival-entries/festival-entry-regulations/ +- Festival entries hub: https://professionals.idfa.nl/program/festival-entries/ + +### Deadlines & fees + +| Deadline | Student | Standard | +|---|---|---| +| 2026-04-14 (early — passed) | €25 | €35 | +| **2026-06-16 (final)** | **€25** | **€45** | + +- Separate **Film Fund DocLab Interactive Grant** deadline was 2026-04-14 — + already passed, AND Dutch-only (see below). Not in play. + +### Eligibility — festival selection + +- **International: YES.** Open to "independent artists, collectives, + production studios, theater companies, and other organizations from all + over the world." No nationality restriction for *selection*. @jeffrey is + eligible to enter. +- **Documentary / non-fiction requirement: YES, and it is the gate.** IDFA + welcomes work that "uses new media and emerging technology to **tell + stories and reflect on reality**." The festival defines documentary + broadly — "a film that deals with reality, including hybrid films and + films that rely on real elements as a core component" — but *reality / + non-fiction* is the through-line of the entire institution. This is the + fit problem (see below). +- **Completion window: the binding constraint.** Per the FAQ: "IDFA DocLab + is open to unreleased projects as well as projects that have been **made + publicly available (on the internet) after July 4, 2025**." Regulations + echo: DocLab projects "completed after July 4, 2025." A project must also + be "at an advanced stage, if not fully completed, by the time it is sent." + → A platform that launched in 2021 does **not** qualify as a whole. A + *specific new project / edition / performance* finished or first published + after 2025-07-04 can. + +### Accepted formats + +Wide: "installations, performances, web-based projects, digital artworks, +games, audio works, XR experiences, or hybrid forms that don't fit neatly +into categories." Web-based and performance both explicitly welcome — good +for AC. + +### Program sections an entry is considered for + +- IDFA DocLab Competition (Immersive Non-Fiction; Digital Storytelling) +- IDFA DocLab Exhibition +- IDFA DocLab Live Events Program + +### What the entry asks for (form fields — partly TODO, confirm in portal) + +Confirmed from FAQ: +- **Viewing materials**: streaming/download link OR documentation. + - Digital projects: compatible build (MAC / PC / iOS / Android / Quest / + Pico / VIVE / Steam / PlayStation / Xbox as applicable). A live URL + counts for web work. + - Physical / live work: "extensive visual and written documentation — + e.g. a description of the audience experience, platform(s) used, script, + score, installation design." +- **If selected**, additional assets: hi-res stills (JPEG, ≥5MB), EPK, + hi-res trailer/teaser, format-tailored presentation assets. + +**TODO (confirm in live entry portal — the public pages don't enumerate the +form):** exact text fields. Almost certainly: project title; logline / +short synopsis; long description; format / category; creative team & +credits; country; year/completion date; project stage; running time or +duration; technical requirements; links; password for private viewing +links. Open the portal to capture the real field list + character limits +before filling DRAFT.md for real. + +### Production grant (note, do not pursue) + +The **Film Fund DocLab Interactive Grant** is **Dutch-only**: "The +creator/director of the project is Dutch" OR "based in the Netherlands" OR +"has a Netherlands-based producer/co-producer." @jeffrey meets none of +these. Also its deadline (2026-04-14) has passed. **Out of scope — ignore.** + +--- + +## FIT ASSESSMENT — the honest part + +This is the thinnest match of the batch, and the reason is structural, not +cosmetic. IDFA is a **documentary** institution. DocLab's whole remit is +work that **reflects on reality** — non-fiction told through new media. AC's +center of gravity is the opposite axis: it is *generative, instrumental, and +formal*. notepat is a synthesizer. No Paint is a drawing tool. KidLisp is a +language. Whistlegraph is a graphic-score performance form. None of these +*documents the real world*. They make new abstract material. That is a +genuine mismatch with the festival's premise, and no amount of framing +changes it. + +Two further hard gates: +1. **The platform itself is ineligible.** AC launched ~2021. The "after + July 4, 2025" completion window means you cannot enter "Aesthetic + Computer" as the work. You'd have to enter a *specific recent piece or + performance* finished/first-shown after that date. +2. **Even a qualifying recent piece still has to be non-fiction.** Recency + alone doesn't fix the documentary problem. + +### Candidate angles, evaluated + +**(a) Whistlegraph as a documentary graphic-score form.** Whistlegraph +documents a real human voice/gesture as a graphic score in real time — there +*is* a non-fiction reading here (the score is a trace of a real performance, +a recording-as-notation). This is the strongest angle. But it's a stretch +for DocLab: it documents *the performer's own act*, not the world, a subject, +or a social reality. It reads as performance/instrument, not documentary. +Plausible only as a **DocLab Live Events / performance** entry, and only if +a specific Whistlegraph *performance* dated after 2025-07-04 exists to enter. + +**(b) No Paint as a living document of a community.** Collaborative drawing +with persistent shared state *is* a continuous record of a public's marks — +a genuinely "real" social trace. Conceptually the closest to non-fiction +(it documents what real people actually did, collectively, over time). But +it's the *platform/tool*, launched years ago — fails the completion window +unless framed as a specific dated edition/installation, and even then the +documentary claim is conceptual, not the festival's center. + +**(c) Net-art / platform-as-document-of-public-creative-computing.** The +"AC as an ongoing document of public creative computing" framing is real but +abstract and self-referential — it documents *the platform's own use*, which +curators will read as art-about-art, not non-fiction about reality. Weakest. + +**(d) A new dated piece built for the call.** Not realistic by 2026-06-16 +and not the right reason to build something. + +### Strongest plausible candidate + +**Whistlegraph — as a performance/live entry** (route it to DocLab Live +Events), reframed honestly around the *record* it produces: a graphic score +that is a real-time trace of a real voice and hand. It is the only AC work +where "documents something real" is defensible rather than invented, and the +"performances" format is explicitly welcomed. Even so: this is a **borderline, +low-confidence** documentary reading. + +### GO / NO-GO recommendation + +**Lean NO-GO for 2026 — soft pass. Optional borderline GO only if a real, +recent Whistlegraph performance exists.** + +Reasoning: +- The festival's premise (non-fiction / reflecting on reality) is genuinely + not what AC does. Forcing a documentary frame onto a synthesizer/score + practice would be the kind of "make it fit" move that reads as thin to a + specialist curatorial panel — and IDFA DocLab's panel *is* the specialist + audience for exactly this distinction. +- The completion window blocks entering the platform; you'd be entering a + single performance, narrowing the already-thin case further. +- The upside is exposure only (no cash). €45 is small, but a weak entry to a + flagship documentary festival has a small *reputational* cost too — better + to enter IDFA later with a piece that genuinely belongs. + +**The case for a GO anyway** (if @jeffrey wants it): €45 is cheap; Whistlegraph +*does* have a defensible real-time-record reading; the performance format is +welcomed; and getting an AC work in front of the DocLab curators has long-term +value even if not selected this year. If — and only if — there is a real +Whistlegraph performance completed/first-shown after 2025-07-04 to point at, +the DRAFT below is ready to fill against the live portal. + +**Fit confidence: LOW.** + +### Biggest open question for @jeffrey + +Is there a specific Whistlegraph *performance* (or a No Paint *installation/ +edition*) that was completed or first made public **after 2025-07-04** — i.e. +something concrete and dated to enter? If not, there is nothing eligible to +submit and this becomes a clear NO-GO for 2026. If yes, decide whether the +documentary framing is honest enough that you'd defend it to a documentary +curator to their face. diff --git a/grants/nlnet-ngi-zero-2026/DRAFT.md b/grants/nlnet-ngi-zero-2026/DRAFT.md new file mode 100644 --- /dev/null +++ b/grants/nlnet-ngi-zero-2026/DRAFT.md @@ -0,0 +1,313 @@ +# NLnet Restack — Application Draft + +> **Fund:** NLnet — **Restack** (NOT the Commons Fund — that closed +> June 1, 2026). Same €5k–€50k band, same FLOSS rules. +> **Deadline:** 2026-08-01, 12:00 CEST. +> **Form:** https://nlnet.nl/propose/ — short, no word limits, plain +> and technical. Fields filled top-to-bottom below. +> **Applicant:** Aesthetic Inc (US C-corp, EIN 33-1483385). +> **Draft v0 — engineering register. Lead with the language + protocol +> as infrastructure; art is the motivating example, never the ask.** + +--- + +## Eligibility — self-check + +- [x] No categorical exclusion of applicant type — companies eligible +- [x] Applicant is a legal entity: **Aesthetic Inc**, US C-corp, + EIN 33-1483385 +- [x] Project results ship under a recognised FOSS license — repo is + **ISC** today; new spec/docs under CC-BY-4.0 / CC0 +- [x] Open standards / interoperability — deliverable IS a spec +- [x] Eligible activities: development + **standardization** + + **documentation** + test suites +- [x] Requested amount in €5,000–€50,000 band (draft = **€48,000**) +- [ ] **Geographic caveat:** US applicant; EU/Horizon-associated get + priority on equal quality. Must show a *clear European / + global-commons dimension* (handled in Ecosystem field) +- [ ] **GenAI disclosure** must be answered honestly (field 16) — see + RESEARCH.md box; rewrite-in-own-hand recommended before submit +- [ ] Select call = **Restack** in the dropdown + +--- + +## CONTACT + +- **Your name:** Jeffrey Alan Scudder (ORCID 0009-0007-4460-4913) +- **Email:** mail@aesthetic.computer +- **Phone:** *(TODO — jeffrey)* +- **Organisation:** Aesthetic Inc (US C-corp, EIN 33-1483385) +- **Country:** United States *(with a clear European/global dimension — + see Ecosystem)* +- **Please select a call:** **Restack** + +--- + +## 7. Proposal name + +**KidLisp — an open language and URL-addressable runtime for +read-fork-share computing** + +*(Working alternates: "KidLisp & the Piece Protocol: a commons runtime +for browser-native programs"; pick the clearer one against the live +form.)* + +--- + +## 8. Website / wiki + +- https://aesthetic.computer — the live runtime +- https://kidlisp.aesthetic.computer *(or the kidlisp/ docs path; TODO + confirm public docs URL)* +- Source: the Aesthetic Computer repository (ISC-licensed) + +--- + +## 9. Abstract — "explain the whole project and its expected outcome(s)" + +Most computing today is locked behind app stores and platforms. You +cannot read the program you are using, you cannot fork it, and you +cannot hand it to someone else by saying where it is. KidLisp and the +"piece" model are a working counter-example — and this project turns +that working software into reusable public infrastructure. + +KidLisp is a minimal Lisp dialect for generative, interactive programs. +It has 118 built-in functions and a single open-source evaluator +(~15,000 lines of JavaScript) that runs in any browser with no install, +no account, and no app store. A program — a "piece" — is addressed by +its URL. You read it, you fork it, you publish your fork to a new URL, +and the next person finds both. Over **17,000 user-authored KidLisp +programs** already exist this way. The runtime also runs on AC Native +OS, an open operating system written to host these pieces directly — +so the same readable program runs browser-native and on bare metal. + +Today the language works but is **under-specified**: there is one +reference implementation, no formal grammar, no language server, no +conformance tests, and incomplete documentation. That is the gap +between "an interesting project" and "infrastructure other people can +build on." This project closes it. + +Deliverables: (1) a **written language specification** for KidLisp — +grammar, evaluation model, the full built-in surface; (2) a +**standalone, dependency-light reference parser** extracted from the +runtime, with a **conformance test suite** any implementation can run +against; (3) a **language server (LSP)** so KidLisp gets diagnostics, +completion, and hover in standard editors; (4) a documented, +versioned **"piece protocol"** — how a URL resolves to source, how a +fork relates to its origin, how storage and retrieval work — published +so anyone can host a compatible runtime; (5) **documentation and a +"write your own runtime" guide.** + +Expected outcome: KidLisp becomes a language anyone can implement, +tool, teach, and host — not a single-vendor feature. A small, +readable, forkable, URL-native programming environment that belongs to +the commons instead of a platform. + +--- + +## 10. Prior experience + +I am the author of **Aesthetic Computer (AC)**, a 5+ year open-source +creative-computing platform, and of **KidLisp**, the language this +proposal is about. I designed and wrote the KidLisp evaluator +(`system/public/aesthetic.computer/lib/kidlisp.mjs`, ~15,000 lines), +its storage/retrieval API (`store-kidlisp.mjs`), and the analysis +tooling around it (`kidlisp/tools/`). I also wrote **AC Native OS**, +the operating system that hosts pieces outside the browser, and the +runtime (`bios.mjs`, `disk.mjs`) that exposes the piece API. + +AC is genuinely in public use: 17,000+ user-authored KidLisp programs, +a continuous stream of ~30 arXiv-style technical papers documenting its +subsystems, and a four-language documentation pipeline. Two pieces — +*No Paint* (collaborative drawing with persistent state) and *notepat* +(a polyphonic synthesizer) — reached the Hacker News front page. The +work is held in the collections of KADIST (San Francisco) and SMK +(Copenhagen). + +Background: Yale MFA (2013), Ringling BFA (2011); artist-in-residence +at UCLA Social Software (host: Casey Reas, creator of Processing); I +run the NELA Computer Club, a free biweekly public creative-computing +meetup in Los Angeles. I have shipped, maintained, and documented this +codebase in the open for five years. The work proposed here is +extracting and specifying a language I already built and operate — not +starting from zero. + +--- + +## 11. Requested Amount — €48,000 — budget & milestones + +Budget is engineering and standardization effort on the open +deliverables above. **Rate: €600/day** (a working open-source +engineering day rate); **80 days total → €48,000.** No other funding +covers this specification/tooling work; AC's general development is +self-funded by Aesthetic Inc. Each milestone is an independently +verifiable artifact (a published document, a passing test suite, a +released package) so it can be signed off on completion. + +| # | Milestone / deliverable | Effort | Amount | +|---|--------------------------|--------|--------| +| M1 | **KidLisp language specification, v1.0** — written spec covering lexical grammar, evaluation model, scoping/timing semantics, and the complete 118-function built-in surface. Published as HTML + PDF under CC-BY-4.0. | 15 days | €9,000 | +| M2 | **Standalone reference parser + grammar** — parser extracted from the runtime into a dependency-light package (grammar file + tokenizer + AST), released to a public package registry under ISC, decoupled from the AC browser environment. | 15 days | €9,000 | +| M3 | **Conformance test suite** — an open, runnable battery of programs + expected results that any KidLisp implementation can be checked against; CI-runnable; documents each built-in's contract. | 12 days | €7,200 | +| M4 | **KidLisp Language Server (LSP)** — diagnostics, completion, hover, and go-to-definition for KidLisp in any LSP-capable editor (VS Code, Emacs, Neovim), released under ISC. | 18 days | €10,800 | +| M5 | **Piece-protocol specification** — documented, versioned protocol for URL→source resolution, fork↔origin lineage, and storage/retrieval, so third parties can host a compatible runtime. Published under CC-BY-4.0. | 12 days | €7,200 | +| M6 | **Documentation + "write your own runtime" guide** — developer docs, a worked guide to implementing a minimal compatible KidLisp host, and migration notes. Published under CC-BY-4.0. | 8 days | €4,800 | +| | **Total** | **80 days** | **€48,000** | + +*Other funding sources: none for this work. AC's ongoing operation is +funded by Aesthetic Inc. This grant funds only the open specification, +reference tooling, and documentation listed above.* + +*(If a tighter ask is wanted, the €30k floor = M1+M2+M3+M5; M4 and M6 +are the scalable extension.)* + +--- + +## 12. Budget comparison — "compare with existing or historical efforts" + +The closest relatives are creative-coding environments and small +embeddable Lisps, and KidLisp's commons angle is what's missing in +each: + +- **Processing / p5.js** (Casey Reas, Lauren McCarthy et al.) — the + reference for open creative coding, and a direct lineage influence. + But a p5.js sketch is a file you download and host yourself; there is + no built-in URL-addressable, forkable program identity, and no + minimal embeddable *language* — it's a JS library. KidLisp adds a + small specified language and a piece protocol on top of the same open + ethic. +- **Scratch / Snap!** — block languages for learners, but tied to a + hosting platform and not a text language you can spec and re-implement + cheaply. KidLisp is plain text, URL-native, and small enough to + re-implement. +- **Embeddable Lisps (Fennel, Janet, small Schemes)** — excellent + small languages, but general-purpose and not built around + browser-native generative programs or URL-addressable sharing. + KidLisp's contribution is the *runtime/protocol* pairing, not just + the interpreter. +- **TIC-80 / PICO-8 fantasy consoles** — closest in spirit + (small, shareable programs), but PICO-8 is proprietary and cartridge- + based, not URL-addressable or fully FOSS. KidLisp is the open, + web-native, no-install version of that idea. + +Historically, the thing being reclaimed is the **shareware / +view-source web** — when you could read the program you were running. +App stores and minified bundles ended that. This project is a small, +concrete way to get "read it, fork it, share it by URL" back, with a +language spec underneath so it's durable. + +The €5k–€50k of effort here is **extraction and specification of +already-working software**, which is why the budget is days of +engineering, not a multi-year build. + +--- + +## 13. Technical challenges + +- **Decoupling the parser from the runtime.** The evaluator is + currently entangled with the AC browser environment (graphics, audio, + the piece API). Extracting a clean, dependency-light parser/AST that + is faithful to the reference behavior — without quietly changing + semantics — is the central engineering risk. +- **Specifying timing and scoping precisely.** KidLisp programs are + temporal (they run every frame) and have generative-art-specific + evaluation semantics. Writing a spec that pins down evaluation order, + state, and timing tightly enough for a second implementation to match + is hard, and the conformance suite is what proves we got it right. +- **A conformance suite for visual/temporal output.** Many built-ins + produce graphics or sound. Defining deterministic, checkable + contracts (rather than "it looks right") for those is non-trivial; + the suite will need a deterministic-evaluation mode and stable, + comparable outputs. +- **Protocol versioning + fork lineage.** The piece protocol must + define how a fork references its origin and how URL resolution and + storage interoperate, in a way a third-party host can implement + without the AC backend. Getting the versioning right so old pieces + keep resolving is the durability concern. +- **LSP over a small dynamic language.** Useful completion/diagnostics + for a dynamically-typed Lisp with 118 built-ins means good static + knowledge of the built-in surface — which the spec (M1) feeds + directly. + +--- + +## 14. Ecosystem engagement — and the European / global dimension + +**Who this is for:** anyone who wants small, readable, forkable +programs without a platform gatekeeper — creative coders, educators, +self-taught programmers, and people building local-first or +browser-native tools. The spec + conformance suite mean **second and +third implementations become possible**, which is the point: the +language stops being one company's feature. + +**European / global-commons dimension** (NLnet geographic note): the +deliverables are browser-native and platform-independent — they work +anywhere, under no app store, which is precisely the "reclaim the +public nature of the internet" goal. The lineage is European-rooted +open creative coding (Processing's community, the demoscene). The work +is held in a European public collection (SMK, Copenhagen). I will +engage the Processing/p5.js and broader FOSS creative-coding +communities directly, publish the spec and tooling openly, and make the +runtime trivially self-hostable so European institutions and educators +can run their own instances with no dependency on a US service. + +**How outcomes get promoted:** publish the spec + guides openly +(CC-BY-4.0); release parser, LSP, and test suite to public registries +(ISC); document everything through the existing AC papermill (~30 +arXiv-style papers already public); demo at the NELA Computer Club and +in the UCLA Social Software residency; and announce to the +creative-coding and small-languages communities. The 17,000+ existing +KidLisp programs are an immediate corpus to validate the spec and +conformance suite against. + +--- + +## 15. Attachments (prepare) + +- [ ] One-page **technical overview PDF** (architecture: evaluator → + parser extraction → spec → LSP → protocol). +- [ ] Link sheet: repo (ISC), live runtime, papermill, a few + representative public KidLisp pieces. +- [ ] Optional: the KidLisp README as plain text. + +## 16–18. GenAI disclosure + +- **MUST answer honestly.** This draft was Claude-assisted. Either (a) + rewrite the Abstract / Technical Challenges in your own words and + answer "no," or (b) answer "yes" and paste the prompt dates, prompts, + and unedited output. Recommend (a). See RESEARCH.md. + +## 19–21. Privacy / submit + +- [ ] Acknowledge privacy statement. +- [ ] "Send me a copy." +- [ ] Optional PGP pubkey. + +--- + +## Submission checklist + +- [ ] Skim CodeSupply + Restack Guide-for-Applicants to confirm Restack + is the best of the open funds and has no EU-only hard rule. +- [ ] Set jeffrey's real day-rate; re-sum the table if it changes. +- [ ] Confirm public KidLisp docs URL for field 8. +- [ ] Fill phone number. +- [ ] Resolve GenAI disclosure (rewrite recommended). +- [ ] Decide €30k vs €48k ask. +- [ ] Build the one-page technical overview PDF attachment. +- [ ] Submit before **2026-08-01, 12:00 CEST**. + +--- + +## Internal notes + +- **Single spine:** KidLisp-as-specified-language + the piece protocol. + Everything else (AC broadly, notepat, No Paint, papers) is context or + evidence of capability — never part of the ask. +- **Register:** engineering/infrastructure, not art-career. No + fellowships, exhibitions, or "support my practice" framing anywhere + in the budget rationale. Art is the motivating example only. +- **Coherence with other live applications** (CAC-IAF, CultureHub): + those lead with the *instrument + score* spine for arts funders; this + one deliberately leads with the *language + protocol* spine for a tech + funder. Same platform, correct framing per venue. diff --git a/grants/nlnet-ngi-zero-2026/RESEARCH.md b/grants/nlnet-ngi-zero-2026/RESEARCH.md new file mode 100644 --- /dev/null +++ b/grants/nlnet-ngi-zero-2026/RESEARCH.md @@ -0,0 +1,209 @@ +# NLnet — Call Research & Fit Assessment + +> Compiled 2026-06-01 from live pages. The brief named the *NGI Zero +> Commons Fund*, but that fund's **thirteenth and final call closed +> June 1, 2026** (verbatim from the page: *"The thirteenth and final +> call of NGI Zero Commons Fund closed on June 1st 2026… No more new +> applications are sought for this fund, but we have other programmes +> running."*). Several **other NLnet programmes share the same +> deadline — August 1, 2026, 12:00 CEST**. The right vehicle for this +> project is **Restack**. Everything below routes the application there. + +--- + +## The fund to apply to: NLnet — Restack + +- **Fund:** Restack (an NLnet / NGI-lineage open-call programme) +- **URL:** https://nlnet.nl/restack/ — submit via https://nlnet.nl/propose/ +- **Deadline:** **2026-08-01, 12:00 CEST (noon)** — same cadence the + brief expected (1st of even months). 61 days out from 2026-06-01. +- **Amount range:** **€5,000 – €50,000** per project, *"with the + possibility to scale them up significantly if there is proven + potential."* (Identical band to the Commons Fund the brief named.) +- **Programme budget:** €7M through 2030. +- **Scope (verbatim themes):** *"technology building blocks for an + open internet stack, from hardware to applications"*; replacing + *"'dark stack' components only available as volatile proprietary + services"*; *"from libre chips to middleware without a vendor + lock-in, from productivity tools to 'local-first' infrastructure"*; + *"open to new ideas and disruptive technologies on every layer, but + also want to nurture and scale existing technologies… practical, + real-world impact."* + +### Eligibility — confirmed company-eligible +- From the NGI0/Commons eligibility page (Restack inherits the same + posture): *"There are no categorical exclusions of persons who may + not receive support."* Companies, for-profits, individuals, and + nonprofits may all apply. → **Aesthetic Inc (US C-corp) is eligible.** +- **Geography caveat:** EU residents and Horizon-Europe-associated + countries get **priority when proposals are of equal quality**. + Non-EU applicants remain eligible if they show *"a clear European + dimension."* The US applicant must make the European/global-commons + angle explicit. (See DRAFT "Ecosystem" field.) +- Minors may apply without parental consent at submission (not + relevant here). + +### Licensing requirement — confirmed FLOSS +- *"Project results shall always become available under a recognised + free or open source license"* so *"anyone can read and validate the + source code."* +- **AC repo license today: ISC** (`package.json` → `"license": "ISC"`). + ISC is an OSI-approved, FSF-recognized permissive license — it + **already satisfies the requirement.** No relicensing needed. Name + ISC; optionally note new spec/docs deliverables can ship under + CC-BY-4.0 / CC0 and the language grammar under ISC to match the runtime. +- Also valued: open standards / interoperability, right to reuse and + repair, standards compliance. + +### Eligible activities (broad) +Open-source development, research, **standardization work**, +**documentation**, security audits, and *"other activities relevant to +robust software development and deployment practices."* → A spec + +language-server + conformance-test deliverable set is squarely fundable. + +--- + +## The exact proposal form (https://nlnet.nl/propose/) + +Captured live, in order. There are **few fields and no word limits** — +NLnet wants concrete, technical, plain answers, not essays. + +**Contact section** +1. Your name +2. Email address +3. Phone number +4. Organisation +5. Country + +**Call selection** +6. *Please select a call* — dropdown. Options seen: *Restack · NGI + TALER · NGI Fediversity · CodeSupply · ELFA · Research & Higher + Education Technology Fund · Open Call · Other*. → **Select "Restack".** + +**General project information** +7. **Proposal name** — project title. +8. **Website / wiki** — project web presence. +9. **Abstract** — *"Can you explain the whole project and its expected + outcome(s)."* (No length cap; this is the heart of the form.) +10. **Prior experience** — *"Have you been involved with projects or + organisations relevant to this project before? And if so, can you + tell us a bit about your contributions?"* + +**Requested support** +11. **Requested Amount** (€) — *"Explain what the requested budget will + be used for? Does the project have other funding sources, both past + and present? A breakdown in the main tasks with associated effort + is appreciated. Make rates explicit."* → This is where the + **milestone budget table** goes. +12. **Budget comparison** — *"Compare your own project with existing or + historical efforts."* (i.e. comparison-to-existing-FOSS field.) +13. **Technical challenges** — *"What are significant technical + challenges you expect to solve during the project, if any?"* +14. **Ecosystem engagement** — *"Describe the ecosystem of the project, + and how you will engage with relevant actors and promote the + outcomes?"* + +**Attachments** +15. Attachments — HTML / PDF / ODF / plain text, ≤ 50 MB total. + +**Generative-AI disclosure** (mandatory) +16. *"Did you use generative AI in writing this proposal?"* (dropdown) +17. If yes: which model, what for, **the dates of prompts, the prompts + themselves, and the unedited output** pasted in. +18. Optional: prompt-files upload (≤ 50 MB). + +**Privacy / submission** +19. Privacy-statement acknowledgment checkbox. +20. "Send me a copy" checkbox. +21. Optional PGP pubkey. + +> **GenAI disclosure note — IMPORTANT.** This draft is a Claude-assisted +> scaffold. If any of this text survives into the submitted proposal, +> jeffrey must answer field 16 truthfully and paste the prompts + +> output, OR rewrite the narrative entirely in his own hand and answer +> "no." NLnet takes this seriously. Cleanest path: jeffrey rewrites the +> Abstract/Challenges in his own words before submitting. + +### How milestone budgeting works +NLnet funds against **concrete deliverables/tasks, each with euro +amount and effort**, paid on completion — not a lump salary. The +Requested-Amount field wants: tasks → effort → rate → sum. "Make rates +explicit." Keep deliverables independently verifiable (a spec +document, a passing test suite, a released package) so each can be +signed off. Budget is for **work on the open deliverables**, framed as +engineering/research effort — never as artist stipend or career support. + +--- + +## Fit assessment + +**Spine chosen (single, fundable):** *KidLisp + the URL-addressable +"piece" protocol as open internet-commons infrastructure* — an open, +specified creative-computing language plus an open runtime/protocol for +programs you can **read, fork, and share by URL**, as a commons +alternative to app-store / platform-locked computing. The funded work +is the **specification, reference tooling (parser/grammar, language +server, conformance test suite), and documentation** that turn a +working-but-undocumented language into reusable public infrastructure. + +**Why this fits Restack well (high):** +- Restack explicitly funds *"productivity tools"*, *"local-first + infrastructure"*, and replacing proprietary *"dark stack"* services. + URL-addressable programs that run in any browser and on an open OS, + without an app store, are exactly a dark-stack replacement. +- Eligible activities name **standardization** and **documentation** — + the precise shape of this deliverable set. +- Licensing already compliant (ISC). +- "Nurture and scale existing technologies that are still + future-proof" — AC is 5+ years live with 17,000+ user programs; this + is a scale-up of working tech, not a speculative new build. + +**Honest caveats / risks:** +1. **"Creative computing" can read as niche / art-career funding.** + NLnet funds *internet technology and commons*, not artists. Mitigate + by leading with the **language + protocol as general-purpose + infrastructure**: the deliverable is a spec and tooling any developer + can use, not paintings. Keep "art" as a motivating example, never the + ask. No mention of fellowships, exhibitions, or collections in the + budget rationale. +2. **Non-EU applicant.** US C-corp gets lower geographic priority. + Mitigate with a genuine European/global dimension: open spec usable + anywhere, browser-native (no platform gatekeeper), explicit + engagement with EU FOSS/creative-coding communities (Processing/p5.js + lineage, Fediverse-adjacent publishing, SMK Copenhagen as a European + touchpoint — as a *collections* fact, not a partner). +3. **GenAI disclosure** (see box above) — must be handled honestly. +4. **Final-Commons-Fund confusion.** Do **not** select the Commons Fund + in the dropdown; it's closed. Select **Restack**. +5. **Budget must read as engineering effort with explicit rates**, not + a round number. The table in DRAFT does this; jeffrey should sanity- + check the day-rate against what he'd actually bill. + +**Fit-confidence: HIGH** for Restack as the vehicle and the +language/protocol spine; MEDIUM on the geographic-priority disadvantage, +which framing can soften but not erase. + +--- + +## Key dates & facts +- Deadline: **2026-08-01, 12:00 CEST**. +- Ask target: **€30,000–€50,000** (draft lands at **€48,000**). +- Applicant: **Aesthetic Inc**, US C-corp, **EIN 33-1483385**. +- Lead: **@jeffrey / Jeffrey Alan Scudder**, ORCID 0009-0007-4460-4913. +- License: **ISC** (existing) + CC-BY-4.0/CC0 for new docs/spec. +- Form: 4 narrative fields (Abstract, Prior experience, Requested + Amount/budget, Budget comparison, Technical challenges, Ecosystem) + + contact + GenAI disclosure. No word limits. + +## TODO / unverified +- [ ] **Confirm Restack is the best of the open funds** vs. CodeSupply + or a generic "Open Call" — Restack reads strongest from its scope + text; quickly skim CodeSupply before committing. +- [ ] Confirm Restack does not impose an EU-only hard requirement + buried in its own Guide for Applicants (eligibility text quoted + here is the NGI0 family's; Restack inherits it but verify on the + Restack Guide for Applicants PDF). +- [ ] Pull the Restack-specific "Guide for Applicants" + FAQ before + submitting (form instructs applicants to read them). +- [ ] Decide GenAI-disclosure path (rewrite-in-own-hand vs. disclose). +- [ ] Confirm jeffrey's real day-rate for the budget table. diff --git a/grants/tokas-2027/DRAFT.md b/grants/tokas-2027/DRAFT.md new file mode 100644 --- /dev/null +++ b/grants/tokas-2027/DRAFT.md @@ -0,0 +1,226 @@ +# TOKAS International Creator Residency Program 2027 — Application Draft + +> **Deadline:** 2026-06-16, 18:00 JST (GMT+9) — 15 days from 2026-06-01 +> **Program:** International Creator Residency Program 2027 (ICR2027) +> **Fully funded:** round-trip airfare + single room + JPY 4,200/day per diem + +> JPY 300,000 project fee + shared studio *(per diem & fee taxed 20.42%)* +> **Online form:** download Application Package, fill `ICR2027_Application.pdf` in +> **Adobe Reader** (Mac: NOT Preview), upload form + 2 letters + portfolio. +> **DRAFT ONLY — never submit.** +> +> *The proposal is not an essay. It is tight micro-fields with hard word caps +> (150 / 150 / 200). Every narrative answer below is written AT or just under its +> cap so it drops straight into the PDF. CV fields are structured, not prose.* + +--- + +## Eligibility — self-check (Outline §4) + +- [x] **① Resides outside Japan** — US-based, Los Angeles. +- [x] **② 5+ years experience** — 13-year practice. Ringling BFA 2011, Yale MFA 2013. +- [x] **③ Sufficient English** — native. +- [x] **④ Independent, can live/work alone** — yes. +- [x] **⑥ Not a student** — no. +- Conditions accepted: stay 3 months consecutively · make a work for **OPEN + STUDIO** + final report · no commercial activity · single-occupancy room + (no guests — flag to jeffrey personally). + +## Period requested (Outline §2) — *TODO: jeffrey picks* + +- ① May–Jul 2027 · ② Sep–Nov 2027 · ③ Jan–Mar 2028 +- **Recommend ② or ③** — clears the fall-2026 *REPL* performance and the UCLA / + NELA calendar. Confirm before filling the form's period field. + +--- + +## FORM ITEM 8 — Statement, Purpose, and Activity Plan + +### 8.1 — Keywords (2 to 4) + +- **graphic-score performance** +- **open-source instruments** +- **public computer club** +- **URL-addressable pieces** + +### 8.2 — Concept of your work, in relation to past/ongoing projects *(max 150 words — draft is 149)* + +I build instruments and make work with them. The body of the practice is +Aesthetic Computer — a five-year-old open-source platform of small, +URL-addressable creative-computing programs called pieces, written in JavaScript +and in KidLisp, a minimal Lisp I designed with 118 built-ins. Three instruments +carry it: notepat, a polyphonic synthesizer played as a performance; No Paint, a +collaborative drawing program; and Whistlegraph, a graphic-score performance +form where the score, the sound, and the runnable code are one object. Over +17,000 user programs now live in the system. The work sits in the collections of +KADIST and SMK. The through-line is simple — a program can be a score, a score +can be performed, and a stranger can pick it up, run it, and change it. I make +tools other people can play. + +### 8.3 — Motivation for TOKAS + why Tokyo + what you expect *(max 150 words — draft is 146)* + +I want to make Whistlegraph performances in Tokyo, with people in Tokyo. The +graphic-score form is built to be read and played by anyone, and I have only ever +tested that against the communities I already know in Los Angeles. Tokyo has a +deep, living lineage of graphic notation, experimental sound, and group +performance — and a public that meets new instruments without flinching. I want +to learn the form back from a room that did not grow up inside my own +assumptions. TOKAS gives me three uninterrupted months, a studio, and other +residents to exchange with — and OPEN STUDIO gives the work the public ending it +needs. I run a free biweekly computer club in LA; I want to build its Tokyo +counterpart inside this residency, and find out what the instruments sound like +in someone else's hands. + +### 8.4 — Purpose of residency + how you'd spend your time (project plan) *(max 200 words — draft is 198)* + +I propose three months making a body of Whistlegraph graphic-score performances +and new notepat/KidLisp pieces, authored with Tokyo participants, ending in a +public workshop and performance at OPEN STUDIO. + +**Month 1 — listen and prototype.** I install the AC instruments in the studio, +walk the city, and respond to it directly: I write new KidLisp pieces from what I +hear and see in Tokyo, and draft Whistlegraph scores from them. I begin meeting +local sound artists, musicians, and students. + +**Month 2 — make with people.** I run small open sessions — the Tokyo +counterpart of my LA computer club — where participants learn to read and play a +Whistlegraph score and author their own short pieces. Each session produces new +URL-addressable pieces and recorded performances. The instruments get tuned to +how this room actually plays them. + +**Month 3 — assemble and present.** I shape the strongest scores and pieces into +a performance set, and hold a free public OPEN STUDIO event: a Whistlegraph +performance plus a hands-on workshop where the audience plays notepat and writes +KidLisp live. Every piece made stays online, free, at its own URL, for anyone in +Tokyo or anywhere to keep playing after I leave. + +### 8.5 — Tokyo institutions / people to contact *(no stated limit)* + +> *Draft — verify which are reachable before listing; do not name contacts jeffrey +> isn't actually prepared to approach. Honest placeholders for jeffrey to confirm:* + +- Experimental-music / sound-art venues and collectives in Tokyo (for performance + partners + a workshop audience) — **TODO: name specific venues jeffrey knows.** +- Art-and-technology / media-art programs at Tokyo universities (for student + participants in the computer-club sessions) — **TODO: confirm any existing + contacts.** +- Note honestly on the form whether already in contact or hoping to connect — the + form asks this directly. If no live contacts yet, say so plainly; don't invent. + +--- + +## CV fields (structured form items — no prose) + +### Item 1 — Name +- First: Jeffrey · Family: Scudder · Artist name: **@jeffrey** +- (ORCID 0009-0007-4460-4913 — not a form field, for reference.) + +### Item 2 — Academic Qualifications (4 most recent, reverse chron) +- 2011–2013 — MFA, Painting/Printmaking, **Yale University**, New Haven, USA +- 2007–2011 — BFA, **Ringling College of Art and Design**, Sarasota, USA + +### Item 3 — Employment (3 most recent) +- 2021–present — Founder/Author, **Aesthetic Computer** (open-source platform) +- 2025–2026 — Author in Residence, **UCLA Social Software** (host: **Casey Reas**) +- *TODO: add one earlier teaching/employment line if the form wants a third.* + +### Item 4 — Language Proficiency +- English: **Native** · Japanese: **Beginner** *(confirm/adjust)* + +### Item 5 — Artist-in-Residence (reverse chron) +- 2025–2026 — Author in Residence, UCLA Social Software, Los Angeles, USA (with + Casey Reas as host) — *TODO: confirm grant status field.* + +### Item 6 — Awards & Grants (reverse chron) +- *TODO: pull from `papers/cv/`. Candidates: Tezos Foundation grant (drafted); + any past fellowships. Do NOT list pending/unawarded as won.* + +### Item 7 — Activity History — Major Exhibitions / Projects (reverse chron) +- Work in the **collection of SMK** (Statens Museum for Kunst), Copenhagen, Denmark +- Work in the **collection of KADIST**, San Francisco, USA +- 2024–present — **NELA Computer Club**, biweekly free public sessions, Plot.Place, + Chinatown, Los Angeles +- *No Paint* and *notepat* — Hacker News front page +- *TODO: pull exhibition rows with venue + year from `papers/cv/`; TOKAS wants + shows with dates, not just platform stats.* + +--- + +## Two letters of recommendation (Outline §9.II) — *jeffrey collects + uploads* + +- Two **different** referees from art/cultural institutions or art-and-culture + professionals. Signed + dated, A4, EN or JA. **TOKAS will NOT accept letters + sent directly by referees — jeffrey uploads the scanned PDFs himself.** +- **Primary:** **Casey Reas** (UCLA Social Software host; knows the practice + deeply, can speak to the instruments + community work). +- **Second (pick one):** a curator at **KADIST** or **SMK** (institutional weight, + speaks to collection context — strongest for a contemporary-art jury), OR + **Lauren Lee McCarthy** / **Sage Jenson** (peers). +- **Ask both this week.** Give them: program one-liner, the three points (breadth + of work · community/public practice · instruments-as-art), the + **2026-06-16 18:00 JST** deadline, and the "return the signed PDF to jeffrey" + instruction (no direct submission). +- Enter referee names + affiliations on the Online Submission Form. +- Files: `Letter_Scudder_1.pdf`, `Letter_Scudder_2.pdf`. + +--- + +## Portfolio plan (Outline §9.III) — `Portfolio_Scudder.pdf` + +**Spec:** ≤ 6 pages, A4 **landscape**, ≤ 10 MB, ≤ 3 projects, images ≤ 1 MB each, +selectable text, name on page 1, role stated per project, links valid through +**Sept 15, 2026**. Must relate to the Activity Plan (item 8). + +**Three projects (pages allocated 2 / 2 / 2):** + +1. **Whistlegraph — graphic-score performance** *(Position: Artist)* + - Performance still(s) + one score sheet (graphic notation = visible art) + + **1 video URL** of a performance. The clearest legible artifact of the + proposed Tokyo project. Caption with the runnable URL. +2. **notepat — polyphonic instrument** *(Position: Artist)* + - UI/instrument image + **1 video URL** of a solo performance reel + optional + **1 sound URL**. Establishes the sound/performance practice the workshops use. +3. **No Paint + NELA Computer Club** *(Position: Artist / Organizer)* + - No Paint output image + a NELA Computer Club session photo (Plot.Place, + Chinatown). Shows the collaborative, public-room practice the Tokyo OPEN + STUDIO mirrors. State role as Organizer for the club. + +**Asset TODO:** confirm performance/installation stills exist with venue + year; +re-host any clip over a public link (YouTube/Vimeo/SoundCloud) so it survives to +Sept 15, 2026; keep total PDF under 10 MB. + +--- + +## Submission checklist + +- [ ] **This week:** ask Casey Reas + second referee for letters; give them the + three points + 2026-06-16 18:00 JST deadline + "return PDF to me" instruction. +- [ ] Open the **Online Submission Form** (gated on the apply page) and capture any + extra fields (period choice, demographics, headshot, contact) — RESEARCH TODO. +- [ ] **jeffrey: choose residency period (①/②/③)** and fill the form's period field. +- [ ] Download Application Package; fill `ICR2027_Application.pdf` in **Adobe Reader** + (Mac: NOT Preview). Trim each item-8 answer to fit the on-form frame. +- [ ] Fill structured CV fields from `papers/cv/` (real shows with venue + year; + no unawarded grants listed as won). +- [ ] Confirm item 8.5 Tokyo contacts — name real ones or state "hope to connect" + honestly; do not invent. +- [ ] Build the 6-page landscape portfolio (Whistlegraph / notepat / No Paint+NELA); + links live through Sept 15; ≤ 10 MB. +- [ ] Collect both signed recommendation PDFs from referees; rename per spec. +- [ ] Rename application PDF `ICR2027_Scudder.pdf`. +- [ ] Upload form + 2 letters + portfolio to the Online Submission Form. +- [ ] **Submit before 2026-06-16, 18:00 JST.** (jeffrey hits send.) + +--- + +## Internal notes + +- **Single spine:** Whistlegraph graphic-score performance + notepat/KidLisp made + WITH Tokyo participants → public OPEN STUDIO workshop + performance. One + through-line, not the whole platform. Coheres with the LA NELA Computer Club model. +- **Framing for the jury:** TOKAS leans contemporary-art-world. Lead with the + performance/score/collection (KADIST, SMK) reading of the work, not "open-source + software." The portfolio must look like art documentation, not screenshots. +- **OPEN STUDIO is the required public ending** — the proposal already names it as + the workshop+performance, which satisfies Outline §5.② for free. +- **Non-commercial only** (Outline §5.④) — AC is free/open-source; keep all framing + non-commercial. Pieces stay free at their URLs after the residency. diff --git a/grants/tokas-2027/RESEARCH.md b/grants/tokas-2027/RESEARCH.md new file mode 100644 --- /dev/null +++ b/grants/tokas-2027/RESEARCH.md @@ -0,0 +1,174 @@ +# TOKAS International Creator Residency Program 2027 — RESEARCH + +> Pulled live from the official TOKAS open-call page, the Outline PDF, and the +> Application Package ZIP (application form + portfolio sample) on 2026-06-01. +> Numbers below are quoted from those source files, not paraphrased. + +## The facts (confirmed live) + +- **Program:** International Creator Residency Program 2027 (ICR2027), Tokyo Arts + and Space (TOKAS). A 3-month, fully-funded artist residency in Tokyo. +- **Deadline:** **June 16 (Tue), 2026, 18:00 (JST, GMT+9)** — hard cap on the + online submission form. Today is 2026-06-01, so **15 days out.** +- **Application window:** May 19 – June 16, 2026. +- **Fields:** visual arts (drawing, installation, painting, **performance**, + photography, **media art**, sculpture, **sound art**, video, etc.), design, + architecture. *(Performance / media art / sound art are explicitly named — that + is the door for a software-and-performance practice.)* +- **Residency period:** 3 consecutive months, in ONE of three windows: + - ① beginning of May 2027 → end of July 2027 + - ② beginning of Sep 2027 → end of Nov 2027 + - ③ beginning of Jan 2028 → end of Mar 2028 +- **Number selected:** 6 creators total (2 per period), tentative. + +### Funding breakdown (confirmed — quoted from Outline §3) + +- **Living space:** single room at TOKAS Residency. +- **Shared studio.** +- **Airfare:** one round-trip economy ticket, nearest airport ↔ Tokyo + (Narita or Haneda). +- **Living expenses (per diem):** **JPY 4,200/day** (consumption tax excluded). + Over a 3-month stay (~92 days) ≈ **JPY 386,400** (~$2,510 USD). +- **Fee for work/project:** **JPY 300,000** (consumption tax excluded) (~$1,950 USD). +- **Caveat:** *income tax 20.42% is deducted* from the per diem and the project fee. + So net cash is lower than the headline numbers. Airfare + housing + studio are + in-kind on top. + +### Eligibility (confirmed — quoted from Outline §4) + +- [x] **① Resides outside of Japan.** — jeffrey is US-based (LA). **OK.** +- [x] **② 5 years or more of experience** in visual arts / design / architecture. + — 13-year practice (Ringling BFA 2011, Yale MFA 2013). **OK, comfortably.** + *(Aggregator sites said "3 years" — that is WRONG; the official Outline and + the TOKAS English page both say 5 years. Trust the Outline.)* +- [x] **③ Sufficient English** to work with staff and other residents. — native. **OK.** +- [x] **④ Independent, can live/work on his own.** — **OK.** +- [x] **⑤ Duos allowed** (not relevant — applying solo). +- [x] **⑥ Students ineligible (except Ph.D. candidates).** — not a student. **OK.** + +### Conditions you sign up for (Outline §5) — read before applying + +- Must stay **consecutively** the full 3 months. +- Must **create a work to present at OPEN STUDIO** and submit a **comprehensive + final report**. *(This is the public-component requirement — the proposal must + name what gets shown at OPEN STUDIO.)* +- Cooperate with TOKAS PR / participate in other programs if asked. +- **No commercial or for-profit activity during the residency.** *(Note: AC is + open-source and free, so a public workshop + free pieces fits cleanly; do NOT + frame anything as a paid product or sale.)* +- No loud noise/vibration in the residence. *(notepat/performance sound work + happens in the studio / at events, not in the bedroom — fine.)* +- **Accommodation is for the creator ONLY** — no family/partner/guest stays. + *(Fia cannot stay in the TOKAS room. Worth flagging to jeffrey.)* + +### Selection timeline (Outline §7) + +1. **First document screening:** mid-June – August 2026 (internal TOKAS team; + result NOT communicated). +2. **Second screening:** late August 2026 (external jury). Only successful + applicants notified individually by **mid-September 2026.** +3. **Final results on TOKAS website by early October 2026.** +4. No individual inquiries answered about the process. + +## Required documents (Outline §8–9) — submit via Online Submission Form + +1. **(I) Application Form (.pdf)** — fill in English, in **Adobe Reader** + (Mac: do NOT use Preview, data won't render). Rename `ICR2027_YourNAME.pdf`. + Respect the per-field character limits so text fits the frame. +2. **(II) Two letters of recommendation (.pdf)** — from **two different referees** + at art/cultural institutions, educational orgs, or art-and-culture + professionals. Signed + dated, A4, Japanese or English. Digital signature OK. + Referee names + affiliations also entered on the online form. **TOKAS does NOT + accept letters sent directly by referees** — jeffrey collects the PDFs and + uploads them himself. Name `Letter_YourNAME_1.pdf`, `Letter_YourNAME_2.pdf`. +3. **(III) Portfolio (.pdf)** — see specs below. `Portfolio_YourNAME.pdf`. + +### Portfolio specs (Outline §9.III) — precise + +- **Max 6 pages, A4, LANDSCAPE.** Page 7+ ignored. +- **Up to 3 projects** within those 6 pages. +- One PDF, **≤ 10 MB**; each embedded image **≤ 1 MB**. +- Text must be **selectable** (not flattened images of text). +- Name on page 1. State your role per project (Artist / Collaborator / etc.). +- Installation views: give exhibition name + venue + year. +- Media: **per project** you may include max **1 video URL** + **1 sound URL** + (hyperlinked); images pasted in. Links must be **valid until Sept 15, 2026**. + If not online, host on YouTube/Vimeo/SoundCloud; Google Drive set to + "Anyone with the link." +- Portfolio projects should relate to the Activity Plan in form item 8. + +## The proposal is NOT a single essay — it's tight micro-fields + +This is the single most important framing fact. The application form (verbatim +field list, item 8 "Artistic/Curatorial Statement, Purpose of your residency and +Activity Plan") is: + +- **8.1 — 2 to 4 keywords** defining practice/approach/interest. +- **8.2 — Concept of your work** in relation to past/ongoing projects. **max 150 words.** +- **8.3 — Motivation for TOKAS + why Tokyo + what you expect** from the residency. + **max 150 words.** +- **8.4 — Purpose of residency + how you'd spend your time** (project plan / + research plan). **max 200 words.** +- **8.5 — Tokyo institutions/people you'd want to contact** (and whether already + in touch). No stated limit. + +Plus structured CV fields (no prose): 1) name + artist name, 2) academic quals +(4 most recent, reverse chron), 3) employment (3 most recent), 4) language +proficiency, 5) AIR history, 6) awards & grants, 7) activity history (major +exhibitions/projects). **There is no long narrative.** Everything load-bearing +must fit in ~150–200 words. DRAFT.md is written to those caps. + +## Fit assessment + +**Confidence: medium-high.** The eligibility is a clean pass and the funding is +real and generous. The risk is framing, not qualification. + +- **The opening for software practice is real and explicit.** TOKAS lists + *performance, media art, sound art, video* under visual arts. notepat (sound + art / performance), Whistlegraph (graphic-score performance), and No Paint + (drawing/media art) all map onto named fields. KidLisp/AC as a software platform + is the through-line, but each artifact reads as a visual/sound/performance work. +- **OPEN STUDIO is built-in.** TOKAS *requires* a public presentation at the end — + which is exactly the NELA-Computer-Club open-workshop + performance spine. The + residency's own structure hands jeffrey his public component. Lean into it. +- **Caveat — TOKAS leans gallery / contemporary-art-world.** Jury is external + contemporary-art professionals; collections context (KADIST, SMK) and the + performance/graphic-score lineage are how you make a code practice legible to + them. Show work that has *been exhibited and performed*, not a GitHub repo. The + portfolio should read as art documentation (installation views, performance + stills, score sheets), not software screenshots. +- **Caveat — no commercial activity.** AC is free/open-source, so this is fine, + but keep all framing non-commercial. The project fee + per diem fund materials + and living, not a product. +- **Caveat — solo housing only.** Flag the no-guests rule to jeffrey for personal + planning (Fia can't stay in the room). + +## URLs + +- Open call (English): https://www.tokyoartsandspace.jp/en/archive/application/2026/20260430-317.html +- Outline PDF: https://www.tokyoartsandspace.jp/static/file/open%20call/Residency/2027/Outline_ICR2027.pdf +- Application Package (ZIP, has form + portfolio sample): + https://www.tokyoartsandspace.jp/static/file/open%20call/Residency/2027/Application%20Package_ICR2027.zip +- Application root: https://www.tokyoartsandspace.jp/en/application/index.html +- Res Artis listing: https://resartis.org/open-call/international-creator-residency-program-2027|tokas-residency-programs/ + +## TODO / unconfirmed + +- [ ] **Online Submission Form** itself not yet inspected (link is gated on the + archive page through the "apply" flow). The form may collect demographics, + a headshot, or contact info beyond the PDF — verify by opening it before + drafting final field text. +- [ ] **Which period to request (①/②/③)** — TODO for jeffrey. The form may or may + not let you rank periods; the Online Submission Form will show this. Likely + preference: ② (Sep–Nov 2027) or ③ (Jan–Mar 2028) to avoid colliding with the + fall 2026 *REPL* performance and to give lead time. Confirm against jeffrey's + teaching calendar (UCLA Social Software) and NELA Computer Club cadence. +- [ ] **Two referees** — must be art/culture professionals at institutions. + Strong candidates: Casey Reas (UCLA Social Software host) + a curator from + KADIST or SMK (collections context), OR Lauren Lee McCarthy / Sage Jenson. + jeffrey collects + uploads the PDFs himself (TOKAS won't take direct). +- [ ] Confirm exact image/video assets exist for a 6-page portfolio (3 projects: + Whistlegraph, notepat, No Paint) with installation/performance context and + links live through Sept 15, 2026. +- [ ] Per-diem net after 20.42% income tax — confirm whether tax also hits the + in-kind airfare valuation (likely not, but verify if it matters to budget). diff --git a/grants/transmediale-lattice-labs-2027/DRAFT.md b/grants/transmediale-lattice-labs-2027/DRAFT.md new file mode 100644 --- /dev/null +++ b/grants/transmediale-lattice-labs-2027/DRAFT.md @@ -0,0 +1,253 @@ +# transmediale — Lattice Labs 2027 — Application Draft + +> **Deadline:** 2026-06-07, 23:59 CEST — ~6 days from 2026-06-01. +> **Lab chosen:** **Reclaiming the Interface** (steward: Olia Lialina). +> **Format:** fully remote working process Jul 2026 → Jan 2027, biweekly +> online; physical presence in Berlin for festival week (Jan 26 – Feb 1, +> 2027 — TODO confirm end date). **Free.** Travel + accommodation for the +> Berlin week covered. +> **Apply:** online form in English at +> https://transmediale.de/en/news/reclaiming-the-interface-lab +> +> *Draft v0 — DRAFT ONLY, never submit. No published word limits; each +> narrative answer is written tight (~300–400 words) and must be resized +> against the live form's actual fields before pasting. Anything marked +> TODO is unconfirmed against the raw official form — see RESEARCH.md.* + +--- + +## Eligibility — self-check + +- [x] Early-career / emerging / interdisciplinary practitioner — framed as + **emerging in this specific line of critical inquiry** (the interface + as commons), not as a career-emerging artist. *(See RESEARCH.md + caveat: this is the one real eligibility question — pitch the + question, not the CV.)* +- [x] International applicant welcome (US / Los Angeles–based). ✅ +- [x] Fluent English. ✅ +- [x] Can commit to all biweekly online sessions Jul 2026 – Jan 2027 + (remote from LA). ✅ +- [x] Available to travel to Berlin for festival week (Jan 26 – Feb 1, + 2027), travel + lodging covered. ✅ +- [x] Applying to **exactly one** lab — Reclaiming the Interface. +- [ ] **Two letters of recommendation** — TODO confirm requirement; if + required, jeffrey to line up writers (e.g. Casey Reas + an + interface/net-art peer) and reach out himself. Do not contact anyone + from this draft. + +--- + +## 1. Lab selected + +**Reclaiming the Interface** (steward: Olia Lialina). + +## 2. Discipline / field + +Artist working in software — creative-computing systems, programming +languages, performance, and writing. I build the runtime, the language, +and the operating system the work runs on, and I treat each as a site of +inquiry rather than a product. + +--- + +## 3. Inquiry title + +**The URL as the smallest shareable unit of culture.** +*(working subtitle: computing you can read, fork, and type to a stranger — +against the agentic web)* + +--- + +## 4. Statement of inquiry / abstract *(~350 words; resize to form)* + +The interface I want to reclaim is the address bar. Not as nostalgia for +the open web, but as a live argument about scale: what is the *smallest +thing* one person can hand to another and have it run? The platform answer +is an app — installed, sandboxed, store-mediated, unreadable. The agentic +answer is worse: a model stands between you and the machine, and the +interface dissolves into a prompt you cannot inspect. In both cases the +person who *receives* computing can no longer read it, change it, or pass +it on. Computing stops being a thing you make and becomes a thing done to +you. + +I want to test a different unit. For five years I have built **Aesthetic +Computer** — a public, open-source space of tiny programs ("pieces") that +are addressed by a word you type, run in any browser, are readable at the +source, and can be forked back into the same address space they came from. +They run on **AC Native OS**, an operating system I wrote so the pieces are +not guests on someone else's platform. Many are written in **KidLisp**, a +minimal Lisp (118 built-ins) small enough that a child can read the whole +language. I am not bringing this as a finished platform to demonstrate. I +am bringing it as an **apparatus** — a working instrument for asking +whether the URL can be defended as the smallest *legible, forkable, +passable* unit of culture, against an industry busy making computing +illegible. + +The inquiry for the lab: if the interface is a "staged performance," then +typing a word into an address bar and getting a readable program back is a +performance with a different politics than tapping an icon or prompting an +agent. What does an interface look like when its first axiom is *the person +on the other end can read what you sent them*? I want to prototype that +interface as gesture and fiction with the cohort — not ship it — and to +test where the URL-as-commons holds and where it breaks. The software is +evidence. The question is the point. + +--- + +## 5. Why transmediale / alignment with the lab's research question +*(~320 words; resize to form)* + +Lialina's framing — the agentic web undermining the idea that the WWW can +belong to the people who make it — is the exact fault line my practice sits +on. I did not arrive at "computing you can read and pass to a stranger" as +a feature decision; I arrived at it as a position, against a decade of +interfaces engineered to make their users *operators* rather than authors. +The lab's move — to revisit the interface as a staged performance and +reclaim it as a space of imagination, negotiation, and agency — is the move +I have been making in code without the critical company to argue it +through. That company is what I want. + +transmediale is the one place this is treated as a question rather than a +pitch. Everywhere else I am asked what the platform *does*. The lab asks +what the interface *means* — who it lets be an author, who it reduces to a +user, what it makes legible and what it hides. I want six months of biweekly +argument with people who take that seriously: the historians who remember +when the interface was contested ground, and the practitioners building +its alternatives now. + +What I bring to the cohort is an unusual position: I am not theorizing the +readable, forkable interface — I have been *living inside one I built* and +can show exactly where the idea holds and where it leaks. AC Native OS and +KidLisp are not slides; they are a testbed the whole lab can type into, +break, and reason about. What I lack — and want — is the critical frame: +the lineage from the vernacular web through net.art to now, the language to +say *why* the small readable unit matters politically, not just why it is +nice. I can offer a working apparatus; I am asking the lab for the argument +to wrap around it. + +--- + +## 6. What I would do — online term (Jul 2026 – Jan 2027) *(~300 words)* + +I work best in public and on a cadence, so the biweekly rhythm suits the +practice exactly. My contribution across the online term, in process terms: + +**Inquiry phase.** Bring AC Native OS + KidLisp into the lab as a shared, +hands-on testbed — a thing the cohort can type into, fork, and break, +rather than a demo to watch. Use it to make the abstract concrete: when we +argue about legibility or forkability, we can run the experiment in the +room. In parallel I keep a working log in the **AC papermill** — the +arXiv-style writing stream the practice already produces — so the inquiry +leaves a readable trail (this is how I think, in public, in the open). + +**Production phase.** Prototype the interface the inquiry implies: not a +product, but *gestures and fictions* (in the lab's language) — small +performable interfaces where the first axiom is "the person you send this +to can read it." Candidate experiments: a piece you author by typing it +*as the URL*; a fork that is a single keystroke and leaves both versions +addressable; a deliberately staged contrast between the readable URL-unit +and an opaque agentic flow, performed side by side. I would want at least +one experiment that *fails on purpose* — the point is to find where +URL-as-commons breaks, not to defend it. + +**Collaboratively.** I do not want to bring a solo project into a shared +lab. I would offer AC as common infrastructure the others can build into, +and take direction from the cohort's questions — especially Lialina's +web-historical frame, which I lack and need. Lateral, not led. My measure +of success for the term is not a finished work but a *sharper question* +than the one I walked in with. + +## 7. What I would do — Berlin week (Jan 26 – Feb 1, 2027) *(~180 words)* + +In Berlin I want the work to be *typed, not screened*. The festival +activation I would push for: a live, performed interface — people type words +and get back readable programs they can change on the spot and hand to the +next person, set against an opaque agentic flow doing the "same" task. The +contrast is the argument, performed rather than explained. I would bring AC +Native OS running on real hardware so the address-bar-as-interface is a +physical, public thing, not a slide. I would also bring **printed cards** — +single sheets that are simultaneously the readable score and the runnable +source — so the smallest shareable unit leaves the building in people's +pockets. Whatever the cohort builds collectively, my instinct for the week +is the same as for the whole practice: make the inquiry *legible and +passable* — something a stranger in the room can pick up, read, run, and +carry out the door. + +--- + +## 8. Short bio *(~150 words)* + +I am @jeffrey (Jeffrey Alan Scudder), an artist working in software. For +five years I have built **Aesthetic Computer**, a public, open-source space +of tiny URL-addressable programs that run in any browser and on **AC Native +OS**, an operating system I wrote to host them. Many are written in +**KidLisp**, a minimal Lisp I designed for generative art. The practice has +produced ~30 papers and 17,000+ user-authored programs, and instruments +including *notepat* (a polyphonic synthesizer), *No Paint* (collaborative +drawing), and *Whistlegraph* (a graphic-score performance form). I run the +**NELA Computer Club** — a free, public, biweekly gathering in Chinatown, +Los Angeles — and serve as artist-in-residence in UCLA's Social Software +course (hosted by Casey Reas). Work in the collections of KADIST (San +Francisco) and SMK (Copenhagen). Yale MFA 2013, Ringling BFA 2011. ORCID +0009-0007-4460-4913. + +--- + +## 9. CV / portfolio note + +- One-page process-oriented CV — emphasize the *inquiry* line, not the + product line: AC as open-source research apparatus, the papermill writing + stream, KidLisp language design, NELA Computer Club as public process, + AC Native OS. De-emphasize anything that reads as commercial / finished. +- Pull from existing CV at `papers/cv/` (Jeffrey-CV pipeline); trim to one + page and re-weight toward process and inquiry for this specifically + theory-forward call. +- Portfolio: link AC live (typing a word IS the portfolio), the Whistlegraph + paper (`papers/arxiv-whistlegraph/whistlegraph.pdf` — a document that is + itself a score), and a KidLisp source sample shown as readable art. + +--- + +## 10. Letters of recommendation *(TODO confirm requirement — see RESEARCH)* + +If two letters are required: +- **Primary:** Casey Reas — UCLA Social Software host; can speak to the + practice and to its place in a critical-software lineage. +- **Second:** an interface / net-art peer who can speak to the inquiry on + its own terms (TODO: jeffrey to choose). +- DRAFT-ONLY: do not contact anyone from this file. If the form confirms + letters are required, jeffrey lines up + emails writers himself, today, + given the 6-day window. + +--- + +## Submission checklist + +- [ ] Open the official Reclaiming the Interface form; capture the REAL + field list + any word/character limits; resize every answer above. +- [ ] Confirm: festival-week end date (26–31 vs 26 Jan–1 Feb). +- [ ] Confirm: letters of recommendation count (2?) — if required, ask + writers immediately. +- [ ] Confirm: production budget / stipend specifics (set expectations). +- [ ] Write the Application Form Data PDF (background + methodology) — same + inquiry spine, process-framed. +- [ ] Trim CV to one page, process-weighted; assemble portfolio links. +- [ ] Final read for VOICE: sharp, critical, first-person, lowercase-leaning, + em-dashes — lead with the question, never the product. +- [ ] Submit before **2026-06-07, 23:59 CEST**. + +--- + +## Internal notes + +- **Single spine:** the URL-as-smallest-shareable-unit-of-culture inquiry; + computing-as-commons vs. computing-as-extraction. One inquiry, one lab, + committed. +- **Apparatus, not product:** AC / KidLisp / AC Native OS are *evidence* and + a *testbed* for the cohort — never the deliverable. transmediale rejects + finished-product pitches; this draft is built to lead with the question. +- **Eligibility risk** ("emerging"): framed as emerging in this critical- + inquiry context. Do not oversell credentials. Let the working apparatus, + not the CV, carry the case. +- **Facts:** all applicant facts are canonical (Reas is host not McCarthy; + KADIST/SMK are collectors → "work in the collections of"; not collaborators). diff --git a/grants/transmediale-lattice-labs-2027/RESEARCH.md b/grants/transmediale-lattice-labs-2027/RESEARCH.md new file mode 100644 --- /dev/null +++ b/grants/transmediale-lattice-labs-2027/RESEARCH.md @@ -0,0 +1,195 @@ +# transmediale — Lattice Labs 2027 — RESEARCH + +> Fetched live 2026-06-01. Official pages (transmediale.de) are JS-rendered +> and return only footers to a text fetcher, so the call facts below are +> cross-checked across three mirrors (Contemporary Performance, artinfoland, +> toshareproject artmaker blog) that quote the call directly and name the +> lab stewards consistently. **Anything not yet seen on the raw official +> form is marked TODO — confirm in the official online form before filling.** + +## The opportunity + +**transmediale (Berlin) — Lattice Labs**, the lab strand of transmediale +2027. Not a residency or a production commission in the ordinary sense — +**collaborative working groups** that "actively rethink, handle, and test +structural alternatives to technological monocultures of corporate +extraction and surveillance." Process over product: the framework +"prioritiz[es] exchange, interdisciplinary thinking, and the development +of methods rather than finalized artworks." + +Structure: ~6-month distributed working process (mostly online, biweekly +sessions) **July 2026 → late January 2027**, culminating in a physical +presence in Berlin during festival week. + +## Deadline + +**Sunday, 7 June 2026, 23:59 CEST.** (Today is 2026-06-01 — ~6 days out.) +Application opened 1 May 2026. Free — no application or participation fee. + +## The labs (pick exactly ONE) + +Five labs, each ~5 participants, each with a steward/lead. Confirmed via +the artmaker-blog mirror (names the stewards): + +1. **Playful Misuse (of everyday software)** — steward **viola he**. + Reappropriating / tactically misusing everyday commercial software as + artistic and political strategy; tools as instruments for collective + performance and play. *Fully remote + Berlin during festival.* +2. **Obscure Detritus** — steward **Alex Declino**. AI characters "defined + by personality and obsession rather than competence and utility" set + loose in a world simulation. *Mostly online; optional in-person at + Sybil Berlin from Sept.* +3. **Minor Indexes** — steward **selfservice.studio**. Institutional + indexing, the politics of metadata, community archives, counter- + cataloging; building a "situated dataset built to confront the + conditions of control." *Berlin-based throughout — presence in the city + required the whole time.* (Likely DISQUALIFYING for a US applicant.) +4. **Reclaiming the Interface** — steward **Olia Lialina**. How the rise of + the "agentic web" undermines the idea that the WWW can belong to the + people who make it; "revisits the interface as a staged performance," + inviting "prototypes, gestures, and fictions that reclaim it as a space + of imagination, negotiation, and agency." Interfaces as political + infrastructures that shape behavior and access. *Fully remote + Berlin + festival week (Jan 26 – Feb 1, 2027).* +5. **Solidarity Economies** — stewards **Sepp Eckenhaussen & Geert + Lovink**. Collaborative economic models for art workers — platform + coops, rotating funds, cultural publishing, NFTs/DAOs, alternatives to + extractive markets. *Online + mandatory Berlin design sprint (first + weekend of October) + festival.* + +### Lab choice for this draft: **Reclaiming the Interface (Olia Lialina)** + +It is the cleanest fit and the one whose published research question maps +onto AC almost word-for-word: the agentic web vs. the WWW belonging to the +people who make it; the interface as a contested political infrastructure; +reclaiming it as a space of imagination and agency. Lialina (vernacular-web +historian, GeoCities archivist, "Turing Complete User") is the ideal +interlocutor for AC's "computing you can read, fork, and type to a +stranger" thesis. It is fully remote until the festival, so a US applicant +can do the whole working process from LA and only travel for the week. + +*(Backup fit: Playful Misuse — also remote-until-festival — if the +Interface lab reads as too crowded with web-history practitioners. AC's +KidLisp-as-misuse-of-the-URL-bar angle would work there too. Minor Indexes +is Berlin-resident-required → effectively disqualifying. Solidarity +Economies requires an October Berlin sprint → a second trip.)* + +## Eligibility + +- "Early career and emerging artists, researchers, curators, students, and + practitioners from all disciplines." Students and PhD candidates eligible. +- **International — open globally, no stated geographic restriction.** US + applicant OK. ✅ +- English-only call; "Lab participation requires English language skills." +- **Mandatory commitments:** attend all biweekly online sessions Jul 2026 – + Jan 2027 **and** be physically in Berlin for festival week (the Interface + lab states **Jan 26 – Feb 1, 2027**; some mirrors say Jan 26–31). TODO: + confirm exact festival-week dates on the official lab page. +- "Early career and emerging" framing — TODO/CAVEAT: this is the one real + eligibility question for jeffrey (see Fit). No hard year cutoff is + published; "emerging" at transmediale is read loosely (career stage in a + *medium/field*, not age), but it is a genuine risk for someone with a + 2013 MFA, museum collections, and a 5-year platform. Pitch as emerging + *in this specific line of critical inquiry*, not as career-emerging. + +## What's provided + +- **Berlin residency week fully covered: round-trip travel + accommodation** + (festival week, late Jan 2027). ✅ (artinfoland mirror, explicit.) +- Steward mentorship + facilitation; peer cohort; workshops / collective + sessions; integration into transmediale 2027 main programming. +- "Dedicated production budget for prototype development" (per artinfoland; + amount not published). TODO: confirm whether there is a participant + **stipend/fee/honorarium** beyond travel+lodging+production budget — not + stated in any mirror. Assume none unless the official form says otherwise. + +## Application — components (per mirrors) + +Submitted in **English** via an online form. Components reported: + +1. Personal contact info + confirmation of discipline. +2. Selection of exactly **one** lab. +3. **Application Form Data (PDF)** — background + methodology. +4. **Statement of Intent** — must address collaborative readiness, interest + in peer-to-peer / lateral learning, and how your current technique / + practice intersects the chosen lab's research question. +5. **CV + portfolio** — emphasize *process-oriented* work, not finished + products. +6. **Two letters of recommendation.** TODO: confirm whether 2 letters are + truly required for the labs (one mirror lists this; cross-check the + official form — some transmediale calls ask for references, not full + letters). + +### Word / character limits + +**TODO — not published in any mirror.** None of the three sources state a +word or character cap on the Statement of Intent or Form Data. Draft each +narrative answer to a tight, transmediale-appropriate length (~300–400 +words; a strong critical statement, not a long one) and resize against the +live form's actual limits before pasting. + +## Key dates + +- 1 May 2026 — call opens. +- **7 Jun 2026, 23:59 CEST — deadline.** +- Jul 2026 → autumn — Phase 1, collective inquiry (biweekly online). +- Autumn 2026 → Jan 2027 — Phase 2, collective production. +- **26 Jan – 1 Feb 2027 — festival week in Berlin** (Phase 3 / activation). + TODO confirm exact end date (26–31 vs 26 Jan–1 Feb). + +## URLs + +- Official call hub: https://transmediale.de/en/news/lattice-labs +- All open calls index: https://transmediale.de/en/media/lattice-labs-open-calls +- Interface lab: https://transmediale.de/en/news/reclaiming-the-interface-lab +- Minor Indexes lab: https://transmediale.de/en/news/minor-indexes-lab +- Festival home: https://transmediale.de/en +- Mirror (Contemporary Performance): https://contemporaryperformance.com/2026/05/22/transmediale-lattice-labs-open-call/ +- Mirror (artinfoland): https://artinfoland.com/opportunities/transmediale-lattice-labs-2027 +- Mirror (toshareproject artmaker blog): https://toshareproject.it/artmakerblog/transmediale-lattice-labs/ + +--- + +## Fit assessment + +**Conceptual fit: very strong.** transmediale is the canonical home for +critical inquiry into interface politics, infrastructure, and the +post-digital condition. The Interface lab's published question — the +agentic web vs. the web belonging to the people who make it; the interface +as a contested political surface to be reclaimed — is the same fault line +AC sits on. AC reframed as *apparatus for an inquiry* (computing-as-commons +vs. computing-as-extraction; the URL as the smallest shareable unit of +culture) is exactly the register transmediale rewards. + +**Process fit: strong, IF reframed.** The single biggest risk is pitching +AC as a finished platform / product. transmediale explicitly does not want +finalized artworks — it wants methods, questions, collective process. The +draft must lead with the *question* and the *process jeffrey would pursue +in the cohort*, and treat AC + KidLisp + AC Native OS as evidence / +apparatus, never as the deliverable. The "single spine" rule helps here: +one inquiry, committed to. + +**Logistics fit: good.** Remote-until-festival (Interface lab) + fully +covered Berlin week = a US/LA applicant can do this without relocating and +without out-of-pocket travel. Only one trip (festival week) required. + +### Honest caveats + +1. **"Emerging" eligibility** is the real question (above). Frame jeffrey as + emerging *in this critical-inquiry context* and let the practice show the + capacity to carry a cohort — but do not oversell career credentials in a + way that flags "too established." Lead with the question, not the CV. +2. **Highly competitive, theory-forward.** ~5 seats per lab, global pool, + stewarded by figures like Lialina/Lovink. The statement has to read as + genuinely *critical* — sharp, situated, in conversation with the lab's + discourse — not as an artist-platform pitch. Voice matters more here than + in most calls. +3. **Two letters of recommendation (TODO-confirm)** may be on a tight + timeline — 6 days. If required, line up writers immediately (e.g. Casey + Reas; an interface/net-art peer). DRAFT-ONLY: do not contact anyone; + flag for jeffrey to decide and reach out himself. +4. **Production budget but (likely) no personal stipend** — set expectations: + the value is the cohort, the steward, the Berlin week (travel+lodging), + and festival integration — not income. +5. **Don't combinatorially buffet the labs.** Pick one lab, one inquiry, + commit. This draft commits to Reclaiming the Interface. diff --git a/kidlisp/SPINNERS.md b/kidlisp/SPINNERS.md new file mode 100644 --- /dev/null +++ b/kidlisp/SPINNERS.md @@ -0,0 +1,525 @@ +# Spinners — Variable-Spin KidLisp Catalog + +A 256-piece design space of `(wipe fade::)` kidlisp +pieces. Each piece is a one-line fade gradient whose direction is animated +by an arbitrary expression over the `frame` counter. + +The original seed was the minted piece **$wwi** = `(wipe fade:red-rainbow)` +listed at 10 XTZ on keeps.kidlisp.com. The catalog below cross-multiplies +16 palettes × 16 motion expressions for 256 distinct minable variations. + +--- + +## Tools built this session + +All under `kidlisp/tools/`. Each is self-contained and runs against a local +dev server (`npm run site`, port 8888) or production (`https://aesthetic.computer`). + +### `gen-spinners.mjs` + +Emits the full catalog of 256 spinner sources. Pure data, no IO. + +``` +node kidlisp/tools/gen-spinners.mjs # plain sources, one per line +node kidlisp/tools/gen-spinners.mjs --json # full JSON with palette + motion +node kidlisp/tools/gen-spinners.mjs --catalog # human-readable index +node kidlisp/tools/gen-spinners.mjs --out sources.txt # write to file +``` + +### `preview-anim.mjs` + +Renders a single kidlisp source as mp4 (or animated webp). Uses puppeteer + +Chrome's `Page.startScreencast` for real-time capture at AC's actual ~60fps +paint rate. Output format chosen by extension: `.mp4` → libx264 h264, `.webp` +→ img2webp. + +``` +node kidlisp/tools/preview-anim.mjs '(wipe fade:red-rainbow:frame)' \ + --out ~/Desktop/spin.mp4 \ + --duration 4000 --size 320x240 --scale 2 --quality 95 +``` + +Flags: `--size` (AC virtual resolution), `--scale` (output pixel multiplier, +ffmpeg nearest-neighbor upscales), `--duration`, `--fps`, `--quality`, +`--boot`, `--settle`, `--keep`. + +The rig does a 2-pass warmup: pass 1 navigates and populates AC's +IndexedDB BDF glyph cache (so the QR overlay's MatrixChunky8 label has +real glyphs available). Pass 2 reloads and captures from t=0 with the +warm cache. + +### `preview-grid.mjs` + +Renders N sources as static thumbnails in a single grid PNG. + +``` +node kidlisp/tools/gen-spinners.mjs --out /tmp/srcs.txt +node kidlisp/tools/preview-grid.mjs --sources /tmp/srcs.txt \ + --out ~/Desktop/spinners-256.png \ + --cols 16 --cell 96x72 --boot 1500 --settle 1000 --label +``` + +Has crash recovery — if Chrome dies mid-render, the rig auto-relaunches +the browser and continues with a black tile in the failed slot. + +### `preview-grid-anim.mjs` + +Renders N sources as animated cells composed into a single grid mp4 via +per-frame sharp composites + ffmpeg encode. + +``` +node kidlisp/tools/preview-grid-anim.mjs --sources /tmp/srcs.txt \ + --out ~/Desktop/spinners-anim-256.mp4 \ + --cols 16 --cell 96x72 --duration 4000 --fps 15 \ + --boot 2000 --settle 1500 +``` + +Uses `Page.startScreencast` with `everyNthFrame` to downsample from AC's +~60fps paint rate to the requested output fps — one persistent CDP +connection per cell instead of N screenshot requests, drastically friendlier +to the server. + +--- + +## AC fix shipped this session + +**`system/public/aesthetic.computer/lib/disk.mjs` lines 15554 and 15954** — +QR overlay's `isLoaded` glyph check. + +The BDF font system returns an animated "loading placeholder" glyph for +chars whose Proxy access hits an unloaded entry. That placeholder has +`resolution`, `pixels`, and `advance` fields set, so the QR overlay's +existing check (`glyph && (glyph.resolution || glyph.pixels || glyph.commands)`) +treated it as "loaded" — caching a label rendered with the placeholder +pattern instead of the real `$xxx` code. Added `!glyph.isPlaceholder` to +both check sites so the QR cache properly waits for real glyphs. + +```js +// before +const isRealGlyph = glyph && glyph !== null && + (glyph.dwidth || glyph.advance !== undefined || glyph.resolution); +// after +const isRealGlyph = glyph && glyph !== null && !glyph.isPlaceholder && + (glyph.dwidth || glyph.advance !== undefined || glyph.resolution); +``` + +--- + +## The design space + +### 16 palettes + +| name | fade string | +|---|---| +| red-rainbow | `red-rainbow` | +| orange-rainbow | `orange-rainbow` | +| yellow-rainbow | `yellow-rainbow` | +| green-rainbow | `green-rainbow` | +| red-band | `black-red-black` | +| orange-band | `black-orange-black` | +| yellow-band | `black-yellow-black` | +| green-band | `black-green-black` | +| blue-band | `black-blue-black` | +| indigo-band | `black-indigo-black` | +| violet-band | `black-violet-black` | +| sunset | `black-orange-red-purple-black` | +| ocean | `navy-blue-cyan-teal-navy` | +| fire | `black-red-yellow-red-black` | +| ice | `white-cyan-blue-indigo-black` | +| vapor | `pink-cyan-purple-cyan-pink` | + +(Plus 10 more in the generator: `midnight`, `lava`, `aurora`, `sakura`, +`candy`, `jungle`, `autumn`, `cosmos`, `purple-red`, `roygbiv`, +`roygbiv-reverse` — only the first 16 fit into 256 with 16 motions each.) + +### 16 motion expressions + +The angle param after the second `:` in `fade:colors:` is an +s-expression evaluated each paint with `frame` as the AC frame counter. + +| name | expression | +|---|---| +| spin | `frame` | +| fast-spin | `(* frame 5)` | +| slow-spin | `(/ frame 3)` | +| swing | `(+ frame (* (sin (* frame 0.05)) 60))` | +| wobble | `(+ frame (* (sin frame) 50))` | +| accelerate | `(* frame frame 0.001)` | +| decelerate | `(* (sqrt frame) 30)` | +| bounce | `(abs (* (sin (* frame 0.03)) 360))` | +| pendulum | `(* (sin (* frame 0.02)) 180)` | +| pulse | `(+ frame (* (sin (* frame 0.1)) 30))` | +| jitter | `(+ frame (* (sin (* frame 2)) 20))` | +| reverse | `(- 0 frame)` | +| chaos | `(+ frame (* (sin (* frame 0.07)) (cos (* frame 0.13)) 200))` | +| meditative | `(* (sin (* frame 0.01)) 90)` | +| exp-sin | `(* frame (sin (* frame 0.05)))` | +| double | `(+ frame (* (sin (* frame 0.1)) (sin (* frame 0.03)) 100))` | + +Variable spin works because the kidlisp evaluator was patched earlier to +re-glue tokenizer-split `fade:colors:` strings with their following +s-expression arg. See the `args.length >= 2` re-glue pass in +`system/public/aesthetic.computer/lib/kidlisp.mjs` around line 9890. + +--- + +## Reproducing the artifacts + +``` +# Static 256-cell contact sheet (~25 min against local, ~30 min against prod) +node kidlisp/tools/gen-spinners.mjs --out /tmp/srcs.txt +node kidlisp/tools/preview-grid.mjs --sources /tmp/srcs.txt \ + --out ~/Desktop/spinners-256.png --cols 16 --cell 96x72 \ + --boot 1500 --settle 1000 --label + +# Animated 256-cell grid mp4 (~30 min against prod with screencast) +node kidlisp/tools/preview-grid-anim.mjs --sources /tmp/srcs.txt \ + --out ~/Desktop/spinners-anim-256.mp4 --base https://aesthetic.computer \ + --cols 16 --cell 96x72 --duration 4000 --fps 15 \ + --boot 2000 --settle 1500 + +# Individual mp4 of a single piece (~15s per render) +node kidlisp/tools/preview-anim.mjs \ + '(wipe fade:black-purple-red-purple-black:(+ frame (* (sin (* frame 0.05)) 60)))' \ + --out ~/Desktop/spin.mp4 --duration 4000 --size 320x240 --scale 2 --quality 95 +``` + +--- + +## Full catalog — 256 sources + +### red-rainbow (red-rainbow) + +| # | motion | source | +|---|---|---| +| 000 | spin | `(wipe fade:red-rainbow:frame)` | +| 001 | fast-spin | `(wipe fade:red-rainbow:(* frame 5))` | +| 002 | slow-spin | `(wipe fade:red-rainbow:(/ frame 3))` | +| 003 | swing | `(wipe fade:red-rainbow:(+ frame (* (sin (* frame 0.05)) 60)))` | +| 004 | wobble | `(wipe fade:red-rainbow:(+ frame (* (sin frame) 50)))` | +| 005 | accelerate | `(wipe fade:red-rainbow:(* frame frame 0.001))` | +| 006 | decelerate | `(wipe fade:red-rainbow:(* (sqrt frame) 30))` | +| 007 | bounce | `(wipe fade:red-rainbow:(abs (* (sin (* frame 0.03)) 360)))` | +| 008 | pendulum | `(wipe fade:red-rainbow:(* (sin (* frame 0.02)) 180))` | +| 009 | pulse | `(wipe fade:red-rainbow:(+ frame (* (sin (* frame 0.1)) 30)))` | +| 010 | jitter | `(wipe fade:red-rainbow:(+ frame (* (sin (* frame 2)) 20)))` | +| 011 | reverse | `(wipe fade:red-rainbow:(- 0 frame))` | +| 012 | chaos | `(wipe fade:red-rainbow:(+ frame (* (sin (* frame 0.07)) (cos (* frame 0.13)) 200)))` | +| 013 | meditative | `(wipe fade:red-rainbow:(* (sin (* frame 0.01)) 90))` | +| 014 | exp-sin | `(wipe fade:red-rainbow:(* frame (sin (* frame 0.05))))` | +| 015 | double | `(wipe fade:red-rainbow:(+ frame (* (sin (* frame 0.1)) (sin (* frame 0.03)) 100)))` | + +### orange-rainbow (orange-rainbow) + +| # | motion | source | +|---|---|---| +| 016 | spin | `(wipe fade:orange-rainbow:frame)` | +| 017 | fast-spin | `(wipe fade:orange-rainbow:(* frame 5))` | +| 018 | slow-spin | `(wipe fade:orange-rainbow:(/ frame 3))` | +| 019 | swing | `(wipe fade:orange-rainbow:(+ frame (* (sin (* frame 0.05)) 60)))` | +| 020 | wobble | `(wipe fade:orange-rainbow:(+ frame (* (sin frame) 50)))` | +| 021 | accelerate | `(wipe fade:orange-rainbow:(* frame frame 0.001))` | +| 022 | decelerate | `(wipe fade:orange-rainbow:(* (sqrt frame) 30))` | +| 023 | bounce | `(wipe fade:orange-rainbow:(abs (* (sin (* frame 0.03)) 360)))` | +| 024 | pendulum | `(wipe fade:orange-rainbow:(* (sin (* frame 0.02)) 180))` | +| 025 | pulse | `(wipe fade:orange-rainbow:(+ frame (* (sin (* frame 0.1)) 30)))` | +| 026 | jitter | `(wipe fade:orange-rainbow:(+ frame (* (sin (* frame 2)) 20)))` | +| 027 | reverse | `(wipe fade:orange-rainbow:(- 0 frame))` | +| 028 | chaos | `(wipe fade:orange-rainbow:(+ frame (* (sin (* frame 0.07)) (cos (* frame 0.13)) 200)))` | +| 029 | meditative | `(wipe fade:orange-rainbow:(* (sin (* frame 0.01)) 90))` | +| 030 | exp-sin | `(wipe fade:orange-rainbow:(* frame (sin (* frame 0.05))))` | +| 031 | double | `(wipe fade:orange-rainbow:(+ frame (* (sin (* frame 0.1)) (sin (* frame 0.03)) 100)))` | + +### yellow-rainbow (yellow-rainbow) + +| # | motion | source | +|---|---|---| +| 032 | spin | `(wipe fade:yellow-rainbow:frame)` | +| 033 | fast-spin | `(wipe fade:yellow-rainbow:(* frame 5))` | +| 034 | slow-spin | `(wipe fade:yellow-rainbow:(/ frame 3))` | +| 035 | swing | `(wipe fade:yellow-rainbow:(+ frame (* (sin (* frame 0.05)) 60)))` | +| 036 | wobble | `(wipe fade:yellow-rainbow:(+ frame (* (sin frame) 50)))` | +| 037 | accelerate | `(wipe fade:yellow-rainbow:(* frame frame 0.001))` | +| 038 | decelerate | `(wipe fade:yellow-rainbow:(* (sqrt frame) 30))` | +| 039 | bounce | `(wipe fade:yellow-rainbow:(abs (* (sin (* frame 0.03)) 360)))` | +| 040 | pendulum | `(wipe fade:yellow-rainbow:(* (sin (* frame 0.02)) 180))` | +| 041 | pulse | `(wipe fade:yellow-rainbow:(+ frame (* (sin (* frame 0.1)) 30)))` | +| 042 | jitter | `(wipe fade:yellow-rainbow:(+ frame (* (sin (* frame 2)) 20)))` | +| 043 | reverse | `(wipe fade:yellow-rainbow:(- 0 frame))` | +| 044 | chaos | `(wipe fade:yellow-rainbow:(+ frame (* (sin (* frame 0.07)) (cos (* frame 0.13)) 200)))` | +| 045 | meditative | `(wipe fade:yellow-rainbow:(* (sin (* frame 0.01)) 90))` | +| 046 | exp-sin | `(wipe fade:yellow-rainbow:(* frame (sin (* frame 0.05))))` | +| 047 | double | `(wipe fade:yellow-rainbow:(+ frame (* (sin (* frame 0.1)) (sin (* frame 0.03)) 100)))` | + +### green-rainbow (green-rainbow) + +| # | motion | source | +|---|---|---| +| 048 | spin | `(wipe fade:green-rainbow:frame)` | +| 049 | fast-spin | `(wipe fade:green-rainbow:(* frame 5))` | +| 050 | slow-spin | `(wipe fade:green-rainbow:(/ frame 3))` | +| 051 | swing | `(wipe fade:green-rainbow:(+ frame (* (sin (* frame 0.05)) 60)))` | +| 052 | wobble | `(wipe fade:green-rainbow:(+ frame (* (sin frame) 50)))` | +| 053 | accelerate | `(wipe fade:green-rainbow:(* frame frame 0.001))` | +| 054 | decelerate | `(wipe fade:green-rainbow:(* (sqrt frame) 30))` | +| 055 | bounce | `(wipe fade:green-rainbow:(abs (* (sin (* frame 0.03)) 360)))` | +| 056 | pendulum | `(wipe fade:green-rainbow:(* (sin (* frame 0.02)) 180))` | +| 057 | pulse | `(wipe fade:green-rainbow:(+ frame (* (sin (* frame 0.1)) 30)))` | +| 058 | jitter | `(wipe fade:green-rainbow:(+ frame (* (sin (* frame 2)) 20)))` | +| 059 | reverse | `(wipe fade:green-rainbow:(- 0 frame))` | +| 060 | chaos | `(wipe fade:green-rainbow:(+ frame (* (sin (* frame 0.07)) (cos (* frame 0.13)) 200)))` | +| 061 | meditative | `(wipe fade:green-rainbow:(* (sin (* frame 0.01)) 90))` | +| 062 | exp-sin | `(wipe fade:green-rainbow:(* frame (sin (* frame 0.05))))` | +| 063 | double | `(wipe fade:green-rainbow:(+ frame (* (sin (* frame 0.1)) (sin (* frame 0.03)) 100)))` | + +### red-band (black-red-black) + +| # | motion | source | +|---|---|---| +| 064 | spin | `(wipe fade:black-red-black:frame)` | +| 065 | fast-spin | `(wipe fade:black-red-black:(* frame 5))` | +| 066 | slow-spin | `(wipe fade:black-red-black:(/ frame 3))` | +| 067 | swing | `(wipe fade:black-red-black:(+ frame (* (sin (* frame 0.05)) 60)))` | +| 068 | wobble | `(wipe fade:black-red-black:(+ frame (* (sin frame) 50)))` | +| 069 | accelerate | `(wipe fade:black-red-black:(* frame frame 0.001))` | +| 070 | decelerate | `(wipe fade:black-red-black:(* (sqrt frame) 30))` | +| 071 | bounce | `(wipe fade:black-red-black:(abs (* (sin (* frame 0.03)) 360)))` | +| 072 | pendulum | `(wipe fade:black-red-black:(* (sin (* frame 0.02)) 180))` | +| 073 | pulse | `(wipe fade:black-red-black:(+ frame (* (sin (* frame 0.1)) 30)))` | +| 074 | jitter | `(wipe fade:black-red-black:(+ frame (* (sin (* frame 2)) 20)))` | +| 075 | reverse | `(wipe fade:black-red-black:(- 0 frame))` | +| 076 | chaos | `(wipe fade:black-red-black:(+ frame (* (sin (* frame 0.07)) (cos (* frame 0.13)) 200)))` | +| 077 | meditative | `(wipe fade:black-red-black:(* (sin (* frame 0.01)) 90))` | +| 078 | exp-sin | `(wipe fade:black-red-black:(* frame (sin (* frame 0.05))))` | +| 079 | double | `(wipe fade:black-red-black:(+ frame (* (sin (* frame 0.1)) (sin (* frame 0.03)) 100)))` | + +### orange-band (black-orange-black) + +| # | motion | source | +|---|---|---| +| 080 | spin | `(wipe fade:black-orange-black:frame)` | +| 081 | fast-spin | `(wipe fade:black-orange-black:(* frame 5))` | +| 082 | slow-spin | `(wipe fade:black-orange-black:(/ frame 3))` | +| 083 | swing | `(wipe fade:black-orange-black:(+ frame (* (sin (* frame 0.05)) 60)))` | +| 084 | wobble | `(wipe fade:black-orange-black:(+ frame (* (sin frame) 50)))` | +| 085 | accelerate | `(wipe fade:black-orange-black:(* frame frame 0.001))` | +| 086 | decelerate | `(wipe fade:black-orange-black:(* (sqrt frame) 30))` | +| 087 | bounce | `(wipe fade:black-orange-black:(abs (* (sin (* frame 0.03)) 360)))` | +| 088 | pendulum | `(wipe fade:black-orange-black:(* (sin (* frame 0.02)) 180))` | +| 089 | pulse | `(wipe fade:black-orange-black:(+ frame (* (sin (* frame 0.1)) 30)))` | +| 090 | jitter | `(wipe fade:black-orange-black:(+ frame (* (sin (* frame 2)) 20)))` | +| 091 | reverse | `(wipe fade:black-orange-black:(- 0 frame))` | +| 092 | chaos | `(wipe fade:black-orange-black:(+ frame (* (sin (* frame 0.07)) (cos (* frame 0.13)) 200)))` | +| 093 | meditative | `(wipe fade:black-orange-black:(* (sin (* frame 0.01)) 90))` | +| 094 | exp-sin | `(wipe fade:black-orange-black:(* frame (sin (* frame 0.05))))` | +| 095 | double | `(wipe fade:black-orange-black:(+ frame (* (sin (* frame 0.1)) (sin (* frame 0.03)) 100)))` | + +### yellow-band (black-yellow-black) + +| # | motion | source | +|---|---|---| +| 096 | spin | `(wipe fade:black-yellow-black:frame)` | +| 097 | fast-spin | `(wipe fade:black-yellow-black:(* frame 5))` | +| 098 | slow-spin | `(wipe fade:black-yellow-black:(/ frame 3))` | +| 099 | swing | `(wipe fade:black-yellow-black:(+ frame (* (sin (* frame 0.05)) 60)))` | +| 100 | wobble | `(wipe fade:black-yellow-black:(+ frame (* (sin frame) 50)))` | +| 101 | accelerate | `(wipe fade:black-yellow-black:(* frame frame 0.001))` | +| 102 | decelerate | `(wipe fade:black-yellow-black:(* (sqrt frame) 30))` | +| 103 | bounce | `(wipe fade:black-yellow-black:(abs (* (sin (* frame 0.03)) 360)))` | +| 104 | pendulum | `(wipe fade:black-yellow-black:(* (sin (* frame 0.02)) 180))` | +| 105 | pulse | `(wipe fade:black-yellow-black:(+ frame (* (sin (* frame 0.1)) 30)))` | +| 106 | jitter | `(wipe fade:black-yellow-black:(+ frame (* (sin (* frame 2)) 20)))` | +| 107 | reverse | `(wipe fade:black-yellow-black:(- 0 frame))` | +| 108 | chaos | `(wipe fade:black-yellow-black:(+ frame (* (sin (* frame 0.07)) (cos (* frame 0.13)) 200)))` | +| 109 | meditative | `(wipe fade:black-yellow-black:(* (sin (* frame 0.01)) 90))` | +| 110 | exp-sin | `(wipe fade:black-yellow-black:(* frame (sin (* frame 0.05))))` | +| 111 | double | `(wipe fade:black-yellow-black:(+ frame (* (sin (* frame 0.1)) (sin (* frame 0.03)) 100)))` | + +### green-band (black-green-black) + +| # | motion | source | +|---|---|---| +| 112 | spin | `(wipe fade:black-green-black:frame)` | +| 113 | fast-spin | `(wipe fade:black-green-black:(* frame 5))` | +| 114 | slow-spin | `(wipe fade:black-green-black:(/ frame 3))` | +| 115 | swing | `(wipe fade:black-green-black:(+ frame (* (sin (* frame 0.05)) 60)))` | +| 116 | wobble | `(wipe fade:black-green-black:(+ frame (* (sin frame) 50)))` | +| 117 | accelerate | `(wipe fade:black-green-black:(* frame frame 0.001))` | +| 118 | decelerate | `(wipe fade:black-green-black:(* (sqrt frame) 30))` | +| 119 | bounce | `(wipe fade:black-green-black:(abs (* (sin (* frame 0.03)) 360)))` | +| 120 | pendulum | `(wipe fade:black-green-black:(* (sin (* frame 0.02)) 180))` | +| 121 | pulse | `(wipe fade:black-green-black:(+ frame (* (sin (* frame 0.1)) 30)))` | +| 122 | jitter | `(wipe fade:black-green-black:(+ frame (* (sin (* frame 2)) 20)))` | +| 123 | reverse | `(wipe fade:black-green-black:(- 0 frame))` | +| 124 | chaos | `(wipe fade:black-green-black:(+ frame (* (sin (* frame 0.07)) (cos (* frame 0.13)) 200)))` | +| 125 | meditative | `(wipe fade:black-green-black:(* (sin (* frame 0.01)) 90))` | +| 126 | exp-sin | `(wipe fade:black-green-black:(* frame (sin (* frame 0.05))))` | +| 127 | double | `(wipe fade:black-green-black:(+ frame (* (sin (* frame 0.1)) (sin (* frame 0.03)) 100)))` | + +### blue-band (black-blue-black) + +| # | motion | source | +|---|---|---| +| 128 | spin | `(wipe fade:black-blue-black:frame)` | +| 129 | fast-spin | `(wipe fade:black-blue-black:(* frame 5))` | +| 130 | slow-spin | `(wipe fade:black-blue-black:(/ frame 3))` | +| 131 | swing | `(wipe fade:black-blue-black:(+ frame (* (sin (* frame 0.05)) 60)))` | +| 132 | wobble | `(wipe fade:black-blue-black:(+ frame (* (sin frame) 50)))` | +| 133 | accelerate | `(wipe fade:black-blue-black:(* frame frame 0.001))` | +| 134 | decelerate | `(wipe fade:black-blue-black:(* (sqrt frame) 30))` | +| 135 | bounce | `(wipe fade:black-blue-black:(abs (* (sin (* frame 0.03)) 360)))` | +| 136 | pendulum | `(wipe fade:black-blue-black:(* (sin (* frame 0.02)) 180))` | +| 137 | pulse | `(wipe fade:black-blue-black:(+ frame (* (sin (* frame 0.1)) 30)))` | +| 138 | jitter | `(wipe fade:black-blue-black:(+ frame (* (sin (* frame 2)) 20)))` | +| 139 | reverse | `(wipe fade:black-blue-black:(- 0 frame))` | +| 140 | chaos | `(wipe fade:black-blue-black:(+ frame (* (sin (* frame 0.07)) (cos (* frame 0.13)) 200)))` | +| 141 | meditative | `(wipe fade:black-blue-black:(* (sin (* frame 0.01)) 90))` | +| 142 | exp-sin | `(wipe fade:black-blue-black:(* frame (sin (* frame 0.05))))` | +| 143 | double | `(wipe fade:black-blue-black:(+ frame (* (sin (* frame 0.1)) (sin (* frame 0.03)) 100)))` | + +### indigo-band (black-indigo-black) + +| # | motion | source | +|---|---|---| +| 144 | spin | `(wipe fade:black-indigo-black:frame)` | +| 145 | fast-spin | `(wipe fade:black-indigo-black:(* frame 5))` | +| 146 | slow-spin | `(wipe fade:black-indigo-black:(/ frame 3))` | +| 147 | swing | `(wipe fade:black-indigo-black:(+ frame (* (sin (* frame 0.05)) 60)))` | +| 148 | wobble | `(wipe fade:black-indigo-black:(+ frame (* (sin frame) 50)))` | +| 149 | accelerate | `(wipe fade:black-indigo-black:(* frame frame 0.001))` | +| 150 | decelerate | `(wipe fade:black-indigo-black:(* (sqrt frame) 30))` | +| 151 | bounce | `(wipe fade:black-indigo-black:(abs (* (sin (* frame 0.03)) 360)))` | +| 152 | pendulum | `(wipe fade:black-indigo-black:(* (sin (* frame 0.02)) 180))` | +| 153 | pulse | `(wipe fade:black-indigo-black:(+ frame (* (sin (* frame 0.1)) 30)))` | +| 154 | jitter | `(wipe fade:black-indigo-black:(+ frame (* (sin (* frame 2)) 20)))` | +| 155 | reverse | `(wipe fade:black-indigo-black:(- 0 frame))` | +| 156 | chaos | `(wipe fade:black-indigo-black:(+ frame (* (sin (* frame 0.07)) (cos (* frame 0.13)) 200)))` | +| 157 | meditative | `(wipe fade:black-indigo-black:(* (sin (* frame 0.01)) 90))` | +| 158 | exp-sin | `(wipe fade:black-indigo-black:(* frame (sin (* frame 0.05))))` | +| 159 | double | `(wipe fade:black-indigo-black:(+ frame (* (sin (* frame 0.1)) (sin (* frame 0.03)) 100)))` | + +### violet-band (black-violet-black) + +| # | motion | source | +|---|---|---| +| 160 | spin | `(wipe fade:black-violet-black:frame)` | +| 161 | fast-spin | `(wipe fade:black-violet-black:(* frame 5))` | +| 162 | slow-spin | `(wipe fade:black-violet-black:(/ frame 3))` | +| 163 | swing | `(wipe fade:black-violet-black:(+ frame (* (sin (* frame 0.05)) 60)))` | +| 164 | wobble | `(wipe fade:black-violet-black:(+ frame (* (sin frame) 50)))` | +| 165 | accelerate | `(wipe fade:black-violet-black:(* frame frame 0.001))` | +| 166 | decelerate | `(wipe fade:black-violet-black:(* (sqrt frame) 30))` | +| 167 | bounce | `(wipe fade:black-violet-black:(abs (* (sin (* frame 0.03)) 360)))` | +| 168 | pendulum | `(wipe fade:black-violet-black:(* (sin (* frame 0.02)) 180))` | +| 169 | pulse | `(wipe fade:black-violet-black:(+ frame (* (sin (* frame 0.1)) 30)))` | +| 170 | jitter | `(wipe fade:black-violet-black:(+ frame (* (sin (* frame 2)) 20)))` | +| 171 | reverse | `(wipe fade:black-violet-black:(- 0 frame))` | +| 172 | chaos | `(wipe fade:black-violet-black:(+ frame (* (sin (* frame 0.07)) (cos (* frame 0.13)) 200)))` | +| 173 | meditative | `(wipe fade:black-violet-black:(* (sin (* frame 0.01)) 90))` | +| 174 | exp-sin | `(wipe fade:black-violet-black:(* frame (sin (* frame 0.05))))` | +| 175 | double | `(wipe fade:black-violet-black:(+ frame (* (sin (* frame 0.1)) (sin (* frame 0.03)) 100)))` | + +### sunset (black-orange-red-purple-black) + +| # | motion | source | +|---|---|---| +| 176 | spin | `(wipe fade:black-orange-red-purple-black:frame)` | +| 177 | fast-spin | `(wipe fade:black-orange-red-purple-black:(* frame 5))` | +| 178 | slow-spin | `(wipe fade:black-orange-red-purple-black:(/ frame 3))` | +| 179 | swing | `(wipe fade:black-orange-red-purple-black:(+ frame (* (sin (* frame 0.05)) 60)))` | +| 180 | wobble | `(wipe fade:black-orange-red-purple-black:(+ frame (* (sin frame) 50)))` | +| 181 | accelerate | `(wipe fade:black-orange-red-purple-black:(* frame frame 0.001))` | +| 182 | decelerate | `(wipe fade:black-orange-red-purple-black:(* (sqrt frame) 30))` | +| 183 | bounce | `(wipe fade:black-orange-red-purple-black:(abs (* (sin (* frame 0.03)) 360)))` | +| 184 | pendulum | `(wipe fade:black-orange-red-purple-black:(* (sin (* frame 0.02)) 180))` | +| 185 | pulse | `(wipe fade:black-orange-red-purple-black:(+ frame (* (sin (* frame 0.1)) 30)))` | +| 186 | jitter | `(wipe fade:black-orange-red-purple-black:(+ frame (* (sin (* frame 2)) 20)))` | +| 187 | reverse | `(wipe fade:black-orange-red-purple-black:(- 0 frame))` | +| 188 | chaos | `(wipe fade:black-orange-red-purple-black:(+ frame (* (sin (* frame 0.07)) (cos (* frame 0.13)) 200)))` | +| 189 | meditative | `(wipe fade:black-orange-red-purple-black:(* (sin (* frame 0.01)) 90))` | +| 190 | exp-sin | `(wipe fade:black-orange-red-purple-black:(* frame (sin (* frame 0.05))))` | +| 191 | double | `(wipe fade:black-orange-red-purple-black:(+ frame (* (sin (* frame 0.1)) (sin (* frame 0.03)) 100)))` | + +### ocean (navy-blue-cyan-teal-navy) + +| # | motion | source | +|---|---|---| +| 192 | spin | `(wipe fade:navy-blue-cyan-teal-navy:frame)` | +| 193 | fast-spin | `(wipe fade:navy-blue-cyan-teal-navy:(* frame 5))` | +| 194 | slow-spin | `(wipe fade:navy-blue-cyan-teal-navy:(/ frame 3))` | +| 195 | swing | `(wipe fade:navy-blue-cyan-teal-navy:(+ frame (* (sin (* frame 0.05)) 60)))` | +| 196 | wobble | `(wipe fade:navy-blue-cyan-teal-navy:(+ frame (* (sin frame) 50)))` | +| 197 | accelerate | `(wipe fade:navy-blue-cyan-teal-navy:(* frame frame 0.001))` | +| 198 | decelerate | `(wipe fade:navy-blue-cyan-teal-navy:(* (sqrt frame) 30))` | +| 199 | bounce | `(wipe fade:navy-blue-cyan-teal-navy:(abs (* (sin (* frame 0.03)) 360)))` | +| 200 | pendulum | `(wipe fade:navy-blue-cyan-teal-navy:(* (sin (* frame 0.02)) 180))` | +| 201 | pulse | `(wipe fade:navy-blue-cyan-teal-navy:(+ frame (* (sin (* frame 0.1)) 30)))` | +| 202 | jitter | `(wipe fade:navy-blue-cyan-teal-navy:(+ frame (* (sin (* frame 2)) 20)))` | +| 203 | reverse | `(wipe fade:navy-blue-cyan-teal-navy:(- 0 frame))` | +| 204 | chaos | `(wipe fade:navy-blue-cyan-teal-navy:(+ frame (* (sin (* frame 0.07)) (cos (* frame 0.13)) 200)))` | +| 205 | meditative | `(wipe fade:navy-blue-cyan-teal-navy:(* (sin (* frame 0.01)) 90))` | +| 206 | exp-sin | `(wipe fade:navy-blue-cyan-teal-navy:(* frame (sin (* frame 0.05))))` | +| 207 | double | `(wipe fade:navy-blue-cyan-teal-navy:(+ frame (* (sin (* frame 0.1)) (sin (* frame 0.03)) 100)))` | + +### fire (black-red-yellow-red-black) + +| # | motion | source | +|---|---|---| +| 208 | spin | `(wipe fade:black-red-yellow-red-black:frame)` | +| 209 | fast-spin | `(wipe fade:black-red-yellow-red-black:(* frame 5))` | +| 210 | slow-spin | `(wipe fade:black-red-yellow-red-black:(/ frame 3))` | +| 211 | swing | `(wipe fade:black-red-yellow-red-black:(+ frame (* (sin (* frame 0.05)) 60)))` | +| 212 | wobble | `(wipe fade:black-red-yellow-red-black:(+ frame (* (sin frame) 50)))` | +| 213 | accelerate | `(wipe fade:black-red-yellow-red-black:(* frame frame 0.001))` | +| 214 | decelerate | `(wipe fade:black-red-yellow-red-black:(* (sqrt frame) 30))` | +| 215 | bounce | `(wipe fade:black-red-yellow-red-black:(abs (* (sin (* frame 0.03)) 360)))` | +| 216 | pendulum | `(wipe fade:black-red-yellow-red-black:(* (sin (* frame 0.02)) 180))` | +| 217 | pulse | `(wipe fade:black-red-yellow-red-black:(+ frame (* (sin (* frame 0.1)) 30)))` | +| 218 | jitter | `(wipe fade:black-red-yellow-red-black:(+ frame (* (sin (* frame 2)) 20)))` | +| 219 | reverse | `(wipe fade:black-red-yellow-red-black:(- 0 frame))` | +| 220 | chaos | `(wipe fade:black-red-yellow-red-black:(+ frame (* (sin (* frame 0.07)) (cos (* frame 0.13)) 200)))` | +| 221 | meditative | `(wipe fade:black-red-yellow-red-black:(* (sin (* frame 0.01)) 90))` | +| 222 | exp-sin | `(wipe fade:black-red-yellow-red-black:(* frame (sin (* frame 0.05))))` | +| 223 | double | `(wipe fade:black-red-yellow-red-black:(+ frame (* (sin (* frame 0.1)) (sin (* frame 0.03)) 100)))` | + +### ice (white-cyan-blue-indigo-black) + +| # | motion | source | +|---|---|---| +| 224 | spin | `(wipe fade:white-cyan-blue-indigo-black:frame)` | +| 225 | fast-spin | `(wipe fade:white-cyan-blue-indigo-black:(* frame 5))` | +| 226 | slow-spin | `(wipe fade:white-cyan-blue-indigo-black:(/ frame 3))` | +| 227 | swing | `(wipe fade:white-cyan-blue-indigo-black:(+ frame (* (sin (* frame 0.05)) 60)))` | +| 228 | wobble | `(wipe fade:white-cyan-blue-indigo-black:(+ frame (* (sin frame) 50)))` | +| 229 | accelerate | `(wipe fade:white-cyan-blue-indigo-black:(* frame frame 0.001))` | +| 230 | decelerate | `(wipe fade:white-cyan-blue-indigo-black:(* (sqrt frame) 30))` | +| 231 | bounce | `(wipe fade:white-cyan-blue-indigo-black:(abs (* (sin (* frame 0.03)) 360)))` | +| 232 | pendulum | `(wipe fade:white-cyan-blue-indigo-black:(* (sin (* frame 0.02)) 180))` | +| 233 | pulse | `(wipe fade:white-cyan-blue-indigo-black:(+ frame (* (sin (* frame 0.1)) 30)))` | +| 234 | jitter | `(wipe fade:white-cyan-blue-indigo-black:(+ frame (* (sin (* frame 2)) 20)))` | +| 235 | reverse | `(wipe fade:white-cyan-blue-indigo-black:(- 0 frame))` | +| 236 | chaos | `(wipe fade:white-cyan-blue-indigo-black:(+ frame (* (sin (* frame 0.07)) (cos (* frame 0.13)) 200)))` | +| 237 | meditative | `(wipe fade:white-cyan-blue-indigo-black:(* (sin (* frame 0.01)) 90))` | +| 238 | exp-sin | `(wipe fade:white-cyan-blue-indigo-black:(* frame (sin (* frame 0.05))))` | +| 239 | double | `(wipe fade:white-cyan-blue-indigo-black:(+ frame (* (sin (* frame 0.1)) (sin (* frame 0.03)) 100)))` | + +### vapor (pink-cyan-purple-cyan-pink) + +| # | motion | source | +|---|---|---| +| 240 | spin | `(wipe fade:pink-cyan-purple-cyan-pink:frame)` | +| 241 | fast-spin | `(wipe fade:pink-cyan-purple-cyan-pink:(* frame 5))` | +| 242 | slow-spin | `(wipe fade:pink-cyan-purple-cyan-pink:(/ frame 3))` | +| 243 | swing | `(wipe fade:pink-cyan-purple-cyan-pink:(+ frame (* (sin (* frame 0.05)) 60)))` | +| 244 | wobble | `(wipe fade:pink-cyan-purple-cyan-pink:(+ frame (* (sin frame) 50)))` | +| 245 | accelerate | `(wipe fade:pink-cyan-purple-cyan-pink:(* frame frame 0.001))` | +| 246 | decelerate | `(wipe fade:pink-cyan-purple-cyan-pink:(* (sqrt frame) 30))` | +| 247 | bounce | `(wipe fade:pink-cyan-purple-cyan-pink:(abs (* (sin (* frame 0.03)) 360)))` | +| 248 | pendulum | `(wipe fade:pink-cyan-purple-cyan-pink:(* (sin (* frame 0.02)) 180))` | +| 249 | pulse | `(wipe fade:pink-cyan-purple-cyan-pink:(+ frame (* (sin (* frame 0.1)) 30)))` | +| 250 | jitter | `(wipe fade:pink-cyan-purple-cyan-pink:(+ frame (* (sin (* frame 2)) 20)))` | +| 251 | reverse | `(wipe fade:pink-cyan-purple-cyan-pink:(- 0 frame))` | +| 252 | chaos | `(wipe fade:pink-cyan-purple-cyan-pink:(+ frame (* (sin (* frame 0.07)) (cos (* frame 0.13)) 200)))` | +| 253 | meditative | `(wipe fade:pink-cyan-purple-cyan-pink:(* (sin (* frame 0.01)) 90))` | +| 254 | exp-sin | `(wipe fade:pink-cyan-purple-cyan-pink:(* frame (sin (* frame 0.05))))` | +| 255 | double | `(wipe fade:pink-cyan-purple-cyan-pink:(+ frame (* (sin (* frame 0.1)) (sin (* frame 0.03)) 100)))` | diff --git a/kidlisp/tools/preview-grid-anim.mjs b/kidlisp/tools/preview-grid-anim.mjs new file mode 100644 --- /dev/null +++ b/kidlisp/tools/preview-grid-anim.mjs @@ -0,0 +1,214 @@ +#!/usr/bin/env node +// preview-grid-anim.mjs — render N kidlisp sources as small animations, +// composite each timestep into a grid frame, encode as a single mp4. +// +// Workflow per cell: nav → wait for preloaded → screencast at native fps +// for `duration` ms → save frames as JPEGs. Then for each frame index k, +// composite all 256 cell-k frames into one grid frame. Encode grid frames +// as mp4 via ffmpeg. +// +// Usage: +// node kidlisp/tools/preview-grid-anim.mjs --sources /tmp/spinner-sources.txt \ +// --out ~/Desktop/spinners-anim.mp4 --cols 16 --cell 96x72 --duration 4000 --fps 15 + +import puppeteer from "puppeteer"; +import { readFileSync, mkdtempSync, mkdirSync, rmSync, existsSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawn } from "node:child_process"; +import sharp from "sharp"; + +const args = process.argv.slice(2); +const opts = {}; +for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (!a.startsWith("--")) continue; + const k = a.slice(2); + const next = args[i + 1]; + if (next === undefined || next.startsWith("--")) opts[k] = true; + else { opts[k] = next; i++; } +} + +const out = expandHome(opts.out || "~/Desktop/spinners-anim.mp4"); +const cols = Number(opts.cols || 16); +const [cw, ch] = (opts.cell || "96x72").split("x").map(Number); +const base = opts.base || "http://localhost:8888"; +const bootMs = Number(opts.boot || 1500); +const settleMs = Number(opts.settle || 500); +const duration = Number(opts.duration || 4000); +const fps = Number(opts.fps || 15); +const quality = Number(opts.quality || 85); +const start = Number(opts.start || 0); +const count = opts.count ? Number(opts.count) : Infinity; +const keep = !!opts.keep; + +let sourcesText; +if (opts.sources) sourcesText = readFileSync(expandHome(opts.sources), "utf8"); +else sourcesText = readFileSync(0, "utf8"); + +const all = sourcesText.split("\n").map(s => s.trim()).filter(s => s && !s.startsWith("#")); +const sources = count === Infinity ? all.slice(start) : all.slice(start, start + count); + +const rows = Math.ceil(sources.length / cols); +const pad = 2; +const gridW = (cw + pad) * cols + pad; +const gridH = (ch + pad) * rows + pad; +const frameCount = Math.round((duration / 1000) * fps); +const frameInterval = 1000 / fps; + +console.log(`grid: ${cols}×${rows}, cell ${cw}×${ch}, total ${gridW}×${gridH}`); +console.log(`capture: ${sources.length} cells × ${frameCount} frames @ ${fps}fps`); +console.log(`out: ${out}`); + +const work = mkdtempSync(join(tmpdir(), "spinner-grid-anim-")); +const cellsDir = join(work, "cells"); +mkdirSync(cellsDir, { recursive: true }); + +const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; +const launchOpts = { + headless: "new", + args: ["--no-sandbox", "--disable-setuid-sandbox", "--mute-audio"], +}; +if (existsSync(CHROME)) launchOpts.executablePath = CHROME; + +let browser = await puppeteer.launch(launchOpts); + +// Pass 1: capture per-cell frame sequences +const t0 = Date.now(); +for (let i = 0; i < sources.length; i++) { + const src = sources[i]; + const cellDir = join(cellsDir, String(i).padStart(4, "0")); + mkdirSync(cellDir, { recursive: true }); + + if (!browser.connected) { + try { await browser.close(); } catch {} + console.warn(` browser died; relaunching at cell ${i}...`); + browser = await puppeteer.launch(launchOpts); + } + + let page; + try { + page = await browser.newPage(); + await page.setViewport({ width: cw, height: ch, deviceScaleFactor: 1 }); + const encoded = encodeKidlispForUrl(src); + const url = `${base}/${encoded}?nogap=true&tv=true&density=1&nolabel=true`; + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); + try { + await page.waitForFunction(() => window.preloaded === true, { timeout: bootMs * 3 }); + } catch {} + await new Promise(r => setTimeout(r, settleMs)); + + // Screencast capture: single persistent CDP connection streams frames + // at AC's native paint rate (~60fps). everyNthFrame samples it down + // to the target output fps. This avoids hammering the dev server with + // 60+ individual screenshot requests per cell. + const client = await page.target().createCDPSession(); + const everyNth = Math.max(1, Math.round(60 / fps)); + let captured = 0; + const onFrame = ({ data, sessionId }) => { + if (captured >= frameCount) return; + const buf = Buffer.from(data, "base64"); + const fpath = join(cellDir, `f${String(captured).padStart(3, "0")}.jpg`); + writeFile(fpath, buf).catch(() => {}); + captured++; + client.send("Page.screencastFrameAck", { sessionId }).catch(() => {}); + }; + client.on("Page.screencastFrame", onFrame); + await client.send("Page.startScreencast", { + format: "jpeg", + quality: quality, + maxWidth: cw, + maxHeight: ch, + everyNthFrame: everyNth, + }); + await new Promise(r => setTimeout(r, duration)); + await client.send("Page.stopScreencast").catch(() => {}); + client.off("Page.screencastFrame", onFrame); + await client.detach().catch(() => {}); + } catch (err) { + console.warn(` cell ${i} failed: ${err.message.slice(0, 80)}`); + } finally { + if (page) { try { await page.close(); } catch {} } + } + + const elapsed = ((Date.now() - t0) / 1000).toFixed(0); + const eta = (((Date.now() - t0) / (i + 1)) * (sources.length - i - 1) / 1000).toFixed(0); + if (i % 8 === 0 || i === sources.length - 1) { + console.log(` [${i + 1}/${sources.length}] elapsed=${elapsed}s eta=${eta}s`); + } +} +try { await browser.close(); } catch {} + +// Pass 2: per-frame compositing +console.log("compositing frames..."); +const blackCell = await sharp({ + create: { width: cw, height: ch, channels: 3, background: { r: 0, g: 0, b: 0 } }, +}).jpeg().toBuffer(); + +const framesDir = join(work, "frames"); +mkdirSync(framesDir, { recursive: true }); + +for (let k = 0; k < frameCount; k++) { + const composites = []; + for (let i = 0; i < sources.length; i++) { + const cellDir = join(cellsDir, String(i).padStart(4, "0")); + const tilePath = join(cellDir, `f${String(k).padStart(3, "0")}.jpg`); + const tile = existsSync(tilePath) ? tilePath : blackCell; + const r = Math.floor(i / cols); + const c = i % cols; + const x = pad + c * (cw + pad); + const y = pad + r * (ch + pad); + composites.push({ input: tile, left: x, top: y }); + } + const framePath = join(framesDir, `g${String(k).padStart(4, "0")}.jpg`); + await sharp({ + create: { width: gridW, height: gridH, channels: 3, background: { r: 12, g: 12, b: 16 } }, + }) + .composite(composites) + .jpeg({ quality: quality }) + .toFile(framePath); + if (k % 10 === 0 || k === frameCount - 1) { + console.log(` frame ${k + 1}/${frameCount}`); + } +} + +// Pass 3: encode mp4 +console.log("encoding mp4..."); +const crf = Math.max(8, Math.min(32, Math.round(30 - quality * 0.2))); +await new Promise((resolve, reject) => { + const proc = spawn("ffmpeg", [ + "-y", + "-framerate", String(fps), + "-i", join(framesDir, "g%04d.jpg"), + "-c:v", "libx264", + "-pix_fmt", "yuv420p", + "-preset", "slow", + "-crf", String(crf), + "-tune", "animation", + "-movflags", "+faststart", + "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", + out, + ], { stdio: ["ignore", "ignore", "inherit"] }); + proc.on("error", reject); + proc.on("exit", c => c === 0 ? resolve() : reject(new Error(`ffmpeg exit ${c}`))); +}); + +const sizeKb = Math.round(statSync(out).size / 1024); +console.log(`wrote ${out} (${sizeKb} KB)`); + +if (!keep) rmSync(work, { recursive: true, force: true }); +else console.log(`kept work dir: ${work}`); + +function expandHome(p) { + return p.startsWith("~/") ? join(process.env.HOME, p.slice(2)) : p; +} + +function encodeKidlispForUrl(src) { + return src + .replace(/ /g, "_") + .replace(/\n/g, "§") + .replace(/%/g, "¤") + .replace(/;/g, "¨") + .replace(/#/g, "%23"); +} diff --git a/kidlisp/tools/preview-grid.mjs b/kidlisp/tools/preview-grid.mjs --- a/kidlisp/tools/preview-grid.mjs +++ b/kidlisp/tools/preview-grid.mjs @@ -52,11 +52,13 @@ } else { sourcesText = readFileSync(0, "utf8"); // stdin } -const sources = sourcesText +const allSources = sourcesText .split("\n") .map((s) => s.trim()) - .filter((s) => s.length > 0 && !s.startsWith("#")) - .slice(start, start + (count === Infinity ? undefined : count)); + .filter((s) => s.length > 0 && !s.startsWith("#")); +const sources = count === Infinity + ? allSources.slice(start) + : allSources.slice(start, start + count); console.log(`rendering ${sources.length} cells at ${cw}x${ch}, ${cols} cols`); @@ -72,46 +74,61 @@ headless: "new", args: ["--no-sandbox", "--disable-setuid-sandbox", "--mute-audio"], }; if (existsSync(CHROME)) launchOpts.executablePath = CHROME; -const browser = await puppeteer.launch(launchOpts); + +let browser = await puppeteer.launch(launchOpts); +let browserDisconnects = 0; +browser.on("disconnected", () => { + browserDisconnects++; +}); const tiles = []; // { buf, index, source } const t0 = Date.now(); +const blackTile = await sharp({ + create: { width: cw, height: ch, channels: 3, background: { r: 0, g: 0, b: 0 } }, +}).png().toBuffer(); -try { - for (let i = 0; i < sources.length; i++) { - const src = sources[i]; - const page = await browser.newPage(); +for (let i = 0; i < sources.length; i++) { + const src = sources[i]; + // Ensure browser is alive — relaunch if it died + if (!browser.connected || browserDisconnects > 0) { + try { await browser.close(); } catch {} + console.warn(` browser disconnected (${browserDisconnects}); relaunching...`); + browser = await puppeteer.launch(launchOpts); + browserDisconnects = 0; + browser.on("disconnected", () => { browserDisconnects++; }); + } + + let page; + let buf = null; + try { + page = await browser.newPage(); + await page.setViewport({ width: cw, height: ch, deviceScaleFactor: 1 }); + const encoded = encodeKidlispForUrl(src); + const url = `${base}/${encoded}?nogap=true&tv=true&density=1&nolabel=true`; + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); try { - await page.setViewport({ width: cw, height: ch, deviceScaleFactor: 1 }); - const encoded = encodeKidlispForUrl(src); - const url = `${base}/${encoded}?nogap=true&tv=true&density=1&nolabel=true`; - await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); - // Wait for AC's preloaded flag - try { - await page.waitForFunction(() => window.preloaded === true, { timeout: bootMs * 3 }); - } catch {} - await new Promise((r) => setTimeout(r, settleMs)); - const buf = await page.screenshot({ type: "png" }); - tiles.push({ buf, index: start + i, source: src }); - const elapsed = ((Date.now() - t0) / 1000).toFixed(1); - const eta = (((Date.now() - t0) / (i + 1)) * (sources.length - i - 1) / 1000).toFixed(0); - if (i % 8 === 0 || i === sources.length - 1) { - console.log(` [${i + 1}/${sources.length}] elapsed=${elapsed}s eta=${eta}s`); - } - } catch (err) { - console.warn(` cell ${i} failed: ${err.message}`); - // Use a black tile as placeholder - const blackBuf = await sharp({ - create: { width: cw, height: ch, channels: 3, background: { r: 0, g: 0, b: 0 } }, - }).png().toBuffer(); - tiles.push({ buf: blackBuf, index: start + i, source: src }); - } finally { - await page.close(); + await page.waitForFunction(() => window.preloaded === true, { timeout: bootMs * 3 }); + } catch {} + await new Promise((r) => setTimeout(r, settleMs)); + buf = await page.screenshot({ type: "png" }); + } catch (err) { + console.warn(` cell ${i} failed: ${err.message.slice(0, 100)}`); + } finally { + if (page) { + try { await page.close(); } catch {} } } -} finally { - await browser.close(); + + tiles.push({ buf: buf || blackTile, index: start + i, source: src }); + + const elapsed = ((Date.now() - t0) / 1000).toFixed(1); + const eta = (((Date.now() - t0) / (i + 1)) * (sources.length - i - 1) / 1000).toFixed(0); + if (i % 8 === 0 || i === sources.length - 1) { + console.log(` [${i + 1}/${sources.length}] elapsed=${elapsed}s eta=${eta}s`); + } } + +try { await browser.close(); } catch {} console.log("compositing grid..."); diff --git a/pop/big-pictures/bin/bake-vowels.mjs b/pop/big-pictures/bin/bake-vowels.mjs new file mode 100644 --- /dev/null +++ b/pop/big-pictures/bin/bake-vowels.mjs @@ -0,0 +1,198 @@ +#!/usr/bin/env node +// bake-vowels.mjs — full "vowel extraction" dance track. Renders the +// loukeman-style 4/4 dance bed (kick / sub / perc / pads) via the C +// engine, then mixes the pure-vowel drone (pop/big-pictures/out/ +// amazing-vowels.wav, built by extract-vowels.mjs) over it — with the +// whole vowel pad SIDECHAINED to the bed's kick so it dips with every +// kick "intelligently and magically" (and stops pumping for free during +// the break/outro where the kick drops out). +// +// bed ──asplit──┬─────────────────────────────[bedMain]──┐ +// └─[bedKey]──► sidechaincompress key ├─amix─► master +// vowels ──reverb wash──► sidechaincompress (ducked) ──────┘ +// +// Output: ~/Documents/Shelf/amazing-grace-dance/ +// amazing-grace-vowels.mp3 + -MASTER.wav +// +// Usage: +// node pop/big-pictures/bin/bake-vowels.mjs [outDir] \ +// [--no-rebuild-bed] [--duck 6] [--vox 0.95] [--bed 0.9] [--reverb 0.22] + +import { execSync, spawnSync } from "node:child_process"; +import { existsSync, statSync, mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(HERE, "../../.."); +const POP = resolve(REPO, "pop"); +const C = resolve(POP, "big-pictures/c"); + +const argv = process.argv.slice(2); +function flag(name, def) { + const i = argv.indexOf(`--${name}`); + return i >= 0 && argv[i + 1] ? argv[i + 1] : def; +} +const outDir = argv.find((a) => !a.startsWith("--") && + argv[argv.indexOf(a) - 1]?.startsWith("--") !== true) || + `${homedir()}/Documents/Shelf/amazing-grace-dance`; +mkdirSync(outDir, { recursive: true }); + +const REBUILD_BED = !argv.includes("--no-rebuild-bed"); +const DUCK_RATIO = flag("duck", "6"); // sidechain ratio (higher = deeper pump) +const VOX_GAIN = flag("vox", "0.7"); +const BED_GAIN = flag("bed", "0.9"); +const REVERB = parseFloat(flag("reverb", "0.22")); +const VOX_FADEIN = parseFloat(flag("vox-fadein", "15")); // slow swell into the track +const VOX_DELAY = parseFloat(flag("vox-delay", "1.5")); // true silence before the swell +const DRIVE = flag("drive", "0.30"); // engine pre-tanh soft-clip (kills maxing) +const MONO_KICK = !argv.includes("--haas-kick"); // mono-solid low end (no HAAS phase smear) +const PEAK_DB = parseFloat(flag("peak", "-1.0")); // master true-peak ceiling +const MONO_BASS_HZ = parseFloat(flag("mono-bass", "120")); // fold bed lows below this to mono (0 = off) +const BED_ONLY = argv.includes("--bed-only"); // master just the bed, no vocals + +const ENGINE = `${C}/amazing-dance`; +const ENGINE_C = `${C}/amazing-dance.c`; +const BUILD = `${C}/build-dance.sh`; +const BED_WAV = `${outDir}/.amazing-vowels-bed.wav`; +const VOX_WAV = resolve(POP, "big-pictures/out/amazing-vowels.wav"); +const FINAL_WAV = `${outDir}/amazing-grace-vowels-MASTER.wav`; +const FINAL_MP3 = `${outDir}/amazing-grace-vowels.mp3`; + +function run(cmd, args, label) { + process.stdout.write(`→ ${label}...\n`); + const r = spawnSync(cmd, args, { stdio: "inherit" }); + if (r.status !== 0) { console.error(`✗ ${label} failed`); process.exit(1); } +} +function dur(p) { + return parseFloat(execSync( + `ffprobe -v error -show_entries format=duration -of csv=p=0 "${p}"` + ).toString().trim()); +} + +if (!existsSync(VOX_WAV)) { + console.error(`✗ vowel stem missing: ${VOX_WAV}\n build it first: node pop/big-pictures/bin/extract-vowels.mjs`); + process.exit(1); +} + +// 1 — build engine if stale +const needsBuild = !existsSync(ENGINE) || + statSync(ENGINE_C).mtimeMs > statSync(ENGINE).mtimeMs; +if (needsBuild) run("bash", [BUILD], "build C engine"); + +// 2 — render bed (rebuild unless told not to). Clean-bed flags: gentle +// soft-clip drive + mono kick (no HAAS phase smear). +if (REBUILD_BED || !existsSync(BED_WAV)) { + const bedArgs = ["--out", BED_WAV, "--drive", DRIVE]; + if (MONO_KICK) bedArgs.push("--mono-kick"); + run(ENGINE, bedArgs, `render dance bed (drive ${DRIVE}${MONO_KICK ? ", mono-kick" : ""})`); +} else { + console.log(`→ reusing bed ${BED_WAV}`); +} + +const bedDur = dur(BED_WAV); +const voxDur = dur(VOX_WAV); +const total = Math.max(bedDur, voxDur); +console.log(`\n bed ${bedDur.toFixed(1)}s · vowels ${voxDur.toFixed(1)}s · master ${total.toFixed(1)}s`); +console.log(` duck ratio ${DUCK_RATIO} · vox ${VOX_GAIN} · bed ${BED_GAIN} · reverb ${REVERB}\n`); + +// 3 — mix: sidechain the vowel pad to the bed's kick, then sum +// • bedKey = the bed itself; its kick is the dominant transient, so the +// pad pumps on kicks and relaxes through the break/outro automatically. +// • light stereo reverb wash on the vowels before ducking. +const reverbStage = REVERB > 0 + ? `aecho=0.85:0.9:90|150:${(REVERB).toFixed(2)}|${(REVERB * 0.7).toFixed(2)},` + : ""; +// Vowel chain: reverb wash → level → EXP swell (slow start, stays quiet +// early) → adelay so the very top of the track is TRUE silence. The swell +// is applied BEFORE the delay, so it begins after the silent pad. +const voxDelayMs = Math.round(VOX_DELAY * 1000); +const PRE_WAV = `${outDir}/.amazing-vowels-premix.wav`; +// Bed conditioning: fold everything below MONO_BASS_HZ to mono so the +// low end is phase-coherent (club-safe), keeping the highs stereo-wide. +// bed-only doesn't need a sidechain key, so don't split off [bedkey] +// (an unconnected filter pad would make ffmpeg error). +// A bare label ([bedmain]) attaches with no comma; a filter (asplit) +// attaches after a comma. +const bedTail = BED_ONLY ? `[bedmain]` : `,asplit=2[bedmain][bedkey]`; +const bedCond = MONO_BASS_HZ > 0 + ? `[0:a]aresample=48000,aformat=channel_layouts=stereo,asplit=2[blo][bhi];` + + `[blo]lowpass=f=${MONO_BASS_HZ},` + + `pan=stereo|c0=0.5*c0+0.5*c1|c1=0.5*c0+0.5*c1[blom];` + + `[bhi]highpass=f=${MONO_BASS_HZ}[bhim];` + + `[blom][bhim]amix=inputs=2:normalize=0${bedTail}` + : `[0:a]aresample=48000,aformat=channel_layouts=stereo${bedTail}`; +const filter = BED_ONLY + ? [bedCond, `[bedmain]volume=${BED_GAIN},atrim=duration=${total.toFixed(3)}[out]`].join(";") + : [ + bedCond, + `[1:a]aresample=48000,aformat=channel_layouts=stereo,${reverbStage}` + + `volume=${VOX_GAIN},afade=t=in:st=0:d=${VOX_FADEIN.toFixed(2)}:curve=exp,` + + `adelay=${voxDelayMs}|${voxDelayMs}[voxwet]`, + `[voxwet][bedkey]sidechaincompress=threshold=0.04:ratio=${DUCK_RATIO}:` + + `attack=5:release=240:makeup=2:level_sc=0.8[voxducked]`, + `[bedmain]volume=${BED_GAIN}[bedlvl]`, + `[bedlvl][voxducked]amix=inputs=2:duration=longest:dropout_transition=0:` + + `normalize=0,atrim=duration=${total.toFixed(3)}[out]`, + ].join(";"); + +console.log(`→ mixing + sidechaining vowels to the kick (premix, no normalization)...`); +execSync( + `ffmpeg -y -loglevel error -i "${BED_WAV}" -i "${VOX_WAV}" ` + + `-filter_complex "${filter}" -map "[out]" ` + + `-ar 48000 -ac 2 -c:a pcm_s16le "${PRE_WAV}"`, + { stdio: ["ignore", "ignore", "inherit"] } +); + +// 3b — FX SAMPLE LAYERS (CC0, cached in the vault): a pitched-down +// chainsaw grind landing on the ~1:25 build, and a tornado-wind wash +// through the break. Folded into the premix before mastering. +let masterSrc = PRE_WAV; +const FX_ENABLED = !argv.includes("--no-fx"); +const CACHE = `${homedir()}/aesthetic-computer-vault/personal/pop/freesound-cache`; +const sawSample = `${CACHE}/463731-chainsaw_cut_loop.wav`; +const windSample = `${CACHE}/251637-insideatornado_mp3.wav`; +if (FX_ENABLED && existsSync(sawSample) && existsSync(windSample)) { + const FX_WAV = `${outDir}/.amazing-vowels-fx.wav`; + const fxF = [ + // pitched-DOWN chainsaw grind (varispeed ×0.6) at ~1:24 (84 s), ~12 s + `[0:a]atrim=0:12,asetrate=48000*0.6,aresample=48000,aformat=channel_layouts=stereo,` + + `volume=0.40,afade=t=in:st=0:d=1.5,afade=t=out:st=9:d=2.5,adelay=84000|84000[saw]`, + // tornado wind wash through the break (~96 s), ~34 s, heavy reverb + `[1:a]atrim=0:34,aformat=channel_layouts=stereo,volume=0.20,` + + `aecho=0.8:0.85:140|240:0.35|0.25,afade=t=in:st=0:d=4,afade=t=out:st=30:d=4,` + + `adelay=96000|96000[wind]`, + `[saw][wind]amix=inputs=2:duration=longest:normalize=0,apad,` + + `atrim=duration=${total.toFixed(3)}[fx]`, + ].join(";"); + execSync(`ffmpeg -y -loglevel error -i "${sawSample}" -i "${windSample}" ` + + `-filter_complex "${fxF}" -map "[fx]" -ar 48000 -ac 2 -c:a pcm_s16le "${FX_WAV}"`); + const COMBINED = `${outDir}/.amazing-vowels-premix-fx.wav`; + execSync(`ffmpeg -y -loglevel error -i "${PRE_WAV}" -i "${FX_WAV}" ` + + `-filter_complex "[0:a][1:a]amix=inputs=2:duration=longest:normalize=0[m]" ` + + `-map "[m]" -ar 48000 -ac 2 -c:a pcm_s16le "${COMBINED}"`); + masterSrc = COMBINED; + console.log(`→ fx layered: pitched chainsaw @ 1:24 + tornado wind in the break`); +} + +// Master with a FADE-PRESERVING static gain (dynamic loudnorm was lifting +// the quiet intro and undoing the swell). Measure the premix peak, apply a +// single makeup gain to hit the true-peak ceiling, and an alimiter to catch +// stray transients — no time-varying gain, so the silent start stays silent. +const detect = execSync( + `ffmpeg -hide_banner -i "${masterSrc}" -af volumedetect -f null - 2>&1 | grep max_volume` +).toString(); +const peakDb = parseFloat(detect.match(/max_volume:\s*(-?[\d.]+)/)?.[1] ?? "0"); +const makeupDb = (PEAK_DB - peakDb).toFixed(2); +console.log(`→ master: premix peak ${peakDb}dB → +${makeupDb}dB makeup → ${PEAK_DB}dB ceiling (static, fade-safe)`); +execSync( + `ffmpeg -y -loglevel error -i "${masterSrc}" ` + + `-af "volume=${makeupDb}dB,alimiter=limit=${PEAK_DB}dB:level=false" ` + + `-ar 48000 -ac 2 -c:a pcm_s16le "${FINAL_WAV}"` +); +execSync(`ffmpeg -y -loglevel error -i "${FINAL_WAV}" -b:a 320k "${FINAL_MP3}"`); + +console.log(`\n✓ ${FINAL_WAV.replace(homedir(), "~")} (${dur(FINAL_WAV).toFixed(1)}s)`); +console.log(`✓ ${FINAL_MP3.replace(homedir(), "~")}`); +console.log(`\n open -a "QuickTime Player" "${FINAL_MP3}"`); diff --git a/pop/big-pictures/bin/extract-vowels.mjs b/pop/big-pictures/bin/extract-vowels.mjs new file mode 100644 --- /dev/null +++ b/pop/big-pictures/bin/extract-vowels.mjs @@ -0,0 +1,265 @@ +#!/usr/bin/env node +// extract-vowels.mjs — turn the Amazing Grace dance vocal into a pure +// VOWEL-EXTRACTION drone. No words, no consonants: just the open vowel +// nucleus of every sung syllable, stretched over long distances and +// stacked so the vowels are always rolling through each other in +// harmony. Less a cover of the hymn, more an extraction of its vowels. +// +// Concept (per @jeffrey, 2026-06-01): +// • ONLY the vowels — strip the consonant onsets/codas from each take. +// • Stretch each vowel WAY out (the "first ahh" feeling) — many seconds. +// • Heavy overlap so multiple held vowels always sound together; the +// G-pentatonic melody harmonizes itself, plus a continuous low drone +// floor (root + fifth) so it's NEVER silent and always in harmony. +// +// Sustain method (robust — the old single 32-48× rubberband stretch +// disintegrated extreme holds into near-silence; the destructive +// silenceremove also ate the soft "see" take down to 22 ms → 24 s of +// silence at the end): +// 1. Grain = MIDDLE window of the RAW take (skips leading + closing +// consonant). No silenceremove. Min-length guard widens the window +// if a take is short, so a grain is always a real vowel. +// 2. WORLD f0-lock the grain to the target pitch (steady tone). +// 3. aloop the pitched grain to ~hold/5, then rubberband ≤~5× to the +// exact hold. Looping-then-moderate-stretch keeps a constant level +// and can't break up. +// 4. Long afade in/out so holds bleed together; loudnorm per voice. +// +// Reads: +// • wave-wizard/samples/amazing-grace-dance/spec.json (note order/midi) +// • pop/big-pictures/voice-takes/wavewizard/-note-.wav +// Writes: +// • pop/big-pictures/out/amazing-vowels.wav (the drone stem) +// • pop/big-pictures/out/amazing-vowels-preview.mp3 (quick listen) +// +// Usage: +// node pop/big-pictures/bin/extract-vowels.mjs \ +// [--hold-per-beat 8] [--stride-per-beat 4] [--harmony 0,-12] \ +// [--drone-gain 0.18] [--crisp 2] [--no-cache] + +import { execSync } from "node:child_process"; +import { readFileSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(HERE, "../../.."); +const POP = resolve(REPO, "pop"); +const SPEC = resolve(REPO, "wave-wizard/samples/amazing-grace-dance/spec.json"); +const TAKES_DIR = resolve(POP, "big-pictures/voice-takes/wavewizard"); +const OUT = resolve(POP, "big-pictures/out/amazing-vowels.wav"); +const OUT_MP3 = OUT.replace(/\.wav$/, "-preview.mp3"); +const TMP = "/tmp/amazing-vowels"; +mkdirSync(TMP, { recursive: true }); +mkdirSync(dirname(OUT), { recursive: true }); + +// ── args ──────────────────────────────────────────────────────────── +const argv = process.argv.slice(2); +function flag(name, def) { + const i = argv.indexOf(`--${name}`); + return i >= 0 && argv[i + 1] ? argv[i + 1] : def; +} +const HOLD_PER_BEAT = parseFloat(flag("hold-per-beat", "8")); // s of sustain per durBeat +const STRIDE_PER_BEAT = parseFloat(flag("stride-per-beat", "4")); // s between onsets per durBeat +const CRISP = flag("crisp", "2"); // rubberband smoothness (low = smooth) +const HARMONY = flag("harmony", "0,-12").split(",").map((s) => parseInt(s, 10)); +const DRONE_GAIN = parseFloat(flag("drone-gain", "0.18")); // continuous floor level (0 = off) +const NO_CACHE = argv.includes("--no-cache"); +const CORE_START = parseFloat(flag("core-start", "0.30")); +const CORE_END = parseFloat(flag("core-end", "0.68")); +const STRETCH_CAP = 5.0; // max rubberband ratio (loop first to stay under) + +// ── tooling ─────────────────────────────────────────────────────────── +const WORLD_PY = resolve(POP, ".venv/bin/python"); +const PITCHSNAP = resolve(POP, "bin/pitchsnap_world.py"); +const USE_WORLD = existsSync(WORLD_PY) && existsSync(PITCHSNAP); +function midiToName(m) { + const NAMES = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"]; + return NAMES[((m % 12) + 12) % 12] + (Math.floor(m / 12) - 1); +} +function dur(path) { + return parseFloat(execSync( + `ffprobe -v error -show_entries format=duration -of csv=p=0 "${path}"` + ).toString().trim()); +} +function sh(cmd) { execSync(cmd, { stdio: ["ignore", "ignore", "pipe"] }); } + +// ── vowel map (documentation only; audio is extracted from real takes) ── +const VOWELS = { + "line-1": ["ah","ah","ih","eh","ah","ee","uh","ah"], // a ma zing grace how sweet the sound + "line-2": ["ah","ay","uh","eh","ah","ee"], // that saved a wretch like me + "line-3": ["ah","uh","uh","aw","uh","ah","ah","ah"], // i once was lost but now am found + "line-4": ["uh","ah","uh","ah","ah","ee"], // was blind but now i see +}; + +if (!existsSync(SPEC)) { console.error(`✗ spec not found: ${SPEC}`); process.exit(1); } +const spec = JSON.parse(readFileSync(SPEC, "utf8")); + +// ── grain: middle window of the RAW take, with a min-length guard ─────── +function makeGrain(takePath, id) { + const grain = `${TMP}/${id}-grain.wav`; + if (!NO_CACHE && existsSync(grain)) return grain; + const d = dur(takePath); + let a = CORE_START, b = CORE_END; + // Guard: never let the window collapse below 0.30 s of source. + if (d * (b - a) < 0.30) { a = 0.20; b = 0.80; } + const ss = (d * a).toFixed(3); + const len = Math.max(0.30, d * (b - a)).toFixed(3); + sh(`ffmpeg -y -loglevel error -ss ${ss} -t ${len} -i "${takePath}" ` + + `-ar 48000 -ac 1 "${grain}"`); + return grain; +} + +// ── sustain: WORLD-lock → aloop → moderate rubberband → envelope ──────── +// Returns a holdSec-long stereo drone locked to targetMidi. Cached by key. +function sustain(grainPath, targetMidi, holdSec, fadeSec, outPath) { + if (!NO_CACHE && existsSync(outPath)) return outPath; + const targetName = midiToName(targetMidi); + const pitched = outPath.replace(/\.wav$/, "-p.wav"); + const looped = outPath.replace(/\.wav$/, "-l.wav"); + const stretched = outPath.replace(/\.wav$/, "-s.wav"); + + // 1) WORLD pitch lock + let src = grainPath; + if (USE_WORLD) { + try { + sh(`"${WORLD_PY}" "${PITCHSNAP}" "${grainPath}" "${pitched}" ` + + `--notes "${targetName}" --xfade-ms 0 --voicing-ramp-ms 20`); + src = pitched; + } catch (e) { + console.error(` WORLD failed @ ${targetName}: ${(e.stderr||"").toString().split("\n")[0]}`); + } + } + const grainDur = dur(src); + + // 2) loop the grain up to ~hold/CAP so the final stretch ratio is ≤ CAP + const loopTargetDur = Math.max(grainDur, holdSec / STRETCH_CAP); + const loops = Math.max(0, Math.ceil(loopTargetDur / grainDur) - 1); + const samples = Math.round(grainDur * 48000); + if (loops > 0) { + sh(`ffmpeg -y -loglevel error -i "${src}" ` + + `-af "aloop=loop=${loops}:size=${samples},atrim=duration=${loopTargetDur.toFixed(3)}" ` + + `-ar 48000 -ac 1 "${looped}"`); + } else { + execSync(`cp "${src}" "${looped}"`); + } + const loopedDur = dur(looped); + + // 3) moderate rubberband stretch to the exact hold + const ratio = (holdSec / loopedDur).toFixed(3); + try { + sh(`rubberband --time ${ratio} --formant --crisp ${CRISP} ` + + `"${looped}" "${stretched}" 2>/dev/null`); + } catch (e) { + execSync(`cp "${looped}" "${stretched}"`); + } + + // 4) envelope + per-voice loudnorm → stereo + const fOut = (holdSec - fadeSec).toFixed(2); + sh(`ffmpeg -y -loglevel error -i "${stretched}" ` + + `-af "afade=t=in:st=0:d=${fadeSec.toFixed(2)},` + + `afade=t=out:st=${fOut}:d=${fadeSec.toFixed(2)},` + + `loudnorm=I=-20:TP=-2:LRA=6,aformat=channel_layouts=stereo" ` + + `-ar 48000 -ac 2 -c:a pcm_s16le "${outPath}"`); + return outPath; +} + +// ── walk the spec → note list with stretched timing ───────────────────── +const notes = []; +let cumBeats = 0; +for (const sample of spec.samples) { + if (!sample.score) continue; + for (let i = 0; i < sample.score.notes.length; i++) { + const n = sample.score.notes[i]; + const takePath = `${TAKES_DIR}/${sample.name}-note-${i}.wav`; + notes.push({ + idx: notes.length, + sampleName: sample.name, + noteIdx: i, + onsetSec: cumBeats * STRIDE_PER_BEAT, + holdSec: n.beats * HOLD_PER_BEAT, + targetMidi: sample.score.rootMel + n.off, + vowel: (VOWELS[sample.name] || [])[i] || "ah", + takePath: existsSync(takePath) ? takePath : null, + }); + cumBeats += n.beats; + } +} +const TOTAL = notes.length + ? Math.max(...notes.map((n) => n.onsetSec + n.holdSec)) + 1 + : 0; +console.log( + `vowel extraction: ${notes.length} syllables · onset span ` + + `${(cumBeats * STRIDE_PER_BEAT).toFixed(1)}s · total ${TOTAL.toFixed(1)}s\n` + + ` hold/beat ${HOLD_PER_BEAT}s · stride/beat ${STRIDE_PER_BEAT}s · ` + + `harmony [${HARMONY.join(", ")}] · drone ${DRONE_GAIN}\n` +); + +// ── render melodic vowel voices ───────────────────────────────────────── +const stems = []; +let made = 0, skipped = 0; +for (const note of notes) { + if (!note.takePath) { skipped++; continue; } + const id = note.idx.toString().padStart(2, "0"); + const grain = makeGrain(note.takePath, id); + const fade = Math.min(note.holdSec * 0.45, 4); + for (const interval of HARMONY) { + const hid = `${id}_${interval >= 0 ? "p" : "m"}${Math.abs(interval)}`; + const droned = `${TMP}/${hid}-drone.wav`; + sustain(grain, note.targetMidi + interval, note.holdSec, fade, droned); + stems.push({ path: droned, onsetSec: note.onsetSec, gain: interval < 0 ? 0.7 : 1.0 }); + } + made++; + process.stdout.write( + ` ${id} ${note.sampleName}#${note.noteIdx} ` + + `${note.vowel.padEnd(2)} → ${midiToName(note.targetMidi).padEnd(3)} ` + + `hold ${note.holdSec.toFixed(1)}s @ ${note.onsetSec.toFixed(1)}s\n` + ); +} + +// ── continuous low harmonic drone floor (root + fifth) ────────────────── +// Built from the very first open "ah" grain, held the whole length at low +// level. Guarantees the track is never silent and always sits in harmony. +if (DRONE_GAIN > 0) { + const floorGrain = makeGrain(notes[0].takePath, "00"); + const FLOOR = [43, 50]; // G2 root + D3 fifth (G pentatonic) + for (const midi of FLOOR) { + const fid = `floor-${midi}`; + const droned = `${TMP}/${fid}.wav`; + sustain(floorGrain, midi, TOTAL, 8, droned); + stems.push({ path: droned, onsetSec: 0, gain: DRONE_GAIN }); + process.stdout.write(` floor ${midiToName(midi)} hold ${TOTAL.toFixed(0)}s @ 0s (gain ${DRONE_GAIN})\n`); + } +} +console.log(`\n vowels made: ${made} · skipped: ${skipped} · voices: ${stems.length}\n`); +if (!stems.length) { console.error(`✗ no stems — check takes at ${TAKES_DIR}`); process.exit(1); } + +// ── stitch: adelay + volume per voice → amix → loudnorm ───────────────── +const args = ["-y", "-loglevel", "error"]; +for (const s of stems) args.push("-i", s.path); +const filters = []; +for (let k = 0; k < stems.length; k++) { + const ms = Math.round(stems[k].onsetSec * 1000); + filters.push( + `[${k}:a]aresample=48000,aformat=channel_layouts=stereo,` + + `volume=${stems[k].gain},adelay=${ms}|${ms}[d${k}]` + ); +} +filters.push( + stems.map((_, k) => `[d${k}]`).join("") + + `amix=inputs=${stems.length}:duration=longest:dropout_transition=0:normalize=0,` + + `atrim=duration=${TOTAL.toFixed(3)}[out]` +); +args.push("-filter_complex", filters.join(";"), "-map", "[out]", + "-ar", "48000", "-ac", "2", "-c:a", "pcm_s16le", `${TMP}/mix.wav`); +console.log(`→ mixing ${stems.length} vowel voices over ${TOTAL.toFixed(1)}s...`); +execSync(`ffmpeg ${args.map((a) => `"${a}"`).join(" ")}`); + +console.log(`→ loudnorm + write stem...`); +sh(`ffmpeg -y -loglevel error -i "${TMP}/mix.wav" ` + + `-af "loudnorm=I=-14:TP=-1.5:LRA=7" ` + + `-ar 48000 -ac 2 -c:a pcm_s16le "${OUT}"`); +sh(`ffmpeg -y -loglevel error -i "${OUT}" -b:a 256k "${OUT_MP3}"`); + +console.log(`\n✓ ${OUT.replace(POP + "/", "pop/")} (${dur(OUT).toFixed(1)}s)`); +console.log(`✓ ${OUT_MP3.replace(POP + "/", "pop/")} ← quick listen`); diff --git a/pop/big-pictures/c/amazing-dance.c b/pop/big-pictures/c/amazing-dance.c --- a/pop/big-pictures/c/amazing-dance.c +++ b/pop/big-pictures/c/amazing-dance.c @@ -44,6 +44,31 @@ static const int SR = 48000; static double BPM = 120.0; static double TOTAL_SEC = 300.0; static const char *OUT_PATH = NULL; +// --drive : pre-tanh scale. 1.0 (default) keeps the original hard +// tanh "limiter" used by the released cover. The vowel bake passes a +// small value (~0.30) so the master sums sit in tanh's gentle knee — +// soft saturation instead of brick-wall clipping (kills the "maxing +// out"/flat-topping). --mono-kick disables the 3 ms HAAS kick pair that +// smears the low end's phase; the bed's bass then folds cleanly to mono. +static double DRIVE = 1.0; +static int MONO_KICK = 0; +// --seed : seeds the per-render variation (chord progression, hihat +// freakout, bell melodies). 0 (default) seeds from the wall clock, so +// every render is different; pass a fixed value to reproduce a take. +static unsigned SEED_ARG = 0; +static uint32_t RNG_STATE = 0x9e3779b9u; +static void rng_seed(uint32_t s) { RNG_STATE = s ? s : 0x9e3779b9u; } +static uint32_t rng_u32(void) { + uint32_t x = RNG_STATE; + x ^= x << 13; x ^= x >> 17; x ^= x << 5; + RNG_STATE = x; return x; +} +static double rng_d(void) { return (double)rng_u32() / 4294967296.0; } +// inclusive integer range [lo, hi] +static int rng_range(int lo, int hi) { + if (hi <= lo) return lo; + return lo + (int)(rng_d() * (hi - lo + 1)); +} // ── shared mix buffers ──────────────────────────────────────────────── static long N = 0; @@ -481,7 +506,26 @@ L[i] += sample * (1.0 - 0.5 * pan); R[i] += sample * (1.0 + 0.5 * pan); } } +// Hi-hats are silent for the first HAT_IN_START seconds, then fade in +// over the next (HAT_IN_END - HAT_IN_START) (@jeffrey "no hihats for the +// first 5 seconds, then fade them in"). Applies to every hat globally; +// a no-op once past the fade. +#define HAT_IN_START 4.0 +#define HAT_IN_END 20.0 +// The ~2:00-2:14 rainstorm (accelerando→ritardando droplets) owns this +// window; steady offbeat hats step aside so it reads cleanly. +#define HAT_SLOW_START 124.0 +#define HAT_SLOW_END 130.5 +static double hat_in_gate(double t) { + if (t < HAT_IN_START) return 0.0; + if (t >= HAT_IN_END) return 1.0; + const double x = (t - HAT_IN_START) / (HAT_IN_END - HAT_IN_START); + return x * x * x; // cubic ease-in: very subtle at first, gradual swell +} static void hat_render(double t0, HatOpts opt) { + const double gate = hat_in_gate(t0); + if (gate <= 0.0) return; // muted intro window + opt.gain *= gate; const double decay = opt.decay_s > 0 ? opt.decay_s : 0.028; const double bright = opt.brightness > 0 ? opt.brightness : 0.95; const double gap = (opt.double_delay_ms > 0 ? opt.double_delay_ms : 4.0) / 1000.0; @@ -493,6 +537,71 @@ hat_burst(t0 + gap, opt.pan, opt.gain * 0.65, decay * 0.85, bright * 0.92, (uint32_t)(t0 * 8087.0 + 17)); } +// ── AC-native perc kit hits (ported from lib/percussion.mjs) ───────── +// A single decaying voice: sine / triangle / square / lowpassed-noise +// with attack + exponential decay. woodblock / cowbell / tambo stack a +// few of these, matching the menuband 12-drum kit specs. +enum PercWave { PW_SINE, PW_TRI, PW_SQR, PW_NOISE }; +static void perc_hit(double t0, int wave, double tone, double dur, + double vol, double atk, double dec, double pan, double wet) { + long iStart = (long)(t0 * SR); + long iEnd = (long)((t0 + dur) * SR); + if (iStart < 0) iStart = 0; + if (iEnd > N) iEnd = N; + double phase = 0.0; + const double dP = tone / (double)SR; + uint32_t s = (uint32_t)(t0 * 99877.0) | 1; + double lp = 0.0; + const double lpc = exp(-TAU * tone / (double)SR); // crude noise colour + const double a = atk > 0 ? atk : 1e-6; + for (long i = iStart; i < iEnd; i++) { + const double t = (i - iStart) / (double)SR; + const double env = (t < a) ? (t / a) : exp(-(t - a) / dec); + if (t > a + 0.0005 && env < 1e-5) break; + double osc; + if (wave == PW_NOISE) { + s ^= s << 13; s ^= s >> 17; s ^= s << 5; + const double w = ((double)s / 4294967296.0) * 2.0 - 1.0; + lp = lp * lpc + (1.0 - lpc) * w; + osc = lp * 2.0; + } else { + phase += dP; if (phase >= 1.0) phase -= 1.0; + if (wave == PW_SINE) osc = sin(TAU * phase); + else if (wave == PW_TRI) osc = 2.0 * fabs(2.0 * (phase - floor(phase + 0.5))) - 1.0; + else osc = (phase < 0.5) ? 1.0 : -1.0; // square + } + const double smp = osc * env * vol; + const double sL = smp * (1.0 - 0.5 * pan); + const double sR = smp * (1.0 + 0.5 * pan); + L[i] += sL; R[i] += sR; + WL[i] += sL * wet; WR[i] += sR * wet; + } +} +// pf = pitch factor (1.0 = stock); the placement passes wild values so the +// hits land on weird, characterful pitches. +static void woodblock(double t0, double v, double pan, double pf) { // g# — wood block + perc_hit(t0, PW_NOISE, 5000 * pf, 0.002, 0.35 * v, 0.0001, 0.0018, pan, 0.0); + perc_hit(t0, PW_TRI, 2500 * pf, 0.060, 0.52 * v, 0.0003, 0.048, pan, 0.12); + perc_hit(t0, PW_TRI, 1250 * pf, 0.060, 0.18 * v, 0.0005, 0.048, pan, 0.12); +} +static void cowbell(double t0, double v, double pan, double pf) { // f# — TR-808 cowbell + perc_hit(t0, PW_SQR, 1800 * pf, 0.004, 0.35 * v, 0.0002, 0.0038, pan, 0.0); + perc_hit(t0, PW_TRI, 800 * pf, 0.300, 0.42 * v, 0.0008, 0.275, pan, 0.15); + perc_hit(t0, PW_TRI, 540 * pf, 0.300, 0.36 * v, 0.0008, 0.275, pan, 0.15); +} +static void tambo(double t0, double v, double pan, double pf) { // a# — tambourine + perc_hit(t0, PW_NOISE, 7000 * pf, 0.080, 0.38 * v, 0.002, 0.075, pan, 0.18); + perc_hit(t0, PW_NOISE, 6500 * pf, 0.100, 0.25 * v, 0.030, 0.070, pan, 0.18); + perc_hit(t0, PW_SQR, 6000 * pf, 0.030, 0.14 * v, 0.001, 0.028, pan, 0.10); +} +// TR-808 snare — two tuned body tones (~180 + ~330 Hz) + a snappy noise +// burst with its own longer decay. The 808 "tssh". +static void snare808(double t0, double v, double pan) { + perc_hit(t0, PW_SINE, 180, 0.20, 0.55 * v, 0.0005, 0.17, pan, 0.10); + perc_hit(t0, PW_SINE, 330, 0.14, 0.32 * v, 0.0005, 0.11, pan, 0.10); + perc_hit(t0, PW_NOISE, 3800, 0.22, 0.70 * v, 0.0004, 0.18, pan, 0.14); +} + // ── snare voice (filtered noise burst + low tone — the "BIZZ") ─────── typedef struct { double pan; @@ -791,31 +900,85 @@ double decay_s; double pan; double gain; double wet_send; + int sidechain; // 1 = duck to the backbeat (clap/"snare" on beats 2 & 4) + double atk; // attack seconds (0 = default 0.002) + double vib_rate; // vibrato Hz (0 = default ~5) + double vib_depth; // vibrato depth as fraction of pitch (0 = default wobble) } GlockOpts; +// Backbeat sidechain gain at absolute time t: dips hard on beats 2 & 4 +// (the clap that plays the snare role) and recovers — so ducked bells +// pump to the snare, not the kick. +static double backbeat_duck(double t) { + const double spb = 60.0 / BPM; + const double barlen = 4.0 * spb; + const double pos = fmod(t, barlen); + const double trigs[2] = { 1.0 * spb, 3.0 * spb }; // beats 2 and 4 + const double depth = 0.85, rel = 0.30; + double g = 1.0; + for (int z = 0; z < 2; z++) { + double dt = pos - trigs[z]; + if (dt < 0) dt += barlen; // wrap: a trigger always precedes + const double gz = 1.0 - depth * exp(-dt / rel); + if (gz < g) g = gz; + } + return g; // 0..1, dips toward 0 right on the backbeat +} static void glock_render(double t0, double dur, double midi, GlockOpts opt) { const double f = m2f(midi); const double decay = opt.decay_s > 0 ? opt.decay_s : 1.5; const double pan = opt.pan; const double wetSend = opt.wet_send; + const double atk = opt.atk > 0 ? opt.atk : 0.002; + // WUB — a tempo-synced LFO. Like a dubstep wobble (resonant LP cutoff + // swept by a synced LFO), but on an additive bell the equivalent is to + // sweep the upper partials (the 2.76× and 5.40× sines = "brightness") + // — a filter wobble — plus a gentle pitch vibrato. Default rate is a + // 1/8-note "wub-wub" locked to the track tempo (@jeffrey "wub wub"). + const double vibRate = opt.vib_rate > 0 ? opt.vib_rate : (BPM / 60.0) * 2.0; + const double vibDepth = opt.vib_depth > 0 ? opt.vib_depth : 0.010; double phs1 = 0, phs2 = 0, phs3 = 0; + double vphase = 0.0; // vibrato LFO phase (accumulated; rate varies over time) long iStart = (long)(t0 * SR); long iEnd = (long)((t0 + dur) * SR); if (iStart < 0) iStart = 0; if (iEnd > N) iEnd = N; for (long i = iStart; i < iEnd; i++) { const double t = (i - iStart) / (double)SR; - double env = (t < 0.002) ? (t / 0.002) : exp(-(t - 0.002) / decay); + double env = (t < atk) ? (t / atk) : exp(-(t - atk) / decay); if (t > 0.005 && env < 1e-6) break; - const double dP1 = f / (double)SR; - const double dP2 = (f * 2.76) / (double)SR; - const double dP3 = (f * 5.40) / (double)SR; + const double ta = (double)i / (double)SR; + const double tRel = (dur > 0) ? (t / dur) : 0.0; // 0 at the hit → 1 at the tail + // backbeat sidechain envelope (1 open, →0 on the snare) + const double g = opt.sidechain ? backbeat_duck(ta) : 1.0; + // VIBRATO grows toward the tail (not at the hit), and the sidechain + // bends it MORE — wobblier, "like the walls are bending" (@jeffrey). + const double sc = 1.0 - g; // 0 open, →1 on backbeat + double vRate = vibRate * (0.40 + 1.60 * tRel) * (1.0 + sc * 0.7); + double vDepth = vibDepth * (0.15 + 1.50 * tRel) * (1.0 + sc * 1.3); + vphase += vRate / (double)SR; if (vphase >= 1.0) vphase -= 1.0; + const double lfo = sin(TAU * vphase); + const double wob = 0.5 + 0.5 * lfo; // 0..1 brightness sweep + const double vib = 1.0 + vDepth * lfo; // pitch wobble + const double dP1 = f * vib / (double)SR; + const double dP2 = (f * 2.76 * vib) / (double)SR; + const double dP3 = (f * 5.40 * vib) / (double)SR; phs1 += dP1; if (phs1 >= 1.0) phs1 -= 1.0; phs2 += dP2; if (phs2 >= 1.0) phs2 -= 1.0; phs3 += dP3; if (phs3 >= 1.0) phs3 -= 1.0; - const double sample = env * opt.gain * - (0.80 * sin(TAU * phs1) + - 0.40 * sin(TAU * phs2) + - 0.20 * sin(TAU * phs3)); + // WUB filter wobble: sweep the upper partials with the LFO. + double a1 = 0.80, a2 = 0.40 * (0.30 + 0.70 * wob), a3 = 0.20 * (0.06 + 0.94 * wob); + // Backbeat sidechain — mostly a FILTER duck (the bell goes darker on + // the snare) plus a gentle amplitude dip, not just a pumpy gate. + double ampDuck = 1.0; + if (opt.sidechain) { + ampDuck = 0.60 + 0.40 * g; // gentle: dips to ~0.6 + a2 *= 0.25 + 0.75 * g; // 2nd partial dims when ducked + a3 *= 0.05 + 0.95 * g; // brightest dims most → lowpass feel + } + const double sample = ampDuck * env * opt.gain * + (a1 * sin(TAU * phs1) + + a2 * sin(TAU * phs2) + + a3 * sin(TAU * phs3)); const double sL = sample * (1.0 - 0.5 * pan); const double sR = sample * (1.0 + 0.5 * pan); L[i] += sL; @@ -888,13 +1051,39 @@ int bass; int root, third, fifth; const char *name; } Chord; -// G major progression — slow 4-bar changes. -static const Chord PROG[4] = { - { 31, 43, 47, 50, "G" }, // I — G2 bass, G2 B2 D3 triad (one octave below hymn pad) - { 36, 48, 52, 55, "C" }, // IV — C2 bass, C3 E3 G3 - { 31, 43, 47, 50, "G" }, // I - { 26, 38, 42, 45, "D" }, // V — D2 bass, D2 F#2 A2 (low for sub feel) +// G major progression — slow 4-bar changes. Populated per-render by +// init_prog() from the diatonic palette below, so the bells, powersaw, +// pads, organ and stabs (all of which read chord_for_bar→PROG) pitch to +// a fresh progression every playback. +static Chord PROG[4] = { + { 31, 43, 47, 50, "G" }, // defaults (overwritten by init_prog) + { 36, 48, 52, 55, "C" }, + { 31, 43, 47, 50, "G" }, + { 26, 38, 42, 45, "D" }, }; +// Diatonic chords of G major (each: bass, root, third, fifth triad). +static const Chord PALETTE[] = { + { 31, 43, 47, 50, "G" }, // I + { 33, 45, 48, 52, "Am" }, // ii A C E + { 35, 47, 50, 54, "Bm" }, // iii B D F# + { 36, 48, 52, 55, "C" }, // IV C E G + { 26, 38, 42, 45, "D" }, // V D F# A (low for sub feel) + { 28, 40, 43, 47, "Em" }, // vi E G B +}; +// Build a fresh 4-bar progression. Slot 0 anchors the tonic (G) so the +// loop still feels grounded; slots 1-3 pick diatonic chords with no +// immediate repeats. +static void init_prog(void) { + const int NP = (int)(sizeof(PALETTE) / sizeof(PALETTE[0])); + PROG[0] = PALETTE[0]; // tonic anchor + int prev = 0; + for (int i = 1; i < 4; i++) { + int pick; + do { pick = rng_range(0, NP - 1); } while (pick == prev); + PROG[i] = PALETTE[pick]; + prev = pick; + } +} // ── helpers for the dance pattern ──────────────────────────────────── // SECTIONS — return which chord index applies for a given bar position. @@ -973,7 +1162,10 @@ // ── 1.5 · TRIANGLE LOW + SINE BELL HIGH (vocal harmony backing) ── // Delayed to bar 12 (t=24 s) along with chord stab + powersaw — // before bar 12 the build stays gentle, just bed + drums + bells. - for (int bar = 12; bar < 64; bar += 4) { + // No big sine bells before the 32 s WOOP — they enter at bar 16 + // with the powersaws (@jeffrey "first bells at 32 s, don't play any of + // the big sine church bells before that"). + for (int bar = 16; bar < 64; bar += 4) { if (bar >= TOTAL_BARS) break; const Chord *c = chord_for_bar(bar); const double t0 = bar * BAR; @@ -982,7 +1174,7 @@ // LOW triangle — pitch narrative just like the high bells: // root → third → fifth → root as the 4-bar cycles progress // (@jeffrey 2026-05-29 "lower pitched sine bells too should // change over time"). - const int lo_variant = ((bar - 12) / 4) % 4; + const int lo_variant = ((bar - 16) / 4) % 4; int lo_a, lo_b; switch (lo_variant) { case 0: lo_a = c->root - 12; lo_b = c->fifth - 12; break; @@ -996,31 +1188,29 @@ .pan = 0.0, .gain = 0.10, .wet_send = 0.32 }); triangle_render(t0, dur, lo_b, (TriOpts){ .atk = 0.50, .rel = 0.70, .pan = -0.15, .gain = 0.06, .wet_send = 0.32 }); - // HIGH sine bells — pitch narrative across 4 chord cycles - // (@jeffrey 2026-05-29 "sine bells too repetitive / change - // pitch over time"). Each 4-bar block picks different chord - // tones so the sparkle moves around the chord, not just - // root+fifth on every cycle. - const int variant = ((bar - 12) / 4) % 4; - int hi1_off, hi2_off, hi3_off; - switch (variant) { - case 0: hi1_off = c->fifth + 12; hi2_off = c->root + 24; hi3_off = c->third + 19; break; - case 1: hi1_off = c->third + 12; hi2_off = c->fifth + 24; hi3_off = c->root + 19; break; - case 2: hi1_off = c->root + 12; hi2_off = c->third + 24; hi3_off = c->fifth + 19; break; - default:hi1_off = c->fifth + 12; hi2_off = c->third + 24; hi3_off = c->root + 24; break; - } - // Volumes pulled down ~40% (@jeffrey 2026-05-29 "sine bells - // lower volume"). 0.16 → 0.10, 0.12 → 0.07, 0.10 → 0.06. - glock_render(t0, 3.5, hi1_off, - (GlockOpts){ .decay_s = 3.0, .pan = +0.25, - .gain = 0.10, .wet_send = 0.55 }); - glock_render(t0 + 1.0, 3.0, hi2_off, - (GlockOpts){ .decay_s = 2.5, .pan = -0.20, - .gain = 0.07, .wet_send = 0.55 }); - if (variant == 1 || variant == 3) { - glock_render(t0 + 2.2, 2.0, hi3_off, - (GlockOpts){ .decay_s = 2.0, .pan = +0.10, - .gain = 0.06, .wet_send = 0.50 }); + // HIGH sine bells — SEEDED melodic sparkle. Each 4-bar block + // draws a fresh set of 2-4 bells from a 3-octave pool of chord + // tones, with varied pitch, timing, decay, gain and pan, so the + // sparkle never settles into a 4-cycle loop (@jeffrey "bells need + // more variation still"). Differs every render. + // kept in the mid octave — the +19/+24 tiers were too high/cringy. + const int hipool[] = { c->root, c->third, c->fifth, + c->root + 12, c->third + 12, c->fifth + 12 }; + const int nhi = (int)(sizeof(hipool) / sizeof(hipool[0])); + // Spread further apart (1 per 4-bar block, ~25% of blocks skipped) + // with MUCH longer decays, quiet, ducked to the backbeat snare. + const int nbells = (rng_d() < 0.25) ? 0 : 1; + for (int b = 0; b < nbells; b++) { + const int off = hipool[rng_range(0, nhi - 1)]; + // onset QUANTIZED to a beat (kick position) — bells align to + // the kick grid, nothing off-beat (@jeffrey). + const double at = t0 + rng_range(0, 15) * (BAR / 4.0) + humanize(t0 + b, 16, 4); + const double dec = 9.0 + rng_d() * 9.0; // 9-18 s — much longer tails + const double pan = (rng_d() - 0.5) * 0.6; + const double gain = 0.035 + rng_d() * 0.030; // quiet + glock_render(at, dec + 1.0, off, + (GlockOpts){ .decay_s = dec, .pan = pan, + .gain = gain, .wet_send = 0.60, .sidechain = 1 }); } } report(" triangle low + sine bell high harmony backing (bars 4..63)"); @@ -1079,9 +1269,13 @@ const double t0 = bar * BAR; // First 3 bars (0..6 s) = soft church-bell intro (no kit), then // the beat enters from bar 3 onwards. Break drops kick at the // bars 48..63 mid-track break, outro drops it at bar 104+. - const int sec_intro = bar < 3; + // No empty intro — the kick drives from the FIRST measure + // (@jeffrey "sounds weird with no kick"). The intro instead opens + // under a lowpass filter sweep (see write_wav), so it starts dark + // and low and brightens by ~32 s. + const int sec_intro = 0; const int sec_break = bar >= 48 && bar < 64; - const int sec_outro = bar >= 104; + const int sec_outro = bar >= TOTAL_BARS - 2; // full groove to the end const int kick_on = !sec_intro && !sec_break && !sec_outro; const int clap_on = !sec_intro && !sec_break && !sec_outro; const int hat_on = !sec_intro; @@ -1148,28 +1342,42 @@ for (int beat = 0; beat < 4; beat++) { // Lazy-human kick: ±8 ms with +3 ms positive bias. // Downbeats stay tight (less wiggle). const double tHit = t0 + beat * SPB + - humanize(t0 + beat, beat == 0 ? 4 : 8, 3); - if (phase < 0.6) { + humanize(t0 + beat, beat == 0 ? 7 : 11, 3); + // SUBTLE per-kick pitch variation — every kick wobbles a + // little (±~4%) so the low end breathes (@jeffrey "kick + // pitch variably on every kick, just in a subtle way"). + uint32_t kr = (uint32_t)((bar * 4 + beat) * 374761393u) | 1; + kr ^= kr << 13; kr ^= kr >> 17; kr ^= kr << 5; + const double kpf = 1.0 + ((double)kr / 4294967296.0 - 0.5) * 0.08; + const double kphi2 = kphi * kpf; + const double kplo2 = kplo * kpf; + if (phase < 0.6 && !MONO_KICK) { // HAAS pair widens the kick image at low energy const double haasPan = 0.35 * (1.0 - phase / 0.6); const double sideGain = kgain * 0.55; const double centerGain = kgain * 0.85; kick_render(tHit, (KickOpts){ .pan = -haasPan, .gain = sideGain, - .pitch_hi = kphi, .pitch_lo = kplo, + .pitch_hi = kphi2, .pitch_lo = kplo2, .pitch_dur = kpdur, .decay_s = kdec }); kick_render(tHit + 0.003, (KickOpts){ .pan = +haasPan, .gain = sideGain, - .pitch_hi = kphi, .pitch_lo = kplo, + .pitch_hi = kphi2, .pitch_lo = kplo2, .pitch_dur = kpdur, .decay_s = kdec }); kick_render(tHit, (KickOpts){ .pan = 0.0, .gain = centerGain, - .pitch_hi = kphi, .pitch_lo = kplo, + .pitch_hi = kphi2, .pitch_lo = kplo2, + .pitch_dur = kpdur, .decay_s = kdec }); + } else if (phase < 0.6) { + // MONO_KICK: single dead-center kick — no HAAS phase smear. + kick_render(tHit, + (KickOpts){ .pan = 0.0, .gain = kgain * 0.95, + .pitch_hi = kphi2, .pitch_lo = kplo2, .pitch_dur = kpdur, .decay_s = kdec }); } else { kick_render(tHit, (KickOpts){ .pan = 0.0, .gain = kgain, - .pitch_hi = kphi, .pitch_lo = kplo, + .pitch_hi = kphi2, .pitch_lo = kplo2, .pitch_dur = kpdur, .decay_s = kdec }); } } @@ -1177,7 +1385,7 @@ // REVERSE KICK — SPRINKLED, never earlier than ~30 s // (@jeffrey 2026-05-29). Picks 6 strategic bar boundaries // through the rest of the track instead of every 4 bars. // Each is a 2-beat rising sweep building into the next bar. - const int rev_bars[] = { 15, 23, 39, 47, 79, 95 }; + const int rev_bars[] = { 23, 39, 47, 79, 95 }; // bar 15 → replaced by the 8 s build const int n_rev = sizeof(rev_bars) / sizeof(rev_bars[0]); for (int q = 0; q < n_rev; q++) { if (bar == rev_bars[q]) { @@ -1199,23 +1407,41 @@ // Real ocean recordings (CC0) layered in via bake-c-dance.mjs: // okeechobee-surf as continuous wave bed + seagulls-distant- // surf as sparser atmospheric overlay. if (hat_on) { - // Open hat (double) on offbeats. Lazy-human: ±12 ms with - // +5 ms late bias. Each hat is internally a doubled burst - // (two layered hits 4 ms apart — the AC-native sound). - for (int beat = 0; beat < 4; beat++) { - const double base = t0 + (beat + 0.5) * SPB; - const double th = base + humanize(base, 12, 5); - // Occasionally widen the double-delay for variety — - // ~25% chance of a 12 ms flam instead of the tight 4 ms. - uint32_t r = (uint32_t)(base * 10007.0) | 1; + // ON-GRID EXPRESSIVE HATS — a 16th-note ride locked to the + // grid (NOTHING off-beat/swung). Each bar seeds a fresh pattern: + // 8ths almost always play, 16th ghosts come and go, the + // occasional 16th drops out — so it keeps "changing around but + // stays regular, playing and expressive" (@jeffrey). Beats are + // accented; a darker foot "chick" lands on 2 & 4. Only a few ms + // of humanize for feel — still on the beat. + const double g16 = SPB * 0.25; // 16th grid step + for (int s = 0; s < 16; s++) { + const double tg = t0 + s * g16; + if (tg >= HAT_SLOW_START && tg < HAT_SLOW_END) continue; + const int onBeat = (s % 4) == 0; // quarter = kick + const int onEighth = (s % 2) == 0; + // seeded hit probability per (bar, step) + uint32_t r = (uint32_t)((bar * 64 + s) * 2654435761u) | 1; r ^= r << 13; r ^= r >> 17; r ^= r << 5; - const int flam = (r & 0x3) == 0; // ~25% probability - // CLICKY: decay 0.045 → 0.022 ("too reverby" — @jeffrey 2026-05-29) + const double rr = (double)r / 4294967296.0; + const double prob = onBeat ? 0.96 : (onEighth ? 0.90 : 0.30); + if (rr > prob) continue; // dropout = variation + uint32_t vr = r * 2246822519u + 1; vr ^= vr << 13; vr ^= vr >> 17; + const double vrand = (double)vr / 4294967296.0; + const double accent = onBeat ? 1.0 : (onEighth ? 0.80 : 0.50); + const double vel = 0.16 * accent * (0.60 + 0.55 * vrand); + const double th = tg + humanize(tg, 15, 4); // loose, laid-back feel hat_render(th, - (HatOpts){ .pan = (beat % 2 == 0 ? -0.18 : +0.18), - .gain = 0.18, .decay_s = 0.022, - .brightness = 0.96, - .double_delay_ms = flam ? 6.0 : 3.0 }); + (HatOpts){ .pan = (s % 2 == 0 ? -0.16 : +0.16), + .gain = vel, .decay_s = 0.018 + 0.012 * vrand, + .brightness = 0.86 + 0.12 * vrand, + .double_delay_ms = 3.0 }); + // foot hi-hat "chick" on 2 & 4 (s=4, s=12) — darker + if (s == 4 || s == 12) { + hat_render(tg + humanize(tg, 12, 4), + (HatOpts){ .pan = 0.0, .gain = 0.13, .decay_s = 0.030, + .brightness = 0.45, .double_delay_ms = 5.0 }); + } } // HI-HAT RUSHES — only at bars 44 and 76 now. The bar-12 // rush was landing exactly when MAY enters (@jeffrey flagged @@ -1259,6 +1485,59 @@ } } report(" drums patterned (beat enters at bar 3 after the bell intro)"); + // ── 2.6 · SINGLE COWBELL ───────────────────────────────────────── + // Just one lone cowbell on the downbeat at ~24 s — no other perc + // sprinkles for now (@jeffrey "just a single cowbell around 24 seconds, + // on a beat, remove all the other ones"). + cowbell(12 * BAR, 0.22, -0.2, pow(2.0, 7.0 / 12.0)); + // The cowbell kicks off a LONG dramatic build — an 8 s rising sweep + // ("eoeooooo") from 24 s climbing into the 32 s WOOP/drop, so there's + // real tension there (@jeffrey "make it a much longer build, more + // drama"). Quadratic envelope = quiet at first, swelling to the peak. + reverse_kick_render(12 * BAR, + (ReverseKickOpts){ .pan = 0.0, .gain = 2.1, + .pitch_lo = 42.0, .pitch_hi = 280.0, + .dur = 4.0 * BAR }); // 24 s → 32 s + report(" single cowbell @ 24 s + 8 s build into the WOOP"); + + // ── 2.7 · WOODBLOCK RATTLE MELODY ~1:15 ────────────────────────── + // A fast, rattly woodblock riff (open-hat-ish) over ~2 bars from + // ~1:15 — on the 16th grid, seeded gaps, pitched through a little + // pentatonic melody, with a soft open hat shadowing it (@jeffrey). + { + const double SPB = 60.0 / BPM; + const double g16 = SPB * 0.25; + const int sem[] = { 0, 2, 4, 7, 9, 12, 14, 16 }; // G-pentatonic degrees + const int nsem = (int)(sizeof(sem) / sizeof(sem[0])); + for (int s = 0; s < 32; s++) { // 2 bars of 16ths + const double t = 37 * BAR + s * g16; // bar 37 ≈ 1:14 + if (rng_d() < 0.40) continue; // rattly gaps + const double pf = pow(2.0, sem[rng_range(0, nsem - 1)] / 12.0) * 1.4; + const int onB = (s % 4) == 0; + woodblock(t, (onB ? 0.18 : 0.11) + rng_d() * 0.05, (rng_d() - 0.5) * 0.9, pf); + // soft open-hat shimmer shadowing the rattle + if (rng_d() < 0.5) + hat_render(t, (HatOpts){ .pan = (rng_d() - 0.5) * 0.8, + .gain = 0.05 + rng_d() * 0.04, .decay_s = 0.06 + rng_d() * 0.10, + .brightness = 0.80, .double_delay_ms = 4.0 }); + } + report(" woodblock rattle melody ~1:15"); + } + + // ── 2.8 · 808 SNARE FROM ~1:25 ─────────────────────────────────── + // An 808 snare enters on the backbeats (2 & 4) at bar 42 (~1:24) and + // drives the second half (skips the break + final outro) (@jeffrey). + { + const double SPB = 60.0 / BPM; + for (int bar = 42; bar < TOTAL_BARS - 2; bar++) { + if (bar >= 48 && bar < 64) continue; // break + const double t0 = bar * BAR; + snare808(t0 + 1.0 * SPB + humanize(t0 + 1, 15, 5), 0.50, -0.05); // beat 2, laid back + snare808(t0 + 3.0 * SPB + humanize(t0 + 3, 15, 5), 0.50, +0.05); // beat 4, laid back + } + report(" 808 snare on backbeats from ~1:25"); + } + // OPENING SINE BELL REMOVED — the long G5/G6 sine tails read as // a "squeegee" sound at t=0 (@jeffrey 2026-05-29). Field bells + // sine-bridge "ah" carry the intro instead. @@ -1268,7 +1547,7 @@ // Long sustained sub-bass note per chord block. Drops out only // during the mid-break (bars 48..63) and final outro fade. for (int bar = 0; bar < TOTAL_BARS; bar += 4) { const int sec_break = bar >= 48 && bar < 64; - const int sec_outro = bar >= 104; + const int sec_outro = bar >= TOTAL_BARS - 2; // full groove to the end if (sec_break || sec_outro) continue; const Chord *c = chord_for_bar(bar); const double t0 = bar * BAR; @@ -1282,35 +1561,50 @@ // ── 4 · CHORD STAB + POWERSAW HYMNAL CHORDS ────────────────────── // Delayed to bar 12 (t=24 s) — the bar-4 entry was the loud // "white noise crash @ 8 s" @jeffrey flagged. Now they enter // 4 bars before the vocal drop for a clean swell. - for (int bar = 12; bar < TOTAL_BARS; bar += 4) { + int topIdx = 0; // persists across bars so the top band rarely jumps + // The power-saws don't enter until the first WOOP at ~32 s (bar 16) — + // the intro stays just dark filtered drums + pads (@jeffrey). + for (int bar = 16; bar < TOTAL_BARS; bar++) { const int sec_break = bar >= 48 && bar < 64; - const int sec_outro = bar >= 104; + const int sec_outro = bar >= TOTAL_BARS - 2; // full groove to the end if (sec_break || sec_outro) continue; const Chord *c = chord_for_bar(bar); const double t0 = bar * BAR; - // Chord stab (piano-like) - chord_stab_render(t0, c->root, c->third, c->fifth, - (ChordStabOpts){ .decay_s = 1.6, .pan = 0.0, - .gain = 0.42, .wet_send = 0.45, - .brightness = 0.60 }); - // POWERSAW HYMNAL — 3 detuned-saw voices stacked into a chord. - // Sustained through the 4-bar chord block, panned wide for the - // hugeness, heavy reverb wet. - const double dur = 4.0 * BAR + 0.15; - powersaw_render(t0, dur, c->root, - (PowersawOpts){ .atk = 0.06, .rel = 0.45, - .pan = -0.20, .gain = 0.12, - .wet_send = 0.40, .detune_cents = 14.0 }); - powersaw_render(t0, dur, c->third, - (PowersawOpts){ .atk = 0.08, .rel = 0.45, - .pan = 0.00, .gain = 0.10, - .wet_send = 0.40, .detune_cents = 16.0 }); - powersaw_render(t0, dur, c->fifth, - (PowersawOpts){ .atk = 0.10, .rel = 0.45, - .pan = +0.20, .gain = 0.10, - .wet_send = 0.40, .detune_cents = 18.0 }); + // Chord stab (piano-like) — on each 4-bar chord change. + if (((bar - 16) & 3) == 0) { + chord_stab_render(t0, c->root, c->third, c->fifth, + (ChordStabOpts){ .decay_s = 1.6, .pan = 0.0, + .gain = 0.42, .wet_send = 0.45, + .brightness = 0.60 }); + } + // POWERSAW HYMNAL — 3 detuned-saw bands (7 saws each), re-attacked + // every bar so they pulse rather than holding one chord for 8 s. + // LOWER register now (a sub band an octave under the root + a + // low-mid root) for weight, and the top band shifts only RARELY + // and within a tight range — subtle voicing, not a melody. + const double dur = BAR + 0.20; // 1-bar sustain, slight overlap + const int tops[4] = { c->third, c->fifth, c->root + 12, c->third }; + if (rng_d() < 0.12) topIdx = rng_range(0, 3); // fewer changes + const int top = tops[topIdx]; + // CHAINSAW ZONE around 1:30 (bars ~38-48): crank the detune + gain + // so the saws snarl and rev like a chainsaw building into the break + // (@jeffrey "pump up the power saw around 1:30, more of a chainsaw"). + double cs = 0.0; + if (bar >= 38 && bar < 48) { cs = 1.0 - fabs((double)bar - 44.0) / 6.0; if (cs < 0) cs = 0; } + const double csDet = cs * 38.0; // up to +38 cents extra buzz + const double csGain = 1.0 + cs * 0.9; // up to +90% louder + const double csAtk = 1.0 - cs * 0.6; // sharper attack = more bite + powersaw_render(t0, dur, c->root - 12, // sub saw — more low + (PowersawOpts){ .atk = 0.06 * csAtk, .rel = 0.30, .pan = -0.16, + .gain = 0.13 * csGain, .wet_send = 0.35, .detune_cents = 12.0 + csDet }); + powersaw_render(t0, dur, c->root, // low-mid root + (PowersawOpts){ .atk = 0.08 * csAtk, .rel = 0.30, .pan = +0.12, + .gain = 0.10 * csGain, .wet_send = 0.40, .detune_cents = 16.0 + csDet }); + powersaw_render(t0, dur, top, // subtle upper voice + (PowersawOpts){ .atk = 0.10 * csAtk, .rel = 0.30, .pan = 0.0, + .gain = 0.07 * csGain, .wet_send = 0.40, .detune_cents = 18.0 + csDet }); } - report(" chord stabs + powersaw hymnal chords (bars 4..47, 64..103)"); + report(" chord stabs + powersaw (chainsaw snarl ~1:30)"); // ── 5 · MUSICAL MOVEMENTS — multiple structured glock layers ───── // @@ -1336,40 +1630,140 @@ .gain = 0.20, .wet_send = 0.60 }); } } - // 5b · DROP 2 melodic counter-melody (bars 64..103). For each 4-bar - // chord block, play a 4-note ascending arpeggio at the chord - // tones (root → third → fifth → octave). This gives DROP 2 a - // distinct musical identity vs the earlier vocal-only PART 1. - for (int bar = 64; bar < 104 && bar < TOTAL_BARS; bar += 4) { + // 5a· · DEEPER SPACE — water droplets + reverberant hats fill the + // empty break (~1:38-2:02) with a cavernous reverb send, so the + // breakdown opens into a wide wet space before the speed-up + // (@jeffrey). On the beat grid, sparse + seeded. + { + const double SPB = 60.0 / BPM; + const int pent[] = { 67, 69, 71, 74, 76, 79, 81 }; // mid G-pentatonic (was too high/cringy) + const int npent = (int)(sizeof(pent) / sizeof(pent[0])); + for (int bar = 48; bar < 62; bar++) { + const double t0 = bar * BAR; + for (int beat = 0; beat < 4; beat++) { + const double tb = t0 + beat * SPB; // on the kick grid + // water droplet — short ping, drowned in reverb + if (rng_d() < 0.38) { + const int m = pent[rng_range(0, npent - 1)]; + glock_render(tb, 0.6, m, + (GlockOpts){ .decay_s = 0.10 + rng_d() * 0.28, + .pan = (rng_d() - 0.5) * 1.4, + .gain = 0.05 + rng_d() * 0.035, .wet_send = 0.88 }); + } + // reverberant hat tick (perc_hit noise → big wet send) + if (rng_d() < 0.30) { + const double th = tb + (rng_d() < 0.5 ? 0.0 : SPB * 0.5); + perc_hit(th, PW_NOISE, 8000, 0.05, 0.07 + rng_d() * 0.05, + 0.0005, 0.035, (rng_d() - 0.5) * 1.2, 0.72); + } + } + } + report(" deeper-space break: water droplets + reverberant hats"); + } + + // 5a+ · HIHAT SPEED-UP — fast hi-hats in a metric accelerando through + // the late break into drop 2 (~2:04..2:08): tick · tick · t·t·t· + // ttttsssss — speeds up through the subdivisions (1/4 → 1/8 → + // 1/16 → 1/32 → 1/64) into a dense buzz that resolves on the + // drop. All locked to the beat grid (in time, very regular). + // Each hat gets seeded tone (brightness ≈ pitch), decay and + // double-delay so attack/decay length and colour vary. + { + const double SPB = 60.0 / BPM; + // hits-per-beat gears: accelerate up to 1/64 (16/beat) and stop — + // the drop's groove takes over. `beats` = gear length (on-grid). + const struct { int perBeat; double beats; } gears[] = { + { 1, 1.5 }, { 2, 1.5 }, { 4, 1.5 }, { 8, 1.5 }, + { 16, 6.0 }, // hold the buzz ~3 s + }; + const int ngears = (int)(sizeof(gears) / sizeof(gears[0])); + // 12 beats (~6 s); start so the buzz holds across drop 2 entry. + double t = 124.0; // ~2:04 start + int hits = 0; + for (int gi = 0; gi < ngears; gi++) { + const double interval = SPB / gears[gi].perBeat; + const int n = (int)(gears[gi].beats * gears[gi].perBeat + 0.5); + // louder at the fast gears (the downpour swells) + const double speedNorm = (gears[gi].perBeat - 1) / 15.0; // 0..1 + for (int k = 0; k < n; k++, t += interval) { + // CRISP & rhythmic (not arhythmic): accent the on-beat hit + // of each gear, tight ping-pong pan, consistent tone — so + // the acceleration reads as a clean buildup (@jeffrey). + const int onBeat = (k % gears[gi].perBeat) == 0; + const double dec = 0.010; // consistent tight tick + const double brt = 0.92; + const double pan = (k % 2 == 0 ? -0.38 : +0.38); + const double gain = (0.085 + 0.075 * speedNorm) * (onBeat ? 1.0 : 0.55); + hat_render(t, + (HatOpts){ .pan = pan, .gain = gain, .decay_s = dec, + .brightness = brt, .double_delay_ms = 3.0 }); + hits++; + } + } + report(" hihat speed-up accel into drop 2 (~2:04-2:10, up to 1/64, %d hits)", hits); + } + + // 5b · DROP 2 melodic bell runs — now run all the way to the end so + // the last 70 s stays full (@jeffrey). For each 4-bar chord block, + // a seeded melodic arpeggio over chord tones across 3 octaves — + // up / down / zig-zag, varied note count, occasional long bell. + // EVERY note lands on a beat (kick grid), nothing off-beat. + for (int bar = 64; bar < TOTAL_BARS; bar += 4) { + if (bar >= 48 && bar < 64) continue; const Chord *c = chord_for_bar(bar); - const int notes[4] = { c->root + 12, c->third + 12, - c->fifth + 12, c->root + 24 }; - const double pans[4] = { -0.22, +0.05, +0.18, -0.10 }; const double SPB = 60.0 / BPM; - for (int n = 0; n < 4; n++) { - // Place hits on beats 1, 2, 3, 4 of bar - glock_render(bar * BAR + n * SPB, 2.0, notes[n], - (GlockOpts){ .decay_s = 1.6, .pan = pans[n], - .gain = 0.20, .wet_send = 0.45 }); + // chord-tone pool, low→high across 3 octaves + // top octave dropped — the +24 tier read as too high/cringy. + const int pool[] = { c->root, c->third, c->fifth, + c->root + 12, c->third + 12, c->fifth + 12, + c->root + 12, c->fifth + 12 }; + const int npool = (int)(sizeof(pool) / sizeof(pool[0])); + // direction: 0=up, 1=down, 2=zigzag. ~2:20 (bar 68) forced up. + int dir = (bar == 68) ? 0 : rng_range(0, 2); + const int nnotes = (bar == 68) ? 6 : rng_range(4, 7); + // ONE hit per beat — every bell aligned to a kick (@jeffrey + // "all sine bells aligned to kicks, nothing outside of beat"). + const double hitStep = SPB; + int idx = (dir == 1) ? npool - 1 : 0; // down starts high + for (int n = 0; n < nnotes; n++) { + if (idx < 0) idx = 0; if (idx >= npool) idx = npool - 1; + // ~15% ring out long; otherwise the bell length tracks the gap, + // so fast bursts are SHORT pings that don't pile into mush + // (@jeffrey "shorter if they burst that way"). + const int longBell = (rng_d() < 0.15); + const double dec = longBell ? (3.0 + rng_d() * 2.0) + : (hitStep * 2.8 + 0.20 + rng_d() * 0.3); + const double dur = longBell ? 5.0 : dec + 0.3; + const double pan = (rng_d() - 0.5) * 0.5; + glock_render(bar * BAR + n * hitStep + humanize(bar * BAR + n, 15, 4), dur, pool[idx], + (GlockOpts){ .decay_s = dec, .pan = pan, + .gain = 0.10, .wet_send = longBell ? 0.65 : 0.45, + .sidechain = 1 }); + // advance the melodic index per direction + if (dir == 0) idx += rng_range(1, 2); // climbing + else if (dir == 1) idx -= rng_range(1, 2); // descending + else idx += (rng_d() < 0.5 ? 1 : -1) * rng_range(1, 2); // zigzag } } - report(" drop 2 counter-melody (4-note arpeggios across the chord)"); + report(" drop 2 bell melodies (seeded up/down runs + long tails)"); - // 5c · OUTRO bell tolls (bars 104..119) — single deep bell every - // 4 bars to mirror the intro. Closes the piece symmetrically. + // 5c · FINAL TOLLS — the groove now drives to the very end, so this is + // just a pair of big deep bells on the last two downbeats to land + // the ending, plus a held tonic powersaw chord ringing out. { - const int outhits[] = { 104, 108, 112, 116 }; - const int n = sizeof(outhits) / sizeof(outhits[0]); - for (int k = 0; k < n; k++) { - const int bar = outhits[k]; - if (bar >= TOTAL_BARS) break; - const Chord *c = chord_for_bar(bar); - // Bell at chord root, low register — same flavor as intro. - glock_render(bar * BAR, 5.0, c->root - 12, - (GlockOpts){ .decay_s = 4.0, .pan = (k % 2 == 0 ? -0.2 : 0.2), - .gain = 0.24, .wet_send = 0.75 }); - } - report(" outro bell tolls (every 4 bars, mirrors intro)"); + const Chord *c = chord_for_bar(0); // tonic (G) + const int b1 = TOTAL_BARS - 2, b2 = TOTAL_BARS - 1; + glock_render(b1 * BAR, 6.0, c->root, (GlockOpts){ .decay_s = 5.0, + .pan = -0.2, .gain = 0.26, .wet_send = 0.80 }); + glock_render(b2 * BAR, 6.0, c->root - 12, (GlockOpts){ .decay_s = 5.0, + .pan = 0.2, .gain = 0.28, .wet_send = 0.80 }); + // held tonic powersaw chord ringing out the ending + const double endDur = 2.0 * BAR + 1.0; + powersaw_render(b1 * BAR, endDur, c->root, (PowersawOpts){ .atk = 0.30, + .rel = 1.5, .pan = -0.2, .gain = 0.12, .wet_send = 0.45, .detune_cents = 14.0 }); + powersaw_render(b1 * BAR, endDur, c->fifth + 12, (PowersawOpts){ .atk = 0.40, + .rel = 1.5, .pan = +0.2, .gain = 0.09, .wet_send = 0.45, .detune_cents = 18.0 }); + report(" final tolls + held tonic chord (ending)"); } // 5d · TRANSITION RISERS — noise sweep before break + before drop 2. @@ -1409,16 +1803,44 @@ } // ── WAV writer ─────────────────────────────────────────────────────── static void write_wav(const char *path) { + // ── INTRO FILTER SWEEP ────────────────────────────────────────── + // Open the whole bed from a dark lowpass up to full bandwidth over + // the first ~32 s, so the track starts on low pitches under a filter + // and brightens into the first WOOP (@jeffrey "start with low pitches, + // low pass filter on them"). One-pole LP with an exponentially rising + // cutoff (≈250 Hz → ≈20 kHz). + { + const double openSec = 32.0; + double lpL = 0.0, lpR = 0.0; + const long iOpen = (long)(openSec * SR); + // Amplitude swell — the instruments entering in the first seconds + // come in QUIET and grow over ~22 s, so the 7-8 s entries aren't + // loud at first (@jeffrey). Cubic ease from 0.30 → 1.0. + const double swellSec = 22.0; + for (long i = 0; i < N && i < iOpen; i++) { + const double tt = (double)i / (double)SR; + const double frac = (double)i / (double)iOpen; // 0..1 + const double cut = 250.0 * pow(20000.0 / 250.0, frac); + const double a = 1.0 - exp(-TAU * cut / (double)SR); + lpL += a * (L[i] - lpL); + lpR += a * (R[i] - lpR); + double g = 1.0; + if (tt < swellSec) { const double x = tt / swellSec; g = 0.30 + 0.70 * x * x * x; } + L[i] = (float)(lpL * g); + R[i] = (float)(lpR * g); + } + } double peak = 0.0; for (long i = 0; i < N; i++) { double a = fabs(L[i]); if (a > peak) peak = a; double b = fabs(R[i]); if (b > peak) peak = b; } - report(" peak before tanh: %.3f", peak); + report(" peak before tanh: %.3f (drive %.3f → %.3f into tanh)", + peak, DRIVE, peak * DRIVE); double post_peak = 0.0; for (long i = 0; i < N; i++) { - L[i] = tanhf(L[i]); - R[i] = tanhf(R[i]); + L[i] = tanhf(L[i] * DRIVE); + R[i] = tanhf(R[i] * DRIVE); double a = fabs(L[i]); if (a > post_peak) post_peak = a; double b = fabs(R[i]); if (b > post_peak) post_peak = b; } @@ -1427,6 +1849,55 @@ const double gain = post_peak > 0 ? target / post_peak : 1.0; report(" peak after tanh: %.3f → gain %.3f (target -3 dB)", post_peak, gain); for (long i = 0; i < N; i++) { L[i] *= gain; R[i] *= gain; } + // ── BITCRUSH — square-wavey + bubbly ───────────────────────────── + // Gentle 4-bit reduction (square steps, NOT brutal) + a short bubbly + // reverb on the crushed signal, NO hard sample-slicing (decimation), + // and a wet cap so the dry mix ALWAYS bleeds through — never that + // extreme (@jeffrey). Time-varying w(t): intro haze → dry over ~8 s, + // and a swing-back on the ~1:28 build. + { + const double WMAX = 0.55; // cap: always ≥45% dry + const double LV = pow(2.0, 4.0 - 1.0); // 4-bit → squarish steps + const double sr_scale = (double)SR / 44100.0; + Comb cL[4], cR[4]; Allpass apL[2], apR[2]; + const int dl_L[4] = { 1116, 1188, 1277, 1356 }; // short = bubbly + const int dl_R[4] = { 1139, 1211, 1300, 1379 }; + for (int k = 0; k < 4; k++) { + comb_init(&cL[k], (long)(dl_L[k] * sr_scale), 0.70f); + comb_init(&cR[k], (long)(dl_R[k] * sr_scale), 0.70f); + } + allpass_init(&apL[0], (long)(225 * sr_scale)); allpass_init(&apL[1], (long)(341 * sr_scale)); + allpass_init(&apR[0], (long)(244 * sr_scale)); allpass_init(&apR[1], (long)(322 * sr_scale)); + for (long i = 0; i < N; i++) { + const double tt = (double)i / (double)SR; + double w = 0.0; + if (tt < 8.0) { const double x = 1.0 - tt / 8.0; w = x * x; } // intro haze → dry + if (tt > 83.0 && tt < 94.0) { // ~1:23-1:34 swing + const double m = 1.0 - fabs(tt - 88.5) / 5.5; + if (m > 0.0 && m > w) w = m; + } + w *= WMAX; + // square-wave bit reduction (no decimation = no hard slicing) + const double dlv = L[i], drv = R[i]; + const double cl = round(dlv * LV) / LV; + const double cr = round(drv * LV) / LV; + // bubbly reverb fed continuously (keeps the tail smooth) + float sL = 0, sR = 0; + for (int k = 0; k < 4; k++) { sL += comb_tick(&cL[k], (float)cl); sR += comb_tick(&cR[k], (float)cr); } + sL = allpass_tick(&apL[0], sL); sL = allpass_tick(&apL[1], sL); + sR = allpass_tick(&apR[0], sR); sR = allpass_tick(&apR[1], sR); + if (w > 0.0) { + const double wetL = cl * 0.70 + sL * 0.55; // crushed + bubbly wash + const double wetR = cr * 0.70 + sR * 0.55; + L[i] = (float)(dlv * (1.0 - w) + wetL * w); + R[i] = (float)(drv * (1.0 - w) + wetR * w); + } + } + for (int k = 0; k < 4; k++) { free(cL[k].buf); free(cR[k].buf); } + for (int k = 0; k < 2; k++) { free(apL[k].buf); free(apR[k].buf); } + report(" bitcrush: 4-bit square + bubbly reverb, dry always bleeds (≤%.0f%% wet)", WMAX * 100); + } + FILE *f = fopen(path, "wb"); if (!f) { fprintf(stderr, "amazing-dance: cannot open %s\n", path); exit(1); } const long data_size = N * 2 * 2; @@ -1467,12 +1938,22 @@ for (int i = 1; i < argc; i++) { if (!strcmp(argv[i], "--out") && i + 1 < argc) OUT_PATH = argv[++i]; else if (!strcmp(argv[i], "--bpm") && i + 1 < argc) BPM = atof(argv[++i]); else if (!strcmp(argv[i], "--seconds") && i + 1 < argc) TOTAL_SEC = atof(argv[++i]); + else if (!strcmp(argv[i], "--drive") && i + 1 < argc) DRIVE = atof(argv[++i]); + else if (!strcmp(argv[i], "--mono-kick")) MONO_KICK = 1; + else if (!strcmp(argv[i], "--seed") && i + 1 < argc) SEED_ARG = (unsigned)strtoul(argv[++i], NULL, 10); else { fprintf(stderr, "amazing-dance: unknown arg %s\n", argv[i]); return 1; } } if (!OUT_PATH) { fprintf(stderr, "amazing-dance: --out required\n"); return 1; } + + // Seed per-render variation, then roll a fresh chord progression. + const uint32_t seed = SEED_ARG ? SEED_ARG : (uint32_t)time(NULL); + rng_seed(seed); + init_prog(); + report(" seed %u · progression %s-%s-%s-%s", + seed, PROG[0].name, PROG[1].name, PROG[2].name, PROG[3].name); N = (long)(TOTAL_SEC * SR); L = calloc(N, sizeof(float)); diff --git a/pop/big-pictures/c/bake-short.mjs b/pop/big-pictures/c/bake-short.mjs new file mode 100644 --- /dev/null +++ b/pop/big-pictures/c/bake-short.mjs @@ -0,0 +1,326 @@ +#!/usr/bin/env node +// bake-short.mjs — the SHORT cut of "amazing grace (dance)", retracked +// from scratch (@jeffrey 2026-05-30). +// +// Design (a hard reset of the old --short remap): +// • START FROM THE ORIGINAL SAMPLE — slice all 26 hymn words straight +// out of amazing-vocal.mp3 (the 7.8 s jeffrey-pvc take) at their +// natural word timings from amazing-vocal-words.json. KEEP THEM +// SHORT — natural length, no rubberband stretching. +// • BEATMAPPED — every word onset snaps to the 120 BPM beat grid +// (beat = 0.5 s, bar = 2.0 s). +// • ANCHOR TO THE FIRST BEAT — the bed's kick enters at bar 3 = 6.0 s +// (bars 0-2 are a bells-only intro). The first word lands there. +// • WITHIN A BAR OR TWO — each of the 4 hymn lines fills exactly 2 +// bars, then the WHOLE HYMN LOOPS ~6× across the 2-min cut. +// +// Pipeline: build engine → render 300 s bed (trim to 120) → +// slice 26 words → render beatmapped looped vocal → +// mix bed + vocal → master chain → -SHORT-* outputs. +// +// Usage: node pop/big-pictures/c/bake-short.mjs [outDir] + +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; +import { existsSync, statSync, mkdirSync } from "node:fs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(HERE, "../../.."); +const POP = resolve(REPO, "pop"); + +const outDir = process.argv[2] || `${homedir()}/Documents/Shelf/amazing-grace-dance`; +mkdirSync(outDir, { recursive: true }); + +const ENGINE = `${HERE}/amazing-dance`; +const ENGINE_C = `${HERE}/amazing-dance.c`; +const BED_WAV = `${outDir}/.amazing-dance-SHORT-bed.wav`; +const VOX_WAV = `${outDir}/.amazing-dance-SHORT-vox.wav`; +const PRE_WAV = `${outDir}/.amazing-dance-SHORT-pre.wav`; +const FINAL_WAV = `${outDir}/amazing-grace-dance-c-SHORT-MASTER.wav`; +const FINAL_MP3 = `${outDir}/amazing-grace-dance-c-SHORT.mp3`; +const PRE_BRIGHT_WAV = `${outDir}/amazing-grace-dance-c-SHORT-MASTER-preBright.wav`; + +const PVC_SOURCE = resolve(POP, "big-pictures/out/amazing-vocal.mp3"); +const WORD_DIR = resolve(POP, "big-pictures/out/short-words"); +mkdirSync(WORD_DIR, { recursive: true }); + +const run = (cmd, a, label, opts = {}) => { + console.log(`\n[bake-short] ${label}`); + const r = spawnSync(cmd, a, { stdio: "inherit", ...opts }); + if (r.status !== 0) { console.error(`[bake-short] FAILED: ${label}`); process.exit(1); } +}; + +// ── 28 hymn SYLLABLES — note + natural source span ─────────────────── +// Notes come from amazing.np ("New Britain", G-major pentatonic, oct 3). +// `from`/`to` (ms) are the syllable's span in amazing-vocal.mp3 — word +// boundaries from amazing-vocal-words.json, except "amazing" which is +// split into its three syllables a-/-ma-/-zing (D3→G3→B3). Each syllable +// is sliced short, WORLD-locked to its single note, then stretched onto +// a beat. Names are unique (filename keys): a_l2/i1/was1/etc disambiguate +// repeats. +const L1 = [ + { name: "a", note: "D3", from: 0, to: 130 }, // a- ┐ + { name: "ma", note: "G3", from: 130, to: 310 }, // -ma- ├ "amazing" + { name: "zing", note: "B3", from: 310, to: 453 }, // -zing ┘ + { name: "grace", note: "G3", from: 488, to: 766 }, + { name: "how", note: "B3", from: 813, to: 952 }, + { name: "sweet", note: "A3", from: 1010, to: 1300 }, + { name: "the", note: "G3", from: 1358, to: 1440 }, + { name: "sound", note: "B3", from: 1486, to: 1800 }, +]; +const L2 = [ + { name: "that", note: "D3", from: 1846, to: 1974 }, + { name: "saved", note: "E3", from: 2020, to: 2322 }, + { name: "a_l2", note: "D3", from: 2357, to: 2380 }, + { name: "wretch", note: "B3", from: 2426, to: 2659 }, + { name: "like", note: "G3", from: 2717, to: 2891 }, + { name: "me", note: "B3", from: 2949, to: 3251 }, +]; +const L3 = [ + { name: "i1", note: "D3", from: 3402, to: 3518 }, + { name: "once", note: "G3", from: 3611, to: 3820 }, + { name: "was1", note: "D4", from: 3855, to: 3971 }, + { name: "lost", note: "D4", from: 4040, to: 4435 }, + { name: "but1", note: "B3", from: 4505, to: 4656 }, + { name: "now1", note: "D4", from: 4714, to: 4923 }, + { name: "am", note: "A3", from: 4992, to: 5097 }, + { name: "found", note: "G3", from: 5178, to: 5503 }, +]; +const L4 = [ + { name: "was2", note: "B3", from: 5573, to: 5689 }, + { name: "blind", note: "D4", from: 5747, to: 6177 }, + { name: "but2", note: "D4", from: 6455, to: 6629 }, + { name: "now2", note: "B3", from: 6676, to: 6908 }, + { name: "i2", note: "A3", from: 6989, to: 7024 }, + { name: "see", note: "G3", from: 7140, to: 7802 }, +]; +const LINES = [L1, L2, L3, L4]; +const WORDS = LINES.flat(); + +// Beat grid (defined early — the sung-stem stretch needs it). +const BEAT = 0.5; // 120 BPM → 0.5 s/beat, 2.0 s/bar +const T0 = 6.0; // first kick (bar 3) +// LYRIC PACING — onset spacing between syllables + rest between lines. +// Raise SYL_BEATS to slow the words down: 1 = a syllable every beat +// (rushed), 1.5 = every dotted beat, 2 = every half-bar. Holds scale +// with it so syllables stay legato. @jeffrey "slow the lyrics down". +const SYL_BEATS = 1.5; // beats between syllable onsets +const LINE_GAP_BEATS = 2; // extra rest between lines +const HOLD_BEATS = SYL_BEATS + 0.5; // sustain past the next onset (legato) +const HOLD_BEATS_END = SYL_BEATS + 2; // line-ending syllables ring longer + +// ── slice each word — natural length, short ────────────────────────── +// PRE-ROLL 15 ms to catch the consonant onset, +60 ms tail, floored to +// 130 ms so ultra-short words (the 35 ms "i") are still audible. Tiny +// fades kill edge clicks. Cached by source mtime. +const PRE = 0.015, TAIL = 0.060, FLOOR = 0.130; +for (const w of WORDS) { + w.file = resolve(WORD_DIR, `${w.name}.wav`); + const start = Math.max(0, w.from / 1000 - PRE); + const dur = Math.max(FLOOR, (w.to - w.from) / 1000 + PRE + TAIL); + w.dur = dur; + if (existsSync(w.file) && statSync(PVC_SOURCE).mtimeMs <= statSync(w.file).mtimeMs) continue; + run("ffmpeg", ["-y", "-loglevel", "error", + "-ss", start.toFixed(3), "-t", dur.toFixed(3), "-i", PVC_SOURCE, + "-af", "afade=t=in:st=0:d=0.005,afade=t=out:st=" + + (dur - 0.03).toFixed(3) + ":d=0.03", + "-ar", "48000", "-ac", "2", "-c:a", "pcm_s16le", w.file], + `slice ${w.name} (${start.toFixed(2)}s, ${dur.toFixed(2)}s)`); +} + +// ── WORLD-autotune + stretch each word → sung, tuned ───────────────── +// Mirrors the mary-lamb / amazing.np pipeline. Each word slice is pitch- +// LOCKED to its melody note(s) with the WORLD vocoder (f0 replacement, +// formants preserved → still jeffrey), THEN time-stretched onto the beat +// grid. WORLD sets the ABSOLUTE target pitch directly, so no source +// base-pitch detection is needed — and unlike `rubberband --pitch` it +// REPLACES jeffrey's spoken wobble with the steady note, so it actually +// sounds autotuned. Notes are straight from amazing.np ("New Britain", +// G-major pentatonic, octave 3 = jeffrey's baritone → small/clean +// shifts). One note PER SYLLABLE (amazing is now 3 syllables a/ma/zing). +const ENDINGS = new Set(["sound", "me", "found", "see"]); +const SUNG_DIR = resolve(POP, "big-pictures/out/short-sung"); +mkdirSync(SUNG_DIR, { recursive: true }); +const WORLD_PY = resolve(POP, ".venv/bin/python"); +const PITCHSNAP = resolve(POP, "bin/pitchsnap_world.py"); +const USE_WORLD = existsSync(WORLD_PY) && existsSync(PITCHSNAP); +if (!USE_WORLD) + console.warn("[bake-short] WARNING: WORLD vocoder not found — words won't be pitch-locked"); + +for (const w of WORDS) { + const notes = [w.note]; // one note per syllable + const holdBeats = ENDINGS.has(w.name) ? HOLD_BEATS_END : HOLD_BEATS; + const targetDur = holdBeats * BEAT; + w.holdSec = targetDur; + const sung = resolve(SUNG_DIR, `${w.name}_${notes.join("-")}_d${targetDur.toFixed(2)}.wav`); + const fresh = existsSync(sung) && statSync(w.file).mtimeMs <= statSync(sung).mtimeMs; + if (!fresh) { + // 1) WORLD f0-replacement → lock to the melody note(s) + let src = w.file; // natural slice + if (USE_WORLD) { + const pitched = resolve(SUNG_DIR, `.${w.name}-pitched.wav`); + console.log(`[bake-short] WORLD ${w.name} → ${notes.join(",")}`); + const r = spawnSync(WORLD_PY, [PITCHSNAP, w.file, pitched, + "--notes", notes.join(","), "--xfade-ms", "30", "--voicing-ramp-ms", "20"], + { encoding: "utf8" }); + if (r.status === 0) src = pitched; + else console.warn(`[bake-short] WORLD failed ${w.name}: ` + + `${(r.stderr || "").trim().split("\n").pop()}`); + } + // 2) time-stretch the pitch-locked word onto its grid slot + const ratio = targetDur / w.dur; + run("rubberband", ["--time", ratio.toFixed(4), "--formant", "--crisp", "5", src, sung], + `stretch ${w.name} ${ratio.toFixed(2)}× → ${targetDur.toFixed(2)}s`); + } + w.file = sung; // placement now uses the autotuned stem +} + +// ── beatmap + loop ─────────────────────────────────────────────────── +// Beat grid: 120 BPM → beat 0.5 s, bar 2.0 s. First word on the first +// kick (6.0 s). Each line = a 2-bar block (8 beats): one word per beat, +// trailing beats rest. Lines stack 4-in-a-row = an 8-bar hymn, then the +// whole hymn loops. Loop while the hymn still starts before the break +// (kick drops at bar 48 = 96 s); the final loop trails into the break. +// One hymn = every syllable spaced SYL_BEATS apart, with LINE_GAP_BEATS +// between lines (and a trailing gap before it repeats). Compute its +// length, then loop the whole hymn with a running cursor until the break. +const HYMN_BEATS = + LINES.reduce((a, ln) => a + ln.length * SYL_BEATS, 0) + + LINE_GAP_BEATS * LINES.length; +const HYMN = HYMN_BEATS * BEAT; +const BREAK_AT = 96.0; + +const placements = []; // { w, at } +for (let k = 0; T0 + k * HYMN < BREAK_AT; k++) { + let cursor = T0 + k * HYMN; // seconds + LINES.forEach((line) => { + line.forEach((w) => { + placements.push({ w, at: +cursor.toFixed(3) }); + cursor += SYL_BEATS * BEAT; + }); + cursor += LINE_GAP_BEATS * BEAT; + }); +} +console.log(`[bake-short] ${placements.length} word placements ` + + `(${placements.length / WORDS.length} hymn loops), ` + + `${T0}s → ${(placements[placements.length - 1].at).toFixed(1)}s`); + +// How many times each distinct word is placed → asplit fan-out count. +const useCount = {}; +for (const pl of placements) useCount[pl.w.name] = (useCount[pl.w.name] || 0) + 1; + +// ── render the beatmapped looped vocal stem ────────────────────────── +// One input per distinct word; asplit into its use-count; adelay each +// copy to its absolute time; amix them all; light forward bus (close, +// dry-ish room + glue compression + presence EQ). +const TOTAL = 120.0; +const voxInputs = []; +WORDS.forEach((w, i) => { w.inputIdx = i; voxInputs.push("-i", w.file); }); + +const splitChains = WORDS.map((w) => { + const n = useCount[w.name] || 0; + if (n === 0) return null; + // label outputs s__0 ... s__(n-1) + const outs = Array.from({ length: n }, (_, j) => `[s_${w.name}_${j}]`).join(""); + return `[${w.inputIdx}:a]asplit=${n}${outs}`; +}).filter(Boolean); + +// per-word running index so each placement grabs a fresh asplit copy +const copyIdx = {}; +const placeChains = placements.map((pl, k) => { + const j = (copyIdx[pl.w.name] = (copyIdx[pl.w.name] ?? -1) + 1); + const ms = Math.round(pl.at * 1000); + return `[s_${pl.w.name}_${j}]adelay=${ms}|${ms},` + + `aresample=48000,aformat=channel_layouts=stereo,` + + `atrim=duration=${TOTAL}[p${k}]`; +}); +const mixIns = placements.map((_, k) => `[p${k}]`).join(""); +const voxComplex = [ + ...splitChains, + ...placeChains, + `${mixIns}amix=inputs=${placements.length}:duration=longest:` + + `dropout_transition=0:normalize=0[voxraw]`, + // FORWARD BUS — keep the words close, LOUD & intelligible. The source + // word slices are quiet (-13..-25 dB), so we drive them hard here: + // • highpass 110 — clear rumble + // • aecho out_gain 0.85 (NOT 0.18 — that was scaling the whole + // vocal down ~15 dB and burying it) — light, short, close room + // • compressor makeup 4 — level the words + push forward + // • +10 dB volume — bring the quiet PVC slices up to lead level + // • 3.4 kHz consonant lift for diction + `[voxraw]highpass=f=110,` + + `aecho=0.85:0.85:55|130:0.12|0.07,` + + `acompressor=threshold=0.05:ratio=4:attack=5:release=160:makeup=4,` + + `equalizer=f=3400:t=q:w=1.2:g=2.5,` + + `volume=7dB,` + + `atrim=duration=${TOTAL}[vox]`, +].join(";"); + +// ── 1 · build engine if stale ──────────────────────────────────────── +if (!existsSync(ENGINE) || statSync(ENGINE_C).mtimeMs > statSync(ENGINE).mtimeMs) + run("bash", [`${HERE}/build-dance.sh`], "build C engine"); + +// ── 2 · render bed (engine always makes 300 s; we trim to 120) ─────── +run(ENGINE, ["--out", BED_WAV], "C engine render (loukeman bed)"); + +// ── 3 · render the vocal stem ──────────────────────────────────────── +run("ffmpeg", ["-y", ...voxInputs, "-filter_complex", voxComplex, + "-map", "[vox]", "-ar", "48000", "-c:a", "pcm_f32le", VOX_WAV], + `render ${placements.length} beatmapped word hits`); + +// ── 4 · mix bed + vocal ────────────────────────────────────────────── +// Bed sits a touch back so the lyric leads. Master fade: silent until +// 2 s, ramp to ~0.5 over 4 s, full by 24 s. Tail fade over the last 3 s. +const BED_GAIN = 0.58, VOX_GAIN = 1.6; +const mixComplex = [ + `[0:a]atrim=duration=${TOTAL},volume=${BED_GAIN}[bed]`, + `[1:a]atrim=duration=${TOTAL},volume=${VOX_GAIN}[vx]`, + `[bed][vx]amix=inputs=2:duration=longest:dropout_transition=0:normalize=0,` + + `volume=eval=frame:volume='max(0\\,min(1\\,(t-2)/4))*` + + `(0.5+0.5*sin(min(1\\,t/24)*PI/2))',` + + `afade=t=out:st=${(TOTAL - 3).toFixed(1)}:d=3[out]`, +].join(";"); +run("ffmpeg", ["-y", "-i", BED_WAV, "-i", VOX_WAV, + "-filter_complex", mixComplex, "-map", "[out]", + "-ar", "48000", "-c:a", "pcm_f32le", PRE_WAV], + "mix bed + beatmapped vocal"); + +// pre-bright stash for A/B +run("ffmpeg", ["-y", "-i", PRE_WAV, "-ar", "44100", "-sample_fmt", "s16", + PRE_BRIGHT_WAV], "pre-bright stash"); + +// ── 5 · master chain (same pop master as the longlong bake) ────────── +const brightChain = [ + "atrim=start=2", "asetpts=PTS-STARTPTS", + "equalizer=f=80:t=q:w=1.0:g=1.2", + "equalizer=f=200:t=q:w=1.0:g=-1.8", + "equalizer=f=420:t=q:w=1.4:g=-1.4", + "equalizer=f=900:t=q:w=1.6:g=-0.6", + "equalizer=f=2400:t=q:w=1.8:g=-1.6", + "equalizer=f=3400:t=q:w=1.0:g=1.8", + "equalizer=f=5500:t=q:w=1.4:g=1.2", + "equalizer=f=7200:t=q:w=2.0:g=-1.6", + "highshelf=f=10000:g=1.8", + "highshelf=f=14000:g=1.4", + "acompressor=threshold=0.18:ratio=2.4:attack=30:release=260:makeup=1.5", + "volume=1.8dB", +].join(","); +const masterComplex = + `[0:a]${brightChain},acrossover=split=200:order=4th[lo][hi];` + + `[lo]pan=stereo|c0=0.5*c0+0.5*c1|c1=0.5*c0+0.5*c1[lo_mono];` + + `[hi]extrastereo=m=1.20:c=true[hi_wide];` + + `[lo_mono][hi_wide]amix=inputs=2:weights='1 1':normalize=0,` + + `alimiter=limit=0.91:attack=3:release=150:level=disabled[out]`; +run("ffmpeg", ["-y", "-i", PRE_WAV, "-filter_complex", masterComplex, + "-map", "[out]", "-ar", "44100", "-sample_fmt", "s16", + FINAL_WAV], "brightening polish + mono-bass widen"); +run("ffmpeg", ["-y", "-i", FINAL_WAV, "-codec:a", "libmp3lame", "-b:a", "320k", + FINAL_MP3], "320k mp3"); + +spawnSync("open", ["-a", "QuickTime Player", FINAL_MP3]); +console.log(`\n[bake-short] done`); +console.log(` master: ${FINAL_WAV}`); +console.log(` mp3: ${FINAL_MP3}`); +console.log(` A/B: ${PRE_BRIGHT_WAV}`); diff --git a/pop/big-pictures/c/pitch-detect.py b/pop/big-pictures/c/pitch-detect.py new file mode 100644 --- /dev/null +++ b/pop/big-pictures/c/pitch-detect.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +# pitch-detect.py — robust per-file base-pitch estimation via the YIN +# algorithm (cumulative mean normalized difference function + absolute +# threshold). YIN is far more octave-stable than plain autocorrelation, +# which kept locking onto 2nd harmonics. Prints one line per file: +# \t\t (or \tnan\tnan if unvoiced) +# numpy-only. +# +# Flags: --track print a per-frame f0 track (time f0 midi) and exit +import sys, wave, math +import numpy as np + +FMIN, FMAX = 70.0, 500.0 # search range (YIN won't octave-jump within it) +WIN, HOP = 2048, 512 +YIN_THRESH = 0.15 # absolute threshold for the first dip + +def load(path): + w = wave.open(path, "rb") + sr = w.getframerate(); ch = w.getnchannels(); n = w.getnframes() + a = np.frombuffer(w.readframes(n), dtype=np.int16).astype(np.float64) + w.close() + if ch > 1: + a = a.reshape(-1, ch).mean(axis=1) + return a / 32768.0, sr + +def yin_f0(frame, sr): + """YIN fundamental for one frame, or None if unvoiced.""" + if math.sqrt(float(np.mean(frame ** 2)) + 1e-12) < 3e-3: + return None + n = len(frame) + tau_max = min(int(sr / FMIN), n - 1) + tau_min = max(2, int(sr / FMAX)) + x = frame - frame.mean() + # difference function via FFT autocorrelation: + # d(tau) = r(0,n-tau) + r(tau,n) - 2*acf(tau) + acf = np.fft.irfft(np.abs(np.fft.rfft(x, 2 * n)) ** 2)[:tau_max + 1] + power = np.concatenate(([0.0], np.cumsum(x ** 2))) # prefix sums + d = np.empty(tau_max + 1) + for tau in range(tau_max + 1): + e0 = power[n - tau] # energy of x[0 : n-tau] + et = power[n] - power[tau] # energy of x[tau : n] + d[tau] = e0 + et - 2 * acf[tau] + # cumulative mean normalized difference + d[0] = 1.0 + cms = np.empty(tau_max + 1); cms[0] = 1.0 + run = 0.0 + for tau in range(1, tau_max + 1): + run += d[tau] + cms[tau] = d[tau] * tau / run if run > 0 else 1.0 + # absolute threshold: first tau (>=tau_min) dipping below YIN_THRESH at a + # local minimum; else the global min in range. + tau = -1 + t = tau_min + while t < tau_max: + if cms[t] < YIN_THRESH: + while t + 1 < tau_max and cms[t + 1] < cms[t]: + t += 1 + tau = t + break + t += 1 + if tau == -1: + tau = tau_min + int(np.argmin(cms[tau_min:tau_max])) + if cms[tau] > 0.6: # nothing convincingly periodic + return None + # parabolic interpolation around tau + if 1 <= tau < tau_max: + a0, b0, c0 = cms[tau - 1], cms[tau], cms[tau + 1] + denom = a0 - 2 * b0 + c0 + if denom != 0: + tau = tau + 0.5 * (a0 - c0) / denom + f0 = sr / tau + return f0 if FMIN <= f0 <= FMAX else None + +def track(path): + a, sr = load(path) + out = [] + for i in range(0, max(1, len(a) - WIN), HOP): + f = yin_f0(a[i:i + WIN], sr) + out.append((i / sr, f)) + return out + +# Jeffrey's voice lives in one octave band. FOLD every per-frame estimate +# into [FOLD_LO, 2*FOLD_LO) BEFORE taking the median — this collapses the +# frame-level octave errors (spurious jumps to 400+ Hz or 70-90 Hz dips) +# onto the true fundamental, so the per-word base is stable and correct. +FOLD_LO = 100.0 # band = [100, 200) Hz (G2..G3) + +def fold(f): + while f < FOLD_LO: + f *= 2.0 + while f >= 2.0 * FOLD_LO: + f /= 2.0 + return f + +def detect(path): + f0s = [fold(f) for _, f in track(path) if f] + if not f0s: + return None + return float(np.median(f0s)) + +if __name__ == "__main__": + args = sys.argv[1:] + if args and args[0] == "--track": + for t, f in track(args[1]): + if f: + print(f"{t:5.2f}\t{f:7.2f}\t{69 + 12 * math.log2(f / 440.0):5.2f}") + else: + print(f"{t:5.2f}\t - \t -") + sys.exit(0) + for path in args: + f0 = detect(path) + if f0 is None: + print(f"{path}\tnan\tnan") + else: + print(f"{path}\t{f0:.2f}\t{69 + 12 * math.log2(f0 / 440.0):.2f}") diff --git a/pop/maytrax/bin/maytrax.mjs b/pop/maytrax/bin/maytrax.mjs --- a/pop/maytrax/bin/maytrax.mjs +++ b/pop/maytrax/bin/maytrax.mjs @@ -189,6 +189,22 @@ add(buf, s0 + i, hp * Math.exp(-t * 14) * g); } } +// shaker / maraca — a two-pole high-passed noise burst with a soft 3 ms +// attack and a longer decay than the closed hat, so it reads as an airy +// "shh" rather than a "tk". The backbone of the sunk-in perc layer. +function shaker(buf, startSec, g = 0.10, decay = 52) { + const dur = 0.09, n = Math.floor(dur * SR), s0 = Math.floor(startSec * SR); + let prev = 0, prev2 = 0; + for (let i = 0; i < n; i++) { + const t = i / SR; + const nz = Math.random() * 2 - 1; + const hp = nz - prev * 0.86; prev = nz; + const hp2 = hp - prev2 * 0.4; prev2 = hp; + const att = Math.min(1, t / 0.003); + add(buf, s0 + i, hp2 * att * Math.exp(-t * decay) * g); + } +} + // helper: render a mono hat once and place it into stereo with a tight // pan (default 0 = dead center). Avoids the decorrelated-noise blowout // you get from calling openHat(busL,...) + openHat(busR,...) separately. @@ -222,8 +238,8 @@ if (sP[e] === "s") snareBrk(drm, t, 0.34 * energy); // hats: ping-pong slightly so the kit breathes in stereo const pan = (e % 2 === 0 ? -1 : 1) * hatPan; const tmp = new Float32Array(busL.length); - if (hP[e] === "h") hat(tmp, t, 0.16 * energy); - if (hP[e] === "H") openHat(tmp, t, 0.20 * energy); + if (hP[e] === "h") hat(tmp, t, 0.115 * energy); + if (hP[e] === "H") openHat(tmp, t, 0.145 * energy); // mix tmp into stereo with pan const s0 = Math.floor(t * SR); const end = Math.min(busL.length, s0 + Math.floor(0.25 * SR)); @@ -235,6 +251,50 @@ } } } +// ── sunk-in perc layer ─────────────────────────────────────────────── +// A busy swung-16th shaker groove, ghosted low and sent to the reverb +// bus so it sits *behind* the kit rather than on top. Accented shakers +// on the &s, open-hat lifts on the & of 2 and & of 4 (also reverb-fed). +// energy scales gain; revSend controls how "sunk" the layer reads. +// Uses small reusable scratch buffers — no per-hit full-length allocs. +function percGroove(bL, bR, rL, rR, t0, energy = 1, opts = {}) { + const { swing = 0.018, base = 0.052, revSend = 0.38 } = opts; + const shLen = Math.floor(0.09 * SR) + 4; + const ohLen = Math.floor(0.22 * SR) + 4; + const tmp = new Float32Array(ohLen); + for (let e = 0; e < 16; e++) { + // swing the odd 16ths slightly late so the groove breathes + const t = t0 + e * sx + (e % 2 === 1 ? swing : 0); + const s0 = Math.floor(t * SR); + const accent = e % 4 === 2; // the &s + const jitter = 0.75 + 0.25 * (((e * 7) % 5) / 4); + const g = (accent ? base * 1.5 : base) * energy * jitter; + const pan = (e % 2 === 0 ? -1 : 1) * 0.28; + tmp.fill(0, 0, shLen); + shaker(tmp, 0, g); + const lg = 1 - pan * 0.5, rg = 1 + pan * 0.5; + for (let i = 0; i < shLen; i++) { + const v = tmp[i]; if (!v) continue; + const dst = s0 + i; if (dst < 0 || dst >= bL.length) continue; + bL[dst] += v * lg; bR[dst] += v * rg; + rL[dst] += v * lg * revSend; rR[dst] += v * rg * revSend; + } + // open-hat lift on the & of 2 and & of 4 — sunk via heavier rev send + if (e === 6 || e === 14) { + tmp.fill(0, 0, ohLen); + openHat(tmp, 0, 0.085 * energy); + const op = (e === 6 ? -1 : 1) * 0.22; + const olg = 1 - op * 0.5, org = 1 + op * 0.5; + for (let i = 0; i < ohLen; i++) { + const v = tmp[i]; if (!v) continue; + const dst = s0 + i; if (dst < 0 || dst >= bL.length) continue; + bL[dst] += v * olg; bR[dst] += v * org; + rL[dst] += v * olg * 0.5; rR[dst] += v * org * 0.5; + } + } + } +} + // ── sub-bass voice ───────────────────────────────────────────────────── // the deep undercarriage — pure sine with a soft attack so it sits // under the kick (sidechain ducks it on each kick). @@ -543,6 +603,7 @@ { for (let b = 0; b < 16; b++) { const t0 = (cursor + b) * bar; breakBar(busL, busR, drm, t0, 1.0); + percGroove(busL, busR, revL, revR, t0, 0.85); kickTimes.push(t0, t0 + 3 * sx, t0 + 10 * sx); // sub follows the root (one per beat) const root = 29 + (FM_ROOTS[b % 4] - FM_ROOTS[0]); // mirror the stab walk @@ -616,6 +677,8 @@ // ── BREAK A (8 bars) — kick out, 303 acid + flugelhorn ──────────────── { for (let b = 0; b < 8; b++) { const t0 = (cursor + b) * bar; + // sunk-in shaker groove keeps the pulse alive while the kick is out + percGroove(busL, busR, revL, revR, t0, 0.55, { base: 0.045, revSend: 0.5 }); // sparse hats — tight, slight pan only for (let e = 0; e < 16; e++) { if (e % 4 === 2) { @@ -671,6 +734,7 @@ { for (let b = 0; b < 16; b++) { const t0 = (cursor + b) * bar; breakBar(busL, busR, drm, t0, 1.0); + percGroove(busL, busR, revL, revR, t0, 1.0); kickTimes.push(t0, t0 + 3 * sx, t0 + 10 * sx); // sub const root = 29 + (FM_ROOTS[b % 4] - FM_ROOTS[0]); @@ -747,6 +811,11 @@ { const t0 = cursor * bar; darkPad(busL, busR, t0, DB_PAD, bar * 4 - 0.05, 0.22); darkPad(busL, busR, t0 + bar * 4, C_PAD, bar * 4 - 0.05, 0.22); + // distant shaker haze — very sunk, builds slightly over the 8 bars + for (let b = 0; b < 8; b++) { + percGroove(busL, busR, revL, revR, t0 + b * bar, 0.35 + b * 0.04, + { base: 0.04, revSend: 0.6, swing: 0.022 }); + } // sub drops in once per bar — half-time pulse for (let b = 0; b < 8; b++) { const root = b < 4 ? 25 : 24; @@ -774,6 +843,7 @@ { for (let b = 0; b < 16; b++) { const t0 = (cursor + b) * bar; breakBar(busL, busR, drm, t0, 1.05); + percGroove(busL, busR, revL, revR, t0, 1.15, { base: 0.06, revSend: 0.42 }); kickTimes.push(t0, t0 + 3 * sx, t0 + 10 * sx); // sub — louder const root = 29 + (FM_ROOTS[b % 4] - FM_ROOTS[0]); @@ -846,6 +916,7 @@ const t0 = (cursor + b) * bar; // break energy ramps from 1.0 → 0.2 over 12 bars const energy = Math.max(0.15, 1.0 - b * 0.075); breakBar(busL, busR, drm, t0, energy); + percGroove(busL, busR, revL, revR, t0, energy, { revSend: 0.45 }); kickTimes.push(t0, t0 + 3 * sx, t0 + 10 * sx); // sub fades same curve const root = b < 6 ? 29 : 24; @@ -977,28 +1048,54 @@ `→ maytrax · ${BPM} BPM · ${totalBars} bars · ${(trimN / SR).toFixed(1)}s · ` + `intro→buildA→drop1→breakA→drop2→breakB→drop3→outro · stereo`, ); -// ── pop master chain (matches marimbaba / helpabeach) ──────────────── -// highpass 30 → glue comp → air @ 7.5k → brickwall limit. Same shape -// as the released pixsies singles so maytrax sits at commercial level. -const MASTER = [ - "highpass=f=30", - "acompressor=threshold=-18dB:ratio=2.6:attack=18:release=220:makeup=2.4:knee=6", - "treble=g=1.2:f=7500", - "alimiter=limit=0.96:attack=4:release=60", +// ── pop master chain ───────────────────────────────────────────────── +// A fuller, more "finished" pop master than the old 4-stage shape: +// +// 1. highpass 28 — strip infrasonic rumble +// 2. tonal balance EQ — sub weight @60, de-mud @300, presence @3.5k, +// air shelf @11k (the modern smiley curve) +// 3. glue compression — slow-ish 2:1 to fuse the kit + bed +// 4. asoftclip (tanh) — harmonic saturation = density + perceived loud +// 5. stereotools — gentle side lift for width (mono-safe ~1.12) +// 6. alimiter — driven brickwall for the loud, glossy ceiling +// +// A final loudnorm stage per target sets the delivery loudness: hot pop +// level for the mp3 preview, streaming-safe -14 LUFS for the DistroKid wav. +// Tonal + dynamics core — everything *except* loudness + the final +// ceiling. Loudness is set once (loudnorm), then a true-peak brickwall +// runs dead last so nothing downstream can push peaks back over. +const MASTER_CORE = [ + "highpass=f=28", + "equalizer=f=60:t=q:w=0.7:g=1.6", // sub weight + "equalizer=f=300:t=q:w=1.1:g=-1.6", // de-mud the low-mids + "equalizer=f=3500:t=q:w=1.2:g=1.8", // presence / clarity + "treble=g=1.6:f=12000", // air shelf (gentle — keeps mp3 clean) + "acompressor=threshold=-18dB:ratio=2.2:attack=18:release=180:makeup=2.6:knee=6", + "asoftclip=type=tanh:threshold=0.96", // light density / glue + "stereotools=slev=1.10", // subtle, mono-safe width ].join(","); +// mp3 preview — loudnorm sets a hot modern pop loudness, then the +// limiter holds the ceiling at ~-1.2 dBFS so LAME's lossy inter-sample +// overshoot still lands under 0 dBTP. Verified via ebur128 (NOT +// loudnorm's print, which mis-reports on dense brickwalled signal): +// post-encode ≈ -8.8 LUFS / -0.8 dBTP. +const MASTER_MP3 = + `${MASTER_CORE},loudnorm=I=-9.5:TP=-1.5:LRA=9,` + + `alimiter=limit=0.89:attack=3:release=55`; + const ff = spawnSync("ffmpeg", [ "-hide_banner", "-y", "-loglevel", "error", "-f", "f32le", "-ar", String(SR), "-ac", "2", "-i", rawPath, - "-af", MASTER, - "-c:a", "libmp3lame", "-q:a", "2", outPath, + "-af", MASTER_MP3, + "-c:a", "libmp3lame", "-b:a", "320k", outPath, ], { stdio: "inherit" }); if (ff.status !== 0) { try { unlinkSync(rawPath); } catch {} console.error("✗ ffmpeg failed"); process.exit(1); } -console.log(`✓ ${outPath} (pop-mastered · ${(trimN / SR).toFixed(1)} s · stereo)`); +console.log(`✓ ${outPath} (pop-mastered · 320k · ${(trimN / SR).toFixed(1)} s · stereo)`); // ── optional DistroKid WAV master ──────────────────────────────────── const wavOut = expandHome(flags.wav); @@ -1007,7 +1104,7 @@ mkdirSync(dirname(wavOut), { recursive: true }); const wff = spawnSync("ffmpeg", [ "-hide_banner", "-y", "-loglevel", "error", "-f", "f32le", "-ar", String(SR), "-ac", "2", "-i", rawPath, - "-af", `${MASTER},loudnorm=I=-14:TP=-1.5:LRA=11`, + "-af", `${MASTER_CORE},loudnorm=I=-14:TP=-1.5:LRA=11,alimiter=limit=0.95:attack=3:release=55`, "-ar", "44100", "-ac", "2", "-c:a", "pcm_s16le", wavOut, ], { stdio: "inherit" }); if (wff.status !== 0) { diff --git a/slab/menuband/KIDLISP_WEBVIEW_SPEC.md b/slab/menuband/KIDLISP_WEBVIEW_SPEC.md new file mode 100644 --- /dev/null +++ b/slab/menuband/KIDLISP_WEBVIEW_SPEC.md @@ -0,0 +1,215 @@ +# KidLisp TV → live aesthetic.computer WebView — spec + +Goal: make the MenuBand "KidLisp TV" render a **live aesthetic.computer +WebKit (WKWebView) page** instead of the native Swift KidLisp port, so the +panel shows the *real* runtime (every builtin, fonts, network pieces, future +language changes) with zero porting work. + +Status: spec only. Part 1 documents the CURRENT (native) path so we know +exactly what we're swapping. Part 2 specs the WebView path. + +--- + +## Part 1 — Current KidLisp path (native Swift port) + +The TV today is fully native — it never touches a browser. It runs a +hand-ported Swift KidLisp interpreter into an offscreen framebuffer and +blits that into a skeuomorphic LCD bezel. + +### Component map + +| Concern | File | Notes | +|---|---|---| +| Floating LCD panel (bezel + power LED) | `Sources/MenuBand/KidLispTVPanel.swift` | `KidLispTVPanel: NSPanel` (borderless, `.popUpMenu` level, child of popover). `screenAspect = 192/120`, `bezelThickness = 14`. Holds one `KidLispTVView`. | +| The actual renderer | `Sources/MenuBand/KidLispTVView.swift` | `KidLispTVView: NSView`. 192×120 framebuffer, own ~30fps timer, nearest-neighbor upscale. `init(source:resWidth:resHeight:)`, `setSource(_:)`. Reads `amplitude` each `tick()`. | +| Swift KidLisp interpreter | `Sources/MenuBand/KidLisp/` | `KLLexer`, `KLExpr`, `KLEvaluator`, `KLFramebuffer`, `KLColor`, `KLRNG`. Conformance target: *KidLisp Decree '26: Core* — **phase-1 corpus only** (`KidLisp/README.md`). Not full parity with `kidlisp.mjs`. | +| Lifecycle + wiring | `Sources/MenuBand/AppDelegate.swift` | `kidlispTVPanel` ivar (`:24`). Created in `showPopover` (~`:2669`), torn down in `closePopover` (~`:2464`). | +| Audio → `amp` | `AppDelegate.swift` — `tv.ampProvider` closure (`:2700`) calls `currentSynthAmp()`; `kidlispTVAmplitude()` (~`:3206`) is the RMS+envelope helper | Combines `menuBand.synthSnapshotWaveform(...)` RMS with a decaying note-on envelope (`kidlispNoteOnTau = 0.25`), scaled to kidlisp.mjs's ~0–10 `amp` range. `KidLispTVView` polls `ampProvider` in `tick()` (`:198`). Note-on time stamped at `:464`. Also has a `busyProvider` (`:2708`) that skips ticks while notes are lit / bend gesture active so the eval never steals the audio thread. | +| Piece chooser | `AppDelegate.swift` (~`:3204`–`:3380`) | Menu built from `GET /api/store-kidlisp?recent=15&sortBy=hits` (`kidlispChooserURL :31`), 300s TTL cache. `loadKidLispPiece` → `tv.setSource(src)` + persists to `UserDefaults("notepat.kidlispTV.source")`. `resetKidLispPiece` restores `KidLispTVPanel.defaultSource`. | +| Default source | `KidLispTVPanel.defaultSource` (`:30`) | Amp-reactive twin-line waveform — first-run demo of the audio→visual loop. | + +### Current data flow + +``` +synth audio ─┐ +note-on ─────┤→ kidlispTVAmplitude() ──(0–10)──► KidLispTVView.amplitude + │ (30fps tick) +store-kidlisp API ─► chooser menu ─► setSource(src) ─► KLLexer/KLEvaluator + │ +UserDefaults("notepat.kidlispTV.source") ◄── persist ▼ + KLFramebuffer 192×120 + │ nearest-neighbor + ▼ + KidLispTVView.draw → bezel panel +``` + +### What the chooser stores + +`loadKidLispPiece` calls `tv.setSource(src)` with the **raw KidLisp source +string** (fetched/expanded), not a `$code`. The native evaluator only knows +source text. The cache `$code` is used to *fetch* source, then discarded. + +### Limits of the native path (the reason to switch) + +- Phase-1 corpus only — anything outside the ported builtins silently no-ops + or diverges from `kidlisp.mjs`. +- No fonts/text, no `paste`/network pieces, no `@handle/piece`, no future + language features without re-porting each one. +- Two implementations to keep in lockstep (the Swift port vs `kidlisp.mjs`). + +--- + +## Part 2 — Proposed: live aesthetic.computer WKWebView + +Swap the **renderer** (`KidLispTVView`) for a `WKWebView` that loads the real +site. Keep the bezel/panel chrome, the chooser, the persistence, and the +amp/audio idea — only the thing inside the screen well changes. + +### 2.1 New component + +`Sources/MenuBand/KidLispTVWebView.swift` — a drop-in replacement that mirrors +`KidLispTVView`'s public surface so `KidLispTVPanel` / `AppDelegate` barely +change: + +```swift +final class KidLispTVWebView: NSView { + init(source: String) // matches the old initializer shape + func setSource(_ src: String) // reloads the webview at a new piece + var amplitude: Float // (optional) forwarded into the page +} +``` + +Internally it owns a `WKWebView` with: +- `setValue(false, forKey: "drawsBackground")` — transparent so the bezel + shows through (same trick as `SheetMusicView.swift:167`). +- `configuration.suppressesIncrementalRendering = true` and a minimal, + non-persistent `WKWebsiteDataStore` if we don't want cookies. +- Lazy creation (build the webview on first popover open, like + `SheetMusicView.ensureWebViews()`), so menu-bar idle cost stays ~0 + (see memory: *Slab/Menuband perf — off-main + lazy*). + +### 2.2 URL contract (the heart of the swap) + +The chooser already has the cache `$code` (it just throws it away today). For +the webview we keep the code and build a URL instead of fetching source. + +**Cached piece:** +``` +https://aesthetic.computer/$?nogap=true&nolabel=true&density=1 +``` +Resolution: `parse.mjs` detects `$`-prefixed nanoid → +`kidlisp.mjs fetchCachedCode()` → `/api/store-kidlisp?code=…`. + +**Raw inline source** (for `defaultSource` and any ad-hoc text) uses the +kidlisp URL encoding from `lib/kidlisp.mjs` (~`:15042`): + +| char | encodes to | +|---|---| +| space | `_` | +| newline | `~` (tilde → newline on decode) | +| `%` | `¤` · `;` → `¨` · `#` → `%23` · `(` `)` → `%28` `%29` | + +So `defaultSource` becomes: +``` +https://aesthetic.computer/(wipe_black)~(ink_rainbow)~...?nogap=true&nolabel=true +``` +A Swift `encodeKidLispForURL(_:)` helper should implement that table once. + +**Clean-embed query params** (confirmed in `boot.mjs`): +- `nogap=true` — drop canvas padding so it fills the screen well. +- `nolabel=true` — hide the corner HUD label. +- `density=1` (or `2`) — pixel density; pick to match the bezel's pixel look. +- `tv=true` — TV display mode (no touch/keyboard) — **candidate**, verify it + doesn't disable rendering we want. +- `zoom=N`, `perf=true` — available, probably unused here. + +> Open question: there is no `nohud=true` — `nolabel=true` is the supported +> flag. Confirm `nogap`+`nolabel` give a fully chrome-free frame for an +> arbitrary `$code`; if a piece forces its own HUD, we may need a dedicated +> embed entry point on the AC side. + +### 2.3 Sizing / pixel look + +Native TV is locked to 192×120 nearest-neighbor. The webview renders at its +own CSS size; to keep the chunky LCD feel: +- Give the `WKWebView` the screen-well frame (same as `tv.frame` today). +- Drive resolution with `density=1` so AC's own pixel buffer stays coarse, + **or** render small + scale the layer with + `webView.layer?.magnificationFilter = .nearest` for hard pixels. +- Keep `KidLispTVPanel.screenAspect = 192/120`; AC honors the container + aspect when `nogap` is set. + +### 2.4 Audio / `amp` reactivity + +Two options, in order of effort: + +1. **Drop it (v1).** The site generates its own audio; pieces that use `amp` + read the page's own mic/synth. Simplest — `kidlispTVAmplitude()` and the + note-on envelope plumbing become dead code we can leave or delete. +2. **Forward MenuBand's amp into the page (v2).** Push the existing 0–10 + value over `webView.evaluateJavaScript("window.__menubandAmp = \(a)")` + each tick, and (AC-side) let kidlisp's `amp` global fall back to + `window.__menubandAmp` when present. Requires a small hook in + `kidlisp.mjs`. Only do this if the synth→viz loop is a feature we care + about keeping. + +Recommendation: ship v1 (no forwarding); revisit v2 only if the reactive +demo is missed. + +### 2.5 Lifecycle + +- Create the webview lazily on first `showPopover` (don't build it at app + launch — menubar idle cost). +- On `closePopover`, either keep it alive (cheap re-show, keeps the piece + running) or `loadHTMLString("")`/`stopLoading()` to silence audio + stop + the rAF loop while hidden. Prefer **pause on close** so a kidlisp piece + isn't burning CPU/audio behind a closed popover. +- Network failure / offline: show the bezel with a dim "no signal" state + (we can keep a tiny static fallback, or reuse the native renderer purely + as an offline fallback — see 2.7). + +### 2.6 Call-site changes (minimal) + +- `KidLispTVPanel.swift`: change `let tv: KidLispTVView` → + `KidLispTVWebView`, and the `init` that constructs it. Bezel untouched. +- `AppDelegate.swift`: + - `loadKidLispPiece`: pass the **`$code`** (build URL) instead of fetching + raw source. The chooser response already carries the code. + - `resetKidLispPiece`: point at the encoded `defaultSource` URL. + - `kidlispTVAmplitude()` + note-on stamping: keep only if doing v2. + - `UserDefaults("notepat.kidlispTV.source")`: switch to storing the + `$code` (or full URL). Migrate old stored raw-source values by treating + a non-`$` value as inline source and encoding it. + +### 2.7 Optional: native as offline fallback + +Keep `KidLispTVView` + the Swift `KidLisp/` port compiled in, used only when +the webview can't reach the network (or for the `--kidlisp-render` headless +CLI in `main.swift`, which is independent and should stay). `KidLispTVPanel` +picks renderer at init based on reachability. Low priority. + +--- + +## Decision summary + +- **Replace the renderer, keep the chrome.** New `KidLispTVWebView` with the + same `init(source:)` / `setSource(_:)` surface. +- **URL over source.** Chooser already has `$code`; load + `aesthetic.computer/$code?nogap=true&nolabel=true&density=1`. Add a + `encodeKidLispForURL` helper for raw source. +- **Drop amp forwarding in v1.** Site is self-contained. +- **Pause on popover close.** Don't run audio/rAF behind a closed popover. +- **Keep the native port** for the headless render CLI (+ optional offline + fallback); it's no longer the live TV. + +## To confirm before building + +1. `nogap=true&nolabel=true` truly yields chrome-free output for an arbitrary + `$code` (not just kidlisp pieces). If not, add an AC-side embed entry. +2. Transparent `WKWebView` (`drawsBackground=false`) composites cleanly over + the bezel at `.popUpMenu` window level. +3. Audio autoplay: does AC need a user gesture to start sound inside a + `WKWebView`? (Set `mediaTypesRequiringUserActionForPlayback = []`.) +4. App sandbox / MAS subset (see memory: *Menu Band → Mac App Store*): a + network WKWebView needs the outgoing-network entitlement; fine for the + prompt.ac build, check the sandboxed subset. diff --git a/slab/menuband/Sources/MenuBand/AboutWindow.swift b/slab/menuband/Sources/MenuBand/AboutWindow.swift --- a/slab/menuband/Sources/MenuBand/AboutWindow.swift +++ b/slab/menuband/Sources/MenuBand/AboutWindow.swift @@ -349,6 +349,26 @@ "Show the cassette tape deck in the menubar. Work in progress." tapeCheckbox.translatesAutoresizingMaskIntoConstraints = false stack.addArrangedSubview(tapeCheckbox) + // Right-hand percussion split — the upper-octave keys play the + // AC-native drum kit while the lower keys stay melodic. Off by + // default; also toggleable live with ⌘⌃⌥D. + let percCheckbox = NSButton( + checkboxWithTitle: "Percussion split (⌘⌃⌥D)", + target: self, + action: #selector(togglePercussionSplit(_:)) + ) + percCheckbox.state = (UserDefaults.standard + .bool(forKey: KeyboardIconRenderer.percussionLeftDefaultsKey) + || UserDefaults.standard + .bool(forKey: KeyboardIconRenderer.percussionRightDefaultsKey)) + ? .on : .off + percCheckbox.font = NSFont.systemFont(ofSize: 11) + percCheckbox.toolTip = + "Both halves of the board play the drum kit (kick/snare/hats/…). " + + "Tap left-⌥ / right-⌥ to latch just one half." + percCheckbox.translatesAutoresizingMaskIntoConstraints = false + stack.addArrangedSubview(percCheckbox) + // Crash-report summary — single orange ⚠️ button reading // "Menu Band crashed N times". Opens the scroll viewer where // the user can review the .ips contents and click Send to @@ -612,6 +632,21 @@ UserDefaults.standard.set( on, forKey: KeyboardIconRenderer.tapeFeatureDefaultsKey) NotificationCenter.default.post( name: .menuBandTapeFeatureChanged, object: nil) + } + + /// Flip the right-hand percussion split. Writes the shared flag and + /// posts so the controller applies side effects (silence drums on + /// disable, redraw the icon) on the main runtime. + @objc private func togglePercussionSplit(_ sender: NSButton) { + // Whole-board master: set BOTH sided keys together, then let the + // controller apply side effects on the main runtime. + let on = sender.state == .on + UserDefaults.standard.set( + on, forKey: KeyboardIconRenderer.percussionLeftDefaultsKey) + UserDefaults.standard.set( + on, forKey: KeyboardIconRenderer.percussionRightDefaultsKey) + NotificationCenter.default.post( + name: .menuBandPercussionSplitChanged, object: nil) } /// Open a scroll panel containing the .ips text for every pending diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -18,6 +18,7 @@ private var focusCaptureHotkey: GlobalHotkey? private var pianoWaveformHotkey: GlobalHotkey? private var exitFocusHotkey: GlobalHotkey? private var layoutToggleHotkey: GlobalHotkey? + private var percussionToggleHotkey: GlobalHotkey? private var popoverPanel: MenuBandPopoverPanel? private var popoverVC: MenuBandPopoverViewController? private var kidlispTVPanel: KidLispTVPanel? @@ -90,6 +91,16 @@ /// ≈ 5.8ms of audio — small window keeps the menubar bars /// snappy on note attack rather than smearing across the prior /// 20+ ms. private var visualizerSampleBuffer = [Float](repeating: 0, count: 256) + /// Fast-responding bass / mid / treble bar levels (0..1) for the chip's + /// 3-bar spectrum meter, plus a shared adaptive peak for normalization + /// and the last-drawn snapshot for the dirty check. + private var visualizerBars: [CGFloat] = [0, 0, 0] + /// Per-band adaptive peaks so each bar normalizes to its OWN dynamics — + /// otherwise bass dominates the shared gain and mid/treble stay stubby, + /// making the three bars look lopsided instead of evenly lively. + private var visualizerBandPeaks: [Float] = [0.05, 0.05, 0.05] + private var visualizerLastDrawnBars: [CGFloat] = [-1, -1, -1] + private var percLatLogCounter = 0 /// Latest mic-input RMS published by `MenuBandSampleVoice`'s tap /// while the user is recording (key `). Drives the menubar VU /// bars when the sample-voice backend is capturing audio. The @@ -135,6 +146,16 @@ /// double-tap. ~300 ms matches macOS's own "press ⌘ twice" feel. private static let rightCommandDoubleTapWindow: CFTimeInterval = 0.30 private var popoverEscMonitor: Any? + /// Option's .flagsChanged virtual keycodes. A single bare TAP of either + /// Option latches that HALF of the board to percussion: left-⌥ (58) = + /// left half, right-⌥ (61) = right half. Tap again to release that side. + private static let rightOptionKeyCode: UInt16 = 61 + private static let leftOptionKeyCode: UInt16 = 58 + private var rightOptionMonitorGlobal: Any? + private var rightOptionMonitorLocal: Any? + private var leftOptionMonitorGlobal: Any? + private var leftOptionMonitorLocal: Any? + /// Sandbox-friendly local key capture. Armed when the user clicks the /// menubar piano (without opening the popover). The companion ghost /// timer paints letter labels on the menubar keys briefly when armed @@ -254,6 +275,12 @@ /// Mirrors `CGAssociateMouseAndMouseCursorPosition(0)` state. /// Avoids spurious calls on every onLitChanged tick — only /// flips on the keyboard-held edges. private var pitchBendCursorLocked = false + /// Sticky pitch-bend MODE. Once the gesture engages (hold a key + + /// swipe), pitch stays latched: ALL subsequent trackpad movement + /// bends, with no note required, until the user hits Esc or Menu + /// Band loses focus. Releasing keys no longer ends it — only those + /// two explicit exits do. + private var pitchBendModeLatched = false /// Floating window that draws the bend wheel above every app. /// Used in lockstep with `CGDisplayHideCursor` so the real /// system cursor is invisible during pitch bend — that's what @@ -271,6 +298,10 @@ /// of the gesture (the real cursor is detached by /// CGAssociateMouseAndMouseCursorPosition so position doesn't /// drift). private var pitchBendLockScreenPoint: NSPoint = .zero + /// Short grace timer that ends the pitch-bend graphic after the last + /// keyboard note lifts. A small delay so a fast legato note change + /// (release one key, press the next) doesn't flicker the overlay. + private var pitchBendEndTimer: Timer? /// Local + global NSEvent monitors for trackpad scroll events. /// Held so we can teardown if needed (we don't, but per-instance /// retention is cleaner than globals). @@ -299,6 +330,13 @@ /// Trackpad-points-per-unit-bend. 80pt of vertical drag pulls /// to a full ±1 (= ±2 semitones at default GM range), so a /// modest two-finger flick reads as a noticeable bend. private static let bendSensitivityPerPoint: Float = 1.0 / 80.0 + /// Max bend magnitude in `bendAmount` units (1 unit = one octave via + /// `bendSemitonesPerUnit`). The accumulator clamps here so there's no + /// wind-up past the edge, and the overlay puck normalizes against it so + /// the grid edge IS the cap. ±2 = two octaves down / up — which is also + /// AVAudioUnitTimePitch's hard limit (±2400 cents) for the radio voice, + /// so the radio reaches its true floor/ceiling at the grid edges. + private static let bendRange: Float = 2.0 /// Trackpad-points-per-unit-space (X-axis). Deliberately less /// touchy than pitch: ~200pt of horizontal travel sweeps fully /// dry → full room, so it's a controllable ambience wash rather @@ -336,6 +374,12 @@ selector: #selector(handleTapeFeatureToggled(_:)), name: .menuBandTapeFeatureChanged, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handlePercussionSplitToggled(_:)), + name: .menuBandPercussionSplitChanged, + object: nil + ) Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in debugLog("heartbeat") } @@ -397,24 +441,36 @@ // piano, so we gate on `keyboardNotesHeld`. Trackpad // pitch-bend is a keyboard-mode feature: hold a // letter → cursor locks → swipe to bend → release // letter → cursor unlocks + spring rubber-band. + // Once latched, pitch-bend MODE persists regardless of which + // notes are (or aren't) held — only Esc / focus loss ends it + // (see endPitchBendSession callers). So we no longer end the + // session here on note release; releasing keys just leaves + // the bend where it is and keeps the mode live. let kbHeld = self.menuBand.keyboardNotesHeld - if kbHeld && !self.pitchBendCursorLocked { - CGAssociateMouseAndMouseCursorPosition(0) - self.pitchBendCursorLocked = true - // Snapshot the cursor's screen position so the - // overlay window can anchor there for the duration - // of the gesture. NSEvent.mouseLocation is in - // bottom-left-origin screen coords — same space - // NSWindow.setFrameOrigin expects. - self.pitchBendLockScreenPoint = NSEvent.mouseLocation - } else if !kbHeld && self.pitchBendCursorLocked - && !self.trackpadTouchActive { - // All notes released AND no finger on the trackpad — - // end the gesture. If a finger is still down we skip - // this so the bend (and the locked cursor/overlay) - // survive a note change / legato; the finger-lift - // callback ends it instead. - self.endPitchBendSession() + if kbHeld { + // A keyboard note is held — keep (or arm) the pitch-bend + // graphic and cancel any pending teardown left over from a + // momentary gap (fast legato note changes). + self.pitchBendEndTimer?.invalidate() + self.pitchBendEndTimer = nil + if !self.pitchBendCursorLocked { + CGAssociateMouseAndMouseCursorPosition(0) + self.pitchBendCursorLocked = true + self.pitchBendModeLatched = true + // Snapshot the cursor's screen position so the + // overlay window can anchor there for the duration + // of the gesture. NSEvent.mouseLocation is in + // bottom-left-origin screen coords — same space + // NSWindow.setFrameOrigin expects. + self.pitchBendLockScreenPoint = NSEvent.mouseLocation + } + } else if self.pitchBendModeLatched { + // No keyboard note held anymore. The pitch-shift graphic is + // a hold-to-bend visual, so tear it down once the last key + // lifts — after a short grace so a quick legato hand-off + // doesn't flicker it. (Shift stays the deliberate exception: + // it keeps bending still-ringing notes with no key down.) + self.scheduleGraphicEndIfNoKeyHeld() } self.lastLitCount = cur self.updateIcon() @@ -475,6 +531,16 @@ pianoWaveformWindowDelegate.onToggleKeymap = { [weak self] in self?.toggleKeyboardLayoutShortcut() } + // Magnify the icon to fill the bar's usable height. The status + // button centers (doesn't downscale) its image, so we scale up only + // as far as the thickness allows — bigger on roomy/notched bars, + // 1× on a tight bar. Hit-testing compensates via the same factor. + let barThickness = NSStatusBar.system.thickness + let baseIconH = KeyboardIconRenderer.baseImageSize.height + if baseIconH > 0 { + KeyboardIconRenderer.iconScale = + max(1.0, min(1.6, (barThickness - 0.5) / baseIconH)) + } statusItem = NSStatusBar.system.statusItem(withLength: KeyboardIconRenderer.imageSize.width) debugLog("statusItem created, button=\(statusItem.button != nil) length=\(statusItem.length)") if let button = statusItem.button { @@ -545,6 +611,7 @@ _ = registerFocusCaptureHotkey(MenuBandShortcutPreferences.focusShortcut) _ = registerPianoWaveformHotkey(MenuBandShortcutPreferences.playPaletteShortcut) _ = registerExitFocusHotkey(MenuBandShortcutPreferences.exitFocusShortcut) registerLayoutToggleHotkey() + registerPercussionToggleHotkey() // Start the Stickies bridge — listens for keystrokes when // the macOS Stickies app is frontmost and the focused @@ -680,9 +747,11 @@ // user sees the layout dynamically appear while typing. localCapture.onKey = { [weak self] keyCode, isDown, isRepeat, flags in guard let self = self else { return false } // Escape disarms capture explicitly. Useful when the user - // wants to release focus without clicking another app. + // wants to release focus without clicking another app. Also + // the explicit exit for latched pitch-bend mode. if isDown && keyCode == 53 /* kVK_Escape */ { NSSound(named: NSSound.Name("Tink"))?.play() + if self.pitchBendModeLatched { self.endPitchBendSession() } self.localCapture.disarm(reason: .cancelled) return true } @@ -693,6 +762,13 @@ ) { self.toggleKeyboardLayoutShortcut() return true } + if isDown && MenuBandShortcut.defaultPercussionToggle.matches( + keyCode: UInt32(keyCode), + modifiers: MenuBandShortcut.carbonModifiers(from: flags) + ) { + self.togglePercussionSplitFromShortcut() + return true + } // Spacebar toggles the metronome whenever the popover is // open. Consumed in both directions so the keystroke // never falls through to the focused app or to the note @@ -783,6 +859,8 @@ startAdaptiveLayoutChecks() startShiftStateMonitors() startRightCommandTapMonitor() + startRightOptionTapMonitor() + startLeftOptionTapMonitor() startVisualizerAnimation() // Retint the running app's icon (About panel + Dock) to the @@ -906,6 +984,36 @@ layoutToggleHotkey = hotkey } } + private func registerPercussionToggleHotkey() { + let hotkey = GlobalHotkey( + signature: OSType(0x4D425052), // 'MBPR' + id: 1 + ) { [weak self] in + self?.togglePercussionSplitFromShortcut() + } + let shortcut = MenuBandShortcut.defaultPercussionToggle + if hotkey.register(keyCode: shortcut.keyCode, modifiers: shortcut.modifiers) { + percussionToggleHotkey = hotkey + } + } + + private func togglePercussionSplitFromShortcut() { + menuBand.togglePercussionSplit() + // Audible cue so the toggle lands without looking at the menubar. + menuBand.playPercussionToggleCue(on: menuBand.percussionSplit) + } + + private func toggleLeftPercussionFromShortcut() { + menuBand.togglePercussionLeft() + // Cue panned left so the side that toggled lands by ear. + menuBand.playPercussionToggleCue(on: menuBand.percussionLeft, pan: 32) + } + + private func toggleRightPercussionFromShortcut() { + menuBand.togglePercussionRight() + menuBand.playPercussionToggleCue(on: menuBand.percussionRight, pan: 96) + } + @discardableResult private func registerPianoWaveformHotkey(_ shortcut: MenuBandShortcut) -> Bool { let hotkey = GlobalHotkey( @@ -1084,18 +1192,13 @@ FocusFlashOverlay.shared.flash(rising: false) menuBand.playFocusCue(rising: false) localCapture.disarm(reason: .cancelled) } else { - // If the popover is closed, open it first so the gesture - // has a visible target — arming silently with no UI on - // screen leaves the user wondering whether anything happened. - let wasClosed = !isPopoverPanelShown - if wasClosed { - showPopover() - } - // Start: arm capture immediately so the very next keys - // play even while ⌘ is still held (right-⌘+f → plays F - // AND enables). No overlay (beginFocusCapture… - // deliberately doesn't call showExpandedForPopover). - beginFocusCaptureFromShortcut(keepPopoverOpen: wasClosed) + // Arm focus capture WITHOUT touching the popover. The + // double-⌘ gesture used to pop the panel open as a visible + // target, but jas wants the shortcut to arm silently — the + // blue flash + rising bell below are the "it landed" cue, so + // no popover is needed. keepPopoverOpen:true also means an + // already-open popover is left as-is rather than closed. + beginFocusCaptureFromShortcut(keepPopoverOpen: true) // Blue glow + rising bell + all keys flash at once. FocusFlashOverlay.shared.flash(rising: true) menuBand.playFocusCue(rising: true) @@ -1147,6 +1250,8 @@ private func finishLocalCapture(reason: LocalKeyCapture.EndReason) { let shouldRestoreFocus = focusCaptureArmedByShortcut && reason == .cancelled focusCaptureArmedByShortcut = false + // Menu Band lost key focus → exit latched pitch mode too. + if pitchBendModeLatched { endPitchBendSession() } menuBand.releaseAllHeldNotes() ghostUntil = 0 ghostRefreshTimer?.invalidate() @@ -1338,6 +1443,92 @@ KeyboardIconRenderer.metronomeFlash *= 0.88 if KeyboardIconRenderer.metronomeFlash < 0.01 { KeyboardIconRenderer.metronomeFlash = 0 } + // Three real bars — split the live waveform into bass / mid / + // treble via one-pole filters, RMS each, normalize to a shared + // adaptive peak, and smooth with INSTANT attack + fast release + // so the meter is honest and time-accurate (no decorative + // wiggle). While recording, the mic level fans across the bars. + var bass: Float = 0, mid: Float = 0, treble: Float = 0 + if self.menuBand.sampleRecordingActive { + bass = self.latestMicLevel + mid = self.latestMicLevel * 0.8 + treble = self.latestMicLevel * 0.6 + } else { + var lpLow: Float = 0, lpMid: Float = 0 + let aLow: Float = 0.035, aMid: Float = 0.22 + var bSq: Float = 0, mSq: Float = 0, tSq: Float = 0 + for s in self.visualizerSampleBuffer { + lpLow += aLow * (s - lpLow) + lpMid += aMid * (s - lpMid) + let b = lpLow + let m = lpMid - lpLow + let t = s - lpMid + bSq += b * b; mSq += m * m; tSq += t * t + } + let n = Float(self.visualizerSampleBuffer.count) + bass = (bSq / n).squareRoot() + mid = (mSq / n).squareRoot() + treble = (tSq / n).squareRoot() + } + // Per-band adaptive gain: normalize each band to ITS OWN recent + // peak so bass (lower absolute energy) still swings full-height + // instead of staying a stub — all three bars read evenly. + let bandVals: [Float] = [bass, mid, treble] + var bandTargets: [CGFloat] = [0, 0, 0] + // Noise gate: below this the signal is effectively silence, so + // skip the per-band gain (which would otherwise amplify the + // noise floor into twitching bars) and let them fall to the + // flat baseline. + let gateOpen = max(bass, max(mid, treble)) > 0.004 + for i in 0..<3 where gateOpen { + if bandVals[i] > self.visualizerBandPeaks[i] { + self.visualizerBandPeaks[i] = bandVals[i] + } else { + self.visualizerBandPeaks[i] = + max(0.015, self.visualizerBandPeaks[i] * 0.95 + bandVals[i] * 0.05) + } + let gain = 0.95 / self.visualizerBandPeaks[i] + bandTargets[i] = CGFloat(min(1, bandVals[i] * gain)) + } + for i in 0..<3 { + if bandTargets[i] >= self.visualizerBars[i] { + self.visualizerBars[i] = bandTargets[i] // instant attack + } else { + self.visualizerBars[i] += (bandTargets[i] - self.visualizerBars[i]) * 0.4 + } + } + KeyboardIconRenderer.miniVisualizerBars = self.visualizerBars + var barsDirty = false + for i in 0..<3 where abs(self.visualizerBars[i] - self.visualizerLastDrawnBars[i]) > 0.02 { + barsDirty = true + } + + // Percussion vibe: push the live per-drum hit pulses so the + // right-hand keys shake/blink with each hit. Keep repainting + // while any pulse is still fresh. + var percActive = false + if self.menuBand.percussionSplit { + let now = CACurrentMediaTime() + let pulses = self.menuBand.percussionPulses() + KeyboardIconRenderer.drumPulses = pulses + KeyboardIconRenderer.drumPulseNow = now + for p in pulses where p.at > 0 && (now - p.at) < 0.45 && p.level > 0.01 { + percActive = true + break + } + // Surface the drum trigger→render latency ~1×/sec while + // playing so we can verify it stays at/below one buffer. + if percActive { + self.percLatLogCounter += 1 + if self.percLatLogCounter % 24 == 0 { + NSLog(String(format: "MenuBand perc latency: trigger→render %.2f ms", + self.menuBand.percussionTriggerHandoffMs())) + } + } + } else if !KeyboardIconRenderer.drumPulses.isEmpty { + KeyboardIconRenderer.drumPulses = [] + } + // Only repaint the status item when one of the animated // signals has actually moved a visible amount. The phase // value updates every tick but the per-bar wiggle is too @@ -1350,14 +1541,15 @@ let midi = CGFloat(KeyboardIconRenderer.midiActivityFlash) let metro = CGFloat(KeyboardIconRenderer.metronomeFlash) let levelStep: CGFloat = 0.01 let flashStep: CGFloat = 0.02 - let dirty = - abs(level - self.visualizerLastDrawnLevel) > levelStep + let dirty = percActive || barsDirty + || abs(level - self.visualizerLastDrawnLevel) > levelStep || abs(midi - self.visualizerLastDrawnMidiFlash) > flashStep || abs(metro - self.visualizerLastDrawnMetroFlash) > flashStep if dirty { self.visualizerLastDrawnLevel = level self.visualizerLastDrawnMidiFlash = midi self.visualizerLastDrawnMetroFlash = metro + self.visualizerLastDrawnBars = self.visualizerBars self.updateIcon() } } @@ -1369,16 +1561,33 @@ /// Watch shift state globally + locally so the menubar piano can /// uppercase its letter labels while the user holds shift. The /// uppercase letters are the visual cue that linger / bell-ring /// mode is armed; lowercase = normal. + /// Map the current modifier mask to which keyboard half should render + /// uppercase. Mirrors `MenuBandController.lingerSide`: the left/right + /// shift are told apart by the device-dependent bits (0x2 / 0x4) the + /// plain `.shift` mask collapses. A single shift sides; caps lock or + /// both shifts arm the whole board. + private static func uppercaseSide(for flags: NSEvent.ModifierFlags) + -> KeyboardIconRenderer.UppercaseSide { + let raw = UInt64(flags.rawValue) + let left = (raw & 0x0000_0002) != 0 + let right = (raw & 0x0000_0004) != 0 + if left && !right { return .left } + if right && !left { return .right } + if flags.contains(.shift) || flags.contains(.capsLock) { return .all } + return .none + } + private func startShiftStateMonitors() { let handler: (NSEvent) -> Void = { [weak self] event in guard let self = self else { return } - // Caps lock latches the mode; shift is the momentary. Either - // arms linger and shows uppercase labels. + // Caps lock latches the mode; shift is the momentary. The + // shift *side* picks which half uppercases (mirrors the sided + // linger); caps / both shifts uppercase the whole board. let caps = event.modifierFlags.contains(.capsLock) - let armed = event.modifierFlags.contains(.shift) || caps + let side = Self.uppercaseSide(for: event.modifierFlags) var dirty = false - if KeyboardIconRenderer.labelsUppercase != armed { - KeyboardIconRenderer.labelsUppercase = armed + if KeyboardIconRenderer.labelsUppercaseSide != side { + KeyboardIconRenderer.labelsUppercaseSide = side dirty = true } if KeyboardIconRenderer.lingerCapsLatched != caps { @@ -1405,10 +1614,10 @@ // until something toggles. Read the current modifier mask once // at startup to seed the renderer correctly. let initial = NSEvent.modifierFlags let initialCaps = initial.contains(.capsLock) - let initialArmed = initial.contains(.shift) || initialCaps + let initialSide = Self.uppercaseSide(for: initial) var initialDirty = false - if KeyboardIconRenderer.labelsUppercase != initialArmed { - KeyboardIconRenderer.labelsUppercase = initialArmed + if KeyboardIconRenderer.labelsUppercaseSide != initialSide { + KeyboardIconRenderer.labelsUppercaseSide = initialSide initialDirty = true } if KeyboardIconRenderer.lingerCapsLatched != initialCaps { @@ -1469,6 +1678,55 @@ matching: [.flagsChanged, .keyDown] ) { handler($0); return $0 } } + /// Single bare right-⌥ TAP latches the RIGHT half of the board to + /// percussion (tap again to release). Rides the same .flagsChanged + /// stream the shift / right-⌘ monitors already use (no new permission). + private func startRightOptionTapMonitor() { + let handler: (NSEvent) -> Void = { [weak self] event in + guard let self = self else { return } + guard event.type == .flagsChanged, + event.keyCode == Self.rightOptionKeyCode else { return } + // .flagsChanged fires on press AND release; `.option` is set + // on the down edge, cleared on the up. Toggle on down only. + let isDown = event.modifierFlags.contains(.option) + guard isDown else { return } + // Bare right-⌥ only — ignore chords (⌥⌘, ⌥⇧, …) so an Option + // used as a real modifier in another app doesn't flip drums. + let mask = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + guard mask == .option else { return } + self.toggleRightPercussionFromShortcut() + } + rightOptionMonitorGlobal = NSEvent.addGlobalMonitorForEvents( + matching: [.flagsChanged] + ) { handler($0) } + rightOptionMonitorLocal = NSEvent.addLocalMonitorForEvents( + matching: [.flagsChanged] + ) { handler($0); return $0 } + } + + /// Single bare left-⌥ TAP latches the LEFT half of the board to + /// percussion (tap again to release) — the mirror of the right-⌥ tap. + private func startLeftOptionTapMonitor() { + let handler: (NSEvent) -> Void = { [weak self] event in + guard let self = self else { return } + guard event.type == .flagsChanged, + event.keyCode == Self.leftOptionKeyCode else { return } + let isDown = event.modifierFlags.contains(.option) + guard isDown else { return } + // Bare left-⌥ only — ignore chords so a real Option modifier in + // another app doesn't flip drums. + let mask = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + guard mask == .option else { return } + self.toggleLeftPercussionFromShortcut() + } + leftOptionMonitorGlobal = NSEvent.addGlobalMonitorForEvents( + matching: [.flagsChanged] + ) { handler($0) } + leftOptionMonitorLocal = NSEvent.addLocalMonitorForEvents( + matching: [.flagsChanged] + ) { handler($0); return $0 } + } + private func applyForcedLayoutIfAny() { let raw = UserDefaults.standard.string(forKey: "forceLayout") ?? "" guard !raw.isEmpty, @@ -1717,6 +1975,9 @@ } func updateIcon() { guard let button = statusItem.button else { return } + // Keep the renderer's drum-zone coloring in sync with the live split. + KeyboardIconRenderer.percussionLeftActive = menuBand.percussionLeft + KeyboardIconRenderer.percussionRightActive = menuBand.percussionRight // Use *effective* keymap/typeMode so popover hover-preview can // override the live state without writing to UserDefaults. The // renderer keeps `imageSize` constant across keymaps (always the @@ -1756,18 +2017,26 @@ // Note: miniVisualizerLevel + miniVisualizerPhase are driven by // the visualizerAnimTimer (24fps smoothed VU motion), not from // here — overriding them on every note-event redraw would // erase the smoothing. - statusItem.length = KeyboardIconRenderer.imageSize.width // Voice-subscript override: backends that don't map to a GM // program get a glyph instead of a digit. Sample voice → "`" - // (the record key); KPBJ radio → "−1" (its conceptual slot — - // matches the "−1 KPBJ" title the popover uses). Everything - // else falls through to the 1-based GM program number. + // (the record key); KPBJ radio → its CALLSIGN ("kpbj") so the + // station keeps its identity in the menubar instead of an opaque + // "−1" slot number. The badge auto-widens to fit (voiceBadgeDigits + // below), same as 3-digit GM numbers. Everything else falls + // through to the 1-based GM program number. let voiceLabel: String? switch menuBand.instrumentBackend { case .sample: voiceLabel = "`" - case .kpbj: voiceLabel = "−1" + case .kpbj: voiceLabel = menuBand.radioStation.label default: voiceLabel = nil } + // Reserve badge width for the actual subscript so 3-digit GM + // numbers (100–128) don't crop. Must be set BEFORE sizing the slot. + let badgeText = menuBand.midiMode + ? "M" + : (voiceLabel ?? String(Int(menuBand.effectiveMelodicProgram) + 1)) + KeyboardIconRenderer.voiceBadgeDigits = badgeText.count + statusItem.length = KeyboardIconRenderer.imageSize.width button.image = KeyboardIconRenderer.image( litNotes: menuBand.litNotes, playbackLitNotes: menuBand.playbackLitNotes, @@ -1934,6 +2203,10 @@ if hoveredElement != result { hoveredElement = result updateIcon() } + // Hover intentionally does NOT reveal the pitch strip - the + // cursor merely passing over the menubar item should never pop + // a GUI. The floating piano/waveform panel appears only via an + // explicit press or the settings popover. } private func handleHoverExit() { @@ -1941,6 +2214,9 @@ if hoveredElement != nil { hoveredElement = nil updateIcon() } + // Pointer left the menubar item → hide the hover-revealed strip + // (deferred while the pointer is over the panel itself). + pianoWaveformWindowDelegate.scheduleHideFromHover() } // MARK: - Click + drag @@ -2056,19 +2332,42 @@ case .none: return } - guard let startNote = playedNote(for: startDisplayNote) else { return } - let initialPt = imagePoint(from: downEvent.locationInWindow) - let (vel0, pan0) = NoteExpression.values(for: startDisplayNote, at: initialPt) let initialShift = downEvent.modifierFlags.contains(.shift) || downEvent.modifierFlags.contains(.capsLock) - menuBand.startTapNote( - startNote, - velocity: vel0, - pan: pan0, - displayNote: startDisplayNote, - linger: initialShift - ) + + // Percussion split: a click on a right-hand key plays the drum kit + // instead of a melodic note — same split the keyboard uses. Track + // whichever voice is live (melodic note vs. drum group) so a drag + // across keys and the mouse-up release the correct one. + var currentDisplay: UInt8? + var currentPlayed: UInt8? + var currentDrumGroup: UInt64? + var currentDrumDisplay: UInt8? + + func stopCurrentVoice() { + if let c = currentPlayed { menuBand.stopTapNote(c); currentPlayed = nil } + if let g = currentDrumGroup { menuBand.percussionNoteOff(g); currentDrumGroup = nil } + if let d = currentDrumDisplay { menuBand.drumLitOff(d); currentDrumDisplay = nil } + } + func startVoice(_ display: UInt8, at pt: NSPoint, shift: Bool) { + let (v, p) = NoteExpression.values(for: display, at: pt) + if menuBand.isPercussionDisplayNote(display) { + // Shift-click accents the drum (a harder hit). + currentDrumGroup = menuBand.percussionNoteOn( + menuBand.percussionDrum(forDisplayNote: display), + velocity: v, pan: p, accent: shift) + menuBand.drumLitOn(display) + currentDrumDisplay = display + } else if let played = playedNote(for: display) { + menuBand.startTapNote(played, velocity: v, pan: p, + displayNote: display, linger: shift) + currentPlayed = played + } + currentDisplay = display + } + + startVoice(startDisplayNote, at: initialPt, shift: initialShift) // Menubar piano taps play the note ONLY — no floating // panel pop-up. Reserve the panel for the popover-paired // flow and the explicit LED-chip / shortcut entry points. @@ -2084,8 +2383,6 @@ // `showIfNeeded()` which would auto-open the panel // here, which is exactly what the user doesn't want. pianoWaveformWindowDelegate.refresh() } - var currentDisplay: UInt8? = startDisplayNote - var currentPlayed: UInt8? = startNote while let next = NSApp.nextEvent( matching: [.leftMouseDragged, .leftMouseUp], until: .distantFuture, @@ -2093,33 +2390,23 @@ inMode: .eventTracking, dequeue: true ) { if next.type == .leftMouseUp { - if let c = currentPlayed { menuBand.stopTapNote(c) } + stopCurrentVoice() break } let pt = imagePoint(from: next.locationInWindow) let hoveredDisplay = KeyboardIconRenderer.noteAt(pt) if hoveredDisplay != currentDisplay { - if let prev = currentPlayed { menuBand.stopTapNote(prev) } - if let nxtDisplay = hoveredDisplay, - let nxtPlayed = playedNote(for: nxtDisplay) { - let (v, p) = NoteExpression.values(for: nxtDisplay, at: pt) + stopCurrentVoice() + if let nxtDisplay = hoveredDisplay { // Sample shift+capslock state per-note during the drag // so the user can shift-press, release shift mid-drag, // and still get linger from a latched caps lock. let shiftNow = next.modifierFlags.contains(.shift) || next.modifierFlags.contains(.capsLock) - menuBand.startTapNote( - nxtPlayed, - velocity: v, - pan: p, - displayNote: nxtDisplay, - linger: shiftNow - ) - currentPlayed = nxtPlayed + startVoice(nxtDisplay, at: pt, shift: shiftNow) } else { - currentPlayed = nil + currentDisplay = nil } - currentDisplay = hoveredDisplay } else if let c = currentPlayed, let display = currentDisplay { let (_, p) = NoteExpression.values(for: display, at: pt) menuBand.updateTapPan(c, pan: p) @@ -2320,6 +2607,10 @@ statusItem.length = KeyboardIconRenderer.imageSize.width updateIcon() } + @objc private func handlePercussionSplitToggled(_ note: Notification) { + menuBand.reloadPercussionSplit() + } + @objc private func handleShowAboutNotification(_ note: Notification) { debugLog("handleShowAboutNotification received") DispatchQueue.main.async { [weak self] in @@ -2378,6 +2669,13 @@ let panel = MenuBandPopoverPanel( content: vc.view, contentSize: contentSize) + // Grow / shrink the whole popover when the notation staff + // slides in or out (metronome on/off), keeping the top edge + // pinned under the menubar. + vc.onRequestResize = { [weak panel] size in + panel?.resizeContent(to: size, animated: true) + } + // Show the floating piano FIRST so its frame is known and // we can flush the popover's left edge against the // floating panel's right edge. @@ -2393,7 +2691,9 @@ let imgSize = KeyboardIconRenderer.imageSize let bb = button.bounds let xOff = (bb.width - imgSize.width) / 2.0 let latch = KeyboardIconRenderer.settingsRectPublic - let gearLocal = NSPoint(x: xOff + latch.midX, y: 0) + // latch is in base coords; the displayed icon is magnified, so + // scale the gear center to land the popover arrow correctly. + let gearLocal = NSPoint(x: xOff + latch.midX * KeyboardIconRenderer.iconScale, y: 0) let gearWindow = button.convert(gearLocal, to: nil) let gearScreen = buttonWindow.convertPoint(toScreen: gearWindow) let buttonScreenFrame = buttonWindow.convertToScreen(button.frame) @@ -2441,29 +2741,23 @@ // key the liquid popover is in the right place" — // fixing the cause here. pianoWaveformWindowDelegate.refresh() - // KidLisp TV anchors against the LEFT floating panel's - // *post-refresh* frame. Order matters: if we did this before - // refresh(), the TV would pin to the predicted position and - // the left column would then slide right, leaving the TV - // stranded to its left. - if let leftPanel = pianoWaveformWindowDelegate.activeWindow, - leftPanel.isVisible { - let tvPanel = KidLispTVPanel(width: leftPanel.frame.width) + // KidLisp TV now hangs flush off the bottom of the popover + // (the settings panel), not under the left player column — + // same width as the popover so it reads as the popover's + // own screen, edge to edge. + do { + let tvPanel = KidLispTVPanel(width: panel.frame.width) // Attach BEFORE anchoring. addChildWindow nudges a child // window so it sits "below" its parent if their frames - // don't intersect — which slides the TV over toward the - // popover (the parent), away from the left column. Set - // the final frame last so it's authoritative. + // don't intersect; set the final frame last so it's + // authoritative. panel.addChildWindow(tvPanel, ordered: .below) - tvPanel.anchor(below: leftPanel.frame) + tvPanel.anchor(belowRightAlignedTo: panel.frame) kidlispTVPanel = tvPanel - // Restore the last-picked source so the user's choice - // sticks across popover open/close cycles. - if let saved = UserDefaults.standard.string( - forKey: Self.kidlispTVSourceDefaultsKey - ), !saved.isEmpty { - tvPanel.tv.setSource(saved) - } + // Restore the shared current piece so the TV (and the + // floating web window) stay in sync across open/close. + tvPanel.tv.show(code: KidLispState.shared.code, + source: KidLispState.shared.source) // Click on the screen → "$ pieces" chooser menu. Bound // here (not in the panel init) because the chooser // needs AppDelegate state (cache + persistence). @@ -2603,9 +2897,13 @@ // keyboard note. We gate on trackpadTouchActive (NSTouch // begun) so a passing MOUSE move can't reactivate the bend // — only an actual finger on the trackpad. let shift = event.modifierFlags.contains(.shift) - let chartUp = pitchBendOverlay?.isVisible ?? false - let reengageViaTrackpad = chartUp && trackpadTouchActive - guard menuBand.keyboardNotesHeld || shift || reengageViaTrackpad else { return } + // Latched pitch mode: once engaged, EVERY trackpad move bends — + // no held note, finger, or shift required. The mode only ends on + // Esc or focus loss (endPitchBendSession). Until it's latched, + // fall back to the legacy gate (held note or shift) so a passing + // mouse move can't bend out of nowhere. + guard pitchBendModeLatched + || menuBand.keyboardNotesHeld || shift else { return } let dy = Float(event.deltaY) let dx = Float(event.deltaX) guard dy != 0 || dx != 0 else { return } @@ -2625,13 +2923,12 @@ fxX = max(Float(-1), min(Float(1), fxX + dx * xSens)) echoAmount = max(Float(0), fxX) spaceAmount = max(Float(0), -fxX) cancelFxRelease() - bendAmount += bendDelta - // No clamp — the trackpad accumulator can swing past ±1 so - // the sample voice (vari-speed) and the staff display - // continue scaling beyond an octave. MIDI bend message - // saturates naturally at the 14-bit boundary inside - // `sendPitchBend`; receiver-side bend range determines the - // audible cap (RPN 0/0 = 12 announces an octave on start). + // Clamp the accumulator to ±bendRange so it can't wind up past the + // edge: at the top/bottom, the instant you move the other way it + // pulls straight back (no dead travel unwinding old overshoot). The + // overlay puck normalizes against the same range, so the grid edge + // IS the cap. ±bendRange = two octaves down / up. + bendAmount = max(-Self.bendRange, min(Self.bendRange, bendAmount + bendDelta)) menuBand.setBend(amount: bendAmount, allChannels: shift) menuBand.setSpace(amount: spaceAmount) menuBand.setEcho(amount: echoAmount) @@ -2723,16 +3020,47 @@ /// a stable reference frame for the whole gesture and the /// post-release spring-back. private func currentFxCursorImage() -> NSImage { // Pass the bipolar fxX so the puck slides both sides of - // center — positive (right) is echo, negative (left) is - // space/reverb. PitchBendCursor renders the signed value - // directly on the puck X axis. - PitchBendCursor.image(forBend: bendAmount, echo: fxX) + // center — positive (right) is echo, negative (left) is the + // closer/tinier proximity filter. Normalize bend by bendRange so + // the puck reaches the grid edge exactly at the ±bendRange cap + // (PitchBendCursor clamps the normalized value to ±1 internally). + PitchBendCursor.image(forBend: bendAmount / Self.bendRange, echo: fxX) } private func showPitchBendOverlay() { let overlay = ensurePitchBendOverlay() overlay.show(image: currentFxCursorImage(), - atScreenPoint: pitchBendLockScreenPoint) + atScreenPoint: safePitchBendAnchor(for: pitchBendLockScreenPoint)) + } + + /// The XY-pad chart normally anchors directly under the frozen + /// cursor. But if that point is up in the system menu bar — or the + /// chart (centered on the point) would poke up across the menu bar + /// and over the Menu Band keys — drop it just below the menu bar + /// while staying under the cursor's horizontal position, so the grid + /// stays local to the mouse without crossing the menu bar or + /// overlapping the keys. The bend gesture reads relative trackpad + /// motion, not the chart position, so this is purely visual. + private func safePitchBendAnchor(for point: NSPoint) -> NSPoint { + let screen = NSScreen.screens.first { NSMouseInRect(point, $0.frame, false) } + ?? NSScreen.main + ?? NSScreen.screens.first + guard let screen else { return point } + let visible = screen.visibleFrame + // Chart top edge in bottom-left-origin coords = point.y + half. + // The menu bar occupies the band above `visible.maxY`. + let halfHeight = PitchBendCursor.cursorSize.height / 2 + let halfWidth = PitchBendCursor.cursorSize.width / 2 + if point.y + halfHeight > visible.maxY { + // Tuck the chart just under the menu bar, keeping it under + // the cursor's x (clamped so it stays fully on screen). + let gap: CGFloat = 8 + let y = visible.maxY - halfHeight - gap + let x = min(max(point.x, visible.minX + halfWidth), + visible.maxX - halfWidth) + return NSPoint(x: x, y: y) + } + return point } private func updatePitchBendOverlayImage() { @@ -2758,22 +3086,50 @@ /// start the post-release hold→linear-ramp on the fx. Shared by /// the two end-of-gesture triggers: all keyboard notes released /// with no finger on the trackpad, or the trackpad finger /// lifting. Idempotent. + /// Arm (or re-arm) the short grace timer that tears the pitch-bend + /// graphic down once the last keyboard note has lifted. Cancelled if a + /// note comes back (see onLitChanged). Shift is the deliberate + /// exception — it keeps bending still-ringing notes with no key down. + private func scheduleGraphicEndIfNoKeyHeld() { + pitchBendEndTimer?.invalidate() + let t = Timer(timeInterval: 0.12, repeats: false) { [weak self] _ in + guard let self = self else { return } + self.pitchBendEndTimer = nil + let shift = NSEvent.modifierFlags.contains(.shift) + if !self.menuBand.keyboardNotesHeld && !shift { + self.endPitchBendSession() + } + } + RunLoop.main.add(t, forMode: .common) + pitchBendEndTimer = t + } + private func endPitchBendSession() { - guard pitchBendCursorLocked else { return } + pitchBendEndTimer?.invalidate() + pitchBendEndTimer = nil + // Clear the latched mode regardless of cursor-lock state so an + // Esc / focus-loss always fully exits, even if the lock flag was + // somehow already cleared. + pitchBendModeLatched = false + guard pitchBendCursorLocked else { + // Mode was latched but cursor not currently locked — still + // make sure the overlay is gone and fx spring back. + pitchBendOverlay?.dismiss() + return + } stopPitchBendCursorPin() if pitchBendCursorPushed { NSCursor.pop() pitchBendCursorPushed = false } - // Keep the chart overlay frozen at the lock point through - // the release ramp so the puck visibly slides back to - // center as bend/echo decay — `startFxRamp` (or the no-fx - // early return inside `startFxRelease`) dismisses it. The - // real system cursor comes back now; the chart floats - // above on the screenSaver level so both read at once. + // Hide the chart the instant the key lifts — the user asked for + // no lingering graph. The fx (bend/space/echo) still spring back + // audibly via `startFxRelease`; only the on-screen overlay goes + // away immediately. The real system cursor comes back now. showSystemCursorIfNeeded() CGAssociateMouseAndMouseCursorPosition(1) pitchBendCursorLocked = false + pitchBendOverlay?.dismiss() startFxRelease() } @@ -3033,7 +3389,7 @@ action: #selector(loadKidLispPiece(_:)), keyEquivalent: "" ) item.target = self - item.representedObject = piece.source + item.representedObject = piece let attr = NSMutableAttributedString() attr.append(NSAttributedString( string: "$\(piece.code)", @@ -3074,14 +3430,26 @@ return menu } @objc private func loadKidLispPiece(_ sender: NSMenuItem) { - guard let src = sender.representedObject as? String else { return } - kidlispTVPanel?.tv.setSource(src) - UserDefaults.standard.set(src, forKey: Self.kidlispTVSourceDefaultsKey) + guard let piece = sender.representedObject as? KidLispChooserItem else { return } + KidLispState.shared.select(code: piece.code, source: piece.source) + applyKidLisp() } @objc private func resetKidLispPiece(_ sender: Any?) { - kidlispTVPanel?.tv.setSource(KidLispTVPanel.defaultSource) - UserDefaults.standard.removeObject(forKey: Self.kidlispTVSourceDefaultsKey) + KidLispState.shared.reset() + applyKidLisp() + } + + /// Push the shared current piece to every live surface: the popover + /// TV always, and the floating web window only when a real `$code` + /// is selected (so the default/raw piece doesn't hijack the window + /// away from the AC homepage). + private func applyKidLisp() { + let state = KidLispState.shared + kidlispTVPanel?.tv.show(code: state.code, source: state.source) + if state.hasCode, let code = state.code { + AestheticWebWindowController.current()?.load(piece: "$\(code)") + } } @objc private func reloadKidLispChooser(_ sender: Any?) { diff --git a/slab/menuband/Sources/MenuBand/BravuraFont.swift b/slab/menuband/Sources/MenuBand/BravuraFont.swift new file mode 100644 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/BravuraFont.swift @@ -0,0 +1,108 @@ +import AppKit +import CoreText + +/// Loads + vends the bundled Bravura music font (SMuFL) so the +/// notation staff can draw real engraving glyphs — clefs, noteheads, +/// flags, rests, time-signature digits — instead of hand-rolled +/// bezier approximations. Bravura is the reference SMuFL font (SIL +/// OFL; see Resources/Bravura-OFL.txt) and is the same typeface +/// Verovio renders the exported PDF with, so the live staff and the +/// PDF read identically. +enum Bravura { + /// SMuFL recommended glyph codepoints, by role. (The Private Use + /// Area assignments are the SMuFL standard, stable across fonts.) + enum Glyph: Character { + case gClef = "\u{E050}" // treble clef + case noteheadBlack = "\u{E0A4}" // filled head (quarter & shorter) + case noteheadHalf = "\u{E0A3}" // open head + space for stem + case noteheadWhole = "\u{E0A2}" // whole note (no stem) + case flag8thUp = "\u{E240}" + case flag8thDown = "\u{E241}" + case flag16thUp = "\u{E242}" + case flag16thDown = "\u{E243}" + case accidentalSharp = "\u{E262}" + case accidentalFlat = "\u{E260}" + case accidentalNatural = "\u{E261}" + case restWhole = "\u{E4E3}" + case restHalf = "\u{E4E4}" + case restQuarter = "\u{E4E5}" + case rest8th = "\u{E4E6}" + case rest16th = "\u{E4E7}" + case timeSig0 = "\u{E080}" // 1..9 follow contiguously + case timeSigCommon = "\u{E08A}" // 4/4 "C" + case timeSigCut = "\u{E08B}" // cut time + } + + /// Time-signature digit glyph for a single digit 0–9. + static func timeSigDigit(_ d: Int) -> Character { + let base = UnicodeScalar("\u{E080}").value + let clamped = UInt32(max(0, min(9, d))) + return Character(UnicodeScalar(base + clamped)!) + } + + private static let fontName = "Bravura" + private static var registered = false + + /// Register the bundled OTF with CoreText (once) so `NSFont(name:)` + /// can find it even though it isn't installed system-wide. + static func ensureRegistered() { + guard !registered else { return } + registered = true + guard let url = Bundle.module.url(forResource: "Bravura", + withExtension: "otf") else { + NSLog("Bravura: Bravura.otf missing from bundle") + return + } + var err: Unmanaged? + if !CTFontManagerRegisterFontsForURL(url as CFURL, .process, &err) { + // Already-registered is fine (e.g. a prior popover open); + // anything else we log but soldier on with the fallback. + NSLog("Bravura: register failed: \(String(describing: err))") + } + } + + /// An `NSFont` of the music face at `size` points. Falls back to + /// the system font if registration somehow failed, so callers + /// never crash (they just draw tofu, which is visible in testing). + static func font(ofSize size: CGFloat) -> NSFont { + ensureRegistered() + return NSFont(name: fontName, size: size) + ?? NSFont.systemFont(ofSize: size) + } + + /// Draw a single SMuFL glyph with its left edge at `origin.x` and + /// its baseline at `origin.y`. Bravura's glyphs are designed on a + /// "staff space = 0.25 em" metric, so a font whose point size is + /// `4 * staffSpace` makes one staff space == `lineSpacing`. + static func draw(_ glyph: Glyph, at origin: NSPoint, + staffSpace: CGFloat, color: NSColor) { + draw(String(glyph.rawValue), at: origin, + staffSpace: staffSpace, color: color) + } + + static func draw(_ text: String, at origin: NSPoint, + staffSpace: CGFloat, color: NSColor) { + let f = font(ofSize: staffSpace * 4) + let attrs: [NSAttributedString.Key: Any] = [ + .font: f, .foregroundColor: color, + ] + NSAttributedString(string: text, attributes: attrs) + .draw(at: NSPoint(x: origin.x, y: origin.y)) + } + + /// Advance width of a glyph string at the given staff-space size — + /// used to place a notehead's stem flush to its right edge and to + /// center time-signature digits. + static func width(_ text: String, staffSpace: CGFloat) -> CGFloat { + let f = font(ofSize: staffSpace * 4) + return NSAttributedString(string: text, attributes: [.font: f]) + .size().width + } + + /// Vertical offset from a glyph's baseline to the visual center of + /// a notehead, so we can place a head centered on a staff line. + /// Bravura noteheads sit ~0.5 staff-space above the baseline. + static func noteheadCenterOffset(staffSpace: CGFloat) -> CGFloat { + staffSpace * 0.5 + } +} diff --git a/slab/menuband/Sources/MenuBand/InstrumentMapView.swift b/slab/menuband/Sources/MenuBand/InstrumentMapView.swift --- a/slab/menuband/Sources/MenuBand/InstrumentMapView.swift +++ b/slab/menuband/Sources/MenuBand/InstrumentMapView.swift @@ -22,6 +22,8 @@ static let cols = 8 static let rows = 16 static let cellW: CGFloat = 28 static let cellH: CGFloat = 14 + /// Width of each radio-station cell in the top row (left of MIDI OUT). + static let stationCellW: CGFloat = 40 /// Special "instrument 0" addressable slot that lives above the /// 1-128 grid. Picking it engages MIDI passthrough mode (notes go /// out the virtual port instead of the internal synth). Replaces @@ -60,6 +62,17 @@ /// palette. Returns true when the key has been consumed as a /// music event. var onMusicKey: ((UInt16, Bool, Bool, NSEvent.ModifierFlags) -> Bool)? + /// Radio-station chooser cells that sit in the top row, to the LEFT of + /// the MIDI-OUT cell. Set by the host view; empty = no radio cells. + var radioStations: [RadioStation] = [] { didSet { needsDisplay = true } } + /// True while the radio ("voice −1") backend is the active instrument — + /// fills the selected station cell, like `midiModeActive` fills MIDI OUT. + var radioBackendActive: Bool = false { didSet { needsDisplay = true } } + /// Which station id is currently tuned (highlighted when active). + var selectedRadioStationID: String? { didSet { needsDisplay = true } } + /// Fires when the user clicks a radio-station cell. + var onRadioCommit: ((RadioStation) -> Void)? + private var trackingArea: NSTrackingArea? override var isFlipped: Bool { true } // top-down rows, reading order @@ -98,6 +111,9 @@ stringForToolTip tag: NSView.ToolTipTag, point: NSPoint, userData data: UnsafeMutableRawPointer? ) -> String { + if let i = radioStationIndex(at: point) { + return "\(radioStations[i].name) - click to play the live radio as voice −1" + } if isMidiOutHit(point) { return "0 MIDI OUT - route notes to the virtual MIDI port; local synth is muted" } @@ -120,10 +136,31 @@ width: Self.cellW, height: Self.cellH) } - /// Full-width "0 MIDI OUT" cell at the top of the chooser. Hit-test - /// is exclusive of the patch grid below. + /// Width reserved on the left of the top row for the radio-station cells. + private var radioStripWidth: CGFloat { + CGFloat(radioStations.count) * Self.stationCellW + } + + private func radioStationRect(_ i: Int) -> NSRect { + NSRect(x: CGFloat(i) * Self.stationCellW, y: 0, + width: Self.stationCellW, height: Self.midiOutH) + } + + /// Radio-cell index under a point in the top row, or nil. + private func radioStationIndex(at point: NSPoint) -> Int? { + guard point.y >= 0, point.y < Self.midiOutH else { return nil } + guard point.x >= 0, point.x < radioStripWidth else { return nil } + let i = Int(point.x / Self.stationCellW) + return (i >= 0 && i < radioStations.count) ? i : nil + } + + /// "0 MIDI OUT" cell — occupies the top row to the RIGHT of the radio + /// strip (full width when there are no radio cells). Hit-test is + /// exclusive of the radio cells and the patch grid below. private var midiOutRect: NSRect { - NSRect(x: 0, y: 0, width: bounds.width, height: Self.midiOutH) + let x = radioStripWidth + return NSRect(x: x, y: 0, + width: max(0, bounds.width - x), height: Self.midiOutH) } private func program(at point: NSPoint) -> Int? { @@ -249,6 +286,34 @@ str.draw(at: NSPoint(x: midiR.midX - size.width / 2, y: midiR.midY - size.height / 2)) } + // Radio-station cells in the top row, to the left of MIDI OUT. Teal + // (vs. the MIDI cell's accent) so the radio strip reads distinctly; + // the tuned station fills solid while the radio backend is active. + for (i, st) in radioStations.enumerated() { + let rr = radioStationRect(i) + guard rr.intersects(dirtyRect) else { continue } + let active = radioBackendActive && selectedRadioStationID == st.id + let teal = NSColor.systemTeal + let cap = NSBezierPath(roundedRect: rr.insetBy(dx: 1.75, dy: 1.5), + xRadius: 3, yRadius: 3) + if active { + teal.withAlphaComponent(0.85).setFill(); cap.fill() + teal.setStroke(); cap.lineWidth = 1.4; cap.stroke() + } else { + teal.withAlphaComponent(0.10).setFill(); cap.fill() + teal.withAlphaComponent(0.55).setStroke(); cap.lineWidth = 0.8; cap.stroke() + } + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 9, weight: .semibold), + .foregroundColor: (active ? NSColor.white : NSColor.labelColor) + .withAlphaComponent(active ? 1.0 : 0.8), + .kern: 0.2, + ] + let str = NSAttributedString(string: st.label, attributes: attrs) + let sz = str.size() + str.draw(at: NSPoint(x: rr.midX - sz.width / 2, y: rr.midY - sz.height / 2)) + } + let selectedRow = Int(selectedProgram) / Self.cols let selectedCol = Int(selectedProgram) % Self.cols @@ -376,6 +441,12 @@ // Take key focus on click so arrow-key navigation works // immediately after the user picks an initial cell. window?.makeFirstResponder(self) let pt = convert(event.locationInWindow, from: nil) + // Radio-station cell — tune the radio voice to that station. Like + // MIDI OUT, there's no audible preview, so it bypasses the drag path. + if let i = radioStationIndex(at: pt) { + onRadioCommit?(radioStations[i]) + return + } // MIDI OUT cell — slot 0. Click toggles MIDI passthrough mode // via the controller. Bypasses the drag/preview path because // there's no audible preview to start. diff --git a/slab/menuband/Sources/MenuBand/KPBJRadioStream.swift b/slab/menuband/Sources/MenuBand/KPBJRadioStream.swift --- a/slab/menuband/Sources/MenuBand/KPBJRadioStream.swift +++ b/slab/menuband/Sources/MenuBand/KPBJRadioStream.swift @@ -5,36 +5,90 @@ import CoreMedia import Darwin /// Live KPBJ.FM Icecast stream surfaced as a "voice -1" backend for the -/// menuband synth: pads play the live audio at varying pitch (middle C = -/// unity rate, up/down by 2^(semitones/12) — vinyl-varispeed semantics, so -/// highs run faster and lows run slower). +/// menuband synth: pads play the live audio pitched per note (middle C = +/// unpitched/live, up/down by semitones). Pitch is shifted INDEPENDENTLY +/// of speed via AVAudioUnitTimePitch — a high note is higher, not faster — +/// so every voice consumes the stream at real time and stays locked to the +/// live edge (no tape-style speed-up, no drift, no replay). (Earlier this +/// used AVAudioUnitVarispeed, which coupled pitch to speed: pitched-up +/// notes ran the buffer faster, caught the live write head, and re-anchored +/// backwards — audible as a repeating loop on held notes.) /// /// Architecture: -/// AVPlayer ──► MTAudioProcessingTap ──► ring AVAudioPCMBuffer (~4 s) +/// URLSession byte stream ──► AudioFileStream (MP3 parse) ──► AudioConverter +/// │ (PCM) +/// ▼ +/// ring AVAudioPCMBuffer (~8 s) /// │ /// per active note ▼ -/// AVAudioPlayerNode ──► Varispeed ──► voiceMixer ─┐ +/// AVAudioPlayerNode ──► TimePitch ──► voiceMixer ─┐ /// ▼ /// crossfadeMixer ──► output /// ▲ /// AVAudioSourceNode (white noise) ──► noiseEQ ──────────┘ /// +/// NOTE: `stream.kpbj.fm` is an endless, container-less Icecast MP3 +/// (no duration, no moov/track table). `AVPlayer` can *play* it but +/// `AVAsset` never exposes an `AVAssetTrack`, so an `MTAudioProcessingTap` +/// can never attach — the old design left the ring empty and only the +/// synthesized AM static was ever audible. We decode the MP3 byte stream +/// ourselves instead, which works for an infinite stream. +/// /// `crossfadeMixer` blends pitched-stream and shaped-static based on a /// `signalStrength` value (1.0 = clean radio, 0.0 = pure static). Strength /// decays toward 0 when fresh tap frames stop arriving, so a network stall /// fades into static the way a real AM dial does. Static volume is also /// modulated by total NIC bytes/sec — quiet network = soft hiss; bursty /// traffic = crackle. -final class KPBJRadioStream { - private static let streamURL = URL(string: "https://stream.kpbj.fm/")! +/// A live MP3 radio station the synth can tune into as "voice −1". All +/// stations are plain Icecast MP3 (KPBJ direct; NTS via a 302 the URLSession +/// follows), so they share the same decode path — only the URL changes. +struct RadioStation: Equatable { + let id: String // persisted + typed-command name ("kpbj", "nts1", …) + let label: String // short grid-cell label ("KPBJ", "NTS1") + let name: String // full readout / tooltip ("KPBJ.FM", "NTS 1") + let url: URL + + static let kpbj = RadioStation( + id: "kpbj", label: "KPBJ", name: "KPBJ.FM", + url: URL(string: "https://stream.kpbj.fm/")!) + static let nts1 = RadioStation( + id: "nts1", label: "NTS1", name: "NTS 1", + url: URL(string: "https://stream-relay-geo.ntslive.net/stream")!) + static let nts2 = RadioStation( + id: "nts2", label: "NTS2", name: "NTS 2", + url: URL(string: "https://stream-relay-geo.ntslive.net/stream2")!) + + /// Display order in the chooser, left → right. + static let all: [RadioStation] = [.kpbj, .nts1, .nts2] + static func by(id: String) -> RadioStation { all.first { $0.id == id } ?? .kpbj } +} + +final class KPBJRadioStream: NSObject, URLSessionDataDelegate { + /// The station currently tuned. Defaults to KPBJ; `setStation` retunes. + private var station: RadioStation = .kpbj /// Internal sample rate. Stream gets decoded into this rate; ring, /// voices, and noise all run here. private let sampleRate: Double = 44_100 - /// Ring length. 4 s leaves headroom even at rate 2.0 (top octave) so a - /// held pad's read head never catches the live write head. - private let ringSeconds: Double = 4.0 + /// Ring length. 8 s of headroom so a pitched-DOWN voice (rate < 1, read + /// head falling behind the live write head) can hold for a while before + /// it wraps into not-yet-written territory. + private let ringSeconds: Double = 8.0 + /// How far behind the live write head a voice reads — its target + /// latency, and where it re-anchors to when it catches up. Small on + /// purpose: the radio backend should track the LIVE stream, not replay + /// a multi-second-old recording. The earlier design started 3 s behind + /// and re-anchored 3 s back on catch-up, so a held note replayed the + /// same 3 s span on a loop — the "it plays then plays it again" repeat. + /// Reading ~`liveLagSeconds` behind keeps every note essentially live; + /// at unity rate the read head tracks the stream continuously and never + /// re-anchors. It's scaled by playback rate per voice (see `noteOn`) so + /// a pitched-up note — which drains the ring faster than it fills — + /// gets a little extra runway before it has to re-anchor, keeping the + /// (now sub-chunk-sized) repeat as rare as the pitch allows. + private let liveLagSeconds: Double = 0.35 /// Stereo float32 non-interleaved — standard AVAudioEngine format. private let format: AVAudioFormat @@ -44,9 +98,20 @@ private var ringWriteFrame: Int64 = 0 private var lastTapHostTime: CFAbsoluteTime = 0 private let ringLock = NSLock() - private let player = AVPlayer() - private var playerItem: AVPlayerItem? - private var assetObserver: NSKeyValueObservation? + // MARK: - Direct MP3 decode (replaces AVPlayer + tap) + /// Network session pulling the endless Icecast MP3 as raw bytes. + private var session: URLSession? + private var dataTask: URLSessionDataTask? + /// AudioFileStream parses the MP3 byte stream into compressed packets. + private var audioFileStream: AudioFileStreamID? + /// Converts decoded source packets → our internal stereo float32. + private var converter: AVAudioConverter? + /// Source PCM format discovered from the stream (set once the parser + /// reports the data format), used to build the converter. + private var sourceFormat: AVAudioFormat? + /// Compressed packets awaiting conversion, with their descriptions. + private var pendingPackets: [(data: Data, desc: AudioStreamPacketDescription)] = [] + private let decodeLock = NSLock() /// Per-channel voice slots. Channel matches the synth's round-robin /// melodic channels (0–7) so chord/retrigger behavior mirrors the GM @@ -66,6 +131,10 @@ /// Noise generator + bandpass + gain. White noise shaped to ~300–4000 /// Hz gives the AM-radio "small speaker" character without sounding /// like a dentist drill at full strength. private var noiseSource: AVAudioSourceNode? + /// Pitches the static with the held note (same cents as the per-note + /// voices) so the connecting hiss tunes ALONG with the stream instead of + /// sitting at a fixed pitch under it. + private let noiseTimePitch = AVAudioUnitTimePitch() private let noiseEQ = AVAudioUnitEQ(numberOfBands: 2) private let noiseGain = AVAudioMixerNode() private let voiceMixer = AVAudioMixerNode() @@ -87,23 +156,34 @@ private(set) var streaming = false private final class Voice { let node = AVAudioPlayerNode() - let varispeed = AVAudioUnitVarispeed() + /// Independent pitch shift — moves pitch WITHOUT changing speed, so + /// the node always consumes the ring at real time (rate 1.0) no + /// matter the note. That's what keeps every voice locked to the + /// live edge: nothing pitches the playback faster/slower, so the + /// read head never races ahead of (or lags behind) the write head. + let timePitch = AVAudioUnitTimePitch() var midiNote: UInt8 = 60 var held: Bool = false /// Read frame inside the global ring timeline. Each completion /// callback pumps the next chunk forward by `chunkFrames`. var readFrame: Int64 = 0 + /// Target latency behind the live write head for this voice, in + /// frames (`liveLagSeconds` scaled by the note's playback rate). + /// Used as the re-anchor point so catch-up snaps back to live, not + /// to a multi-second-old position. + var lagFrames: Int64 = 0 } - init() { + override init() { format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 2)! ringFrames = Int(sampleRate * ringSeconds) ring = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(ringFrames))! ring.frameLength = AVAudioFrameCount(ringFrames) + super.init() // Zero-init both channel buffers so silence reads cleanly before - // the first tap callback delivers audio. + // the first decoded frames arrive. if let chans = ring.floatChannelData { for c in 0...size) @@ -145,6 +225,7 @@ } noiseSource = src engine.attach(src) + engine.attach(noiseTimePitch) engine.attach(noiseEQ) engine.attach(noiseGain) engine.attach(voiceMixer) @@ -165,8 +246,10 @@ lp.filterType = .lowPass lp.frequency = 4_000 lp.bypass = false - // Wire: noiseSource → noiseEQ → noiseGain → crossfadeMixer - engine.connect(src, to: noiseEQ, format: format) + // Wire: noiseSource → noiseTimePitch → noiseEQ → noiseGain → + // crossfadeMixer. The time-pitch shifts the static with the note. + engine.connect(src, to: noiseTimePitch, format: format) + engine.connect(noiseTimePitch, to: noiseEQ, format: format) engine.connect(noiseEQ, to: noiseGain, format: format) engine.connect(noiseGain, to: crossfadeMixer, format: format) @@ -190,12 +273,28 @@ } // MARK: - Master output gate + /// Whether the radio is the active backend (synth opens it; the + /// 15 s linger closes it). The radio only actually SOUNDS while a + /// pad is also held — see `updateMasterGate`. + private var outputEnabled = false + /// Open/close the radio's master output. When closed, the radio's /// nodes contribute zero to the pre-limiter sum bus regardless of /// streaming state — used both for the "not the active backend" /// case and for the synth's 15 s linger window. func setOutputEnabled(_ enabled: Bool) { - crossfadeMixer.outputVolume = enabled ? 1.0 : 0.0 + outputEnabled = enabled + if !enabled { stopBed() } + updateMasterGate() + } + + /// Key-gate: the radio behaves like a sampler of the live stream, not + /// an always-on tuner. The master mixer — which carries both the + /// pitched per-pad voices and the AM static — only opens while at + /// least one pad is held. Lift every pad and the radio goes silent. + private func updateMasterGate() { + let anyHeld = voices.contains { $0.value.held } + crossfadeMixer.outputVolume = (outputEnabled && anyHeld) ? 1.0 : 0.0 } // MARK: - Stream lifecycle @@ -203,68 +302,133 @@ func startStreaming() { guard !streaming else { return } streaming = true - NSLog("MenuBand KPBJ: startStreaming → \(Self.streamURL.absoluteString)") + reconnectAttempts = 0 + NSLog("MenuBand radio: startStreaming (\(station.name), direct MP3 decode) → \(station.url.absoluteString)") + beginStreamRequest() - let item = AVPlayerItem(url: Self.streamURL) - playerItem = item + // No always-on bed: the radio stays silent until a pad is held + // (key-gated sampler semantics). The health timer shapes the AM + // static and tracks stream freshness for the crossfade. + startHealthTimer() + } - // Watch player item status so we can surface AVPlayer load - // failures (ATS block, DNS fail, 5xx) — without this the user - // just hears silence with no log. - assetObserver = item.observe(\.status, options: [.new, .initial]) { item, _ in - switch item.status { - case .readyToPlay: - NSLog("MenuBand KPBJ: player item readyToPlay") - case .failed: - NSLog("MenuBand KPBJ: player item FAILED — \(String(describing: item.error))") - case .unknown: - break - @unknown default: - break - } + /// Open (or re-open) the MP3 parser and kick off the network read. + /// Used both for the initial connect and for auto-reconnect after the + /// connection drops, so the parser/converter always start clean. + private func beginStreamRequest() { + // A reconnect may begin mid-frame; reset the parser + converter so + // stale half-packets can't corrupt the new MP3 byte boundary. + if let afs = audioFileStream { + AudioFileStreamClose(afs) + audioFileStream = nil } + converter = nil + sourceFormat = nil + decodeLock.lock(); pendingPackets.removeAll(); decodeLock.unlock() - // Track loading is async — install the tap when the audio track - // is ready. AVAsset.tracks isn't safe to read until status is - // .loaded. - let asset = item.asset - asset.loadValuesAsynchronously(forKeys: ["tracks"]) { [weak self, weak item] in - guard let self = self, let item = item else { return } - var error: NSError? - let status = asset.statusOfValue(forKey: "tracks", error: &error) - guard status == .loaded else { - NSLog("MenuBand KPBJ: asset tracks load failed: \(String(describing: error))") - return - } - DispatchQueue.main.async { - self.installTap(on: item) - } + // Open an AudioFileStream parser for the incoming MP3 bytes. It + // calls back with the data format (once known) and with parsed + // compressed packets, which we convert to PCM and write into the + // ring. This replaces AVPlayer, which can't expose a track for an + // endless container-less Icecast MP3. + let selfPtr = Unmanaged.passUnretained(self).toOpaque() + let status = AudioFileStreamOpen(selfPtr, + kpbjPropertyListener, + kpbjPacketsProc, + kAudioFileMP3Type, + &audioFileStream) + if status != noErr { + NSLog("MenuBand KPBJ: AudioFileStreamOpen failed err=\(status)") } - player.replaceCurrentItem(with: item) - player.volume = 1.0 - player.automaticallyWaitsToMinimizeStalling = true - player.play() + // Reuse the session across reconnects (a dropped task leaves the + // session valid); only build one on first connect or after teardown. + let session: URLSession + if let existing = self.session { + session = existing + } else { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 60 + config.requestCachePolicy = .reloadIgnoringLocalCacheData + session = URLSession(configuration: config, delegate: self, + delegateQueue: nil) + self.session = session + } + // IMPORTANT: do NOT send `Icy-MetaData: 1` — that REQUESTS the + // server to interleave ICY title metadata into the audio bytes + // every ~16 KB, which corrupts the raw MP3 the parser decodes + // (→ garbage/no PCM → only synthesized static is audible). Plain + // GET gives a clean continuous MP3. + let req = URLRequest(url: station.url) + let task = session.dataTask(with: req) + dataTask = task + task.resume() + } - // Kick off the always-on faint bed so the user hears "tuned in" - // audio even before a pad is pressed. Bed reads from the same - // ring as the pads but always at rate 1.0. - startBed() - startHealthTimer() + /// Auto-reconnect bookkeeping. The endless Icecast feed can drop on a + /// wifi blip, an audio-device switch, or a server hiccup; without this + /// the radio would go permanently silent until the backend is toggled. + private var reconnectAttempts = 0 + private var reconnectWork: DispatchWorkItem? + /// Set when we intentionally cancel the data task (e.g. retuning to a + /// new station) so the cancel's completion callback doesn't fire the + /// auto-reconnect. Consumed once. + private var suppressReconnectOnce = false + + /// Retune to a different station. If currently streaming, drop the old + /// connection and open the new URL right away — the brief gap is masked + /// by the AM static crossfade. The intentional cancel must NOT trigger + /// the auto-reconnect, hence `suppressReconnectOnce`. + func setStation(_ newStation: RadioStation) { + guard newStation != station else { return } + station = newStation + loggedFirstFrames = false + NSLog("MenuBand radio: station → \(station.name) (\(station.url.absoluteString))") + guard streaming else { return } + reconnectWork?.cancel(); reconnectWork = nil + reconnectAttempts = 0 + suppressReconnectOnce = true + dataTask?.cancel() + dataTask = nil + beginStreamRequest() + } + + /// Schedule a reconnect with capped exponential backoff. Cancelled by + /// `stopStreaming`; reset to attempt 0 once fresh frames decode again. + private func scheduleReconnect() { + reconnectAttempts += 1 + let delay = min(8.0, pow(1.6, Double(min(reconnectAttempts, 6)))) + NSLog("MenuBand KPBJ: connection lost — reconnecting in " + + "\(String(format: "%.1f", delay))s (attempt \(reconnectAttempts))") + reconnectWork?.cancel() + let work = DispatchWorkItem { [weak self] in + guard let self = self, self.streaming else { return } + self.beginStreamRequest() + } + reconnectWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) } func stopStreaming() { guard streaming else { return } NSLog("MenuBand KPBJ: stopStreaming") streaming = false + reconnectWork?.cancel() + reconnectWork = nil + reconnectAttempts = 0 healthTimer?.cancel() healthTimer = nil - player.pause() - player.replaceCurrentItem(with: nil) - playerItem?.audioMix = nil - playerItem = nil - assetObserver?.invalidate() - assetObserver = nil + dataTask?.cancel() + dataTask = nil + session?.invalidateAndCancel() + session = nil + if let afs = audioFileStream { + AudioFileStreamClose(afs) + audioFileStream = nil + } + converter = nil + sourceFormat = nil + decodeLock.lock(); pendingPackets.removeAll(); decodeLock.unlock() stopBed() // Stop active voices and reset gain so a re-enable starts clean. for (_, v) in voices { @@ -275,6 +439,153 @@ signalStrength = 0 applyMixGains() } + // MARK: - URLSessionDataDelegate + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, + didReceive data: Data) { + guard streaming, let afs = audioFileStream else { return } + data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> Void in + guard let base = raw.baseAddress else { return } + let err = AudioFileStreamParseBytes(afs, UInt32(data.count), base, []) + if err != noErr { + NSLog("MenuBand KPBJ: parse bytes err=\(err)") + } + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, + didCompleteWithError error: Error?) { + if let error = error, (error as NSError).code != NSURLErrorCancelled { + NSLog("MenuBand radio: stream task ended — \(error)") + } + // A station retune cancels the old task on purpose — that cancel + // must not bounce into a reconnect (a fresh task is already up). + if suppressReconnectOnce { + suppressReconnectOnce = false + return + } + // The feed is endless, so ANY completion while we still want to be + // streaming (network drop, device switch, server close, even a + // clean EOF) means the connection died — reconnect with backoff. + // Intentional teardown sets `streaming = false` first, so a + // cancelled task no-ops here. + guard streaming else { return } + scheduleReconnect() + } + + // MARK: - AudioFileStream → ring + + /// Called by the property listener once the parser knows the source + /// audio format. Builds the converter into our internal PCM format. + fileprivate func handleStreamPropertyReady() { + guard let afs = audioFileStream, converter == nil else { return } + var asbd = AudioStreamBasicDescription() + var size = UInt32(MemoryLayout.size) + guard AudioFileStreamGetProperty(afs, kAudioFileStreamProperty_DataFormat, + &size, &asbd) == noErr else { return } + guard let src = AVAudioFormat(streamDescription: &asbd) else { + NSLog("MenuBand KPBJ: could not build source format") + return + } + sourceFormat = src + converter = AVAudioConverter(from: src, to: format) + NSLog("MenuBand KPBJ: source format \(src) → converter ready") + } + + /// Called by the packets proc with freshly parsed compressed packets. + /// Convert them to PCM and write into the ring. + fileprivate func handlePackets(bytes: UnsafeRawPointer, byteCount: UInt32, + packetCount: UInt32, + descs: UnsafePointer?) { + guard let converter = converter, let src = sourceFormat, + let descs = descs else { return } + + // Queue the parsed packets (copying their bytes) so the converter's + // pull-callback can hand them over one batch at a time. + decodeLock.lock() + for i in 0.. 0 { writePCMToRing(out) } + if st == .endOfStream || st == .inputRanDry { break } + } + } + + /// Append a freshly decoded PCM buffer (our internal stereo float32) + /// to the ring, advancing the live write head. + private func writePCMToRing(_ buf: AVAudioPCMBuffer) { + guard let srcCh = buf.floatChannelData, let dest = ring.floatChannelData else { return } + let n = Int(buf.frameLength) + let channels = Int(format.channelCount) + let srcChannels = Int(buf.format.channelCount) + ringLock.lock() + let writeStart = Int(ringWriteFrame % Int64(ringFrames)) + for c in 0..= ringFrames { w = 0 } + } + } + ringWriteFrame &+= Int64(n) + ringLock.unlock() + lastTapHostTime = CFAbsoluteTimeGetCurrent() + // Fresh frames decoded → the connection is healthy again, so reset + // the backoff so a future drop starts from the short delay. + if reconnectAttempts != 0 { reconnectAttempts = 0 } + // One-time confirmation that decoded stream audio is actually + // reaching the ring (vs. only synthesized static playing). + if !loggedFirstFrames { + loggedFirstFrames = true + NSLog("MenuBand KPBJ: first decoded frames in ring (n=\(n)) — stream audio live") + } + } + private var loggedFirstFrames = false + // MARK: - Faint always-on bed private func startBed() { @@ -299,7 +610,10 @@ private func pumpBed() { guard bedActive else { return } let chunkFrames = AVAudioFrameCount(sampleRate * 0.150) - guard let chunk = readRing(from: &bedReadFrame, frames: chunkFrames) else { + // Bed runs at unity rate; if it ever catches up, re-anchor to a + // small 0.25 s live lag so it stays tuned to the live edge. + guard let chunk = readRing(from: &bedReadFrame, frames: chunkFrames, + reanchorLagFrames: Int64(sampleRate * 0.25)) else { return } bedNode.scheduleBuffer(chunk, @@ -309,130 +623,74 @@ DispatchQueue.main.async { self.pumpBed() } } } - private func installTap(on item: AVPlayerItem) { - guard let track = item.asset.tracks(withMediaType: .audio).first else { - NSLog("MenuBand KPBJ: no audio track on player item") - return - } - - var callbacks = MTAudioProcessingTapCallbacks( - version: kMTAudioProcessingTapCallbacksVersion_0, - clientInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()), - init: kpbjTapInit, - finalize: kpbjTapFinalize, - prepare: kpbjTapPrepare, - unprepare: kpbjTapUnprepare, - process: kpbjTapProcess) + // MARK: - Per-voice playback - // Current SDK bridges MTAudioProcessingTapCreate's out-pointer as - // `MTAudioProcessingTap?` directly — Swift handles the CFType - // retain/release automatically. (Older signatures returned an - // `Unmanaged?` requiring takeRetainedValue; - // that form no longer type-checks here.) - var tap: MTAudioProcessingTap? - let err = MTAudioProcessingTapCreate( - kCFAllocatorDefault, - &callbacks, - kMTAudioProcessingTapCreationFlag_PreEffects, - &tap) - guard err == noErr, let createdTap = tap else { - NSLog("MenuBand KPBJ: tap create failed err=\(err)") - return - } - - let inputParams = AVMutableAudioMixInputParameters(track: track) - inputParams.audioTapProcessor = createdTap - let mix = AVMutableAudioMix() - mix.inputParameters = [inputParams] - item.audioMix = mix + /// Pitch shift in cents for a MIDI note relative to middle C (60). + /// Middle C → 0 cents (unpitched/live). 100 cents per semitone. This + /// drives AVAudioUnitTimePitch's `pitch`, which shifts pitch WITHOUT + /// changing playback speed — so a high note isn't sped up, it's just + /// higher. + private func pitchCents(forNote note: UInt8) -> Float { + Float(Int(note) - 60) * 100.0 } - // MARK: - Tap → ring buffer - - /// Called from the tap process callback. `data` is interleaved or - /// non-interleaved depending on the source; we coerce to our internal - /// stereo non-interleaved float32 by reading channel-major and - /// writing into the ring's per-channel arrays. - fileprivate func ingestTapBuffers(abl: UnsafeMutableAudioBufferListPointer, - frames: Int) { - guard let dest = ring.floatChannelData else { return } - ringLock.lock() - defer { ringLock.unlock() } - - let channels = Int(format.channelCount) - let writeStart = Int(ringWriteFrame % Int64(ringFrames)) - - if abl.count >= channels { - // Non-interleaved: one AudioBuffer per channel. - for c in 0..= ringFrames { w = 0 } - } - } - } + /// Current trackpad pitch-bend in semitones. One controller unit = + /// one octave (12 semitones), matching `MenuBandSampleVoice`. Added on + /// top of each held voice's note pitch so the bend gesture slides the + /// radio's pitch live. AVAudioUnitTimePitch ignores MIDI pitch-bend, so + /// the controller routes the signed amount in-process via `setBend`. + private var bendSemitones: Float = 0 - ringWriteFrame &+= Int64(frames) - lastTapHostTime = CFAbsoluteTimeGetCurrent() + /// Apply a voice's note + current bend to its time-pitch unit, in cents, + /// clamped to AVAudioUnitTimePitch's ±2400-cent (±2 octave) range. Rate + /// is left at 1.0 always — only pitch moves. + private func applyPitch(_ v: Voice) { + let cents = pitchCents(forNote: v.midiNote) + bendSemitones * 100.0 + let clamped = max(-2400, min(2400, cents)) + v.timePitch.pitch = clamped + // Tune the static to the (most recent) held note too, so the + // connecting hiss pitches right along with the stream. + noiseTimePitch.pitch = clamped } - private func copyIntoRing(channel c: Int, - dest: UnsafePointer>, - src: UnsafePointer, - frames: Int, writeStart: Int) { - let firstChunk = min(frames, ringFrames - writeStart) - memcpy(dest[c].advanced(by: writeStart), src, - firstChunk * MemoryLayout.size) - let remaining = frames - firstChunk - if remaining > 0 { - memcpy(dest[c], src.advanced(by: firstChunk), - remaining * MemoryLayout.size) + /// Trackpad pitch-bend hook. `amount` is the controller's signed + /// bend (one unit = one octave); slides every playing voice's pitch + /// live and stashes the amount so notes started mid-bend pick it up. + func setBend(amount: Float) { + bendSemitones = amount * 12.0 + for (_, v) in voices where v.node.isPlaying { + applyPitch(v) } } - // MARK: - Per-voice playback - - /// Pitch ratio for a MIDI note relative to middle C (60). Middle C - /// returns 1.0 (unpitched/live). Each octave doubles or halves rate - /// — same as a vinyl pitch wheel. - private func ratio(forNote note: UInt8) -> Float { - Float(pow(2.0, Double(Int(note) - 60) / 12.0)) - } - func noteOn(_ midi: UInt8, velocity: UInt8 = 100, channel: UInt8 = 0) { guard attached, let engine = engine else { return } let voice = ensureVoice(for: channel) voice.midiNote = midi voice.held = true - voice.varispeed.rate = ratio(forNote: midi) + applyPitch(voice) // time-pitch only — playback stays at real time voice.node.volume = Float(velocity) / 127.0 - // Start ~250 ms behind the live write head so high-pitch reads - // (rate > 1) have headroom before catching up. + // Read just behind the live write head so the voice plays the LIVE + // stream, not a delayed recording. Because time-pitch keeps the + // node at real-time rate regardless of note, the read head tracks + // the write head exactly — it never catches up, so it never has to + // re-anchor (no repeat). The small fixed lag is just latency + // headroom against stream jitter / a busy main thread. + voice.lagFrames = Int64(Double(sampleRate) * liveLagSeconds) ringLock.lock() let live = ringWriteFrame ringLock.unlock() - voice.readFrame = max(0, live - Int64(sampleRate * 0.25)) + voice.readFrame = max(0, live - voice.lagFrames) - // Pre-queue two chunks so playback is continuous; completion - // chain feeds the rest while held. - pumpVoice(voice) - pumpVoice(voice) + // Pre-queue several chunks so a late refill can't underrun the + // node. Deeper queue = more latency tolerance against the busy + // main thread; refills run on a dedicated serial queue below. + for _ in 0..<4 { pumpVoice(voice) } if !voice.node.isPlaying { voice.node.play() } + updateMasterGate() // a pad is now held → open the master gate _ = engine } @@ -444,6 +702,7 @@ if voice.midiNote == midi || !voice.held { voice.held = false voice.node.stop() } + updateMasterGate() // last pad lifted → close the gate (silence) } func panic() { @@ -451,6 +710,7 @@ for (_, v) in voices { v.held = false v.node.stop() } + updateMasterGate() } private func ensureVoice(for channel: UInt8) -> Voice { @@ -458,27 +718,37 @@ if let v = voices[channel] { return v } let v = Voice() if let engine = engine { engine.attach(v.node) - engine.attach(v.varispeed) - engine.connect(v.node, to: v.varispeed, format: format) - engine.connect(v.varispeed, to: voiceMixer, format: format) + engine.attach(v.timePitch) + engine.connect(v.node, to: v.timePitch, format: format) + engine.connect(v.timePitch, to: voiceMixer, format: format) } voices[channel] = v return v } + + /// Dedicated serial queue for ring reads + buffer scheduling. The old + /// code refilled on `DispatchQueue.main`, which is saturated by the + /// menubar's icon-animation + hover timers — a late refill underran + /// the player node and you heard a skip. A private high-priority queue + /// keeps refills punctual regardless of UI load. + private let pumpQueue = DispatchQueue(label: "kpbj.pump", qos: .userInitiated) private func pumpVoice(_ voice: Voice) { guard voice.held else { return } - let chunkFrames = AVAudioFrameCount(sampleRate * 0.150) // 150 ms - guard let chunk = readRing(from: &voice.readFrame, frames: chunkFrames) else { + // Smaller chunks (80 ms) than the bed so the read head tracks the + // live edge finely — and so the rare re-anchor on a pitched-up hold + // repeats at most a chunk's worth, not a long swatch. + let chunkFrames = AVAudioFrameCount(sampleRate * 0.080) // 80 ms + guard let chunk = readRing(from: &voice.readFrame, frames: chunkFrames, + reanchorLagFrames: voice.lagFrames) else { return } voice.node.scheduleBuffer(chunk, completionCallbackType: .dataConsumed) { [weak self, weak voice] _ in guard let self = self, let voice = voice, voice.held else { return } - // Hop back to main queue — scheduleBuffer fires on the audio - // thread and we want to keep the lock-protected ring read on - // a regular queue. - DispatchQueue.main.async { self.pumpVoice(voice) } + // Refill on our own serial queue, NOT main — main is busy with + // menubar timers and would deliver the next chunk late (→ skip). + self.pumpQueue.async { self.pumpVoice(voice) } } } @@ -489,7 +759,8 @@ /// write head — if the voice is consuming faster than the stream is /// writing (e.g. rate > 1 holding for too long), we re-anchor 200 ms /// behind the live edge instead of reading garbage. private func readRing(from readFrame: inout Int64, - frames: AVAudioFrameCount) -> AVAudioPCMBuffer? { + frames: AVAudioFrameCount, + reanchorLagFrames: Int64) -> AVAudioPCMBuffer? { guard let out = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frames), let dest = out.floatChannelData, let src = ring.floatChannelData else { return nil } @@ -499,7 +770,12 @@ ringLock.lock() let live = ringWriteFrame let safetyFrames = Int64(sampleRate * 0.05) // 50 ms read-ahead floor if readFrame > live - safetyFrames { - readFrame = max(0, live - Int64(sampleRate * 0.20)) + // Caught up to the live edge: snap back to this voice's small + // live-lag target so it keeps playing the freshest audio. The + // repeat this introduces is at most `reanchorLagFrames` long (a + // fraction of a second) instead of the old multi-second replay — + // and at unity rate it effectively never fires. + readFrame = max(0, live - reanchorLagFrames) } let startInRing = Int((readFrame % Int64(ringFrames) + Int64(ringFrames)) % Int64(ringFrames)) let n = Int(frames) @@ -541,9 +817,16 @@ // exponentially toward 0 if frames stop arriving (network stall // or stream error). 200 ms half-life feels about right — fast // enough to be musical when the connection blips, slow enough // not to flutter on normal jitter. + // Static is a CONNECTING cue, not a per-burst meter. MP3 frames + // arrive in chunky network bursts, so a tight freshness window made + // the static breathe in between perfectly-healthy bursts. Use a + // generous 2 s window (the ring holds seconds of audio, so a sub-2 s + // gap is not a stall) and snap to clean FAST when bits arrive but + // fall to static SLOW — so streaming reads as full clean (static ≈ + // 0) and only a genuine long stall lets the hiss creep back. let age = CFAbsoluteTimeGetCurrent() - lastTapHostTime - let target: Float = age < 0.10 ? 1.0 : 0.0 - let alpha: Float = 0.20 // ~5-tick rise/fall at 30 Hz + let target: Float = age < 2.0 ? 1.0 : 0.0 + let alpha: Float = target > signalStrength ? 0.5 : 0.03 signalStrength += (target - signalStrength) * alpha // Network activity → noise gain. Sample total bytes across all @@ -562,11 +845,12 @@ private func applyMixGains() { // Clean voice path scales with signal strength. voiceMixer.outputVolume = signalStrength - // Static path: floor of 0.05 (always-present hiss when off-air), - // up to 0.6 when network is busy AND signal is weak. Multiplying - // by (1 - signal) means the static fades as the carrier locks in. - let staticBase: Float = 0.05 - let staticPeak: Float = 0.6 + // Static path: only meaningful while connecting (signalStrength + // low). Once stream bits flow, signalStrength → 1 and the (1 − + // signal) factor zeroes the hiss entirely. Kept gentle even at the + // connecting peak so it reads as "tuning in", not a blast. + let staticBase: Float = 0.03 + let staticPeak: Float = 0.30 let netGain = staticBase + (staticPeak - staticBase) * networkActivity noiseGain.outputVolume = (1.0 - signalStrength) * netGain } @@ -598,31 +882,23 @@ return total } } -// MARK: - MTAudioProcessingTap C callbacks +// MARK: - AudioFileStream C callbacks -private let kpbjTapInit: MTAudioProcessingTapInitCallback = { tap, clientInfo, tapStorageOut in - tapStorageOut.pointee = clientInfo +/// Fires when the parser discovers a stream property — we only care +/// about the data format being ready, which lets us build the converter. +private let kpbjPropertyListener: AudioFileStream_PropertyListenerProc = { + clientData, _, propertyID, _ in + guard propertyID == kAudioFileStreamProperty_ReadyToProducePackets || + propertyID == kAudioFileStreamProperty_DataFormat else { return } + let stream = Unmanaged.fromOpaque(clientData).takeUnretainedValue() + stream.handleStreamPropertyReady() } -private let kpbjTapFinalize: MTAudioProcessingTapFinalizeCallback = { _ in } - -private let kpbjTapPrepare: MTAudioProcessingTapPrepareCallback = { _, _, _ in } - -private let kpbjTapUnprepare: MTAudioProcessingTapUnprepareCallback = { _ in } - -private let kpbjTapProcess: MTAudioProcessingTapProcessCallback = { - tap, numberFrames, _, bufferListInOut, numberFramesOut, _ in - var flags: MTAudioProcessingTapFlags = 0 - let status = MTAudioProcessingTapGetSourceAudio( - tap, numberFrames, bufferListInOut, &flags, nil, numberFramesOut) - guard status == noErr else { - numberFramesOut.pointee = 0 - return - } - let storage = MTAudioProcessingTapGetStorage(tap) - let stream = Unmanaged - .fromOpaque(storage) - .takeUnretainedValue() - let abl = UnsafeMutableAudioBufferListPointer(bufferListInOut) - stream.ingestTapBuffers(abl: abl, frames: Int(numberFramesOut.pointee)) +/// Fires with freshly parsed compressed audio packets. +private let kpbjPacketsProc: AudioFileStream_PacketsProc = { + clientData, byteCount, packetCount, bytes, descs in + guard let descs = descs else { return } + let stream = Unmanaged.fromOpaque(clientData).takeUnretainedValue() + stream.handlePackets(bytes: bytes, byteCount: byteCount, + packetCount: packetCount, descs: descs) } diff --git a/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift b/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift --- a/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift +++ b/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift @@ -32,6 +32,93 @@ 9: NSColor(srgbRed: 130/255, green: 50/255, blue: 200/255, alpha: 1), // A 11: NSColor(srgbRed: 180/255, green: 80/255, blue: 255/255, alpha: 1), // B ] + /// Drum-pad colors (port of PERCUSSION_COLORS in lib/percussion.mjs), + /// indexed by pitch class. Painted over the right-hand keys while the + /// percussion split is active so the drum zone reads at a glance. + private static let percussionColorByPitchClass: [Int: NSColor] = [ + 0: NSColor(srgbRed: 220/255, green: 90/255, blue: 40/255, alpha: 1), // kick + 1: NSColor(srgbRed: 220/255, green: 150/255, blue: 240/255, alpha: 1), // crash + 2: NSColor(srgbRed: 220/255, green: 180/255, blue: 110/255, alpha: 1), // snare + 3: NSColor(srgbRed: 240/255, green: 160/255, blue: 220/255, alpha: 1), // splash + 4: NSColor(srgbRed: 240/255, green: 220/255, blue: 130/255, alpha: 1), // clap + 5: NSColor(srgbRed: 220/255, green: 240/255, blue: 140/255, alpha: 1), // snap + 6: NSColor(srgbRed: 200/255, green: 150/255, blue: 80/255, alpha: 1), // cowbell + 7: NSColor(srgbRed: 120/255, green: 220/255, blue: 180/255, alpha: 1), // closed hat + 8: NSColor(srgbRed: 190/255, green: 120/255, blue: 70/255, alpha: 1), // block + 9: NSColor(srgbRed: 120/255, green: 200/255, blue: 240/255, alpha: 1), // open hat + 10: NSColor(srgbRed: 230/255, green: 210/255, blue: 170/255, alpha: 1), // tambo + 11: NSColor(srgbRed: 180/255, green: 180/255, blue: 230/255, alpha: 1), // ride + ] + + /// Set by AppDelegate when either percussion side toggles. Each half of + /// the board latches to drums independently (tap left-⌥ / right-⌥). + static var percussionLeftActive: Bool = false + static var percussionRightActive: Bool = false + + /// Whether the key at display-MIDI `m` is currently a drum — its half of + /// the board has to be latched. Left half is `< lingerSplitMidi`. + static func percussionActive(forMidi m: Int) -> Bool { + m < MenuBandLayout.lingerSplitMidi ? percussionLeftActive : percussionRightActive + } + + /// The drum-pad color to wash over a key, or nil when that key's half + /// isn't latched to drums (so it's still a melodic key). + static func percussionDrumColor(forMidi m: Int) -> NSColor? { + guard percussionActive(forMidi: m) else { return nil } + return percussionColorByPitchClass[((m % 12) + 12) % 12] + } + + /// Per-pitch-class drum hit pulses (time + 0…1 level), pushed by the + /// AppDelegate visualizer tick straight from the live percussion data. + /// Drives the per-key shake + blink so the menubar keys physically + /// "vibe" with each drum hit. + static var drumPulses: [MenuBandPercussion.DrumPulse] = [] + static var drumPulseNow: Double = 0 + + /// Shake offset + blink (0…1) for a right-hand drum key, derived from + /// its most recent hit: a velocity-scaled damped buzz that rings down + /// over ~0.4 s. Returns zeros for melodic keys / when the split is off. + static func percussionVibe(forMidi m: Int) -> (dx: CGFloat, dy: CGFloat, blink: CGFloat) { + guard percussionActive(forMidi: m), + !drumPulses.isEmpty else { return (0, 0, 0) } + let pc = ((m % 12) + 12) % 12 + guard pc < drumPulses.count else { return (0, 0, 0) } + let p = drumPulses[pc] + guard p.at > 0 else { return (0, 0, 0) } + let elapsed = drumPulseNow - p.at + guard elapsed >= 0, elapsed < 0.5 else { return (0, 0, 0) } + let env = p.level * exp(-elapsed * 9.0) + if env < 0.02 { return (0, 0, 0) } + // Frequencies kept under the 24 fps repaint Nyquist (12 Hz) so the + // buzz reads as a fast jitter, not an aliased slow wobble. + let amp = CGFloat(env) * 2.4 + let dx = amp * CGFloat(sin(elapsed * 2 * .pi * 9)) + let dy = amp * 0.5 * CGFloat(cos(elapsed * 2 * .pi * 7)) + let blink = CGFloat(min(1.0, env * 1.35)) + return (dx, dy, blink) + } + + /// Rotation (radians) for the menubar music-note glyph, derived from + /// the most recent percussion hit across ALL drum pads — a damped + /// left/right wobble that rings down over ~0.4 s so the icon visibly + /// shimmies each time a drum is struck. Independent of the split + /// being active (drums can be hit via the percussion split keys). + static func percussionIconWobble() -> CGFloat { + guard !drumPulses.isEmpty else { return 0 } + // Strongest still-ringing pulse drives the wobble. + var best: (env: Double, elapsed: Double)? = nil + for p in drumPulses where p.at > 0 { + let elapsed = drumPulseNow - p.at + guard elapsed >= 0, elapsed < 0.5 else { continue } + let env = p.level * exp(-elapsed * 8.0) + if env > (best?.env ?? 0) { best = (env, elapsed) } + } + guard let b = best, b.env > 0.02 else { return 0 } + // ~9° peak swing, oscillating a couple of times as it decays. + let amp = CGFloat(b.env) * (9.0 * .pi / 180.0) + return amp * CGFloat(sin(b.elapsed * 2 * .pi * 6)) + } + /// Updated by AppDelegate.updateIcon() before each render so the renderer /// can pick the right letter labels and active-range without threading /// the keymap through every static method's signature. @@ -123,10 +210,30 @@ /// `forceLayout` UserDefaults key so users (and we) can verify the /// slim render without needing to actually squeeze the menubar. static var forceLayout: DisplayLayout? = nil - /// Shift-held → labels render uppercase as the visual cue for - /// "linger / bell-ring mode." AppDelegate flips this on .flagsChanged - /// and re-issues updateIcon() so the menubar redraws. - static var labelsUppercase: Bool = false + /// Which half of the keyboard renders uppercase labels — the visual + /// cue for "linger / bell-ring mode," now sided to match the sided + /// linger: left shift uppercases the left (lower) half, right shift + /// the right (upper) half, caps lock / both shifts the whole board. + /// AppDelegate sets this on .flagsChanged and re-issues updateIcon(). + enum UppercaseSide { case none, left, right, all } + static var labelsUppercaseSide: UppercaseSide = .none + + /// True if any uppercase cue is active. Kept for the non-per-key + /// consumers (linger fermata gating, label baseline nudge) that only + /// care whether the mode is armed at all, not which half. + static var labelsUppercase: Bool { labelsUppercaseSide != .none } + + /// Whether the label for a given display MIDI note should render + /// uppercase, honoring the active side. Notes are the menubar piano's + /// C4–C5 window; the split matches the audio linger split. + static func uppercaseForMidi(_ midi: Int) -> Bool { + switch labelsUppercaseSide { + case .none: return false + case .all: return true + case .left: return midi < MenuBandLayout.lingerSplitMidi + case .right: return midi >= MenuBandLayout.lingerSplitMidi + } + } /// Caps-lock latched (as opposed to a momentary shift). Drawn /// state in the chip is gated differently for the two: caps @@ -240,6 +347,11 @@ /// 0..1 amplitude derived from current note activity, smoothed at /// the AppDelegate's animation tick so bars move continuously /// instead of stepping. static var miniVisualizerLevel: CGFloat = 0.0 + /// Real per-bar levels (0..1) — bass / mid / treble RMS of the live + /// waveform tap, updated every animation tick with a fast response so + /// the three bars are an honest, time-accurate spectrum meter rather + /// than a decorative wiggle. Empty until the first tick populates it. + static var miniVisualizerBars: [CGFloat] = [0, 0, 0] /// Wall-clock seconds, updated once per animation tick. Drives the /// per-bar phase offset so bars wiggle independently — reads as /// live audio metering rather than three identical pulses. @@ -306,7 +418,16 @@ /// covers the entire GM lineup except the top 28) sits cleanly /// inside the slot. 3-digit voices may extend a hair past the /// edge but still read clearly. Keeps the icon's right margin /// close to standard macOS menubar spacing. - static let voiceBadgeRightPad: CGFloat = 5.0 + /// Character count of the current voice subscript, set by the + /// AppDelegate each `updateIcon`. Lets the badge pad grow for 3-digit + /// GM numbers (100–128) so the rightmost digit isn't cropped. + static var voiceBadgeDigits: Int = 1 + /// Right pad reserved for the voice-number badge. Base covers the + /// common 1–2 character case; each extra character adds ~one + /// monospaced 7pt digit width so the slot widens only when needed. + static var voiceBadgeRightPad: CGFloat { + 5.0 + CGFloat(max(0, voiceBadgeDigits - 2)) * 5.0 + } enum HitResult: Equatable { case openSettings @@ -368,6 +489,15 @@ /// UserDefaults key for the tape-deck feature flag. static let tapeFeatureDefaultsKey = "MenuBandTapeDeckEnabled" + /// UserDefaults key for the right-hand percussion split toggle. Shared + /// between MenuBandController (source of truth) and the About-window + /// checkbox so the string never diverges. + static let percussionSplitDefaultsKey = "notepat.percussionSplit" + /// Per-side latched percussion (tap left-⌥ / right-⌥). Source of truth + /// shared between MenuBandController and the About-window checkbox. + static let percussionLeftDefaultsKey = "notepat.percussionLeft" + static let percussionRightDefaultsKey = "notepat.percussionRight" + // Compact Cassette physical aspect: 100.4mm × 63.8mm ≈ 1.574:1. // Cassette fills the full piano height (whiteH = 21pt); width // tracks the real-world ratio: 21 × 33 ≈ 1.571:1. @@ -469,7 +599,18 @@ static var octaveSlideDistance: CGFloat { 7 * whiteW } - static var imageSize: NSSize { + /// Global magnification of the menubar icon. The image is drawn at + /// base coordinates then blown up by this factor so the whole thing + /// (piano, chip, bars, tape) reads larger in the bar — same + /// proportions, just bigger. Hit-testing divides incoming points by it + /// so clicks still land. Set adaptively at launch to fill the bar's + /// usable height without overflowing (the button centers — doesn't + /// downscale — so overshooting would clip). 1.0 = original size. + static var iconScale: CGFloat = 1.0 + + /// Base (unscaled) menubar icon size — the coordinate space all drawing + /// and hit rects are computed in. + static var baseImageSize: NSSize { // imageSize is always queried in tape-included context (it's // the menubar status-item size). Compute as if the static // flag were set, regardless of the flag's transient state. @@ -478,11 +619,18 @@ let totalH = ceil(whiteH + pad * 2) return NSSize(width: totalW, height: totalH) } + /// The displayed menubar image size — base blown up by `iconScale`. + /// Drives the status-item length and the window→image hit mapping. + static var imageSize: NSSize { + NSSize(width: ceil(baseImageSize.width * iconScale), + height: ceil(baseImageSize.height * iconScale)) + } + /// Cassette body rect — hangs off the LEFT side of the deck on /// visible drive spokes. Same vertical extent as the piano keys /// so the cassette reads as the same "device height" as the deck. static var tapeRect: NSRect { - let bodyY = (imageSize.height - tapeBodyH) / 2.0 + let bodyY = (baseImageSize.height - tapeBodyH) / 2.0 return NSRect(x: tapeLeadPad, y: bodyY, width: tapeBodyW, @@ -597,12 +745,23 @@ // `tapeReservedInLayout` to match it for the duration of the // draw, then restores. Default static value (true) covers // hit-testing + size queries that happen outside any // drawing context. - let size = includeSettings ? imageSize : pianoImageSize(layout: layout) + // `size` stays in BASE coordinates (all the drawing math below uses + // it). The menubar canvas is blown up by iconScale and a matching + // transform is applied so the whole icon renders larger; the + // floating palette (includeSettings == false) is left at 1×. + let size = includeSettings ? baseImageSize : pianoImageSize(layout: layout) + let s: CGFloat = includeSettings ? iconScale : 1.0 + let canvasSize = NSSize(width: ceil(size.width * s), height: ceil(size.height * s)) - let img = NSImage(size: size, flipped: false) { _ in + let img = NSImage(size: canvasSize, flipped: false) { _ in let savedTapeReserved = tapeReservedInLayout tapeReservedInLayout = includeSettings defer { tapeReservedInLayout = savedTapeReserved } + if s != 1.0 { + let scale = NSAffineTransform() + scale.scale(by: s) + scale.concat() + } // Piano. NSGraphicsContext.saveGraphicsState() @@ -706,6 +865,11 @@ tileTransform.concat() } for (idx, m) in whites.enumerated() { if !isActive(m) { continue } // negative space — skip draw + // Percussion vibe: a fresh drum hit jolts only the + // LETTER with a damped buzz — the keycap + stripe stay + // put so just the label shakes (the blink still rides + // the same envelope on the drum-pad wash below). + let vibe = Self.percussionVibe(forMidi: m) let rect = whiteRect(at: idx + slotOffset) let isLit = tileOffset == 0 && litNotes.contains(UInt8(m)) let isPlaybackLit = tileOffset == 0 && playbackLitNotes.contains(UInt8(m)) @@ -739,13 +903,28 @@ if isHover && !isLit { NSColor.controlAccentColor.withAlphaComponent(0.50).setFill() path.fill() } + // Percussion split: wash the right-hand keys in their + // drum-pad color so the drum zone is unmistakable. Takes + // the place of the chromatic stripe for those keys. + let drumColor = Self.percussionDrumColor(forMidi: m) + if let drumColor, !isLit { + // Blink: a fresh hit flashes the pad brighter/whiter, + // riding the same envelope as the shake. + let b = vibe.blink + let c = b > 0.01 + ? (drumColor.blended(withFraction: b * 0.85, of: .white) ?? drumColor) + : drumColor + let baseAlpha: CGFloat = isDark ? 0.50 : 0.62 + c.withAlphaComponent(min(1.0, baseAlpha + b * 0.38)).setFill() + path.fill() + } // Chromatic stripe — thin flat ROYGBIV band along // the bottom of each natural key, idle only. Hidden // on press so the lit accent fill reads cleanly. // Dark mode dims the chroma toward black so it // doesn't read as neon against dark slate keys. let stripeH: CGFloat = keyHeightScale > 1.0 ? 3.0 : 2.0 - if let chroma = Self.chromaticColorByPitchClass[m % 12], !isLit { + if drumColor == nil, let chroma = Self.chromaticColorByPitchClass[m % 12], !isLit { let stripeChroma: NSColor = isDark ? (chroma.blended(withFraction: 0.18, of: .black) ?? chroma) : chroma @@ -816,7 +995,7 @@ // key the user is currently playing. Falls back to the // legacy binary `typeMode` rendering when no closure is // supplied (e.g., previews that don't drive animation). if tileOffset == 0, let letter = labelByMidi[m] { - let display = labelsUppercase ? letter.uppercased() : letter + let display = Self.uppercaseForMidi(m) ? letter.uppercased() : letter let a: CGFloat if isLit { a = 1.0 @@ -829,9 +1008,12 @@ if a > 0.01 { // Labels stay anchored at the bottom of each // key — the chromatic stripe paints behind // them, so the letter reads on the colored - // band rather than floating above it. - drawWhiteLabel(display, in: rect, lit: isLit, alpha: a, - chroma: Self.chromaticColorByPitchClass[m % 12]) + // band rather than floating above it. The + // percussion buzz shakes ONLY the letter rect. + let labelRect = rect.offsetBy(dx: vibe.dx, dy: vibe.dy) + drawWhiteLabel(display, in: labelRect, lit: isLit, alpha: a, + chroma: Self.chromaticColorByPitchClass[m % 12], + uppercase: Self.uppercaseForMidi(m)) } } } @@ -840,6 +1022,9 @@ if !isActive(m) { continue } // negative space var leftWhite = m - 1 while !isWhite(leftWhite) { leftWhite -= 1 } guard let leftIdx = whiteIndex[leftWhite] else { continue } + // Vibe shakes only the letter (see white-key note) — + // the keycap stays put. + let vibe = Self.percussionVibe(forMidi: m) let rect = blackRect(rightOfWhiteIndex: leftIdx + slotOffset) let isLit = tileOffset == 0 && litNotes.contains(UInt8(m)) let isPlaybackLit = tileOffset == 0 && playbackLitNotes.contains(UInt8(m)) @@ -879,11 +1064,23 @@ if isHover && !isLit { NSColor.white.withAlphaComponent(0.20).setFill() path.fill() } + // Percussion split: tint the right-hand sharps (the + // accent drums — crash/splash/cowbell/block/tambo) with + // their pad color. Higher alpha than the naturals so it + // reads against the dark keycap. + if let drumColor = Self.percussionDrumColor(forMidi: m), !isLit { + let b = vibe.blink + let c = b > 0.01 + ? (drumColor.blended(withFraction: b * 0.85, of: .white) ?? drumColor) + : drumColor + c.withAlphaComponent(min(1.0, 0.72 + b * 0.28)).setFill() + path.fill() + } groove.setStroke() path.lineWidth = 0.6 path.stroke() if tileOffset == 0, let letter = labelByMidi[m] { - let display = labelsUppercase ? letter.uppercased() : letter + let display = Self.uppercaseForMidi(m) ? letter.uppercased() : letter let a: CGFloat if isLit { a = 1.0 @@ -893,7 +1090,9 @@ } else { a = typeMode ? 1.0 : 0.0 } if a > 0.01 { - drawBlackLabel(display, in: rect, lit: isLit, alpha: a) + // Percussion buzz shakes only the letter rect. + let labelRect = rect.offsetBy(dx: vibe.dx, dy: vibe.dy) + drawBlackLabel(display, in: labelRect, lit: isLit, alpha: a) } } } @@ -964,8 +1163,8 @@ // (REC toggle) and draggable (eject) even in `.compact`. let leftX = displayLayout == .compact ? tapeReservedWidth : pianoOriginX + pianoWidth(layout: .fixedCanvas) - let rightX = imageSize.width - return NSRect(x: leftX, y: 0, width: rightX - leftX, height: imageSize.height) + let rightX = baseImageSize.width + return NSRect(x: leftX, y: 0, width: rightX - leftX, height: baseImageSize.height) } /// Visible icon bounds — a pill-sized region centered on the music @@ -979,19 +1178,22 @@ // Anchor the icon to the image's "old" right edge — i.e. // before the voice-badge pad was added. Otherwise the // music-note glyph would drift right whenever the badge pad // claims its slice of the image. - let oldRight = imageSize.width - voiceBadgeRightPad + let oldRight = baseImageSize.width - voiceBadgeRightPad let cx = oldRight - w / 2 - pad // Nudge the chip slightly UP so the SF Symbol `music.note` // glyph — which is visually bottom-heavy (the stem extends // upward from a wide note head) — reads as centered in the // menubar slot. Without this the icon sits a touch low. - let cy = imageSize.height / 2 + 1.2 + let cy = baseImageSize.height / 2 + 1.2 return NSRect(x: cx - w / 2, y: cy - h / 2, width: w, height: h) } // MARK: - Hit testing - static func hit(at point: NSPoint) -> HitResult? { + static func hit(at rawPoint: NSPoint) -> HitResult? { + // The displayed icon is magnified by `iconScale`; map the click back + // into the base coordinate space the hit rects live in. + let point = NSPoint(x: rawPoint.x / iconScale, y: rawPoint.y / iconScale) if tapeFeatureEnabled { // Tape sits at the leading edge — checked first so the // cassette wins over any speculative key hit math that @@ -1072,7 +1274,12 @@ /// Drag-friendly hit test for piano keys only. Vertically forgiving (y /// can be anywhere); horizontally tolerates a small overshoot past the /// leftmost/rightmost white key so a drag rolling past the edge keeps /// the edge key sounding. - static func noteAt(_ point: NSPoint, layout: Layout = .fixedCanvas) -> UInt8? { + static func noteAt(_ rawPoint: NSPoint, layout: Layout = .fixedCanvas) -> UInt8? { + // Menubar canvas is magnified by iconScale — map clicks back to base + // coords. The 1× floating palette passes its own layout, untouched. + let point = layout == .fixedCanvas + ? NSPoint(x: rawPoint.x / iconScale, y: rawPoint.y / iconScale) + : rawPoint let whites = whiteList() let activeWhites = activeWhites(layout: layout) guard !activeWhites.isEmpty else { return nil } @@ -1214,7 +1421,7 @@ } // MARK: - Key labels - private static func drawWhiteLabel(_ text: String, in rect: NSRect, lit: Bool, alpha: CGFloat = 1.0, bottomOffset: CGFloat = 0, chroma: NSColor? = nil) { + private static func drawWhiteLabel(_ text: String, in rect: NSRect, lit: Bool, alpha: CGFloat = 1.0, bottomOffset: CGFloat = 0, chroma: NSColor? = nil, uppercase: Bool = false) { guard alpha > 0.01 else { return } // Lit keys fill with the system accent — label flips to a // dark on-color shade so the letter reads as ink stamped on @@ -1245,7 +1452,9 @@ // mode the whole keycap stack is squashed; the labels were // floating too high inside the cap, so push them down 2pt // so they sit grounded on the chromatic stripe. let slim = displayLayout == .fullSlim - let baseY: CGFloat = (labelsUppercase ? 2.0 : 3.0) - (slim ? 2.0 : 0) + // Only the keys whose own label is uppercased drop lower — so a + // one-sided shift nudges just that half, not the whole row. + let baseY: CGFloat = (uppercase ? 2.0 : 3.0) - (slim ? 2.0 : 0) str.draw(at: NSPoint(x: rect.midX - size.width / 2, y: rect.minY + baseY + bottomOffset)) } @@ -1687,32 +1896,35 @@ private static func drawChipVisualizer(in rect: NSRect, level: CGFloat, hovered: Bool, color: NSColor, baseAlpha: CGFloat) { - let lvl = max(0, min(1, level)) let alpha: CGFloat = hovered ? 1.0 : baseAlpha - let t = pow(lvl, 0.45) + color.withAlphaComponent(alpha).setFill() - // Active silhouette: 3 vertical VU bars side by side at the - // band's bottom. Bars are ALWAYS drawn — even at silence — - // so the chip stays alive. A minimum floor height keeps the - // three "flat / short" bars visible when there is no audio. - let phase = CGFloat(miniVisualizerPhase) - let wiggleFreqs: [CGFloat] = [5.1, 6.4, 5.7] - let wigglePhases: [CGFloat] = [0.0, 1.3, 2.5] - let peakFracs: [CGFloat] = [0.82, 1.0, 0.82] + // Three real bars — bass / mid / treble RMS of the live waveform, + // already fast-smoothed by the AppDelegate tick. No decorative + // wiggle: the heights ARE the audio. + let bars: [CGFloat] = miniVisualizerBars.count >= 3 + ? miniVisualizerBars : [level, level, level] let barCount = 3 let barW: CGFloat = 1.6 let barGap = (rect.width - CGFloat(barCount) * barW) / CGFloat(barCount - 1) - // Silent floor: bars never fall below ~22% of the slot so the - // user always sees a small flat row of three nubs in the chip. - let silentFloor: CGFloat = 0.22 + + // Silence → a single flat baseline line across the slot instead of + // three idle nubs. The bars rise out of this line as sound returns. + let peak = bars.prefix(3).max() ?? 0 + if peak < 0.05 { + let lineH: CGFloat = 1.0 + let line = NSRect(x: rect.minX, y: rect.minY, width: rect.width, height: lineH) + NSBezierPath(roundedRect: line, xRadius: 0.5, yRadius: 0.5).fill() + return + } for i in 0.. 0 ? String(voiceNumber) : nil) }() if let activeLabel = activeLabel { - let digitFont = NSFont.monospacedDigitSystemFont(ofSize: 7, weight: .heavy) + // The sample backend's "`" badge renders bigger than the + // numeric voice slots (see the subscript draw below), so + // size the hover pill to the same larger glyph. + let isBacktick = activeLabel == "`" + let digitFont = NSFont.monospacedDigitSystemFont( + ofSize: isBacktick ? 12 : 7, weight: .heavy) let label = NSAttributedString(string: activeLabel, attributes: [ .font: digitFont, .kern: -0.4, ]) let oneDigitW = NSAttributedString(string: "0", attributes: [ - .font: digitFont, + .font: NSFont.monospacedDigitSystemFont(ofSize: 7, weight: .heavy), ]).size().width // First digit hugs iconBox's right edge (see digit-draw // logic below); additional digits flow rightward into @@ -2158,35 +2375,34 @@ var color = (f > 0.001) ? baseColor.blended(withFraction: f, of: flashTarget) ?? baseColor : baseColor let mF = max(0, min(1, metronomeFlash)) + // Beat pulse tints the glyph yellow on each tick. (The little side + // "tick" blips that used to radiate from the chip are removed — they + // read as clutter while the metronome runs.) if mF > 0.01, let yellowed = color.blended(withFraction: mF * 0.85, of: .systemYellow) { color = yellowed - // Loudness lines — small horizontal blips radiating - // left and right from the chip, fading with the - // metronome flash. Reads as the icon visibly "ticking" - // each beat, like a peripheral pulse around the - // music-note glyph. - let blipColor = NSColor.systemYellow.withAlphaComponent(mF * 0.85) - blipColor.setStroke() - let blip = NSBezierPath() - blip.lineWidth = 1.0 - blip.lineCapStyle = .round - let midY = iconBox.midY - let outer: CGFloat = 4 + 4 * mF // distance grows with flash - let inner: CGFloat = 1.5 - let leftStart = NSPoint(x: iconBox.minX - inner, y: midY) - let leftEnd = NSPoint(x: iconBox.minX - outer, y: midY) - let rightStart = NSPoint(x: iconBox.maxX + inner, y: midY) - let rightEnd = NSPoint(x: iconBox.maxX + outer, y: midY) - blip.move(to: leftStart); blip.line(to: leftEnd) - blip.move(to: rightStart); blip.line(to: rightEnd) - blip.stroke() } // Draw the SF Symbol music.note.list at its original pointSize // (13) over the full chip. At rest the staff lines should // remain visible (the chip reads as the natural system glyph). // Only when there's audible signal do we punch out the staff // lines and overlay animated VU bars in the resulting hole. - drawTintedSymbol("music.note.list", in: iconBox, pointSize: 13.0, color: color) + // + // Percussion wobble: a fresh drum hit rotates the glyph + // left/right about the icon center with a damped swing. + let wobble = percussionIconWobble() + if abs(wobble) > 0.0005 { + NSGraphicsContext.saveGraphicsState() + let c = NSPoint(x: iconBox.midX, y: iconBox.midY) + let t = NSAffineTransform() + t.translateX(by: c.x, yBy: c.y) + t.rotate(byRadians: wobble) + t.translateX(by: -c.x, yBy: -c.y) + t.concat() + drawTintedSymbol("music.note.list", in: iconBox, pointSize: 13.0, color: color) + NSGraphicsContext.restoreGraphicsState() + } else { + drawTintedSymbol("music.note.list", in: iconBox, pointSize: 13.0, color: color) + } // Always punch out the staff-lines slot and overlay 3 vertical // VU bars — the chip is "always live." When silent, the bars // fall to a short flat floor instead of vanishing back into @@ -2288,10 +2504,16 @@ let subscriptText: String? = midiOn ? "M" : voiceLabel ?? (voiceNumber > 0 ? String(voiceNumber) : nil) if let subscriptText = subscriptText { + // The sample backend's "`" badge is drawn BIGGER than the + // numeric voice slots: the backtick is a small high mark and + // reads as a smudge at 7pt, so bump it to 12pt. Digits / "M" + // keep the compact size. + let isBacktick = subscriptText == "`" + let fontSize: CGFloat = isBacktick ? 12.0 : 7.0 // Negative kerning tightens the digits so multi-digit // values feel more like a tag than spaced text. let attrs: [NSAttributedString.Key: Any] = [ - .font: NSFont.monospacedDigitSystemFont(ofSize: 7, weight: .heavy), + .font: NSFont.monospacedDigitSystemFont(ofSize: fontSize, weight: .heavy), .foregroundColor: color, .kern: -0.4, ] @@ -2300,11 +2522,19 @@ // Width of a single "0" — anchor for the first digit. let oneDigit = NSAttributedString(string: "0", attributes: [ .font: NSFont.monospacedDigitSystemFont(ofSize: 7, weight: .heavy), ]).size() - // Nudge the first digit a small tad LEFT so it visually - // hugs the music-note glyph; remaining digits flow - // rightward into the reserved badge pad. - let leftX = iconBox.maxX - oneDigit.width - 1 - str.draw(at: NSPoint(x: leftX, y: iconBox.minY - 1)) + if isBacktick { + // Right-align the wider backtick to the icon's right edge + // and drop its baseline so the high glyph hugs the note + // instead of floating above the chip. + str.draw(at: NSPoint(x: iconBox.maxX - str.size().width, + y: iconBox.minY - 5)) + } else { + // Nudge the first digit a small tad LEFT so it visually + // hugs the music-note glyph; remaining digits flow + // rightward into the reserved badge pad. + let leftX = iconBox.maxX - oneDigit.width - 1 + str.draw(at: NSPoint(x: leftX, y: iconBox.minY - 1)) + } } } diff --git a/slab/menuband/Sources/MenuBand/KidLispScreen.swift b/slab/menuband/Sources/MenuBand/KidLispScreen.swift new file mode 100644 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/KidLispScreen.swift @@ -0,0 +1,119 @@ +// Shared plumbing for "what KidLisp piece is the TV showing" so the +// popover's KidLisp TV and the floating AestheticWebWindow can render +// the SAME piece. Two renderers conform to `KidLispScreen`: +// +// • KidLispTVView — native Swift evaluator (phase-1 corpus) +// • KidLispTVWebView — live aesthetic.computer in a WKWebView +// +// `KidLispState.shared` is the single source of truth for the current +// selection (a `$code` when one is known, plus the raw source for the +// native fallback). The chooser writes to it; both surfaces read it. + +import AppKit + +/// A view that can display a KidLisp piece. Adopted by both the native +/// framebuffer view and the webview so `KidLispTVPanel` can hold either +/// behind one type and `AppDelegate`'s wiring stays renderer-agnostic. +protocol KidLispScreen: AnyObject { + /// Show a piece. `code` is the bare cache code (no `$`) when known — + /// the webview prefers it for a short `/$code` URL; the native + /// renderer ignores it and evaluates `source` directly. + func show(code: String?, source: String) + + /// Click inside the screen well → owner pops the "$ pieces" chooser. + var onScreenClick: ((NSView, NSEvent) -> Void)? { get set } + /// 0–10 audio amplitude feed (native `amp`). Unused by the webview. + var ampProvider: (() -> Double)? { get set } + /// True while the app is latency-critical; native renderer skips + /// ticks. Unused by the webview. + var busyProvider: (() -> Bool)? { get set } +} + +/// URL construction for AC KidLisp pieces. Centralized so the popover +/// (clean embed) and the window (full chrome) build the same paths. +enum KidLispURL { + static let base = "https://aesthetic.computer/" + + /// Encode raw KidLisp source for the URL path. Mirrors the AC + /// convention (space→`_`, newline→`~`) then percent-encodes the + /// rest. NOTE: verify against `lib/kidlisp.mjs` before relying on + /// exotic glyphs — this covers the common case. + static func encodeSource(_ src: String) -> String { + var s = src.replacingOccurrences(of: "\r\n", with: "\n") + s = s.replacingOccurrences(of: "\n", with: "~") // newline → tilde (decodes back) + s = s.replacingOccurrences(of: " ", with: "_") // space → underscore + let allowed = CharacterSet(charactersIn: + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~()*:!,;=+$&@") + return s.addingPercentEncoding(withAllowedCharacters: allowed) ?? s + } + + /// Path component for a selection: `$code` when known, else the + /// encoded inline source. + static func path(code: String?, source: String) -> String { + if let code = code, !code.isEmpty { return "$\(code)" } + return encodeSource(source) + } + + /// Full URL. `embed: true` adds chrome-stripping query params for + /// the small TV; the window passes `embed: false` for full fidelity. + static func url(code: String?, source: String, embed: Bool) -> URL { + var str = base + path(code: code, source: source) + if embed { str += "?nogap=true&nolabel=true&density=1" } + return URL(string: str) ?? URL(string: base)! + } +} + +/// Single source of truth for the currently-selected KidLisp piece, +/// shared by the popover TV and the floating web window. Backed by +/// UserDefaults so the choice sticks across launches. +final class KidLispState { + static let shared = KidLispState() + + private static let codeKey = "notepat.kidlispTV.code" + /// Same key the old single-renderer path used, so existing saved + /// raw sources migrate forward (they load with `code == nil`). + private static let sourceKey = "notepat.kidlispTV.source" + + private(set) var code: String? + private(set) var source: String + + private init() { + let d = UserDefaults.standard + self.code = d.string(forKey: Self.codeKey) + self.source = d.string(forKey: Self.sourceKey) ?? KidLispTVPanel.defaultSource + } + + /// True when a real cached piece is selected (vs. the default / + /// raw source). The web window only mirrors real `$code` pieces so + /// it isn't hijacked away from the AC homepage by the default. + var hasCode: Bool { (code?.isEmpty == false) } + + /// Clean-chrome URL for the small TV. + var embedURL: URL { KidLispURL.url(code: code, source: source, embed: true) } + /// Full-chrome URL for the window. + var pieceURL: URL { KidLispURL.url(code: code, source: source, embed: false) } + + func select(code: String?, source: String) { + self.code = (code?.isEmpty == true) ? nil : code + self.source = source + let d = UserDefaults.standard + if let c = self.code { d.set(c, forKey: Self.codeKey) } + else { d.removeObject(forKey: Self.codeKey) } + d.set(source, forKey: Self.sourceKey) + } + + func reset() { + self.code = nil + self.source = KidLispTVPanel.defaultSource + let d = UserDefaults.standard + d.removeObject(forKey: Self.codeKey) + d.removeObject(forKey: Self.sourceKey) + } +} + +// MARK: - Native renderer conformance + +extension KidLispTVView: KidLispScreen { + /// The native evaluator only knows source text; ignore the code. + func show(code: String?, source: String) { setSource(source) } +} diff --git a/slab/menuband/Sources/MenuBand/KidLispTVPanel.swift b/slab/menuband/Sources/MenuBand/KidLispTVPanel.swift --- a/slab/menuband/Sources/MenuBand/KidLispTVPanel.swift +++ b/slab/menuband/Sources/MenuBand/KidLispTVPanel.swift @@ -14,12 +14,19 @@ import AppKit final class KidLispTVPanel: NSPanel { - let tv: KidLispTVView + let tv: NSView & KidLispScreen private let bezel: KidLispTVBezelView static let bezelThickness: CGFloat = 14 static let screenAspect: CGFloat = 192.0 / 120.0 static let panelInset: CGFloat = 3 // gap below the anchor panel + + /// Renderer selector. The live-webview path is the default; flip + /// `notepat.kidlispTV.webview` to `false` in defaults to fall back + /// to the native Swift evaluator (handy for offline / debugging). + static var useWebView: Bool { + UserDefaults.standard.object(forKey: "notepat.kidlispTV.webview") as? Bool ?? true + } /// Default piece — a simple amp-reactive twin-line waveform that /// spreads from the center as MenuBand's synth output gets louder. @@ -38,8 +45,15 @@ init(width: CGFloat) { let screenWidth = width - 2 * Self.bezelThickness let screenHeight = (screenWidth / Self.screenAspect).rounded() let panelHeight = screenHeight + 2 * Self.bezelThickness - self.tv = KidLispTVView(source: Self.defaultSource, - resWidth: 192, resHeight: 120) + // Restore the shared current piece so the TV opens on whatever + // the user last picked (and matches the floating web window). + let state = KidLispState.shared + if Self.useWebView { + self.tv = KidLispTVWebView(code: state.code, source: state.source) + } else { + self.tv = KidLispTVView(source: state.source, + resWidth: 192, resHeight: 120) + } self.bezel = KidLispTVBezelView(frame: NSRect(x: 0, y: 0, width: width, height: panelHeight)) @@ -84,6 +98,22 @@ let panelFrame = NSRect( x: anchorFrame.minX, y: anchorFrame.minY - h - Self.panelInset, width: anchorFrame.width, + height: h + ) + setFrame(panelFrame, display: true) + } + + /// Position the panel under `anchorFrame` (the popover's frame) with + /// its RIGHT edge flush to the popover's right edge — so the TV reads + /// as hanging off the bottom-right of the settings popover instead of + /// the left player column. Keeps its own (left-panel-derived) width. + func anchor(belowRightAlignedTo anchorFrame: NSRect) { + let h = frame.height + let w = frame.width + let panelFrame = NSRect( + x: anchorFrame.maxX - w, + y: anchorFrame.minY - h - Self.panelInset, + width: w, height: h ) setFrame(panelFrame, display: true) diff --git a/slab/menuband/Sources/MenuBand/KidLispTVWebView.swift b/slab/menuband/Sources/MenuBand/KidLispTVWebView.swift new file mode 100644 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/KidLispTVWebView.swift @@ -0,0 +1,96 @@ +// KidLisp TV renderer backed by a live aesthetic.computer WKWebView. +// +// Drop-in alternative to the native `KidLispTVView`: same `KidLispScreen` +// surface, same screen-well frame, but it shows the REAL runtime — every +// builtin, fonts, network pieces, future language features — by loading +// `aesthetic.computer/$code` (or encoded inline source) with chrome +// stripped (`nogap`/`nolabel`). +// +// The TV is a passive display: a transparent click-catcher sits over the +// webview and forwards clicks to the "$ pieces" chooser, matching the +// native view's interaction (you pick pieces, you don't poke the canvas). + +import AppKit +import WebKit + +final class KidLispTVWebView: NSView, KidLispScreen { + + private let webView: WKWebView + private let clickCatcher: ClickCatcher + /// Last URL handed to `load` — guards against redundant reloads when + /// `show` is called with the same selection (e.g. popover re-open). + private var loadedURL: URL? + + // Unused by the webview (the page generates its own audio/visuals), + // but part of `KidLispScreen` so the panel/wiring stays uniform. + var ampProvider: (() -> Double)? + var busyProvider: (() -> Bool)? + + var onScreenClick: ((NSView, NSEvent) -> Void)? { + didSet { + clickCatcher.onClick = onScreenClick + window?.invalidateCursorRects(for: clickCatcher) + } + } + + init(code: String?, source: String) { + let config = WKWebViewConfiguration() + // Pieces start audio/animation without a click — the TV should + // come alive on its own like the native renderer does. + config.mediaTypesRequiringUserActionForPlayback = [] + config.preferences.javaScriptCanOpenWindowsAutomatically = false + self.webView = WKWebView(frame: .zero, configuration: config) + self.clickCatcher = ClickCatcher(frame: .zero) + super.init(frame: .zero) + + wantsLayer = true + layer?.backgroundColor = NSColor.black.cgColor + + webView.autoresizingMask = [.width, .height] + // Transparent so the bezel's black screen well shows through any + // alpha the piece renders (same trick as AestheticWebWindow). + webView.setValue(false, forKey: "drawsBackground") + webView.allowsMagnification = false + // Hard pixels to match the chunky LCD look of the native TV. + webView.layer?.magnificationFilter = .nearest + addSubview(webView) + + clickCatcher.autoresizingMask = [.width, .height] + clickCatcher.onClick = nil + addSubview(clickCatcher) // on top of the webview + + show(code: code, source: source) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) not supported") } + + override func layout() { + super.layout() + webView.frame = bounds + clickCatcher.frame = bounds + } + + func show(code: String?, source: String) { + let url = KidLispURL.url(code: code, source: source, embed: true) + guard url != loadedURL else { return } + loadedURL = url + webView.load(URLRequest(url: url)) + } + + /// Transparent overlay that grabs every click (the TV canvas isn't + /// interactive) and forwards it to the chooser callback. + final class ClickCatcher: NSView { + var onClick: ((NSView, NSEvent) -> Void)? + + override func mouseDown(with event: NSEvent) { + if let cb = onClick { cb(self, event) } else { super.mouseDown(with: event) } + } + // Capture clicks even though a WKWebView is below us. + override func hitTest(_ point: NSPoint) -> NSView? { + return onClick != nil ? self : nil + } + override func resetCursorRects() { + if onClick != nil { addCursorRect(bounds, cursor: .pointingHand) } + } + } +} diff --git a/slab/menuband/Sources/MenuBand/MenuBandController.swift b/slab/menuband/Sources/MenuBand/MenuBandController.swift --- a/slab/menuband/Sources/MenuBand/MenuBandController.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandController.swift @@ -49,13 +49,74 @@ /// Per-key linger flag captured at keyDown. We sample shift state /// once on press so a release-shift-mid-hold still rings out the /// note that was started under shift. private var heldKeyLinger: [UInt16: Bool] = [:] + /// Enter-latch (port of notepat-native): while Return is held, any + /// note pressed is *latched* — its key-up is swallowed so the note + /// sustains indefinitely. Backspace pops the most-recent latched + /// note. Latched notes stay in `heldNotes`/`litNotes`, so the + /// menubar keyboard visualizer shows them held with no extra wiring. + private var enterLatchHeld: Bool = false + /// Keys pressed during the current Enter-hold whose key-up should + /// latch (not release) the note. + private var latchArmedKeys: Set = [] + /// LIFO stack of currently-latched keyCodes — Backspace unlatches + /// the top one. + private var latchedKeys: [UInt16] = [] + /// Percussion-split: which drum-voice group each held right-hand key + /// started, so key-up releases exactly that hit (hi-hat down/up sounds). + private var heldDrumKeys: [UInt16: UInt64] = [:] + /// Display note each held drum key lit, so key-up extinguishes the + /// right menubar cell (drum keys stay accent-lit for the whole hold). + private var heldDrumDisplay: [UInt16: UInt8] = [:] private let heldLock = NSLock() + /// Refcount of held drum lights per display note (a display note can be + /// held by more than one source — keyboard + click). Main-thread only. + private var drumLitRefs: [UInt8: Int] = [:] + + /// Which shift key (if any) is arming linger, and over which half of + /// the keyboard. Left shift lingers only the left half (lower octave, + /// notes < `lingerSplitMidi`); right shift only the right half. Caps + /// lock — or both shifts at once — latches the whole board. Each note + /// still pans by its own column, so a left-half note naturally rings + /// out to the left without any forced pan. + enum LingerSide { + case none // not lingering + case left // left shift → linger left half only + case right // right shift → linger right half only + case neutral // caps lock / both shifts → linger whole board + + var isLingering: Bool { self != .none } + } + + // Device-dependent modifier bits (IOLLEvent / NSEvent legacy). These + // distinguish the physical left vs right shift — the device-independent + // `.shift` mask collapses both into one bit. Present in the raw value + // of both CGEventFlags and NSEvent.ModifierFlags. + private static let kLeftShiftBit: UInt64 = 0x0000_0002 + private static let kRightShiftBit: UInt64 = 0x0000_0004 + + /// Resolve which shift side is arming linger from the raw modifier + /// bits. Falls back to `.neutral` when shift/caps is on but the + /// device bits are unavailable, so behavior degrades to the old + /// natural-pan linger rather than dropping the hold entirely. + private static func lingerSide(rawFlags: UInt64, + shiftDown: Bool, + capsOn: Bool) -> LingerSide { + let left = (rawFlags & kLeftShiftBit) != 0 + let right = (rawFlags & kRightShiftBit) != 0 + if left && !right { return .left } + if right && !left { return .right } + if shiftDown || capsOn { return .neutral } + return .none + } + private let midiModeKey = "notepat.midiMode" private let typeModeKey = "notepat.typeMode" private let octaveShiftKey = "notepat.octaveShift" private let melodicProgramKey = "notepat.melodicProgram" private let keymapKey = "notepat.keymap" + private let percussionLeftKey = KeyboardIconRenderer.percussionLeftDefaultsKey + private let percussionRightKey = KeyboardIconRenderer.percussionRightDefaultsKey private let masterVolumeKey = "notepat.masterVolume" private let hapticsEnabledKey = "notepat.hapticsEnabled" /// Active instrument backend: `"gm"` for the General MIDI bank, or @@ -63,6 +124,7 @@ /// `"gb"` for a GarageBand sampler patch. Default is GM. Stored as a /// string so future backends (Logic, EXS3rd-party, etc.) can be /// added without breaking older saved values. private let instrumentBackendKey = "notepat.instrumentBackend" + private let radioStationKey = "notepat.radioStation" /// File URL string of the GarageBand patch the user picked. Empty /// when no GB patch has been selected yet (we'll fall back to the /// first scanned patch when the backend is GarageBand and this is @@ -127,7 +189,15 @@ /// lock + trackpad-Y bending are keyboard-only features so /// the user can still drag the mouse across menubar keys. var keyboardNotesHeld: Bool { heldLock.lock(); defer { heldLock.unlock() } - return !heldNotes.isEmpty + // Latched notes (Enter-latch) live in `heldNotes` but their + // physical key is UP — they must NOT count as "held" for the + // trackpad pitch-bend lock, or the bend cursor would stay + // locked open forever after a latch. Count only keys whose + // physical key is still down (i.e. not latch-armed). + let melodicHeld = heldNotes.keys.contains { !latchArmedKeys.contains($0) } + // Held percussion keys count too — drumming engages the same + // trackpad pitch-bend gesture so the kit can be bent live. + return melodicHeld || !heldDrumKeys.isEmpty } /// Fires whenever an outbound MIDI noteOn lands on the @@ -531,6 +601,137 @@ if octaveShift == 0 { return "0" } return octaveShift > 0 ? "+\(octaveShift)" : "\(octaveShift)" } + /// Sided percussion. Each half of the board latches to the AC-native + /// drum kit independently: tap left-⌥ for the LEFT half, right-⌥ for the + /// RIGHT half (both → whole board). A latched half fires drums instead + /// of melodic notes; the other half stays melodic. Off by default. + /// Toggling a side releases held notes and, when BOTH go off, silences + /// any ringing drums. + var percussionLeft: Bool { + get { UserDefaults.standard.bool(forKey: percussionLeftKey) } + set { + UserDefaults.standard.set(newValue, forKey: percussionLeftKey) + applyPercussionSideEffects() + } + } + var percussionRight: Bool { + get { UserDefaults.standard.bool(forKey: percussionRightKey) } + set { + UserDefaults.standard.set(newValue, forKey: percussionRightKey) + applyPercussionSideEffects() + } + } + + /// Whole-board convenience used by the About-window checkbox and the + /// global ⌘⌃⌥D shortcut: reads as on if EITHER side is latched, and + /// setting it flips both sides together. + var percussionSplit: Bool { + get { percussionLeft || percussionRight } + set { + UserDefaults.standard.set(newValue, forKey: percussionLeftKey) + UserDefaults.standard.set(newValue, forKey: percussionRightKey) + applyPercussionSideEffects() + } + } + + /// Shared side effects for any percussion-state change: drop held notes + /// so nothing hangs across the melodic↔drum flip, silence drums once + /// both sides are off, and keep the engine warm while any side drums. + private func applyPercussionSideEffects() { + releaseAllHeldNotes() + if !percussionLeft && !percussionRight { synth.percussion.silence() } + synth.keepEngineWarm = percussionLeft || percussionRight + onChange?() + } + + func togglePercussionSplit() { percussionSplit.toggle() } + func togglePercussionLeft() { percussionLeft.toggle() } + func togglePercussionRight() { percussionRight.toggle() } + + /// Re-apply side effects after state changed out of band (e.g. the + /// About-window checkbox writes UserDefaults directly). The getters + /// already reflect the new values. + func reloadPercussionSplit() { + applyPercussionSideEffects() + } + + /// Audible confirmation for a percussion toggle — a kick when armed, a + /// closed-hat tick when disarmed — so the gesture lands by ear. `pan` + /// places the cue on the side that toggled (32 left, 96 right, 64 both). + func playPercussionToggleCue(on: Bool, pan: UInt8 = 64) { + synth.playPercussion(on ? .kick : .hatClosed, + velocity: on ? 100 : 80, pan: pan) + } + + /// Key/click-down for a split drum — returns the group token to release + /// on up. Used by both the keyboard split and menubar-piano clicks. + @discardableResult + func percussionNoteOn(_ drum: MenuBandPercussion.Drum, + velocity: UInt8, pan: UInt8, accent: Bool = false) -> UInt64 { + synth.percussionNoteOn(drum, velocity: velocity, pan: pan, accent: accent) + } + + /// Drum trigger→render latency (ms) for the debug latency readout. + func percussionTriggerHandoffMs() -> Double { synth.percussionTriggerHandoffMs() } + + /// Key/click-up for a split drum (hi-hat foot-pedal release). + func percussionNoteOff(_ group: UInt64) { + synth.percussionNoteOff(group) + } + + /// Live per-pitch-class drum hit pulses driving the menubar key vibe. + func percussionPulses() -> [MenuBandPercussion.DrumPulse] { + synth.percussionPulses() + } + + /// True when the given menubar display note is a right-hand drum key + /// (split armed and the note is in the upper half). + func isPercussionDisplayNote(_ displayNote: UInt8) -> Bool { + percussionSplit && Int(displayNote) >= MenuBandLayout.lingerSplitMidi + } + + /// Drum for a menubar display note (octave-invariant pitch class). + func percussionDrum(forDisplayNote displayNote: UInt8) -> MenuBandPercussion.Drum { + .forPitchClass(Int(displayNote) % 12) + } + + /// Briefly light a menubar display cell for a one-shot drum hit, then + /// extinguish it. Drums have no key-up sustain, so the off is self- + /// scheduled after a short fixed flash. Lit-state is main-thread only, + /// so hop there when called from the global CGEventTap thread. + /// Light a menubar cell for a held drum key in the accent color and + /// keep it lit until `drumLitOff` — so long as the key is down the cell + /// stays "hold green." Refcounted so two sources on the same cell don't + /// extinguish early. Main-thread only. + func drumLitOn(_ displayNote: UInt8) { + let work = { [weak self] in + guard let self = self else { return } + self.drumLitRefs[displayNote, default: 0] += 1 + self.litDownAt[displayNote] = CACurrentMediaTime() + if self.litNotes.insert(displayNote).inserted { self.onLitChanged?() } + } + if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) } + } + + /// Release a held drum light; extinguishes once the last holder lets go + /// (unless a melodic note still holds the same cell). + func drumLitOff(_ displayNote: UInt8) { + let work = { [weak self] in + guard let self = self else { return } + let n = (self.drumLitRefs[displayNote] ?? 1) - 1 + if n > 0 { self.drumLitRefs[displayNote] = n; return } + self.drumLitRefs.removeValue(forKey: displayNote) + self.heldLock.lock() + let heldByMelodic = self.heldKeyDisplayNote.values.contains(displayNote) + self.heldLock.unlock() + if !heldByMelodic { + self.litDownAt.removeValue(forKey: displayNote) + if self.litNotes.remove(displayNote) != nil { self.onLitChanged?() } + } + } + if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) } + } + var playableNoteRangeLabel: String { let upper = keymap == .ableton ? 76 : 83 let lowerNote = UInt8(max(0, min(127, 60 + octaveShift * 12))) @@ -692,6 +893,15 @@ // ignores MIDI pitch-bend — route the signed amount // separately so trackpad pitch-bend slides the looping // sample alongside the MIDISynth-based voices. synth.setSamplePitchBend(amount: amount) + // The internally-synthesized drum kit isn't on a MIDI + // channel (it's skipped in the loop above), so route the + // bend straight into the percussion engine's pitch scale so + // tonal drums warp with everything else. + synth.setPercussionPitchBend(amount: amount) + // KPBJ radio is also a varispeed backend (ignores MIDI + // pitch-bend) — route the signed bend so the live stream + // slides in pitch with everything else. + synth.setRadioPitchBend(amount: amount) } for ch in channels { midi.sendPitchBend(value: value, channel: ch) } } @@ -783,6 +993,7 @@ /// whichever GM program was last selected. func setRadioBackend(_ enabled: Bool) { if enabled { UserDefaults.standard.set("kpbj", forKey: instrumentBackendKey) + synth.setRadioStation(radioStation) // tune to the saved station synth.setRadioBackend(true) } else { UserDefaults.standard.set("gm", forKey: instrumentBackendKey) @@ -797,6 +1008,31 @@ } func toggleRadioBackend() { setRadioBackend(instrumentBackend != .kpbj) + } + + /// The radio station the "voice −1" backend is tuned to (persisted). + var radioStation: RadioStation { + get { RadioStation.by(id: UserDefaults.standard.string(forKey: radioStationKey) ?? "kpbj") } + set { + UserDefaults.standard.set(newValue.id, forKey: radioStationKey) + synth.setRadioStation(newValue) + onChange?() + onInstrumentVisualChange?() + } + } + + /// Pick a station AND make the radio the active backend — the action + /// behind a station cell in the chooser and the `-kpbj` / `-nts1` / + /// `-nts2` typed commands. Tuning while already on radio just retunes. + func selectRadioStation(_ station: RadioStation) { + UserDefaults.standard.set(station.id, forKey: radioStationKey) + synth.setRadioStation(station) + if instrumentBackend != .kpbj { + setRadioBackend(true) // engages radio + applies this station + } else { + onChange?() + onInstrumentVisualChange?() + } } // MARK: - Plugins (third-party AU instruments) @@ -850,7 +1086,7 @@ switch instrumentBackend { case .sample: return sampleVoiceHasRecording ? "Sample Voice" : "Sample Voice - no recording" case .kpbj: - return "KPBJ.FM radio" + return "\(radioStation.name) radio" case .garageBand: return garageBandPatchURL?.deletingPathExtension().lastPathComponent ?? "GarageBand patch" case .gm: @@ -1764,11 +2000,40 @@ /// = KPBJ radio). Same `voiceDigitFlushInterval` timeout as the /// digit buffer so a stray `-` doesn't hijack the next typed /// voice number. private var voiceDigitNegative: Bool = false + /// Letters typed after a `-` accumulate here so a negative voice can be + /// picked by NAME instead of slot number — e.g. `-kpbj`, `-nts1`, + /// `-nts2` each tune the radio backend ("voice −1") to that station. + /// Cleared on `-`, on a match, on divergence from any known name, and on + /// the same `voiceDigitFlushInterval` staleness window as the digits. + private var voiceCommandBuffer: String = "" + /// Negative voice names recognized after a `-` — one per radio station + /// id. Matched as the buffer grows so it can bail the moment it diverges + /// from every known name. + private static let negativeVoiceNames: Set = + Set(RadioStation.all.map { $0.id }) /// Burst window for multi-digit voice entry. Tuned so "128" typed /// at a normal pace stays one number, while a re-pick after a beat /// starts clean. private static let voiceDigitFlushInterval: CFTimeInterval = 0.8 + /// Lowercase letter on a hardware key cap, or nil for non-letter keys. + /// Used to capture typed voice names like `-kpbj`. ANSI layout. + @inline(__always) + private static func letterForKeyCode(_ kc: UInt16) -> Character? { + switch kc { + case 0: return "a"; case 1: return "s"; case 2: return "d" + case 3: return "f"; case 4: return "h"; case 5: return "g" + case 6: return "z"; case 7: return "x"; case 8: return "c" + case 9: return "v"; case 11: return "b"; case 12: return "q" + case 13: return "w"; case 14: return "e"; case 15: return "r" + case 16: return "y"; case 17: return "t"; case 31: return "o" + case 32: return "u"; case 34: return "i"; case 35: return "p" + case 37: return "l"; case 38: return "j"; case 40: return "k" + case 45: return "n"; case 46: return "m" + default: return nil + } + } + /// Map a hardware key code to the digit on its key cap, or nil for /// non-digit keys. Covers the top number row only — keypad digits /// have separate codes and are intentionally skipped (most laptops @@ -1806,9 +2071,13 @@ } let hasMod = flags.contains(.maskCommand) || flags.contains(.maskControl) || flags.contains(.maskAlternate) // Linger armed when EITHER shift is currently held OR caps lock // is on. Caps lock latches the mode so the user can play a - // long ambient passage without having to keep shift down. - let linger = flags.contains(.maskShift) || flags.contains(.maskAlphaShift) - return playKeyEvent(keyCode: keyCode, isDown: isDown, isRepeat: isRepeat, hasModifier: hasMod, linger: linger) + // long ambient passage without having to keep shift down. The + // shift *side* now pans the lingering note (left shift → left, + // right shift → right); caps keeps the natural per-key pan. + let side = Self.lingerSide(rawFlags: flags.rawValue, + shiftDown: flags.contains(.maskShift), + capsOn: flags.contains(.maskAlphaShift)) + return playKeyEvent(keyCode: keyCode, isDown: isDown, isRepeat: isRepeat, hasModifier: hasMod, lingerSide: side) } /// Sandbox-friendly key path: same note logic as the global tap, but @@ -1827,8 +2096,12 @@ // Option still pass through for real chords. let hasMod = flags.contains(.control) || flags.contains(.option) // Caps lock latches linger so the user can play hands-free // without holding shift; shift held still works as a momentary. - let linger = flags.contains(.shift) || flags.contains(.capsLock) - return playKeyEvent(keyCode: keyCode, isDown: isDown, isRepeat: isRepeat, hasModifier: hasMod, linger: linger) + // The shift side pans the lingering note (left → left, right → + // right); caps lock keeps the note's natural per-key pan. + let side = Self.lingerSide(rawFlags: UInt64(flags.rawValue), + shiftDown: flags.contains(.shift), + capsOn: flags.contains(.capsLock)) + return playKeyEvent(keyCode: keyCode, isDown: isDown, isRepeat: isRepeat, hasModifier: hasMod, lingerSide: side) } /// Shared note logic for both the global CGEventTap path and the @@ -1838,9 +2111,29 @@ /// `linger` engages bell-ring mode: the note is held by the synth /// past key-up so it rings on its release envelope (sustained /// voices) rather than cutting on release. @discardableResult - private func playKeyEvent(keyCode: UInt16, isDown: Bool, isRepeat: Bool, hasModifier: Bool, linger: Bool = false) -> Bool { + private func playKeyEvent(keyCode: UInt16, isDown: Bool, isRepeat: Bool, hasModifier: Bool, lingerSide: LingerSide = .none) -> Bool { // Modifier combos pass through so cmd-c, cmd-tab etc. work as usual. if hasModifier { return false } + + // Enter-latch (notepat-native parity). Return (keyCode 36) is the + // latch modifier: while it's held, notes you press get latched on + // release so they sustain indefinitely. Backspace (51) pops the + // most-recent latched note. Both keys are consumed so they never + // leak to the focused app. + if keyCode == 36 /* kVK_Return */ { + enterLatchHeld = isDown + if !isDown { latchArmedKeys.removeAll() } + return true + } + if keyCode == 51 /* kVK_Delete (Backspace) */ { + // Plain Backspace pops the most-recent latched note (LIFO); + // Shift+Backspace clears them all at once. + if isDown && !isRepeat { + if lingerSide.isLingering { unlatchAll() } + else { unlatchMostRecent() } + } + return true + } // Arrow keys step through the GM instrument grid: ←/→ by one // slot, ↑/↓ by one row (= InstrumentListView.cols). Mirrors @@ -1884,17 +2177,19 @@ default: break } - // Minus key (`-`, keyCode 27) primes a negative voice slot — - // the trigger sequence is `-1`, NOT a bare `-`. Typing `-1` - // toggles the live KPBJ radio backend ("voice −1": the piano - // plays the live stream pitched by 2^((note−60)/12), with - // stalls fading into AM-style static). Standalone `-` is a - // no-op so the negative-prefix UX matches how positive voices - // are typed (number row commits the pick). Consumed in both + // Minus key (`-`, keyCode 27) primes a negative voice slot. The + // trigger is `-1` OR a voice name like `-kpbj`, NOT a bare `-`. + // Either selects the live KPBJ radio backend ("voice −1": the + // piano plays the live stream pitched per note, stalls fading into + // AM-style static). `-1` toggles; `-` accumulates the letters + // that follow and selects by callsign. Standalone `-` is a no-op so + // the negative-prefix UX matches how positive voices are typed + // (the rest of the sequence commits the pick). Consumed in both // directions so the key never leaks to the focused app. if keyCode == 27 { if isDown && !isRepeat { voiceDigitBuffer = "" + voiceCommandBuffer = "" voiceDigitNegative = true voiceDigitLastPress = CACurrentMediaTime() } @@ -1990,6 +2285,45 @@ } return true } + // Negative voice by NAME: once `-` has primed negative mode, the + // letters that follow spell a voice callsign — `-kpbj` selects the + // KPBJ radio. We accumulate letters and match against the known + // names as the buffer grows, bailing the moment it can't be any of + // them. The letter keys are consumed (no note plays) only while a + // name is still plausibly being typed; a stale `-` or a divergent + // letter releases them back to normal note play. + if voiceDigitNegative, isDown, !isRepeat, + let ch = Self.letterForKeyCode(keyCode) { + let now = CACurrentMediaTime() + if now - voiceDigitLastPress > Self.voiceDigitFlushInterval { + // Stale prefix — abandon the capture and let this key play + // as a normal note (fall through to the handlers below). + voiceDigitNegative = false + voiceCommandBuffer = "" + } else { + voiceDigitLastPress = now + voiceCommandBuffer.append(ch) + let token = voiceCommandBuffer + let isPrefix = Self.negativeVoiceNames.contains { $0.hasPrefix(token) } + if Self.negativeVoiceNames.contains(token) { + // Complete match → tune the radio to that station and + // engage it (idempotent: a name selects, it doesn't + // toggle off like `-1` does). + voiceDigitNegative = false + voiceCommandBuffer = "" + let station = RadioStation.by(id: token) + DispatchQueue.main.async { [weak self] in + self?.selectRadioStation(station) + } + } else if !isPrefix { + // Diverged from every known name → stop capturing. + voiceDigitNegative = false + voiceCommandBuffer = "" + } + return true // consumed the letter while capturing a name + } + } + // Octave shift: notepat uses , (43) / . (47); Ableton uses z (6) / // x (7) since those are unmapped in Live's M-mode keymap and the // comma/period live next to mapped notes there. Acts globally — @@ -2014,6 +2348,52 @@ } let shift = octaveShift // a single UserDefaults read; cheap + // Right-hand percussion split: when armed, upper-octave keys + // (intrinsic semitone ≥ 12) fire the AC-native drum kit instead of + // a melodic note — octave-invariant, so the drum is picked by + // `semitone % 12`. One-shot: triggers on key-down, ignores linger, + // and consumes the key-up with no melodic note ever stored. + // Sided percussion: this key fires a drum (not a melodic note) when + // the HALF of the board it sits on is latched — left half (display + // note < lingerSplitMidi) follows `percussionLeft`, right half + // follows `percussionRight`. Drum chosen by pitch class. One-shot: + // triggers on key-down, consumes key-up, never stores a melodic note. + let percDisplayNote: UInt8? = MenuBandLayout + .midiNote(forKeyCode: keyCode, octaveShift: shift, keymap: keymap) + .map { UInt8(max(60, min(83, Int($0) - shift * 12))) } + let percActiveForKey: Bool = { + guard !hasModifier, let dn = percDisplayNote else { return false } + return Int(dn) < MenuBandLayout.lingerSplitMidi ? percussionLeft : percussionRight + }() + if percActiveForKey, + let semi = MenuBandLayout.semitone(forKeyCode: keyCode, keymap: keymap) { + if isDown { + if !isRepeat { + let drum = MenuBandPercussion.Drum.forPitchClass(semi % 12) + let pan = MenuBandLayout.panForKeyCode(keyCode) + // Shift (the linger arm) ACCENTS the drum — a harder, + // more intense hit (the percussion analogue of linger). + let accent = lingerSide.isLingering + let group = synth.percussionNoteOn(drum, velocity: 100, pan: pan, + accent: accent) + let dn = percDisplayNote + heldLock.lock() + heldDrumKeys[keyCode] = group + if let dn { heldDrumDisplay[keyCode] = dn } + heldLock.unlock() + if let dn { drumLitOn(dn) } + } + } else { + heldLock.lock() + let group = heldDrumKeys.removeValue(forKey: keyCode) + let dn = heldDrumDisplay.removeValue(forKey: keyCode) + heldLock.unlock() + if let group { synth.percussionNoteOff(group) } + if let dn { drumLitOff(dn) } + } + return true + } + if isDown { if isRepeat { return true } // consume repeats but don't retrigger guard let note = MenuBandLayout.midiNote(forKeyCode: keyCode, octaveShift: shift, keymap: keymap) else { @@ -2032,6 +2412,19 @@ let displayNote: UInt8 = { let v = Int(note) - shift * 12 let clamped = max(60, min(83, v)) return UInt8(clamped) + }() + // Sided linger: left shift rings out only the left half of the + // board (display note below the split), right shift only the + // right half; caps lock / both shifts ring the whole board. + // Keying off the display note keeps which notes linger in sync + // with which letters the menubar uppercases. + let linger: Bool = { + switch lingerSide { + case .none: return false + case .neutral: return true + case .left: return Int(displayNote) < MenuBandLayout.lingerSplitMidi + case .right: return Int(displayNote) >= MenuBandLayout.lingerSplitMidi + } }() heldLock.lock() let prevNote = heldNotes[keyCode] @@ -2089,8 +2482,27 @@ self.onLitChanged?() } } if Thread.isMainThread { setLit() } else { DispatchQueue.main.async(execute: setLit) } + // If Enter is held while this note is pressed, arm it so its + // upcoming key-up latches (sustains) instead of releasing. + if enterLatchHeld { + latchArmedKeys.insert(keyCode) + if !latchedKeys.contains(keyCode) { latchedKeys.append(keyCode) } + } return true } else { + // Latched key: swallow the key-up so the note keeps ringing. + // It stays in heldNotes / litNotes (visualizer shows it held) + // until Backspace pops it via unlatchKey(_:). + if latchArmedKeys.contains(keyCode) { + // The physical key is now UP even though the note keeps + // sounding — poke the lit-changed hook so AppDelegate + // re-evaluates `keyboardNotesHeld` and releases the + // trackpad pitch-bend lock (latched ≠ physically held). + let notify: () -> Void = { [weak self] in self?.onLitChanged?() } + if Thread.isMainThread { notify() } + else { DispatchQueue.main.async(execute: notify) } + return true + } heldLock.lock() let note = heldNotes.removeValue(forKey: keyCode) let synthCh = heldKeyChannel.removeValue(forKey: keyCode) ?? 0 @@ -2131,6 +2543,49 @@ return true } } + /// Plain Backspace: release the most-recently latched note (LIFO). + /// No-op when nothing is latched. + private func unlatchMostRecent() { + guard let keyCode = latchedKeys.popLast() else { return } + unlatchKey(keyCode) + } + + /// Shift+Backspace: release ALL currently-latched notes at once. + private func unlatchAll() { + // Snapshot — unlatchKey mutates `latchedKeys` as it goes. + for keyCode in latchedKeys { unlatchKey(keyCode) } + latchedKeys.removeAll() + } + + /// Release a single latched key — mirrors the normal key-up branch + /// of `playKeyEvent` (linger-aware note-off + extinguish the lit + /// cell) but is driven by Backspace rather than the physical key-up. + private func unlatchKey(_ keyCode: UInt16) { + latchArmedKeys.remove(keyCode) + latchedKeys.removeAll { $0 == keyCode } + heldLock.lock() + let note = heldNotes.removeValue(forKey: keyCode) + let synthCh = heldKeyChannel.removeValue(forKey: keyCode) ?? 0 + let displayNote = heldKeyDisplayNote.removeValue(forKey: keyCode) + let wasLinger = heldKeyLinger.removeValue(forKey: keyCode) ?? false + heldLock.unlock() + guard let releasedNote = note else { return } + if wasLinger { + releaseLingering(midiNote: releasedNote, synthChannel: synthCh, + midiChannel: 0, isDrum: false, originalVelocity: 100) + } else { + if !midiMode { synth.noteOff(releasedNote, channel: synthCh) } + midi.noteOff(releasedNote) + } + let visualNote = displayNote ?? releasedNote + let extinguish = { [weak self] in + guard let self = self else { return } + self.litDownAt.removeValue(forKey: visualNote) + if self.litNotes.remove(visualNote) != nil { self.onLitChanged?() } + } + if Thread.isMainThread { extinguish() } else { DispatchQueue.main.async(execute: extinguish) } + } + func releaseAllHeldNotes() { heldLock.lock() let noteSnapshot = heldNotes @@ -2139,7 +2594,21 @@ heldNotes.removeAll() heldKeyChannel.removeAll() heldKeyDisplayNote.removeAll() heldKeyLinger.removeAll() + // Drop latch bookkeeping too — a blanket release means no note + // is held anymore, latched or otherwise. + latchArmedKeys.removeAll() + latchedKeys.removeAll() + enterLatchHeld = false + let drumSnapshot = heldDrumKeys + let drumDisplaySnapshot = heldDrumDisplay + heldDrumKeys.removeAll() + heldDrumDisplay.removeAll() heldLock.unlock() + // Release any held drum voices (open-hat foot pedal up) so a held + // key doesn't ring forever across an octave change / split toggle, + // and extinguish their hold lights. + for (_, group) in drumSnapshot { synth.percussionNoteOff(group) } + for (_, dn) in drumDisplaySnapshot { drumLitOff(dn) } let tapSnapshot = tapHeld let tapChanSnapshot = tapNoteChannel tapHeld.removeAll() diff --git a/slab/menuband/Sources/MenuBand/MenuBandMIDI.swift b/slab/menuband/Sources/MenuBand/MenuBandMIDI.swift --- a/slab/menuband/Sources/MenuBand/MenuBandMIDI.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandMIDI.swift @@ -121,6 +121,14 @@ guard keyCode < 128 else { return 64 } return panByKeyCode[Int(keyCode)] } + /// Display-note (in the menubar piano's clamped C4–C5 window, MIDI + /// 60–83) that splits the keyboard into a left half (lower octave, + /// 60–71) and a right half (upper octave, 72–83). Left shift lingers + /// + uppercases notes below this; right shift, at or above it. A + /// single value keeps the audio linger decision and the menubar + /// uppercase-cue in lockstep. + static let lingerSplitMidi = 72 + /// Hardware key codes used as octave-down / octave-up shifters in /// the given keymap. Notepat reserves , (43) and . (47); Ableton /// remaps to z (6) and x (7) because those sit unmapped in Live's @@ -145,6 +153,20 @@ if semitone == Int8.min { return nil } let value = 60 + Int(semitone) + (octaveShift * 12) guard value >= 0, value <= 127 else { return nil } return UInt8(value) + } + + /// The key's intrinsic semitone offset from middle C (before any octave + /// shift), or nil if unmapped. C = 0, +C (the right-hand octave) = 12. + /// The right-hand percussion split uses this: keys at semitone ≥ 12 are + /// the right hand, and `semitone % 12` picks their drum (octave- + /// invariant, matching notepat's drum wave). + @inline(__always) + static func semitone(forKeyCode keyCode: UInt16, + keymap: Keymap = .notepat) -> Int? { + guard keyCode < 128 else { return nil } + let table = (keymap == .ableton) ? semitoneByKeyCodeAbleton : semitoneByKeyCode + let s = table[Int(keyCode)] + return s == Int8.min ? nil : Int(s) } } diff --git a/slab/menuband/Sources/MenuBand/MenuBandPercussion.swift b/slab/menuband/Sources/MenuBand/MenuBandPercussion.swift new file mode 100644 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/MenuBandPercussion.swift @@ -0,0 +1,653 @@ +import AVFoundation +import Foundation +import QuartzCore + +/// Real-time percussion synthesizer — a Swift port of the AC-native +/// `lib/percussion.mjs` 12-drum kit (kick/snare/clap/snap/hats/ride + +/// crash/splash/cowbell/block/tambo accents). Each drum is a stack of +/// layered one-shot voices (sine / triangle / square / biquad-filtered +/// noise with attack+decay envelopes), synthesized live per hit with the +/// same `rj`/`rn` randomization as notepat so repeats don't sound +/// identical. Mirrors the oscillator + `compute_envelope` math in +/// `fedac/native/src/audio.c`. +/// +/// Used by Menu Band's right-hand percussion split: the upper-octave keys +/// trigger drums through this node while the lower keys stay melodic. +final class MenuBandPercussion { + // MARK: Drum kit + + /// The 12 drum slots, indexed by pitch class (0 = C … 11 = B), so a + /// key's intrinsic semitone (mod 12) selects its drum — octave- + /// invariant, exactly like notepat's drum wave. + enum Drum: Int, CaseIterable { + case kick = 0, crash = 1, snare = 2, splash = 3, clap = 4, snap = 5 + case cowbell = 6, hatClosed = 7, block = 8, hatOpen = 9, tambo = 10, ride = 11 + + static func forPitchClass(_ pc: Int) -> Drum { + Drum(rawValue: ((pc % 12) + 12) % 12) ?? .kick + } + + /// 3-char pad label, matching PERCUSSION_LABELS in percussion.mjs. + var label: String { + switch self { + case .kick: return "BAS" + case .snare: return "SNR" + case .clap: return "CLP" + case .snap: return "SNP" + case .hatClosed: return "HHC" + case .hatOpen: return "HHO" + case .ride: return "RDE" + case .crash: return "CRS" + case .splash: return "SPL" + case .cowbell: return "CBL" + case .block: return "BLK" + case .tambo: return "TMB" + } + } + } + + private enum Wave { case sine, triangle, square, noise } + + /// A drum hit's timestamp + 0…1 level, kept per pitch class so the + /// menubar can shake/blink the matching key with the live hit data. + struct DrumPulse { + var at: Double = 0 // CACurrentMediaTime of the hit + var level: Double = 0 // 0…1 (velocity-derived) + } + /// Most recent hit per pitch class (index == Drum.rawValue). Read as a + /// snapshot by the icon renderer each frame. + private var pulses = [DrumPulse](repeating: DrumPulse(), count: 12) + + /// Thread-safe copy of the current per-pitch-class hit pulses. + func pulseSnapshot() -> [DrumPulse] { + lock.lock(); defer { lock.unlock() } + return pulses + } + + // MARK: Voice state (render thread owns the active pool) + + private struct Voice { + var wave: Wave + var freq: Double // Hz; for noise this is the LPF cutoff + var duration: Double // seconds (.infinity for held voices) + var volume: Double // linear amplitude + var attack: Double // seconds + var decay: Double // seconds (fade at the tail) + var gainL: Float + var gainR: Float + // held / release behavior (hi-hat foot-pedal model) + var group: UInt64 = 0 // owning key-press, for release routing + var held: Bool = false // rings until released (ignores duration) + var releaseFade: Double = 0 // fade-out seconds once released + var toneTarget: Double = 0 // freq slides here on release (0 = keep) + var releasing: Bool = false + var releaseElapsed: Double = 0 + var releaseFromFreq: Double = 0 + // running state + var phase: Double = 0 + var elapsed: Double = 0 + // biquad low-pass state for noise voices + var nb0: Double = 0, nb1: Double = 0, nb2: Double = 0 + var na1: Double = 0, na2: Double = 0 + var nx1: Double = 0, nx2: Double = 0, ny1: Double = 0, ny2: Double = 0 + var seed: UInt32 = 1 + } + + // MARK: Audio graph + + /// Render sample rate — set to the engine's output rate at attach so + /// our synthesis runs at the hardware clock with no resample (the noise + /// biquad + phase increments use this). Pitch itself is absolute Hz, so + /// every drum's tones match AC-native regardless of the rate. + private var sampleRate: Double = 48_000 + private var format: AVAudioFormat! + private var sourceNode: AVAudioSourceNode! + private weak var engine: AVAudioEngine? + private var attached = false + + /// Master headroom so a fistful of simultaneous drums doesn't slam the + /// limiter — the kit's raw layer volumes sum well past 1.0 by design. + private let masterGain: Float = 0.34 + + /// Global pitch multiplier applied to every voice's phase increment + /// (and noise LPF cutoff) so the trackpad pitch-bend warps the whole + /// kit alongside the melodic voices. 1.0 = no shift; 2.0 = +1 octave. + /// Written from the main thread by `setPitchBend`, read on the render + /// thread — a plain Double load/store is atomic enough here (worst + /// case a single block reads a half-stale value, inaudible). + private var pitchScale: Double = 1.0 + + /// Set the kit-wide pitch shift from a bend amount in the same units + /// the melodic voices use (±1 == ±12 semitones). Applied live to all + /// sounding + future drum voices. + func setPitchBend(amount: Float) { + pitchScale = pow(2.0, Double(amount)) // 1 unit = one octave + } + + /// Active voices, mutated only on the render thread. Fixed cap keeps + /// the per-cycle work bounded under fast play / rolls. + private var active: [Voice] = [] + private let maxVoices = 96 + + /// Pending voices staged by the control thread, drained at the top of + /// each render cycle. Guarded by a tiny critical section. + private var pending: [Voice] = [] + private let lock = NSLock() + + /// Monotonic id per key-press, so a key-up can release exactly the held + /// voices it started (the hi-hat foot-pedal model). + private var groupCounter: UInt64 = 0 + /// Release-burst voices staged at key-down, fired at key-up (closed-hat + /// "lift click"). Keyed by group. + private var releaseStore: [UInt64: [Voice]] = [:] + /// Groups awaiting release, drained by the render thread to flip their + /// held voices into the fade-out (open-hat foot-pedal damping). + private var pendingReleases: [UInt64] = [] + + /// Latency probe: when voices were staged (noteOn) vs. when the render + /// thread first picked them up — the trigger→render handoff, the + /// percussion-specific slice of keypress→sound latency. + private var pendingStageTime: Double = 0 + private var lastHandoffSec: Double = 0 + /// Most recent trigger→render handoff in milliseconds (≤ one buffer). + func triggerHandoffMs() -> Double { + lock.lock(); defer { lock.unlock() } + return lastHandoffSec * 1000.0 + } + + init() { + active.reserveCapacity(maxVoices) + pending.reserveCapacity(maxVoices) + } + + /// Attach into the synth graph — mirrors MenuBandTape / radio / sample + /// backends, connecting straight to the pre-limiter sum so drums share + /// the master gain + limiter with every other voice. The source node is + /// built here at the engine's output rate so there's no resample. + func attach(to engine: AVAudioEngine, output: AVAudioNode) { + guard !attached else { return } + self.engine = engine + let outRate = engine.outputNode.outputFormat(forBus: 0).sampleRate + sampleRate = outRate > 0 ? outRate : 48_000 + format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, + channels: 2)! + sourceNode = AVAudioSourceNode(format: format) { + [weak self] _, _, frameCount, ablPointer -> OSStatus in + self?.render(frameCount: Int(frameCount), abl: ablPointer) + return noErr + } + engine.attach(sourceNode) + engine.connect(sourceNode, to: output, format: format) + attached = true + } + + // MARK: Trigger + + /// Fire a drum. `velocity` 0–127, `pan` −1…1. Synthesizes the layered + /// voices on the calling (control) thread and stages them for the + /// render thread — no audio work happens here. + /// One-shot convenience (UI cues): fire a drum's down voices and let it + /// ring out. Any held voices are forced finite — used only for the + /// kick / closed-hat toggle cues, which have none. + func play(_ drum: Drum, velocity: UInt8, pan: Double) { + let v = max(0.1, min(2.2, Double(velocity) / 100.0)) + let dv = build(for: drum, v: v, basePan: pan, group: 0) + let voices = dv.down.map(finite) + guard !voices.isEmpty else { return } + let now = CACurrentMediaTime() + let level = min(1.0, max(0.0, Double(velocity) / 127.0)) + lock.lock() + for s in voices where pending.count < maxVoices { pending.append(s) } + pulses[drum.rawValue] = DrumPulse(at: now, level: level) + lock.unlock() + } + + /// Key-down: fire the drum and return a group token. Held voices (open + /// hat) ring until `noteOff(group)`; closed-hat stores its release burst + /// for that moment. The caller tracks the group per key. + @discardableResult + func noteOn(_ drum: Drum, velocity: UInt8, pan: Double, accent: Bool = false) -> UInt64 { + let v = max(0.1, min(2.2, Double(velocity) / 100.0)) + lock.lock(); groupCounter &+= 1; let g = groupCounter; lock.unlock() + let dv = build(for: drum, v: v, basePan: pan, group: g) + var down = dv.down + if accent { + // Accent (shift + drum): a HARDER hit, not a reversal. Boost the + // whole kit and stack a per-drum "intensity" layer so each sound + // has its own idea of slamming harder — punchier kick, crackier + // snare, brighter hats… It's the percussion analogue of the + // melodic linger: shift = more. + for i in down.indices { down[i].volume *= 1.30 } + down.append(contentsOf: accentLayers(for: drum, v: v, basePan: pan)) + } + let now = CACurrentMediaTime() + let level = min(1.0, max(0.0, Double(velocity) / 127.0)) + lock.lock() + for s in down where pending.count < maxVoices { pending.append(s) } + if !dv.release.isEmpty { releaseStore[g] = dv.release } + pulses[drum.rawValue] = DrumPulse(at: now, level: min(1.0, level * (accent ? 1.2 : 1.0))) + pendingStageTime = now + lock.unlock() + return g + } + + /// Per-drum "harder hit" layers stacked on top of the normal voices when + /// shift accents a drum. Each drum gets its own character boost rather + /// than a generic louder — a deeper/snappier kick, a crackier snare, + /// brighter cymbals, and so on. + private func accentLayers(for drum: Drum, v: Double, basePan: Double) -> [Voice] { + var out: [Voice] = [] + let dp = basePan + switch drum { + case .kick: + out.append(makeVoice(.sine, 44, rj(0.32, 0.15), 1.5 * v, 0.002, 0.31, dp)) // deeper sub + out.append(makeVoice(.noise, 4200, 0.003, 0.62 * v, 0.0001, 0.0028, dp)) // sharper beater click + out.append(makeVoice(.sine, 120, 0.060, 1.05 * v, 0.001, 0.058, dp)) // extra punch + case .snare: + out.append(makeVoice(.noise, 4800, rj(0.13, 0.2), 0.7 * v, 0.0004, 0.122, dp)) // brighter crack + out.append(makeVoice(.triangle, 330, 0.020, 0.30 * v, 0.001, 0.019, dp)) // rim ring + case .clap: + out.append(makeVoice(.noise, 1400, 0.050, 0.7 * v, 0.001, 0.045, dp)) // extra burst + case .snap: + out.append(makeVoice(.noise, 7200, 0.003, 0.55 * v, 0.0001, 0.0028, dp)) // sharper snap + case .hatClosed: + out.append(makeVoice(.noise, 9500, rj(0.05, 0.2), 0.40 * v, 0.0004, 0.045, dp)) // brighter sizzle + case .hatOpen: + out.append(makeVoice(.noise, 9500, rj(0.55, 0.2), 0.34 * v, 0.003, 0.5, dp)) // longer sizzle + case .ride: + out.append(makeVoice(.square, 880, 0.030, 0.13 * v, 0.0005, 0.029, dp)) // harder bell ping + case .crash: + out.append(makeVoice(.noise, 9500, rj(1.6, 0.15), 0.5 * v, 0.006, 1.5, dp)) // bigger wash + case .splash: + out.append(makeVoice(.noise, 10000, rj(0.30, 0.2), 0.40 * v, 0.003, 0.29, dp)) // brighter splash + case .cowbell: + out.append(makeVoice(.square, 1000, rj(0.22, 0.15), 0.30 * v, 0.001, 0.21, dp)) // clangier + case .block: + out.append(makeVoice(.noise, 6000, 0.0018, 0.42 * v, 0.0001, 0.0016, dp)) // harder knock + out.append(makeVoice(.triangle, 3000, 0.040, 0.40 * v, 0.0003, 0.038, dp)) + case .tambo: + out.append(makeVoice(.noise, 8200, rj(0.18, 0.2), 0.35 * v, 0.002, 0.17, dp)) // more jingle + } + return out + } + + /// Key-up: damp the group's held voices (open-hat foot-pedal close) and + /// fire its stored release burst (closed-hat lift click). No-op for the + /// one-shot drums. + func noteOff(_ group: UInt64) { + guard group != 0 else { return } + lock.lock() + if let bursts = releaseStore.removeValue(forKey: group) { + for s in bursts where pending.count < maxVoices { pending.append(s) } + } + pendingReleases.append(group) + lock.unlock() + } + + /// Drop all ringing voices (e.g. when the split toggles off). + func silence() { + lock.lock() + pending.removeAll(keepingCapacity: true) + releaseStore.removeAll(keepingCapacity: true) + pendingReleases.removeAll(keepingCapacity: true) + lock.unlock() + // active is cleared lazily by the render thread; mark a flush. + flushRequested = true + } + private var flushRequested = false + + /// Force a (possibly held) voice to a short finite one-shot — for cues + /// that never get a matching release. + private func finite(_ v: Voice) -> Voice { + guard v.held else { return v } + var c = v + c.held = false + c.duration = 0.25 + c.decay = 0.25 + return c + } + + // MARK: Recipe → voices (port of percussion.mjs) + + private func rj(_ c: Double, _ f: Double) -> Double { + c * (1 + (Double.random(in: 0..<1) - 0.5) * 2 * f) + } + private func rn(_ a: Double, _ b: Double) -> Double { + a + Double.random(in: 0..<1) * (b - a) + } + + private static let hatFreqs: [Double] = [800, 540, 522.7, 369.6] + + /// Build one layer. `decay == 0` (the sustain voices) fades across the + /// whole duration so the tail never clicks off — the JS relies on the + /// engine's default release for that, which we make explicit here. + private func makeVoice(_ wave: Wave, _ tone: Double, _ duration: Double, + _ volume: Double, _ attack: Double, _ decay: Double, + _ pan: Double) -> Voice { + let p = max(-1.0, min(1.0, pan)) + let theta = (p + 1) * 0.25 * .pi // constant-power pan + var voice = Voice( + wave: wave, freq: max(1, tone), duration: max(0.0005, duration), + volume: volume, attack: max(0, attack), + decay: decay > 0 ? decay : max(0.0005, duration), + gainL: Float(cos(theta)), gainR: Float(sin(theta)), + seed: UInt32.random(in: 1...UInt32.max) + ) + if wave == .noise { setupNoiseFilter(&voice) } + return voice + } + + private struct DrumVoices { + var down: [Voice] = [] // fired on key-down + var release: [Voice] = [] // fired on key-up (closed-hat lift click) + } + + /// A held voice that rings until released. On release it fades over + /// `releaseFade`; noise voices step their LPF cutoff to `toneTarget` + /// (the open-hat pedal-close darkening) — mirrors percussion.mjs's + /// addSustain releaseFade + releaseUpdate.tone. + private func makeHeld(_ wave: Wave, _ tone: Double, _ vol: Double, + _ atk: Double, _ pan: Double, group: UInt64, + releaseFade: Double, toneTarget: Double) -> Voice { + let p = max(-1.0, min(1.0, pan)) + let theta = (p + 1) * 0.25 * .pi + var voice = Voice( + wave: wave, freq: max(1, tone), duration: .infinity, + volume: vol, attack: max(0, atk), decay: 0, + gainL: Float(cos(theta)), gainR: Float(sin(theta)), + seed: UInt32.random(in: 1...UInt32.max) + ) + voice.group = group + voice.held = true + voice.releaseFade = max(0.01, releaseFade) + voice.toneTarget = toneTarget + if wave == .noise { setupNoiseFilter(&voice) } + return voice + } + + private func build(for drum: Drum, v: Double, basePan: Double, + group: UInt64) -> DrumVoices { + var dv = DrumVoices() + // addHit: finite one-shot. sustain(): finite one-shot for cymbals + // that ring out regardless of key (ride/crash). heldSustain(): rings + // until release, then damps (the hi-hat foot pedal). releaseHit(): + // a one-shot fired on key-up (closed-hat lift click). + func hit(_ w: Wave, _ tone: Double, _ dur: Double, _ vol: Double, + _ atk: Double, _ dec: Double, _ pan: Double) { + dv.down.append(makeVoice(w, tone, dur, vol, atk, dec, pan)) + } + func sustain(_ w: Wave, _ tone: Double, _ both: Double, _ vol: Double, + _ atk: Double, _ pan: Double) { + dv.down.append(makeVoice(w, tone, both, vol, atk, 0, pan)) + } + func heldSustain(_ w: Wave, _ tone: Double, _ vol: Double, _ atk: Double, + _ pan: Double, releaseFade: Double, toneTarget: Double) { + dv.down.append(makeHeld(w, tone, vol, atk, pan, group: group, + releaseFade: releaseFade, toneTarget: toneTarget)) + } + func releaseHit(_ w: Wave, _ tone: Double, _ dur: Double, _ vol: Double, + _ atk: Double, _ dec: Double, _ pan: Double) { + dv.release.append(makeVoice(w, tone, dur, vol, atk, dec, pan)) + } + + switch drum { + case .kick: + let dp = basePan + rn(-0.02, 0.02) + hit(.noise, 2500, 0.0025, rj(0.50, 0.12) * v, 0.0002, 0.0022, dp) + hit(.sine, 200, 0.012, rj(1.1, 0.10) * v, 0.0005, 0.011, dp) + hit(.sine, 150, 0.045, rj(1.3, 0.10) * v, 0.001, 0.044, dp) + hit(.sine, 90, 0.080, rj(0.85, 0.12) * v, 0.002, 0.078, dp) + hit(.sine, 55, rj(0.35, 0.20), rj(1.0, 0.12) * v, 0.003, 0.345, dp) + // Key-up "beater off" — a soft low thump, quieter + shorter + // than the hit so a held-then-released kick reads as a + // damped close rather than a second strike. + releaseHit(.sine, 70, 0.045, rj(0.40, 0.12) * v, 0.001, 0.044, dp) + releaseHit(.noise, 1600, 0.003, rj(0.16, 0.18) * v, 0.0002, 0.0028, dp) + case .snare: + let dp = basePan + rn(-0.02, 0.02) + hit(.noise, 3500, 0.004, rj(0.95, 0.10) * v, 0.0001, 0.004, dp) + hit(.sine, 238, 0.030, rj(0.35, 0.12) * v, 0.0003, 0.029, dp) + hit(.sine, 476, 0.030, rj(0.28, 0.12) * v, 0.0003, 0.029, dp) + hit(.noise, 3500, rj(0.11, 0.20), rj(0.85, 0.10) * v, 0.0005, 0.108, dp + rn(-0.04, 0.04)) + hit(.noise, 1800, rj(0.07, 0.20), rj(0.38, 0.15) * v, 0.0008, 0.068, dp + rn(-0.04, 0.04)) + hit(.triangle, 180, 0.025, rj(0.22, 0.15) * v, 0.001, 0.024, dp) + // Key-up — a short, dry snare-wire "tick": a brief noise + // crack plus a faint body ring, lighter than the down hit. + releaseHit(.noise, 4000, 0.006, rj(0.30, 0.15) * v, 0.0002, 0.0055, dp) + releaseHit(.triangle, 200, 0.018, rj(0.14, 0.15) * v, 0.0006, 0.017, dp) + case .clap: + let dp = basePan + rn(-0.06, 0.02) + hit(.noise, 1000, 0.025, rj(0.90, 0.15) * v, 0.005, 0.020, dp) + hit(.noise, 1100, 0.035, rj(0.95, 0.15) * v, 0.015, 0.020, dp) + hit(.noise, 900, 0.045, rj(0.85, 0.15) * v, 0.025, 0.020, dp) + hit(.noise, 3000, 0.008, rj(0.55, 0.15) * v, 0.001, 0.007, dp) + hit(.noise, 1000, rj(0.14, 0.25), rj(0.85, 0.15) * v, 0.045, 0.135, dp + rn(-0.02, 0.10)) + hit(.noise, 2200, rj(0.10, 0.25), rj(0.35, 0.18) * v, 0.050, 0.095, dp + rn(-0.02, 0.10)) + case .snap: + let dp = basePan + rn(-0.04, 0.04) + hit(.noise, 6000, 0.003, rj(0.70, 0.15) * v, 0.0001, 0.0028, dp) + hit(.sine, 2100, rj(0.045, 0.20), rj(0.55, 0.12) * v, 0.0005, 0.044, dp) + hit(.sine, 3500, rj(0.020, 0.25), rj(0.28, 0.18) * v, 0.0005, 0.019, dp) + case .hatClosed: + // Down: the 4-square cluster + bright noise chick. Up: the + // foot-pedal "lift click" release burst (separate key-up sound). + let dp = basePan + rn(-0.03, 0.03) + for f in Self.hatFreqs { + hit(.square, f, rj(0.008, 0.20), rj(0.18, 0.18) * v, 0.0005, 0.0075, dp) + } + hit(.noise, 8000, rj(0.040, 0.20), rj(0.38, 0.12) * v, 0.0005, 0.038, dp) + releaseHit(.noise, 9000, 0.004, rj(0.22, 0.20) * v, 0.0002, 0.0038, dp) + releaseHit(.square, 6000, 0.003, rj(0.08, 0.25) * v, 0.0003, 0.0027, dp) + case .hatOpen: + // Down: cluster + noise chick, then LONG held shimmer that rings + // while the key is down. Up: the held voices damp fast (foot + // pedal closes), the noise layers darkening as they fade. + let dp = basePan + rn(-0.04, 0.04) + for f in Self.hatFreqs { + hit(.square, f, 0.012, rj(0.16, 0.18) * v, 0.0005, 0.011, dp) + } + hit(.noise, 8200, 0.012, rj(0.42, 0.12) * v, 0.0003, 0.011, dp) + heldSustain(.noise, 7000, rj(0.32, 0.15) * v, 0.003, dp + rn(-0.02, 0.08), + releaseFade: 0.12, toneTarget: 3500) + heldSustain(.noise, 5000, rj(0.20, 0.18) * v, 0.003, dp + rn(-0.02, 0.08), + releaseFade: 0.10, toneTarget: 2800) + heldSustain(.square, 800, rj(0.08, 0.20) * v, 0.005, dp, + releaseFade: 0.08, toneTarget: 0) + case .ride: + let dp = basePan + rn(-0.03, 0.03) + hit(.square, 800, 0.020, rj(0.10, 0.18) * v, 0.0005, 0.019, dp) + hit(.square, 540, 0.020, rj(0.08, 0.18) * v, 0.0005, 0.019, dp) + sustain(.sine, 440, rj(0.40, 0.20), rj(0.24, 0.12) * v, 0.0008, dp) + sustain(.sine, 587, rj(0.40, 0.20), rj(0.20, 0.12) * v, 0.0008, dp) + sustain(.noise, 4200, rj(0.9, 0.20), rj(0.26, 0.12) * v, 0.005, dp + rn(-0.03, 0.03)) + case .crash: + let dp = basePan + rn(-0.05, 0.05) + hit(.noise, 8000, 0.030, rj(0.75, 0.15) * v, 0.0005, 0.029, dp) + for f in Self.hatFreqs { + hit(.square, f, 0.030, rj(0.12, 0.20) * v, 0.0005, 0.029, dp) + } + sustain(.noise, 5000, rj(1.4, 0.18), rj(0.45, 0.12) * v, 0.008, dp + rn(-0.04, 0.04)) + sustain(.noise, 7500, rj(0.9, 0.18), rj(0.30, 0.15) * v, 0.008, dp + rn(-0.04, 0.04)) + sustain(.square, 800, rj(0.5, 0.20), rj(0.08, 0.20) * v, 0.008, dp) + case .splash: + let dp = basePan + rn(-0.04, 0.04) + hit(.noise, 9000, 0.012, rj(0.55, 0.15) * v, 0.0003, 0.011, dp) + hit(.square, 800, 0.015, rj(0.14, 0.20) * v, 0.0005, 0.014, dp) + hit(.square, 540, 0.015, rj(0.10, 0.20) * v, 0.0005, 0.014, dp) + hit(.noise, 6000, rj(0.35, 0.20), rj(0.42, 0.12) * v, 0.004, 0.345, dp + rn(-0.03, 0.03)) + hit(.noise, 8500, rj(0.22, 0.20), rj(0.25, 0.15) * v, 0.004, 0.215, dp + rn(-0.03, 0.03)) + case .cowbell: + let dp = basePan + rn(-0.03, 0.03) + hit(.square, 1800, 0.004, rj(0.35, 0.15) * v, 0.0002, 0.0038, dp) + hit(.triangle, 800, rj(0.28, 0.20), rj(0.42, 0.12) * v, 0.0008, 0.275, dp) + hit(.triangle, 540, rj(0.28, 0.20), rj(0.36, 0.12) * v, 0.0008, 0.275, dp) + // Key-up — a short muted "clack" an octave-ish up, like a + // hand damping the bell: brief, bright, and quiet. + releaseHit(.square, 2400, 0.006, rj(0.16, 0.15) * v, 0.0003, 0.0055, dp) + releaseHit(.triangle, 1080, 0.020, rj(0.12, 0.15) * v, 0.0006, 0.019, dp) + case .block: + let dp = basePan + rn(-0.03, 0.03) + hit(.noise, 5000, 0.002, rj(0.35, 0.18) * v, 0.0001, 0.0018, dp) + hit(.triangle, 2500, rj(0.050, 0.25), rj(0.52, 0.12) * v, 0.0003, 0.048, dp) + hit(.triangle, 1250, rj(0.050, 0.25), rj(0.18, 0.18) * v, 0.0005, 0.048, dp) + case .tambo: + let dp = basePan + rn(-0.04, 0.04) + hit(.noise, 7000, 0.08, rj(0.38, 0.18) * v, 0.002, 0.075, dp) + hit(.noise, 7500, 0.09, rj(0.30, 0.18) * v, 0.015, 0.075, dp) + hit(.noise, 6500, 0.10, rj(0.25, 0.18) * v, 0.030, 0.070, dp) + hit(.square, 6000, 0.030, rj(0.14, 0.20) * v, 0.001, 0.028, dp) + hit(.noise, 7000, rj(0.20, 0.22), rj(0.32, 0.18) * v, 0.050, 0.195, dp + rn(-0.04, 0.04)) + hit(.noise, 4500, rj(0.15, 0.22), rj(0.20, 0.18) * v, 0.055, 0.145, dp + rn(-0.04, 0.04)) + } + return dv + } + + // MARK: Noise biquad (matches audio.c setup_noise_filter, Q = 1) + + private func setupNoiseFilter(_ v: inout Voice) { + let cutoff = min(v.freq, sampleRate * 0.45) + let q = 1.0 + let w0 = 2.0 * .pi * cutoff / sampleRate + let alpha = sin(w0) / (2.0 * q) + let cosw = cos(w0) + let b = (1.0 - cosw) / 2.0 + let norm = 1.0 + alpha + v.nb0 = b / norm + v.nb1 = (1.0 - cosw) / norm + v.nb2 = b / norm + v.na1 = (-2.0 * cosw) / norm + v.na2 = (1.0 - alpha) / norm + } + + @inline(__always) + private func xorshift(_ s: inout UInt32) -> UInt32 { + s ^= s << 13; s ^= s >> 17; s ^= s << 5 + return s + } + + // MARK: Envelope (matches audio.c compute_envelope) + + @inline(__always) + private func envelope(_ v: Voice, at t: Double) -> Double { + var env = 1.0 + if v.attack > 0, t < v.attack { + env = t / v.attack + } + if v.decay > 0 { + let decayStart = max(0, v.duration - v.decay) + if t > decayStart { + var prog = (t - decayStart) / v.decay + if prog > 1 { prog = 1 } + env *= (1 - prog) + } + } + return env + } + + // MARK: Render thread + + private func render(frameCount: Int, abl: UnsafeMutablePointer) { + let buffers = UnsafeMutableAudioBufferListPointer(abl) + let left = buffers[0].mData!.assumingMemoryBound(to: Float.self) + let right = (buffers.count > 1 ? buffers[1].mData! : buffers[0].mData!) + .assumingMemoryBound(to: Float.self) + for i in 0.. 0 { lastHandoffSec = max(0, now - pendingStageTime) } + } + var releases: Set = [] + if !pendingReleases.isEmpty { + releases = Set(pendingReleases) + pendingReleases.removeAll(keepingCapacity: true) + } + lock.unlock() + + // Flip newly-released held voices into their fade-out. Noise voices + // step their LPF cutoff to the darker release target (pedal close). + if !releases.isEmpty { + for idx in 0.. 0, active[idx].wave == .noise { + active[idx].freq = active[idx].toneTarget + setupNoiseFilter(&active[idx]) + } + } + } + if active.isEmpty { return } + + let dt = 1.0 / sampleRate + let g = Double(masterGain) + var w = 0 + for idx in 0..= v.releaseFade { alive = false; break } + } else if !v.held { + if v.elapsed >= v.duration { alive = false; break } + } + let env = envelope(v, at: v.elapsed) + let relGain = v.releasing + ? max(0.0, 1.0 - v.releaseElapsed / v.releaseFade) : 1.0 + var s: Double + switch v.wave { + case .sine: + s = sin(2.0 * .pi * v.phase) + case .triangle: + // Wrap the +0.25 phase offset back into [0,1) — without + // the wrap the last quarter ramps to +2.0, adding bright + // harmonics that alias at 48k and read as a higher, + // harsher pitch than AC-native (block / o key). + let tp = (v.phase + 0.25).truncatingRemainder(dividingBy: 1.0) + s = 4.0 * abs(tp - 0.5) - 1.0 + case .square: + s = v.phase < 0.5 ? 1.0 : -1.0 + case .noise: + let r = Double(xorshift(&v.seed)) / Double(UInt32.max) + let white = r * 2.0 - 1.0 + let y = v.nb0 * white + v.nb1 * v.nx1 + v.nb2 * v.nx2 + - v.na1 * v.ny1 - v.na2 * v.ny2 + v.nx2 = v.nx1; v.nx1 = white + v.ny2 = v.ny1; v.ny1 = y + s = y + } + let amp = s * env * relGain * v.volume * g + left[i] += Float(amp * Double(v.gainL)) + right[i] += Float(amp * Double(v.gainR)) + // Kit-wide pitch bend scales the phase increment so + // tonal drums (kick/toms/blocks/cowbell) bend with the + // melodic voices. Noise voices use `freq` as an LPF + // cutoff, not a pitch, so they're left unscaled. + v.phase += v.freq * (v.wave == .noise ? 1.0 : pitch) * dt + if v.phase >= 1 { v.phase -= floor(v.phase) } + v.elapsed += dt + if v.releasing { v.releaseElapsed += dt } + } + if alive { + active[w] = v + w += 1 + } + } + if w < active.count { active.removeLast(active.count - w) } + } +} diff --git a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift --- a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift @@ -149,6 +149,11 @@ /// visualizer bezel, mirroring the floating play palette so the /// popover gets the same searchable chord readout. private var chordCandidatesStack: NSStackView! private var chordCandidatesRow: NSView! + /// All-Swift scrolling staff (Phase 1, below the Verovio sheet) + its + /// backing take. Declarations added to unblock the in-progress + /// tape-staff wiring already referencing them below. + private var tapeStaff: TapeStaffScroller! + private let tapeScore = TapeScore() /// Internal but exposed so AppDelegate can push the pitch-shift /// value (octave + bend) directly when those change — the staff /// translates vertically by that amount so the user feels the @@ -179,6 +184,12 @@ /// retired; the housing stays for visual continuity (rounded /// dark recess) but holds only the held-notes pills (top) and /// chord candidate cards (bottom). private var waveformBezel: NSView! + /// Height of the staff bezel when shown. Animated to 0 (and the + /// bezel hidden) when the metronome is off — the notation staff + /// only exists while a beat grid is running, and slides into view + /// when the metronome starts. + private var tapeBezelHeightConstraint: NSLayoutConstraint? + private static let tapeBezelHeight: CGFloat = 88 + 12 private var metronome: MetronomeWidget! /// Transport controls that appear next to the metronome when a /// Menu Band PDF score has been loaded into the staff. Play @@ -364,11 +375,12 @@ // Yellow-flash the menubar music-note icon on every // metronome beat. AppDelegate's animation tick decays // it back to zero between beats. KeyboardIconRenderer.metronomeFlash = 1 - // Forward each beat to the sheet card so Verovio's - // tick stream stays in lockstep with the visible - // metronome — notes placed in JS land in the right - // beat slot of the right measure. - self?.sheetView?.beat() + // The all-Swift tape staff is time-based, so it needs no + // per-beat tick. Keep the metronome BPM in sync for any + // beat-grid drawing the staff may do. + if let bpm = self?.metronome?.bpm { + self?.tapeStaff?.bpm = Double(bpm) + } // Drop a beat marker on the (now-hidden) StaffView // too — its chord-display path still depends on it // and removing it would break the existing chord @@ -385,7 +397,11 @@ // Repaint the menubar icon as soon as the running state // flips so the wave indicator appears (or vanishes) // even while the popover stays open. (NSApp.delegate as? AppDelegate)?.updateIcon() - _ = self + // The notation staff only exists while the metronome runs + // (the beat grid needs a tempo). Slide it in on start, + // collapse it on stop. + self?.setNotationVisible(self?.metronome?.isPlaying == true, + animated: true) } titleRow.addArrangedSubview(metronome) titleRow.setCustomSpacing(6, after: metronome) @@ -715,69 +731,23 @@ // Bezel extends to match the popover stack's full width (= preferredWidth + 16) // so the staff lines + scrolling notes paint EDGE TO EDGE of // the popover, ignoring the chooser-row's interior margins. waveformBezel.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth + 16).isActive = true - // Bezel grows to fit the sheet view inside (chordCandidates - // row pads top/bottom by 4pt, stack pads inner 2pt). Sheet - // is letter portrait at popover width — bezel ends up - // ~bezelWidth × 11/8.5 + 12pt for the pad chrome. - let sheetIntrinsicWidth = InstrumentListView.preferredWidth + 16 - let sheetIntrinsicHeight = sheetIntrinsicWidth * 11.0 / 8.5 - waveformBezel.heightAnchor.constraint(equalToConstant: sheetIntrinsicHeight + 12).isActive = true + // Bezel grows to fit the tape staff inside. The old Verovio + // sheet was an 8.5×11 portrait (~330pt tall) and the bezel was + // sized to match; the all-Swift tape staff is a short wide + // strip, so the bezel is now just the staff height plus the + // row chrome (chordCandidatesRow pads 4pt top/bottom, stack + // pads 2pt) — no more dead portrait whitespace. + let bezelH = waveformBezel.heightAnchor.constraint( + equalToConstant: Self.tapeBezelHeight) + bezelH.isActive = true + tapeBezelHeightConstraint = bezelH - // Mini visualizer strip — dedicated short box below the - // staff bezel. Reads as a separate audio-reactive readout - // sitting under the popover's main content rather than - // crowding the bezel that holds the staff. - miniWaveformView = WaveformView() - miniWaveformView.menuBand = menuBand - // Flip live so the WaveformView starts its CVDisplayLink and - // calls `setWaveformCaptureEnabled(true)` on the synth — bars - // stay flat at 0 until this lands. - miniWaveformView.isLive = true - miniWaveformView.translatesAutoresizingMaskIntoConstraints = false - miniWaveformBezel = HoverTrackingView() - miniWaveformBezel.wantsLayer = true - miniWaveformBezel.layer?.cornerRadius = 6 - miniWaveformBezel.layer?.borderWidth = 0.6 - miniWaveformBezel.layer?.backgroundColor = NSColor(white: 0.06, - alpha: 0.25).cgColor - miniWaveformBezel.layer?.borderColor = NSColor.separatorColor - .withAlphaComponent(0.5).cgColor - miniWaveformBezel.translatesAutoresizingMaskIntoConstraints = false - miniWaveformBezel.addSubview(miniWaveformView) - let miniBezelInset: CGFloat = 4 - NSLayoutConstraint.activate([ - miniWaveformView.leadingAnchor.constraint(equalTo: miniWaveformBezel.leadingAnchor, constant: miniBezelInset), - miniWaveformView.trailingAnchor.constraint(equalTo: miniWaveformBezel.trailingAnchor, constant: -miniBezelInset), - miniWaveformView.topAnchor.constraint(equalTo: miniWaveformBezel.topAnchor, constant: miniBezelInset), - miniWaveformView.bottomAnchor.constraint(equalTo: miniWaveformBezel.bottomAnchor, constant: -miniBezelInset), - ]) - // Hover affordance: brighten the bezel border + nudge alpha - // so the strip reads as "click to expand." On click, jump - // to Esteban's full-screen liquid panel via the popover's - // expand entry point. - miniWaveformBezel.onHoverChanged = { [weak self] hovered in - guard let bezel = self?.miniWaveformBezel else { return } - NSAnimationContext.runAnimationGroup({ ctx in - ctx.duration = 0.18 - ctx.allowsImplicitAnimation = true - bezel.layer?.borderColor = (hovered - ? NSColor.controlAccentColor.withAlphaComponent(0.85) - : NSColor.separatorColor.withAlphaComponent(0.5)).cgColor - bezel.layer?.borderWidth = hovered ? 1.2 : 0.6 - bezel.alphaValue = hovered ? 1.0 : 0.85 - }) - NSCursor.pointingHand.set() - } - let miniViewClick = NSClickGestureRecognizer( - target: self, - action: #selector(miniVisualizerClicked(_:)) - ) - miniWaveformBezel.addGestureRecognizer(miniViewClick) - miniWaveformBezel.toolTip = "Click to open the full-screen piano" - stack.setCustomSpacing(8, after: waveformBezel) - stack.addArrangedSubview(miniWaveformBezel) - miniWaveformBezel.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth).isActive = true - miniWaveformBezel.heightAnchor.constraint(equalToConstant: 56).isActive = true + // Mini visualizer strip removed — the audio-reactive readout + // now lives in the floating KidLisp TV panel below the + // popover, so the in-popover bars were redundant. The + // `miniWaveformView` / `miniWaveformBezel` ivars stay declared + // (left nil) since the retint + appearance paths already guard + // on `if let`. // Pill container is allocated but unused (kept so any // legacy refresh path doesn't crash); not added to the @@ -834,10 +804,14 @@ // chord-candidate display is ported to the sheet view, // staffView can be removed entirely. staffView = StaffView() staffView.translatesAutoresizingMaskIntoConstraints = false + // SheetMusicView is no longer shown — it's kept only as the + // lazy Verovio engine for on-demand PDF export + PDF drag-in + // playback. The visible live staff is the all-Swift tapeStaff + // below. Not added to the view hierarchy; its webviews stay + // unbuilt until a PDF is requested. sheetView = SheetMusicView() sheetView.menuBand = menuBand sheetView.translatesAutoresizingMaskIntoConstraints = false - chordCandidatesStack.addArrangedSubview(sheetView) // Drive the transport-button visibility whenever the sheet // toggles playback state (PDF load, manual stop, natural // end-of-score). The visibility update is cheap so we don't @@ -851,9 +825,21 @@ // aspect (8.5×11). The popover lengthens to host it. No // inset around the page; the cream paper sits flush with // the bezel's bounds. let sheetWidth = InstrumentListView.preferredWidth + 16 + + // The all-Swift scrolling staff — now the sole visible score + // surface. Short wide strip (saves the vertical space the old + // 8.5×11 Verovio portrait ate) with bigger, more legible + // notes. Auto-scrolls to follow the playhead; two-finger + // trackpad scrubs back through the take. + tapeStaff = TapeStaffScroller() + tapeStaff.translatesAutoresizingMaskIntoConstraints = false + tapeStaff.score = tapeScore + tapeStaff.live = true + tapeStaff.bpm = Double(KeyboardIconRenderer.metronomeBPM) + chordCandidatesStack.addArrangedSubview(tapeStaff) NSLayoutConstraint.activate([ - sheetView.widthAnchor.constraint(equalToConstant: sheetWidth), - sheetView.heightAnchor.constraint(equalToConstant: sheetWidth * 11.0 / 8.5), + tapeStaff.widthAnchor.constraint(equalToConstant: sheetWidth), + tapeStaff.heightAnchor.constraint(equalToConstant: 88), ]) // (MIDI switch lives in the title row above — see octave + MIDI block.) @@ -1218,14 +1204,18 @@ return UInt8(max(0, min(127, played))) }) let newOnsets = currentHeld.subtracting(lastSeenHeldMidis) let newReleases = lastSeenHeldMidis.subtracting(currentHeld) - if let sheet = sheetView { - for midi in newOnsets.sorted() { - sheet.recordNote(pitch: midi) - } - for midi in newReleases { - sheet.releaseNote(pitch: midi) - } + // Notes flow only into the all-Swift take now — the Verovio + // pane is no longer driven per-keystroke. It's engraved on + // demand from `tapeScore` when the user exports a PDF. + // Only inscribe while the metronome runs: the notation staff is + // a metronome-gated surface, so there's always a beat grid to + // place notes on. Releases still flow through so a note held + // across a metronome stop closes cleanly. + let t = CACurrentMediaTime() + if metronome?.isPlaying == true { + for midi in newOnsets.sorted() { tapeScore.noteOn(midi, at: t) } } + for midi in newReleases { tapeScore.noteOff(midi, at: t) } lastSeenHeldMidis = currentHeld qwertyMap?.litKeyCodes = m.heldKeyCodes() @@ -2122,6 +2112,34 @@ /// the spacebar shortcut in AppDelegate's local key handler so /// users can start/stop the swing without aiming the mouse. func toggleMetronome() { metronome?.isPlaying.toggle() + } + + /// Wired by AppDelegate to resize the HOST PANEL when the popover's + /// natural height changes (notation staff sliding in/out), so the + /// whole popover grows/shrinks rather than clipping an inner row. + var onRequestResize: ((NSSize) -> Void)? + + /// Show or hide the notation staff. The staff lives inside + /// `waveformBezel`; collapsing its height to 0 + hiding it makes the + /// popover shrink to a compact height when the metronome is off, and + /// the whole panel grows the staff back in when it starts. + private func setNotationVisible(_ visible: Bool, animated: Bool) { + guard let bezel = waveformBezel, + let h = tapeBezelHeightConstraint else { return } + // Pause/resume the staff's live follow timer with visibility. + tapeStaff?.live = visible + // Resize the inner row immediately, then publish the new fitting + // size so the panel animates its whole frame to match (the panel + // frame animation is what the user sees move — no inner-only + // animation that would clip against a fixed window). + h.constant = visible ? Self.tapeBezelHeight : 0 + bezel.alphaValue = visible ? 1 : 0 + bezel.isHidden = !visible + view.layoutSubtreeIfNeeded() + let fitting = view.fittingSize + preferredContentSize = fitting + onRequestResize?(fitting) + _ = animated } @objc private func playScoreClicked(_ sender: NSButton) { diff --git a/slab/menuband/Sources/MenuBand/MenuBandPopoverPanel.swift b/slab/menuband/Sources/MenuBand/MenuBandPopoverPanel.swift --- a/slab/menuband/Sources/MenuBand/MenuBandPopoverPanel.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandPopoverPanel.swift @@ -76,6 +76,28 @@ // bottom-left). Convert from screen-x. let arrowLocalX = arrowScreenX - leftScreenX chrome.setArrowOffsetFromLeft(arrowLocalX) } + + /// Resize the whole panel to host `contentSize` (+ the arrow strip), + /// keeping the TOP edge pinned to the menubar so the popover grows / + /// shrinks downward — used when the notation staff slides in/out and + /// the entire popover height changes, not just an inner row. + func resizeContent(to contentSize: NSSize, animated: Bool) { + let newH = contentSize.height + Self.arrowHeight + let newW = max(contentSize.width, frame.width) + let top = frame.maxY // pin top edge + let originX = frame.minX // keep left edge + let newFrame = NSRect(x: originX, y: top - newH, + width: newW, height: newH) + if animated { + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.2 + ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + animator().setFrame(newFrame, display: true) + } + } else { + setFrame(newFrame, display: true) + } + } } final class MenuBandPopoverChrome: NSView { diff --git a/slab/menuband/Sources/MenuBand/MenuBandShortcut.swift b/slab/menuband/Sources/MenuBand/MenuBandShortcut.swift --- a/slab/menuband/Sources/MenuBand/MenuBandShortcut.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandShortcut.swift @@ -30,6 +30,13 @@ keyCode: UInt32(kVK_ANSI_P), modifiers: UInt32(cmdKey | controlKey | optionKey) ) + /// Toggle the right-hand percussion split. ⌘⌃⌥D ("D" for drums) — + /// same modifier family as the other Menu Band global toggles. + static let defaultPercussionToggle = MenuBandShortcut( + keyCode: UInt32(kVK_ANSI_D), + modifiers: UInt32(cmdKey | controlKey | optionKey) + ) + static let layoutToggle = defaultLayoutToggle var isValidForRecording: Bool { diff --git a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift --- a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift @@ -44,6 +44,9 @@ /// then plays it back as a varispeed-pitched piano voice. Same /// melodic-only routing semantics as the radio backend (channel 9 /// drums always pass through to GM). private let sampleVoice = MenuBandSampleVoice() + /// Right-hand percussion split — the AC-native 12-drum kit, synthesized + /// live. Always attached; only sounds when the controller fires hits. + let percussion = MenuBandPercussion() /// Sums every backend before the limiter so simultaneous voices share one /// gain stage. Without this, each backend would feed `mainMixerNode` /// directly and a chord across melodic + drums + midiSynth could exceed @@ -95,6 +98,25 @@ r.loadFactoryPreset(.largeHall2) r.wetDryMix = 0 return r }() + /// "Proximity" filter on the master path — the LEFT half of the X-axis + /// gesture. Reverb (right idea, wrong direction) pushes a sound big and + /// far; this does the opposite: as the gesture pulls left it narrows the + /// band (lifts the low cutoff, drops the high cutoff) so the sound + /// shrinks and pulls in CLOSE — a tiny pocket-radio / cupped-hands + /// timbre. Flat + bypassed at center so parked-left is identical to dry. + private let proximityEQ: AVAudioUnitEQ = { + let eq = AVAudioUnitEQ(numberOfBands: 2) + let hp = eq.bands[0] + hp.filterType = .highPass + hp.frequency = 40 + hp.bypass = true + let lp = eq.bands[1] + lp.filterType = .lowPass + lp.frequency = 18_000 + lp.bypass = true + eq.globalGain = 0 + return eq + }() /// Global tape-style echo on the master path, sitting just BEFORE /// the reverb so its repeats wash into the room (not the other way /// round) and pre-master so the volume slider scales the echo tail @@ -146,6 +168,17 @@ /// output device. Tracks the device ID alongside so we can /// release the right one on shutdown / device switch. private var hogModeDeviceID: AudioDeviceID = 0 private var hogModeAcquired = false + + /// When true the idle-suspend never pauses the engine — kept warm so + /// the next note has zero engine-resume latency. Armed while the + /// percussion split is on (drumming is bursty and silent between hits, + /// so otherwise the engine would pause and the first hit would lag). + var keepEngineWarm = false { + didSet { + guard keepEngineWarm, started else { return } + _ = resumeAudioEngineIfNeeded() // warm it immediately on arm + } + } /// True once MIDISynth has loaded its bank, preloaded all programs, /// and successfully attached to the engine. `noteOn` and /// `setMelodicProgram` route through the MIDISynth when this flips. @@ -252,6 +285,7 @@ guard !started else { return } engine.attach(preLimiterMixer) engine.attach(echo) engine.attach(spaceReverb) + engine.attach(proximityEQ) engine.attach(compressor) engine.attach(limiter) engine.attach(melodic) @@ -267,6 +301,9 @@ // Sample voice: same pre-limiter sum bus. Master gate stays // closed until the user records a clip and `setSampleBackend` // opens it. Voice nodes attach lazily on first noteOn. sampleVoice.attach(to: engine, output: preLimiterMixer) + // Percussion: same pre-limiter sum bus. Renders silence until the + // right-hand split fires a drum, so it's free while inactive. + percussion.attach(to: engine, output: preLimiterMixer) engine.prepare() // Request a smaller hardware IO buffer BEFORE the engine starts, // so the first render cycle is already at the low-latency size. @@ -295,6 +332,10 @@ // backend bypasses AVAudioEngine and can actually benefit. lowerOutputBufferSizeIfNeeded() loadDefaultPatches() primeForLowLatency() + // Report the output-latency budget once the device has settled. + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + self?.measuredOutputLatency() + } // Apply the persisted master volume now that preLimiterMixer is // attached + connected (setting outputVolume before attach is a // silent no-op on AVAudioMixerNode). @@ -344,12 +385,14 @@ // Still running (channel-only change): just re-point + re-assert. applyOutputDeviceOverride() lowerOutputBufferSizeIfNeeded() reapplyCurrentPrograms() + reassertActiveBackend() } else if needsEngine { do { try engine.start() applyOutputDeviceOverride() lowerOutputBufferSizeIfNeeded() reapplyCurrentPrograms() + reassertActiveBackend() } catch { NSLog("MenuBand: engine restart after device change failed: \(error)") } @@ -479,15 +522,18 @@ /// `start()` before any backend is connected so backends can route /// straight to `preLimiterMixer`. private func connectLimiterIfNeeded() { guard !limiterConnected else { return } - // preLimiterMixer → echo → spaceReverb → compressor → limiter → main. - // Echo is first so the reverb washes its repeats (not vice-versa); - // both sit pre-compressor so their tails are leveled + peak-controlled + // preLimiterMixer → echo → spaceReverb → proximityEQ → compressor → + // limiter → main. Echo is first so the reverb washes its repeats + // (not vice-versa); proximityEQ (left-axis "closer/tinier") sits + // last in the fx group so it shrinks the whole wet+dry blend. All + // sit pre-compressor so their tails are leveled + peak-controlled // and pre-master so the volume slider scales them with everything. // The compressor does the loudness normalization (glue + makeup gain); // the PeakLimiter remains the final brick wall at 0 dBFS. engine.connect(preLimiterMixer, to: echo, format: nil) engine.connect(echo, to: spaceReverb, format: nil) - engine.connect(spaceReverb, to: compressor, format: nil) + engine.connect(spaceReverb, to: proximityEQ, format: nil) + engine.connect(proximityEQ, to: compressor, format: nil) engine.connect(compressor, to: limiter, format: nil) engine.connect(limiter, to: engine.mainMixerNode, format: nil) @@ -533,12 +579,36 @@ kAudioUnitScope_Global, 0, 0.0, 0) limiterConnected = true } - /// Master "space" amount, 0…1. 0 = bone dry / up front; 1 = a - /// big hall. Capped below fully-wet so the dry signal (and the - /// note's attack + pitch) is always still present even at max. + /// Master "proximity" amount, 0…1 — the LEFT half of the X gesture. + /// 0 = untouched / natural; 1 = pulled all the way in close and tiny. + /// This is the deliberate *opposite* of reverb: instead of a big hall + /// pushing the sound back and far, it narrows the band so the sound + /// shrinks and comes right up to the listener (pocket-radio / cupped- + /// hands timbre). Driven from `setSpace` so the same X-axis plumbing + /// (and the spring-back ramp) feeds it. func setSpace(_ amount: Float) { - let clamped = max(0, min(1, amount)) - spaceReverb.wetDryMix = clamped * 72 + let p = max(0, min(1, amount)) + let hp = proximityEQ.bands[0] + let lp = proximityEQ.bands[1] + if p <= 0.001 { + // Fully open / flat — identical to before the node existed. + hp.bypass = true + lp.bypass = true + proximityEQ.globalGain = 0 + return + } + hp.bypass = false + lp.bypass = false + // Lift the low cutoff (drop the body) and pull the high cutoff down + // (drop the air) so the band closes toward a small midrange window + // as the gesture pushes left — the "shrinking, coming closer" cue. + hp.frequency = 40 + p * (1_000 - 40) + lp.frequency = 18_000 - p * (18_000 - 2_800) + // A little make-up gain so "closer" reads as present and intimate + // rather than just thin and distant. + proximityEQ.globalGain = p * 2.0 + // Keep the hall fully dry — proximity replaces reverb on this axis. + spaceReverb.wetDryMix = 0 } /// Master echo knob, 0…1. Drives wet mix + feedback together so a @@ -660,6 +730,24 @@ selectMelodicProgram(au, program: currentMelodicProgram) selectDrumKit(au) } + /// After an engine restart (e.g. an audio-device / aux switch) the GM + /// program is re-asserted by `reapplyCurrentPrograms`, but a non-GM + /// backend would otherwise fall silent or read as reverting to a plain + /// voice. Re-point whichever backend is actually active so the device + /// switch doesn't change the instrument out from under the user. + private func reassertActiveBackend() { + guard started else { return } + if usingRadioBackend { + // Re-open the radio's master gate and make sure the stream is + // still pulling (both idempotent); its graph nodes survive the + // reconfig, they just need the gate + stream re-asserted. + radio.setOutputEnabled(true) + radio.startStreaming() + } + // Sample voice + GB/plugin keep their attached nodes across a config + // change and re-arm on the next note, so no extra work is needed. + } + private func scheduleIdleSuspendIfNeeded() { guard started, !waveformCaptureEnabled, activeNotes.isEmpty, !sampleRecordingActive else { return } @@ -675,7 +763,7 @@ private func suspendAudioEngineForHiddenIdleIfNeeded() { engineLock.lock(); defer { engineLock.unlock() } idleSuspendWorkItem = nil guard started, engine.isRunning, !waveformCaptureEnabled, activeNotes.isEmpty, - !sampleRecordingActive else { return } + !sampleRecordingActive, !keepEngineWarm else { return } removeWaveformTapIfNeeded() engine.pause() } @@ -842,11 +930,16 @@ var rangeSize = UInt32(MemoryLayout.size) let rangeStatus = AudioObjectGetPropertyData( deviceID, &rangeAddr, 0, nil, &rangeSize, &range) + // Aim for the device's reported MINIMUM buffer (with hog mode that + // floor is genuinely reachable), so keypress→sound sits as close to + // the hardware's theoretical limit as macOS allows. Keep a small + // safety floor so the built-in output doesn't xrun under load. + let safetyFloor: UInt32 = 32 var target = Self.targetIOBufferFrames if rangeStatus == noErr { - let lo = UInt32(range.mMinimum) + let lo = max(safetyFloor, UInt32(range.mMinimum)) let hi = UInt32(range.mMaximum) - target = max(lo, min(hi, target)) + target = min(hi, lo) } var currAddr = AudioObjectPropertyAddress( @@ -884,6 +977,40 @@ NSLog("MenuBand: lowered IO buffer to \(target) frames (was \(current))") } } + /// The audio side of the keypress→sound budget: the live IO buffer + /// size + the engine's reported output presentation latency. Logged at + /// startup and callable from a test/CLI to verify we're near the + /// hardware floor. (A full keypress→audible measurement needs a + /// loopback capture; this reports the deterministic output latency.) + @discardableResult + func measuredOutputLatency() -> (frames: UInt32, sampleRate: Double, + bufferMs: Double, presentationMs: Double, totalMs: Double) { + let sr = engine.outputNode.outputFormat(forBus: 0).sampleRate + let presentation = engine.outputNode.presentationLatency // seconds + var frames: UInt32 = 0 + if let outAU = engine.outputNode.audioUnit { + var dev = AudioDeviceID(0) + var ds = UInt32(MemoryLayout.size) + if AudioUnitGetProperty(outAU, kAudioOutputUnitProperty_CurrentDevice, + kAudioUnitScope_Global, 0, &dev, &ds) == noErr { + var addr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyBufferFrameSize, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var f: UInt32 = 0 + var fs = UInt32(MemoryLayout.size) + if AudioObjectGetPropertyData(dev, &addr, 0, nil, &fs, &f) == noErr { frames = f } + } + } + let bufferMs = sr > 0 ? Double(frames) / sr * 1000.0 : 0 + let presentationMs = presentation * 1000.0 + let totalMs = bufferMs + presentationMs + NSLog(String(format: + "MenuBand latency: buffer=%u frames (%.2f ms) @ %.0f Hz · presentation %.2f ms · output ≈ %.2f ms", + frames, bufferMs, sr, presentationMs, totalMs)) + return (frames, sr, bufferMs, presentationMs, totalMs) + } + /// Take exclusive ownership of the engine's current output device. /// Hog mode is what lets us push the IO buffer below the shared- /// device floor (typically 512 frames). When we own the device, @@ -1248,6 +1375,13 @@ leaveRadioWithLinger() } } + /// Retune the radio backend to a different station. Safe to call while + /// the radio is active (it reconnects) or inactive (takes effect when + /// next enabled). + func setRadioStation(_ station: RadioStation) { + radio.setStation(station) + } + // MARK: - Third-party AU instrument backend /// Install (or clear) a third-party AU as the active melodic instrument. @@ -1426,6 +1560,74 @@ sendMIDIEvent(au, status: 0xB0 | (channel & 0x0F), data1: 10, data2: pan & 0x7F) } + /// Fire a right-hand-split drum. `pan` is the 0–127 CC10 convention the + /// keyboard uses (64 = center); converted to the −1…1 the percussion + /// node wants. + func playPercussion(_ drum: MenuBandPercussion.Drum, velocity: UInt8, pan: UInt8) { + guard started else { return } + // Drums don't register in `activeNotes`, so the idle-suspend logic + // would otherwise leave the engine paused and the hit silent. Wake + // it (and cancel any pending suspend) before staging the voices. + _ = resumeAudioEngineIfNeeded() + let p = max(-1.0, min(1.0, (Double(pan) - 64.0) / 63.0)) + percussion.play(drum, velocity: velocity, pan: p) + // Keep the engine awake long enough for the drum tail to ring out, + // since `activeNotes` stays empty. Re-arm the idle suspend timer so + // it can't fire mid-tail. + scheduleIdleSuspendAfterPercussion() + } + + /// Key-down for a split drum. Returns a group token the caller passes to + /// `percussionNoteOff` on key-up (hi-hat foot-pedal down/up sounds). + @discardableResult + func percussionNoteOn(_ drum: MenuBandPercussion.Drum, velocity: UInt8, pan: UInt8, + accent: Bool = false) -> UInt64 { + guard started else { return 0 } + // Warm path (split armed): the engine is already running and won't + // idle-suspend, so skip the resume lock AND the idle reschedule — + // the only work on a hit is staging the voices. Cold path keeps the + // safety net for cues fired while the split is off. + if keepEngineWarm { + if !engine.isRunning { _ = resumeAudioEngineIfNeeded() } + } else { + _ = resumeAudioEngineIfNeeded() + } + let p = max(-1.0, min(1.0, (Double(pan) - 64.0) / 63.0)) + let g = percussion.noteOn(drum, velocity: velocity, pan: p, accent: accent) + if !keepEngineWarm { scheduleIdleSuspendAfterPercussion() } + return g + } + + /// Most recent percussion trigger→render handoff (ms) — the drum- + /// specific latency, ≤ one IO buffer. + func percussionTriggerHandoffMs() -> Double { percussion.triggerHandoffMs() } + + /// Key-up for a split drum — damps held voices (open hat) and fires the + /// release burst (closed hat). Harmless for one-shot drums. + func percussionNoteOff(_ group: UInt64) { + guard started, group != 0 else { return } + percussion.noteOff(group) + if !keepEngineWarm { scheduleIdleSuspendAfterPercussion() } + } + + /// Live per-pitch-class drum hit pulses, for the menubar key vibe. + func percussionPulses() -> [MenuBandPercussion.DrumPulse] { + percussion.pulseSnapshot() + } + + /// Re-arm the hidden-idle suspend after a drum hit so the engine stays + /// up through the tail (crashes ring ~1.4 s) instead of pausing the + /// instant the staging call returns. + private func scheduleIdleSuspendAfterPercussion() { + idleSuspendWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.suspendAudioEngineForHiddenIdleIfNeeded() + } + idleSuspendWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + max(idleSuspendDelay, 2.0), + execute: workItem) + } + func noteOn(_ midi: UInt8, velocity: UInt8 = 100, channel: UInt8 = 0) { guard started else { return } guard resumeAudioEngineIfNeeded() else { return } @@ -1504,6 +1706,20 @@ /// varispeed doesn't respond to MIDI pitch-bend so we have to /// route this signal in-process. func setSamplePitchBend(amount: Float) { sampleVoice.setBend(amount: amount) + } + + /// Route the trackpad pitch-bend into the internally-synthesized + /// drum kit so tonal drums warp with the melodic voices. + func setPercussionPitchBend(amount: Float) { + percussion.setPitchBend(amount: amount) + } + + /// Route the trackpad pitch-bend into the KPBJ radio backend. Like + /// the sample voice, the radio plays through AVAudioUnitVarispeed and + /// ignores MIDI pitch-bend, so the signed amount is routed in-process + /// — the live stream slides in pitch alongside every other voice. + func setRadioPitchBend(amount: Float) { + radio.setBend(amount: amount) } /// Per-channel Expression (CC 11), 0–127. Used by the linger diff --git a/slab/menuband/Sources/MenuBand/MenuBandTape.swift b/slab/menuband/Sources/MenuBand/MenuBandTape.swift --- a/slab/menuband/Sources/MenuBand/MenuBandTape.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandTape.swift @@ -11,6 +11,9 @@ /// Fired when the user flips the tape-deck feature flag in the /// About window. AppDelegate re-reads UserDefaults, resizes the /// status item, and redraws the menubar icon. static let menuBandTapeFeatureChanged = Notification.Name("MenuBandTapeFeatureChanged") + /// Fired when the user flips the right-hand percussion split in the + /// About window. AppDelegate re-applies the split's side effects. + static let menuBandPercussionSplitChanged = Notification.Name("MenuBandPercussionSplitChanged") } /// 90-second analog-style tape with **stem separation** baked in. @@ -56,6 +59,18 @@ private var synthWriteFrame: Int = 0 private var micWriteFrame: Int = 0 private var playFrame: Int = 0 + + /// Leading-transient trim. The hot mic is already running when the + /// user presses backtick to arm REC, so the first frames captured + /// include the mechanical click of the record key itself (and a few + /// ms of pre-press room tone from the in-flight tap block). The + /// sample-voice capture path drops the same window via + /// `recordKeyClickSkipFrames`; the tape needs its own copy because + /// it ingests raw frames directly. We skip the SAME count from both + /// stems so synth and mic stay sample-aligned for clean DAW stems. + private static let leadTrimFrames = Int(sampleRate * 0.035) // ~35 ms + private var synthLeadSkip: Int = 0 + private var micLeadSkip: Int = 0 private(set) var state: State = .idle { didSet { if oldValue != state { postChange() } } @@ -161,6 +176,8 @@ } synthWriteFrame = 0 micWriteFrame = 0 playFrame = 0 + synthLeadSkip = Self.leadTrimFrames + micLeadSkip = Self.leadTrimFrames recordStartDate = Date() cachedEject = nil bufferLock.unlock() @@ -493,6 +510,18 @@ private func writeSynthFrames(left: UnsafePointer, right: UnsafePointer, frames: Int) { bufferLock.lock() + // Drop the leading record-key transient window before anything + // lands in the buffer. Whole blocks inside the window vanish; + // a block straddling the boundary advances the source pointer. + var left = left, right = right, frames = frames + if synthLeadSkip > 0 { + let skip = min(synthLeadSkip, frames) + synthLeadSkip -= skip + left = left.advanced(by: skip) + right = right.advanced(by: skip) + frames -= skip + if frames <= 0 { bufferLock.unlock(); return } + } let remaining = Self.maxFrames - synthWriteFrame let take = min(frames, remaining) if take > 0, @@ -515,6 +544,16 @@ } private func writeMicFrames(mono: UnsafePointer, frames: Int) { bufferLock.lock() + // Same leading record-key transient trim as the synth stem, + // and the SAME frame count, so the two stems stay aligned. + var mono = mono, frames = frames + if micLeadSkip > 0 { + let skip = min(micLeadSkip, frames) + micLeadSkip -= skip + mono = mono.advanced(by: skip) + frames -= skip + if frames <= 0 { bufferLock.unlock(); return } + } let remaining = Self.maxFrames - micWriteFrame let take = min(frames, remaining) if take > 0, let dst = micBuffer.floatChannelData?[0] { diff --git a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift --- a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift +++ b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift @@ -108,6 +108,13 @@ return self?.menuBand?.handleLocalKey( keyCode: kc, isDown: isDown, isRepeat: isRepeat, flags: flags ) ?? false } + // Radio-station cells sit in the top row, left of MIDI OUT. Clicking + // one tunes the radio ("voice −1") to that station and engages it. + instrumentList.radioStations = RadioStation.all + instrumentList.onRadioCommit = { [weak self] station in + self?.menuBand?.selectRadioStation(station) + self?.refresh() + } qwertyMap.translatesAutoresizingMaskIntoConstraints = false qwertyMap.scale = 1.0 @@ -330,6 +337,8 @@ // the giant selected number stays anchored to the committed // voice while the preview note plays a different program. instrumentList.selectedProgram = menuBand.effectiveMelodicProgram instrumentList.midiModeActive = menuBand.midiMode + instrumentList.radioBackendActive = (menuBand.instrumentBackend == .kpbj) + instrumentList.selectedRadioStationID = menuBand.radioStation.id applyInstrumentReadout(safe: safe, familyColor: familyColor, isDark: isDark) diff --git a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/PianoWaveformWindowDelegate.swift b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/PianoWaveformWindowDelegate.swift --- a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/PianoWaveformWindowDelegate.swift +++ b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/PianoWaveformWindowDelegate.swift @@ -115,6 +115,12 @@ } var isDocked: Bool { collapsedCustomOrigin == nil } + /// True while the collapsed pitch strip is on screen because the + /// pointer is hovering the menubar item (not because the popover or + /// a user action opened it). Gates the hover auto-hide so we never + /// dismiss a popover-paired or user-pinned panel. + private var hoverPresented = false + /// When the popover is on screen, its window frame is fed in here /// so the floating panel can right-align snug against the popover's /// left edge instead of anchoring under the menubar status item. @@ -164,6 +170,7 @@ /// strip (which has no chooser). func showExpandedForPopover(restoringTo previousApp: NSRunningApplication? = nil) { setEnabled(true) cancelPendingHide() + hoverPresented = false // popover is driving now, not hover transitionToExpanded() showExpanded(restoringTo: previousApp) } @@ -177,6 +184,7 @@ /// standalone open later still honors it. func showCollapsedForPopover() { setEnabled(true) cancelPendingHide() + hoverPresented = false // popover is driving now, not hover if panel == nil { buildPanel() } if presentationState == .expanded { dismissExpanded(reason: .programmatic) @@ -259,6 +267,63 @@ // which felt like the panel was "timing out" mid-play. cancelPendingHide() } + // MARK: - Menubar hover reveal + + /// Reveal the collapsed pitch strip while the pointer is over the + /// menubar item. Anchors under the status item (no popover is up). + /// Idempotent and non-persistent: it never touches the saved + /// preferred state, and it bows out while the popover or the + /// expanded view is driving the panel so it can't fight them. + func showFromHover() { + guard !isCollapsedPresentationSuppressed, popoverFrameProvider?() == nil else { return } + if presentationState == .expanded, panel?.isVisible == true { return } + // Already up because of hover → just keep it up. + if hoverPresented, presentationState == .collapsed, panel?.isVisible == true { + cancelPendingHide() + return + } + setEnabled(true) + cancelPendingHide() + if panel == nil { buildPanel() } + if presentationState == .expanded { dismissExpanded(reason: .programmatic) } + presentationState = .collapsed + hoverPresented = true + showCollapsedIfNeeded() + } + + /// Begin a hover-driven hide. Defers (re-arming itself) while the + /// pointer is still over the panel or the menubar item, so moving + /// from the icon down into the strip doesn't dismiss it; once the + /// pointer is truly away the strip fades out. + func scheduleHideFromHover() { + guard hoverPresented else { return } + cancelPendingHide() + let work = DispatchWorkItem { [weak self] in self?.hoverHideTick() } + hideWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.30, execute: work) + } + + private func hoverHideTick() { + guard hoverPresented, presentationState == .collapsed else { return } + // The popover took over → leave that flow alone. + if popoverFrameProvider?() != nil { hoverPresented = false; return } + let mouse = NSEvent.mouseLocation + let overPanel = (panel?.isVisible == true) && (panel?.frame.contains(mouse) ?? false) + let overButton: Bool = { + guard let b = statusItemButton, let w = b.window else { return false } + return w.convertToScreen(b.frame).contains(mouse) + }() + if overPanel || overButton { + // Still hovering the icon→panel duo — re-check shortly. + let work = DispatchWorkItem { [weak self] in self?.hoverHideTick() } + hideWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.30, execute: work) + return + } + hoverPresented = false + dismissCollapsedPanel() + } + func reposition(statusItemButton: NSStatusBarButton?) { self.statusItemButton = statusItemButton guard presentationState == .collapsed, let panel, panel.isVisible else { return } @@ -438,6 +503,7 @@ } private func dismissCollapsedPanel() { cancelPendingHide() + hoverPresented = false // Popover dismiss is the canonical "go away" signal for // the paired panel. Fade out (~140 ms) instead of // cutting so the panel dissolves alongside the popover. @@ -643,9 +709,15 @@ private func clampedFrame(origin: NSPoint, size: NSSize, preferredScreen: NSScreen?) -> NSRect { let visible = visibleFrame(for: origin, preferredScreen: preferredScreen) let margin: CGFloat = 16 - let x = min(max(origin.x, visible.minX + margin), visible.maxX - size.width - margin) - let y = min(max(origin.y, visible.minY + margin), visible.maxY - size.height - margin) - return NSRect(origin: NSPoint(x: x, y: y), size: size) + // Cap the panel to the usable screen so a tall pitch grid "maxes + // out under the menu bar" instead of overflowing off the bottom: + // pin the top just beneath the menu bar (visible.maxY) and clip + // the height to whatever fits down to the dock/edge. + let h = min(size.height, visible.height - 2 * margin) + let w = min(size.width, visible.width - 2 * margin) + let x = min(max(origin.x, visible.minX + margin), visible.maxX - w - margin) + let y = min(max(origin.y, visible.minY + margin), visible.maxY - h - margin) + return NSRect(origin: NSPoint(x: x, y: y), size: NSSize(width: w, height: h)) } private func visibleFrame(for origin: NSPoint, preferredScreen: NSScreen?) -> NSRect { diff --git a/slab/menuband/Sources/MenuBand/Resources/Bravura-OFL.txt b/slab/menuband/Sources/MenuBand/Resources/Bravura-OFL.txt new file mode 100644 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/Resources/Bravura-OFL.txt @@ -0,0 +1,93 @@ +Copyright © 2019, Steinberg Media Technologies GmbH (http://www.steinberg.net/), +with Reserved Font Name "Bravura". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/slab/menuband/Sources/MenuBand/Resources/Bravura.otf b/slab/menuband/Sources/MenuBand/Resources/Bravura.otf new file mode 100644 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/Resources/Bravura.otf diff --git a/slab/menuband/Sources/MenuBand/SheetMusicView.swift b/slab/menuband/Sources/MenuBand/SheetMusicView.swift --- a/slab/menuband/Sources/MenuBand/SheetMusicView.swift +++ b/slab/menuband/Sources/MenuBand/SheetMusicView.swift @@ -109,16 +109,33 @@ // gutter for the popover's voice-tinted glass (or any tray // color) to show through behind it. wantsLayer = true layer?.backgroundColor = NSColor.clear.cgColor + // Webviews are now created LAZILY (see `ensureWebViews`). The + // live view is the all-Swift `TapeStaffView`; Verovio's 7MB + // WASM only loads when a PDF is engraved on demand or a Menu + // Band PDF is dragged back in. Nothing here spins it up. + registerForDraggedTypes([.fileURL]) + } + + /// True once the Verovio webviews have been built. Stays false for + /// the whole popover session unless a PDF export / drag-in needs + /// them — that's the performance win over the old always-on pane. + private var webViewsReady = false + + /// Build the Verovio panes on first demand. Idempotent. + private func ensureWebViews() { + guard !webViewsReady else { return } + webViewsReady = true setUpExportWebView() setUpWebView() setUpOverlay() - registerForDraggedTypes([.fileURL]) } required init?(coder: NSCoder) { nil } override func layout() { super.layout() + // Nothing to lay out until the lazy Verovio panes exist. + guard webViewsReady, webView != nil else { return } webView.frame = bounds overlay.frame = bounds // Keep the offscreen export webview the same size as the @@ -132,16 +149,9 @@ width: max(bounds.width, 1), height: max(bounds.height, 1)) exportWebView.frame = NSRect(origin: .zero, size: exportWebViewHost.bounds.size) } - // Once the popover is actually on screen and the webView has - // a non-zero size, prime the PDF cache. Without this guard - // a too-early `createPDF` on a 0×0 webView hangs forever and - // poisons pdfRenderInFlight. Re-prime on size changes too — - // the layout messages from Verovio renders fire only on note - // changes, not on plain resizes. - if isLoaded && webView.bounds.width > 0 && webView.bounds.height > 0 - && cachedPDFURL == nil && !pdfRenderInFlight { - schedulePDFRefresh() - } + // PDF priming on popover-open is gone — PDFs are now engraved + // on demand (icon click / drag) from the Swift `TapeScore`, + // not eagerly cached per popover session. } private func setUpWebView() { @@ -509,6 +519,92 @@ } } } + // MARK: - On-demand PDF from a Swift TapeScore + // + // The live view is all-Swift now, so the recording lives in a + // `TapeScore`, not in the Verovio pane. When the user clicks / + // drags the corner page icon we spin Verovio up (lazily), feed + // it the take via `Sheet.loadNotes`, engrave one PDF, and embed + // the same MusicXML + events round-trip payload the drag-out PDFs + // have always carried. + + /// Engrave `score` to a PDF and call back with the file URL (nil + /// on failure). Lazily builds the Verovio panes on first use. + func requestPDF(from score: TapeScore, completion: @escaping (URL?) -> Void) { + ensureWebViews() + let payload = score.loadNotesPayload() + waitForExportLoaded(retries: 80) { [weak self] ok in + guard let self = self, ok else { completion(nil); return } + // Seed the export pane from the take, then let Verovio lay + // it out before we read XML / events / createPDF. + let js = "(function(){ try { Sheet.loadNotes(\(payload)); return 'ok'; } catch(e){ return 'err:'+e.message; } })()" + self.exportWebView.evaluateJavaScript(js) { _, _ in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.exportWebView.evaluateJavaScript("Sheet.getMusicXML()") { xmlRes, _ in + let xml = (xmlRes as? String) ?? "" + self.exportWebView.evaluateJavaScript("Sheet.getPlaybackEvents()") { evRes, _ in + let events = (evRes as? String) ?? "[]" + self.engravePDF(xml: xml, events: events, completion: completion) + } + } + } + } + } + } + + /// createPDF on the export pane + embed the round-trip metadata. + /// Shares the metadata shape with the legacy `runCreatePDF`. + private func engravePDF(xml: String, events: String, + completion: @escaping (URL?) -> Void) { + guard let exportWebView = exportWebView else { completion(nil); return } + let stamp = Self.fileTimestamp.string(from: Date()) + let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("MenuBand-PDFDrags", isDirectory: true) + try? FileManager.default.createDirectory( + at: tmpDir, withIntermediateDirectories: true) + let pdfURL = tmpDir.appendingPathComponent("Menu-Band-\(stamp).pdf") + exportWebView.createPDF { [weak self] result in + guard let self = self else { completion(nil); return } + switch result { + case .success(let data): + do { try data.write(to: pdfURL) } + catch { + NSLog("SheetMusicView: PDF write failed — \(error)") + completion(nil); return + } + if let pdf = PDFDocument(url: pdfURL) { + var attrs = pdf.documentAttributes ?? [:] + attrs[PDFDocumentAttribute.subjectAttribute] = xml + attrs[PDFDocumentAttribute.keywordsAttribute] = [ + Self.pdfFormatKeyword, + "events:\(events)", + ] + attrs[PDFDocumentAttribute.creatorAttribute] = "Menu Band" + attrs[PDFDocumentAttribute.titleAttribute] = "Menu Band score" + pdf.documentAttributes = attrs + pdf.write(to: pdfURL) + } + self.cachedPDFURL = pdfURL + completion(pdfURL) + case .failure(let error): + NSLog("SheetMusicView: createPDF failed — \(error)") + completion(nil) + } + } + } + + /// Poll until the offscreen export pane has finished loading + /// sheet.html (and therefore has the `Sheet` API). Calls back on + /// the main queue with success/failure. + private func waitForExportLoaded(retries: Int, + _ done: @escaping (Bool) -> Void) { + if exportLoaded { done(true); return } + guard retries > 0 else { done(false); return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.waitForExportLoaded(retries: retries - 1, done) + } + } + private func snapshotImage() -> NSImage { let rep = bitmapImageRepForCachingDisplay(in: bounds) ?? NSBitmapImageRep() cacheDisplay(in: bounds, to: rep) @@ -624,6 +720,8 @@ // playback timing off — the events list is what the user // actually played, sample-accurate. Falls back to MIDI route // when the PDF predates the events embedding. let events = Self.extractPlaybackEvents(from: pdfURL) + // Drag-in needs the Verovio pane — build it lazily on first use. + ensureWebViews() // Hand the embedded MusicXML to Verovio: it re-renders the // staff (same code path as the live transcription, just // seeded from XML instead of accumulated noteOn events) and @@ -645,7 +743,9 @@ return ''; } })() """ - webView.evaluateJavaScript(js) { [weak self] result, error in + waitForVisibleLoaded(retries: 80) { [weak self] ready in + guard let self = self, ready else { return } + self.webView.evaluateJavaScript(js) { [weak self] result, error in guard let self = self else { return } if let error = error { NSLog("SheetMusicView: drag-in eval failed — \(error)") @@ -667,7 +767,20 @@ } NSLog("SheetMusicView: drag-in produced \(base64.count) chars of base64 MIDI — handing to player (legacy path)") self.playImportedMIDI(base64: base64) } + } return true + } + + /// Poll until the visible Verovio pane has loaded sheet.html (sets + /// `isLoaded` via `pollForReady`). Used by `loadAndPlay` now that + /// the pane is built lazily and may not be ready at drop time. + private func waitForVisibleLoaded(retries: Int, + _ done: @escaping (Bool) -> Void) { + if isLoaded { done(true); return } + guard retries > 0 else { done(false); return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.waitForVisibleLoaded(retries: retries - 1, done) + } } /// One auto-play event extracted from a Menu Band PDF. @@ -1082,10 +1195,9 @@ switch type { case "layout": if let xml = dict["musicXML"] as? String { cachedMusicXML = xml } handleLayout(dict) - // Re-render the PDF in the background so the next drag - // pulls the freshest version. Coalesced via the in-flight - // flag so a burst of layouts doesn't pile up renders. - schedulePDFRefresh() + // No per-note PDF churn anymore: PDFs are engraved on + // demand from the Swift TapeScore, not re-rendered on + // every layout message. default: break } @@ -1176,6 +1288,8 @@ } } func printScore() { + ensureWebViews() + guard webView != nil else { return } let info = NSPrintInfo.shared.copy() as! NSPrintInfo info.orientation = .portrait info.topMargin = 36 diff --git a/slab/menuband/Sources/MenuBand/TapePageIcon.swift b/slab/menuband/Sources/MenuBand/TapePageIcon.swift new file mode 100644 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/TapePageIcon.swift @@ -0,0 +1,152 @@ +import AppKit +import UniformTypeIdentifiers + +/// Small "page" affordance pinned to the bottom-right of the tape +/// staff. The score itself is now the live Swift staff (no draggable +/// page), so this icon is the handle for the PDF: +/// +/// • click → engrave a PDF on demand and open it in Preview +/// • drag → export the PDF (drop on Finder / Mail / etc.) +/// • drag to Trash → clear the take (with the paper-crumple SFX) +/// +/// PDF engraving is delegated back to the popover via `requestPDF`, +/// which routes to the lazy Verovio `SheetMusicView`. +final class TapePageIcon: NSView, NSDraggingSource { + weak var score: TapeScore? + /// Popover hook: engrave the current take and call back with the + /// PDF URL (nil on failure). + var requestPDF: ((@escaping (URL?) -> Void) -> Void)? + /// Called after a successful drag (export or trash) so the popover + /// can reset the staff/scroll alongside the score clear. + var onCleared: (() -> Void)? + + var tint: NSColor = .secondaryLabelColor { didSet { needsDisplay = true } } + + private var mouseDownAt: NSPoint? + private var dragging = false + /// Freshest engraved PDF, primed on mouse-down so a drag has a + /// file to carry. May be a few ms stale relative to the very last + /// note — acceptable, matches the old drag-out behavior. + private var cachedPDFURL: URL? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + toolTip = "Score PDF — click to open, drag to export, drag to Trash to clear" + } + required init?(coder: NSCoder) { nil } + + override var intrinsicContentSize: NSSize { NSSize(width: 22, height: 28) } + + // MARK: - Draw — a little folded-corner page with staff ticks + + override func draw(_ dirtyRect: NSRect) { + let r = bounds.insetBy(dx: 2, dy: 2) + let fold: CGFloat = 6 + let page = NSBezierPath() + page.move(to: NSPoint(x: r.minX, y: r.minY)) + page.line(to: NSPoint(x: r.minX, y: r.maxY)) + page.line(to: NSPoint(x: r.maxX - fold, y: r.maxY)) + page.line(to: NSPoint(x: r.maxX, y: r.maxY - fold)) + page.line(to: NSPoint(x: r.maxX, y: r.minY)) + page.close() + + (NSColor.textBackgroundColor).withAlphaComponent(0.9).setFill() + page.fill() + tint.withAlphaComponent(0.85).setStroke() + page.lineWidth = 1 + page.stroke() + + // Folded corner triangle. + let corner = NSBezierPath() + corner.move(to: NSPoint(x: r.maxX - fold, y: r.maxY)) + corner.line(to: NSPoint(x: r.maxX - fold, y: r.maxY - fold)) + corner.line(to: NSPoint(x: r.maxX, y: r.maxY - fold)) + tint.withAlphaComponent(0.6).setStroke() + corner.lineWidth = 1 + corner.stroke() + + // A few faux staff lines so it reads as "sheet music". + tint.withAlphaComponent(0.5).setStroke() + let lines = 3 + for i in 0.. 6 || abs(p.y - start.y) > 6 else { return } + guard let url = cachedPDFURL else { + // Not engraved yet — kick one for the next attempt and + // let this gesture fall through (the first drag right + // after a fresh note can miss; the next one lands). + requestPDF? { [weak self] u in self?.cachedPDFURL = u } + return + } + dragging = true + mouseDownAt = nil + let item = NSDraggingItem(pasteboardWriter: url as NSURL) + item.setDraggingFrame(bounds, contents: snapshot()) + beginDraggingSession(with: [item], event: event, source: self) + } + + override func mouseUp(with event: NSEvent) { + let wasClick = mouseDownAt != nil && !dragging + mouseDownAt = nil + guard wasClick, score?.isEmpty == false else { return } + // Click: engrave fresh + open in Preview. + requestPDF? { url in + guard let url = url else { return } + NSWorkspace.shared.open(url) + } + } + + private func snapshot() -> NSImage { + let rep = bitmapImageRepForCachingDisplay(in: bounds) ?? NSBitmapImageRep() + cacheDisplay(in: bounds, to: rep) + let img = NSImage(size: bounds.size) + img.addRepresentation(rep) + return img + } + + // MARK: - NSDraggingSource + + func draggingSession(_ session: NSDraggingSession, + sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { + // `.delete` makes the Dock Trash a valid drop → clears the + // take; `.copy` is the regular export-anywhere path. + return [.copy, .delete] + } + + func draggingSession(_ session: NSDraggingSession, + endedAt screenPoint: NSPoint, + operation: NSDragOperation) { + dragging = false + guard operation != [] else { return } + // Trash drop = the explicit "throw it away" gesture → SFX. + if operation.contains(.delete) { + CrumpleSound.shared.play() + } + // Either way a successful drop means "done with this take". + score?.clear() + cachedPDFURL = nil + onCleared?() + } +} diff --git a/slab/menuband/Sources/MenuBand/TapeScore.swift b/slab/menuband/Sources/MenuBand/TapeScore.swift new file mode 100644 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/TapeScore.swift @@ -0,0 +1,137 @@ +import QuartzCore + +/// Single source of truth for one recorded "take" — a flat list of +/// note events with wall-clock onset / release times. This replaces +/// the old arrangement where the authoritative buffer lived inside +/// the Verovio WKWebView (`notes[]` in sheet.html). Swift now owns +/// the recording; Verovio is only spun up on demand to engrave a PDF. +/// +/// Times are in `CACurrentMediaTime()` base. `startTime` is captured +/// at the first onset so every consumer (the live staff, the PDF +/// exporter) can express positions relative to the take's beginning. +final class TapeScore { + /// One note. `release == nil` means the key is still held — the + /// live staff draws it growing to "now" until the off arrives. + struct Event { + let midi: UInt8 + let onset: TimeInterval + var release: TimeInterval? + } + + private(set) var events: [Event] = [] + /// midi → index into `events` for the currently-held note of that + /// pitch, so a release lands O(1) on the right event. + private var active: [UInt8: Int] = [:] + + /// Wall-clock time of the first onset in this take. 0 when empty. + private(set) var startTime: TimeInterval = 0 + + /// Fired on every mutation so the view can mark `needsDisplay`. + var onChange: (() -> Void)? + + var isEmpty: Bool { events.isEmpty } + + /// True while at least one note is still held (release == nil) — + /// the live staff only needs to repaint/auto-follow while this is + /// true, so the 30 Hz timer can idle otherwise. + var hasHeldNote: Bool { !active.isEmpty } + + // MARK: - Recording + + func noteOn(_ midi: UInt8, at t: TimeInterval = CACurrentMediaTime()) { + if events.isEmpty { startTime = t } + // Defensive: if the same pitch is somehow already held (a + // missed off), close the prior one at this onset so we never + // leak a never-released event. + if let prior = active[midi], prior < events.count, + events[prior].release == nil { + events[prior].release = t + } + events.append(Event(midi: midi, onset: t, release: nil)) + active[midi] = events.count - 1 + onChange?() + } + + func noteOff(_ midi: UInt8, at t: TimeInterval = CACurrentMediaTime()) { + guard let idx = active[midi], idx < events.count else { return } + events[idx].release = t + active[midi] = nil + onChange?() + } + + func clear() { + events.removeAll() + active.removeAll() + startTime = 0 + onChange?() + } + + // MARK: - Read models + + /// Seconds from `startTime` to the most recent note ONSET. Drives + /// the staff's auto-scroll so a fresh attack pulls the viewport + /// along while a sustained note leaves it still. + var lastOnsetSeconds: TimeInterval { + guard let last = events.last else { return 0 } + return max(0, last.onset - startTime) + } + + /// Seconds from `startTime` to the end of the take. Held notes + /// (release == nil) extend to `now`. Returns 0 for an empty take. + func endSeconds(now: TimeInterval = CACurrentMediaTime()) -> TimeInterval { + guard !events.isEmpty else { return 0 } + var end: TimeInterval = 0 + for e in events { + let off = e.release ?? now + end = max(end, off - startTime) + } + return max(0, end) + } + + /// Onset/offset of every event in absolute `CACurrentMediaTime()` + /// base (held notes use `now` for their offset). The beat-grid + /// renderer converts these to metronome-relative beats so notes + /// land on a real bar/beat grid instead of guessed wall-clock x. + func absoluteEvents(now: TimeInterval = CACurrentMediaTime()) + -> [(midi: UInt8, on: TimeInterval, off: TimeInterval)] { + events.map { e in + (midi: e.midi, on: e.onset, off: e.release ?? now) + } + } + + /// Onset/offset of every event relative to `startTime`. Held + /// notes use `now` for their offset so the live staff can draw + /// the growing bar. Used by `TapeStaffView`. + func relativeEvents(now: TimeInterval = CACurrentMediaTime()) + -> [(midi: UInt8, on: TimeInterval, off: TimeInterval)] { + guard !events.isEmpty else { return [] } + return events.map { e in + let on = e.onset - startTime + let off = (e.release ?? now) - startTime + return (midi: e.midi, on: max(0, on), off: max(on, off)) + } + } + + /// JSON payload shaped for `Sheet.loadNotes(...)` in sheet.html: + /// `[{pitch, onset, release}]` with seconds relative to the take + /// start and `release: null` for notes still held at export time. + /// Used by the on-demand PDF exporter. + func loadNotesPayload(now: TimeInterval = CACurrentMediaTime()) -> String { + guard !events.isEmpty else { return "[]" } + let items: [[String: Any]] = events.map { e in + var dict: [String: Any] = [ + "pitch": Int(e.midi), + "onset": (e.onset - startTime), + ] + if let r = e.release { + dict["release"] = (r - startTime) + } else { + dict["release"] = NSNull() + } + return dict + } + guard let data = try? JSONSerialization.data(withJSONObject: items), + let str = String(data: data, encoding: .utf8) else { return "[]" } + return str + } +} diff --git a/slab/menuband/Sources/MenuBand/TapeStaffView.swift b/slab/menuband/Sources/MenuBand/TapeStaffView.swift new file mode 100644 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/TapeStaffView.swift @@ -0,0 +1,551 @@ +import AppKit +import QuartzCore + +/// Horizontally-scrolling, all-Swift notation staff. Renders a whole +/// `TapeScore` as one continuous treble staff: time flows left → right +/// at `pixelsPerSecond`, notes draw as glossy chromatic pills at their +/// diatonic Y (same visual language as `StaffView`'s rolling pills), +/// ledger lines mark off-staff pitches. Saves vertical space vs. the +/// old portrait Verovio page and keeps the notes large + legible. +/// +/// `TapeStaffScroller` wraps this in an `NSScrollView` so two-finger +/// trackpad scrolling natively scrubs back through the take; while the +/// take is live the scroller auto-follows the playhead unless the user +/// has scrolled back to look at earlier bars. +final class TapeStaffView: NSView { + weak var score: TapeScore? + var bpm: Double = 120 + var live: Bool = false + + /// Beats are the horizontal unit now (not wall-clock seconds): the + /// staff is a true metronome-driven grid. One beat == one quarter + /// note == `pixelsPerBeat` points; barlines every `beatsPerBar`. + var pixelsPerBeat: CGFloat = 46 + let beatsPerBar = 4 // 4/4 + + /// Playhead position in BEATS from the grid origin. Advanced by the + /// scroller's live timer; drawn as a thin red line when `live`. + var playheadBeat: Double = 0 + + var staffColor: NSColor = .labelColor + var noteColor: NSColor = .labelColor + + // Layout constants. y-UP (origin bottom-left) so the diatonic + // pitch math reads "higher pitch = larger y" directly. + static let lineSpacing: CGFloat = 13 + static let leftPad: CGFloat = 8 + /// Gap reserved at the left for the clef + time signature before + /// the first note column. + static let clefGap: CGFloat = 44 + static let rightPad: CGFloat = 40 + + /// Bottom staff line (E4) Y. Placed so the 5 staff lines sit in the + /// lower-middle with headroom above for high notes + ledger lines + /// (and a bit below for bass ledgers). The staff spans + /// `baselineY ... baselineY + 4*lineSpacing`. + private var baselineY: CGFloat { bounds.height * 0.34 } + + override var isFlipped: Bool { false } + + // MARK: - Pitch → staff geometry + // + // Mirror of StaffView's (private) half-line math so the two Swift + // staves agree on note placement. Half-line steps from E4: C4 = -2, + // E4 = 0, B4 = 4, F5 = 8; each octave adds 7. + private static let pitchClassStep: [Int] = [ + -2, -2, -1, -1, 0, 1, 1, 2, 2, 3, 3, 4, + ] + private static let pitchIsSharp: [Bool] = [ + false, true, false, true, false, + false, true, false, true, false, true, false, + ] + private static func halfLineSteps(forMidi midi: UInt8) -> Int { + let pc = Int(midi) % 12 + let octave = Int(midi) / 12 - 1 + return pitchClassStep[pc] + (octave - 4) * 7 + } + + /// Notehead Y center for a pitch. Unlike the old pill view, sharps + /// sit on the SAME line/space as their natural (a real accidental + /// glyph in front carries the chromatic info), so no quarter-step + /// nudge. + private func y(forMidi midi: UInt8) -> CGFloat { + let steps = Self.halfLineSteps(forMidi: midi) + return baselineY + CGFloat(steps) * (Self.lineSpacing / 2) + } + + /// X (content coords) of a beat position on the grid. + private func x(forBeat b: Double) -> CGFloat { + Self.leftPad + Self.clefGap + CGFloat(b) * pixelsPerBeat + } + + // MARK: - Beat clock + // + // The grid is anchored to the metronome's global downbeat clock so + // a note's x position is its real beat number, not a guess from + // inter-onset gaps. Absolute media-time → beats: + // beat = (t - metronomeStartTime) / secondsPerBeat + // Notes quantize to the nearest 16th (quarter of a beat) for tidy + // placement; the metronome's first audible tick is beat 0. + + private var secondsPerBeat: Double { 60.0 / max(20.0, bpm) } + + private func beat(forAbsolute t: TimeInterval) -> Double { + let start = KeyboardIconRenderer.metronomeStartTime + guard start > 0 else { return 0 } + return (t - start) / secondsPerBeat + } + + /// Snap a beat to the 16th-note grid (0.25-beat resolution). + private func quantize(_ b: Double) -> Double { + (b * 4).rounded() / 4 + } + + /// Beat of the most recent note onset (quantized) — drives the + /// scroller's onset-follow so a fresh attack pulls the viewport. + var lastOnsetBeat: Double { + guard let evs = score?.absoluteEvents(), let last = evs.last else { return 0 } + return quantize(beat(forAbsolute: last.on)) + } + + /// Total beats spanned by the take (for content width), at least a + /// few empty bars so a fresh staff has room to play into. + private func totalBeats(now: TimeInterval) -> Double { + let evs = score?.absoluteEvents(now: now) ?? [] + let lastEnd = evs.map { beat(forAbsolute: $0.off) }.max() ?? 0 + let head = max(playheadBeat, lastEnd) + // Round up to whole bars + one spare bar. + let bars = (head / Double(beatsPerBar)).rounded(.up) + 1 + return max(Double(beatsPerBar) * 2, bars * Double(beatsPerBar)) + } + + /// Total content width to show the whole take plus a margin. + func contentWidth(now: TimeInterval = CACurrentMediaTime()) -> CGFloat { + let raw = Self.leftPad + Self.clefGap + + CGFloat(totalBeats(now: now)) * pixelsPerBeat + Self.rightPad + return max(raw, 320) + } + + // MARK: - Draw + + override func draw(_ dirtyRect: NSRect) { + let now = CACurrentMediaTime() + // Paper background — warm cream in light, cocoa-dark in dark, + // matching the retired Verovio page's palette family. + let paper: NSColor = isDarkMode + ? NSColor(srgbRed: 0.11, green: 0.086, blue: 0.063, alpha: 1) + : NSColor(srgbRed: 0.972, green: 0.937, blue: 0.851, alpha: 1) + paper.setFill() + bounds.fill() + + let base = baselineY + // Five staff lines (E4 G4 B4 D5 F5), full content width. + staffColor.withAlphaComponent(0.85).setStroke() + for i in 0..<5 { + let ly = base + CGFloat(i) * Self.lineSpacing + let p = NSBezierPath() + p.lineWidth = 1 + p.move(to: NSPoint(x: 0, y: ly)) + p.line(to: NSPoint(x: bounds.maxX, y: ly)) + p.stroke() + } + + // Barlines every bar across the whole content width. + let bars = Int((totalBeats(now: now) / Double(beatsPerBar)).rounded(.up)) + staffColor.withAlphaComponent(0.55).setStroke() + for barIdx in 0...max(1, bars) { + let bx = x(forBeat: Double(barIdx * beatsPerBar)) + let bl = NSBezierPath() + bl.lineWidth = 1 + bl.move(to: NSPoint(x: bx, y: base)) + bl.line(to: NSPoint(x: bx, y: base + 4 * Self.lineSpacing)) + bl.stroke() + } + + drawClefAndMeter(staffBottomY: base) + + guard let score = score else { return } + let evs = score.absoluteEvents(now: now) + if !evs.isEmpty { + NSLog("TapeStaff draw: %d events, metStart=%.2f bpm=%.0f bounds.w=%.0f content first onBeat=%.2f x=%.0f", + evs.count, KeyboardIconRenderer.metronomeStartTime, bpm, + bounds.width, + quantize(beat(forAbsolute: evs[0].on)), + x(forBeat: quantize(beat(forAbsolute: evs[0].on)))) + } + for e in evs { + let onBeat = quantize(beat(forAbsolute: e.on)) + let durBeats = beat(forAbsolute: e.off) - beat(forAbsolute: e.on) + let value = noteValue(forBeats: durBeats) + let xOn = x(forBeat: onBeat) + let yy = y(forMidi: e.midi) + drawLedgerLinesIfNeeded(forMidi: e.midi, centerX: xOn, + staffBottomY: base) + drawNote(midi: e.midi, x: xOn, y: yy, value: value) + } + + // Live playhead — thin red line at the current beat. + if live { + let px = x(forBeat: playheadBeat) + let top = base + 4 * Self.lineSpacing + 6 + let bot = base - 6 + NSColor.systemRed.withAlphaComponent(0.85).setStroke() + let ph = NSBezierPath() + ph.lineWidth = 1.4 + ph.move(to: NSPoint(x: px, y: bot)) + ph.line(to: NSPoint(x: px, y: top)) + ph.stroke() + } + } + + private var isDarkMode: Bool { + effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + } + + /// Standard note values, longest → shortest. `flags` drives how + /// many flags hang off the stem; `filled`/`stemmed` shape the head. + enum NoteValue { + case whole, half, quarter, eighth, sixteenth + var filled: Bool { self == .quarter || self == .eighth || self == .sixteenth } + var stemmed: Bool { self != .whole } + var flags: Int { + switch self { + case .eighth: return 1 + case .sixteenth: return 2 + default: return 0 + } + } + } + + /// Map a held duration in BEATS to the nearest classical note + /// value (quarter == 1 beat). Thresholds sit at the geometric + /// midpoints so a note rounds to the closest value. + private func noteValue(forBeats beats: Double) -> NoteValue { + if beats >= 3.0 { return .whole } + if beats >= 1.5 { return .half } + if beats >= 0.75 { return .quarter } + if beats >= 0.375 { return .eighth } + return .sixteenth + } + + /// SMuFL notehead/flag glyphs for a value. + private func headGlyph(_ v: NoteValue) -> Bravura.Glyph { + switch v { + case .whole: return .noteheadWhole + case .half: return .noteheadHalf + default: return .noteheadBlack + } + } + + /// Draw a note from real Bravura glyphs: notehead + (stroked) stem + /// + SMuFL flag glyph + sharp accidental. `x` is the notehead's + /// horizontal center, `y` its vertical center on the staff. + private func drawNote(midi: UInt8, x: CGFloat, y: CGFloat, + value: NoteValue) { + if x < -30 || x > bounds.maxX + 30 { return } + let sp = Self.lineSpacing // one staff space + let ink = noteColor + + // Accidental glyph in front of the head for black keys (we + // spell everything with sharps). + if Self.pitchIsSharp[Int(midi) % 12] { + let sharp = String(Bravura.Glyph.accidentalSharp.rawValue) + let aw = Bravura.width(sharp, staffSpace: sp) + Bravura.draw(.accidentalSharp, + at: NSPoint(x: x - sp * 0.95 - aw, + y: y - Bravura.noteheadCenterOffset(staffSpace: sp)), + staffSpace: sp, color: ink) + } + + // Notehead. Bravura draws from a left-baseline origin; nudge + // left by half the glyph width to center it on `x`, and down by + // the notehead center offset to center it on the staff line. + let head = headGlyph(value) + let hw = Bravura.width(String(head.rawValue), staffSpace: sp) + let headX = x - hw / 2 + let baselineY = y - Bravura.noteheadCenterOffset(staffSpace: sp) + Bravura.draw(head, at: NSPoint(x: headX, y: baselineY), + staffSpace: sp, color: ink) + + guard value.stemmed else { return } + + // Stem — up on the right edge of the head for notes below the + // middle line, down on the left edge above it. Bravura's stem + // anchor sits at the head's right (up) / left (down) side. + let stemUp = Self.halfLineSteps(forMidi: midi) < 4 + let stemLen = sp * 3.3 + let stemX = stemUp ? (x + hw / 2 - 0.6) : (x - hw / 2 + 0.6) + let stemTop = stemUp ? (y + stemLen) : y + let stemBot = stemUp ? y : (y - stemLen) + ink.setStroke() + let stem = NSBezierPath() + stem.lineWidth = 1.3 + stem.move(to: NSPoint(x: stemX, y: stemBot)) + stem.line(to: NSPoint(x: stemX, y: stemTop)) + stem.stroke() + + // Flag glyph at the free stem end (eighth = 1, sixteenth = 2). + let flag: Bravura.Glyph? + switch value { + case .eighth: flag = stemUp ? .flag8thUp : .flag8thDown + case .sixteenth: flag = stemUp ? .flag16thUp : .flag16thDown + default: flag = nil + } + if let flag = flag { + let tipY = stemUp ? stemTop : stemBot + Bravura.draw(flag, at: NSPoint(x: stemX, y: tipY), + staffSpace: sp, color: ink) + } + } + + /// Short ledger strokes through a note head that sits outside the + /// 5-line staff (above F5 or below E4). + private func drawLedgerLinesIfNeeded(forMidi midi: UInt8, + centerX: CGFloat, + staffBottomY: CGFloat) { + let steps = Self.halfLineSteps(forMidi: midi) + guard steps < 0 || steps > 8 else { return } + let half = Self.lineSpacing + let w: CGFloat = Self.lineSpacing * 0.9 + let path = NSBezierPath() + path.lineWidth = 1 + if steps > 8 { + var s = 10 + while s <= steps { + let ly = staffBottomY + CGFloat(s) * (Self.lineSpacing / 2) + path.move(to: NSPoint(x: centerX - w, y: ly)) + path.line(to: NSPoint(x: centerX + w, y: ly)) + s += 2 + } + } else { + var s = -2 + while s >= steps { + let ly = staffBottomY + CGFloat(s) * (Self.lineSpacing / 2) + path.move(to: NSPoint(x: centerX - w, y: ly)) + path.line(to: NSPoint(x: centerX + w, y: ly)) + s -= 2 + } + } + _ = half + guard !path.isEmpty else { return } + staffColor.withAlphaComponent(0.7).setStroke() + path.stroke() + } + + /// Treble clef + 4/4 time signature from Bravura, at the staff's + /// left edge. The clef's curl sits on the G line (2nd from bottom); + /// the meter digits stack centered on the staff. Drawn at content + /// origin (scrolls with the music, like a real engraving — the very + /// first bar carries the clef + meter). + private func drawClefAndMeter(staffBottomY: CGFloat) { + let sp = Self.lineSpacing + let ink = staffColor + + // G clef: SMuFL baseline sits on the G line (staffBottomY + 1 + // space). Bravura positions the spiral correctly when its + // baseline is on that line. + let gLineY = staffBottomY + sp // 2nd line from bottom = G4 + Bravura.draw(.gClef, at: NSPoint(x: Self.leftPad, y: gLineY), + staffSpace: sp, color: ink) + + // 4/4 time signature — two digit glyphs stacked, centered + // between the staff lines. Top "4" straddles the 4th line, + // bottom "4" the 2nd line (standard placement). + let four = String(Bravura.timeSigDigit(4)) + let dw = Bravura.width(four, staffSpace: sp) + let clefW = Bravura.width(String(Bravura.Glyph.gClef.rawValue), + staffSpace: sp) + let meterX = Self.leftPad + clefW + sp * 0.4 + // Each digit spans ~2 spaces; baseline of the top digit sits + // on the middle line, the bottom digit one staff-half below. + let topBaseline = staffBottomY + sp * 2 // top number centered on upper half + let botBaseline = staffBottomY // bottom number on lower half + _ = dw + Bravura.draw(four, at: NSPoint(x: meterX, y: topBaseline), + staffSpace: sp, color: ink) + Bravura.draw(four, at: NSPoint(x: meterX, y: botBaseline), + staffSpace: sp, color: ink) + } +} + +/// Scroll container for `TapeStaffView`. Owns a horizontal-only +/// `NSScrollView`, exposes the staff's knobs, runs the live-follow +/// timer, and detects two-finger scrub-back so the user can review +/// earlier bars without the playhead yanking them back to "now". +final class TapeStaffScroller: NSView { + private let scrollView = NSScrollView() + let staff = TapeStaffView() + + var score: TapeScore? { + get { staff.score } + set { staff.score = newValue; refresh() } + } + var bpm: Double { + get { staff.bpm } + set { staff.bpm = newValue } + } + var live: Bool { + get { staff.live } + set { + staff.live = newValue + if newValue { startTimer() } else { stopTimer() } + } + } + var staffColor: NSColor { + get { staff.staffColor } + set { staff.staffColor = newValue; staff.needsDisplay = true } + } + var noteColor: NSColor { + get { staff.noteColor } + set { staff.noteColor = newValue; staff.needsDisplay = true } + } + + private var followTimer: Timer? + /// True while the user has scrolled away from the live edge — the + /// timer keeps advancing the playhead + growing the page but stops + /// dragging the viewport so the user can read history in peace. + private var userScrolledBack = false + /// Set while WE move the clip view so the bounds-changed observer + /// doesn't misread our own auto-follow as a user scrub. + private var programmaticScroll = false + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setUp() + } + required init?(coder: NSCoder) { + super.init(coder: coder) + setUp() + } + + private func setUp() { + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.hasHorizontalScroller = true + scrollView.hasVerticalScroller = false + scrollView.horizontalScrollElasticity = .allowed + scrollView.verticalScrollElasticity = .none + scrollView.autohidesScrollers = true + scrollView.drawsBackground = false + scrollView.borderType = .noBorder + scrollView.documentView = staff + addSubview(scrollView) + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + let clip = scrollView.contentView + clip.postsBoundsChangedNotifications = true + NotificationCenter.default.addObserver( + self, selector: #selector(clipBoundsChanged), + name: NSView.boundsDidChangeNotification, object: clip) + } + + deinit { NotificationCenter.default.removeObserver(self) } + + override func layout() { + super.layout() + sizeDocument() + } + + /// Resize the document view: width = content width, height = clip + /// height (so the staff fills vertically and never scrolls in Y). + private func sizeDocument() { + let h = scrollView.contentView.bounds.height + let w = staff.contentWidth() + if staff.frame.size != NSSize(width: w, height: h) { + staff.frame = NSRect(x: 0, y: 0, width: w, height: max(h, 1)) + } + } + + func refresh() { + sizeDocument() + staff.needsDisplay = true + // A note edit (onset or release) just landed — scroll to the + // latest onset so a new attack pulls the staff along, but a + // sustain (which also fires onChange as its value updates) + // leaves the viewport where it is. + followToLastOnset() + } + + /// Clear back to an empty staff scrolled home. + func resetView() { + userScrolledBack = false + playheadHome() + refresh() + } + + private func playheadHome() { + staff.playheadBeat = 0 + programmaticScroll = true + scrollView.contentView.scroll(to: NSPoint(x: 0, y: 0)) + scrollView.reflectScrolledClipView(scrollView.contentView) + programmaticScroll = false + } + + // MARK: - Live follow + + private func startTimer() { + if followTimer != nil { return } + let t = Timer(timeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in + self?.tick() + } + RunLoop.main.add(t, forMode: .common) + followTimer = t + } + + private func stopTimer() { + followTimer?.invalidate() + followTimer = nil + } + + private func tick() { + // Advance the playhead along the beat grid from the metronome + // clock so the red line tracks real time even between notes. + let start = KeyboardIconRenderer.metronomeStartTime + if start > 0 { + let spb = 60.0 / max(20.0, staff.bpm) + staff.playheadBeat = max(0, (CACurrentMediaTime() - start) / spb) + } + // Repaint so the playhead moves + a held note's value updates + // in place, and grow the page — but DON'T scroll while a note + // is sustaining. Scrolling follows note ONSETS only (see + // followToLastOnset), so holding a note keeps the staff still. + sizeDocument() + staff.needsDisplay = true + } + + /// Scroll so the most recent note ONSET sits ~70% across the + /// visible width. Called when a note is added (via refresh), not on + /// every frame — so a sustained note never drags the viewport. + private func followToLastOnset() { + guard live, !userScrolledBack else { return } + let clip = scrollView.contentView + let visW = clip.bounds.width + guard visW > 0 else { return } + let docW = staff.frame.width + // Most-recent onset in beats, on the metronome grid. + let onsetBeat = staff.lastOnsetBeat + let headX = TapeStaffView.leftPad + TapeStaffView.clefGap + + CGFloat(onsetBeat) * staff.pixelsPerBeat + var targetX = headX - visW * 0.70 + targetX = max(0, min(targetX, max(0, docW - visW))) + programmaticScroll = true + clip.scroll(to: NSPoint(x: targetX, y: 0)) + scrollView.reflectScrolledClipView(clip) + programmaticScroll = false + } + + @objc private func clipBoundsChanged() { + guard live, !programmaticScroll else { return } + // A user-driven scroll. If they're within a few px of the live + // edge, treat it as "back at now" and resume auto-follow; + // otherwise pause follow so they can review earlier bars. + let clip = scrollView.contentView + let visW = clip.bounds.width + let docW = staff.frame.width + let rightEdge = clip.bounds.origin.x + visW + userScrolledBack = rightEdge < docW - 24 + } +} diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -507,6 +507,28 @@ } } } + /// Tidy every Stickies note into a tight top-left stack, collapsed. + /// Rather than hand-roll the geometry, we drive Stickies' own built-in + /// "Window ▸ Arrange By" command: first set its "When Arranging" mode to + /// "Collapse All" (a persistent radio choice), then invoke "Arrange By ▸ + /// Color", which packs the notes into a flush column in the top-left and + /// collapses each to its title bar. Native, idempotent, and it leaves the + /// notes exactly where Stickies' own "Arrange" would. + @objc func stackStickies() { + let script = """ + tell application "Stickies" to activate + tell application "System Events" to tell process "Stickies" + set abMenu to menu 1 of menu item "Arrange By" of menu 1 of menu bar item "Window" of menu bar 1 + -- Collapse-on-arrange mode (persists across runs). + click menu item "Collapse All" of abMenu + delay 0.2 + -- Perform the arrange — packs top-left + collapses. + click menu item "Color" of abMenu + end tell + """ + ShellRunner.runAsync("/usr/bin/osascript", args: ["-e", script]) + } + @objc func reloadDaemon() { DispatchQueue.global(qos: .userInitiated).async { ShellRunner.run("/bin/launchctl", args: ["unload", Paths.daemonPlist]) diff --git a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift --- a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift @@ -72,6 +72,14 @@ selector: #selector(AppDelegate.closeFrontTerminal), target: target) closeTerm.toolTip = "Close the frontmost Terminal or iTerm2 window. If auto-tile is on, the remaining windows re-tile." menu.addItem(closeTerm) + + // Tidy a scattered desktop of Stickies notes into a tight collapsed + // column in the top-left via Stickies' own "Arrange By" command. + let stackSticky = item("Arrange Stickies (collapse + top-left)", + selector: #selector(AppDelegate.stackStickies), + target: target) + stackSticky.toolTip = "Run Stickies' built-in Arrange By (Color) with Collapse All — packs all notes into a collapsed column in the top-left corner." + menu.addItem(stackSticky) menu.addItem(.separator()) let stayAwake = item("Stay awake (lid closed)", selector: #selector(AppDelegate.toggleStayAwake), target: target)