Skip to content

Instantly share code, notes, and snippets.

@voxels
Last active November 14, 2025 14:01
Show Gist options
  • Select an option

  • Save voxels/16fc3430b45b09baaed53537a4d28fbe to your computer and use it in GitHub Desktop.

Select an option

Save voxels/16fc3430b45b09baaed53537a4d28fbe to your computer and use it in GitHub Desktop.
CDJ 3000 midi manager actor
import Foundation
import CoreMIDI
import Combine
// MARK: - MIDI Command Mapping
/// A namespace for mapping CDJ-3000 MIDI commands from the official PDF.
/// We focus on MIDI 1.0 Channel Voice messages (Note On/Off, Control Change).
/// "n" (e.g., 9n, Bn) corresponds to the MIDI channel (0-15).
private enum CDJ3000Commands {
/// Status 9n: Note On (Data 2 = Velocity)
/// A Note On with Velocity 0 is a Note Off.
enum NoteOn: UInt8 {
case playPause = 0x00 // 7F:ON, 00:OFF
case cue = 0x01 // 7F:ON, 00:OFF
case searchFwd = 0x02 // 7F:ON, 00:OFF
case searchRev = 0x03 // 7F:ON, 00:OFF
case trackSearchFwd = 0x04 // 7F:ON, 00:OFF
case trackSearchRev = 0x05 // 7F:ON, 00:OFF
case quantize = 0x06 // 7F:ON, 00:OFF
case slip = 0x07 // 7F:ON, 00:OFF
case tempoReset = 0x09 // 7F:ON, 00:OFF
case tempoRange = 0x0A // 7F:ON, 00:OFF
case masterTempo = 0x0B // 7F:ON, 00:OFF
case jogMode = 0x0C // 7F:ON, 00:OFF
case memory = 0x0D // 7F:ON, 00:OFF
case beatJumpFwd = 0x15 // 7F:ON, 00:OFF
case sync = 0x16 // 7F:ON, 00:OFF
case jogTouch = 0x17 // 7F:ON(Touch), 00:OFF(Release)
case slipReverse = 0x18 // 7F:ON, 00:OFF
case reverse = 0x19 // 7F:ON, 00:OFF
case hotCueA = 0x1B // 7F:ON, 00:OFF
case hotCueB = 0x1C // 7F:ON, 00:OFF
case hotCueC = 0x1D // 7F:ON, 00:OFF
case hotCueD = 0x1E // 7F:ON, 00:OFF
case hotCueE = 0x1F // 7F:ON, 00:OFF
case hotCueF = 0x20 // 7F:ON, 00:OFF
case hotCueG = 0x21 // 7F:ON, 00:OFF
case hotCueH = 0x22 // 7F:ON, 00:OFF
case loopIn = 0x23 // 7F:ON, 00:OFF
case loopOut = 0x24 // 7F:ON, 00:OFF
case reloopExit = 0x25 // 7F:ON, 00:OFF
case beatJumpRev = 0x36 // 7F:ON, 00:OFF
case master = 0x48 // 7F:ON, 00:OFF
case fourBeatLoop = 0x54 // 7F:ON, 00:OFF
case eightBeatLoop = 0x55 // 7F:ON, 00:OFF
}
/// Status Bn: Control Change (Data 2 = Value)
enum ControlChange: UInt8 {
case jogRotation = 0x10 // 64=Stop, 65-127=Fwd, 63-0=Rev
case needlePosition = 0x1C // 0-127 position
case tempoSlider = 0x1D // 0-127 position
case jogAdjust = 0x30 // 0-127 value
case browserRotate = 0x4F // 1-30=CW, 98-127=CCW
}
}
// MARK: - Manager Actor
/// An actor to manage all Core MIDI interactions for a single CDJ-3000.
/// It creates a virtual MIDI destination, listens for incoming packets,
/// parses them, and publishes a clean, `Sendable` state.
public actor CDJ3000Manager {
/// The current, comprehensive state of the CDJ-3000.
public private(set) var state: CDJ3000State
/// An `AsyncStream` that publishes state updates.
/// UI components can subscribe to this stream to receive updates.
public let stateStream: AsyncStream<CDJ3000State>
private let stateContinuation: AsyncStream<CDJ3000State>.Continuation
// --- OLD ---
// private let midiAdapter = MIDIAdapter(logging: true)
// private var destination = MIDIEndpointRef()
// private var pollTimer: Timer?
// --- NEW: Network MIDI Receiver ---
private var client = MIDIClientRef()
private var inPort = MIDIPortRef()
// --- End NEW ---
/// The MIDI channel (0-15) this manager is listening on.
private let midiChannel: UInt8
/// Initializes the manager for a specific deck and MIDI channel.
/// - Parameters:
/// - deckID: A unique identifier for this deck (e.g., 1, 2, 3, 4).
/// - midiChannel: The MIDI channel (1-16) the CDJ is set to.
/// - destinationName: The name of the virtual MIDI port to create.
public init(deckID: Int, midiChannel: Int = 1, destinationName: String) {
self.state = CDJ3000State(deckID: deckID)
// MIDI channels are 1-16 in UI, but 0-15 in the protocol.
self.midiChannel = UInt8(max(0, min(15, midiChannel - 1)))
// Setup the async stream for state publishing
(self.stateStream, self.stateContinuation) = AsyncStream<CDJ3000State>.makeStream()
// --- OLD ---
// setupMIDIClient(destinationName: destinationName)
// --- NEW ---
setupMIDI()
connectToNetworkSources()
// --- End NEW ---
}
deinit {
// --- OLD ---
// stopListening()
// if destination != 0 {
// MIDIEndpointDispose(destination)
// }
// --- NEW ---
if inPort != 0 {
MIDIPortDispose(inPort)
}
// --- End NEW ---
if client != 0 {
MIDIClientDispose(client)
}
}
// --- NEW: Replaced setupMIDIClient and createMIDIDestination ---
/// Creates the Core MIDI client and input port.
private func setupMIDI() {
var status = MIDIClientCreate("com.daw.cdj3000-manager-\(deckID)" as CFString, nil, nil, &client)
if status != noErr {
print("Failed to create MIDI client: \(status)")
return
}
status = MIDIInputPortCreateWithBlock(client, "DAW-CDJ-InPort-\(deckID)" as CFString, &inPort) { packetListPtr, _ in
self.handle(packetListPtr: packetListPtr)
}
if status != noErr {
print("Failed to create MIDI input port: \(status)")
}
}
/// Connects our input port to all available network sources.
private func connectToNetworkSources() {
let sourceCount = MIDIGetNumberOfSources()
print("Manager \(deckID): Found \(sourceCount) MIDI sources.")
for i in 0..<sourceCount {
let src = MIDIGetSource(i)
if src == 0 { continue }
var name: Unmanaged<CFString>?
MIDIObjectGetStringProperty(src, kMIDIPropertyName, &name)
guard let cfName = name?.takeRetainedValue() else { continue }
let n = cfName as String
// Heuristic: attach to any "Network …" or "Session …" source
if n.contains("Network") || n.contains("Session") {
let status = MIDIPortConnectSource(inPort, src, nil)
if status == noErr {
print("Manager \(deckID): Successfully connected to network source: \(n)")
} else {
print("Manager \(deckID): Failed to connect to source \(n). Error: \(status)")
}
}
}
}
/// Handles incoming MIDI 1.0 Packet Lists from the network.
private func handle(packetListPtr: UnsafePointer<MIDIPacketList>) {
let packetList = packetListPtr.pointee
var packetPtr: UnsafeMutablePointer<MIDIPacket> = UnsafeMutablePointer(mutating: &packetList.packet)
var packetsProcessed = 0
for _ in 0..<packetList.numPackets {
let packet = packetPtr.pointee
let length = Int(packet.length)
// Copy packet data into a Swift array
let data = withUnsafeBytes(of: packet.data) { rawBuffer in
Array(rawBuffer.prefix(length))
}
if data.count >= 3 {
parseAndRoute(bytes: data)
packetsProcessed += 1
}
packetPtr = MIDIPacketNext(packetPtr)
}
if packetsProcessed > 0 {
publishStateUpdate()
}
}
// --- End NEW ---
// --- REMOVED `startListening()`, `stopListening()`, `pollMIDIMessages()` ---
// (No longer needed, CoreMIDI now pushes events to us)
/// Parses a single `MIDIEventPacket` and updates the internal state.
// --- OLD ---
// private func parseAndRoute(_ packet: MIDIEventPacket) {
// ...
// let words = packet.words
// ...
// }
// --- NEW: Parses raw MIDI 1.0 bytes ---
private func parseAndRoute(bytes: [UInt8]) {
// We only care about MIDI 1.0 Channel Voice messages
guard bytes.count >= 3 else { return }
// Extract MIDI 1.0 message components
let statusByte = bytes[0]
let statusType = (statusByte >> 4) & 0xF // 0x9 = Note On, 0x8 = Note Off, 0xB = CC
let channel = statusByte & 0xF
// Filter for this manager's specific MIDI channel
guard channel == self.midiChannel else { return }
let data1 = bytes[1]
let data2 = bytes[2]
// Convert to our standard ButtonState
let buttonState: ButtonState = (data2 > 0) ? .on : .off
let value = UInt8(data2)
switch statusType {
case 0x9: // Note On
guard let command = CDJ3000Commands.NoteOn(rawValue: data1) else { return }
handleNoteOn(command: command, state: buttonState)
case 0x8: // Note Off (Treat as Note On w/ vel 0)
guard let command = CDJ3000Commands.NoteOn(rawValue: data1) else { return }
handleNoteOn(command: command, state: .off)
case 0xB: // Control Change (CC)
guard let command = CDJ3000Commands.ControlChange(rawValue: data1) else { return }
handleControlChange(command: command, value: value)
default:
break // Ignore other message types (Program Change, etc.)
}
}
// --- End NEW ---
/// Handles all Note On/Off state updates.
private func handleNoteOn(command: CDJ3000Commands.NoteOn, state: ButtonState) {
switch command {
case .playPause:
self.state.playState = (state == .on) ? .playing : .paused
case .cue:
// Cue is momentary, but we can model it if needed.
// For now, we'll just log its state.
break
case .jogTouch:
self.state.jogWheel.isTouched = (state == .on)
// --- Hot Cues ---
case .hotCueA: self.state.hotCues[0] = state
case .hotCueB: self.state.hotCues[1] = state
case .hotCueC: self.state.hotCues[2] = state
case .hotCueD: self.state.hotCues[3] = state
case .hotCueE: self.state.hotCues[4] = state
case .hotCueF: self.state.hotCues[5] = state
case .hotCueG: self.state.hotCues[6] = state
case .hotCueH: self.state.hotCues[7] = state
// --- Toggles ---
case .quantize: self.state.quantizeState = state
case .slip: self.state.slipState = state
case .masterTempo: self.state.tempo.isMasterTempo = state
case .sync: self.state.syncState = state
case .master: self.state.masterState = state
// --- Loop ---
case .loopIn: self.state.loop.inPoint = state
case .loopOut: self.state.loop.outPoint = state
case .reloopExit: self.state.loop.reloopExit = state
case .fourBeatLoop: self.state.loop.fourBeat = state
case .eightBeatLoop: self.state.loop.eightBeat = state
// --- Other ---
case .jogMode:
// This is a toggle, so we flip the state on press
if state == .on {
self.state.jogMode = (self.state.jogMode == .vinyl) ? .cdj : .vinyl
}
case .reverse:
self.state.reverseState = (state == .on) ? .on : .off
case .slipReverse:
self.state.reverseState = (state == .on) ? .slipReverse : .off
case .beatJumpFwd:
self.state.beatJump.lastAction = (state == .on) ? .forward : .none
case .beatJumpRev:
self.state.beatJump.lastAction = (state == .on) ? .backward : .none
case .tempoRange:
// This cycles on press
if state == .on {
switch self.state.tempo.range {
case .six: self.state.tempo.range = .ten
case .ten: self.state.tempo.range = .sixteen
case .sixteen: self.state.tempo.range = .wide
case .wide: self.state.tempo.range = .six
}
}
default:
// Unhandled note, e.g., Search, Track Search, Memory
break
}
}
/// Handles all Control Change state updates.
private func handleControlChange(command: CDJ3000Commands.ControlChange, value: UInt8) {
switch command {
case .jogRotation:
// 64 = stop, 65-127 = fwd, 63-0 = rev
if value == 64 {
self.state.jogWheel.rotation = .stopped
} else if value > 64 {
// Normalize 65-127 to 0.0-1.0
let speed = Double(value - 65) / 62.0
self.state.jogWheel.rotation = .forward(speed: speed)
} else {
// Normalize 63-0 to 0.0-1.0
let speed = Double(63 - value) / 63.0
self.state.jogWheel.rotation = .reverse(speed: speed)
}
case .tempoSlider:
// Normalize 0-127 to 0.0-1.0
self.state.tempo.sliderValue = Double(value) / 127.0
case .needlePosition:
// Normalize 0-127 to 0.0-1.0
self.state.needlePosition = Double(value) / 127.0
case .browserRotate:
// 1-30=CW, 98-127=CCW
if value >= 1 && value <= 30 {
self.state.browser.lastRotation = .clockwise(value: Int(value))
} else if value >= 98 && value <= 127 {
self.state.browser.lastRotation = .counterClockwise(value: Int(value))
} else {
self.state.browser.lastRotation = .none
}
default:
// Unhandled CC, e.g., Jog Adjust
break
}
}
/// Publishes the current state to the async stream for UI consumption.
private func publishStateUpdate() {
stateContinuation.yield(state)
// Reset momentary states
if self.state.browser.lastRotation != .none {
self.state.browser.lastRotation = .none
}
if self.state.beatJump.lastAction != .none {
self.state.beatJump.lastAction = .none
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment