diff --git a/grants/culturehub-la-2026/macneopolitan/bin/singconduct.mjs b/grants/culturehub-la-2026/macneopolitan/bin/singconduct.mjs index e87b3a27fa..edf4c186ae 100644 --- a/grants/culturehub-la-2026/macneopolitan/bin/singconduct.mjs +++ b/grants/culturehub-la-2026/macneopolitan/bin/singconduct.mjs @@ -39,6 +39,29 @@ const MY = new Set([hostname(), hostname().replace(/\.local$/, ""), "local", "localhost", "self"].map((s) => s.toLowerCase())); const isLocal = (h) => MY.has(String(h).toLowerCase()); +// voxplay: schedule a wav on the AUDIO clock so file-time `preroll` lands +// exactly on the shared epoch — afplay's variable spawn latency made the +// vocal drift against Menu Band's sample-accurate grid. +const VOXPLAY_SRC = `import AVFoundation +let a = CommandLine.arguments +let url = URL(fileURLWithPath: a[1]) +let epoch = Double(a[2])! +let preroll = a.count > 3 ? (Double(a[3]) ?? 0) : 0 +let player = try! AVAudioPlayer(contentsOf: url) +player.prepareToPlay() +let delay = (epoch - preroll) - Date().timeIntervalSince1970 +if delay <= 0 { player.play() } +else { player.play(atTime: player.deviceCurrentTime + delay) } +RunLoop.main.run(until: Date(timeIntervalSinceNow: max(0, delay) + player.duration + 0.5))`; + +function ensureVoxplay() { + if (spawnSync("sh", ["-c", "test -x /tmp/voxplay"]).status === 0) return true; + const r = spawnSync("sh", ["-c", + "cat > /tmp/voxplay.swift && swiftc -O /tmp/voxplay.swift -o /tmp/voxplay"], + { input: VOXPLAY_SRC, encoding: "utf8" }); + return r.status === 0; +} + function ensurePoster(host) { const build = "cat > /tmp/mbpost.swift && swiftc -O /tmp/mbpost.swift -o /tmp/mbpost"; const have = (isLocal(host) @@ -114,9 +137,13 @@ for (const voice of score.voices || []) { continue; } } - const waitMs = Math.max(0, epoch * 1000 - Date.now() - 120); - setTimeout(() => spawn("afplay", [wav], { stdio: "ignore" }), waitMs); - console.log(` ✎ ${voice.name}: voxxed line at the downbeat (${wav.split("/").pop()})`); + if (!ensureVoxplay()) { + console.error(" ✗ could not build /tmp/voxplay"); + continue; + } + // 0.5 = the PREROLL baked into the render by bin/vox.py. + spawn("/tmp/voxplay", [wav, epoch.toFixed(3), "0.5"], { stdio: "ignore" }); + console.log(` ✎ ${voice.name}: voxxed line scheduled on the audio clock (${wav.split("/").pop()})`); continue; } if (voice.lyrics) { diff --git a/grants/culturehub-la-2026/macneopolitan/bin/vox.py b/grants/culturehub-la-2026/macneopolitan/bin/vox.py index 3b846b9c06..11e34b4f0d 100644 --- a/grants/culturehub-la-2026/macneopolitan/bin/vox.py +++ b/grants/culturehub-la-2026/macneopolitan/bin/vox.py @@ -91,8 +91,12 @@ def sing_syllable(text, midi, dur_s): return y, onset_s # ---- assemble on the beat grid: nucleus on the beat, consonant as pickup -- +# PREROLL seconds of silence precede beat 0, so the first consonant pickup +# is never clipped and a scheduling player can align file-time PREROLL to +# the shared downbeat exactly (bin/singconduct.mjs passes it to voxplay). +PREROLL = 0.5 total_beats = sum(d for _, d in notes) -master = np.zeros(int((total_beats * beat + 2.0) * fs)) +master = np.zeros(int((PREROLL + total_beats * beat + 2.0) * fs)) pos_beats = 0.0 s = 0 for tok, dur in notes: @@ -101,7 +105,7 @@ for tok, dur in notes: syl = syllables[s] if s < len(syllables) else "la" s += 1 y, onset_s = sing_syllable(syl, midi, dur * beat) - start = int(max(0.0, pos_beats * beat - onset_s) * fs) + start = int(max(0.0, PREROLL + pos_beats * beat - onset_s) * fs) end = min(len(master), start + len(y)) master[start:end] += y[: end - start] print(f" {syl:>6} → midi {midi:.0f}, {dur:g} beat(s)", file=sys.stderr) diff --git a/grants/culturehub-la-2026/macneopolitan/scores/mary-had-a-little-lamb.mbscore b/grants/culturehub-la-2026/macneopolitan/scores/mary-had-a-little-lamb.mbscore index a1a2f328ad..7f4823aac9 100644 --- a/grants/culturehub-la-2026/macneopolitan/scores/mary-had-a-little-lamb.mbscore +++ b/grants/culturehub-la-2026/macneopolitan/scores/mary-had-a-little-lamb.mbscore @@ -16,10 +16,10 @@ "singBase": 62 }, { - "name": "whistle double", + "name": "whistle double (unison)", "program": 78, "velocity": 64, - "notes": "88:1,86:1,84:1,86:1,88:1,88:1,88:2,86:1,86:1,86:2,88:1,91:1,91:2,88:1,86:1,84:1,86:1,88:1,88:1,88:1,88:1,86:1,86:1,88:1,86:1,84:4" + "notes": "64:1,62:1,60:1,62:1,64:1,64:1,64:2,62:1,62:1,62:2,64:1,67:1,67:2,64:1,62:1,60:1,62:1,64:1,64:1,64:1,64:1,62:1,62:1,64:1,62:1,60:4" } ] } diff --git a/grants/culturehub-la-2026/macneopolitan/scores/mary-had-a-little-lamb.vox0-espeak.wav b/grants/culturehub-la-2026/macneopolitan/scores/mary-had-a-little-lamb.vox0-espeak.wav index aea9a7a9a38b60f43be9455511a111d722375434..72681787f749c28026a83b30c77b13de096ff820 100644 GIT binary patch delta 140 zcmeyCZohSyeNx=y3-Nr@ z8ytA}rpfULO}?;$WBLU}9=_=b!i*f#13oeFOjnr2&NaQjjE86X0%LZb_5*Ji+Yh{9 xYCrIXx&6Q!mi7a0SlbW0VQW9|hQ0m38;Q)fj2xhHvmhiIyL|R delta 112 zcmbQY&Eij|IeU<&o7rzQBpCeSs5u`vND9_61Iy?F*bh RYPj1MIPq*>;KW;Z0|2UyD&_zH diff --git a/grants/culturehub-la-2026/macneopolitan/scores/mary-had-a-little-lamb.vox0-fred.wav b/grants/culturehub-la-2026/macneopolitan/scores/mary-had-a-little-lamb.vox0-fred.wav index 175a5fba846df526d604c4d753861181ce6965b3..919b73306d3f62508c21d03d4ad56f240b3fb4e5 100644 GIT binary patch delta 70 zcmeyU a*jsftT6H*Eb+}q}xLb92w(9U+I069ea1?F; delta 78 zcmbQY&Eij|IeU<&o7