From 1d3995ddc847fccadde1a51266ba332e7e76d8a6 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 23 Sep 2026 12:59:25 -0700 Subject: [PATCH] Add self-paced scale tracks beneath Menu Band keys --- .../Sources/MenuBand/AppDelegate.swift | 94 ++++----- .../MenuBand/KeyboardIconRenderer.swift | 42 +++- .../Sources/MenuBand/ToneTrials.swift | 181 ++++++++++++++++-- .../Tests/MenuBandTests/ShapedownTests.swift | 4 +- .../Tests/MenuBandTests/ToneTrialsTests.swift | 37 ++++ 5 files changed, 282 insertions(+), 76 deletions(-) create mode 100644 slab/menuband/Tests/MenuBandTests/ToneTrialsTests.swift diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index bb21b26b61..e2e574897f 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -501,15 +501,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var polyrhythmEntryLastKeyAt: CFTimeInterval = -.infinity private static let polyrhythmEntryTimeout: CFTimeInterval = 2.0 /// `/` off the TrackDrum surface: ToneTrials, the scale combo-trials - /// director (trial ladder, drop rules, CLEAR banner — ToneTrials.swift). + /// director, with desktop lanes anchored beneath the menu-bar keys. private let toneTrials = ToneTrials() - /// Drives the strip's hit/drop/CLEAR animation between gesture - /// repaints; runs only while the trials are up. + private let toneTracks = ToneTrialsTracks() + /// Animates the desktop lanes only during practice. private var toneTrialsRepaintTimer: Timer? /// Previous lit-note set, so the trials are fed exactly the notes that /// just began (`onLitChanged` itself only reports that the set changed). private var toneTrialsLitNotes: Set = [] - private static let toneTrialsIndexDefaultsKey = "toneTrialsIndex" private var trackpadEnergyTimer: Timer? private var trackpadOverlayLastDraw: Double = 0 /// Keep the last percussion surface readable after the final lift, like @@ -615,7 +614,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Routed by what's on screen: circles when the // TrackDrum surface is up, ToneTrials otherwise. case .trainer: self.handleTrainerSlash() - case .help: Self.openTips() + case .scales: self.startScaleTracks() } } } @@ -1020,6 +1019,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.toneTrials.registerNote(Int(note), at: now) } self.toneTrialsLitNotes = lit + self.updateScaleTracks() } // Subtle flash on every fresh note hit so the icon // pulses with playing activity. Only on count @@ -1684,7 +1684,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Routed by what's on screen: circles when the // TrackDrum surface is up, ToneTrials otherwise. case .trainer: self.handleTrainerSlash() - case .help: Self.openTips() + case .scales: self.startScaleTracks() } } return true @@ -7042,15 +7042,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { forBend: displayBendAmount / Self.bendRange, echo: fxX, keyDown: menuBand.keyboardNotesHeld ) - // The trials strip rides the fx chart image itself, so every path - // that shows or refreshes the chart carries it for free. - guard trackpadPadMode == .fx, - let practice = toneTrials.snapshot(at: CACurrentMediaTime()) - else { return chart } - let dark = NSApp.effectiveAppearance - .bestMatch(from: [.darkAqua, .aqua]) == .darkAqua - return ToneTrialsStrip.composite(chart: chart, snapshot: practice, - dark: dark) + return chart } /// Keep the puck attached to the bend that is actually sounding. Drawing @@ -7158,57 +7150,55 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } - /// `/` toggles ToneTrials, hosted on the pitch-slider page — summoning - /// it from the skin page hands the pad over first (the quiet Tab flip). - /// Ladder position persists across sessions; clearing a trial advances - /// it automatically, `-`/`=` skip by hand. private func toneTrialsSlashKey() { - guard pitchBendCursorPushed || keyboardPerformanceFocusActive else { - return - } - #if MAC_APP_STORE - guard trackpadPadMode == .fx else { return } - #else + if toneTrials.isActive { stopToneTrials() } + else { startScaleTracks() } + } + + /// `?` always begins at the first scale, including while practicing. + private func startScaleTracks() { + #if !MAC_APP_STORE if trackpadPadMode != .fx { restoreFocusedPitchSlider() } #endif - if toneTrials.isActive { - stopToneTrials() - updatePitchBendOverlayImage() - // The strip was the reason the overlay stayed up; without a - // live gesture, let the bare chart take its leave. - pitchBendOverlay?.fadeOut(after: 0.6, duration: 0.4) - debugLog("ToneTrials = off") - } else { - toneTrials.onIndexChange = { index in - UserDefaults.standard.set( - index, forKey: Self.toneTrialsIndexDefaultsKey - ) - } - toneTrials.start(at: UserDefaults.standard.integer( - forKey: Self.toneTrialsIndexDefaultsKey - )) - toneTrialsLitNotes = Set(menuBand.litNotes) - startToneTrialsRepaint() - showPitchBendOverlay() - debugLog("ToneTrials = trial \(toneTrials.index + 1) " - + toneTrials.trial.title) + stopPolyrhythmTrainer() + toneTrials.start(at: 0) + toneTrialsLitNotes = Set(menuBand.litNotes) + startToneTrialsRepaint() + updateScaleTracks() + } + + private func updateScaleTracks() { + var target: Int? + if toneTrials.isActive, toneTrials.progress < toneTrials.trial.intervals.count, + let range = KeyboardIconRenderer.activeRange { + let midi = 60 + toneTrials.trial.root + toneTrials.trial.intervals[toneTrials.progress] + target = range.contains(midi) ? midi : range.first { $0 % 12 == midi % 12 } } + if KeyboardIconRenderer.practiceTargetMidi != target { + KeyboardIconRenderer.practiceTargetMidi = target + updateIcon() + } + guard let button = statusItem?.button else { return } + toneTracks.update(trials: toneTrials, button: button, + now: CACurrentMediaTime()) } private func stepToneTrial(by delta: Int) { toneTrials.step(by: delta) - updatePitchBendOverlayImage() + updateScaleTracks() debugLog("ToneTrials = trial \(toneTrials.index + 1) " + toneTrials.trial.title) } - /// Everything that tears the slider page down funnels through here so - /// the strip, its repaint clock, and the note diff reset together. + /// End the desktop overlay and note tracking when performance focus ends. private func stopToneTrials() { guard toneTrials.isActive || toneTrialsRepaintTimer != nil else { return } toneTrials.stop() + toneTracks.hide() + KeyboardIconRenderer.practiceTargetMidi = nil + updateIcon() stopToneTrialsRepaint() toneTrialsLitNotes = [] } @@ -7221,7 +7211,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // The director owns the CLEAR-banner clock; ticking it here is // what makes a finished trial load the next one. self.toneTrials.update(at: CACurrentMediaTime()) - self.updatePitchBendOverlayImage() + self.updateScaleTracks() } RunLoop.main.add(timer, forMode: .common) toneTrialsRepaintTimer = timer @@ -7232,7 +7222,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { toneTrialsRepaintTimer = nil } - enum TrackDrumSlashAction: Equatable { case trainer, help } + enum TrackDrumSlashAction: Equatable { case trainer, scales } static func isABCToggleShortcut( keyCode: UInt16, @@ -7252,7 +7242,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { guard keyCode == UInt16(kVK_ANSI_Slash), !flags.contains(.command), !flags.contains(.option), !flags.contains(.control) else { return nil } - return flags.contains(.shift) ? .help : .trainer + return flags.contains(.shift) ? .scales : .trainer } static func trackDrumRateDelta( diff --git a/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift b/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift index 4db79ad57b..360a0c3221 100644 --- a/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift +++ b/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift @@ -47,6 +47,40 @@ enum KeyboardIconRenderer { 11: NSColor(srgbRed: 180/255, green: 80/255, blue: 255/255, alpha: 1), // B ] + /// Practice target is a visual cue, never a sounding or held note. + static var practiceTargetMidi: Int? + + private static func drawPracticeTarget(midi: Int, in rect: NSRect) { + let color = noteColor(forMidi: midi) + let face = NSBezierPath(roundedRect: rect.insetBy(dx: 0.5, dy: 0.5), + xRadius: 2, yRadius: 2) + color.setFill() + face.fill() + NSColor.white.withAlphaComponent(0.9).setStroke() + face.lineWidth = 1 + face.stroke() + guard let label = labelByMidi[midi] else { return } + let rgb = color.usingColorSpace(.sRGB) ?? color + let light = 0.2126 * rgb.redComponent + 0.7152 * rgb.greenComponent + + 0.0722 * rgb.blueComponent + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedSystemFont(ofSize: 10, weight: .bold), + .foregroundColor: light > 0.5 ? NSColor.black : NSColor.white + ] + let size = label.size(withAttributes: attrs) + let y = isWhite(midi) ? rect.minY + 1 : rect.midY - size.height / 2 + label.draw(at: NSPoint(x: rect.midX - size.width / 2, y: y), withAttributes: attrs) + } + + /// Shared melodic palette; accidentals sit between their neighboring naturals. + static func noteColor(forMidi midi: Int) -> NSColor { + let pitch = ((midi % 12) + 12) % 12 + if let color = chromaticColorByPitchClass[pitch] { return color } + let lower = chromaticColorByPitchClass[(pitch + 11) % 12]! + let upper = chromaticColorByPitchClass[(pitch + 1) % 12]! + return lower.blended(withFraction: 0.5, of: upper) ?? lower + } + /// Drum-pad colors (port of PERCUSSION_COLORS in lib/percussion.mjs), /// indexed by pitch class. Painted over the right-hand keys while the /// percussion split is active so the drum zone reads at a glance. @@ -1234,7 +1268,9 @@ enum KeyboardIconRenderer { // key the user is currently playing. Falls back to the // legacy binary `typeMode` rendering when no closure is // supplied (e.g., previews that don't drive animation). - if tileOffset == 0, let letter = labelByMidi[m] { + if tileOffset == 0, practiceTargetMidi == m { + drawPracticeTarget(midi: m, in: rect) + } else if tileOffset == 0, let letter = labelByMidi[m] { let display = Self.uppercaseForMidi(m) ? letter.uppercased() : letter let a: CGFloat if isLit { @@ -1343,7 +1379,9 @@ enum KeyboardIconRenderer { path.lineWidth = 0.6 } path.stroke() - if tileOffset == 0, let letter = labelByMidi[m] { + if tileOffset == 0, practiceTargetMidi == m { + drawPracticeTarget(midi: m, in: rect) + } else if tileOffset == 0, let letter = labelByMidi[m] { let display = Self.uppercaseForMidi(m) ? letter.uppercased() : letter let a: CGFloat if isLit { diff --git a/slab/menuband/Sources/MenuBand/ToneTrials.swift b/slab/menuband/Sources/MenuBand/ToneTrials.swift index 6b29a8df8f..1856653b6a 100644 --- a/slab/menuband/Sources/MenuBand/ToneTrials.swift +++ b/slab/menuband/Sources/MenuBand/ToneTrials.swift @@ -1,16 +1,6 @@ import AppKit -/// Combo trials for scales — the fighting-game practice loop worn by the -/// pitch-slider page. A ladder of numbered trials (each one scale: name, -/// root, ascending intervals); the strip shows the whole sequence as -/// colored pills, a wrong note DROPS the pass back to the start (like a -/// dropped combo), and playing it clean flashes a CLEAR banner, rings the -/// clear chime, and auto-advances to the next trial. `/` toggles the mode, -/// `-`/`=` skip backward/forward through the ladder. -/// -/// A class, deliberately: this is the director that owns the trial list, -/// the pass state, and the banner→advance clock, so the app delegate only -/// feeds it notes and repaints. +/// Self-paced scale practice: only the expected pitch advances the desktop tracks. final class ToneTrials { struct Trial { let title: String @@ -57,6 +47,10 @@ final class ToneTrials { private var lastHitAt: CFTimeInterval = -.infinity private var lastDropAt: CFTimeInterval = -.infinity private var clearedAt: CFTimeInterval? + func trackPosition(at now: CFTimeInterval) -> Double { + let arrival = max(0, min(1, (now - lastHitAt) / 0.22)) + return Double(progress) - (progress > 0 ? pow(1 - arrival, 3) : 0) + } var trial: Trial { Self.trials[index] } @@ -90,11 +84,7 @@ final class ToneTrials { lastDropAt = -.infinity } - /// Judge a sounded note against the next degree, by pitch class so any - /// octave counts. Right note advances; re-striking the degree just hit - /// is free (repeats while phrasing are not drops); a wrong note DROPS - /// the whole pass back to the start — trials rules, not a scold: the - /// restart IS the practice. Notes during the CLEAR banner are ignored. + /// Wrong pitches leave the next note waiting. Practice begins immediately. func registerNote(_ midi: Int, at now: CFTimeInterval) { guard isActive, clearedAt == nil else { return } let intervals = trial.intervals @@ -113,7 +103,6 @@ final class ToneTrials { pitchClass == (trial.root + intervals[progress - 1]) % 12 { return } - progress = 0 lastDropAt = now } @@ -174,6 +163,7 @@ struct ToneTrialsSnapshot { /// for eyeballing states, and the pixel source if a reel ever wants one. /// /// Flags after `--render-tonetrials`: +/// --tracks render desktop tracks and the actual target keyboard /// --trial 3 1-based ladder position (default 1) /// --progress 4 degrees already landed (default 0) /// --clear | --drop flash state to capture @@ -228,9 +218,30 @@ enum ToneTrialsCLI { ) } - let chart = PitchBendCursor.image(forBend: 0, echo: 0, keyDown: false) - let image = ToneTrialsStrip.composite(chart: chart, - snapshot: snapshot, dark: dark) + let image: NSImage + if args.contains("--tracks") { + if trials.progress < intervals.count { + KeyboardIconRenderer.practiceTargetMidi = 60 + trials.trial.root + intervals[trials.progress] + } + let keyboard = KeyboardIconRenderer.image(litNotes: [], enabled: false) + let view = ToneTrialsTracksView(frame: NSRect(x: 0, y: 0, + width: keyboard.size.width, height: 180)) + view.keys = (KeyboardIconRenderer.activeRange ?? 60...83).compactMap { midi in + KeyboardIconRenderer.keyRect(for: UInt8(midi)).map { (midi, $0) } + } + view.trials = trials + view.now = strikeAt + 0.3 + image = NSImage(size: NSSize(width: keyboard.size.width, + height: 180 + keyboard.size.height)) + image.lockFocus() + view.draw(view.bounds) + keyboard.draw(at: NSPoint(x: 0, y: 180), from: .zero, + operation: .sourceOver, fraction: 1) + image.unlockFocus() + } else { + let chart = PitchBendCursor.image(forBend: 0, echo: 0, keyDown: false) + image = ToneTrialsStrip.composite(chart: chart, snapshot: snapshot, dark: dark) + } let scale = max(1, Double(value("--scale") ?? "3") ?? 3) let output = value("--out") ?? "/tmp/menuband-tonetrials.png" let pixelWidth = Int((image.size.width * scale).rounded()) @@ -419,3 +430,133 @@ enum ToneTrialsStrip { } } } + +/// Click-through desktop lanes aligned with the real menu-bar keys. +final class ToneTrialsTracks { + private let view = ToneTrialsTracksView(frame: .zero) + private let panel: NSPanel + + init() { + panel = NSPanel(contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, defer: false) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.ignoresMouseEvents = true + panel.level = .statusBar + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.contentView = view + } + + func hide() { panel.orderOut(nil) } + + func update(trials: ToneTrials, button: NSStatusBarButton, + now: CFTimeInterval) { + guard trials.isActive, let window = button.window, + let range = KeyboardIconRenderer.activeRange else { + hide() + return + } + let buttonFrame = window.convertToScreen(button.convert(button.bounds, to: nil)) + let scale = KeyboardIconRenderer.iconScale + let offset = (button.bounds.width - KeyboardIconRenderer.imageSize.width) / 2 + let keys: [(midi: Int, rect: NSRect)] = range.compactMap { midi in + guard let rect = KeyboardIconRenderer.keyRect(for: UInt8(midi)) else { return nil } + return (midi, NSRect(x: offset + rect.minX * scale, y: 0, + width: rect.width * scale, height: 0)) + } + guard let left = keys.map({ $0.rect.minX }).min(), + let right = keys.map({ $0.rect.maxX }).max() else { hide(); return } + let height: CGFloat = 180 + let frame = NSRect(x: buttonFrame.minX + left, y: buttonFrame.minY - height, + width: right - left, height: height) + panel.setFrame(frame, display: false) + view.keys = keys.map { ($0.midi, $0.rect.offsetBy(dx: -left, dy: 0)) } + view.trials = trials + view.now = now + view.needsDisplay = true + panel.orderFrontRegardless() + } +} + +final class ToneTrialsTracksView: NSView { + var keys: [(midi: Int, rect: NSRect)] = [] + var trials: ToneTrials? + var now: CFTimeInterval = 0 + + override func draw(_ dirtyRect: NSRect) { + guard let trials, let snapshot = trials.snapshot(at: now) else { return } + let targetY = bounds.height + 10 + let spacing: CGFloat = 20 + let position = trials.trackPosition(at: now) + let upcoming = trials.trial.intervals.enumerated().compactMap { index, interval + -> (index: Int, key: (midi: Int, rect: NSRect))? in + let midi = 60 + trials.trial.root + interval + guard let key = keys.first(where: { $0.midi == midi }) + ?? keys.first(where: { $0.midi % 12 == midi % 12 }) else { return nil } + return (index, key) + } + // Quiet vertical dividers keep every lane registered to its key. + let centers = keys.map { $0.rect.midX }.sorted() + let lastIndex = upcoming.last?.index ?? trials.progress + let trackBottom = max(18, targetY - CGFloat(Double(lastIndex) - position) * spacing - 8) + if trials.progress < upcoming.count, centers.count > 1 { + NSGraphicsContext.saveGraphicsState() + let shadow = NSShadow() + shadow.shadowColor = NSColor.black.withAlphaComponent(0.35) + shadow.shadowBlurRadius = 1 + shadow.shadowOffset = .zero + shadow.set() + NSColor.white.withAlphaComponent(0.22).setStroke() + for (left, right) in zip(centers, centers.dropFirst()) { + let x = (left + right) / 2 + let divider = NSBezierPath() + divider.move(to: NSPoint(x: x, y: trackBottom)) + divider.line(to: NSPoint(x: x, y: bounds.height)) + divider.lineWidth = 0.5 + divider.stroke() + } + NSGraphicsContext.restoreGraphicsState() + } + for item in upcoming where item.index > trials.progress { + let distance = CGFloat(Double(item.index) - position) + let y = targetY - distance * spacing + guard y >= 22, y < bounds.height + 8, + let label = KeyboardIconRenderer.labelByMidi[item.key.midi] else { continue } + let width = max(10, min(20, item.key.rect.width - 2)) + let rect = NSRect(x: item.key.rect.midX - width / 2, y: y - 8, + width: width, height: 16) + let color = KeyboardIconRenderer.noteColor(forMidi: item.key.midi) + NSGraphicsContext.saveGraphicsState() + let shadow = NSShadow() + shadow.shadowColor = NSColor.black.withAlphaComponent(0.5) + shadow.shadowBlurRadius = 3 + shadow.shadowOffset = NSSize(width: 0, height: -1) + shadow.set() + let pill = NSBezierPath(roundedRect: rect, xRadius: 3, yRadius: 3) + color.setFill() + pill.fill() + NSGraphicsContext.restoreGraphicsState() + let rgb = color.usingColorSpace(.sRGB) ?? color + let light = 0.2126 * rgb.redComponent + 0.7152 * rgb.greenComponent + + 0.0722 * rgb.blueComponent + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedSystemFont(ofSize: 11, weight: .semibold), + .foregroundColor: light > 0.5 ? NSColor.black : NSColor.white + ] + let size = label.size(withAttributes: attrs) + label.draw(at: NSPoint(x: rect.midX - size.width / 2, + y: rect.midY - size.height / 2), withAttributes: attrs) + } + let label = snapshot.clearBanner > 0 ? "✓" : snapshot.title + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 10, weight: .medium), + .foregroundColor: NSColor.white, + .strokeColor: NSColor.black, .strokeWidth: -3 + ] + let size = label.size(withAttributes: attrs) + let labelY = max(1, targetY - CGFloat(upcoming.count - trials.progress) * spacing - 12) + label.draw(at: NSPoint(x: bounds.midX - size.width / 2, y: labelY), withAttributes: attrs) + } +} diff --git a/slab/menuband/Tests/MenuBandTests/ShapedownTests.swift b/slab/menuband/Tests/MenuBandTests/ShapedownTests.swift index eca0061eaa..a340297611 100644 --- a/slab/menuband/Tests/MenuBandTests/ShapedownTests.swift +++ b/slab/menuband/Tests/MenuBandTests/ShapedownTests.swift @@ -715,14 +715,14 @@ final class ShapedownTests: XCTestCase { ) } - func testTrackDrumSlashAndHelpRemainDistinct() { + func testTrackDrumSlashAndScalesRemainDistinct() { XCTAssertEqual( AppDelegate.trackDrumSlashAction(keyCode: 44, flags: []), .trainer ) XCTAssertEqual( AppDelegate.trackDrumSlashAction(keyCode: 44, flags: [.shift]), - .help + .scales ) XCTAssertNil( AppDelegate.trackDrumSlashAction(keyCode: 44, flags: [.command]) diff --git a/slab/menuband/Tests/MenuBandTests/ToneTrialsTests.swift b/slab/menuband/Tests/MenuBandTests/ToneTrialsTests.swift new file mode 100644 index 0000000000..24d6761f8d --- /dev/null +++ b/slab/menuband/Tests/MenuBandTests/ToneTrialsTests.swift @@ -0,0 +1,37 @@ +import XCTest +@testable import MenuBand + +final class ToneTrialsTests: XCTestCase { + func testFirstNoteIsAcceptedImmediately() { + let trials = ToneTrials() + trials.start(at: 0) + trials.registerNote(60, at: 0) + XCTAssertEqual(trials.progress, 1) + } + + func testWrongNotesAndElapsedTimeDoNotMoveTheTrack() { + let trials = ToneTrials() + trials.start(at: 0) + trials.registerNote(60, at: 3) + trials.registerNote(63, at: 4) + trials.update(at: 500) + XCTAssertEqual(trials.progress, 1) + XCTAssertEqual(trials.index, 0) + trials.registerNote(62, at: 501) + XCTAssertEqual(trials.progress, 2) + } + + func testRestartReturnsToFirstScaleImmediately() { + let trials = ToneTrials() + trials.start(at: 5) + trials.start(at: 0) + XCTAssertEqual(trials.trial.title, "C Major") + XCTAssertEqual(trials.progress, 0) + trials.registerNote(60, at: 20) + XCTAssertEqual(trials.progress, 1) + trials.stop() + trials.registerNote(60, at: 30) + XCTAssertNil(trials.snapshot(at: 30)) + XCTAssertEqual(trials.progress, 0) + } +} -- 2.51.2