From c7c27266df844c793bf6aeed44643ba49baf1f0a Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 16 Aug 2026 12:46:30 -0400 Subject: [PATCH] =?UTF-8?q?The=20room's=20last=20inch=20of=20silence:=20th?= =?UTF-8?q?e=20aesthetic=20source=20taps=20Menu=20Band's=20OWN=20process,?= =?UTF-8?q?=20and=20mutedWhenTapped=20is=20process-wide=20=E2=80=94=20so?= =?UTF-8?q?=20the=20in-process=20Neo=20receiver=20was=20rendering=20the=20?= =?UTF-8?q?left=20channel=20straight=20into=20the=20mute=20while=20every?= =?UTF-8?q?=20meter=20read=20healthy=20(tap=20peak=20fine,=20both=20receiv?= =?UTF-8?q?ers=20locked,=20'signal=20present'=20at=20both=20ends,=20zero?= =?UTF-8?q?=20sound=20in=20the=20air).=20Neo's=20leg=20now=20renders=20fro?= =?UTF-8?q?m=20OUTSIDE=20the=20muted=20process:=20the=20same=20ac-audio-ro?= =?UTF-8?q?om=20CLI=20blueberry=20runs,=20minus=20the=20ssh,=20with=20the?= =?UTF-8?q?=20same=20heal-on-death=20budget,=20cleaned=20up=20in=20stop(),?= =?UTF-8?q?=20and=20restarted=20through=20the=20healing=20path=20on=20outp?= =?UTF-8?q?ut-device=20changes;=20the=20spotify=20source=20keeps=20its=20i?= =?UTF-8?q?n-process=20receiver=20since=20juked=20is=20the=20muted=20proce?= =?UTF-8?q?ss=20there,=20not=20us?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/JukeWizard/JukeRoomAudio.swift | 69 +++++++++++++++++-- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/juke-wizard/Sources/JukeWizard/JukeRoomAudio.swift b/juke-wizard/Sources/JukeWizard/JukeRoomAudio.swift index 1eb9055d0..6d7a47127 100644 --- a/juke-wizard/Sources/JukeWizard/JukeRoomAudio.swift +++ b/juke-wizard/Sources/JukeWizard/JukeRoomAudio.swift @@ -47,6 +47,9 @@ final class JukeRoomAudio { private var sender: ACAudioRoomSender? private var localReceiver: ACAudioRoomReceiver? + /// Out-of-process local leg, used when the tap mutes our own process + /// (aesthetic source) — see startDistributed. + private var localHelper: Process? private var spotifyTap: AnyObject? private var remoteReceiver: Process? private var localOutputGeneration = 0 @@ -121,14 +124,55 @@ final class JukeRoomAudio { try sender.start() var local: ACAudioRoomReceiver? + var helperProcess: Process? var remoteProcess: Process? 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("Menu Band Juke room Neo: \($0)") } - try receiver.start() - local = receiver + if source == .aesthetic { + // The aesthetic tap mutes THIS process — and + // .mutedWhenTapped is process-wide, so an in-process + // receiver renders into the mute and Neo goes silent + // while every log line reads healthy. Neo's leg must + // render from OUTSIDE the muted process: the same CLI + // blueberry runs, minus the ssh. + let helper = Process() + helper.executableURL = URL(fileURLWithPath: "/bin/bash") + let gain = String(format: "%.3f", localMix.gain) + helper.arguments = ["-lc", + "receiver=\"$HOME/.local/bin/ac-audio-room\"; " + + "/usr/bin/pkill -f \"^[^ ]*ac-audio-room receive --host 127.0.0.1\" 2>/dev/null || true; " + + "exec \"$receiver\" receive --host 127.0.0.1 --channel \(localMix.channel.name) --gain \(gain) --name Neo >> /tmp/ac-audio-room.log 2>&1"] + helper.standardOutput = FileHandle.nullDevice + helper.standardError = FileHandle.nullDevice + let generation = applyGeneration + helper.terminationHandler = { [weak self] process in + DispatchQueue.main.async { + guard let self, case .live = self.state, + generation == self.applyGeneration else { return } + if self.remoteRetries < 3 { + self.remoteRetries += 1 + NSLog("Menu Band Juke room: Neo helper exited (status %d) — re-engaging %d/3", + process.terminationStatus, self.remoteRetries) + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + guard let self, case .live = self.state, + generation == self.applyGeneration else { return } + self.engage(self.layout, pan: self.pan, remote: remote) + } + } else { + self.stop(notify: false) + self.state = .failed("Neo helper exited (status \(process.terminationStatus))") + } + } + } + try helper.run() + helperProcess = helper + } else { + let receiver = ACAudioRoomReceiver(configuration: .init( + host: "127.0.0.1", name: "Neo", channel: localMix.channel, gain: localMix.gain)) + receiver.onLog = { NSLog("Menu Band Juke room Neo: \($0)") } + try receiver.start() + local = receiver + } } if let remoteMix = mix.remote { @@ -184,11 +228,16 @@ final class JukeRoomAudio { self.sender = sender self.localReceiver = local + self.localHelper = helperProcess self.remoteReceiver = remoteProcess self.spotifyTap = tap state = .live(snapshot(for: layout)) } catch { if remoteProcess?.isRunning == true { remoteProcess?.terminate() } + if let helperProcess { + helperProcess.terminationHandler = nil + if helperProcess.isRunning { helperProcess.terminate() } + } local?.stop(); sender.stop() throw error } @@ -199,6 +248,13 @@ final class JukeRoomAudio { /// Reopen only Neo's renderer on the newly selected Core Audio output. /// The sender, clock, and Blueberry receiver continue uninterrupted. func refreshLocalOutputDevice() { + if let helper = localHelper { + // Helper-process leg: its engine won't follow a device change on + // its own — terminate it and let the healing path re-engage the + // room on the new output. + helper.terminate() + return + } guard isDistributing, let mix = channels(for: layout).local else { return } localOutputGeneration += 1 let generation = localOutputGeneration @@ -246,6 +302,9 @@ final class JukeRoomAudio { if #available(macOS 14.2, *), let tap = spotifyTap as? ACProcessAudioTap { tap.stop() } spotifyTap = nil localReceiver?.stop(); localReceiver = nil + localHelper?.terminationHandler = nil + if localHelper?.isRunning == true { localHelper?.terminate() } + localHelper = nil sender?.stop(); sender = nil remoteReceiver?.terminationHandler = nil if remoteReceiver?.isRunning == true { remoteReceiver?.terminate() } -- 2.51.2