From e423ffa8074a0888c3ed6072dab9f05d83c577e0 Mon Sep 17 00:00:00 2001 From: sameeeeeeep <191771413+sameeeeeeep@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:00:44 +0530 Subject: [PATCH] Switchboard 0.4.2: voice server memory and idle CPU fixes - god-tts: cap MLX's buffer cache (128 MB, GOD_TTS_CACHE_MB) and clear it after every GPU job. The default cache grew to the whole GPU working set: an idle voice server held ~5.3 GB on an 8 GB M1; now ~0.6 GB. - The app refreshes an installed voice engine's server script from the bundle on launch (the engine is opt-in and only copied it once at install). - Menu-bar app idle CPU ~22% -> ~6%: stop animations nobody can see. The hidden panel's hero DotMatrix, 1 Hz shortcut tick and onboarding breathe now pause when the panel is off screen; the orb and loose creature only animate when visible; the team pulse only runs while a team is live; the god-status drop drops its hosting view when hidden; the 1.6 s status poll writes state and redraws the menu-bar glyph only on change. Co-Authored-By: Claude Opus 5.5 --- examples/god/tts/god-tts-server.py | 16 +++++- packages/menubar/Info.plist | 4 +- packages/menubar/LooseNotch.swift | 3 +- packages/menubar/RelayMenuBar.swift | 83 ++++++++++++++++++++++++----- 4 files changed, 89 insertions(+), 17 deletions(-) diff --git a/examples/god/tts/god-tts-server.py b/examples/god/tts/god-tts-server.py index 6d095a4..c9a80a1 100644 --- a/examples/god/tts/god-tts-server.py +++ b/examples/god/tts/god-tts-server.py @@ -112,9 +112,23 @@ def _normalize_wav(src: Path, dst: Path, start: float = 0.0, dur: float | None = # standard MLX-under-a-server pattern; without it /speak fails with "no Stream(gpu, 0)".) _gpu = ThreadPoolExecutor(max_workers=1, thread_name_prefix="god-tts-gpu") +# MEMORY: MLX keeps every freed Metal buffer in its allocator cache, and the default cache limit is the +# whole recommended GPU working set (~5.7GB on an 8GB M1). Each synth allocates fresh activations, so the +# cache ratcheted up to ~5GB of DEAD buffers held forever by an idle voice server — the machine swapped +# (measured 2026-10-02: 5.25GB phys_footprint, 5.0GB of it IOAccelerator). The model's live weights are a +# small fraction of that. Cap the cache and hand freed buffers back after every GPU job; the compiled +# kernels (the expensive ~22s cold-compile keep-warm protects) live elsewhere and are unaffected. +CACHE_LIMIT_MB = int(os.environ.get("GOD_TTS_CACHE_MB", "128")) +mx.set_cache_limit(CACHE_LIMIT_MB * 1024 * 1024) + def _on_gpu(fn, *a, **k): - return _gpu.submit(fn, *a, **k).result() + def job(): + try: + return fn(*a, **k) + finally: + mx.clear_cache() # return this job's freed activations to the OS (runs on the MLX thread) + return _gpu.submit(job).result() def _model_get(): diff --git a/packages/menubar/Info.plist b/packages/menubar/Info.plist index c3fde9e..c954f55 100644 --- a/packages/menubar/Info.plist +++ b/packages/menubar/Info.plist @@ -13,7 +13,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.4.1 + 0.4.2 CFBundleURLTypes @@ -26,7 +26,7 @@ CFBundleVersion - 43 + 44 LSMinimumSystemVersion 13.0 LSUIElement diff --git a/packages/menubar/LooseNotch.swift b/packages/menubar/LooseNotch.swift index a02d393..b267b60 100644 --- a/packages/menubar/LooseNotch.swift +++ b/packages/menubar/LooseNotch.swift @@ -150,7 +150,8 @@ struct CreatureView: View { let shape = Capsule() ZStack { shape.fill(Color.page) - NotchField(accent: tint, working: model.working, animated: model.running, eyes: true, + // Docked = panel ordered out but this view still alive; its 24 fps TimelineView kept ticking unseen. + NotchField(accent: tint, working: model.working, animated: model.running && model.creatureLoose, eyes: true, eyeColor: model.cameraOn ? Color.senseCamera : (model.listening ? Color.senseAudio : nil), gaze: { loose.creatureCenter() }) .padding(.horizontal, 6).padding(.vertical, 3) diff --git a/packages/menubar/RelayMenuBar.swift b/packages/menubar/RelayMenuBar.swift index 501b0b9..332546d 100644 --- a/packages/menubar/RelayMenuBar.swift +++ b/packages/menubar/RelayMenuBar.swift @@ -1278,8 +1278,11 @@ struct Panel: View { // USE B: the hero liveness BEACON — a real 7×5 operator lamp field replacing the flat dot. // The phase is carried by the PATTERN (thinking idle · working busy · listening signed-out), // one accent only (lime, or the sanctioned danger when signed-out; faint + still when offline). + // Animate ONLY while the panel is on screen: an ordered-out NSHostingView keeps its + // TimelineView ticking, and this 35-view HStack/VStack grid re-laid-out the WHOLE hidden + // panel 24×/s (~22% idle CPU, measured 2026-10-02). Same fix as PanelDotField's `paused`. DotMatrix(pattern: heroPattern, accent: heroBeaconAccent, cols: 7, rows: 5, dot: 2.6, gap: 2.4, - animated: model.running) + animated: model.running && model.panelVisible) .frame(width: 34, alignment: .leading) Text(heroTitle).font(.display).foregroundColor(heroColor).lineLimit(1) } @@ -1812,8 +1815,13 @@ struct Panel: View { Text(sub).font(.hanken(10.5)).foregroundColor(.inkFaint) } Spacer(minLength: 6) - // Refresh once a second so "detected" decays back to "used Ns ago" on its own. - TimelineView(.periodic(from: Date(), by: 1)) { ctx in testPill(pulse: pulse, now: ctx.date) } + // Refresh once a second so "detected" decays back to "used Ns ago" on its own — only while the + // panel is on screen (Settings stays open across hides; a hidden 1 Hz tick re-laid-out the panel). + if model.panelVisible { + TimelineView(.periodic(from: Date(), by: 1)) { ctx in testPill(pulse: pulse, now: ctx.date) } + } else { + testPill(pulse: pulse, now: Date()) + } trailing() }.padding(.vertical, 7) } @@ -2064,7 +2072,9 @@ struct Panel: View { } else { HStack(spacing: 5) { Circle().fill(Color.lime).frame(width: 5, height: 5) - .opacity(breathe ? 1 : 0.3).animation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true), value: breathe) + // Breathe only while the panel is on screen (a repeatForever keeps the hidden panel's + // display cycle running); `breathe` follows panelVisible, nil animation = stop instantly. + .opacity(breathe ? 1 : 0.3).animation(model.panelVisible ? .easeInOut(duration: 0.8).repeatForever(autoreverses: true) : nil, value: breathe) Text("try it").font(.hanken(10, .semibold)).foregroundColor(.lime) }.padding(.horizontal, 9).padding(.vertical, 5).background(Capsule().stroke(Color.lime.opacity(0.4), lineWidth: 1)) } @@ -2283,7 +2293,8 @@ struct Panel: View { ) .clipShape(NotchDropShape()) // no stroke — the black shape blends into the notch, no grey line .ignoresSafeArea() - .onAppear { breathe = true } + .onAppear { breathe = model.panelVisible } + .onChange(of: model.panelVisible) { v in breathe = v } .onChange(of: showSettings) { open in if open { onboard.note(.settings) } } // tour step 2 } } @@ -2482,13 +2493,18 @@ struct OrbView: View { var onOpen: () -> Void @State private var hovering = false @State private var teamPulse = false + private func setTeamPulse(_ on: Bool) { + if on { withAnimation(.easeInOut(duration: 2.6).repeatForever(autoreverses: true)) { teamPulse = true } } + else { var t = Transaction(); t.animation = nil; withTransaction(t) { teamPulse = false } } + } var body: some View { // Health tint: lime = running + signed-in · red = running, signed-out · faint = daemon down. let tint = model.running ? (model.signedIn ? Color.lime : Color.danger) : Color.inkFaint let shape = NotchDropShape(ear: 9, botR: 10) ZStack { shape.fill(Color.page) // the black notch body — a notch on any Mac - NotchField(accent: tint, working: model.working, animated: model.running, eyes: (model.journalOn || model.cameraOn || model.listening) && !model.creatureLoose, + // While the creature is loose the orb is alpha-0 but still ordered front — don't animate an invisible field. + NotchField(accent: tint, working: model.working, animated: model.running && !model.creatureLoose, eyes: (model.journalOn || model.cameraOn || model.listening) && !model.creatureLoose, eyeColor: model.cameraOn ? Color.senseCamera : (model.listening ? Color.senseAudio : nil), gaze: { LooseNotch.shared.notchCenter() }) .padding(.horizontal, 6).padding(.top, 1).padding(.bottom, 4) @@ -2527,7 +2543,10 @@ struct OrbView: View { .onChanged { v in LooseNotch.shared.pullFromNotch(translation: v.translation, ended: false) } .onEnded { v in LooseNotch.shared.pullFromNotch(translation: v.translation, ended: true) }) .onTapGesture { onOpen() } // a deliberate CLICK opens the panel - .onAppear { withAnimation(.easeInOut(duration: 2.6).repeatForever(autoreverses: true)) { teamPulse = true } } + // The team heartbeat runs ONLY while a team is live — a repeatForever started unconditionally at + // launch kept the always-on orb's display cycle busy for every user, team or not. + .onAppear { setTeamPulse(model.teamActive) } + .onChange(of: model.teamActive) { on in setTeamPulse(on) } .help("Click to open Switchboard") } } @@ -5201,6 +5220,7 @@ struct ActionConsentDrop: View { IgnitionController.shared.install() // arms ~/.relay/ignite: the full-screen dot-matrix ignition (onboarding Frame 0) → chains into the tour installUpdateCheck() // daily GitHub release check → ONE notch card when a newer build ships installOpenWrappTrigger() // ~/.relay/open-wrapp.json → open that wrapp in the native bridged window (CLI threads can launch surfaces) + refreshInstalledVoiceServer() // an app update carries a newer god-tts-server.py → swap it into an installed voice engine // Feedback capture: a fail (or fn↓) during a guide raises the notch note field + arms the fn-drag grab. CursorGuide.shared.onFeedbackBegin = { [weak self] _ in Task { @MainActor in self?.showFeedbackNote() } } CursorGuide.shared.onFeedbackEnd = { [weak self] in Task { @MainActor in self?.hideFeedbackNote() } } @@ -8990,6 +9010,10 @@ struct ActionConsentDrop: View { // ambient notch is already there the instant the phase drop clears (no flash of nothing). orb?.orderFrontRegardless() godStatusPanel?.orderOut(nil) + // Drop the content too: showGodStatus() builds a fresh hosting view on every show, and an ordered-out + // NSHostingView keeps its NotchFieldLED / DotMatrix TimelineView running (30 fps forever after the + // first God turn or dictation). + godStatusPanel?.contentView = nil } // ── Ambient mode ───────────────────────────────────────────────────────────────────────────── @@ -9078,6 +9102,7 @@ struct ActionConsentDrop: View { ambientSuppressUntil = Date().addingTimeInterval(120) // a manual dismiss hushes ambient for 2 min } + private var glyphKey = "" private func poll() { checkReachable { ok in self.checkWorking { busy in @@ -9086,15 +9111,24 @@ struct ActionConsentDrop: View { // Rung 4: only meaningful once the daemon is up. When up-but-signed-out, the glyph // goes RED and the tooltip names the one fix — the cliff caught before the first call. let signedIn = ok ? readSignedIn() : true - self.model.running = ok - self.model.working = ok && busy && signedIn - self.model.signedIn = signedIn - self.model.updateAvailable = updateReady + // Write @Published state ONLY on change: an unconditional assignment fires objectWillChange + // every 1.6 s, re-rendering every view that observes Model (the orb, the hidden panel…). + let working = ok && busy && signedIn + if self.model.running != ok { self.model.running = ok } + if self.model.working != working { self.model.working = working } + if self.model.signedIn != signedIn { self.model.signedIn = signedIn } + if self.model.updateAvailable != updateReady { self.model.updateAvailable = updateReady } self.phase += 1 - self.statusItem.button?.image = glyphImage(running: ok, working: self.model.working, signedIn: signedIn, phase: self.phase) - self.statusItem.button?.toolTip = !ok ? "Switchboard — sidekick offline" + // The glyph only changes with state (+ the blink phase while working) — skip identical redraws. + let glyphKey = "\(ok)|\(working)|\(signedIn)|\(working ? self.phase % 2 : 0)" + if glyphKey != self.glyphKey { + self.glyphKey = glyphKey + self.statusItem.button?.image = glyphImage(running: ok, working: working, signedIn: signedIn, phase: self.phase) + } + let tip = !ok ? "Switchboard — sidekick offline" : !signedIn ? "Switchboard — \(SIGN_IN_HINT)" : (ok && busy) ? "Switchboard — your model is working…" : "Switchboard — connected" + if self.statusItem.button?.toolTip != tip { self.statusItem.button?.toolTip = tip } if self.panel.isVisible { self.model.refreshFiles(); self.ollama.refresh() } } } @@ -9241,6 +9275,29 @@ struct ActionConsentDrop: View { DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in self?.model.toast = nil } } + /// The voice engine is opt-in: install-voice-engine.sh copies the bundled god-tts-server.py into + /// ~/.relay/tts ONCE, so a fix to the server (e.g. 0.4.2's MLX cache cap: 5.3 GB → ~0.6 GB resident) + /// would never reach a machine that already has the engine. On launch, if the engine is installed and + /// its server differs from this build's copy, replace it and restart the LaunchAgent. Never installs + /// the engine where it isn't (no venv/model download) — only refreshes the one script. + private func refreshInstalledVoiceServer() { + DispatchQueue.global(qos: .utility).async { + let fm = FileManager.default + let bundled = ((Bundle.main.resourcePath ?? "") as NSString).appendingPathComponent("god/tts/god-tts-server.py") + let installed = (RELAY_DIR as NSString).appendingPathComponent("tts/god-tts-server.py") + guard fm.fileExists(atPath: installed), let new = fm.contents(atPath: bundled), + let old = fm.contents(atPath: installed), new != old else { return } + do { + try new.write(to: URL(fileURLWithPath: installed), options: .atomic) + } catch { godLog("voice server refresh failed: \(error.localizedDescription)"); return } + godLog("voice server updated from the app bundle — restarting com.relay.godtts") + let p = Process() + p.executableURL = URL(fileURLWithPath: "/bin/launchctl") + p.arguments = ["kickstart", "-k", "gui/\(getuid())/com.relay.godtts"] + try? p.run(); p.waitUntilExit() + } + } + private func launchctl(_ args: [String]) { let p = Process() p.executableURL = URL(fileURLWithPath: "/bin/launchctl")