From 20824108ae1b73abe4bbb4656078b1dcae93f746 Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Thu, 06 Aug 2026 15:42:36 +0000 Subject: [PATCH] Merge remote-tracking branch 'origin/agent/frame-hitl-action-trails' into HEAD # Conflicts: # juke-wizard/Sources/JukeWizard/MenuBarCD.swift # juke-wizard/Sources/JukeWizard/main.swift --- .mcp.json | 7 +++++++ captutor/README.md | 6 ++++++ captutor/bin/storyboard-receipt.mjs | 6 +++++- captutor/captutor.mjs | 31 +++++++++++++++++++++++++++++++ captutor/lib/cue-timing.mjs | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ captutor/screenplays/making-cartoons.mjs | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ captutor/screenplays/talking-taco.mjs | 25 ++++++++++++++++++------- captutor/test/cue-timing.test.mjs | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ captutor/test/storyboard-receipt.test.mjs | 5 ++++- juke-wizard/Package.swift | 15 ++++++++------- juke-wizard/Sources/JukeWizard/BackdropView.swift | 8 ++------ juke-wizard/Sources/JukeWizard/ControlServer.swift | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ juke-wizard/Sources/JukeWizard/DJMixerView.swift | 307 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------- juke-wizard/Sources/JukeWizard/DJPopoutDeck.swift | 47 ++++++++++++++++++++++++++++++++++++++++++++--- juke-wizard/Sources/JukeWizard/DJPrimpats.swift | 2 +- juke-wizard/Sources/JukeWizard/DockIcon.swift | 95 ----------------------------------------------------------------------------------------------- juke-wizard/Sources/JukeWizard/JukeAppleMusic.swift | 145 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ juke-wizard/Sources/JukeWizard/JukeCloud.swift | 2 +- juke-wizard/Sources/JukeWizard/JukeController.swift | 778 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------------------------------------------------------------------------------------- juke-wizard/Sources/JukeWizard/JukeLiquidSurface.swift | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ juke-wizard/Sources/JukeWizard/JukeMiniPlayerView.swift | 2 +- juke-wizard/Sources/JukeWizard/JukeProviderDeckView.swift | 180 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ juke-wizard/Sources/JukeWizard/JukeResources.swift | 26 ++++++++++++++++++++++++++ juke-wizard/Sources/JukeWizard/JukeRoomAudio.swift | 8 ++++---- juke-wizard/Sources/JukeWizard/JukeSource.swift | 46 ++++++++++++++++++++++++++++++++++++++++++++++ juke-wizard/Sources/JukeWizard/JukeSpotify.swift | 12 ++++++++---- juke-wizard/Sources/JukeWizard/MascotView.swift | 8 ++------ juke-wizard/Sources/JukeWizard/MenuBandJuke.swift | 161 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ juke-wizard/Sources/JukeWizard/SpotifyViews.swift | 3 ++- juke-wizard/Sources/JukeWizard/TrackRowView.swift | 2 +- juke-wizard/Sources/JukeWizard/WaveformView.swift | 62 +++++++++++++++++++++++++++++++++++++++++++------------------- juke-wizard/Sources/JukeWizard/WorkStatus.swift | 4 +++- juke-wizard/Tests/JukeWizardTests/DJPrimpatsTests.swift | 2 +- juke-wizard/Tests/JukeWizardTests/JukeSourceTests.swift | 22 ++++++++++++++++++++++ juke-wizard/bin/jukewizard | 45 ++++++++++----------------------------------- juke-wizard/bin/jukewizard-control.mjs | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ juke-wizard/bin/jukewizard-installed | 3 ++- juke-wizard/bin/jukewizard-mcp.mjs | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ juke-wizard/design/renders/source-aesthetic.png | 0 juke-wizard/design/renders/source-apple.png | 0 juke-wizard/design/renders/source-browser-sheet.png | 0 juke-wizard/design/renders/source-local.png | 0 juke-wizard/design/renders/source-spotify.png | 0 juke-wizard/design/source-browser-notes.md | 25 +++++++++++++++++++++++++ juke-wizard/design/source-browser-prototype.html | 527 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ juke-wizard/install.sh | 3 ++- marketing/av-reels/bin/stamp-reel.mjs | 39 ++++++++++++++++++++++++++++----------- papers/AESTHETIC-EYE.md | 45 +++++++++++++++++++++++++++++++++++++-------- papers/QUALITY-RUBRIC.md | 9 ++++++--- papers/SCORE.md | 2 +- papers/aesthetic-eye.mjs | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--------- papers/arxiv-hott-kidlisp/aesthetic-eye.json | 14 ++++++++++++-- papers/arxiv-hott-kidlisp/references.bib | 2 +- papers/arxiv-pieces/aesthetic-eye.json | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ papers/arxiv-pieces/data/monorepo-snapshot.json | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ papers/arxiv-pieces/figures/ac-dannom-play.png | 0 papers/arxiv-pieces/figures/ac-kidlisp-fia-birthday-live.png | 0 papers/arxiv-pieces/figures/ac-laer-klokken-live.png | 0 papers/arxiv-pieces/figures/ac-list-crop.png | 0 papers/arxiv-pieces/figures/ac-list.png | 0 papers/arxiv-pieces/figures/ac-notepat-crop.png | 0 papers/arxiv-pieces/figures/ac-notepat.png | 0 papers/arxiv-pieces/figures/ac-profile-sat-live.png | 0 papers/arxiv-pieces/figures/cover-puzzle-masked.png | 0 papers/arxiv-pieces/figures/cover-puzzle-prompt.txt | 8 ++++++++ papers/arxiv-pieces/figures/cover-puzzle-v2.png | 0 papers/arxiv-pieces/figures/piece-contract-tikz.tex | 33 +++++++++++++++++++++++++++++++++ papers/arxiv-pieces/figures/piece-ecology-tikz.tex | 31 +++++++++++++++++++++++++++++++ papers/arxiv-pieces/figures/piece-lifecycle-tikz.tex | 30 ++++++++++++++++++++++++++++++ papers/arxiv-pieces/figures/screenshots.json | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ papers/arxiv-pieces/pieces.log | 859 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- papers/arxiv-pieces/pieces.tex | 667 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- papers/arxiv-pieces/references.bib | 109 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------- pop/jungle/bin/render.mjs | 1 + pop/menuband/raytracer/.gitignore | 1 + pop/teknull/bin/remaster-taksmukkeklokken-harsh-c.mjs | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pop/teknull/bin/render-taksmukkeklokken-sinebells.mjs | 256 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pop/wattajetta/bin/render-wattajetta-breakfirst.mjs | 254 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pop/wattajetta/bin/render-wattajetta.mjs | 32 ++++++++++++++++++++++++-------- slab/bin/codex-slab | 7 ++++--- slab/bin/imsg.mjs | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- slab/blueberry-wallpaper/BlueberryWallpaper.swift | 222 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ slab/blueberry-wallpaper/build.sh | 9 +++++++++ slab/menuband/.gitignore | 1 + slab/menuband/Info.plist | 4 +++- slab/menuband/Package.swift | 4 +++- slab/menuband/SCORE.md | 19 +++++++++++++++++++ slab/menuband/Sources/MenuBand/AppDelegate.swift | 34 ++++++++++++++++++++++++++++++++++ slab/menuband/Sources/MenuBand/MenuBandPopover.swift | 136 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ slab/menuband/Sources/MenuBand/MenuBandPopoverPanel.swift | 26 ++++++++++++++++++++++++++ slab/menuband/Sources/MenuBand/MenuBandSynth.swift | 7 ------- slab/menuband/Sources/MenuBand/QwertyCapture.swift | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ slab/menuband/Sources/MenuBand/ScaleStaffCapture.swift | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ slab/menuband/Sources/MenuBand/Shapedown.swift | 72 ++++++++---------------------------------------------------------------- slab/menuband/Sources/MenuBand/main.swift | 10 ++++++++++ slab/menuband/Sources/MenuBandLauncher/main.swift | 34 +++++++++++----------------------- slab/menuband/install.sh | 61 +++++++++++++++++++++++++++++++++++++++++++++++++------------ slab/menuband/marketing/instrument-playing/README.md | 17 +++++++++++++++++ slab/menuband/marketing/instrument-playing/captions.srt | 7 +++++++ slab/menuband/marketing/instrument-playing/escape-line.txt | 1 + slab/menuband/marketing/instrument-playing/jeffrey-double-tap-command.mp3.hash | 1 + slab/menuband/marketing/instrument-playing/jeffrey-press-escape.mp3.hash | 1 + slab/menuband/marketing/instrument-playing/render-prototype.mjs | 190 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ slab/menuband/marketing/instrument-playing/voice-line.txt | 1 + slab/menuband/screenshots/menuband-shortcuts.html | 2 +- slab/menubar-swift/Sources/SlabMenubar/FrameCapture.swift | 41 ++++++++++++++++++++++++++++++++++------- slab/menubar-swift/Sources/SlabMenubar/Ledger.swift | 29 +++++++++++++++++++++++++++-- slab/menubar-swift/Sources/SlabMenubar/ProxMemoirs.swift | 30 +++++++++++++++++++++++++++--- slab/menubar-swift/Sources/SlabMenubar/ScreenRecord.swift | 18 ++++++++++++++++++ slab/settings-fragment.json | 1 + slab/tracktramp/.gitignore | 2 ++ slab/tracktramp/ExportOptions-AppStore.plist | 14 ++++++++++++++ slab/tracktramp/Info.plist | 36 ++++++++++++++++++++++++++++++++++++ slab/tracktramp/README.md | 35 +++++++++++++++++++++++++++++++++++ slab/tracktramp/Sources/MenubandUIBridge.swift | 12 ++++++++++++ slab/tracktramp/Sources/PhysicalTrackpadCalibration.swift | 45 +++++++++++++++++++++++++++++++++++++++++++++ slab/tracktramp/Sources/TrackDrumContact.swift | 38 ++++++++++++++++++++++++++++++++++++++ slab/tracktramp/Sources/TrackDrumIcon.swift | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ slab/tracktramp/Sources/TracktrampApp.swift | 787 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ slab/tracktramp/Sources/main.swift | 19 +++++++++++++++++++ slab/tracktramp/Tests/main.swift | 41 +++++++++++++++++++++++++++++++++++++++++ slab/tracktramp/TrackDrum.entitlements | 8 ++++++++ slab/tracktramp/build-app-store.sh | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ slab/tracktramp/build-dmg.sh | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ slab/tracktramp/project.yml | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ slab/tracktramp/render-icon.sh | 28 ++++++++++++++++++++++++++++ slab/tracktramp/render-icon.swift | 22 ++++++++++++++++++++++ slab/tracktramp/test.sh | 11 +++++++++++ slab/tracktramp/verify-app.sh | 36 ++++++++++++++++++++++++++++++++++++ system/public/aesthetic.computer/disks/video.mjs | 11 ++++++++++- toolchain/fleet/fleet-mcp.mjs | 0 131 file(s) changed, 7590 insertion(s)(+), 1104 deletion(s)(-) diff --git a/.mcp.json b/.mcp.json --- a/.mcp.json +++ b/.mcp.json @@ -48,6 +48,13 @@ "command": "node", "args": [ "slab/bin/dmgify-mcp.mjs" ] + }, + "jukewizard": { + "type": "stdio", + "command": "node", + "args": [ + "juke-wizard/bin/jukewizard-mcp.mjs" + ] } } } diff --git a/captutor/README.md b/captutor/README.md --- a/captutor/README.md +++ b/captutor/README.md @@ -108,6 +108,12 @@ returns ElevenLabs' per-character alignment, so every line's exact duration — and every word's exact position inside it — is known *before* the camera rolls. The UI then performs to that clock. +New screenplays set `actionCuePolicy: "required"`, declare each beat's spoken +`cues`, and call `await nextCue()` before its visible action. Captutor validates +every phrase before voice synthesis, blocks an action that starts before its +first cue, and fails a take with unused cues. Older screenplays remain compatible +while they are migrated. + Two consequences worth knowing: - **Captions are free and exact.** No whisper pass, no forced aligner, and none diff --git a/captutor/bin/storyboard-receipt.mjs b/captutor/bin/storyboard-receipt.mjs --- a/captutor/bin/storyboard-receipt.mjs +++ b/captutor/bin/storyboard-receipt.mjs @@ -143,8 +143,12 @@ : "source and receipt language are English"], ["Delivery geometry", videoStream?.width === expected[0] && videoStream?.height === expected[1], `${videoStream?.width}×${videoStream?.height}; expected ${expected.join("×")}`], ...(acceptance.minimumDurationSec ? [[ - "Introduction length", duration >= acceptance.minimumDurationSec, + "Minimum length", duration >= acceptance.minimumDurationSec, `${duration.toFixed(2)} s; minimum ${acceptance.minimumDurationSec} s`, + ]] : []), + ...(acceptance.maximumDurationSec ? [[ + "Maximum length", duration <= acceptance.maximumDurationSec, + `${duration.toFixed(2)} s; maximum ${acceptance.maximumDurationSec} s`, ]] : []), ...(acceptance.requireOpeningCard ? [[ "Opening title card", Boolean(opening?.result?.filmed), diff --git a/captutor/captutor.mjs b/captutor/captutor.mjs --- a/captutor/captutor.mjs +++ b/captutor/captutor.mjs @@ -37,6 +37,9 @@ import { dirname, join, resolve, basename } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { narrate } from "./lib/narrate.mjs"; +import { + createBeatCue, createBeatCuePlan, validateActionCuePlan, +} from "./lib/cue-timing.mjs"; import { attach, BrowserCrashError } from "./lib/cdp.mjs"; import { clickOn, dillydallyAtPoint, dragBetween, pointAt, startNativeCursor, @@ -288,6 +291,8 @@ return hit; } async function cmdNarrate(sp, workDir, locale) { + // Validate action-to-word choreography before synthesis can spend money. + validateActionCuePlan(sp, { locale }); console.log(`\n♪ narrating ${sp.beats.length} beats (${sp.voice || "jeffrey"} · ${LANGUAGES[locale]?.native || locale})`); const localized = sp.beats.map((b) => ({ ...b, say: say(b.say, locale) })); // The voice cache is keyed by LOCALE, not by format. The narration for a reel @@ -420,6 +425,8 @@ // reusable effectTheme; a beat's local options are the most specific layer. const themedEffectOptions = (opts = {}) => ({ ...(sp.effectTheme || {}), ...opts }); let traceSince = null; let bakeTimeSequence = 0; + let activeCue = null; + let activeCuePlan = null; const storyboardEvents = []; const trace = (kind, details = {}) => { if (traceSince == null) return; @@ -470,6 +477,7 @@ screen:envelope.meta?.screen || null, }; }; const perform = async (kind, details, action) => { + activeCuePlan?.assertActionReady(kind); const startedAt = now(); const before = await uiSnapshot(); let frameAudit = null; @@ -537,6 +545,14 @@ signboard: (card, options) => perform("signboard", { card:localizeCard(card), options, }, () => presentSignboard(cdp, localizeCard(card), options)), check: (name, evidence = {}) => trace("check", { name, evidence }), + cue: (phrase, options) => { + if (!activeCue) throw new Error("cue() is only available while a narrated beat is running"); + return activeCue(phrase, options); + }, + nextCue: () => { + if (!activeCuePlan) throw new Error("nextCue() is only available while a narrated beat is running"); + return activeCuePlan.next(); + }, // A model wait is real capture evidence, but its inert middle is not useful // teaching time. Mark the exact async boundary; composition keeps a short // live lead, applies the canonical bake-time fold, then returns on the @@ -815,6 +831,18 @@ if (activeChapter?.wallpaperColor) { setAmbient({ accent:activeChapter.wallpaperColor }); } const startedAt = now(); + activeCue = createBeatCue({ + beat, + startedAt, + now, + sleep, + onCue:(details) => trace("cue", details), + }); + activeCuePlan = createBeatCuePlan({ + beat, + cue:activeCue, + required:sp.actionCuePolicy === "required" && beat.actionCues !== false, + }); director.publish(directorBeatState(beats, beat.index, startedAt * 1000)); director.playVoice(beat.mp3); const offsetSec = startedAt - since; @@ -823,12 +851,15 @@ process.stdout.write( ` ${String(beat.index + 1).padStart(2)}. @${offsetSec.toFixed(1)}s ${beat.say.slice(0, 52)}\n`); if (beat.do) await beat.do(ctx); + activeCuePlan.assertComplete(); // Hold the shot for at least as long as the line takes to say. If the action // already outlasted it, we do NOT claw the time back — the next beat is // stamped where it truly starts, so the voice stays glued to the picture. const remain = beat.durationSec + (beat.holdMs ?? 350) / 1000 - (now() - startedAt); if (remain > 0) await sleep(remain * 1000); + activeCue = null; + activeCuePlan = null; result.push({ ...beat, offsetSec }); } if (sp.closingCard) { diff --git a/captutor/lib/cue-timing.mjs b/captutor/lib/cue-timing.mjs new file mode 100644 --- /dev/null +++ b/captutor/lib/cue-timing.mjs @@ -0,0 +1,125 @@ +// Word-aligned stage directions for narrated beats. + +const normalize = (value) => String(value || "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, ""); + +const normalizedWords = (value) => String(value || "") + .trim() + .split(/\s+/) + .map(normalize) + .filter(Boolean); + +function localizedNarration(value, locale) { + if (typeof value === "string") return value; + return value?.[locale] || ""; +} + +function cueDeclaration(value) { + if (typeof value === "string") return { phrase:value }; + if (value && typeof value === "object" && typeof value.phrase === "string") return value; + throw new Error(`invalid action cue ${JSON.stringify(value)}`); +} + +export function validateActionCuePlan(screenplay, { locale = "en" } = {}) { + if (screenplay.actionCuePolicy !== "required") return screenplay; + for (const [index, beat] of screenplay.beats.entries()) { + if (typeof beat.do !== "function" || beat.actionCues === false) continue; + if (!Array.isArray(beat.cues) || beat.cues.length === 0) { + throw new Error(`${screenplay.slug} beat ${index + 1} performs an action without a word cue`); + } + const spoken = normalizedWords(localizedNarration(beat.say, locale)); + for (const raw of beat.cues) { + const declaration = cueDeclaration(raw); + const wanted = normalizedWords(declaration.phrase); + const found = wanted.length > 0 && spoken.some((_, start) => + wanted.every((word, offset) => spoken[start + offset] === word)); + if (!found) { + throw new Error( + `${screenplay.slug} beat ${index + 1} cue ${JSON.stringify(declaration.phrase)} ` + + `is not present in the ${locale} narration`, + ); + } + } + } + return screenplay; +} + +export function cueTimeMs(words, phrase, { + occurrence = 1, + anchor = "start", +} = {}) { + const wanted = normalizedWords(phrase); + if (!wanted.length) throw new Error("cue phrase must contain a word"); + if (!Number.isInteger(occurrence) || occurrence < 1) { + throw new Error("cue occurrence must be a positive integer"); + } + if (anchor !== "start" && anchor !== "end") { + throw new Error(`unsupported cue anchor ${JSON.stringify(anchor)}`); + } + + const spoken = (words || []).map((word) => normalize(word.text)); + let seen = 0; + for (let i = 0; i <= spoken.length - wanted.length; i += 1) { + if (!wanted.every((word, offset) => spoken[i + offset] === word)) continue; + seen += 1; + if (seen !== occurrence) continue; + const matched = anchor === "end" ? words[i + wanted.length - 1] : words[i]; + const value = anchor === "end" ? matched.toMs : matched.fromMs; + if (!Number.isFinite(value)) throw new Error(`cue ${JSON.stringify(phrase)} has no ${anchor} time`); + return value; + } + throw new Error(`cue phrase ${JSON.stringify(phrase)} was not found in the narration`); +} + +export function createBeatCue({ beat, startedAt, now, sleep, onCue = () => {} }) { + return async (phrase, options = {}) => { + const targetMs = cueTimeMs(beat.words, phrase, options); + const leadMs = Number(options.leadMs || 0); + if (!Number.isFinite(leadMs) || leadMs < 0) { + throw new Error("cue leadMs must be a non-negative number"); + } + const elapsedMs = Math.max(0, (now() - startedAt) * 1000); + const waitMs = Math.max(0, targetMs - leadMs - elapsedMs); + if (waitMs > 0) await sleep(waitMs); + const result = { + phrase, + anchor:options.anchor || "start", + occurrence:options.occurrence || 1, + targetMs, + leadMs, + waitMs:+waitMs.toFixed(3), + }; + onCue(result); + return result; + }; +} + +export function createBeatCuePlan({ beat, cue, required = false }) { + const declarations = (beat.cues || []).map(cueDeclaration); + let consumed = 0; + return { + async next() { + const declaration = declarations[consumed]; + if (!declaration) { + if (required) throw new Error(`beat ${beat.index + 1} has no remaining action cue`); + return null; + } + consumed += 1; + const { phrase, ...options } = declaration; + return cue(phrase, options); + }, + assertActionReady(kind) { + if (required && consumed === 0) { + throw new Error(`beat ${beat.index + 1} attempted ${kind} before nextCue()`); + } + }, + assertComplete() { + if (required && consumed !== declarations.length) { + throw new Error( + `beat ${beat.index + 1} consumed ${consumed} of ${declarations.length} action cues`, + ); + } + }, + }; +} diff --git a/captutor/screenplays/making-cartoons.mjs b/captutor/screenplays/making-cartoons.mjs new file mode 100644 --- /dev/null +++ b/captutor/screenplays/making-cartoons.mjs @@ -0,0 +1,102 @@ +// making-cartoons — generate a two-character cartoon still, then animate the +// same cast through an explicit image-to-video connection. +// +// The automation is the proven talking-taco path; this variant owns the +// editorial contract: about three minutes, one visible two-character cast, and +// enough prompting rationale to teach a repeatable cartoon workflow. +import { createImageToVideoScreenplay } from "./talking-taco.mjs"; + +const CHARACTER_PROMPT = + "Two original cartoon characters standing together: a cheerful moon-headed " + + "skateboarder in a cobalt jacket and a tiny cloud dog with orange rain boots. " + + "Full-body view, clear contrasting silhouettes, expressive faces, simple " + + "flat 2D animation shapes, limited color palette, clean pale background, no " + + "text, no logos, model sheet quality"; + +const MOTION_PROMPT = + "The moon-headed skateboarder rolls slowly forward and waves while the tiny " + + "cloud dog bounds alongside, its orange boots landing with soft squash and " + + "stretch. Preserve both character designs, faces, colors, and proportions. " + + "Gentle side view, simple looping action, locked camera, no cuts, no text"; + +const tutorial = createImageToVideoScreenplay({ + slug:"making-cartoons", + title:"Making Cartoons in Fuser", + subtitle:"Design a two-character cast, then animate the same image", + closingTitle:"Two characters. One connected image-to-video cartoon.", + imagePrompt:CHARACTER_PROMPT, + motionPrompt:MOTION_PROMPT, +}); + +const narration = [ + "In the next three minutes, we'll make a tiny cartoon in Fuser: first a still image of two original characters, then a moving scene generated from that exact image.", + "Start with a fresh project. Keeping the canvas empty makes the construction easy to read: one node will design the cast, and one node will animate it.", + "Open the node picker and search for Gemini Image. This is our character-design step, where a written description becomes the visual reference for everything that follows.", + "Choose Gemini Image. Fuser places the node on the canvas with its prompt, model controls, and image output together, so the source of the character art stays visible.", + "Describe the cast before the action. Name each character, give each one a distinct silhouette and color, request full bodies, and keep the background simple enough for animation.", + "Generate the image. Fuser sends the prompt to the selected model while keeping the prompt and result in the same node, so the design decision remains attached to its evidence.", + "Here is our cast: a moon-headed skateboarder and a cloud dog. Check that both characters are readable at a glance, separated from the background, and shown head to toe.", + "Now add Kling 3.0 Video. This second node will not redesign the cast; it will receive the finished character image and use it as the opening visual reference.", + "Place the video node beside the image node. A left-to-right layout makes the recipe legible even without narration: character design first, animation second.", + "Set the clip to three seconds for this first test. A short generation is enough to judge whether the motion preserves the characters before you spend time on a longer shot.", + "Connect the generated image output to Kling's Image input. This line is the provenance of the cartoon: the moving scene must begin with the character art we just approved.", + "Write motion, not appearance. The skateboarder rolls and waves; the cloud dog bounds with squash and stretch. Ask for the same faces, colors, proportions, and a locked camera.", + "Generate the video. Fuser now combines two instructions: the connected image defines who the characters are, while the motion prompt defines what they do and how the camera behaves.", + "The result keeps both characters in one shot and turns the still design into a small performance. Watch for identity drift, extra limbs, camera cuts, or a background that changes unexpectedly.", + "That is the reusable cartoon workflow: design a clear cast image, connect it as evidence, describe only the motion, generate a short test, then refine either node without losing the chain.", +]; + +const actionCues = [ + "next three minutes", + "fresh project", + "Open the node picker", + "Choose Gemini Image", + "Describe the cast", + "Generate the image", + "Here is our cast", + "Now add Kling 3.0 Video", + "Place the video node", + "Set the clip", + "Connect the generated image output", + "Write motion", + "Generate the video", + "The result", + "That is the reusable cartoon workflow", +]; + +if (narration.length !== tutorial.beats.length) { + throw new Error(`making-cartoons narration has ${narration.length} lines for ${tutorial.beats.length} beats`); +} + +tutorial.beats = tutorial.beats.map((beat, index) => ({ + ...beat, + say:narration[index], + cues:[{ phrase:actionCues[index], leadMs:520 }], + do:async (ctx) => { + await ctx.nextCue(); + return beat.do(ctx); + }, +})); + +tutorial.actionCuePolicy = "required"; +tutorial.openingCard = { + title:"Making Cartoons in Fuser", + showMark:false, + durationMs:2800, + transition:"slide", +}; +tutorial.targetDurationSec = 180; +tutorial.chapters = [ + { title:"Plan the cast", shortTitle:"Plan", beatIndex:0, color:"#a794ca", wallpaperColor:"#6f5a98" }, + { title:"Generate characters", shortTitle:"Characters", beatIndex:2, color:"#89bab4", wallpaperColor:"#4f8b85" }, + { title:"Inspect the image", shortTitle:"Inspect", beatIndex:6, color:"#d1b77f", wallpaperColor:"#9a7838" }, + { title:"Animate the cast", shortTitle:"Animate", beatIndex:7, color:"#d49b8f", wallpaperColor:"#a46054" }, + { title:"Review the cartoon", shortTitle:"Review", beatIndex:13, color:"#c68fb0", wallpaperColor:"#914f77" }, +]; +tutorial.acceptance = { + ...tutorial.acceptance, + minimumDurationSec:165, + maximumDurationSec:195, +}; + +export default tutorial; diff --git a/captutor/screenplays/talking-taco.mjs b/captutor/screenplays/talking-taco.mjs --- a/captutor/screenplays/talking-taco.mjs +++ b/captutor/screenplays/talking-taco.mjs @@ -201,8 +201,16 @@ const creditsExpr = `[...document.querySelectorAll('button')] .map((button) => (button.innerText || '').replace(/\\s+/g, ' ').trim()) .find((text) => /✦/.test(text) && /\\d/.test(text)) || ''`; -export default { - slug: "talking-taco", +export function createImageToVideoScreenplay({ + slug = "talking-taco", + title = "Learn Fuser by making a Talking Taco", + subtitle = "A fresh project → a generated image → a generated video", + closingTitle = "One prompt became an image. That image became a video.", + imagePrompt = TACO_IMAGE_PROMPT, + motionPrompt = TACO_MOTION_PROMPT, +} = {}) { +return { + slug, voice: "jeffrey", window: "Fuser", desktopFrame: true, @@ -216,8 +224,8 @@ effectTheme: fuserEffectTheme, brandChrome: fuserBrandChrome, billable: true, fps: 60, - title: "Learn Fuser by making a Talking Taco", - subtitle: "A fresh project → a generated image → a generated video", + title, + subtitle, openingCard: { title: "Learn Fuser", showMark: false, @@ -225,7 +233,7 @@ durationMs: 2400, transition: "slide", }, closingCard: { - title: "One prompt became an image. That image became a video.", + title: closingTitle, durationMs: 2400, transition: "genie", }, @@ -347,7 +355,7 @@ { say: "Describe the image you want. Let's make a talking taco: bold, cheerful, mouth wide open mid-laugh.", do: async (ctx) => { const { cdp, check } = ctx; - await typePrompt(ctx, GEMINI_PROMPT, TACO_IMAGE_PROMPT); + await typePrompt(ctx, GEMINI_PROMPT, imagePrompt); check("image_prompt_entered", { text: await cdp.eval(`document.querySelector(${JSON.stringify(GEMINI_PROMPT)}).value`) }); }, }, @@ -458,7 +466,7 @@ { say: "Add a short motion prompt describing how the taco should move and talk.", do: async (ctx) => { const { cdp, check } = ctx; - await typePrompt(ctx, KLING_PROMPT, TACO_MOTION_PROMPT); + await typePrompt(ctx, KLING_PROMPT, motionPrompt); check("video_prompt_entered", { text: await cdp.eval(`document.querySelector(${JSON.stringify(KLING_PROMPT)}).value`) }); }, }, @@ -544,3 +552,6 @@ }, }, ], }; +} + +export default createImageToVideoScreenplay(); diff --git a/captutor/test/cue-timing.test.mjs b/captutor/test/cue-timing.test.mjs new file mode 100644 --- /dev/null +++ b/captutor/test/cue-timing.test.mjs @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + createBeatCue, createBeatCuePlan, cueTimeMs, validateActionCuePlan, +} from "../lib/cue-timing.mjs"; + +const words = [ + { text:"Click", fromMs:100, toMs:300 }, + { text:"Publish.", fromMs:320, toMs:760 }, + { text:"The", fromMs:900, toMs:1030 }, + { text:"pill", fromMs:1050, toMs:1300 }, + { text:"flips", fromMs:1320, toMs:1600 }, + { text:"to", fromMs:1620, toMs:1710 }, + { text:"Live.", fromMs:1730, toMs:2040 }, +]; + +test("cueTimeMs matches normalized phrases at either edge", () => { + assert.equal(cueTimeMs(words, "Publish"), 320); + assert.equal(cueTimeMs(words, "pill flips to Live", { anchor:"end" }), 2040); +}); + +test("createBeatCue subtracts movement lead time from the word cue", async () => { + let clock = 10; + const waits = []; + const seen = []; + const cue = createBeatCue({ + beat:{ words }, + startedAt:10, + now:() => clock, + sleep:async (ms) => { waits.push(ms); clock += ms / 1000; }, + onCue:(result) => seen.push(result), + }); + await cue("Live", { leadMs:500 }); + assert.deepEqual(waits, [1230]); + assert.equal(seen[0].targetMs, 1730); +}); + +test("missing cues fail before an unrelated action can run", () => { + assert.throws(() => cueTimeMs(words, "Generate"), /was not found/); +}); + +test("required action cue plans fail before narration synthesis", () => { + const screenplay = { + slug:"new-tutorial", + actionCuePolicy:"required", + beats:[{ say:"Click Publish.", do:async () => {} }], + }; + assert.throws(() => validateActionCuePlan(screenplay), /without a word cue/); + screenplay.beats[0].cues = [{ phrase:"Generate" }]; + assert.throws(() => validateActionCuePlan(screenplay), /is not present/); + screenplay.beats[0].cues = [{ phrase:"Click Publish" }]; + assert.equal(validateActionCuePlan(screenplay), screenplay); +}); + +test("required cue plans block early actions and unconsumed declarations", async () => { + const called = []; + const plan = createBeatCuePlan({ + beat:{ index:2, cues:["Publish", { phrase:"Live", leadMs:400 }] }, + cue:async (phrase, options) => called.push({ phrase, options }), + required:true, + }); + assert.throws(() => plan.assertActionReady("click"), /before nextCue/); + await plan.next(); + plan.assertActionReady("click"); + assert.throws(() => plan.assertComplete(), /1 of 2/); + await plan.next(); + plan.assertComplete(); + assert.deepEqual(called, [ + { phrase:"Publish", options:{} }, + { phrase:"Live", options:{ leadMs:400 } }, + ]); +}); diff --git a/captutor/test/storyboard-receipt.test.mjs b/captutor/test/storyboard-receipt.test.mjs --- a/captutor/test/storyboard-receipt.test.mjs +++ b/captutor/test/storyboard-receipt.test.mjs @@ -26,7 +26,10 @@ createdAt:"2026-07-22T20:00:00.000Z", screenplay:"receipt-smoke", locale:"en", format:"docs", theme:"light", title:"Receipt smoke", subtitle:"Trace-derived frame evidence", media:{ file:"take.mp4", width:320, height:180, durationSec:1.5, bytes:1 }, - acceptance:{ resolution:[320, 180], minimumDurationSec:1, requiredChecks:["route_complete"] }, + acceptance:{ + resolution:[320, 180], minimumDurationSec:1, maximumDurationSec:2, + requiredChecks:["route_complete"], + }, beats:[{ index:0, offsetSec:0, durationSec:1.2, narration:"Route the output.", logic:"The exact upstream output reaches the downstream node.", cursorIntent:"clear", diff --git a/juke-wizard/Package.swift b/juke-wizard/Package.swift --- a/juke-wizard/Package.swift +++ b/juke-wizard/Package.swift @@ -2,8 +2,11 @@ // swift-tools-version:5.9 import PackageDescription let package = Package( - name: "JukeWizard", + name: "MenuBandJuke", platforms: [.macOS(.v12)], + products: [ + .library(name: "MenuBandJuke", targets: ["MenuBandJuke"]), + ], dependencies: [ .package(path: "../slab/macos-audio"), ], @@ -13,16 +16,14 @@ name: "JukeDSP", path: "Sources/JukeDSP", publicHeadersPath: "include" ), - .executableTarget( - name: "JukeWizard", + .target( + name: "MenuBandJuke", dependencies: [ .product(name: "ACMacAudio", package: "macos-audio"), "JukeDSP", ], path: "Sources/JukeWizard", - resources: [ - .copy("Assets"), - ] + exclude: ["Assets"] ), .testTarget( name: "JukeDSPTests", @@ -31,7 +32,7 @@ path: "Tests/JukeDSPTests" ), .testTarget( name: "JukeWizardTests", - dependencies: ["JukeWizard"], + dependencies: ["MenuBandJuke"], path: "Tests/JukeWizardTests" ), ] diff --git a/juke-wizard/Sources/JukeWizard/BackdropView.swift b/juke-wizard/Sources/JukeWizard/BackdropView.swift --- a/juke-wizard/Sources/JukeWizard/BackdropView.swift +++ b/juke-wizard/Sources/JukeWizard/BackdropView.swift @@ -15,9 +15,7 @@ override init(frame: NSRect) { // static fallback (same illy the dock icon comes from) fallbackImage = { - let b = Bundle.module - if let u = b.url(forResource: "jukewizard-mascot", withExtension: "png", subdirectory: "Assets") - ?? b.url(forResource: "jukewizard-mascot", withExtension: "png"), + if let u = JukeResources.url(forResource: "jukewizard-mascot", withExtension: "png"), let img = NSImage(contentsOf: u) { return img } return nil }() @@ -30,9 +28,7 @@ override func hitTest(_ point: NSPoint) -> NSView? { nil } private func setupVideo() { - let b = Bundle.module - guard let url = b.url(forResource: "jukewizard-backdrop", withExtension: "mp4", subdirectory: "Assets") - ?? b.url(forResource: "jukewizard-backdrop", withExtension: "mp4") else { return } + guard let url = JukeResources.url(forResource: "jukewizard-backdrop", withExtension: "mp4") else { return } let item = AVPlayerItem(url: url) let queue = AVQueuePlayer() queue.isMuted = true diff --git a/juke-wizard/Sources/JukeWizard/ControlServer.swift b/juke-wizard/Sources/JukeWizard/ControlServer.swift new file mode 100644 --- /dev/null +++ b/juke-wizard/Sources/JukeWizard/ControlServer.swift @@ -0,0 +1,82 @@ +import Foundation +import Darwin + +/// Local, bounded JSON-line control for Menu Band's Juke window. The Unix +/// socket is user-only; every command is still validated by JukeController. +final class JukeControlServer { + private let controller: JukeController + private let socketPath: String + private var listenFD: Int32 = -1 + private var source: DispatchSourceRead? + + init(controller: JukeController) { + self.controller = controller + let config = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config/jukewizard", isDirectory: true) + try? FileManager.default.createDirectory(at: config, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + socketPath = config.appendingPathComponent("control.sock").path + } + + func start() { + guard socketPath.utf8.count < MemoryLayout.size - 2 else { return } + unlink(socketPath) + listenFD = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard listenFD >= 0 else { return } + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + withUnsafeMutablePointer(to: &address.sun_path) { raw in + raw.withMemoryRebound(to: CChar.self, capacity: 104) { path in + _ = socketPath.withCString { strlcpy(path, $0, 104) } + } + } + let length = socklen_t(MemoryLayout.size + socketPath.utf8.count + 1) + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.bind(listenFD, $0, length) } + } + guard bound == 0, Darwin.listen(listenFD, 8) == 0 else { stop(); return } + chmod(socketPath, S_IRUSR | S_IWUSR) + let s = DispatchSource.makeReadSource(fileDescriptor: listenFD, queue: .main) + s.setEventHandler { [weak self] in self?.acceptOne() } + s.setCancelHandler { [fd = listenFD] in if fd >= 0 { Darwin.close(fd) } } + source = s + s.resume() + } + + private func acceptOne() { + let fd = Darwin.accept(listenFD, nil, nil) + guard fd >= 0 else { return } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + var data = Data() + var byte: UInt8 = 0 + while data.count < 65_536, Darwin.read(fd, &byte, 1) == 1, byte != 10 { data.append(byte) } + guard !data.isEmpty, data.count < 65_536, + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + Self.reply(fd, ["ok": false, "error": "invalid or oversized JSON request"]); return + } + DispatchQueue.main.async { + guard let self else { Self.reply(fd, ["ok": false, "error": "Menu Band Juke unavailable"]); return } + Self.reply(fd, self.controller.control(object)) + } + } + } + + private static func reply(_ fd: Int32, _ object: [String: Any]) { + var data = (try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])) ?? Data("{\"ok\":false}".utf8) + data.append(10) + data.withUnsafeBytes { bytes in + if let base = bytes.baseAddress { _ = Darwin.write(fd, base, data.count) } + } + Darwin.close(fd) + } + + func stop() { + let hadSource = source != nil + source?.cancel(); source = nil + if listenFD >= 0, !hadSource { Darwin.close(listenFD) } + listenFD = -1 + unlink(socketPath) + } + + deinit { stop() } +} diff --git a/juke-wizard/Sources/JukeWizard/DJMixerView.swift b/juke-wizard/Sources/JukeWizard/DJMixerView.swift --- a/juke-wizard/Sources/JukeWizard/DJMixerView.swift +++ b/juke-wizard/Sources/JukeWizard/DJMixerView.swift @@ -391,6 +391,12 @@ } func resetBPM() { setBPM(sourceBPM) } + func setRate(_ value: Double) { + targetBPM = sourceBPM * max(0.5, min(1.5, value)) + applyRate() + onStateChange?() + } + func setGain(_ value: Float) { gain = max(0, min(1, value)) engine.mainMixerNode.outputVolume = gain @@ -422,7 +428,7 @@ ] return specs.compactMap { name, variant, filename, key in guard let url = render(name: filename, variant: variant) else { return nil } let track = Track(url: url, lane: "practice", title: name) - track.meta = TrackMeta(artist: "JukeWizard", backend: "C synthesis", status: "PRACTICE", + track.meta = TrackMeta(artist: "Menu Band Juke", backend: "C synthesis", status: "PRACTICE", updated: nil, revisions: nil, bytes: nil, durationSec: duration, bpm: bpm, key: key, releaseDate: nil, art: nil, media: nil, links: nil) @@ -470,11 +476,16 @@ final class DJPlatterView: NSView { weak var deck: DJDeckPlayer? var accent: NSColor = Palette.teal var deckName = "A" + var canDetachRecord = true + var onDetachRequested: ((NSPoint) -> Void)? private var lastAngle: CGFloat? private var lastTimestamp: TimeInterval? private var scratchOrigin: Double = 0 private var scratchOffset: Double = 0 private var scratchIdleTimer: Timer? + private var dragStartScreen: NSPoint? + private var detachGestureResolved = false + private var detachDeniedUntil: TimeInterval = 0 override var acceptsFirstResponder: Bool { true } override var mouseDownCanMoveWindow: Bool { false } @@ -503,6 +514,27 @@ override func draw(_ dirtyRect: NSRect) { guard let context = NSGraphicsContext.current?.cgContext else { return } let c = center, r = radius + guard deck?.track != nil else { + NSColor.black.withAlphaComponent(0.08).setFill() + NSBezierPath(ovalIn: NSRect(x: c.x - r, y: c.y - r, + width: r * 2, height: r * 2)).fill() + let bed = NSBezierPath(ovalIn: NSRect(x: c.x - r + 2, y: c.y - r + 2, + width: r * 2 - 4, height: r * 2 - 4)) + bed.setLineDash([7, 7], count: 2, phase: 0) + bed.lineWidth = 2 + accent.withAlphaComponent(0.42).setStroke() + bed.stroke() + let empty = "EMPTY" as NSString + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: max(13, r * 0.12), weight: .bold), + .foregroundColor: accent.withAlphaComponent(0.62) + ] + let size = empty.size(withAttributes: attrs) + empty.draw(at: NSPoint(x: c.x - size.width / 2, y: c.y - size.height / 2), + withAttributes: attrs) + return + } + context.saveGState() let shadow = NSShadow() shadow.shadowColor = NSColor.black.withAlphaComponent(0.55) @@ -550,6 +582,14 @@ ring.lineWidth = 3 ring.stroke() } + if ProcessInfo.processInfo.systemUptime < detachDeniedUntil { + Palette.coral.withAlphaComponent(0.9).setStroke() + let denied = NSBezierPath(ovalIn: NSRect(x: c.x - r + 2, y: c.y - r + 2, + width: r * 2 - 4, height: r * 2 - 4)) + denied.lineWidth = 5 + denied.stroke() + } + let attrs: [NSAttributedString.Key: Any] = [ .font: NSFont.systemFont(ofSize: max(15, r * 0.20), weight: .black), .foregroundColor: NSColor.white @@ -565,6 +605,7 @@ return atan2(point.y - center.y, point.x - center.x) } override func mouseDown(with event: NSEvent) { + guard deck?.track != nil else { return } guard hypot(convert(event.locationInWindow, from: nil).x - center.x, convert(event.locationInWindow, from: nil).y - center.y) <= radius else { return } window?.makeFirstResponder(self) @@ -573,6 +614,8 @@ lastAngle = angle(for: event) lastTimestamp = event.timestamp scratchOrigin = deck?.currentTime ?? 0 scratchOffset = 0 + dragStartScreen = NSEvent.mouseLocation + detachGestureResolved = false deck?.beginScratch() scratchIdleTimer?.invalidate() scratchIdleTimer = DJRunLoopTimer.scheduled(every: 0.02) { [weak self] _ in @@ -582,6 +625,32 @@ } } override func mouseDragged(with event: NSEvent) { + if !detachGestureResolved, let start = dragStartScreen { + let screen = NSEvent.mouseLocation + let travel = hypot(screen.x - start.x, screen.y - start.y) + let local = convert(event.locationInWindow, from: nil) + let outsideBed = hypot(local.x - center.x, local.y - center.y) > radius * 1.08 + if travel > max(52, radius * 0.46), outsideBed { + detachGestureResolved = true + scratchIdleTimer?.invalidate() + scratchIdleTimer = nil + deck?.endScratch() + lastAngle = nil + lastTimestamp = nil + if canDetachRecord { + onDetachRequested?(screen) + } else { + detachDeniedUntil = ProcessInfo.processInfo.systemUptime + 0.42 + NSSound.beep() + needsDisplay = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in + self?.needsDisplay = true + } + } + return + } + } + guard !detachGestureResolved else { return } guard let prior = lastAngle else { return } let next = angle(for: event) var delta = next - prior @@ -601,7 +670,9 @@ lastAngle = nil lastTimestamp = nil scratchIdleTimer?.invalidate() scratchIdleTimer = nil - deck?.endScratch() + if !detachGestureResolved { deck?.endScratch() } + dragStartScreen = nil + detachGestureResolved = false NSCursor.openHand.set() } } @@ -704,6 +775,13 @@ } } } + func clear() { + loadToken += 1 + peaks = [] + peakDuration = 0 + needsDisplay = true + } + override func draw(_ dirtyRect: NSRect) { let dark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua updateColors(dark: dark) @@ -1053,7 +1131,7 @@ let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: solo ? 150 : 300, height: 400), styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) - window.title = "JukeWizard · Alignment" + window.title = "Menu Band Juke · Alignment" window.titleVisibility = .hidden window.titlebarAppearsTransparent = true window.isMovableByWindowBackground = true @@ -1112,7 +1190,7 @@ } } final class DJDeckView: NSView { - let deck = DJDeckPlayer() + private(set) var deck = DJDeckPlayer() let platter = DJPlatterView(frame: .zero) private let deckLabel: NSTextField private let trackPopup = NSPopUpButton(frame: .zero, pullsDown: false) @@ -1127,7 +1205,16 @@ private var tracks: [Track] = [] var onStateChange: (() -> Void)? var onTrackLoaded: ((Track) -> Void)? var onSync: (() -> Void)? - var onPopout: (() -> Void)? + var onDetach: ((DJDeckPlayer, Track, NSPoint) -> Void)? + var canDetachRecord = true { + didSet { + platter.canDetachRecord = canDetachRecord + popoutButton.isHidden = !canDetachRecord || deck.track == nil + platter.setAccessibilityHelp(canDetachRecord + ? "Turn the record to scratch, or pull it off the bed to float it" + : "Turn the record to scratch. This source must stay on the main deck") + } + } init(name: String, accent: NSColor) { deckLabel = NSTextField(labelWithString: name) @@ -1171,10 +1258,15 @@ popoutButton.target = self popoutButton.action = #selector(popout) popoutButton.bezelStyle = .rounded popoutButton.contentTintColor = accent - popoutButton.toolTip = "Float this record as its own scratch deck" + popoutButton.toolTip = "Pull this record off the bed into a floating deck" + platter.onDetachRequested = { [weak self] point in _ = self?.detachRecord(at: point) } [deckLabel, trackPopup, platter, playButton, bpmSlider, bpmLabel, timeLabel, syncButton, resetButton, popoutButton].forEach(addSubview) + installDeckCallback() + } + + private func installDeckCallback() { deck.onStateChange = { [weak self] in self?.refresh() self?.onStateChange?() @@ -1206,11 +1298,33 @@ load(index) if autoplay { deck.play() } } + func load(_ track: Track, autoplay: Bool) { + if let index = tracks.firstIndex(where: { $0.url == track.url }) { + trackPopup.selectItem(at: index) + } else { + tracks.append(track) + trackPopup.addItem(withTitle: "\(track.title) — \(track.lane)") + trackPopup.selectItem(at: tracks.count - 1) + } + deck.load(track) + onTrackLoaded?(track) + bpmSlider.minValue = deck.sourceBPM * 0.5 + bpmSlider.maxValue = deck.sourceBPM * 1.5 + if autoplay { deck.play() } + refresh() + } + func refresh() { + let hasTrack = deck.track != nil playButton.title = deck.isPlaying ? "❚❚" : "▶" - bpmLabel.stringValue = String(format: "%.1f BPM", deck.targetBPM) + bpmLabel.stringValue = hasTrack ? String(format: "%.1f BPM", deck.targetBPM) : "— BPM" bpmSlider.doubleValue = deck.targetBPM timeLabel.stringValue = "\(JukeController.mmss(deck.currentTime)) / \(JukeController.mmss(deck.duration))" + playButton.isEnabled = hasTrack + bpmSlider.isEnabled = hasTrack + syncButton.isEnabled = hasTrack + resetButton.isEnabled = hasTrack + popoutButton.isHidden = !canDetachRecord || !hasTrack platter.needsDisplay = true } @@ -1252,7 +1366,70 @@ @objc private func togglePlay() { deck.toggle() } @objc private func bpmChanged() { deck.setBPM(bpmSlider.doubleValue); refresh() } @objc private func sync() { onSync?() } @objc private func resetBPM() { deck.resetBPM(); refresh() } - @objc private func popout() { onPopout?() } + @objc private func popout() { + let point = window.map { NSPoint(x: $0.frame.midX, y: $0.frame.midY) } ?? NSEvent.mouseLocation + _ = detachRecord(at: point) + } + + @discardableResult + func detachRecord(at point: NSPoint) -> Bool { + guard canDetachRecord, let track = deck.track else { return false } + let floatingDeck = deck + floatingDeck.endScratch() + floatingDeck.onStateChange = nil + deck = DJDeckPlayer() + platter.deck = deck + installDeckCallback() + trackPopup.selectItem(at: -1) + refresh() + onDetach?(floatingDeck, track, point) + return true + } +} + +/// One compact channel in the main-window mixer for a record that has been +/// pulled into its own window. Floating records remain playable directly; +/// this strip keeps their transport and level reachable from the selector. +final class DJDetachedChannelView: NSView { + weak var controller: DJPopoutDeckController? + private let playButton = NSButton(title: "❚❚", target: nil, action: nil) + private let titleLabel = NSTextField(labelWithString: "") + private let levelSlider = NSSlider(value: 1, minValue: 0, maxValue: 1, + target: nil, action: nil) + + init(controller: DJPopoutDeckController) { + self.controller = controller + super.init(frame: .zero) + wantsLayer = true + layer?.cornerRadius = 7 + layer?.backgroundColor = NSColor.black.withAlphaComponent(0.14).cgColor + titleLabel.font = .systemFont(ofSize: 10, weight: .semibold) + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.stringValue = controller.trackTitle + playButton.bezelStyle = .inline + playButton.target = self + playButton.action = #selector(toggle) + playButton.toolTip = "Play or pause this floating record" + levelSlider.controlSize = .mini + levelSlider.isContinuous = true + levelSlider.target = self + levelSlider.action = #selector(levelChanged) + levelSlider.toolTip = "Floating record level" + [titleLabel, playButton, levelSlider].forEach(addSubview) + refresh() + } + required init?(coder: NSCoder) { fatalError() } + + override func layout() { + playButton.frame = NSRect(x: 4, y: 4, width: 30, height: bounds.height - 8) + titleLabel.frame = NSRect(x: 38, y: bounds.height - 17, + width: max(30, bounds.width - 44), height: 14) + levelSlider.frame = NSRect(x: 38, y: 2, width: max(30, bounds.width - 44), height: 14) + } + + func refresh() { playButton.title = controller?.isPlaying == true ? "❚❚" : "▶" } + @objc private func toggle() { controller?.toggle(); refresh() } + @objc private func levelChanged() { controller?.setChannelGain(levelSlider.floatValue) } } final class DJMixerView: NSView { @@ -1265,6 +1442,8 @@ private let waveformB = DJWaveformOutputView(frame: .zero) private let crossfader = NSSlider(value: 0, minValue: -1, maxValue: 1, target: nil, action: nil) private let crossLabel = NSTextField(labelWithString: "A 50 · 50 B") private let practiceButton = NSButton(title: "PRIMPATS", target: nil, action: nil) + private let detachedScroll = NSScrollView(frame: .zero) + private let detachedRack = NSStackView(frame: .zero) private var displayTimer: Timer? private var availableTracks: [Track] = [] private var primpatCount = 0 @@ -1278,12 +1457,18 @@ private var popoutD: DJPopoutDeckController? private var alignmentPopout: DJAlignmentWindowController? private var rateSyncTimer: Timer? private var peakAlignTimer: Timer? + private var detachedDecks: [DJPopoutDeckController] = [] + private var detachedChannels: [DJDetachedChannelView] = [] + private var detachedSerial = 0 private(set) var masterVolume: Float = 0.8 var onStateChange: (() -> Void)? var onDetach: (() -> Void)? private var deckAppearance: NSAppearance? - var isPlaying: Bool { deckA.deck.isPlaying || deckB.deck.isPlaying || deckC.isPlaying || deckD.isPlaying } + var isPlaying: Bool { + deckA.deck.isPlaying || deckB.deck.isPlaying || deckC.isPlaying || deckD.isPlaying + || detachedDecks.contains(where: { $0.isPlaying }) + } var dominantDeck: DJDeckView { crossfader.doubleValue <= 0 ? deckA : deckB } var dominantTitle: String { dominantDeck.deck.track?.title ?? "DJ Mix" } var dominantBPM: Double { dominantDeck.deck.targetBPM } @@ -1309,7 +1494,19 @@ waveformB.deck = deckB.deck waveformB.accent = Palette.coral waveformB.deckName = "B" waveformB.setAccessibilityLabel("Deck B output waveform") - [deckA, deckB, waveformA, waveformB, crossfader, crossLabel, practiceButton].forEach(addSubview) + detachedRack.orientation = .horizontal + detachedRack.alignment = .centerY + detachedRack.spacing = 6 + detachedScroll.documentView = detachedRack + detachedScroll.hasHorizontalScroller = true + detachedScroll.hasVerticalScroller = false + detachedScroll.autohidesScrollers = true + detachedScroll.scrollerStyle = .overlay + detachedScroll.drawsBackground = false + detachedScroll.borderType = .noBorder + detachedScroll.isHidden = true + [deckA, deckB, waveformA, waveformB, crossfader, crossLabel, + practiceButton, detachedScroll].forEach(addSubview) deckA.onStateChange = { [weak self] in self?.onStateChange?() } deckB.onStateChange = { [weak self] in self?.onStateChange?() } deckA.onTrackLoaded = { [weak self] track in @@ -1324,8 +1521,16 @@ self?.alignmentPopout?.trackChanged(track, at: 1) } deckA.onSync = { [weak self] in self?.sync(self?.deckA, to: self?.deckB) } deckB.onSync = { [weak self] in self?.sync(self?.deckB, to: self?.deckA) } - deckA.onPopout = { [weak self] in self?.showPopoutA() } - deckB.onPopout = { [weak self] in self?.showPopoutB() } + deckA.onDetach = { [weak self] deck, track, point in + self?.waveformA.deck = self?.deckA.deck + self?.waveformA.clear() + self?.float(deck: deck, track: track, near: point) + } + deckB.onDetach = { [weak self] deck, track, point in + self?.waveformB.deck = self?.deckB.deck + self?.waveformB.clear() + self?.float(deck: deck, track: track, near: point) + } applyCrossfade() } required init?(coder: NSCoder) { fatalError() } @@ -1337,6 +1542,7 @@ } func configure(tracks: [Track], primaryIndex: Int) { soloMode = false + updateSoloVisibility() let primpats = DJPrimpats.makeTracks() let practice = DJPracticeTracks.make() primpatCount = primpats.count @@ -1354,6 +1560,7 @@ } func configureSolo(tracks: [Track], primaryIndex: Int) { soloMode = true + updateSoloVisibility() let primpats = DJPrimpats.makeTracks() let practice = DJPracticeTracks.make() primpatCount = primpats.count @@ -1377,17 +1584,32 @@ if !self.soloMode { self.deckB.refresh() self.waveformB.needsDisplay = true } + self.detachedChannels.forEach { $0.refresh() } } } func stopDisplay() { displayTimer?.invalidate(); displayTimer = nil } - func pauseAll() { deckA.deck.pause(); deckB.deck.pause(); deckC.pause(); deckD.pause() } + func pauseAll() { + deckA.deck.pause(); deckB.deck.pause(); deckC.pause(); deckD.pause() + detachedDecks.forEach { $0.pause() } + } func toggleDominant() { dominantDeck.deck.toggle() } func stepDominant(by offset: Int) { dominantDeck.step(by: offset) } + func loadPrimary(_ track: Track, autoplay: Bool = true) { deckA.load(track, autoplay: autoplay) } + @discardableResult + func detachPrimary() -> Bool { + let point = window.map { NSPoint(x: $0.frame.midX, y: $0.frame.midY) } ?? NSEvent.mouseLocation + return deckA.detachRecord(at: point) + } + func setRecordDetachmentAllowed(_ allowed: Bool) { + deckA.canDetachRecord = allowed + deckB.canDetachRecord = allowed + } func setMasterVolume(_ value: Float) { masterVolume = max(0, min(1, value)) applyCrossfade() + detachedDecks.forEach { $0.setMasterGain(masterVolume) } } func setAppearance(_ appearance: NSAppearance?) { @@ -1397,11 +1619,55 @@ popoutB?.window?.appearance = appearance popoutC?.window?.appearance = appearance popoutD?.window?.appearance = appearance alignmentPopout?.window?.appearance = appearance + detachedDecks.forEach { $0.window?.appearance = appearance } popoutA?.window?.contentView?.needsDisplay = true popoutB?.window?.contentView?.needsDisplay = true popoutC?.window?.contentView?.needsDisplay = true popoutD?.window?.contentView?.needsDisplay = true alignmentPopout?.window?.contentView?.needsDisplay = true + } + + private func updateSoloVisibility() { + deckB.isHidden = soloMode + waveformB.isHidden = soloMode + crossfader.isHidden = soloMode + crossLabel.isHidden = soloMode + } + + private func float(deck: DJDeckPlayer, track: Track, near point: NSPoint) { + detachedSerial += 1 + let controller = DJPopoutDeckController( + deck: deck, name: String(detachedSerial), accent: Palette.teal) + controller.setMasterGain(masterVolume) + controller.window?.appearance = deckAppearance + controller.trackChanged(track) + let channel = DJDetachedChannelView(controller: controller) + channel.translatesAutoresizingMaskIntoConstraints = false + channel.widthAnchor.constraint(equalToConstant: 148).isActive = true + channel.heightAnchor.constraint(equalToConstant: 38).isActive = true + controller.onStateChange = { [weak self, weak channel] in + channel?.refresh() + self?.onStateChange?() + } + controller.onClose = { [weak self, weak controller] in + guard let self, let controller else { return } + self.detachedDecks.removeAll { $0 === controller } + if let index = self.detachedChannels.firstIndex(where: { $0.controller === controller }) { + let channel = self.detachedChannels.remove(at: index) + self.detachedRack.removeArrangedSubview(channel) + channel.removeFromSuperview() + } + self.detachedScroll.isHidden = self.detachedChannels.isEmpty + self.needsLayout = true + self.onStateChange?() + } + detachedDecks.append(controller) + detachedChannels.append(channel) + detachedRack.addArrangedSubview(channel) + detachedScroll.isHidden = false + controller.show(track: track, near: point) + needsLayout = true + onStateChange?() } @objc func loadPractice() { loadPrimpats() } @@ -1657,6 +1923,21 @@ let crossHeight: CGFloat = 52 let waveHeight: CGFloat = min(104, max(76, bounds.height * 0.22)) let waveRow = (waveHeight - 4) / 2 let waveBottom = crossHeight + gap + if soloMode { + waveformA.frame = NSRect(x: pad, y: crossHeight, width: bounds.width - pad * 2, + height: waveHeight) + deckA.frame = NSRect(x: pad, y: crossHeight + waveHeight + gap, + width: bounds.width - pad * 2, + height: max(160, bounds.height - crossHeight - waveHeight - gap)) + practiceButton.frame = NSRect(x: pad, y: 8, width: 86, height: 26) + detachedScroll.frame = NSRect(x: 98, y: 3, width: max(0, bounds.width - 102), height: 44) + detachedRack.frame = NSRect(x: 0, y: 0, + width: max(detachedScroll.bounds.width, + CGFloat(detachedChannels.count) * 154), + height: 38) + return + } + detachedScroll.frame = .zero waveformB.frame = NSRect(x: pad, y: waveBottom, width: bounds.width - pad * 2, height: waveRow) waveformA.frame = NSRect(x: pad, y: waveBottom + waveRow + 4, width: bounds.width - pad * 2, height: waveRow) diff --git a/juke-wizard/Sources/JukeWizard/DJPopoutDeck.swift b/juke-wizard/Sources/JukeWizard/DJPopoutDeck.swift --- a/juke-wizard/Sources/JukeWizard/DJPopoutDeck.swift +++ b/juke-wizard/Sources/JukeWizard/DJPopoutDeck.swift @@ -8,11 +8,19 @@ /// control: press to stop it under the hand, drag around the groove to scratch, /// and release to resume the state it had before the touch. final class DJPopoutDeckController: NSWindowController, NSWindowDelegate { private let recordView: DJRadialRecordView + private let player: DJDeckPlayer private let deckName: String private var displayTimer: Timer? private var hasPositioned = false + private var masterGain: Float = 0.8 + private var channelGain: Float = 1 + private(set) var trackTitle = "record" + var onClose: (() -> Void)? + var onStateChange: (() -> Void)? + var isPlaying: Bool { player.isPlaying } init(deck: DJDeckPlayer, name: String, accent: NSColor) { + player = deck deckName = name recordView = DJRadialRecordView(frame: NSRect(x: 0, y: 0, width: 350, height: 350)) recordView.deck = deck @@ -25,7 +33,7 @@ styleMask: [.borderless], backing: .buffered, defer: false ) - window.title = "JukeWizard · Deck \(name)" + window.title = "Menu Band Juke · Deck \(name)" window.isMovableByWindowBackground = true window.level = .floating window.collectionBehavior = [.fullScreenAuxiliary, .moveToActiveSpace] @@ -38,6 +46,7 @@ super.init(window: window) window.delegate = self recordView.onClose = { [weak window] in window?.performClose(nil) } + player.onStateChange = { [weak self] in self?.onStateChange?() } } required init?(coder: NSCoder) { fatalError() } @@ -45,16 +54,46 @@ deinit { displayTimer?.invalidate() } func show(track: Track?) { if let track { recordView.load(track) } - window?.title = "JukeWizard · \(deckName) · \(track?.title ?? "record")" + trackTitle = track?.title ?? "record" + window?.title = "Menu Band Juke · \(deckName) · \(track?.title ?? "record")" positionOnce() showWindow(nil) window?.orderFrontRegardless() startDisplay() } + func show(track: Track?, near point: NSPoint) { + if let track { recordView.load(track) } + trackTitle = track?.title ?? "record" + window?.title = "Menu Band Juke · \(deckName) · \(track?.title ?? "record")" + showWindow(nil) + if let window { + let origin = NSPoint(x: point.x - window.frame.width / 2, + y: point.y - window.frame.height / 2) + window.setFrameOrigin(origin) + } + window?.orderFrontRegardless() + hasPositioned = true + startDisplay() + } + + func setMasterGain(_ gain: Float) { + masterGain = max(0, min(1, gain)) + applyGain() + } + func setChannelGain(_ gain: Float) { + channelGain = max(0, min(1, gain)) + applyGain() + } + func toggle() { player.toggle() } + func pause() { player.pause() } + + private func applyGain() { player.setGain(masterGain * channelGain) } + func trackChanged(_ track: Track) { + trackTitle = track.title recordView.load(track) - window?.title = "JukeWizard · \(deckName) · \(track.title)" + window?.title = "Menu Band Juke · \(deckName) · \(track.title)" } private func startDisplay() { @@ -83,9 +122,11 @@ window.setFrameOrigin(origin) } func windowWillClose(_ notification: Notification) { + player.pause() recordView.cancelTrackpadLock() displayTimer?.invalidate() displayTimer = nil + onClose?() } func windowDidResignKey(_ notification: Notification) { diff --git a/juke-wizard/Sources/JukeWizard/DJPrimpats.swift b/juke-wizard/Sources/JukeWizard/DJPrimpats.swift --- a/juke-wizard/Sources/JukeWizard/DJPrimpats.swift +++ b/juke-wizard/Sources/JukeWizard/DJPrimpats.swift @@ -56,7 +56,7 @@ catalog.compactMap { metadata in guard let url = render(metadata) else { return nil } let track = Track(url: url, lane: "primpats", title: metadata.title) track.meta = TrackMeta( - artist: "JukeWizard", + artist: "Menu Band Juke", backend: "Primpats local \(metadata.waveform.rawValue) synthesis · \(frequencyLabel(metadata.frequency)) Hz", status: "PRIMPAT", updated: nil, diff --git a/juke-wizard/Sources/JukeWizard/DockIcon.swift b/juke-wizard/Sources/JukeWizard/DockIcon.swift deleted file mode 100644 --- a/juke-wizard/Sources/JukeWizard/DockIcon.swift +++ /dev/null @@ -1,95 +0,0 @@ -// DockIcon.swift — artwork-backed JukeWizard Dock record. Cover art is -// composed into a classic CD once per track; Core Animation rotates that -// cached image in the Dock compositor, with no per-frame AppKit redraw. -import AppKit -import QuartzCore - -private final class DockRecordView: NSView { - private let recordLayer = CALayer() - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - wantsLayer = true - layer?.backgroundColor = NSColor.clear.cgColor - recordLayer.frame = bounds - recordLayer.contentsGravity = .resizeAspect - recordLayer.magnificationFilter = .trilinear - recordLayer.minificationFilter = .trilinear - layer?.addSublayer(recordLayer) - } - required init?(coder: NSCoder) { fatalError() } - - override func layout() { recordLayer.frame = bounds } - - func set(image: NSImage, playing: Bool, bpm: Double) { - recordLayer.contents = image.cgImage(forProposedRect: nil, context: nil, hints: nil) - recordLayer.removeAnimation(forKey: "record-spin") - recordLayer.transform = CATransform3DIdentity - guard playing else { return } - let spin = CABasicAnimation(keyPath: "transform.rotation.z") - spin.fromValue = 0 - spin.toValue = -Double.pi * 2 - spin.duration = (60.0 / bpm) * 8.0 - spin.repeatCount = .infinity - spin.timingFunction = CAMediaTimingFunction(name: .linear) - spin.isRemovedOnCompletion = false - recordLayer.add(spin, forKey: "record-spin") - } -} - -enum DockIcon { - private static var fallbackImage: NSImage? - private static var artwork: NSImage? - private static var recordImage: NSImage? - private static var bpm: Double = 120 - private static var isPlaying = false - private static let recordView = DockRecordView(frame: NSRect(x: 0, y: 0, width: 256, height: 256)) - - static func install(prefix: String) { - apply(prefix: prefix) - DistributedNotificationCenter.default().addObserver( - forName: NSNotification.Name("AppleInterfaceThemeChangedNotification"), - object: nil, queue: .main - ) { _ in if artwork == nil { apply(prefix: prefix) } } - } - - static func setNowPlaying(art: NSImage?, playing: Bool, bpm newBPM: Double? = nil) { - let artChanged = artwork !== art - let playingChanged = isPlaying != playing - let nextBPM = min(200, max(40, newBPM ?? 120)) - let tempoChanged = abs(nextBPM - bpm) > 0.01 - artwork = art - isPlaying = playing - bpm = nextBPM - if artChanged { - recordImage = art.map { CDArtworkRenderer.disc(from: $0, side: 256, shadow: true) } - } - guard artChanged || playingChanged || tempoChanged else { return } - guard let recordImage else { - NSApp.dockTile.contentView = nil - if let fallbackImage { NSApp.applicationIconImage = fallbackImage } - NSApp.dockTile.display() - return - } - recordView.set(image: recordImage, playing: playing, bpm: bpm) - NSApp.dockTile.contentView = recordView - NSApp.dockTile.display() - } - - private static func apply(prefix: String) { - let dark = NSApp.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua - let variant = dark ? "dark" : "light" - let bundle = Bundle.module - let image = bundle.url(forResource: "\(prefix)-icon-\(variant)", - withExtension: "png", subdirectory: "Assets") - .flatMap { NSImage(contentsOf: $0) } - ?? bundle.url(forResource: "\(prefix)-icon-\(variant)", withExtension: "png") - .flatMap { NSImage(contentsOf: $0) } - ?? bundle.url(forResource: "\(prefix)-mascot", withExtension: "png", subdirectory: "Assets") - .flatMap { NSImage(contentsOf: $0) } - if let image { - fallbackImage = image - if artwork == nil { NSApp.applicationIconImage = image } - } - } -} diff --git a/juke-wizard/Sources/JukeWizard/JukeAppleMusic.swift b/juke-wizard/Sources/JukeWizard/JukeAppleMusic.swift new file mode 100644 --- /dev/null +++ b/juke-wizard/Sources/JukeWizard/JukeAppleMusic.swift @@ -0,0 +1,145 @@ +import AppKit +import Foundation +import MusicKit + +struct AppleMusicTrackResult { + let title: String + let artist: String + let album: String + let duration: Double + let artworkURL: URL? + fileprivate let song: Song +} + +@available(macOS 12.0, *) +final class JukeAppleMusic { + enum AppleMusicError: LocalizedError { + case denied + + var errorDescription: String? { + switch self { + case .denied: return "Apple Music access was not granted." + } + } + } + + var authorizationStatus: MusicAuthorization.Status { MusicAuthorization.currentStatus } + + func authorize() async throws { + guard await MusicAuthorization.request() == .authorized else { + throw AppleMusicError.denied + } + } + + func search(_ query: String) async throws -> [AppleMusicTrackResult] { + if MusicAuthorization.currentStatus != .authorized { try await authorize() } + var request = MusicCatalogSearchRequest(term: query, types: [Song.self]) + request.limit = 30 + let response = try await request.response() + return response.songs.map { song in + AppleMusicTrackResult( + title: song.title, + artist: song.artistName, + album: song.albumTitle ?? "", + duration: song.duration ?? 0, + artworkURL: song.artwork?.url(width: 512, height: 512), + song: song) + } + } + + @available(macOS 14.0, *) + func play(_ result: AppleMusicTrackResult) async throws { + let player = ApplicationMusicPlayer.shared + player.queue = ApplicationMusicPlayer.Queue(for: [result.song]) + try await player.play() + } + + @available(macOS 14.0, *) + func pause() { ApplicationMusicPlayer.shared.pause() } + + @available(macOS 14.0, *) + func toggle() async throws { + let player = ApplicationMusicPlayer.shared + if player.state.playbackStatus == .playing { player.pause() } + else { try await player.play() } + } +} + +final class AppleMusicTrackRowView: NSTableCellView { + static let id = NSUserInterfaceItemIdentifier("apple-music-track-row") + private let titleField = NSTextField(labelWithString: "") + private let detailField = NSTextField(labelWithString: "") + private let durationField = NSTextField(labelWithString: "") + var selected = false { didSet { needsDisplay = true } } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + titleField.font = .systemFont(ofSize: 13, weight: .semibold) + titleField.lineBreakMode = .byTruncatingTail + detailField.font = .systemFont(ofSize: 11) + detailField.textColor = .secondaryLabelColor + detailField.lineBreakMode = .byTruncatingTail + durationField.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) + durationField.textColor = .secondaryLabelColor + durationField.alignment = .right + [titleField, detailField, durationField].forEach(addSubview) + } + required init?(coder: NSCoder) { fatalError() } + + func configure(_ track: AppleMusicTrackResult) { + titleField.stringValue = track.title + detailField.stringValue = [track.artist, track.album].filter { !$0.isEmpty }.joined(separator: " · ") + durationField.stringValue = JukeController.mmss(track.duration) + } + + override func layout() { + titleField.frame = NSRect(x: 12, y: bounds.height - 23, width: bounds.width - 80, height: 18) + detailField.frame = NSRect(x: 12, y: 5, width: bounds.width - 80, height: 15) + durationField.frame = NSRect(x: bounds.width - 63, y: 13, width: 51, height: 16) + } + + override func draw(_ dirtyRect: NSRect) { + if selected { + NSColor.white.withAlphaComponent(0.16).setFill() + NSBezierPath(roundedRect: bounds.insetBy(dx: 4, dy: 2), xRadius: 6, yRadius: 6).fill() + } + super.draw(dirtyRect) + } +} + +final class AestheticCloudRowView: NSTableCellView { + static let id = NSUserInterfaceItemIdentifier("aesthetic-cloud-track-row") + private let titleField = NSTextField(labelWithString: "") + private let detailField = NSTextField(labelWithString: "") + var selected = false { didSet { needsDisplay = true } } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + titleField.font = .systemFont(ofSize: 13, weight: .semibold) + titleField.lineBreakMode = .byTruncatingTail + detailField.font = .systemFont(ofSize: 11) + detailField.textColor = .secondaryLabelColor + addSubview(titleField); addSubview(detailField) + } + required init?(coder: NSCoder) { fatalError() } + + func configure(_ track: JukeCloudTrack) { + titleField.stringValue = track.name + detailField.stringValue = ByteCountFormatter.string(fromByteCount: track.bytes, countStyle: .file) + } + + override func layout() { + titleField.frame = NSRect(x: 12, y: bounds.height - 23, width: bounds.width - 24, height: 18) + detailField.frame = NSRect(x: 12, y: 5, width: bounds.width - 24, height: 15) + } + + override func draw(_ dirtyRect: NSRect) { + if selected { + Palette.coral.withAlphaComponent(0.20).setFill() + NSBezierPath(roundedRect: bounds.insetBy(dx: 4, dy: 2), xRadius: 6, yRadius: 6).fill() + } + super.draw(dirtyRect) + } +} diff --git a/juke-wizard/Sources/JukeWizard/JukeCloud.swift b/juke-wizard/Sources/JukeWizard/JukeCloud.swift --- a/juke-wizard/Sources/JukeWizard/JukeCloud.swift +++ b/juke-wizard/Sources/JukeWizard/JukeCloud.swift @@ -194,7 +194,7 @@ scroll.frame = NSRect(x: 12, y: 54, width: 556, height: 286) scroll.autoresizingMask = [.width, .height] content.addSubview(scroll) - configure(loadButton, "Load in JukeWizard", #selector(loadAction)) + configure(loadButton, "Load in Menu Band Juke", #selector(loadAction)) configure(copyButton, "Copy play command", #selector(copyAction)) loadButton.frame = NSRect(x: 12, y: 12, width: 144, height: 30) copyButton.frame = NSRect(x: 160, y: 12, width: 138, height: 30) diff --git a/juke-wizard/Sources/JukeWizard/JukeController.swift b/juke-wizard/Sources/JukeWizard/JukeController.swift --- a/juke-wizard/Sources/JukeWizard/JukeController.swift +++ b/juke-wizard/Sources/JukeWizard/JukeController.swift @@ -12,8 +12,8 @@ // watched folder it's added to the queue and starts playing, window to // front — so a new render announces itself. import AppKit -// the JukeWizard palette — pulled from the mascot illy: deep teal robe, -// buttery gold star, coral hat-band, warm cream ground. +// Juke keeps its teal / gold / coral identity, now as translucent washes over +// the same liquid material used by Menu Band. enum Palette { static let teal = NSColor(srgbRed: 0.10, green: 0.52, blue: 0.55, alpha: 1) static let gold = NSColor(srgbRed: 0.95, green: 0.74, blue: 0.20, alpha: 1) @@ -21,7 +21,9 @@ static let coral = NSColor(srgbRed: 0.95, green: 0.45, blue: 0.38, alpha: 1) static let cream = NSColor(srgbRed: 0.99, green: 0.97, blue: 0.91, alpha: 1) static let inkDim = NSColor(srgbRed: 0.42, green: 0.40, blue: 0.36, alpha: 1) static func bg(_ dark: Bool) -> NSColor { - dark ? NSColor(srgbRed: 0.10, green: 0.12, blue: 0.13, alpha: 1) : cream + dark + ? NSColor(srgbRed: 0.04, green: 0.12, blue: 0.14, alpha: 0.10) + : NSColor(srgbRed: 0.92, green: 0.98, blue: 1.00, alpha: 0.04) } static func deckSurface(_ accent: NSColor, dark: Bool, alpha: CGFloat = 1) -> NSColor { let base = dark ? NSColor(white: 0.025, alpha: 1) : cream @@ -44,7 +46,6 @@ let selectPath: String? var playlistName: String? let fullLibraryPath: String var current: Int = -1 - var menuBar: MenuBarCD? var watchTimer: Timer? var activityTimer: Timer? var activityPollInFlight = false @@ -117,13 +118,17 @@ var appearanceTabs: NSSegmentedControl! var outputPopup: AudioOutputPopUpButton! var outputDevices: [MacAudioOutput.Device] = [] var playButton: NSButton! + var speedSlider: NSSlider! + var speedLabel: NSTextField! + var speedResetButton: NSButton! var ledLabel: NSTextField! var notesToggle: NSButton! - var djButton: NSButton! - var cloudButton: NSButton! + var sourceActionButton: NSButton! var cloudWindow: JukeCloudWindowController? var djMixer: DJMixerView! + var providerDeck: JukeProviderDeckView! var djMode = false + var djConfigured = false var roomButton: NSButton! var djButton: NSButton! var djMixer: DJMixerView! @@ -136,12 +141,23 @@ var miniPopover: NSPopover? var miniPlayer: JukeMiniPlayerView? let roomAudio = JukeRoomAudio() let spotify = JukeSpotify() + let cloud = JukeCloudClient() + private lazy var appleMusic = JukeAppleMusic() + var activeSource: JukeSource = .local var spotifyMode = false + var appleMusicMode = false var spotifyResults: [SpotifyTrackResult] = [] var selectedSpotifyRow = -1 var spotifyState: SpotifyPlaybackState? var spotifyArtworkURL: URL? var spotifyArt: NSImage? + var cloudTracks: [JukeCloudTrack] = [] + var selectedCloudRow = -1 + var appleMusicResults: [AppleMusicTrackResult] = [] + var selectedAppleMusicRow = -1 + var appleMusicPlaying = false + var appleMusicArtworkURL: URL? + var appleMusicArt: NSImage? var drawerPanel: NSView! var drawerOpen = false var currentArt: NSImage? @@ -170,11 +186,13 @@ self.selectPath = selectArg self.playlistName = playlistName self.fullLibraryPath = fullLibraryPath let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 820, height: 540), - styleMask: [.titled, .closable, .miniaturizable, .resizable], + contentRect: NSRect(x: 0, y: 0, width: 820, height: 568), + styleMask: [.titled, .closable, .miniaturizable, .resizable, + .fullSizeContentView], backing: .buffered, defer: false) - window.title = "JukeWizard — \(library.tracks.count) tracks" - // JukeWizard is a compact listening utility: keep it visible above + window.title = "" + window.setAccessibilityLabel("Menu Band Juke") + // Juke is a compact Menu Band listening surface: keep it visible above // normal document windows and available across Spaces. Previously it // could fall behind a full-screen development stack while its process // remained healthy, which looked exactly like a crash. @@ -182,9 +200,18 @@ window.level = .floating window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] window.hidesOnDeactivate = false window.isMovableByWindowBackground = true + window.isOpaque = false + window.backgroundColor = .clear + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.titlebarSeparatorStyle = .none + window.hasShadow = true + if let frame = window.contentView?.bounds { + window.contentView = JukeLiquidSurface(frame: frame) + } window.isRestorable = false // don't let AppKit re-select a stale row over our pick window.isReleasedWhenClosed = false // keep it around so the menu-bar CD can reopen it - window.minSize = NSSize(width: 640, height: 420) + window.minSize = NSSize(width: 720, height: 420) window.center() super.init(window: window) window.delegate = self @@ -203,6 +230,11 @@ if UserDefaults.standard.object(forKey: "playerVolume") != nil { quickVolume = UserDefaults.standard.float(forKey: "playerVolume") } wave.volume = quickVolume + if UserDefaults.standard.object(forKey: "listPlaybackRate") != nil { + speedSlider.doubleValue = max(0.5, min(1.5, + UserDefaults.standard.double(forKey: "listPlaybackRate"))) + } + speedLabel.stringValue = String(format: "%.2f×", speedSlider.doubleValue) relayout() // the spinning-CD menu-bar presence (persists when the window is closed) menuBar = MenuBarCD() @@ -274,23 +306,25 @@ return false // esc leaves playlist scope case 49: togglePlay(); return true // space case 123: if djMode { djMixer.dominantDeck.deck.seek(to: djMixer.dominantDeck.deck.currentTime - 5) } - else if spotifyMode { spotify.seek(offsetMS: -5000) } else { wave.seek(to: wave.currentTime - 5) } + else if spotifyMode { spotify.seek(offsetMS: -5000) } + else if !appleMusicMode { wave.seek(to: wave.currentTime - 5) } return true // ← back 5s case 124: if djMode { djMixer.dominantDeck.deck.seek(to: djMixer.dominantDeck.deck.currentTime + 5) } - else if spotifyMode { spotify.seek(offsetMS: 5000) } else { wave.seek(to: wave.currentTime + 5) } + else if spotifyMode { spotify.seek(offsetMS: 5000) } + else if !appleMusicMode { wave.seek(to: wave.currentTime + 5) } return true // → fwd 5s case 126: prevTrack(); return true // ↑ prev track case 125: nextTrack(); return true // ↓ next track case 18, 19, 20, 21, 23: // 1–5 stars let map: [UInt16: Int] = [18: 1, 19: 2, 20: 3, 21: 4, 23: 5] - if !spotifyMode, let n = map[e.keyCode], let t = track { + if activeSource == .local, let n = map[e.keyCode], let t = track { t.data.stars = n; renderStars(n); t.save() listTable.reloadData(forRowIndexes: IndexSet(integer: current), columnIndexes: IndexSet(integer: 0)) } return true - case 8: if !spotifyMode { addCommentNow() }; return true // c comment @ now - case 29: if !spotifyMode { clearStarsClicked() }; return true // 0 clear stars + case 8: if activeSource == .local { addCommentNow() }; return true // c comment @ now + case 29: if activeSource == .local { clearStarsClicked() }; return true // 0 clear stars default: return false } } @@ -342,6 +376,21 @@ playButton = NSButton(title: "▶", target: self, action: #selector(togglePlay)) playButton.bezelStyle = .rounded playButton.setButtonType(.momentaryPushIn) content.addSubview(playButton) + speedSlider = NSSlider(value: 1, minValue: 0.5, maxValue: 1.5, + target: self, action: #selector(speedChanged(_:))) + speedSlider.controlSize = .small + speedSlider.isContinuous = true + speedSlider.toolTip = "Playback speed · drag the waveform to scratch" + content.addSubview(speedSlider) + speedLabel = label("1.00×", size: 11, color: Palette.teal) + speedLabel.font = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .semibold) + speedLabel.alignment = .right + content.addSubview(speedLabel) + speedResetButton = NSButton(title: "↺", target: self, action: #selector(resetSpeed)) + speedResetButton.bezelStyle = .inline + speedResetButton.toolTip = "Reset playback speed" + speedResetButton.contentTintColor = Palette.teal + content.addSubview(speedResetButton) let prev = NSButton(title: "⏮", target: self, action: #selector(prevTrack)) prev.bezelStyle = .rounded; prev.tag = 1 let next = NSButton(title: "⏭", target: self, action: #selector(nextTrack)) @@ -389,10 +438,10 @@ scopeButton.toolTip = "Leave this playlist and show all local tracks (Esc)" content.addSubview(scopeButton) refreshPlaylistScopeButton() - sourceTabs = NSSegmentedControl(labels: ["Spotify", "Aesthetic"], + sourceTabs = NSSegmentedControl(labels: JukeSource.allCases.map { $0.label() }, trackingMode: .selectOne, target: self, action: #selector(sourceTabChanged)) - sourceTabs.selectedSegment = 0 + sourceTabs.selectedSegment = JukeSource.local.rawValue sourceTabs.controlSize = .small content.addSubview(sourceTabs) @@ -401,7 +450,7 @@ trackingMode: .selectOne, target: self, action: #selector(appearanceChanged)) appearanceTabs.selectedSegment = 0 appearanceTabs.controlSize = .small - appearanceTabs.toolTip = "Follow macOS, or pin JukeWizard to light or dark" + appearanceTabs.toolTip = "Follow macOS, or pin Menu Band Juke to light or dark" content.addSubview(appearanceTabs) djButton = NSButton(title: "DJ", target: self, action: #selector(toggleDJMode)) @@ -429,11 +478,11 @@ content.addSubview(outputPopup) reloadOutputDevices() spotifySearchField = NSSearchField(frame: .zero) - spotifySearchField.placeholderString = "Search Spotify" + spotifySearchField.placeholderString = "Search" spotifySearchField.sendsSearchStringImmediately = false spotifySearchField.sendsWholeSearchString = true spotifySearchField.target = self - spotifySearchField.action = #selector(searchSpotifyFromField) + spotifySearchField.action = #selector(searchActiveSourceFromField) content.addSubview(spotifySearchField) listTable = NSTableView() @@ -463,6 +512,15 @@ djMixer.onStateChange = { [weak self] in self?.refreshMenuBar() } djMixer.onDetach = { [weak self] in self?.window?.orderOut(nil) } content.addSubview(djMixer) + providerDeck = JukeProviderDeckView(frame: .zero) + providerDeck.isHidden = true + providerDeck.onToggle = { [weak self] in self?.togglePlay() } + providerDeck.onSeek = { [weak self] target in + guard let self, self.spotifyMode, let state = self.spotifyState else { return } + self.spotify.seek(offsetMS: Int((target - state.position) * 1000)) + } + content.addSubview(providerDeck) + playButton.contentTintColor = Palette.teal transportExtra.forEach { $0.contentTintColor = Palette.teal } } @@ -471,7 +529,7 @@ // The collapsible notes + comments + rating drawer (hidden by default). private func buildDrawer(in content: NSView) { drawerPanel = NSView() drawerPanel.wantsLayer = true - drawerPanel.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.82).cgColor + drawerPanel.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.52).cgColor drawerPanel.layer?.cornerRadius = 10 drawerPanel.layer?.borderWidth = 1 drawerPanel.layer?.borderColor = Palette.teal.withAlphaComponent(0.5).cgColor @@ -541,12 +599,9 @@ notesToggle.state = drawerOpen ? .on : .off relayout() } - @objc private func toggleDJMode() { setDJMode(!djMode) } - private func setDJMode(_ enabled: Bool, singleDeck: Bool = false) { guard enabled != djMode else { return } if enabled { - if spotifyMode { activateLibraryMode() } wave.pause() playButton.title = "▶" nowPlaying.setPaused(true) @@ -554,31 +609,37 @@ if drawerOpen { drawerOpen = false notesToggle.state = .off } - if singleDeck { - djMixer.configureSolo(tracks: library.tracks, primaryIndex: max(0, current)) - } else { - djMixer.configure(tracks: library.tracks, primaryIndex: max(0, current)) + if !djConfigured { + if singleDeck { + djMixer.configureSolo(tracks: library.tracks, primaryIndex: max(0, current)) + } else { + djMixer.configure(tracks: library.tracks, primaryIndex: max(0, current)) + } + djConfigured = true } djMixer.setMasterVolume(quickVolume) djMode = true - djButton.state = .on - djButton.contentTintColor = Palette.coral djMixer.isHidden = false + providerDeck.isHidden = true setPlayerChromeHidden(true) + listScroll.isHidden = false + activityLabel.isHidden = false djMixer.startDisplay() roomAudio.useSource(.aesthetic) window?.isMovableByWindowBackground = false - window?.title = "JukeWizard · DJ" } else { - djMixer.pauseAll() djMixer.stopDisplay() djMixer.isHidden = true djMode = false - djButton.state = .off - djButton.contentTintColor = Palette.teal window?.isMovableByWindowBackground = true - setPlayerChromeHidden(false) - window?.title = spotifyMode ? "JukeWizard · Spotify" : "JukeWizard — \(library.tracks.count) tracks" + if spotifyMode || appleMusicMode { + setPlayerChromeHidden(true) + listScroll.isHidden = false + activityLabel.isHidden = false + spotifySearchField.isHidden = false + } else { + setPlayerChromeHidden(false) + } } relayout() refreshPlaybackPresence() @@ -586,7 +647,8 @@ } private func setPlayerChromeHidden(_ hidden: Bool) { [nowPlaying, titleLabel, artistLabel, laneLabel, activityLabel, playButton, - ledLabel, notesToggle, roomButton, sortPopup, spotifySearchField, + ledLabel, notesToggle, roomButton, speedSlider, speedLabel, speedResetButton, + sortPopup, spotifySearchField, listScroll, wave, spotifyProgress, drawerPanel].forEach { $0?.isHidden = hidden } transportExtra.forEach { $0.isHidden = hidden } linkButtons.forEach { $0.isHidden = hidden } @@ -600,12 +662,16 @@ activityLabel.isHidden = false playButton.isHidden = false ledLabel.isHidden = false roomButton.isHidden = false + let externalSource = spotifyMode || appleMusicMode + speedSlider.isHidden = externalSource + speedLabel.isHidden = externalSource + speedResetButton.isHidden = externalSource listScroll.isHidden = false transportExtra.forEach { $0.isHidden = false } - wave.isHidden = spotifyMode - spotifyProgress.isHidden = !spotifyMode - sortPopup.isHidden = spotifyMode - spotifySearchField.isHidden = !spotifyMode + wave.isHidden = externalSource + spotifyProgress.isHidden = !externalSource + sortPopup.isHidden = externalSource + spotifySearchField.isHidden = !externalSource notesToggle.isHidden = false drawerPanel.isHidden = !drawerOpen if !spotifyMode, let t = track { loadLinks(t) } @@ -614,8 +680,8 @@ private func applyThemeBackground() { let dark = window?.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua let chassis = Palette.bg(dark) - window?.backgroundColor = chassis - window?.contentView?.layer?.backgroundColor = chassis.cgColor + window?.backgroundColor = .clear + (window?.contentView as? JukeLiquidSurface)?.setTint(chassis) window?.contentView?.needsDisplay = true listTable?.reloadData() } @@ -627,6 +693,7 @@ case .light: window?.appearance = NSAppearance(named: .aqua) case .dark: window?.appearance = NSAppearance(named: .darkAqua) } djMixer?.setAppearance(window?.appearance) + providerDeck?.needsDisplay = true applyThemeBackground() } @@ -758,7 +825,7 @@ height: H - topBarH - pad) return } // ── header (now-playing) across the top ─────────────────────────────── - let headerH = max(178, min(245, (H - topBarH) * 0.39)) + let headerH = max(178, min(245, (usableTop - topBarH) * 0.39)) let headerBottom = contentTop - headerH let mediaSide = min(headerH - pad * 2, W * 0.34) nowPlaying.frame = NSRect(x: pad, y: headerBottom + pad, width: mediaSide, height: headerH - pad * 2) @@ -794,14 +861,18 @@ ledLabel.frame = NSRect(x: rx + rw - 135, y: transY + 3, width: 135, height: 20) // waveform fills the space between the links row and the transport let waveTop = linksBottom - 6 - let waveBottom = transY + 31 + let speedY = transY + 30 + speedLabel.frame = NSRect(x: rx, y: speedY + 1, width: 45, height: 18) + speedSlider.frame = NSRect(x: rx + 48, y: speedY, width: max(70, rw - 80), height: 20) + speedResetButton.frame = NSRect(x: rx + rw - 27, y: speedY, width: 27, height: 20) + let waveBottom = speedY + 23 wave.frame = NSRect(x: rx, y: waveBottom, width: rw, height: max(32, waveTop - waveBottom)) spotifyProgress.frame = wave.frame // ── track list underneath ───────────────────────────────────────────── let sortY = headerBottom - 2 - 20 sortPopup.frame = NSRect(x: pad, y: sortY, width: 180, height: 20) - let searchX = spotifyMode ? pad : pad + 184 + let searchX = externalSource ? pad : pad + 184 spotifySearchField.frame = NSRect(x: searchX, y: sortY, width: max(120, W - searchX - pad), height: 20) listScroll.frame = NSRect(x: pad, y: pad, width: W - pad * 2, height: sortY - pad - 3) @@ -852,12 +923,6 @@ menuBar?.setPlaying(playing) menuBar?.setVolume(quickVolume) DockIcon.setNowPlaying(art: currentArt, playing: playing, bpm: bpm) miniPlayer?.refresh() - } - - private static func artwork(for track: Track?) -> NSImage? { - track?.meta?.art.flatMap { - NSImage(contentsOf: URL(fileURLWithPath: ($0 as NSString).expandingTildeInPath)) - } } private func refreshPlaylistScopeButton() { @@ -879,7 +944,6 @@ }) { current = index } listTable.reloadData() - window?.title = "JukeWizard — \(library.tracks.count) tracks" activityLabel.stringValue = "● full Aesthetic library · \(library.tracks.count) tracks" activityLabel.textColor = Palette.teal relayout() @@ -950,66 +1014,372 @@ roomMixer?.show(state, layout: roomAudio.layout, pan: roomAudio.pan) miniPlayer?.refresh() } - // ── headless Spotify source ─────────────────────────────────────────── - private func activateSpotifyMode() { - if djMode { setDJMode(false) } - if wave?.isPlaying == true { wave.pause() } + // ── music sources ───────────────────────────────────────────────────── + private func activateSource(_ source: JukeSource) { + let departingSource = activeSource + if departingSource == .spotify, source != .spotify { spotify.pause() } + if departingSource == .appleMusic, source != .appleMusic { + appleMusicPlaying = false + if #available(macOS 14.0, *) { appleMusic.pause() } + } + if source == .spotify || source == .appleMusic { + djMixer.pauseAll() + } + activeSource = source + spotifyMode = source == .spotify + appleMusicMode = source == .appleMusic + sourceTabs?.selectedSegment = source.rawValue + djMixer?.setRecordDetachmentAllowed(source.canDetachRecords) + if drawerOpen { drawerOpen = false drawerPanel?.isHidden = true notesToggle?.state = .off } - spotifyMode = true - roomAudio.useSource(.spotify) - window?.title = "JukeWizard · Spotify" - wave?.isHidden = true - spotifyProgress?.isHidden = false + + switch source { + case .local: + providerDeck.isHidden = true + sourceActionButton.title = "Add files…" + sourceActionButton.isEnabled = true + spotifySearchField.placeholderString = "Search \(source.label())" + setDJMode(true, singleDeck: true) + activityLabel.stringValue = "\(library.tracks.count) tracks on \(source.label())" + activityLabel.textColor = Palette.gold + listTable.reloadData() + pollActivityStatus() + case .aesthetic: + providerDeck.isHidden = true + sourceActionButton.title = ACSession.shared.token() == nil ? "Sign in" : "Publish…" + sourceActionButton.isEnabled = true + spotifySearchField.placeholderString = "Search Aesthetic releases" + setDJMode(true, singleDeck: true) + activityLabel.stringValue = "Loading Aesthetic releases…" + activityLabel.textColor = Palette.coral + loadAestheticCloud() + case .spotify: + sourceActionButton.title = "Connected" + sourceActionButton.isEnabled = false + spotifySearchField.placeholderString = "Search Spotify" + if djMode { setDJMode(false) } + configureExternalSource() + providerDeck.configure(source: .spotify) + providerDeck.update(title: spotifyState?.title ?? "", + artist: spotifyState?.artists ?? "", + album: spotifyState?.album ?? "", + art: spotifyArt, + duration: spotifyState?.duration ?? 0, + position: spotifyState?.position ?? 0, + playing: spotifyState?.isPlaying ?? false, + canSeek: true) + roomAudio.useSource(.spotify) + currentArt = spotifyArt + activityLabel.stringValue = "● juked headless · connecting" + activityLabel.textColor = Palette.teal + if let state = spotifyState { renderSpotifyState(state) } + case .appleMusic: + sourceActionButton.title = "Connect" + sourceActionButton.isEnabled = true + spotifySearchField.placeholderString = "Search Apple Music" + if djMode { setDJMode(false) } + configureExternalSource() + providerDeck.configure(source: .appleMusic) + let selected = appleMusicResults.indices.contains(selectedAppleMusicRow) + ? appleMusicResults[selectedAppleMusicRow] : nil + providerDeck.update(title: selected?.title ?? "", + artist: selected?.artist ?? "", + album: selected?.album ?? "", + art: appleMusicArt, + duration: selected?.duration ?? 0, + position: 0, + playing: appleMusicPlaying, + canSeek: false) + activityLabel.stringValue = if #available(macOS 14.0, *) { + "● Apple Music · ready to connect" + } else { + "⚠ Apple Music requires macOS 14 or newer" + } + activityLabel.textColor = if #available(macOS 14.0, *) { .labelColor } else { .systemRed } + } + relayout() + refreshPlaybackPresence() + } + + private func configureExternalSource() { + setPlayerChromeHidden(true) + providerDeck?.isHidden = false + listScroll?.isHidden = false + activityLabel?.isHidden = false sortPopup?.isHidden = true spotifySearchField?.isHidden = false notesToggle?.isEnabled = false - sourceTabs?.selectedSegment = 0 - currentArt = spotifyArt - activityLabel?.stringValue = "● juked headless · connecting" - activityLabel?.textColor = Palette.teal listTable?.reloadData() - relayout() - if let state = spotifyState { renderSpotifyState(state) } - else { refreshPlaybackPresence() } } - private func activateLibraryMode() { - if djMode { setDJMode(false) } - if spotifyMode, spotifyState?.isPlaying == true { spotify.pause() } - spotifyMode = false - roomAudio.useSource(.aesthetic) - window?.title = "JukeWizard — \(library.tracks.count) tracks" - wave.isHidden = false - spotifyProgress.isHidden = true - sortPopup.isHidden = false - spotifySearchField.isHidden = true - notesToggle.isEnabled = true - sourceTabs.selectedSegment = 1 - listTable.reloadData() - if let t = track { - titleLabel.stringValue = t.title - artistLabel.stringValue = t.meta?.artist ?? "Aesthetic Dot Computer" - laneLabel.stringValue = Self.metaLine(t) - updateNowPlaying(t); loadLinks(t); updateTime() + private func refreshProviderDeck() { + guard !providerDeck.isHidden else { return } + if spotifyMode { + providerDeck.update(title: spotifyState?.title ?? "", + artist: spotifyState?.artists ?? "", + album: spotifyState?.album ?? "", + art: spotifyArt, + duration: spotifyState?.duration ?? 0, + position: spotifyState?.position ?? 0, + playing: spotifyState?.isPlaying ?? false, + canSeek: true) + } else if appleMusicMode { + let selected = appleMusicResults.indices.contains(selectedAppleMusicRow) + ? appleMusicResults[selectedAppleMusicRow] : nil + providerDeck.update(title: selected?.title ?? "", + artist: selected?.artist ?? "", + album: selected?.album ?? "", + art: appleMusicArt, + duration: selected?.duration ?? 0, + position: 0, + playing: appleMusicPlaying, + canSeek: false) } - playButton.title = wave.isPlaying ? "❚❚" : "▶" - nowPlaying.setPaused(!wave.isPlaying) - relayout() - refreshPlaybackPresence() - pollActivityStatus() } + + private func activateSpotifyMode() { activateSource(.spotify) } + private func activateLibraryMode() { activateSource(.local) } @objc private func sourceTabChanged() { if djMode { setDJMode(false) } sourceTabs.selectedSegment == 0 ? activateSpotifyMode() : activateLibraryMode() } - @objc private func searchSpotifyFromField() { - searchSpotify(spotifySearchField.stringValue) + @objc private func searchActiveSourceFromField() { + switch activeSource { + case .local: break + case .aesthetic: + selectedCloudRow = filteredCloudTracks.isEmpty ? -1 : 0 + activityLabel.stringValue = "\(filteredCloudTracks.count) Aesthetic release\(filteredCloudTracks.count == 1 ? "" : "s")" + listTable.reloadData() + case .spotify: searchSpotify(spotifySearchField.stringValue) + case .appleMusic: searchAppleMusic(spotifySearchField.stringValue) + } + } + + private var filteredCloudTracks: [JukeCloudTrack] { + let query = spotifySearchField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !query.isEmpty else { return cloudTracks } + return cloudTracks.filter { $0.name.localizedCaseInsensitiveContains(query) } + } + + @objc private func sourceActionClicked() { + switch activeSource { + case .local: + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = true + panel.beginSheetModal(for: window!) { [weak self] response in + guard response == .OK, let self else { return } + panel.urls.forEach { self.library.add(path: $0.path) } + self.listTable.reloadData() + self.activityLabel.stringValue = "\(self.library.tracks.count) tracks on \(JukeSource.local.label())" + } + case .aesthetic: + if ACSession.shared.token() == nil { + ACLogin.shared.signIn { [weak self] result in + guard case .success = result else { return } + self?.sourceActionButton.title = "Publish…" + self?.loadAestheticCloud() + } + } else { + publishToAesthetic() + } + case .spotify: + break + case .appleMusic: + connectAppleMusic() + } + } + + private func loadAestheticCloud() { + guard ACSession.shared.token() != nil else { + cloudTracks = [] + selectedCloudRow = -1 + activityLabel.stringValue = "Sign in to browse Aesthetic releases" + listTable.reloadData() + return + } + Task { [weak self] in + guard let self else { return } + do { + let tracks = try await cloud.list() + await MainActor.run { + guard self.activeSource == .aesthetic else { return } + self.cloudTracks = tracks + self.selectedCloudRow = tracks.isEmpty ? -1 : 0 + self.activityLabel.stringValue = tracks.isEmpty + ? "No Aesthetic releases" + : "\(tracks.count) Aesthetic release\(tracks.count == 1 ? "" : "s")" + self.listTable.reloadData() + } + } catch { + await MainActor.run { + self.activityLabel.stringValue = error.localizedDescription + self.activityLabel.textColor = .systemRed + } + } + } + } + + private func publishToAesthetic() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = false + panel.beginSheetModal(for: window!) { [weak self] response in + guard response == .OK, let self else { return } + self.activityLabel.stringValue = "Publishing…" + Task { + do { + for url in panel.urls { _ = try await self.cloud.upload(file: url) } + await MainActor.run { self.loadAestheticCloud() } + } catch { + await MainActor.run { + self.activityLabel.stringValue = error.localizedDescription + self.activityLabel.textColor = .systemRed + } + } + } + } + } + + private func playAestheticCloudResult(at row: Int) { + let visibleTracks = filteredCloudTracks + guard visibleTracks.indices.contains(row) else { return } + selectedCloudRow = row + listTable.reloadData() + let cloudTrack = visibleTracks[row] + activityLabel.stringValue = "Loading \(cloudTrack.name)…" + Task { [weak self] in + guard let self else { return } + do { + let url = try await cloud.download(cloudTrack) + await MainActor.run { + self.library.addFile(url, lane: "Aesthetic") + guard let track = self.library.tracks.first(where: { + $0.url.standardizedFileURL == url.standardizedFileURL + }) else { return } + self.djMixer.loadPrimary(track) + self.activityLabel.stringValue = "Pull the record off the bed to float it" + self.refreshPlaybackPresence() + } + } catch { + await MainActor.run { + self.activityLabel.stringValue = error.localizedDescription + self.activityLabel.textColor = .systemRed + } + } + } + } + + private func connectAppleMusic() { + guard #available(macOS 14.0, *) else { return } + activityLabel.stringValue = "Connecting Apple Music…" + Task { [weak self] in + guard let self else { return } + do { + try await appleMusic.authorize() + await MainActor.run { + self.sourceActionButton.title = "Connected" + self.sourceActionButton.isEnabled = false + self.activityLabel.stringValue = "● Apple Music · connected" + self.spotifySearchField.becomeFirstResponder() + } + } catch { + await MainActor.run { + self.activityLabel.stringValue = error.localizedDescription + self.activityLabel.textColor = .systemRed + } + } + } + } + + private func searchAppleMusic(_ rawQuery: String) { + guard #available(macOS 14.0, *) else { return } + let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return } + activityLabel.stringValue = "Searching Apple Music for “\(query)”…" + Task { [weak self] in + guard let self else { return } + do { + let results = try await appleMusic.search(query) + await MainActor.run { + guard self.activeSource == .appleMusic else { return } + self.appleMusicResults = results + self.selectedAppleMusicRow = results.isEmpty ? -1 : 0 + self.sourceActionButton.title = "Connected" + self.sourceActionButton.isEnabled = false + self.activityLabel.stringValue = results.isEmpty + ? "No Apple Music tracks for “\(query)”" + : "\(results.count) Apple Music tracks" + self.listTable.reloadData() + } + } catch { + await MainActor.run { + self.activityLabel.stringValue = error.localizedDescription + self.activityLabel.textColor = .systemRed + } + } + } + } + + private func playAppleMusicResult(at row: Int) { + guard #available(macOS 14.0, *), appleMusicResults.indices.contains(row) else { return } + selectedAppleMusicRow = row + let result = appleMusicResults[row] + titleLabel.stringValue = result.title + titleLabel.textColor = .labelColor + artistLabel.stringValue = result.artist + laneLabel.stringValue = [result.album, "Apple Music · stays on this deck"] + .filter { !$0.isEmpty }.joined(separator: " · ") + spotifyProgress.duration = result.duration + spotifyProgress.position = 0 + playButton.title = "❚❚" + appleMusicPlaying = true + djMixer.pauseAll() + spotify.pause() + listTable.reloadData() + refreshProviderDeck() + presentAppleMusicArtwork(result.artworkURL) + Task { [weak self] in + do { + try await self?.appleMusic.play(result) + await MainActor.run { self?.refreshPlaybackPresence() } + } + catch { + await MainActor.run { + self?.appleMusicPlaying = false + self?.activityLabel.stringValue = error.localizedDescription + self?.activityLabel.textColor = .systemRed + self?.refreshProviderDeck() + self?.refreshPlaybackPresence() + } + } + } + } + + private func presentAppleMusicArtwork(_ url: URL?) { + appleMusicArtworkURL = url + appleMusicArt = nil + currentArt = nil + refreshProviderDeck() + guard let url else { nowPlaying.present(art: nil, videoURL: nil); return } + URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in + guard let data, let art = NSImage(data: data) else { return } + DispatchQueue.main.async { + guard self?.appleMusicArtworkURL == url else { return } + self?.appleMusicArt = art + self?.currentArt = art + self?.nowPlaying.present(art: art, videoURL: nil) + self?.nowPlaying.setPaused(false) + self?.refreshProviderDeck() + self?.refreshPlaybackPresence() + } + }.resume() } private func searchSpotify(_ rawQuery: String, autoplayFirst: Bool = false) { @@ -1045,10 +1415,17 @@ let result = spotifyResults[row] titleLabel.stringValue = result.title titleLabel.textColor = NSColor(srgbRed: 0.11, green: 0.73, blue: 0.33, alpha: 1) artistLabel.stringValue = result.artists - laneLabel.stringValue = [result.album, "Spotify · juked"].filter { !$0.isEmpty }.joined(separator: " · ") + laneLabel.stringValue = [result.album, "Spotify · stays on this deck"].filter { !$0.isEmpty }.joined(separator: " · ") spotifyProgress.duration = result.duration spotifyProgress.position = 0 playButton.title = "❚❚" + djMixer.pauseAll() + if #available(macOS 14.0, *) { appleMusic.pause() } + appleMusicPlaying = false + providerDeck.update(title: result.title, artist: result.artists, + album: result.album, art: nil, + duration: result.duration, position: 0, + playing: true, canSeek: true) spotify.play(result) var rows = IndexSet(integer: row) if old >= 0, old < spotifyResults.count { rows.insert(old) } @@ -1061,7 +1438,7 @@ guard spotifyMode, let state else { return } titleLabel.stringValue = state.title titleLabel.textColor = NSColor(srgbRed: 0.11, green: 0.73, blue: 0.33, alpha: 1) artistLabel.stringValue = state.artists - laneLabel.stringValue = [state.album, "Spotify · headless juked"].filter { !$0.isEmpty }.joined(separator: " · ") + laneLabel.stringValue = [state.album, "Spotify · stays on this deck"].filter { !$0.isEmpty }.joined(separator: " · ") laneLabel.textColor = NSColor(white: 0.68, alpha: 1) spotifyProgress.duration = state.duration spotifyProgress.position = state.position @@ -1072,10 +1449,12 @@ if let spotifyArt { currentArt = spotifyArt nowPlaying.present(art: spotifyArt, videoURL: nil) } + refreshProviderDeck() if state.artworkURL != spotifyArtworkURL { spotifyArtworkURL = state.artworkURL spotifyArt = nil currentArt = nil + refreshProviderDeck() guard let url = state.artworkURL else { nowPlaying.present(art: nil, videoURL: nil); return } URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in guard let data, let art = NSImage(data: data) else { return } @@ -1085,6 +1464,7 @@ self?.spotifyArt = art self?.currentArt = art self?.nowPlaying.present(art: art, videoURL: nil) self?.nowPlaying.setPaused(!state.isPlaying) + self?.refreshProviderDeck() self?.refreshPlaybackPresence() } }.resume() @@ -1092,16 +1472,26 @@ } refreshPlaybackPresence() } var quickTitle: String { - djMode ? djMixer.dominantTitle - : (spotifyMode ? (spotifyState?.title ?? "Spotify") : (track?.title ?? "Aesthetic")) + if djMode { return djMixer.dominantTitle } + if spotifyMode { return spotifyState?.title ?? "Spotify" } + if appleMusicMode, appleMusicResults.indices.contains(selectedAppleMusicRow) { + return appleMusicResults[selectedAppleMusicRow].title + } + return track?.title ?? activeSource.label() } var quickSubtitle: String { if djMode { return String(format: "DJ · %.1f BPM", djMixer.dominantBPM) } if spotifyMode { return [spotifyState?.artists ?? "", "Spotify"].filter { !$0.isEmpty }.joined(separator: " · ") } + if appleMusicMode, appleMusicResults.indices.contains(selectedAppleMusicRow) { + return [appleMusicResults[selectedAppleMusicRow].artist, "Apple Music"] + .filter { !$0.isEmpty }.joined(separator: " · ") + } return [track?.meta?.artist ?? "Aesthetic Dot Computer", "Aesthetic"].joined(separator: " · ") } var quickIsPlaying: Bool { - djMode ? djMixer.isPlaying : (spotifyMode ? (spotifyState?.isPlaying ?? false) : wave.isPlaying) + djMode ? djMixer.isPlaying + : (spotifyMode ? (spotifyState?.isPlaying ?? false) + : (appleMusicMode ? appleMusicPlaying : wave.isPlaying)) } var quickRoomSummary: String { switch roomAudio.state { @@ -1207,7 +1597,7 @@ miniPlayer?.refresh() } func makeDockMenu() -> NSMenu { - let menu = NSMenu(title: "JukeWizard") + let menu = NSMenu(title: "Menu Band Juke") let heading = NSMenuItem(title: "\(quickTitle) — \(quickSubtitle)", action: nil, keyEquivalent: "") heading.isEnabled = false menu.addItem(heading) @@ -1286,7 +1676,6 @@ private var track: Track? { (current >= 0 && current < library.tracks.count) ? library.tracks[current] : nil } func select(_ i: Int, autoplay: Bool) { guard i >= 0, i < library.tracks.count else { return } - if djMode { setDJMode(false) } if spotifyMode { spotify.pause(); activateLibraryMode() } commitNotes() let old = current @@ -1301,7 +1690,19 @@ laneLabel.textColor = .secondaryLabelColor updateNowPlaying(t) loadLinks(t) relayout() // link count changes the header row width - wave.load(url: t.url) + if djMode { + djMixer.loadPrimary(t, autoplay: autoplay) + var rows = IndexSet(integer: i) + if old >= 0, old < library.tracks.count { rows.insert(old) } + if activeSource == .local { + listTable.reloadData(forRowIndexes: rows, columnIndexes: IndexSet(integer: 0)) + listTable.scrollRowToVisible(i) + } + refreshPlaybackPresence() + return + } + wave.load(track: t) + wave.playbackRate = speedSlider.doubleValue wave.comments = t.data.comments notesView.string = t.data.notes notesPlaceholder.isHidden = !t.data.notes.isEmpty @@ -1372,8 +1773,12 @@ } @objc private func listClicked() { let r = listTable.clickedRow - if spotifyMode { playSpotifyResult(at: r) } - else if r >= 0 { select(r, autoplay: true) } + switch activeSource { + case .local: if r >= 0 { select(r, autoplay: true) } + case .aesthetic: playAestheticCloudResult(at: r) + case .spotify: playSpotifyResult(at: r) + case .appleMusic: playAppleMusicResult(at: r) + } } @objc private func togglePlay() { if djMode { @@ -1383,24 +1788,154 @@ spotify.toggle() let playing = !(spotifyState?.isPlaying ?? false) playButton.title = playing ? "❚❚" : "▶" nowPlaying.setPaused(!playing) + } else if appleMusicMode { + guard #available(macOS 14.0, *) else { return } + appleMusicPlaying.toggle() + playButton.title = appleMusicPlaying ? "❚❚" : "▶" + nowPlaying.setPaused(!appleMusicPlaying) + Task { [weak self] in + do { try await self?.appleMusic.toggle() } + catch { + await MainActor.run { + self?.appleMusicPlaying.toggle() + self?.refreshPlaybackPresence() + } + } + } } else { wave.togglePlay() playButton.title = wave.isPlaying ? "❚❚" : "▶" nowPlaying.setPaused(!wave.isPlaying) } + refreshProviderDeck() refreshPlaybackPresence() } + @objc private func speedChanged(_ sender: NSSlider) { + guard !spotifyMode, !djMode else { return } + wave.playbackRate = sender.doubleValue + speedLabel.stringValue = String(format: "%.2f×", sender.doubleValue) + UserDefaults.standard.set(sender.doubleValue, forKey: "listPlaybackRate") + } + @objc private func resetSpeed() { + speedSlider.doubleValue = 1 + speedChanged(speedSlider) + } @objc private func prevTrack() { if djMode { djMixer.stepDominant(by: -1) } else if spotifyMode { spotify.previous() } + else if appleMusicMode { return } else if current > 0 { select(current - 1, autoplay: true) } } @objc private func nextTrack() { if djMode { djMixer.stepDominant(by: 1) } else if spotifyMode { spotify.next() } + else if appleMusicMode { return } else if current < library.tracks.count - 1 { select(current + 1, autoplay: true) } } + /// Stable command surface used by the user-only Unix socket. This is + /// always called on AppKit's main thread by JukeControlServer. + func control(_ request: [String: Any]) -> [String: Any] { + guard Thread.isMainThread else { return ["ok": false, "error": "control must run on main thread"] } + guard let command = request["command"] as? String else { + return ["ok": false, "error": "missing command"] + } + func state() -> [String: Any] { + var out: [String: Any] = [ + "ok": true, + "mode": djMode ? "dj" : (spotifyMode ? "spotify" : (appleMusicMode ? "appleMusic" : "library")), + "source": String(describing: activeSource), + "playing": quickIsPlaying, "index": current, + "queueCount": library.tracks.count + ] + if let t = track { out["title"] = t.title; out["path"] = t.url.path; out["lane"] = t.lane } + if !spotifyMode && !appleMusicMode && !djMode { + out["position"] = wave.currentTime; out["duration"] = wave.duration + out["speed"] = wave.playbackRate + } + return out + } + switch command { + case "status": return state() + case "source": + guard let rawSource = request["source"] as? String else { + return ["ok": false, "error": "source requires local, aesthetic, spotify, or appleMusic"] + } + let source: JukeSource? + switch rawSource.lowercased() { + case "local": source = .local + case "aesthetic": source = .aesthetic + case "spotify": source = .spotify + case "apple", "applemusic", "apple-music": source = .appleMusic + default: source = nil + } + guard let source else { + return ["ok": false, "error": "unknown source"] + } + activateSource(source) + return state() + case "detach": + guard djMode, activeSource.canDetachRecords else { + return ["ok": false, "error": "this source must stay on the main deck"] + } + guard djMixer.detachPrimary() else { + return ["ok": false, "error": "the main bed has no record"] + } + return state() + case "list": + let limit = max(1, min(1000, request["limit"] as? Int ?? 500)) + let rows: [[String: Any]] = library.tracks.prefix(limit).enumerated().map { i, t in + ["index": i, "title": t.title, "path": t.url.path, "lane": t.lane] + } + return ["ok": true, "count": library.tracks.count, "tracks": rows, "truncated": rows.count < library.tracks.count] + case "select", "play": + if let path = request["path"] as? String { + let wanted = URL(fileURLWithPath: (path as NSString).expandingTildeInPath).standardizedFileURL.path + guard let i = library.tracks.firstIndex(where: { $0.url.standardizedFileURL.path == wanted }) else { + return ["ok": false, "error": "exact path is not in the queue"] + } + select(i, autoplay: command == "play") + } else if let title = request["title"] as? String { + let matches = library.tracks.indices.filter { library.tracks[$0].title == title } + guard matches.count == 1 else { + return ["ok": false, "error": matches.isEmpty ? "exact title not found" : "title is ambiguous; use path or index"] + } + select(matches[0], autoplay: command == "play") + } else if let i = request["index"] as? Int { + guard library.tracks.indices.contains(i) else { return ["ok": false, "error": "index out of range"] } + select(i, autoplay: command == "play") + } else if command == "play" { + guard !spotifyMode, !djMode else { return ["ok": false, "error": "plain play is unavailable in Spotify or DJ mode"] } + wave.play(); playButton.title = "❚❚"; nowPlaying.setPaused(false); refreshPlaybackPresence() + } else { return ["ok": false, "error": "select requires exact path, title, or index"] } + return state() + case "pause": + guard !spotifyMode, !djMode else { return ["ok": false, "error": "pause is unavailable in Spotify or DJ mode"] } + wave.pause(); playButton.title = "▶"; nowPlaying.setPaused(true); refreshPlaybackPresence(); return state() + case "toggle": + guard !spotifyMode, !djMode else { return ["ok": false, "error": "toggle is unavailable in Spotify or DJ mode"] } + togglePlay(); return state() + case "seek": + guard !spotifyMode, !djMode, let seconds = request["seconds"] as? Double, seconds.isFinite else { + return ["ok": false, "error": "seek requires finite seconds in library mode"] + } + wave.seek(to: seconds); return state() + case "speed": + guard !spotifyMode, !djMode, let value = request["speed"] as? Double, + value.isFinite, (0.5...1.5).contains(value) else { + return ["ok": false, "error": "speed must be between 0.5 and 1.5 in library mode"] + } + speedSlider.doubleValue = value; speedChanged(speedSlider); return state() + case "next": + guard !spotifyMode, !djMode else { return ["ok": false, "error": "next is unavailable in Spotify or DJ mode"] } + nextTrack(); return state() + case "previous": + guard !spotifyMode, !djMode else { return ["ok": false, "error": "previous is unavailable in Spotify or DJ mode"] } + prevTrack(); return state() + default: return ["ok": false, "error": "unknown command"] + } + } + // ── rating ───────────────────────────────────────────────────────────── private func renderStars(_ n: Int) { for (i, b) in starButtons.enumerated() { b.title = (i < n) ? "★" : "☆" } @@ -1504,7 +2039,7 @@ self?.pollActivityStatus() } } private func pollActivityStatus() { - guard !spotifyMode, !activityPollInFlight else { return } + guard activeSource == .local, !activityPollInFlight else { return } activityPollInFlight = true let tracks = library.tracks DispatchQueue.global(qos: .utility).async { [weak self] in @@ -1512,7 +2047,7 @@ let activities = WorkStatus.snapshot(tracks: tracks) DispatchQueue.main.async { guard let self else { return } self.activityPollInFlight = false - guard !self.spotifyMode else { return } + guard self.activeSource == .local else { return } self.renderActivityStatus(activities) } } @@ -1544,18 +2079,40 @@ } // ── tables ─────────────────────────────────────────────────────────────── func numberOfRows(in tableView: NSTableView) -> Int { - if tableView == listTable { return spotifyMode ? spotifyResults.count : library.tracks.count } + if tableView == listTable { + switch activeSource { + case .local: return library.tracks.count + case .aesthetic: return filteredCloudTracks.count + case .spotify: return spotifyResults.count + case .appleMusic: return appleMusicResults.count + } + } return track?.data.comments.count ?? 0 } // list = dressed-up view rows; comments = plain cell strings. func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { - if tableView == listTable, spotifyMode, row < spotifyResults.count { + if tableView == listTable, activeSource == .spotify, row < spotifyResults.count { let cell = (listTable.makeView(withIdentifier: SpotifyTrackRowView.id, owner: self) as? SpotifyTrackRowView) ?? { let view = SpotifyTrackRowView(); view.identifier = SpotifyTrackRowView.id; return view }() cell.configure(spotifyResults[row]) cell.selected = row == selectedSpotifyRow return cell } + if tableView == listTable, activeSource == .aesthetic, row < filteredCloudTracks.count { + let cell = (listTable.makeView(withIdentifier: AestheticCloudRowView.id, owner: self) as? AestheticCloudRowView) + ?? { let view = AestheticCloudRowView(); view.identifier = AestheticCloudRowView.id; return view }() + cell.configure(filteredCloudTracks[row]) + cell.selected = row == selectedCloudRow + return cell + } + if tableView == listTable, activeSource == .appleMusic, row < appleMusicResults.count { + let cell = (listTable.makeView(withIdentifier: AppleMusicTrackRowView.id, owner: self) as? AppleMusicTrackRowView) + ?? { let view = AppleMusicTrackRowView(); view.identifier = AppleMusicTrackRowView.id; return view }() + cell.configure(appleMusicResults[row]) + cell.selected = row == selectedAppleMusicRow + return cell + } + guard activeSource == .local else { return nil } guard tableView == listTable, row < library.tracks.count else { return nil } let cell = (listTable.makeView(withIdentifier: TrackRowView.id, owner: self) as? TrackRowView) ?? { let v = TrackRowView(); v.identifier = TrackRowView.id; return v }() @@ -1572,7 +2129,7 @@ // Export the actual audio file to Finder, Messages, Mail, etc. AppKit's // file-URL pasteboard type lets each destination decide whether to copy or // attach it; JukeWizard never moves or mutates the source track. func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? { - guard tableView == listTable, !spotifyMode, + guard tableView == listTable, activeSource == .local, row >= 0, row < library.tracks.count else { return nil } return library.tracks[row].url as NSURL } @@ -1618,7 +2175,6 @@ let sel = listTable.selectedRow if library.tracks.contains(where: { $0.url.standardizedFileURL.path == here }) { listTable.reloadData() // refresh its sidecar/stars } else if library.addFile(url, lane: lane) != nil { - window?.title = "JukeWizard — \(library.tracks.count) tracks" listTable.reloadData() } if sel >= 0 { listTable.selectRowIndexes(IndexSet(integer: sel), byExtendingSelection: false) } diff --git a/juke-wizard/Sources/JukeWizard/JukeLiquidSurface.swift b/juke-wizard/Sources/JukeWizard/JukeLiquidSurface.swift new file mode 100644 --- /dev/null +++ b/juke-wizard/Sources/JukeWizard/JukeLiquidSurface.swift @@ -0,0 +1,52 @@ +import AppKit + +/// The shared glass chassis behind Juke's controls. It mirrors Menu Band's +/// popover material while keeping Juke independently buildable as a package. +final class JukeLiquidSurface: NSView { + private final class PassthroughTintView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { nil } + } + + private let backdrop: NSView + private let tint = PassthroughTintView() + + override init(frame frameRect: NSRect) { + if #available(macOS 26.0, *) { + let glass = NSGlassEffectView() + glass.style = .clear + glass.tintColor = .clear + backdrop = glass + } else { + let vibrancy = NSVisualEffectView() + vibrancy.material = .popover + vibrancy.blendingMode = .behindWindow + vibrancy.state = .active + backdrop = vibrancy + } + + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + + // Keep the glass effect at full strength so copy behind the window is + // refracted instead of competing with Juke. The explicit clear tint + // above is what lets the desktop's actual color remain present. + backdrop.alphaValue = 1 + backdrop.frame = bounds + backdrop.autoresizingMask = [.width, .height] + addSubview(backdrop) + + tint.wantsLayer = true + tint.frame = bounds + tint.autoresizingMask = [.width, .height] + addSubview(tint) + } + + required init?(coder: NSCoder) { fatalError() } + + override var isOpaque: Bool { false } + + func setTint(_ color: NSColor) { + tint.layer?.backgroundColor = color.cgColor + } +} diff --git a/juke-wizard/Sources/JukeWizard/JukeMiniPlayerView.swift b/juke-wizard/Sources/JukeWizard/JukeMiniPlayerView.swift --- a/juke-wizard/Sources/JukeWizard/JukeMiniPlayerView.swift +++ b/juke-wizard/Sources/JukeWizard/JukeMiniPlayerView.swift @@ -3,7 +3,7 @@ final class JukeMiniPlayerView: NSView { weak var controller: JukeController? private let art = NSImageView() - private let title = NSTextField(labelWithString: "JukeWizard") + private let title = NSTextField(labelWithString: "Menu Band Juke") private let subtitle = NSTextField(labelWithString: "") private let room = NSTextField(labelWithString: "") private let previous = NSButton(title: "⏮", target: nil, action: nil) diff --git a/juke-wizard/Sources/JukeWizard/JukeProviderDeckView.swift b/juke-wizard/Sources/JukeWizard/JukeProviderDeckView.swift new file mode 100644 --- /dev/null +++ b/juke-wizard/Sources/JukeWizard/JukeProviderDeckView.swift @@ -0,0 +1,180 @@ +import AppKit + +/// The main-window deck used by streaming providers whose media cannot leave +/// the service player. It deliberately shares the launch bed's record grammar, +/// but exposes no drag or pop-out affordance. +final class JukeProviderDeckView: NSView { + private let recordView = JukeProviderRecordView(frame: .zero) + private let titleLabel = NSTextField(labelWithString: "Choose a track") + private let artistLabel = NSTextField(labelWithString: "") + private let albumLabel = NSTextField(labelWithString: "") + private let fixedLabel = NSTextField(labelWithString: "FIXED TO DECK") + private let playButton = NSButton(title: "▶", target: nil, action: nil) + private let timeLabel = NSTextField(labelWithString: "0:00 / 0:00") + private let progress = SpotifyProgressView(frame: .zero) + private var accent = Palette.teal + + var onToggle: (() -> Void)? + var onSeek: ((Double) -> Void)? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.cornerRadius = 12 + layer?.borderWidth = 1 + titleLabel.font = .systemFont(ofSize: 16, weight: .bold) + titleLabel.lineBreakMode = .byTruncatingTail + artistLabel.font = .systemFont(ofSize: 12, weight: .medium) + artistLabel.textColor = .secondaryLabelColor + artistLabel.lineBreakMode = .byTruncatingTail + albumLabel.font = .systemFont(ofSize: 11) + albumLabel.textColor = .tertiaryLabelColor + albumLabel.lineBreakMode = .byTruncatingTail + fixedLabel.font = .monospacedSystemFont(ofSize: 10, weight: .bold) + fixedLabel.alignment = .center + fixedLabel.toolTip = "This service keeps playback on the main deck" + playButton.bezelStyle = .rounded + playButton.target = self + playButton.action = #selector(toggle) + timeLabel.font = .monospacedDigitSystemFont(ofSize: 12, weight: .medium) + timeLabel.textColor = .secondaryLabelColor + progress.onSeek = { [weak self] target in self?.onSeek?(target) } + [recordView, titleLabel, artistLabel, albumLabel, fixedLabel, + playButton, timeLabel, progress].forEach(addSubview) + setAccessibilityRole(.group) + setAccessibilityLabel("Fixed streaming deck") + } + required init?(coder: NSCoder) { fatalError() } + + func configure(source: JukeSource) { + accent = source == .spotify + ? NSColor(srgbRed: 0.11, green: 0.73, blue: 0.33, alpha: 1) + : NSColor(srgbRed: 0.98, green: 0.22, blue: 0.35, alpha: 1) + recordView.accent = accent + recordView.providerMark = source == .spotify ? "S" : "♪" + fixedLabel.textColor = accent + playButton.contentTintColor = accent + layer?.borderColor = accent.withAlphaComponent(0.55).cgColor + if titleLabel.stringValue.isEmpty { titleLabel.stringValue = "Choose a track" } + needsDisplay = true + } + + func update(title: String, artist: String, album: String, art: NSImage?, + duration: Double, position: Double, playing: Bool, canSeek: Bool) { + titleLabel.stringValue = title.isEmpty ? "Choose a track" : title + artistLabel.stringValue = artist + albumLabel.stringValue = album + recordView.art = art + recordView.isPlaying = playing + playButton.title = playing ? "❚❚" : "▶" + playButton.isEnabled = !title.isEmpty + progress.duration = duration + progress.position = position + progress.allowsSeeking = canSeek + progress.alphaValue = canSeek ? 1 : 0.45 + timeLabel.stringValue = "\(JukeController.mmss(position)) / \(JukeController.mmss(duration))" + } + + override func layout() { + let pad: CGFloat = 14 + let top = bounds.height - pad + titleLabel.frame = NSRect(x: pad, y: top - 24, width: bounds.width - pad * 2, height: 22) + artistLabel.frame = NSRect(x: pad, y: top - 43, width: bounds.width - pad * 2, height: 17) + albumLabel.frame = NSRect(x: pad, y: top - 60, width: bounds.width - pad * 2, height: 15) + + let controlsH: CGFloat = 88 + let recordTop = top - 67 + let recordBottom = controlsH + 10 + let diameter = max(90, min(bounds.width - 38, recordTop - recordBottom)) + recordView.frame = NSRect(x: (bounds.width - diameter) / 2, + y: recordBottom + (recordTop - recordBottom - diameter) / 2, + width: diameter, height: diameter) + + fixedLabel.frame = NSRect(x: bounds.midX - 70, y: 66, width: 140, height: 16) + playButton.frame = NSRect(x: pad, y: 32, width: 45, height: 27) + progress.frame = NSRect(x: pad + 53, y: 32, width: max(80, bounds.width - pad * 2 - 53), height: 27) + timeLabel.frame = NSRect(x: pad, y: 9, width: bounds.width - pad * 2, height: 18) + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + needsDisplay = true + recordView.needsDisplay = true + } + + override func draw(_ dirtyRect: NSRect) { + let dark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + Palette.deckSurface(accent, dark: dark).withAlphaComponent(0.34).setFill() + NSBezierPath(roundedRect: bounds, xRadius: 12, yRadius: 12).fill() + } + + @objc private func toggle() { onToggle?() } +} + +final class JukeProviderRecordView: NSView { + var accent = Palette.teal { didSet { needsDisplay = true } } + var providerMark = "S" { didSet { needsDisplay = true } } + var art: NSImage? { didSet { needsDisplay = true } } + var isPlaying = false { didSet { needsDisplay = true } } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + setAccessibilityRole(.image) + setAccessibilityLabel("Streaming record fixed to the main deck") + } + required init?(coder: NSCoder) { fatalError() } + + override func draw(_ dirtyRect: NSRect) { + let c = NSPoint(x: bounds.midX, y: bounds.midY) + let r = max(1, min(bounds.width, bounds.height) / 2 - 6) + let disc = NSRect(x: c.x - r, y: c.y - r, width: r * 2, height: r * 2) + + NSGraphicsContext.saveGraphicsState() + let shadow = NSShadow() + shadow.shadowColor = .black.withAlphaComponent(0.58) + shadow.shadowBlurRadius = 11 + shadow.shadowOffset = NSSize(width: 0, height: -4) + shadow.set() + NSColor(white: 0.025, alpha: 1).setFill() + NSBezierPath(ovalIn: disc).fill() + NSGraphicsContext.restoreGraphicsState() + + for groove in stride(from: r * 0.38, through: r * 0.93, by: max(3, r * 0.04)) { + NSColor(white: 0.22, alpha: 0.58).setStroke() + let path = NSBezierPath(ovalIn: NSRect(x: c.x - groove, y: c.y - groove, + width: groove * 2, height: groove * 2)) + path.lineWidth = 0.7 + path.stroke() + } + + let labelR = r * 0.31 + let labelRect = NSRect(x: c.x - labelR, y: c.y - labelR, + width: labelR * 2, height: labelR * 2) + if let art { + NSGraphicsContext.saveGraphicsState() + NSBezierPath(ovalIn: labelRect).addClip() + art.draw(in: labelRect, from: .zero, operation: .sourceOver, fraction: 1) + NSGraphicsContext.restoreGraphicsState() + } else { + accent.setFill() + NSBezierPath(ovalIn: labelRect).fill() + let mark = providerMark as NSString + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: max(20, r * 0.22), weight: .black), + .foregroundColor: NSColor.white + ] + let size = mark.size(withAttributes: attrs) + mark.draw(at: NSPoint(x: c.x - size.width / 2, y: c.y - size.height / 2), + withAttributes: attrs) + } + + accent.withAlphaComponent(isPlaying ? 1 : 0.46).setStroke() + let marker = NSBezierPath() + marker.move(to: NSPoint(x: c.x, y: c.y + r * 0.42)) + marker.line(to: NSPoint(x: c.x, y: c.y + r * 0.87)) + marker.lineWidth = max(2, r * 0.025) + marker.lineCapStyle = .round + marker.stroke() + } +} diff --git a/juke-wizard/Sources/JukeWizard/JukeResources.swift b/juke-wizard/Sources/JukeWizard/JukeResources.swift new file mode 100644 --- /dev/null +++ b/juke-wizard/Sources/JukeWizard/JukeResources.swift @@ -0,0 +1,26 @@ +import Foundation + +enum JukeResources { + /// Resolve without SwiftPM's generated `Bundle.module` accessor, which + /// embeds architecture-specific build directories in the executable. + static func url(forResource name: String, withExtension ext: String) -> URL? { + let home = FileManager.default.homeDirectoryForCurrentUser + let executable = URL(fileURLWithPath: CommandLine.arguments[0]) + .standardizedFileURL.deletingLastPathComponent() + let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + let roots = [ + Bundle.main.resourceURL?.appendingPathComponent("Assets"), + executable.appendingPathComponent("Assets"), + home.appendingPathComponent(".local/lib/jukewizard/Assets"), + home.appendingPathComponent("aesthetic-computer/juke-wizard/Sources/JukeWizard/Assets"), + home.appendingPathComponent("Developer/aesthetic-computer/juke-wizard/Sources/JukeWizard/Assets"), + cwd.appendingPathComponent("Sources/JukeWizard/Assets"), + cwd.appendingPathComponent("juke-wizard/Sources/JukeWizard/Assets"), + ].compactMap { $0 } + for root in roots { + let candidate = root.appendingPathComponent(name).appendingPathExtension(ext) + if FileManager.default.fileExists(atPath: candidate.path) { return candidate } + } + return nil + } +} diff --git a/juke-wizard/Sources/JukeWizard/JukeRoomAudio.swift b/juke-wizard/Sources/JukeWizard/JukeRoomAudio.swift --- a/juke-wizard/Sources/JukeWizard/JukeRoomAudio.swift +++ b/juke-wizard/Sources/JukeWizard/JukeRoomAudio.swift @@ -93,7 +93,7 @@ } let mix = channels(for: layout) let sender = ACAudioRoomSender() - sender.onLog = { NSLog("JukeWizard room sender: \($0)") } + sender.onLog = { NSLog("Menu Band Juke room sender: \($0)") } try sender.start() var local: ACAudioRoomReceiver? @@ -102,7 +102,7 @@ do { if let localMix = mix.local { let receiver = ACAudioRoomReceiver(configuration: .init( host: "127.0.0.1", name: "Neo", channel: localMix.channel, gain: localMix.gain)) - receiver.onLog = { NSLog("JukeWizard room Neo: \($0)") } + receiver.onLog = { NSLog("Menu Band Juke room Neo: \($0)") } try receiver.start() local = receiver } @@ -130,8 +130,8 @@ try process.run() remoteProcess = process } - let tap = ACProcessAudioTap(processID: pid, name: "JukeWizard \(source.rawValue)", muteOriginal: true) - tap.onLog = { NSLog("JukeWizard room tap: \($0)") } + let tap = ACProcessAudioTap(processID: pid, name: "Menu Band Juke \(source.rawValue)", muteOriginal: true) + tap.onLog = { NSLog("Menu Band Juke room tap: \($0)") } try tap.start { sender.send($0) } self.sender = sender diff --git a/juke-wizard/Sources/JukeWizard/JukeSource.swift b/juke-wizard/Sources/JukeWizard/JukeSource.swift new file mode 100644 --- /dev/null +++ b/juke-wizard/Sources/JukeWizard/JukeSource.swift @@ -0,0 +1,46 @@ +import Foundation + +enum JukeSource: Int, CaseIterable { + case local, aesthetic, spotify, appleMusic + + var fixedLabel: String? { + switch self { + case .local: return nil + case .aesthetic: return "Aesthetic" + case .spotify: return "Spotify" + case .appleMusic: return "Apple Music" + } + } + + var canDetachRecords: Bool { + switch self { + case .local, .aesthetic: return true + case .spotify, .appleMusic: return false + } + } + + func label(machineName: String = JukeSource.machineName) -> String { + fixedLabel ?? Self.shortMachineName(machineName) + } + + static var machineName: String { + Host.current().localizedName ?? ProcessInfo.processInfo.hostName + } + + static func shortMachineName(_ raw: String) -> String { + var name = raw.trimmingCharacters(in: .whitespacesAndNewlines) + for suffix in [".localdomain", ".local"] where name.lowercased().hasSuffix(suffix) { + name.removeLast(suffix.count) + break + } + guard !name.isEmpty else { return "This Mac" } + if let macRange = name.range(of: "MacBook ", options: [.caseInsensitive, .backwards]) { + let suffix = name[macRange.upperBound...].trimmingCharacters(in: .whitespaces) + if !suffix.isEmpty { return suffix } + } + if let last = name.split(separator: " ").last, name.count > 18 { + return String(last) + } + return name + } +} diff --git a/juke-wizard/Sources/JukeWizard/JukeSpotify.swift b/juke-wizard/Sources/JukeWizard/JukeSpotify.swift --- a/juke-wizard/Sources/JukeWizard/JukeSpotify.swift +++ b/juke-wizard/Sources/JukeWizard/JukeSpotify.swift @@ -146,10 +146,14 @@ private static func executableURL() -> URL? { let installed = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".local/bin/juked") if FileManager.default.isExecutableFile(atPath: installed.path) { return installed } - var root = URL(fileURLWithPath: #filePath) - for _ in 0..<4 { root.deleteLastPathComponent() } - let source = root.appendingPathComponent("slab/juked/bin/juked") - return FileManager.default.isExecutableFile(atPath: source.path) ? source : nil + let home = FileManager.default.homeDirectoryForCurrentUser + let candidates = [ + home.appendingPathComponent("aesthetic-computer/slab/juked/bin/juked"), + home.appendingPathComponent("Developer/aesthetic-computer/slab/juked/bin/juked"), + URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appendingPathComponent("slab/juked/bin/juked"), + ] + return candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0.path) }) } private static func run(_ arguments: [String]) throws -> Data { diff --git a/juke-wizard/Sources/JukeWizard/MascotView.swift b/juke-wizard/Sources/JukeWizard/MascotView.swift --- a/juke-wizard/Sources/JukeWizard/MascotView.swift +++ b/juke-wizard/Sources/JukeWizard/MascotView.swift @@ -7,13 +7,9 @@ import AppKit final class MascotView: NSView { private let image: NSImage? = { - let bundle = Bundle.module - if let url = bundle.url(forResource: "jukewizard-mascot", withExtension: "png", subdirectory: "Assets"), - let img = NSImage(contentsOf: url) { return img } - if let url = bundle.url(forResource: "jukewizard-mascot", withExtension: "png"), + if let url = JukeResources.url(forResource: "jukewizard-mascot", withExtension: "png"), let img = NSImage(contentsOf: url) { return img } - let here = FileManager.default.currentDirectoryPath - return NSImage(contentsOfFile: "\(here)/juke-wizard/Sources/JukeWizard/Assets/jukewizard-mascot.png") + return nil }() override func hitTest(_ point: NSPoint) -> NSView? { nil } diff --git a/juke-wizard/Sources/JukeWizard/MenuBandJuke.swift b/juke-wizard/Sources/JukeWizard/MenuBandJuke.swift new file mode 100644 --- /dev/null +++ b/juke-wizard/Sources/JukeWizard/MenuBandJuke.swift @@ -0,0 +1,161 @@ +// MenuBandJuke.swift — Menu Band's complete Juke feature. Menu Band owns the +// process, application menu, status item, and Dock identity; this module owns +// only the Juke window, playback, library, DJ, room, cloud, annotations, and +// local control socket. +import AppKit + +public final class MenuBandJuke { + private var controller: JukeController? + private var controlServer: JukeControlServer? + + public init() {} + + public var isVisible: Bool { + controller?.window?.isVisible == true && controller?.window?.isMiniaturized == false + } + + /// Start the Juke once. Later calls reuse the same controller and simply + /// raise its window, so Menu Band never creates competing players. + public func start( + arguments: [String] = [], + showsWindow: Bool = true + ) { + if controller != nil { + if showsWindow { open() } + return + } + + var watch: [String] = [] + var paths: [String] = [] + var selectPath: String? + var spotifySearch: String? + var startPrimpats = false + var startBeats = false + var launchInBackground = !showsWindow + var index = 0 + while index < arguments.count { + let argument = arguments[index] + if argument == "--watch", index + 1 < arguments.count { + watch.append(arguments[index + 1]); index += 2; continue + } + if argument == "--select", index + 1 < arguments.count { + selectPath = arguments[index + 1]; index += 2; continue + } + if argument == "--spotify-search", index + 1 < arguments.count { + spotifySearch = arguments[index + 1]; index += 2; continue + } + if argument == "--primpats" { startPrimpats = true; index += 1; continue } + if argument == "--beats" { startBeats = true; index += 1; continue } + if argument == "--background" { launchInBackground = true; index += 1; continue } + paths.append(argument); index += 1 + } + + let aesthetic = Self.defaultLibraryPath() + if paths.isEmpty { + let master = NSHomeDirectory() + "/Desktop/MASTER-playlist.m3u8" + if FileManager.default.fileExists(atPath: aesthetic) { paths = [aesthetic] } + else if FileManager.default.fileExists(atPath: master) { paths = [master] } + } + + let masterPath = URL(fileURLWithPath: aesthetic).standardizedFileURL.path + let includesMasterLibrary = paths.contains { + URL(fileURLWithPath: ($0 as NSString).expandingTildeInPath) + .standardizedFileURL.path == masterPath + } + let scopedInputs = includesMasterLibrary ? [] : paths + let playlistName: String? = scopedInputs.isEmpty ? nil : { + if scopedInputs.count > 1 { return "\(scopedInputs.count)-track playlist" } + let url = URL(fileURLWithPath: (scopedInputs[0] as NSString).expandingTildeInPath) + if ["m3u", "m3u8"].contains(url.pathExtension.lowercased()) { + return url.deletingPathExtension().lastPathComponent + } + return url.hasDirectoryPath ? url.lastPathComponent : "Playlist" + }() + + let library = Library(inputs: paths) + if let selectPath { + let url = URL(fileURLWithPath: (selectPath as NSString).expandingTildeInPath) + if !library.tracks.contains(where: { + $0.url.standardizedFileURL.path == url.standardizedFileURL.path + }) { + let lane = url.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent + library.addFile(url, lane: lane) + } + } + + let controller = JukeController( + library: library, + watch: watch, + select: selectPath, + spotifySearch: spotifySearch, + startPrimpats: startPrimpats, + startBeats: startBeats, + playlistName: playlistName, + fullLibraryPath: aesthetic + ) + self.controller = controller + let server = JukeControlServer(controller: controller) + self.controlServer = server + server.start() + + if launchInBackground { + controller.window?.orderOut(nil) + } else { + show(controller) + } + + guard !launchInBackground else { return } + if startBeats { + DispatchQueue.main.async { [weak controller] in controller?.showDetachedBeats() } + } else if startPrimpats { + DispatchQueue.main.async { [weak controller] in controller?.showDetachedPrimpats() } + } else { + DispatchQueue.main.async { [weak controller] in controller?.quickOpenFull() } + } + } + + public func open() { + guard let controller else { + start(showsWindow: true) + return + } + show(controller) + controller.quickOpenFull() + } + + public func toggle() { + guard let controller else { + start(showsWindow: true) + return + } + controller.quickToggleFull() + } + + public func stop() { + controlServer?.stop() + controlServer = nil + } + + private func show(_ controller: JukeController) { + controller.showWindow(nil) + if controller.window?.isMiniaturized == true { controller.window?.deminiaturize(nil) } + controller.window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + private static func defaultLibraryPath() -> String { + if let configured = ProcessInfo.processInfo.environment["MENU_BAND_JUKE_LIBRARY"], + !configured.isEmpty { + return (configured as NSString).expandingTildeInPath + } + let home = FileManager.default.homeDirectoryForCurrentUser + let candidates = [ + home.appendingPathComponent("aesthetic-computer/pop/out/pop-library.json"), + home.appendingPathComponent("Developer/aesthetic-computer/pop/out/pop-library.json"), + URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appendingPathComponent("pop/out/pop-library.json"), + ] + return candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) })?.path + ?? candidates[0].path + } +} diff --git a/juke-wizard/Sources/JukeWizard/SpotifyViews.swift b/juke-wizard/Sources/JukeWizard/SpotifyViews.swift --- a/juke-wizard/Sources/JukeWizard/SpotifyViews.swift +++ b/juke-wizard/Sources/JukeWizard/SpotifyViews.swift @@ -47,6 +47,7 @@ final class SpotifyProgressView: NSView { var duration: Double = 0 { didSet { needsDisplay = true } } var position: Double = 0 { didSet { needsDisplay = true } } + var allowsSeeking = true var onSeek: ((Double) -> Void)? override func draw(_ dirtyRect: NSRect) { @@ -65,7 +66,7 @@ override func mouseDown(with event: NSEvent) { seek(event) } override func mouseDragged(with event: NSEvent) { seek(event) } private func seek(_ event: NSEvent) { - guard duration > 0 else { return } + guard allowsSeeking, duration > 0 else { return } let x = convert(event.locationInWindow, from: nil).x onSeek?(duration * Double(min(1, max(0, x / max(1, bounds.width))))) } diff --git a/juke-wizard/Sources/JukeWizard/TrackRowView.swift b/juke-wizard/Sources/JukeWizard/TrackRowView.swift --- a/juke-wizard/Sources/JukeWizard/TrackRowView.swift +++ b/juke-wizard/Sources/JukeWizard/TrackRowView.swift @@ -73,7 +73,7 @@ // colored chip tinted by lane; brighter when selected let r = bounds.insetBy(dx: 3, dy: 2) let path = NSBezierPath(roundedRect: r, xRadius: 7, yRadius: 7) (selected ? tint.blended(withFraction: 0.45, of: .white) ?? tint - : tint).withAlphaComponent(selected ? 0.55 : 0.22).setFill() + : tint).withAlphaComponent(selected ? 0.46 : 0.16).setFill() path.fill() if selected { tint.withAlphaComponent(0.9).setStroke() diff --git a/juke-wizard/Sources/JukeWizard/WaveformView.swift b/juke-wizard/Sources/JukeWizard/WaveformView.swift --- a/juke-wizard/Sources/JukeWizard/WaveformView.swift +++ b/juke-wizard/Sources/JukeWizard/WaveformView.swift @@ -13,7 +13,7 @@ func waveformDidFinish() // track ended → auto-advance func waveformTick() // ~30fps → update time label } -final class WaveformView: NSView, AVAudioPlayerDelegate { +final class WaveformView: NSView { weak var delegate: WaveformViewDelegate? private var player: AVAudioPlayer? private var loadedURL: URL? @@ -32,10 +32,10 @@ guard !tempoMap.isEmpty else { return nil } return tempoMap.min(by: { abs($0.time - currentTime) < abs($1.time - currentTime) })?.bpm } var volume: Float { - get { player?.volume ?? preferredVolume } + get { preferredVolume } set { preferredVolume = max(0, min(1, newValue)) - player?.volume = preferredVolume + deck.setGain(preferredVolume) } } @@ -44,14 +44,19 @@ override init(frame: NSRect) { super.init(frame: frame) wantsLayer = true - layer?.backgroundColor = NSColor(white: 0.07, alpha: 1).cgColor + layer?.backgroundColor = NSColor(white: 0.03, alpha: 0.62).cgColor + layer?.borderWidth = 1 + layer?.borderColor = NSColor.white.withAlphaComponent(0.10).cgColor layer?.cornerRadius = 8 layer?.masksToBounds = true + toolTip = "Click to seek · drag to scratch · Option-click to comment" + setAccessibilityLabel("Track waveform and scratch surface") + setAccessibilityHelp("Click to seek, drag left or right to scratch, or Option-click to add a comment") } required init?(coder: NSCoder) { fatalError() } // ── load / transport ──────────────────────────────────────────────── - func load(url: URL) { + func load(track: Track) { stop() loadedURL = url peaks = [] @@ -81,34 +86,36 @@ player?.delegate = self player?.prepareToPlay() } - func play() { player?.play(); startTimer() } - func pause() { player?.pause(); stopTimer(); needsDisplay = true } + func play() { deck.play(); wasPlaying = true; startTimer() } + func pause() { deck.pause(); wasPlaying = false; stopTimer(); needsDisplay = true } func togglePlay() { if isPlaying { pause() } else { play() } } func seek(to t: Double) { - player?.currentTime = max(0, min(duration, t)) + deck.seek(to: max(0, min(duration, t))) needsDisplay = true delegate?.waveformTick() } func stop() { stopTimer() - player?.stop() - player = nil + deck.pause() + wasPlaying = false needsDisplay = true } private func startTimer() { stopTimer() timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in - self?.needsDisplay = true - self?.delegate?.waveformTick() + guard let self else { return } + self.needsDisplay = true + self.delegate?.waveformTick() + if self.wasPlaying, !self.deck.isPlaying, + self.duration > 0, self.currentTime >= self.duration - 0.05 { + self.wasPlaying = false + self.stopTimer() + self.delegate?.waveformDidFinish() + } } } private func stopTimer() { timer?.invalidate(); timer = nil } - - func audioPlayerDidFinishPlaying(_ p: AVAudioPlayer, successfully flag: Bool) { - stopTimer() - delegate?.waveformDidFinish() - } // ── waveform peaks (read on a background queue) ────────────────────── private func computePeaks(url: URL) { @@ -236,10 +243,27 @@ if event.modifierFlags.contains(.option) { delegate?.waveformRequestComment(at: t) } else { seek(to: t) + scratchOrigin = t + lastScratchTime = event.timestamp + isScratching = true + deck.beginScratch() } } override func mouseDragged(with event: NSEvent) { - guard duration > 0, !event.modifierFlags.contains(.option) else { return } - seek(to: timeAt(event)) + guard duration > 0, isScratching else { return } + let target = timeAt(event) + let elapsed = max(1.0 / 240.0, event.timestamp - lastScratchTime) + deck.scratch(to: target, movement: target - scratchOrigin, elapsed: elapsed) + scratchOrigin = target + lastScratchTime = event.timestamp + needsDisplay = true + delegate?.waveformTick() + } + override func mouseUp(with event: NSEvent) { + guard isScratching else { return } + isScratching = false + deck.endScratch() + wasPlaying = deck.isPlaying + if wasPlaying { startTimer() } } } diff --git a/juke-wizard/Sources/JukeWizard/WorkStatus.swift b/juke-wizard/Sources/JukeWizard/WorkStatus.swift --- a/juke-wizard/Sources/JukeWizard/WorkStatus.swift +++ b/juke-wizard/Sources/JukeWizard/WorkStatus.swift @@ -68,7 +68,9 @@ guard let text = String(data: data, encoding: .utf8) else { return [] } let markers = ["render-", "render-c.mjs", "bake.mjs", "ffmpeg", "swift build", "gen-score"] return text.split(separator: "\n").compactMap { raw in let command = String(raw) - guard markers.contains(where: command.contains), !command.contains("JukeWizard") else { return nil } + guard markers.contains(where: command.contains), + !command.contains("JukeWizard"), + !command.contains("/MenuBand") else { return nil } let lane = popLane(in: command) ?? tracks.first(where: { command.lowercased().contains($0.title.lowercased()) })?.lane guard let lane else { return nil } let track = tracks.first(where: { $0.lane == lane && command.lowercased().contains($0.title.lowercased()) })?.title diff --git a/juke-wizard/Tests/JukeWizardTests/DJPrimpatsTests.swift b/juke-wizard/Tests/JukeWizardTests/DJPrimpatsTests.swift --- a/juke-wizard/Tests/JukeWizardTests/DJPrimpatsTests.swift +++ b/juke-wizard/Tests/JukeWizardTests/DJPrimpatsTests.swift @@ -1,6 +1,6 @@ import AVFoundation import XCTest -@testable import JukeWizard +@testable import MenuBandJuke final class DJPrimpatsTests: XCTestCase { func testCatalogRendersDeterministicLoopableStereoTracks() throws { diff --git a/juke-wizard/Tests/JukeWizardTests/JukeSourceTests.swift b/juke-wizard/Tests/JukeWizardTests/JukeSourceTests.swift new file mode 100644 --- /dev/null +++ b/juke-wizard/Tests/JukeWizardTests/JukeSourceTests.swift @@ -0,0 +1,22 @@ +import XCTest +@testable import MenuBandJuke + +final class JukeSourceTests: XCTestCase { + func testSourceOrderAndLabels() { + XCTAssertEqual(JukeSource.allCases.map { $0.label(machineName: "Jeffrey’s MacBook Neo") }, + ["Neo", "Aesthetic", "Spotify", "Apple Music"]) + } + + func testDetachmentFollowsMediaRights() { + XCTAssertTrue(JukeSource.local.canDetachRecords) + XCTAssertTrue(JukeSource.aesthetic.canDetachRecords) + XCTAssertFalse(JukeSource.spotify.canDetachRecords) + XCTAssertFalse(JukeSource.appleMusic.canDetachRecords) + } + + func testShortMachineNamePreservesShortHostnames() { + XCTAssertEqual(JukeSource.shortMachineName("blueberry.local"), "blueberry") + XCTAssertEqual(JukeSource.shortMachineName("Mac.localdomain"), "Mac") + XCTAssertEqual(JukeSource.shortMachineName(""), "This Mac") + } +} diff --git a/juke-wizard/bin/jukewizard b/juke-wizard/bin/jukewizard --- a/juke-wizard/bin/jukewizard +++ b/juke-wizard/bin/jukewizard @@ -1,41 +1,16 @@ #!/bin/sh -# juke-wizard/bin/jukewizard — build (cached) + launch JukeWizard. -# -# JukeWizard is the MASTER JUKE: it ALWAYS loads the whole /pop library so -# every track is visible. Open it with no args to browse, or pass a specific -# track to jump straight to it — the full library still loads underneath. -# Each track shows its cover art, per-service listen links (Spotify/Apple/ -# YouTube/DistroKid), and any reels/videos made for it (from the lane's out/). -# The sidebar sort menu reorders by newest-rendered, rating, title, etc. -# -# usage: -# juke-wizard/bin/jukewizard # browse the whole /pop library -# juke-wizard/bin/jukewizard # load library + select/play that track -# juke-wizard/bin/jukewizard --queue # focused ordered playlist; play a -# juke-wizard/bin/jukewizard --primpats # two floating bass-sine records -# juke-wizard/bin/jukewizard --beats # two floating practice beats -# juke-wizard/bin/jukewizard # load library + add these too -# juke-wizard/bin/jukewizard ... --watch # auto-pop new renders -# -# Runs from anywhere; resolves the package from its own location. -set -e +# Compatibility command for Juke inside Menu Band. This script never builds +# or launches a second macOS application. +set -eu + HERE="$(cd "$(dirname "$0")" && pwd)" -WIZ="$HERE/.." -REPO="$(cd "$WIZ/.." && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +MENU_BAND_APP="${MENU_BAND_APP:-$HOME/Applications/Menu Band.app}" +CONTROL="$HERE/jukewizard-control.mjs" -if [ "${1:-}" = "login" ]; then - shift - if command -v ac-login >/dev/null 2>&1; then exec ac-login "$@"; fi - exec node "$REPO/tezos/ac-login.mjs" "$@" -fi -if [ "${1:-}" = "cloud" ]; then - shift - exec node "$HERE/juke-cloud.mjs" "$@" -fi -if [ "${1:-}" = "--help" ] || [ "${1:-}" = "help" ]; then - node "$HERE/juke-cloud.mjs" --help - exit 0 -fi +usage() { + echo "usage: jukewizard [open|status|list|play|select|pause|toggle|seek|speed|next|previous|source|detach|login|cloud]" +} swift build -c release --package-path "$WIZ" 2>&1 | grep -v '^$' | tail -2 || true diff --git a/juke-wizard/bin/jukewizard-control.mjs b/juke-wizard/bin/jukewizard-control.mjs new file mode 100644 --- /dev/null +++ b/juke-wizard/bin/jukewizard-control.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import { connect } from "node:net"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; + +const SOCKET = process.env.JUKEWIZARD_SOCKET || join(homedir(), ".config", "jukewizard", "control.sock"); + +export function request(command) { + return new Promise((resolvePromise, reject) => { + const client = connect(SOCKET); + let response = ""; + client.setEncoding("utf8"); + client.setTimeout(3000); + client.on("connect", () => client.end(`${JSON.stringify(command)}\n`)); + client.on("data", (chunk) => { response += chunk; if (response.length > 4_000_000) client.destroy(new Error("response too large")); }); + client.on("timeout", () => client.destroy(new Error("Menu Band Juke control timed out"))); + client.on("error", (error) => reject(new Error(`Menu Band Juke is not reachable at ${SOCKET}: ${error.message}`))); + client.on("end", () => { + try { + const value = JSON.parse(response.trim()); + if (!value.ok) reject(new Error(value.error || "Menu Band Juke command failed")); + else resolvePromise(value); + } catch (error) { reject(error); } + }); + }); +} + +export function parse(argv) { + const [name = "status", ...args] = argv; + if (["status", "list", "pause", "toggle", "next", "previous", "prev", "detach"].includes(name)) { + return { command: name === "prev" ? "previous" : name, ...(name === "list" && args[0] ? { limit: Number(args[0]) } : {}) }; + } + if (name === "seek") return { command: "seek", seconds: Number(args[0]) }; + if (name === "speed") return { command: "speed", speed: Number(args[0]) }; + if (name === "source") { + if (!args[0]) throw new Error("source requires local, aesthetic, spotify, or appleMusic"); + return { command: "source", source: args[0] }; + } + if (name === "play" || name === "select") { + if (!args.length) return { command: name }; + if (args[0] === "--title") return { command: name, title: args.slice(1).join(" ") }; + if (args[0] === "--index") return { command: name, index: Number(args[1]) }; + return { command: name, path: resolve(args.join(" ")) }; + } + throw new Error("usage: jukewizard {status|list [limit]|play [path|--title title|--index n]|select ...|pause|toggle|seek seconds|speed 0.5..1.5|next|previous|source name|detach}"); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + try { console.log(JSON.stringify(await request(parse(process.argv.slice(2))), null, 2)); } + catch (error) { console.error(JSON.stringify({ ok: false, error: error.message })); process.exitCode = 1; } +} diff --git a/juke-wizard/bin/jukewizard-installed b/juke-wizard/bin/jukewizard-installed --- a/juke-wizard/bin/jukewizard-installed +++ b/juke-wizard/bin/jukewizard-installed @@ -1,5 +1,6 @@ #!/bin/sh -# Stable launcher for a JukeWizard installed by ../install.sh. +# Installed compatibility command for Juke inside Menu Band. It never launches +# a separate JukeWizard process. set -eu INSTALL_ROOT="${JUKEWIZARD_HOME:-$HOME/.local/lib/jukewizard}" if [ "${1:-}" = "login" ]; then diff --git a/juke-wizard/bin/jukewizard-mcp.mjs b/juke-wizard/bin/jukewizard-mcp.mjs new file mode 100644 --- /dev/null +++ b/juke-wizard/bin/jukewizard-mcp.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { serveStdio } from "../../toolchain/mcp/http-front.mjs"; + +const exec = promisify(execFile); +const cli = join(dirname(fileURLToPath(import.meta.url)), "jukewizard-control.mjs"); +const TOOLS = [ + { name: "jukewizard_status", description: "Return Jukewizard's mode, current track, transport position, speed, and actual playing state.", inputSchema: { type: "object", properties: {} } }, + { name: "jukewizard_list", description: "List the native Jukewizard queue with exact indices, titles, lanes, and paths.", inputSchema: { type: "object", properties: { limit: { type: "integer", minimum: 1, maximum: 1000 } } } }, + { name: "jukewizard_play", description: "Play the current track, or select and play one queue item by exact path, exact unambiguous title, or index.", inputSchema: { type: "object", properties: { path: { type: "string" }, title: { type: "string" }, index: { type: "integer" } } } }, + { name: "jukewizard_pause", description: "Pause library playback.", inputSchema: { type: "object", properties: {} } }, + { name: "jukewizard_toggle", description: "Toggle library playback.", inputSchema: { type: "object", properties: {} } }, + { name: "jukewizard_seek", description: "Seek the current library track to seconds.", inputSchema: { type: "object", properties: { seconds: { type: "number", minimum: 0 } }, required: ["seconds"] } }, + { name: "jukewizard_speed", description: "Set library playback speed from 0.5 through 1.5.", inputSchema: { type: "object", properties: { speed: { type: "number", minimum: 0.5, maximum: 1.5 } }, required: ["speed"] } }, + { name: "jukewizard_next", description: "Select and play the next library track.", inputSchema: { type: "object", properties: {} } }, + { name: "jukewizard_previous", description: "Select and play the previous library track.", inputSchema: { type: "object", properties: {} } }, +]; + +async function run(name, args) { + const command = name.replace("jukewizard_", ""); + const argv = [command]; + if (command === "list" && args.limit) argv.push(String(args.limit)); + if (command === "seek") argv.push(String(args.seconds)); + if (command === "speed") argv.push(String(args.speed)); + if (command === "play") { + if (args.path) argv.push(args.path); + else if (args.title) argv.push("--title", args.title); + else if (Number.isInteger(args.index)) argv.push("--index", String(args.index)); + } + const { stdout } = await exec(process.execPath, [cli, ...argv], { timeout: 5000, maxBuffer: 4_000_000 }); + return [{ type: "text", text: stdout.trim() }]; +} + +async function handleMessage(message) { + const { id, method, params } = message; + try { + if (method === "initialize") return { jsonrpc: "2.0", id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "jukewizard-mcp", version: "1.0.0" } } }; + if (method === "initialized" || method === "notifications/initialized") return null; + if (method === "ping") return { jsonrpc: "2.0", id, result: {} }; + if (method === "tools/list") return { jsonrpc: "2.0", id, result: { tools: TOOLS } }; + if (method === "tools/call") return { jsonrpc: "2.0", id, result: { content: await run(params?.name, params?.arguments || {}) } }; + return { jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } }; + } catch (error) { + return { jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text: String(error.stderr || error.message || error) }] } }; + } +} + +serveStdio({ handleMessage, banner: "💿 jukewizard MCP started" }); diff --git a/juke-wizard/design/renders/source-aesthetic.png b/juke-wizard/design/renders/source-aesthetic.png new file mode 100644 --- /dev/null +++ b/juke-wizard/design/renders/source-aesthetic.png diff --git a/juke-wizard/design/renders/source-apple.png b/juke-wizard/design/renders/source-apple.png new file mode 100644 --- /dev/null +++ b/juke-wizard/design/renders/source-apple.png diff --git a/juke-wizard/design/renders/source-browser-sheet.png b/juke-wizard/design/renders/source-browser-sheet.png new file mode 100644 --- /dev/null +++ b/juke-wizard/design/renders/source-browser-sheet.png diff --git a/juke-wizard/design/renders/source-local.png b/juke-wizard/design/renders/source-local.png new file mode 100644 --- /dev/null +++ b/juke-wizard/design/renders/source-local.png diff --git a/juke-wizard/design/renders/source-spotify.png b/juke-wizard/design/renders/source-spotify.png new file mode 100644 --- /dev/null +++ b/juke-wizard/design/renders/source-spotify.png diff --git a/juke-wizard/design/source-browser-notes.md b/juke-wizard/design/source-browser-notes.md new file mode 100644 --- /dev/null +++ b/juke-wizard/design/source-browser-notes.md @@ -0,0 +1,25 @@ +# Menu Band Juke source browser + +`Neo` → `Aesthetic` → `Spotify` → `Apple Music` + +- `Neo` is the current computer name, shortened from `Jeffrey’s MacBook Neo`. +- `Aesthetic` contains cloud releases. Publish and account actions live inside this tab. +- Spotify and Apple Music each own their search, connection state, attribution, and errors. +- Browsing another tab never changes the playing track. Only Play changes the persistent transport. +- No global Cloud button and no mixed `All` tab. +- DJ, waveform editing, notes, and local-file actions appear only where the source permits them. + +## FOSS case studies + +| Project | Source model | Keep | Avoid | +| --- | --- | --- | --- | +| [Music Assistant](https://developers.music-assistant.io/) | Filesystem, SMB, Spotify, YouTube Music, and other services implement the same music-provider contract. | One provider interface with explicit capabilities. | Pretending every provider supports the same playback or mutation features. | +| [OwnTone](https://owntone.github.io/owntone-server/control-clients/web/) | One browser and queue span local music, files, radio, podcasts, and Spotify. | Persistent playback while browse context changes. | Burying source identity after content enters the queue. | +| [Moosync](https://github.com/Moosync/Moosync-electron) | Local, Spotify, and YouTube share one desktop shell with source filters. | Provider-scoped search and one visual grammar. | Its repository is archived; use it as interaction precedent, not a dependency. | +| [Strawberry](https://github.com/strawberrymusicplayer/strawberry) | Local collection and streaming services live in separate navigation branches. | Clear source boundaries and service-specific setup. | Making remote providers feel like secondary utilities in a dense sidebar. | + +## Integration boundaries + +- Apple Music is viable as a first-class macOS source through [MusicKit for Swift](https://developer.apple.com/documentation/musickit): catalog search, library access, and playback are supported after user authorization. Menu Band needs the MusicKit App Service and `NSAppleMusicUsageDescription` before the tab ships. +- Spotify stays a browse/playback source. Its [developer policy](https://developer.spotify.com/policy) prohibits mixing, remixing, overlapping, or integrating Spotify content with another service, so Menu Band must not expose Spotify tracks to DJ decks or effects. +- Aesthetic and local files can keep the full Juke toolset because Menu Band controls those media paths. diff --git a/juke-wizard/design/source-browser-prototype.html b/juke-wizard/design/source-browser-prototype.html new file mode 100644 --- /dev/null +++ b/juke-wizard/design/source-browser-prototype.html @@ -0,0 +1,527 @@ + + + + + + Menu Band Juke — source browser sketch + + + +
+
+ + Menu Band Juke +
+ + + +
+
+
+ + +
+
+
+ + +
+ +
+
+
+
+
Slow Tide
+
Neo · Aesthetic Computer
+
+
+
+ + + +
+
+
+
+ + + + diff --git a/juke-wizard/install.sh b/juke-wizard/install.sh --- a/juke-wizard/install.sh +++ b/juke-wizard/install.sh @@ -1,5 +1,6 @@ #!/bin/sh -# Build and install a self-contained JukeWizard CLI bundle for this Mac. +# Juke is a Menu Band feature; this compatibility entry point installs the +# owning application and its control-only `jukewizard` shell command. set -eu ROOT="$(cd "$(dirname "$0")" && pwd)" diff --git a/marketing/av-reels/bin/stamp-reel.mjs b/marketing/av-reels/bin/stamp-reel.mjs --- a/marketing/av-reels/bin/stamp-reel.mjs +++ b/marketing/av-reels/bin/stamp-reel.mjs @@ -43,6 +43,11 @@ const assetsDir = `${dirname(BASE)}/chrome-assets`; mkdirSync(assetsDir, { recursive: true }); const W = 1080, H = 1920, FPS = parseInt(flags.fps || 30, 10); +// Scale only the decorative Pals/title movement. Audio-envelope glow stays at +// real time. Short instructional reels can pass 0.25–0.4 to avoid cramming the +// default campaign reel's full wiggle vocabulary into a few seconds. +const MOTION_RATE = Math.max(0, parseFloat(flags["motion-rate"] ?? "1") || 0); +const INTRO_PULSE = flags["intro-pulse"] !== "0"; // total duration from the base mp4 const durProbe = spawnSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", BASE], { encoding: "utf8" }); const TOTAL = parseFloat((durProbe.stdout || "0").trim()) || 10; @@ -126,12 +131,22 @@ if (!cmdTimeline.length || cmdTimeline[0].t > 0.1) cmdTimeline.unshift({ t: 0, display: TITLE, rgb: [205, 205, 215] }); const charSet = new Map(); for (const d of [...new Set(cmdTimeline.map((c) => c.display))]) charSet.set(d, await prerenderTitleChars({ text: d, ptSize: 96, palette: ["#FFFFFF"], shadowColor: null, assetsDir })); const currentCmdAt = (t) => { let c = cmdTimeline[0]; for (const e of cmdTimeline) if (e.t <= t) c = e; return c; }; -const flashAt = (t) => { let last = -999; for (const e of cmdTimeline) if (e.t <= t) last = e.t; const age = t - last, D = 0.75; return age >= 0 && age < D ? (1 - age / D) ** 1.4 : 0; }; +const flashAt = (t) => { + let last = -999; + for (const e of cmdTimeline) if (e.t <= t) last = e.t; + // A static reel title is already present on its first frame. Callers can + // suppress the generic command-change entrance so it doesn't appear to + // inflate abruptly during the opening beat. + if (!INTRO_PULSE && last === cmdTimeline[0]?.t) return 0; + const age = t - last, D = 0.75; + return age >= 0 && age < D ? (1 - age / D) ** 1.4 : 0; +}; console.log(` ${cmdTimeline.length} command changes → dynamic side stamps`); const PALS_S = 145, PALS_HALF = PALS_S / 2, PALS_EDGE_X = 64, CHARS_EDGE_X = PALS_EDGE_X + 12; const CHAR_SCALE = 0.42; -const CHAR_SPAN = 360, BOUNCE_BUF = 26; // fixed nominal span so the pals don't jump per command +const CHAR_SPAN = 360; +const BOUNCE_BUF = Math.max(0, parseFloat(flags["stamp-gap"] ?? "26") || 0); const LEFT_CHARS_CY = H * 0.78, RIGHT_CHARS_CY = H * 0.22; const LEFT_PALS_CY = LEFT_CHARS_CY - CHAR_SPAN / 2 - BOUNCE_BUF - PALS_HALF; const RIGHT_PALS_CY = RIGHT_CHARS_CY + CHAR_SPAN / 2 + BOUNCE_BUF + PALS_HALF; @@ -187,15 +202,16 @@ const palsTinted = (c, src) => tintInto(wmCanvas, wmCtx, src, c); const tintCharGlyph = (img, c) => tintInto(charTintCanvas, charTintCtx, img, c); function drawWatermark(audioT) { - const u = audioT * FPS / FRAMES, TAU = Math.PI * 2, col = sectionTcRgb(audioT); + const mt = audioT * MOTION_RATE; + const u = audioT * FPS / FRAMES * MOTION_RATE, TAU = Math.PI * 2, col = sectionTcRgb(audioT); const flash = flashAt(audioT); const s = PALS_S * (1 + flash * 0.3); // scale bump on command change const e = Math.min(1, envAt(audioT)), glow = Math.max(e * e, flash); const ledCol = col.map((c) => Math.round(c + (255 - c) * glow * 0.6)); const shk = flash * 22; // shake on change - const wig = 13 * Math.sin(TAU * 24 * u + 0.7) + 4 * Math.sin(TAU * 9 * u) + shk * Math.sin(audioT * 61); - const bob = 3 * Math.sin(TAU * 17 * u + 2.1) + 1.5 * Math.sin(TAU * 31 * u) + shk * Math.sin(audioT * 53 + 1); - const swiv = 0.05 * Math.sin(TAU * 19 * u) + 0.025 * Math.sin(TAU * 38 * u + 1.4) + flash * 0.2 * Math.sin(audioT * 47); + const wig = 13 * Math.sin(TAU * 24 * u + 0.7) + 4 * Math.sin(TAU * 9 * u) + shk * Math.sin(mt * 61); + const bob = 3 * Math.sin(TAU * 17 * u + 2.1) + 1.5 * Math.sin(TAU * 31 * u) + shk * Math.sin(mt * 53 + 1); + const swiv = 0.05 * Math.sin(TAU * 19 * u) + 0.025 * Math.sin(TAU * 38 * u + 1.4) + flash * 0.2 * Math.sin(mt * 47); const spots = [ { cx: PALS_EDGE_X - wig, cy: LEFT_PALS_CY + bob, rot: Math.PI / 2 + swiv }, { cx: W - PALS_EDGE_X + wig, cy: RIGHT_PALS_CY - bob, rot: -Math.PI / 2 - swiv }, @@ -222,10 +238,11 @@ drawTitleChars(audioT); } function drawTitleChars(audioT) { - const u = audioT * FPS / FRAMES, TAU = Math.PI * 2; + const mt = audioT * MOTION_RATE; + const u = audioT * FPS / FRAMES * MOTION_RATE, TAU = Math.PI * 2; const cmd = currentCmdAt(audioT), set = charSet.get(cmd.display); if (!set) return; const chars = set.chars, flash = flashAt(audioT), shk = flash * 16; - const wig = 11 * Math.sin(TAU * 30 * u + 3.6) + 4 * Math.sin(TAU * 12 * u + 1.1) + shk * Math.sin(audioT * 58); + const wig = 11 * Math.sin(TAU * 30 * u + 3.6) + 4 * Math.sin(TAU * 12 * u + 1.1) + shk * Math.sin(mt * 58); const span = set.totalWidth * CHAR_SCALE, palsRgb = sectionTcRgb(audioT), startX = -span / 2; const spots = [ { charsCx: CHARS_EDGE_X - wig, cy: LEFT_CHARS_CY, rot: Math.PI / 2 }, @@ -238,10 +255,10 @@ for (let i = 0; i < chars.length; i++) { const ch = chars[i]; if (!ch.img) continue; const x = startX + ch.prefixWidth * CHAR_SCALE, dw = ch.img.width * CHAR_SCALE, dh = ch.img.height * CHAR_SCALE; const charEnv = envAt(audioT - i * 0.03); - const lift = 4 * Math.sin(audioT * 4.0 + i * 0.8) * (0.3 + charEnv) + flash * 11 * Math.sin(audioT * 42 + i), y = -dh / 2 + lift; - const charRot = 0.03 * Math.sin(audioT * 3.1 + i * 1.15) + flash * 0.14 * Math.sin(audioT * 45 + i * 2); + const lift = 4 * Math.sin(mt * 4.0 + i * 0.8) * (0.3 + charEnv) + flash * 11 * Math.sin(mt * 42 + i), y = -dh / 2 + lift; + const charRot = 0.03 * Math.sin(mt * 3.1 + i * 1.15) + flash * 0.14 * Math.sin(mt * 45 + i * 2); ctx.save(); ctx.translate(x + dw / 2, y + dh / 2); ctx.rotate(charRot); ctx.translate(-(x + dw / 2), -(y + dh / 2)); - const sh = 0.5 + 0.5 * Math.sin(audioT * 3.2 - i * 0.7); + const sh = 0.5 + 0.5 * Math.sin(mt * 3.2 - i * 0.7); const charRgb = palsRgb.map((c) => Math.round(c + (255 - c) * sh * 0.35)); ctx.save(); ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = 0.26; ctx.drawImage(tintCharGlyph(ch.img, [0, 0, 0]), x + 3, y + 4, dw, dh); ctx.restore(); diff --git a/papers/AESTHETIC-EYE.md b/papers/AESTHETIC-EYE.md --- a/papers/AESTHETIC-EYE.md +++ b/papers/AESTHETIC-EYE.md @@ -1,10 +1,30 @@ # Aesthetic Eye -`aesthetic-eye` is the render-first design gate for paper diagrams. A successful -TeX build is not visual approval. Every diagram must be rendered from the final -PDF, inspected by visual inference, and assigned the literal verdict +`aesthetic-eye` is the render-first design gate for papers and their diagrams. A +successful TeX build is not visual approval. The final PDF must receive a +paper-wide brand verdict, and every diagram must receive the literal verdict `design: pass` or `design: fail` in an `aesthetic-eye.json` beside the paper. +## Brand rule + +Every visible project-name wordmark is `Aesthetic.Computer`. The period is +mandatory and uses AC pink, `#B44887`; a visible `Aesthetic Computer` fails the +gate. This rule applies to titles, bylines, prose, captions, tables, diagrams, +and references. + +Use one macro throughout LaTeX source, including bibliography fields: + +```tex +\definecolor{acpink}{RGB}{180,72,135} +\newcommand{\acdot}{{\color{acpink}.}} +\newcommand{\ac}{Aesthetic{\acdot}Computer} +``` + +The checker extracts PDF text and fails automatically when the period is +missing. Because text extraction cannot verify color, `prepare` also renders +`pages-contact.png`; visual inference must confirm the dot color in the +manifest's `brand` block. + ## The six checks Every diagram receives all six checks. `design: pass` is valid only when all six @@ -41,6 +61,15 @@ "visualInference": true, "pdfSha256": "sha256-of-reviewed-pdf", "reviewedAt": "2026-07-20T23:00:00Z", "reviewer": { "kind": "visual-inference", "agent": "Codex" }, + "brand": { + "canonicalName": "Aesthetic.Computer", + "dotColor": "#B44887", + "design": "pass", + "checks": { + "period": "pass", + "dotColor": "pass" + } + }, "diagrams": [ { "id": "system-map", @@ -70,12 +99,12 @@ ## Pass ```bash node papers/aesthetic-eye.mjs prepare papers/arxiv-example -# Open .aesthetic-eye/diagrams-contact.png and every diagram-*.png. +# Open .aesthetic-eye/pages-contact.png, diagrams-contact.png, and every crop. # Record the visual-inference verdicts and the printed pdfSha256. node papers/aesthetic-eye.mjs check papers/arxiv-example ``` -The check fails when a diagram is missing, any verdict is absent, any diagram is -`design: fail`, a passing diagram has a failing subcheck, or the PDF changed -after review. `.aesthetic-eye/` is disposable rendered evidence; the manifest is -the durable review record. +The check fails when a visible brand name omits its period, the brand review is +absent, the dot-color check fails, a diagram is missing, a verdict is absent, a +diagram fails, or the PDF changed after review. `.aesthetic-eye/` is disposable +rendered evidence; the manifest is the durable review record. diff --git a/papers/QUALITY-RUBRIC.md b/papers/QUALITY-RUBRIC.md --- a/papers/QUALITY-RUBRIC.md +++ b/papers/QUALITY-RUBRIC.md @@ -84,9 +84,12 @@ - **D7 [HARD] Aesthetic Eye.** Every diagram is inventoried in the paper's `aesthetic-eye.json` and receives an explicit visual-inference verdict of `design: pass|fail`. A pass requires clear tangents, accessible type, balanced composition, intentional use of available space, legible hierarchy, and - unambiguous edge routing. The manifest's PDF hash must match the built PDF. - Run `node papers/aesthetic-eye.mjs prepare `, inspect every crop, - record the verdicts, then run `node papers/aesthetic-eye.mjs check `. + unambiguous edge routing. The paper-wide brand verdict also requires every + visible wordmark to read `Aesthetic.Computer`, with its period in AC pink + (`#B44887`). The manifest's PDF hash must match the built PDF. Run + `node papers/aesthetic-eye.mjs prepare `, inspect the page contact + sheet and every crop, record the verdicts, then run + `node papers/aesthetic-eye.mjs check `. ## E — Cover (`bin/gen-cover.mjs`) diff --git a/papers/SCORE.md b/papers/SCORE.md --- a/papers/SCORE.md +++ b/papers/SCORE.md @@ -196,7 +196,7 @@ | [`bin/build-dossier.mjs`](bin/build-dossier.mjs) | Multi-pass xelatex + bibtex build for a single dossier or `--all`. Reports undefined citations. | | [`bin/crunch.mjs`](bin/crunch.mjs) | Build a paper on the **oven** instead of locally — for a machine with no TeX, or a document that must stay private. Tars the directory, POSTs it to `/paper-crunch`, streams the log, writes the PDF. Never commits and never publishes: the crunched PDF comes back to you and goes nowhere near `papers.aesthetic.computer` (that is `cli.mjs publish`, which builds from `main`). House `.sty` + webfonts resolve inside the oven's sandbox. | | [`bin/gen-cover.mjs`](bin/gen-cover.mjs) | Generate the colored-pencil vignette cover illustration for a dossier from `figures/cover-prompt.txt` via OpenAI gpt-image-2 (1024×1024 square, faded edges). | | [`bin/gen-qrs.mjs`](bin/gen-qrs.mjs) | Generate per-paper QR-code PNG pointing to the deployed permalink at `papers.aesthetic.computer/.pdf`. Uses `qrencode` CLI. | -| [`aesthetic-eye.mjs`](aesthetic-eye.mjs) | Render diagram crops from the final PDF and enforce a current visual-inference manifest with `design: pass|fail` for every diagram. See [`AESTHETIC-EYE.md`](AESTHETIC-EYE.md). | +| [`aesthetic-eye.mjs`](aesthetic-eye.mjs) | Render final-PDF page contacts and diagram crops; enforce the colored `Aesthetic.Computer` period and current visual-inference verdicts. See [`AESTHETIC-EYE.md`](AESTHETIC-EYE.md). | | [`bin/migrate-cover.mjs`](bin/migrate-cover.mjs) | One-shot migration: rewrite an old-style cover block (4em pals + 15em hero) into the new vignette layout (pals top-left + QR top-right + TikZ-overlaid title floating over faded illustration). Idempotent. | | [`bin/fix-people-tables.mjs`](bin/fix-people-tables.mjs) | One-shot: convert `tabularx{lXl}` people tables to `tabularx{lXX}` so the third column wraps and stops overflowing the column width. | diff --git a/papers/aesthetic-eye.mjs b/papers/aesthetic-eye.mjs --- a/papers/aesthetic-eye.mjs +++ b/papers/aesthetic-eye.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// aesthetic-eye — render-first visual QA for paper diagrams. +// aesthetic-eye — render-first visual QA for papers and their diagrams. // // A TeX build proves syntax, not design. This tool prepares diagram crops for // visual inference and enforces a current-PDF manifest whose verdict is the @@ -7,13 +7,18 @@ // literal `design: pass|fail` for every diagram. import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; -import { access, mkdir, readFile } from "node:fs/promises"; +import { access, mkdir, readFile, readdir } from "node:fs/promises"; import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; const exec = promisify(execFile); const REQUIRED_CHECKS = ["tangents", "type", "balance", "spaceUse", "hierarchy", "edgeRouting"]; +const BRAND_NAME = "Aesthetic.Computer"; +const BRAND_DOT_COLOR = "#B44887"; +const BRAND_CHECKS = ["period", "dotColor"]; +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const CONTACT_FONT = resolve(SCRIPT_DIR, "../oven/fonts/ComicRelief-Regular.ttf"); async function exists(path) { try { await access(path); return true; } @@ -47,6 +52,7 @@ export function validateManifest(manifest, currentPdfSha256) { const errors = []; const diagrams = Array.isArray(manifest?.diagrams) ? manifest.diagrams : []; + const brand = manifest?.brand; if (manifest?.schema !== 1) errors.push("schema must be 1"); if (!manifest?.visualInference) errors.push("visualInference must be true"); if (manifest?.reviewer?.kind !== "visual-inference") errors.push("reviewer.kind must be visual-inference"); @@ -59,6 +65,20 @@ } else if (diagrams.length !== manifest.expectedDiagrams) { errors.push(`expected ${manifest.expectedDiagrams} diagram(s), found ${diagrams.length}`); } + if (!brand || typeof brand !== "object") errors.push("brand review is required"); + else { + if (brand.canonicalName !== BRAND_NAME) errors.push(`brand.canonicalName must be ${BRAND_NAME}`); + if (brand.dotColor !== BRAND_DOT_COLOR) errors.push(`brand.dotColor must be ${BRAND_DOT_COLOR}`); + if (!['pass', 'fail'].includes(brand.design)) errors.push("brand.design must be pass or fail"); + for (const check of BRAND_CHECKS) { + if (!['pass', 'fail'].includes(brand.checks?.[check])) errors.push(`brand.checks.${check} must be pass or fail`); + } + const failedBrandChecks = BRAND_CHECKS.filter((check) => brand.checks?.[check] === "fail"); + if (brand.design === "pass" && failedBrandChecks.length) { + errors.push(`brand.design cannot pass while ${failedBrandChecks.join(", ")} fail`); + } + } + const ids = new Set(); for (const [index, diagram] of diagrams.entries()) { const prefix = `diagram ${diagram?.id || index + 1}`; @@ -83,18 +103,32 @@ } const allDesignPass = diagrams.length === manifest?.expectedDiagrams && diagrams.every((diagram) => diagram.design === "pass"); - return { pass: errors.length === 0 && allDesignPass, errors, diagrams }; + const brandPass = brand?.design === "pass" + && BRAND_CHECKS.every((check) => brand?.checks?.[check] === "pass"); + return { pass: errors.length === 0 && allDesignPass && brandPass, errors, diagrams, brand }; +} + +export async function findVisibleBrandViolations(pdfPath) { + const { stdout } = await exec("pdftotext", [pdfPath, "-"], { maxBuffer: 16 * 1024 * 1024 }); + return [...stdout.matchAll(/\bAesthetic\s+Computer\b/gi)].map((match) => match[0].replace(/\s+/g, " ")); } async function check(input, manifestArg) { const review = await loadReview(input, manifestArg); const digest = await sha256(review.pdfPath); const verdict = validateManifest(review.manifest, digest); + const brandViolations = await findVisibleBrandViolations(review.pdfPath); + const errors = [...verdict.errors]; + if (brandViolations.length) { + errors.push(`${brandViolations.length} visible brand name(s) omit the colored period; use ${BRAND_NAME}`); + } + const pass = verdict.pass && errors.length === 0; + console.log(`brand: ${verdict.brand?.design || "missing"} ${BRAND_NAME} dot ${BRAND_DOT_COLOR}`); for (const diagram of verdict.diagrams) console.log(`design: ${diagram.design} ${diagram.id} page ${diagram.page}`); - if (verdict.errors.length) for (const error of verdict.errors) console.error(`FAIL: ${error}`); - if (!verdict.pass && !verdict.errors.length) console.error("FAIL: one or more diagrams have design: fail"); - console.log(`aesthetic-eye: ${verdict.pass ? "PASS" : "FAIL"} ${basename(review.pdfPath)}`); - if (!verdict.pass) process.exitCode = 1; + if (errors.length) for (const error of errors) console.error(`FAIL: ${error}`); + if (!pass && !errors.length) console.error("FAIL: one or more visual verdicts have design: fail"); + console.log(`aesthetic-eye: ${pass ? "PASS" : "FAIL"} ${basename(review.pdfPath)}`); + if (!pass) process.exitCode = 1; } async function prepare(input, manifestArg, outputArg) { @@ -103,6 +137,16 @@ const outputDir = outputArg ? resolve(outputArg) : join(review.paperDir, ".aesthetic-eye"); await mkdir(outputDir, { recursive: true }); const pagePrefix = join(outputDir, "page"); await exec("pdftoppm", ["-png", "-r", "144", review.pdfPath, pagePrefix], { maxBuffer: 8 * 1024 * 1024 }); + const pagePaths = (await readdir(outputDir)) + .filter((name) => /^page-\d+\.png$/.test(name)) + .sort((a, b) => Number(a.match(/\d+/)?.[0]) - Number(b.match(/\d+/)?.[0])) + .map((name) => join(outputDir, name)); + + if (pagePaths.length) { + const pagesContactPath = join(outputDir, "pages-contact.png"); + await exec("magick", ["montage", "-font", CONTACT_FONT, ...pagePaths, "-thumbnail", "360x480", "-tile", "4x", "-geometry", "+16+16", pagesContactPath]); + console.log(`pages: ${pagesContactPath}`); + } const crops = []; for (const diagram of review.manifest.diagrams || []) { @@ -120,11 +164,13 @@ } if (crops.length) { const contactPath = join(outputDir, "diagrams-contact.png"); - await exec("magick", ["montage", ...crops, "-thumbnail", "1000x700", "-tile", "2x", "-geometry", "+24+24", contactPath]); + await exec("magick", ["montage", "-font", CONTACT_FONT, ...crops, "-thumbnail", "1000x700", "-tile", "2x", "-geometry", "+24+24", contactPath]); console.log(`contact: ${contactPath}`); } + const brandViolations = await findVisibleBrandViolations(review.pdfPath); + console.log(`brand text: ${brandViolations.length ? `FAIL (${brandViolations.length} missing period)` : "PASS"}`); console.log(`pdfSha256: ${await sha256(review.pdfPath)}`); - console.log("Next: inspect every crop visually, record design: pass|fail plus all six checks, then run `aesthetic-eye check`."); + console.log("Next: inspect pages-contact.png for the colored Aesthetic.Computer period, inspect every diagram crop, record the verdicts, then run `aesthetic-eye check`."); } function option(args, name) { diff --git a/papers/arxiv-hott-kidlisp/aesthetic-eye.json b/papers/arxiv-hott-kidlisp/aesthetic-eye.json --- a/papers/arxiv-hott-kidlisp/aesthetic-eye.json +++ b/papers/arxiv-hott-kidlisp/aesthetic-eye.json @@ -1,13 +1,23 @@ { "schema": 1, "pdf": "hott-kidlisp.pdf", - "pdfSha256": "e872f9715335f275a0072f5a2104b23348da49c9d2720fdc247fe5bebfc18ca2", + "pdfSha256": "954cb803a04d478dd6e176ee6344ff7934c19a6798f8d395ae57e5e103542a56", "visualInference": true, "reviewer": { "kind": "visual-inference", "name": "Codex" }, - "reviewedAt": "2026-07-24T02:12:29Z", + "reviewedAt": "2026-08-05T18:55:13Z", + "brand": { + "canonicalName": "Aesthetic.Computer", + "dotColor": "#B44887", + "design": "pass", + "checks": { + "period": "pass", + "dotColor": "pass" + }, + "notes": "Every visible project-name wordmark uses the AC macro: the period is present and rendered in AC pink." + }, "expectedDiagrams": 3, "diagrams": [ { diff --git a/papers/arxiv-hott-kidlisp/references.bib b/papers/arxiv-hott-kidlisp/references.bib --- a/papers/arxiv-hott-kidlisp/references.bib +++ b/papers/arxiv-hott-kidlisp/references.bib @@ -124,7 +124,7 @@ @misc{scudder2026kidlisp, author = {{@jeffrey}}, title = {KidLisp '26: A Minimal Lisp for Generative Art}, year = {2026}, - note = {Aesthetic Computer paper, \texttt{papers/arxiv-kidlisp/}} + note = {{\ac} paper, \texttt{papers/arxiv-kidlisp/}} } @misc{scudder2026decree, diff --git a/papers/arxiv-pieces/aesthetic-eye.json b/papers/arxiv-pieces/aesthetic-eye.json new file mode 100644 --- /dev/null +++ b/papers/arxiv-pieces/aesthetic-eye.json @@ -0,0 +1,70 @@ +{ + "schema": 1, + "paper": "Pieces Not Programs", + "pdf": "pieces.pdf", + "pdfSha256": "cebcba30285904162a116bc88dc7c40da3dd6a44019139e55ce7be90dceaf8dc", + "visualInference": true, + "reviewer": { + "kind": "visual-inference", + "name": "Codex" + }, + "reviewedAt": "2026-08-05T18:55:13Z", + "brand": { + "canonicalName": "Aesthetic.Computer", + "dotColor": "#B44887", + "design": "pass", + "checks": { + "period": "pass", + "dotColor": "pass" + }, + "notes": "Every visible project-name wordmark uses the AC macro: the period is present and rendered in AC pink." + }, + "expectedDiagrams": 3, + "diagrams": [ + { + "id": "piece-contract", + "page": 3, + "crop": [0.07, 0.04, 0.86, 0.22], + "design": "pass", + "checks": { + "tangents": "pass", + "type": "pass", + "balance": "pass", + "spaceUse": "pass", + "hierarchy": "pass", + "edgeRouting": "pass" + }, + "notes": "Four-part identity path reads left to right; same-source and contract-port branches are distinct by solid versus dashed routing, labels clear their nodes, and the diagram remains legible at normal page size." + }, + { + "id": "piece-lifecycle", + "page": 3, + "crop": [0.07, 0.22, 0.86, 0.2], + "design": "pass", + "checks": { + "tangents": "pass", + "type": "pass", + "balance": "pass", + "spaceUse": "pass", + "hierarchy": "pass", + "edgeRouting": "pass" + }, + "notes": "The optional lifecycle reads left to right, the event/frame loop runs below the nodes without collisions, and the dashed exit distinguishes cleanup from the recurring performance path." + }, + { + "id": "piece-ecology", + "page": 8, + "crop": [0.10, 0.04, 0.80, 0.27], + "design": "pass", + "checks": { + "tangents": "pass", + "type": "pass", + "balance": "pass", + "spaceUse": "pass", + "hierarchy": "pass", + "edgeRouting": "pass" + }, + "notes": "The piece is visually primary at the center; bag, channel, session, and server remain distinct neighboring units. The tightened render preserves readable labels, short orthogonal relationships, and no crossing edges." + } + ] +} diff --git a/papers/arxiv-pieces/data/monorepo-snapshot.json b/papers/arxiv-pieces/data/monorepo-snapshot.json new file mode 100644 --- /dev/null +++ b/papers/arxiv-pieces/data/monorepo-snapshot.json @@ -0,0 +1,56 @@ +{ + "snapshot": { + "local_date": "2026-08-04", + "timezone": "America/Los_Angeles", + "git_commit": "c65015fe25", + "scope": "system/public/aesthetic.computer/disks", + "notes": "Counts describe the checked-out monorepo, not the production database. Unrelated working-tree changes were excluded from the count and from the paper." + }, + "built_in_entry_files": { + "total": 477, + "mjs": 455, + "lisp": 22 + }, + "javascript_entry_files": { + "with_static_imports": 188, + "total_lines_split_on_newline": 166690, + "median_lines": 152, + "p90_lines": 762, + "largest_lines": 10018 + }, + "exported_hooks": { + "boot": 381, + "paint": 381, + "act": 316, + "sim": 248, + "leave": 80, + "beat": 27, + "meta": 194, + "receive": 18, + "preview": 7 + }, + "supporting_source": { + "top_level_library_modules_mjs": 96, + "top_level_server_functions_js_or_mjs": 163 + }, + "platter_snapshot": { + "source_commit": "6012979f8c72473543df302d5c82f8279317ee90", + "source_date": "2026-07-21", + "built_in_pieces": 413, + "library_modules": 88, + "server_functions": 126, + "registered_handles": 2810, + "user_published_pieces": 265, + "paintings": 4425, + "kidlisp_programs": 16523, + "chat_messages": 18048, + "papers": 60 + }, + "method": { + "entry_file_count": "Count regular files with .mjs or .lisp suffix directly inside system/public/aesthetic.computer/disks.", + "import_count": "Count .mjs entry files containing a top-level line beginning with import.", + "hook_count": "Count .mjs entry files with a top-level export line containing the named hook. This is a syntactic census, not a runtime trace.", + "line_statistics": "Split UTF-8 .mjs source on newline, sort per-file lengths, and report median, 90th percentile, and maximum.", + "platter": "Values transcribed from the rendered Research Platter and its last modifying commit. Production records were not queried for this paper revision." + } +} diff --git a/papers/arxiv-pieces/figures/ac-dannom-play.png b/papers/arxiv-pieces/figures/ac-dannom-play.png new file mode 100644 --- /dev/null +++ b/papers/arxiv-pieces/figures/ac-dannom-play.png diff --git a/papers/arxiv-pieces/figures/ac-kidlisp-fia-birthday-live.png b/papers/arxiv-pieces/figures/ac-kidlisp-fia-birthday-live.png new file mode 100644 --- /dev/null +++ b/papers/arxiv-pieces/figures/ac-kidlisp-fia-birthday-live.png diff --git a/papers/arxiv-pieces/figures/ac-laer-klokken-live.png b/papers/arxiv-pieces/figures/ac-laer-klokken-live.png new file mode 100644 --- /dev/null +++ b/papers/arxiv-pieces/figures/ac-laer-klokken-live.png diff --git a/papers/arxiv-pieces/figures/ac-list-crop.png b/papers/arxiv-pieces/figures/ac-list-crop.png new file mode 100644 --- /dev/null +++ b/papers/arxiv-pieces/figures/ac-list-crop.png diff --git a/papers/arxiv-pieces/figures/ac-list.png b/papers/arxiv-pieces/figures/ac-list.png new file mode 100644 --- /dev/null +++ b/papers/arxiv-pieces/figures/ac-list.png diff --git a/papers/arxiv-pieces/figures/ac-notepat-crop.png b/papers/arxiv-pieces/figures/ac-notepat-crop.png new file mode 100644 --- /dev/null +++ b/papers/arxiv-pieces/figures/ac-notepat-crop.png diff --git a/papers/arxiv-pieces/figures/ac-notepat.png b/papers/arxiv-pieces/figures/ac-notepat.png new file mode 100644 --- /dev/null +++ b/papers/arxiv-pieces/figures/ac-notepat.png diff --git a/papers/arxiv-pieces/figures/ac-profile-sat-live.png b/papers/arxiv-pieces/figures/ac-profile-sat-live.png new file mode 100644 --- /dev/null +++ b/papers/arxiv-pieces/figures/ac-profile-sat-live.png diff --git a/papers/arxiv-pieces/figures/cover-puzzle-masked.png b/papers/arxiv-pieces/figures/cover-puzzle-masked.png new file mode 100644 --- /dev/null +++ b/papers/arxiv-pieces/figures/cover-puzzle-masked.png diff --git a/papers/arxiv-pieces/figures/cover-puzzle-prompt.txt b/papers/arxiv-pieces/figures/cover-puzzle-prompt.txt new file mode 100644 --- /dev/null +++ b/papers/arxiv-pieces/figures/cover-puzzle-prompt.txt @@ -0,0 +1,8 @@ +Use case: illustration-story +Asset type: archival paper title-page illustration +Primary request: Edit the original seated-Jeffrey cover illustration so Jeffrey is actively assembling a large jigsaw puzzle instead of reading sheet music. He holds one oversized piece as he fits it into a partial puzzle; four or five large pieces float around him at different depths, suggesting software pieces connecting across a system. +Style/medium: preserve the refined hand-drawn colored-pencil editorial illustration, natural anatomy, graphite texture, restrained color, and clean white paper field. +Composition: full seated figure and chair centered with generous white space; the puzzle action reads at thumbnail size; nothing is cropped. +Palette: white shirt, blue trousers, black shoes, and restrained Aesthetic Computer pink, purple, blue, green, and amber puzzle pieces. +Constraints: preserve Jeffrey's recognizable face, hair, clothing, chair, scale, and concentrated mood; clear jigsaw tabs and sockets; no logos, writing, interface text, musical notation, or watermark. +Avoid: cartoon style, photorealism, extra people, tiny pieces, clutter, impossible hands, duplicated limbs, or a conventional scenic puzzle image. diff --git a/papers/arxiv-pieces/figures/cover-puzzle-v2.png b/papers/arxiv-pieces/figures/cover-puzzle-v2.png new file mode 100644 --- /dev/null +++ b/papers/arxiv-pieces/figures/cover-puzzle-v2.png diff --git a/papers/arxiv-pieces/figures/piece-contract-tikz.tex b/papers/arxiv-pieces/figures/piece-contract-tikz.tex new file mode 100644 --- /dev/null +++ b/papers/arxiv-pieces/figures/piece-contract-tikz.tex @@ -0,0 +1,33 @@ +\begin{tikzpicture}[ + node distance=0.95cm and 0.88cm, + every node/.style={font=\sffamily}, + box/.style={draw=acgray!55, rounded corners=2pt, fill=white, + minimum height=0.92cm, align=center, inner xsep=8pt, inner ysep=5pt}, + core/.style={box, draw=acpink!80, fill=acpink!7, line width=0.8pt, + minimum width=3.18cm}, + surface/.style={box, draw=acpurple!65, fill=acpurple!6, + minimum width=4.95cm}, + record/.style={core, draw=acblue!70, fill=acblue!6}, + arr/.style={-{Stealth[length=5pt]}, draw=acdark!70, line width=0.65pt}, + port/.style={arr, dashed, draw=acpink!80}, + lab/.style={font=\scriptsize\sffamily, text=acgray, fill=white, + inner xsep=2pt, inner ysep=1pt, align=center}, + note/.style={font=\scriptsize\sffamily, text=acgray, align=center} +] + \node[core] (entry) {\textbf{authored entry}\\[-1pt]\texttt{.mjs} \; \texttt{.lisp} \; \texttt{.js} \; \texttt{.lua}}; + \node[core, right=of entry] (address) {\textbf{named address}\\[-1pt]\texttt{name} \; \texttt{@handle/name} \; \texttt{\$code}}; + \node[core, right=of address] (run) {\textbf{performance instance}\\[-1pt]\texttt{boot act sim paint leave}}; + \node[record, right=of run] (record) {\textbf{durable record}\\[-1pt]source \; author \; media \; run log}; + + \draw[arr] (entry) -- node[lab, above]{resolve} (address); + \draw[arr] (address) -- node[lab, above]{invoke} (run); + \draw[arr] (run) -- node[lab, above]{record} (record); + + \node[surface, below=1.04cm of address, xshift=-0.84cm] (ports) {\textbf{contract ports}\\[-1pt]AC Native \;\textperiodcentered\; MenuBand \;\textperiodcentered\; native ports}; + \node[surface, below=1.04cm of run, xshift=0.84cm] (same) {\textbf{same-source surfaces}\\[-1pt]browser / PWA \;\textperiodcentered\; Electron \;\textperiodcentered\; pack}; + + \draw[port] (address.south) -- node[lab, left]{preserve score} (ports.north); + \draw[arr] (run.south) -- node[lab, right]{execute source} (same.north); + + \node[note, below=0.34cm of ports, xshift=3.57cm, text width=12.2cm] {Solid routing preserves the authored entry inside the web runtime. Dashed routing preserves the piece's learned action, attribution, and recognizable behavior across another implementation.}; +\end{tikzpicture} diff --git a/papers/arxiv-pieces/figures/piece-ecology-tikz.tex b/papers/arxiv-pieces/figures/piece-ecology-tikz.tex new file mode 100644 --- /dev/null +++ b/papers/arxiv-pieces/figures/piece-ecology-tikz.tex @@ -0,0 +1,31 @@ +\begin{tikzpicture}[ + node distance=1.0cm and 1.55cm, + every node/.style={font=\sffamily}, + core/.style={draw=acpink!85, rounded corners=2pt, fill=acpink!8, + minimum width=3.35cm, minimum height=1.12cm, align=center, + inner xsep=8pt, inner ysep=5pt, line width=0.8pt}, + neighbor/.style={draw=acpurple!65, rounded corners=2pt, fill=acpurple!6, + minimum width=3.2cm, minimum height=0.9cm, align=center, + inner xsep=8pt, inner ysep=5pt}, + authority/.style={neighbor, draw=acblue!70, fill=acblue!6}, + arr/.style={-{Stealth[length=5pt]}, draw=acdark!72, line width=0.7pt}, + back/.style={{Stealth[length=5pt]}-{Stealth[length=5pt]}, draw=acdark!72, + line width=0.7pt}, + lab/.style={font=\scriptsize\sffamily, text=acgray, fill=white, + inner xsep=2pt, inner ysep=1pt, align=center}, + note/.style={font=\scriptsize\sffamily, text=acgray, align=center} +] + \node[core] (piece) {\textbf{piece}\\[-1pt]invoke \; attribute \; revise}; + \node[neighbor, above=1.05cm of piece] (bag) {\textbf{bag}\\[-1pt]curated collection}; + \node[neighbor, left=1.7cm of piece] (channel) {\textbf{channel}\\[-1pt]development route}; + \node[neighbor, right=1.7cm of piece] (session) {\textbf{session}\\[-1pt]shared performance}; + \node[authority, below=1.05cm of piece] (server) {\textbf{server}\\[-1pt]authoritative state}; + + \draw[arr] (bag) -- node[lab, right]{contains} (piece); + \draw[arr] (channel) -- node[lab, above]{updates} (piece); + \draw[arr] (piece) -- node[lab, above]{joins} (session); + \draw[back] (piece) -- node[lab, right]{coordinates} (server); + + \node[note, below=0.48cm of server, text width=12.8cm] + {These neighboring units support the work without becoming its identity. The piece remains the smallest unit that can be named, invoked, attributed, revised, and returned to.}; +\end{tikzpicture} diff --git a/papers/arxiv-pieces/figures/piece-lifecycle-tikz.tex b/papers/arxiv-pieces/figures/piece-lifecycle-tikz.tex new file mode 100644 --- /dev/null +++ b/papers/arxiv-pieces/figures/piece-lifecycle-tikz.tex @@ -0,0 +1,30 @@ +\begin{tikzpicture}[ + node distance=0.82cm and 0.92cm, + every node/.style={font=\sffamily}, + hook/.style={draw=acpurple!70, rounded corners=2pt, fill=acpurple!6, + minimum width=2.55cm, minimum height=0.88cm, align=center, + inner xsep=7pt, inner ysep=5pt}, + once/.style={hook, draw=acpink!80, fill=acpink!7}, + exit/.style={hook, draw=acblue!75, fill=acblue!6}, + arr/.style={-{Stealth[length=5pt]}, draw=acdark!72, line width=0.7pt}, + opt/.style={arr, dashed, draw=acgray!85}, + lab/.style={font=\scriptsize\sffamily, text=acgray, fill=white, + inner xsep=2pt, inner ysep=1pt, align=center}, + note/.style={font=\scriptsize\sffamily, text=acgray, align=center} +] + \node[once] (boot) {\textbf{boot}\\[-1pt]prepare once}; + \node[hook, right=of boot] (act) {\textbf{act}\\[-1pt]receive input}; + \node[hook, right=of act] (sim) {\textbf{sim}\\[-1pt]advance state}; + \node[hook, right=of sim] (paint) {\textbf{paint}\\[-1pt]render if needed}; + \node[exit, right=of paint] (leave) {\textbf{leave}\\[-1pt]release resources}; + + \draw[arr] (boot) -- (act); + \draw[arr] (act) -- (sim); + \draw[arr] (sim) -- (paint); + \draw[opt] (paint) -- (leave); + \draw[arr] (paint.south) -- ++(0,-0.62) -| + node[lab, pos=0.25, below]{next event or frame} (act.south); + + \node[note, below=1.12cm of sim, text width=12.5cm] + {Hooks are optional; runtime defaults keep partial lifecycles valid. \texttt{needsPaint()} can suppress quiet frames. Extended hooks such as \texttt{beat}, \texttt{receive}, \texttt{preview}, and \texttt{meta} join at their own moments.}; +\end{tikzpicture} diff --git a/papers/arxiv-pieces/figures/screenshots.json b/papers/arxiv-pieces/figures/screenshots.json new file mode 100644 --- /dev/null +++ b/papers/arxiv-pieces/figures/screenshots.json @@ -0,0 +1,65 @@ +{ + "capturedAt": "2026-08-05", + "viewport": [1280, 900], + "captures": [ + { + "id": "notepat", + "url": "https://aesthetic.computer/notepat", + "publicState": true, + "selector": "canvas", + "raw": "ac-notepat.png", + "rawSha256": "5f0e6bde69bcb145fca6010bfb428114cdc2e458b503890392cf3d0f16e532eb", + "crop": [0, 0, 1280, 560], + "figure": "ac-notepat-crop.png", + "figureSha256": "28637429380a9b97103ce73084c2208cebbca79779c8f3b656ca628493ec9729" + }, + { + "id": "list", + "url": "https://aesthetic.computer/list", + "publicState": true, + "selector": "body", + "raw": "ac-list.png", + "rawSha256": "3772086673310cd7cd0e4c4b6c7de41c61c37e4e10e5a110863465c02f1f6508", + "crop": [0, 0, 1280, 520], + "figure": "ac-list-crop.png", + "figureSha256": "dbed02ed63f8b367a89c2675497be7cfd1f668a22dcbb6ec37ea0cd000f34307" + }, + { + "id": "dannom", + "url": "https://aesthetic.computer/dannom", + "publicState": true, + "viewport": [1280, 720], + "waitMs": 7000, + "figure": "ac-dannom-play.png", + "figureSha256": "ab61b0ba035758373a39fa5826123bb9d5e8ad5aa7e3d1456136acbb73e7f23d" + }, + { + "id": "fia-birthday", + "url": "https://aesthetic.computer/fia-birthday", + "publicState": true, + "viewport": [1280, 720], + "waitMs": 6000, + "figure": "ac-kidlisp-fia-birthday-live.png", + "figureSha256": "448dcc3ba551633608ff2496a1eee778e401bb3214542e11ff13a7d04f6f8870" + }, + { + "id": "profile-sat", + "url": "https://aesthetic.computer/@sat", + "publicState": true, + "viewport": [1280, 720], + "waitMs": 7000, + "figure": "ac-profile-sat-live.png", + "figureSha256": "51d563887a894884b77a74975a615d32287aa9ccc008ff3386a6ff658d5e1a79" + }, + { + "id": "laer-klokken", + "url": "https://aesthetic.computer/laer-klokken", + "publicState": true, + "viewport": [1280, 720], + "waitMs": 7000, + "figure": "ac-laer-klokken-live.png", + "figureSha256": "d1f0807cfcc932fa69d7e8e0093a96ed1e09248bb88c16472b3e5984b6d4d9e2" + } + ], + "privacy": "Only signed-out public product states are included. The @sat profile and laer-klokken public room intentionally show their public handle, portfolio, and room messages; no admin surfaces or telemetry records were captured." +} diff --git a/papers/arxiv-pieces/pieces.log b/papers/arxiv-pieces/pieces.log --- a/papers/arxiv-pieces/pieces.log +++ b/papers/arxiv-pieces/pieces.log @@ -1,4 +1,4 @@ -This is XeTeX, Version 3.141592653-2.6-0.999998 (TeX Live 2026) (preloaded format=xelatex 2026.3.1) 18 JUN 2026 22:17 +This is XeTeX, Version 3.141592653-2.6-0.999998 (TeX Live 2026) (preloaded format=xelatex 2026.3.1) 5 AUG 2026 11:53 entering extended mode restricted \write18 enabled. %&-line parsing enabled. @@ -52,7 +52,7 @@ ) (/usr/local/texlive/2026basic/texmf-dist/tex/latex/fontspec/fontspec.sty (/usr/local/texlive/2026basic/texmf-dist/tex/latex/l3packages/xparse/xparse.sty (/usr/local/texlive/2026basic/texmf-dist/tex/latex/l3kernel/expl3.sty -Package: expl3 2026-01-19 L3 programming layer (loader) +Package: expl3 2026-01-19 L3 programming layer (loader) (/usr/local/texlive/2026basic/texmf-dist/tex/latex/l3backend/l3backend-xetex.de f @@ -107,7 +107,7 @@ File: ts1enc.def 2001/06/05 v3.0e (jk/car/fm) Standard LaTeX file LaTeX Font Info: Redeclaring font encoding TS1 on input line 47. LaTeX Encoding Info: Redeclaring text command \capitalcedilla (encoding TS1) on input line 49. -LaTeX Encoding Info: Redeclaring text command \capitalogonek (encoding TS1) +LaTeX Encoding Info: Redeclaring text command \capitalogonek (encoding TS1) on input line 52. LaTeX Encoding Info: Redeclaring text command \capitalgrave (encoding TS1) o n input line 55. @@ -127,17 +127,17 @@ LaTeX Encoding Info: Redeclaring text command \capitalcaron (encoding TS1) o n input line 62. LaTeX Encoding Info: Redeclaring text command \capitalbreve (encoding TS1) o n input line 63. -LaTeX Encoding Info: Redeclaring text command \capitalmacron (encoding TS1) +LaTeX Encoding Info: Redeclaring text command \capitalmacron (encoding TS1) on input line 64. LaTeX Encoding Info: Redeclaring text command \capitaldotaccent (encoding TS 1) on input line 65. LaTeX Encoding Info: Redeclaring text command \t (encoding TS1) on input lin e 66. -LaTeX Encoding Info: Redeclaring text command \capitaltie (encoding TS1) on +LaTeX Encoding Info: Redeclaring text command \capitaltie (encoding TS1) on input line 67. LaTeX Encoding Info: Redeclaring text command \newtie (encoding TS1) on inpu t line 68. -LaTeX Encoding Info: Redeclaring text command \capitalnewtie (encoding TS1) +LaTeX Encoding Info: Redeclaring text command \capitalnewtie (encoding TS1) on input line 69. LaTeX Encoding Info: Redeclaring text symbol \textcapitalcompwordmark (encod ing TS1) on input line 70. @@ -153,7 +153,7 @@ LaTeX Encoding Info: Redeclaring text symbol \textthreequartersemdash (encod ing TS1) on input line 75. LaTeX Encoding Info: Redeclaring text symbol \textleftarrow (encoding TS1) o n input line 76. -LaTeX Encoding Info: Redeclaring text symbol \textrightarrow (encoding TS1) +LaTeX Encoding Info: Redeclaring text symbol \textrightarrow (encoding TS1) on input line 77. LaTeX Encoding Info: Redeclaring text symbol \textblank (encoding TS1) on in put line 78. @@ -165,7 +165,7 @@ LaTeX Encoding Info: Redeclaring text command \textasteriskcentered (encodin g TS1) on input line 81. LaTeX Encoding Info: Redeclaring text symbol \textdblhyphen (encoding TS1) o n input line 92. -LaTeX Encoding Info: Redeclaring text symbol \textfractionsolidus (encoding +LaTeX Encoding Info: Redeclaring text symbol \textfractionsolidus (encoding TS1) on input line 93. LaTeX Encoding Info: Redeclaring text symbol \textzerooldstyle (encoding TS1 ) on input line 94. @@ -205,11 +205,11 @@ LaTeX Encoding Info: Redeclaring text symbol \textlbrackdbl (encoding TS1) o n input line 116. LaTeX Encoding Info: Redeclaring text symbol \textrbrackdbl (encoding TS1) o n input line 117. -LaTeX Encoding Info: Redeclaring text symbol \textuparrow (encoding TS1) on +LaTeX Encoding Info: Redeclaring text symbol \textuparrow (encoding TS1) on input line 118. LaTeX Encoding Info: Redeclaring text symbol \textdownarrow (encoding TS1) o n input line 119. -LaTeX Encoding Info: Redeclaring text symbol \textasciigrave (encoding TS1) +LaTeX Encoding Info: Redeclaring text symbol \textasciigrave (encoding TS1) on input line 120. LaTeX Encoding Info: Redeclaring text symbol \textborn (encoding TS1) on inp ut line 121. @@ -219,7 +219,7 @@ LaTeX Encoding Info: Redeclaring text symbol \textdied (encoding TS1) on inp ut line 123. LaTeX Encoding Info: Redeclaring text symbol \textleaf (encoding TS1) on inp ut line 124. -LaTeX Encoding Info: Redeclaring text symbol \textmarried (encoding TS1) on +LaTeX Encoding Info: Redeclaring text symbol \textmarried (encoding TS1) on input line 125. LaTeX Encoding Info: Redeclaring text symbol \textmusicalnote (encoding TS1) on input line 126. @@ -227,9 +227,9 @@ LaTeX Encoding Info: Redeclaring text symbol \texttildelow (encoding TS1) on input line 127. LaTeX Encoding Info: Redeclaring text symbol \textdblhyphenchar (encoding TS 1) on input line 128. -LaTeX Encoding Info: Redeclaring text symbol \textasciibreve (encoding TS1) +LaTeX Encoding Info: Redeclaring text symbol \textasciibreve (encoding TS1) on input line 129. -LaTeX Encoding Info: Redeclaring text symbol \textasciicaron (encoding TS1) +LaTeX Encoding Info: Redeclaring text symbol \textasciicaron (encoding TS1) on input line 130. LaTeX Encoding Info: Redeclaring text symbol \textacutedbl (encoding TS1) on input line 131. @@ -245,7 +245,7 @@ LaTeX Encoding Info: Redeclaring text symbol \textperthousand (encoding TS1) on input line 136. LaTeX Encoding Info: Redeclaring text symbol \textbullet (encoding TS1) on i nput line 137. -LaTeX Encoding Info: Redeclaring text symbol \textcelsius (encoding TS1) on +LaTeX Encoding Info: Redeclaring text symbol \textcelsius (encoding TS1) on input line 138. LaTeX Encoding Info: Redeclaring text symbol \textdollaroldstyle (encoding T S1) on input line 139. @@ -259,7 +259,7 @@ LaTeX Encoding Info: Redeclaring text symbol \textwon (encoding TS1) on inpu t line 143. LaTeX Encoding Info: Redeclaring text symbol \textnaira (encoding TS1) on in put line 144. -LaTeX Encoding Info: Redeclaring text symbol \textguarani (encoding TS1) on +LaTeX Encoding Info: Redeclaring text symbol \textguarani (encoding TS1) on input line 145. LaTeX Encoding Info: Redeclaring text symbol \textpeso (encoding TS1) on inp ut line 146. @@ -269,7 +269,7 @@ LaTeX Encoding Info: Redeclaring text symbol \textrecipe (encoding TS1) on i nput line 148. LaTeX Encoding Info: Redeclaring text symbol \textinterrobang (encoding TS1) on input line 149. -LaTeX Encoding Info: Redeclaring text symbol \textinterrobangdown (encoding +LaTeX Encoding Info: Redeclaring text symbol \textinterrobangdown (encoding TS1) on input line 150. LaTeX Encoding Info: Redeclaring text symbol \textdong (encoding TS1) on inp ut line 151. @@ -277,7 +277,7 @@ LaTeX Encoding Info: Redeclaring text symbol \texttrademark (encoding TS1) o n input line 152. LaTeX Encoding Info: Redeclaring text symbol \textpertenthousand (encoding T S1) on input line 153. -LaTeX Encoding Info: Redeclaring text symbol \textpilcrow (encoding TS1) on +LaTeX Encoding Info: Redeclaring text symbol \textpilcrow (encoding TS1) on input line 154. LaTeX Encoding Info: Redeclaring text symbol \textbaht (encoding TS1) on inp ut line 155. @@ -287,7 +287,7 @@ LaTeX Encoding Info: Redeclaring text symbol \textdiscount (encoding TS1) on input line 157. LaTeX Encoding Info: Redeclaring text symbol \textestimated (encoding TS1) o n input line 158. -LaTeX Encoding Info: Redeclaring text symbol \textopenbullet (encoding TS1) +LaTeX Encoding Info: Redeclaring text symbol \textopenbullet (encoding TS1) on input line 159. LaTeX Encoding Info: Redeclaring text symbol \textservicemark (encoding TS1) on input line 160. @@ -305,7 +305,7 @@ LaTeX Encoding Info: Redeclaring text symbol \textyen (encoding TS1) on inpu t line 166. LaTeX Encoding Info: Redeclaring text symbol \textbrokenbar (encoding TS1) o n input line 167. -LaTeX Encoding Info: Redeclaring text symbol \textsection (encoding TS1) on +LaTeX Encoding Info: Redeclaring text symbol \textsection (encoding TS1) on input line 168. LaTeX Encoding Info: Redeclaring text symbol \textasciidieresis (encoding TS 1) on input line 169. @@ -319,7 +319,7 @@ LaTeX Encoding Info: Redeclaring text symbol \textlnot (encoding TS1) on inp ut line 173. LaTeX Encoding Info: Redeclaring text symbol \textcircledP (encoding TS1) on input line 174. -LaTeX Encoding Info: Redeclaring text symbol \textregistered (encoding TS1) +LaTeX Encoding Info: Redeclaring text symbol \textregistered (encoding TS1) on input line 175. LaTeX Encoding Info: Redeclaring text symbol \textasciimacron (encoding TS1) on input line 176. @@ -331,7 +331,7 @@ LaTeX Encoding Info: Redeclaring text symbol \texttwosuperior (encoding TS1) on input line 179. LaTeX Encoding Info: Redeclaring text symbol \textthreesuperior (encoding TS 1) on input line 180. -LaTeX Encoding Info: Redeclaring text symbol \textasciiacute (encoding TS1) +LaTeX Encoding Info: Redeclaring text symbol \textasciiacute (encoding TS1) on input line 181. LaTeX Encoding Info: Redeclaring text symbol \textmu (encoding TS1) on input line 182. @@ -347,9 +347,9 @@ LaTeX Encoding Info: Redeclaring text symbol \textordmasculine (encoding TS1 ) on input line 187. LaTeX Encoding Info: Redeclaring text symbol \textsurd (encoding TS1) on inp ut line 188. -LaTeX Encoding Info: Redeclaring text symbol \textonequarter (encoding TS1) +LaTeX Encoding Info: Redeclaring text symbol \textonequarter (encoding TS1) on input line 189. -LaTeX Encoding Info: Redeclaring text symbol \textonehalf (encoding TS1) on +LaTeX Encoding Info: Redeclaring text symbol \textonehalf (encoding TS1) on input line 190. LaTeX Encoding Info: Redeclaring text symbol \textthreequarters (encoding TS 1) on input line 191. @@ -436,16 +436,16 @@ (/usr/local/texlive/2026basic/texmf-dist/tex/latex/unicode-math/unicode-math-ta ble.tex))) -Package fontspec Info: +Package fontspec Info: (fontspec) Font family 'LatinModernRoman(0)' created for font (fontspec) 'Latin Modern Roman' with options (fontspec) [Ligatures=TeX,Extension=.otf,UprightFont=lmroman10-regu lar,BoldFont=lmroman10-bold,ItalicFont=lmroman10-italic,BoldItalicFont=lmroman1 0-bolditalic]. -(fontspec) +(fontspec) (fontspec) This font family consists of the following NFSS (fontspec) series/shapes: -(fontspec) +(fontspec) (fontspec) - 'normal' (m/n) with NFSS spec.: (fontspec) <->"[lmroman10-regular.otf]/OT:script=latn;language=dflt ;mapping=tex-text;" @@ -460,16 +460,16 @@ (fontspec) <->"[lmroman10-bolditalic.otf]/OT:script=latn;language=d flt;mapping=tex-text;" -Package fontspec Info: +Package fontspec Info: (fontspec) Font family 'LatinModernRoman(1)' created for font (fontspec) 'Latin Modern Roman' with options (fontspec) [Extension=.otf,UprightFont=lmroman10-regular,BoldFont=l mroman10-bold,ItalicFont=lmroman10-italic,BoldItalicFont=lmroman10-bolditalic]. -(fontspec) +(fontspec) (fontspec) This font family consists of the following NFSS (fontspec) series/shapes: -(fontspec) +(fontspec) (fontspec) - 'normal' (m/n) with NFSS spec.: (fontspec) <->"[lmroman10-regular.otf]/OT:script=latn;language=dflt ;" @@ -492,16 +492,16 @@ LaTeX Font Info: Overwriting math alphabet `\mathbf' in version `normal' (Font) OT1/cmr/bx/n --> TU/LatinModernRoman(1)/b/n on input li ne 16. -Package fontspec Info: +Package fontspec Info: (fontspec) Font family 'LatinModernSans(0)' created for font (fontspec) 'Latin Modern Sans' with options (fontspec) [Ligatures=TeX,Extension=.otf,UprightFont=lmsans10-regul ar,BoldFont=lmsans10-bold,ItalicFont=lmsans10-oblique,BoldItalicFont=lmsans10-b oldoblique]. -(fontspec) +(fontspec) (fontspec) This font family consists of the following NFSS (fontspec) series/shapes: -(fontspec) +(fontspec) (fontspec) - 'normal' (m/n) with NFSS spec.: (fontspec) <->"[lmsans10-regular.otf]/OT:script=latn;language=dflt; mapping=tex-text;" @@ -516,15 +516,15 @@ (fontspec) <->"[lmsans10-boldoblique.otf]/OT:script=latn;language=d flt;mapping=tex-text;" -Package fontspec Info: +Package fontspec Info: (fontspec) Font family 'LatinModernSans(1)' created for font (fontspec) 'Latin Modern Sans' with options (fontspec) [Extension=.otf,UprightFont=lmsans10-regular,BoldFont=lm sans10-bold,ItalicFont=lmsans10-oblique,BoldItalicFont=lmsans10-boldoblique]. -(fontspec) +(fontspec) (fontspec) This font family consists of the following NFSS (fontspec) series/shapes: -(fontspec) +(fontspec) (fontspec) - 'normal' (m/n) with NFSS spec.: (fontspec) <->"[lmsans10-regular.otf]/OT:script=latn;language=dflt; " @@ -544,28 +544,28 @@ LaTeX Font Info: Overwriting math alphabet `\mathsf' in version `bold' (Font) OT1/cmss/bx/n --> TU/LatinModernSans(1)/b/n on input li ne 23. -Package fontspec Info: +Package fontspec Info: (fontspec) lmmono10-regular scale = 0.85. -Package fontspec Info: +Package fontspec Info: (fontspec) lmmono10-regular scale = 0.85. -Package fontspec Info: +Package fontspec Info: (fontspec) lmmono10-italic scale = 0.85. -Package fontspec Info: +Package fontspec Info: (fontspec) Font family 'LatinModernMono(0)' created for font (fontspec) 'Latin Modern Mono' with options (fontspec) [WordSpace={1,0,0},HyphenChar=None,PunctuationSpace=Word Space,Extension=.otf,UprightFont=lmmono10-regular,ItalicFont=lmmono10-italic,Sc ale=0.85]. -(fontspec) +(fontspec) (fontspec) This font family consists of the following NFSS (fontspec) series/shapes: -(fontspec) +(fontspec) (fontspec) - 'normal' (m/n) with NFSS spec.: (fontspec) <->s*[0.85]"[lmmono10-regular.otf]/OT:script=latn;langua ge=dflt;" @@ -574,27 +574,27 @@ (fontspec) <->s*[0.85]"[lmmono10-italic.otf]/OT:script=latn;languag e=dflt;" -Package fontspec Info: +Package fontspec Info: (fontspec) lmmono10-regular scale = 0.85. -Package fontspec Info: +Package fontspec Info: (fontspec) lmmono10-regular scale = 0.85. -Package fontspec Info: +Package fontspec Info: (fontspec) lmmono10-italic scale = 0.85. -Package fontspec Info: +Package fontspec Info: (fontspec) Font family 'LatinModernMono(1)' created for font (fontspec) 'Latin Modern Mono' with options (fontspec) [Extension=.otf,UprightFont=lmmono10-regular,ItalicFont= lmmono10-italic,Scale=0.85]. -(fontspec) +(fontspec) (fontspec) This font family consists of the following NFSS (fontspec) series/shapes: -(fontspec) +(fontspec) (fontspec) - 'normal' (m/n) with NFSS spec.: (fontspec) <->s*[0.85]"[lmmono10-regular.otf]/OT:script=latn;langua ge=dflt;" @@ -609,33 +609,19 @@ LaTeX Font Info: Overwriting math alphabet `\mathtt' in version `bold' (Font) OT1/cmtt/m/n --> TU/LatinModernMono(1)/b/n on input lin e 29. -Package fontspec Info: +Package fontspec Info: (fontspec) Font family 'ywft-processing-bold(0)' created for font (fontspec) 'ywft-processing-bold' with options (fontspec) [Path=../../system/public/type/webfonts/,Extension=.ttf] . -(fontspec) +(fontspec) (fontspec) This font family consists of the following NFSS (fontspec) series/shapes: -(fontspec) +(fontspec) (fontspec) - 'normal' (m/n) with NFSS spec.: (fontspec) <->"[../../system/public/type/webfonts/ywft-processing-b old.ttf]/OT:script=latn;language=dflt;" - -Package fontspec Info: -(fontspec) Font family 'ywft-processing-light(0)' created for font -(fontspec) 'ywft-processing-light' with options -(fontspec) [Path=../../system/public/type/webfonts/,Extension=.ttf] -. -(fontspec) -(fontspec) This font family consists of the following NFSS -(fontspec) series/shapes: -(fontspec) -(fontspec) - 'normal' (m/n) with NFSS spec.: -(fontspec) <->"[../../system/public/type/webfonts/ywft-processing-l -ight.ttf]/OT:script=latn;language=dflt;" - (/usr/local/texlive/2026basic/texmf-dist/tex/latex/xcolor/xcolor.sty Package: xcolor 2024/09/29 v3.02 LaTeX color extensions (UK) (/usr/local/texlive/2026basic/texmf-dist/tex/latex/graphics-cfg/color.cfg @@ -925,43 +911,401 @@ (/usr/local/texlive/2026basic/texmf-dist/tex/latex/microtype/microtype.cfg File: microtype.cfg 2026/03/01 v3.2d microtype main configuration file (RS) ) LaTeX Info: Redefining \microtypesetup on input line 3065. -) (/usr/local/texlive/2026basic/texmf-dist/tex/latex/natbib/natbib.sty +) (/usr/local/texlive/2026basic/texmf-dist/tex/latex/caption/caption.sty +Package: caption 2023/08/05 v3.6o Customizing captions (AR) + +(/usr/local/texlive/2026basic/texmf-dist/tex/latex/caption/caption3.sty +Package: caption3 2023/07/31 v2.4d caption3 kernel (AR) +\caption@tempdima=\dimen258 +\captionmargin=\dimen259 +\caption@leftmargin=\dimen260 +\caption@rightmargin=\dimen261 +\caption@width=\dimen262 +\caption@indent=\dimen263 +\caption@parindent=\dimen264 +\caption@hangindent=\dimen265 +Package caption Info: Standard document class detected. +) +\c@caption@flags=\count334 +\c@continuedfloat=\count335 +Package caption Info: hyperref package is loaded. +) +(/usr/local/texlive/2026basic/texmf-dist/tex/latex/natbib/natbib.sty Package: natbib 2010/09/13 8.31b (PWD, AO) \bibhang=\skip89 \bibsep=\skip90 LaTeX Info: Redefining \cite on input line 694. -\c@NAT@ctr=\count334 +\c@NAT@ctr=\count336 +) +(/usr/local/texlive/2026basic/texmf-dist/tex/latex/tools/multicol.sty +Package: multicol 2025/10/21 v2.0b multicolumn formatting (FMi) +\c@tracingmulticols=\count337 + + +Package multicol Warning: May not work with the twocolumn option on input line +145. + +\mult@box=\box61 +\multicol@leftmargin=\dimen266 +\c@unbalance=\count338 +\c@collectmore=\count339 +\doublecol@number=\count340 +\multicoltolerance=\count341 +\multicolpretolerance=\count342 +\full@width=\dimen267 +\page@free=\dimen268 +\premulticols=\dimen269 +\postmulticols=\dimen270 +\multicolsep=\skip91 +\multicolbaselineskip=\skip92 +\partial@page=\box62 +\last@line=\box63 +\mc@boxedresult=\box64 +\maxbalancingoverflow=\dimen271 +\mult@rightbox=\box65 +\mult@grightbox=\box66 +\mult@firstbox=\box67 +\mult@gfirstbox=\box68 +\@tempa=\box69 +\@tempa=\box70 +\@tempa=\box71 +\@tempa=\box72 +\@tempa=\box73 +\@tempa=\box74 +\@tempa=\box75 +\@tempa=\box76 +\@tempa=\box77 +\@tempa=\box78 +\@tempa=\box79 +\@tempa=\box80 +\@tempa=\box81 +\@tempa=\box82 +\@tempa=\box83 +\@tempa=\box84 +\@tempa=\box85 +\@tempa=\box86 +\@tempa=\box87 +\@tempa=\box88 +\@tempa=\box89 +\@tempa=\box90 +\@tempa=\box91 +\@tempa=\box92 +\@tempa=\box93 +\@tempa=\box94 +\@tempa=\box95 +\@tempa=\box96 +\@tempa=\box97 +\@tempa=\box98 +\@tempa=\box99 +\@tempa=\box100 +\@tempa=\box101 +\@tempa=\box102 +\@tempa=\box103 +\@tempa=\box104 +\c@minrows=\count343 +\c@columnbadness=\count344 +\c@finalcolumnbadness=\count345 +\last@try=\dimen272 +\multicolovershoot=\dimen273 +\multicolundershoot=\dimen274 +\mult@nat@firstbox=\box105 +\colbreak@box=\box106 +\mc@col@check@num=\count346 +\g__mc_curr_col_int=\count347 +) (/Users/jas/Library/texmf/tex/latex/pgf/frontendlayer/tikz.sty +(/Users/jas/Library/texmf/tex/latex/pgf/basiclayer/pgf.sty +(/Users/jas/Library/texmf/tex/latex/pgf/utilities/pgfrcs.sty +(/Users/jas/Library/texmf/tex/generic/pgf/utilities/pgfutil-common.tex +\pgfutil@everybye=\toks31 +\pgfutil@tempdima=\dimen275 +\pgfutil@tempdimb=\dimen276 +) +(/Users/jas/Library/texmf/tex/generic/pgf/utilities/pgfutil-latex.def +\pgfutil@abb=\box107 +) +(/Users/jas/Library/texmf/tex/generic/pgf/utilities/pgfrcs.code.tex +(/Users/jas/Library/texmf/tex/generic/pgf/pgf.revision.tex) +Package: pgfrcs 2025-08-29 v3.1.11a (3.1.11a) +)) +Package: pgf 2025-08-29 v3.1.11a (3.1.11a) + +(/Users/jas/Library/texmf/tex/latex/pgf/basiclayer/pgfcore.sty +(/Users/jas/Library/texmf/tex/latex/pgf/systemlayer/pgfsys.sty +(/Users/jas/Library/texmf/tex/generic/pgf/systemlayer/pgfsys.code.tex +Package: pgfsys 2025-08-29 v3.1.11a (3.1.11a) + +(/Users/jas/Library/texmf/tex/generic/pgf/utilities/pgfkeys.code.tex +\pgfkeys@pathtoks=\toks32 +\pgfkeys@temptoks=\toks33 + +(/Users/jas/Library/texmf/tex/generic/pgf/utilities/pgfkeyslibraryfiltered.code +.tex +\pgfkeys@tmptoks=\toks34 +)) +\pgf@x=\dimen277 +\pgf@y=\dimen278 +\pgf@xa=\dimen279 +\pgf@ya=\dimen280 +\pgf@xb=\dimen281 +\pgf@yb=\dimen282 +\pgf@xc=\dimen283 +\pgf@yc=\dimen284 +\pgf@xd=\dimen285 +\pgf@yd=\dimen286 +\w@pgf@writea=\write3 +\r@pgf@reada=\read2 +\c@pgf@counta=\count348 +\c@pgf@countb=\count349 +\c@pgf@countc=\count350 +\c@pgf@countd=\count351 +\t@pgf@toka=\toks35 +\t@pgf@tokb=\toks36 +\t@pgf@tokc=\toks37 +\pgf@sys@id@count=\count352 + (/Users/jas/Library/texmf/tex/generic/pgf/systemlayer/pgf.cfg +File: pgf.cfg 2025-08-29 v3.1.11a (3.1.11a) +) +Driver file for pgf: pgfsys-xetex.def + +(/Users/jas/Library/texmf/tex/generic/pgf/systemlayer/pgfsys-xetex.def +File: pgfsys-xetex.def 2025-08-29 v3.1.11a (3.1.11a) + +(/Users/jas/Library/texmf/tex/generic/pgf/systemlayer/pgfsys-dvipdfmx.def +File: pgfsys-dvipdfmx.def 2025-08-29 v3.1.11a (3.1.11a) + +(/Users/jas/Library/texmf/tex/generic/pgf/systemlayer/pgfsys-common-pdf.def +File: pgfsys-common-pdf.def 2025-08-29 v3.1.11a (3.1.11a) +) +\pgfsys@objnum=\count353 +))) (/Users/jas/Library/texmf/tex/generic/pgf/systemlayer/pgfsyssoftpath.code.t +ex +File: pgfsyssoftpath.code.tex 2025-08-29 v3.1.11a (3.1.11a) +\pgfsyssoftpath@smallbuffer@items=\count354 +\pgfsyssoftpath@bigbuffer@items=\count355 +) (/Users/jas/Library/texmf/tex/generic/pgf/systemlayer/pgfsysprotocol.code.tex +File: pgfsysprotocol.code.tex 2025-08-29 v3.1.11a (3.1.11a) +)) (/Users/jas/Library/texmf/tex/generic/pgf/basiclayer/pgfcore.code.tex +Package: pgfcore 2025-08-29 v3.1.11a (3.1.11a) + +(/Users/jas/Library/texmf/tex/generic/pgf/math/pgfmath.code.tex +(/Users/jas/Library/texmf/tex/generic/pgf/math/pgfmathutil.code.tex) +(/Users/jas/Library/texmf/tex/generic/pgf/math/pgfmathparser.code.tex +\pgfmath@dimen=\dimen287 +\pgfmath@count=\count356 +\pgfmath@box=\box108 +\pgfmath@toks=\toks38 +\pgfmath@stack@operand=\toks39 +\pgfmath@stack@operation=\toks40 +) +(/Users/jas/Library/texmf/tex/generic/pgf/math/pgfmathfunctions.code.tex) +(/Users/jas/Library/texmf/tex/generic/pgf/math/pgfmathfunctions.basic.code.tex) + +(/Users/jas/Library/texmf/tex/generic/pgf/math/pgfmathfunctions.trigonometric.c +ode.tex) +(/Users/jas/Library/texmf/tex/generic/pgf/math/pgfmathfunctions.random.code.tex +) +(/Users/jas/Library/texmf/tex/generic/pgf/math/pgfmathfunctions.comparison.code +.tex) +(/Users/jas/Library/texmf/tex/generic/pgf/math/pgfmathfunctions.base.code.tex) +(/Users/jas/Library/texmf/tex/generic/pgf/math/pgfmathfunctions.round.code.tex) +(/Users/jas/Library/texmf/tex/generic/pgf/math/pgfmathfunctions.misc.code.tex) +(/Users/jas/Library/texmf/tex/generic/pgf/math/pgfmathfunctions.integerarithmet +ics.code.tex) +(/Users/jas/Library/texmf/tex/generic/pgf/math/pgfmathcalc.code.tex) +(/Users/jas/Library/texmf/tex/generic/pgf/math/pgfmathfloat.code.tex +\c@pgfmathroundto@lastzeros=\count357 +)) +(/Users/jas/Library/texmf/tex/generic/pgf/math/pgfint.code.tex) +(/Users/jas/Library/texmf/tex/generic/pgf/basiclayer/pgfcorepoints.code.tex +File: pgfcorepoints.code.tex 2025-08-29 v3.1.11a (3.1.11a) +\pgf@picminx=\dimen288 +\pgf@picmaxx=\dimen289 +\pgf@picminy=\dimen290 +\pgf@picmaxy=\dimen291 +\pgf@pathminx=\dimen292 +\pgf@pathmaxx=\dimen293 +\pgf@pathminy=\dimen294 +\pgf@pathmaxy=\dimen295 +\pgf@xx=\dimen296 +\pgf@xy=\dimen297 +\pgf@yx=\dimen298 +\pgf@yy=\dimen299 +\pgf@zx=\dimen300 +\pgf@zy=\dimen301 +) +(/Users/jas/Library/texmf/tex/generic/pgf/basiclayer/pgfcorepathconstruct.code. +tex +File: pgfcorepathconstruct.code.tex 2025-08-29 v3.1.11a (3.1.11a) +\pgf@path@lastx=\dimen302 +\pgf@path@lasty=\dimen303 +) +(/Users/jas/Library/texmf/tex/generic/pgf/basiclayer/pgfcorepathusage.code.tex +File: pgfcorepathusage.code.tex 2025-08-29 v3.1.11a (3.1.11a) +\pgf@shorten@end@additional=\dimen304 +\pgf@shorten@start@additional=\dimen305 +) (/Users/jas/Library/texmf/tex/generic/pgf/basiclayer/pgfcorescopes.code.tex +File: pgfcorescopes.code.tex 2025-08-29 v3.1.11a (3.1.11a) +\pgfpic=\box109 +\pgf@hbox=\box110 +\pgf@layerbox@main=\box111 +\pgf@picture@serial@count=\count358 +) +(/Users/jas/Library/texmf/tex/generic/pgf/basiclayer/pgfcoregraphicstate.code.t +ex +File: pgfcoregraphicstate.code.tex 2025-08-29 v3.1.11a (3.1.11a) +\pgflinewidth=\dimen306 +) +(/Users/jas/Library/texmf/tex/generic/pgf/basiclayer/pgfcoretransformations.cod +e.tex +File: pgfcoretransformations.code.tex 2025-08-29 v3.1.11a (3.1.11a) +\pgf@pt@x=\dimen307 +\pgf@pt@y=\dimen308 +\pgf@pt@temp=\dimen309 +) +(/Users/jas/Library/texmf/tex/generic/pgf/basiclayer/pgfcorequick.code.tex +File: pgfcorequick.code.tex 2025-08-29 v3.1.11a (3.1.11a) +) +(/Users/jas/Library/texmf/tex/generic/pgf/basiclayer/pgfcoreobjects.code.tex +File: pgfcoreobjects.code.tex 2025-08-29 v3.1.11a (3.1.11a) +) +(/Users/jas/Library/texmf/tex/generic/pgf/basiclayer/pgfcorepathprocessing.code +.tex +File: pgfcorepathprocessing.code.tex 2025-08-29 v3.1.11a (3.1.11a) +) +(/Users/jas/Library/texmf/tex/generic/pgf/basiclayer/pgfcorearrows.code.tex +File: pgfcorearrows.code.tex 2025-08-29 v3.1.11a (3.1.11a) +\pgfarrowsep=\dimen310 +) +(/Users/jas/Library/texmf/tex/generic/pgf/basiclayer/pgfcoreshade.code.tex +File: pgfcoreshade.code.tex 2025-08-29 v3.1.11a (3.1.11a) +\pgf@max=\dimen311 +\pgf@sys@shading@range@num=\count359 +\pgf@shadingcount=\count360 +) +(/Users/jas/Library/texmf/tex/generic/pgf/basiclayer/pgfcoreimage.code.tex +File: pgfcoreimage.code.tex 2025-08-29 v3.1.11a (3.1.11a) +) +(/Users/jas/Library/texmf/tex/generic/pgf/basiclayer/pgfcoreexternal.code.tex +File: pgfcoreexternal.code.tex 2025-08-29 v3.1.11a (3.1.11a) +\pgfexternal@startupbox=\box112 +) +(/Users/jas/Library/texmf/tex/generic/pgf/basiclayer/pgfcorelayers.code.tex +File: pgfcorelayers.code.tex 2025-08-29 v3.1.11a (3.1.11a) +) +(/Users/jas/Library/texmf/tex/generic/pgf/basiclayer/pgfcoretransparency.code.t +ex +File: pgfcoretransparency.code.tex 2025-08-29 v3.1.11a (3.1.11a) +) +(/Users/jas/Library/texmf/tex/generic/pgf/basiclayer/pgfcorepatterns.code.tex +File: pgfcorepatterns.code.tex 2025-08-29 v3.1.11a (3.1.11a) +) +(/Users/jas/Library/texmf/tex/generic/pgf/basiclayer/pgfcorerdf.code.tex +File: pgfcorerdf.code.tex 2025-08-29 v3.1.11a (3.1.11a) +))) +(/Users/jas/Library/texmf/tex/generic/pgf/modules/pgfmoduleshapes.code.tex +File: pgfmoduleshapes.code.tex 2025-08-29 v3.1.11a (3.1.11a) +\pgfnodeparttextbox=\box113 +) +(/Users/jas/Library/texmf/tex/generic/pgf/modules/pgfmoduleplot.code.tex +File: pgfmoduleplot.code.tex 2025-08-29 v3.1.11a (3.1.11a) +) +(/Users/jas/Library/texmf/tex/latex/pgf/compatibility/pgfcomp-version-0-65.sty +Package: pgfcomp-version-0-65 2025-08-29 v3.1.11a (3.1.11a) +\pgf@nodesepstart=\dimen312 +\pgf@nodesepend=\dimen313 +) (/Users/jas/Library/texmf/tex/latex/pgf/compatibility/pgfcomp-version-1-18.st +y +Package: pgfcomp-version-1-18 2025-08-29 v3.1.11a (3.1.11a) +)) (/Users/jas/Library/texmf/tex/latex/pgf/utilities/pgffor.sty +(/Users/jas/Library/texmf/tex/latex/pgf/utilities/pgfkeys.sty +(/Users/jas/Library/texmf/tex/generic/pgf/utilities/pgfkeys.code.tex)) +(/Users/jas/Library/texmf/tex/latex/pgf/math/pgfmath.sty +(/Users/jas/Library/texmf/tex/generic/pgf/math/pgfmath.code.tex)) +(/Users/jas/Library/texmf/tex/generic/pgf/utilities/pgffor.code.tex +Package: pgffor 2025-08-29 v3.1.11a (3.1.11a) +\pgffor@iter=\dimen314 +\pgffor@skip=\dimen315 +\pgffor@stack=\toks41 +\pgffor@toks=\toks42 +)) +(/Users/jas/Library/texmf/tex/generic/pgf/frontendlayer/tikz/tikz.code.tex +Package: tikz 2025-08-29 v3.1.11a (3.1.11a) + +(/Users/jas/Library/texmf/tex/generic/pgf/libraries/pgflibraryplothandlers.code +.tex +File: pgflibraryplothandlers.code.tex 2025-08-29 v3.1.11a (3.1.11a) +\pgf@plot@mark@count=\count361 +\pgfplotmarksize=\dimen316 ) -(/usr/local/texlive/2026basic/texmf-dist/tex/latex/draftwatermark/draftwatermar -k.sty -Package: draftwatermark 2024/03/04 3.3 Put a gray textual watermark on document - pages +\tikz@lastx=\dimen317 +\tikz@lasty=\dimen318 +\tikz@lastxsaved=\dimen319 +\tikz@lastysaved=\dimen320 +\tikz@lastmovetox=\dimen321 +\tikz@lastmovetoy=\dimen322 +\tikzleveldistance=\dimen323 +\tikzsiblingdistance=\dimen324 +\tikz@figbox=\box114 +\tikz@figbox@bg=\box115 +\tikz@tempbox=\box116 +\tikz@tempbox@bg=\box117 +\tikztreelevel=\count362 +\tikznumberofchildren=\count363 +\tikznumberofcurrentchild=\count364 +\tikz@fig@count=\count365 + +(/Users/jas/Library/texmf/tex/generic/pgf/modules/pgfmodulematrix.code.tex +File: pgfmodulematrix.code.tex 2025-08-29 v3.1.11a (3.1.11a) +\pgfmatrixcurrentrow=\count366 +\pgfmatrixcurrentcolumn=\count367 +\pgf@matrix@numberofcolumns=\count368 ) -Package hyperref Info: Option `colorlinks' set `true' on input line 78. -\acrandtmp=\count335 - (./pieces.aux) +\tikz@expandcount=\count369 + +(/Users/jas/Library/texmf/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibr +arytopaths.code.tex +File: tikzlibrarytopaths.code.tex 2025-08-29 v3.1.11a (3.1.11a) +))) +(/Users/jas/Library/texmf/tex/generic/pgf/libraries/pgflibraryarrows.meta.code. +tex +File: pgflibraryarrows.meta.code.tex 2025-08-29 v3.1.11a (3.1.11a) +\pgfarrowinset=\dimen325 +\pgfarrowlength=\dimen326 +\pgfarrowwidth=\dimen327 +\pgfarrowlinewidth=\dimen328 +) +(/Users/jas/Library/texmf/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibr +arycalc.code.tex +File: tikzlibrarycalc.code.tex 2025-08-29 v3.1.11a (3.1.11a) +) +(/Users/jas/Library/texmf/tex/generic/pgf/frontendlayer/tikz/libraries/tikzlibr +arypositioning.code.tex +File: tikzlibrarypositioning.code.tex 2025-08-29 v3.1.11a (3.1.11a) +) +Package hyperref Info: Option `colorlinks' set `true' on input line 70. + (./version.tex) (./pieces.aux) \openout1 = `pieces.aux'. -LaTeX Font Info: Checking defaults for OML/cmm/m/it on input line 134. -LaTeX Font Info: ... okay on input line 134. -LaTeX Font Info: Checking defaults for OMS/cmsy/m/n on input line 134. -LaTeX Font Info: ... okay on input line 134. -LaTeX Font Info: Checking defaults for OT1/cmr/m/n on input line 134. -LaTeX Font Info: ... okay on input line 134. -LaTeX Font Info: Checking defaults for T1/cmr/m/n on input line 134. -LaTeX Font Info: ... okay on input line 134. -LaTeX Font Info: Checking defaults for TS1/cmr/m/n on input line 134. -LaTeX Font Info: ... okay on input line 134. -LaTeX Font Info: Checking defaults for TU/lmr/m/n on input line 134. -LaTeX Font Info: ... okay on input line 134. -LaTeX Font Info: Checking defaults for OMX/cmex/m/n on input line 134. -LaTeX Font Info: ... okay on input line 134. -LaTeX Font Info: Checking defaults for U/cmr/m/n on input line 134. -LaTeX Font Info: ... okay on input line 134. -LaTeX Font Info: Checking defaults for PD1/pdf/m/n on input line 134. -LaTeX Font Info: ... okay on input line 134. -LaTeX Font Info: Checking defaults for PU/pdf/m/n on input line 134. -LaTeX Font Info: ... okay on input line 134. +LaTeX Font Info: Checking defaults for OML/cmm/m/it on input line 122. +LaTeX Font Info: ... okay on input line 122. +LaTeX Font Info: Checking defaults for OMS/cmsy/m/n on input line 122. +LaTeX Font Info: ... okay on input line 122. +LaTeX Font Info: Checking defaults for OT1/cmr/m/n on input line 122. +LaTeX Font Info: ... okay on input line 122. +LaTeX Font Info: Checking defaults for T1/cmr/m/n on input line 122. +LaTeX Font Info: ... okay on input line 122. +LaTeX Font Info: Checking defaults for TS1/cmr/m/n on input line 122. +LaTeX Font Info: ... okay on input line 122. +LaTeX Font Info: Checking defaults for TU/lmr/m/n on input line 122. +LaTeX Font Info: ... okay on input line 122. +LaTeX Font Info: Checking defaults for OMX/cmex/m/n on input line 122. +LaTeX Font Info: ... okay on input line 122. +LaTeX Font Info: Checking defaults for U/cmr/m/n on input line 122. +LaTeX Font Info: ... okay on input line 122. +LaTeX Font Info: Checking defaults for PD1/pdf/m/n on input line 122. +LaTeX Font Info: ... okay on input line 122. +LaTeX Font Info: Checking defaults for PU/pdf/m/n on input line 122. +LaTeX Font Info: ... okay on input line 122. *geometry* driver: auto-detecting *geometry* detected driver: xetex @@ -970,7 +1314,7 @@ * driver: xetex * paper: letterpaper * layout: * layoutoffset:(h,v)=(0.0pt,0.0pt) -* modes: +* modes: * h-part:(L,W,R)=(54.2025pt, 505.89pt, 54.2025pt) * v-part:(T,H,B)=(54.2025pt, 686.56499pt, 54.2025pt) * \paperwidth=614.295pt @@ -983,7 +1327,7 @@ * \topmargin=-55.06749pt * \headheight=12.0pt * \headsep=25.0pt * \topskip=10.0pt -* \footskip=30.0pt +* \footskip=12.0pt * \marginparwidth=4.0pt * \marginparsep=10.0pt * \columnsep=18.00003pt @@ -998,15 +1342,15 @@ * \@reversemarginfalse * (1in=72.27pt=25.4mm, 1cm=28.453pt) -Package fontspec Info: +Package fontspec Info: (fontspec) Font family 'latinmodern-math.otf(0)' created for font (fontspec) 'latinmodern-math.otf' with options (fontspec) [BoldItalicFont={},ItalicFont={},SmallCapsFont={},Script =Math,BoldFont={latinmodern-math.otf}]. -(fontspec) +(fontspec) (fontspec) This font family consists of the following NFSS (fontspec) series/shapes: -(fontspec) +(fontspec) (fontspec) - 'normal' (m/n) with NFSS spec.: (fontspec) <->"[latinmodern-math.otf]/OT:script=math;language=dflt; " @@ -1015,17 +1359,17 @@ (fontspec) <->"[latinmodern-math.otf]/OT:script=math;language=dflt; " -Package fontspec Info: +Package fontspec Info: (fontspec) Font family 'latinmodern-math.otf(1)' created for font (fontspec) 'latinmodern-math.otf' with options (fontspec) [BoldItalicFont={},ItalicFont={},SmallCapsFont={},Script =Math,SizeFeatures={{Size=8.5-},{Size=6-8.5,Font=latinmodern-math.otf,Style=Mat hScript},{Size=-6,Font=latinmodern-math.otf,Style=MathScriptScript}},BoldFont={ latinmodern-math.otf}]. -(fontspec) +(fontspec) (fontspec) This font family consists of the following NFSS (fontspec) series/shapes: -(fontspec) +(fontspec) (fontspec) - 'normal' (m/n) with NFSS spec.: (fontspec) <8.5->"[latinmodern-math.otf]/OT:script=math;language=df lt;"<6-8.5>"[latinmodern-math.otf]/OT:script=math;language=dflt;+ssty=0;"<-6>"[ @@ -1035,38 +1379,38 @@ (fontspec) <->"[latinmodern-math.otf]/OT:script=math;language=dflt; " LaTeX Font Info: Encoding `OT1' has changed to `TU' for symbol font -(Font) `operators' in the math version `normal' on input line 134. +(Font) `operators' in the math version `normal' on input line 122. LaTeX Font Info: Overwriting symbol font `operators' in version `normal' (Font) OT1/cmr/m/n --> TU/latinmodern-math.otf(1)/m/n on input - line 134. + line 122. LaTeX Font Info: Encoding `OT1' has changed to `TU' for symbol font -(Font) `operators' in the math version `bold' on input line 134. +(Font) `operators' in the math version `bold' on input line 122. LaTeX Font Info: Overwriting symbol font `operators' in version `bold' (Font) OT1/cmr/bx/n --> TU/latinmodern-math.otf(1)/b/n on inpu -t line 134. +t line 122. -Package fontspec Info: +Package fontspec Info: (fontspec) latinmodern-math scale = 1.0001. -Package fontspec Info: +Package fontspec Info: (fontspec) latinmodern-math scale = 1.0001. -Package fontspec Info: +Package fontspec Info: (fontspec) latinmodern-math scale = 1.0001. -Package fontspec Info: +Package fontspec Info: (fontspec) latinmodern-math scale = 1.0001. -Package fontspec Info: +Package fontspec Info: (fontspec) latinmodern-math scale = 1.0001. -Package fontspec Info: +Package fontspec Info: (fontspec) Font family 'latinmodern-math.otf(2)' created for font (fontspec) 'latinmodern-math.otf' with options (fontspec) [BoldItalicFont={},ItalicFont={},SmallCapsFont={},Script @@ -1083,10 +1427,10 @@ (fontspec) =2.47pt\relax \fontdimen 18\font =2.5pt\relax (fontspec) \fontdimen 19\font =2.0pt\relax \fontdimen 22\font (fontspec) =2.5pt\relax \fontdimen 20\font =0pt\relax \fontdimen (fontspec) 21\font =0pt\relax }]. -(fontspec) +(fontspec) (fontspec) This font family consists of the following NFSS (fontspec) series/shapes: -(fontspec) +(fontspec) (fontspec) - 'normal' (m/n) with NFSS spec.: (fontspec) <8.5->s*[1.0001]"[latinmodern-math.otf]/OT:script=math;l anguage=dflt;"<6-8.5>s*[1.0001]"[latinmodern-math.otf]/OT:script=math;language= @@ -1097,37 +1441,37 @@ (fontspec) <->s*[1.0001]"[latinmodern-math.otf]/OT:script=math;lang uage=dflt;" LaTeX Font Info: Encoding `OMS' has changed to `TU' for symbol font -(Font) `symbols' in the math version `normal' on input line 134. +(Font) `symbols' in the math version `normal' on input line 122. LaTeX Font Info: Overwriting symbol font `symbols' in version `normal' (Font) OMS/cmsy/m/n --> TU/latinmodern-math.otf(2)/m/n on inpu -t line 134. +t line 122. LaTeX Font Info: Encoding `OMS' has changed to `TU' for symbol font -(Font) `symbols' in the math version `bold' on input line 134. +(Font) `symbols' in the math version `bold' on input line 122. LaTeX Font Info: Overwriting symbol font `symbols' in version `bold' (Font) OMS/cmsy/b/n --> TU/latinmodern-math.otf(2)/b/n on inpu -t line 134. +t line 122. -Package fontspec Info: +Package fontspec Info: (fontspec) latinmodern-math scale = 0.9999. -Package fontspec Info: +Package fontspec Info: (fontspec) latinmodern-math scale = 0.9999. -Package fontspec Info: +Package fontspec Info: (fontspec) latinmodern-math scale = 0.9999. -Package fontspec Info: +Package fontspec Info: (fontspec) latinmodern-math scale = 0.9999. -Package fontspec Info: +Package fontspec Info: (fontspec) latinmodern-math scale = 0.9999. -Package fontspec Info: +Package fontspec Info: (fontspec) Font family 'latinmodern-math.otf(3)' created for font (fontspec) 'latinmodern-math.otf' with options (fontspec) [BoldItalicFont={},ItalicFont={},SmallCapsFont={},Script @@ -1138,10 +1482,10 @@ (fontspec) 8\font =0.4pt\relax \fontdimen 9\font =2.0pt\relax (fontspec) \fontdimen 10\font =1.67pt\relax \fontdimen 11\font (fontspec) =1.11pt\relax \fontdimen 12\font =6.0pt\relax (fontspec) \fontdimen 13\font =0pt\relax }]. -(fontspec) +(fontspec) (fontspec) This font family consists of the following NFSS (fontspec) series/shapes: -(fontspec) +(fontspec) (fontspec) - 'normal' (m/n) with NFSS spec.: (fontspec) <8.5->s*[0.9999]"[latinmodern-math.otf]/OT:script=math;l anguage=dflt;"<6-8.5>s*[0.9999]"[latinmodern-math.otf]/OT:script=math;language= @@ -1153,28 +1497,28 @@ uage=dflt;" LaTeX Font Info: Encoding `OMX' has changed to `TU' for symbol font (Font) `largesymbols' in the math version `normal' on input line 1 -34. +22. LaTeX Font Info: Overwriting symbol font `largesymbols' in version `normal' (Font) OMX/cmex/m/n --> TU/latinmodern-math.otf(3)/m/n on inpu -t line 134. +t line 122. LaTeX Font Info: Encoding `OMX' has changed to `TU' for symbol font -(Font) `largesymbols' in the math version `bold' on input line 134 +(Font) `largesymbols' in the math version `bold' on input line 122 . LaTeX Font Info: Overwriting symbol font `largesymbols' in version `bold' (Font) OMX/cmex/m/n --> TU/latinmodern-math.otf(3)/b/n on inpu -t line 134. -Package hyperref Info: Link coloring ON on input line 134. +t line 122. +Package hyperref Info: Link coloring ON on input line 122. (./pieces.out) (./pieces.out) -\@outlinefile=\write3 -\openout3 = `pieces.out'. +\@outlinefile=\write4 +\openout4 = `pieces.out'. -LaTeX Info: Redefining \microtypecontext on input line 134. -Package microtype Info: Applying patch `item' on input line 134. -Package microtype Info: Applying patch `toc' on input line 134. -Package microtype Info: Applying patch `eqnum' on input line 134. -Package microtype Info: Applying patch `footnote' on input line 134. -Package microtype Info: Applying patch `verbatim' on input line 134. -LaTeX Info: Redefining \microtypesetup on input line 134. +LaTeX Info: Redefining \microtypecontext on input line 122. +Package microtype Info: Applying patch `item' on input line 122. +Package microtype Info: Applying patch `toc' on input line 122. +Package microtype Info: Applying patch `eqnum' on input line 122. +Package microtype Info: Applying patch `footnote' on input line 122. +Package microtype Info: Applying patch `verbatim' on input line 122. +LaTeX Info: Redefining \microtypesetup on input line 122. Package microtype Info: Character protrusion enabled (level 2). Package microtype Info: Using default protrusion set `alltext'. Package microtype Info: No adjustment of tracking. @@ -1186,8 +1530,8 @@ n.cfg File: mt-LatinModernRoman.cfg 2026/02/26 v1.2 microtype config. file: Latin Mod ern Roman (RS) ) -File: pals.pdf Graphic file (type pdf) - +Package caption Info: Begin \AtBeginDocument code. +Package caption Info: End \AtBeginDocument code. Package microtype Info: Loading generic protrusion settings for font family (microtype) `ywft-processing-bold' (encoding: TU). (microtype) For optimal results, create family-specific settings. @@ -1518,138 +1862,47 @@ Package microtype Info: Character `\guilsinglright ' is missing (microtype) in font `\TU/ywft-processing-bold(0)/m/n/10'. (microtype) Ignoring protrusion settings for this character. Package microtype Info: Character `\textendash ' is missing -(microtype) in font `\TU/ywft-processing-bold(0)/m/n/24'. +(microtype) in font `\TU/ywft-processing-bold(0)/m/n/28'. (microtype) Ignoring protrusion settings for this character. Package microtype Info: Character `\textemdash ' is missing -(microtype) in font `\TU/ywft-processing-bold(0)/m/n/24'. +(microtype) in font `\TU/ywft-processing-bold(0)/m/n/28'. (microtype) Ignoring protrusion settings for this character. Package microtype Info: Character `\textquoteleft ' is missing -(microtype) in font `\TU/ywft-processing-bold(0)/m/n/24'. +(microtype) in font `\TU/ywft-processing-bold(0)/m/n/28'. (microtype) Ignoring protrusion settings for this character. Package microtype Info: Character `\textquoteright ' is missing -(microtype) in font `\TU/ywft-processing-bold(0)/m/n/24'. +(microtype) in font `\TU/ywft-processing-bold(0)/m/n/28'. (microtype) Ignoring protrusion settings for this character. Package microtype Info: Character `\textquotedblleft ' is missing -(microtype) in font `\TU/ywft-processing-bold(0)/m/n/24'. +(microtype) in font `\TU/ywft-processing-bold(0)/m/n/28'. (microtype) Ignoring protrusion settings for this character. Package microtype Info: Character `\textquotedblright ' is missing -(microtype) in font `\TU/ywft-processing-bold(0)/m/n/24'. +(microtype) in font `\TU/ywft-processing-bold(0)/m/n/28'. (microtype) Ignoring protrusion settings for this character. Package microtype Info: Character `\quotesinglbase ' is missing -(microtype) in font `\TU/ywft-processing-bold(0)/m/n/24'. +(microtype) in font `\TU/ywft-processing-bold(0)/m/n/28'. (microtype) Ignoring protrusion settings for this character. Package microtype Info: Character `\quotedblbase ' is missing -(microtype) in font `\TU/ywft-processing-bold(0)/m/n/24'. +(microtype) in font `\TU/ywft-processing-bold(0)/m/n/28'. (microtype) Ignoring protrusion settings for this character. Package microtype Info: Character `\guilsinglleft ' is missing -(microtype) in font `\TU/ywft-processing-bold(0)/m/n/24'. +(microtype) in font `\TU/ywft-processing-bold(0)/m/n/28'. (microtype) Ignoring protrusion settings for this character. Package microtype Info: Character `\guilsinglright ' is missing -(microtype) in font `\TU/ywft-processing-bold(0)/m/n/24'. +(microtype) in font `\TU/ywft-processing-bold(0)/m/n/28'. (microtype) Ignoring protrusion settings for this character. -File: figures/cover.png Graphic file (type bmp) - +File: figures/cover-puzzle-masked.png Graphic file (type bmp) + Package microtype Info: Loading generic protrusion settings for font family -(microtype) `ywft-processing-light' (encoding: TU). +(microtype) `LatinModernSans' (encoding: TU). (microtype) For optimal results, create family-specific settings. (microtype) See the microtype manual for details. -Package microtype Info: Character `\textendash ' is missing -(microtype) in font `\TU/ywft-processing-light(0)/m/n/10'. -(microtype) Ignoring protrusion settings for this character. -Package microtype Info: Character `\textemdash ' is missing -(microtype) in font `\TU/ywft-processing-light(0)/m/n/10'. -(microtype) Ignoring protrusion settings for this character. -Package microtype Info: Character `\textquoteleft ' is missing -(microtype) in font `\TU/ywft-processing-light(0)/m/n/10'. -(microtype) Ignoring protrusion settings for this character. -Package microtype Info: Character `\textquoteright ' is missing -(microtype) in font `\TU/ywft-processing-light(0)/m/n/10'. -(microtype) Ignoring protrusion settings for this character. -Package microtype Info: Character `\textquotedblleft ' is missing -(microtype) in font `\TU/ywft-processing-light(0)/m/n/10'. -(microtype) Ignoring protrusion settings for this character. -Package microtype Info: Character `\textquotedblright ' is missing -(microtype) in font `\TU/ywft-processing-light(0)/m/n/10'. -(microtype) Ignoring protrusion settings for this character. -Package microtype Info: Character `\quotesinglbase ' is missing -(microtype) in font `\TU/ywft-processing-light(0)/m/n/10'. -(microtype) Ignoring protrusion settings for this character. -Package microtype Info: Character `\quotedblbase ' is missing -(microtype) in font `\TU/ywft-processing-light(0)/m/n/10'. -(microtype) Ignoring protrusion settings for this character. -Package microtype Info: Character `\guilsinglleft ' is missing -(microtype) in font `\TU/ywft-processing-light(0)/m/n/10'. -(microtype) Ignoring protrusion settings for this character. -Package microtype Info: Character `\guilsinglright ' is missing -(microtype) in font `\TU/ywft-processing-light(0)/m/n/10'. -(microtype) Ignoring protrusion settings for this character. -Package microtype Info: Character `\textendash ' is missing -(microtype) in font `\TU/ywft-processing-light(0)/m/n/11'. -(microtype) Ignoring protrusion settings for this character. -Package microtype Info: Character `\textemdash ' is missing -(microtype) in font `\TU/ywft-processing-light(0)/m/n/11'. -(microtype) Ignoring protrusion settings for this character. -Package microtype Info: Character `\textquoteleft ' is missing -(microtype) in font `\TU/ywft-processing-light(0)/m/n/11'. -(microtype) Ignoring protrusion settings for this character. -Package microtype Info: Character `\textquoteright ' is missing -(microtype) in font `\TU/ywft-processing-light(0)/m/n/11'. -(microtype) Ignoring protrusion settings for this character. -Package microtype Info: Character `\textquotedblleft ' is missing -(microtype) in font `\TU/ywft-processing-light(0)/m/n/11'. -(microtype) Ignoring protrusion settings for this character. -Package microtype Info: Character `\textquotedblright ' is missing -(microtype) in font `\TU/ywft-processing-light(0)/m/n/11'. -(microtype) Ignoring protrusion settings for this character. -Package microtype Info: Character `\quotesinglbase ' is missing -(microtype) in font `\TU/ywft-processing-light(0)/m/n/11'. -(microtype) Ignoring protrusion settings for this character. -Package microtype Info: Character `\quotedblbase ' is missing -(microtype) in font `\TU/ywft-processing-light(0)/m/n/11'. -(microtype) Ignoring protrusion settings for this character. -Package microtype Info: Character `\guilsinglleft ' is missing -(microtype) in font `\TU/ywft-processing-light(0)/m/n/11'. -(microtype) Ignoring protrusion settings for this character. -Package microtype Info: Character `\guilsinglright ' is missing -(microtype) in font `\TU/ywft-processing-light(0)/m/n/11'. -(microtype) Ignoring protrusion settings for this character. - -LaTeX Font Warning: Font shape `TU/ywft-processing-light(0)/m/sc' undefined -(Font) using `TU/ywft-processing-light(0)/m/n' instead on input li -ne 165. - LaTeX Font Info: Font shape `TU/LatinModernMono(0)/m/n' will be -(Font) scaled to size 7.65005pt on input line 165. +(Font) scaled to size 8.50006pt on input line 149. Package microtype Info: Loading generic protrusion settings for font family (microtype) `LatinModernMono' (encoding: TU). (microtype) For optimal results, create family-specific settings. (microtype) See the microtype manual for details. -Package microtype Info: Loading generic protrusion settings for font family -(microtype) `latinmodern-math.otf' (encoding: TU). -(microtype) For optimal results, create family-specific settings. -(microtype) See the microtype manual for details. -LaTeX Font Info: Font shape `TU/latinmodern-math.otf(2)/m/n' will be -(Font) scaled to size 9.00096pt on input line 165. -LaTeX Font Info: Font shape `TU/latinmodern-math.otf(2)/m/n' will be -(Font) scaled to size 6.00064pt on input line 165. -LaTeX Font Info: Font shape `TU/latinmodern-math.otf(2)/m/n' will be -(Font) scaled to size 5.00053pt on input line 165. -LaTeX Font Info: Font shape `TU/latinmodern-math.otf(3)/m/n' will be -(Font) scaled to size 8.99904pt on input line 165. -LaTeX Font Info: Font shape `TU/latinmodern-math.otf(3)/m/n' will be -(Font) scaled to size 5.99936pt on input line 165. -LaTeX Font Info: Font shape `TU/latinmodern-math.otf(3)/m/n' will be -(Font) scaled to size 4.99947pt on input line 165. - -LaTeX Font Warning: Font shape `TU/LatinModernRoman(0)/m/sc' undefined -(Font) using `TU/LatinModernRoman(0)/m/n' instead on input line 16 -5. - - -Underfull \hbox (badness 1259) in paragraph at lines 169--170 -\TU/LatinModernRoman(0)/m/n/10 the basic object a “computable number” produced - [] - Underfull \vbox (badness 10000) has occurred while \output is active [] @@ -1660,102 +1913,94 @@ [1 ] -Underfull \hbox (badness 4940) in paragraph at lines 181--182 -[]\TU/LatinModernRoman(0)/m/sc/10 This duality—spec and performance, text and +Underfull \hbox (badness 3861) in paragraph at lines 161--162 +[]\TU/LatinModernRoman(0)/m/n/10 This vocabulary is constitutive, not decorativ +e. [] - +(./figures/piece-contract-tikz.tex) Underfull \vbox (badness 10000) has occurred while \output is active [] LaTeX Font Info: Font shape `TU/LatinModernMono(0)/m/n' will be -(Font) scaled to size 8.50006pt on input line 209. +(Font) scaled to size 7.65005pt on input line 190. -Overfull \hbox (27.12282pt too wide) in paragraph at lines 223--224 -[]\TU/LatinModernRoman(0)/m/sc/10 Every piece is addressable by URL: \TU/LatinM -odernMono(0)/m/n/10 aesthetic.computer/piece- - [] +(./figures/piece-lifecycle-tikz.tex +LaTeX Font Info: Font shape `TU/LatinModernMono(0)/m/n' will be +(Font) scaled to size 5.95004pt on input line 29. +) +File: figures/ac-notepat-crop.png Graphic file (type bmp) + +LaTeX Font Info: Font shape `TU/LatinModernMono(0)/m/n' will be +(Font) scaled to size 6.80005pt on input line 205. + [2] +File: figures/ac-dannom-play.png Graphic file (type bmp) + +File: figures/ac-kidlisp-fia-birthday-live.png Graphic file (type bmp) + +Underfull \vbox (badness 10000) has occurred while \output is active [] -Underfull \vbox (badness 10000) has occurred while \output is active [] +File: figures/ac-list-crop.png Graphic file (type bmp) + - [2] Underfull \vbox (badness 10000) has occurred while \output is active [] - -Underfull \hbox (badness 1810) in paragraph at lines 247--248 -[]\TU/LatinModernRoman(0)/m/sc/10 Norman’s affordance concept [[][]Norman[][], -[][]1988[][]] de- - [] + [3] +File: figures/ac-profile-sat-live.png Graphic file (type bmp) + +File: figures/ac-laer-klokken-live.png Graphic file (type bmp) + +Underfull \vbox (badness 10000) has occurred while \output is active [] -Underfull \hbox (badness 10000) in paragraph at lines 267--267 -[]|\TU/LatinModernRoman(0)/m/n/9 Compile, run, - [] +(./figures/piece-ecology-tikz.tex) +Underfull \vbox (badness 10000) has occurred while \output is active [] -Underfull \hbox (badness 10000) in paragraph at lines 267--267 -[]|\TU/LatinModernRoman(0)/m/n/9 Owner + [4] +[5] +Underfull \hbox (badness 3407) in paragraph at lines 308--309 +[]\TU/LatinModernRoman(0)/m/n/10 AC Native introduces a second relation. Its [] -Underfull \hbox (badness 10000) in paragraph at lines 267--267 -[]|\TU/LatinModernRoman(0)/m/n/9 Executable bi- +Underfull \hbox (badness 5533) in paragraph at lines 308--309 +\TU/LatinModernRoman(0)/m/n/10 QuickJS/C environment implements a compatible [] -Underfull \hbox (badness 10000) in paragraph at lines 267--267 -[]|\TU/LatinModernRoman(0)/m/n/9 Revise, repub- - [] +Underfull \vbox (badness 10000) has occurred while \output is active [] -Underfull \hbox (badness 10000) in paragraph at lines 267--267 -[]|\TU/LatinModernRoman(0)/m/n/9 Never (only - [] +Underfull \vbox (badness 10000) has occurred while \output is active [] -[3] -Underfull \hbox (badness 3646) in paragraph at lines 280--281 -[]\TU/LatinModernRoman(0)/b/n/10 The browser as concert hall. \TU/LatinModernRo -man(0)/m/sc/10 The web - [] + [6] +[7] +Underfull \vbox (badness 10000) has occurred while \output is active [] + [8] (./pieces.bbl -Underfull \vbox (badness 10000) has occurred while \output is active [] - LaTeX Font Info: Font shape `TU/latinmodern-math.otf(2)/m/n' will be -(Font) scaled to size 10.00107pt on input line 18. -LaTeX Font Info: Font shape `TU/latinmodern-math.otf(2)/m/n' will be -(Font) scaled to size 7.00075pt on input line 18. +(Font) scaled to size 8.00085pt on input line 11. LaTeX Font Info: Font shape `TU/latinmodern-math.otf(3)/m/n' will be -(Font) scaled to size 9.99893pt on input line 18. -LaTeX Font Info: Font shape `TU/latinmodern-math.otf(3)/m/n' will be -(Font) scaled to size 6.99925pt on input line 18. - -Underfull \hbox (badness 4084) in paragraph at lines 103--109 -[]\TU/LatinModernRoman(0)/m/sc/10 Mitchel Resnick, John Maloney, Andrés Monroy- - - [] - -[4]) [5 +(Font) scaled to size 7.99915pt on input line 11. +) [9 ] (./pieces.aux) *********** LaTeX2e <2025-11-01> L3 programming layer <2026-01-19> *********** - - -LaTeX Font Warning: Some font shapes were not available, defaults substituted. - Package rerunfilecheck Info: File `pieces.out' has not changed. -(rerunfilecheck) Checksum: FFC7A8B5185FED0E5676AFDCEBE2AD5B;2826. - ) +(rerunfilecheck) Checksum: 874CD0F397AC4565C4B473994DF32C2D;3324. + ) Here is how much of TeX's memory you used: - 19701 strings out of 469709 - 365389 string characters out of 5466024 - 971640 words of memory out of 5000000 - 48076 multiletter control sequences out of 15000+600000 - 630276 words of font info for 100 fonts, out of 8000000 for 9000 + 31829 strings out of 469709 + 629471 string characters out of 5466024 + 1229724 words of memory out of 5000000 + 59963 multiletter control sequences out of 15000+600000 + 630535 words of font info for 103 fonts, out of 8000000 for 9000 319 hyphenation exceptions out of 8191 - 86i,12n,107p,1003b,668s stack positions out of 10000i,1000n,20000p,200000b,200000s + 88i,11n,107p,1134b,915s stack positions out of 10000i,1000n,20000p,200000b,200000s -Output written on pieces.pdf (5 pages). +Output written on pieces.pdf (9 pages). diff --git a/papers/arxiv-pieces/pieces.tex b/papers/arxiv-pieces/pieces.tex --- a/papers/arxiv-pieces/pieces.tex +++ b/papers/arxiv-pieces/pieces.tex @@ -1,295 +1,372 @@ -% !TEX program = xelatex -\documentclass[10pt,letterpaper,twocolumn]{article} - -% === GEOMETRY === -\usepackage[top=0.75in, bottom=0.75in, left=0.75in, right=0.75in]{geometry} - -% === FONTS === -\usepackage{fontspec} -\usepackage{unicode-math} -\setmainfont{Latin Modern Roman}[ - Extension=.otf, - UprightFont=lmroman10-regular, - BoldFont=lmroman10-bold, - ItalicFont=lmroman10-italic, - BoldItalicFont=lmroman10-bolditalic, -] -\setsansfont{Latin Modern Sans}[ - Extension=.otf, - UprightFont=lmsans10-regular, - BoldFont=lmsans10-bold, - ItalicFont=lmsans10-oblique, - BoldItalicFont=lmsans10-boldoblique, -] -\setmonofont{Latin Modern Mono}[ - Extension=.otf, - UprightFont=lmmono10-regular, - ItalicFont=lmmono10-italic, - Scale=0.85, -] -\newfontfamily\acbold{ywft-processing-bold}[ - Path=../../system/public/type/webfonts/, - Extension=.ttf -] -\newfontfamily\aclight{ywft-processing-light}[ - Path=../../system/public/type/webfonts/, - Extension=.ttf -] - -% === PACKAGES === -\usepackage{xcolor} -\usepackage{titlesec} -\usepackage{enumitem} -\usepackage{booktabs} -\usepackage{tabularx} -\usepackage{fancyhdr} -\usepackage{hyperref} -\usepackage{xurl} -\usepackage{graphicx} -\usepackage{ragged2e} -\usepackage{microtype} -\usepackage{natbib} -\usepackage[colorspec=0.92]{draftwatermark} - -% === COLORS === -\definecolor{acpink}{RGB}{180,72,135} -\definecolor{acpurple}{RGB}{120,80,180} -\definecolor{acdark}{RGB}{64,56,74} -\definecolor{acgray}{RGB}{119,119,119} -\definecolor{draftcolor}{RGB}{180,72,135} - -% === DRAFT WATERMARK === -\DraftwatermarkOptions{ - text=WORKING DRAFT, - fontsize=3cm, - color=draftcolor!18, - angle=45, - pos={0.5\paperwidth, 0.5\paperheight} -} - -% === HYPERREF === -\hypersetup{ - colorlinks=true, - linkcolor=acpurple, - urlcolor=acpurple, - citecolor=acpurple, - pdfauthor={@jeffrey}, - pdftitle={Pieces Not Programs}, -} - -% === SECTION FORMATTING === -\titleformat{\section} - {\normalfont\bfseries\normalsize\uppercase} - {\thesection.} - {0.5em} - {} -\titlespacing{\section}{0pt}{1.2em}{0.3em} - -\titleformat{\subsection} - {\normalfont\bfseries\small} - {\thesubsection} - {0.5em} - {} -\titlespacing{\subsection}{0pt}{0.8em}{0.2em} - -% === HEADER/FOOTER === -\pagestyle{fancy} -\fancyhf{} -\renewcommand{\headrulewidth}{0pt} -\fancyhead[C]{\footnotesize\color{draftcolor}\textit{Working Draft --- not for citation}} -\fancyfoot[C]{\footnotesize\thepage} - -% === LIST SETTINGS === -\setlist[itemize]{nosep, leftmargin=1.2em, itemsep=0.1em} -\setlist[enumerate]{nosep, leftmargin=1.2em} - -% === COLUMN SEPARATION === -\setlength{\columnsep}{1.8em} - -% === PARAGRAPH SETTINGS === -\setlength{\parindent}{1em} -\setlength{\parskip}{0.3em} - -% Hyphenation for narrow two-column layout -\tolerance=800 -\emergencystretch=1em -\hyphenpenalty=50 - -\newcommand{\acdot}{{\color{acpink}.}} -\newcommand{\ac}{\textsc{Aesthetic.Computer}} -% Random caps for Aesthetic.Computer branding -\newcount\acrandtmp -\newcommand{\acrandletter}[2]{% - \acrandtmp=\uniformdeviate 2\relax - \ifnum\acrandtmp=0\relax#1\else#2\fi% -} -\newcommand{\acrandname}{% - \acrandletter{a}{A}\acrandletter{e}{E}\acrandletter{s}{S}\acrandletter{t}{T}% - \acrandletter{h}{H}\acrandletter{e}{E}\acrandletter{t}{T}\acrandletter{i}{I}% - \acrandletter{c}{C}{\color{acpink}.}\acrandletter{c}{C}\acrandletter{o}{O}% - \acrandletter{m}{M}\acrandletter{p}{P}\acrandletter{u}{U}\acrandletter{t}{T}% - \acrandletter{e}{E}\acrandletter{r}{R}% -} - -\begin{document} - -\twocolumn[{% -\begin{center} -\includegraphics[height=4em]{pals}\par\vspace{0.5em} -{\acbold\fontsize{24pt}{28pt}\selectfont\color{acdark} Pieces Not Programs}\par -\includegraphics[width=0.5\textwidth]{figures/cover}\par -\vspace{0.35em} -\vspace{0.2em} -{\aclight\fontsize{11pt}{13pt}\selectfont\color{acpink} The Piece as a Unit of Creative Cognition in \ac{}}\par -\vspace{0.6em} -{\normalsize\href{https://prompt.ac/@jeffrey}{@jeffrey}}\par -{\small\color{acgray} Aesthetic.Computer}\par -{\small\color{acgray} ORCID: \href{https://orcid.org/0009-0007-4460-4913}{0009-0007-4460-4913}}\par -\vspace{0.3em} -{\small\color{acpurple} \url{https://aesthetic.computer}}\par -\vspace{0.6em} -\rule{\textwidth}{1.5pt} -\vspace{0.5em} -\end{center} - -\begin{center} -{\small\color{draftcolor}\textbf{[ working draft --- not for citation ]}} -\end{center} -\vspace{0.3em} - -\begin{quote} -\small\noindent\textbf{Abstract.} -\ac{} calls its interactive programs \emph{pieces}---not sketches, patches, scripts, or apps. The choice of word is not cosmetic. It imports a framework from music and visual art in which a creative work is an \emph{authored, performable, iterable artifact} rather than a specification for a machine. I trace the genealogy of the piece as a computational unit, comparing it to the Processing sketch, the Max/MSP patch, the Unix process, the Smalltalk image, and the Jupyter notebook. Then I examine what AC pieces actually are: single-file URL-addressable programs with a lifecycle (\texttt{boot}, \texttt{paint}, \texttt{act}, \texttt{sim}, \texttt{leave}) that mirrors the perception-action cycle of embodied agents~\citep{varela1991embodied}. Naming and designing around the piece concept---rather than the program concept---produces qualitatively different relationships between authors, platforms, and audiences: one in which software is practiced, not just deployed. -\end{quote} -\vspace{0.5em} -}] - -\section{Introduction} - -When Turing defined computation in 1936, he called the basic object a ``computable number'' produced by a ``machine''~\citep{turing1936computable}. The word \emph{program}---from the Greek \emph{programma}, a public notice or written announcement---entered computing as a natural extension of this bureaucratic framing: a program is a set of instructions, a plan to be executed. Programs are correct or incorrect. They compile or they don't. They are deployed, not performed. - -The history of creative computing has been, in part, a slow erosion of this framing. The Processing sketch~\citep{reas2007processing} replaced the program with something disposable and exploratory. The Max/MSP patch~\citep{puckette1988patcher} replaced the source file with a living dataflow graph. Scratch~\citep{resnick2009scratch} replaced the instruction sequence with snapping blocks. Each renaming was also a redesign: the affordances of the new unit carried new assumptions about who makes things, how long they take, and what counts as done. - -\ac{} calls its programs \emph{pieces}~\citep{scudder2026ac}. This paper examines why that matters. - -\section{Genealogy of Creative Units} - -\subsection{The musical piece} - -In music, a \emph{piece} is a complete, authored work that exists in at least two forms: the score (symbolic specification) and the performance (instantiation). Langer's \emph{Feeling and Form}~\citep{langer1953feeling} argues that art is not self-expression but the creation of ``forms symbolic of human feeling''---the piece is the form, not the feeling; it is a crafted artifact that \emph{presents} experience rather than \emph{reports} it. These are not identical---performances differ, scores are interpreted, and pieces accumulate variants over time. A piece is never exhausted by any single performance. It is also socially situated: pieces have names, authors, premieres, and canons. They are taught, analyzed, covered, and forgotten. - -This duality---spec and performance, text and enactment---is absent from the program concept. A program is typically identified with its source (or binary). Its ``performance'' is incidental; what matters is whether the output is correct. Nelson's \emph{Computer Lib / Dream Machines}~\citep{nelson1974computerlib} envisioned a different relationship: the computer as a medium for dream-like interactive exploration, where the boundary between author and audience dissolves. - -\subsection{The Processing sketch} - -The Processing sketch~\citep{reas2007processing} imported the artistic register deliberately. The word \emph{sketch} signals incompleteness, exploration, and iteration. Sketches are not deployed; they are shared, built upon, or abandoned. The Processing environment reinforced this with an IDE organized around writing, running, and exporting---a cycle closer to drawing than engineering. - -The sketch model had limits: sketches are local files, not URLs. They are not socially distributed by the platform. Their lifecycle ends at the window close. - -\subsection{The Max/MSP patch} - -Max's \emph{patch}~\citep{puckette1988patcher,puckette1996pure} is a dataflow graph rather than a control-flow sequence. Patches are constructed spatially, through connection rather than inscription. They are inherently performative: a Max patch is not meaningful until it is running, and its behavior is defined by the live data flowing through it. This makes patches closer to instruments than to programs---they are played, not executed. - -Patches are also ephemeral in a specific way: closing a patch and reopening it returns the same structure, but the performance state is gone. The patch is the score; the running instance is the performance. - -\subsection{The Unix process} - -The Unix \emph{process} is a different concept: not the source code or the binary, but the running instance---a program in execution, with its own address space, file descriptors, and PID. The process is the performance, not the score. Unix philosophy (one tool, one job, composition via pipes) treats processes as combinable primitives rather than authored works. The process has no author in the artistic sense; it has an owner in the administrative sense. - -The Unix model is powerful and enduring, but it points in the opposite direction from the piece concept: toward correctness, composability, and system administration rather than authorship, iteration, and expression. - -\subsection{The Smalltalk image and Jupyter notebook} - -Alan Kay's Smalltalk~\citep{kay1977personal} proposed a different unit: the \emph{image}---a persistent, living snapshot of all objects and their state. The image is not a file to be compiled; it is a world to be inhabited. Objects are sent messages; the environment responds. This is closer to the piece concept than to the program concept, but it has not transferred to mainstream creative coding because it resists the file-as-unit paradigm of modern operating systems. - -The Jupyter notebook occupies an interesting middle position: it is a document (narrative) and a process (executable cells) simultaneously. Notebooks are closer to essays than to programs, but their unit is the cell, not the whole. - -\section{What AC Pieces Are} - -An \ac{} piece is a single \texttt{.mjs} or \texttt{.lisp} file exporting lifecycle functions: - -\begin{itemize} - \item \texttt{boot} --- initialization, runs once on load, - \item \texttt{paint} --- rendering, runs every frame, - \item \texttt{act} --- event handling (input, network, time), - \item \texttt{sim} --- simulation logic, runs every frame, - \item \texttt{leave} --- cleanup on exit. -\end{itemize} - -This lifecycle is not arbitrary. It maps onto the perception-action cycle described by enactive cognitive science~\citep{varela1991embodied}: an agent perceives its environment (\texttt{act}), updates its internal state (\texttt{sim}), and produces an output (\texttt{paint}). The piece is not a specification for a computation; it is a description of an ongoing cognitive process. - -\subsection{URL addressability} - -Every piece is addressable by URL: \texttt{aesthetic.computer/piece-name}. This makes pieces memorizable paths rather than files. The URL namespace is the piece namespace. Users discover pieces the way they discover musical works---by name, by reference, by navigating a collection. This is what distinguishes AC from IDEs and sandboxes: the piece is social before it is technical. - -\subsection{The immediate-mode rendering model} - -\ac{} uses immediate-mode graphics: \texttt{paint} is called every frame, and the screen is rebuilt from scratch. There is no retained scene graph, no persistent DOM, no object model. This is cognitively significant: the piece is a pure function of time and state, not an accumulated structure. The programmer thinks in terms of \emph{what to draw now}, not \emph{what to change}. This aligns with how musicians think during performance---not ``what note am I holding'' but ``what do I play next.'' The \emph{10 PRINT} project~\citep{montfort201210print} demonstrated that even a single line of procedural code can be the basis for rich aesthetic investigation; AC's immediate-mode model extends this insight to interactive, real-time programs. - -\subsection{Authorship and iteration} - -\citet{ingold2010textility} argues that making is not the imposition of form on matter but a process of ``following the materials''---a textility, a weaving of practitioner and medium. The piece, in AC, is the record of this weaving: not a finished product but a trace of ongoing practice. Stiegler's concept of \emph{idiotext}---the singular memory woven through technical prostheses~\citep{staunaes2021stiegler, stiegler1998technics}---describes exactly what a piece becomes over time: an externalized memory, irreducibly personal, that grows through iteration rather than deployment. - -Pieces have authors. They are published by name, attributed, and fork-able via the \texttt{source} command. When a user issues \texttt{source piece-name}, they receive a copy of the piece's source and become a new author. This is the musical concept of covering: taking an existing work and making it yours. - -The iteration cycle---write, publish, play, observe, revise---is closer to musical composition or visual art practice than to software deployment. \citet{mcpherson2020idiomatic} document how computer music languages shape aesthetic outcomes through the idioms they make easy; the piece lifecycle shapes what gets made by making iterative, performative development the path of least resistance. - -\section{The Piece as Cognitive Extension} - -Clark and Chalmers argue that cognitive processes can extend into external tools when those tools are reliably used, available, and endorsed~\citep{clark1998extended}. A notebook used for thinking is part of the cognitive system; the content in the notebook is as much ``in the mind'' as a remembered fact. - -A piece, played regularly, becomes a cognitive extension in exactly this sense. \texttt{notepat}~\citep{scudder2026notepat} is not just a tool Jeffrey uses to make sounds; it is a prosthetic for musical thinking. The 401 commits to \texttt{notepat} are not bug fixes to a utility---they are refinements to a cognitive organ. The piece and its author co-evolve. - -This is what \citet{magnusson2010designing} describes as the epistemic dimension of musical instruments: instruments are ``epistemic tools'' that shape what their players can think and express. Digital instruments differ from acoustic ones in that they are software---and software can be modified by its user. When the tool is a piece and its author is also its player, the boundary between instrument and composition dissolves. - -\subsection{The named path as mnemonic} - -Norman's affordance concept~\citep{norman1988design} describes how objects signal their possible uses. A URL-addressable piece affords memorization and return: the name \texttt{notepat} is not just an identifier but a mnemonic, a path back to a practice. The AC namespace is a mnemonic landscape. Users who know \texttt{wrl}, \texttt{grid}, \texttt{clock}, \texttt{prompt} have internalized a map of possible experiences. This is how musical canons work: the names of pieces are handles for practices, not just labels for files. - -\section{Pieces vs.\ Programs: A Comparison} - -\begin{table}[h] -\small -\centering -\begin{tabularx}{\columnwidth}{l X X} -\toprule - & \textbf{Program} & \textbf{Piece} \\ -\midrule -Unit of evaluation & Correctness & Expression \\ -Primary lifecycle & Compile, run, exit & Boot, play, leave \\ -Authorship & Owner (user/root) & Artist \\ -Social unit & Executable binary & Named URL \\ -Iteration mode & Debug, redeploy & Revise, republish \\ -Relationship to time & Terminates & Ongoing \\ -Forking metaphor & Fork (git) & Cover (music) \\ -Done when & Tests pass & Never (only abandoned) \\ -\bottomrule -\end{tabularx} -\caption{Program vs.\ piece as creative units.} -\label{tab:comparison} -\end{table} - -\section{Implications for Platform Design} - -The piece concept has concrete design consequences for \ac{}: - -\begin{enumerate} - \item \textbf{One file, one piece.} No project structure, no build system, no dependencies outside the platform API. The piece is self-contained because a musical piece is self-contained. - \item \textbf{The prompt as instrument.} The AC command prompt is the interface through which pieces are navigated, played, and published. It is designed to be memorized, like a keyboard layout---not configured, like a GUI. - \item \textbf{Publishing as performance.} Issuing \texttt{publish} is an act of authorship, not deployment. The piece enters the namespace immediately and is playable by anyone with the URL. - \item \textbf{The browser as concert hall.} The web browser~\citep{wyse2013viability} is the performance space. It is already present on every device; it is already social. Pieces play in the browser not because it is technically optimal but because it is the most accessible performance venue that exists. - \item \textbf{Live coding as rehearsal.} The module loader hot-reloads pieces on save. This is not a developer convenience---it is the equivalent of practicing at tempo: the author plays the piece as it changes, not after. -\end{enumerate} - -\section{Conclusion} - -Calling interactive programs \emph{pieces} is a design claim: it asserts that the right framework for thinking about creative software is not the engineering framework (correctness, deployment, maintenance) but the artistic one (authorship, iteration, performance, practice). The Processing sketch made a similar claim in 2001. Max's patch made it in 1988. AC's piece makes it again, in a browser, with a URL namespace, in 2024. - -Each reframing carries different affordances. The piece concept in AC foregrounds: memorizable URLs over installable apps, immediate-mode rendering over retained scene graphs, lifecycle functions over event listeners, and covering over forking. These are not just naming choices---they are constraints that, as \citet{magnusson2010designing} argues, enable by delimiting. Shklovsky called this \emph{defamiliarization}~\citep{shklovsky1917art}: art works by making the familiar strange, by forcing perception where habit would otherwise let us sleepwalk. Calling programs ``pieces'' is a defamiliarizing move---it disrupts the engineering frame and forces the user to perceive the creative act. Adorno's essay on punctuation~\citep{adorno1990punctuation} makes a parallel argument: that formal choices in notation are not cosmetic but constitutive---they shape what can be thought. - -The deeper claim is that software designed around the piece concept treats \emph{practice} as the fundamental relationship between a user and a program. Not installation, not configuration, not deployment---practice. The piece is something you return to, refine, and perform. It extends your cognition~\citep{clark1998extended} precisely because it is stable enough to be learned and flexible enough to grow. - -\bibliographystyle{plainnat} -\bibliography{references} - -\end{document} +% !TEX program = xelatex +\documentclass[10pt,letterpaper,twocolumn]{article} + +% === PAGE === +\usepackage[top=0.75in,bottom=0.75in,left=0.75in,right=0.75in,footskip=12pt]{geometry} + +% === FONTS === +\usepackage{fontspec} +\usepackage{unicode-math} +\setmainfont{Latin Modern Roman}[ + Extension=.otf, + UprightFont=lmroman10-regular, + BoldFont=lmroman10-bold, + ItalicFont=lmroman10-italic, + BoldItalicFont=lmroman10-bolditalic, +] +\setsansfont{Latin Modern Sans}[ + Extension=.otf, + UprightFont=lmsans10-regular, + BoldFont=lmsans10-bold, + ItalicFont=lmsans10-oblique, + BoldItalicFont=lmsans10-boldoblique, +] +\setmonofont{Latin Modern Mono}[ + Extension=.otf, + UprightFont=lmmono10-regular, + ItalicFont=lmmono10-italic, + Scale=0.85, +] +% Match the Keymaps paper: YWFT Processing appears only in the title. +\newfontfamily\acbold{ywft-processing-bold}[ + Path=../../system/public/type/webfonts/, + Extension=.ttf +] + +% === PACKAGES === +\usepackage{xcolor} +\usepackage{titlesec} +\usepackage{enumitem} +\usepackage{booktabs} +\usepackage{tabularx} +\usepackage{fancyhdr} +\usepackage{hyperref} +\usepackage{xurl} +\usepackage{graphicx} +\graphicspath{{./}{figures/}} +\usepackage{ragged2e} +\usepackage{microtype} +\usepackage[font=footnotesize]{caption} +\usepackage{natbib} +\usepackage{multicol} +\usepackage{tikz} +\usetikzlibrary{arrows.meta,calc,positioning} + +% === COLORS === +\definecolor{acpink}{RGB}{180,72,135} +\definecolor{acpurple}{RGB}{120,80,180} +\definecolor{acdark}{RGB}{64,56,74} +\definecolor{acgray}{RGB}{119,119,119} +\definecolor{acblue}{RGB}{42,100,212} + +% === LINKS === +\hypersetup{ + colorlinks=true, + linkcolor=acpurple, + urlcolor=acpurple, + citecolor=acpurple, + pdfauthor={@jeffrey}, + pdftitle={Pieces Not Programs: The Piece as a Versioned, Performable Unit of Creative Computing}, +} + +% === SECTIONS === +\titleformat{\section} + {\normalfont\bfseries\normalsize\uppercase} + {\thesection.} + {0.5em} + {} +\titlespacing{\section}{0pt}{1.2em}{0.3em} +\titleformat{\subsection} + {\normalfont\bfseries\small} + {\thesubsection} + {0.5em} + {} +\titlespacing{\subsection}{0pt}{0.8em}{0.2em} + +% === RUNNING MATTER === +\pagestyle{fancy} +\fancyhf{} +\renewcommand{\headrulewidth}{0pt} +\fancyfoot[C]{\footnotesize\thepage} +\setlist[itemize]{nosep,leftmargin=1.2em,itemsep=0.1em} +\setlist[enumerate]{nosep,leftmargin=1.2em} +\setlength{\columnsep}{1.8em} +\setlength{\parindent}{1em} +\setlength{\parskip}{0.3em} +\tolerance=800 +\emergencystretch=1em +\hyphenpenalty=50 + +\newcommand{\acdot}{{\color{acpink}.}} +\newcommand{\ac}{Aesthetic{\acdot}Computer} +\newcommand{\code}[1]{\texttt{#1}} +\newcommand{\datum}[1]{\textbf{\color{acdark}#1}} +\newcommand{\coverplate}{% + \begin{tikzpicture} + \node[inner sep=0pt,outer sep=0pt,rounded corners=18pt,fill=acpink!6] + {\includegraphics[width=0.66\textwidth]{figures/cover-puzzle-masked}}; + \end{tikzpicture}% +} + +% Version macros are stamped by papers/cli.mjs. The fallback keeps a manual +% xelatex build working before the papermill has generated version.tex. +\InputIfFileExists{version}{}{% + \providecommand{\paperhash}{dev}\providecommand{\paperrev}{?}} + +% Cover language bar, matching Keymaps. These are sibling editions, not +% language names, so the Latin-only cover font can render the row everywhere. +\newcommand{\plang}[2]{\href{https://papers.aesthetic.computer/pieces-not-programs-26-arxiv#2.pdf}{\color{acgray}#1}} +\newcommand{\planghere}[1]{{\bfseries\color{acpink}#1}} +\newcommand{\plangsep}{{\color{acgray!60}\,\textperiodcentered\,}} + +\begin{document} + +\twocolumn[{% +\begin{center} +{\acbold\fontsize{28pt}{32pt}\selectfont\color{acdark} Pieces Not Programs}\par +\vspace{0.45em} +\coverplate\par +\vspace{0.35em} +{\itshape\fontsize{14.5pt}{17pt}\selectfont\color{acpink} The Piece as a Versioned, Performable Unit of Creative Computing}\par +\vspace{0.5em} +{\small\color{acgray} \href{https://prompt.ac/@jeffrey}{@jeffrey} \,\textperiodcentered\, Aesthetic{\acdot}Computer \,\textperiodcentered\, ORCID \href{https://orcid.org/0009-0007-4460-4913}{0009-0007-4460-4913}}\par +\vspace{0.45em} +{\footnotesize\sffamily\planghere{EN}\plangsep\plang{DA}{-da}\plangsep\plang{ES}{-es}\plangsep\plang{ZH}{-zh}\plangsep\plang{JA}{-ja}}\par +\vspace{0.55em} +{\color{acpink}\rule{\textwidth}{1.2pt}} +\vspace{0.5em} +\end{center} + +\begin{quote} +\small\noindent\textbf{Abstract.} +\ac{} calls an interactive work a \emph{piece}. The term once described a compact browser program; the August 2026 monorepo shows a larger object. Its 477 built-in entry files include musical instruments, drawing tools, multiplayer worlds, media decks, prompts, and containers. Forty-one percent of the JavaScript entries import shared modules; some depend on authoritative servers; the loader also adapts KidLisp, p5-style JavaScript, and Lua. ``One file, one piece'' is therefore no longer an implementation fact. I define the piece instead as a four-part contract: an authored entry, a named address, a performance instance governed by lifecycle hooks, and a durable record of source, attribution, media, and runs. A piece may execute unchanged in the browser, Electron, or a standalone pack, or persist by contract through a native port. Repository and Platter evidence shows how this unit organizes authorship, composition, security, observability, and return. The contribution is not a synonym for \emph{program}, but a unit of analysis for software whose identity must survive execution, revision, and performance. +\end{quote} +\vspace{0.5em} +}] + +\section{An Entry, Not a Container} + +The smallest public instruction for making something in \ac{} is still simple: write one \code{.mjs} or \code{.lisp} file, drag or save it into the runtime, and issue \code{publish}. The result receives a path. A built-in work such as \code{notepat} resolves at \code{aesthetic.computer/notepat}; a published work resolves beneath an author's handle, such as \code{@bash/hub}. The \code{source} command returns code for study or revision. This is why \ac{} calls the work a \emph{piece}: it has a name, an author, repeatable performances, and variants. + +The earlier version of this paper treated that entry file as the whole object. The monorepo no longer permits that simplification. At commit \code{c65015fe25}, 188 of 455 JavaScript piece entries import shared modules. \code{arena.mjs} shares movement physics with an authoritative session server; thin audiovisual pieces import a common \code{pads.mjs} engine; \code{prompt.mjs} is roughly ten thousand lines and imports other pieces and libraries. One file remains the front door, not the building~\citep{acrepo2026}. + +This distinction repairs the title's rhetoric. A piece is not the opposite of a program. It is implemented by programs. \emph{Program} names executable instructions and their dependency graph; \emph{piece} names the authored, addressable work that persists while those instructions change. Tests, correctness, maintenance, and security remain necessary. The artistic term identifies a different boundary. + +\section{Related Creative Units} + +Creative-computing systems have repeatedly renamed their unit of work. Processing's \emph{sketch} imports provisional drawing practice into the IDE~\citep{reas2007processing}. Max's \emph{patch} names both a spatial dataflow graph and something continually changed while live~\citep{puckette1988patcher,puckette1996pure}. Smalltalk's \emph{image} is a persistent object world rather than a source bundle~\citep{kay1977personal}. Live coding treats source as a changing performance condition rather than a prelude to a finished executable~\citep{blackwell2022livecoding}. + +The \emph{piece} takes a different cut through the same field. From music it inherits the distinction between score and performance: no rendition exhausts the work, but renditions remain recognizable as instances of it. From visual art it inherits attribution, title, edition, and the possibility of a practice accumulating around one work. From the web it inherits address and circulation. From Unix it inherits the practical value of small named things that can be composed, while declining to equate the running process with the work. + +This vocabulary is constitutive, not decorative. Langer's account of art as the making of symbolic form locates the work in an authored form rather than in a reported feeling~\citep{langer1953feeling}. Magnusson shows that digital instruments shape thought through their designed constraints~\citep{magnusson2010designing}; McPherson and Tah{\i}ro{\u{g}}lu show that programming-language idioms influence musical outcomes~\citep{mcpherson2020idiomatic}. Naming the unit changes what the platform makes easy to return to, share, and recognize. + +\section{The Four-Part Contract} + +Figure~\ref{fig:contract} separates four layers that casual use collapses into the word \emph{piece}. + +\begin{figure*}[t] +\centering +\resizebox{\textwidth}{!}{\input{figures/piece-contract-tikz}} +\caption{The piece contract. An authored entry resolves to a named address; invocation creates a performance instance; attribution and observation leave a durable record. Browser, Electron, and standalone packs can execute the same source. Native implementations may preserve the contract without sharing the implementation.} +\label{fig:contract} +\end{figure*} + +\subsection{Authored entry} + +The entry is the smallest source artifact the runtime can resolve. Built-ins are currently \code{.mjs} or \code{.lisp}; the loader can also adapt dropped or fetched p5-style \code{.js} and Lua \code{.lua} sources. The entry may import libraries, call platform services, or coordinate server state. Its unity is editorial and addressable, not dependency-free. + +\subsection{Named address} + +An entry becomes a piece when the system gives it a returnable name. Built-ins occupy top-level paths; published works occupy handle namespaces; KidLisp records use compact \code{\$code} addresses. Colon parameters specialize an invocation without creating another project: \code{share:notepat} asks one piece to act on another. The prompt and URL parser accept the same notation, making a typed command a portable score~\citep{scudder2026url}. + +\subsection{Performance instance} + +Invocation creates a run. The module may export \code{boot}, \code{act}, \code{sim}, \code{paint}, and \code{leave}; extended hooks include \code{beat}, \code{receive}, \code{preview}, and \code{meta}. Each is optional because the runtime supplies defaults. \code{act} receives input and system events, \code{sim} advances state independently of display refresh, and \code{paint} renders when a frame is needed. The newer \code{needsPaint()} path lets a quiet piece stop redrawing until an event invalidates its image. The lifecycle is therefore a performance protocol, not a mandate to animate forever~\citep{scudder2026api}. + +The API arrives by parameter injection. A function declares the capabilities it uses by destructuring them: + +\begin{center} +\small\ttfamily +function paint(\{ wipe, ink, circle, screen \}) +\end{center} + +This signature is both a working interface and a partial dependency manifest. Graphics, sound, input, storage, networking, navigation, gamepads, camera, and system services can share one calling convention while remaining under runtime control. + +\begin{figure*}[t] +\centering +\resizebox{\textwidth}{!}{\input{figures/piece-lifecycle-tikz}} +\caption{The runtime lifecycle is a score with optional movements. A piece may implement only the hooks it needs; the host supplies defaults, controls repainting, and performs cleanup when the work ends.} +\label{fig:lifecycle} +\end{figure*} + +\begin{figure*}[t] +\centering +\includegraphics[width=\textwidth]{figures/ac-notepat-crop} +\caption{\code{notepat} running at \code{aesthetic.computer/notepat}, captured August 5, 2026. The address resolves directly to a performance surface; visible pitch names and QWERTY bindings make learned action part of the work.} +\label{fig:notepat} +\end{figure*} + +\subsection{Durable record} + +The source and its author outlive any run. Publication records, handle paths, generated previews, paintings, tapes, and source retrieval allow a piece to circulate in forms other than a live tab. Since 2026, each load also receives a fresh \code{pieceId}; the runtime batches console events and records start, log, error, and completion phases in an admin-readable collection. A run is evidence about a piece, not the piece itself. + +This separation aligns the piece with the \emph{versioned virtual object} developed in the companion Keymaps paper: identity persists through multiple implementations and revisions because a community continues to treat them as the same thing~\citep{scudder2026keymaps}. The named work is the continuity claim; source and runs are its versions and performances. + +\section{Evidence from the Monorepo} + +The repository census in Table~\ref{tab:census} was computed from the checked-out source on August 4, 2026. The method and raw counts live beside the paper in \code{data/monorepo-snapshot.json}. These are source facts, not production analytics. + +\begin{table*}[t] +\small +\centering +\begin{tabularx}{\textwidth}{l r X} +\toprule +\textbf{Measure} & \textbf{Count} & \textbf{What it establishes} \\ +\midrule +Built-in entry files & 477 & 455 JavaScript modules and 22 KidLisp files in \code{disks/}. \\ +JavaScript entries with imports & 188 & The entry-file boundary does not imply a self-contained implementation. \\ +Exporting \code{boot} / \code{paint} & 381 / 381 & Most JavaScript entries opt into the visible start-and-render protocol. \\ +Exporting \code{act} / \code{sim} & 316 / 248 & Interaction is more common than fixed-step simulation; neither is required. \\ +Exporting \code{leave} / \code{beat} & 80 / 27 & Cleanup and metrical time are explicit specialized hooks. \\ +Exporting \code{meta} / \code{receive} & 194 / 18 & Discoverability and inter-piece messaging are opt-in layers. \\ +JavaScript length, median / 90th pct. & 152 / 762 & A compact center coexists with a long tail; the largest entry is about 10k lines. \\ +Top-level library modules / server functions & 96 / 163 & The piece corpus sits inside substantial shared and operational infrastructure. \\ +\bottomrule +\end{tabularx} +\caption{Syntactic census of the August 4, 2026 monorepo at commit \code{c65015fe25}. Hook counts detect top-level export lines and do not measure how often a hook runs.} +\label{tab:census} +\end{table*} + +The counts support three narrower claims. + +First, the lifecycle is a family resemblance, not a schema every work must fill. Both \code{boot} and \code{paint} appear in 381 JavaScript entries; \code{beat} appears in 27 and \code{receive} in 18. Runtime defaults make partial participation valid. A static score, a networked game, and a musical tool can remain pieces without exporting the same hooks. + +Second, the corpus has outgrown the sketch-sized implementation without abandoning the sketch-sized entry. The median JavaScript entry is 152 lines, but the ninetieth percentile is 762 and the largest is about 10,000. Shared engines let related works remain distinct at the address layer. The July 2026 \code{pads} family is illustrative: many named audiovisual instruments import one engine, while each entry supplies its own palette, voice, and behavior. + +\code{dannom.mjs} makes the same point at a smaller scale. Its 19-line entry fixes a Danish language mode and re-exports lifecycle functions from the shared \code{nom.mjs} engine. The compact source is real, but it belongs to the authored-entry layer rather than describing the size of the performed system. + +\begin{figure*}[p] +\centering +\includegraphics[width=0.90\textwidth]{figures/ac-dannom-play}\par +\vspace{0.65em} +\includegraphics[width=0.90\textwidth]{figures/ac-kidlisp-fia-birthday-live} +\caption{Small entries can open substantial performance surfaces. Top: \code{dannom}, a Danish edition over the shared \code{nom.mjs} game engine. Bottom: \code{fia-birthday}, one of 22 built-in KidLisp entries. Both were captured from their public addresses on August 5, 2026.} +\label{fig:entry-surfaces} +\end{figure*} + +Third, the corpus changes faster than a paper can. The Research Platter last synchronized on July 21 listed 413 built-in pieces, 88 library modules, and 126 server functions~\citep{acplatter2026}. Two weeks later the checkout contained 477 entries, 96 libraries, and 163 functions. The gap is not an error to smooth away. It is evidence that publication needs dated provenance. + +\section{Address and Composition} + +\subsection{A path is a public score} + +The path \code{notepat} is short enough to say aloud, type from memory, print as a QR, or embed in another piece. URL addressability turns invocation into notation. The prompt is not merely a launcher layered over a project system; it is another interpreter for the same names the web already routes~\citep{scudder2026url}. This makes the piece social before anyone opens its source. + +Steyerl's ``poor image'' gains force by circulating in degraded copies~\citep{steyerl2009poor}. A piece faces the opposite problem: it needs a runtime, input, and time. The named address is the compression that lets this richer object circulate without reducing it to a screenshot. The path travels cheaply; the encounter rehydrates at the destination. + +\begin{figure*}[t] +\centering +\includegraphics[width=\textwidth]{figures/ac-list-crop} +\caption{The public \code{list} piece, captured August 5, 2026. Names and one-line descriptions turn the built-in corpus into a navigable repertoire rather than a directory of implementation files.} +\label{fig:list} +\end{figure*} + +\subsection{Publishing makes a claim of authorship} + +Registered authors publish beneath a chosen handle. \code{source name} exposes a starting point for revision; the new work can receive another name and author. The musical metaphor of a \emph{cover} is useful only if provenance remains visible. Otherwise it romanticizes copying. In practice, the system needs both operations: source-level forking for technical lineage and naming/attribution for cultural lineage. + +The July 21 Platter snapshot recorded 2,810 handles, 265 user-published JavaScript pieces, 4,425 paintings, 16,523 KidLisp programs, and 18,048 chat messages~\citep{acplatter2026,scudder2026network}. These categories should not be collapsed. A built-in entry, a published JavaScript work, a compact KidLisp code, a painting, and a chat utterance have different storage and governance. They meet at the prompt because the prompt can address each one. + +\code{@sat} shows a handle acting as a returnable portfolio rather than a generic account row. \code{laer-klokken} shows the complementary community boundary: its original address now aliases \code{laklok}, while the live room remains reachable under the name its participants know. + +\begin{figure*}[p] +\centering +\includegraphics[width=0.90\textwidth]{figures/ac-profile-sat-live}\par +\vspace{0.65em} +\includegraphics[width=0.90\textwidth]{figures/ac-laer-klokken-live} +\caption{Addresses join works to people and communities. Top: \code{@sat}'s public profile gathers paintings, KidLisp works, and moods under one designed handle. Bottom: the signed-out public \code{laer-klokken} room preserves an established name while its entry delegates to \code{laklok}. Captured August 5, 2026.} +\label{fig:social-addresses} +\end{figure*} + +\subsection{The piece is composable, not total} + +The feature set now includes units above and beside pieces. A \code{\^bag} is an admin-curated container mixing pieces, KidLisp codes, paintings, and tapes. Prompt conductors can expand \code{\^pads} into a sequence of constituent works. A development \code{channel} broadcasts incremental source changes to several devices. Multiplayer pieces such as \code{arena} coordinate a browser entry with authoritative server logic. These are not failures of the piece model. They show where its boundary ends. + +The piece is the unit one can invoke and attribute. A bag is a collection; a channel is a rehearsal route; a session is a shared performance; a server is an authority. Treating all four as ``the piece'' would erase useful architecture. Treating none of them as part of the work would erase the conditions of performance. + +\begin{figure*}[t] +\centering +\resizebox{0.86\textwidth}{!}{\input{figures/piece-ecology-tikz}} +\caption{A piece sits beside other named operational units. Bags contain pieces, channels update them, sessions coordinate shared runs, and servers may own authoritative state; none replaces the piece as the unit of address and attribution.} +\label{fig:ecology} +\end{figure*} + +\section{Across Substrates} + +The web runtime remains the canonical execution surface. The same entry can run in a browser or PWA, inside the Electron application's webview, or inside a generated standalone pack. These are source-preserving distributions: the adapter and host change, while the entry executes as written. + +AC Native introduces a second relation. Its QuickJS/C environment implements a compatible creative-computing surface on bare metal, but not every web piece runs unchanged and some works have purpose-built native implementations~\citep{scudder2026os}. \code{notepat}, for example, exists as a browser piece, a native instrument, a macOS menubar instrument, and other ports. The Keymaps paper shows that its input mapping is the cross-substrate contract~\citep{scudder2026keymaps}; the present paper extends that observation. A piece can survive a port when its name, interaction score, and recognizable behavior survive, even when the code does not. + +This yields a practical test: two implementations count as performances or ports of one piece when a returning participant can carry learned action, expectation, and attribution from one to the other. Shared source is sufficient, not necessary. The test is social and contestable. It prevents a brand label alone from declaring equivalence while allowing a work to cross hardware boundaries. + +\section{Trust, Cleanup, Observation} + +An artistic name does not suspend software's risks. Ruha Benjamin's account of discriminatory design is a warning against treating humanized language as evidence of humane infrastructure~\citep{benjamin2019race}. The piece model therefore has to state who may do what, what survives a run, and what the operator records. + +\subsection{Capability boundaries} + +Built-in entries are trusted. User-uploaded JavaScript receives a restricted API that namespaces storage, blocks authentication tokens, and requests permission before API-mediated network access, external upload, or navigation. KidLisp is constrained by language design: it omits general file, network, and string facilities~\citep{scudder2026kidlisp}. This is a useful capability boundary, but it is not a complete sandbox. Static imports and browser globals require their own threat model. The honest claim is narrower: injected capabilities make sensitive operations visible and mediatable; they do not prove containment. + +\subsection{Leaving is part of performing} + +Only 80 JavaScript entries explicitly export \code{leave}, so the host also performs global cleanup when switching pieces. Both layers matter. A musical or camera piece can leave audio nodes, media tracks, timers, or session membership behind if teardown is treated as an afterthought. \code{leave} is the formal end of a performance; host cleanup is the safety net when the performer fails. + +\subsection{Run logs create obligations} + +Piece-run telemetry stores a slug, boot identifier, host, user agent, approximate geography from request headers, console events, errors, and completion summaries. Reads are admin-gated and event arrays are capped, but the contents can still include accidental personal data. This paper uses only the public schema and no raw run records. A mature piece model needs retention limits, disclosure, and a way to keep private creative work from becoming operational exhaust. Observability makes maintenance possible; minimization keeps the maintenance system from becoming another audience. + +\section{Design Consequences} + +The contemporary feature set replaces five romantic slogans with narrower contracts: + +\begin{enumerate} + \item \textbf{One addressable entry, not one implementation file.} Imports and server companions are allowed; the public boundary stays small. + \item \textbf{A lifecycle with defaults.} Hooks describe moments of performance, and pieces opt into only the moments they need. + \item \textbf{Capabilities are passed, not presumed.} Injection supports documentation, testing, and partial restriction. + \item \textbf{Names carry attribution and return.} Paths, handles, source retrieval, previews, and media records preserve continuity beyond a run. + \item \textbf{Composition uses explicit neighboring units.} Bags, channels, sessions, and ports should name their own contracts instead of disappearing inside ``piece.'' +\end{enumerate} + +The shift also changes evaluation. A program can be tested for correctness without asking whether anyone can find it again. A piece needs both technical and cultural tests: Does it boot and leave cleanly? Does input remain learnable across revisions? Is its address stable? Is its author visible? Can a participant return without relearning the work? Does a port preserve the score or only the logo? + +\section{Limitations} + +This is a situated account of one monorepo, most of whose built-in corpus has one primary author. It cannot show that calling software \emph{pieces} causes a practice culture; the architecture, community, and vocabulary developed together. Hook counts are syntactic and do not measure runtime use. Platter adoption figures are a July 21 snapshot rather than a new production query. No demographic or raw telemetry data was inspected. + +The prompt's memorizable namespace also has an accessibility cost: a path is easy only after someone learns it. Lists, autocomplete, metadata, documentation, and direct links remain necessary. Only 194 of 455 JavaScript entries export \code{meta}; curated documentation partly fills the gap, but reachability is not discoverability. + +Finally, the piece concept can overstate autonomy. Sadie Plant's history of computing makes visible how apparently singular artifacts emerge from distributed, gendered technical labor~\citep{plant1997zeros}. A named author and entry file should not hide browser standards, library maintainers, server operators, hardware, energy, or community knowledge. The piece is a useful unit of address and analysis, not the only unit that matters. + +\section{Conclusion} + +``Pieces not programs'' is not a demand to stop engineering. It is a demand to engineer for a work that must survive more than execution. In the current \ac{} monorepo, a piece is an authored entry, a named address, a lifecycle-governed performance, and a durable record. It can be 30 lines or 10,000; self-contained or backed by libraries and services; executed from the same source or carried across substrates by a behavioral contract. + +The contemporary evidence strengthens the artistic claim by removing its weakest literalism. The piece is not one file. It is the smallest work the system can name, invoke, attribute, revise, and return to. Programs make it run. Practice makes it persist. + +\bibliographystyle{plainnat} +\setlength{\bibsep}{1.2pt plus 0.2ex} +\onecolumn +\begin{multicols}{2} +{\footnotesize +\bibliography{references} +} +\end{multicols} + +\vspace{0.8em} +\begin{center} +\footnotesize\sffamily\color{acgray} +Revision \paperrev\;\textperiodcentered\;\texttt{\paperhash}\;\textperiodcentered\; +\href{https://papers.aesthetic.computer/pieces-not-programs-26-arxiv.pdf}{papers.aesthetic.computer/pieces-not-programs-26-arxiv} +\end{center} + +\end{document} diff --git a/papers/arxiv-pieces/references.bib b/papers/arxiv-pieces/references.bib --- a/papers/arxiv-pieces/references.bib +++ b/papers/arxiv-pieces/references.bib @@ -1,18 +1,82 @@ % === Aesthetic Computer === @misc{scudder2026ac, - title={Aesthetic Computer '26: A Mobile-First Runtime for Creative Computing}, + title={{\ac} '26: A Mobile-First Runtime for Creative Computing}, author={{@jeffrey}}, year={2026}, note={arXiv preprint draft in repository} } -@misc{scudder2026notepat, - title={notepat.com: From Keyboard Toy to System Front Door}, - author={{@jeffrey}}, - year={2026}, - note={arXiv preprint draft in repository} -} +@misc{scudder2026notepat, + title={notepat.com: From Keyboard Toy to System Front Door}, + author={{@jeffrey}}, + year={2026}, + note={arXiv preprint draft in repository} +} + +@misc{scudder2026api, + title={From setup() to boot(): Processing at the Core of the Piece API}, + author={{@jeffrey}}, + year={2026}, + howpublished={\url{https://papers.aesthetic.computer/piece-api-26-arxiv.pdf}}, + note={Companion paper} +} + +@misc{scudder2026url, + title={The URL Tradition}, + author={{@jeffrey}}, + year={2026}, + howpublished={\url{https://papers.aesthetic.computer/url-tradition-26-arxiv.pdf}}, + note={Companion paper} +} + +@misc{scudder2026network, + title={Network Audit: Who Uses {\ac} and What Do They Make?}, + author={{@jeffrey}}, + year={2026}, + howpublished={\url{https://papers.aesthetic.computer/network-audit-26-arxiv.pdf}}, + note={Companion paper} +} + +@misc{scudder2026kidlisp, + title={KidLisp: A Minimal Lisp for Generative Art}, + author={{@jeffrey}}, + year={2026}, + howpublished={\url{https://papers.aesthetic.computer/kidlisp-26-arxiv.pdf}}, + note={Companion paper} +} + +@misc{scudder2026os, + title={AC Native OS '26: A Bare-Metal Creative Computing Operating System}, + author={{@jeffrey}}, + year={2026}, + howpublished={\url{https://papers.aesthetic.computer/ac-native-os-26-arxiv.pdf}}, + note={Companion paper} +} + +@misc{scudder2026keymaps, + title={Keymaps as Social Software: Versioned Virtual Objects via Social Contract}, + author={{@jeffrey}}, + year={2026}, + howpublished={\url{https://papers.aesthetic.computer/keymaps-social-software-26-arxiv.pdf}}, + note={Companion paper} +} + +@misc{acrepo2026, + title={{\ac} Monorepo}, + author={{{\ac} contributors}}, + year={2026}, + howpublished={\url{https://tangled.org/aesthetic.computer/core}}, + note={Commit c65015fe25, inspected August 4, 2026. Reproducible counts in \texttt{data/monorepo-snapshot.json}} +} + +@misc{acplatter2026, + title={{\ac} Research Platter}, + author={{@jeffrey}}, + year={2026}, + howpublished={\url{https://papers.aesthetic.computer/platter.html}}, + note={Public research index and platform snapshot, synchronized July 21, 2026} +} % === Creative Coding === @@ -239,14 +303,41 @@ booktitle={Proceedings of the International Conference on Live Coding (ICLC)}, year={2023} } -@article{staunaes2021stiegler, +@article{staunaes2021stiegler, title={Stiegler's Concept of Idiotext}, author={Staun{\ae}s, Asker Bryld}, journal={Stiegler Studies}, volume={1}, number={1}, year={2021} -} +} + +% === Platter reading shelf === + +@book{benjamin2019race, + title={Race After Technology: Abolitionist Tools for the New Jim Code}, + author={Benjamin, Ruha}, + year={2019}, + publisher={Polity Press}, + address={Cambridge, UK} +} + +@book{plant1997zeros, + title={Zeros and Ones: Digital Women + the New Technoculture}, + author={Plant, Sadie}, + year={1997}, + publisher={Doubleday}, + address={New York} +} + +@article{steyerl2009poor, + title={In Defense of the Poor Image}, + author={Steyerl, Hito}, + journal={e-flux journal}, + volume={10}, + year={2009}, + url={https://www.e-flux.com/journal/10/61362/in-defense-of-the-poor-image/} +} @book{stiegler1998technics, title={Technics and Time, 1: The Fault of Epimetheus}, diff --git a/pop/jungle/bin/render.mjs b/pop/jungle/bin/render.mjs --- a/pop/jungle/bin/render.mjs +++ b/pop/jungle/bin/render.mjs @@ -276,6 +276,7 @@ } // ── main render ─────────────────────────────────────────────────────── function render(slug) { + mkdirSync(`${LANE}/out`, { recursive: true }); const def = DEFAULTS[slug] || DEFAULTS.raggasol; const STYLE = String(flags.style || def.style); const BPM = Number(flags.bpm ?? def.bpm); diff --git a/pop/menuband/raytracer/.gitignore b/pop/menuband/raytracer/.gitignore new file mode 100644 --- /dev/null +++ b/pop/menuband/raytracer/.gitignore @@ -0,0 +1,1 @@ +.build/ diff --git a/pop/teknull/bin/remaster-taksmukkeklokken-harsh-c.mjs b/pop/teknull/bin/remaster-taksmukkeklokken-harsh-c.mjs new file mode 100644 --- /dev/null +++ b/pop/teknull/bin/remaster-taksmukkeklokken-harsh-c.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node +// Re-cut and musically rebalance the spatial/orchestral harsh-C mix. +// Keeps the source immutable and adds restrained, transport-locked dance reinforcement. + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, resolve, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, "../../.."); +const OUT = resolve(HERE, "../out"); +const SOURCE = resolve(OUT, "taksmukkeklokken-spatial-orchestral-harsh-c.mp3"); +const TARGET = resolve(OUT, "taksmukkeklokken-spatial-orchestral-harsh-c-remaster.mp3"); +const RECEIPT = resolve(OUT, "taksmukkeklokken-spatial-orchestral-harsh-c-remaster.json"); +const RAW = resolve(OUT, ".taksmukkeklokken-harsh-c-remaster-percussion.f32le"); +const SR = 48_000; +const BPM = 140; +const BEAT = 60 / BPM; +// Bar 10's downbeat (zero-indexed bar 9): the musical boundary nearest 0:16. +const SOURCE_START = 9 * 4 * BEAT; // 15.428571428571429 +const SOURCE_DURATION = 120; +const DURATION = SOURCE_DURATION - SOURCE_START; + +mkdirSync(OUT, { recursive: true }); + +// Ground the inherited orchestration with a centered classic kick and a short, +// cool backbeat. These are reinforcement, not a replacement drum track. +const frames = Math.floor(DURATION * SR); +const pcm = new Float32Array(frames * 2); +let noiseState = 0x51c1c; +const noise = () => { + noiseState = (Math.imul(noiseState, 1664525) + 1013904223) >>> 0; + return noiseState / 0x80000000 - 1; +}; +function addMono(time, length, sample) { + const first = Math.round(time * SR); + const count = Math.min(Math.round(length * SR), frames - first); + for (let i = 0; i < count; i++) { + const value = sample(i / SR, i); + pcm[(first + i) * 2] += value; + pcm[(first + i) * 2 + 1] += value; + } +} +for (let beat = 0; beat * BEAT < DURATION; beat++) { + const at = beat * BEAT; + addMono(at, 0.34, (x) => { + const phase = 2 * Math.PI * (49 * x + 2.7 * (1 - Math.exp(-x / 0.027))); + const click = Math.sin(2 * Math.PI * 1450 * x) * Math.exp(-x / 0.006); + return 0.175 * Math.sin(phase) * Math.exp(-x / 0.115) + 0.016 * click; + }); + if (beat % 4 === 1 || beat % 4 === 3) { + addMono(at, 0.115, (x) => { + const env = Math.exp(-x / 0.032); + const body = Math.sin(2 * Math.PI * 184 * x) * Math.exp(-x / 0.055); + const grit = (noise() - 0.55 * noise()) * env; + return 0.045 * body + 0.072 * grit; + }); + } +} +writeFileSync(RAW, Buffer.from(pcm.buffer)); + +const filter = [ + // Steady the image, mono the sub foundation, and soften the painful bands. + "[0:a]atrim=start=" + SOURCE_START.toFixed(12) + ",asetpts=PTS-STARTPTS,", + "afade=t=in:st=0:d=0.012,afade=t=out:st=" + (DURATION - 0.12).toFixed(6) + ":d=0.12,", + "highpass=f=27,equalizer=f=3150:t=q:w=1.15:g=-2.8,equalizer=f=6900:t=q:w=0.9:g=-2.1,", + "equalizer=f=1150:t=q:w=0.75:g=1.45,stereotools=mlev=1.06:slev=0.78,asplit=2[main][low];", + "[low]lowpass=f=145,pan=stereo|c0=0.5*c0+0.5*c1|c1=0.5*c0+0.5*c1,volume=0.24[sub];", + "[main][sub][1:a]amix=inputs=3:weights='1 1 0.78':normalize=0,", + "acompressor=threshold=0.16:ratio=2.1:attack=18:release=150:makeup=1.05,", + "loudnorm=I=-13.0:LRA=6.0:TP=-1.0:linear=false,aresample=48000[out]", +].join(""); + +const ffmpegArgs = [ + "-y", "-hide_banner", "-loglevel", "warning", + "-i", SOURCE, + "-f", "f32le", "-ar", String(SR), "-ac", "2", "-i", RAW, + "-filter_complex", filter, "-map", "[out]", "-t", DURATION.toFixed(9), + "-ar", String(SR), "-ac", "2", "-codec:a", "libmp3lame", "-b:a", "320k", TARGET, +]; +const rendered = spawnSync("ffmpeg", ffmpegArgs, { stdio: "inherit" }); +rmSync(RAW, { force: true }); +if (rendered.status !== 0) process.exit(rendered.status ?? 1); + +const sha256 = (path) => createHash("sha256").update(readFileSync(path)).digest("hex"); +writeFileSync(RECEIPT, JSON.stringify({ + schema: "aesthetic.computer/pop-remaster/v1", + source: relative(ROOT, SOURCE), + output: relative(ROOT, TARGET), + sourceSha256: sha256(SOURCE), + outputSha256: sha256(TARGET), + sourceStartSec: SOURCE_START, + sourceStartReason: "140 BPM bar downbeat nearest requested 0:16 (bar 10 begins at 15.428571s)", + durationSec: DURATION, + sampleRate: SR, + channels: 2, + changes: [ + "12 ms boundary fade and 120 ms tail fade", + "centered sub reinforcement below 145 Hz and restrained stereo-side level", + "classic transport-locked kick on quarter notes and tight snare on beats 2/4", + "upper-mid/high resonance cuts at 3.15 kHz and 6.9 kHz", + "broad 1.15 kHz melody-presence lift", + "gentle bus compression and -13 LUFS / -1 dBTP final loudness target", + ], + renderer: relative(ROOT, fileURLToPath(import.meta.url)), + ffmpegArgs, +}, null, 2) + "\n"); +console.log(TARGET); diff --git a/pop/teknull/bin/render-taksmukkeklokken-sinebells.mjs b/pop/teknull/bin/render-taksmukkeklokken-sinebells.mjs new file mode 100644 --- /dev/null +++ b/pop/teknull/bin/render-taksmukkeklokken-sinebells.mjs @@ -0,0 +1,256 @@ +#!/usr/bin/env node +// Taksmukkeklokken sine-bell refinement: a bar-10 recut with composed bell lines. + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, "../../.."); +const OUT = resolve(HERE, "../out"); +const GREEK_V2 = process.argv.includes("--greek-theatre-v2"); +const GREEK_THEATRE = GREEK_V2 || process.argv.includes("--greek-theatre"); +const SLOW_BUILD = GREEK_THEATRE || process.argv.includes("--slow-build"); +const SOURCE = resolve(OUT, "taksmukkeklokken-spatial-orchestral-harsh-c.mp3"); +const STEM = GREEK_V2 ? "taksmukkeklokken-sinebells-greektheatre-v2" + : GREEK_THEATRE ? "taksmukkeklokken-sinebells-greektheatre" + : SLOW_BUILD ? "taksmukkeklokken-sinebells-slowbuild" : "taksmukkeklokken-sinebells"; +const TARGET = resolve(OUT, `${STEM}.mp3`); +const RECEIPT = resolve(OUT, `${STEM}.json`); +const RAW = resolve(OUT, `.${STEM}.f32le`); +const SR = 48_000, BPM = 140, BEAT = 60 / BPM, BAR = 4 * BEAT; +const SOURCE_START = 9 * BAR; // original bar 10: 15.428571428571429 s +const DURATION = 120 - SOURCE_START; +const frames = Math.floor(DURATION * SR); +const pcm = new Float32Array(frames * 2); +mkdirSync(OUT, { recursive: true }); + +let seed = 0x51be11; +const noise = () => ((seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) / 0x80000000 - 1); +const hz = midi => 440 * 2 ** ((midi - 69) / 12); +function add(time, length, pan, voice) { + const first = Math.max(0, Math.round(time * SR)); + const count = Math.min(Math.round(length * SR), frames - first); + const l = Math.sqrt((1 - pan) * .5), r = Math.sqrt((1 + pan) * .5); + for (let i = 0; i < count; i++) { + const v = voice(i / SR, i); + pcm[(first + i) * 2] += v * l; + pcm[(first + i) * 2 + 1] += v * r; + } +} +function kick(t, gain = 1) { + add(t, .33, 0, x => gain * (.15 * Math.sin(2 * Math.PI * (48 * x + 2.8 * (1 - Math.exp(-x / .026)))) * Math.exp(-x / .12) + + .012 * Math.sin(2 * Math.PI * 1350 * x) * Math.exp(-x / .005))); +} +function snare(t) { + add(t, .105, 0, x => .044 * Math.sin(2 * Math.PI * 178 * x) * Math.exp(-x / .05) + + .052 * (noise() - .5 * noise()) * Math.exp(-x / .027)); +} +function sineBell(t, midi, gain, pan, decay = 1.15) { + const f = hz(midi); + add(t, decay * 4.3, pan, x => { + const attack = 1 - Math.exp(-x / .006); + const env = attack * Math.exp(-x / decay); + // A clean fundamental carries the tune; quiet octave/12th give it identity. + return gain * env * (Math.sin(2 * Math.PI * f * x) + .19 * Math.sin(2 * Math.PI * 2 * f * x + .25) + + .07 * Math.sin(2 * Math.PI * 3 * f * x + .8)); + }); +} +function femBell(t, midi, gain, pan, material = "bronze") { + const f = hz(midi); + const bronze = [[1, 1, 1.75], [2.01, .31, 1.1], [2.72, .18, .72], [4.08, .09, .42]]; + const glass = [[1, 1, 1.35], [2.32, .2, .78], [3.86, .11, .48], [5.41, .045, .3]]; + const modes = material === "bronze" ? bronze : glass; + add(t + (material === "glass" ? .011 : .022), 5.6, pan, x => { + const attack = 1 - Math.exp(-x / (material === "bronze" ? .018 : .009)); + let y = 0; + // Sub-cent platter drift makes the material breathe without detuning the hook. + const drift = 1 + .00045 * Math.sin(2 * Math.PI * .31 * x + midi); + for (const [ratio, amp, decay] of modes) y += amp * Math.sin(2 * Math.PI * f * ratio * drift * x) * Math.exp(-x / decay); + return gain * attack * y; + }); +} + +function protagonist(t, midi, gain = .036) { + // Solo: centered-left, pure and speech-like, with a decisive short release. + sineBell(t, midi, gain, -.13, .72); +} +function coryphaeus(t, midi, gain = .03) { + // Leader's response: warmer and opposite the protagonist in the theatre. + sineBell(t, midi, gain, .2, 1.02); +} + +function grooveBar(t, intensity) { + let previous = 0; + // Authored surface drag: strongest after the downbeat, then it recedes to + // leave the backbeat and phrase ending clear. Differenced noise avoids hiss. + add(t, BAR * .88, 0, x => { + const n = noise(), grain = n - previous; previous = n; + const pulse = .28 + .72 * Math.exp(-((x % BEAT) / .115)); + const phraseFade = Math.max(0, 1 - x / (BAR * .88)); + return intensity * .007 * grain * pulse * (.45 + .55 * phraseFade); + }); + // Two quiet groove-wall contacts, deliberately away from kick/snare attacks. + for (const off of [1.72, 3.68]) add(t + off * BEAT, .012, off < 2 ? -.12 : .12, + x => intensity * .018 * noise() * Math.exp(-x / .0027)); +} + +// Centered classic pulse, with alternating lighter kicks to keep it dance-pop. +for (let b = 0; b * BEAT < DURATION; b++) { + const bar = Math.floor(b / 4), inBar = b % 4; + if (!SLOW_BUILD || bar >= 16) kick(b * BEAT, inBar === 0 ? 1 : .82); + else if (bar >= 8 && inBar % 2 === 0) kick(b * BEAT, inBar === 0 ? .68 : .48); + else if (bar >= 4 && inBar === 0) kick(b * BEAT, .45); + if ((!SLOW_BUILD || bar >= 16) && (inBar === 1 || inBar === 3)) snare(b * BEAT); + else if (SLOW_BUILD && bar >= 12 && inBar === 3) { + // A single cool backbeat foreshadows the full 2/4 snare lane. + add(b * BEAT, .09, 0, x => .032 * (noise() - .5 * noise()) * Math.exp(-x / .024)); + } +} + +// 8-bar harmonic sentence: Am9 | Fmaj9/A | Cmaj9/G | G6/9 | Dm9/F | +// Am/E | Bdim7/F | E7(b9). Hook is stable enough to sing, with two altered +// answers supplying the adventurous harmony. Rests at each phrase end matter. +const roots = [57, 57, 55, 55, 53, 52, 53, 52]; +const hook = [ + [[0,76],[1,79],[2.5,81]], [[0,76],[1.5,74],[2.5,72]], + [[0,76],[1,79],[2.5,83]], [[0,81],[1.5,79],[2.5,76]], + [[0,77],[1,81],[2.5,84]], [[0,76],[1.5,79],[2.5,83]], + [[0,77],[1,80],[2.5,86]], [[0,76],[1,74],[2.5,71]], +]; +const answer = [69,69,67,67,65,64,65,68]; +// Slow-build mode is a new composition, not a postponed version of the hook. +// Each harmony lasts two bars: Am11 | Cmaj7/E | Fmaj9 | E7sus(b9) | +// Dm9 | G13 | Cmaj9 | E7(#5). Its four-note "pressing" cell expands by form. +const slowRoots = [45, 52, 53, 52, 50, 55, 48, 52]; +const slowCells = [ + [69,72,71,76], [67,71,72,76], [69,72,76,79], [68,71,74,77], + [69,72,76,77], [71,74,76,81], [67,71,74,79], [68,72,77,76], +]; +const bars = Math.ceil(DURATION / BAR); +for (let bar = 0; bar < bars; bar++) { + const phase = bar % 8, slowPhase = Math.floor(bar / 2) % 8, base = bar * BAR; + const section = SLOW_BUILD ? (bar < 8 ? 0 : bar < 16 ? 1 : bar < 32 ? 2 : 3) + : (bar < 8 ? 0 : bar < 24 ? 1 : bar < 40 ? 2 : 3); + grooveBar(base, bar % 8 === 7 ? .35 : section === 2 ? .8 : .58); + // Slow build's new pressing-cell melody grows across long harmonic fields. + if (GREEK_THEATRE && bar >= 4 && bar % 16 !== 15) { + const cell = slowCells[slowPhase]; + // V2 reserves the chorus for the late climax. Earlier structural turns are + // solo/source battles and therefore cannot silently inflate voice count. + const structural = GREEK_V2 ? (bar === 40 || bar === 48) + : (bar === 16 || bar === 32 || bar === 40 || bar === 48); + if (structural) { + // Chorus: three separated registers only at a major turn. It speaks once, + // then yields an entire beat of stage space. + protagonist(base, cell[0], .038); + coryphaeus(base, cell[1] + 7, .027); + sineBell(base, cell[2] + 12, .021, 0, .88); + } else if (bar % 4 === 0 || bar % 4 === 1) { + // Episode: protagonist states one line; no simultaneous melodic reply. + protagonist(base + (bar < 12 ? 1.5 : .5) * BEAT, cell[bar % 2 ? 2 : 0], bar < 12 ? .024 : .035); + } else if (bar % 4 === 2) { + // Coryphaeus answers antiphonally after the prior bar's phrase has cleared. + coryphaeus(base + 1.25 * BEAT, cell[3] - (bar % 8 === 6 ? 2 : 0), bar < 16 ? .024 : .031); + } + // Every fourth bar is a stasimon/rest; surface and rhythm carry the scene. + } else if (SLOW_BUILD && bar >= 4 && bar % 16 !== 15) { + const cell = slowCells[slowPhase]; + const count = bar < 8 ? 1 : bar < 16 ? 2 : bar < 32 ? 4 : 5; + const events = [[0,cell[0]],[1.5,cell[1]],[2.5,cell[2]],[3.25,cell[3]],[3.625,cell[0]+12]]; + for (let i = 0; i < count; i++) { + // Second bar of each harmony answers later and in contrary contour. + const [beat, pitch] = events[i]; + const answered = bar % 2 ? (i % 2 ? pitch - 2 : pitch + 3) : pitch; + const climax = bar >= 40 && bar % 4 < 2 ? 12 : 0; + sineBell(base + (beat + (bar % 2 ? .25 : 0)) * BEAT, answered + climax, + bar < 8 ? .02 : bar < 16 ? .027 : bar >= 40 ? .044 : .037, + i % 2 ? .16 : -.1, i >= 3 ? .76 : 1.15); + } + // Original accepted composition remains unchanged in default mode. + } else if (!SLOW_BUILD && bar >= 2 && bar % 8 !== 7) { + for (const [noteIndex, [beat, note]] of hook[phase].entries()) { + if (section === 0 && beat === 1) continue; + const lift = section === 3 && bar % 4 < 2 ? 12 : 0; + const openingGain = section === 2 ? .045 : .038; + sineBell(base + beat * BEAT, note + lift, openingGain, beat === 1 ? .18 : -.12, .9 + .12 * (beat === 2.5)); + } + } + // Contrary-motion counterpoint only in alternating phrases: legible space. + if (!SLOW_BUILD && section > 0 && bar % 8 >= 4 && bar % 8 <= 6) { + sineBell(base + .5 * BEAT, answer[phase], .026, .23, 1.28); + sineBell(base + 2 * BEAT, answer[phase] + (phase === 6 ? 6 : 4), .022, .1, 1.05); + } + // One warm tactile body per bar, below the melodic register. Alternating + // material and side; staggered attack avoids a hard layered clang. + if (bar % 8 !== 7 && (!SLOW_BUILD || bar >= 6) && (!GREEK_THEATRE || bar % 8 === 7 || bar % 4 === 3)) { + const bodyGain = SLOW_BUILD && bar < 10 ? .0045 : SLOW_BUILD && bar < 16 ? .008 : .0125; + femBell(base, SLOW_BUILD ? slowRoots[slowPhase] : roots[phase], bodyGain, bar % 2 ? .2 : -.2, bar % 4 === 2 ? "glass" : "bronze"); + } +} +writeFileSync(RAW, Buffer.from(pcm.buffer)); + +const v2FirstAntagonist = 16 * BAR; +const v2SecondAntagonist = 24 * BAR; +const v2ThirdAntagonist = 32 * BAR; +const v2ClimaxStart = 40 * BAR; +const v2ClimaxEnd = 48 * BAR; +const bedTreatment = GREEK_V2 + // Hard zero until bar 17. Three isolated, sub-bar source utterances follow, + // each separated by at least seven bars of literal inherited-bed silence. + // Only the bar 41–48 climax permits a continuous ensemble field. + ? `asplit=2[antSrc][climaxSrc];` + + `[antSrc]highpass=f=260,lowpass=f=2100,volume='if(between(t,${v2FirstAntagonist.toFixed(6)},${(v2FirstAntagonist+.62).toFixed(6)}),.34*sin(PI*(t-${v2FirstAntagonist.toFixed(6)})/.62)^2,if(between(t,${v2SecondAntagonist.toFixed(6)},${(v2SecondAntagonist+.74).toFixed(6)}),.31*sin(PI*(t-${v2SecondAntagonist.toFixed(6)})/.74)^2,if(between(t,${v2ThirdAntagonist.toFixed(6)},${(v2ThirdAntagonist+.88).toFixed(6)}),.36*sin(PI*(t-${v2ThirdAntagonist.toFixed(6)})/.88)^2,0)))':eval=frame[ant];` + + `[climaxSrc]highpass=f=45,lowpass=f=7600,volume='if(between(t,${v2ClimaxStart.toFixed(6)},${v2ClimaxEnd.toFixed(6)}),.36*min(1,min(max(0,(t-${v2ClimaxStart.toFixed(6)})/.08),max(0,(${v2ClimaxEnd.toFixed(6)}-t)/.12))),0)':eval=frame[climax];` + + `[ant][climax]amix=inputs=2:normalize=0,stereotools=mlev=1.08:slev=.58[bed];` + : GREEK_THEATRE + ? "highpass=f=28,equalizer=f=3150:t=q:w=1.1:g=-4.2,equalizer=f=6900:t=q:w=.9:g=-3.1,lowpass=f=520,stereotools=mlev=1.1:slev=.48,volume=.56[bed];" + : "highpass=f=28,equalizer=f=3150:t=q:w=1.1:g=-3.5,equalizer=f=6900:t=q:w=.9:g=-2.6,equalizer=f=980:t=q:w=.7:g=1.3,stereotools=mlev=1.07:slev=.7,volume=.88[bed];"; +const filter = [ + `[0:a]atrim=start=${SOURCE_START.toFixed(12)},asetpts=PTS-STARTPTS,afade=t=in:st=0:d=0.014,afade=t=out:st=${(DURATION-.14).toFixed(6)}:d=0.14,`, + bedTreatment, + // Bell bus is kept coherent and warm; sub-250 Hz stays mono after summing. + "[1:a]highpass=f=42,equalizer=f=2600:t=q:w=.8:g=-1.6,equalizer=f=720:t=q:w=.7:g=1.4,volume=.92[bells];", + "[bed][bells]amix=inputs=2:weights='1 1':normalize=0,acompressor=threshold=.17:ratio=1.8:attack=22:release=175:makeup=1.03,", + `stereotools=mlev=1.03:slev=.82,loudnorm=I=-13:LRA=6:TP=-1:linear=false,${GREEK_V2 ? "alimiter=limit=.82:attack=5:release=50:level=false,volume=-1.4dB," : ""}aresample=48000[out]` +].join(""); +const ffmpegArgs = ["-y","-hide_banner","-loglevel","warning","-i",SOURCE,"-f","f32le","-ar",String(SR),"-ac","2","-i",RAW, + "-filter_complex",filter,"-map","[out]","-t",DURATION.toFixed(9),"-ar",String(SR),"-ac","2","-codec:a","libmp3lame","-b:a","320k", + "-metadata",`title=${STEM}`,"-metadata","artist=aesthetic.computer",TARGET]; +const result = spawnSync("ffmpeg", ffmpegArgs, { stdio: "inherit" }); +rmSync(RAW, { force: true }); +if (result.status !== 0) process.exit(result.status ?? 1); +const sha256 = p => createHash("sha256").update(readFileSync(p)).digest("hex"); +writeFileSync(RECEIPT, JSON.stringify({ + schema: "aesthetic.computer/pop-remaster/v1", track: STEM, + source: relative(ROOT, SOURCE), output: relative(ROOT, TARGET), sourceSha256: sha256(SOURCE), outputSha256: sha256(TARGET), + renderer: relative(ROOT, fileURLToPath(import.meta.url)), sourceStartSec: SOURCE_START, durationSec: DURATION, sampleRate: SR, channels: 2, + harmony: SLOW_BUILD + ? ["Am11 (2 bars)","Cmaj7/E (2 bars)","Fmaj9 (2 bars)","E7sus(b9) (2 bars)","Dm9 (2 bars)","G13 (2 bars)","Cmaj9 (2 bars)","E7(#5) (2 bars)"] + : ["Am9","Fmaj9/A","Cmaj9/G","G6(add9)","Dm9/F","Am/E","Bdim7/F","E7(b9)"], + arc: SLOW_BUILD + ? ["four-bar material-only threshold","single-note hook fragments and downbeat kick","two-note phrases with gradual FEM bloom and one backbeat","bar-17 full classic drums and complete hook","contrary-motion payoff and octave-bright return"] + : ["two-bar sparse hook entrance","full eight-bar sine hook","alternating contrary-motion answer","octave-bright final return"], + bellDesign: "sine fundamental hook/counterpoint with staggered warm bronze/glass modal FEM-style bodies", + composition: SLOW_BUILD ? "new two-bar harmonic rhythm and four-note pressing-cell development; no reuse of the accepted sinebells hook" : "accepted eight-bar sinebells hook", + dramaturgy: GREEK_THEATRE ? { + normalMelodicVoiceCap: 1, climaxMelodicVoiceCap: 3, + protagonist: "short pure sine, center-left; states episode lines alone", + coryphaeus: "warm sine, right; answers in a later bar", + chorus: "three-register structural utterance only at bars 17, 33, 41, and 49", + stasimon: "every fourth bar clears pitched melody for rhythm and record material" + } : null, + inheritedBedPlan: GREEK_V2 ? { + bars1To16: "hard-muted (0.0); no inherited harmonic or melodic voices", + isolatedAntagonists: ["bar 17: 0.62 s", "bar 25: 0.74 s", "bar 33: 0.88 s"], + silenceBetweenAntagonists: "more than seven bars; source gain exactly 0", + lateClimax: "bars 41–48 only, continuous ensemble at 0.36 gain", + outsideListedWindows: "source gain exactly 0" + } : null, + surface: "bar-authored responsive groove drag, offbeat groove-wall contacts, and sub-cent material drift; no constant hiss", + mix: "narrow coherent bell field, centered drums/sub, reduced 3.15/6.9 kHz harshness, -13 LUFS / -1 dBTP target", + slowBuild: SLOW_BUILD, greekTheatre: GREEK_THEATRE, greekTheatreV2: GREEK_V2, ffmpegArgs +}, null, 2) + "\n"); +console.log(TARGET); diff --git a/pop/wattajetta/bin/render-wattajetta-breakfirst.mjs b/pop/wattajetta/bin/render-wattajetta-breakfirst.mjs new file mode 100644 --- /dev/null +++ b/pop/wattajetta/bin/render-wattajetta-breakfirst.mjs @@ -0,0 +1,254 @@ +#!/usr/bin/env node +// Rebegin Wattajetta at the musical state heard at 1:24 in the accepted +// original master. This is an arrangement of that state, not a trim: long +// phrases keep the groove legible while increasingly hard half-/quarter-beat +// edits turn the steel drop into a crunchier, slicier second composition. + +import { mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, ".."); +const OUT = resolve(ROOT, "out"); +mkdirSync(OUT, { recursive: true }); + +const SR = 48000; +const BPM = 138; +const BEAT = 60 / BPM; +const BAR = 4 * BEAT; +const BARS = 96; +const SOURCE_START = 84; // user's chosen musical downbeat in the original master +const SPATIAL = process.argv.includes("--spatial"); +const sourceMp3 = resolve(OUT, "wattajetta.mp3"); +const decoded = resolve(OUT, ".wattajetta-breakfirst.source.f32"); +const mixed = resolve(OUT, ".wattajetta-breakfirst.mixed.f32"); +const output = resolve(OUT, SPATIAL ? "wattajetta-breakfirst-spatial.mp3" + : "wattajetta-breakfirst.mp3"); + +const decode = spawnSync("ffmpeg", ["-hide_banner", "-y", "-loglevel", "error", + "-i", sourceMp3, "-f", "f32le", "-ar", String(SR), "-ac", "2", decoded], + { stdio: "inherit" }); +if (decode.status !== 0) process.exit(decode.status ?? 1); + +const bytes = readFileSync(decoded); +const src = new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4); +const sourceFrames = src.length / 2; +const frames = Math.floor(BARS * BAR * SR); +const out = new Float32Array(frames * 2); +const frame = (seconds) => Math.floor(seconds * SR); +const beatFrame = frame(BEAT); +const barFrame = frame(BAR); +const chosenFrame = frame(SOURCE_START); + +// Short equal-power edges keep a slice percussive without digital clicks. +function copySlice(dst, source, length, gain = 1, reverse = false, edgeMs = 7) { + const n = Math.min(length, frames - dst, reverse ? source + 1 : sourceFrames - source); + const edge = Math.max(1, frame(edgeMs / 1000)); + for (let i = 0; i < n; i++) { + const si = reverse ? source - i : source + i; + if (si < 0 || si >= sourceFrames) break; + const e = Math.min(1, i / edge, (n - 1 - i) / edge); + const g = gain * Math.sin(Math.max(0, e) * Math.PI * 0.5); + out[2 * (dst + i)] += src[2 * si] * g; + out[2 * (dst + i) + 1] += src[2 * si + 1] * g; + } +} + +function straight(dstBar, bars, sourceSec) { + copySlice(dstBar * barFrame, frame(sourceSec), bars * barFrame, 1, false, 2); +} + +// Each bar still states one recognizable source bar. Only selected cells are +// displaced, repeated or reversed; the unfilled cells are intentional air. +const HALF_ORDER = [[0, 1, 3, 2, 4, 5, 6, 7], [0, 2, 1, 3, 4, 6, 5, 7]]; +function halfBeatPhrase(dstBar, bars, sourceSec, sparse = false) { + for (let b = 0; b < bars; b++) { + const order = HALF_ORDER[b % HALF_ORDER.length]; + for (let cell = 0; cell < 8; cell++) { + if (sparse && ((b + cell) % 5 === 3 || cell === 6)) continue; + const sourceCell = order[cell]; + const dst = (dstBar + b) * barFrame + Math.floor(cell * beatFrame / 2); + const from = frame(sourceSec) + b * barFrame + Math.floor(sourceCell * beatFrame / 2); + copySlice(dst, from, Math.floor(beatFrame / 2), cell === 7 ? 1.08 : 1, + sparse && cell === 5 && b % 2 === 1, 5); + } + } +} + +function quarterBeatPhrase(dstBar, bars, sourceSec) { + const cellFrames = Math.floor(beatFrame / 4); + for (let b = 0; b < bars; b++) { + for (let cell = 0; cell < 16; cell++) { + // Kick/downbeat cells remain fixed. Three cells per bar become negative + // space; the remaining edits make a single spicy syncopated answer. + if ([3, 10, 14].includes(cell)) continue; + let sourceCell = cell; + if (cell === 5 || cell === 9) sourceCell -= 1; + if (cell === 7 || cell === 13) sourceCell += 1; + const dst = (dstBar + b) * barFrame + cell * cellFrames; + const from = frame(sourceSec) + b * barFrame + sourceCell * cellFrames; + copySlice(dst, from, cellFrames, cell % 4 === 0 ? 1.08 : 0.96, + cell === 13 && b % 2 === 1, 3); + } + } +} + +// Form: recognition → first cuts → forward travel → air → harder rebegin → +// deepest forward state → knife-work → release. Every return starts on the +// exact 84-second downbeat, so the user's chosen moment is the composition's +// tonic as well as its first sound. +straight(0, 16, SOURCE_START); // actual 1:24 state, uninterrupted +halfBeatPhrase(16, 8, SOURCE_START); // first controlled re-cut +straight(24, 16, SOURCE_START + 16 * BAR); +halfBeatPhrase(40, 8, SOURCE_START + 8 * BAR, true); +straight(48, 8, SOURCE_START); // hard second rebegin +quarterBeatPhrase(56, 8, SOURCE_START + 8 * BAR); +straight(64, 16, SOURCE_START + 24 * BAR); +quarterBeatPhrase(80, 8, SOURCE_START + 28 * BAR); +straight(88, 8, SOURCE_START + 40 * BAR); + +// Parallel industrial bite. It is rhythmically keyed to the existing bars: +// no new free-running voice, just transient emphasis and crunchy material +// contrast. Later acts get more bite while breaths retain their holes. +let heldL = 0, heldR = 0; +const hold = 4; +for (let f = 0; f < frames; f++) { + const b = Math.floor(f / barFrame); + const phase = (f % beatFrame) / beatFrame; + if (f % hold === 0) { heldL = out[2 * f]; heldR = out[2 * f + 1]; } + const bite = b < 16 ? 0.04 : b < 48 ? 0.10 : b < 80 ? 0.16 : 0.20; + const transient = Math.exp(-phase / 0.055); + const drive = 1.8 + 1.2 * transient; + const crushedL = Math.tanh(heldL * drive) / Math.tanh(drive); + const crushedR = Math.tanh(heldR * drive) / Math.tanh(drive); + out[2 * f] = out[2 * f] * (1 - bite) + crushedL * bite * (1 + 0.16 * transient); + out[2 * f + 1] = out[2 * f + 1] * (1 - bite) + crushedR * bite * (1 + 0.16 * transient); +} + +// A coherent spatial stage for the separate audition. Wattajetta arrives as a +// stereo master, so the crossover is deliberately conservative: everything +// below 155 Hz is mono and un-delayed, and sharp drum transients pull the upper +// body toward center. Material chapters own stable anchors. Only selected edit +// passages describe one smooth arc; azimuth is continuously slewed, never +// changed at a block or slice boundary. +if (SPATIAL) { + const dry = out.slice(); + const staged = new Float32Array(out.length); + const delaySize = 128; + const monoDelay = new Float32Array(delaySize); + let delayWrite = 0, lowL = 0, lowR = 0, az = -0.26, depth = 0.16; + let env = 0, previousMono = 0; + const lowA = 1 - Math.exp(-2 * Math.PI * 155 / SR); + const azSlew = 1 - Math.exp(-1 / (0.28 * SR)); + const depthSlew = 1 - Math.exp(-1 / (0.65 * SR)); + for (let f = 0; f < frames; f++) { + const bFloat = f / barFrame; + const b = Math.floor(bFloat); + const barPhase = bFloat - b; + // Steel is left/high and near; the later stone body is right/lower and + // deeper. The hard rebegin returns to steel's known location. + let azTarget = b < 24 ? -0.26 : b < 48 ? 0.24 : b < 64 ? -0.18 : 0.28; + let depthTarget = b < 24 ? 0.14 : b < 48 ? 0.22 : b < 64 ? 0.16 : 0.25; + const edited = (b >= 16 && b < 24) || (b >= 40 && b < 48) + || (b >= 56 && b < 64) || (b >= 80 && b < 88); + if (edited) { + // One deliberate bar-long glide, alternating direction. It crosses no + // more than ~24 degrees and returns to the chapter's material anchor. + const arc = Math.sin(Math.PI * barPhase) * (b % 2 ? -1 : 1); + azTarget += 0.16 * arc; + depthTarget += 0.035 * Math.sin(Math.PI * barPhase); + } + az += (azTarget - az) * azSlew; + depth += (depthTarget - depth) * depthSlew; + + const l = dry[2 * f], r = dry[2 * f + 1]; + lowL += lowA * (l - lowL); lowR += lowA * (r - lowR); + const lowMono = 0.5 * (lowL + lowR); + const highL = l - lowL, highR = r - lowR; + const highMono = 0.5 * (highL + highR); + const originalSide = 0.5 * (highL - highR); + + // Broadband derivative is only a transient detector. Its smoothed value + // centers attacks; it never gates audio or changes delay discontinuously. + const derivative = Math.abs(highMono - previousMono); + previousMono = highMono; + env += (derivative - env) * (derivative > env ? 0.12 : 0.0018); + const centerAttack = Math.min(1, env * 15); + const effectiveAz = az * (1 - 0.72 * centerAttack); + const itd = Math.abs(effectiveAz) * 0.00056 * SR; + monoDelay[delayWrite] = highMono; + const delayedIndex = (delayWrite - Math.floor(itd) + delaySize) % delaySize; + const delayed = monoDelay[delayedIndex]; + delayWrite = (delayWrite + 1) % delaySize; + const near = highMono; + const far = delayed * (0.50 - 0.22 * Math.abs(effectiveAz)); + const placedL = effectiveAz < 0 ? near : far; + const placedR = effectiveAz < 0 ? far : near; + const spatialWet = (0.57 + depth * 0.46) * (1 - 0.55 * centerAttack); + // Retain the accepted stereo detail underneath the listener-relative body. + staged[2 * f] = lowMono + highL * (1 - spatialWet) + + (placedL + originalSide * 0.42) * spatialWet; + staged[2 * f + 1] = lowMono + highR * (1 - spatialWet) + + (placedR - originalSide * 0.42) * spatialWet; + } + + // Four low-level, cross-room early images, following the first-party room + // pattern. Prime-ish delays prevent a metallic comb; returns are high-passed + // by subtraction of a 190 Hz one-pole so the mono low end never enters them. + const roomSource = staged.slice(); + const delays = [0.011, 0.019, 0.031, 0.047].map((s) => frame(s)); + const gains = [0.036, 0.025, 0.017, 0.011]; + const roomHigh = new Float32Array(staged.length); + let roomLowL = 0, roomLowR = 0; + const roomA = 1 - Math.exp(-2 * Math.PI * 190 / SR); + for (let f = 0; f < frames; f++) { + roomLowL += roomA * (roomSource[2 * f] - roomLowL); + roomLowR += roomA * (roomSource[2 * f + 1] - roomLowR); + roomHigh[2 * f] = roomSource[2 * f] - roomLowL; + roomHigh[2 * f + 1] = roomSource[2 * f + 1] - roomLowR; + } + for (let f = 0; f < frames; f++) { + for (let q = 0; q < delays.length; q++) { + const from = f - delays[q]; + if (from < 0) continue; + // Cross returns imply a room wall while remaining too quiet to smear cuts. + const reflectedL = roomHigh[2 * from]; + const reflectedR = roomHigh[2 * from + 1]; + staged[2 * f] += reflectedR * gains[q]; + staged[2 * f + 1] += reflectedL * gains[q]; + } + } + out.set(staged); +} + +// Preserve the chosen source frame at time zero, then taper only the last four +// bars. Re-peak before mastering. +for (let f = frames - 4 * barFrame; f < frames; f++) { + const p = (f - (frames - 4 * barFrame)) / (4 * barFrame); + const g = Math.cos(p * Math.PI * 0.5) ** 2; + out[2 * f] *= g; out[2 * f + 1] *= g; +} +let peak = 0; +for (const x of out) peak = Math.max(peak, Math.abs(x)); +if (peak > 0.94) for (let i = 0; i < out.length; i++) out[i] *= 0.94 / peak; + +writeFileSync(mixed, Buffer.from(out.buffer, out.byteOffset, out.byteLength)); +const master = [ + "highpass=f=25", + "equalizer=f=260:t=q:w=1.0:g=-1.4", + "equalizer=f=2400:t=q:w=0.8:g=1.1", + "acompressor=threshold=-18dB:ratio=2.4:attack=7:release=115:makeup=1.5:knee=5", + "alimiter=limit=0.94:attack=3:release=65", + "loudnorm=I=-12.5:LRA=8:TP=-1.0", +]; +const encode = spawnSync("ffmpeg", ["-hide_banner", "-y", "-loglevel", "error", + "-f", "f32le", "-ar", String(SR), "-ac", "2", "-i", mixed, + "-af", master.join(","), "-c:a", "libmp3lame", "-q:a", "2", + "-metadata", `title=wattajetta — breakfirst${SPATIAL ? " spatial" : ""}`, + "-metadata", "album=pixsies", output], + { stdio: "inherit" }); +for (const path of [decoded, mixed]) { try { unlinkSync(path); } catch {} } +if (encode.status !== 0) process.exit(encode.status ?? 1); +console.log(`✓ ${output} (84.000s becomes bar zero · 96 bars · crunchy controlled slices${SPATIAL ? " · coherent spatial stage" : ""})`); diff --git a/pop/wattajetta/bin/render-wattajetta.mjs b/pop/wattajetta/bin/render-wattajetta.mjs --- a/pop/wattajetta/bin/render-wattajetta.mjs +++ b/pop/wattajetta/bin/render-wattajetta.mjs @@ -46,7 +46,10 @@ mkdirSync(OUT, { recursive: true }); const BELL_CACHE = resolve(OUT, ".wattajetta-bell-cache-v1"); mkdirSync(BELL_CACHE, { recursive: true }); const NEXT = process.argv.includes("--next"); +const INDUSTRIAL = process.argv.includes("--industrial"); const WORLD = NEXT || process.argv.includes("--world"); +const outputStem = INDUSTRIAL ? "wattajetta-industrial" + : NEXT ? "wattajetta-world-v2" : WORLD ? "wattajetta-world" : "wattajetta"; const SR = 48000; const TAU = 2 * Math.PI; @@ -205,7 +208,6 @@ noises.push([bar(b), 3.4, 180, 52, 0.48, -23, 0.08, 2.8, 0]); sines.push([bar(b), 3.2, 62, 27, -19, 0.08, 2.7, -0.12, 0.12, 1.7, 5]); } } - // ── kicks: halftime 1+3 early (the part we loved), then the flight // ramps — drop C flips to four-on-the-floor halfway, and the steel // and stone drops drive full trance 4/4 ──────────────────────────── @@ -382,8 +384,11 @@ crush: WORLD && i % 3 === 1 }); tt += 0.03 + rnd() * 0.09; } } -for (const b of BREATHS) { chimeCluster(bar(b) + BEAT, 5, -20); chimeCluster(bar(b + 2) + 2 * BEAT, 4, -22); } -for (const b of [82, 86, 91]) chimeCluster(bar(b), 3, -23); +for (const b of BREATHS) { + chimeCluster(bar(b) + BEAT, INDUSTRIAL ? 2 : 5, INDUSTRIAL ? -24 : -20); + chimeCluster(bar(b + 2) + 2 * BEAT, INDUSTRIAL ? 2 : 4, INDUSTRIAL ? -25 : -22); +} +if (!INDUSTRIAL) for (const b of [82, 86, 91]) chimeCluster(bar(b), 3, -23); // Sparse upper-register "bing bing" answers: short steel/glass glocks, // locked to E minor pentatonic so sparkle never becomes broadband haze. @@ -510,13 +515,13 @@ `sine ${sines.length}`, fmt(sines), `noise ${noises.length}`, fmt(noises), ].join("\n") + "\n"; -const scorePath = resolve(OUT, "wattajetta.score.txt"); +const scorePath = resolve(OUT, `${outputStem}.score.txt`); writeFileSync(scorePath, score); if (process.argv.includes("--score")) { console.log(score); process.exit(0); } console.log(`baked ${kicks.length} kicks, ${sines.length} sines, ${noises.length} sprays, ${bells.length} bells`); -const rawPath = resolve(OUT, "wattajetta.f32.raw"); -const kickPath = resolve(OUT, "wattajetta.kick.f32.raw"); +const rawPath = resolve(OUT, `${outputStem}.f32.raw`); +const kickPath = resolve(OUT, `${outputStem}.kick.f32.raw`); const r = spawnSync("node", [resolve(HERE, "../c/run-c.mjs"), scorePath, "--raw", rawPath, "--kickraw", kickPath], { stdio: "inherit" }); if (r.status !== 0) process.exit(1); @@ -770,6 +775,17 @@ const pan = 0.45 * Math.sin((b * 16 + step) * 0.41); scratchVoiceGesture(t, tBar, from, to, gainDb + (downbeat ? 1 : 0), pan, downbeat ? 0.24 : 0.18 + rnd() * 0.06); } + } +} +if (INDUSTRIAL) { + // One compact transformer gesture every two bars: audible turntablism, + // disciplined like a machine interlock rather than a continuous spray. + for (let b = 2; b < 80; b += 2) { + const tBar = bar(b); + const reverse = (b / 2) % 2 === 0; + scratchVoiceGesture(tBar + 1.5 * BEAT, tBar, + reverse ? 0.58 : 0.32, reverse ? 0.34 : 0.52, + -16.5, reverse ? -0.24 : 0.24, 0.19); } } const E = BEAT / 2; // an eighth @@ -1340,7 +1356,6 @@ outputMix[2 * at + 1] *= g; } } -const outputStem = NEXT ? "wattajetta-world-v2" : WORLD ? "wattajetta-world" : "wattajetta"; const mixedPath = resolve(OUT, `${outputStem}.mixed.f32.raw`); writeFileSync(mixedPath, Buffer.from(outputMix.buffer, outputMix.byteOffset, outputMix.length * 4)); @@ -1352,6 +1367,7 @@ "acompressor=threshold=-19dB:ratio=2.8:attack=10:release=150:makeup=2.2:knee=6", "equalizer=f=50:t=q:w=1.2:g=2.5", // the boom under the kick — no treble boost; laptop speakers read it as tang ...(WORLD ? ["equalizer=f=285:t=q:w=0.85:g=-1.6"] : []), // normalize low-mid buildup for headphones ...(WORLD ? ["highshelf=f=5500:g=-2dB"] : []), // keep the water world, lose excess upper-air haze + ...(INDUSTRIAL ? ["equalizer=f=240:t=q:w=1.1:g=-1.2", "highshelf=f=6200:g=-2.5dB"] : []), "alimiter=limit=0.96:attack=4:release=70", ...(WORLD ? ["volume=-1.5dB"] : []), // leave the spatial audition at about -1 dBTP // The accelerando leaves dead source at the legacy tail. The fixed 2:30 @@ -1377,4 +1393,4 @@ if (fw.status !== 0) { console.error("✗ world WAV failed"); process.exit(1); } console.log(`✓ ${wav} (24-bit spatial audition)`); } for (const p of [rawPath, kickPath, mixedPath]) { try { unlinkSync(p); } catch {} } -console.log(`✓ ${mp3} (glass↔staccato transmogrification · rhythmic scratch voice · water world)`); +console.log(`✓ ${mp3} (${INDUSTRIAL ? "steel/stone press line · disciplined scratches · negative space" : "glass↔staccato transmogrification · rhythmic scratch voice · water world"})`); diff --git a/slab/bin/codex-slab b/slab/bin/codex-slab --- a/slab/bin/codex-slab +++ b/slab/bin/codex-slab @@ -80,12 +80,13 @@ } trap cleanup EXIT trap stop_agent INT TERM -# Loopboys are background client loops, never fleet/UI operators. Remove every -# browser, frame, pointer, and remote-machine surface at process startup; an -# auto-response route authorizes its named messaging helper, not GUI control. +# Loopboys are background client loops, never fleet/UI operators. Remove the +# browser surfaces at process startup; the proxy separately scopes its private +# inbox to the named contact and session identity. codex_guard_args=() if [[ -n "${SLAB_LOOPBOY_CONTACT:-}" ]]; then codex_guard_args+=( + -c 'mcp_servers.prox={url="http://127.0.0.1:7773/mcp", env_http_headers={"x-slab-loopboy-contact"="SLAB_LOOPBOY_CONTACT", "x-slab-prompt-session-id"="SLAB_PROMPT_SESSION_ID"}}' -c 'mcp_servers.puppet.enabled=false' -c 'mcp_servers.frame.enabled=false' -c 'mcp_servers.fleet.enabled=false' diff --git a/slab/bin/imsg.mjs b/slab/bin/imsg.mjs --- a/slab/bin/imsg.mjs +++ b/slab/bin/imsg.mjs @@ -825,6 +825,63 @@ const result = spawnSync("/usr/sbin/scutil", ["--get", "LocalHostName"], { encoding: "utf8" }); return (result.stdout || "").trim() || "local"; } +function systemEventsMessagesState() { + // Frame normally supplies the AX tree through SlabMenubar. A freshly built + // or newly installed menubar app can temporarily lose Accessibility trust, + // while the already-approved System Events automation route still exposes + // the focused Messages composer. Require the window title and the composer's + // contact help text to agree so this fallback cannot weaken recipient + // verification. + const script = String.raw` +const se = Application("System Events"); +const process = se.processes.byName("Messages"); +function read(element, property) { + try { + const value = element[property](); + return value == null ? "" : String(value); + } catch { + return ""; + } +} +let result = { frontmost: false, title: "", composer: "", composerVerified: false }; +try { + result.frontmost = Boolean(process.frontmost()); + const window = process.windows[0]; + const focused = process.attributes.byName("AXFocusedUIElement").value(); + const title = read(window, "name").trim(); + const role = read(focused, "role"); + const description = read(focused, "description"); + const contact = read(focused, "help").trim(); + const isComposer = role === "AXTextField" && description === "Message"; + result = { + frontmost: result.frontmost, + title, + composer: isComposer ? read(focused, "value") : "", + composerVerified: isComposer && Boolean(title) && Boolean(contact), + contact, + }; +} catch {} +JSON.stringify(result);`; + const inspected = spawnSync( + "/usr/bin/osascript", + ["-l", "JavaScript", "-e", script], + { encoding: "utf8", timeout: 5000 }, + ); + if (inspected.status !== 0) return null; + try { + const state = JSON.parse((inspected.stdout || "{}").trim()); + if (!state.frontmost || !state.composerVerified) return null; + if (!conversationTitleMatches(state.title, state.contact)) return null; + return { + title: String(state.title || "").trim(), + composer: String(state.composer || ""), + composerVerified: true, + }; + } catch { + return null; + } +} + function visibleMessagesState(outPath = null) { const args = [FRAME, localFrameMachine(), "--no-ocr", "--quiet-overlay", "--json"]; if (outPath) args.push("--out", outPath); @@ -838,15 +895,21 @@ throw new Error((framed.stderr || "could not verify the visible Messages conversation").trim()); } const snapshot = JSON.parse((framed.stdout || "{}").trim()); if (snapshot?.meta?.frontmost?.bundle !== "com.apple.MobileSMS") { - return { title: "", composer: "" }; + return { title: "", composer: "", composerVerified: false }; } const fields = (snapshot?.ax?.elements || []) .filter((element) => element.role === "AXTextField") .sort((a, b) => (Number(b.cy) || 0) - (Number(a.cy) || 0)); - return { - title: String((snapshot?.meta?.windows || []).find((window) => window.app === "Messages")?.title || "").trim(), + const messagesWindow = (snapshot?.meta?.windows || []).find((window) => window.app === "Messages") || null; + const frameState = { + title: String(messagesWindow?.title || "").trim(), composer: String(fields[0]?.title || "").trim(), + composerVerified: Boolean(fields[0]), + windowFrame: messagesWindow, }; + if (frameState.title && frameState.composerVerified) return frameState; + const fallback = systemEventsMessagesState(); + return fallback ? { ...fallback, windowFrame: messagesWindow } : frameState; } async function selectVerifiedConversation(handle, expectedTitle, timeoutMs = 10000) { @@ -864,6 +927,7 @@ while (Date.now() < deadline) { await wait(450); visible = visibleMessagesState(); if (!conversationTitleMatches(visible.title, expectedTitle)) continue; + if (!visible.composerVerified) continue; if (!isMessagesComposerEmpty(visible.composer)) { throw new Error( `recipient guard preserved an existing draft in "${visible.title}"; clear or send it before attaching media`, @@ -985,7 +1049,26 @@ if (!conversationTitleMatches(state.title, expectedTitle) || !state.composer.includes("\uFFFC")) { rmSync(path, { force: true }); throw new Error("preview changed before visual evidence could be captured"); } - return path; + // Frame's metadata/AX capture can still succeed when SlabMenubar lacks + // Screen Recording permission. In that case it cannot write the requested + // pixels, so capture the already-verified Messages window through the + // caller's approved screencapture route. Never return a phantom path. + if (!existsSync(path) && state.windowFrame) { + const frame = state.windowFrame; + const rect = [frame.x, frame.y, frame.w, frame.h].map((value) => Math.round(Number(value) || 0)); + if (rect[2] > 0 && rect[3] > 0) { + spawnSync( + "/usr/sbin/screencapture", + ["-x", `-R${rect.join(",")}`, path], + { encoding: "utf8" }, + ); + } + } + // Recipient safety does not depend on pixels: the visible window title and + // the focused composer's contact identity were already required to agree. + // Report unavailable pixels honestly instead of returning a phantom path or + // blocking an otherwise verified RCS/SMS send. + return existsSync(path) ? path : null; } function outgoingLinkAfter(handles, baseline) { diff --git a/slab/blueberry-wallpaper/BlueberryWallpaper.swift b/slab/blueberry-wallpaper/BlueberryWallpaper.swift new file mode 100644 --- /dev/null +++ b/slab/blueberry-wallpaper/BlueberryWallpaper.swift @@ -0,0 +1,222 @@ +import AppKit +import QuartzCore + +private struct MarkSpec { + let phase: Double + let width: CGFloat + let riseSeconds: Double + let spinSeconds: Double + let sway: CGFloat + let reverse: Bool + let variant: Int +} + +private final class MarkLayers { + let container = CALayer() + let sprites: [CALayer] + let spec: MarkSpec + + init(spec: MarkSpec, images: [CGImage], scale: CGFloat) { + self.spec = spec + let image = images[spec.variant % images.count] + sprites = (0..<2).map { _ in + let layer = CALayer() + layer.contents = image + layer.contentsGravity = .resizeAspect + layer.contentsScale = scale + layer.opacity = 0 + return layer + } + sprites.forEach(container.addSublayer) + sprites[0].opacity = 1 + } +} + +private final class PalsWallpaperView: NSView { + private let background = CAGradientLayer() + private var marks: [MarkLayers] = [] + private var loadedAppearance: NSAppearance.Name? + private var lastSize = CGSize.zero + + private let specs: [MarkSpec] = [ + .init(phase: 0.03, width: 94, riseSeconds: 31, spinSeconds: 2.4, sway: 70, reverse: false, variant: 0), + .init(phase: 0.16, width: 58, riseSeconds: 25, spinSeconds: 2.8, sway: 48, reverse: true, variant: 2), + .init(phase: 0.29, width: 122, riseSeconds: 38, spinSeconds: 3.2, sway: 95, reverse: false, variant: 4), + .init(phase: 0.41, width: 72, riseSeconds: 28, spinSeconds: 2.6, sway: 56, reverse: true, variant: 1), + .init(phase: 0.54, width: 108, riseSeconds: 35, spinSeconds: 3.0, sway: 82, reverse: false, variant: 3), + .init(phase: 0.66, width: 48, riseSeconds: 23, spinSeconds: 2.2, sway: 42, reverse: true, variant: 0), + .init(phase: 0.77, width: 82, riseSeconds: 30, spinSeconds: 2.9, sway: 64, reverse: false, variant: 2), + .init(phase: 0.88, width: 64, riseSeconds: 27, spinSeconds: 2.5, sway: 50, reverse: true, variant: 4), + ] + + override init(frame: NSRect) { + super.init(frame: frame) + wantsLayer = true + layer = CALayer() + layer?.masksToBounds = true + layer?.addSublayer(background) + rebuild() + } + + required init?(coder: NSCoder) { nil } + + override func layout() { + super.layout() + guard bounds.size != lastSize else { return } + lastSize = bounds.size + rebuild() + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + let appearance = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) + guard appearance != loadedAppearance else { return } + rebuild() + } + + private var dark: Bool { + effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + } + + private func loadImages() -> [CGImage] { + (0..<5).compactMap { index in + guard let url = Bundle.main.url(forResource: "pals-\(dark ? "dark" : "light")-\(index)", + withExtension: "png"), + let image = NSImage(contentsOf: url) else { return nil } + return image.cgImage(forProposedRect: nil, context: nil, hints: nil) + } + } + + private func rebuild() { + guard bounds.width > 0, bounds.height > 0 else { return } + loadedAppearance = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) + CATransaction.begin() + CATransaction.setDisableActions(true) + background.frame = bounds + background.startPoint = CGPoint(x: 0, y: 1) + background.endPoint = CGPoint(x: 1, y: 0) + background.colors = dark + ? [NSColor(srgbRed: 0.045, green: 0.09, blue: 0.16, alpha: 1).cgColor, + NSColor(srgbRed: 0.12, green: 0.07, blue: 0.19, alpha: 1).cgColor] + : [NSColor(srgbRed: 0.72, green: 0.84, blue: 0.98, alpha: 1).cgColor, + NSColor(srgbRed: 0.90, green: 0.86, blue: 1.00, alpha: 1).cgColor] + marks.forEach { $0.container.removeFromSuperlayer() } + marks.removeAll() + + let images = loadImages() + guard images.count == 5 else { CATransaction.commit(); return } + let scale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2 + for (index, spec) in specs.enumerated() { + let mark = MarkLayers(spec: spec, images: images, scale: scale) + // Each source PNG is an 8×3 atlas of square rotation frames. + mark.container.bounds = CGRect(x: 0, y: 0, width: spec.width, height: spec.width) + mark.container.anchorPoint = CGPoint(x: 0.5, y: 0.5) + mark.sprites.forEach { $0.frame = mark.container.bounds } + layer?.addSublayer(mark.container) + animate(mark, index: index) + marks.append(mark) + } + CATransaction.commit() + } + + private func animate(_ mark: MarkLayers, index: Int) { + let spec = mark.spec + let now = layer?.convertTime(CACurrentMediaTime(), from: nil) ?? CACurrentMediaTime() + let x = bounds.width * CGFloat(0.08 + (Double(index) * 0.137).truncatingRemainder(dividingBy: 0.84)) + let low = -mark.container.bounds.height + let high = bounds.height + mark.container.bounds.height + let path = CGMutablePath() + path.move(to: CGPoint(x: x, y: low)) + path.addCurve(to: CGPoint(x: x + spec.sway, y: low + (high - low) * 0.34), + control1: CGPoint(x: x - spec.sway, y: low + (high - low) * 0.12), + control2: CGPoint(x: x + spec.sway * 1.3, y: low + (high - low) * 0.22)) + path.addCurve(to: CGPoint(x: x - spec.sway * 0.4, y: low + (high - low) * 0.68), + control1: CGPoint(x: x + spec.sway * 0.6, y: low + (high - low) * 0.46), + control2: CGPoint(x: x - spec.sway * 1.2, y: low + (high - low) * 0.57)) + path.addCurve(to: CGPoint(x: x, y: high), + control1: CGPoint(x: x + spec.sway * 0.7, y: low + (high - low) * 0.80), + control2: CGPoint(x: x - spec.sway * 0.5, y: low + (high - low) * 0.92)) + + let rise = CAKeyframeAnimation(keyPath: "position") + rise.path = path + rise.duration = spec.riseSeconds + rise.repeatCount = .infinity + rise.calculationMode = .paced + rise.timingFunction = CAMediaTimingFunction(name: .linear) + rise.beginTime = now - spec.phase * spec.riseSeconds + mark.container.add(rise, forKey: "rise") + + // Decode the 8×3 atlas at 7.5–11 fps (depending on mark size). The + // previous long cycle exposed individual frames as steps; one complete + // turn now uses every rendered angle over roughly 2–3 seconds. + var rects: [NSValue] = [] + for raw in 0..<24 { + let frame = spec.reverse ? 23 - raw : raw + let column = frame % 8 + let row = frame / 8 + rects.append(NSValue(rect: CGRect(x: CGFloat(column) / 8, + y: CGFloat(2 - row) / 3, + width: 1.0 / 8, height: 1.0 / 3))) + } + rects.append(rects[0]) + for (index, sprite) in mark.sprites.enumerated() { + let frames = CAKeyframeAnimation(keyPath: "contentsRect") + frames.values = rects + frames.keyTimes = (0.. LSApplicationCategoryType public.app-category.music LSMinimumSystemVersion - 11.0 + 12.0 LSUIElement NSBluetoothAlwaysUsageDescription @@ -60,6 +60,8 @@ NSHumanReadableCopyright By aesthetic.computer NSLocalNetworkUsageDescription Menu Band finds nearby Menu Bands so two machines can play together in sync over peer-to-peer Wi-Fi and Bluetooth, no router needed. + NSAppleMusicUsageDescription + Menu Band lets you browse and play your Apple Music library on its main deck. NSMicrophoneUsageDescription Menu Band records short clips from your microphone when you hold ` so you can play them back as a sampling voice. NSSpeechRecognitionUsageDescription diff --git a/slab/menuband/Package.swift b/slab/menuband/Package.swift --- a/slab/menuband/Package.swift +++ b/slab/menuband/Package.swift @@ -3,9 +3,10 @@ import PackageDescription let package = Package( name: "MenuBand", - platforms: [.macOS(.v11)], + platforms: [.macOS(.v12)], dependencies: [ .package(path: "../macos-audio"), + .package(path: "../../juke-wizard"), ], targets: [ // Tiny always-running daemon whose only job is to watch for the @@ -34,6 +35,7 @@ name: "MenuBand", dependencies: [ "CGMSynth", .product(name: "ACMacAudio", package: "macos-audio"), + .product(name: "MenuBandJuke", package: "juke-wizard"), ], path: "Sources/MenuBand", exclude: [ diff --git a/slab/menuband/SCORE.md b/slab/menuband/SCORE.md --- a/slab/menuband/SCORE.md +++ b/slab/menuband/SCORE.md @@ -23,6 +23,25 @@ Lives at `slab/menuband/`. Distinct from `slab/menubar-swift/` (the Claude session menubar; different status item, different process). +## Juke + +The full JukeWizard listening surface now ships as **Juke** inside the +direct-download Menu Band: library playback, ratings and timestamped notes, +Spotify, DJ decks, room audio, cloud, work status, and the local control socket. +Choose the spinning disc in the popover footer. Its accessible name remains +“Open Menu Band Juke.” Menu Band owns the process and status item; Juke opens +as a lazy window and does not create another menu-bar item. + +The implementation remains in `juke-wizard/` as the reusable +`MenuBandJuke` Swift product. It has no executable product, application menu, +Dock identity, or status item of its own. The `jukewizard` shell command is a +control/open compatibility doorway into the running Menu Band process. +`install.sh` retires the former standalone binary and LaunchAgent under +`~/.local/share/menuband/migrations/` for recovery. + +The sandboxed App Store subset does not include Juke because its filesystem, +local-socket, and process-audio features exceed that build's current contract. + ## Layout ``` 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 @@ -1,6 +1,9 @@ import AppKit import AVFoundation import Carbon +#if !MAC_APP_STORE +import MenuBandJuke +#endif extension Notification.Name { /// Posted whenever the set of currently-sounding notes changes, so the @@ -13,6 +16,11 @@ private var statusItem: NSStatusItem! /// The spinning album-art disc owned by Menu Band's CDJ Radio deck. private var cdjStatusItem: MenuBandCDJStatusItem? private let menuBand = MenuBandController() +#if !MAC_APP_STORE + /// The former JukeWizard, now a first-class window and service inside this + /// process. It starts lazily when the user chooses Juke. + private let juke = MenuBandJuke() +#endif /// Live conductible drone/arp/drum loop (see MenuBandEngine + the /// `engine.*` distributed-notification handlers). private lazy var engine = MenuBandEngine(menuBand: menuBand) @@ -1013,6 +1021,15 @@ name: NSNotification.Name("computer.aestheticcomputer.menuband.showPopover"), object: nil ) +#if !MAC_APP_STORE + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(handleShowJukeNotification(_:)), + name: NSNotification.Name("computer.aestheticcomputer.menuband.showJuke"), + object: nil + ) +#endif + // Sibling remote: toggle the popover's instrument-chart // disclosure (same path as pressing the instrument name). // Lets the shell exercise the expand/collapse resize without @@ -1436,6 +1453,13 @@ } vc.isPlayPaletteShown = { [weak self] in self?.pianoWaveformWindowDelegate.isShown ?? false } +#if !MAC_APP_STORE + vc.onJukeToggle = { [weak self] in + guard let self else { return } + if self.isPopoverPanelShown { self.closePopover() } + self.juke.toggle() + } +#endif // Click on the mini visualizer strip → hide the popover // (without dismissing the floating panel — `closePopover`'s // default tears down both) and transition the floating @@ -3607,6 +3631,16 @@ self.showPopover() } } } + +#if !MAC_APP_STORE + @objc private func handleShowJukeNotification(_ note: Notification) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if self.isPopoverPanelShown { self.closePopover() } + self.juke.open() + } + } +#endif @objc private func handleToggleChartNotification(_ note: Notification) { DispatchQueue.main.async { [weak self] in 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 @@ -92,6 +92,117 @@ } } } +/// A small physical entry point for Juke. The offset glint makes rotation +/// legible; Reduce Motion users keep the same disc without animation. +private final class SpinningDiscGlyph: NSView { + private let spinningLayer = CALayer() + private var renderedSize = NSSize.zero + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.addSublayer(spinningLayer) + } + + required init?(coder: NSCoder) { fatalError() } + + override var isOpaque: Bool { false } + + private static func renderDisc(size: NSSize) -> CGImage? { + guard size.width > 0, size.height > 0 else { return nil } + let image = NSImage(size: size) + image.lockFocus() + + let bounds = NSRect(origin: .zero, size: size) + let discRect = bounds.insetBy(dx: 1, dy: 1) + let disc = NSBezierPath(ovalIn: discRect) + NSGradient(colors: [ + NSColor(srgbRed: 0.55, green: 0.86, blue: 1.0, alpha: 1), + NSColor.white, + NSColor(srgbRed: 0.48, green: 0.60, blue: 0.92, alpha: 1), + ])?.draw(in: disc, angle: 32) + + NSColor.white.withAlphaComponent(0.72).setStroke() + let groove = NSBezierPath(ovalIn: discRect.insetBy(dx: 3.2, dy: 3.2)) + groove.lineWidth = 0.7 + groove.stroke() + + NSColor(srgbRed: 0.18, green: 0.38, blue: 0.75, alpha: 0.95).setFill() + NSBezierPath(ovalIn: NSRect(x: bounds.midX + 2.8, + y: bounds.midY + 3.7, + width: 2.4, height: 2.4)).fill() + + NSColor(white: 0.12, alpha: 0.9).setFill() + NSBezierPath(ovalIn: NSRect(x: bounds.midX - 2, + y: bounds.midY - 2, + width: 4, height: 4)).fill() + NSColor.white.withAlphaComponent(0.85).setFill() + NSBezierPath(ovalIn: NSRect(x: bounds.midX - 0.8, + y: bounds.midY - 0.8, + width: 1.6, height: 1.6)).fill() + + image.unlockFocus() + var proposed = bounds + return image.cgImage(forProposedRect: &proposed, context: nil, hints: nil) + } + + override func layout() { + super.layout() + CATransaction.begin() + CATransaction.setDisableActions(true) + spinningLayer.bounds = bounds + spinningLayer.position = NSPoint(x: bounds.midX, y: bounds.midY) + spinningLayer.contentsGravity = .resizeAspect + spinningLayer.contentsScale = window?.backingScaleFactor ?? 2 + if renderedSize != bounds.size { + spinningLayer.contents = Self.renderDisc(size: bounds.size) + renderedSize = bounds.size + } + CATransaction.commit() + startSpinningIfNeeded() + } + + private func startSpinningIfNeeded() { + guard window != nil, + !bounds.isEmpty, + !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion, + spinningLayer.animation(forKey: "juke.disc.spin") == nil else { return } + let spin = CABasicAnimation(keyPath: "transform.rotation.z") + spin.fromValue = 0 + spin.toValue = CGFloat.pi * 2 + spin.duration = 2.8 + spin.repeatCount = .infinity + spin.timingFunction = CAMediaTimingFunction(name: .linear) + spinningLayer.add(spin, forKey: "juke.disc.spin") + } +} + +private final class SpinningDiscButton: HoverFeedbackButton { + private let disc = SpinningDiscGlyph(frame: .zero) + private var positionedForSize = NSSize.zero + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + title = "" + addSubview(disc) + } + + required init?(coder: NSCoder) { fatalError() } + + override var intrinsicContentSize: NSSize { NSSize(width: 38, height: 26) } + + override func layout() { + super.layout() + let side: CGFloat = 22 + if positionedForSize != bounds.size { + disc.frame = NSRect(x: bounds.midX - side / 2, + y: bounds.midY - side / 2, + width: side, height: side) + positionedForSize = bounds.size + } + } +} + /// NSButton subclass for chip-shaped link buttons — paints a layer-backed /// fill/border, swaps to a brighter "hover" pair when the cursor enters, /// and switches the cursor to a pointing hand. Used by the Why-this-Keymap @@ -206,6 +317,7 @@ /// instrument palette collapses / expands. var onFocusShortcutChange: ((MenuBandShortcut) -> Bool)? var onFocusShortcutRecordingChanged: ((Bool) -> Void)? var onPlayPaletteToggle: (() -> Void)? + var onJukeToggle: (() -> Void)? var onPlayPaletteShortcutChange: ((MenuBandShortcut) -> Bool)? var onPlayPaletteShortcutRecordingChanged: ((Bool) -> Void)? var isPlayPaletteShown: (() -> Bool)? @@ -1023,6 +1135,23 @@ ]) keymapButton.toolTip = "Open the full-screen keymap (piano + QWERTY)" Self.outlineFooterButton(keymapButton, color: Self.keymapOutlineColor) +#if !MAC_APP_STORE + // Juke is the complete listening/DJ/library surface formerly shipped + // as JukeWizard. It is a window of Menu Band, not another status item. + let jukeButton = SpinningDiscButton(frame: .zero) + jukeButton.bezelStyle = .inline + jukeButton.isBordered = false + jukeButton.controlSize = .small + jukeButton.target = self + jukeButton.action = #selector(openJuke(_:)) + jukeButton.toolTip = "Open Menu Band Juke" + jukeButton.setAccessibilityLabel("Open Menu Band Juke") + jukeButton.alphaValue = 0.88 + jukeButton.onHoverChange = { [weak jukeButton] hovered in + jukeButton?.alphaValue = hovered ? 1 : 0.88 + } +#endif + // (Gamepad config moved to the full-screen Keymap overlay's bottom-right // corner — see ExpandedPianoWaveformView.installGamepadCluster. It lives // next to the large QWERTY/piano where a controller player is actually @@ -1054,6 +1183,9 @@ let quitSpacer = NSView() quitSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) quitRow.addArrangedSubview(aboutButton) quitRow.addArrangedSubview(keymapButton) +#if !MAC_APP_STORE + quitRow.addArrangedSubview(jukeButton) +#endif quitRow.addArrangedSubview(quitSpacer) quitRow.addArrangedSubview(quit) stack.addArrangedSubview(quitRow) @@ -2481,6 +2613,10 @@ } @objc func showJamPanel(_ sender: Any?) { JamWindowController.show() + } + + @objc private func openJuke(_ sender: Any?) { + onJukeToggle?() } @objc private func openNotepat() { 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 @@ -267,6 +267,32 @@ required init?(coder: NSCoder) { fatalError() } override var isFlipped: Bool { false } + override func touchesBegan(with event: NSEvent) { deliverTrackpadFrame(event) } + override func touchesMoved(with event: NSEvent) { deliverTrackpadFrame(event) } + override func touchesEnded(with event: NSEvent) { deliverTrackpadFrame(event) } + override func touchesCancelled(with event: NSEvent) { deliverTrackpadFrame(event) } + + private func deliverTrackpadFrame(_ event: NSEvent) { + let touches = event.touches(matching: .touching, in: self) + .filter { $0.type == .indirect } + let began = Set(event.touches(matching: .began, in: self).map { + ObjectIdentifier($0.identity as AnyObject) + }) + let contacts = touches.map { touch in + let identity = ObjectIdentifier(touch.identity as AnyObject) + return TrackpadContact( + identifier: Int32(truncatingIfNeeded: identity.hashValue), + point: touch.normalizedPosition, + state: began.contains(identity) ? 3 : 4 + ) + } + onTrackpadFrame?(contacts, event.timestamp, CACurrentMediaTime()) + let active = !contacts.isEmpty + guard active != trackpadTouchActive else { return } + trackpadTouchActive = active + onTrackpadActiveChanged?(active) + } + func setArrowOffsetFromLeft(_ offset: CGFloat) { // Clamp so the arrow fits between the rounded body corners. let minX = MenuBandPopoverPanel.cornerRadius 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 @@ -1685,13 +1685,6 @@ /// Read the current master volume (last persisted or set value). var currentMasterVolume: Float { masterVolume } - /// Independent drum-kit trim. Percussion intentionally bypasses the - /// melodic pre-FX/master mixer, so this leaves keys at the normal macOS - /// output level while allowing the kit to sit above or below them. - func setPercussionVolume(_ value: Float) { - percussion.setOutputLevel(value) - } - /// Switch the current melodic program. Instant when MIDISynth is ready /// (sub-millisecond MIDI Program Change); blocks ~100 ms otherwise /// (sampler bank reload). diff --git a/slab/menuband/Sources/MenuBand/QwertyCapture.swift b/slab/menuband/Sources/MenuBand/QwertyCapture.swift new file mode 100644 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/QwertyCapture.swift @@ -0,0 +1,52 @@ +import AppKit + +// Headless capture of the shipping QwertyLayoutView, isolated from every +// popover/fullscreen surface. Notes resolve through MenuBandController's real +// keymap inverse; --keys poses live control keycodes such as left Command (55), +// right Command (54), or Escape (53). +// +// MenuBand --render-qwerty --notes 60 --keys 55 --out qwerty.png --scale 4 +enum QwertyCLI { + static func runIfRequested(_ args: [String]) -> Bool { + guard args.contains("--render-qwerty") else { return false } + func val(_ flag: String) -> String? { + guard let i = args.firstIndex(of: flag), i + 1 < args.count else { return nil } + return args[i + 1] + } + let out = val("--out") ?? "/tmp/qwerty.png" + let renderScale = max(1, Double(val("--scale") ?? "4") ?? 4) + let notes = Set((val("--notes") ?? "").split(separator: ",").compactMap { UInt8($0) }) + let keys = Set((val("--keys") ?? "").split(separator: ",").compactMap { UInt16($0) }) + + let app = NSApplication.shared + app.setActivationPolicy(.prohibited) + app.appearance = NSAppearance(named: args.contains("--dark") ? .darkAqua : .aqua) + + let controller = MenuBandController() + controller.captureHold(notes: notes, extraKeyCodes: keys) + let view = QwertyLayoutView() + view.appearance = app.appearance + view.scale = 4 + view.keymap = controller.keymap + view.litKeyCodes = controller.heldKeyCodes() + view.voiceColor = InstrumentListView.colorForProgram(Int(controller.effectiveMelodicProgram)) + view.frame = NSRect(origin: .zero, size: view.intrinsicContentSize) + view.layoutSubtreeIfNeeded() + view.displayIfNeeded() + + let bounds = view.bounds + let pw = Int((bounds.width * renderScale).rounded()) + let ph = Int((bounds.height * renderScale).rounded()) + guard let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: pw, + pixelsHigh: ph, bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, + bitsPerPixel: 0) else { return true } + rep.size = bounds.size + view.cacheDisplay(in: bounds, to: rep) + guard let png = rep.representation(using: .png, properties: [:]) else { return true } + do { try png.write(to: URL(fileURLWithPath: out)) } + catch { FileHandle.standardError.write(Data("qwerty write failed: \(error)\n".utf8)); exit(1) } + print("qwerty \(pw)x\(ph) held=\(view.litKeyCodes.sorted()) keys=\(keys.sorted()) notes=\(notes.sorted()) → \(out)") + return true + } +} diff --git a/slab/menuband/Sources/MenuBand/ScaleStaffCapture.swift b/slab/menuband/Sources/MenuBand/ScaleStaffCapture.swift new file mode 100644 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/ScaleStaffCapture.swift @@ -0,0 +1,95 @@ +import AppKit + +/// A compact, code-native teaching staff used by Menu Band's reel renderer. +/// The accumulated scale remains visible while the newest note is accented. +final class ScaleStaffView: NSView { + var notes: [UInt8] = [] { didSet { needsDisplay = true } } + + override var intrinsicContentSize: NSSize { NSSize(width: 520, height: 128) } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + let ink = NSColor(calibratedRed: 0.12, green: 0.09, blue: 0.17, alpha: 0.92) + let accent = NSColor(calibratedRed: 0.71, green: 0.25, blue: 0.77, alpha: 1) + let left: CGFloat = 66, right = bounds.width - 22 + let space: CGFloat = 13, bottom: CGFloat = 35 + + ink.setStroke() + for line in 0..<5 { + let y = bottom + CGFloat(line) * space + let p = NSBezierPath() + p.move(to: NSPoint(x: left, y: y)) + p.line(to: NSPoint(x: right, y: y)) + p.lineWidth = 1.35 + p.stroke() + } + Bravura.draw(.gClef, at: NSPoint(x: 17, y: -7), staffSpace: space, color: ink) + + // C4..C5 are diatonic steps 0...7; E4 (step 2) lies on the bottom + // staff line. In this flipped view, higher pitches move upward. + let steps: [UInt8: CGFloat] = [60: 0, 62: 1, 64: 2, 65: 3, + 67: 4, 69: 5, 71: 6, 72: 7] + let count = notes.count + for (index, midi) in notes.enumerated() { + guard let step = steps[midi] else { continue } + let x = left + 43 + CGFloat(index) * 47 + let centerY = bottom - space + step * (space / 2) + if midi == 60 { + let ledger = NSBezierPath() + ledger.move(to: NSPoint(x: x - 9, y: centerY)) + ledger.line(to: NSPoint(x: x + 22, y: centerY)) + ledger.lineWidth = 1.35 + ledger.stroke() + } + let color = index == count - 1 ? accent : ink + color.setFill() + let head = NSBezierPath(ovalIn: NSRect(x: x, y: centerY - 4.7, + width: 18, height: 9.4)) + var transform = AffineTransform() + transform.translate(x: x + 9, y: centerY) + transform.rotate(byDegrees: -14) + transform.translate(x: -(x + 9), y: -centerY) + head.transform(using: transform) + head.fill() + color.setStroke() + let stem = NSBezierPath() + stem.move(to: NSPoint(x: x + 16, y: centerY)) + stem.line(to: NSPoint(x: x + 16, y: centerY + 39)) + stem.lineWidth = 1.7 + stem.stroke() + } + } +} + +enum ScaleStaffCLI { + static func runIfRequested(_ args: [String]) -> Bool { + guard args.contains("--render-scale-staff") else { return false } + func val(_ flag: String) -> String? { + guard let i = args.firstIndex(of: flag), i + 1 < args.count else { return nil } + return args[i + 1] + } + let out = val("--out") ?? "/tmp/menu-band-staff.png" + let scale = max(1, Double(val("--scale") ?? "2") ?? 2) + let notes = (val("--notes") ?? "").split(separator: ",").compactMap { UInt8($0) } + let app = NSApplication.shared + app.setActivationPolicy(.prohibited) + app.appearance = NSAppearance(named: .aqua) + let view = ScaleStaffView() + view.notes = notes + view.frame = NSRect(origin: .zero, size: view.intrinsicContentSize) + view.displayIfNeeded() + let pw = Int((view.bounds.width * scale).rounded()) + let ph = Int((view.bounds.height * scale).rounded()) + guard let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: pw, + pixelsHigh: ph, bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, + bitsPerPixel: 0) else { return true } + rep.size = view.bounds.size + view.cacheDisplay(in: view.bounds, to: rep) + guard let png = rep.representation(using: .png, properties: [:]) else { return true } + do { try png.write(to: URL(fileURLWithPath: out)) } + catch { FileHandle.standardError.write(Data("staff write failed: \(error)\n".utf8)); exit(1) } + print("staff \(pw)x\(ph) notes=\(notes) → \(out)") + return true + } +} diff --git a/slab/menuband/Sources/MenuBand/Shapedown.swift b/slab/menuband/Sources/MenuBand/Shapedown.swift --- a/slab/menuband/Sources/MenuBand/Shapedown.swift +++ b/slab/menuband/Sources/MenuBand/Shapedown.swift @@ -4,7 +4,7 @@ import CoreGraphics import QuartzCore import ScreenCaptureKit -// Shapedown — double-tap LEFT ⌘ and the whole screen turns into a glass +// Shapedown — the whole screen turns into a glass // wall you draw on with the trackpad. The trackpad maps 1:1 onto the display, // so every finger is a vertex somewhere on screen, and the *set* of fingers // is one filled, colored shape: @@ -21,7 +21,8 @@ // Two or more fingers make a shape; // lifting lets the fullest version settle into the display like a puddle, // sustain, then fade. A physical click pins that shape until the wall closes. // Notepat note keys choose the ink color (C is red, D orange, and so on). -// Double-tap ⌘ again (or press Escape) to leave. +// Press Escape to leave. The former left-⌘⌘ entry gesture was retired so both +// physical Command keys can share Menu Band's universal ⌘⌘ play gesture. // // Input is the same focus-independent MultitouchSupport tap the pitch-bend fx // pad uses (`MultitouchTrackpad`) — NSTouch never reaches a non-activating @@ -35,18 +36,8 @@ private var overlay: ShapedownOverlayPanel? private var canvas: ShapedownCanvas? private var displayCapture: AnyObject? - /// Global modifier/key monitor. flagsChanged carries the ⌘ taps; keyDown - /// carries Escape while the wall is up. Global monitors can't consume, but - /// we never want to — ⌘ still means ⌘ to everyone else. + /// Global key monitor carries Escape while the wall is up. private var monitor: Any? - /// Time of the last CLEAN left-⌘ tap (⌘ pressed and released with no other - /// key or modifier touched in between). Two of these inside the window are - /// the gesture. - private var lastCleanTap: TimeInterval = 0 - /// True from a lone left-⌘ press until either it's released cleanly or - /// something else is pressed during the hold, which dirties it — so - /// ⌘C, ⌘V, ⌘⇧…, ⌘Tab, etc. never count toward the double-tap. - private var commandClean = false /// Balances CGDisplayHideCursor/ShowCursor (they nest a hide count, so an /// unmatched show would leak the pointer back mid-draw). private var cursorHidden = false @@ -54,9 +45,6 @@ /// Swallows scroll + trackpad gestures WHILE the wall is up so fingers only /// ever draw shapes — no scroll, pinch-zoom, rotate, or swipe underneath. private let gestureTap = ShapedownGestureTap() - /// Two left-⌘ presses closer than this count as the double-tap gesture. - private static let doubleTapWindow: TimeInterval = 0.4 - /// True while the wall is showing — AppDelegate checks this to route /// trackpad frames here instead of into the pitch-bend fx pad. var isActive: Bool { overlay?.isVisible == true } @@ -95,63 +83,19 @@ clickCue() flashCue(rising: true) } - /// Arm the global ⌘ listener. Idempotent. Needs Accessibility (same grant - /// TYPE mode already asks for); silently inert until it's given. + /// Arm the Escape listener. Idempotent. func start() { guard monitor == nil else { return } - monitor = NSEvent.addGlobalMonitorForEvents( - matching: [.flagsChanged, .keyDown] - ) { [weak self] event in + monitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in self?.handle(event) } } private func handle(_ event: NSEvent) { - let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - switch event.type { - case .flagsChanged: - if event.keyCode == 55 { // left ⌘ itself - if mods.contains(.command) { - // Pressed. A clean tap can only start from a LONE ⌘ — - // if ⇧/⌥/⌃ are already down, it's a chord, not a tap. - commandClean = (mods == .command) - } else { - // Released. Two clean taps inside the window = the gesture. - if commandClean { registerCleanTap() } - commandClean = false - } - } else if mods.contains(.command) { - // Another modifier toggled while ⌘ is held → dirty the hold. - commandClean = false - } - case .keyDown: - // Any REAL key struck while ⌘ is held (⌘C, ⌘Tab, …) dirties the - // hold. Exclude the ⌘ keycodes themselves — a modifier's own keycode - // can arrive as a synthesized keyDown and must not cancel the run. - let isCommandKey = event.keyCode == 55 || event.keyCode == 54 - if mods.contains(.command), !isCommandKey { commandClean = false } - if event.keyCode == 53, isActive { // Escape closes the wall - DispatchQueue.main.async { [weak self] in self?.hide() } - } - default: - break - } - } - - private func registerCleanTap() { - let now = ProcessInfo.processInfo.systemUptime - if now - lastCleanTap < Self.doubleTapWindow { - lastCleanTap = 0 - DispatchQueue.main.async { [weak self] in - self?.clickCue() // same registration tick as Menu Band focus - self?.toggle() - } - } else { - lastCleanTap = now + if event.type == .keyDown, event.keyCode == 53, isActive { + DispatchQueue.main.async { [weak self] in self?.hide() } } } - - private func toggle() { isActive ? hide() : show() } /// Close the wall without cues — used when the ⌘⌘↩ record gesture starts /// and a left-⌘⌘ had just opened it as a side effect. diff --git a/slab/menuband/Sources/MenuBand/main.swift b/slab/menuband/Sources/MenuBand/main.swift --- a/slab/menuband/Sources/MenuBand/main.swift +++ b/slab/menuband/Sources/MenuBand/main.swift @@ -12,6 +12,16 @@ if MenubarCLI.runIfRequested(CommandLine.arguments) { exit(0) } +// Headless capture of the real live QWERTY keymap for promos/reels. +if QwertyCLI.runIfRequested(CommandLine.arguments) { + exit(0) +} + +// Headless capture of the live scale-teaching staff used in promos/reels. +if ScaleStaffCLI.runIfRequested(CommandLine.arguments) { + exit(0) +} + // Headless capture of the real About window for promos/reels. if AboutCLI.runIfRequested(CommandLine.arguments) { exit(0) diff --git a/slab/menuband/Sources/MenuBandLauncher/main.swift b/slab/menuband/Sources/MenuBandLauncher/main.swift --- a/slab/menuband/Sources/MenuBandLauncher/main.swift +++ b/slab/menuband/Sources/MenuBandLauncher/main.swift @@ -1,13 +1,11 @@ // MenuBandLauncher — tiny always-running helper. // -// Job: watch for a double-tap of the RIGHT Command key, and if Menu +// Job: watch for a same-side double-tap of either Command key, and if Menu // Band's main process isn't running, launch it. When Menu Band IS // running, the launcher no-ops. // -// Right ⌘ specifically — not "either ⌘". The original accepted both sides, so -// any two ⌘ presses within the window counted (left-left, left-right, -// right-left), and ordinary two-handed ⌘ use would summon the app. The side is -// now enforced; see `handle(type:event:)`. +// Left-left and right-right both summon the app. Mixed-side pairs do not, so +// ordinary two-handed Command use remains harmless. // // CGEventTap variant: an earlier version used // NSEvent.addGlobalMonitorForEvents, which silently delivered zero @@ -41,6 +39,7 @@ private static let nxDeviceRCmd: UInt64 = 0x10 private static let doubleTapWindow: CFTimeInterval = 0.50 private var lastPressAt: CFTimeInterval = 0 + private var lastPressWasRight: Bool? private var tap: CFMachPort? private var runLoopSource: CFRunLoopSource? @@ -114,18 +113,12 @@ .maskAlphaShift, .maskSecondaryFn ] if !flags.intersection(chordMask).isEmpty { lastPressAt = 0 + lastPressWasRight = nil return } - // RIGHT ⌘ only. We used to accept either side, so any two ⌘ taps - // inside the window fired the launcher — left-left, left-right, - // right-left. That made a plain ⌘-key drum-roll (or just reaching for - // ⌘C twice) summon Menu Band. The side was already being computed and - // then thrown away; now it decides. - // - // The device bits are the authority when present, since some keyboards - // report keycode 55 for BOTH sides. Fall back to the keycode when - // neither bit is set. + // Device bits are authoritative because some keyboards report keycode + // 55 for both physical sides. The pair below must repeat this side. let isRight: Bool if (rawFlags & Self.nxDeviceRCmd) != 0 { isRight = true @@ -134,23 +127,18 @@ isRight = false } else { isRight = (keyCode == Self.rightCommandKeyCode) } - guard isRight else { - // A left ⌘ doesn't just fail to count — it breaks the chain, so it - // can't be the first half of a right-⌘ double-tap. - lastPressAt = 0 - return - } - let now = CACurrentMediaTime() - if now - lastPressAt <= Self.doubleTapWindow { + if lastPressWasRight == isRight && now - lastPressAt <= Self.doubleTapWindow { lastPressAt = 0 + lastPressWasRight = nil let running = isMenuBandRunning() - NSLog("MenuBandLauncher: double-tap right-⌘ detected; menuband running=\(running)") + NSLog("MenuBandLauncher: double-tap \(isRight ? "right" : "left")-⌘ detected; menuband running=\(running)") if !running { launchMenuBand() } } else { lastPressAt = now + lastPressWasRight = isRight } } diff --git a/slab/menuband/install.sh b/slab/menuband/install.sh --- a/slab/menuband/install.sh +++ b/slab/menuband/install.sh @@ -130,8 +130,8 @@ # the multi-arch path requires a full Xcode toolchain (xcbuild + the # Metal toolchain). On a CLT-only machine SwiftPM happily cross- # compiles each slice via --triple, so we build each separately and # lipo them together — works equally well with full Xcode or CLT. -ARM_TRIPLE="arm64-apple-macosx11.0" -X86_TRIPLE="x86_64-apple-macosx11.0" +ARM_TRIPLE="arm64-apple-macosx12.0" +X86_TRIPLE="x86_64-apple-macosx12.0" say "building MenuBand arm64 slice" swift build -c release --triple "${ARM_TRIPLE}" >/dev/null @@ -221,6 +221,17 @@ # and anything at the bundle root fails `codesign --strict` ("unsealed # contents present in the bundle root") and breaks notarization. rm -rf "${APP_DIR}/${PKG_BUNDLE_NAME}" "${APP_RES}/${PKG_BUNDLE_NAME}" cp -R "${PKG_BUNDLE_SRC}/." "${APP_RES}/" +fi + +# Menu Band Juke is linked into this process and reads assets from the app's +# signed Resources/Assets directory. Copying source assets explicitly avoids +# SwiftPM's generated accessor and its embedded local build path. +JUKE_ASSETS_SRC="${SCRIPT_DIR}/../../juke-wizard/Sources/JukeWizard/Assets" +if [[ -d "${JUKE_ASSETS_SRC}" ]]; then + rm -rf "${APP_RES:?}/Assets" + cp -R "${JUKE_ASSETS_SRC}" "${APP_RES}/Assets" +else + warn "Menu Band Juke assets missing at ${JUKE_ASSETS_SRC}" fi # --- Apple Help book --- @@ -418,16 +429,42 @@ launchctl bootstrap "gui/${_uid}" "${_plist}" 2>/dev/null || launchctl load "${_plist}" fi done sleep 1 -if launchctl list | grep -q computer.aestheticcomputer.menuband; then - ok "computer.aestheticcomputer.menuband is running" -else - warn "launchctl did not register the agent — check /tmp/menuband.err" -fi -if launchctl list | grep -q computer.aestheticcomputer.menubandlauncher; then - ok "computer.aestheticcomputer.menubandlauncher is running" -else - warn "launcher agent did not register — check /tmp/menubandlauncher.err" -fi + +# Replacing a signed executable in place occasionally leaves launchd's cached +# lightweight-code-requirement (LWCR) tied to the previous signature. The job +# stays registered but exits 78 before the program starts. Keep the quiet +# kickstart path above; only refresh registration when the service is not +# actually running after that restart. +ensure_service_running() { + local label="$1" plist="$2" log="$3" attempt=0 + while (( attempt < 10 )); do + if launchctl print "gui/${_uid}/${label}" 2>/dev/null | grep -q 'state = running'; then + ok "${label} is running" + return 0 + fi + sleep 0.2 + attempt=$((attempt + 1)) + done + + warn "${label} retained a stale launch record — refreshing it once" + launchctl bootout "gui/${_uid}/${label}" 2>/dev/null || true + launchctl bootstrap "gui/${_uid}" "${plist}" 2>/dev/null || launchctl load "${plist}" + launchctl kickstart -k "gui/${_uid}/${label}" 2>/dev/null || true + attempt=0 + while (( attempt < 20 )); do + if launchctl print "gui/${_uid}/${label}" 2>/dev/null | grep -q 'state = running'; then + ok "${label} is running" + return 0 + fi + sleep 0.2 + attempt=$((attempt + 1)) + done + warn "${label} is registered but not running — check ${log}" + return 1 +} + +ensure_service_running computer.aestheticcomputer.menuband "${PLIST_PATH}" /tmp/menuband.err +ensure_service_running computer.aestheticcomputer.menubandlauncher "${LAUNCHER_PLIST_PATH}" /tmp/menubandlauncher.err printf "\n%sdone.%s\n" "${BOLD}" "${RESET}" echo " bundle: ${APP_DIR}" diff --git a/slab/menuband/marketing/instrument-playing/README.md b/slab/menuband/marketing/instrument-playing/README.md new file mode 100644 --- /dev/null +++ b/slab/menuband/marketing/instrument-playing/README.md @@ -0,0 +1,17 @@ +# Menu Band — instrument-playing film + +The prototype opens with Jeffrey saying “Double tap Command for Menu Band,” +showing two quick live Command-key highlights before teaching one thing: an +ascending C-major scale. It uses Menu Band's real headless AppKit menu-bar, +QWERTY-map, and music-staff renderers, all synchronized to the played notes. + +No percussion, radio, Spotify, sampling, recording, or hardware MIDI appears. + +```sh +node render-prototype.mjs +``` + +The final pass uses the established AC vertical reel chrome: 1080×1920 with +audio-reactive Pals and `MenuBand.app` side titles. + +Output lands in `out/menu-band-command-scale-instagram-reel-v9.mp4`. diff --git a/slab/menuband/marketing/instrument-playing/captions.srt b/slab/menuband/marketing/instrument-playing/captions.srt new file mode 100644 --- /dev/null +++ b/slab/menuband/marketing/instrument-playing/captions.srt @@ -0,0 +1,7 @@ +1 +00:00:00,000 --> 00:00:02,150 +Double tap Command for Menu Band. + +2 +00:00:08,700 --> 00:00:10,650 +Press Escape to get back to your Mac. diff --git a/slab/menuband/marketing/instrument-playing/escape-line.txt b/slab/menuband/marketing/instrument-playing/escape-line.txt new file mode 100644 --- /dev/null +++ b/slab/menuband/marketing/instrument-playing/escape-line.txt @@ -0,0 +1,1 @@ +Press Escape to get back to your Mac. diff --git a/slab/menuband/marketing/instrument-playing/jeffrey-double-tap-command.mp3.hash b/slab/menuband/marketing/instrument-playing/jeffrey-double-tap-command.mp3.hash new file mode 100644 --- /dev/null +++ b/slab/menuband/marketing/instrument-playing/jeffrey-double-tap-command.mp3.hash @@ -0,0 +1,1 @@ +f8f994d0d5137f43 diff --git a/slab/menuband/marketing/instrument-playing/jeffrey-press-escape.mp3.hash b/slab/menuband/marketing/instrument-playing/jeffrey-press-escape.mp3.hash new file mode 100644 --- /dev/null +++ b/slab/menuband/marketing/instrument-playing/jeffrey-press-escape.mp3.hash @@ -0,0 +1,1 @@ +c79450c4331da3cc diff --git a/slab/menuband/marketing/instrument-playing/render-prototype.mjs b/slab/menuband/marketing/instrument-playing/render-prototype.mjs new file mode 100644 --- /dev/null +++ b/slab/menuband/marketing/instrument-playing/render-prototype.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node +// Render a small, honest Menu Band playing film from the app's real keymap UI. +// Scope is deliberately narrow: one piano voice, QWERTY playing, no drums. + +import { execFileSync } from "node:child_process"; +import { mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, "../.."); +const repo = resolve(root, "../.."); +const bin = resolve(root, ".build/debug/MenuBand"); +const reelBackground = resolve(root, "screenshots/reel-background.png"); +const stampReel = resolve(repo, "marketing/av-reels/bin/stamp-reel.mjs"); +const out = resolve(here, "out"); +const framesDir = resolve(out, "frames"); +const fps = 15; +const sampleRate = 48_000; +const duration = 11; +const commandTaps = [[0.70, 0.16], [0.98, 0.16]]; +const escapeTap = [9.35, 0.30]; +const voicePath = resolve(here, "jeffrey-double-tap-command.mp3"); +const escapeVoicePath = resolve(here, "jeffrey-press-escape.mp3"); +const captionPath = resolve(here, "captions.srt"); + +if (!existsSync(bin)) { + console.error(`Build Menu Band first: cd ${root} && swift build`); + process.exit(1); +} + +rmSync(out, { recursive: true, force: true }); +mkdirSync(framesDir, { recursive: true }); + +// One ascending C-major scale. The generous pace makes this a legible lesson, +// not a performance montage: watch one key, hear one note, move right. +const notes = [ + [2.35, 0.48, 60], // C + [3.10, 0.48, 62], // D + [3.85, 0.48, 64], // E + [4.60, 0.48, 65], // F + [5.35, 0.48, 67], // G + [6.10, 0.48, 69], // A + [6.85, 0.48, 71], // B + [7.60, 1.10, 72], // C +]; + +const totalFrames = Math.round(duration * fps); +const sequence = Array.from({ length: totalFrames }, (_, i) => { + const t = i / fps; + const held = notes.filter(([at, len]) => t >= at && t < at + len) + .map(([, , midi]) => midi); + const played = notes.filter(([at]) => t >= at).map(([, , midi]) => midi); + const energy = notes.reduce((sum, [at, len]) => { + if (t < at || t >= at + len + 0.7) return sum; + const age = t - at; + return sum + (age < len ? 0.72 : 0.72 * Math.exp(-(age - len) * 5)); + }, 0); + const levels = Array.from({ length: 90 }, (_, x) => { + const phase = t * 10 + x * 0.29; + return Math.min(1, energy * (0.12 + 0.12 * Math.abs(Math.sin(phase)))); + }); + const command = commandTaps.some(([at, len]) => t >= at && t < at + len); + const escape = t >= escapeTap[0] && t < escapeTap[0] + escapeTap[1]; + return { notes: held, played, command, escape, levels, cursor: (t / duration) % 1, program: 0 }; +}); +// Render each distinct state through the shipping menu-bar and QWERTY views, +// then assemble the timed frame sequence from those real product surfaces. +const statesDir = resolve(out, "states"); +mkdirSync(statesDir, { recursive: true }); +const openingCaptionPath = resolve(statesDir, "caption-opening.png"); +const closingCaptionPath = resolve(statesDir, "caption-closing.png"); +for (const [caption, path] of [ + ["Double tap Command for Menu Band.", openingCaptionPath], + ["Press Escape to get back to your Mac.", closingCaptionPath], +]) execFileSync("magick", ["-background", "none", "-fill", "white", + "-stroke", "#241b31", "-strokewidth", "3", "-font", "/System/Library/Fonts/Helvetica.ttc", + "-pointsize", "46", `label:${caption}`, "-bordercolor", "none", "-border", "24x14", path]); +const statePath = new Map(); +const stateKey = (frame) => frame.command ? "command" : frame.escape ? "escape" + : `p-${frame.played.join("-") || "none"}-h-${frame.notes.join("-") || "none"}`; +const states = [...new Map(sequence.map((frame) => [stateKey(frame), { + key: stateKey(frame), notes: frame.notes, played: frame.played, + keys: frame.command ? [55] : frame.escape ? [53] : [], + red: frame.command || frame.escape, +}])).values()]; +for (const state of states) { + const { key } = state; + if (statePath.has(key)) continue; + const menuPath = resolve(statesDir, `${key}-menubar.png`); + const qwertyPath = resolve(statesDir, `${key}-qwerty.png`); + const staffPath = resolve(statesDir, `${key}-staff.png`); + execFileSync(bin, ["--render-menubar", "--notes", state.notes.join(","), + "--out", menuPath, "--scale", "4", "--light", "--key-accent", + "--program", "0", "--no-settings"], { stdio: "inherit" }); + execFileSync(bin, ["--render-qwerty", "--notes", state.notes.join(","), + "--keys", state.keys.join(","), "--out", qwertyPath, "--scale", "2"], + { stdio: "inherit" }); + execFileSync(bin, ["--render-scale-staff", "--notes", state.played.join(","), + "--out", staffPath, "--scale", "2"], { stdio: "inherit" }); + statePath.set(key, { menuPath, qwertyPath, staffPath, red: state.red }); +} +for (let i = 0; i < sequence.length; i++) { + const t = i / fps; + const state = statePath.get(stateKey(sequence[i])); + const staffY = 678 + Math.round(8 * Math.sin(t * 2.0)); + const menuY = 925 + Math.round(6 * Math.sin(t * 2.35 + 1.4)); + const qwertyY = 1125 + Math.round(9 * Math.sin(t * 1.75 + 2.2)); + const args = [reelBackground, "-resize", "1080x1920^", "-gravity", "center", + "-extent", "1080x1920"]; + if (state.red) args.push("-fill", "#ff1f2d", "-colorize", "18"); + args.push( + "(", state.staffPath, "-resize", "900x", ")", "-gravity", "north", "-geometry", `+0+${staffY}`, "-composite", + "(", state.menuPath, "-resize", "920x", ")", "-gravity", "north", "-geometry", `+0+${menuY}`, "-composite", + "(", state.qwertyPath, "-resize", "840x", ")", "-gravity", "north", "-geometry", `+0+${qwertyY}`, "-composite"); + const caption = t < 2.15 ? openingCaptionPath + : t >= 8.7 && t < 10.65 ? closingCaptionPath : null; + if (caption) args.push(caption, "-gravity", "north", "-geometry", "+0+1540", "-composite"); + args.push(resolve(framesDir, `lesson-${String(i).padStart(4, "0")}.png`)); + execFileSync("magick", args, { stdio: "ignore" }); +} + +// Lightweight piano-like synthesis: struck harmonic partials with a quick +// attack and exponential decay. It is deterministic and aligned to the UI. +const samples = new Float32Array(Math.ceil(duration * sampleRate)); +for (const [at, len, midi] of notes) { + const start = Math.round(at * sampleRate); + const end = Math.min(samples.length, Math.round((at + len + 1.8) * sampleRate)); + const f = 440 * 2 ** ((midi - 69) / 12); + for (let i = start; i < end; i++) { + const t = (i - start) / sampleRate; + const attack = Math.min(1, t / 0.008); + const release = t > len ? Math.exp(-(t - len) * 4.8) : 1; + const body = Math.exp(-t * 1.35) * release; + const s = Math.sin(2 * Math.PI * f * t) + + 0.42 * Math.sin(2 * Math.PI * f * 2.01 * t) + + 0.17 * Math.sin(2 * Math.PI * f * 3.98 * t); + samples[i] += 0.13 * attack * body * s; + } +} +// Two short tactile cues accompany the two Command-key flashes. +for (const [at] of commandTaps) { + const start = Math.round(at * sampleRate); + const end = Math.min(samples.length, start + Math.round(0.12 * sampleRate)); + for (let i = start; i < end; i++) { + const t = (i - start) / sampleRate; + samples[i] += 0.16 * Math.sin(2 * Math.PI * 880 * t) * Math.exp(-t * 28); + } +} +{ + const start = Math.round(escapeTap[0] * sampleRate); + const end = Math.min(samples.length, start + Math.round(0.15 * sampleRate)); + for (let i = start; i < end; i++) { + const t = (i - start) / sampleRate; + samples[i] += 0.16 * Math.sin(2 * Math.PI * 660 * t) * Math.exp(-t * 24); + } +} + +const wav = Buffer.alloc(44 + samples.length * 2); +const channels = 1, bits = 16, byteRate = sampleRate * channels * bits / 8; +wav.write("RIFF", 0); wav.writeUInt32LE(wav.length - 8, 4); wav.write("WAVE", 8); +wav.write("fmt ", 12); wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); +wav.writeUInt16LE(channels, 22); wav.writeUInt32LE(sampleRate, 24); +wav.writeUInt32LE(byteRate, 28); wav.writeUInt16LE(channels * bits / 8, 32); +wav.writeUInt16LE(bits, 34); wav.write("data", 36); wav.writeUInt32LE(samples.length * 2, 40); +for (let i = 0; i < samples.length; i++) { + const v = Math.max(-1, Math.min(1, samples[i])); + wav.writeInt16LE(Math.round(v * 32767), 44 + i * 2); +} +const audioPath = resolve(out, "performance.wav"); +writeFileSync(audioPath, wav); + +const baseVideoPath = resolve(out, "base-menu-band-scale.mp4"); +execFileSync("ffmpeg", ["-v", "error", "-y", "-framerate", String(fps), + "-i", resolve(framesDir, "lesson-%04d.png"), "-i", audioPath, "-i", voicePath, + "-i", escapeVoicePath, + "-filter_complex", "[1:a]volume=0.82[synth];[2:a]volume=1.0[open];[3:a]adelay=8700|8700,volume=1.0[close];[synth][open][close]amix=inputs=3:duration=longest:normalize=0[a]", + "-map", "0:v", "-map", "[a]", "-vf", "format=yuv420p", + "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-c:a", "aac", "-b:a", "192k", + "-t", String(duration), "-movflags", "+faststart", baseVideoPath], { stdio: "inherit" }); + +// Finish through the same 9:16 AC reel chrome as the other Menu Band films: +// audio-reactive Pals at both edges and climbing MenuBand.app title columns. +const videoPath = resolve(out, "menu-band-command-scale-instagram-reel-v9.mp4"); +execFileSync(process.execPath, [stampReel, baseVideoPath, + "--title", "MenuBand.app", "--tint", "128,96,164", "--no-fingers", + "--motion-rate", "0.28", "--intro-pulse", "0", "--stamp-gap", "4", + "--fps", "30", "--out", videoPath], { stdio: "inherit" }); + +console.log(videoPath); diff --git a/slab/menuband/marketing/instrument-playing/voice-line.txt b/slab/menuband/marketing/instrument-playing/voice-line.txt new file mode 100644 --- /dev/null +++ b/slab/menuband/marketing/instrument-playing/voice-line.txt @@ -0,0 +1,1 @@ +Double tap Command for Menu Band. diff --git a/slab/menuband/screenshots/menuband-shortcuts.html b/slab/menuband/screenshots/menuband-shortcuts.html --- a/slab/menuband/screenshots/menuband-shortcuts.html +++ b/slab/menuband/screenshots/menuband-shortcuts.html @@ -85,7 +85,7 @@
trackpadUp/down = bend · left/right = reverb/echo
⇧ ShiftBend all voices · grab ringing tails
⌥Fine-tune the fx axis
-
TabAbsolute trackpad pad · 1:1 mirror
+
TabPercussion / pitch bend
diff --git a/slab/menubar-swift/Sources/SlabMenubar/FrameCapture.swift b/slab/menubar-swift/Sources/SlabMenubar/FrameCapture.swift --- a/slab/menubar-swift/Sources/SlabMenubar/FrameCapture.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/FrameCapture.swift @@ -173,6 +173,10 @@ guard !spotlight.isNull, spotlight.width > 0, spotlight.height > 0 else { return } let win = NSWindow(contentRect: screen.frame, styleMask: .borderless, backing: .buffered, defer: false) + // These windows are owned strongly by FrameCapture. Letting + // NSWindow also release itself on close can over-release during + // the main run loop's autorelease-pool drain. + win.isReleasedWhenClosed = false win.isOpaque = false win.backgroundColor = .clear win.level = .screenSaver @@ -633,6 +637,10 @@ guard let screen = NSScreen.main else { return } let H = screen.frame.height let win = NSWindow(contentRect: screen.frame, styleMask: .borderless, backing: .buffered, defer: false) + // FrameCapture retains this window until cleanup. Disable + // NSWindow's legacy self-release so closing a transient overlay + // cannot race ARC at the main autorelease-pool boundary. + win.isReleasedWhenClosed = false win.isOpaque = false win.backgroundColor = .clear win.level = .screenSaver @@ -661,17 +669,19 @@ win.orderFrontRegardless() self.registerOverlay(win) self.ocrOverlayWindow = win DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - // Always retire this exact window. A later overlay may have - // replaced the tracked pointer, but that must never orphan an - // earlier surface or leave its timer as a no-op. + // A replacement clears and closes the old window immediately. + // Its delayed cleanup must then become a no-op; otherwise the + // same NSWindow receives a second animation + close sequence. + guard self.ocrOverlayWindow === win else { return } NSAnimationContext.runAnimationGroup({ ctx in ctx.duration = 0.18 win.animator().alphaValue = 0.0 }, completionHandler: { + guard self.ocrOverlayWindow === win else { return } + self.ocrOverlayWindow = nil self.unregisterOverlay(win) win.orderOut(nil) win.close() - if self.ocrOverlayWindow === win { self.ocrOverlayWindow = nil } }) } } @@ -1061,6 +1071,12 @@ func nowNs() -> UInt64 { DispatchTime.now().uptimeNanoseconds } func msSince(_ t: UInt64) -> Double { (Double(nowNs() - t) / 1e6 * 10).rounded() / 10 } var env: [String: Any] = [:] var tm: [String: Double] = [:] + let reelActive: Bool + if #available(macOS 15.0, *) { + reelActive = ScreenRecord.shared.reservesExternalProcesses + } else { + reelActive = false + } // An explicit crop always wins. Otherwise capture the topmost window // of the frontmost app unless the caller explicitly requested screen. @@ -1120,14 +1136,25 @@ } else { t = nowNs(); let boxes = ocr(cg, scale: captureScale, fast: fast, origin: region.origin); tm["ocr"] = msSince(t) env["ocr"] = boxes - if showOverlay { showOcrOverlay(boxes) } + if showOverlay && !reelActive { showOcrOverlay(boxes) } } let cursorMeta = (mt["cursor"] as? [String: Int]).map { CGPoint(x: $0["x"] ?? 0, y: $0["y"] ?? 0) } let visualOrigin = region.origin - t = nowNs(); env["visual"] = visualControls(cg, scale: captureScale, - origin: visualOrigin, focus: cursorOverride ?? cursorMeta); tm["visual"] = msSince(t) + if reelActive { + // VNDetectContours can overlap SCRecordingOutput's + // WindowServer/CoreImage work and has repeatedly taken the + // host app down mid-reel. It is supplemental to OCR + AX, so + // omit only this pass while preserving the audit screenshot. + env["visual"] = [] + env["visual_suppressed"] = "screen-recording" + tm["visual"] = 0 + } else { + t = nowNs(); env["visual"] = visualControls(cg, scale: captureScale, + origin: visualOrigin, focus: cursorOverride ?? cursorMeta) + tm["visual"] = msSince(t) + } t = nowNs() let marker = virtualCursor ? (cursorOverride ?? cursorMeta) : nil let jpg = thumbJPEG(cg, maxWidth: 1568, cursor: marker, crop: region) ?? Data() diff --git a/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift --- a/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift @@ -163,6 +163,14 @@ let prompt = (body["prompt"] as? String) ?? "" guard prompt.count <= 4_000 else { return ["ok": false, "error": "prompt exceeds 4000 characters"] } + let localHost = ProcessInfo.processInfo.hostName + .lowercased().split(separator: ".").first.map(String.init) ?? "" + if localHost == "neo" && isHeavyBuildPrompt(prompt) { + return [ + "ok": false, + "error": "neo is an 8 GB interactive-only prompt host; run builds and typechecks on blueberry, panda, or chicken", + ] + } let loopboyContact = ((body["loopboyContact"] as? String) ?? "").lowercased() if !loopboyContact.isEmpty, loopboyContact.range(of: "^[a-z0-9_-]{1,40}$", options: .regularExpression) == nil { @@ -203,9 +211,9 @@ // Codex input, corrupting the prompt whenever focus changes. Slab // already knows how to focus the exact tty and type the wake prompt // through trusted CGEvents, so an extra terminal layer is harmful. command = "cd \(shellQuote(cwd)) && " - + "SLAB_TERMINAL_TTY=$(basename \"$(tty)\") " + + "exec env SLAB_TERMINAL_TTY=$(basename \"$(tty)\") " + "SLAB_LOOPBOY_CONTACT=\(shellQuote(loopboyContact)) " - + "exec \(shellQuote(binary))" + + "\(shellQuote(binary))" } else { command = "cd \(shellQuote(cwd)) && exec \(shellQuote(binary))" } @@ -257,6 +265,23 @@ let by = String(((body["by"] as? String) ?? "prox").prefix(100)) NSLog("🪨 [ledger] %@ launched %@ in %@", by, agent, cwd) return ["ok": true, "host": selfIdentity().host, "agent": agent, "cwd": cwd, "loopboyContact": loopboyContact, "nudgeScreen": nudgeScreen] + } + + /// Neo is the seat's 8 GB controller. Parallel compilers drive it deeply + /// into swap, so reject explicitly requested heavy jobs before launching + /// an agent. Ordinary coding and diagnostic prompts remain available. + private static func isHeavyBuildPrompt(_ prompt: String) -> Bool { + let text = prompt.lowercased() + let patterns = [ + #"\btype[ -]?checks?\b"#, + #"\btsc\b"#, + #"\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:build|test|typecheck)\b"#, + #"\b(?:swift|cargo)\s+build\b"#, + #"\bxcodebuild\b"#, + ] + return patterns.contains { pattern in + text.range(of: pattern, options: .regularExpression) != nil + } } private static func shellQuote(_ value: String) -> String { diff --git a/slab/menubar-swift/Sources/SlabMenubar/ProxMemoirs.swift b/slab/menubar-swift/Sources/SlabMenubar/ProxMemoirs.swift --- a/slab/menubar-swift/Sources/SlabMenubar/ProxMemoirs.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/ProxMemoirs.swift @@ -61,6 +61,9 @@ /// Offer the current live set to the heartbeat. This is called from the /// already-backgrounded state gather and returns quickly; actual transcript /// reading and inference happen on one serial utility queue. func refresh(_ sessions: [ClaudeSession]) { + // A tutorial reel owns the machine's background inference budget. Its + // audit frames and encoder must not overlap optional memoir models. + guard !recordingReserved else { return } lock.lock() let now = Date() guard inFlight == nil, @@ -173,9 +176,11 @@ Recent exchange: \(exchange) """ - var provider = "apple-foundation-model" - var text = onDeviceMemoir(prompt) ?? "" - if text.isEmpty, Date() >= claudeUnavailableUntil, let binary = claudeBinary() { + var provider = "extractive" + var text = recordingReserved ? "" : (onDeviceMemoir(prompt) ?? "") + if !text.isEmpty { provider = "apple-foundation-model" } + if text.isEmpty, externalProviderAllowed, + Date() >= claudeUnavailableUntil, let binary = claudeBinary() { provider = "claude-haiku" let result = ShellRunner.run( binary, @@ -239,6 +244,25 @@ "/opt/homebrew/bin/claude", "/usr/local/bin/claude", ] return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } + } + + /// External memoir inference is deliberately opt-in. A background GUI app + /// launching an agent CLI can trigger macOS App Data permission UI, and + /// that system dialog is capturable even when this app's own windows are + /// excluded. Recording is an unconditional second gate so an opted-in seat + /// still cannot contaminate a tutorial take. + private var externalProviderAllowed: Bool { + guard UserDefaults.standard.bool(forKey: "enableExternalMemoirs") else { + return false + } + return !recordingReserved + } + + private var recordingReserved: Bool { + if #available(macOS 15.0, *) { + return ScreenRecord.shared.reservesExternalProcesses + } + return false } private func fallbackMemoir(subject: String, exchange: String) -> String { diff --git a/slab/menubar-swift/Sources/SlabMenubar/ScreenRecord.swift b/slab/menubar-swift/Sources/SlabMenubar/ScreenRecord.swift --- a/slab/menubar-swift/Sources/SlabMenubar/ScreenRecord.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/ScreenRecord.swift @@ -36,6 +36,8 @@ private var output: SCRecordingOutput? private var outURL: URL? private var startedAt: Date? private var lastError: String? + private let reservationLock = NSLock() + private var captureReserved = false /// True only while we are deliberately tearing the stream down, so the /// delegate can tell our own stop apart from a real mid-take failure. private var stopping = false @@ -76,6 +78,7 @@ private func beginRecording(_ req: [String: Any]) { guard stream == nil else { lastError = "already recording"; writeState(); markDone(); return } + setCaptureReserved(true) lastError = nil stopping = false @@ -125,6 +128,7 @@ } catch { self.lastError = "\(error)" self.stream = nil self.output = nil + self.setCaptureReserved(false) } } sem.wait() @@ -233,10 +237,23 @@ // atom. A truncated mp4 is worse than a slow one — wait for the file to // stop growing before we say done, so the caller never reads a partial. if let url = outURL { waitForFlush(url) } startedAt = nil + setCaptureReserved(false) writeState() markDone() } + /// True from the moment a start request is accepted until the take has + /// stopped or failed. Background features use this to avoid launching + /// external processes whose permission UI could land inside the recording. + var reservesExternalProcesses: Bool { + reservationLock.lock(); defer { reservationLock.unlock() } + return captureReserved + } + + private func setCaptureReserved(_ value: Bool) { + reservationLock.lock(); captureReserved = value; reservationLock.unlock() + } + private func fileSize(_ path: String) -> UInt64 { let attrs = try? fm.attributesOfItem(atPath: path) return (attrs?[.size] as? NSNumber)?.uint64Value ?? 0 @@ -281,6 +298,7 @@ self.lastError = "stream stopped: \(error.localizedDescription)" self.stream = nil self.output = nil self.startedAt = nil + self.setCaptureReserved(false) self.writeState() } } diff --git a/slab/settings-fragment.json b/slab/settings-fragment.json --- a/slab/settings-fragment.json +++ b/slab/settings-fragment.json @@ -51,6 +51,7 @@ ], "SessionStart": [ { "hooks": [ + { "type": "command", "command": "@HOME@/.local/bin/claude-session-start.sh", "async": true }, { "type": "command", "command": "@HOME@/.local/bin/slab-cancel-pending", "async": true } ] } diff --git a/slab/tracktramp/.gitignore b/slab/tracktramp/.gitignore new file mode 100644 --- /dev/null +++ b/slab/tracktramp/.gitignore @@ -0,0 +1,2 @@ +build/ +TrackDrum.xcodeproj/ diff --git a/slab/tracktramp/ExportOptions-AppStore.plist b/slab/tracktramp/ExportOptions-AppStore.plist new file mode 100644 --- /dev/null +++ b/slab/tracktramp/ExportOptions-AppStore.plist @@ -0,0 +1,14 @@ + + + + + destination + export + method + app-store-connect + signingStyle + automatic + teamID + FB5948YR3S + + diff --git a/slab/tracktramp/Info.plist b/slab/tracktramp/Info.plist new file mode 100644 --- /dev/null +++ b/slab/tracktramp/Info.plist @@ -0,0 +1,36 @@ + + + + + CFBundleDisplayName + TrackDrum + CFBundleExecutable + TrackDrum + CFBundleIconFile + TrackDrumIcon + CFBundleIdentifier + computer.aestheticcomputer.trackdrum + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + TrackDrum + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + ITSAppUsesNonExemptEncryption + + LSApplicationCategoryType + public.app-category.music + LSMinimumSystemVersion + 12.0 + NSHighResolutionCapable + + NSHumanReadableCopyright + By aesthetic.computer + NSPrincipalClass + NSApplication + + diff --git a/slab/tracktramp/README.md b/slab/tracktramp/README.md new file mode 100644 --- /dev/null +++ b/slab/tracktramp/README.md @@ -0,0 +1,35 @@ +# TrackDrum + +TrackDrum turns the active Mac trackpad into Menu Band’s percussion surface. +Its borderless window is drawn at the touch device’s reported physical point +range, uses the production Metal membrane and audio engine, and quits with +Escape. + +The app has one capability contract for every distribution channel: + +- public AppKit `NSTouch` input while TrackDrum is frontmost; +- App Sandbox enabled; +- no private frameworks, global event taps, helper processes, login items, or + persistent background mode; +- one bundle identifier: `computer.aestheticcomputer.trackdrum`. + +Build the signed universal direct-download artifact: + +```bash +./build-dmg.sh +``` + +Verify the App Store target without signing: + +```bash +./build-app-store.sh --verify +``` + +Create an App Store archive and signed installer package: + +```bash +./build-app-store.sh --archive +``` + +Both routes compile the same app sources and ship the same feature set. Only +the distribution signature and container differ. diff --git a/slab/tracktramp/Sources/MenubandUIBridge.swift b/slab/tracktramp/Sources/MenubandUIBridge.swift new file mode 100644 --- /dev/null +++ b/slab/tracktramp/Sources/MenubandUIBridge.swift @@ -0,0 +1,12 @@ +import AppKit + +// PitchBendCursor.swift is compiled directly from Menu Band. These two seams +// are supplied by the larger app there and by this tiny shell here. +enum KeyboardIconRenderer { + static var accent: NSColor { NSColor.controlAccentColor } +} + +extension Bundle { + static var module: Bundle { .main } + static var appResources: Bundle { .main } +} diff --git a/slab/tracktramp/Sources/PhysicalTrackpadCalibration.swift b/slab/tracktramp/Sources/PhysicalTrackpadCalibration.swift new file mode 100644 --- /dev/null +++ b/slab/tracktramp/Sources/PhysicalTrackpadCalibration.swift @@ -0,0 +1,45 @@ +import AppKit + +enum TrackDrumDockLayout { + /// AppKit owns the sandbox-safe usable-screen calculation. TrackDrum adds + /// bottom clearance for an auto-hidden Dock without reading Dock settings. + static func safeFrame(for screen: NSScreen) -> NSRect { + var safe = screen.visibleFrame + let reserve = min(72, safe.height / 5) + safe.origin.y += reserve + safe.size.height = max(0, safe.height - reserve) + return safe + } +} + +/// AppKit reports an indirect touch device's physical coordinate range in +/// points through `NSTouch.deviceSize`. That is the public, sandbox-safe source +/// of truth for TrackDrum's one-to-one surface. The fallback is the common +/// built-in MacBook trackpad at 72 points per inch and is replaced by the +/// first real touch frame. +struct PhysicalTrackpadCalibration { + static let fallbackSurfacePoints = NSSize( + width: 111.55 * 72 / 25.4, + height: 67.90 * 72 / 25.4 + ) + + let surfacePoints: NSSize + let source: String + + static func current(deviceSize: NSSize? = nil) -> PhysicalTrackpadCalibration { + if let deviceSize, + deviceSize.width >= 100, + deviceSize.height >= 60, + deviceSize.width <= 1_000, + deviceSize.height <= 1_000 { + return PhysicalTrackpadCalibration( + surfacePoints: deviceSize, + source: "NSTouch.deviceSize" + ) + } + return PhysicalTrackpadCalibration( + surfacePoints: fallbackSurfacePoints, + source: "built-in fallback" + ) + } +} diff --git a/slab/tracktramp/Sources/TrackDrumContact.swift b/slab/tracktramp/Sources/TrackDrumContact.swift new file mode 100644 --- /dev/null +++ b/slab/tracktramp/Sources/TrackDrumContact.swift @@ -0,0 +1,38 @@ +import CoreGraphics + +struct TrackpadContact: Equatable { + let identifier: Int32 + let point: CGPoint + let state: Int32 + + var isActive: Bool { state == 3 || state == 4 } + var isBegan: Bool { state == 3 } +} + +struct TrackpadContactChanges { + let active: [TrackpadContact] + let began: [CGPoint] + let lifted: [CGPoint] + let activeByID: [Int32: CGPoint] + + static func resolve(previous: [Int32: CGPoint], + contacts: [TrackpadContact]) -> TrackpadContactChanges { + let active = contacts.filter(\.isActive) + let current = Dictionary(uniqueKeysWithValues: active.map { + ($0.identifier, $0.point) + }) + let began = active.compactMap { contact in + contact.isBegan || previous[contact.identifier] == nil + ? contact.point : nil + } + let lifted = previous.compactMap { identifier, point in + current[identifier] == nil ? point : nil + } + return TrackpadContactChanges( + active: active, + began: began, + lifted: lifted, + activeByID: current + ) + } +} diff --git a/slab/tracktramp/Sources/TrackDrumIcon.swift b/slab/tracktramp/Sources/TrackDrumIcon.swift new file mode 100644 --- /dev/null +++ b/slab/tracktramp/Sources/TrackDrumIcon.swift @@ -0,0 +1,162 @@ +import AppKit + +enum TrackDrumIcon { + static func image(size: CGFloat = 1024, + accent: NSColor = .controlAccentColor) -> NSImage { + let canvas = NSSize(width: size, height: size) + return NSImage(size: canvas, flipped: false) { rect in + draw(in: rect, accent: accent) + return true + } + } + + private static func draw(in rect: NSRect, accent: NSColor) { + let scale = min(rect.width, rect.height) / 1024 + func s(_ value: CGFloat) -> CGFloat { value * scale } + let accent = accent.usingColorSpace(.deviceRGB) ?? .systemPink + + NSColor.clear.setFill() + rect.fill() + + func path(_ box: NSRect, radius: CGFloat) -> NSBezierPath { + NSBezierPath(roundedRect: box, xRadius: s(radius), yRadius: s(radius)) + } + func shifted(_ box: NSRect, y: CGFloat) -> NSRect { + box.offsetBy(dx: 0, dy: s(y)) + } + func fillGradient(_ shape: NSBezierPath, in box: NSRect, + top: NSColor, bottom: NSColor) { + NSGraphicsContext.saveGraphicsState() + shape.addClip() + NSGradient(starting: top, ending: bottom)?.draw(in: box, angle: -90) + NSGraphicsContext.restoreGraphicsState() + } + func raised(_ shape: NSBezierPath, in box: NSRect, + top: NSColor, bottom: NSColor, + shadowY: CGFloat = -10, blur: CGFloat = 14, + edgeWidth: CGFloat = 7) { + NSGraphicsContext.saveGraphicsState() + let shadow = NSShadow() + shadow.shadowColor = NSColor.black.withAlphaComponent(0.48) + shadow.shadowBlurRadius = s(blur) + shadow.shadowOffset = NSSize(width: 0, height: s(shadowY)) + shadow.set() + bottom.setFill() + shape.fill() + NSGraphicsContext.restoreGraphicsState() + fillGradient(shape, in: box, top: top, bottom: bottom) + NSColor.black.withAlphaComponent(0.48).setStroke() + shape.lineWidth = s(edgeWidth) + shape.stroke() + NSColor.white.withAlphaComponent(0.18).setStroke() + shape.lineWidth = s(2.5) + shape.stroke() + } + + // A physical slab seen slightly from above. The lower copy is the + // chassis thickness; the raised material zones preserve TrackDrum's + // exact outside-to-center instrument map in an icon-shaped footprint. + let pad = NSRect(x: rect.minX + s(46), y: rect.minY + s(72), + width: s(932), height: s(884)) + let body = path(pad, radius: 210) + let bodyBase = path(shifted(pad, y: -36), radius: 210) + let accentBase = accent.blended(withFraction: 0.58, of: .black) + ?? NSColor(srgbRed: 0.20, green: 0.04, blue: 0.16, alpha: 1) + + NSGraphicsContext.saveGraphicsState() + let bodyShadow = NSShadow() + bodyShadow.shadowColor = NSColor.black.withAlphaComponent(0.62) + bodyShadow.shadowBlurRadius = s(38) + bodyShadow.shadowOffset = NSSize(width: 0, height: -s(24)) + bodyShadow.set() + accentBase.setFill() + bodyBase.fill() + NSGraphicsContext.restoreGraphicsState() + accentBase.setFill() + bodyBase.fill() + + let creamTop = NSColor(srgbRed: 1.00, green: 0.96, blue: 0.83, alpha: 1) + let creamBottom = NSColor(srgbRed: 0.83, green: 0.72, blue: 0.52, alpha: 1) + raised(body, in: pad, top: creamTop, bottom: creamBottom, + shadowY: -8, blur: 12, edgeWidth: 8) + + func zone(_ inset: CGFloat, radius: CGFloat) -> (NSRect, NSBezierPath) { + let box = pad.insetBy(dx: s(inset), dy: s(inset)) + return (box, path(box, radius: radius)) + } + + let (hatBox, hat) = zone(58, radius: 166) + let (wireBox, snareWire) = zone(176, radius: 116) + let (tomBox, tom) = zone(262, radius: 80) + let (kickBox, kick) = zone(338, radius: 50) + let sageTop = NSColor(srgbRed: 0.82, green: 0.87, blue: 0.70, alpha: 1) + let sageBottom = NSColor(srgbRed: 0.49, green: 0.57, blue: 0.38, alpha: 1) + let terraTop = NSColor(srgbRed: 0.91, green: 0.59, blue: 0.40, alpha: 1) + let terraBottom = NSColor(srgbRed: 0.55, green: 0.27, blue: 0.18, alpha: 1) + let ochreTop = NSColor(srgbRed: 0.95, green: 0.79, blue: 0.49, alpha: 1) + let ochreBottom = NSColor(srgbRed: 0.63, green: 0.43, blue: 0.20, alpha: 1) + let umberTop = NSColor(srgbRed: 0.59, green: 0.36, blue: 0.24, alpha: 1) + let umberBottom = NSColor(srgbRed: 0.24, green: 0.12, blue: 0.08, alpha: 1) + raised(hat, in: hatBox, top: sageTop, bottom: sageBottom) + raised(snareWire, in: wireBox, top: terraTop, bottom: terraBottom) + + NSGraphicsContext.saveGraphicsState() + snareWire.addClip() + let wires = NSBezierPath() + stride(from: pad.minX - pad.height, + through: pad.maxX, by: s(28)).forEach { x in + wires.move(to: NSPoint(x: x, y: pad.minY)) + wires.line(to: NSPoint(x: x + pad.height, y: pad.maxY)) + } + NSColor.black.withAlphaComponent(0.14).setStroke() + wires.lineWidth = s(4) + wires.stroke() + NSGraphicsContext.restoreGraphicsState() + + raised(tom, in: tomBox, top: ochreTop, bottom: ochreBottom, + shadowY: -8, blur: 12) + raised(kick, in: kickBox, top: umberTop, bottom: umberBottom, + shadowY: -7, blur: 10) + + // Two restrained registration points make this read as the live + // touch surface, not a cassette, even at Dock size. + let touches = [ + NSPoint(x: pad.minX + pad.width * 0.36, + y: pad.minY + pad.height * 0.68), + NSPoint(x: pad.minX + pad.width * 0.65, + y: pad.minY + pad.height * 0.36), + ] + let tether = NSBezierPath() + tether.move(to: touches[0]) + tether.line(to: touches[1]) + NSGraphicsContext.saveGraphicsState() + let tetherShadow = NSShadow() + tetherShadow.shadowColor = NSColor.black.withAlphaComponent(0.55) + tetherShadow.shadowBlurRadius = s(8) + tetherShadow.shadowOffset = NSSize(width: 0, height: -s(5)) + tetherShadow.set() + accent.withAlphaComponent(0.64).setStroke() + tether.lineWidth = s(12) + tether.stroke() + NSGraphicsContext.restoreGraphicsState() + for point in touches { + let radius = s(20) + let dot = NSBezierPath(ovalIn: NSRect( + x: point.x - radius, y: point.y - radius, + width: radius * 2, height: radius * 2 + )) + NSGraphicsContext.saveGraphicsState() + let glow = NSShadow() + glow.shadowColor = accent.withAlphaComponent(0.80) + glow.shadowBlurRadius = s(12) + glow.shadowOffset = .zero + glow.set() + NSColor.white.withAlphaComponent(0.98).setFill() + dot.fill() + NSGraphicsContext.restoreGraphicsState() + accent.setStroke() + dot.lineWidth = s(5) + dot.stroke() + } + } +} diff --git a/slab/tracktramp/Sources/TracktrampApp.swift b/slab/tracktramp/Sources/TracktrampApp.swift new file mode 100644 --- /dev/null +++ b/slab/tracktramp/Sources/TracktrampApp.swift @@ -0,0 +1,787 @@ +import AppKit +import AVFoundation +import QuartzCore + +final class TracktrampAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { + private let performer = TracktrampPerformer() + private let surface = TracktrampView(frame: NSRect(x: 0, y: 0, width: 660, height: 510)) + private var window: NSWindow? + private var keyMonitor: Any? + private var instrumentStarted = false + private var isQuitting = false + private var lastTrackpadDeviceSize: NSSize? + private var systemColorsObserver: NSObjectProtocol? + private var screenParametersObserver: NSObjectProtocol? + + func applicationWillFinishLaunching(_ notification: Notification) { + // A Dock launch activates TrackDrum before its window is ready. Hide + // the pointer at that boundary so it never flashes over the first + // instrument frame; TracktrampView balances this on resign and quit. + surface.syncPointerVisibility(applicationActive: true) + } + + func applicationDidFinishLaunching(_ notification: Notification) { + systemColorsObserver = NotificationCenter.default.addObserver( + forName: NSColor.systemColorsDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.applyDynamicIcon() + } + let window = NSWindow( + contentRect: surface.bounds, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.title = "TrackDrum" + window.isOpaque = false + window.backgroundColor = .clear + window.hasShadow = false + // Stay in ordinary app-window space. Position keeps the surface above + // the Dock; z-order must never cover or compete with the Dock itself. + window.level = .normal + window.isReleasedWhenClosed = false + window.contentView = surface + window.delegate = self + self.window = window + applyPhysicalCalibration(to: window, screen: pointerScreen()) + screenParametersObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self, weak window] _ in + guard let self, let window else { return } + self.applyPhysicalCalibration( + to: window, + screen: window.screen ?? self.pointerScreen() + ) + } + + surface.onDefocus = { [weak self] in self?.quitInstrument() } + surface.showEscapeInstructionTemporarily() + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { + [weak self] event in + guard event.keyCode == 53 else { return event } + self?.quitInstrument() + return nil + } + + performer.onActivity = { [weak surface] touches, membrane in + DispatchQueue.main.async { + surface?.update(touches: touches, membrane: membrane) + } + } + surface.onTrackpadFrame = { [weak performer] contacts, timestamp, callbackTime in + performer?.receive( + contacts: contacts, + timestamp: timestamp, + callbackTime: callbackTime + ) + } + surface.onTrackpadDeviceSize = { [weak self, weak window] size in + guard let self, let window else { return } + self.lastTrackpadDeviceSize = size + self.applyPhysicalCalibration(to: window, deviceSize: size) + } + + presentInstrumentWindow() + } + + private func presentInstrumentWindow() { + guard let window, !isQuitting else { return } + applyPhysicalCalibration(to: window, screen: pointerScreen()) + // Commit a lightweight drum surface before initializing Metal, audio, + // event taps, or the dynamic 1024 px Dock icon. A Dock click therefore + // produces a visible window on the first AppKit presentation turn. + window.makeKeyAndOrderFront(nil) + window.makeFirstResponder(surface) + NSApp.activate(ignoringOtherApps: true) + surface.syncPointerVisibility(applicationActive: true) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { [weak self] in + self?.startInstrumentAfterFirstFrame() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in + self?.applyDynamicIcon() + } + } + + private func startInstrumentAfterFirstFrame() { + guard !instrumentStarted, !isQuitting else { return } + instrumentStarted = true + surface.prepareRenderer() + do { + try performer.start() + FocusFlashOverlay.shared.flash(rising: false) + FocusCueBeep.shared.play(rising: true) + } catch { + presentStartError(error) + } + } + + func applicationWillTerminate(_ notification: Notification) { + if let systemColorsObserver { + NotificationCenter.default.removeObserver(systemColorsObserver) + self.systemColorsObserver = nil + } + if let screenParametersObserver { + NotificationCenter.default.removeObserver(screenParametersObserver) + self.screenParametersObserver = nil + } + if let keyMonitor { + NSEvent.removeMonitor(keyMonitor) + self.keyMonitor = nil + } + surface.restorePointer() + performer.stop() + } + + func applicationDidBecomeActive(_ notification: Notification) { + surface.syncPointerVisibility(applicationActive: window?.isVisible == true) + } + + func applicationDidResignActive(_ notification: Notification) { + surface.syncPointerVisibility(applicationActive: false) + performer.releaseAllContacts() + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + true + } + + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + true + } + + func applicationShouldHandleReopen(_ sender: NSApplication, + hasVisibleWindows flag: Bool) -> Bool { + presentInstrumentWindow() + return true + } + + func windowDidChangeScreen(_ notification: Notification) { + guard let window else { return } + applyPhysicalCalibration(to: window, screen: window.screen ?? pointerScreen()) + } + + private func applyPhysicalCalibration(to window: NSWindow, + screen: NSScreen? = nil, + deviceSize: NSSize? = nil) { + guard let screen = screen ?? window.screen ?? pointerScreen() else { return } + let calibration = PhysicalTrackpadCalibration.current( + deviceSize: deviceSize ?? lastTrackpadDeviceSize + ) + surface.setTrackpadSize(calibration.surfacePoints) + window.setContentSize(surface.preferredContentSize) + let safeFrame = TrackDrumDockLayout.safeFrame(for: screen) + window.setFrameOrigin(NSPoint( + x: safeFrame.midX - window.frame.width / 2, + y: safeFrame.minY + max(8, safeFrame.height * 0.02) + )) + NSLog( + "TrackDrum 1:1: source=%@ surface=%.2fx%.2fpt", + calibration.source, + calibration.surfacePoints.width, + calibration.surfacePoints.height + ) + } + + private func applyDynamicIcon() { + NSApp.applicationIconImage = TrackDrumIcon.image( + accent: NSColor.controlAccentColor + ) + } + + private func pointerScreen() -> NSScreen? { + let pointer = NSEvent.mouseLocation + return NSScreen.screens.first { NSMouseInRect(pointer, $0.frame, false) } + ?? NSScreen.main + } + + private func presentStartError(_ error: Error) { + let alert = NSAlert() + alert.messageText = "Audio unavailable" + alert.informativeText = error.localizedDescription + alert.alertStyle = .warning + alert.addButton(withTitle: "OK") + alert.beginSheetModal(for: window!) + } + + private func quitInstrument() { + guard !isQuitting else { return } + isQuitting = true + FocusFlashOverlay.shared.flash(rising: false) + FocusCueBeep.shared.play(rising: false) + surface.restorePointer() + performer.stop() + // Let the falling bell and red pulse finish before terminating. A Dock + // click starts the same single, sandboxed app again; no helper process + // or login item remains behind after Quit. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) { + NSApp.terminate(nil) + } + } +} + +private enum TracktrampError: LocalizedError { + case audio + + var errorDescription: String? { + switch self { + case .audio: return "Audio could not start." + } + } +} + +private struct TracktrampEnergy { + var point: CGPoint + var level: Double + var at: Double +} + +private final class TracktrampPerformer { + var onActivity: (([CGPoint], TrackpadMembraneSimulation.Snapshot) -> Void)? + + private let engine = AVAudioEngine() + private let percussion = MenuBandPercussion() + private let queue = DispatchQueue(label: "computer.aesthetic.tracktramp.performance", qos: .userInteractive) + private var contactsByID: [Int32: CGPoint] = [:] + private var energies: [TracktrampEnergy] = [] + private var previousTimestamp: Double = 0 + private var scratchFrames = 0 + private var scratchTravel: CGFloat = 0 + private var scratchSpeed: Double? + private var membrane = TrackpadMembraneSimulation() + private var visibleTouches: [CGPoint] = [] + private var displayTimer: DispatchSourceTimer? + private var isRunning = false + + func start() throws { + guard !isRunning else { return } + percussion.attach(to: engine, output: engine.mainMixerNode) + engine.mainMixerNode.outputVolume = 0.92 + engine.prepare() + do { + try engine.start() + } catch { + throw TracktrampError.audio + } + + queue.sync { + membrane.reset(at: CACurrentMediaTime()) + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now(), repeating: 1.0 / 60.0, + leeway: .microseconds(800)) + timer.setEventHandler { [weak self] in self?.presentFrame() } + displayTimer = timer + timer.resume() + } + isRunning = true + } + + func stop() { + guard isRunning else { return } + isRunning = false + queue.sync { + displayTimer?.cancel() + displayTimer = nil + contactsByID.removeAll(keepingCapacity: true) + visibleTouches.removeAll(keepingCapacity: true) + energies.removeAll(keepingCapacity: true) + previousTimestamp = 0 + resetScratch() + percussion.silence() + } + engine.stop() + } + + func receive(contacts: [TrackpadContact], timestamp: Double, callbackTime: Double) { + guard isRunning else { return } + queue.async { [weak self] in + self?.process(contacts: contacts, timestamp: timestamp, callbackTime: callbackTime) + } + } + + func releaseAllContacts() { + receive( + contacts: [], + timestamp: ProcessInfo.processInfo.systemUptime, + callbackTime: CACurrentMediaTime() + ) + } + + private func process(contacts: [TrackpadContact], timestamp: Double, callbackTime: Double) { + let changes = TrackpadContactChanges.resolve(previous: contactsByID, contacts: contacts) + let previous = contactsByID + contactsByID = changes.activeByID + let touches = changes.active.map(\.point) + visibleTouches = touches + let now = CACurrentMediaTime() + membrane.advance(to: now, touches: touches) + decayEnergies(now: now) + + if !changes.began.isEmpty || !changes.lifted.isEmpty { + percussion.markTrackpadInput(at: callbackTime) + } + + for lift in changes.lifted { + let velocity = UInt8(max(36, Int(Double(MenuBandPercussion.surfaceVelocity( + at: lift, + anchors: touches, + inertia: energy(at: lift) + )) * 0.62))) + percussion.playSurfaceLift(at: lift, anchors: touches, velocity: velocity, synthetic: false) + } + + for strike in changes.began { + let anchors = touches.filter { $0 != strike } + let velocity = MenuBandPercussion.surfaceVelocity( + at: strike, + anchors: anchors, + inertia: energy(at: strike) + ) + percussion.playDrumSkin(strike: strike, anchors: anchors, velocity: velocity) + membrane.impulse( + at: strike, + amount: 0.28 + Double(velocity) / 127.0 * 0.54 + ) + energies.append(TracktrampEnergy( + point: strike, + level: 0.48 + Double(velocity) / 127.0 * 0.42, + at: now + )) + } + + let contactChanged = !changes.began.isEmpty || !changes.lifted.isEmpty + if contactChanged || touches.isEmpty { + resetScratch() + } else if previousTimestamp > 0, + let movement = dominantMovement(previous: previous, current: contactsByID) { + let dt = max(1.0 / 240.0, min(0.050, timestamp - previousTimestamp)) + scratchFrames += 1 + scratchTravel += movement.distance + if scratchFrames >= 2, scratchTravel >= 0.012 { + let measured = Double(movement.distance) / dt + let alpha = 1.0 - exp(-dt / 0.040) + let speed = scratchSpeed.map { $0 + alpha * (measured - $0) } ?? measured + scratchSpeed = speed + let anchors = touches.filter { $0 != movement.point } + percussion.setDrumSkinScratch( + at: movement.point, + speed: speed, + anchors: anchors, + direction: movement.vector, + surfaceEnergy: energy(at: movement.point), + synthetic: false + ) + energize(at: movement.point, amount: min(0.035, speed * 0.008), now: now) + } + } + previousTimestamp = touches.isEmpty ? 0 : timestamp + // The hardware callback can run well above display cadence. Rendering + // here as well as from `presentFrame` floods AppKit/Metal with stale + // full-size frames and makes the newest contact appear late. Audio + // remains raw-frame driven; visuals are coalesced to the presentation + // timer below and always read the latest touch state. + } + + private func presentFrame() { + let now = CACurrentMediaTime() + membrane.advance(to: now, touches: visibleTouches) + onActivity?(visibleTouches, membrane.snapshot()) + } + + private func resetScratch() { + scratchFrames = 0 + scratchTravel = 0 + scratchSpeed = nil + percussion.stopDrumSkinScratch() + } + + private func dominantMovement(previous: [Int32: CGPoint], current: [Int32: CGPoint]) + -> (point: CGPoint, distance: CGFloat, vector: CGVector)? { + current.compactMap { identifier, point -> (CGPoint, CGFloat, CGVector)? in + guard let old = previous[identifier] else { return nil } + let vector = CGVector(dx: point.x - old.x, dy: point.y - old.y) + return (point, hypot(vector.dx * 1.64, vector.dy), vector) + }.max { $0.1 < $1.1 } + } + + private func decayEnergies(now: Double) { + for index in energies.indices { + let age = max(0, now - energies[index].at) + energies[index].level *= exp(-age / 0.90) + energies[index].at = now + } + energies.removeAll { $0.level < 0.01 } + } + + private func energy(at point: CGPoint) -> Double { + min(1, energies.reduce(0) { total, energy in + let dx = Double(point.x - energy.point.x) * 1.64 + let dy = Double(point.y - energy.point.y) + return total + energy.level * exp(-(dx * dx + dy * dy) / (2 * 0.19 * 0.19)) + }) + } + + private func energize(at point: CGPoint, amount: Double, now: Double) { + if let index = energies.indices.min(by: { + distanceSquared(energies[$0].point, point) < distanceSquared(energies[$1].point, point) + }), distanceSquared(energies[index].point, point) < 0.05 { + energies[index].level = min(1, energies[index].level + amount) + energies[index].at = now + } else { + energies.append(TracktrampEnergy(point: point, level: amount, at: now)) + } + } + + private func distanceSquared(_ a: CGPoint, _ b: CGPoint) -> Double { + let dx = Double(a.x - b.x) * 1.64 + let dy = Double(a.y - b.y) + return dx * dx + dy * dy + } +} + +private func playfulTrackDrumFont(_ pointSize: CGFloat) -> NSFont { + for name in ["Comic Sans MS Bold", "ComicSansMS-Bold", + "Chalkboard SE Bold", "ChalkboardSE-Bold"] { + if let font = NSFont(name: name, size: pointSize) { return font } + } + return NSFont.systemFont(ofSize: pointSize, weight: .heavy) +} + +private final class TrackDrumCharacterLayer: CALayer { + var attributedCharacter: NSAttributedString? + var inset: CGFloat = 0 + + override func draw(in context: CGContext) { + guard let attributedCharacter else { return } + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: false) + attributedCharacter.draw(at: NSPoint(x: inset, y: inset)) + NSGraphicsContext.restoreGraphicsState() + } +} + +/// The same MacPal-style per-character bubble lettering used by Slab's prox +/// prompt rocks, kept local so this small feedback app does not pull in Slab. +private final class TrackDrumInstructionView: NSView { + private var instruction = "esc to quit" + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + setAccessibilityElement(true) + setAccessibilityRole(.staticText) + rebuildCharacters() + } + + required init?(coder: NSCoder) { + fatalError("TrackDrumInstructionView is code-only") + } + + func setInstruction(_ newValue: String) { + guard instruction != newValue else { return } + instruction = newValue + setAccessibilityLabel(newValue) + rebuildCharacters() + } + + override func layout() { + super.layout() + rebuildCharacters() + } + + private func rebuildCharacters() { + guard let root = layer else { return } + root.sublayers?.forEach { $0.removeFromSuperlayer() } + setAccessibilityLabel(instruction) + + let font = playfulTrackDrumFont(18) + let shadow = NSShadow() + shadow.shadowColor = NSColor.controlAccentColor + shadow.shadowBlurRadius = 0 + shadow.shadowOffset = NSSize(width: 2, height: -2) + let scale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2 + let inset: CGFloat = 7 + var characters: [TrackDrumCharacterLayer] = [] + var widths: [CGFloat] = [] + var totalWidth: CGFloat = 0 + + for character in instruction { + let attributed = NSAttributedString(string: String(character), attributes: [ + .font: font, + .foregroundColor: NSColor.white, + .strokeColor: NSColor(white: 0.08, alpha: 1), + .strokeWidth: -3.5, + .shadow: shadow, + ]) + let size = attributed.size() + let characterLayer = TrackDrumCharacterLayer() + characterLayer.attributedCharacter = attributed + characterLayer.inset = inset + characterLayer.contentsScale = scale + characterLayer.bounds = NSRect(x: 0, y: 0, + width: size.width + inset * 2, + height: size.height + inset * 2) + characterLayer.anchorPoint = CGPoint(x: 0.5, y: 0.5) + + var hash: UInt32 = 2166136261 + for byte in "trackdrum\(characters.count)\(instruction)".utf8 { + hash = (hash ^ UInt32(byte)) &* 16777619 + } + let dy = CGFloat(Int(hash % 5)) / 2 - 1 + let rotation = (CGFloat(Int((hash >> 8) % 9)) - 4) * 0.9 * .pi / 180 + var transform = CATransform3DMakeTranslation(0, dy, 0) + transform = CATransform3DRotate(transform, rotation, 0, 0, 1) + characterLayer.transform = transform + root.addSublayer(characterLayer) + characters.append(characterLayer) + widths.append(size.width) + totalWidth += size.width + characterLayer.setNeedsDisplay() + } + + var x = (bounds.width - totalWidth) / 2 + let start = CACurrentMediaTime() + let timing = Array(repeating: CAMediaTimingFunction(name: .easeInEaseOut), count: 3) + for (index, characterLayer) in characters.enumerated() { + characterLayer.position = CGPoint(x: x + widths[index] / 2, y: bounds.midY) + x += widths[index] + let begin = start + Double(index) * 0.12 + + let sway = CAKeyframeAnimation(keyPath: "transform.translation.y") + sway.values = [0, 1.2, -0.8, 0] + sway.keyTimes = [0, 0.25, 0.75, 1] + sway.timingFunctions = timing + sway.duration = 1.8 + sway.repeatCount = .infinity + sway.beginTime = begin + sway.isAdditive = true + characterLayer.add(sway, forKey: "wiggleY") + + let rotate = CAKeyframeAnimation(keyPath: "transform.rotation.z") + rotate.values = [0, 1.2 * Double.pi / 180, -0.8 * Double.pi / 180, 0] + rotate.keyTimes = [0, 0.25, 0.75, 1] + rotate.timingFunctions = timing + rotate.duration = 1.8 + rotate.repeatCount = .infinity + rotate.beginTime = begin + rotate.isAdditive = true + characterLayer.add(rotate, forKey: "wiggleR") + } + } +} + +private final class TracktrampView: NSView { + private static let instructionBandHeight: CGFloat = 48 + private let surfaceClip = NSView(frame: .zero) + private let launchSurface = TrackDrumLaunchSurfaceView(frame: .zero) + private var menubandSurface: TracktrampMetalView? + private let focusInstruction = TrackDrumInstructionView(frame: .zero) + private var trackpadSize = TracktrampMetalView.logicalSize + var onDefocus: (() -> Void)? + var onTrackpadFrame: (([TrackpadContact], Double, Double) -> Void)? + var onTrackpadDeviceSize: ((NSSize) -> Void)? + private var pointerHidden = false + private var reportedDeviceSize: NSSize? + + override var isFlipped: Bool { false } + override var acceptsFirstResponder: Bool { true } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + allowedTouchTypes = [.indirect] + wantsRestingTouches = true + layer?.backgroundColor = NSColor.clear.cgColor + layer?.masksToBounds = true + surfaceClip.wantsLayer = true + surfaceClip.layer?.backgroundColor = NSColor.clear.cgColor + surfaceClip.layer?.masksToBounds = true + addSubview(surfaceClip) + surfaceClip.addSubview(launchSurface) + addSubview(focusInstruction) + } + + required init?(coder: NSCoder) { + fatalError("TracktrampView is code-only") + } + + func setTrackpadSize(_ size: NSSize) { + trackpadSize = size + needsLayout = true + needsDisplay = true + } + + var preferredContentSize: NSSize { + NSSize(width: trackpadSize.width, + height: trackpadSize.height + Self.instructionBandHeight) + } + + func prepareRenderer() { + guard menubandSurface == nil else { return } + let renderer = TracktrampMetalView() + renderer.frame = rendererRect + surfaceClip.addSubview(renderer, positioned: .above, relativeTo: launchSurface) + menubandSurface = renderer + } + + func update(touches: [CGPoint], membrane: TrackpadMembraneSimulation.Snapshot) { + menubandSurface?.update(membrane, touches: touches) + } + + func showEscapeInstructionTemporarily() { + let defaults = UserDefaults.standard + let key = "trackDrumEscapeInstructionLaunchCount" + let launchCount = defaults.integer(forKey: key) + 1 + defaults.set(launchCount, forKey: key) + let environment = ProcessInfo.processInfo.environment + let developerMode = environment["TRACKDRUM_DEVELOPER_MODE"] == "1" + || Bundle.main.bundlePath.contains("/aesthetic-computer/") + let shouldShow = developerMode || launchCount <= 15 + focusInstruction.isHidden = !shouldShow + guard shouldShow else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak focusInstruction] in + focusInstruction?.isHidden = true + } + } + + func syncPointerVisibility(applicationActive: Bool) { + let shouldHide = applicationActive + if shouldHide, !pointerHidden { + NSCursor.hide() + pointerHidden = true + } else if !shouldHide, pointerHidden { + NSCursor.unhide() + pointerHidden = false + } + } + + func restorePointer() { + if pointerHidden { + NSCursor.unhide() + pointerHidden = false + } + } + + override func layout() { + super.layout() + surfaceClip.frame = physicalTrackpadRect + launchSurface.frame = surfaceClip.bounds + menubandSurface?.frame = rendererRect + focusInstruction.frame = NSRect( + x: 0, + y: trackpadSize.height, + width: bounds.width, + height: Self.instructionBandHeight + ) + } + + override func keyDown(with event: NSEvent) { + if event.keyCode == 53 { onDefocus?() } else { super.keyDown(with: event) } + } + + override func touchesBegan(with event: NSEvent) { reportTouches(from: event) } + override func touchesMoved(with event: NSEvent) { reportTouches(from: event) } + override func touchesEnded(with event: NSEvent) { reportTouches(from: event) } + override func touchesCancelled(with event: NSEvent) { reportTouches(from: event) } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + + private func reportTouches(from event: NSEvent) { + let touches = event.touches(matching: .touching, in: self) + .filter { $0.type == .indirect } + let began = Set(event.touches(matching: .began, in: self).map { + ObjectIdentifier($0.identity as AnyObject) + }) + let contacts = touches.map { touch in + let identity = ObjectIdentifier(touch.identity as AnyObject) + return TrackpadContact( + identifier: Int32(truncatingIfNeeded: identity.hashValue), + point: touch.normalizedPosition, + state: began.contains(identity) ? 3 : 4 + ) + } + + if let size = touches.first?.deviceSize, + size.width > 0, + size.height > 0, + reportedDeviceSize.map({ + abs($0.width - size.width) > 0.5 || abs($0.height - size.height) > 0.5 + }) ?? true { + reportedDeviceSize = size + onTrackpadDeviceSize?(size) + } + + onTrackpadFrame?(contacts, event.timestamp, CACurrentMediaTime()) + } + + private var rendererRect: NSRect { + let marginX = 4 * trackpadSize.width / 132 + let marginY = 4 * trackpadSize.height / 80 + return surfaceClip.bounds.insetBy(dx: -marginX, dy: -marginY) + } + + private var physicalTrackpadRect: NSRect { + NSRect( + x: 0, + y: 0, + width: trackpadSize.width, + height: trackpadSize.height + ) + } +} + +/// Cheap first-frame stand-in for the Metal drum surface. It is deliberately +/// layer-only: no shader creation, texture rasterization, or image decoding may +/// delay the window becoming visible. +private final class TrackDrumLaunchSurfaceView: NSView { + private let zoneColors: [NSColor] = [ + NSColor(calibratedRed: 0.48, green: 0.55, blue: 0.41, alpha: 1), + NSColor(calibratedRed: 0.68, green: 0.31, blue: 0.22, alpha: 1), + NSColor(calibratedRed: 0.76, green: 0.56, blue: 0.20, alpha: 1), + NSColor(calibratedRed: 0.30, green: 0.22, blue: 0.17, alpha: 1), + ] + private lazy var zones: [CALayer] = zoneColors.map { color in + let zone = CALayer() + zone.backgroundColor = color.cgColor + zone.cornerRadius = 8 + return zone + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor(calibratedWhite: 0.10, alpha: 1).cgColor + layer?.borderColor = NSColor.controlAccentColor.withAlphaComponent(0.65).cgColor + layer?.borderWidth = 2 + layer?.cornerRadius = 12 + layer?.masksToBounds = true + zones.forEach { layer?.addSublayer($0) } + } + + required init?(coder: NSCoder) { + fatalError("TrackDrumLaunchSurfaceView is code-only") + } + + override func layout() { + super.layout() + let gap: CGFloat = 3 + let inner = bounds.insetBy(dx: 5, dy: 5) + let halfWidth = (inner.width - gap) / 2 + let halfHeight = (inner.height - gap) / 2 + zones[0].frame = NSRect(x: inner.minX, y: inner.minY, + width: halfWidth, height: halfHeight) + zones[1].frame = NSRect(x: inner.midX + gap / 2, y: inner.minY, + width: halfWidth, height: halfHeight) + zones[2].frame = NSRect(x: inner.minX, y: inner.midY + gap / 2, + width: halfWidth, height: halfHeight) + zones[3].frame = NSRect(x: inner.midX + gap / 2, y: inner.midY + gap / 2, + width: halfWidth, height: halfHeight) + } +} diff --git a/slab/tracktramp/Sources/main.swift b/slab/tracktramp/Sources/main.swift new file mode 100644 --- /dev/null +++ b/slab/tracktramp/Sources/main.swift @@ -0,0 +1,19 @@ +import AppKit + +let app = NSApplication.shared +let delegate = TracktrampAppDelegate() +app.delegate = delegate +app.setActivationPolicy(.regular) + +let mainMenu = NSMenu() +let appMenuItem = NSMenuItem() +mainMenu.addItem(appMenuItem) +let appMenu = NSMenu(title: "TrackDrum") +appMenu.addItem( + withTitle: "Quit TrackDrum", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q" +) +appMenuItem.submenu = appMenu +app.mainMenu = mainMenu +app.run() diff --git a/slab/tracktramp/Tests/main.swift b/slab/tracktramp/Tests/main.swift new file mode 100644 --- /dev/null +++ b/slab/tracktramp/Tests/main.swift @@ -0,0 +1,41 @@ +import CoreGraphics + +private func contact(_ id: Int32, _ x: CGFloat, _ y: CGFloat, + began: Bool = false) -> TrackpadContact { + TrackpadContact( + identifier: id, + point: CGPoint(x: x, y: y), + state: began ? 3 : 4 + ) +} + +let first = TrackpadContactChanges.resolve( + previous: [:], + contacts: [contact(1, 0.2, 0.3, began: true)] +) +precondition(first.began == [CGPoint(x: 0.2, y: 0.3)]) +precondition(first.lifted.isEmpty) + +let held = TrackpadContactChanges.resolve( + previous: first.activeByID, + contacts: [contact(1, 0.4, 0.5)] +) +precondition(held.began.isEmpty) +precondition(held.lifted.isEmpty) +precondition(held.activeByID[1] == CGPoint(x: 0.4, y: 0.5)) + +let replaced = TrackpadContactChanges.resolve( + previous: held.activeByID, + contacts: [contact(2, 0.7, 0.8, began: true)] +) +precondition(replaced.began == [CGPoint(x: 0.7, y: 0.8)]) +precondition(replaced.lifted == [CGPoint(x: 0.4, y: 0.5)]) + +let lifted = TrackpadContactChanges.resolve( + previous: replaced.activeByID, + contacts: [] +) +precondition(lifted.active.isEmpty) +precondition(lifted.lifted == [CGPoint(x: 0.7, y: 0.8)]) + +print("✓ TrackDrum contact transitions") diff --git a/slab/tracktramp/TrackDrum.entitlements b/slab/tracktramp/TrackDrum.entitlements new file mode 100644 --- /dev/null +++ b/slab/tracktramp/TrackDrum.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/slab/tracktramp/build-app-store.sh b/slab/tracktramp/build-app-store.sh new file mode 100644 --- /dev/null +++ b/slab/tracktramp/build-app-store.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MODE="${1:---verify}" +DERIVED="${SCRIPT_DIR}/build/appstore-derived" +ARCHIVE="${SCRIPT_DIR}/build/TrackDrum.xcarchive" +EXPORT="${SCRIPT_DIR}/build/appstore-export" + +case "${MODE}" in + --verify|--archive) ;; + *) echo "usage: ./build-app-store.sh [--verify|--archive]" >&2; exit 2 ;; +esac + +cd "${SCRIPT_DIR}" +xcodegen generate +"${SCRIPT_DIR}/test.sh" + +if [[ "${MODE}" == "--verify" ]]; then + xcodebuild \ + -project TrackDrum.xcodeproj \ + -scheme TrackDrum \ + -configuration Release \ + -destination 'generic/platform=macOS' \ + -derivedDataPath "${DERIVED}" \ + ONLY_ACTIVE_ARCH=NO \ + "ARCHS=arm64 x86_64" \ + CODE_SIGNING_ALLOWED=NO \ + build + APP="${DERIVED}/Build/Products/Release/TrackDrum.app" + codesign --force --entitlements "${SCRIPT_DIR}/TrackDrum.entitlements" \ + --sign - "${APP}" + "${SCRIPT_DIR}/verify-app.sh" "${APP}" + echo "✓ App Store target verified: ${APP}" + exit 0 +fi + +xcodebuild \ + -project TrackDrum.xcodeproj \ + -scheme TrackDrum \ + -configuration Release \ + -destination 'generic/platform=macOS' \ + -archivePath "${ARCHIVE}" \ + ONLY_ACTIVE_ARCH=NO \ + "ARCHS=arm64 x86_64" \ + -allowProvisioningUpdates \ + archive + +APP="${ARCHIVE}/Products/Applications/TrackDrum.app" +"${SCRIPT_DIR}/verify-app.sh" "${APP}" + +xcodebuild \ + -exportArchive \ + -archivePath "${ARCHIVE}" \ + -exportPath "${EXPORT}" \ + -exportOptionsPlist "${SCRIPT_DIR}/ExportOptions-AppStore.plist" \ + -allowProvisioningUpdates + +PKG="${EXPORT}/TrackDrum.pkg" +pkgutil --check-signature "${PKG}" | grep -q \ + '3rd Party Mac Developer Installer' + +echo "✓ App Store archive: ${ARCHIVE}" +echo "✓ App Store package: ${PKG}" diff --git a/slab/tracktramp/build-dmg.sh b/slab/tracktramp/build-dmg.sh new file mode 100644 --- /dev/null +++ b/slab/tracktramp/build-dmg.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NOTARIZE=false +APP_ONLY=false +for argument in "$@"; do + case "${argument}" in + --notarize) NOTARIZE=true ;; + --app-only) APP_ONLY=true ;; + *) echo "usage: ./build-dmg.sh [--app-only] [--notarize]" >&2; exit 2 ;; + esac +done + +BUILD_DIR="${SCRIPT_DIR}/build" +APP="${BUILD_DIR}/TrackDrum.app" +CONTENTS="${APP}/Contents" +MACOS="${CONTENTS}/MacOS" +RESOURCES="${CONTENTS}/Resources" +MENUBAND_SOURCES="${SCRIPT_DIR}/../menuband/Sources/MenuBand" +SDK="$(xcrun --sdk macosx --show-sdk-path)" +ARM_BIN="${BUILD_DIR}/TrackDrum-arm64" +X86_BIN="${BUILD_DIR}/TrackDrum-x86_64" +UNIVERSAL_BIN="${MACOS}/TrackDrum" +DMG="${BUILD_DIR}/TrackDrum.dmg" +SHADER_AIR="${BUILD_DIR}/TracktrampShaders.air" +SHADER_METALLIB="${RESOURCES}/TracktrampShaders.metallib" +ENTITLEMENTS="${SCRIPT_DIR}/TrackDrum.entitlements" + +rm -rf "${APP:?}" +mkdir -p "${BUILD_DIR}" "${MACOS}" "${RESOURCES}" + +echo "• precompiling Metal surface" +xcrun -sdk macosx metal -x metal -c \ + "${MENUBAND_SOURCES}/TracktrampShaders.metalsource" \ + -o "${SHADER_AIR}" +xcrun -sdk macosx metallib "${SHADER_AIR}" -o "${SHADER_METALLIB}" + +SOURCES=( + "${SCRIPT_DIR}/Sources/main.swift" + "${SCRIPT_DIR}/Sources/MenubandUIBridge.swift" + "${SCRIPT_DIR}/Sources/PhysicalTrackpadCalibration.swift" + "${SCRIPT_DIR}/Sources/TrackDrumContact.swift" + "${SCRIPT_DIR}/Sources/TracktrampApp.swift" + "${SCRIPT_DIR}/Sources/TrackDrumIcon.swift" + "${MENUBAND_SOURCES}/MenuBandPercussion.swift" + "${MENUBAND_SOURCES}/PitchBendCursor.swift" + "${MENUBAND_SOURCES}/FocusFlashOverlay.swift" + "${MENUBAND_SOURCES}/FocusCueBeep.swift" +) + +echo "• building arm64" +xcrun swiftc -O -whole-module-optimization -sdk "${SDK}" \ + -target arm64-apple-macosx12.0 "${SOURCES[@]}" -o "${ARM_BIN}" + +echo "• building x86_64" +xcrun swiftc -O -whole-module-optimization -sdk "${SDK}" \ + -target x86_64-apple-macosx12.0 "${SOURCES[@]}" -o "${X86_BIN}" + +lipo -create -output "${UNIVERSAL_BIN}" "${ARM_BIN}" "${X86_BIN}" +cp "${SCRIPT_DIR}/Info.plist" "${CONTENTS}/Info.plist" +cp "${MENUBAND_SOURCES}/TracktrampShaders.metalsource" \ + "${RESOURCES}/TracktrampShaders.metalsource" + +echo "• drawing TrackDrum icon" +"${SCRIPT_DIR}/render-icon.sh" \ + "${RESOURCES}/TrackDrumIcon.icns" \ + "${BUILD_DIR}/icon-work" + +SIGN_IDENTITY="$(security find-identity -v -p codesigning 2>/dev/null \ + | awk -F\" '/Developer ID Application/{print $2; exit}')" +if [[ -n "${SIGN_IDENTITY}" ]]; then + echo "• signing app with Developer ID + App Sandbox" + codesign --force --options runtime --timestamp \ + --entitlements "${ENTITLEMENTS}" \ + --sign "${SIGN_IDENTITY}" "${APP}" +else + echo "• signing app ad hoc + App Sandbox" + codesign --force --entitlements "${ENTITLEMENTS}" --sign - "${APP}" +fi + +"${SCRIPT_DIR}/verify-app.sh" "${APP}" + +if [[ "${NOTARIZE}" == true ]]; then + APPLE_NOTARY_PASSWORD="${APPLE_APP_PASSWORD:-${APPLE_APP_SPECIFIC_PASSWORD:-${APP_SPECIFIC_PASSWORD:-}}}" + : "${APPLE_ID:?APPLE_ID is required for --notarize}" + : "${APPLE_NOTARY_PASSWORD:?an Apple app-specific password is required for --notarize}" + if [[ -z "${APPLE_TEAM_ID:-}" ]]; then + APPLE_TEAM_ID="$(security find-identity -v -p codesigning 2>/dev/null \ + | sed -nE 's/.*Developer ID Application: [^(]+\(([A-Z0-9]+)\).*/\1/p' \ + | head -1)" + fi + : "${APPLE_TEAM_ID:?APPLE_TEAM_ID is required for --notarize}" + [[ -n "${SIGN_IDENTITY}" ]] || { + echo "Developer ID signing identity is required for --notarize" >&2 + exit 1 + } + + APP_ZIP="${BUILD_DIR}/TrackDrum-notary.zip" + ditto -c -k --keepParent "${APP}" "${APP_ZIP}" + echo "• notarizing app" + xcrun notarytool submit "${APP_ZIP}" \ + --apple-id "${APPLE_ID}" --team-id "${APPLE_TEAM_ID}" \ + --password "${APPLE_NOTARY_PASSWORD}" --wait + xcrun stapler staple "${APP}" + xcrun stapler validate "${APP}" +fi + +if [[ "${APP_ONLY}" == true ]]; then + echo "✓ ${APP}" + exit 0 +fi + +STAGE="$(mktemp -d /tmp/trackdrum-dmg.XXXXXX)" +cleanup_stage() { rm -rf "${STAGE:?}"; } +trap cleanup_stage EXIT +cp -R "${APP}" "${STAGE}/TrackDrum.app" +ln -s /Applications "${STAGE}/Applications" + +[[ -f "${DMG}" ]] && rm "${DMG}" +hdiutil create -fs HFS+ -volname "TrackDrum" \ + -srcfolder "${STAGE}" -ov -format UDZO "${DMG}" >/dev/null + +if [[ -n "${SIGN_IDENTITY}" ]]; then + codesign --force --timestamp --sign "${SIGN_IDENTITY}" "${DMG}" +fi + +if [[ "${NOTARIZE}" == true ]]; then + echo "• notarizing DMG" + xcrun notarytool submit "${DMG}" \ + --apple-id "${APPLE_ID}" --team-id "${APPLE_TEAM_ID}" \ + --password "${APPLE_NOTARY_PASSWORD}" --wait + xcrun stapler staple "${DMG}" + xcrun stapler validate "${DMG}" + spctl --assess --type install --verbose=2 "${DMG}" +fi + +echo "✓ ${DMG}" +echo " $(du -h "${DMG}" | awk '{print $1}') · $(lipo -archs "${UNIVERSAL_BIN}")" diff --git a/slab/tracktramp/project.yml b/slab/tracktramp/project.yml new file mode 100644 --- /dev/null +++ b/slab/tracktramp/project.yml @@ -0,0 +1,48 @@ +name: TrackDrum + +options: + bundleIdPrefix: computer.aestheticcomputer + deploymentTarget: + macOS: "12.0" + createIntermediateGroups: true + +settings: + base: + SWIFT_VERSION: "5.9" + ARCHS: arm64 x86_64 + ONLY_ACTIVE_ARCH: NO + MARKETING_VERSION: "1.0" + CURRENT_PROJECT_VERSION: "1" + +targets: + TrackDrum: + type: application + platform: macOS + deploymentTarget: "12.0" + sources: + - path: Sources + - path: ../menuband/Sources/MenuBand/MenuBandPercussion.swift + - path: ../menuband/Sources/MenuBand/PitchBendCursor.swift + - path: ../menuband/Sources/MenuBand/FocusFlashOverlay.swift + - path: ../menuband/Sources/MenuBand/FocusCueBeep.swift + - path: ../menuband/Sources/MenuBand/TracktrampShaders.metalsource + buildPhase: resources + settings: + base: + PRODUCT_NAME: TrackDrum + PRODUCT_BUNDLE_IDENTIFIER: computer.aestheticcomputer.trackdrum + INFOPLIST_FILE: Info.plist + CODE_SIGN_ENTITLEMENTS: TrackDrum.entitlements + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: FB5948YR3S + ENABLE_APP_SANDBOX: YES + ENABLE_HARDENED_RUNTIME: YES + ENABLE_USER_SCRIPT_SANDBOXING: NO + GENERATE_INFOPLIST_FILE: NO + postBuildScripts: + - name: Render TrackDrum Icon + basedOnDependencyAnalysis: false + script: | + /bin/bash "${SRCROOT}/render-icon.sh" \ + "${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Resources/TrackDrumIcon.icns" \ + "${DERIVED_FILE_DIR}/TrackDrumIcon" diff --git a/slab/tracktramp/render-icon.sh b/slab/tracktramp/render-icon.sh new file mode 100644 --- /dev/null +++ b/slab/tracktramp/render-icon.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUTPUT="${1:?usage: render-icon.sh OUTPUT.icns [WORK_DIR]}" +WORK_DIR="${2:-$(mktemp -d /tmp/trackdrum-icon.XXXXXX)}" +SDK="$(xcrun --sdk macosx --show-sdk-path)" +RENDERER="${WORK_DIR}/render-trackdrum-icon" +PNG="${WORK_DIR}/TrackDrumIcon-1024.png" +ICONSET="${WORK_DIR}/TrackDrumIcon.iconset" + +mkdir -p "${WORK_DIR}" "$(dirname "${OUTPUT}")" +xcrun swiftc -O -sdk "${SDK}" \ + "${SCRIPT_DIR}/Sources/TrackDrumIcon.swift" \ + "${SCRIPT_DIR}/render-icon.swift" \ + -o "${RENDERER}" +"${RENDERER}" "${PNG}" +mkdir -p "${ICONSET}" +for spec in "16:icon_16x16.png" "32:icon_16x16@2x.png" \ + "32:icon_32x32.png" "64:icon_32x32@2x.png" \ + "128:icon_128x128.png" "256:icon_128x128@2x.png" \ + "256:icon_256x256.png" "512:icon_256x256@2x.png" \ + "512:icon_512x512.png" "1024:icon_512x512@2x.png"; do + size="${spec%%:*}" + name="${spec#*:}" + sips -z "${size}" "${size}" "${PNG}" --out "${ICONSET}/${name}" >/dev/null +done +iconutil -c icns "${ICONSET}" -o "${OUTPUT}" diff --git a/slab/tracktramp/render-icon.swift b/slab/tracktramp/render-icon.swift new file mode 100644 --- /dev/null +++ b/slab/tracktramp/render-icon.swift @@ -0,0 +1,22 @@ +import AppKit + +@main +private enum TrackDrumIconRenderer { + static func main() throws { + guard CommandLine.arguments.count == 2 else { + fputs("usage: render-icon output.png\n", stderr) + exit(2) + } + + let image = TrackDrumIcon.image(size: 1024, accent: .systemPink) + guard let tiff = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff), + let png = bitmap.representation(using: .png, properties: [:]) else { + exit(1) + } + try png.write( + to: URL(fileURLWithPath: CommandLine.arguments[1]), + options: .atomic + ) + } +} diff --git a/slab/tracktramp/test.sh b/slab/tracktramp/test.sh new file mode 100644 --- /dev/null +++ b/slab/tracktramp/test.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TEST_BIN="${SCRIPT_DIR}/build/TrackDrumContactTests" +mkdir -p "${SCRIPT_DIR}/build" +xcrun swiftc \ + "${SCRIPT_DIR}/Sources/TrackDrumContact.swift" \ + "${SCRIPT_DIR}/Tests/main.swift" \ + -o "${TEST_BIN}" +"${TEST_BIN}" diff --git a/slab/tracktramp/verify-app.sh b/slab/tracktramp/verify-app.sh new file mode 100644 --- /dev/null +++ b/slab/tracktramp/verify-app.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/build/TrackDrum.app}" +BIN="${APP}/Contents/MacOS/TrackDrum" + +[[ -x "${BIN}" ]] || { echo "missing TrackDrum executable" >&2; exit 1; } +if [[ ! -f "${APP}/Contents/Resources/TracktrampShaders.metallib" \ + && ! -f "${APP}/Contents/Resources/TracktrampShaders.metalsource" ]]; then + echo "missing Metal shader" >&2 + exit 1 +fi +[[ -f "${APP}/Contents/Resources/TrackDrumIcon.icns" ]] || { + echo "missing app icon" >&2; exit 1; +} +[[ ! -e "${APP}/Contents/MacOS/TrackDrumLauncher" ]] || { + echo "legacy launcher must not ship" >&2; exit 1; +} + +codesign --verify --deep --strict --verbose=2 "${APP}" +SANDBOX="$(codesign -d --entitlements :- "${APP}" 2>/dev/null \ + | plutil -extract 'com\.apple\.security\.app-sandbox' raw -)" +[[ "${SANDBOX}" == "true" ]] || { echo "App Sandbox is missing" >&2; exit 1; } + +if strings "${BIN}" | grep -Eq \ + 'MultitouchSupport\.framework|MTDeviceCreateList|CGEventTapCreate|Library/LaunchAgents'; then + echo "private/global input implementation leaked into TrackDrum" >&2 + exit 1 +fi + +ARCHS="$(lipo -archs "${BIN}")" +[[ " ${ARCHS} " == *" arm64 "* && " ${ARCHS} " == *" x86_64 "* ]] || { + echo "TrackDrum must be universal: ${ARCHS}" >&2; exit 1; +} + +echo "✓ sandboxed public-input TrackDrum (${ARCHS})" diff --git a/system/public/aesthetic.computer/disks/video.mjs b/system/public/aesthetic.computer/disks/video.mjs --- a/system/public/aesthetic.computer/disks/video.mjs +++ b/system/public/aesthetic.computer/disks/video.mjs @@ -766,7 +766,7 @@ ink(255, 75).box(0, 0, screen.width, screen.height, "inline"); } // Thin progress bar at the bottom edge, behind the controls. const barH = 2; - const barY = screen.height - barH; + const barY = screen.height - CONTROL_RAIL_HEIGHT - barH; ink(255, 40).box(0, barY, screen.width, barH); ink(255, 200, 0).box(0, barY, Math.floor(mp4Progress * screen.width), barH); @@ -784,6 +784,15 @@ if (presenting && !playing && !isPrinting) { // Paused: subtle overlay without a black background ink(255, 200).write("||", { center: "xy" }); ink(255, 75).box(0, 0, screen.width, screen.height, "inline"); + } + + if (exportAvailable && !isPrinting) { + ink(0, 0, 0, 105).box( + 0, + screen.height - CONTROL_RAIL_HEIGHT, + screen.width, + CONTROL_RAIL_HEIGHT, + ); } // Draw export buttons - reposition every frame (simple!) diff --git a/toolchain/fleet/fleet-mcp.mjs b/toolchain/fleet/fleet-mcp.mjs --- a/toolchain/fleet/fleet-mcp.mjs +++ b/toolchain/fleet/fleet-mcp.mjs -- tangled.sh