Last active
November 14, 2025 13:55
-
-
Save voxels/e9cd23e8b28a98c15783c8e6c09f4843 to your computer and use it in GitHub Desktop.
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 | |
| // Note: This file assumes the `MIDIAdapter.h` and `MIDIAdapter.mm` files | |
| // from your project are available and included in the build target. | |
| // It also assumes Core MIDI definitions like MIDIMessage_32 are available. | |
| // --- UPDATE: We are removing the MIDIAdapter dependency for this class --- | |
| // Define a simple Deck ID for clarity | |
| public enum DeckID: Sendable { | |
| case A, B | |
| } | |
| /// An actor to manage sending MIDI 1.0 UMP messages to Traktor. | |
| // --- UPDATE: Now sends MIDI 1.0 Packet Lists --- | |
| public actor TraktorMidiSender { | |
| private let mapping: TraktorMapping | |
| // --- OLD --- | |
| // private let midiAdapter: MIDIAdapter | |
| private var client = MIDIClientRef() | |
| private var port = MIDIPortRef() | |
| private var destinationEndpoint = MIDIEndpointRef() | |
| /// Initializes the sender with a specific mapping and MIDI destination name. | |
| /// - Parameters: | |
| /// - mapping: The `TraktorMapping` struct defining what to send. | |
| /// - destinationName: The name of the virtual MIDI port Traktor is listening to. | |
| /// (e.g., "Traktor Virtual Input"). | |
| public init(mapping: TraktorMapping, destinationName: String) { | |
| self.mapping = mapping | |
| // --- OLD --- | |
| // self.midiAdapter = MIDIAdapter(logging: false) | |
| setupMIDI(destinationName: destinationName) | |
| } | |
| /// Creates the Core MIDI client and output port. | |
| private func setupMIDI(destinationName: String) { | |
| var status = MIDIClientCreate("com.daw.traktor-sender" as CFString, nil, nil, &client) | |
| if status != noErr { | |
| print("Failed to create MIDI client: \(status)") | |
| return | |
| } | |
| status = MIDIOutputPortCreate(client, "DAW-Traktor-Port" as CFString, &port) | |
| if status != noErr { | |
| print("Failed to create MIDI output port: \(status)") | |
| return | |
| } | |
| findDestination(named: destinationName) | |
| } | |
| /// Scans the system for a MIDI destination matching the provided name. | |
| private func findDestination(named: String) { | |
| let count = MIDIGetNumberOfDestinations() | |
| print("Found \(count) MIDI destinations.") | |
| for i in 0..<count { | |
| let endpoint = MIDIGetDestination(i) | |
| if endpoint != 0 { | |
| var prop: Unmanaged<CFString>? | |
| if MIDIObjectGetStringProperty(endpoint, kMIDIPropertyDisplayName, &prop) == noErr { | |
| if let name = prop?.takeRetainedValue() as String? { | |
| print("Checking destination: \(name)") | |
| // --- NEW: Look for Network Session --- | |
| if name.contains("Network") || name.contains("Session") || name.contains(named) { | |
| print("Found target destination: \(name)") | |
| // --- End NEW --- | |
| self.destinationEndpoint = endpoint | |
| return | |
| } | |
| } | |
| } | |
| } | |
| } | |
| print("Warning: Could not find MIDI destination named '\(named)'. Messages will not be sent.") | |
| } | |
| // MARK: - Private MIDI Message Builders | |
| /// Constructs a 32-bit UMP for a MIDI 1.0 Note On message. | |
| // --- OLD --- | |
| // private func buildNoteOn(group: UInt8, channel: UInt8, note: UInt8, velocity: UInt8) -> MIDIMessage_32 { | |
| // ... | |
| // } | |
| // --- NEW: Returns raw bytes --- | |
| private func buildNoteOn(channel: UInt8, note: UInt8, velocity: UInt8) -> [UInt8] { | |
| let status = (0x9 << 4) | (channel & 0x0F) // Note On status is 0x9n | |
| return [status, note, velocity] | |
| } | |
| // --- End NEW --- | |
| /// Constructs a 32-bit UMP for a MIDI 1.0 Control Change message. | |
| // --- OLD --- | |
| // private func buildControlChange(group: UInt8, channel: UInt8, controller: UInt8, value: UInt8) -> MIDIMessage_32 { | |
| // ... | |
| // } | |
| // --- NEW: Returns raw bytes --- | |
| private func buildControlChange(channel: UInt8, controller: UInt8, value: UInt8) -> [UInt8] { | |
| let status = (0xB << 4) | (channel & 0x0F) // CC status is 0xBn | |
| return [status, controller, value] | |
| } | |
| // --- End NEW --- | |
| /// Sends a 32-bit MIDI 1.0 UMP message. | |
| // --- OLD --- | |
| // private func send(message: MIDIMessage_32) { | |
| // guard destinationEndpoint != 0 else { | |
| // // print("Cannot send MIDI: No destination.") | |
| // return | |
| // } | |
| // midiAdapter.sendMIDI1UPMessage(message, port: port, destination: destinationEndpoint) | |
| // } | |
| // --- NEW: Sends a MIDI 1.0 Packet List, based on user's example --- | |
| private func send(bytes: [UInt8]) { | |
| guard destinationEndpoint != 0 else { return } | |
| // Allocate a buffer for the MIDIPacketList. | |
| // 1024 bytes is ample for a single 3-byte message. | |
| let bufferSize = 1024 | |
| var packetListBuffer = [UInt8](repeating: 0, count: bufferSize) | |
| packetListBuffer.withUnsafeMutableBufferPointer { ptr in | |
| guard let baseAddress = ptr.baseAddress else { return } | |
| var packetList = UnsafeMutablePointer<MIDIPacketList>(OpaquePointer(baseAddress)) | |
| // Initialize the packet list | |
| var currentPacket = MIDIPacketListInit(packetList) | |
| // Add our packet to the list | |
| currentPacket = MIDIPacketListAdd(packetList, | |
| bufferSize, | |
| currentPacket, | |
| 0, // timestamp (send immediately) | |
| bytes.count, | |
| bytes) | |
| // Send the packet list | |
| MIDISend(port, destinationEndpoint, packetList) | |
| } | |
| } | |
| // --- End NEW --- | |
| // MARK: - Public Control Methods | |
| // --- Transport --- | |
| /// Toggles Play/Pause for a deck. | |
| public func play(deck: DeckID, state: Bool) { | |
| let map = (deck == .A) ? mapping.deckA.play : mapping.deckB.play | |
| let velocity: UInt8 = state ? 127 : 0 | |
| let bytes = buildNoteOn(channel: map.channel, note: map.note, velocity: velocity) | |
| send(bytes: bytes) | |
| } | |
| /// Toggles Sync for a deck. | |
| public func sync(deck: DeckID, state: Bool) { | |
| let map = (deck == .A) ? mapping.deckA.sync : mapping.deckB.sync | |
| let velocity: UInt8 = state ? 127 : 0 | |
| let bytes = buildNoteOn(channel: map.channel, note: map.note, velocity: velocity) | |
| send(bytes: bytes) | |
| } | |
| /// Toggles the main Cue button state. | |
| public func cue(deck: DeckID, state: Bool) { | |
| let map = (deck == .A) ? mapping.deckA.cue : mapping.deckB.cue | |
| let velocity: UInt8 = state ? 127 : 0 | |
| let bytes = buildNoteOn(channel: map.channel, note: map.note, velocity: velocity) | |
| send(bytes: bytes) | |
| } | |
| /// Toggles the Reverse state. | |
| public func reverse(deck: DeckID, state: Bool) { | |
| let map = (deck == .A) ? mapping.deckA.reverse : mapping.deckB.reverse | |
| let velocity: UInt8 = state ? 127 : 0 | |
| let bytes = buildNoteOn(channel: map.channel, note: map.note, velocity: velocity) | |
| send(bytes: bytes) | |
| } | |
| /// Toggles Sync for a deck. | |
| public func sync(deck: DeckID, state: Bool) { | |
| let map = (deck == .A) ? mapping.deckA.sync : mapping.deckB.sync | |
| let velocity: UInt8 = state ? 127 : 0 | |
| let message = buildNoteOn(group: 0, channel: map.channel, note: map.note, velocity: velocity) | |
| send(message) | |
| } | |
| /// Toggles the main Cue button state. | |
| public func cue(deck: DeckID, state: Bool) { | |
| let map = (deck == .A) ? mapping.deckA.cue : mapping.deckB.cue | |
| let velocity: UInt8 = state ? 127 : 0 | |
| let message = buildNoteOn(group: 0, channel: map.channel, note: map.note, velocity: velocity) | |
| send(message) | |
| } | |
| /// Toggles the Reverse state. | |
| public func reverse(deck: DeckID, state: Bool) { | |
| let map = (deck == .A) ? mapping.deckA.reverse : mapping.deckB.reverse | |
| let velocity: UInt8 = state ? 127 : 0 | |
| let message = buildNoteOn(group: 0, channel: map.channel, note: map.note, velocity: velocity) | |
| send(message) | |
| } | |
| // --- Jog Wheel --- | |
| /// Toggles the Jog Touch (Scratch) state. | |
| public func jogTouch(deck: DeckID, state: Bool) { | |
| let map = (deck == .A) ? mapping.deckA.jogTouch : mapping.deckB.jogTouch | |
| let velocity: UInt8 = state ? 127 : 0 | |
| let bytes = buildNoteOn(channel: map.channel, note: map.note, velocity: velocity) | |
| send(bytes: bytes) | |
| } | |
| /// Sends a jog wheel turn message. | |
| /// This uses a relative value (65 for forward, 63 for backward). | |
| public func jogTurn(deck: DeckID, delta: Int) { | |
| let map = (deck == .A) ? mapping.deckA.jogTurn : mapping.deckB.jogTurn | |
| var ccValue: UInt8 = 64 // 64 = no change | |
| if delta > 0 { | |
| ccValue = 65 // Relative increase | |
| } else if delta < 0 { | |
| ccValue = 63 // Relative decrease | |
| } | |
| let bytes = buildControlChange(channel: map.channel, controller: map.controller, value: ccValue) | |
| send(bytes: bytes) | |
| } | |
| // --- Mixer --- | |
| /// Sets the volume for a deck (0.0 - 1.0). | |
| public func setVolume(deck: DeckID, value: Double) { | |
| let map = (deck == .A) ? mapping.deckA.volume : mapping.deckB.volume | |
| let ccValue = UInt8(max(0.0, min(1.0, value)) * 127.0) // Normalize to 0-127 | |
| let bytes = buildControlChange(channel: map.channel, controller: map.controller, value: ccValue) | |
| send(bytes: bytes) | |
| } | |
| /// Sets the tempo for a deck (0.0 - 1.0). | |
| public func setTempo(deck: DeckID, value: Double) { | |
| let map = (deck == .A) ? mapping.deckA.tempo : mapping.deckB.tempo | |
| let ccValue = UInt8(max(0.0, min(1.0, value)) * 127.0) // Normalize to 0-127 | |
| let bytes = buildControlChange(channel: map.channel, controller: map.controller, value: ccValue) | |
| send(bytes: bytes) | |
| } | |
| /// Sets the crossfader position (0.0 = A, 1.0 = B). | |
| public func setCrossfader(value: Double) { | |
| let map = mapping.crossfader | |
| let ccValue = UInt8(max(0.0, min(1.0, value)) * 127.0) // Normalize to 0-127 | |
| let bytes = buildControlChange(channel: map.channel, controller: map.controller, value: ccValue) | |
| send(bytes: bytes) | |
| } | |
| // --- Cues & Loops --- | |
| /// Sets a Hot Cue state (0-7 for A-H). | |
| public func setHotCue(deck: DeckID, cue: Int, state: Bool) { | |
| let deckMap = (deck == .A) ? mapping.deckA : mapping.deckB | |
| var map: MidiNoteMapping? | |
| switch cue { | |
| case 0: map = deckMap.hotCueA | |
| case 1: map = deckMap.hotCueB | |
| case 2: map = deckMap.hotCueC | |
| case 3: map = deckMap.hotCueD | |
| case 4: map = deckMap.hotCueE | |
| case 5: map = deckMap.hotCueF | |
| case 6: map = deckMap.hotCueG | |
| case 7: map = deckMap.hotCueH | |
| default: break | |
| } | |
| guard let cueMap = map else { return } | |
| let velocity: UInt8 = state ? 127 : 0 | |
| let bytes = buildNoteOn(channel: cueMap.channel, note: cueMap.note, velocity: velocity) | |
| send(bytes: bytes) | |
| } | |
| public func loopIn(deck: DeckID, state: Bool) { | |
| let map = (deck == .A) ? mapping.deckA.loopIn : mapping.deckB.loopIn | |
| let velocity: UInt8 = state ? 127 : 0 | |
| let bytes = buildNoteOn(channel: map.channel, note: map.note, velocity: velocity) | |
| send(bytes: bytes) | |
| } | |
| public func loopOut(deck: DeckID, state: Bool) { | |
| let map = (deck == .A) ? mapping.deckA.loopOut : mapping.deckB.loopOut | |
| let velocity: UInt8 = state ? 127 : 0 | |
| let bytes = buildNoteOn(channel: map.channel, note: map.note, velocity: velocity) | |
| send(bytes: bytes) | |
| } | |
| public func reloopExit(deck: DeckID, state: Bool) { | |
| let map = (deck == .A) ? mapping.deckA.reloopExit : mapping.deckB.reloopExit | |
| let velocity: UInt8 = state ? 127 : 0 | |
| let bytes = buildNoteOn(channel: map.channel, note: map.note, velocity: velocity) | |
| send(bytes: bytes) | |
| } | |
| public func loop4Beat(deck: DeckID, state: Bool) { | |
| let map = (deck == .A) ? mapping.deckA.loop4Beat : mapping.deckB.loop4Beat | |
| let velocity: UInt8 = state ? 127 : 0 | |
| let bytes = buildNoteOn(channel: map.channel, note: map.note, velocity: velocity) | |
| send(bytes: bytes) | |
| } | |
| public func loop8Beat(deck: DeckID, state: Bool) { | |
| let map = (deck == .A) ? mapping.deckA.loop8Beat : mapping.deckB.loop8Beat | |
| let velocity: UInt8 = state ? 127 : 0 | |
| let bytes = buildNoteOn(channel: map.channel, note: map.note, velocity: velocity) | |
| send(bytes: bytes) | |
| } | |
| // --- Beat Jump & Browser --- | |
| public func beatJump(deck: DeckID, direction: Int) { | |
| let map: MidiNoteMapping | |
| if direction > 0 { | |
| map = (deck == .A) ? mapping.deckA.beatJumpFwd : mapping.deckB.beatJumpFwd | |
| } else { | |
| map = (deck == .A) ? mapping.deckA.beatJumpRev : mapping.deckB.beatJumpRev | |
| } | |
| // Beat Jump is a momentary press (Note On, then Note Off) | |
| let bytesOn = buildNoteOn(channel: map.channel, note: map.note, velocity: 127) | |
| let bytesOff = buildNoteOn(channel: map.channel, note: map.note, velocity: 0) | |
| send(bytes: bytesOn) | |
| send(bytes: bytesOff) | |
| } | |
| public func browserRotate(delta: Int) { | |
| let map = mapping.browserRotate | |
| var ccValue: UInt8 = 64 // 64 = no change | |
| if delta > 0 { | |
| ccValue = 65 // Relative increase | |
| } else if delta < 0 { | |
| ccValue = 63 // Relative decrease | |
| } | |
| let bytes = buildControlChange(channel: map.channel, controller: map.controller, value: ccValue) | |
| send(bytes: bytes) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment