diff --git a/slab/menuband/Sources/MenuBand/PitchBendCursor.swift b/slab/menuband/Sources/MenuBand/PitchBendCursor.swift index 66d9f526db..15b5b0cfeb 100644 --- a/slab/menuband/Sources/MenuBand/PitchBendCursor.swift +++ b/slab/menuband/Sources/MenuBand/PitchBendCursor.swift @@ -1,4 +1,6 @@ import AppKit +import Metal +import MetalKit /// Sparse inertial energy field shared by the physical and electronic /// surfaces. Charges persist, merge locally, and decay; audio and drawing read @@ -94,6 +96,129 @@ struct TrackpadSurfaceEnergy { } } +/// A damped, rim-fixed membrane advanced from the same touch clock as the +/// instrument. Every cell exchanges velocity with its neighbors, so a strike +/// bends and rings the whole sheet instead of moving an isolated decoration. +struct TrackpadMembraneSimulation { + struct Snapshot { + let columns: Int + let rows: Int + let heights: [Double] + + var isFlat: Bool { + !heights.contains { abs($0) > 0.000_5 } + } + + func height(at point: CGPoint) -> CGFloat { + let x = max(0, min(1, point.x)) * CGFloat(columns - 1) + let y = max(0, min(1, point.y)) * CGFloat(rows - 1) + let x0 = min(columns - 1, Int(floor(x))) + let y0 = min(rows - 1, Int(floor(y))) + let x1 = min(columns - 1, x0 + 1) + let y1 = min(rows - 1, y0 + 1) + let tx = Double(x - CGFloat(x0)) + let ty = Double(y - CGFloat(y0)) + func sample(_ column: Int, _ row: Int) -> Double { + heights[row * columns + column] + } + let bottom = sample(x0, y0) * (1 - tx) + sample(x1, y0) * tx + let top = sample(x0, y1) * (1 - tx) + sample(x1, y1) * tx + return CGFloat(bottom * (1 - ty) + top * ty) + } + } + + // 41:25 follows the physical 1.64:1 surface. The earlier 23×15 sheet was + // inexpensive at menubar size but visibly softened its contours when the + // same renderer was enlarged to a physical trackpad. This remains only + // 1,025 cells, keeping the touch/audio lane comfortably lightweight. + private let columns = 41 + private let rows = 25 + private var heights: [Double] = Array(repeating: 0, count: 41 * 25) + private var velocities: [Double] = Array(repeating: 0, count: 41 * 25) + private var lastTime: Double = 0 + + mutating func reset(at now: Double) { + heights = Array(repeating: 0, count: columns * rows) + velocities = Array(repeating: 0, count: columns * rows) + lastTime = now + } + + mutating func impulse(at point: CGPoint, amount: Double) { + for row in 1..<(rows - 1) { + for column in 1..<(columns - 1) { + let normalized = CGPoint( + x: CGFloat(column) / CGFloat(columns - 1), + y: CGFloat(row) / CGFloat(rows - 1) + ) + let dx = Double(normalized.x - point.x) * 1.64 + let dy = Double(normalized.y - point.y) + let falloff = exp(-(dx * dx + dy * dy) / (2 * 0.105 * 0.105)) + // Taut fabric takes a compact shove instead of a broad fluid + // splash. The stronger damping in `step` turns this into one + // readable rebound rather than a long train of ripples. + velocities[row * columns + column] += max(0, amount) * falloff * 6.2 + } + } + } + + mutating func advance(to now: Double, touches: [CGPoint]) { + if lastTime == 0 { lastTime = now; return } + let elapsed = max(0, min(0.080, now - lastTime)) + lastTime = now + guard elapsed > 0 else { return } + let stepCount = max(1, Int(ceil(elapsed / (1.0 / 240.0)))) + let dt = elapsed / Double(stepCount) + for _ in 0.. Snapshot { + Snapshot(columns: columns, rows: rows, heights: heights) + } +} + private enum TrackpadEnergyVisual { static func draw(_ charges: [TrackpadSurfaceEnergy.Charge], in chart: NSRect, accent: NSColor) { @@ -119,6 +244,71 @@ private enum TrackpadEnergyVisual { } } +/// Lights the original drum chart as a height field. Geometry never slides: +/// local slope produces a moving highlight and shadow over the existing ink. +private enum TrackpadMembraneLighting { + static func draw(_ membrane: TrackpadMembraneSimulation.Snapshot?, + in chart: NSRect) { + guard let membrane, !membrane.isFlat else { return } + // Render in the simulation's normalized coordinate grid and let + // AppKit scale the tiny texture. The old code rasterized every logical + // chart point on every frame (and Retina scaled it once more), doing + // ~10,000 bilinear samples and allocating ~42 KB at 60 Hz. A 2× grid + // retains smooth slopes while keeping this decoration out of the + // notation surface's frame budget. + let width = max(2, membrane.columns * 2 - 1) + let height = max(2, membrane.rows * 2 - 1) + guard let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: width, pixelsHigh: height, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, + bitmapFormat: [], bytesPerRow: 0, bitsPerPixel: 0 + ), let data = bitmap.bitmapData else { return } + let dx = 1 / CGFloat(width - 1) + let dy = 1 / CGFloat(height - 1) + for row in 0..= 0 ? alpha : 0 + data[offset] = channel + data[offset + 1] = channel + data[offset + 2] = channel + data[offset + 3] = alpha + } + } + let lighting = NSImage(size: NSSize(width: width, height: height)) + lighting.addRepresentation(bitmap) + let context = NSGraphicsContext.current + let priorInterpolation = context?.imageInterpolation + context?.imageInterpolation = .high + lighting.draw(in: chart, + from: NSRect(x: 0, y: 0, width: width, height: height), + operation: .sourceOver, fraction: 1) + if let priorInterpolation { context?.imageInterpolation = priorInterpolation } + } +} + /// Finger count chooses articulation; vertical position chooses the kit half. /// Compound two-finger gestures enter a cooldown until that half is clear, so /// lifting one finger never fires an unintended single-finger hit. @@ -406,26 +596,35 @@ enum TrackpadDrumSkinPad { } static func image(touches: [CGPoint], - energy: [TrackpadSurfaceEnergy.Charge] = []) -> NSImage { + energy: [TrackpadSurfaceEnergy.Charge] = [], + membrane: TrackpadMembraneSimulation.Snapshot? = nil, + appearance: NSAppearance? = nil) -> NSImage { // 1.64:1, matching the physical trackpad rather than the square FX // cursor. Large enough to read the four inset material contours. let size = NSSize(width: 140, height: 88) - let appearance = NSApp.effectiveAppearance + // Overlay panels can live on a screen whose appearance differs from + // NSApp's cached effective appearance. Resolve from the presenting + // view when supplied so light mode never receives a dark skin texture. + let appearance = appearance ?? NSApp.effectiveAppearance let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua return NSImage(size: size, flipped: false) { rect in if #available(macOS 11.0, *) { appearance.performAsCurrentDrawingAppearance { - draw(in: rect, touches: touches, energy: energy, isDark: isDark) + draw(in: rect, touches: touches, energy: energy, + membrane: membrane, isDark: isDark) } } else { - draw(in: rect, touches: touches, energy: energy, isDark: isDark) + draw(in: rect, touches: touches, energy: energy, + membrane: membrane, isDark: isDark) } return true } } private static func draw(in rect: NSRect, touches: [CGPoint], - energy: [TrackpadSurfaceEnergy.Charge], isDark: Bool) { + energy: [TrackpadSurfaceEnergy.Charge], + membrane: TrackpadMembraneSimulation.Snapshot?, + isDark: Bool) { // Match the physical trackpad's wide rounded rectangle instead of // depicting the synthesis surface as a circular drum. let chart = rect.insetBy(dx: 4, dy: 4) @@ -447,21 +646,23 @@ enum TrackpadDrumSkinPad { let rimZone = insetZone(14.4) let snareZone = insetZone(21.6) let kickZone = insetZone(28) + // Preserve the original natural material palette. System accent is a + // performance signal on the rim and contacts, not the skin's pigment. let clickColor = isDark - ? NSColor(white: 0.82, alpha: 1) - : NSColor(srgbRed: 0.98, green: 0.95, blue: 0.82, alpha: 1) + ? NSColor(srgbRed: 0.48, green: 0.43, blue: 0.34, alpha: 1) + : NSColor(srgbRed: 0.97, green: 0.91, blue: 0.77, alpha: 1) // cream let hatColor = isDark - ? NSColor(white: 0.29, alpha: 1) - : NSColor(srgbRed: 0.66, green: 0.68, blue: 0.65, alpha: 1) + ? NSColor(srgbRed: 0.34, green: 0.39, blue: 0.29, alpha: 1) + : NSColor(srgbRed: 0.72, green: 0.77, blue: 0.62, alpha: 1) // sage let rimColor = isDark - ? NSColor(srgbRed: 0.58, green: 0.31, blue: 0.16, alpha: 1) - : NSColor(srgbRed: 0.70, green: 0.38, blue: 0.18, alpha: 1) + ? NSColor(srgbRed: 0.43, green: 0.34, blue: 0.20, alpha: 1) + : NSColor(srgbRed: 0.84, green: 0.68, blue: 0.40, alpha: 1) // ochre let snareColor = isDark - ? NSColor(srgbRed: 0.38, green: 0.22, blue: 0.15, alpha: 1) - : NSColor(srgbRed: 0.80, green: 0.58, blue: 0.38, alpha: 1) + ? NSColor(srgbRed: 0.43, green: 0.25, blue: 0.17, alpha: 1) + : NSColor(srgbRed: 0.76, green: 0.45, blue: 0.30, alpha: 1) // terracotta let kickColor = isDark - ? NSColor(srgbRed: 0.075, green: 0.055, blue: 0.045, alpha: 1) - : NSColor(srgbRed: 0.34, green: 0.20, blue: 0.13, alpha: 1) + ? NSColor(srgbRed: 0.24, green: 0.15, blue: 0.10, alpha: 1) + : NSColor(srgbRed: 0.48, green: 0.29, blue: 0.19, alpha: 1) // umber NSGraphicsContext.saveGraphicsState() body.addClip() clickColor.setFill(); body.fill() @@ -517,8 +718,9 @@ enum TrackpadDrumSkinPad { clickRail.stroke() TrackpadEnergyVisual.draw(energy, in: chart, accent: accent) + TrackpadMembraneLighting.draw(membrane, in: chart) let boundaryColor = (isDark ? NSColor.white : NSColor.black) - .withAlphaComponent(0.32) + .withAlphaComponent(isDark ? 0.32 : 0.50) boundaryColor.setStroke() for (zone, width) in [(hatZone, 0.9), (rimZone, 2.0), (snareZone, 0.9), (kickZone, 1.3)] { @@ -844,6 +1046,7 @@ enum PitchBendCursor { /// cursor so the chart visibly replaces it. final class PitchBendCursorOverlayWindow: NSPanel { private let imageView = NSImageView() + private let tracktrampView = TracktrampMetalView() private var anchorScreenPoint = NSPoint.zero private var fadeTimer: Timer? @@ -869,6 +1072,9 @@ final class PitchBendCursorOverlayWindow: NSPanel { imageView.frame = frame imageView.imageScaling = .scaleNone contentView?.addSubview(imageView) + tracktrampView.frame = frame + tracktrampView.isHidden = true + contentView?.addSubview(tracktrampView) } /// `screenPoint` is the absolute center chosen by AppDelegate, normally @@ -877,6 +1083,8 @@ final class PitchBendCursorOverlayWindow: NSPanel { fadeTimer?.invalidate() fadeTimer = nil anchorScreenPoint = screenPoint + tracktrampView.isHidden = true + imageView.isHidden = false apply(image: image) alphaValue = 1 if !isVisible { orderFrontRegardless() } @@ -885,14 +1093,52 @@ final class PitchBendCursorOverlayWindow: NSPanel { /// Update only the chart image (puck position changes); window /// position stays put unless the caller supplies a refreshed anchor. func update(image: NSImage) { + tracktrampView.isHidden = true + imageView.isHidden = false apply(image: image) } func update(image: NSImage, atScreenPoint screenPoint: NSPoint) { anchorScreenPoint = screenPoint + tracktrampView.isHidden = true + imageView.isHidden = false apply(image: image) } + /// Present the physical skin through the cached Metal texture. Only the + /// small height field changes between frames; no NSImage is constructed. + func showTracktramp(_ membrane: TrackpadMembraneSimulation.Snapshot, + touches: [CGPoint], + atScreenPoint screenPoint: NSPoint) { + fadeTimer?.invalidate() + fadeTimer = nil + anchorScreenPoint = screenPoint + applyTracktrampFrame() + imageView.isHidden = true + tracktrampView.isHidden = false + tracktrampView.update(membrane, touches: touches) + alphaValue = 1 + if !isVisible { orderFrontRegardless() } + } + + func updateTracktramp(_ membrane: TrackpadMembraneSimulation.Snapshot, + touches: [CGPoint], + atScreenPoint screenPoint: NSPoint) { + anchorScreenPoint = screenPoint + applyTracktrampFrame() + imageView.isHidden = true + tracktrampView.isHidden = false + tracktrampView.update(membrane, touches: touches) + } + + private func applyTracktrampFrame() { + let size = TracktrampMetalView.logicalSize + setFrame(NSRect(x: anchorScreenPoint.x - size.width / 2, + y: anchorScreenPoint.y - size.height / 2, + width: size.width, height: size.height), display: false) + tracktrampView.frame = NSRect(origin: .zero, size: size) + } + /// 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. @@ -946,10 +1192,250 @@ final class PitchBendCursorOverlayWindow: NSPanel { fadeTimer?.invalidate() fadeTimer = nil alphaValue = 1 + tracktrampView.pause() orderOut(nil) } } +/// GPU presentation for the physical drum skin. The AppKit artwork is cached +/// as a texture and a single fragment pass backward-warps its pixels from the +/// membrane slope. This view has no clock of its own: the existing 60 Hz +/// presentation timer supplies snapshots, while touch/audio callbacks remain +/// entirely outside the renderer. +final class TracktrampMetalView: MTKView, MTKViewDelegate { + static let logicalSize = NSSize(width: 140, height: 88) + + private struct Uniforms { + var columns: UInt32 + var rows: UInt32 + // A restrained fabric pull, not a lens/refraction effect. + var warp: Float = 0.046 + var lighting: Float = 0.14 + var touchCount: UInt32 = 0 + var _padding: UInt32 = 0 + var logicalSize = SIMD2(140, 88) + var accent = SIMD4(0.2, 0.48, 1, 1) + // kick, tom, snare, hat; click follows separately for 16-byte layout. + var zoneLevels = SIMD4(repeating: 0) + var clickLevel: Float = 0 + var _zonePadding = SIMD3(repeating: 0) + } + + private var queue: MTLCommandQueue? + private var pipeline: MTLRenderPipelineState? + private var baseTexture: MTLTexture? + private var heightBuffer: MTLBuffer? + private var touchBuffer: MTLBuffer? + private var uniforms = Uniforms(columns: 1, rows: 1) + private var cachedScale: CGFloat = 0 + private var cachedLogicalSize = NSSize.zero + private var drewFlatFrame = false + private var zoneLevels = [Float](repeating: 0, count: 5) + private var lastZoneUpdate = CACurrentMediaTime() + + init() { + let device = MTLCreateSystemDefaultDevice() + super.init(frame: NSRect(origin: .zero, size: Self.logicalSize), + device: device) + wantsLayer = true + // MTKView's CAMetalLayer defaults to opaque even when the render-pass + // clear color has zero alpha. Keep the rounded corners truly clear. + layer?.isOpaque = false + layer?.backgroundColor = NSColor.clear.cgColor + clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0) + framebufferOnly = true + enableSetNeedsDisplay = true + isPaused = true + preferredFramesPerSecond = 60 + delegate = self + queue = device?.makeCommandQueue() + if let device { buildPipeline(device) } + } + + required init(coder: NSCoder) { + fatalError("TracktrampMetalView is code-only") + } + + override var isOpaque: Bool { false } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + rebuildTextureIfNeeded(force: true) + } + + override func viewDidChangeBackingProperties() { + super.viewDidChangeBackingProperties() + rebuildTextureIfNeeded(force: true) + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + rebuildTextureIfNeeded(force: true) + } + + func update(_ snapshot: TrackpadMembraneSimulation.Snapshot, + touches: [CGPoint]) { + guard !isHidden, window?.isVisible == true, let device else { return } + rebuildTextureIfNeeded(force: false) + let byteCount = snapshot.heights.count * MemoryLayout.stride + if heightBuffer == nil || heightBuffer!.length < byteCount { + heightBuffer = device.makeBuffer(length: byteCount, options: .storageModeShared) + } + guard let heightBuffer else { return } + let destination = heightBuffer.contents().bindMemory(to: Float.self, + capacity: snapshot.heights.count) + for index in snapshot.heights.indices { + destination[index] = Float(snapshot.heights[index]) + } + uniforms.columns = UInt32(snapshot.columns) + uniforms.rows = UInt32(snapshot.rows) + let visibleTouches = touches.prefix(10) + let touchBytes = max(1, visibleTouches.count) * MemoryLayout>.stride + if touchBuffer == nil || touchBuffer!.length < touchBytes { + touchBuffer = device.makeBuffer(length: touchBytes, options: .storageModeShared) + } + if let touchBuffer { + let destination = touchBuffer.contents().bindMemory( + to: SIMD2.self, capacity: max(1, visibleTouches.count)) + for (index, touch) in visibleTouches.enumerated() { + destination[index] = SIMD2(Float(touch.x), Float(touch.y)) + } + } + uniforms.touchCount = UInt32(visibleTouches.count) + uniforms.logicalSize = SIMD2( + Float(max(1, bounds.width)), Float(max(1, bounds.height)) + ) + let now = CACurrentMediaTime() + let elapsed = min(0.1, max(0, now - lastZoneUpdate)) + lastZoneUpdate = now + let decay = Float(exp(-elapsed / 0.24)) + for index in zoneLevels.indices { zoneLevels[index] *= decay } + for touch in visibleTouches { + let index: Int + switch MenuBandPercussion.drumSkinZone(at: touch) { + case .kick: index = 0 + case .tom: index = 1 + case .snare: index = 2 + case .hat: index = 3 + case .click: index = 4 + } + zoneLevels[index] = 1 + } + uniforms.zoneLevels = SIMD4(zoneLevels[0], zoneLevels[1], + zoneLevels[2], zoneLevels[3]) + uniforms.clickLevel = zoneLevels[4] + let accent = KeyboardIconRenderer.accent.usingColorSpace(.sRGB) + ?? NSColor.controlAccentColor + uniforms.accent = SIMD4(Float(accent.redComponent), Float(accent.greenComponent), + Float(accent.blueComponent), Float(accent.alphaComponent)) + // Paint one final undistorted frame, then stop submitting GPU work. + if snapshot.isFlat && visibleTouches.isEmpty + && (zoneLevels.max() ?? 0) < 0.01 { + guard !drewFlatFrame else { return } + drewFlatFrame = true + } else { + drewFlatFrame = false + } + // MTKView is paused and dirty-driven; draw exactly when the existing + // capped presentation path hands us a fresh visual snapshot. + draw() + } + + func pause() { drewFlatFrame = false } + + private func buildPipeline(_ device: MTLDevice) { + do { + let library: MTLLibrary + if let url = Bundle.appResources.url(forResource: "TracktrampShaders", + withExtension: "metallib") { + library = try device.makeLibrary(URL: url) + } else if let url = Bundle.appResources.url(forResource: "TracktrampShaders", + withExtension: "metalsource"), + let source = try? String(contentsOf: url, encoding: .utf8) { + library = try device.makeLibrary(source: source, options: nil) + } else { + library = try device.makeDefaultLibrary(bundle: .module) + } + let descriptor = MTLRenderPipelineDescriptor() + descriptor.vertexFunction = library.makeFunction(name: "tracktramp_vertex") + descriptor.fragmentFunction = library.makeFunction(name: "tracktramp_fragment") + descriptor.colorAttachments[0].pixelFormat = colorPixelFormat + descriptor.colorAttachments[0].isBlendingEnabled = true + descriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha + descriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha + descriptor.colorAttachments[0].sourceAlphaBlendFactor = .one + descriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha + pipeline = try device.makeRenderPipelineState(descriptor: descriptor) + } catch { + NSLog("MenuBand: tracktramp Metal pipeline failed: \(error)") + } + } + + private func rebuildTextureIfNeeded(force: Bool) { + guard let device else { return } + let scale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2 + let logicalSize = NSSize( + width: max(1, bounds.width), height: max(1, bounds.height) + ) + guard force || baseTexture == nil || scale != cachedScale + || logicalSize != cachedLogicalSize else { return } + cachedScale = scale + cachedLogicalSize = logicalSize + let image = TrackpadDrumSkinPad.image(touches: [], energy: [], membrane: nil, + appearance: effectiveAppearance) + let pixelWidth = max(1, Int(ceil(logicalSize.width * scale))) + let pixelHeight = max(1, Int(ceil(logicalSize.height * scale))) + guard let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, + pixelsWide: pixelWidth, + pixelsHigh: pixelHeight, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + // Keep standard RGBA for + // MTKTextureLoader. `.alphaFirst` + // turns the natural skin blue. + bitmapFormat: [], + bytesPerRow: 0, + bitsPerPixel: 0), + let context = NSGraphicsContext(bitmapImageRep: bitmap) else { return } + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + context.cgContext.scaleBy(x: scale, y: scale) + image.draw(in: NSRect(origin: .zero, size: logicalSize), + from: .zero, operation: .copy, fraction: 1) + NSGraphicsContext.restoreGraphicsState() + guard let cgImage = bitmap.cgImage else { return } + baseTexture = try? MTKTextureLoader(device: device).newTexture( + cgImage: cgImage, + options: [.SRGB: true, .origin: MTKTextureLoader.Origin.topLeft] + ) + drewFlatFrame = false + } + + func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {} + + func draw(in view: MTKView) { + guard let drawable = currentDrawable, + let pass = currentRenderPassDescriptor, + let queue, let pipeline, let baseTexture, let heightBuffer, + let touchBuffer, + let command = queue.makeCommandBuffer(), + let encoder = command.makeRenderCommandEncoder(descriptor: pass) else { return } + encoder.setRenderPipelineState(pipeline) + encoder.setFragmentTexture(baseTexture, index: 0) + encoder.setFragmentBuffer(heightBuffer, offset: 0, index: 0) + encoder.setFragmentBytes(&uniforms, length: MemoryLayout.stride, + index: 1) + encoder.setFragmentBuffer(touchBuffer, offset: 0, index: 2) + encoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4) + encoder.endEncoding() + command.present(drawable) + command.commit() + } +} + extension NSCursor { /// Convenience to push the neutral pitch-bend cursor onto the /// stack. Mirrors the original `PitchBendCursor.shared.push()` diff --git a/slab/menuband/Sources/MenuBand/TracktrampShaders.metalsource b/slab/menuband/Sources/MenuBand/TracktrampShaders.metalsource new file mode 100644 index 0000000000..460e8ce21d --- /dev/null +++ b/slab/menuband/Sources/MenuBand/TracktrampShaders.metalsource @@ -0,0 +1,165 @@ +#include +using namespace metal; + +struct TracktrampVertexOut { + float4 position [[position]]; + float2 uv; +}; + +struct TracktrampUniforms { + uint columns; + uint rows; + float warp; + float lighting; + uint touchCount; + uint padding; + float2 logicalSize; + float4 accent; + float4 zoneLevels; + float clickLevel; + float3 zonePadding; +}; + +vertex TracktrampVertexOut tracktramp_vertex(uint id [[vertex_id]]) { + const float2 positions[4] = { + float2(-1.0, -1.0), float2(1.0, -1.0), + float2(-1.0, 1.0), float2(1.0, 1.0) + }; + TracktrampVertexOut out; + out.position = float4(positions[id], 0.0, 1.0); + out.uv = positions[id] * 0.5 + 0.5; // normalized, bottom-left + return out; +} + +static float membraneHeight(device const float *heights, uint columns, + uint rows, float2 uv) { + float2 grid = clamp(uv, 0.0, 1.0) * float2(columns - 1, rows - 1); + uint2 a = uint2(floor(grid)); + uint2 b = min(a + 1, uint2(columns - 1, rows - 1)); + float2 f = fract(grid); + float bottom = mix(heights[a.y * columns + a.x], + heights[a.y * columns + b.x], f.x); + float top = mix(heights[b.y * columns + a.x], + heights[b.y * columns + b.x], f.x); + return mix(bottom, top, f.y); +} + +static float roundedTrackpadDistance(float2 point) { + float2 scaled = (point - 0.5) * 2.0; + const float halfWidth = 0.82; + const float halfHeight = 0.50; + const float corner = 0.055; + float2 p = abs(scaled) * float2(halfWidth, halfHeight); + float2 q = p - float2(halfWidth - corner, halfHeight - corner); + float outside = length(max(q, 0.0)); + float inside = min(max(q.x, q.y), 0.0); + float inwardDepth = max(0.0, -(outside + inside - corner)); + return 1.0 - min(1.0, inwardDepth / halfHeight); +} + +static uint trackpadZone(float2 point) { + float distance = roundedTrackpadDistance(point); + if (distance < 0.30) return 0; // kick + if (distance < 0.46) return 1; // tom + if (distance < 0.64) return 2; // snare + if (distance < 0.88) return 3; // hat + return 4; // click +} + +fragment float4 tracktramp_fragment( + TracktrampVertexOut in [[stage_in]], + texture2d base [[texture(0)]], + device const float *heights [[buffer(0)]], + constant TracktrampUniforms &u [[buffer(1)]], + device const float2 *touches [[buffer(2)]]) { + constexpr sampler linearSampler(coord::normalized, address::clamp_to_edge, + filter::linear); + float2 cell = 1.0 / float2(u.columns - 1, u.rows - 1); + float left = membraneHeight(heights, u.columns, u.rows, + in.uv - float2(cell.x, 0)); + float right = membraneHeight(heights, u.columns, u.rows, + in.uv + float2(cell.x, 0)); + float below = membraneHeight(heights, u.columns, u.rows, + in.uv - float2(0, cell.y)); + float above = membraneHeight(heights, u.columns, u.rows, + in.uv + float2(0, cell.y)); + float centerHeight = membraneHeight(heights, u.columns, u.rows, in.uv); + float2 gradient = float2(right - left, above - below); + + // The fixed rim smoothly kills displacement across the outer 12%. + float rimDistance = min(min(in.uv.x, 1.0 - in.uv.x), + min(in.uv.y, 1.0 - in.uv.y)); + float freeSkin = smoothstep(0.0, 0.12, rimDistance); + float2 warped = clamp(in.uv - gradient * u.warp * freeSkin, 0.0, 1.0); + + // AppKit/CG textures have a top-left first row. This is the sole Y flip; + // simulation, touches, gradients, and shader UVs remain bottom-left. + float4 color = base.sample(linearSampler, float2(warped.x, 1.0 - warped.y)); + + // Enforce the base chart's rounded silhouette after texture filtering. + float2 point = in.uv * float2(140.0, 88.0); + float2 center = float2(70.0, 44.0); + float2 halfSize = float2(66.0, 40.0); + float radius = 8.0; + float2 rounded = abs(point - center) - (halfSize - radius); + float signedDistance = length(max(rounded, 0.0)) + + min(max(rounded.x, rounded.y), 0.0) - radius; + float roundedMask = 1.0 - smoothstep(-0.65, 0.65, signedDistance); + color *= roundedMask; + float light = dot(gradient, normalize(float2(-0.55, 0.84))) + * u.lighting * freeSkin; + float membraneEnergy = abs(centerHeight) * 0.42 + length(gradient) * 1.65; + float accentWave = clamp(membraneEnergy, 0.0, 0.44) + * freeSkin * color.a; + color.rgb = mix(color.rgb, u.accent.rgb, accentWave); + color.rgb *= 1.0 + min(light, 0.0) * 2.2; + color.rgb = clamp(color.rgb, 0.0, 1.0); + + // Tie the chassis to the same accent used by Menu Band's active keys. + // Restrict tinting to a narrow band of already-opaque rim pixels so the + // transparent rounded corners remain clean. + float accentRim = (1.0 - smoothstep(0.045, 0.075, rimDistance)) * color.a; + color.rgb = mix(color.rgb, u.accent.rgb, accentRim * 0.22); + + // The base artwork keeps Menu Band's 4pt staging margin. Remap the + // fragment to the playable chart before comparing it with normalized + // hardware contacts, then brighten every material band currently held. + float2 chartUV = (in.uv - float2(4.0 / 140.0, 4.0 / 88.0)) + / float2(132.0 / 140.0, 80.0 / 88.0); + bool insideChart = all(chartUV >= 0.0) && all(chartUV <= 1.0); + float sectionHighlight = 0.0; + if (insideChart) { + uint fragmentZone = trackpadZone(chartUV); + sectionHighlight = fragmentZone == 0 ? u.zoneLevels.x + : fragmentZone == 1 ? u.zoneLevels.y + : fragmentZone == 2 ? u.zoneLevels.z + : fragmentZone == 3 ? u.zoneLevels.w + : u.clickLevel; + } + color.rgb = mix(color.rgb, u.accent.rgb, + sectionHighlight * 0.28 * roundedMask * color.a); + + // A small white registration point preserves exact finger position. The + // material band—not an oversized colored cursor—is the primary feedback. + float contactOutline = 0.0; + float contactCore = 0.0; + float spotScale = max(1.0, min(u.logicalSize.x / 140.0, + u.logicalSize.y / 88.0)); + for (uint i = 0; i < u.touchCount; ++i) { + float2 cursorUV = float2(4.0 / 140.0, 4.0 / 88.0) + + touches[i] * float2(132.0 / 140.0, 80.0 / 88.0); + float2 pixelDelta = (in.uv - cursorUV) * u.logicalSize; + float distance = length(pixelDelta); + // Scale the original small diagram's registration core with the + // artwork. Keep it crisp and neutral instead of restoring the large + // colored video-like halo. + contactOutline = max(contactOutline, + 1.0 - smoothstep(3.15 * spotScale, 3.65 * spotScale, distance)); + contactCore = max(contactCore, + 1.0 - smoothstep(2.35 * spotScale, 2.85 * spotScale, distance)); + } + color.rgb = mix(color.rgb, float3(0.035), contactOutline * 0.82); + color.rgb = mix(color.rgb, float3(1.0), contactCore * 0.92); + color.a = max(color.a, contactOutline); + return color; +} diff --git a/slab/tracktramp/.gitignore b/slab/tracktramp/.gitignore new file mode 100644 index 0000000000..fe0b207f3f --- /dev/null +++ b/slab/tracktramp/.gitignore @@ -0,0 +1,2 @@ +build/ +TrackDrum.xcodeproj/ diff --git a/slab/tracktramp/ExportOptions-AppStore.plist b/slab/tracktramp/ExportOptions-AppStore.plist new file mode 100644 index 0000000000..4a366e9273 --- /dev/null +++ b/slab/tracktramp/ExportOptions-AppStore.plist @@ -0,0 +1,14 @@ + + + + + destination + export + method + app-store-connect + signingStyle + automatic + teamID + FB5948YR3S + + diff --git a/slab/tracktramp/Info.plist b/slab/tracktramp/Info.plist new file mode 100644 index 0000000000..83975b4437 --- /dev/null +++ b/slab/tracktramp/Info.plist @@ -0,0 +1,36 @@ + + + + + CFBundleDisplayName + TrackDrum + CFBundleExecutable + TrackDrum + CFBundleIconFile + TrackDrumIcon + CFBundleIdentifier + computer.aestheticcomputer.trackdrum + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + TrackDrum + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + ITSAppUsesNonExemptEncryption + + LSApplicationCategoryType + public.app-category.music + LSMinimumSystemVersion + 12.0 + NSHighResolutionCapable + + NSHumanReadableCopyright + By aesthetic.computer + NSPrincipalClass + NSApplication + + diff --git a/slab/tracktramp/README.md b/slab/tracktramp/README.md new file mode 100644 index 0000000000..09d0944e52 --- /dev/null +++ b/slab/tracktramp/README.md @@ -0,0 +1,35 @@ +# TrackDrum + +TrackDrum turns the active Mac trackpad into Menu Band’s percussion surface. +Its borderless window is drawn at the touch device’s reported physical point +range, uses the production Metal membrane and audio engine, and quits with +Escape. + +The app has one capability contract for every distribution channel: + +- public AppKit `NSTouch` input while TrackDrum is frontmost; +- App Sandbox enabled; +- no private frameworks, global event taps, helper processes, login items, or + persistent background mode; +- one bundle identifier: `computer.aestheticcomputer.trackdrum`. + +Build the signed universal direct-download artifact: + +```bash +./build-dmg.sh +``` + +Verify the App Store target without signing: + +```bash +./build-app-store.sh --verify +``` + +Create an App Store archive and signed installer package: + +```bash +./build-app-store.sh --archive +``` + +Both routes compile the same app sources and ship the same feature set. Only +the distribution signature and container differ. diff --git a/slab/tracktramp/Sources/MenubandUIBridge.swift b/slab/tracktramp/Sources/MenubandUIBridge.swift new file mode 100644 index 0000000000..c4b8ca3bd1 --- /dev/null +++ b/slab/tracktramp/Sources/MenubandUIBridge.swift @@ -0,0 +1,12 @@ +import AppKit + +// PitchBendCursor.swift is compiled directly from Menu Band. These two seams +// are supplied by the larger app there and by this tiny shell here. +enum KeyboardIconRenderer { + static var accent: NSColor { NSColor.controlAccentColor } +} + +extension Bundle { + static var module: Bundle { .main } + static var appResources: Bundle { .main } +} diff --git a/slab/tracktramp/Sources/PhysicalTrackpadCalibration.swift b/slab/tracktramp/Sources/PhysicalTrackpadCalibration.swift new file mode 100644 index 0000000000..84f008256a --- /dev/null +++ b/slab/tracktramp/Sources/PhysicalTrackpadCalibration.swift @@ -0,0 +1,45 @@ +import AppKit + +enum TrackDrumDockLayout { + /// AppKit owns the sandbox-safe usable-screen calculation. TrackDrum adds + /// bottom clearance for an auto-hidden Dock without reading Dock settings. + static func safeFrame(for screen: NSScreen) -> NSRect { + var safe = screen.visibleFrame + let reserve = min(72, safe.height / 5) + safe.origin.y += reserve + safe.size.height = max(0, safe.height - reserve) + return safe + } +} + +/// AppKit reports an indirect touch device's physical coordinate range in +/// points through `NSTouch.deviceSize`. That is the public, sandbox-safe source +/// of truth for TrackDrum's one-to-one surface. The fallback is the common +/// built-in MacBook trackpad at 72 points per inch and is replaced by the +/// first real touch frame. +struct PhysicalTrackpadCalibration { + static let fallbackSurfacePoints = NSSize( + width: 111.55 * 72 / 25.4, + height: 67.90 * 72 / 25.4 + ) + + let surfacePoints: NSSize + let source: String + + static func current(deviceSize: NSSize? = nil) -> PhysicalTrackpadCalibration { + if let deviceSize, + deviceSize.width >= 100, + deviceSize.height >= 60, + deviceSize.width <= 1_000, + deviceSize.height <= 1_000 { + return PhysicalTrackpadCalibration( + surfacePoints: deviceSize, + source: "NSTouch.deviceSize" + ) + } + return PhysicalTrackpadCalibration( + surfacePoints: fallbackSurfacePoints, + source: "built-in fallback" + ) + } +} diff --git a/slab/tracktramp/Sources/TrackDrumContact.swift b/slab/tracktramp/Sources/TrackDrumContact.swift new file mode 100644 index 0000000000..0b533ae542 --- /dev/null +++ b/slab/tracktramp/Sources/TrackDrumContact.swift @@ -0,0 +1,38 @@ +import CoreGraphics + +struct TrackpadContact: Equatable { + let identifier: Int32 + let point: CGPoint + let state: Int32 + + var isActive: Bool { state == 3 || state == 4 } + var isBegan: Bool { state == 3 } +} + +struct TrackpadContactChanges { + let active: [TrackpadContact] + let began: [CGPoint] + let lifted: [CGPoint] + let activeByID: [Int32: CGPoint] + + static func resolve(previous: [Int32: CGPoint], + contacts: [TrackpadContact]) -> TrackpadContactChanges { + let active = contacts.filter(\.isActive) + let current = Dictionary(uniqueKeysWithValues: active.map { + ($0.identifier, $0.point) + }) + let began = active.compactMap { contact in + contact.isBegan || previous[contact.identifier] == nil + ? contact.point : nil + } + let lifted = previous.compactMap { identifier, point in + current[identifier] == nil ? point : nil + } + return TrackpadContactChanges( + active: active, + began: began, + lifted: lifted, + activeByID: current + ) + } +} diff --git a/slab/tracktramp/Sources/TrackDrumIcon.swift b/slab/tracktramp/Sources/TrackDrumIcon.swift new file mode 100644 index 0000000000..b47436bb79 --- /dev/null +++ b/slab/tracktramp/Sources/TrackDrumIcon.swift @@ -0,0 +1,162 @@ +import AppKit + +enum TrackDrumIcon { + static func image(size: CGFloat = 1024, + accent: NSColor = .controlAccentColor) -> NSImage { + let canvas = NSSize(width: size, height: size) + return NSImage(size: canvas, flipped: false) { rect in + draw(in: rect, accent: accent) + return true + } + } + + private static func draw(in rect: NSRect, accent: NSColor) { + let scale = min(rect.width, rect.height) / 1024 + func s(_ value: CGFloat) -> CGFloat { value * scale } + let accent = accent.usingColorSpace(.deviceRGB) ?? .systemPink + + NSColor.clear.setFill() + rect.fill() + + func path(_ box: NSRect, radius: CGFloat) -> NSBezierPath { + NSBezierPath(roundedRect: box, xRadius: s(radius), yRadius: s(radius)) + } + func shifted(_ box: NSRect, y: CGFloat) -> NSRect { + box.offsetBy(dx: 0, dy: s(y)) + } + func fillGradient(_ shape: NSBezierPath, in box: NSRect, + top: NSColor, bottom: NSColor) { + NSGraphicsContext.saveGraphicsState() + shape.addClip() + NSGradient(starting: top, ending: bottom)?.draw(in: box, angle: -90) + NSGraphicsContext.restoreGraphicsState() + } + func raised(_ shape: NSBezierPath, in box: NSRect, + top: NSColor, bottom: NSColor, + shadowY: CGFloat = -10, blur: CGFloat = 14, + edgeWidth: CGFloat = 7) { + NSGraphicsContext.saveGraphicsState() + let shadow = NSShadow() + shadow.shadowColor = NSColor.black.withAlphaComponent(0.48) + shadow.shadowBlurRadius = s(blur) + shadow.shadowOffset = NSSize(width: 0, height: s(shadowY)) + shadow.set() + bottom.setFill() + shape.fill() + NSGraphicsContext.restoreGraphicsState() + fillGradient(shape, in: box, top: top, bottom: bottom) + NSColor.black.withAlphaComponent(0.48).setStroke() + shape.lineWidth = s(edgeWidth) + shape.stroke() + NSColor.white.withAlphaComponent(0.18).setStroke() + shape.lineWidth = s(2.5) + shape.stroke() + } + + // A physical slab seen slightly from above. The lower copy is the + // chassis thickness; the raised material zones preserve TrackDrum's + // exact outside-to-center instrument map in an icon-shaped footprint. + let pad = NSRect(x: rect.minX + s(46), y: rect.minY + s(72), + width: s(932), height: s(884)) + let body = path(pad, radius: 210) + let bodyBase = path(shifted(pad, y: -36), radius: 210) + let accentBase = accent.blended(withFraction: 0.58, of: .black) + ?? NSColor(srgbRed: 0.20, green: 0.04, blue: 0.16, alpha: 1) + + NSGraphicsContext.saveGraphicsState() + let bodyShadow = NSShadow() + bodyShadow.shadowColor = NSColor.black.withAlphaComponent(0.62) + bodyShadow.shadowBlurRadius = s(38) + bodyShadow.shadowOffset = NSSize(width: 0, height: -s(24)) + bodyShadow.set() + accentBase.setFill() + bodyBase.fill() + NSGraphicsContext.restoreGraphicsState() + accentBase.setFill() + bodyBase.fill() + + let creamTop = NSColor(srgbRed: 1.00, green: 0.96, blue: 0.83, alpha: 1) + let creamBottom = NSColor(srgbRed: 0.83, green: 0.72, blue: 0.52, alpha: 1) + raised(body, in: pad, top: creamTop, bottom: creamBottom, + shadowY: -8, blur: 12, edgeWidth: 8) + + func zone(_ inset: CGFloat, radius: CGFloat) -> (NSRect, NSBezierPath) { + let box = pad.insetBy(dx: s(inset), dy: s(inset)) + return (box, path(box, radius: radius)) + } + + let (hatBox, hat) = zone(58, radius: 166) + let (wireBox, snareWire) = zone(176, radius: 116) + let (tomBox, tom) = zone(262, radius: 80) + let (kickBox, kick) = zone(338, radius: 50) + let sageTop = NSColor(srgbRed: 0.82, green: 0.87, blue: 0.70, alpha: 1) + let sageBottom = NSColor(srgbRed: 0.49, green: 0.57, blue: 0.38, alpha: 1) + let terraTop = NSColor(srgbRed: 0.91, green: 0.59, blue: 0.40, alpha: 1) + let terraBottom = NSColor(srgbRed: 0.55, green: 0.27, blue: 0.18, alpha: 1) + let ochreTop = NSColor(srgbRed: 0.95, green: 0.79, blue: 0.49, alpha: 1) + let ochreBottom = NSColor(srgbRed: 0.63, green: 0.43, blue: 0.20, alpha: 1) + let umberTop = NSColor(srgbRed: 0.59, green: 0.36, blue: 0.24, alpha: 1) + let umberBottom = NSColor(srgbRed: 0.24, green: 0.12, blue: 0.08, alpha: 1) + raised(hat, in: hatBox, top: sageTop, bottom: sageBottom) + raised(snareWire, in: wireBox, top: terraTop, bottom: terraBottom) + + NSGraphicsContext.saveGraphicsState() + snareWire.addClip() + let wires = NSBezierPath() + stride(from: pad.minX - pad.height, + through: pad.maxX, by: s(28)).forEach { x in + wires.move(to: NSPoint(x: x, y: pad.minY)) + wires.line(to: NSPoint(x: x + pad.height, y: pad.maxY)) + } + NSColor.black.withAlphaComponent(0.14).setStroke() + wires.lineWidth = s(4) + wires.stroke() + NSGraphicsContext.restoreGraphicsState() + + raised(tom, in: tomBox, top: ochreTop, bottom: ochreBottom, + shadowY: -8, blur: 12) + raised(kick, in: kickBox, top: umberTop, bottom: umberBottom, + shadowY: -7, blur: 10) + + // Two restrained registration points make this read as the live + // touch surface, not a cassette, even at Dock size. + let touches = [ + NSPoint(x: pad.minX + pad.width * 0.36, + y: pad.minY + pad.height * 0.68), + NSPoint(x: pad.minX + pad.width * 0.65, + y: pad.minY + pad.height * 0.36), + ] + let tether = NSBezierPath() + tether.move(to: touches[0]) + tether.line(to: touches[1]) + NSGraphicsContext.saveGraphicsState() + let tetherShadow = NSShadow() + tetherShadow.shadowColor = NSColor.black.withAlphaComponent(0.55) + tetherShadow.shadowBlurRadius = s(8) + tetherShadow.shadowOffset = NSSize(width: 0, height: -s(5)) + tetherShadow.set() + accent.withAlphaComponent(0.64).setStroke() + tether.lineWidth = s(12) + tether.stroke() + NSGraphicsContext.restoreGraphicsState() + for point in touches { + let radius = s(20) + let dot = NSBezierPath(ovalIn: NSRect( + x: point.x - radius, y: point.y - radius, + width: radius * 2, height: radius * 2 + )) + NSGraphicsContext.saveGraphicsState() + let glow = NSShadow() + glow.shadowColor = accent.withAlphaComponent(0.80) + glow.shadowBlurRadius = s(12) + glow.shadowOffset = .zero + glow.set() + NSColor.white.withAlphaComponent(0.98).setFill() + dot.fill() + NSGraphicsContext.restoreGraphicsState() + accent.setStroke() + dot.lineWidth = s(5) + dot.stroke() + } + } +} diff --git a/slab/tracktramp/Sources/TracktrampApp.swift b/slab/tracktramp/Sources/TracktrampApp.swift new file mode 100644 index 0000000000..2f41140ab7 --- /dev/null +++ b/slab/tracktramp/Sources/TracktrampApp.swift @@ -0,0 +1,787 @@ +import AppKit +import AVFoundation +import QuartzCore + +final class TracktrampAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { + private let performer = TracktrampPerformer() + private let surface = TracktrampView(frame: NSRect(x: 0, y: 0, width: 660, height: 510)) + private var window: NSWindow? + private var keyMonitor: Any? + private var instrumentStarted = false + private var isQuitting = false + private var lastTrackpadDeviceSize: NSSize? + private var systemColorsObserver: NSObjectProtocol? + private var screenParametersObserver: NSObjectProtocol? + + func applicationWillFinishLaunching(_ notification: Notification) { + // A Dock launch activates TrackDrum before its window is ready. Hide + // the pointer at that boundary so it never flashes over the first + // instrument frame; TracktrampView balances this on resign and quit. + surface.syncPointerVisibility(applicationActive: true) + } + + func applicationDidFinishLaunching(_ notification: Notification) { + systemColorsObserver = NotificationCenter.default.addObserver( + forName: NSColor.systemColorsDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.applyDynamicIcon() + } + let window = NSWindow( + contentRect: surface.bounds, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.title = "TrackDrum" + window.isOpaque = false + window.backgroundColor = .clear + window.hasShadow = false + // Stay in ordinary app-window space. Position keeps the surface above + // the Dock; z-order must never cover or compete with the Dock itself. + window.level = .normal + window.isReleasedWhenClosed = false + window.contentView = surface + window.delegate = self + self.window = window + applyPhysicalCalibration(to: window, screen: pointerScreen()) + screenParametersObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self, weak window] _ in + guard let self, let window else { return } + self.applyPhysicalCalibration( + to: window, + screen: window.screen ?? self.pointerScreen() + ) + } + + surface.onDefocus = { [weak self] in self?.quitInstrument() } + surface.showEscapeInstructionTemporarily() + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { + [weak self] event in + guard event.keyCode == 53 else { return event } + self?.quitInstrument() + return nil + } + + performer.onActivity = { [weak surface] touches, membrane in + DispatchQueue.main.async { + surface?.update(touches: touches, membrane: membrane) + } + } + surface.onTrackpadFrame = { [weak performer] contacts, timestamp, callbackTime in + performer?.receive( + contacts: contacts, + timestamp: timestamp, + callbackTime: callbackTime + ) + } + surface.onTrackpadDeviceSize = { [weak self, weak window] size in + guard let self, let window else { return } + self.lastTrackpadDeviceSize = size + self.applyPhysicalCalibration(to: window, deviceSize: size) + } + + presentInstrumentWindow() + } + + private func presentInstrumentWindow() { + guard let window, !isQuitting else { return } + applyPhysicalCalibration(to: window, screen: pointerScreen()) + // Commit a lightweight drum surface before initializing Metal, audio, + // event taps, or the dynamic 1024 px Dock icon. A Dock click therefore + // produces a visible window on the first AppKit presentation turn. + window.makeKeyAndOrderFront(nil) + window.makeFirstResponder(surface) + NSApp.activate(ignoringOtherApps: true) + surface.syncPointerVisibility(applicationActive: true) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { [weak self] in + self?.startInstrumentAfterFirstFrame() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in + self?.applyDynamicIcon() + } + } + + private func startInstrumentAfterFirstFrame() { + guard !instrumentStarted, !isQuitting else { return } + instrumentStarted = true + surface.prepareRenderer() + do { + try performer.start() + FocusFlashOverlay.shared.flash(rising: false) + FocusCueBeep.shared.play(rising: true) + } catch { + presentStartError(error) + } + } + + func applicationWillTerminate(_ notification: Notification) { + if let systemColorsObserver { + NotificationCenter.default.removeObserver(systemColorsObserver) + self.systemColorsObserver = nil + } + if let screenParametersObserver { + NotificationCenter.default.removeObserver(screenParametersObserver) + self.screenParametersObserver = nil + } + if let keyMonitor { + NSEvent.removeMonitor(keyMonitor) + self.keyMonitor = nil + } + surface.restorePointer() + performer.stop() + } + + func applicationDidBecomeActive(_ notification: Notification) { + surface.syncPointerVisibility(applicationActive: window?.isVisible == true) + } + + func applicationDidResignActive(_ notification: Notification) { + surface.syncPointerVisibility(applicationActive: false) + performer.releaseAllContacts() + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + true + } + + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + true + } + + func applicationShouldHandleReopen(_ sender: NSApplication, + hasVisibleWindows flag: Bool) -> Bool { + presentInstrumentWindow() + return true + } + + func windowDidChangeScreen(_ notification: Notification) { + guard let window else { return } + applyPhysicalCalibration(to: window, screen: window.screen ?? pointerScreen()) + } + + private func applyPhysicalCalibration(to window: NSWindow, + screen: NSScreen? = nil, + deviceSize: NSSize? = nil) { + guard let screen = screen ?? window.screen ?? pointerScreen() else { return } + let calibration = PhysicalTrackpadCalibration.current( + deviceSize: deviceSize ?? lastTrackpadDeviceSize + ) + surface.setTrackpadSize(calibration.surfacePoints) + window.setContentSize(surface.preferredContentSize) + let safeFrame = TrackDrumDockLayout.safeFrame(for: screen) + window.setFrameOrigin(NSPoint( + x: safeFrame.midX - window.frame.width / 2, + y: safeFrame.minY + max(8, safeFrame.height * 0.02) + )) + NSLog( + "TrackDrum 1:1: source=%@ surface=%.2fx%.2fpt", + calibration.source, + calibration.surfacePoints.width, + calibration.surfacePoints.height + ) + } + + private func applyDynamicIcon() { + NSApp.applicationIconImage = TrackDrumIcon.image( + accent: NSColor.controlAccentColor + ) + } + + private func pointerScreen() -> NSScreen? { + let pointer = NSEvent.mouseLocation + return NSScreen.screens.first { NSMouseInRect(pointer, $0.frame, false) } + ?? NSScreen.main + } + + private func presentStartError(_ error: Error) { + let alert = NSAlert() + alert.messageText = "Audio unavailable" + alert.informativeText = error.localizedDescription + alert.alertStyle = .warning + alert.addButton(withTitle: "OK") + alert.beginSheetModal(for: window!) + } + + private func quitInstrument() { + guard !isQuitting else { return } + isQuitting = true + FocusFlashOverlay.shared.flash(rising: false) + FocusCueBeep.shared.play(rising: false) + surface.restorePointer() + performer.stop() + // Let the falling bell and red pulse finish before terminating. A Dock + // click starts the same single, sandboxed app again; no helper process + // or login item remains behind after Quit. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) { + NSApp.terminate(nil) + } + } +} + +private enum TracktrampError: LocalizedError { + case audio + + var errorDescription: String? { + switch self { + case .audio: return "Audio could not start." + } + } +} + +private struct TracktrampEnergy { + var point: CGPoint + var level: Double + var at: Double +} + +private final class TracktrampPerformer { + var onActivity: (([CGPoint], TrackpadMembraneSimulation.Snapshot) -> Void)? + + private let engine = AVAudioEngine() + private let percussion = MenuBandPercussion() + private let queue = DispatchQueue(label: "computer.aesthetic.tracktramp.performance", qos: .userInteractive) + private var contactsByID: [Int32: CGPoint] = [:] + private var energies: [TracktrampEnergy] = [] + private var previousTimestamp: Double = 0 + private var scratchFrames = 0 + private var scratchTravel: CGFloat = 0 + private var scratchSpeed: Double? + private var membrane = TrackpadMembraneSimulation() + private var visibleTouches: [CGPoint] = [] + private var displayTimer: DispatchSourceTimer? + private var isRunning = false + + func start() throws { + guard !isRunning else { return } + percussion.attach(to: engine, output: engine.mainMixerNode) + engine.mainMixerNode.outputVolume = 0.92 + engine.prepare() + do { + try engine.start() + } catch { + throw TracktrampError.audio + } + + queue.sync { + membrane.reset(at: CACurrentMediaTime()) + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now(), repeating: 1.0 / 60.0, + leeway: .microseconds(800)) + timer.setEventHandler { [weak self] in self?.presentFrame() } + displayTimer = timer + timer.resume() + } + isRunning = true + } + + func stop() { + guard isRunning else { return } + isRunning = false + queue.sync { + displayTimer?.cancel() + displayTimer = nil + contactsByID.removeAll(keepingCapacity: true) + visibleTouches.removeAll(keepingCapacity: true) + energies.removeAll(keepingCapacity: true) + previousTimestamp = 0 + resetScratch() + percussion.silence() + } + engine.stop() + } + + func receive(contacts: [TrackpadContact], timestamp: Double, callbackTime: Double) { + guard isRunning else { return } + queue.async { [weak self] in + self?.process(contacts: contacts, timestamp: timestamp, callbackTime: callbackTime) + } + } + + func releaseAllContacts() { + receive( + contacts: [], + timestamp: ProcessInfo.processInfo.systemUptime, + callbackTime: CACurrentMediaTime() + ) + } + + private func process(contacts: [TrackpadContact], timestamp: Double, callbackTime: Double) { + let changes = TrackpadContactChanges.resolve(previous: contactsByID, contacts: contacts) + let previous = contactsByID + contactsByID = changes.activeByID + let touches = changes.active.map(\.point) + visibleTouches = touches + let now = CACurrentMediaTime() + membrane.advance(to: now, touches: touches) + decayEnergies(now: now) + + if !changes.began.isEmpty || !changes.lifted.isEmpty { + percussion.markTrackpadInput(at: callbackTime) + } + + for lift in changes.lifted { + let velocity = UInt8(max(36, Int(Double(MenuBandPercussion.surfaceVelocity( + at: lift, + anchors: touches, + inertia: energy(at: lift) + )) * 0.62))) + percussion.playSurfaceLift(at: lift, anchors: touches, velocity: velocity, synthetic: false) + } + + for strike in changes.began { + let anchors = touches.filter { $0 != strike } + let velocity = MenuBandPercussion.surfaceVelocity( + at: strike, + anchors: anchors, + inertia: energy(at: strike) + ) + percussion.playDrumSkin(strike: strike, anchors: anchors, velocity: velocity) + membrane.impulse( + at: strike, + amount: 0.28 + Double(velocity) / 127.0 * 0.54 + ) + energies.append(TracktrampEnergy( + point: strike, + level: 0.48 + Double(velocity) / 127.0 * 0.42, + at: now + )) + } + + let contactChanged = !changes.began.isEmpty || !changes.lifted.isEmpty + if contactChanged || touches.isEmpty { + resetScratch() + } else if previousTimestamp > 0, + let movement = dominantMovement(previous: previous, current: contactsByID) { + let dt = max(1.0 / 240.0, min(0.050, timestamp - previousTimestamp)) + scratchFrames += 1 + scratchTravel += movement.distance + if scratchFrames >= 2, scratchTravel >= 0.012 { + let measured = Double(movement.distance) / dt + let alpha = 1.0 - exp(-dt / 0.040) + let speed = scratchSpeed.map { $0 + alpha * (measured - $0) } ?? measured + scratchSpeed = speed + let anchors = touches.filter { $0 != movement.point } + percussion.setDrumSkinScratch( + at: movement.point, + speed: speed, + anchors: anchors, + direction: movement.vector, + surfaceEnergy: energy(at: movement.point), + synthetic: false + ) + energize(at: movement.point, amount: min(0.035, speed * 0.008), now: now) + } + } + previousTimestamp = touches.isEmpty ? 0 : timestamp + // The hardware callback can run well above display cadence. Rendering + // here as well as from `presentFrame` floods AppKit/Metal with stale + // full-size frames and makes the newest contact appear late. Audio + // remains raw-frame driven; visuals are coalesced to the presentation + // timer below and always read the latest touch state. + } + + private func presentFrame() { + let now = CACurrentMediaTime() + membrane.advance(to: now, touches: visibleTouches) + onActivity?(visibleTouches, membrane.snapshot()) + } + + private func resetScratch() { + scratchFrames = 0 + scratchTravel = 0 + scratchSpeed = nil + percussion.stopDrumSkinScratch() + } + + private func dominantMovement(previous: [Int32: CGPoint], current: [Int32: CGPoint]) + -> (point: CGPoint, distance: CGFloat, vector: CGVector)? { + current.compactMap { identifier, point -> (CGPoint, CGFloat, CGVector)? in + guard let old = previous[identifier] else { return nil } + let vector = CGVector(dx: point.x - old.x, dy: point.y - old.y) + return (point, hypot(vector.dx * 1.64, vector.dy), vector) + }.max { $0.1 < $1.1 } + } + + private func decayEnergies(now: Double) { + for index in energies.indices { + let age = max(0, now - energies[index].at) + energies[index].level *= exp(-age / 0.90) + energies[index].at = now + } + energies.removeAll { $0.level < 0.01 } + } + + private func energy(at point: CGPoint) -> Double { + min(1, energies.reduce(0) { total, energy in + let dx = Double(point.x - energy.point.x) * 1.64 + let dy = Double(point.y - energy.point.y) + return total + energy.level * exp(-(dx * dx + dy * dy) / (2 * 0.19 * 0.19)) + }) + } + + private func energize(at point: CGPoint, amount: Double, now: Double) { + if let index = energies.indices.min(by: { + distanceSquared(energies[$0].point, point) < distanceSquared(energies[$1].point, point) + }), distanceSquared(energies[index].point, point) < 0.05 { + energies[index].level = min(1, energies[index].level + amount) + energies[index].at = now + } else { + energies.append(TracktrampEnergy(point: point, level: amount, at: now)) + } + } + + private func distanceSquared(_ a: CGPoint, _ b: CGPoint) -> Double { + let dx = Double(a.x - b.x) * 1.64 + let dy = Double(a.y - b.y) + return dx * dx + dy * dy + } +} + +private func playfulTrackDrumFont(_ pointSize: CGFloat) -> NSFont { + for name in ["Comic Sans MS Bold", "ComicSansMS-Bold", + "Chalkboard SE Bold", "ChalkboardSE-Bold"] { + if let font = NSFont(name: name, size: pointSize) { return font } + } + return NSFont.systemFont(ofSize: pointSize, weight: .heavy) +} + +private final class TrackDrumCharacterLayer: CALayer { + var attributedCharacter: NSAttributedString? + var inset: CGFloat = 0 + + override func draw(in context: CGContext) { + guard let attributedCharacter else { return } + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: false) + attributedCharacter.draw(at: NSPoint(x: inset, y: inset)) + NSGraphicsContext.restoreGraphicsState() + } +} + +/// The same MacPal-style per-character bubble lettering used by Slab's prox +/// prompt rocks, kept local so this small feedback app does not pull in Slab. +private final class TrackDrumInstructionView: NSView { + private var instruction = "esc to quit" + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + setAccessibilityElement(true) + setAccessibilityRole(.staticText) + rebuildCharacters() + } + + required init?(coder: NSCoder) { + fatalError("TrackDrumInstructionView is code-only") + } + + func setInstruction(_ newValue: String) { + guard instruction != newValue else { return } + instruction = newValue + setAccessibilityLabel(newValue) + rebuildCharacters() + } + + override func layout() { + super.layout() + rebuildCharacters() + } + + private func rebuildCharacters() { + guard let root = layer else { return } + root.sublayers?.forEach { $0.removeFromSuperlayer() } + setAccessibilityLabel(instruction) + + let font = playfulTrackDrumFont(18) + let shadow = NSShadow() + shadow.shadowColor = NSColor.controlAccentColor + shadow.shadowBlurRadius = 0 + shadow.shadowOffset = NSSize(width: 2, height: -2) + let scale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2 + let inset: CGFloat = 7 + var characters: [TrackDrumCharacterLayer] = [] + var widths: [CGFloat] = [] + var totalWidth: CGFloat = 0 + + for character in instruction { + let attributed = NSAttributedString(string: String(character), attributes: [ + .font: font, + .foregroundColor: NSColor.white, + .strokeColor: NSColor(white: 0.08, alpha: 1), + .strokeWidth: -3.5, + .shadow: shadow, + ]) + let size = attributed.size() + let characterLayer = TrackDrumCharacterLayer() + characterLayer.attributedCharacter = attributed + characterLayer.inset = inset + characterLayer.contentsScale = scale + characterLayer.bounds = NSRect(x: 0, y: 0, + width: size.width + inset * 2, + height: size.height + inset * 2) + characterLayer.anchorPoint = CGPoint(x: 0.5, y: 0.5) + + var hash: UInt32 = 2166136261 + for byte in "trackdrum\(characters.count)\(instruction)".utf8 { + hash = (hash ^ UInt32(byte)) &* 16777619 + } + let dy = CGFloat(Int(hash % 5)) / 2 - 1 + let rotation = (CGFloat(Int((hash >> 8) % 9)) - 4) * 0.9 * .pi / 180 + var transform = CATransform3DMakeTranslation(0, dy, 0) + transform = CATransform3DRotate(transform, rotation, 0, 0, 1) + characterLayer.transform = transform + root.addSublayer(characterLayer) + characters.append(characterLayer) + widths.append(size.width) + totalWidth += size.width + characterLayer.setNeedsDisplay() + } + + var x = (bounds.width - totalWidth) / 2 + let start = CACurrentMediaTime() + let timing = Array(repeating: CAMediaTimingFunction(name: .easeInEaseOut), count: 3) + for (index, characterLayer) in characters.enumerated() { + characterLayer.position = CGPoint(x: x + widths[index] / 2, y: bounds.midY) + x += widths[index] + let begin = start + Double(index) * 0.12 + + let sway = CAKeyframeAnimation(keyPath: "transform.translation.y") + sway.values = [0, 1.2, -0.8, 0] + sway.keyTimes = [0, 0.25, 0.75, 1] + sway.timingFunctions = timing + sway.duration = 1.8 + sway.repeatCount = .infinity + sway.beginTime = begin + sway.isAdditive = true + characterLayer.add(sway, forKey: "wiggleY") + + let rotate = CAKeyframeAnimation(keyPath: "transform.rotation.z") + rotate.values = [0, 1.2 * Double.pi / 180, -0.8 * Double.pi / 180, 0] + rotate.keyTimes = [0, 0.25, 0.75, 1] + rotate.timingFunctions = timing + rotate.duration = 1.8 + rotate.repeatCount = .infinity + rotate.beginTime = begin + rotate.isAdditive = true + characterLayer.add(rotate, forKey: "wiggleR") + } + } +} + +private final class TracktrampView: NSView { + private static let instructionBandHeight: CGFloat = 48 + private let surfaceClip = NSView(frame: .zero) + private let launchSurface = TrackDrumLaunchSurfaceView(frame: .zero) + private var menubandSurface: TracktrampMetalView? + private let focusInstruction = TrackDrumInstructionView(frame: .zero) + private var trackpadSize = TracktrampMetalView.logicalSize + var onDefocus: (() -> Void)? + var onTrackpadFrame: (([TrackpadContact], Double, Double) -> Void)? + var onTrackpadDeviceSize: ((NSSize) -> Void)? + private var pointerHidden = false + private var reportedDeviceSize: NSSize? + + override var isFlipped: Bool { false } + override var acceptsFirstResponder: Bool { true } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + allowedTouchTypes = [.indirect] + wantsRestingTouches = true + layer?.backgroundColor = NSColor.clear.cgColor + layer?.masksToBounds = true + surfaceClip.wantsLayer = true + surfaceClip.layer?.backgroundColor = NSColor.clear.cgColor + surfaceClip.layer?.masksToBounds = true + addSubview(surfaceClip) + surfaceClip.addSubview(launchSurface) + addSubview(focusInstruction) + } + + required init?(coder: NSCoder) { + fatalError("TracktrampView is code-only") + } + + func setTrackpadSize(_ size: NSSize) { + trackpadSize = size + needsLayout = true + needsDisplay = true + } + + var preferredContentSize: NSSize { + NSSize(width: trackpadSize.width, + height: trackpadSize.height + Self.instructionBandHeight) + } + + func prepareRenderer() { + guard menubandSurface == nil else { return } + let renderer = TracktrampMetalView() + renderer.frame = rendererRect + surfaceClip.addSubview(renderer, positioned: .above, relativeTo: launchSurface) + menubandSurface = renderer + } + + func update(touches: [CGPoint], membrane: TrackpadMembraneSimulation.Snapshot) { + menubandSurface?.update(membrane, touches: touches) + } + + func showEscapeInstructionTemporarily() { + let defaults = UserDefaults.standard + let key = "trackDrumEscapeInstructionLaunchCount" + let launchCount = defaults.integer(forKey: key) + 1 + defaults.set(launchCount, forKey: key) + let environment = ProcessInfo.processInfo.environment + let developerMode = environment["TRACKDRUM_DEVELOPER_MODE"] == "1" + || Bundle.main.bundlePath.contains("/aesthetic-computer/") + let shouldShow = developerMode || launchCount <= 15 + focusInstruction.isHidden = !shouldShow + guard shouldShow else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak focusInstruction] in + focusInstruction?.isHidden = true + } + } + + func syncPointerVisibility(applicationActive: Bool) { + let shouldHide = applicationActive + if shouldHide, !pointerHidden { + NSCursor.hide() + pointerHidden = true + } else if !shouldHide, pointerHidden { + NSCursor.unhide() + pointerHidden = false + } + } + + func restorePointer() { + if pointerHidden { + NSCursor.unhide() + pointerHidden = false + } + } + + override func layout() { + super.layout() + surfaceClip.frame = physicalTrackpadRect + launchSurface.frame = surfaceClip.bounds + menubandSurface?.frame = rendererRect + focusInstruction.frame = NSRect( + x: 0, + y: trackpadSize.height, + width: bounds.width, + height: Self.instructionBandHeight + ) + } + + override func keyDown(with event: NSEvent) { + if event.keyCode == 53 { onDefocus?() } else { super.keyDown(with: event) } + } + + override func touchesBegan(with event: NSEvent) { reportTouches(from: event) } + override func touchesMoved(with event: NSEvent) { reportTouches(from: event) } + override func touchesEnded(with event: NSEvent) { reportTouches(from: event) } + override func touchesCancelled(with event: NSEvent) { reportTouches(from: event) } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + + private func reportTouches(from event: NSEvent) { + let touches = event.touches(matching: .touching, in: self) + .filter { $0.type == .indirect } + let began = Set(event.touches(matching: .began, in: self).map { + ObjectIdentifier($0.identity as AnyObject) + }) + let contacts = touches.map { touch in + let identity = ObjectIdentifier(touch.identity as AnyObject) + return TrackpadContact( + identifier: Int32(truncatingIfNeeded: identity.hashValue), + point: touch.normalizedPosition, + state: began.contains(identity) ? 3 : 4 + ) + } + + if let size = touches.first?.deviceSize, + size.width > 0, + size.height > 0, + reportedDeviceSize.map({ + abs($0.width - size.width) > 0.5 || abs($0.height - size.height) > 0.5 + }) ?? true { + reportedDeviceSize = size + onTrackpadDeviceSize?(size) + } + + onTrackpadFrame?(contacts, event.timestamp, CACurrentMediaTime()) + } + + private var rendererRect: NSRect { + let marginX = 4 * trackpadSize.width / 132 + let marginY = 4 * trackpadSize.height / 80 + return surfaceClip.bounds.insetBy(dx: -marginX, dy: -marginY) + } + + private var physicalTrackpadRect: NSRect { + NSRect( + x: 0, + y: 0, + width: trackpadSize.width, + height: trackpadSize.height + ) + } +} + +/// Cheap first-frame stand-in for the Metal drum surface. It is deliberately +/// layer-only: no shader creation, texture rasterization, or image decoding may +/// delay the window becoming visible. +private final class TrackDrumLaunchSurfaceView: NSView { + private let zoneColors: [NSColor] = [ + NSColor(calibratedRed: 0.48, green: 0.55, blue: 0.41, alpha: 1), + NSColor(calibratedRed: 0.68, green: 0.31, blue: 0.22, alpha: 1), + NSColor(calibratedRed: 0.76, green: 0.56, blue: 0.20, alpha: 1), + NSColor(calibratedRed: 0.30, green: 0.22, blue: 0.17, alpha: 1), + ] + private lazy var zones: [CALayer] = zoneColors.map { color in + let zone = CALayer() + zone.backgroundColor = color.cgColor + zone.cornerRadius = 8 + return zone + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor(calibratedWhite: 0.10, alpha: 1).cgColor + layer?.borderColor = NSColor.controlAccentColor.withAlphaComponent(0.65).cgColor + layer?.borderWidth = 2 + layer?.cornerRadius = 12 + layer?.masksToBounds = true + zones.forEach { layer?.addSublayer($0) } + } + + required init?(coder: NSCoder) { + fatalError("TrackDrumLaunchSurfaceView is code-only") + } + + override func layout() { + super.layout() + let gap: CGFloat = 3 + let inner = bounds.insetBy(dx: 5, dy: 5) + let halfWidth = (inner.width - gap) / 2 + let halfHeight = (inner.height - gap) / 2 + zones[0].frame = NSRect(x: inner.minX, y: inner.minY, + width: halfWidth, height: halfHeight) + zones[1].frame = NSRect(x: inner.midX + gap / 2, y: inner.minY, + width: halfWidth, height: halfHeight) + zones[2].frame = NSRect(x: inner.minX, y: inner.midY + gap / 2, + width: halfWidth, height: halfHeight) + zones[3].frame = NSRect(x: inner.midX + gap / 2, y: inner.midY + gap / 2, + width: halfWidth, height: halfHeight) + } +} diff --git a/slab/tracktramp/Sources/main.swift b/slab/tracktramp/Sources/main.swift new file mode 100644 index 0000000000..69869fdc60 --- /dev/null +++ b/slab/tracktramp/Sources/main.swift @@ -0,0 +1,19 @@ +import AppKit + +let app = NSApplication.shared +let delegate = TracktrampAppDelegate() +app.delegate = delegate +app.setActivationPolicy(.regular) + +let mainMenu = NSMenu() +let appMenuItem = NSMenuItem() +mainMenu.addItem(appMenuItem) +let appMenu = NSMenu(title: "TrackDrum") +appMenu.addItem( + withTitle: "Quit TrackDrum", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q" +) +appMenuItem.submenu = appMenu +app.mainMenu = mainMenu +app.run() diff --git a/slab/tracktramp/Tests/main.swift b/slab/tracktramp/Tests/main.swift new file mode 100644 index 0000000000..5a3fd1d7f9 --- /dev/null +++ b/slab/tracktramp/Tests/main.swift @@ -0,0 +1,41 @@ +import CoreGraphics + +private func contact(_ id: Int32, _ x: CGFloat, _ y: CGFloat, + began: Bool = false) -> TrackpadContact { + TrackpadContact( + identifier: id, + point: CGPoint(x: x, y: y), + state: began ? 3 : 4 + ) +} + +let first = TrackpadContactChanges.resolve( + previous: [:], + contacts: [contact(1, 0.2, 0.3, began: true)] +) +precondition(first.began == [CGPoint(x: 0.2, y: 0.3)]) +precondition(first.lifted.isEmpty) + +let held = TrackpadContactChanges.resolve( + previous: first.activeByID, + contacts: [contact(1, 0.4, 0.5)] +) +precondition(held.began.isEmpty) +precondition(held.lifted.isEmpty) +precondition(held.activeByID[1] == CGPoint(x: 0.4, y: 0.5)) + +let replaced = TrackpadContactChanges.resolve( + previous: held.activeByID, + contacts: [contact(2, 0.7, 0.8, began: true)] +) +precondition(replaced.began == [CGPoint(x: 0.7, y: 0.8)]) +precondition(replaced.lifted == [CGPoint(x: 0.4, y: 0.5)]) + +let lifted = TrackpadContactChanges.resolve( + previous: replaced.activeByID, + contacts: [] +) +precondition(lifted.active.isEmpty) +precondition(lifted.lifted == [CGPoint(x: 0.7, y: 0.8)]) + +print("✓ TrackDrum contact transitions") diff --git a/slab/tracktramp/TrackDrum.entitlements b/slab/tracktramp/TrackDrum.entitlements new file mode 100644 index 0000000000..13cb114cf8 --- /dev/null +++ b/slab/tracktramp/TrackDrum.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/slab/tracktramp/build-app-store.sh b/slab/tracktramp/build-app-store.sh new file mode 100755 index 0000000000..e72dcac119 --- /dev/null +++ b/slab/tracktramp/build-app-store.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MODE="${1:---verify}" +DERIVED="${SCRIPT_DIR}/build/appstore-derived" +ARCHIVE="${SCRIPT_DIR}/build/TrackDrum.xcarchive" +EXPORT="${SCRIPT_DIR}/build/appstore-export" + +case "${MODE}" in + --verify|--archive) ;; + *) echo "usage: ./build-app-store.sh [--verify|--archive]" >&2; exit 2 ;; +esac + +cd "${SCRIPT_DIR}" +xcodegen generate +"${SCRIPT_DIR}/test.sh" + +if [[ "${MODE}" == "--verify" ]]; then + xcodebuild \ + -project TrackDrum.xcodeproj \ + -scheme TrackDrum \ + -configuration Release \ + -destination 'generic/platform=macOS' \ + -derivedDataPath "${DERIVED}" \ + ONLY_ACTIVE_ARCH=NO \ + "ARCHS=arm64 x86_64" \ + CODE_SIGNING_ALLOWED=NO \ + build + APP="${DERIVED}/Build/Products/Release/TrackDrum.app" + codesign --force --entitlements "${SCRIPT_DIR}/TrackDrum.entitlements" \ + --sign - "${APP}" + "${SCRIPT_DIR}/verify-app.sh" "${APP}" + echo "✓ App Store target verified: ${APP}" + exit 0 +fi + +xcodebuild \ + -project TrackDrum.xcodeproj \ + -scheme TrackDrum \ + -configuration Release \ + -destination 'generic/platform=macOS' \ + -archivePath "${ARCHIVE}" \ + ONLY_ACTIVE_ARCH=NO \ + "ARCHS=arm64 x86_64" \ + -allowProvisioningUpdates \ + archive + +APP="${ARCHIVE}/Products/Applications/TrackDrum.app" +"${SCRIPT_DIR}/verify-app.sh" "${APP}" + +xcodebuild \ + -exportArchive \ + -archivePath "${ARCHIVE}" \ + -exportPath "${EXPORT}" \ + -exportOptionsPlist "${SCRIPT_DIR}/ExportOptions-AppStore.plist" \ + -allowProvisioningUpdates + +PKG="${EXPORT}/TrackDrum.pkg" +pkgutil --check-signature "${PKG}" | grep -q \ + '3rd Party Mac Developer Installer' + +echo "✓ App Store archive: ${ARCHIVE}" +echo "✓ App Store package: ${PKG}" diff --git a/slab/tracktramp/build-dmg.sh b/slab/tracktramp/build-dmg.sh new file mode 100755 index 0000000000..3d912e3f80 --- /dev/null +++ b/slab/tracktramp/build-dmg.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NOTARIZE=false +APP_ONLY=false +for argument in "$@"; do + case "${argument}" in + --notarize) NOTARIZE=true ;; + --app-only) APP_ONLY=true ;; + *) echo "usage: ./build-dmg.sh [--app-only] [--notarize]" >&2; exit 2 ;; + esac +done + +BUILD_DIR="${SCRIPT_DIR}/build" +APP="${BUILD_DIR}/TrackDrum.app" +CONTENTS="${APP}/Contents" +MACOS="${CONTENTS}/MacOS" +RESOURCES="${CONTENTS}/Resources" +MENUBAND_SOURCES="${SCRIPT_DIR}/../menuband/Sources/MenuBand" +SDK="$(xcrun --sdk macosx --show-sdk-path)" +ARM_BIN="${BUILD_DIR}/TrackDrum-arm64" +X86_BIN="${BUILD_DIR}/TrackDrum-x86_64" +UNIVERSAL_BIN="${MACOS}/TrackDrum" +DMG="${BUILD_DIR}/TrackDrum.dmg" +SHADER_AIR="${BUILD_DIR}/TracktrampShaders.air" +SHADER_METALLIB="${RESOURCES}/TracktrampShaders.metallib" +ENTITLEMENTS="${SCRIPT_DIR}/TrackDrum.entitlements" + +rm -rf "${APP:?}" +mkdir -p "${BUILD_DIR}" "${MACOS}" "${RESOURCES}" + +echo "• precompiling Metal surface" +xcrun -sdk macosx metal -x metal -c \ + "${MENUBAND_SOURCES}/TracktrampShaders.metalsource" \ + -o "${SHADER_AIR}" +xcrun -sdk macosx metallib "${SHADER_AIR}" -o "${SHADER_METALLIB}" + +SOURCES=( + "${SCRIPT_DIR}/Sources/main.swift" + "${SCRIPT_DIR}/Sources/MenubandUIBridge.swift" + "${SCRIPT_DIR}/Sources/PhysicalTrackpadCalibration.swift" + "${SCRIPT_DIR}/Sources/TrackDrumContact.swift" + "${SCRIPT_DIR}/Sources/TracktrampApp.swift" + "${SCRIPT_DIR}/Sources/TrackDrumIcon.swift" + "${MENUBAND_SOURCES}/MenuBandPercussion.swift" + "${MENUBAND_SOURCES}/PitchBendCursor.swift" + "${MENUBAND_SOURCES}/FocusFlashOverlay.swift" + "${MENUBAND_SOURCES}/FocusCueBeep.swift" +) + +echo "• building arm64" +xcrun swiftc -O -whole-module-optimization -sdk "${SDK}" \ + -target arm64-apple-macosx12.0 "${SOURCES[@]}" -o "${ARM_BIN}" + +echo "• building x86_64" +xcrun swiftc -O -whole-module-optimization -sdk "${SDK}" \ + -target x86_64-apple-macosx12.0 "${SOURCES[@]}" -o "${X86_BIN}" + +lipo -create -output "${UNIVERSAL_BIN}" "${ARM_BIN}" "${X86_BIN}" +cp "${SCRIPT_DIR}/Info.plist" "${CONTENTS}/Info.plist" +cp "${MENUBAND_SOURCES}/TracktrampShaders.metalsource" \ + "${RESOURCES}/TracktrampShaders.metalsource" + +echo "• drawing TrackDrum icon" +"${SCRIPT_DIR}/render-icon.sh" \ + "${RESOURCES}/TrackDrumIcon.icns" \ + "${BUILD_DIR}/icon-work" + +SIGN_IDENTITY="$(security find-identity -v -p codesigning 2>/dev/null \ + | awk -F\" '/Developer ID Application/{print $2; exit}')" +if [[ -n "${SIGN_IDENTITY}" ]]; then + echo "• signing app with Developer ID + App Sandbox" + codesign --force --options runtime --timestamp \ + --entitlements "${ENTITLEMENTS}" \ + --sign "${SIGN_IDENTITY}" "${APP}" +else + echo "• signing app ad hoc + App Sandbox" + codesign --force --entitlements "${ENTITLEMENTS}" --sign - "${APP}" +fi + +"${SCRIPT_DIR}/verify-app.sh" "${APP}" + +if [[ "${NOTARIZE}" == true ]]; then + APPLE_NOTARY_PASSWORD="${APPLE_APP_PASSWORD:-${APPLE_APP_SPECIFIC_PASSWORD:-${APP_SPECIFIC_PASSWORD:-}}}" + : "${APPLE_ID:?APPLE_ID is required for --notarize}" + : "${APPLE_NOTARY_PASSWORD:?an Apple app-specific password is required for --notarize}" + if [[ -z "${APPLE_TEAM_ID:-}" ]]; then + APPLE_TEAM_ID="$(security find-identity -v -p codesigning 2>/dev/null \ + | sed -nE 's/.*Developer ID Application: [^(]+\(([A-Z0-9]+)\).*/\1/p' \ + | head -1)" + fi + : "${APPLE_TEAM_ID:?APPLE_TEAM_ID is required for --notarize}" + [[ -n "${SIGN_IDENTITY}" ]] || { + echo "Developer ID signing identity is required for --notarize" >&2 + exit 1 + } + + APP_ZIP="${BUILD_DIR}/TrackDrum-notary.zip" + ditto -c -k --keepParent "${APP}" "${APP_ZIP}" + echo "• notarizing app" + xcrun notarytool submit "${APP_ZIP}" \ + --apple-id "${APPLE_ID}" --team-id "${APPLE_TEAM_ID}" \ + --password "${APPLE_NOTARY_PASSWORD}" --wait + xcrun stapler staple "${APP}" + xcrun stapler validate "${APP}" +fi + +if [[ "${APP_ONLY}" == true ]]; then + echo "✓ ${APP}" + exit 0 +fi + +STAGE="$(mktemp -d /tmp/trackdrum-dmg.XXXXXX)" +cleanup_stage() { rm -rf "${STAGE:?}"; } +trap cleanup_stage EXIT +cp -R "${APP}" "${STAGE}/TrackDrum.app" +ln -s /Applications "${STAGE}/Applications" + +[[ -f "${DMG}" ]] && rm "${DMG}" +hdiutil create -fs HFS+ -volname "TrackDrum" \ + -srcfolder "${STAGE}" -ov -format UDZO "${DMG}" >/dev/null + +if [[ -n "${SIGN_IDENTITY}" ]]; then + codesign --force --timestamp --sign "${SIGN_IDENTITY}" "${DMG}" +fi + +if [[ "${NOTARIZE}" == true ]]; then + echo "• notarizing DMG" + xcrun notarytool submit "${DMG}" \ + --apple-id "${APPLE_ID}" --team-id "${APPLE_TEAM_ID}" \ + --password "${APPLE_NOTARY_PASSWORD}" --wait + xcrun stapler staple "${DMG}" + xcrun stapler validate "${DMG}" + spctl --assess --type install --verbose=2 "${DMG}" +fi + +echo "✓ ${DMG}" +echo " $(du -h "${DMG}" | awk '{print $1}') · $(lipo -archs "${UNIVERSAL_BIN}")" diff --git a/slab/tracktramp/project.yml b/slab/tracktramp/project.yml new file mode 100644 index 0000000000..bbc2857199 --- /dev/null +++ b/slab/tracktramp/project.yml @@ -0,0 +1,48 @@ +name: TrackDrum + +options: + bundleIdPrefix: computer.aestheticcomputer + deploymentTarget: + macOS: "12.0" + createIntermediateGroups: true + +settings: + base: + SWIFT_VERSION: "5.9" + ARCHS: arm64 x86_64 + ONLY_ACTIVE_ARCH: NO + MARKETING_VERSION: "1.0" + CURRENT_PROJECT_VERSION: "1" + +targets: + TrackDrum: + type: application + platform: macOS + deploymentTarget: "12.0" + sources: + - path: Sources + - path: ../menuband/Sources/MenuBand/MenuBandPercussion.swift + - path: ../menuband/Sources/MenuBand/PitchBendCursor.swift + - path: ../menuband/Sources/MenuBand/FocusFlashOverlay.swift + - path: ../menuband/Sources/MenuBand/FocusCueBeep.swift + - path: ../menuband/Sources/MenuBand/TracktrampShaders.metalsource + buildPhase: resources + settings: + base: + PRODUCT_NAME: TrackDrum + PRODUCT_BUNDLE_IDENTIFIER: computer.aestheticcomputer.trackdrum + INFOPLIST_FILE: Info.plist + CODE_SIGN_ENTITLEMENTS: TrackDrum.entitlements + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: FB5948YR3S + ENABLE_APP_SANDBOX: YES + ENABLE_HARDENED_RUNTIME: YES + ENABLE_USER_SCRIPT_SANDBOXING: NO + GENERATE_INFOPLIST_FILE: NO + postBuildScripts: + - name: Render TrackDrum Icon + basedOnDependencyAnalysis: false + script: | + /bin/bash "${SRCROOT}/render-icon.sh" \ + "${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Resources/TrackDrumIcon.icns" \ + "${DERIVED_FILE_DIR}/TrackDrumIcon" diff --git a/slab/tracktramp/render-icon.sh b/slab/tracktramp/render-icon.sh new file mode 100755 index 0000000000..f9cfd32a7e --- /dev/null +++ b/slab/tracktramp/render-icon.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUTPUT="${1:?usage: render-icon.sh OUTPUT.icns [WORK_DIR]}" +WORK_DIR="${2:-$(mktemp -d /tmp/trackdrum-icon.XXXXXX)}" +SDK="$(xcrun --sdk macosx --show-sdk-path)" +RENDERER="${WORK_DIR}/render-trackdrum-icon" +PNG="${WORK_DIR}/TrackDrumIcon-1024.png" +ICONSET="${WORK_DIR}/TrackDrumIcon.iconset" + +mkdir -p "${WORK_DIR}" "$(dirname "${OUTPUT}")" +xcrun swiftc -O -sdk "${SDK}" \ + "${SCRIPT_DIR}/Sources/TrackDrumIcon.swift" \ + "${SCRIPT_DIR}/render-icon.swift" \ + -o "${RENDERER}" +"${RENDERER}" "${PNG}" +mkdir -p "${ICONSET}" +for spec in "16:icon_16x16.png" "32:icon_16x16@2x.png" \ + "32:icon_32x32.png" "64:icon_32x32@2x.png" \ + "128:icon_128x128.png" "256:icon_128x128@2x.png" \ + "256:icon_256x256.png" "512:icon_256x256@2x.png" \ + "512:icon_512x512.png" "1024:icon_512x512@2x.png"; do + size="${spec%%:*}" + name="${spec#*:}" + sips -z "${size}" "${size}" "${PNG}" --out "${ICONSET}/${name}" >/dev/null +done +iconutil -c icns "${ICONSET}" -o "${OUTPUT}" diff --git a/slab/tracktramp/render-icon.swift b/slab/tracktramp/render-icon.swift new file mode 100644 index 0000000000..19640570a7 --- /dev/null +++ b/slab/tracktramp/render-icon.swift @@ -0,0 +1,22 @@ +import AppKit + +@main +private enum TrackDrumIconRenderer { + static func main() throws { + guard CommandLine.arguments.count == 2 else { + fputs("usage: render-icon output.png\n", stderr) + exit(2) + } + + let image = TrackDrumIcon.image(size: 1024, accent: .systemPink) + guard let tiff = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff), + let png = bitmap.representation(using: .png, properties: [:]) else { + exit(1) + } + try png.write( + to: URL(fileURLWithPath: CommandLine.arguments[1]), + options: .atomic + ) + } +} diff --git a/slab/tracktramp/test.sh b/slab/tracktramp/test.sh new file mode 100755 index 0000000000..28e19142de --- /dev/null +++ b/slab/tracktramp/test.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TEST_BIN="${SCRIPT_DIR}/build/TrackDrumContactTests" +mkdir -p "${SCRIPT_DIR}/build" +xcrun swiftc \ + "${SCRIPT_DIR}/Sources/TrackDrumContact.swift" \ + "${SCRIPT_DIR}/Tests/main.swift" \ + -o "${TEST_BIN}" +"${TEST_BIN}" diff --git a/slab/tracktramp/verify-app.sh b/slab/tracktramp/verify-app.sh new file mode 100755 index 0000000000..6d433c29c9 --- /dev/null +++ b/slab/tracktramp/verify-app.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/build/TrackDrum.app}" +BIN="${APP}/Contents/MacOS/TrackDrum" + +[[ -x "${BIN}" ]] || { echo "missing TrackDrum executable" >&2; exit 1; } +if [[ ! -f "${APP}/Contents/Resources/TracktrampShaders.metallib" \ + && ! -f "${APP}/Contents/Resources/TracktrampShaders.metalsource" ]]; then + echo "missing Metal shader" >&2 + exit 1 +fi +[[ -f "${APP}/Contents/Resources/TrackDrumIcon.icns" ]] || { + echo "missing app icon" >&2; exit 1; +} +[[ ! -e "${APP}/Contents/MacOS/TrackDrumLauncher" ]] || { + echo "legacy launcher must not ship" >&2; exit 1; +} + +codesign --verify --deep --strict --verbose=2 "${APP}" +SANDBOX="$(codesign -d --entitlements :- "${APP}" 2>/dev/null \ + | plutil -extract 'com\.apple\.security\.app-sandbox' raw -)" +[[ "${SANDBOX}" == "true" ]] || { echo "App Sandbox is missing" >&2; exit 1; } + +if strings "${BIN}" | grep -Eq \ + 'MultitouchSupport\.framework|MTDeviceCreateList|CGEventTapCreate|Library/LaunchAgents'; then + echo "private/global input implementation leaked into TrackDrum" >&2 + exit 1 +fi + +ARCHS="$(lipo -archs "${BIN}")" +[[ " ${ARCHS} " == *" arm64 "* && " ${ARCHS} " == *" x86_64 "* ]] || { + echo "TrackDrum must be universal: ${ARCHS}" >&2; exit 1; +} + +echo "✓ sandboxed public-input TrackDrum (${ARCHS})"