diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index 2bbf11b32d..fbe0ea8f27 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -750,6 +750,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { menuBand.bootstrap() let cdjStatusItem = MenuBandCDJStatusItem() cdjStatusItem.onClick = { [weak self] in self?.showPopover() } + cdjStatusItem.onScratchBegin = { [weak self] in + self?.menuBand.beginCDJScratch() ?? false + } + cdjStatusItem.onScratch = { [weak self] delta, velocity in + self?.menuBand.scratchCDJ(deltaPoints: delta, velocity: velocity) + } + cdjStatusItem.onScratchEnd = { [weak self] in + self?.menuBand.endCDJScratch() + } self.cdjStatusItem = cdjStatusItem updateCDJStatusItem() // Subscribe to mic RMS during sample-voice recording. The diff --git a/slab/menuband/Sources/MenuBand/MenuBandCDJRadio.swift b/slab/menuband/Sources/MenuBand/MenuBandCDJRadio.swift index 9e73663eab..2701d3fb6f 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandCDJRadio.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandCDJRadio.swift @@ -18,6 +18,117 @@ enum CDJRadioSource: Equatable { } } +/// Shared platter feel for the status-item CD. A point of pointer travel moves +/// the needle by a small, fixed amount of tape; velocity controls varispeed. +/// Keeping this pure makes the gesture curve regression-testable without an +/// audio device. +enum MenuBandCDJScratchGesture { + static let secondsPerPoint = 0.015 + + static func signedRate(forVelocity velocity: Double) -> Float { + guard velocity.isFinite, abs(velocity) > 0.01 else { return 0 } + let direction: Float = velocity < 0 ? -1 : 1 + let magnitude = max(0.16, min(4.0, abs(velocity) / 120.0)) + return direction * Float(magnitude) + } +} + +/// A tiny grain player fed from the active deck's rolling PCM ring. Each drag +/// event moves a virtual needle and replaces the currently sounding grain. +/// Reading the grain backwards gives a real reverse scratch; varispeed makes +/// faster hand motion sound and pitch faster like a physical platter. +final class MenuBandCDJScratchVoice { + private let player = AVAudioPlayerNode() + private let varispeed = AVAudioUnitVarispeed() + private weak var engine: AVAudioEngine? + private var attached = false + private var tape: AVAudioPCMBuffer? + private var needleFrame = 0 + + func attach(to engine: AVAudioEngine, output: AVAudioNode, + format: AVAudioFormat) { + guard !attached else { return } + self.engine = engine + engine.attach(player) + engine.attach(varispeed) + engine.connect(player, to: varispeed, format: format) + engine.connect(varispeed, to: output, format: format) + attached = true + } + + @discardableResult + func begin(with buffer: AVAudioPCMBuffer?) -> Bool { + end() + guard attached, let buffer, + buffer.frameLength > 256, + buffer.floatChannelData != nil else { return false } + tape = buffer + // Leave a little forward runway while beginning at the audible live + // edge. A rightward flick can therefore accelerate before it catches + // the newest captured frame. + let runway = Int(buffer.format.sampleRate * 0.18) + needleFrame = max(0, Int(buffer.frameLength) - 1 - runway) + return true + } + + func scrub(deltaPoints: Double, velocity: Double) { + guard let tape, + let source = tape.floatChannelData, + abs(deltaPoints) > 0.001 else { return } + let sampleRate = tape.format.sampleRate + let lastFrame = max(0, Int(tape.frameLength) - 1) + let movedFrames = Int( + deltaPoints * MenuBandCDJScratchGesture.secondsPerPoint * sampleRate) + needleFrame = max(0, min(lastFrame, needleFrame + movedFrames)) + + let signedRate = MenuBandCDJScratchGesture.signedRate( + forVelocity: velocity) + let direction = deltaPoints < 0 ? -1 : 1 + let rate = max(0.16, min(4.0, abs(signedRate))) + // Consume enough source for a roughly 85 ms output grain at the + // current hand speed. Frequent drag events replace this grain, so a + // held platter naturally stops instead of free-running underneath. + let requested = max(256, Int(sampleRate * 0.085 * Double(rate))) + let available = direction > 0 + ? lastFrame - needleFrame + 1 + : needleFrame + 1 + let frameCount = min(requested, available) + guard frameCount > 128, + let grain = AVAudioPCMBuffer( + pcmFormat: tape.format, + frameCapacity: AVAudioFrameCount(frameCount)), + let destination = grain.floatChannelData else { return } + grain.frameLength = AVAudioFrameCount(frameCount) + + let channels = Int(tape.format.channelCount) + let fadeFrames = min(frameCount / 2, max(1, Int(sampleRate * 0.003))) + for channel in 0..= frameCount - fadeFrames { + gain = Float(frameCount - 1 - frame) / Float(fadeFrames) + } + destination[channel][frame] = source[channel][sourceFrame] * gain + } + } + + varispeed.rate = rate + player.stop() + player.scheduleBuffer(grain, at: nil, options: .interrupts) + player.play() + } + + func end() { + player.stop() + varispeed.rate = 1 + tape = nil + needleFrame = 0 + } +} + /// Routes `juked`'s Core Audio process into Menu Band's AVAudioEngine. The /// process tap mutes juked's original output, then this player re-emits it on /// Menu Band's pre-FX bus so space, echo, proximity, compression, tape, and @@ -106,6 +217,15 @@ final class MenuBandCDJSpotifyDeck { mixer.outputVolume = enabled ? 1 : 0 } + /// Return from a platter scratch at the live edge. Stopping the player + /// drops any buffers accumulated while the live deck was gated; the next + /// process-tap buffer starts immediately at unity rate. + func resumeLiveOutput() { + player.stop() + timePitch.rate = 1 + mixer.outputVolume = 1 + } + /// 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? { @@ -240,7 +360,14 @@ final class MenuBandCDJStatusItem { private var artworkTask: URLSessionDataTask? private var representedArtworkURL: URL? private let side: CGFloat = 22 + private var mouseDownPoint: NSPoint? + private var lastDragPoint: NSPoint? + private var lastDragTimestamp: TimeInterval = 0 + private var scratching = false var onClick: (() -> Void)? + var onScratchBegin: (() -> Bool)? + var onScratch: ((_ deltaPoints: Double, _ velocity: Double) -> Void)? + var onScratchEnd: (() -> Void)? init() { statusItem = NSStatusBar.system.statusItem(withLength: 25) @@ -252,20 +379,25 @@ final class MenuBandCDJStatusItem { button.imagePosition = .imageOnly button.imageScaling = .scaleProportionallyDown button.target = self - button.action = #selector(clicked) - button.toolTip = "Menu Band CDJ Radio" + button.action = #selector(handlePlatterEvent) + button.sendAction(on: [.leftMouseDown, .leftMouseDragged, .leftMouseUp]) + button.toolTip = "Menu Band CDJ Radio · drag the disc to scratch" } setVisible(false) } func setVisible(_ visible: Bool) { statusItem.isVisible = visible - if !visible { setPlaying(false) } + if !visible { + finishScratch() + setPlaying(false) + } } func update(title: String, artworkURL: URL?, playing: Bool) { statusItem.button?.toolTip = title.isEmpty - ? "Menu Band CDJ Radio" : "CDJ Radio · \(title)" + ? "Menu Band CDJ Radio · drag the disc to scratch" + : "CDJ Radio · \(title) · drag to scratch" updateArtwork(artworkURL) setPlaying(playing) } @@ -279,6 +411,7 @@ final class MenuBandCDJStatusItem { RunLoop.main.add(timer, forMode: .common) self.timer = timer } else { + finishScratch() timer?.invalidate() timer = nil angle = 0 @@ -307,9 +440,54 @@ final class MenuBandCDJStatusItem { artworkTask?.resume() } - @objc private func clicked() { onClick?() } + @objc private func handlePlatterEvent() { + guard let event = NSApp.currentEvent, + let button = statusItem.button else { return } + let point = button.convert(event.locationInWindow, from: nil) + switch event.type { + case .leftMouseDown: + mouseDownPoint = point + lastDragPoint = point + lastDragTimestamp = event.timestamp + scratching = false + case .leftMouseDragged: + guard let down = mouseDownPoint, let previous = lastDragPoint else { return } + let totalDistance = hypot(point.x - down.x, point.y - down.y) + if !scratching, totalDistance >= 1.5 { + guard onScratchBegin?() == true else { return } + scratching = true + } + guard scratching else { return } + // Horizontal travel dominates, with a little vertical coupling so + // a circular finger motion around the tiny disc still feels live. + let delta = Double((point.x - previous.x) - (point.y - previous.y) * 0.35) + let elapsed = max(1.0 / 240.0, event.timestamp - lastDragTimestamp) + let velocity = delta / elapsed + angle -= CGFloat(delta * 7.5) + angle.formTruncatingRemainder(dividingBy: 360) + button.image = rotated(baseImage, by: angle) + onScratch?(delta, velocity) + lastDragPoint = point + lastDragTimestamp = event.timestamp + case .leftMouseUp: + let wasScratching = scratching + finishScratch() + if !wasScratching { onClick?() } + default: + break + } + } + + private func finishScratch() { + if scratching { onScratchEnd?() } + scratching = false + mouseDownPoint = nil + lastDragPoint = nil + lastDragTimestamp = 0 + } private func tick() { + guard !scratching else { return } angle -= 1.5 if angle <= -360 { angle += 360 } statusItem.button?.image = rotated(baseImage, by: angle) @@ -332,6 +510,7 @@ final class MenuBandCDJStatusItem { } deinit { + finishScratch() timer?.invalidate() artworkTask?.cancel() NSStatusBar.system.removeStatusItem(statusItem) diff --git a/slab/menuband/Sources/MenuBand/MenuBandController.swift b/slab/menuband/Sources/MenuBand/MenuBandController.swift index 59e45c74bf..b9c400c738 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandController.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandController.swift @@ -186,6 +186,7 @@ final class MenuBandController { private var spotifyCallbacksInstalled = false private(set) var cdjRadioPresented = false private(set) var cdjRadioSource: CDJRadioSource = .station(.kpbj) + private var cdjScratchSource: CDJRadioSource? private(set) var spotifyPlayback: MenuBandSpotifyPlayback? private(set) var spotifyStatus = "CDJ Radio" private(set) var spotifyStatusIsError = false @@ -1275,6 +1276,7 @@ final class MenuBandController { /// 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) { + endCDJScratch() if cdjRadioSource == .spotify { synth.silenceCDJSpotify() spotify.pause { [weak self] in @@ -1302,6 +1304,7 @@ final class MenuBandController { /// before any immediately-following search/play command, so this is safe /// to call from a single click on the source strip. func activateSpotifyPlayer() { + endCDJScratch() installSpotifyCallbacksIfNeeded() synth.stopCDJInternetRadio() cdjRadioSource = .spotify @@ -1319,6 +1322,7 @@ final class MenuBandController { } func deactivateCDJRadio() { + endCDJScratch() if cdjRadioSource == .spotify { synth.silenceCDJSpotify() spotify.pause { [weak self] in @@ -1348,6 +1352,25 @@ final class MenuBandController { spotify.seek(to: seconds, from: currentPosition) } + @discardableResult + func beginCDJScratch() -> Bool { + guard cdjRadioPresented, cdjScratchSource == nil, + synth.beginCDJScratch(source: cdjRadioSource) else { return false } + cdjScratchSource = cdjRadioSource + return true + } + + func scratchCDJ(deltaPoints: Double, velocity: Double) { + guard cdjScratchSource != nil else { return } + synth.scratchCDJ(deltaPoints: deltaPoints, velocity: velocity) + } + + func endCDJScratch() { + guard let source = cdjScratchSource else { return } + cdjScratchSource = nil + synth.endCDJScratch(source: source) + } + func searchSpotify( _ query: String, completion: @escaping (Result<[MenuBandSpotifyTrack], Error>) -> Void diff --git a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift index 456adf5b39..70f46e1875 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift @@ -40,6 +40,9 @@ final class MenuBandSynth { /// Headless Spotify audio re-emitted inside Menu Band's pre-FX graph for /// the standalone Spotify side of CDJ Radio. private let spotifyDeck = MenuBandCDJSpotifyDeck() + /// Gesture-driven grain voice for the little spinning status-item disc. + /// Fed only from the active deck's ring while the pointer is held. + private let cdjScratch = MenuBandCDJScratchVoice() /// Microphone-sampled "voice": user holds backtick to record a clip, /// then plays it back as a duration-preserving pitch-shifted piano /// voice (TimePitch). Same @@ -402,6 +405,14 @@ final class MenuBandSynth { // listening source is selected. radio.attach(to: engine, output: preLimiterMixer) spotifyDeck.attach(to: engine, output: preLimiterMixer) + cdjScratch.attach( + to: engine, + output: preLimiterMixer, + format: AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: 44_100, + channels: 2, + interleaved: false)!) // 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. @@ -1711,6 +1722,43 @@ final class MenuBandSynth { func stopCDJSpotify() { spotifyDeck.stop() } func silenceCDJSpotify() { spotifyDeck.setOutputEnabled(false) } + /// Lift the live deck off the bus and put the gesture grain voice on its + /// rolling audio. Returns false until enough current-CD audio exists. + @discardableResult + func beginCDJScratch(source: CDJRadioSource) -> Bool { + let audio: AVAudioPCMBuffer? + switch source { + case .station: + audio = radio.copyRecentAudio(seconds: 8) + case .spotify: + audio = spotifyDeck.copyRecentAudio(seconds: 8) + } + guard cdjScratch.begin(with: audio) else { return false } + switch source { + case .station: + radio.setOutputEnabled(false) + case .spotify: + spotifyDeck.setOutputEnabled(false) + } + return true + } + + func scratchCDJ(deltaPoints: Double, velocity: Double) { + cdjScratch.scrub(deltaPoints: deltaPoints, velocity: velocity) + } + + /// Drop the scratch grain, restore the live edge, and explicitly return + /// every rate control to unity. This is the platter's mouse-up snap. + func endCDJScratch(source: CDJRadioSource) { + cdjScratch.end() + switch source { + case .station: + radio.setOutputEnabled(true) + case .spotify: + spotifyDeck.resumeLiveOutput() + } + } + /// Copy a short rolling slice into the global Piano Sampler. The caller /// switches the piano backend only after a successful import. @discardableResult diff --git a/slab/menuband/Sources/MenuBand/Shapedown.swift b/slab/menuband/Sources/MenuBand/Shapedown.swift index 4adbe4ccb2..67b2c214e2 100644 --- a/slab/menuband/Sources/MenuBand/Shapedown.swift +++ b/slab/menuband/Sources/MenuBand/Shapedown.swift @@ -486,6 +486,7 @@ final class ShapedownDisplayBufferView: NSView { private let displayLayer = AVSampleBufferDisplayLayer() private var nudging = false private var hasFrame = false + private var pixelScale: CGFloat = 1 override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -510,6 +511,11 @@ final class ShapedownDisplayBufferView: NSView { CATransaction.commit() } + func configure(pixelScale: CGFloat) { + self.pixelScale = max(1, pixelScale) + displayLayer.contentsScale = self.pixelScale + } + /// Frames arrive on the main callback queue and go straight from the /// IOSurface-backed CMSampleBuffer to the compositor without a CPU copy. func enqueue(_ sampleBuffer: CMSampleBuffer) { @@ -541,9 +547,14 @@ final class ShapedownDisplayBufferView: NSView { func translate(x: CGFloat, y: CGFloat) { guard nudging, hasFrame else { return } + // Fractional backing-pixel transforms make the entire captured display + // soft. Land the surface on exact pixels so menu text and glyph edges + // stay as sharp while moving as they are in the live framebuffer. + let crispX = (x * pixelScale).rounded() / pixelScale + let crispY = (y * pixelScale).rounded() / pixelScale CATransaction.begin() CATransaction.setDisableActions(true) - displayLayer.setAffineTransform(CGAffineTransform(translationX: x, y: y)) + displayLayer.setAffineTransform(CGAffineTransform(translationX: crispX, y: crispY)) CATransaction.commit() } @@ -598,12 +609,19 @@ final class ShapedownDisplayStream: NSObject, SCStreamOutput, SCStreamDelegate { } let config = SCStreamConfiguration() - config.width = display.width - config.height = display.height + // SCDisplay dimensions are POINTS; SCStreamConfiguration takes + // PIXELS. Passing the point size here produced a half-resolution + // stream on Retina displays and then enlarged it over the screen. + let pixelScale = screen.backingScaleFactor + config.width = Int(CGFloat(display.width) * pixelScale) + config.height = Int(CGFloat(display.height) * pixelScale) config.pixelFormat = kCVPixelFormatType_32BGRA config.minimumFrameInterval = CMTime(value: 1, timescale: 60) config.queueDepth = 3 config.showsCursor = false + await MainActor.run { [weak self] in + self?.surface?.configure(pixelScale: pixelScale) + } let stream = SCStream(filter: filter, configuration: config, delegate: self) try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: .main) diff --git a/slab/menuband/Tests/MenuBandTests/CDJRadioTests.swift b/slab/menuband/Tests/MenuBandTests/CDJRadioTests.swift index 6c93c8e5b8..9ef3cd99d4 100644 --- a/slab/menuband/Tests/MenuBandTests/CDJRadioTests.swift +++ b/slab/menuband/Tests/MenuBandTests/CDJRadioTests.swift @@ -40,4 +40,14 @@ final class CDJRadioTests: XCTestCase { XCTAssertFalse(sampler.loadRecording(from: buffer)) XCTAssertFalse(sampler.hasRecording) } + + func testScratchGestureTracksHandDirectionAndClampsRate() { + XCTAssertEqual(MenuBandCDJScratchGesture.signedRate(forVelocity: 0), 0) + XCTAssertEqual(MenuBandCDJScratchGesture.signedRate(forVelocity: 12), 0.16) + XCTAssertEqual(MenuBandCDJScratchGesture.signedRate(forVelocity: -12), -0.16) + XCTAssertEqual(MenuBandCDJScratchGesture.signedRate(forVelocity: 120), 1) + XCTAssertEqual(MenuBandCDJScratchGesture.signedRate(forVelocity: -120), -1) + XCTAssertEqual(MenuBandCDJScratchGesture.signedRate(forVelocity: 2_000), 4) + XCTAssertEqual(MenuBandCDJScratchGesture.signedRate(forVelocity: -2_000), -4) + } }