From 7e0d4c8b7f5d3d8213d502f79a2418bbcdd02ac7 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 28 Sep 2026 23:18:12 -0700 Subject: [PATCH] =?UTF-8?q?menuband:=20Electro=20TrackDrum=20kit=20on=20?= =?UTF-8?q?=E2=86=90/=E2=86=92,=20bells=20ring=20under=20linger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a TrackDrum hit, ←/→ flip between the Menu Band membrane and an Electro kit (909 kick chirp, Simmons tom, 909 snare + clap, 606 hat, 808 cowbell) on the same five concentric zones. The kit's name and number flash over the pad; it has its own neon ring artwork. The percussion voice gains pitch sweeps, exponential decays, HP/BP noise, the six-oscillator 808 metal bank, tanh drive and delayed onsets. Each Electro zone is loudness-matched at runtime (BS.1770 K-weighting, seeded renders) to the membrane's zone; a test holds every pad within 0.1 dB. Chromatic percussion (celesta, glock, vibes…) now rings out under Shift/Caps linger instead of the stuttering retrigger tail. --- .../Sources/MenuBand/GeneralMIDI.swift | 3 +- .../Sources/MenuBand/MenuBandController.swift | 57 +++- .../Sources/MenuBand/MenuBandDrumKits.swift | 319 ++++++++++++++++++ .../Sources/MenuBand/MenuBandPercussion.swift | 282 ++++++++++++---- .../Sources/MenuBand/PitchBendCursor.swift | 167 +++++---- .../Sources/MenuBand/TrackDrumKitArt.swift | 115 +++++++ .../MenuBandTests/DrumKitLoudnessTests.swift | 75 ++++ 7 files changed, 890 insertions(+), 128 deletions(-) create mode 100644 slab/menuband/Sources/MenuBand/MenuBandDrumKits.swift create mode 100644 slab/menuband/Sources/MenuBand/TrackDrumKitArt.swift create mode 100644 slab/menuband/Tests/MenuBandTests/DrumKitLoudnessTests.swift diff --git a/slab/menuband/Sources/MenuBand/GeneralMIDI.swift b/slab/menuband/Sources/MenuBand/GeneralMIDI.swift index 0ba187b2a0..dab56b7eea 100644 --- a/slab/menuband/Sources/MenuBand/GeneralMIDI.swift +++ b/slab/menuband/Sources/MenuBand/GeneralMIDI.swift @@ -262,7 +262,8 @@ enum GeneralMIDI { static func lingerCategory(for program: UInt8) -> LingerCategory { switch Int(program) { case 0...7: return .sustained // Pianos: long natural release - case 8...15: return .staccato // Chromatic Percussion (mallets, bells) + case 8...15: return .sustained // Chromatic Percussion: bells ring out + // (the doppler retrigger stuttered) case 16...23: return .sustained // Organs hold forever case 24...31: return .staccato // Guitars (plucked) case 32...37: return .staccato // Acoustic / Electric / Slap basses diff --git a/slab/menuband/Sources/MenuBand/MenuBandController.swift b/slab/menuband/Sources/MenuBand/MenuBandController.swift index f7ff1a5ded..7de771f8c6 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandController.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandController.swift @@ -385,7 +385,10 @@ final class MenuBandController { /// touched, instead of always defaulting to middle C. /// Defaults to 60 (C4) on a fresh session. private(set) var lastPlayedNote: UInt8 = 60 { - didSet { lastPlayedChord = [lastPlayedNote] } + didSet { + lastPlayedChord = [lastPlayedNote] + lastSoundWasTrackDrum = false // arrows go back to instruments + } } /// The last thing played, as pitches: one note, or a root and the /// extensions it was chorded with. ⇧Space tunes its air to this. @@ -1114,9 +1117,47 @@ final class MenuBandController { func trackpadDrumSkin(strike: CGPoint, anchors: [CGPoint], velocity: UInt8) { mixAnalysis.mark("skin-\(MenuBandPercussion.drumSkinZone(at: strike).rawValue)") + lastSoundWasTrackDrum = true synth.playDrumSkin(strike: strike, anchors: anchors, velocity: velocity) } + // MARK: - TrackDrum kits + + private let drumKitKey = "notepat.trackDrumKit" + + /// True while the most recent sound came off the TrackDrum skin. Then + /// ←/→ step drum kits instead of the melodic instrument; the next played + /// note hands the arrows back. + private(set) var lastSoundWasTrackDrum = false + + var drumKit: MenuBandPercussion.DrumKit { + get { + MenuBandPercussion.DrumKit( + rawValue: UserDefaults.standard.integer(forKey: drumKitKey) + ) ?? .menuBand + } + set { + UserDefaults.standard.set(newValue.rawValue, forKey: drumKitKey) + synth.percussion.kit = newValue + MenuBandPercussion.DrumKit.current = newValue + NotificationCenter.default.post(name: MenuBandPercussion.DrumKit.didChange, + object: newValue) + } + } + + /// Step the kit and audition it: kick, then snare, on the new voicing. + func stepDrumKit(delta: Int) { + let kit = drumKit.stepped(by: delta) + drumKit = kit + debugLog("TrackDrum kit = \(kit.label)") + synth.playDrumSkin(strike: CGPoint(x: 0.5, y: 0.5), anchors: [], velocity: 96) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) { [weak self] in + self?.synth.playDrumSkin(strike: CGPoint(x: 0.5, y: 0.775), + anchors: [], velocity: 90) + } + onChange?() + } + func trackpadSynthSurface(strike: CGPoint, anchors: [CGPoint], velocity: UInt8) { mixAnalysis.mark("synth-\(MenuBandPercussion.drumSkinZone(at: strike).rawValue)") synth.playSynthSurface(strike: strike, anchors: anchors, velocity: velocity) @@ -2351,6 +2392,8 @@ final class MenuBandController { // survive the relaunch. synth.setMasterVolume(masterVolume) synth.setPercussionVolume(percussionVolume) + synth.percussion.kit = drumKit + MenuBandPercussion.DrumKit.current = drumKit synth.setTonesVolume(tonesVolume) synth.setMonitorGain(monitorGain) // Studio reflex: a Focusrite on the bus means "monitor me". Checked @@ -3856,6 +3899,18 @@ final class MenuBandController { // while the user drags through cells — so the user can scan // voices by ear, not just by name. Auto-repeat is allowed so // holding an arrow streams through voices. + // …unless the last sound came off TrackDrum: then ←/→ flip between + // the Menu Band and Electro kits instead. Held + // arrows don't auto-repeat through kits — one press, one kit. + if (keyCode == 123 || keyCode == 124) && lastSoundWasTrackDrum { + if isDown && !isRepeat { + let delta = keyCode == 123 ? -1 : 1 + DispatchQueue.main.async { [weak self] in + self?.stepDrumKit(delta: delta) + } + } + return true + } switch keyCode { case 123: if isDown { diff --git a/slab/menuband/Sources/MenuBand/MenuBandDrumKits.swift b/slab/menuband/Sources/MenuBand/MenuBandDrumKits.swift new file mode 100644 index 0000000000..123fb4d7e2 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/MenuBandDrumKits.swift @@ -0,0 +1,319 @@ +import CoreGraphics +import Foundation +import QuartzCore + +/// Drum-machine kits for the TrackDrum skin. Menu Band's own membrane stays +/// in MenuBandPercussion; a kit here is five recipes (layers of swept +/// oscillators, filtered noise and the 808 metal bank), one per concentric +/// zone of the membrane — kick, tom, snare, hat, click — blended across the +/// same contours so the trackpad plays the same way in every kit. +/// +/// Every pad is loudness-matched at runtime: its recipe is rendered offline +/// through the same `nextSample` the audio thread uses, K-weighted +/// (ITU-R BS.1770), and trimmed so it lands on the loudness the Menu Band +/// membrane has for the same role. Switching kits changes the sound, not the +/// level. +extension MenuBandPercussion { + + // MARK: Recipe + + struct Layer { + var wave: Wave + var freq: Double + var level: Double + /// Exponential decay time constant (s); the voice lasts ~5·tau. + var tau: Double + var attack: Double = 0.0005 + /// Pitch starts `sweep` Hz above `freq` and falls with `sweepTau`. + var sweep: Double = 0 + var sweepTau: Double = 0.02 + var filter: NoiseFilter = .lowpass + var q: Double = 0.707 + var drive: Double = 0 + var delay: Double = 0 + var metalScale: Double = 1 + } + + enum Role { case kick, tom, snare, hat, click } + + struct KitPad { + let name: String + let role: Role + /// Deliberate offset from the role's matched loudness, in dB. + var trimDB: Double = 0 + let layers: [Layer] + } + + /// Pads in zone order, center out: kick, tom, snare, hat, click. + struct KitSpec { + let pads: [KitPad] + } + + static func spec(for kit: DrumKit) -> KitSpec? { + switch kit { + case .menuBand: return nil + case .electro: return electro + } + } + + // MARK: Electro — 909 / 606, concentric rings (continuous morph) + // 909 kick chirp (Tune = 30–120 ms), 909 snare tones at 180/330 Hz, + // Simmons-style toms with a zap, 606 hat, 808 cowbell. + + private static let electro = KitSpec(pads: [ + KitPad(name: "909", role: .kick, layers: [ + // ~230 → 52 Hz chirp, a hard HP'd noise click, light drive. + Layer(wave: .sine, freq: 52, level: 1.0, tau: 0.10, sweep: 180, sweepTau: 0.018, drive: 1.5), + Layer(wave: .noise, freq: 3000, level: 0.45, tau: 0.0012, filter: .highpass, q: 0.7), + Layer(wave: .square, freq: 1000, level: 0.12, tau: 0.0005), + ]), + KitPad(name: "SIMMONS", role: .tom, layers: [ + // Simmons tom: f0 × 2 → f0 over ~200 ms, plus a zap on top and + // a 1.5 kHz stick. + Layer(wave: .sine, freq: 125, level: 1.0, tau: 0.10, sweep: 125, sweepTau: 0.06), + Layer(wave: .triangle, freq: 160, level: 0.25, tau: 0.018, sweep: 2600, sweepTau: 0.012), + Layer(wave: .noise, freq: 1500, level: 0.25, tau: 0.003, filter: .bandpass, q: 1.2), + ]), + KitPad(name: "SNARE", role: .snare, layers: [ + // 909 snare: 180/330 Hz tones dropping ~15%, noise band 1–8 kHz + // longer and brighter than the 808's, and a clap flam on top. + Layer(wave: .sine, freq: 180, level: 0.55, tau: 0.03, sweep: 30, sweepTau: 0.015), + Layer(wave: .sine, freq: 330, level: 0.30, tau: 0.018, sweep: 50, sweepTau: 0.015), + Layer(wave: .noise, freq: 3500, level: 0.85, tau: 0.05, filter: .bandpass, q: 0.35), + Layer(wave: .noise, freq: 1200, level: 0.55, tau: 0.003, filter: .bandpass, q: 2.0), + Layer(wave: .noise, freq: 1200, level: 0.55, tau: 0.003, filter: .bandpass, q: 2.0, delay: 0.009), + Layer(wave: .noise, freq: 1200, level: 0.55, tau: 0.003, filter: .bandpass, q: 2.0, delay: 0.018), + ]), + KitPad(name: "606", role: .hat, layers: [ + // 606 hat: the metal bank pitched up, high-passed at 8 kHz, tight. + Layer(wave: .metal, freq: 8000, level: 1.0, tau: 0.008, filter: .highpass, q: 0.8, metalScale: 1.5), + Layer(wave: .noise, freq: 10_000, level: 0.2, tau: 0.005, filter: .highpass), + ]), + KitPad(name: "BELL", role: .click, layers: [ + // 808 cowbell: 540 + 800 Hz squares through a 2.3 kHz band-pass, + // a fast 15 ms drop then a longer tail. + Layer(wave: .square, freq: 540, level: 0.9, tau: 0.005, filter: .bandpass, q: 1.5), + Layer(wave: .square, freq: 800, level: 0.9, tau: 0.005, filter: .bandpass, q: 1.5), + Layer(wave: .square, freq: 540, level: 0.35, tau: 0.07, filter: .bandpass, q: 1.5), + Layer(wave: .square, freq: 800, level: 0.35, tau: 0.07, filter: .bandpass, q: 1.5), + ]), + ]) + + // MARK: Playing a kit + + /// Zone weights for kick, tom, snare, hat, click — the membrane's own + /// contours, so a kit blends exactly where the default does. + func ringWeights(at point: CGPoint) -> [Double] { + let sx = Double(point.x - 0.5) * 2 + let sy = Double(point.y - 0.5) * 2 + let r = Self.roundedTrackpadDistance(sx: sx, sy: sy) + let edge = smoothstep(0.62, 0.70, r) + let outerClick = smoothstep(0.88, 0.965, r) + return [ + 1.0 - smoothstep(0.23, 0.31, r), + smoothstep(0.23, 0.31, r) * (1.0 - smoothstep(0.40, 0.48, r)), + smoothstep(0.40, 0.48, r) * (1.0 - edge), + edge * (1.0 - outerClick), + outerClick, + ] + } + + /// Build a pad's voices. `tension` raises tonal pitch (resting fingers + /// tighten the head) and `damping` shortens decays. + func padVoices(_ pad: KitPad, gain: Double, pan: Double, + tension: Double = 1, damping: Double = 1, + jitter: Bool = true) -> [Voice] { + pad.layers.map { layer in + let tonal = layer.wave != .noise && layer.wave != .metal + let tune = tonal ? tension : 1 + let tau = layer.tau * damping + let length = layer.attack + tau * 5.5 + let level = layer.level * gain * (jitter ? rj(1, 0.06) : 1) + var voice = makeVoice(layer.wave, layer.freq * tune, length, level, + layer.attack, min(0.012, length * 0.2), + pan + (jitter ? rn(-0.02, 0.02) : 0)) + voice.tau = tau + voice.sweep = layer.sweep * (tonal ? tension : 1) + voice.sweepTau = layer.sweepTau + voice.drive = layer.drive + voice.delay = layer.delay + voice.metalScale = layer.metalScale + voice.filter = layer.filter + voice.q = layer.q + if layer.wave == .noise || layer.wave == .metal { + if jitter { voice.freq *= rj(1, 0.04) } + setupNoiseFilter(&voice) + } else if layer.filter == .bandpass { + // Tonal waves take the filter only when asked: the 808 bell's + // shared ~2.3 kHz band-pass over its 540/800 Hz squares. + voice.filtered = true + var shaped = voice + shaped.freq = 2_300 + setupNoiseFilter(&shaped) + voice.nb0 = shaped.nb0; voice.nb1 = shaped.nb1; voice.nb2 = shaped.nb2 + voice.na1 = shaped.na1; voice.na2 = shaped.na2 + } + return voice + } + } + + func playKit(_ kit: DrumKit, strike: CGPoint, anchors: [CGPoint], + velocity: UInt8) { + guard let spec = Self.spec(for: kit) else { return } + let gains = padGains(for: kit) + let v = max(0.1, min(2.2, Double(velocity) / 100.0)) + let sx = Double(strike.x - 0.5) * 2 + let sy = Double(strike.y - 0.5) * 2 + let pan = stereo(sx, sy, 0.72) + let tension = 1.0 + min(0.30, Double(anchors.count) * 0.07) + let damping = 1.0 - min(0.55, Double(anchors.count) * 0.12) + var voices: [Voice] = [] + let weights = ringWeights(at: strike) + for (index, weight) in weights.enumerated() + where weight > 0.02 && index < spec.pads.count { + voices += padVoices(spec.pads[index], gain: gains[index] * v * weight, + pan: pan, tension: tension, damping: damping) + } + let top = weights.indices.max { weights[$0] < weights[$1] } ?? 0 + + let pulseDrum: Drum + switch spec.pads[min(top, spec.pads.count - 1)].role { + case .kick: pulseDrum = .kick + case .tom: pulseDrum = .block + case .snare: pulseDrum = .snare + case .hat: pulseDrum = .hatClosed + case .click: pulseDrum = .cowbell + } + let now = CACurrentMediaTime() + lock.lock() + pending.append(contentsOf: voices) + pulses[pulseDrum.rawValue] = DrumPulse( + at: now, level: min(1.0, Double(velocity) / 127.0) + ) + pendingStageTime = now + lock.unlock() + } + + // MARK: Loudness matching + + /// Per-pad linear gains that put every pad on its role's reference + /// loudness. Computed once per kit per sample rate, off the audio thread. + func padGains(for kit: DrumKit) -> [Double] { + guard let spec = Self.spec(for: kit) else { return [] } + Self.gainLock.lock() + defer { Self.gainLock.unlock() } + let key = "\(kit.rawValue)@\(Int(sampleRate))" + if let cached = Self.gainCache[key] { return cached } + let targets = roleTargets() + let gains = spec.pads.map { pad -> Double in + let measured = averagedLoudness(of: pad) + let target = targets[pad.role] ?? -20 + return pow(10, (target + pad.trimDB - measured) / 20) + } + Self.gainCache[key] = gains + return gains + } + + /// Noise layers differ render to render; average the energy of a few so + /// a pad's trim doesn't inherit one lucky or unlucky seed. + func averagedLoudness(of pad: KitPad, renders: Int = 8) -> Double { + var energy = 0.0 + for render in 0.. [Voice] { + voices.enumerated().map { index, voice in + var copy = voice + copy.seed = UInt32(truncatingIfNeeded: 0x9E37_79B9 &* (render * 64 + index + 1)) + if copy.seed == 0 { copy.seed = 1 } + return copy + } + } + + private static let gainLock = NSLock() + private static var gainCache: [String: [Double]] = [:] + + /// Reference loudness per role: the Menu Band membrane struck at the + /// middle of each zone at velocity 100. + func roleTargets() -> [Role: Double] { + let probes: [(Role, CGFloat)] = [ + (.kick, 0.0), (.tom, 0.35), (.snare, 0.55), (.hat, 0.78), (.click, 0.94), + ] + var targets: [Role: Double] = [:] + for (role, radius) in probes { + // roundedTrackpadDistance ≈ |sy| near the vertical center line. + let point = CGPoint(x: 0.5, y: 0.5 + radius / 2) + var energy = 0.0 + for render in 0..<8 { + let voices = Self.seeded( + membraneVoices(strike: point, anchors: [], velocity: 100).0, render) + energy += pow(10, loudness(of: voices) / 10) + } + targets[role] = 10 * log10(energy / 8) + } + return targets + } + + /// K-weighted loudness (dB, relative) of a set of voices over the 400 ms + /// after onset — BS.1770's momentary window, which suits one drum hit. + func loudness(of voices: [Voice]) -> Double { + let rate = sampleRate + let frames = Int(rate * 0.4) + var buffer = [Double](repeating: 0, count: frames) + let dt = 1.0 / rate + for var voice in voices { + for i in 0..= voice.duration + voice.delay { break } + buffer[i] += Self.nextSample(&voice, pitch: 1, dt: dt) + * Double(voice.gainL + voice.gainR) * 0.5 + } + } + var shelf = Biquad.kShelf(rate) + var highpass = Biquad.kHighpass(rate) + var energy = 0.0 + for sample in buffer { + let y = highpass.process(shelf.process(sample)) + energy += y * y + } + return 10 * log10(max(1e-12, energy / Double(frames))) + } + + /// BS.1770 K-weighting stages (pyloudnorm's parameterization, so they + /// hold at 44.1k and 96k as well as 48k). + struct Biquad { + var b0 = 1.0, b1 = 0.0, b2 = 0.0, a1 = 0.0, a2 = 0.0 + var x1 = 0.0, x2 = 0.0, y1 = 0.0, y2 = 0.0 + + mutating func process(_ x: Double) -> Double { + let y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2 + x2 = x1; x1 = x; y2 = y1; y1 = y + return y + } + + static func kShelf(_ rate: Double) -> Biquad { + let gain = 3.99984385397, q = 0.7071752369554193, fc = 1681.9744509555319 + let a = pow(10, gain / 40), w = 2 * .pi * fc / rate + let alpha = sin(w) / (2 * q), c = cos(w), sa = 2 * sqrt(a) * alpha + let a0 = (a + 1) - (a - 1) * c + sa + return Biquad(b0: a * ((a + 1) + (a - 1) * c + sa) / a0, + b1: -2 * a * ((a - 1) + (a + 1) * c) / a0, + b2: a * ((a + 1) + (a - 1) * c - sa) / a0, + a1: 2 * ((a - 1) - (a + 1) * c) / a0, + a2: ((a + 1) - (a - 1) * c - sa) / a0) + } + + static func kHighpass(_ rate: Double) -> Biquad { + let q = 0.5003270373253953, fc = 38.13547087613982 + let w = 2 * .pi * fc / rate + let alpha = sin(w) / (2 * q), c = cos(w), a0 = 1 + alpha + return Biquad(b0: (1 + c) / 2 / a0, b1: -(1 + c) / a0, + b2: (1 + c) / 2 / a0, + a1: -2 * c / a0, a2: (1 - alpha) / a0) + } + } +} diff --git a/slab/menuband/Sources/MenuBand/MenuBandPercussion.swift b/slab/menuband/Sources/MenuBand/MenuBandPercussion.swift index 6b92e885d5..61efebc314 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandPercussion.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandPercussion.swift @@ -48,7 +48,44 @@ final class MenuBandPercussion { } } - private enum Wave { case sine, triangle, square, noise } + /// Which voicing the TrackDrum skin speaks with. `.menuBand` is the + /// original modal membrane; the genre kits keep the same five concentric + /// zones (kick, tom, snare, hat, click) but swap what each zone sounds + /// like. ←/→ steps through them when the last sound was a TrackDrum hit. + enum DrumKit: Int, CaseIterable { + case menuBand = 0, electro + + var label: String { + switch self { + case .menuBand: return "Menu Band" + case .electro: return "Electro" + } + } + + /// 1-based, as shown on the pad and in the switch banner. + var number: Int { rawValue + 1 } + + /// Main-thread mirror of the chosen kit for the pad artwork, plus the + /// notification the overlay listens to (rebuild texture, flash name). + static var current: DrumKit = .menuBand + static let didChange = Notification.Name("MenuBandDrumKitDidChange") + + func stepped(by delta: Int) -> DrumKit { + let n = Self.allCases.count + return DrumKit(rawValue: ((rawValue + delta) % n + n) % n) ?? .menuBand + } + } + + /// Written from the main thread, read on the control thread that builds + /// voices — a plain enum load/store, same as `pitchScale`. + var kit: DrumKit = .menuBand + + /// `metal` is the TR-808 cymbal/hat source: six detuned square + /// oscillators summed, then shaped by the voice's filter like noise. + enum Wave { case sine, triangle, square, noise, metal } + + /// Filter shape for noise / metal voices (`freq` is the cutoff/center). + enum NoiseFilter: UInt8 { case lowpass, highpass, bandpass } /// A drum hit's timestamp + 0…1 level, kept per pitch class so the /// menubar can shake/blink the matching key with the live hit data. @@ -58,7 +95,7 @@ final class MenuBandPercussion { } /// Most recent hit per pitch class (index == Drum.rawValue). Read as a /// snapshot by the icon renderer each frame. - private var pulses = [DrumPulse](repeating: DrumPulse(), count: 12) + var pulses = [DrumPulse](repeating: DrumPulse(), count: 12) /// Thread-safe copy of the current per-pitch-class hit pulses. func pulseSnapshot() -> [DrumPulse] { @@ -68,7 +105,7 @@ final class MenuBandPercussion { // MARK: Voice state (render thread owns the active pool) - private struct Voice { + struct Voice { var wave: Wave var freq: Double // Hz; for noise this is the LPF cutoff var duration: Double // seconds (.infinity for held voices) @@ -93,6 +130,20 @@ final class MenuBandPercussion { var na1: Double = 0, na2: Double = 0 var nx1: Double = 0, nx2: Double = 0, ny1: Double = 0, ny2: Double = 0 var seed: UInt32 = 1 + // Genre-kit shaping (all inert at their defaults, so the Menu Band + // membrane and the keyboard kit render exactly as before). + var delay: Double = 0 // silent lead-in (clap flams) + var tau: Double = 0 // exponential decay time constant + var expGain: Double = 1 + var expK: Double = 0 // per-sample multiplier, set lazily + var sweep: Double = 0 // Hz above `freq`, decaying to 0 + var sweepTau: Double = 0 + var sweepK: Double = 0 + var drive: Double = 0 // tanh saturation amount (0 = clean) + var filter: NoiseFilter = .lowpass + var q: Double = 1.0 + var metalScale: Double = 1.0 // multiplies the six metal oscillators + var filtered = false // run a tonal wave through the biquad } // MARK: Audio graph @@ -101,7 +152,7 @@ final class MenuBandPercussion { /// our synthesis runs at the hardware clock with no resample (the noise /// biquad + phase increments use this). Pitch itself is absolute Hz, so /// every drum's tones match AC-native regardless of the rate. - private var sampleRate: Double = 48_000 + private(set) var sampleRate: Double = 48_000 private var format: AVAudioFormat! private var sourceNode: AVAudioSourceNode! private weak var engine: AVAudioEngine? @@ -146,8 +197,8 @@ final class MenuBandPercussion { /// Pending voices staged by the control thread, drained at the top of /// each render cycle. Guarded by a tiny critical section. - private var pending: [Voice] = [] - private let lock = NSLock() + var pending: [Voice] = [] + let lock = NSLock() /// Which surface axis the stereo image follows, as a unit vector over the /// centered coordinates (sx, sy). A Mac trackpad pans along its long axis — @@ -156,7 +207,7 @@ final class MenuBandPercussion { /// right on the glass stay left and right in the ears. Default is the Mac's. var panAxis = (x: 1.0, y: 0.0) - private func stereo(_ sx: Double, _ sy: Double, _ reach: Double) -> Double { + func stereo(_ sx: Double, _ sy: Double, _ reach: Double) -> Double { max(-1.0, min(1.0, (sx * panAxis.x + sy * panAxis.y) * reach)) } @@ -174,7 +225,7 @@ final class MenuBandPercussion { /// Latency probe: when voices were staged (noteOn) vs. when the render /// thread first picked them up — the trigger→render handoff, the /// percussion-specific slice of keypress→sound latency. - private var pendingStageTime: Double = 0 + var pendingStageTime: Double = 0 private var lastHandoffSec: Double = 0 private var pendingTrackpadInputTime: Double = 0 private var lastTrackpadInputToRenderSec: Double = 0 @@ -251,6 +302,11 @@ final class MenuBandPercussion { engine.attach(sourceNode) engine.connect(sourceNode, to: output, format: format) attached = true + // Loudness-match every genre kit now, at the real output rate, so the + // first hit on a freshly chosen kit never waits on a measurement. + DispatchQueue.global(qos: .utility).async { [self] in + for kit in DrumKit.allCases { _ = padGains(for: kit) } + } } // MARK: Trigger @@ -301,6 +357,29 @@ final class MenuBandPercussion { /// the membrane from the outer edge, where short noise/square modes become /// the hat. func playDrumSkin(strike: CGPoint, anchors: [CGPoint], velocity: UInt8) { + if kit != .menuBand { + playKit(kit, strike: strike, anchors: anchors, velocity: velocity) + return + } + let (voices, pulseDrum) = membraneVoices(strike: strike, anchors: anchors, + velocity: velocity) + let now = CACurrentMediaTime() + lock.lock() + // Stage every onset. The render thread owns the bounded 96-voice + // policy and records any pressure; capping here could silently erase + // the last finger of a same-frame multitouch strike. + pending.append(contentsOf: voices) + pulses[pulseDrum.rawValue] = DrumPulse( + at: now, level: min(1.0, Double(velocity) / 127.0) + ) + pendingStageTime = now + lock.unlock() + } + + /// The Menu Band membrane's voices for one strike, unstaged — so the + /// genre kits can be loudness-matched against it offline. + func membraneVoices(strike: CGPoint, anchors: [CGPoint], + velocity: UInt8) -> ([Voice], Drum) { struct Mode { let m: Int32; let root: Double; let gain: Double } let modes = [ Mode(m: 0, root: 2.4048, gain: 0.46), @@ -435,20 +514,10 @@ final class MenuBandPercussion { 0.20 * click, 0.0001, 0.0048, pan)) } - let now = CACurrentMediaTime() - lock.lock() - // Stage every onset. The render thread owns the bounded 96-voice - // policy and records any pressure; capping here could silently erase - // the last finger of a same-frame multitouch strike. - pending.append(contentsOf: voices) let pulseDrum: Drum = edge > 0.55 ? .hatClosed : (snareBand > 0.45 ? .snare : (tomBand > 0.45 ? .block : .kick)) - pulses[pulseDrum.rawValue] = DrumPulse( - at: now, level: min(1.0, Double(velocity) / 127.0) - ) - pendingStageTime = now - lock.unlock() + return (voices, pulseDrum) } /// Finger-up articulation for the continuous surfaces. It is deliberately @@ -456,6 +525,9 @@ final class MenuBandPercussion { /// tiny closure, and the last perimeter strip a near-instant click. func playSurfaceLift(at point: CGPoint, anchors: [CGPoint], velocity: UInt8, synthetic: Bool) { + // Drum machines don't answer a finger lifting off; only the membrane + // (and the synth surface) do. + guard synthetic || kit == .menuBand else { return } let sx = Double(point.x - 0.5) * 2 let sy = Double(point.y - 0.5) * 2 let distance = Self.roundedTrackpadDistance(sx: sx, sy: sy) @@ -615,6 +687,12 @@ final class MenuBandPercussion { direction: CGVector = .zero, surfaceEnergy: Double = 0, synthetic: Bool = false) { + // Head friction belongs to the membrane; the drum machine stays dry + // under a dragging finger. + if !synthetic, kit == .electro { + stopDrumSkinScratch() + return + } let sx = Double(point.x - 0.5) * 2 let sy = Double(point.y - 0.5) * 2 let distance = Self.roundedTrackpadDistance( @@ -799,7 +877,7 @@ final class MenuBandPercussion { return 1.0 - min(1.0, inwardDepth / halfHeight) } - private func smoothstep(_ edge0: Double, _ edge1: Double, _ x: Double) -> Double { + func smoothstep(_ edge0: Double, _ edge1: Double, _ x: Double) -> Double { let t = max(0, min(1, (x - edge0) / (edge1 - edge0))) return t * t * (3 - 2 * t) } @@ -911,10 +989,10 @@ final class MenuBandPercussion { // MARK: Recipe → voices (port of percussion.mjs) - private func rj(_ c: Double, _ f: Double) -> Double { + func rj(_ c: Double, _ f: Double) -> Double { c * (1 + (Double.random(in: 0..<1) - 0.5) * 2 * f) } - private func rn(_ a: Double, _ b: Double) -> Double { + func rn(_ a: Double, _ b: Double) -> Double { a + Double.random(in: 0..<1) * (b - a) } @@ -923,7 +1001,7 @@ final class MenuBandPercussion { /// Build one layer. `decay == 0` (the sustain voices) fades across the /// whole duration so the tail never clicks off — the JS relies on the /// engine's default release for that, which we make explicit here. - private func makeVoice(_ wave: Wave, _ tone: Double, _ duration: Double, + func makeVoice(_ wave: Wave, _ tone: Double, _ duration: Double, _ volume: Double, _ attack: Double, _ decay: Double, _ pan: Double) -> Voice { let p = max(-1.0, min(1.0, pan)) @@ -939,7 +1017,7 @@ final class MenuBandPercussion { return voice } - private struct DrumVoices { + struct DrumVoices { var down: [Voice] = [] // fired on key-down var release: [Voice] = [] // fired on key-up (closed-hat lift click) } @@ -1106,17 +1184,34 @@ final class MenuBandPercussion { // MARK: Noise biquad (matches audio.c setup_noise_filter, Q = 1) - private func setupNoiseFilter(_ v: inout Voice) { + func setupNoiseFilter(_ v: inout Voice) { + Self.setupFilter(&v, sampleRate: sampleRate) + } + + /// RBJ cookbook biquads. Low-pass at Q 1 is the original noise filter. + static func setupFilter(_ v: inout Voice, sampleRate: Double) { let cutoff = min(v.freq, sampleRate * 0.45) - let q = 1.0 + let q = max(0.1, v.q) let w0 = 2.0 * .pi * cutoff / sampleRate let alpha = sin(w0) / (2.0 * q) let cosw = cos(w0) - let b = (1.0 - cosw) / 2.0 let norm = 1.0 + alpha - v.nb0 = b / norm - v.nb1 = (1.0 - cosw) / norm - v.nb2 = b / norm + switch v.filter { + case .lowpass: + let b = (1.0 - cosw) / 2.0 + v.nb0 = b / norm + v.nb1 = (1.0 - cosw) / norm + v.nb2 = b / norm + case .highpass: + let b = (1.0 + cosw) / 2.0 + v.nb0 = b / norm + v.nb1 = -(1.0 + cosw) / norm + v.nb2 = b / norm + case .bandpass: + v.nb0 = alpha / norm + v.nb1 = 0 + v.nb2 = -alpha / norm + } v.na1 = (-2.0 * cosw) / norm v.na2 = (1.0 - alpha) / norm } @@ -1130,7 +1225,7 @@ final class MenuBandPercussion { // MARK: Envelope (matches audio.c compute_envelope) @inline(__always) - private func envelope(_ v: Voice, at t: Double) -> Double { + private static func envelope(_ v: Voice, at t: Double) -> Double { var env = 1.0 if v.attack > 0, t < v.attack { env = t / v.attack @@ -1146,6 +1241,92 @@ final class MenuBandPercussion { return env } + @inline(__always) + private static func xorshiftS(_ s: inout UInt32) -> UInt32 { + s ^= s << 13; s ^= s >> 17; s ^= s << 5 + return s + } + + /// The 808 cymbal/hat oscillator bank (Hz), per the TR-808 service notes. + static let metalFreqs: [Double] = [205.3, 304.4, 369.6, 522.7, 540, 800] + + /// Advance one voice by one sample and return its amplitude (before the + /// master gain and pan). Shared by the render thread and the offline + /// loudness meter so both hear exactly the same synthesis. + @inline(__always) + static func nextSample(_ v: inout Voice, pitch: Double, dt: Double) -> Double { + let t = v.elapsed - v.delay + guard t >= 0 else { + v.elapsed += dt + return 0 + } + var env = envelope(v, at: t) + if v.tau > 0 { + if v.expK == 0 { v.expK = exp(-dt / v.tau) } + env *= v.expGain + v.expGain *= v.expK + } + let relGain = v.releasing + ? max(0.0, 1.0 - v.releaseElapsed / v.releaseFade) : 1.0 + var s: Double + switch v.wave { + case .sine: + s = sin(2.0 * .pi * v.phase) + case .triangle: + // Wrap the +0.25 phase offset back into [0,1) — without the wrap + // the last quarter ramps to +2.0, adding bright harmonics that + // alias at 48k and read as a higher, harsher pitch than AC-native. + let tp = (v.phase + 0.25).truncatingRemainder(dividingBy: 1.0) + s = 4.0 * abs(tp - 0.5) - 1.0 + case .square: + s = v.phase < 0.5 ? 1.0 : -1.0 + if v.filtered { + let y = v.nb0 * s + v.nb1 * v.nx1 + v.nb2 * v.nx2 + - v.na1 * v.ny1 - v.na2 * v.ny2 + v.nx2 = v.nx1; v.nx1 = s + v.ny2 = v.ny1; v.ny1 = y + s = y + } + case .noise, .metal: + var x: Double + if v.wave == .noise { + let r = Double(xorshiftS(&v.seed)) / Double(UInt32.max) + x = r * 2.0 - 1.0 + } else { + x = 0 + for f in metalFreqs { + let ph = (t * f * v.metalScale).truncatingRemainder(dividingBy: 1) + x += ph < 0.5 ? 1 : -1 + } + x /= 6 + } + let y = v.nb0 * x + v.nb1 * v.nx1 + v.nb2 * v.nx2 + - v.na1 * v.ny1 - v.na2 * v.ny2 + v.nx2 = v.nx1; v.nx1 = x + v.ny2 = v.ny1; v.ny1 = y + s = y + } + var out = s * env * relGain + if v.drive > 0 { + out = tanh(out * v.drive) / tanh(v.drive) + } + out *= v.volume + // Kit-wide pitch bend scales the phase increment so tonal drums bend + // with the melodic voices. Noise voices use `freq` as a filter + // cutoff, not a pitch, so they're left unscaled. + var f = v.freq + if v.sweep > 0 { + if v.sweepK == 0 { v.sweepK = exp(-dt / max(1e-4, v.sweepTau)) } + f += v.sweep + v.sweep *= v.sweepK + } + v.phase += f * (v.wave == .noise ? 1.0 : pitch) * dt + if v.phase >= 1 { v.phase -= floor(v.phase) } + v.elapsed += dt + if v.releasing { v.releaseElapsed += dt } + return out + } + // MARK: Render thread private func render(frameCount: Int, abl: UnsafeMutablePointer) { @@ -1171,7 +1352,7 @@ final class MenuBandPercussion { let voice = active[index] let alive = voice.releasing ? voice.releaseElapsed < voice.releaseFade - : (voice.held || voice.elapsed < voice.duration) + : (voice.held || voice.elapsed < voice.duration + voice.delay) if alive { active[liveWrite] = voice liveWrite += 1 @@ -1266,44 +1447,11 @@ final class MenuBandPercussion { if v.releasing { if v.releaseElapsed >= v.releaseFade { alive = false; break } } else if !v.held { - if v.elapsed >= v.duration { alive = false; break } - } - let env = envelope(v, at: v.elapsed) - let relGain = v.releasing - ? max(0.0, 1.0 - v.releaseElapsed / v.releaseFade) : 1.0 - var s: Double - switch v.wave { - case .sine: - s = sin(2.0 * .pi * v.phase) - case .triangle: - // Wrap the +0.25 phase offset back into [0,1) — without - // the wrap the last quarter ramps to +2.0, adding bright - // harmonics that alias at 48k and read as a higher, - // harsher pitch than AC-native (block / o key). - let tp = (v.phase + 0.25).truncatingRemainder(dividingBy: 1.0) - s = 4.0 * abs(tp - 0.5) - 1.0 - case .square: - s = v.phase < 0.5 ? 1.0 : -1.0 - case .noise: - let r = Double(xorshift(&v.seed)) / Double(UInt32.max) - let white = r * 2.0 - 1.0 - let y = v.nb0 * white + v.nb1 * v.nx1 + v.nb2 * v.nx2 - - v.na1 * v.ny1 - v.na2 * v.ny2 - v.nx2 = v.nx1; v.nx1 = white - v.ny2 = v.ny1; v.ny1 = y - s = y + if v.elapsed >= v.duration + v.delay { alive = false; break } } - let amp = s * env * relGain * v.volume * g + let amp = Self.nextSample(&v, pitch: pitch, dt: dt) * g left[i] += Float(amp * Double(v.gainL)) right[i] += Float(amp * Double(v.gainR)) - // Kit-wide pitch bend scales the phase increment so - // tonal drums (kick/toms/blocks/cowbell) bend with the - // melodic voices. Noise voices use `freq` as an LPF - // cutoff, not a pitch, so they're left unscaled. - v.phase += v.freq * (v.wave == .noise ? 1.0 : pitch) * dt - if v.phase >= 1 { v.phase -= floor(v.phase) } - v.elapsed += dt - if v.releasing { v.releaseElapsed += dt } } if alive { active[w] = v diff --git a/slab/menuband/Sources/MenuBand/PitchBendCursor.swift b/slab/menuband/Sources/MenuBand/PitchBendCursor.swift index bf49a57b7f..468179c725 100644 --- a/slab/menuband/Sources/MenuBand/PitchBendCursor.swift +++ b/slab/menuband/Sources/MenuBand/PitchBendCursor.swift @@ -457,67 +457,76 @@ enum TrackpadDrumSkinPad { : NSColor(srgbRed: 0.48, green: 0.29, blue: 0.19, alpha: 1) // umber NSGraphicsContext.saveGraphicsState() body.addClip() - clickColor.setFill(); body.fill() - hatColor.setFill(); hatZone.fill() - snareColor.setFill(); rimZone.fill() - - // Dense parallel wires immediately read as the snare material. The - // inner tom and kick fills below mask them out of the center. - NSGraphicsContext.saveGraphicsState() - rimZone.addClip() - let wires = NSBezierPath() - stride(from: chart.minX - chart.height, - through: chart.maxX, by: 6).forEach { x in - wires.move(to: NSPoint(x: x, y: chart.minY)) - wires.line(to: NSPoint(x: x + chart.height, y: chart.maxY)) - } - (isDark ? NSColor.white : NSColor.black) - .withAlphaComponent(0.16).setStroke() - wires.lineWidth = 0.55 - 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 - // the hard chassis click. This remains legible at the overlay's actual - // size and makes the two edge instruments visually different. - let teeth = NSBezierPath() - stride(from: chart.minX + 9, through: chart.maxX - 9, by: 9).forEach { x in - let slant: CGFloat = Int((x - chart.minX) / 9).isMultiple(of: 2) ? 2 : -2 - teeth.move(to: NSPoint(x: x, y: chart.maxY - 5.2)) - teeth.line(to: NSPoint(x: x + slant, y: chart.maxY - 13.6)) - teeth.move(to: NSPoint(x: x, y: chart.minY + 5.2)) - teeth.line(to: NSPoint(x: x - slant, y: chart.minY + 13.6)) - } - stride(from: chart.minY + 10, through: chart.maxY - 10, by: 9).forEach { y in - let slant: CGFloat = Int((y - chart.minY) / 9).isMultiple(of: 2) ? 2 : -2 - teeth.move(to: NSPoint(x: chart.minX + 5.2, y: y)) - teeth.line(to: NSPoint(x: chart.minX + 13.6, y: y + slant)) - teeth.move(to: NSPoint(x: chart.maxX - 5.2, y: y)) - teeth.line(to: NSPoint(x: chart.maxX - 13.6, y: y - slant)) - } - clickColor.withAlphaComponent(0.72).setStroke() - teeth.lineWidth = 1.25 - teeth.stroke() - - let clickRail = NSBezierPath(roundedRect: chart.insetBy(dx: 1.5, dy: 1.5), - xRadius: 6.5, yRadius: 6.5) - (isDark ? NSColor.white : NSColor.black) - .withAlphaComponent(0.62).setStroke() - clickRail.lineWidth = 2.2 - clickRail.stroke() - - TrackpadEnergyVisual.draw(energy, in: chart, accent: accent) - TrackpadMembraneLighting.draw(membrane, in: chart) let boundaryColor = (isDark ? NSColor.white : NSColor.black) .withAlphaComponent(isDark ? 0.32 : 0.50) - boundaryColor.setStroke() - for (zone, width) in [(hatZone, 0.9), (rimZone, 2.0), - (snareZone, 0.9), (kickZone, 1.3)] { - zone.lineWidth = width - zone.stroke() + let kit = MenuBandPercussion.DrumKit.current + if kit != .menuBand { + // Genre kits bring their own palette and arrangement. + TrackDrumKitArt.drawMaterial(kit, in: chart, body: body, dark: isDark) + TrackpadEnergyVisual.draw(energy, in: chart, accent: accent) + TrackpadMembraneLighting.draw(membrane, in: chart) + } else { + clickColor.setFill(); body.fill() + hatColor.setFill(); hatZone.fill() + snareColor.setFill(); rimZone.fill() + + // Dense parallel wires immediately read as the snare material. The + // inner tom and kick fills below mask them out of the center. + NSGraphicsContext.saveGraphicsState() + rimZone.addClip() + let wires = NSBezierPath() + stride(from: chart.minX - chart.height, + through: chart.maxX, by: 6).forEach { x in + wires.move(to: NSPoint(x: x, y: chart.minY)) + wires.line(to: NSPoint(x: x + chart.height, y: chart.maxY)) + } + (isDark ? NSColor.white : NSColor.black) + .withAlphaComponent(0.16).setStroke() + wires.lineWidth = 0.55 + 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 + // the hard chassis click. This remains legible at the overlay's actual + // size and makes the two edge instruments visually different. + let teeth = NSBezierPath() + stride(from: chart.minX + 9, through: chart.maxX - 9, by: 9).forEach { x in + let slant: CGFloat = Int((x - chart.minX) / 9).isMultiple(of: 2) ? 2 : -2 + teeth.move(to: NSPoint(x: x, y: chart.maxY - 5.2)) + teeth.line(to: NSPoint(x: x + slant, y: chart.maxY - 13.6)) + teeth.move(to: NSPoint(x: x, y: chart.minY + 5.2)) + teeth.line(to: NSPoint(x: x - slant, y: chart.minY + 13.6)) + } + stride(from: chart.minY + 10, through: chart.maxY - 10, by: 9).forEach { y in + let slant: CGFloat = Int((y - chart.minY) / 9).isMultiple(of: 2) ? 2 : -2 + teeth.move(to: NSPoint(x: chart.minX + 5.2, y: y)) + teeth.line(to: NSPoint(x: chart.minX + 13.6, y: y + slant)) + teeth.move(to: NSPoint(x: chart.maxX - 5.2, y: y)) + teeth.line(to: NSPoint(x: chart.maxX - 13.6, y: y - slant)) + } + clickColor.withAlphaComponent(0.72).setStroke() + teeth.lineWidth = 1.25 + teeth.stroke() + + let clickRail = NSBezierPath(roundedRect: chart.insetBy(dx: 1.5, dy: 1.5), + xRadius: 6.5, yRadius: 6.5) + (isDark ? NSColor.white : NSColor.black) + .withAlphaComponent(0.62).setStroke() + clickRail.lineWidth = 2.2 + clickRail.stroke() + + TrackpadEnergyVisual.draw(energy, in: chart, accent: accent) + TrackpadMembraneLighting.draw(membrane, in: chart) + boundaryColor.setStroke() + for (zone, width) in [(hatZone, 0.9), (rimZone, 2.0), + (snareZone, 0.9), (kickZone, 1.3)] { + zone.lineWidth = width + zone.stroke() + } + TrackDrumKitArt.badge(kit.number, in: chart, on: kickColor) } // While the polyrhythm trainer is up, the skin divides into one @@ -1226,6 +1235,9 @@ final class TracktrampMetalView: MTKView, MTKViewDelegate { private var drewFlatFrame = false private var zoneLevels = [Float](repeating: 0, count: 5) private var lastZoneUpdate = CACurrentMediaTime() + private var builtKit = MenuBandPercussion.DrumKit.current + private let kitBanner = NSTextField(labelWithString: "") + private var bannerHide: DispatchWorkItem? init() { let device = MTLCreateSystemDefaultDevice() @@ -1245,6 +1257,43 @@ final class TracktrampMetalView: MTKView, MTKViewDelegate { delegate = self queue = device?.makeCommandQueue() if let device { buildPipeline(device) } + + kitBanner.alignment = .center + kitBanner.font = .systemFont(ofSize: 13, weight: .heavy) + kitBanner.textColor = .white + kitBanner.wantsLayer = true + kitBanner.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.62).cgColor + kitBanner.layer?.cornerRadius = 7 + kitBanner.alphaValue = 0 + addSubview(kitBanner) + NotificationCenter.default.addObserver( + self, selector: #selector(kitDidChange(_:)), + name: MenuBandPercussion.DrumKit.didChange, object: nil) + } + + /// New kit: re-bake the artwork and flash "number · NAME" over the pad. + @objc private func kitDidChange(_ note: Notification) { + builtKit = MenuBandPercussion.DrumKit.current + rebuildTextureIfNeeded(force: true) + let kit = builtKit + kitBanner.stringValue = " \(kit.number) · \(kit.label.uppercased()) " + kitBanner.sizeToFit() + let size = NSSize(width: kitBanner.frame.width, height: 22) + kitBanner.frame = NSRect(x: (bounds.width - size.width) / 2, + y: (bounds.height - size.height) / 2, + width: size.width, height: size.height) + kitBanner.alphaValue = 1 + bannerHide?.cancel() + let hide = DispatchWorkItem { [weak self] in + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.35 + self?.kitBanner.animator().alphaValue = 0 + } + } + bannerHide = hide + DispatchQueue.main.asyncAfter(deadline: .now() + 1.1, execute: hide) + drewFlatFrame = false + draw() } required init(coder: NSCoder) { diff --git a/slab/menuband/Sources/MenuBand/TrackDrumKitArt.swift b/slab/menuband/Sources/MenuBand/TrackDrumKitArt.swift new file mode 100644 index 0000000000..e81a67c033 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/TrackDrumKitArt.swift @@ -0,0 +1,115 @@ +import AppKit + +/// Pad artwork for the drum-machine kits. The Menu Band membrane keeps its +/// own earthy rings in TrackpadDrumSkinPad; Electro draws the same five +/// contours in neon over scanlines, so a kit change reads at a glance while +/// every zone stays where the sound puts it. +enum TrackDrumKitArt { + typealias Kit = MenuBandPercussion.DrumKit + + // MARK: Palette + + private static func rgb(_ hex: UInt32, _ alpha: CGFloat = 1) -> NSColor { + NSColor(srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, + green: CGFloat((hex >> 8) & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, alpha: alpha) + } + + /// Fill per zone, kick (center) → click (rim). + static func fills(for kit: Kit, dark: Bool) -> [NSColor] { + switch kit { + case .menuBand: + return [] + case .electro: + // 909 SIMMONS SNARE 606 BELL — neon from the center out. + return dark + ? [0x7A1466, 0x4B2390, 0x1F4FA8, 0x0F7C8C, 0x5F8A12].map { rgb($0) } + : [0xE0409F, 0x8E5BE0, 0x4A86F0, 0x3CC6D4, 0xA6D63A].map { rgb($0) } + } + } + + static func chassis(for kit: Kit, dark: Bool) -> NSColor { + switch kit { + case .menuBand: return .clear + case .electro: return dark ? rgb(0x0A0A14) : rgb(0x18182A) + } + } + + // MARK: Drawing + + /// Draw a drum-machine kit's material inside `body` (already clipped by caller). + static func drawMaterial(_ kit: Kit, in chart: NSRect, body: NSBezierPath, + dark: Bool) { + guard let spec = MenuBandPercussion.spec(for: kit) else { return } + let fills = fills(for: kit, dark: dark) + chassis(for: kit, dark: dark).setFill() + body.fill() + + // Electro: the membrane's contours in neon over scanlines. + let insets: [CGFloat] = [0, 4.8, 14.4, 21.6, 28] // click → kick + for (step, inset) in insets.enumerated() { + let zone = NSBezierPath( + roundedRect: chart.insetBy(dx: inset, dy: inset), + xRadius: max(2, 8 - inset * 0.16), yRadius: max(2, 8 - inset * 0.16)) + // insets run outside-in; fills run kick-first. + fills[fills.count - 1 - step].withAlphaComponent(0.85).setFill() + zone.fill() + rgb(0xFFFFFF, dark ? 0.35 : 0.5).setStroke() + zone.lineWidth = 0.7 + zone.stroke() + } + NSGraphicsContext.saveGraphicsState() + body.addClip() + let lines = NSBezierPath() + stride(from: chart.minY + 1, through: chart.maxY, by: 3).forEach { y in + lines.move(to: NSPoint(x: chart.minX, y: y)) + lines.line(to: NSPoint(x: chart.maxX, y: y)) + } + NSColor.black.withAlphaComponent(0.22).setStroke() + lines.lineWidth = 0.6 + lines.stroke() + NSGraphicsContext.restoreGraphicsState() + label(spec.pads[0].name, at: NSPoint(x: chart.midX, y: chart.midY), + on: fills[0]) + badge(kit.number, in: chart, on: chassis(for: kit, dark: dark)) + } + + private static func isLight(_ color: NSColor) -> Bool { + guard let c = color.usingColorSpace(.sRGB) else { return false } + return 0.299 * c.redComponent + 0.587 * c.greenComponent + + 0.114 * c.blueComponent > 0.6 + } + + private static func label(_ text: String, at point: NSPoint, on fill: NSColor) { + let ink = isLight(fill) ? NSColor.black.withAlphaComponent(0.72) + : NSColor.white.withAlphaComponent(0.88) + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 6, weight: .heavy), + .foregroundColor: ink, + .kern: 0.15, + ] + let string = NSAttributedString(string: text, attributes: attributes) + let size = string.size() + string.draw(at: NSPoint(x: point.x - size.width / 2, y: point.y - size.height / 2)) + } + + /// Kit number in the top-right corner, inside the rounded rim. + static func badge(_ number: Int, in chart: NSRect, on chassis: NSColor) { + let diameter: CGFloat = 10 + let rect = NSRect(x: chart.maxX - diameter - 3.5, y: chart.maxY - diameter - 3.5, + width: diameter, height: diameter) + let disc = NSBezierPath(ovalIn: rect) + NSColor.white.withAlphaComponent(0.92).setFill() + disc.fill() + chassis.setStroke() + disc.lineWidth = 0.8 + disc.stroke() + let string = NSAttributedString(string: "\(number)", attributes: [ + .font: NSFont.monospacedDigitSystemFont(ofSize: 7, weight: .heavy), + .foregroundColor: NSColor.black, + ]) + let size = string.size() + string.draw(at: NSPoint(x: rect.midX - size.width / 2, + y: rect.midY - size.height / 2)) + } +} diff --git a/slab/menuband/Tests/MenuBandTests/DrumKitLoudnessTests.swift b/slab/menuband/Tests/MenuBandTests/DrumKitLoudnessTests.swift new file mode 100644 index 0000000000..0c364f224f --- /dev/null +++ b/slab/menuband/Tests/MenuBandTests/DrumKitLoudnessTests.swift @@ -0,0 +1,75 @@ +import XCTest +@testable import MenuBand + +/// Every genre-kit pad should land on its role's K-weighted loudness (the +/// Menu Band membrane's), so arrowing between kits never jumps in level. +/// Also prints a table — loudness, peak, brightness, decay — for tuning. +final class DrumKitLoudnessTests: XCTestCase { + private typealias P = MenuBandPercussion + + private func render(_ voices: [P.Voice], seconds: Double = 1.5) -> [Double] { + let rate = 48_000.0 + var out = [Double](repeating: 0, count: Int(rate * seconds)) + for var v in voices { + for i in out.indices { + if v.elapsed >= v.duration + v.delay { break } + out[i] += P.nextSample(&v, pitch: 1, dt: 1 / rate) + * Double(v.gainL + v.gainR) * 0.5 + } + } + return out + } + + /// Zero-crossings per second: a cheap brightness proxy. + private func brightness(_ x: [Double]) -> Double { + var crossings = 0 + let n = min(x.count, 4_800) // first 100 ms + for i in 1.. Double { + let hop = 240 + var rms: [Double] = [] + var i = 0 + while i + hop <= x.count { + let slice = x[i..<(i + hop)] + rms.append(sqrt(slice.reduce(0) { $0 + $1 * $1 } / Double(hop))) + i += hop + } + guard let peak = rms.max(), peak > 0, + let top = rms.firstIndex(of: peak) else { return 0 } + let floor = peak * 0.1 + let end = rms[top...].firstIndex { $0 < floor } ?? rms.count + return Double(end) * 5 + } + + func testEveryPadMatchesItsRoleLoudness() { + let perc = P() + let targets = perc.roleTargets() + var report = "\nkit pad role target matched peak Hz≈ decay\n" + for kit in P.DrumKit.allCases { + guard let spec = P.spec(for: kit) else { continue } + let gains = perc.padGains(for: kit) + for (index, pad) in spec.pads.enumerated() { + let voices = perc.padVoices(pad, gain: gains[index], pan: 0, jitter: false) + let matched = perc.averagedLoudness(of: pad) + 20 * log10(gains[index]) + let target = targets[pad.role]! + pad.trimDB + let audio = render(voices) + let peak = audio.map(abs).max() ?? 0 + report += String(format: "%-9@ %-8@ %-6@ %6.1f %6.1f %5.2f %6.0f %5.0fms\n", + kit.label as NSString, pad.name as NSString, + "\(pad.role)" as NSString, target, matched, peak, + brightness(audio), decayMs(audio)) + XCTAssertEqual(matched, target, accuracy: 0.1, + "\(kit.label) \(pad.name) is off its role loudness") + } + } + report += "\nMenu Band membrane references:\n" + for (role, target) in targets.sorted(by: { $0.value > $1.value }) { + report += String(format: " %-6@ %6.1f\n", "\(role)" as NSString, target) + } + print(report) + } +} -- 2.54.0