diff --git a/slab/menuband/SCORE.md b/slab/menuband/SCORE.md index 2da9f7264..51912e8c2 100644 --- a/slab/menuband/SCORE.md +++ b/slab/menuband/SCORE.md @@ -13,6 +13,13 @@ instrument chooser, octave control, layout pickers, shortcut binders, and a metronome. Paired with a floating "play palette" panel for the expanded keyboard view + waveform visualizer. +The keymap picker offers Menu Band/Notepat, AWSED (Ableton-style), and +MilkyTracker keyjazz. Compact maps retain the physical QWERTY geometry: +unused caps are invisible spacers rather than being removed and shifting the +remaining notes left. MilkyTracker maps `ZSXDCVGBHNJM` and +`Q2W3ER5T6Y7U` as two chromatic octaves; `0` stays available for Menu Band's +MIDI-mode shortcut. The shared chord modifiers work on every layout. + Lives at `slab/menuband/`. Distinct from `slab/menubar-swift/` (the Claude session menubar; different status item, different process). diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index 842483ceb..8af8071cc 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -1831,7 +1831,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } private func toggleKeyboardLayoutShortcut() { - menuBand.keymap = (menuBand.keymap == .ableton) ? .notepat : .ableton + switch menuBand.keymap { + case .notepat: menuBand.keymap = .ableton + case .ableton: menuBand.keymap = .milkyTracker + case .milkyTracker: menuBand.keymap = .notepat + } } /// Drag-and-drop entry point — replays the file's notes @@ -2795,7 +2799,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { slideOffsetX: currentSlideOffset(), settingsFlash: currentFlashStrength() ) - let layoutName = menuBand.keymap == .ableton ? "Ableton" : "Notepat" + let layoutName: String + switch menuBand.keymap { + case .notepat: layoutName = "Notepat" + case .ableton: layoutName = "AWSED" + case .milkyTracker: layoutName = "MilkyTracker" + } let routing = menuBand.audioRoutingContextLabel.map { " - \($0)" } ?? "" button.toolTip = "\(menuBand.voiceContextLabel) - \(menuBand.octaveContextLabel) - \(layoutName) layout\(routing)" // Force a synchronous redraw — the click drag-loop runs the runloop diff --git a/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift b/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift index 6ca1fc51b..8608061fc 100644 --- a/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift +++ b/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift @@ -629,8 +629,19 @@ enum KeyboardIconRenderer { 75: "p", 76: ";", ] + private static let labelByMidiMilkyTracker: [Int: String] = [ + 60: "z", 61: "s", 62: "x", 63: "d", 64: "c", 65: "v", + 66: "g", 67: "b", 68: "h", 69: "n", 70: "j", 71: "m", + 72: "q", 73: "2", 74: "w", 75: "3", 76: "e", 77: "r", + 78: "5", 79: "t", 80: "6", 81: "y", 82: "7", 83: "u", + ] + static var labelByMidi: [Int: String] { - activeKeymap == .ableton ? labelByMidiAbleton : labelByMidiNotepat + switch activeKeymap { + case .notepat: return labelByMidiNotepat + case .ableton: return labelByMidiAbleton + case .milkyTracker: return labelByMidiMilkyTracker + } } @inline(__always) diff --git a/slab/menuband/Sources/MenuBand/KeymapCapture.swift b/slab/menuband/Sources/MenuBand/KeymapCapture.swift index 6e0c669fb..8764d3322 100644 --- a/slab/menuband/Sources/MenuBand/KeymapCapture.swift +++ b/slab/menuband/Sources/MenuBand/KeymapCapture.swift @@ -28,10 +28,16 @@ enum KeymapCLI { if let lang = val("--lang") { Localization.current = lang } let controller = MenuBandController() + if let rawKeymap = val("--keymap"), let keymap = Keymap(rawValue: rawKeymap) { + controller.keymap = keymap + } let prog = UInt8(val("--program") ?? "0") ?? 0 controller.setMelodicProgram(prog) let view = ExpandedPianoWaveformView(menuBand: controller) + // Synchronize controls such as the keymap dropdown with CLI-seeded + // controller state before the first layout/snapshot pass. + view.refresh() // The view never enters a window, so `effectiveAppearance` would fall // back to the SYSTEM appearance rather than the one set on NSApp — and // `refresh()` restyles the scope from it. Pin it to the app's. diff --git a/slab/menuband/Sources/MenuBand/MenuBandController.swift b/slab/menuband/Sources/MenuBand/MenuBandController.swift index 8f32bad8a..4d587c50e 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandController.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandController.swift @@ -432,9 +432,7 @@ final class MenuBandController { /// independent. Used to filter chord suggestions down to ones the /// user could actually finish playing on the current QWERTY layout. func keymapPitchClasses() -> Set { - let table = (keymap == .ableton) - ? MenuBandLayout.semitoneByKeyCodeAbleton - : MenuBandLayout.semitoneByKeyCode + let table = MenuBandLayout.semitoneTable(for: keymap) var pcs: Set = [] for st in table where st != Int8.min { pcs.insert(((Int(st) % 12) + 12) % 12) @@ -1022,7 +1020,12 @@ final class MenuBandController { } var playableNoteRangeLabel: String { - let upper = keymap == .ableton ? 76 : 83 + let upper: Int + switch keymap { + case .ableton: upper = 76 + case .notepat: upper = 83 + case .milkyTracker: upper = 83 + } let lowerNote = UInt8(max(0, min(127, 60 + octaveShift * 12))) let upperNote = UInt8(max(0, min(127, upper + octaveShift * 12))) return "\(Self.noteName(lowerNote))-\(Self.noteName(upperNote))" @@ -3438,7 +3441,8 @@ final class MenuBandController { // playback so the user can sweep out of MIDI mode by typing. // 3-digit cap means the 4th press starts a fresh sequence. // Down-events only. - if let digit = Self.digitForKeyCode(keyCode) { + if let digit = Self.digitForKeyCode(keyCode), + MenuBandLayout.semitone(forKeyCode: keyCode, keymap: keymap) == nil { // Track the digit's press/release in the control-keys // set so the QWERTY visualization can light it up // alongside note-mapped keys. Posting onLitChanged diff --git a/slab/menuband/Sources/MenuBand/MenuBandMIDI.swift b/slab/menuband/Sources/MenuBand/MenuBandMIDI.swift index 383d5a4b0..9304734ea 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandMIDI.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandMIDI.swift @@ -7,7 +7,7 @@ import CoreMIDI // Indexed by Carbon kVK_ANSI_* virtual key codes for zero-allocation lookup. // Keymap raw values are persisted in UserDefaults — keep the `notepat` raw // value stable so existing settings continue to load. -enum Keymap: String { case notepat, ableton } +enum Keymap: String, CaseIterable { case notepat, ableton, milkyTracker } enum MenuBandLayout { // Pre-built dense lookup: index = virtual key code (0-127), value = semitone or Int8.min if unmapped. @@ -77,6 +77,31 @@ enum MenuBandLayout { return t }() + /// MilkyTracker / FastTracker-style two-row keyjazz layout. The lower + /// octave starts at Z and the upper octave at Q; number-row keys between + /// QWERTY letters supply the upper octave's black notes. We intentionally + /// stop at U (B) so 0 remains Menu Band's direct MIDI-mode control. + static let semitoneByKeyCodeMilkyTracker: [Int8] = { + var t = [Int8](repeating: Int8.min, count: 128) + let mapping: [(UInt16, Int8)] = [ + (6, 0), (1, 1), (7, 2), (2, 3), (8, 4), (9, 5), + (5, 6), (11, 7), (4, 8), (45, 9), (38, 10), (46, 11), + (12, 12), (19, 13), (13, 14), (20, 15), (14, 16), (15, 17), + (23, 18), (17, 19), (22, 20), (16, 21), (26, 22), (32, 23), + ] + for (kc, st) in mapping { t[Int(kc)] = st } + return t + }() + + @inline(__always) + static func semitoneTable(for keymap: Keymap) -> [Int8] { + switch keymap { + case .notepat: return semitoneByKeyCode + case .ableton: return semitoneByKeyCodeAbleton + case .milkyTracker: return semitoneByKeyCodeMilkyTracker + } + } + /// Pan (MIDI 0–127, 64 = center) per QWERTY position. Derived from /// notepat native's `getPanForQwertyKey` — physical keyboard /// column maps to stereo placement so left-hand keys sit left, @@ -139,7 +164,7 @@ enum MenuBandLayout { static func octaveKeyCodes(for keymap: Keymap) -> (down: UInt16, up: UInt16) { switch keymap { case .ableton: return (6, 7) - case .notepat: return (43, 47) + case .notepat, .milkyTracker: return (43, 47) } } @@ -148,7 +173,7 @@ enum MenuBandLayout { octaveShift: Int, keymap: Keymap = .notepat) -> UInt8? { guard keyCode < 128 else { return nil } - let table = (keymap == .ableton) ? semitoneByKeyCodeAbleton : semitoneByKeyCode + let table = semitoneTable(for: keymap) let semitone = table[Int(keyCode)] if semitone == Int8.min { return nil } let value = 60 + Int(semitone) + (octaveShift * 12) @@ -165,7 +190,7 @@ enum MenuBandLayout { static func semitone(forKeyCode keyCode: UInt16, keymap: Keymap = .notepat) -> Int? { guard keyCode < 128 else { return nil } - let table = (keymap == .ableton) ? semitoneByKeyCodeAbleton : semitoneByKeyCode + let table = semitoneTable(for: keymap) let s = table[Int(keyCode)] return s == Int8.min ? nil : Int(s) } @@ -185,7 +210,7 @@ enum MenuBandLayout { /// keymaps contain a full C-major scale, so every degree resolves; /// the `?? 0` is an unreachable safety net. static func cMajorKeyCodes(for keymap: Keymap) -> [UInt16] { - let table = (keymap == .ableton) ? semitoneByKeyCodeAbleton : semitoneByKeyCode + let table = semitoneTable(for: keymap) return cMajorSemitones.map { semitone in UInt16(table.firstIndex(of: semitone) ?? 0) } diff --git a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift index 49553f138..f2e609271 100644 --- a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift +++ b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift @@ -53,11 +53,11 @@ final class CollapsedPianoWaveformView: NSView { /// commit on release. Mirrors the popover's old arrows hint /// position (under the keyboard) one level down. private let arrowsCluster = ArrowKeysIndicator() - /// Notepat / Ableton mode picker — moved out of the popover so + /// Keymap picker — moved out of the popover so /// the liquid panel reads as the operational/physical "extended /// instrument," and the popover stays a music-theory surface. private let modeStack = NSStackView() - private var modeButtons: [NSButton] = [] + private let keymapPopUp = NSPopUpButton(frame: .zero, pullsDown: false) /// Live audio strip above the instrument name — shows the output /// waveform + reverse/forward direction so spacebar-rewind is debuggable. private let waveformStrip = WaveformStripView() @@ -225,47 +225,29 @@ final class CollapsedPianoWaveformView: NSView { modeStack.alignment = .centerY modeStack.spacing = 6 modeStack.translatesAutoresizingMaskIntoConstraints = false - let modeSymbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .regular) - let modeSpecs: [(label: String, image: NSImage?, tag: Int)] = [ - ("Notepat", - NotepatFavicon.image - ?? NSImage(systemSymbolName: "keyboard", accessibilityDescription: "Notepat")? - .withSymbolConfiguration(modeSymbolConfig), - 0), - ("Ableton", AbletonLogo.image(height: 11), 1), - ] - for (idx, spec) in modeSpecs.enumerated() { - let b = NSButton(title: spec.label, target: self, - action: #selector(modeButtonClicked(_:))) - b.tag = spec.tag - b.bezelStyle = .recessed - b.setButtonType(.pushOnPushOff) - b.controlSize = .small - b.imagePosition = .imageLeading - b.imageHugsTitle = true - b.image = spec.image - b.translatesAutoresizingMaskIntoConstraints = false - modeButtons.append(b) - modeStack.addArrangedSubview(b) - // "?" help button immediately after the Notepat (idx 0) - // button — opens the keymaps paper so the user can read - // why notepat looks the way it does. - if idx == 0 { - let helpConfig = NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold) - let help = NSButton() - help.image = NSImage(systemSymbolName: "questionmark.circle", - accessibilityDescription: "Why this layout?")? - .withSymbolConfiguration(helpConfig) - help.imagePosition = .imageOnly - help.bezelStyle = .recessed - help.controlSize = .small - help.toolTip = "Why this layout?" - help.target = self - help.action = #selector(whyKeymapClicked(_:)) - help.translatesAutoresizingMaskIntoConstraints = false - modeStack.addArrangedSubview(help) - } + keymapPopUp.addItems(withTitles: ["Menu Band", "AWSED", "MilkyTracker"]) + for (index, tag) in [0, 1, 2].enumerated() { + keymapPopUp.item(at: index)?.tag = tag } + keymapPopUp.controlSize = .small + keymapPopUp.target = self + keymapPopUp.action = #selector(keymapPopUpChanged(_:)) + keymapPopUp.toolTip = "Choose the computer-keyboard note layout" + keymapPopUp.translatesAutoresizingMaskIntoConstraints = false + modeStack.addArrangedSubview(keymapPopUp) + let helpConfig = NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold) + let help = NSButton() + help.image = NSImage(systemSymbolName: "questionmark.circle", + accessibilityDescription: "Why this layout?")? + .withSymbolConfiguration(helpConfig) + help.imagePosition = .imageOnly + help.bezelStyle = .recessed + help.controlSize = .small + help.toolTip = "Why this layout?" + help.target = self + help.action = #selector(whyKeymapClicked(_:)) + help.translatesAutoresizingMaskIntoConstraints = false + modeStack.addArrangedSubview(help) arrowsCluster.translatesAutoresizingMaskIntoConstraints = false arrowsCluster.displayMode = .cluster @@ -562,10 +544,13 @@ final class CollapsedPianoWaveformView: NSView { qwertyMap.octaveShift = menuBand.octaveShift // Mirror the keymap selection on the mode-picker buttons. - let activeTag = (menuBand.keymap == .ableton) ? 1 : 0 - for button in modeButtons { - button.state = (button.tag == activeTag) ? .on : .off + let activeTag: Int + switch menuBand.keymap { + case .notepat: activeTag = 0 + case .ableton: activeTag = 1 + case .milkyTracker: activeTag = 2 } + keymapPopUp.selectItem(withTag: activeTag) if Self.shouldUseLiquidGlass, #available(macOS 26.0, *) { // Tinting is disabled for the collapsed view — even a @@ -698,16 +683,17 @@ final class CollapsedPianoWaveformView: NSView { } } - @objc private func modeButtonClicked(_ sender: NSButton) { + @objc private func keymapPopUpChanged(_ sender: NSPopUpButton) { guard let menuBand else { return } - let next: Keymap = (sender.tag == 1) ? .ableton : .notepat + let next: Keymap + switch sender.selectedItem?.tag ?? 0 { + case 1: next = .ableton + case 2: next = .milkyTracker + default: next = .notepat + } if menuBand.keymap != next { menuBand.keymap = next } - // Manual radio behaviour: only the clicked button stays .on. - for button in modeButtons { - button.state = (button == sender) ? .on : .off - } } private func installLiquidGlassBackgrounds() { diff --git a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/ExpandedPianoWaveformView.swift b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/ExpandedPianoWaveformView.swift index 4eacf75fc..592a49578 100644 --- a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/ExpandedPianoWaveformView.swift +++ b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/ExpandedPianoWaveformView.swift @@ -16,11 +16,11 @@ final class ExpandedPianoWaveformView: NSView { private weak var menuBand: MenuBandController? private let contentStack = NSStackView() - /// Notepat / Conventional keymap toggle — moved here from the collapsed + /// Keymap selector — moved here from the collapsed /// picker so the full-screen view is the single place the layout is /// chosen (it shows the large QWERTY that the choice drives). private let modeStack = NSStackView() - private var modeButtons: [NSButton] = [] + private let keymapPopUp = NSPopUpButton(frame: .zero, pullsDown: false) private let waveformSection = NSView() /// Needle + rolling-buffer scope — the same live "playhead with chunky /// min/max columns" the popover shows (`WaveformStripView`), swapped in for @@ -267,31 +267,16 @@ final class ExpandedPianoWaveformView: NSView { modeStack.addArrangedSubview(recordButton) #endif let modeSymbol = NSImage.SymbolConfiguration(pointSize: 12, weight: .regular) - let modeSpecs: [(label: String, image: NSImage?, tag: Int)] = [ - ("Menu Band", - NotepatFavicon.image - ?? NSImage(systemSymbolName: "keyboard", accessibilityDescription: "Menu Band")? - .withSymbolConfiguration(modeSymbol), - 0), - ("AWSED", - NSImage(systemSymbolName: "pianokeys", accessibilityDescription: "AWSED")? - .withSymbolConfiguration(modeSymbol), - 1), - ] - for spec in modeSpecs { - let b = NSButton(title: spec.label, target: self, - action: #selector(modeButtonClicked(_:))) - b.tag = spec.tag - b.bezelStyle = .recessed - b.setButtonType(.pushOnPushOff) - b.controlSize = .regular - b.imagePosition = .imageLeading - b.imageHugsTitle = true - b.image = spec.image - b.translatesAutoresizingMaskIntoConstraints = false - modeButtons.append(b) - modeStack.addArrangedSubview(b) + keymapPopUp.addItems(withTitles: ["Menu Band", "AWSED", "MilkyTracker"]) + for (index, tag) in [0, 1, 2].enumerated() { + keymapPopUp.item(at: index)?.tag = tag } + keymapPopUp.controlSize = .regular + keymapPopUp.target = self + keymapPopUp.action = #selector(keymapPopUpChanged(_:)) + keymapPopUp.toolTip = "Choose the computer-keyboard note layout" + keymapPopUp.translatesAutoresizingMaskIntoConstraints = false + modeStack.addArrangedSubview(keymapPopUp) // Gamepad — toggles the controller-config cluster, which is hidden by // default so it doesn't clutter the full-screen keymap view. let gamepadToggle = NSButton(title: "Gamepad", target: self, @@ -712,13 +697,15 @@ final class ExpandedPianoWaveformView: NSView { pianoView.needsDisplay = true } - @objc private func modeButtonClicked(_ sender: NSButton) { + @objc private func keymapPopUpChanged(_ sender: NSPopUpButton) { guard let menuBand else { return } - let next: Keymap = (sender.tag == 1) ? .ableton : .notepat - if menuBand.keymap != next { menuBand.keymap = next } - for button in modeButtons { - button.state = (button == sender) ? .on : .off + let next: Keymap + switch sender.selectedItem?.tag ?? 0 { + case 1: next = .ableton + case 2: next = .milkyTracker + default: next = .notepat } + if menuBand.keymap != next { menuBand.keymap = next } qwertyView.keymap = menuBand.keymap refresh() } @@ -727,10 +714,13 @@ final class ExpandedPianoWaveformView: NSView { private func updateModeToggle() { guard let menuBand else { return } qwertyView.keymap = menuBand.keymap - for button in modeButtons { - let isAbleton = (button.tag == 1) - button.state = (menuBand.keymap == .ableton) == isAbleton ? .on : .off + let activeTag: Int + switch menuBand.keymap { + case .notepat: activeTag = 0 + case .ableton: activeTag = 1 + case .milkyTracker: activeTag = 2 } + keymapPopUp.selectItem(withTag: activeTag) } // MARK: - Sample-record pill @@ -898,7 +888,7 @@ final class ExpandedPianoWaveformView: NSView { /// narrower layouts simply re-center within the fixed width. private func stableKeyboardWidth() -> CGFloat { var widest: CGFloat = 0 - for km in [Keymap.notepat, .ableton] { + for km in Keymap.allCases { let w = KeyboardIconRenderer.withPianoWaveformKeyboard(keymap: km) { KeyboardIconRenderer.pianoImageSize(layout: .tightActiveRange).width * pianoScale } diff --git a/slab/menuband/Sources/MenuBand/QwertyLayoutView.swift b/slab/menuband/Sources/MenuBand/QwertyLayoutView.swift index fa368e904..3b6183ca1 100644 --- a/slab/menuband/Sources/MenuBand/QwertyLayoutView.swift +++ b/slab/menuband/Sources/MenuBand/QwertyLayoutView.swift @@ -202,24 +202,18 @@ final class QwertyLayoutView: NSView { for (rIdx, row) in Self.rows.enumerated() { let y = topY - CGFloat(rIdx) * (kSize + kGap) let xOffset = Self.rowOffsets[rIdx] * kSize - // Sum the row's visible widths so we can right-truncate - // it (Ableton hides unmapped letters) and still center - // the row's caps relative to the home row's left edge. + // Every cap keeps its real physical slot even when a keymap does + // not use it. Earlier this measured and advanced through visible + // Ableton caps only, compacting AWSED toward the left edge and + // destroying the QWERTY stagger (W no longer sat between A/S, + // etc.). Unmapped caps are now invisible spacers: they do not + // draw or hit-test, but subsequent caps remain where the actual + // keyboard puts them. // For the modifier row (4) we instead center on the // panel midline so shift / space / shift sit centered. - var rowWidth: CGFloat = 0 - var visibleCount = 0 - for cap in row { - let st = semitone(cap.kc) - let isOct = (cap.kc == octaves.down || cap.kc == octaves.up) - if keymap == .ableton && st == nil && !isOct - && !Self.isModifierKey(cap.kc) { continue } - rowWidth += cap.width * kSize - visibleCount += 1 - } - if visibleCount > 0 { - rowWidth += CGFloat(visibleCount - 1) * kGap - } + let rowWidth = row.reduce(CGFloat.zero) { + $0 + $1.width * kSize + } + CGFloat(max(0, row.count - 1)) * kGap let leftX: CGFloat if rIdx == Self.rows.count - 1 { // Modifier row centers on the panel midline so the @@ -233,11 +227,11 @@ final class QwertyLayoutView: NSView { for cap in row { let st = semitone(cap.kc) let isOct = (cap.kc == octaves.down || cap.kc == octaves.up) - if keymap == .ableton && st == nil && !isOct - && !Self.isModifierKey(cap.kc) { continue } let w = cap.width * kSize let kr = NSRect(x: cursorX, y: y, width: w, height: kSize) - body(cap, kr) + let hiddenUnusedCap = keymap != .notepat && st == nil && !isOct + && !Self.isModifierKey(cap.kc) + if !hiddenUnusedCap { body(cap, kr) } cursorX += w + kGap } } @@ -293,9 +287,7 @@ final class QwertyLayoutView: NSView { /// whether that note is a white or black piano key. private func semitone(_ kc: UInt16) -> Int? { guard kc < 128 else { return nil } - let table = (keymap == .ableton) - ? MenuBandLayout.semitoneByKeyCodeAbleton - : MenuBandLayout.semitoneByKeyCode + let table = MenuBandLayout.semitoneTable(for: keymap) let v = table[Int(kc)] return (v == Int8.min) ? nil : Int(v) } diff --git a/slab/menuband/Tests/MenuBandTests/GamepadMappingTests.swift b/slab/menuband/Tests/MenuBandTests/GamepadMappingTests.swift index eb6d61b43..73fcade05 100644 --- a/slab/menuband/Tests/MenuBandTests/GamepadMappingTests.swift +++ b/slab/menuband/Tests/MenuBandTests/GamepadMappingTests.swift @@ -20,12 +20,24 @@ final class GamepadMappingTests: XCTestCase { [0, 1, 2, 3, 5, 4, 38, 40]) } + func testMilkyTrackerUsesCanonicalTwoRowKeyjazzNotes() { + let lower: [UInt16] = [6, 1, 7, 2, 8, 9, 5, 11, 4, 45, 38, 46] + let upper: [UInt16] = [12, 19, 13, 20, 14, 15, 23, 17, 22, 16, 26, 32] + let notes = (lower + upper).compactMap { + MenuBandLayout.midiNote(forKeyCode: $0, octaveShift: 0, + keymap: .milkyTracker) + } + XCTAssertEqual(notes, Array(UInt8(60)...UInt8(83))) + XCTAssertNil(MenuBandLayout.semitone(forKeyCode: 29, keymap: .milkyTracker), + "0 stays available for Menu Band's MIDI-mode shortcut") + } + /// The key codes must resolve to the actual C-major MIDI notes /// (60 62 64 65 67 69 71 72) through the same lookup `playKeyEvent` - /// uses — in both keymaps. + /// uses — in every keymap. func testKeyCodesProduceCMajorMidiNotes() { let expected: [UInt8] = [60, 62, 64, 65, 67, 69, 71, 72] - for keymap in [Keymap.notepat, .ableton] { + for keymap in Keymap.allCases { let notes = MenuBandLayout.cMajorKeyCodes(for: keymap).map { MenuBandLayout.midiNote(forKeyCode: $0, octaveShift: 0, keymap: keymap) }