diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index 7847320ea5..61c615e6db 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -20,6 +20,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private let speechSynth = AVSpeechSynthesizer() /// Peer-to-peer link to other Menu Bands (Bluetooth + peer Wi-Fi, no LAN). private let fleet = MenuBandFleet() + /// Wall-clock instant the current fleet/conductor performance ends. While + /// `now < fleetDriveUntil` the chip paints its robot badge. Refreshed by + /// every synced `play`/`say` cue; a one-shot timer clears the badge when + /// the window elapses. + private var fleetDriveUntil: TimeInterval = 0 + private var fleetClearTimer: Timer? private let hoverResponder = HoverResponder() /// Bridges typing in the macOS Stickies app to Menu Band note /// playback when the focused sticky matches the trigger color. @@ -3055,6 +3061,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate { playFromInfo(note.userInfo as? [String: String] ?? [:]) } + /// Light the chip's robot badge through `end` (Unix seconds). Called by + /// synced `play`/`say` cues so the menubar shows "a machine is driving me" + /// for the length of a fleet performance. Extends the window if a later + /// cue reaches further; a one-shot timer drops the badge when it lapses. + private func markFleetActive(until end: TimeInterval) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.fleetDriveUntil = max(self.fleetDriveUntil, end) + KeyboardIconRenderer.fleetDriving = true + self.updateIcon() + self.fleetClearTimer?.invalidate() + let delay = max(0.2, self.fleetDriveUntil - Date().timeIntervalSince1970) + self.fleetClearTimer = Timer.scheduledTimer(withTimeInterval: delay, + repeats: false) { [weak self] _ in + guard let self = self else { return } + KeyboardIconRenderer.fleetDriving = false + self.updateIcon() + } + } + } + /// Parse a play spec (from a distributed notification or a fleet message) /// and schedule it. Keys are documented on handlePlayNotification. func playFromInfo(_ info: [String: String]) { @@ -3085,6 +3112,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { velocity)) } + // Fleet tell: a synced cue (carries startEpoch) means a conductor is + // driving us — light the robot badge through the end of the longest + // track so it stays lit for the whole part, then auto-clears. + if let epoch = Double(info["startEpoch"] ?? "") { + let maxBeats = tracks.map { track in + track.spec.split(separator: ",").reduce(0.0) { acc, tok in + let p = tok.split(separator: ":") + return acc + (p.count == 2 ? (Double(p[1]) ?? 0) : 0) + } + }.max() ?? 0 + markFleetActive(until: epoch + maxBeats * beat + 0.5) + } + DispatchQueue.main.async { [weak self] in guard let self = self else { return } self.menuBand.setMelodicProgram(program) @@ -3221,6 +3261,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { var leadIn = 0.0 if let epoch = Double(info["startEpoch"] ?? "") { leadIn = max(0, epoch - Date().timeIntervalSince1970) + // Synced speech = a fleet performance (greeting / sign-off). + // Keep the robot badge lit through the utterance (~13 chars/sec). + markFleetActive(until: epoch + Double(text.count) / 13.0 + 0.7) } DispatchQueue.main.asyncAfter(deadline: .now() + leadIn) { [weak self] in let u = AVSpeechUtterance(string: text) diff --git a/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift b/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift index bf7832ac78..de447ec184 100644 --- a/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift +++ b/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift @@ -372,6 +372,13 @@ enum KeyboardIconRenderer { /// reads as the metronome's "carrier" running, with the /// per-tick yellow flash riding on top. static var metronomeOn: Bool = false + /// True while a fleet/conductor performance is driving this Menu + /// Band — i.e. between the first synced cue and the end of the last + /// one. AppDelegate raises it when a `play`/`say` arrives carrying a + /// `startEpoch` and drops it when the performance's window elapses. + /// The chip paints a little robot head in its top-left corner while + /// set, signalling "a machine (not the keyboard) is playing me." + static var fleetDriving: Bool = false /// BPM + swing-start timestamp pushed by the popover's /// metronome on every restart. The chip's needle uses these /// to compute its swing phase from CACurrentMediaTime so the @@ -1685,6 +1692,36 @@ enum KeyboardIconRenderer { } } + /// Little round-headed robot with two dot eyes, painted in the + /// chip's top-left corner while a fleet/conductor performance is + /// driving the app (`fleetDriving`). Same quiet visual weight as the + /// MIDI square — a "a machine, not the keyboard, is playing me" tell. + /// The head is a filled rounded square (reads round at this size) and + /// the eyes are cleared to transparent so they glow as eye-holes + /// against either a light or dark menu bar. + private static func drawChipFleetRobot(in corner: NSRect, + color: NSColor, + baseAlpha: CGFloat) { + let side = min(corner.width, corner.height) + let head = NSRect(x: corner.minX, y: corner.maxY - side, + width: side, height: side).insetBy(dx: 0.3, dy: 0.3) + let radius = head.width * 0.34 + color.withAlphaComponent(min(1, baseAlpha + 0.15)).setFill() + NSBezierPath(roundedRect: head, xRadius: radius, yRadius: radius).fill() + guard let ctx = NSGraphicsContext.current else { return } + ctx.saveGraphicsState() + ctx.compositingOperation = .destinationOut + NSColor.black.set() + let eyeR = head.width * 0.24 + let eyeY = head.midY - eyeR / 2 + let dx = head.width * 0.22 + for cx in [head.midX - dx, head.midX + dx] { + NSBezierPath(ovalIn: NSRect(x: cx - eyeR / 2, y: eyeY, + width: eyeR, height: eyeR)).fill() + } + ctx.restoreGraphicsState() + } + /// Tiny rotating needle inside the chip's visualizer slot — /// pivots from the bottom-center, swings ±25° at ~1 Hz, just /// like the popover's metronome trapezoid. Reads as "metronome @@ -2542,6 +2579,15 @@ enum KeyboardIconRenderer { color: color, baseAlpha: alpha) } } + // Fleet tell — a little robot head in the top-left corner while + // a conductor/fleet performance is driving this Menu Band. + if fleetDriving { + let robotSide: CGFloat = 6.5 + let robotCorner = NSRect(x: iconBox.minX - 1.0, + y: iconBox.maxY - robotSide + 1.5, + width: robotSide, height: robotSide) + drawChipFleetRobot(in: robotCorner, color: color, baseAlpha: alpha) + } // Linger / bell-ring flourish — a fermata mark (the music // notation for "let ring / hold this note") drawn above the // music-note glyph. Caps lock latches the mode and always