diff --git a/ac-electron/reinstall.sh b/ac-electron/reinstall.sh index 5a8425cfa5..26a03a89cb 100755 --- a/ac-electron/reinstall.sh +++ b/ac-electron/reinstall.sh @@ -5,6 +5,21 @@ set -e cd "$(dirname "$0")" +# Preflight memory guard (8 GB fleet discipline). `electron-builder --mac +# --universal` is a heavy build; on an 8 GB fleet Mac already under memory +# pressure it can tip the box into swap thrashing (the same failure that once +# locked sshd out of neo). Refuse before building. FLEET_FORCE=1 overrides; a +# missing guard script degrades to a warning, never a hard fail. +FLEET_GUARD="$(cd .. && pwd)/toolchain/macos/fleet/fleet-guard.sh" +if [ -x "$FLEET_GUARD" ]; then + bash "$FLEET_GUARD" guard-build self || { + echo "❌ build refused — this machine is under memory pressure (FLEET_FORCE=1 to override)." + exit 1 + } +else + echo "⚠️ fleet-guard.sh missing ($FLEET_GUARD) — skipping preflight memory guard." +fi + echo "🔄 Closing running instances..." pkill -f "Aesthetic Computer" 2>/dev/null || true sleep 0.5 diff --git a/slab/menubar-swift/Sources/SlabMenubar/PerformanceAlarm.swift b/slab/menubar-swift/Sources/SlabMenubar/PerformanceAlarm.swift index cb01a9e758..a4edc9edbc 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/PerformanceAlarm.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PerformanceAlarm.swift @@ -45,8 +45,8 @@ enum AlarmFont { final class AlarmOverlayView: NSView { var onDismiss: (() -> Void)? private let ring = CAShapeLayer() - private let headline = AlarmOverlayView.makeLabel(size: 42) - private let readout = AlarmOverlayView.makeLabel(size: 20) + private let headline = AlarmOverlayView.makeLabel(size: 68) + private let readout = AlarmOverlayView.makeLabel(size: 30) private let red = NSColor(calibratedRed: 1, green: 0.05, blue: 0.05, alpha: 1) override init(frame: NSRect) { @@ -76,10 +76,12 @@ final class AlarmOverlayView: NSView { l.drawsBackground = false l.isEditable = false l.lineBreakMode = .byClipping + // A crisp, tight drop shadow (low blur, clear offset) so the white text + // reads sharply against the desktop showing through the clear center. let shadow = NSShadow() - shadow.shadowColor = NSColor(calibratedRed: 0.6, green: 0, blue: 0, alpha: 0.95) - shadow.shadowBlurRadius = 12 - shadow.shadowOffset = NSSize(width: 0, height: -1) + shadow.shadowColor = NSColor.black.withAlphaComponent(0.95) + shadow.shadowBlurRadius = 2 + shadow.shadowOffset = NSSize(width: 0, height: -4) l.shadow = shadow return l } @@ -88,25 +90,31 @@ final class AlarmOverlayView: NSView { private func positionLabels(dx: CGFloat, dy: CGFloat) { let w = bounds.width, cy = bounds.height / 2 - headline.frame = NSRect(x: dx, y: cy - 2 + dy, width: w, height: 56) - readout.frame = NSRect(x: dx, y: cy - 48 + dy, width: w, height: 30) + headline.frame = NSRect(x: dx, y: cy - 2 + dy, width: w, height: 84) + readout.frame = NSRect(x: dx, y: cy - 54 + dy, width: w, height: 40) } - /// One blink frame. `intensity` is the eased 0…1 pulse; `severity` widens the - /// ring, raises the glow, and drives the text shake as suffocation deepens. - func render(intensity: CGFloat, severity: CGFloat) { - let rw = 22 + 30 * severity // fatter ring deeper in + /// One blink frame. `intensity` is the eased 0…1 pulse; `t` is continuous + /// seconds (for a smooth wobble, not per-frame jitter); `severity` widens + /// the ring, raises the glow, and grows the shake as suffocation deepens. + func render(intensity: CGFloat, severity: CGFloat, t: CGFloat) { + // A THIN bright line hugging the very screen edge (not a thick red band) + // — the alarm presence comes from the glowing bloom around it, not width. + let rw = 3 + 4 * severity // slim edge line let rect = bounds.insetBy(dx: rw / 2, dy: rw / 2) ring.path = CGPath(rect: rect, transform: nil) ring.lineWidth = rw - let floor: CGFloat = 0.10 + 0.25 * severity // ring never fully dark - let peak: CGFloat = 0.85 + 0.15 * severity + let floor: CGFloat = 0.12 + 0.28 * severity // line never fully dark + let peak: CGFloat = 0.9 + 0.1 * severity ring.opacity = Float(floor + (peak - floor) * intensity) - ring.shadowRadius = 24 + 48 * intensity * (0.5 + severity) - // Shake the text — a rumble that grows with severity and flashes with - // the blink, so the warning reads as panic, not a static label. - let amp = (2 + 6 * severity) * (0.4 + 0.6 * intensity) - positionLabels(dx: .random(in: -amp...amp), dy: .random(in: -amp...amp)) + ring.shadowRadius = 18 + 34 * intensity * (0.5 + severity) + // Smooth Lissajous wobble — two close, incommensurate frequencies give + // an organic drift rather than random buzz; amplitude breathes with the + // blink and grows with severity, so it reads as a panicked sway. + let amp = (3 + 8 * severity) * (0.55 + 0.45 * intensity) + let dx = sin(t * .pi * 2 * 1.6) * amp + let dy = sin(t * .pi * 2 * 2.15 + 0.9) * amp + positionLabels(dx: dx, dy: dy) } func setReadout(_ text: String) { readout.stringValue = text } @@ -118,71 +126,91 @@ final class AlarmOverlayView: NSView { } } -/// A dissonant, looping tonal chord synthesized with AVAudioEngine — a cluster -/// of detuned partials a tritone + minor-second apart, under a gasping tremolo, -/// so it reads as suffocation rather than a tidy beep. Volume tracks severity. -final class AlarmChord { +/// A short, bright DING that repeats while the alarm is lit — a struck-bell +/// tone with a fast decay (~0.4 s), not a sustained drone. It carries a tritone +/// partial for an alarm edge, and the repeat gets faster + louder the deeper +/// into the red, so it reads as an escalating klaxon of dings. +final class AlarmDing { private let engine = AVAudioEngine() private let player = AVAudioPlayerNode() private var buffer: AVAudioPCMBuffer? private var running = false + private var volume: Float = 1 + private var severity: CGFloat = 0 init() { engine.attach(player) let fmt = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 2)! engine.connect(player, to: engine.mainMixerNode, format: fmt) - buffer = Self.renderChord(format: fmt) + buffer = Self.renderDing(format: fmt) } - /// Build a ~2 s loopable stereo buffer: summed detuned sines forming a - /// dissonant cluster, shaped by a ~7 Hz tremolo (the "gasp") and a gentle - /// stereo detune so it swirls. Peak-scaled so the sum never clips. - private static func renderChord(format: AVAudioFormat) -> AVAudioPCMBuffer? { + /// One short struck-bell ding: a high fundamental + tritone + octave under a + /// fast exponential decay. Short attack, quick tail — a ding, not a chord. + private static func renderDing(format: AVAudioFormat) -> AVAudioPCMBuffer? { let sr = format.sampleRate - let frames = AVAudioFrameCount(sr * 2.0) + let dur = 0.45 + let frames = AVAudioFrameCount(sr * dur) guard let buf = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frames) else { return nil } buf.frameLength = frames - // A suffocating cluster: F3, F#3 (minor 2nd), B3 (tritone), plus a high - // detuned octave — none of these want to resolve. - let partials: [Double] = [174.6, 185.0, 246.9, 349.2, 466.16] + let partials: [(f: Double, a: Double)] = [(880, 1.0), (1244.5, 0.55), (1760, 0.4)] // A5, tritone, A6 let n = Int(frames) let ch = Int(format.channelCount) for c in 0.. TimeInterval { 1.4 - 0.9 * Double(min(1, max(0, severity))) } + + private func scheduleNext() { + guard running else { return } + ding() + DispatchQueue.main.asyncAfter(deadline: .now() + interval()) { [weak self] in self?.scheduleNext() } + } + + private func ding() { + guard running, volume > 0, let buf = buffer else { return } + player.volume = volume + // .interrupts so a new ding cleanly replaces a still-ringing one rather + // than queueing up a backlog. + player.scheduleBuffer(buf, at: nil, options: [.interrupts], completionHandler: nil) + if !player.isPlaying { player.play() } } } @@ -197,6 +225,8 @@ final class PerformanceAlarmController { private var windows: [NSWindow] = [] private var blinkTimer: Timer? private var phase: CGFloat = 0 + private var elapsed: CGFloat = 0 + private let fps: CGFloat = 60 private var armed = false private var severity: CGFloat = 0 private var muted = false @@ -204,7 +234,7 @@ final class PerformanceAlarmController { /// actually gets you a breather instead of the alarm snapping back instantly. private var snoozeUntil: Date? private let snooze: TimeInterval = 12 - private let chord = AlarmChord() + private let ding = AlarmDing() private var escMonitor: Any? /// Called each refresh tick with the freshest whole-machine stats. `soundOff` @@ -230,7 +260,7 @@ final class PerformanceAlarmController { } // Still critical — refresh severity + readout live. severity = CGFloat(max(s.severity, forceTest ? 0.9 : 0)) - chord.setVolume(muted ? 0 : volumeForSeverity()) + ding.update(volume: muted ? 0 : volumeForSeverity(), severity: severity) setReadout(for: s) } @@ -240,7 +270,7 @@ final class PerformanceAlarmController { severity = CGFloat(max(s.severity, 0.05)) buildWindows() setReadout(for: s) - if !muted { chord.start(volume: volumeForSeverity()) } + if !muted { ding.start(volume: volumeForSeverity(), severity: severity) } startBlink() installEscMonitor() } @@ -248,7 +278,7 @@ final class PerformanceAlarmController { private func disarm() { armed = false stopBlink() - chord.stop() + ding.stop() removeEscMonitor() for w in windows { w.orderOut(nil) } windows.removeAll() @@ -289,7 +319,7 @@ final class PerformanceAlarmController { private func startBlink() { stopBlink() - let t = Timer(timeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in self?.tick() } + let t = Timer(timeInterval: 1.0 / Double(fps), repeats: true) { [weak self] _ in self?.tick() } blinkTimer = t RunLoop.main.add(t, forMode: .common) } @@ -297,13 +327,14 @@ final class PerformanceAlarmController { private func stopBlink() { blinkTimer?.invalidate(); blinkTimer = nil } private func tick() { - // Blink speed ramps 2 Hz → ~6.5 Hz with severity; a raised-cosine gives - // a sharp klaxon flash rather than a lazy fade. - let hz = 2.0 + 4.5 * severity - phase += CGFloat(hz) / 30.0 + // 60 fps + a raised-cosine pulse = a smooth klaxon breathe (no stutter). + // Blink speed ramps ~1.6 Hz → ~6 Hz with severity. + let hz = 1.6 + 4.4 * severity + phase += CGFloat(hz) / fps if phase > 1 { phase -= 1 } + elapsed += 1 / fps let intensity = 0.5 - 0.5 * cos(phase * 2 * .pi) - for v in overlays { v.render(intensity: intensity, severity: severity) } + for v in overlays { v.render(intensity: intensity, severity: severity, t: elapsed) } } private func setReadout(for s: SystemStats) {