From 387fe28838fa3b494fa1d9d114968e8f69a82234 Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Fri, 22 May 2026 20:27:07 +0000 Subject: [PATCH] pop: hum lane + RFA sing-wizard end-to-end (melody → record → recompile) New pop/hum/ lane — a 16-note singable melody (D minor, one octave), the testbed for the request-for-audio voice-take workflow: - voice-takes/manifest.json: the melody + words (score of record) - bin/render.mjs: synth lead + chord pad, mixes each recorded take in on top (lead ducks under the voice); always renderable rfa.mjs: after the sing loop, offer to run the lane's render.mjs and play the recompiled track back — closes the record→hear loop. slab menubar: "Request for Audio" item launches the wizard in iTerm2 (Paths.acRepo locates the checkout). Co-Authored-By: Claude Opus 4.7 (1M context) --- pop/.gitignore | 5 +++++ pop/bin/rfa.mjs | 17 +++++++++++++++-- pop/hum/README.md | 36 ++++++++++++++++++++++++++++++++++++ pop/hum/bin/render.mjs | 175 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pop/hum/voice-takes/manifest.json | 30 ++++++++++++++++++++++++++++++ slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift | 19 +++++++++++++++++++ slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift | 9 +++++++++ slab/menubar-swift/Sources/SlabMenubar/Paths.swift | 6 ++++++ 8 file(s) changed, 295 insertion(s)(+), 2 deletion(s)(-) diff --git a/pop/.gitignore b/pop/.gitignore --- a/pop/.gitignore +++ b/pop/.gitignore @@ -4,3 +4,8 @@ !references/README.md # track outputs big-pictures/out/ +hum/out/ + +# RFA recorded voice takes — local performances, kept off the repo +# (the manifest.json punch-list IS tracked) +**/voice-takes/*.wav diff --git a/pop/bin/rfa.mjs b/pop/bin/rfa.mjs --- a/pop/bin/rfa.mjs +++ b/pop/bin/rfa.mjs @@ -238,6 +238,19 @@ else console.log(` · skipped\n`); } try { unlinkSync(tmpGuide); } catch {} +const done = manifest.notes.filter((n) => hasTake(n.id)).length; +console.log(`\ndone · ${done}/${manifest.notes.length} takes recorded`); + +// ── recompile + playback — close the loop. If the lane has a renderer, +// offer to rebuild the track with the fresh takes and play it back. +const renderer = resolve(LANE_DIR, "bin", "render.mjs"); +if (done > 0 && existsSync(renderer)) { + const a = await ask(`\n ▸ recompile ${TRACK} with your voice + play it back? [Y/n] `); + if (a !== "n" && a !== "q") { + console.log(` ⚙ rendering…`); + spawnSync("node", [renderer, "--play"], { stdio: "inherit" }); + } +} else { + console.log(`re-render the track to hear them in.`); +} rl.close(); -const done = manifest.notes.filter((n) => hasTake(n.id)).length; -console.log(`done · ${done}/${manifest.notes.length} takes recorded · re-render the track to hear them in.`); diff --git a/pop/hum/README.md b/pop/hum/README.md new file mode 100644 --- /dev/null +++ b/pop/hum/README.md @@ -0,0 +1,36 @@ +# hum + +A tiny singable melody — the testbed for the **RFA** (request-for-audio) +voice-take workflow. Sixteen notes, one octave (D3–D4), D natural minor. + +``` +deep in the hum / a note grows slow / hold to the tone / now let it go +``` + +## The loop + +``` +voice-takes/manifest.json the melody + words (the score of record) + │ + rfa.mjs ──▶ wizard: per note, plays the pitch + shows the word, + │ records you singing it, keep / redo / skip + ▼ +voice-takes/.wav one take per sung note (id = "-") + │ + render.mjs ──▶ synth lead + chord pad, with every recorded take + │ mixed in on top (lead ducks under your voice) + ▼ +out/hum.wav · out/hum.mp3 always playable, "real" one note at a time +``` + +## Commands + +```bash +node pop/hum/bin/render.mjs --play # hear the tune (synth lead) +node pop/bin/rfa.mjs --track hum # sing it — the wizard walks you +node pop/bin/rfa.mjs --track hum --status # punch-list: notes sung / missing +node pop/bin/rfa.mjs --track hum --only 6-0 # re-sing one note +``` + +After the wizard finishes it offers to recompile + play the track back. +A Slab menubar item triggers the same wizard hands-free. diff --git a/pop/hum/bin/render.mjs b/pop/hum/bin/render.mjs new file mode 100644 --- /dev/null +++ b/pop/hum/bin/render.mjs @@ -0,0 +1,175 @@ +#!/usr/bin/env node +// render.mjs — the "hum" lane: a tiny singable melody, rendered. +// +// A testbed for the RFA voice-take workflow. The melody + words live in +// voice-takes/manifest.json. This renderer plays the tune as a clear +// sustained sine lead over a soft chord pad, and — for every note that +// has a recorded take (voice-takes/.wav, captured by +// `node pop/bin/rfa.mjs --track hum`) — mixes @jeffrey's actual voice +// in on top, ducking the synth lead under it. So the track is always +// playable and becomes "real" one sung note at a time. +// +// Usage: node pop/hum/bin/render.mjs [--play] +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const PLAY = process.argv.includes("--play"); +const HERE = dirname(fileURLToPath(import.meta.url)); +const LANE = resolve(HERE, ".."); +const TAKES = resolve(LANE, "voice-takes"); +const OUTDIR = resolve(LANE, "out"); +const SR = 48_000; +const TAU = Math.PI * 2; +const m2f = (m) => 440 * Math.pow(2, (m - 69) / 12); + +const manifest = JSON.parse(readFileSync(resolve(TAKES, "manifest.json"), "utf8")); +const SPB = 60 / manifest.bpm; +const notes = manifest.notes; + +// ── chord bed — D natural minor, one triad per phrase ───────────────── +const CHORDS = [ + { beat: 0, name: "Dm", midis: [50, 53, 57] }, + { beat: 8, name: "Bb", midis: [46, 50, 53] }, + { beat: 18, name: "F", midis: [53, 57, 60] }, + { beat: 28, name: "Dm", midis: [50, 53, 57] }, +]; +const TOTAL_BEATS = 44; // 40 melody + 4 tail +const N = Math.ceil((TOTAL_BEATS * SPB + 1.0) * SR); +const buf = new Float32Array(N); + +function add(t, dur, fn) { + let i = Math.max(0, Math.floor(t * SR)); + const e = Math.min(N, Math.ceil((t + dur) * SR)); + for (; i < e; i++) buf[i] += fn(i / SR - t); +} + +// soft sustained chord pad — background +function pad(t, dur, midi, gain) { + const f = m2f(midi); + add(t, dur, (lt) => { + let env = Math.min(1, lt / 0.4); + if (lt > dur - 0.6) env *= Math.max(0, (dur - lt) / 0.6); + const x = Math.sin(TAU * f * lt) + 0.3 * Math.sin(TAU * f * 2 * lt); + return Math.tanh(x * 0.6) * env * gain; + }); +} + +// clear sustained melody lead — the tune to sing along to +function lead(t, dur, midi, gain) { + const f = m2f(midi); + add(t, dur, (lt) => { + let env = Math.min(1, lt / 0.03); + if (lt > dur - 0.12) env *= Math.max(0, (dur - lt) / 0.12); + const vib = 1 + Math.sin(TAU * 5 * lt) * 0.004 * Math.min(1, lt / 0.2); + const x = Math.sin(TAU * f * vib * lt) + 0.25 * Math.sin(TAU * f * 2 * lt) + + 0.1 * Math.sin(TAU * f * 3 * lt); + return Math.tanh(x * 0.7) * env * gain; + }); +} + +// soft beat click — keeps time +function click(t, gain) { + add(t, 0.05, (lt) => Math.sin(TAU * 2000 * lt) * Math.exp(-lt / 0.012) * gain); +} + +// ── wav loader — mono float @ SR, silence-trimmed, peak-normalized ──── +function loadWav(path) { + const b = readFileSync(path); + let p = 12, fmt = null, dOff = 0, dLen = 0; + while (p + 8 <= b.length) { + const id = b.toString("ascii", p, p + 4); + const sz = b.readUInt32LE(p + 4); + if (id === "fmt ") fmt = { format: b.readUInt16LE(p + 8), ch: b.readUInt16LE(p + 10), + sr: b.readUInt32LE(p + 12), bits: b.readUInt16LE(p + 22) }; + else if (id === "data") { dOff = p + 8; dLen = sz; } + p += 8 + sz + (sz & 1); + } + if (!fmt || !dOff) throw new Error(`bad WAV: ${path}`); + const fb = (fmt.bits / 8) * fmt.ch, frames = Math.floor(dLen / fb); + let mono = new Float32Array(frames); + for (let i = 0; i < frames; i++) { + let acc = 0; + for (let c = 0; c < fmt.ch; c++) { + const o = dOff + i * fb + c * (fmt.bits / 8); + if (fmt.format === 3 && fmt.bits === 32) acc += b.readFloatLE(o); + else if (fmt.bits === 16) acc += b.readInt16LE(o) / 32768; + else if (fmt.bits === 24) acc += (b.readUInt8(o) | (b.readUInt8(o + 1) << 8) | (b.readInt8(o + 2) << 16)) / 8388608; + else if (fmt.bits === 32) acc += b.readInt32LE(o) / 2147483648; + } + mono[i] = acc / fmt.ch; + } + if (fmt.sr !== SR) { + const outN = Math.round(frames * SR / fmt.sr), rs = new Float32Array(outN); + for (let i = 0; i < outN; i++) { + const x = i * fmt.sr / SR, i0 = Math.floor(x), fr = x - i0; + rs[i] = (mono[i0] || 0) + ((mono[i0 + 1] || 0) - (mono[i0] || 0)) * fr; + } + mono = rs; + } + let a = 0, e = mono.length; const TH = 0.02; + while (a < e && Math.abs(mono[a]) < TH) a++; + while (e > a && Math.abs(mono[e - 1]) < TH) e--; + mono = mono.subarray(a, e); + let pk = 0; for (let i = 0; i < mono.length; i++) pk = Math.max(pk, Math.abs(mono[i])); + if (pk > 0) for (let i = 0; i < mono.length; i++) mono[i] /= pk; + return mono; +} + +function placeSample(t, samp, gain) { + const start = Math.floor(t * SR), fadeN = Math.floor(0.015 * SR); + for (let k = 0; k < samp.length; k++) { + const di = start + k; + if (di < 0 || di >= N) continue; + let s = samp[k]; + if (k < fadeN) s *= k / fadeN; + if (samp.length - k < fadeN) s *= (samp.length - k) / fadeN; + buf[di] += s * gain; + } +} + +// ── render ──────────────────────────────────────────────────────────── +for (let c = 0; c < CHORDS.length; c++) { + const ch = CHORDS[c]; + const endBeat = c + 1 < CHORDS.length ? CHORDS[c + 1].beat : TOTAL_BEATS; + for (const m of ch.midis) pad(ch.beat * SPB, (endBeat - ch.beat) * SPB, m, 0.05); +} +for (let b = 0; b < TOTAL_BEATS; b++) click(b * SPB, b % 4 === 0 ? 0.06 : 0.035); + +let takeCount = 0; +for (const n of notes) { + const takePath = resolve(TAKES, `${n.id}.wav`); + const hasTake = existsSync(takePath); + lead(n.startSec, n.durSec, n.midi, hasTake ? 0.10 : 0.22); // duck lead under a voice take + if (hasTake) { placeSample(n.startSec, loadWav(takePath), 0.95); takeCount++; } +} + +// ── master + write ──────────────────────────────────────────────────── +let peak = 0; +for (let i = 0; i < N; i++) peak = Math.max(peak, Math.abs(buf[i])); +const g = peak > 0 ? 0.9 / peak : 1; +mkdirSync(OUTDIR, { recursive: true }); +const wavPath = resolve(OUTDIR, "hum.wav"); +const w = Buffer.alloc(44 + N * 2); +w.write("RIFF", 0); w.writeUInt32LE(36 + N * 2, 4); w.write("WAVE", 8); +w.write("fmt ", 12); w.writeUInt32LE(16, 16); w.writeUInt16LE(1, 20); +w.writeUInt16LE(1, 22); w.writeUInt32LE(SR, 24); w.writeUInt32LE(SR * 2, 28); +w.writeUInt16LE(2, 32); w.writeUInt16LE(16, 34); +w.write("data", 36); w.writeUInt32LE(N * 2, 40); +for (let i = 0; i < N; i++) { + const s = Math.tanh(buf[i] * g * 1.02); + w.writeInt16LE(Math.max(-32768, Math.min(32767, Math.round(s * 32767))), 44 + i * 2); +} +writeFileSync(wavPath, w); +const mp3Path = resolve(OUTDIR, "hum.mp3"); +spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-i", wavPath, + "-af", "loudnorm=I=-16:TP=-1.5", "-b:a", "320k", mp3Path], { stdio: "ignore" }); + +console.log(`hum · ${manifest.bpm} BPM · ${notes.length} notes · ` + + `${takeCount}/${notes.length} sung · ${(N / SR).toFixed(1)}s`); +console.log(`→ ${wavPath}`); +if (takeCount === 0) + console.log(` (no voice takes yet — synth lead only. sing: ` + + `node pop/bin/rfa.mjs --track hum)`); +if (PLAY) spawnSync("afplay", [wavPath], { stdio: "ignore" }); diff --git a/pop/hum/voice-takes/manifest.json b/pop/hum/voice-takes/manifest.json new file mode 100644 --- /dev/null +++ b/pop/hum/voice-takes/manifest.json @@ -0,0 +1,30 @@ +{ + "track": "hum", + "lane": "hum", + "title": "hum — a melody to sing", + "bpm": 182, + "beatSec": 0.3296703296703297, + "barSec": 1.3186813186813187, + "scale": "D natural minor", + "range": "D3-D4 (one octave)", + "lyric": "deep in the hum / a note grows slow / hold to the tone / now let it go", + "note_on_vowel": "the `vowel` field carries the WORD to sing — rfa.mjs prints it verbatim", + "notes": [ + { "id": "0-0", "bar": 0, "beat": 0, "midi": 50, "note": "D3", "word": "deep", "durBeats": 2, "durSec": 0.65934, "startSec": 0.0, "vowel": "deep", "hasTake": false }, + { "id": "0-2", "bar": 0, "beat": 2, "midi": 53, "note": "F3", "word": "in", "durBeats": 2, "durSec": 0.65934, "startSec": 0.65934, "vowel": "in", "hasTake": false }, + { "id": "1-0", "bar": 1, "beat": 0, "midi": 55, "note": "G3", "word": "the", "durBeats": 2, "durSec": 0.65934, "startSec": 1.31868, "vowel": "the", "hasTake": false }, + { "id": "1-2", "bar": 1, "beat": 2, "midi": 57, "note": "A3", "word": "hum", "durBeats": 2, "durSec": 0.65934, "startSec": 1.97802, "vowel": "hum", "hasTake": false }, + { "id": "2-0", "bar": 2, "beat": 0, "midi": 57, "note": "A3", "word": "a", "durBeats": 2, "durSec": 0.65934, "startSec": 2.63736, "vowel": "a", "hasTake": false }, + { "id": "2-2", "bar": 2, "beat": 2, "midi": 55, "note": "G3", "word": "note", "durBeats": 2, "durSec": 0.65934, "startSec": 3.29670, "vowel": "note", "hasTake": false }, + { "id": "3-0", "bar": 3, "beat": 0, "midi": 53, "note": "F3", "word": "grows", "durBeats": 2, "durSec": 0.65934, "startSec": 3.95604, "vowel": "grows", "hasTake": false }, + { "id": "3-2", "bar": 3, "beat": 2, "midi": 50, "note": "D3", "word": "slow", "durBeats": 4, "durSec": 1.31868, "startSec": 4.61538, "vowel": "slow", "hasTake": false }, + { "id": "4-2", "bar": 4, "beat": 2, "midi": 53, "note": "F3", "word": "hold", "durBeats": 2, "durSec": 0.65934, "startSec": 5.93407, "vowel": "hold", "hasTake": false }, + { "id": "5-0", "bar": 5, "beat": 0, "midi": 55, "note": "G3", "word": "to", "durBeats": 2, "durSec": 0.65934, "startSec": 6.59341, "vowel": "to", "hasTake": false }, + { "id": "5-2", "bar": 5, "beat": 2, "midi": 57, "note": "A3", "word": "the", "durBeats": 2, "durSec": 0.65934, "startSec": 7.25275, "vowel": "the", "hasTake": false }, + { "id": "6-0", "bar": 6, "beat": 0, "midi": 60, "note": "C4", "word": "tone", "durBeats": 4, "durSec": 1.31868, "startSec": 7.91209, "vowel": "tone", "hasTake": false }, + { "id": "7-0", "bar": 7, "beat": 0, "midi": 57, "note": "A3", "word": "now", "durBeats": 2, "durSec": 0.65934, "startSec": 9.23077, "vowel": "now", "hasTake": false }, + { "id": "7-2", "bar": 7, "beat": 2, "midi": 60, "note": "C4", "word": "let", "durBeats": 2, "durSec": 0.65934, "startSec": 9.89011, "vowel": "let", "hasTake": false }, + { "id": "8-0", "bar": 8, "beat": 0, "midi": 62, "note": "D4", "word": "it", "durBeats": 4, "durSec": 1.31868, "startSec": 10.54945, "vowel": "it", "hasTake": false }, + { "id": "9-0", "bar": 9, "beat": 0, "midi": 50, "note": "D3", "word": "go", "durBeats": 4, "durSec": 1.31868, "startSec": 11.86813, "vowel": "go", "hasTake": false } + ] +} diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -337,6 +337,25 @@ @objc func openSoundsFolder() { ShellRunner.run("/usr/bin/open", args: [Paths.soundsDir]) } + /// Open iTerm2 running the RFA ("request for audio") wizard for a /pop + /// lane — a per-note loop that plays the pitch, shows the word, records + /// you singing it, then recompiles the track + plays it back. It's + /// interactive (stdin keep/redo), so it needs a real terminal; `exec` + /// replaces the shell so closing the pane ends the session cleanly. + @objc func requestForAudio(_ sender: NSMenuItem) { + let lane = (sender.representedObject as? String) ?? "hum" + let repoEsc = Paths.acRepo.replacingOccurrences(of: "'", with: "'\\''") + let cmd = "cd '\(repoEsc)' && exec node pop/bin/rfa.mjs --track \(lane)" + let script = """ + tell application "iTerm2" + activate + create window with default profile + tell current session of current window to write text "\(cmd)" + end tell + """ + ShellRunner.runAsync("/usr/bin/osascript", args: ["-e", script]) + } + @objc func reloadDaemon() { DispatchQueue.global(qos: .userInitiated).async { ShellRunner.run("/bin/launchctl", args: ["unload", Paths.daemonPlist]) diff --git a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift --- a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift @@ -46,6 +46,15 @@ menu.addItem(buildTailnet(state: state, target: target)) menu.addItem(buildMail(status: mailStatus, target: target)) menu.addItem(buildImsg(status: imsgStatus, configured: imsgConfigured, target: target)) + + // Request for Audio — sing a /pop melody, one note at a time. The + // wizard plays the pitch + shows the word per note, records, then + // recompiles the track and plays it back. + let rfa = item("🎙 Request for Audio — sing “hum”", + selector: #selector(AppDelegate.requestForAudio(_:)), target: target) + rfa.representedObject = "hum" + rfa.toolTip = "Open the RFA wizard in iTerm2 — sing the 'hum' melody note by note, then hear it recompiled." + menu.addItem(rfa) menu.addItem(.separator()) let stayAwake = item("Stay awake (lid closed)", selector: #selector(AppDelegate.toggleStayAwake), target: target) diff --git a/slab/menubar-swift/Sources/SlabMenubar/Paths.swift b/slab/menubar-swift/Sources/SlabMenubar/Paths.swift --- a/slab/menubar-swift/Sources/SlabMenubar/Paths.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/Paths.swift @@ -11,6 +11,12 @@ static var slabBin: String { ProcessInfo.processInfo.environment["SLAB_BIN"] ?? "\(home)/.local/bin" } + /// The aesthetic-computer checkout — used to launch repo tooling + /// (e.g. the RFA voice-recording wizard) from the menubar. + static var acRepo: String { + ProcessInfo.processInfo.environment["AC_REPO"] ?? "\(home)/aesthetic-computer" + } + static var slabWallpaper: String { "\(slabBin)/slab-wallpaper" } static var wallpaperStatusDir: String { "\(slabHome)/wallpaper/status" } /// Cached near-black + status-glow PNGs set as the macOS desktop -- tangled.sh