Last active
November 14, 2025 14:01
-
-
Save voxels/16fc3430b45b09baaed53537a4d28fbe to your computer and use it in GitHub Desktop.
CDJ 3000 midi manager actor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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