Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182import Foundation
/// A live, *conductible* musical engine.////// Instead of scheduling a finite sequence and ending, the engine holds a/// chord as a sustained pad and runs a repeating arpeggio + drum loop off a/// single timer — indefinitely. The chord, the arpeggio, the drum pattern,/// and the tempo can all be changed on the fly while the loop keeps running./// A chord change is *crossfaded*: the new chord is brought up on an idle/// voice bank via Expression (CC 11) while the old bank fades out, so the/// harmony **morphs** rather than cuts. The arpeggio reads the current chord/// every step, so morphing the harmony evolves the melodic line for free.////// You conduct it by posting `computer.aestheticcomputer.menuband.engine.*`/// notifications (see AppDelegate) — start / chord / pattern / bpm / stop.final class MenuBandEngine { private weak var mb: MenuBandController? init(menuBand: MenuBandController) { self.mb = menuBand }
// Two pad banks so a chord change can crossfade A↔B. Arp on its own // channel. Channel 9 is GM percussion — left clear for the drum loop. private let padA: [UInt8] = [0, 1, 2] private let padB: [UInt8] = [3, 4, 5] private let arpCh: UInt8 = 6 private var useA = true // which bank currently sounds
private(set) var running = false private var bpm = 110.0 private var stepBeats = 0.5 // arp/drum grid (0.5 = eighths) private var chord: [UInt8] = [60, 64, 67] private var arp: [Int] = [0, 1, 2, 1] // chord-tone indices, -1 = rest private var arpOctave = 12 // arp sits an octave above the pad private var drums: [String] = [] // per-step letters: k/s/h/c, "" = rest private var program: UInt8 = 89 // warm pad private var padVelocity: UInt8 = 70 private var step = 0 private var timer: Timer? private var heldPad: [(note: UInt8, channel: UInt8)] = [] private var arpHeld: UInt8?
// MARK: - Lifecycle
func start(bpm: Double, chord: [UInt8], program: UInt8, arp: [Int], stepBeats: Double, drums: [String]) { stop(fade: 0) self.bpm = bpm self.chord = chord.isEmpty ? [60, 64, 67] : chord self.program = program self.arp = arp.isEmpty ? [0, 1, 2, 1] : arp self.stepBeats = stepBeats > 0 ? stepBeats : 0.5 self.drums = drums self.step = 0 self.useA = true mb?.engineSetProgram(program) soundPad(self.chord, bank: padA) running = true restartTimer() }
func stop(fade: Double = 0.6) { running = false timer?.invalidate(); timer = nil if let prev = arpHeld { mb?.engineVoiceOff(prev, channel: arpCh); arpHeld = nil } let held = heldPad heldPad = [] if fade <= 0 { for v in held { mb?.engineVoiceOff(v.note, channel: v.channel) } return } let steps = 12 for s in 0...steps { let frac = Double(s) / Double(steps) DispatchQueue.main.asyncAfter(deadline: .now() + fade * frac) { [weak self] in let e = UInt8((1 - frac) * 127) for v in held { self?.mb?.engineExpression(e, channel: v.channel) } } } DispatchQueue.main.asyncAfter(deadline: .now() + fade + 0.05) { [weak self] in for v in held { self?.mb?.engineVoiceOff(v.note, channel: v.channel) } } }
// MARK: - Live mutation
/// Morph the held chord to a new voicing, crossfading onto the idle pad /// bank over `glide` seconds. The arpeggio picks up the new chord tones /// on its next step automatically. func morph(toChord newChord: [UInt8], glide: Double) { guard !newChord.isEmpty else { return } guard running else { start(bpm: bpm, chord: newChord, program: program, arp: arp, stepBeats: stepBeats, drums: drums) return } let fromBank = useA ? padA : padB let toBank = useA ? padB : padA
// Bring the new chord up silently on the idle bank. var newHeld: [(note: UInt8, channel: UInt8)] = [] for (i, n) in newChord.enumerated() where i < toBank.count { mb?.engineExpression(0, channel: toBank[i]) mb?.engineVoiceOn(n, channel: toBank[i], velocity: padVelocity) newHeld.append((n, toBank[i])) } // Equal-ish crossfade via Expression ramps. let g = max(0.05, glide) let steps = max(2, Int(g / 0.03)) for s in 0...steps { let frac = Double(s) / Double(steps) DispatchQueue.main.asyncAfter(deadline: .now() + g * frac) { [weak self] in guard let self = self else { return } let up = UInt8(frac * 127), down = UInt8((1 - frac) * 127) for ch in toBank { self.mb?.engineExpression(up, channel: ch) } for ch in fromBank { self.mb?.engineExpression(down, channel: ch) } } } let oldHeld = heldPad DispatchQueue.main.asyncAfter(deadline: .now() + g + 0.05) { [weak self] in guard let self = self else { return } for v in oldHeld where fromBank.contains(v.channel) { self.mb?.engineVoiceOff(v.note, channel: v.channel) } } heldPad = newHeld chord = newChord useA.toggle() }
func setArp(_ a: [Int]) { if !a.isEmpty { arp = a } } func setDrums(_ d: [String]) { drums = d } func setBPM(_ b: Double) { bpm = max(20, b); if running { restartTimer() } } func setStepBeats(_ s: Double) { if s > 0 { stepBeats = s; if running { restartTimer() } } }
// MARK: - Internals
private func soundPad(_ notes: [UInt8], bank: [UInt8]) { for (i, n) in notes.enumerated() where i < bank.count { mb?.engineExpression(127, channel: bank[i]) mb?.engineVoiceOn(n, channel: bank[i], velocity: padVelocity) heldPad.append((n, bank[i])) } }
private func restartTimer() { timer?.invalidate() let interval = stepBeats * 60.0 / max(1, bpm) let t = Timer(timeInterval: interval, repeats: true) { [weak self] _ in self?.tick() } RunLoop.main.add(t, forMode: .common) timer = t }
private func tick() { // Arpeggio — reads the *current* chord, so it follows morphs live. let idx = arp[step % arp.count] if idx >= 0, !chord.isEmpty { if let prev = arpHeld { mb?.engineVoiceOff(prev, channel: arpCh) } let tone = Int(chord[((idx % chord.count) + chord.count) % chord.count]) let note = UInt8(max(0, min(127, tone + arpOctave))) mb?.engineVoiceOn(note, channel: arpCh, velocity: 96) arpHeld = note } // Drum loop. if !drums.isEmpty { let token = drums[step % drums.count] let drum: MenuBandPercussion.Drum? = { switch token { case "k": return .kick case "s": return .snare case "h": return .hatClosed case "ho": return .hatOpen case "c": return .clap default: return nil } }() if let drum = drum { mb?.engineDrum(drum, velocity: token == "h" ? 60 : 116) } } step += 1 }}