diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index acdafec04..fbbf78e3e 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -390,6 +390,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var trackpadScratchCandidateTravel: CGFloat = 0 private var trackpadScratchCandidateFrames = 0 private var trackpadScratchArmed = false + /// Ordinary registered button state, separate from touch contacts so a + /// light tap never enters the physically-clicked expression layer. + private var trackpadPhysicalClickHeld = false + static func shouldArmTrackpadScratch(frames: Int, travel: CGFloat) -> Bool { + frames >= 2 && travel >= 0.012 + } + static func physicalClickScratchMultiplier(clicked: Bool) -> Double { + // A physical click should reveal more skin detail, not pitch the + // friction into a cartoon/vinyl chirp. Keep the boost modest because + // gesture speed also drives the scratch's octave lift. + clicked ? 1.55 : 1.0 + } private var trackpadSurfaceEnergy = TrackpadSurfaceEnergy() private var trackpadEnergyTimer: Timer? private var trackpadOverlayLastDraw: Double = 0 @@ -575,6 +587,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { MenuBandLoginItem.apply() #endif #if !MAC_APP_STORE + trackpadPercussionGestureTap.onPhysicalClickChanged = { + [weak self] clicked in + self?.handleTrackpadPhysicalClick(clicked) + } // Global trackpad tap via private MultitouchSupport — the // focus-independent input source for the trackpad pad (NSTouch never // reaches this non-activating menubar panel). Frames arrive on the main @@ -1291,6 +1307,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { localCapture.onTrackpadTouchActiveChanged = { [weak self] active in self?.setTrackpadTouchActive(active) } + localCapture.onTrackpadPhysicalClick = { [weak self] clicked in + self?.handleTrackpadPhysicalClick(clicked) + } #if MAC_APP_STORE localCapture.onTrackpadFrame = { [weak self] contacts, timestamp, callbackTime in self?.handleTrackpadFrame(contacts, timestamp: timestamp, @@ -4544,6 +4563,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } if pitchBendCursorPushed { if !touches.isEmpty { + if !changes.began.isEmpty { + pitchBendOverlay?.resumeFromFade() + } // Focus arms the instrument but does not advertise it. The // first real hardware contact reveals the surface instantly; // subsequent frames stay coalesced to display cadence. @@ -4564,6 +4586,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + /// A registered button-down adds expression without changing ordinary + /// touch semantics. Its rising edge fires once; holding boosts scratch. + private func handleTrackpadPhysicalClick(_ clicked: Bool) { + let wasHeld = trackpadPhysicalClickHeld + trackpadPhysicalClickHeld = clicked + debugLog("trackpad physical click = \(clicked ? "down" : "up") contacts=\(mtTouches.count)") + guard pitchBendCursorPushed, clicked, !wasHeld, + let strike = mtTouches.first(where: { + MenuBandPercussion.drumSkinZone(at: $0) == .kick + }) else { return } + if MenuBandPercussion.drumSkinZone(at: strike) == .kick { + let anchors = mtTouches.filter { $0 != strike } + debugLog("trackpad physical click triggered super-kick") + menuBand.trackpadSuperKick(strike: strike, anchors: anchors) + trackpadSurfaceEnergy.energize( + at: strike, amount: 0.90, now: CACurrentMediaTime() + ) + updateTrackpadOverlayIfDue(force: true) + } + } + /// Trigger only newly entered articulations. Two bottom fingers hold the /// open hat; removing either closes it without firing the remaining pad. private func updateTrackpadPercussion(_ touches: [CGPoint], @@ -4687,8 +4730,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if !trackpadScratchArmed { trackpadScratchCandidateFrames += 1 trackpadScratchCandidateTravel += scratch.movement - trackpadScratchArmed = trackpadScratchCandidateFrames >= 3 - && trackpadScratchCandidateTravel >= 0.018 + trackpadScratchArmed = Self.shouldArmTrackpadScratch( + frames: trackpadScratchCandidateFrames, + travel: trackpadScratchCandidateTravel + ) } guard trackpadScratchArmed else { trackpadSkinTouches = touches @@ -4711,9 +4756,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { ) let retained = trackpadSurfaceEnergy.energy(at: scratch.point, now: now) + let expressiveSpeed = speed * Self.physicalClickScratchMultiplier( + clicked: trackpadPhysicalClickHeld + ) menuBand.trackpadDrumSkinScratch( at: scratch.point, - speed: speed, + speed: expressiveSpeed, anchors: scratch.anchors, direction: scratch.delta, surfaceEnergy: retained, diff --git a/slab/menuband/Sources/MenuBand/LocalKeyCapture.swift b/slab/menuband/Sources/MenuBand/LocalKeyCapture.swift index fca087119..1abb7b27d 100644 --- a/slab/menuband/Sources/MenuBand/LocalKeyCapture.swift +++ b/slab/menuband/Sources/MenuBand/LocalKeyCapture.swift @@ -34,6 +34,8 @@ final class LocalKeyCapture { /// Public, sandbox-safe multi-contact frames from NSTouch. Available only /// while Menu Band owns the focused window/responder chain. var onTrackpadFrame: (([TrackpadContact], Double, Double) -> Void)? + /// Ordinary registered primary-button state from a physical trackpad click. + var onTrackpadPhysicalClick: ((Bool) -> Void)? var cancelShortcut: MenuBandShortcut? private var panel: NSPanel? @@ -71,8 +73,18 @@ final class LocalKeyCapture { panel.makeFirstResponder(sensor) } if monitor == nil { - monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { [weak self] event in + monitor = NSEvent.addLocalMonitorForEvents( + matching: [.keyDown, .keyUp, .leftMouseDown, .leftMouseUp] + ) { [weak self] event in guard let self = self else { return event } + if event.type == .leftMouseDown { + self.onTrackpadPhysicalClick?(true) + return event + } + if event.type == .leftMouseUp { + self.onTrackpadPhysicalClick?(false) + return event + } let isDown = (event.type == .keyDown) if isDown, self.cancelShortcut?.matches(event: event) == true { self.disarm(reason: .cancelled) diff --git a/slab/menuband/Sources/MenuBand/MenuBandController.swift b/slab/menuband/Sources/MenuBand/MenuBandController.swift index 31c19a6ab..690e62df7 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandController.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandController.swift @@ -182,6 +182,7 @@ final class MenuBandController { private let percussionLeftKey = KeyboardIconRenderer.percussionLeftDefaultsKey private let percussionRightKey = KeyboardIconRenderer.percussionRightDefaultsKey private let percussionVolumeKey = "notepat.percussionVolume" + private let percussionVolume90MigrationKey = "notepat.percussionVolume90Migration" private let masterVolumeKey = "notepat.masterVolume" /// Active instrument backend: `"gm"` for the General MIDI bank, or /// `"gb"` for a GarageBand sampler patch. Default is GM. Stored as a @@ -872,6 +873,11 @@ final class MenuBandController { synth.playDrumSkin(strike: strike, anchors: anchors, velocity: velocity) } + func trackpadSuperKick(strike: CGPoint, anchors: [CGPoint]) { + mixAnalysis.mark("skin-super-kick") + synth.playSuperKick(strike: strike, anchors: anchors) + } + func trackpadSynthSurface(strike: CGPoint, anchors: [CGPoint], velocity: UInt8) { mixAnalysis.mark("synth-\(MenuBandPercussion.drumSkinZone(at: strike).rawValue)") synth.playSynthSurface(strike: strike, anchors: anchors, velocity: velocity) @@ -1055,9 +1061,18 @@ final class MenuBandController { var percussionVolume: Float { get { if UserDefaults.standard.object(forKey: percussionVolumeKey) == nil { - return 0.80 + return 0.90 + } + var raw = UserDefaults.standard.double(forKey: percussionVolumeKey) + if !UserDefaults.standard.bool(forKey: percussionVolume90MigrationKey) { + // Lift only the former untouched default. Preserve every + // deliberate user setting above or below it. + if abs(raw - 0.80) < 0.000_1 { + raw = 0.90 + UserDefaults.standard.set(raw, forKey: percussionVolumeKey) + } + UserDefaults.standard.set(true, forKey: percussionVolume90MigrationKey) } - let raw = UserDefaults.standard.double(forKey: percussionVolumeKey) return Float(max(0.0, min(1.0, raw))) } set { diff --git a/slab/menuband/Sources/MenuBand/MenuBandPercussion.swift b/slab/menuband/Sources/MenuBand/MenuBandPercussion.swift index 83615fa00..09bc0d9fe 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandPercussion.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandPercussion.swift @@ -110,9 +110,9 @@ final class MenuBandPercussion { /// Master headroom so a fistful of simultaneous drums doesn't slam the /// limiter — the kit's raw layer volumes sum well past 1.0 by design. /// `outputLevel` is the user's independent percussion trim: 1.0 retains - /// the kit's historical loudness, while fresh installs begin at 0.80. + /// the kit's historical loudness, while fresh installs begin at 0.90. private let masterGain: Float = 0.34 - private var outputLevel: Float = 0.80 + private var outputLevel: Float = 0.90 /// Set the independent percussion trim. Snapshot under the same tiny lock /// used for staged hits so the audio thread sees one stable value per @@ -320,12 +320,10 @@ final class MenuBandPercussion { let edge = smoothstep(0.62, 0.70, radius) let outerClick = smoothstep(0.88, 0.965, radius) let hatEdge = edge * (1.0 - outerClick * 0.94) - // A broad inset contour between the center body and metal rim. It excites - // a short filtered-noise "wire" layer so the radial travel reads as - // kick/body → snare → hat instead of body fading straight into hat. - let snareBand = smoothstep(0.23, 0.31, radius) + // Five concentric instruments: kick, tom, snare, hat, click. + let tomBand = smoothstep(0.23, 0.31, radius) * (1.0 - smoothstep(0.40, 0.48, radius)) - let rimBand = smoothstep(0.40, 0.48, radius) + let snareBand = smoothstep(0.40, 0.48, radius) * (1.0 - smoothstep(0.62, 0.70, radius)) let tension = 1.0 + min(0.45, Double(anchors.count) * 0.10) let fingerDamping = min(0.68, Double(anchors.count) * 0.13) @@ -336,10 +334,8 @@ final class MenuBandPercussion { let pan = max(-1.0, min(1.0, sx * 0.72)) var voices: [Voice] = [] - // At the deepest part of the surface, anchor the physical model with - // the default kit kick's beater, descending body, and sub. Membrane - // modes are nearly absent at the exact centroid, removing the hollow - // modal tail; they blend back in toward the snare contour. + // A compact kick anchors the centroid; the modal membrane immediately + // around it supplies the pitched tom ring. let centerKick = 1.0 - smoothstep(0.10, 0.38, radius) if centerKick > 0.01 { let k = centerKick * strikeLevel @@ -389,6 +385,14 @@ final class MenuBandPercussion { (0.22 + hatEdge * 0.09) * (1.0 - outerClick * 0.58) * strikeLevel, 0.0002, contactDuration * 0.92, pan)) + if tomBand > 0.01 { + voices.append(makeVoice(.sine, 148 * tension, 0.105, + 0.25 * tomBand * strikeLevel, 0.0005, + 0.098, pan)) + voices.append(makeVoice(.triangle, 222 * tension, 0.052, + 0.12 * tomBand * strikeLevel, 0.0003, + 0.048, pan)) + } if snareBand > 0.01 { voices.append(makeVoice(.noise, 4300, 0.064, 0.24 * snareBand * strikeLevel, 0.0003, @@ -397,14 +401,6 @@ final class MenuBandPercussion { 0.045 * snareBand * strikeLevel, 0.0004, 0.035, pan)) } - if rimBand > 0.01 { - voices.append(makeVoice(.triangle, 760 * tension, 0.030, - 0.13 * rimBand * strikeLevel, 0.0003, - 0.027, pan)) - voices.append(makeVoice(.noise, 5600, 0.020, - 0.10 * rimBand * strikeLevel, 0.0002, - 0.018, pan)) - } if hatEdge > 0.02 { for frequency in Self.hatFreqs { voices.append(makeVoice(.square, frequency, @@ -434,8 +430,8 @@ final class MenuBandPercussion { // the last finger of a same-frame multitouch strike. pending.append(contentsOf: voices) let pulseDrum: Drum = edge > 0.55 ? .hatClosed - : (rimBand > 0.45 ? .block - : (snareBand > 0.45 ? .snare : .kick)) + : (snareBand > 0.45 ? .snare + : (tomBand > 0.45 ? .block : .kick)) pulses[pulseDrum.rawValue] = DrumPulse( at: now, level: min(1.0, Double(velocity) / 127.0) ) @@ -466,9 +462,9 @@ final class MenuBandPercussion { let edge = smoothstep(0.62, 0.70, distance) let outerClick = smoothstep(0.88, 0.965, distance) let centerKick = 1.0 - smoothstep(0.10, 0.38, distance) - let snareBand = smoothstep(0.23, 0.31, distance) + let tomBand = smoothstep(0.23, 0.31, distance) * (1.0 - smoothstep(0.40, 0.48, distance)) - let rimBand = smoothstep(0.40, 0.48, distance) + let snareBand = smoothstep(0.40, 0.48, distance) * (1.0 - smoothstep(0.62, 0.70, distance)) var voices: [Voice] = [] @@ -492,6 +488,12 @@ final class MenuBandPercussion { 0.003, 0.07 * centerKick * v, 0.0001, 0.0027, pan)) } + if tomBand > 0.01 { + voices.append(makeVoice(.sine, 148 * tension, + 0.022 * damping, + 0.10 * tomBand * v, + 0.0002, 0.020 * damping, pan)) + } if snareBand > 0.01 { voices.append(makeVoice(.noise, 3_500 * tension, 0.006 * damping, @@ -502,12 +504,6 @@ final class MenuBandPercussion { 0.065 * snareBand * v, 0.0002, 0.014 * damping, pan)) } - if rimBand > 0.01 { - voices.append(makeVoice(.triangle, 820 * tension, - 0.009 * damping, - 0.09 * rimBand * v, - 0.0001, 0.008 * damping, pan)) - } let hatLift = edge * (1.0 - outerClick * 0.92) if hatLift > 0.01 { for frequency in Self.hatFreqs { @@ -735,16 +731,16 @@ final class MenuBandPercussion { lock.unlock() } - enum DrumSkinZone: String { case center, snare, rim, hat, click } + enum DrumSkinZone: String { case kick, tom, snare, hat, click } static func drumSkinZone(at strike: CGPoint) -> DrumSkinZone { let distance = roundedTrackpadDistance( sx: Double(strike.x - 0.5) * 2, sy: Double(strike.y - 0.5) * 2 ) - if distance < 0.30 { return .center } - if distance < 0.46 { return .snare } - if distance < 0.64 { return .rim } + if distance < 0.30 { return .kick } + if distance < 0.46 { return .tom } + if distance < 0.64 { return .snare } if distance < 0.88 { return .hat } return .click } diff --git a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift index 173701198..d5b15273e 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift @@ -826,10 +826,11 @@ final class MenuBandSynth { // audible pumping on sustained pads/chords. AudioUnitSetParameter(cAU, kDynamicsProcessorParam_ReleaseTime, kAudioUnitScope_Global, 0, 0.18, 0) - // Neutral makeup. (kDynamicsProcessorParam_MasterGain is spelled - // _OverallGain in the Swift-imported header.) + // Gentle solo normalization. The percussion-triggered duck downstream + // still makes room when both buses play, while a lone key/voice no + // longer feels recessed against the system output. AudioUnitSetParameter(cAU, kDynamicsProcessorParam_OverallGain, - kAudioUnitScope_Global, 0, 0.0, 0) + kAudioUnitScope_Global, 0, 1.5, 0) let au = limiter.audioUnit // Fast attack catches chord/transient peaks; medium release avoids @@ -1637,12 +1638,13 @@ final class MenuBandSynth { /// deepest pocket; brighter percussion makes a lighter one. Chords duck a /// little further than single notes so polyphony cannot mask the groove. private func triggerMelodicDuck(depth: Float, velocity: UInt8, - hold: TimeInterval) { + hold: TimeInterval, + floor: Float = 0.48) { let velocityScale = Float(velocity) / 127 let polyphony = max(0, activeNotes.count - 1) let polyphonyDepth = min(Float(0.12), Float(polyphony) * 0.03) let scaledDepth = depth * (0.55 + 0.45 * velocityScale) + polyphonyDepth - let target = max(Float(0.48), 1 - scaledDepth) + let target = max(floor, 1 - scaledDepth) let apply = { [weak self] in guard let self else { return } self.melodicDuckTarget = min(self.melodicDuckTarget, target) @@ -2125,19 +2127,39 @@ final class MenuBandSynth { guard started else { return } _ = resumeAudioEngineIfNeeded() let zone = MenuBandPercussion.drumSkinZone(at: strike) - let depth: Float = zone == .center ? 0.40 - : (zone == .snare ? 0.25 : (zone == .rim ? 0.16 : 0.10)) + let depth: Float = zone == .kick ? 0.40 + : (zone == .tom ? 0.30 : (zone == .snare ? 0.22 : 0.10)) triggerMelodicDuck(depth: depth, velocity: velocity, - hold: zone == .center ? 0.10 : 0.06) + hold: zone == .kick ? 0.10 : 0.06) percussion.playDrumSkin(strike: strike, anchors: anchors, velocity: velocity) scheduleIdleSuspendAfterPercussion() } + /// Pressure-stage kick: reinforce the acoustic hit, then pulse the melodic + /// bus twice during recovery for an intentional pump/wobble. + func playSuperKick(strike: CGPoint, anchors: [CGPoint]) { + guard started else { return } + _ = resumeAudioEngineIfNeeded() + triggerMelodicDuck(depth: 0.72, velocity: 127, hold: 0.075, + floor: 0.28) + percussion.playDrumSkin(strike: strike, anchors: anchors, velocity: 127) + percussion.play(.kick, velocity: 127, pan: 0) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.090) { [weak self] in + self?.triggerMelodicDuck(depth: 0.46, velocity: 127, hold: 0.045, + floor: 0.38) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.180) { [weak self] in + self?.triggerMelodicDuck(depth: 0.30, velocity: 116, hold: 0.035, + floor: 0.44) + } + scheduleIdleSuspendAfterPercussion() + } + func playSynthSurface(strike: CGPoint, anchors: [CGPoint], velocity: UInt8) { guard started else { return } _ = resumeAudioEngineIfNeeded() let zone = MenuBandPercussion.drumSkinZone(at: strike) - triggerMelodicDuck(depth: zone == .center ? 0.34 : 0.16, + triggerMelodicDuck(depth: zone == .kick ? 0.34 : 0.16, velocity: velocity, hold: 0.07) percussion.playSynthSurface(strike: strike, anchors: anchors, velocity: velocity) scheduleIdleSuspendAfterPercussion() diff --git a/slab/menuband/Sources/MenuBand/PitchBendCursor.swift b/slab/menuband/Sources/MenuBand/PitchBendCursor.swift index ecc93a0c4..fced197a2 100644 --- a/slab/menuband/Sources/MenuBand/PitchBendCursor.swift +++ b/slab/menuband/Sources/MenuBand/PitchBendCursor.swift @@ -432,7 +432,7 @@ enum TrackpadDrumSkinPad { let body = NSBezierPath(roundedRect: chart, xRadius: 8, yRadius: 8) let accent = NSColor.controlAccentColor // These insets mirror the synthesis thresholds in - // MenuBandPercussion.drumSkinZone: click → hat → rim → snare → kick. + // MenuBandPercussion.drumSkinZone: click → hat → snare → tom → kick. // Equal pixel insets are geometrically correct because the chart and // physical trackpad share the same 1.64:1 aspect ratio. func insetZone(_ inset: CGFloat) -> NSBezierPath { @@ -466,13 +466,12 @@ enum TrackpadDrumSkinPad { body.addClip() clickColor.setFill(); body.fill() hatColor.setFill(); hatZone.fill() - rimColor.setFill(); rimZone.fill() - snareColor.setFill(); snareZone.fill() + snareColor.setFill(); rimZone.fill() // Dense parallel wires immediately read as the snare material. The - // kick fill below masks them out of the center without adding labels. + // inner tom and kick fills below mask them out of the center. NSGraphicsContext.saveGraphicsState() - snareZone.addClip() + rimZone.addClip() let wires = NSBezierPath() stride(from: chart.minX - chart.height, through: chart.maxX, by: 6).forEach { x in @@ -485,6 +484,7 @@ enum TrackpadDrumSkinPad { wires.stroke() NSGraphicsContext.restoreGraphicsState() + rimColor.setFill(); snareZone.fill() kickColor.setFill(); kickZone.fill() // Hat teeth span the widened playable metal band; the final bright rail is @@ -913,6 +913,17 @@ final class PitchBendCursorOverlayWindow: NSPanel { apply(image: image) } + /// A new hardware contact reclaims a surface that is still visible but + /// partway through its idle fade. Image updates alone must not inherit the + /// old alpha or allow the old timer to keep dissolving the new gesture. + func resumeFromFade() { + fadeTimer?.invalidate() + fadeTimer = nil + alphaValue = 1 + } + + var isFadeScheduled: Bool { fadeTimer != nil } + private func apply(image: NSImage) { let size = image.size let frame = NSRect(x: anchorScreenPoint.x - size.width / 2, diff --git a/slab/menuband/Sources/MenuBand/Shapedown.swift b/slab/menuband/Sources/MenuBand/Shapedown.swift index 67b2c214e..35a81d4db 100644 --- a/slab/menuband/Sources/MenuBand/Shapedown.swift +++ b/slab/menuband/Sources/MenuBand/Shapedown.swift @@ -321,6 +321,8 @@ final class ShapedownGestureTap { /// Fired (on main) when the trackpad is physically clicked in — the wall /// uses it to pin the current shape permanently. var onClick: (() -> Void)? + /// Full ordinary button state for clients that need click-and-hold. + var onPhysicalClickChanged: ((Bool) -> Void)? /// Note keys pass through untouched, but Shapedown sees key-down first so /// TYPE mode (which consumes musical keys later in the event chain) cannot /// prevent the wall from selecting the matching Notepat color. @@ -347,7 +349,14 @@ final class ShapedownGestureTap { return Unmanaged.passUnretained(event) } if type == .leftMouseDown { - DispatchQueue.main.async { me.onClick?() } + DispatchQueue.main.async { + me.onClick?() + me.onPhysicalClickChanged?(true) + } + } else if type == .leftMouseUp { + DispatchQueue.main.async { + me.onPhysicalClickChanged?(false) + } } else if type == .keyDown { let keyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode)) DispatchQueue.main.async { me.onKeyDown?(keyCode) } diff --git a/slab/menuband/Tests/MenuBandTests/ShapedownTests.swift b/slab/menuband/Tests/MenuBandTests/ShapedownTests.swift index 6f7442cdc..acd7dacb3 100644 --- a/slab/menuband/Tests/MenuBandTests/ShapedownTests.swift +++ b/slab/menuband/Tests/MenuBandTests/ShapedownTests.swift @@ -57,11 +57,11 @@ final class ShapedownTests: XCTestCase { func testDrumSkinZonesFollowRoundedTrackpadInsets() { XCTAssertEqual(MenuBandPercussion.drumSkinZone( - at: CGPoint(x: 0.5, y: 0.5)), .center) + at: CGPoint(x: 0.5, y: 0.5)), .kick) XCTAssertEqual(MenuBandPercussion.drumSkinZone( - at: CGPoint(x: 0.5, y: 0.70)), .snare) + at: CGPoint(x: 0.5, y: 0.70)), .tom) XCTAssertEqual(MenuBandPercussion.drumSkinZone( - at: CGPoint(x: 0.5, y: 0.80)), .rim) + at: CGPoint(x: 0.5, y: 0.80)), .snare) XCTAssertEqual(MenuBandPercussion.drumSkinZone( at: CGPoint(x: 0.5, y: 0.88)), .hat) XCTAssertEqual(MenuBandPercussion.drumSkinZone( @@ -71,11 +71,11 @@ final class ShapedownTests: XCTestCase { // from normalized center than Y; this is trackpad geometry, not a // circular or diamond approximation. XCTAssertEqual(MenuBandPercussion.drumSkinZone( - at: CGPoint(x: 0.75, y: 0.5)), .center) + at: CGPoint(x: 0.75, y: 0.5)), .kick) XCTAssertEqual(MenuBandPercussion.drumSkinZone( - at: CGPoint(x: 0.82, y: 0.5)), .snare) + at: CGPoint(x: 0.82, y: 0.5)), .tom) XCTAssertEqual(MenuBandPercussion.drumSkinZone( - at: CGPoint(x: 0.88, y: 0.5)), .rim) + at: CGPoint(x: 0.88, y: 0.5)), .snare) XCTAssertEqual(MenuBandPercussion.drumSkinZone( at: CGPoint(x: 0.93, y: 0.5)), .hat) XCTAssertEqual(MenuBandPercussion.drumSkinZone( @@ -258,6 +258,34 @@ final class ShapedownTests: XCTestCase { XCTAssertTrue(secondUp.activeByID.isEmpty) } + func testFreshTrackpadContactCancelsOverlayFadeAndRestoresOpacity() { + let overlay = PitchBendCursorOverlayWindow() + overlay.show(image: NSImage(size: NSSize(width: 20, height: 20)), + atScreenPoint: .zero) + overlay.fadeOut(after: 1, duration: 1) + XCTAssertTrue(overlay.isFadeScheduled) + overlay.alphaValue = 0.35 + + overlay.resumeFromFade() + + XCTAssertFalse(overlay.isFadeScheduled) + XCTAssertEqual(overlay.alphaValue, 1) + overlay.dismiss() + } + + func testScratchArmsSoonerButNeverFromOneMovementFrame() { + XCTAssertFalse(AppDelegate.shouldArmTrackpadScratch(frames: 1, travel: 0.20)) + XCTAssertFalse(AppDelegate.shouldArmTrackpadScratch(frames: 2, travel: 0.0119)) + XCTAssertTrue(AppDelegate.shouldArmTrackpadScratch(frames: 2, travel: 0.012)) + } + + func testPhysicalClickBoostsScratchButRestingTouchDoesNot() { + XCTAssertEqual(AppDelegate.physicalClickScratchMultiplier( + clicked: false), 1, accuracy: 0.000_001) + XCTAssertEqual(AppDelegate.physicalClickScratchMultiplier( + clicked: true), 1.55, accuracy: 0.000_001) + } + func testSamePadReplacementRetriggersKitVoice() { let first = CGPoint(x: 0.30, y: 0.80) let down = TrackpadPercussionPad.transition( diff --git a/slab/menuband/fastlane/Fastfile b/slab/menuband/fastlane/Fastfile index 014b3a753..03dc2420b 100644 --- a/slab/menuband/fastlane/Fastfile +++ b/slab/menuband/fastlane/Fastfile @@ -9,8 +9,8 @@ API_KEY_ID = "S4TQKG6U99" # Admin role — required for cloud signing (cert/prof API_ISSUER = "69a6de78-fa3c-47e3-e053-5b8c7c11a4d1" API_KEY_PATH = File.expand_path("~/.appstoreconnect/private_keys/AuthKey_S4TQKG6U99.p8") BUNDLE = "computer.aesthetic.menuband" -VERSION = ENV.fetch("MENUBAND_VERSION", "1.6.6") -BUILD_NUMBER = ENV.fetch("MENUBAND_BUILD_NUMBER", "166") +VERSION = ENV.fetch("MENUBAND_VERSION", "1.6.7") +BUILD_NUMBER = ENV.fetch("MENUBAND_BUILD_NUMBER", "167") # Where `xcodebuild -exportArchive` actually drops the package: slab/menuband/ # build/export/. This used to point at /tmp/MenuBand-export/, a directory that # does not exist and never did — the `upload` lane would have failed the moment diff --git a/slab/menuband/fastlane/metadata/en-US/release_notes.txt b/slab/menuband/fastlane/metadata/en-US/release_notes.txt index 4594a2757..f33cbb52f 100644 --- a/slab/menuband/fastlane/metadata/en-US/release_notes.txt +++ b/slab/menuband/fastlane/metadata/en-US/release_notes.txt @@ -1 +1 @@ -Your trackpad is now a percussion instrument. Focus Menu Band, tap the multi-touch drum skin, and move from deep center hits toward brighter edges; Tab cycles physical, synthetic, FX, and kit surfaces. Melodic parts automatically make room for each strike, score percussion follows its written note lengths, and the whole mix is louder and better balanced. +Your trackpad is now a percussion instrument. Focus Menu Band and play its clearer five-zone surface: kick in the center, then tom, snare, hat, and click toward the edge. Physically click the kick for a deeper pumping hit, or hold a click while dragging for a more responsive skin scratch. Tab cycles physical, synthetic, FX, and kit surfaces. Returning touches restore a fading pad immediately, solo keys and percussion are better normalized, and melodic parts automatically make room for each strike. diff --git a/slab/menuband/fastlane/report.xml b/slab/menuband/fastlane/report.xml index afc37765a..c9a5cfb42 100644 --- a/slab/menuband/fastlane/report.xml +++ b/slab/menuband/fastlane/report.xml @@ -5,12 +5,12 @@ - + - + diff --git a/slab/menuband/project.yml b/slab/menuband/project.yml index 8e38ecb17..05b60d494 100644 --- a/slab/menuband/project.yml +++ b/slab/menuband/project.yml @@ -38,8 +38,8 @@ settings: # sat a build behind through the whole 1.5.3 release. # The direct-download build keeps its own literals in Info.plist (SwiftPM # never sees these settings); bump both when releasing. - MARKETING_VERSION: "1.6.6" - CURRENT_PROJECT_VERSION: "166" + MARKETING_VERSION: "1.6.7" + CURRENT_PROJECT_VERSION: "167" packages: ACMacAudio: