diff --git a/juke-wizard/Sources/JukeWizard/JukeController.swift b/juke-wizard/Sources/JukeWizard/JukeController.swift index 04aa258a8..2917a6054 100644 --- a/juke-wizard/Sources/JukeWizard/JukeController.swift +++ b/juke-wizard/Sources/JukeWizard/JukeController.swift @@ -32,7 +32,6 @@ final class JukeController: NSWindowController, NSWindowDelegate, let watchDirs: [String] let selectPath: String? var current: Int = -1 - var menuBar: MenuBarCD? var watchTimer: Timer? var activityTimer: Timer? var activityPollInFlight = false @@ -175,10 +174,9 @@ final class JukeController: NSWindowController, NSWindowDelegate, } wave.volume = quickVolume relayout() - // the spinning-CD menu-bar presence (persists when the window is closed) - menuBar = MenuBarCD() - menuBar?.onClick = { [weak self] in self?.showMiniPlayer() } - menuBar?.onDoubleClick = { [weak self] in self?.quickOpenFull() } + // CDJ Radio and its spinning menu-bar disc now belong to Menu Band. + // JukeWizard remains the library/editor and does not create a second + // status item that would compete for the same juked session. roomAudio.onState = { [weak self] state in DispatchQueue.main.async { self?.renderRoomState(state) } } @@ -582,15 +580,11 @@ final class JukeController: NSWindowController, NSWindowDelegate, notesPlaceholder.frame = NSRect(x: dp + 6, y: notesBottom + notesH - 20, width: 100, height: 16) } - // ── menu-bar CD ──────────────────────────────────────────────────────── - // Keep the bar disc's tempo + spin in step with playback. - private func refreshMenuBar() { + // Keep Dock artwork and any open in-window mini player in sync. The + // menu-bar CD itself is owned by Menu Band's CDJ Radio. + private func refreshPlaybackPresence() { let bpm = spotifyMode ? nil : track?.meta?.bpm.map(Double.init) let playing = spotifyMode ? (spotifyState?.isPlaying ?? false) : wave.isPlaying - let title = spotifyMode ? (spotifyState?.title ?? "Spotify") : (track?.title ?? "JukeWizard") - menuBar?.setBPM(bpm) - menuBar?.setNowPlaying(title: title, art: currentArt) - menuBar?.setPlaying(playing) DockIcon.setNowPlaying(art: currentArt, playing: playing, bpm: bpm) miniPlayer?.refresh() } @@ -683,7 +677,7 @@ final class JukeController: NSWindowController, NSWindowDelegate, listTable?.reloadData() relayout() if let state = spotifyState { renderSpotifyState(state) } - else { refreshMenuBar() } + else { refreshPlaybackPresence() } } private func activateLibraryMode() { @@ -707,7 +701,7 @@ final class JukeController: NSWindowController, NSWindowDelegate, playButton.title = wave.isPlaying ? "❚❚" : "▶" nowPlaying.setPaused(!wave.isPlaying) relayout() - refreshMenuBar() + refreshPlaybackPresence() pollActivityStatus() } @@ -792,11 +786,11 @@ final class JukeController: NSWindowController, NSWindowDelegate, self?.currentArt = art self?.nowPlaying.present(art: art, videoURL: nil) self?.nowPlaying.setPaused(!state.isPlaying) - self?.refreshMenuBar() + self?.refreshPlaybackPresence() } }.resume() } - refreshMenuBar() + refreshPlaybackPresence() } var quickTitle: String { spotifyMode ? (spotifyState?.title ?? "Spotify") : (track?.title ?? "Aesthetic") @@ -816,23 +810,6 @@ final class JukeController: NSWindowController, NSWindowDelegate, } } - private func showMiniPlayer() { - if miniPopover?.isShown == true { - miniPopover?.close() - return - } - let player = JukeMiniPlayerView(controller: self) - let viewController = NSViewController() - viewController.view = player - let popover = NSPopover() - popover.behavior = .transient - popover.contentSize = NSSize(width: 370, height: 170) - popover.contentViewController = viewController - miniPlayer = player - miniPopover = popover - menuBar?.show(popover) - } - @objc func quickOpenFull() { miniPopover?.close() guard let w = window else { return } @@ -928,7 +905,7 @@ final class JukeController: NSWindowController, NSWindowDelegate, updateTime() if autoplay { wave.play(); playButton.title = "❚❚"; nowPlaying.setPaused(false) } else { playButton.title = "▶"; nowPlaying.setPaused(true) } - refreshMenuBar() // new tempo + play state → spin the bar CD + refreshPlaybackPresence() } // ── sorting ──────────────────────────────────────────────────────────── @@ -999,7 +976,7 @@ final class JukeController: NSWindowController, NSWindowDelegate, playButton.title = wave.isPlaying ? "❚❚" : "▶" nowPlaying.setPaused(!wave.isPlaying) } - refreshMenuBar() + refreshPlaybackPresence() } @objc private func prevTrack() { if spotifyMode { spotify.previous() } @@ -1064,7 +1041,7 @@ final class JukeController: NSWindowController, NSWindowDelegate, } } if wasPlaying { wave.play(); playButton.title = "❚❚"; nowPlaying.setPaused(false) } - refreshMenuBar() + refreshPlaybackPresence() } @objc private func commentClicked() { guard let t = track else { return } @@ -1084,7 +1061,7 @@ final class JukeController: NSWindowController, NSWindowDelegate, } // ── waveform delegate ─────────────────────────────────────────────────── - func waveformDidFinish() { playButton.title = "▶"; nextTrack(); refreshMenuBar() } + func waveformDidFinish() { playButton.title = "▶"; nextTrack(); refreshPlaybackPresence() } func waveformTick() { updateTime() } private func updateTime() { if let state = spotifyMode ? spotifyState : nil { diff --git a/juke-wizard/Sources/JukeWizard/MenuBarCD.swift b/juke-wizard/Sources/JukeWizard/MenuBarCD.swift deleted file mode 100644 index 51dfe026e..000000000 --- a/juke-wizard/Sources/JukeWizard/MenuBarCD.swift +++ /dev/null @@ -1,141 +0,0 @@ -// MenuBarCD.swift — JukeWizard's presence in the macOS menu bar: a little -// compact disc that lives up top even when the window is closed, and SPINS -// while a track plays — its rate locked to the track's BPM (one revolution -// every two beats, so the speed visibly tracks the tempo). Click it to -// show/hide the JukeWizard window; it sits near DateWizard's wand. -import AppKit - -final class MenuBarCD { - private let statusItem: NSStatusItem - private let fallbackImage: NSImage - private var baseImage: NSImage - private var timer: Timer? - private var angle: CGFloat = 0 // degrees, clockwise - private var bpm: Double = 120 - private var playing = false - private let side: CGFloat = 22 - private let beatsPerRev: Double = 8 // calm turntable pace - private var currentTitle = "" - private var currentArtwork: NSImage? - private var clickGeneration = 0 - - var onClick: (() -> Void)? - var onDoubleClick: (() -> Void)? - - init() { - statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - fallbackImage = MenuBarCD.loadCD(side: side) - baseImage = fallbackImage - if let b = statusItem.button { - b.image = baseImage - b.imagePosition = .imageRight - b.imageScaling = .scaleProportionallyDown - b.toolTip = "JukeWizard" - b.target = self - b.action = #selector(clicked) - b.sendAction(on: [.leftMouseUp, .rightMouseUp]) - } - } - - private static func loadCD(side: CGFloat) -> NSImage { - let bundle = Bundle.module - let url = bundle.url(forResource: "jukewizard-cd", withExtension: "png", subdirectory: "Assets") - ?? bundle.url(forResource: "jukewizard-cd", withExtension: "png") - let img = (url.flatMap { NSImage(contentsOf: $0) }) ?? NSImage(size: NSSize(width: side, height: side)) - img.size = NSSize(width: side, height: side) - img.isTemplate = false // keep the iridescent color in the bar - return img - } - - @objc private func clicked() { - if NSApp.currentEvent?.type == .rightMouseUp { - onClick?() - return - } - clickGeneration += 1 - let generation = clickGeneration - if (NSApp.currentEvent?.clickCount ?? 1) >= 2 { - onDoubleClick?() - } else { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) { [weak self] in - guard let self, self.clickGeneration == generation else { return } - self.onClick?() - } - } - } - - func show(_ popover: NSPopover) { - guard let button = statusItem.button else { return } - popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) - } - - func setNowPlaying(title: String, art: NSImage?) { - let clipped = title.count > 28 ? String(title.prefix(27)) + "…" : title - let changedTrack = !currentTitle.isEmpty && - (clipped != currentTitle || currentArtwork !== art) - guard clipped != currentTitle || currentArtwork !== art else { return } - currentTitle = clipped - currentArtwork = art - statusItem.button?.title = clipped.isEmpty ? "" : "\(clipped) " - statusItem.button?.font = .systemFont(ofSize: 12, weight: .medium) - baseImage = art.map { CDArtworkRenderer.disc(from: $0, side: side) } ?? fallbackImage - statusItem.button?.image = angle == 0 ? baseImage : rotated(baseImage, by: angle) - statusItem.button?.toolTip = clipped.isEmpty ? "JukeWizard" : clipped - if changedTrack, let button = statusItem.button { MenuBarNoteBurst.emit(from: button) } - } - - // Feed the current track tempo; clamped to a sane spin range. - func setBPM(_ b: Double?) { - let v = b ?? 120 - bpm = min(200, max(40, v.isFinite && v > 0 ? v : 120)) - } - - // Start/stop the spin on a playback-state change (idempotent). - func setPlaying(_ p: Bool) { - guard p != playing else { return } - let resumed = p && !playing - playing = p - if p { startSpin() } else { stopSpin() } - if resumed, let button = statusItem.button { MenuBarNoteBurst.emit(from: button) } - } - - private func startSpin() { - timer?.invalidate() - // ~30 fps; smooth enough for a small bar glyph, cheap to redraw. - let t = Timer(timeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in self?.tick() } - RunLoop.main.add(t, forMode: .common) // keep spinning during menu tracking / resize - timer = t - } - - private func stopSpin() { - timer?.invalidate(); timer = nil - angle = 0 - statusItem.button?.image = baseImage // settle upright when paused - } - - private func tick() { - // revolutions per second = (bpm/60) / beatsPerRev → degrees per frame at 30 fps - let degPerFrame = 360.0 * (bpm / 60.0) / beatsPerRev / 30.0 - angle -= CGFloat(degPerFrame) // clockwise, like a turntable - if angle <= -360 { angle += 360 } - statusItem.button?.image = rotated(baseImage, by: angle) - } - - private func rotated(_ img: NSImage, by deg: CGFloat) -> NSImage { - let size = img.size - let out = NSImage(size: size) - out.lockFocus() - NSGraphicsContext.current?.imageInterpolation = .high - let t = NSAffineTransform() - t.translateX(by: size.width / 2, yBy: size.height / 2) - t.rotate(byDegrees: deg) - t.translateX(by: -size.width / 2, yBy: -size.height / 2) - t.concat() - img.draw(at: .zero, from: NSRect(origin: .zero, size: size), - operation: .sourceOver, fraction: 1) - out.unlockFocus() - out.isTemplate = false - return out - } - -} diff --git a/juke-wizard/Sources/JukeWizard/MenuBarNoteBurst.swift b/juke-wizard/Sources/JukeWizard/MenuBarNoteBurst.swift deleted file mode 100644 index fbe554eb0..000000000 --- a/juke-wizard/Sources/JukeWizard/MenuBarNoteBurst.swift +++ /dev/null @@ -1,96 +0,0 @@ -import AppKit -import QuartzCore - -/// A tiny, click-through fountain below JukeWizard's menu-bar CD. It borrows -/// MenuBand's purple/pink/chartreuse note language, but exists for only two -/// seconds and uses a single Core Animation emitter (no display-link work). -enum MenuBarNoteBurst { - private static var windows: [NSWindow] = [] - private static var lastBurst = Date.distantPast - - static func emit(from button: NSStatusBarButton) { - guard Date().timeIntervalSince(lastBurst) > 0.28, - let hostWindow = button.window else { return } - lastBurst = Date() - let inWindow = button.convert(button.bounds, to: nil) - let anchor = hostWindow.convertToScreen(inWindow) - let size = NSSize(width: 170, height: 135) - let frame = NSRect(x: anchor.midX - size.width / 2, - y: anchor.minY - size.height + 3, - width: size.width, height: size.height) - let window = NSWindow(contentRect: frame, styleMask: .borderless, - backing: .buffered, defer: false) - window.isOpaque = false - window.backgroundColor = .clear - window.hasShadow = false - window.ignoresMouseEvents = true - window.level = .statusBar - window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] - - let view = NSView(frame: NSRect(origin: .zero, size: size)) - view.wantsLayer = true - view.layer?.backgroundColor = NSColor.clear.cgColor - window.contentView = view - - let emitter = CAEmitterLayer() - emitter.frame = view.bounds - emitter.emitterPosition = CGPoint(x: view.bounds.midX, y: view.bounds.maxY - 4) - emitter.emitterShape = .point - emitter.emitterMode = .points - emitter.renderMode = .unordered - let colors: [NSColor] = [ - NSColor(red: 167/255, green: 139/255, blue: 250/255, alpha: 1), - NSColor(red: 255/255, green: 107/255, blue: 157/255, alpha: 1), - NSColor(red: 158/255, green: 212/255, blue: 80/255, alpha: 1), - ] - emitter.emitterCells = colors.enumerated().map { index, color in - let cell = CAEmitterCell() - cell.contents = noteImage(color: color, glyphIndex: index) - cell.birthRate = 7 - cell.lifetime = 1.7 - cell.lifetimeRange = 0.25 - cell.velocity = 42 - cell.velocityRange = 22 - cell.emissionLongitude = -.pi / 2 - cell.emissionRange = .pi / 3 - cell.yAcceleration = -54 - cell.xAcceleration = CGFloat(index - 1) * 5 - cell.scale = 0.72 - cell.scaleRange = 0.18 - cell.scaleSpeed = -0.08 - cell.alphaSpeed = -0.52 - cell.spin = 0.35 - cell.spinRange = 0.7 - return cell - } - view.layer?.addSublayer(emitter) - windows.append(window) - window.orderFrontRegardless() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.055) { emitter.birthRate = 0 } - DispatchQueue.main.asyncAfter(deadline: .now() + 2.15) { - window.orderOut(nil) - windows.removeAll { $0 === window } - } - } - - private static func noteImage(color: NSColor, glyphIndex: Int) -> CGImage? { - let glyphs = ["♪", "♫", "♩"] - let side: CGFloat = 24 - let image = NSImage(size: NSSize(width: side, height: side)) - image.lockFocus() - let shadow = NSShadow() - shadow.shadowColor = NSColor.black.withAlphaComponent(0.72) - shadow.shadowBlurRadius = 0 - shadow.shadowOffset = NSSize(width: 1.2, height: -1.2) - shadow.set() - let text = NSAttributedString(string: glyphs[glyphIndex % glyphs.count], attributes: [ - .font: NSFont.systemFont(ofSize: 16, weight: .bold), - .foregroundColor: color, - ]) - let textSize = text.size() - text.draw(at: NSPoint(x: (side - textSize.width) / 2, - y: (side - textSize.height) / 2)) - image.unlockFocus() - return image.cgImage(forProposedRect: nil, context: nil, hints: nil) - } -} diff --git a/juke-wizard/Sources/JukeWizard/main.swift b/juke-wizard/Sources/JukeWizard/main.swift index 022b170ae..dd71d00dc 100644 --- a/juke-wizard/Sources/JukeWizard/main.swift +++ b/juke-wizard/Sources/JukeWizard/main.swift @@ -61,9 +61,9 @@ final class JukeAppDelegate: NSObject, NSApplicationDelegate { DispatchQueue.main.async { [weak self] in self?.controller?.quickOpenFull() } } - // Stay resident when the window closes — the spinning-CD menu-bar item is - // JukeWizard's persistent face; click it to bring the window back. - func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false } + // The persistent spinning CD now belongs to Menu Band's CDJ Radio. With + // no JukeWizard status item to reopen, closing its last window exits. + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } // A hidden resident window should always come back from a Dock click. // Without this, AppKit can activate the process while leaving its only diff --git a/slab/menuband/Help/MenuBand.help/Contents/Resources/en.lproj/index.html b/slab/menuband/Help/MenuBand.help/Contents/Resources/en.lproj/index.html index 347883400..a69320419 100644 --- a/slab/menuband/Help/MenuBand.help/Contents/Resources/en.lproj/index.html +++ b/slab/menuband/Help/MenuBand.help/Contents/Resources/en.lproj/index.html @@ -84,6 +84,10 @@ clip from your microphone. Release to stop. Your recording becomes the instrument — every key plays it back, pitched to that note. It's speed-independent, so high notes don't chipmunk.

+

CDJ Radio → Piano Sampler: while a station or Spotify is playing, + press SAMPLE → PIANO in the CDJ Radio card. The latest 2.5 seconds + become the sampler instrument. This is an explicit handoff; CDJ Radio never + remaps the piano keys by itself.

@@ -106,19 +110,21 @@
-

Instruments, MIDI & radio

+

Instruments, MIDI & CDJ Radio

diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index c1338df98..c3312f9e7 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -10,6 +10,8 @@ extension Notification.Name { final class AppDelegate: NSObject, NSApplicationDelegate { private var statusItem: NSStatusItem! + /// The spinning album-art disc owned by Menu Band's CDJ Radio deck. + private var cdjStatusItem: MenuBandCDJStatusItem? private let menuBand = MenuBandController() /// Live conductible drone/arp/drum loop (see MenuBandEngine + the /// `engine.*` distributed-notification handlers). @@ -723,15 +725,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.updatePianoWaveformWindow() } } -#if !MAC_APP_STORE - menuBand.onSpotifyChange = { [weak self] in + menuBand.onCDJRadioChange = { [weak self] in DispatchQueue.main.async { guard let self else { return } - self.popoverVC?.refreshSpotifyPlayer() + self.popoverVC?.refreshCDJRadio() + self.updateCDJStatusItem() self.updatePianoWaveformWindow() } } -#endif menuBand.onMIDIEvent = { // Spike the square indicator to full on every // outbound noteOn; the visualizer animation tick @@ -740,6 +741,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // [v1 cutoff] KidLisp TV amp-envelope stamp removed with the TV. } menuBand.bootstrap() + let cdjStatusItem = MenuBandCDJStatusItem() + cdjStatusItem.onClick = { [weak self] in self?.showPopover() } + self.cdjStatusItem = cdjStatusItem + updateCDJStatusItem() // Subscribe to mic RMS during sample-voice recording. The // sample voice's input tap fires this on the main queue with // each block's RMS [0, 1]. We just stash it; the visualizer @@ -2574,6 +2579,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return flashStrength * (1 - t) } + private func updateCDJStatusItem() { + guard let cdjStatusItem else { return } + cdjStatusItem.setVisible(menuBand.cdjRadioPresented) + cdjStatusItem.update( + title: menuBand.cdjRadioTitle, + artworkURL: menuBand.cdjRadioArtworkURL, + playing: menuBand.cdjRadioPlaying) + } + func updateIcon() { guard let button = statusItem.button else { return } // Keep the renderer's drum-zone coloring in sync with the live split. diff --git a/slab/menuband/Sources/MenuBand/InstrumentMapView.swift b/slab/menuband/Sources/MenuBand/InstrumentMapView.swift index 59cf19d6a..7f9bcf83b 100644 --- a/slab/menuband/Sources/MenuBand/InstrumentMapView.swift +++ b/slab/menuband/Sources/MenuBand/InstrumentMapView.swift @@ -84,16 +84,13 @@ final class InstrumentListView: NSView { /// BOTTOM of the board, below the patch grid. Set by the host view; /// empty = no radio cells. var radioStations: [RadioStation] = [] { didSet { needsDisplay = true } } - /// True while the radio ("voice −1") backend is the active instrument — - /// fills the selected station cell, like `midiModeActive` fills MIDI OUT. + /// True while an internet station is the active CDJ Radio source. var radioBackendActive: Bool = false { didSet { needsDisplay = true } } /// Which station id is currently tuned (highlighted when active). var selectedRadioStationID: String? { didSet { needsDisplay = true } } /// Fires when the user clicks a radio-station cell. var onRadioCommit: ((RadioStation) -> Void)? - /// Direct-download builds append Spotify to the same listening-source - /// strip. It is a player (not a pitchable radio voice), so it gets its own - /// state/callback while sharing the row's geometry and interaction. + /// Direct-download builds append Spotify to the same CDJ source strip. var spotifyEnabled: Bool = false { didSet { needsDisplay = true } } var spotifyActive: Bool = false { didSet { needsDisplay = true } } var onSpotifyCommit: (() -> Void)? @@ -140,10 +137,10 @@ final class InstrumentListView: NSView { return "🦜 Squawk — click to toggle, or hold ⌘⌃⌥` to talk; types into the frontmost app" } if let i = radioStationIndex(at: point) { - return "\(radioStations[i].name) - click to play the live radio as voice −1" + return "CDJ Radio · \(radioStations[i].name) — play alongside the piano" } if isSpotifyHit(point) { - return "Spotify — headless juked player with search, artwork, and transport" + return "CDJ Radio · Spotify — juked playback through Menu Band effects" } if isMidiOutHit(point) { return "0 MIDI OUT - route notes to the virtual MIDI port; local synth is muted" @@ -599,7 +596,7 @@ final class InstrumentListView: NSView { // immediately after the user picks an initial cell. window?.makeFirstResponder(self) let pt = convert(event.locationInWindow, from: nil) - // Radio-station cell — tune the radio voice to that station. Like + // CDJ source cell — tune the independent deck to that station. Like // MIDI OUT, there's no audible preview, so it bypasses the drag path. if let i = radioStationIndex(at: pt) { onRadioCommit?(radioStations[i]) diff --git a/slab/menuband/Sources/MenuBand/KPBJRadioStream.swift b/slab/menuband/Sources/MenuBand/KPBJRadioStream.swift index 2c9d1c613..77d3da93a 100644 --- a/slab/menuband/Sources/MenuBand/KPBJRadioStream.swift +++ b/slab/menuband/Sources/MenuBand/KPBJRadioStream.swift @@ -4,9 +4,12 @@ import AudioToolbox import CoreMedia import Darwin -/// Live KPBJ.FM Icecast stream surfaced as a "voice -1" backend for the -/// menuband synth: pads play the live audio pitched per note (middle C = -/// unpitched/live, up/down by semitones). Pitch is shifted INDEPENDENTLY +/// Live Icecast stream used by Menu Band's standalone CDJ Radio deck. +/// The continuous deck is independent from the piano instrument; its recent +/// ring can be copied into the Piano Sampler only through the explicit +/// "Sample to Piano" action. The older per-note radio voices remain as an +/// internal compatibility path, but ordinary piano notes never route here. +/// Pitch is shifted INDEPENDENTLY /// of speed via AVAudioUnitTimePitch — a high note is higher, not faster — /// so every voice consumes the stream at real time and stays locked to the /// live edge (no tape-style speed-up, no drift, no replay). (Earlier this @@ -40,7 +43,7 @@ import Darwin /// fades into static the way a real AM dial does. Static volume is also /// modulated by total NIC bytes/sec — quiet network = soft hiss; bursty /// traffic = crackle. -/// A live MP3 radio station the synth can tune into as "voice −1". All +/// A live MP3 station selectable on the standalone CDJ Radio deck. All /// stations are plain Icecast MP3 (KPBJ direct; NTS via a 302 the URLSession /// follows), so they share the same decode path — only the URL changes. struct RadioStation: Equatable { @@ -123,12 +126,10 @@ final class KPBJRadioStream: NSObject, URLSessionDataDelegate { /// path. Allocated lazily; once attached they stay in the graph. private var voices: [UInt8: Voice] = [:] - /// Faint always-on monitor of the live stream — plays the ring at - /// rate 1.0 underneath the per-pad voices so the user always hears - /// the radio "tuned in" while in voice −1, even before pressing a - /// pad. Volume is intentionally low (~0.18) so a held chord still - /// dominates the mix. + /// Continuous unity-rate CDJ deck player. This used to be a faint bed + /// under key-gated radio voices; it is now the primary radio output. private let bedNode = AVAudioPlayerNode() + private let bedTimePitch = AVAudioUnitTimePitch() private var bedReadFrame: Int64 = 0 private var bedActive = false @@ -236,8 +237,10 @@ final class KPBJRadioStream: NSObject, URLSessionDataDelegate { engine.attach(voiceMixer) engine.attach(crossfadeMixer) engine.attach(bedNode) - engine.connect(bedNode, to: voiceMixer, format: format) - bedNode.volume = 0.18 + engine.attach(bedTimePitch) + engine.connect(bedNode, to: bedTimePitch, format: format) + engine.connect(bedTimePitch, to: voiceMixer, format: format) + bedNode.volume = 1.0 // Bandpass via two EQ bands: high-pass at 250 Hz, low-pass at // 4 kHz. AM broadcast voicing is roughly 100–5 kHz; we narrow it @@ -278,9 +281,7 @@ final class KPBJRadioStream: NSObject, URLSessionDataDelegate { // MARK: - Master output gate - /// Whether the radio is the active backend (synth opens it; the - /// 15 s linger closes it). The radio only actually SOUNDS while a - /// pad is also held — see `updateMasterGate`. + /// Whether this station is the selected CDJ Radio source. private var outputEnabled = false /// Open/close the radio's master output. When closed, the radio's @@ -289,17 +290,15 @@ final class KPBJRadioStream: NSObject, URLSessionDataDelegate { /// case and for the synth's 15 s linger window. func setOutputEnabled(_ enabled: Bool) { outputEnabled = enabled + if enabled, streaming { startBed() } if !enabled { stopBed() } updateMasterGate() } - /// Key-gate: the radio behaves like a sampler of the live stream, not - /// an always-on tuner. The master mixer — which carries both the - /// pitched per-pad voices and the AM static — only opens while at - /// least one pad is held. Lift every pad and the radio goes silent. + /// CDJ Radio is a continuous deck. Piano key state never controls this + /// gate; the source selector and close button do. private func updateMasterGate() { - let anyHeld = voices.contains { $0.value.held } - crossfadeMixer.outputVolume = (outputEnabled && anyHeld) ? 1.0 : 0.0 + crossfadeMixer.outputVolume = outputEnabled ? 1.0 : 0.0 } // MARK: - Stream lifecycle @@ -311,9 +310,8 @@ final class KPBJRadioStream: NSObject, URLSessionDataDelegate { NSLog("MenuBand radio: startStreaming (\(station.name), direct MP3 decode) → \(station.url.absoluteString)") beginStreamRequest() - // No always-on bed: the radio stays silent until a pad is held - // (key-gated sampler semantics). The health timer shapes the AM - // static and tracks stream freshness for the crossfade. + if outputEnabled { startBed() } + // The health timer shapes the AM static and tracks stream freshness. startHealthTimer() } @@ -663,6 +661,7 @@ final class KPBJRadioStream: NSObject, URLSessionDataDelegate { /// live and stashes the amount so notes started mid-bend pick it up. func setBend(amount: Float) { bendSemitones = amount * 12.0 + bedTimePitch.pitch = max(-2400, min(2400, bendSemitones * 100)) for (_, v) in voices where v.node.isPlaying { applyPitch(v) } @@ -799,6 +798,29 @@ final class KPBJRadioStream: NSObject, URLSessionDataDelegate { return out } + /// Copy the newest decoded station audio for CDJ Radio's explicit + /// Sample-to-Piano handoff. The live deck keeps playing unchanged. + func copyRecentAudio(seconds: Double) -> AVAudioPCMBuffer? { + ringLock.lock(); defer { ringLock.unlock() } + let available = min(Int64(ringFrames), ringWriteFrame) + let wanted = min(available, Int64(max(0.1, seconds) * sampleRate)) + guard wanted > 0, + let output = AVAudioPCMBuffer( + pcmFormat: format, frameCapacity: AVAudioFrameCount(wanted)), + let source = ring.floatChannelData, + let destination = output.floatChannelData else { return nil } + output.frameLength = AVAudioFrameCount(wanted) + let start = ringWriteFrame - wanted + for channel in 0.. Void)? + + func attach(to engine: AVAudioEngine, output: AVAudioNode) { + guard !attached else { return } + self.engine = engine + engine.attach(player) + engine.attach(timePitch) + engine.attach(mixer) + engine.connect(player, to: timePitch, format: format) + engine.connect(timePitch, to: mixer, format: format) + engine.connect(mixer, to: output, format: format) + mixer.outputVolume = 1 + ring.frameLength = AVAudioFrameCount(ringFrames) + if let channels = ring.floatChannelData { + for channel in 0...size) + } + } + attached = true + } + + func start(processID: pid_t) { + guard attached else { return } + mixer.outputVolume = 1 + guard tap == nil else { return } + guard #available(macOS 14.2, *) else { + onError?("CDJ Radio Spotify routing requires macOS 14.2 or newer") + return + } + do { + let processTap = ACProcessAudioTap( + processID: processID, name: "Menu Band CDJ Radio", + muteOriginal: true) + processTap.onLog = { NSLog("MenuBand CDJ Radio: \($0)") } + try processTap.start { [weak self] buffer in + self?.ingest(buffer) + } + tap = processTap + } catch { + onError?(error.localizedDescription) + } + } + + func stop() { + if #available(macOS 14.2, *), let processTap = tap as? ACProcessAudioTap { + processTap.stop() + } + tap = nil + mixer.outputVolume = 0 + player.stop() + audioLock.lock() + converter = nil + converterInputFormat = nil + audioLock.unlock() + } + + func setPitch(semitones: Float) { + timePitch.pitch = max(-2_400, min(2_400, semitones * 100)) + } + + func setOutputEnabled(_ enabled: Bool) { + mixer.outputVolume = enabled ? 1 : 0 + } + + /// Snapshot the newest deck audio for the explicit CDJ → Piano Sampler + /// handoff. This never changes the active instrument by itself. + func copyRecentAudio(seconds: Double) -> AVAudioPCMBuffer? { + audioLock.lock(); defer { audioLock.unlock() } + let available = min(Int64(ringFrames), ringWriteFrame) + let wanted = min(available, Int64(max(0.1, seconds) * format.sampleRate)) + guard wanted > 0, + let output = AVAudioPCMBuffer( + pcmFormat: format, frameCapacity: AVAudioFrameCount(wanted)), + let source = ring.floatChannelData, + let destination = output.floatChannelData else { return nil } + output.frameLength = AVAudioFrameCount(wanted) + let start = ringWriteFrame - wanted + for channel in 0.. 0, + let source = output.floatChannelData, + let destination = ring.floatChannelData else { return } + + let frames = Int(output.frameLength) + let writeStart = ringWriteFrame + for channel in 0.. NSImage { + let image = NSImage(size: NSSize(width: side, height: side)) + image.lockFocus() + NSGraphicsContext.current?.imageInterpolation = .high + let outer = NSRect(x: side * 0.025, y: side * 0.025, + width: side * 0.95, height: side * 0.95) + NSColor.black.withAlphaComponent(0.92).setFill() + NSBezierPath(ovalIn: outer).fill() + let face = outer.insetBy(dx: side * 0.012, dy: side * 0.012) + NSGraphicsContext.current?.saveGraphicsState() + NSBezierPath(ovalIn: face).addClip() + let scale = max(face.width / max(1, art.size.width), + face.height / max(1, art.size.height)) + let artSize = NSSize(width: art.size.width * scale, + height: art.size.height * scale) + art.draw(in: NSRect(x: face.midX - artSize.width / 2, + y: face.midY - artSize.height / 2, + width: artSize.width, height: artSize.height)) + NSColor.white.withAlphaComponent(0.26).setStroke() + let ring = NSBezierPath(ovalIn: face.insetBy(dx: side * 0.08, + dy: side * 0.08)) + ring.lineWidth = max(0.4, side * 0.006) + ring.stroke() + NSGraphicsContext.current?.restoreGraphicsState() + let hubSide = side * 0.20 + let hub = NSRect(x: side / 2 - hubSide / 2, + y: side / 2 - hubSide / 2, + width: hubSide, height: hubSide) + NSColor.white.withAlphaComponent(0.38).setFill() + NSBezierPath(ovalIn: hub).fill() + let holeSide = side * 0.075 + NSGraphicsContext.current?.compositingOperation = .clear + NSBezierPath(ovalIn: NSRect(x: side / 2 - holeSide / 2, + y: side / 2 - holeSide / 2, + width: holeSide, height: holeSide)).fill() + NSGraphicsContext.current?.compositingOperation = .sourceOver + image.unlockFocus() + image.isTemplate = false + return image + } + + static func fallback(side: CGFloat) -> NSImage { + let art = NSImage(size: NSSize(width: side, height: side)) + art.lockFocus() + NSGradient(colors: [.systemTeal, .systemPurple])?.draw( + in: NSRect(x: 0, y: 0, width: side, height: side), angle: 35) + art.unlockFocus() + return disc(from: art, side: side) + } +} + +/// Menu Band's small spinning CD status item. It is deliberately owned by +/// the Menu Band process and opens Menu Band's CDJ Radio panel when clicked. +final class MenuBandCDJStatusItem { + private let statusItem: NSStatusItem + private let fallback: NSImage + private var baseImage: NSImage + private var timer: Timer? + private var angle: CGFloat = 0 + private var artworkTask: URLSessionDataTask? + private var representedArtworkURL: URL? + private let side: CGFloat = 22 + var onClick: (() -> Void)? + + init() { + statusItem = NSStatusBar.system.statusItem(withLength: 25) + statusItem.autosaveName = "menuband-cdj-radio" + fallback = MenuBandCDArtworkRenderer.fallback(side: side) + baseImage = fallback + if let button = statusItem.button { + button.image = fallback + button.imagePosition = .imageOnly + button.imageScaling = .scaleProportionallyDown + button.target = self + button.action = #selector(clicked) + button.toolTip = "Menu Band CDJ Radio" + } + setVisible(false) + } + + func setVisible(_ visible: Bool) { + statusItem.isVisible = visible + if !visible { setPlaying(false) } + } + + func update(title: String, artworkURL: URL?, playing: Bool) { + statusItem.button?.toolTip = title.isEmpty + ? "Menu Band CDJ Radio" : "CDJ Radio · \(title)" + updateArtwork(artworkURL) + setPlaying(playing) + } + + func setPlaying(_ playing: Bool) { + if playing { + guard timer == nil else { return } + let timer = Timer(timeInterval: 1.0 / 30.0, repeats: true) { + [weak self] _ in self?.tick() + } + RunLoop.main.add(timer, forMode: .common) + self.timer = timer + } else { + timer?.invalidate() + timer = nil + angle = 0 + statusItem.button?.image = baseImage + } + } + + private func updateArtwork(_ url: URL?) { + guard representedArtworkURL != url else { return } + representedArtworkURL = url + artworkTask?.cancel() + artworkTask = nil + baseImage = fallback + statusItem.button?.image = fallback + guard let url else { return } + artworkTask = URLSession.shared.dataTask(with: url) { + [weak self] data, _, _ in + guard let self, let data, let art = NSImage(data: data) else { return } + let disc = MenuBandCDArtworkRenderer.disc(from: art, side: self.side) + DispatchQueue.main.async { + guard self.representedArtworkURL == url else { return } + self.baseImage = disc + self.statusItem.button?.image = disc + } + } + artworkTask?.resume() + } + + @objc private func clicked() { onClick?() } + + private func tick() { + angle -= 1.5 + if angle <= -360 { angle += 360 } + statusItem.button?.image = rotated(baseImage, by: angle) + } + + private func rotated(_ image: NSImage, by degrees: CGFloat) -> NSImage { + let output = NSImage(size: image.size) + output.lockFocus() + let transform = NSAffineTransform() + transform.translateX(by: image.size.width / 2, yBy: image.size.height / 2) + transform.rotate(byDegrees: degrees) + transform.translateX(by: -image.size.width / 2, + yBy: -image.size.height / 2) + transform.concat() + image.draw(at: .zero, from: NSRect(origin: .zero, size: image.size), + operation: .sourceOver, fraction: 1) + output.unlockFocus() + output.isTemplate = false + return output + } + + deinit { + timer?.invalidate() + artworkTask?.cancel() + NSStatusBar.system.removeStatusItem(statusItem) + } +} diff --git a/slab/menuband/Sources/MenuBand/MenuBandSpotifyPlayerView.swift b/slab/menuband/Sources/MenuBand/MenuBandCDJRadioView.swift similarity index 65% rename from slab/menuband/Sources/MenuBand/MenuBandSpotifyPlayerView.swift rename to slab/menuband/Sources/MenuBand/MenuBandCDJRadioView.swift index b64892870..4a3eefb88 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandSpotifyPlayerView.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandCDJRadioView.swift @@ -1,14 +1,15 @@ import AppKit -/// Compact Spotify card shown directly beneath Menu Band's instrument cluster. -/// It deliberately stays small enough for a menu-bar panel: current artwork, -/// track metadata, seekable progress, transport, and catalog search. -final class MenuBandSpotifyPlayerView: NSView, NSSearchFieldDelegate { - static let preferredSize = NSSize(width: 224, height: 142) +/// Menu Band's unified external-audio deck: internet radio and Spotify share +/// one continuous CDJ channel, with one explicit handoff into Piano Sampler. +final class MenuBandCDJRadioView: NSView, NSSearchFieldDelegate { + static let preferredSize = NSSize(width: 224, height: 170) + static let radioHeight: CGFloat = 116 private weak var menuBand: MenuBandController? private let artwork = NSImageView() - private let titleLabel = NSTextField(labelWithString: "Spotify") + private let deckLabel = NSTextField(labelWithString: "CDJ RADIO") + private let titleLabel = NSTextField(labelWithString: "CDJ Radio") private let artistLabel = NSTextField(labelWithString: "") private let detailLabel = NSTextField(labelWithString: "juked headless player") private let timeLabel = NSTextField(labelWithString: "") @@ -16,13 +17,20 @@ final class MenuBandSpotifyPlayerView: NSView, NSSearchFieldDelegate { private let previousButton = NSButton(title: "⏮", target: nil, action: nil) private let playButton = NSButton(title: "▶", target: nil, action: nil) private let nextButton = NSButton(title: "⏭", target: nil, action: nil) + private let sampleButton = NSButton( + title: "SAMPLE → PIANO", target: nil, action: nil) private let closeButton = NSButton(title: "×", target: nil, action: nil) private let searchField = NSSearchField() private var searchResults: [MenuBandSpotifyTrack] = [] private var representedArtworkURL: URL? private var artworkTask: URLSessionDataTask? + private var spinTimer: Timer? - override var intrinsicContentSize: NSSize { Self.preferredSize } + override var intrinsicContentSize: NSSize { + NSSize(width: Self.preferredSize.width, + height: menuBand?.cdjRadioSource == .spotify + ? Self.preferredSize.height : Self.radioHeight) + } init(menuBand: MenuBandController) { self.menuBand = menuBand @@ -35,6 +43,11 @@ final class MenuBandSpotifyPlayerView: NSView, NSSearchFieldDelegate { artwork.wantsLayer = true artwork.layer?.cornerRadius = 6 artwork.layer?.masksToBounds = true + artwork.image = MenuBandCDArtworkRenderer.fallback(side: 58) + + deckLabel.font = .systemFont(ofSize: 8, weight: .bold) + deckLabel.textColor = .secondaryLabelColor + deckLabel.stringValue = "CDJ RADIO" titleLabel.font = .systemFont(ofSize: 13, weight: .bold) titleLabel.lineBreakMode = .byTruncatingTail @@ -59,12 +72,19 @@ final class MenuBandSpotifyPlayerView: NSView, NSSearchFieldDelegate { nextButton.target = self nextButton.action = #selector(nextClicked) + sampleButton.bezelStyle = .recessed + sampleButton.controlSize = .small + sampleButton.font = .systemFont(ofSize: 9, weight: .semibold) + sampleButton.target = self + sampleButton.action = #selector(sampleClicked) + sampleButton.toolTip = "Capture the latest 2.5 seconds into Menu Band Piano Sampler" + closeButton.isBordered = false closeButton.font = .systemFont(ofSize: 15, weight: .medium) closeButton.contentTintColor = .secondaryLabelColor closeButton.target = self closeButton.action = #selector(closeClicked) - closeButton.toolTip = "Pause Spotify and close the player" + closeButton.toolTip = "Stop and close CDJ Radio" searchField.placeholderString = "Search Spotify" searchField.controlSize = .small @@ -80,8 +100,9 @@ final class MenuBandSpotifyPlayerView: NSView, NSSearchFieldDelegate { self.menuBand?.seekSpotify(to: seconds, from: state.position) } - [artwork, titleLabel, artistLabel, detailLabel, timeLabel, progress, - previousButton, playButton, nextButton, closeButton, searchField] + [artwork, deckLabel, titleLabel, artistLabel, detailLabel, timeLabel, progress, + previousButton, playButton, nextButton, sampleButton, closeButton, + searchField] .forEach(addSubview) refresh() } @@ -91,22 +112,37 @@ final class MenuBandSpotifyPlayerView: NSView, NSSearchFieldDelegate { override func layout() { super.layout() let width = bounds.width - artwork.frame = NSRect(x: 7, y: 76, width: 58, height: 58) - closeButton.frame = NSRect(x: width - 25, y: 116, width: 20, height: 20) + if menuBand?.cdjRadioSource != .spotify { + artwork.frame = NSRect(x: 7, y: 51, width: 58, height: 58) + closeButton.frame = NSRect(x: width - 25, y: 90, width: 20, height: 20) + let textX: CGFloat = 73 + let textWidth = max(40, width - textX - 25) + deckLabel.frame = NSRect(x: textX, y: 101, width: textWidth, height: 10) + titleLabel.frame = NSRect(x: textX, y: 84, width: textWidth, height: 18) + artistLabel.frame = NSRect(x: textX, y: 66, width: textWidth, height: 15) + detailLabel.frame = NSRect(x: textX, y: 49, width: width - textX - 7, + height: 14) + sampleButton.frame = NSRect(x: 49, y: 13, width: 126, height: 26) + return + } + artwork.frame = NSRect(x: 7, y: 104, width: 58, height: 58) + closeButton.frame = NSRect(x: width - 25, y: 144, width: 20, height: 20) let textX: CGFloat = 73 let textWidth = max(40, width - textX - 25) - titleLabel.frame = NSRect(x: textX, y: 113, width: textWidth, height: 18) - artistLabel.frame = NSRect(x: textX, y: 95, width: textWidth, height: 15) + deckLabel.frame = NSRect(x: textX, y: 157, width: textWidth, height: 10) + titleLabel.frame = NSRect(x: textX, y: 141, width: textWidth, height: 18) + artistLabel.frame = NSRect(x: textX, y: 123, width: textWidth, height: 15) let showsError = menuBand?.spotifyStatusIsError == true detailLabel.frame = NSRect( - x: textX, y: 78, + x: textX, y: 106, width: max(30, width - textX - (showsError ? 7 : 73)), height: 14) - timeLabel.frame = NSRect(x: width - 73, y: 78, + timeLabel.frame = NSRect(x: width - 73, y: 106, width: showsError ? 0 : 66, height: 14) - progress.frame = NSRect(x: 7, y: 61, width: width - 14, height: 10) - previousButton.frame = NSRect(x: 49, y: 32, width: 38, height: 24) - playButton.frame = NSRect(x: 93, y: 32, width: 38, height: 24) - nextButton.frame = NSRect(x: 137, y: 32, width: 38, height: 24) + progress.frame = NSRect(x: 7, y: 89, width: width - 14, height: 10) + previousButton.frame = NSRect(x: 49, y: 60, width: 38, height: 24) + playButton.frame = NSRect(x: 93, y: 60, width: 38, height: 24) + nextButton.frame = NSRect(x: 137, y: 60, width: 38, height: 24) + sampleButton.frame = NSRect(x: 49, y: 32, width: 126, height: 24) searchField.frame = NSRect(x: 7, y: 5, width: width - 14, height: 22) } @@ -123,12 +159,13 @@ final class MenuBandSpotifyPlayerView: NSView, NSSearchFieldDelegate { func refresh() { guard let menuBand else { return } let state = menuBand.spotifyPlayback - titleLabel.stringValue = state?.title ?? "Spotify" - artistLabel.stringValue = state?.artists ?? "" + let spotifySource = menuBand.cdjRadioSource == .spotify + titleLabel.stringValue = menuBand.cdjRadioTitle + artistLabel.stringValue = menuBand.cdjRadioSubtitle if menuBand.spotifyStatusIsError { detailLabel.stringValue = menuBand.spotifyStatus timeLabel.stringValue = "" - } else if let state { + } else if spotifySource, let state { detailLabel.stringValue = state.album timeLabel.stringValue = "\(Self.mmss(state.position))/\(Self.mmss(state.duration))" @@ -141,7 +178,11 @@ final class MenuBandSpotifyPlayerView: NSView, NSSearchFieldDelegate { playButton.title = state?.isPlaying == true ? "❚❚" : "▶" progress.duration = state?.duration ?? 0 progress.position = state?.position ?? 0 - updateArtwork(state?.artworkURL) + [previousButton, playButton, nextButton, progress, timeLabel, + searchField].forEach { $0.isHidden = !spotifySource } + invalidateIntrinsicContentSize() + updateArtwork(menuBand.cdjRadioArtworkURL) + updateSpin(menuBand.cdjRadioPlaying) needsLayout = true needsDisplay = true } @@ -151,14 +192,14 @@ final class MenuBandSpotifyPlayerView: NSView, NSSearchFieldDelegate { representedArtworkURL = url artworkTask?.cancel() artworkTask = nil - artwork.image = NSImage( - systemSymbolName: "music.note", accessibilityDescription: "Spotify") + artwork.image = MenuBandCDArtworkRenderer.fallback(side: 58) guard let url else { return } artworkTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in guard let data, let image = NSImage(data: data) else { return } DispatchQueue.main.async { guard self?.representedArtworkURL == url else { return } - self?.artwork.image = image + self?.artwork.image = MenuBandCDArtworkRenderer.disc( + from: image, side: 58) } } artworkTask?.resume() @@ -167,7 +208,28 @@ final class MenuBandSpotifyPlayerView: NSView, NSSearchFieldDelegate { @objc private func previousClicked() { menuBand?.previousSpotifyTrack() } @objc private func playClicked() { menuBand?.toggleSpotifyPlayback() } @objc private func nextClicked() { menuBand?.nextSpotifyTrack() } - @objc private func closeClicked() { menuBand?.deactivateSpotifyPlayer() } + @objc private func sampleClicked() { _ = menuBand?.sampleCDJRadioToPiano() } + @objc private func closeClicked() { menuBand?.deactivateCDJRadio() } + + private func updateSpin(_ playing: Bool) { + if playing { + guard spinTimer == nil else { return } + let timer = Timer(timeInterval: 1.0 / 30.0, repeats: true) { + [weak self] _ in + guard let self else { return } + self.artwork.frameCenterRotation -= 1.5 + if self.artwork.frameCenterRotation <= -360 { + self.artwork.frameCenterRotation += 360 + } + } + RunLoop.main.add(timer, forMode: .common) + spinTimer = timer + } else { + spinTimer?.invalidate() + spinTimer = nil + artwork.frameCenterRotation = 0 + } + } @objc private func searchSubmitted() { let query = searchField.stringValue @@ -224,7 +286,10 @@ final class MenuBandSpotifyPlayerView: NSView, NSSearchFieldDelegate { return String(format: "%d:%02d", total / 60, total % 60) } - deinit { artworkTask?.cancel() } + deinit { + artworkTask?.cancel() + spinTimer?.invalidate() + } } private final class MenuBandSpotifyProgressView: NSView { diff --git a/slab/menuband/Sources/MenuBand/MenuBandController.swift b/slab/menuband/Sources/MenuBand/MenuBandController.swift index 22835df34..59e45c74b 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandController.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandController.swift @@ -179,15 +179,19 @@ final class MenuBandController { /// added without breaking older saved values. private let instrumentBackendKey = "notepat.instrumentBackend" private let radioStationKey = "notepat.radioStation" - /// Headless Spotify playback is deliberately separate from the synth's - /// instrument backend: the player can run underneath a GM/sample voice so - /// the user can play along. Selecting an internet-radio voice pauses it. + /// CDJ Radio is deliberately separate from the piano instrument backend: + /// the deck can run beneath GM/sample/MIDI playback, and only the explicit + /// Sample-to-Piano action moves a slice onto the keys. private let spotify = MenuBandSpotify() private var spotifyCallbacksInstalled = false - private(set) var spotifyPlayerPresented = false + private(set) var cdjRadioPresented = false + private(set) var cdjRadioSource: CDJRadioSource = .station(.kpbj) private(set) var spotifyPlayback: MenuBandSpotifyPlayback? - private(set) var spotifyStatus = "juked headless player" + private(set) var spotifyStatus = "CDJ Radio" private(set) var spotifyStatusIsError = false + var spotifyPlayerPresented: Bool { + cdjRadioPresented && cdjRadioSource == .spotify + } /// File URL string of the GarageBand patch the user picked. Empty /// when no GB patch has been selected yet (we'll fall back to the /// first scanned patch when the backend is GarageBand and this is @@ -210,10 +214,10 @@ final class MenuBandController { var onOctaveLimitNudge: ((Int) -> Void)? var onLitChanged: (() -> Void)? var onInstrumentVisualChange: (() -> Void)? - /// Lightweight now-playing updates (once per second while Spotify is in - /// use). Kept separate from `onChange`, whose full icon/window refresh is - /// intentionally too expensive for a progress timer. - var onSpotifyChange: (() -> Void)? + /// Lightweight CDJ deck / now-playing updates. Kept separate from + /// `onChange`, whose full icon/window refresh is too expensive for the + /// Spotify progress timer. + var onCDJRadioChange: (() -> Void)? private(set) var sampleInputLevel: Float = 0 /// Last MIDI note actually played (mouse tap or keyboard). Used by @@ -1236,33 +1240,28 @@ final class MenuBandController { onChange?() } - /// Switch the active backend to the live KPBJ.FM radio stream - /// (conceptually "voice −1"). The piano keys play the live audio - /// pitched by 2^((note−60)/12) — middle C is unpitched, every other - /// note is varispeed-shifted off the same buffer. Disabling restores - /// whichever GM program was last selected. + /// Compatibility entry point for the former radio-as-instrument mode. + /// Radio now toggles the independent CDJ deck and never changes the + /// piano's selected instrument. func setRadioBackend(_ enabled: Bool) { if enabled { - if spotifyPlayerPresented { deactivateSpotifyPlayer() } - UserDefaults.standard.set("kpbj", forKey: instrumentBackendKey) - synth.setRadioStation(radioStation) // tune to the saved station - synth.setRadioBackend(true) + selectRadioStation(radioStation) } else { - UserDefaults.standard.set("gm", forKey: instrumentBackendKey) - synth.setRadioBackend(false) - // Reload whatever GM voice was last picked so the user lands - // back on a familiar instrument instead of silence. - synth.setMelodicProgram(melodicProgram) + deactivateCDJRadio() } - onChange?() - onInstrumentVisualChange?() } func toggleRadioBackend() { - setRadioBackend(instrumentBackend != .kpbj) + let sameStation: Bool + if case .station(let station) = cdjRadioSource { + sameStation = station == radioStation + } else { + sameStation = false + } + setRadioBackend(!(cdjRadioPresented && sameStation)) } - /// The radio station the "voice −1" backend is tuned to (persisted). + /// The persisted internet station for the CDJ Radio deck. var radioStation: RadioStation { get { RadioStation.by(id: UserDefaults.standard.string(forKey: radioStationKey) ?? "kpbj") } set { @@ -1273,43 +1272,67 @@ final class MenuBandController { } } - /// Pick a station AND make the radio the active backend — the action - /// behind a station cell in the chooser and the `-kpbj` / `-nts1` / - /// `-nts2` typed commands. Tuning while already on radio just retunes. + /// Tune the standalone CDJ deck. The currently-selected piano instrument + /// continues to receive every key and can be played over the station. func selectRadioStation(_ station: RadioStation) { - // Radio and Spotify are peer listening sources in the bottom strip; - // never let both streams speak at once. This leaves the normal GM - // voice untouched when Spotify is later re-opened for play-along. - if spotifyPlayerPresented { deactivateSpotifyPlayer() } - UserDefaults.standard.set(station.id, forKey: radioStationKey) - synth.setRadioStation(station) - if instrumentBackend != .kpbj { - setRadioBackend(true) // engages radio + applies this station + if cdjRadioSource == .spotify { + synth.silenceCDJSpotify() + spotify.pause { [weak self] in + guard let self, + !self.cdjRadioPresented || self.cdjRadioSource != .spotify + else { return } + self.synth.stopCDJSpotify() + } } else { - onChange?() - onInstrumentVisualChange?() + synth.stopCDJSpotify() } + UserDefaults.standard.set(station.id, forKey: radioStationKey) + cdjRadioSource = .station(station) + cdjRadioPresented = true + spotifyStatus = "LIVE · through Menu Band FX" + spotifyStatusIsError = false + synth.startCDJRadio(station: station) + onCDJRadioChange?() + onInstrumentVisualChange?() } - // MARK: - Headless Spotify player + // MARK: - CDJ Radio / headless Spotify /// Reveal and start the compact Spotify player. `juked` serializes start /// before any immediately-following search/play command, so this is safe /// to call from a single click on the source strip. func activateSpotifyPlayer() { installSpotifyCallbacksIfNeeded() - spotifyPlayerPresented = true + synth.stopCDJInternetRadio() + cdjRadioSource = .spotify + cdjRadioPresented = true spotifyStatus = "connecting to juked…" spotifyStatusIsError = false spotify.start() - onSpotifyChange?() + connectSpotifyDeckSoon() + onCDJRadioChange?() onInstrumentVisualChange?() } func deactivateSpotifyPlayer() { - spotify.pause() - spotifyPlayerPresented = false - onSpotifyChange?() + deactivateCDJRadio() + } + + func deactivateCDJRadio() { + if cdjRadioSource == .spotify { + synth.silenceCDJSpotify() + spotify.pause { [weak self] in + guard let self, + !self.cdjRadioPresented || self.cdjRadioSource != .spotify + else { return } + self.synth.stopCDJSpotify() + } + } else { + synth.stopCDJSpotify() + } + synth.stopCDJInternetRadio() + cdjRadioPresented = false + onCDJRadioChange?() onInstrumentVisualChange?() } @@ -1338,6 +1361,79 @@ final class MenuBandController { spotify.play(track) } + /// Explicit CDJ Radio → Piano Sampler handoff. A short slice ending at + /// the current playhead becomes the sampler's global recording; only a + /// successful capture switches the piano instrument to SAMPLE. + @discardableResult + func sampleCDJRadioToPiano() -> Bool { + guard cdjRadioPresented else { return false } + let captured = synth.sampleCDJRadioToPiano(source: cdjRadioSource) + if captured { + spotifyStatus = "Sampled 2.5 s → Piano" + spotifyStatusIsError = false + setSampleBackend(true) + } else { + spotifyStatus = "CDJ buffer is still filling — try again" + spotifyStatusIsError = true + } + onCDJRadioChange?() + return captured + } + + var cdjRadioTitle: String { + switch cdjRadioSource { + case .station(let station): return station.name + case .spotify: return spotifyPlayback?.title ?? "Spotify" + } + } + + var cdjRadioSubtitle: String { + switch cdjRadioSource { + case .station: return "LIVE INTERNET RADIO" + case .spotify: return spotifyPlayback?.artists ?? "juked headless" + } + } + + var cdjRadioArtworkURL: URL? { + cdjRadioSource == .spotify ? spotifyPlayback?.artworkURL : nil + } + + var cdjRadioPlaying: Bool { + guard cdjRadioPresented else { return false } + switch cdjRadioSource { + case .station: return true + case .spotify: return spotifyPlayback?.isPlaying == true + } + } + + private func connectSpotifyDeckSoon(attemptsRemaining: Int = 8) { + spotify.daemonPID { [weak self] processID in + guard let self, self.cdjRadioPresented, + self.cdjRadioSource == .spotify else { return } + guard let processID else { + guard attemptsRemaining > 0 else { + self.spotifyStatus = "juked started, but its audio process was not found" + self.spotifyStatusIsError = true + self.onCDJRadioChange?() + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + [weak self] in self?.connectSpotifyDeckSoon( + attemptsRemaining: attemptsRemaining - 1) + } + return + } + self.synth.startCDJSpotify(processID: processID) { + [weak self] message in + DispatchQueue.main.async { + self?.spotifyStatus = message + self?.spotifyStatusIsError = true + self?.onCDJRadioChange?() + } + } + } + } + private func installSpotifyCallbacksIfNeeded() { guard !spotifyCallbacksInstalled else { return } spotifyCallbacksInstalled = true @@ -1345,20 +1441,21 @@ final class MenuBandController { guard let self else { return } self.spotifyPlayback = state if state != nil { self.spotifyStatusIsError = false } - self.onSpotifyChange?() + self.onCDJRadioChange?() } spotify.onStatus = { [weak self] message, failed in guard let self else { return } self.spotifyStatus = message self.spotifyStatusIsError = failed - self.onSpotifyChange?() + self.onCDJRadioChange?() } } /// Deterministic, side-effect-free state for the offscreen popover capture /// harness. Never starts `juked` or touches the user's real playback. func seedSpotifyPlayerForCapture() { - spotifyPlayerPresented = true + cdjRadioSource = .spotify + cdjRadioPresented = true spotifyStatus = "juked headless · ready" spotifyStatusIsError = false spotifyPlayback = MenuBandSpotifyPlayback( @@ -1657,11 +1754,13 @@ final class MenuBandController { // volume (matches previous behaviour); explicit lower picks // survive the relaunch. synth.setMasterVolume(masterVolume) - // Restore radio backend if it was active in the previous session. - // Done after setMelodicProgram so the GM voice is primed underneath - // — toggling radio off later returns the user to that voice. + // Migrate the former radio-as-piano backend into the standalone CDJ + // deck. The saved GM voice remains the keyboard instrument. if instrumentBackend == .kpbj { - synth.setRadioBackend(true) + UserDefaults.standard.set("gm", forKey: instrumentBackendKey) + cdjRadioSource = .station(radioStation) + cdjRadioPresented = true + synth.startCDJRadio(station: radioStation) } // Sample backend doesn't survive relaunch — there's no // recording on disk yet, so fall back to GM. Persist the @@ -2764,19 +2863,17 @@ final class MenuBandController { /// `voiceDigitFlushInterval` means the user is picking a new voice, /// not continuing a multi-digit number. private var voiceDigitLastPress: CFTimeInterval = 0 - /// True after the `-` key was pressed and the next digit will be - /// read as part of a negative voice slot (currently only `-1` - /// = KPBJ radio). Same `voiceDigitFlushInterval` timeout as the + /// True after `-` primes a CDJ Radio shortcut (`-1` toggles the saved + /// station). Same `voiceDigitFlushInterval` timeout as the /// digit buffer so a stray `-` doesn't hijack the next typed /// voice number. private var voiceDigitNegative: Bool = false - /// Letters typed after a `-` accumulate here so a negative voice can be - /// picked by NAME instead of slot number — e.g. `-kpbj`, `-nts1`, - /// `-nts2` each tune the radio backend ("voice −1") to that station. + /// Letters typed after `-` accumulate into a CDJ station callsign — e.g. + /// `-kpbj`, `-nts1`, or `-nts2`. The piano instrument is unchanged. /// Cleared on `-`, on a match, on divergence from any known name, and on /// the same `voiceDigitFlushInterval` staleness window as the digits. private var voiceCommandBuffer: String = "" - /// Negative voice names recognized after a `-` — one per radio station + /// CDJ station names recognized after `-` — one per radio station /// id. Matched as the buffer grows so it can bail the moment it diverges /// from every known name. private static let negativeVoiceNames: Set = @@ -3066,15 +3163,9 @@ final class MenuBandController { break } - // Minus key (`-`, keyCode 27) primes a negative voice slot. The - // trigger is `-1` OR a voice name like `-kpbj`, NOT a bare `-`. - // Either selects the live KPBJ radio backend ("voice −1": the - // piano plays the live stream pitched per note, stalls fading into - // AM-style static). `-1` toggles; `-` accumulates the letters - // that follow and selects by callsign. Standalone `-` is a no-op so - // the negative-prefix UX matches how positive voices are typed - // (the rest of the sequence commits the pick). Consumed in both - // directions so the key never leaks to the focused app. + // Minus primes a CDJ Radio shortcut. `-1` toggles the saved station; + // `-kpbj`, `-r8dio`, `-nts1`, and `-nts2` tune the separate deck. + // None of these commands changes the piano instrument. if keyCode == 27 { if isDown && !isRepeat { voiceDigitBuffer = "" @@ -3173,10 +3264,8 @@ final class MenuBandController { let now = CACurrentMediaTime() let staleGap = now - voiceDigitLastPress > Self.voiceDigitFlushInterval - // Negative-voice slot, primed by a preceding `-`. - // `-1` is the only negative voice today (KPBJ radio); - // it toggles, so a second `-1` switches back to the - // last GM voice. Other digits after `-` are no-ops — + // CDJ Radio shortcut, primed by a preceding `-`. + // `-1` toggles the saved station. Other digits are no-ops — // we consume them so they don't quietly pick a GM // voice the user wasn't aiming for. if voiceDigitNegative && !staleGap { @@ -3217,9 +3306,8 @@ final class MenuBandController { return true } - // Negative voice by NAME: once `-` has primed negative mode, the - // letters that follow spell a voice callsign — `-kpbj` selects the - // KPBJ radio. We accumulate letters and match against the known + // CDJ station by NAME: once `-` primes the shortcut, the letters + // spell a callsign. We accumulate and match against the known // names as the buffer grows, bailing the moment it can't be any of // them. The letter keys are consumed (no note plays) only while a // name is still plausibly being typed; a stale `-` or a divergent @@ -3238,9 +3326,8 @@ final class MenuBandController { let token = voiceCommandBuffer let isPrefix = Self.negativeVoiceNames.contains { $0.hasPrefix(token) } if Self.negativeVoiceNames.contains(token) { - // Complete match → tune the radio to that station and - // engage it (idempotent: a name selects, it doesn't - // toggle off like `-1` does). + // Complete match → tune the separate CDJ deck. A name + // selects; it does not toggle off like `-1` does. voiceDigitNegative = false voiceCommandBuffer = "" let station = RadioStation.by(id: token) diff --git a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift index d6056cf0f..c296a4b23 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift @@ -303,11 +303,9 @@ final class MenuBandPopoverViewController: NSViewController { /// out of the now-retired floating piano window and hosted directly in /// this popover so the whole instrument lives in a single column. private var instrumentCluster: CollapsedPianoWaveformView? -#if !MAC_APP_STORE - /// `juked`-backed now-playing card. Hidden/collapsed until the Spotify - /// cell in the instrument cluster's listening-source strip is chosen. - private var spotifyPlayerView: MenuBandSpotifyPlayerView? -#endif + /// Unified CDJ Radio card. Direct-download builds additionally expose the + /// Spotify source; App Store builds use the same deck for radio stations. + private var cdjRadioView: MenuBandCDJRadioView? /// Transport controls that appear next to the metronome when a /// Menu Band PDF score has been loaded into the staff. Play /// restarts from the head; Stop cancels in-flight playback. @@ -557,20 +555,15 @@ final class MenuBandPopoverViewController: NSViewController { stack.addArrangedSubview(cluster) stack.setCustomSpacing(8, after: cluster) -#if !MAC_APP_STORE - let spotifyPlayer = MenuBandSpotifyPlayerView(menuBand: mb) - spotifyPlayer.translatesAutoresizingMaskIntoConstraints = false - spotifyPlayer.isHidden = !mb.spotifyPlayerPresented - spotifyPlayer.widthAnchor.constraint( - equalToConstant: MenuBandSpotifyPlayerView.preferredSize.width + let cdjRadio = MenuBandCDJRadioView(menuBand: mb) + cdjRadio.translatesAutoresizingMaskIntoConstraints = false + cdjRadio.isHidden = !mb.cdjRadioPresented + cdjRadio.widthAnchor.constraint( + equalToConstant: MenuBandCDJRadioView.preferredSize.width ).isActive = true - spotifyPlayer.heightAnchor.constraint( - equalToConstant: MenuBandSpotifyPlayerView.preferredSize.height - ).isActive = true - spotifyPlayerView = spotifyPlayer - stack.addArrangedSubview(spotifyPlayer) - stack.setCustomSpacing(6, after: spotifyPlayer) -#endif + cdjRadioView = cdjRadio + stack.addArrangedSubview(cdjRadio) + stack.setCustomSpacing(6, after: cdjRadio) } // Input mode picker. Three states: @@ -1424,9 +1417,7 @@ final class MenuBandPopoverViewController: NSViewController { applyPopoverRootChrome() applyAppearanceToVisualizer() updateInstrumentReadout() -#if !MAC_APP_STORE - refreshSpotifyPlayer(resize: false) -#endif + refreshCDJRadio(resize: false) // Keep the QWERTY layout's keymap + tint synced with the // controller. Voice color picks up the family hue for the // current voice; keymap variant follows the controller. @@ -1473,13 +1464,11 @@ final class MenuBandPopoverViewController: NSViewController { // correct (self-contained constraint chain), so the target is // cluster + the non-cluster chrome measured at loadView. var extra = chromeExtraHeight -#if !MAC_APP_STORE - if spotifyPlayerView?.isHidden == false { + if cdjRadioView?.isHidden == false { // Baseline chrome already includes the cluster→footer spacing. // Revealing the card adds its height plus its own footer gap. - extra += MenuBandSpotifyPlayerView.preferredSize.height + 6 + extra += (cdjRadioView?.intrinsicContentSize.height ?? 0) + 6 } -#endif let target = NSSize( width: preferredContentSize.width, height: extra + cluster.fittingSize.height) @@ -1801,22 +1790,22 @@ final class MenuBandPopoverViewController: NSViewController { instrumentCluster?.refresh() } -#if !MAC_APP_STORE /// Update the compact now-playing card without running the popover's full /// state sync every second. Only a visibility edge changes panel geometry; /// ordinary position/title ticks repaint in place. - func refreshSpotifyPlayer(resize: Bool = true) { - guard isViewLoaded, let player = spotifyPlayerView, let menuBand else { + func refreshCDJRadio(resize: Bool = true) { + guard isViewLoaded, let player = cdjRadioView, let menuBand else { return } - let shouldHide = !menuBand.spotifyPlayerPresented + let shouldHide = !menuBand.cdjRadioPresented let visibilityChanged = player.isHidden != shouldHide + let oldHeight = player.intrinsicContentSize.height player.isHidden = shouldHide player.refresh() + let heightChanged = oldHeight != player.intrinsicContentSize.height instrumentCluster?.refresh() - if resize && visibilityChanged { refitAndResizePanel() } + if resize && (visibilityChanged || heightChanged) { refitAndResizePanel() } } -#endif private func currentVoiceColor() -> NSColor { guard let m = menuBand else { return .controlAccentColor } diff --git a/slab/menuband/Sources/MenuBand/MenuBandSampleVoice.swift b/slab/menuband/Sources/MenuBand/MenuBandSampleVoice.swift index 5c2a56dfc..827b4c8b3 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandSampleVoice.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandSampleVoice.swift @@ -512,6 +512,62 @@ final class MenuBandSampleVoice { } } + /// Replace the global Piano Sampler recording with audio supplied by an + /// in-process source such as CDJ Radio. Unlike microphone recording this + /// is an instantaneous, explicit handoff: no input permission, no key + /// gate, and no accidental monitoring of the room. + @discardableResult + func loadRecording(from input: AVAudioPCMBuffer) -> Bool { + let minimumFrames = Int(sampleRate * 0.1) + guard input.frameLength > 0, + let converter = AVAudioConverter( + from: input.format, to: storageFormat) else { return false } + let ratio = storageFormat.sampleRate / input.format.sampleRate + let capacity = AVAudioFrameCount( + ceil(Double(input.frameLength) * ratio) + 16) + guard let converted = AVAudioPCMBuffer( + pcmFormat: storageFormat, frameCapacity: capacity) else { return false } + var supplied = false + var conversionError: NSError? + let status = converter.convert(to: converted, error: &conversionError) { + _, outStatus in + if supplied { + outStatus.pointee = .noDataNow + return nil + } + supplied = true + outStatus.pointee = .haveData + return input + } + let frames = Int(converted.frameLength) + guard status != .error, conversionError == nil, frames >= minimumFrames, + let data = converted.floatChannelData?[0] else { return false } + + let stats = shapeCapturedSample(data, frames: frames) + // The rolling CDJ snapshot ends at "now", so soften that arbitrary + // cut before the sampler loops it. + let fadeFrames = min(frames, Int(sampleRate * 0.015)) + if fadeFrames > 1 { + for index in 0.. Void)? = nil) { + command(["pause"]) { _ in completion?() } + pollSoon() + } func next() { command(["next"]); pollSoon() } func previous() { command(["previous"]); pollSoon() } func seek(to seconds: Double, from currentPosition: Double) { @@ -98,6 +101,21 @@ final class MenuBandSpotify { pollSoon() } + /// Resolve the headless daemon's PID so CDJ Radio can attach a first-party + /// Core Audio process tap and route Spotify through Menu Band's FX graph. + func daemonPID(_ completion: @escaping (pid_t?) -> Void) { + command(["pid"], reportError: false) { result in + guard case .success(let data) = result, + let text = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + let value = Int32(text) else { + completion(nil) + return + } + completion(value) + } + } + private func pollSoon() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in self?.poll() diff --git a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift index 11200af6b..456adf5b3 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift @@ -34,11 +34,12 @@ final class MenuBandSynth { /// Fallback backend, always available immediately. private let melodic = AVAudioUnitSampler() private let drums = AVAudioUnitSampler() - /// Live KPBJ.FM radio backend — pads play the live stream pitched by - /// 2^((note-60)/12), with stalls fading into AM-style static driven - /// by real-time NIC byte counters. Attached to the engine on - /// `start()`; AVPlayer only spins up while `usingRadioBackend` is on. + /// Continuous internet-radio side of CDJ Radio, with stalls fading into + /// AM-style static driven by real-time NIC byte counters. private let radio = KPBJRadioStream() + /// Headless Spotify audio re-emitted inside Menu Band's pre-FX graph for + /// the standalone Spotify side of CDJ Radio. + private let spotifyDeck = MenuBandCDJSpotifyDeck() /// Microphone-sampled "voice": user holds backtick to record a clip, /// then plays it back as a duration-preserving pitch-shifted piano /// voice (TimePitch). Same @@ -397,10 +398,10 @@ final class MenuBandSynth { connectLimiterIfNeeded() connectMelodicSamplerIfNeeded() connectDrumsSamplerIfNeeded() - // Wire the radio's static graph into the same pre-limiter sum - // bus. The AVPlayer stays paused until `setRadioBackend(true)`, - // so this just adds idle nodes — no CPU cost while inactive. + // CDJ Radio shares the pre-limiter FX bus but stays silent until a + // listening source is selected. radio.attach(to: engine, output: preLimiterMixer) + spotifyDeck.attach(to: engine, output: preLimiterMixer) // Sample voice: same pre-limiter sum bus. Master gate stays // closed until the user records a clip and `setSampleBackend` // opens it. Voice nodes attach lazily on first noteOn. @@ -1678,6 +1679,55 @@ final class MenuBandSynth { // MARK: - KPBJ radio backend + // MARK: CDJ Radio deck + + /// Start an internet-radio station as a continuous CDJ deck. This is + /// independent from `usingRadioBackend`, the retired key-pitched mode, + /// so piano notes continue using their selected instrument. + func startCDJRadio(station: RadioStation) { + guard started else { return } + _ = resumeAudioEngineIfNeeded() + spotifyDeck.stop() + radio.setStation(station) + radio.setOutputEnabled(true) + radio.startStreaming() + } + + func stopCDJInternetRadio() { + radio.setOutputEnabled(false) + radio.stopStreaming() + } + + func startCDJSpotify(processID: pid_t, + onError: @escaping (String) -> Void) { + guard started else { return } + _ = resumeAudioEngineIfNeeded() + radio.setOutputEnabled(false) + radio.stopStreaming() + spotifyDeck.onError = onError + spotifyDeck.start(processID: processID) + } + + func stopCDJSpotify() { spotifyDeck.stop() } + func silenceCDJSpotify() { spotifyDeck.setOutputEnabled(false) } + + /// Copy a short rolling slice into the global Piano Sampler. The caller + /// switches the piano backend only after a successful import. + @discardableResult + func sampleCDJRadioToPiano( + source: CDJRadioSource, seconds: Double = 2.5 + ) -> Bool { + let audio: AVAudioPCMBuffer? + switch source { + case .station: + audio = radio.copyRecentAudio(seconds: seconds) + case .spotify: + audio = spotifyDeck.copyRecentAudio(seconds: seconds) + } + guard let audio else { return false } + return sampleVoice.loadRecording(from: audio) + } + /// True while the live KPBJ stream is the active melodic source. /// Drum keys (channel 9) still go to GM — drums never go through /// the radio path. @@ -1708,41 +1758,18 @@ final class MenuBandSynth { private var radioLingerWorkItem: DispatchWorkItem? private let radioLingerSeconds: TimeInterval = 15.0 - /// Switch the active melodic source between the local synth and the - /// live KPBJ stream. Enabling silences any in-flight sampler / - /// MIDISynth notes so we don't double-trigger; disabling closes the - /// radio's master gate immediately (no stuck audio when picking a GM - /// voice mid-play) and schedules a 15 s teardown — so a quick flip - /// back finds the stream still warm. + /// Compatibility shim for older controller builds. Radio is now a + /// continuous CDJ deck and never becomes the melodic key backend. func setRadioBackend(_ enabled: Bool) { if enabled { - // Cancel any pending teardown — we're back in voice −1. - radioLingerWorkItem?.cancel() - radioLingerWorkItem = nil - usingRadioBackend = true - usingGarageBandPatch = false - // Radio + sample are mutually exclusive — same melodic - // note path. - if usingSampleBackend { - leaveSampleBackend() - } - for unit in [melodic, drums] { - stopAllSamplerNotes(unit) - } - if midiSynthReady, let au = midiSynth?.audioUnit { - for ch: UInt8 in 0..<16 { - sendMIDIEvent(au, status: 0xB0 | ch, data1: 123, data2: 0) - } - } if started { _ = resumeAudioEngineIfNeeded() radio.setOutputEnabled(true) - radio.startStreaming() // idempotent if already running + radio.startStreaming() } } else { - // Close the master gate immediately and start the linger - // teardown — see `leaveRadioWithLinger` for details. - leaveRadioWithLinger() + radio.setOutputEnabled(false) + radio.stopStreaming() } } @@ -2040,15 +2067,8 @@ final class MenuBandSynth { sendMIDIEvent(au, status: 0x90 | (channel & 0x0F), data1: midi, data2: velocity) return } - // Radio backend takes melodic ahead of GM/sampler/MIDISynth. - // Drums still pass through to GM below — the KPBJ pads are a - // melodic-only voice. - if usingRadioBackend && channel != 9 { - radio.noteOn(midi, velocity: velocity, channel: channel) - return - } - // Sample backend — same melodic-only routing semantics as - // radio. Drums always continue down to the GM path. + // Sample backend is melodic-only. CDJ Radio is a separate continuous + // deck and therefore never appears in this note-routing switch. if usingSampleBackend && channel != 9 { // Per-key/global sample plays it; if this key has no sample, fall // through to the GM instrument (hybrid kit — instruments per key). @@ -2162,6 +2182,7 @@ final class MenuBandSynth { /// — the live stream slides in pitch alongside every other voice. func setRadioPitchBend(amount: Float) { radio.setBend(amount: amount) + spotifyDeck.setPitch(semitones: amount * 12) } /// Per-channel Expression (CC 11), 0–127. Used by the linger @@ -2204,10 +2225,6 @@ final class MenuBandSynth { sendMIDIEvent(au, status: 0x80 | (channel & 0x0F), data1: midi) return } - if usingRadioBackend && channel != 9 { - radio.noteOff(midi, channel: channel) - return - } if usingSampleBackend && channel != 9 { // Release the sample voice, then DON'T return — fall through to // also send a GM note-off, so keys that fell back to GM (no sample) diff --git a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift index b92049902..49553f138 100644 --- a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift +++ b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift @@ -196,8 +196,8 @@ final class CollapsedPianoWaveformView: NSView { keyCode: kc, isDown: isDown, isRepeat: isRepeat, flags: flags ) ?? false } - // Radio-station cells sit in the top row, left of MIDI OUT. Clicking - // one tunes the radio ("voice −1") to that station and engages it. + // Listening sources drive the standalone CDJ Radio deck; they never + // replace the instrument played by the piano keys. instrumentList.radioStations = RadioStation.all instrumentList.onRadioCommit = { [weak self] station in self?.menuBand?.selectRadioStation(station) @@ -534,7 +534,11 @@ final class CollapsedPianoWaveformView: NSView { // voice while the preview note plays a different program. instrumentList.selectedProgram = menuBand.effectiveMelodicProgram instrumentList.midiModeActive = menuBand.midiMode - instrumentList.radioBackendActive = (menuBand.instrumentBackend == .kpbj) + if case .station = menuBand.cdjRadioSource { + instrumentList.radioBackendActive = menuBand.cdjRadioPresented + } else { + instrumentList.radioBackendActive = false + } instrumentList.sampleBackendActive = (menuBand.instrumentBackend == .sample) instrumentList.selectedRadioStationID = menuBand.radioStation.id instrumentList.spotifyActive = menuBand.spotifyPlayerPresented diff --git a/slab/menuband/Sources/MenuBand/PopoverCapture.swift b/slab/menuband/Sources/MenuBand/PopoverCapture.swift index 88176759a..d238ff248 100644 --- a/slab/menuband/Sources/MenuBand/PopoverCapture.swift +++ b/slab/menuband/Sources/MenuBand/PopoverCapture.swift @@ -52,6 +52,9 @@ enum PopoverCLI { controller.seedSpotifyPlayerForCapture() } #endif + if args.contains("--radio") { + controller.selectRadioStation(.nts1) + } let vc = MenuBandPopoverViewController() vc.menuBand = controller diff --git a/slab/menuband/Tests/MenuBandTests/CDJRadioTests.swift b/slab/menuband/Tests/MenuBandTests/CDJRadioTests.swift new file mode 100644 index 000000000..6c93c8e5b --- /dev/null +++ b/slab/menuband/Tests/MenuBandTests/CDJRadioTests.swift @@ -0,0 +1,43 @@ +import AVFoundation +import XCTest +@testable import MenuBand + +final class CDJRadioTests: XCTestCase { + func testSourceLabelsAreDeckSourcesNotInstrumentNumbers() { + XCTAssertEqual(CDJRadioSource.station(.nts1).label, "NTS1") + XCTAssertEqual(CDJRadioSource.spotify.label, "SPOTIFY") + } + + func testCDJBufferLoadsIntoPianoSampler() throws { + let format = try XCTUnwrap(AVAudioFormat( + commonFormat: .pcmFormatFloat32, sampleRate: 48_000, + channels: 2, interleaved: false)) + let frames = 24_000 + let buffer = try XCTUnwrap(AVAudioPCMBuffer( + pcmFormat: format, frameCapacity: AVAudioFrameCount(frames))) + buffer.frameLength = AVAudioFrameCount(frames) + let channels = try XCTUnwrap(buffer.floatChannelData) + for frame in 0..