From e93d1a984520de1588554ea6b42953509d2290e3 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 6 Jul 2026 21:41:58 -0700 Subject: [PATCH] =?UTF-8?q?macpal:=20programmable=20change-chime=20?= =?UTF-8?q?=E2=80=94=20server-driven=20sound=20+=20volume?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The affirmation star's message-change sound was hardcoded (NSSound Glass @0.35). Now the payload carries an optional named system sound (Glass/Tink/Pop/Bottle/ Hero/…) and volume 0-1; a playlist can name a per-entry chime via a parallel `sounds[]` array (falling back to `sound`, then Glass). Fully backward-compatible — old payloads and unknown names still ring Glass. node macpal/affirm.mjs "ta-da ✨" --sound Hero --volume 0.5 Touches the endpoint (store/echo), AffirmationsPlugin (play), and affirm.mjs (flags). Co-Authored-By: Claude Opus 4.8 (1M context) --- macpal/Sources/AffirmationsPlugin.swift | 21 +++++++--- macpal/affirm.mjs | 20 ++++++++-- system/netlify/functions/macpal-status.mjs | 45 ++++++++++++++++++++-- 3 files changed, 72 insertions(+), 14 deletions(-) diff --git a/macpal/Sources/AffirmationsPlugin.swift b/macpal/Sources/AffirmationsPlugin.swift index d2e177dcee..723ca187fb 100644 --- a/macpal/Sources/AffirmationsPlugin.swift +++ b/macpal/Sources/AffirmationsPlugin.swift @@ -1,9 +1,10 @@ // AffirmationsPlugin — a little caption beneath Fía's star that @jeffrey can // change remotely. The star polls an aesthetic.computer endpoint -// GET /api/macpal-status?to= → { to, text, seq } +// GET /api/macpal-status?to= → { to, text, seq, sound?, volume? } // every ~45s; when the `seq` bumps, the new affirmation slides in, the name -// hops, and a soft chime plays. The last affirmation is cached to disk so she -// still sees it offline / on the next launch. +// hops, and a chime plays — Glass by default, or whichever named system sound +// (and volume) the payload carries. The last affirmation is cached to disk so +// she still sees it offline / on the next launch. // // @jeffrey pushes one with: node macpal/affirm.mjs "proud of you 💛" --to fia // @@ -86,12 +87,16 @@ final class AffirmationsPlugin: NSObject, PalPlugin, WidthHinting { let newSeq = (obj["seq"] as? Int) ?? (obj["seq"] as? NSNumber)?.intValue else { return } let newText = (obj["text"] as? String) ?? "" + let newSound = obj["sound"] as? String + let newVolume = (obj["volume"] as? Double) ?? (obj["volume"] as? NSNumber)?.doubleValue guard newSeq != self.seq else { return } - DispatchQueue.main.async { self.apply(seq: newSeq, text: newText) } + DispatchQueue.main.async { + self.apply(seq: newSeq, text: newText, sound: newSound, volume: newVolume) + } }.resume() } - private func apply(seq newSeq: Int, text newText: String) { + private func apply(seq newSeq: Int, text newText: String, sound: String? = nil, volume: Double? = nil) { let firstFill = seq < 0 seq = newSeq text = newText @@ -99,9 +104,13 @@ final class AffirmationsPlugin: NSObject, PalPlugin, WidthHinting { renderCaption() c?.layout() // Celebrate a genuinely new message (not the silent seed on launch). + // The chime is server-programmable; an unknown name falls back to Glass. if !firstFill, !text.isEmpty { c?.nameLabel.bounce() - let snd = NSSound(named: "Glass"); snd?.volume = 0.35; snd?.play() + let named = sound.flatMap { $0.isEmpty ? nil : $0 } ?? "Glass" + let snd = NSSound(named: named) ?? NSSound(named: "Glass") + snd?.volume = Float(min(1, max(0, volume ?? 0.35))) + snd?.play() } } diff --git a/macpal/affirm.mjs b/macpal/affirm.mjs index 30f07867c9..1669d318da 100644 --- a/macpal/affirm.mjs +++ b/macpal/affirm.mjs @@ -9,6 +9,11 @@ // node macpal/affirm.mjs "testing" --local # → https://localhost:8888 // node macpal/affirm.mjs --clear --to fia # blank the caption // +// The chime that plays when the message changes is programmable — a named macOS +// system sound (Glass, Tink, Pop, Bottle, Hero, Submarine, Ping…) at --volume 0–1: +// +// node macpal/affirm.mjs "ta-da ✨" --sound Hero --volume 0.5 +// // Playlists rotate server-side (survives this machine sleeping) — each quoted // argument is one message, cycling every --every seconds (default 120): // @@ -31,8 +36,10 @@ const to = flag("--to") || "fia"; const local = has("--local"); const playlist = has("--playlist"); const every = Number(flag("--every")) || 120; +const sound = flag("--sound"); +const volume = flag("--volume"); const host = flag("--host") || (local ? "https://localhost:8888" : "https://aesthetic.computer"); -const valueFlags = ["--to", "--host", "--every"]; +const valueFlags = ["--to", "--host", "--every", "--sound", "--volume"]; const words = args.filter((a, i) => !a.startsWith("--") && !valueFlags.includes(args[i - 1])); // One message is every non-flag argument joined (so quotes are optional-ish); // a --playlist keeps each quoted argument as its own message. @@ -63,10 +70,14 @@ if (!token) { // The dev site serves a self-signed cert on localhost; trust it only there. if (local) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; +const payload = playlist ? { to, playlist: words, every } : { to, text }; +if (sound !== undefined) payload.sound = sound; +if (volume !== undefined) payload.volume = Number(volume); + const res = await fetch(`${host}/api/macpal-status`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, - body: JSON.stringify(playlist ? { to, playlist: words, every } : { to, text }), + body: JSON.stringify(payload), }); const out = await res.json().catch(() => ({})); @@ -75,8 +86,9 @@ if (!res.ok) { if (res.status === 401) console.error(" token expired? re-run: node tezos/ac-login.mjs"); process.exit(1); } +const chime = out.sound ? ` 🔔 ${out.sound}${out.volume != null ? ` @${out.volume}` : ""}` : ""; if (out.playlist) { - console.log(`✓ ${to} ← playlist [${out.playlist.map((m) => `"${m}"`).join(" → ")}] every ${out.every}s (seq ${out.seq})`); + console.log(`✓ ${to} ← playlist [${out.playlist.map((m) => `"${m}"`).join(" → ")}] every ${out.every}s (seq ${out.seq})${chime}`); } else { - console.log(`✓ ${to} ← "${out.text}" (seq ${out.seq})`); + console.log(`✓ ${to} ← "${out.text}" (seq ${out.seq})${chime}`); } diff --git a/system/netlify/functions/macpal-status.mjs b/system/netlify/functions/macpal-status.mjs index 4429147dcb..9b15a390d5 100644 --- a/system/netlify/functions/macpal-status.mjs +++ b/system/netlify/functions/macpal-status.mjs @@ -2,12 +2,17 @@ // The remote status line under a MacPal star — affirmations @jeffrey pushes to // Fía's desktop pal (see macpal/Sources/AffirmationsPlugin.swift). // -// GET /api/macpal-status?to= → { to, text, seq, at } (public read) +// GET /api/macpal-status?to= → { to, text, seq, at, sound?, volume? } // POST /api/macpal-status → set it (admin-only: @jeffrey) // body: { to, text } → one message // body: { to, playlist: [...], every } → rotate through messages, // one per `every` seconds // +// The chime the star plays when a message changes is programmable: `sound` is a +// named macOS system sound (Glass, Tink, Pop, Bottle, Hero, Submarine, Ping…) +// and `volume` is 0–1. On a playlist, a parallel `sounds: [...]` array gives +// each entry its own chime (falling back to `sound`, then the star's Glass). +// // Stored as a JSON string in the Redis hash "macpal", keyed by recipient. // A playlist is rotated at read time: the GET computes the current entry from // the elapsed time since it was set, and derives `seq` so the pal celebrates @@ -22,6 +27,21 @@ const MAX_LEN = 240; const MAX_PLAYLIST = 24; const MIN_EVERY = 30; // seconds — the star polls every ~45s const MAX_EVERY = 86400; +const MAX_SOUND = 32; + +// A chime name is handed straight to NSSound(named:) on the star, so keep it to +// the tame alphanumerics real system sounds use ("Glass", "Tink"). Anything +// that sanitizes to nothing becomes undefined — the star then plays its Glass. +function cleanSound(raw) { + if (raw == null) return undefined; + return raw.toString().replace(/[^A-Za-z0-9]/g, "").slice(0, MAX_SOUND) || undefined; +} + +function cleanVolume(raw) { + if (raw == null || raw === "") return undefined; + const v = Number(raw); + return Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : undefined; +} // Recipient keys are short, lowercase slugs — keep them tame so they're safe // hash fields and predictable from the pal's `--to` flag. @@ -39,18 +59,25 @@ function cleanKey(raw) { // rotations have elapsed since it was set. function currentView(stored, now = Date.now()) { if (!stored) return { text: "", seq: 0, at: null }; - const { playlist, every, seq = 0, at = null } = stored; + const { playlist, sounds, sound, volume, every, seq = 0, at = null } = stored; + const chime = (s) => ({ + ...(s ? { sound: s } : {}), + ...(volume != null ? { volume } : {}), + }); if (!Array.isArray(playlist) || playlist.length === 0) { - return { text: stored.text ?? "", seq, at }; + return { text: stored.text ?? "", seq, at, ...chime(sound) }; } const elapsed = Math.max(0, now - Date.parse(at)); const turns = Math.floor(elapsed / (every * 1000)); + const idx = turns % playlist.length; + const curSound = (Array.isArray(sounds) ? sounds[idx] || undefined : undefined) ?? sound; return { - text: playlist[turns % playlist.length], + text: playlist[idx], seq: seq + turns, at, playlist, every, + ...chime(curSound), }; } @@ -77,6 +104,8 @@ export async function handler(event) { return respond(400, { message: "Bad JSON." }); } const to = cleanKey(body.to); + const sound = cleanSound(body.sound); + const volume = cleanVolume(body.volume); let entry; if (Array.isArray(body.playlist)) { @@ -92,9 +121,17 @@ export async function handler(event) { Math.max(MIN_EVERY, Math.round(Number(body.every) || 120)), ); entry = { playlist, every }; + // A parallel per-entry chime list — cleaned, aligned to the playlist, + // kept only if at least one entry actually names a sound. + if (Array.isArray(body.sounds)) { + const sounds = playlist.map((_, i) => cleanSound(body.sounds[i]) || ""); + if (sounds.some((s) => s)) entry.sounds = sounds; + } } else { entry = { text: (body.text ?? "").toString().slice(0, MAX_LEN) }; } + if (sound) entry.sound = sound; + if (volume != null) entry.volume = volume; await KeyValue.connect(); const raw = await KeyValue.get(COLLECTION, to); -- 2.51.2