From 6fe7bee9e74172c9352f96ac0d5e71cdd6709aa8 Mon Sep 17 00:00:00 2001
From: "prompt.ac/@jeffrey"
Date: Tue, 21 Jul 2026 12:47:41 -0700
Subject: [PATCH] Make CDJ Radio a Menu Band deck
---
.../Sources/JukeWizard/JukeController.swift | 51 +--
.../Sources/JukeWizard/MenuBarCD.swift | 141 --------
.../Sources/JukeWizard/MenuBarNoteBurst.swift | 96 -----
juke-wizard/Sources/JukeWizard/main.swift | 6 +-
.../Contents/Resources/en.lproj/index.html | 16 +-
.../Sources/MenuBand/AppDelegate.swift | 22 +-
.../Sources/MenuBand/InstrumentMapView.swift | 13 +-
.../Sources/MenuBand/KPBJRadioStream.swift | 68 ++--
.../Sources/MenuBand/MenuBandCDJRadio.swift | 339 ++++++++++++++++++
...rView.swift => MenuBandCDJRadioView.swift} | 123 +++++--
.../Sources/MenuBand/MenuBandController.swift | 245 +++++++++----
.../Sources/MenuBand/MenuBandPopover.swift | 51 ++-
.../MenuBand/MenuBandSampleVoice.swift | 56 +++
.../Sources/MenuBand/MenuBandSpotify.swift | 20 +-
.../Sources/MenuBand/MenuBandSynth.swift | 113 +++---
.../CollapsedPianoWaveformView.swift | 10 +-
.../Sources/MenuBand/PopoverCapture.swift | 3 +
.../Tests/MenuBandTests/CDJRadioTests.swift | 43 +++
18 files changed, 908 insertions(+), 508 deletions(-)
delete mode 100644 juke-wizard/Sources/JukeWizard/MenuBarCD.swift
delete mode 100644 juke-wizard/Sources/JukeWizard/MenuBarNoteBurst.swift
create mode 100644 slab/menuband/Sources/MenuBand/MenuBandCDJRadio.swift
rename slab/menuband/Sources/MenuBand/{MenuBandSpotifyPlayerView.swift => MenuBandCDJRadioView.swift} (65%)
create mode 100644 slab/menuband/Tests/MenuBandTests/CDJRadioTests.swift
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.
- Instruments, MIDI & radio
+ Instruments, MIDI & CDJ Radio
- Instruments — the numbered grid is all 128 General MIDI
voices, colored by family. Click to choose; drag to audition.
- MIDI OUT — pick the 0 MIDI OUT cell to send
notes to a DAW or hardware over a virtual MIDI port instead of the
built-in synth.
- - Radio — the strip along the bottom tunes a live internet
- radio station in as a playable "voice −1".
+ - CDJ Radio — the strip along the bottom selects KPBJ, R8DIO,
+ NTS, or Spotify as a continuous deck. It plays alongside the selected
+ piano instrument and runs through Menu Band's effects and master mix.
- Spotify — in the direct-download build, pick
- SPOTIFY in that same strip to open the headless player. It shows
+ SPOTIFY in that strip to open CDJ Radio's headless player. It shows
artwork and current-track progress, has previous/play/next and seeking,
- and searches the Spotify catalog. Spotify Premium and one-time
+ searches the Spotify catalog, and spins its artwork in the menu bar.
+ Spotify Premium and one-time
juked authenticate setup are required.
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..