Skip to content

Commit e7cc4e0

Browse files
authored
MIDI controller support (#26)
* Update Swift PM * Split out KeyboardController from BardController * Split out ToneSampler * Add basic MIDIController * Add split out files * Add playMode to midiController * Add notification when MIDI device is plugged in * Update README * Update CHANGELOG * Update copy
1 parent 6c96020 commit e7cc4e0

File tree

13 files changed

+339
-161
lines changed

13 files changed

+339
-161
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Added
6+
7+
- Add MIDI controller support
8+
59
## 1.2.0
610

711
### Added

GarageBard.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
5EA3896B27D2663600C4C2D3 /* GarageBardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA3896A27D2663600C4C2D3 /* GarageBardTests.swift */; };
3434
5EA3897527D2663600C4C2D3 /* GarageBardUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA3897427D2663600C4C2D3 /* GarageBardUITests.swift */; };
3535
5EA3897727D2663600C4C2D3 /* GarageBardUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA3897627D2663600C4C2D3 /* GarageBardUITestsLaunchTests.swift */; };
36+
5EE4C19A2879331E00D7C320 /* MIDIController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EE4C1992879331E00D7C320 /* MIDIController.swift */; };
37+
5EE4C19E28793FB100D7C320 /* ToneSampler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EE4C19D28793FB100D7C320 /* ToneSampler.swift */; };
38+
5EE4C1A028793FF100D7C320 /* KeyboardController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EE4C19F28793FF100D7C320 /* KeyboardController.swift */; };
3639
5EE9E07827D3778F006F70BD /* AudioKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5EE9E07727D3778F006F70BD /* AudioKit */; };
3740
5EF3524827D6478C006E3A06 /* TimeFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EF3524727D6478C006E3A06 /* TimeFormatter.swift */; };
3841
5EF408E327DC1C62004AD546 /* PopoverMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EF408E227DC1C62004AD546 /* PopoverMenu.swift */; };
@@ -102,6 +105,9 @@
102105
5EA3897027D2663600C4C2D3 /* GarageBardUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GarageBardUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
103106
5EA3897427D2663600C4C2D3 /* GarageBardUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarageBardUITests.swift; sourceTree = "<group>"; };
104107
5EA3897627D2663600C4C2D3 /* GarageBardUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarageBardUITestsLaunchTests.swift; sourceTree = "<group>"; };
108+
5EE4C1992879331E00D7C320 /* MIDIController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MIDIController.swift; sourceTree = "<group>"; };
109+
5EE4C19D28793FB100D7C320 /* ToneSampler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToneSampler.swift; sourceTree = "<group>"; };
110+
5EE4C19F28793FF100D7C320 /* KeyboardController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardController.swift; sourceTree = "<group>"; };
105111
5EF3524727D6478C006E3A06 /* TimeFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeFormatter.swift; sourceTree = "<group>"; };
106112
5EF408E227DC1C62004AD546 /* PopoverMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopoverMenu.swift; sourceTree = "<group>"; };
107113
5EF408E727DCA272004AD546 /* Toast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toast.swift; sourceTree = "<group>"; };
@@ -208,6 +214,9 @@
208214
5E693B5927D3F18900EAA678 /* BardEngine.swift */,
209215
5EF408F327DD8316004AD546 /* ProcessManager.swift */,
210216
5EF3524727D6478C006E3A06 /* TimeFormatter.swift */,
217+
5EE4C1992879331E00D7C320 /* MIDIController.swift */,
218+
5EE4C19D28793FB100D7C320 /* ToneSampler.swift */,
219+
5EE4C19F28793FF100D7C320 /* KeyboardController.swift */,
211220
);
212221
path = Utils;
213222
sourceTree = "<group>";
@@ -430,6 +439,7 @@
430439
5EA1DD8B27E4C8CE004608C7 /* InputField.swift in Sources */,
431440
5EF408E827DCA272004AD546 /* Toast.swift in Sources */,
432441
5E06FCB227D4DCD000214BE3 /* PlaylistItemRow.swift in Sources */,
442+
5EE4C19E28793FB100D7C320 /* ToneSampler.swift in Sources */,
433443
5E693B4F27D3CDC000EAA678 /* PlayerViewModel.swift in Sources */,
434444
5E431BC827D521AA005016D3 /* FakePlayerViewModel.swift in Sources */,
435445
5E693B5A27D3F18900EAA678 /* BardEngine.swift in Sources */,
@@ -443,9 +453,11 @@
443453
5EA1DD8F27E4CA29004608C7 /* SubToolbar.swift in Sources */,
444454
5EF408F427DD8316004AD546 /* ProcessManager.swift in Sources */,
445455
5EA1DD8D27E4C960004608C7 /* PlayerButton.swift in Sources */,
456+
5EE4C1A028793FF100D7C320 /* KeyboardController.swift in Sources */,
446457
5EF408EA27DCA5EB004AD546 /* Notifications.swift in Sources */,
447458
5EA3895927D2663500C4C2D3 /* GarageBardApp.swift in Sources */,
448459
5EF408E327DC1C62004AD546 /* PopoverMenu.swift in Sources */,
460+
5EE4C19A2879331E00D7C320 /* MIDIController.swift in Sources */,
449461
5E431BCC27D52CE4005016D3 /* SongLibrary.swift in Sources */,
450462
5E50C6BC27E7158C00CE9EBD /* CheckForUpdatesView.swift in Sources */,
451463
5EF3524827D6478C006E3A06 /* TimeFormatter.swift in Sources */,

GarageBard.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 29 additions & 31 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

GarageBard/Components/Notifications.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,16 @@ struct Notifications<ViewModel: PlayerViewModelProtocol>: View {
3434
}
3535
}
3636

37+
if vm.midiDeviceNames.count > 0 {
38+
let devices = vm.midiDeviceNames.joined(separator: ", ")
39+
Toast(image: Image(systemName: "pianokeys")) {
40+
Text("Playing with MIDI controller")
41+
Text("Connected to: \(devices)")
42+
.font(.system(size: 12.0))
43+
.foregroundColor(Color("grey400"))
44+
}
45+
}
46+
3747
if !vm.foundXIVprocess, vm.playMode == .perform {
3848
Toast(image: Image(systemName: "gamecontroller")) {
3949
Text("Can't find game instance. Is the game running?")
@@ -74,7 +84,8 @@ struct Notifications_Previews: PreviewProvider {
7484
.environmentObject(
7585
FakePlayerViewModel(
7686
hasAccessibilityPermissions: false,
77-
foundXIVprocess: false
87+
foundXIVprocess: false,
88+
midiDeviceNames: ["My Keyboard", "My Other Keyboard"]
7889
)
7990
)
8091
.frame(width: space(100))

GarageBard/Fakes/ViewModels/FakePlayerViewModel.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class FakePlayerViewModel: PlayerViewModelProtocol {
3737
var notesTransposed: Bool = false
3838
var hasAccessibilityPermissions: Bool = true
3939
var foundXIVprocess: Bool = true
40+
var midiDeviceNames: [String] = []
4041
var floatWindow: Bool = false
4142

4243
init(
@@ -46,7 +47,8 @@ class FakePlayerViewModel: PlayerViewModelProtocol {
4647
currentProgress: Double = 0.3,
4748
songs: [Song] = [],
4849
hasAccessibilityPermissions: Bool = true,
49-
foundXIVprocess: Bool = true
50+
foundXIVprocess: Bool = true,
51+
midiDeviceNames: [String] = []
5052
) {
5153
self.song = song
5254
self.track = track
@@ -62,6 +64,7 @@ class FakePlayerViewModel: PlayerViewModelProtocol {
6264

6365
self.hasAccessibilityPermissions = hasAccessibilityPermissions
6466
self.foundXIVprocess = foundXIVprocess
67+
self.midiDeviceNames = midiDeviceNames
6568
}
6669

6770
func playOrPause() {}

GarageBard/Utils/BardController.swift

Lines changed: 14 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -6,144 +6,46 @@
66
//
77

88
import AudioKit
9-
import Carbon.HIToolbox
109
import Foundation
1110

1211
struct Note {
1312
let keyCode: CGKeyCode
1413
let desiredState: Bool
1514
}
1615

16+
/**
17+
Queues and executes notes to be played
18+
*/
1719
class BardController {
18-
private var hasAccessibilityPermissions = false
19-
private let sourceRef = CGEventSource(stateID: .combinedSessionState)
20-
private let noteKeyMap = [
21-
// C
22-
84: kVK_ANSI_8,
23-
72: kVK_ANSI_1,
24-
60: kVK_ANSI_9,
25-
48: kVK_ANSI_Y,
26-
// C#
27-
73: kVK_ANSI_D,
28-
61: kVK_ANSI_K,
29-
49: kVK_ANSI_V,
30-
// D
31-
74: kVK_ANSI_2,
32-
62: kVK_ANSI_0,
33-
50: kVK_ANSI_U,
34-
// Eb
35-
75: kVK_ANSI_F,
36-
63: kVK_ANSI_L,
37-
51: kVK_ANSI_B,
38-
// E
39-
76: kVK_ANSI_3,
40-
64: kVK_ANSI_Q,
41-
52: kVK_ANSI_I,
42-
// F
43-
77: kVK_ANSI_4,
44-
65: kVK_ANSI_W,
45-
53: kVK_ANSI_O,
46-
// F#
47-
78: kVK_ANSI_G,
48-
66: kVK_ANSI_Z,
49-
54: kVK_ANSI_N,
50-
// G
51-
79: kVK_ANSI_5,
52-
67: kVK_ANSI_E,
53-
55: kVK_ANSI_P,
54-
// G#
55-
80: kVK_ANSI_H,
56-
68: kVK_ANSI_X,
57-
56: kVK_ANSI_M,
58-
// A
59-
81: kVK_ANSI_6,
60-
69: kVK_ANSI_R,
61-
57: kVK_ANSI_A,
62-
// Bb
63-
82: kVK_ANSI_J,
64-
70: kVK_ANSI_C,
65-
58: kVK_ANSI_Comma,
66-
// B
67-
83: kVK_ANSI_7,
68-
71: kVK_ANSI_T,
69-
59: kVK_ANSI_S,
70-
]
7120
private var keyBuffer: CGKeyCode?
7221
private let queue = DispatchQueue(label: "bardcontroller.queue", qos: .userInteractive)
7322
private var noteBuffer: [Note] = []
7423

7524
private var running = false
7625

77-
var tickRateMs: UInt32
26+
private var tickRateMs: UInt32
27+
private var keyboard: KeyboardController
7828

79-
init(tickRateMs: UInt32 = 25) {
29+
init(tickRateMs: UInt32 = 25, keyboard: KeyboardController = KeyboardController()) {
8030
self.tickRateMs = tickRateMs
81-
82-
hasAccessibilityPermissions = AXIsProcessTrusted()
83-
84-
if sourceRef == nil {
85-
NSLog("BardController: No event source")
86-
}
87-
88-
if !hasAccessibilityPermissions {
89-
NSLog("Do not have accessbility permissions")
90-
}
91-
}
92-
93-
private func getKeyCode(note: MIDINoteNumber) -> CGKeyCode? {
94-
let keyNumber = noteKeyMap[Int(note)] ?? -1
95-
96-
if keyNumber == -1 {
97-
NSLog("Note '\(note)' is out of bounds")
98-
return nil
99-
}
100-
101-
return CGKeyCode(keyNumber)
102-
}
103-
104-
private func keyDown(_ keyCode: CGKeyCode) {
105-
let keyDownEvent = CGEvent(
106-
keyboardEventSource: sourceRef,
107-
virtualKey: keyCode,
108-
keyDown: true
109-
)
110-
111-
if let pid = ProcessManager.instance.getXIVProcessId() {
112-
keyDownEvent?.postToPid(pid)
113-
} else {
114-
keyDownEvent?.post(tap: .cghidEventTap)
115-
}
116-
}
117-
118-
private func keyUp(_ keyCode: CGKeyCode) {
119-
let keyUpEvent = CGEvent(
120-
keyboardEventSource: sourceRef,
121-
virtualKey: keyCode,
122-
keyDown: false
123-
)
124-
125-
if let pid = ProcessManager.instance.getXIVProcessId() {
126-
keyUpEvent?.postToPid(pid)
127-
} else {
128-
keyUpEvent?.post(tap: .cghidEventTap)
129-
}
31+
self.keyboard = keyboard
13032
}
13133

13234
func noteOn(_ note: MIDINoteNumber) {
133-
if let keyCode = getKeyCode(note: note) {
35+
if let keyCode = keyboard.getKeyCode(note: note) {
13436
noteBuffer.append(Note(keyCode: keyCode, desiredState: true))
13537
}
13638
}
13739

13840
func noteOff(_ note: MIDINoteNumber) {
139-
if let keyCode = getKeyCode(note: note) {
41+
if let keyCode = keyboard.getKeyCode(note: note) {
14042
noteBuffer.append(Note(keyCode: keyCode, desiredState: false))
14143
}
14244
}
14345

14446
func allNotesOff() {
145-
for (_, key) in noteKeyMap {
146-
keyUp(CGKeyCode(key))
47+
for (_, key) in keyboard.noteKeyMap {
48+
keyboard.keyUp(CGKeyCode(key))
14749
}
14850
}
14951

@@ -166,13 +68,13 @@ class BardController {
16668

16769
if note.desiredState {
16870
if let prevKeyCode = self.keyBuffer {
169-
self.keyUp(prevKeyCode)
71+
self.keyboard.keyUp(prevKeyCode)
17072
usleep(tickRate)
17173
}
172-
self.keyDown(note.keyCode)
74+
self.keyboard.keyDown(note.keyCode)
17375
self.keyBuffer = note.keyCode
17476
} else {
175-
self.keyUp(note.keyCode)
77+
self.keyboard.keyUp(note.keyCode)
17678
self.keyBuffer = nil
17779
}
17880
}

GarageBard/Utils/BardEngine.swift

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,9 @@ enum LoopMode {
2323
class BardEngine {
2424
private let sequencer = AppleSequencer()
2525
private let instrument = MIDICallbackInstrument()
26+
private let sampler = ToneSampler()
2627
private let controlInstrument = MIDICallbackInstrument()
2728
private let nullInstrument = MIDICallbackInstrument()
28-
private let sampler = MIDISampler()
29-
private let engine = AudioEngine()
3029
private let bardController = BardController()
3130
private var controlTrack: MusicTrackManager?
3231
private var musicTrack: MusicTrackManager?
@@ -41,9 +40,9 @@ class BardEngine {
4140
var playMode: PlayMode = .perform {
4241
didSet {
4342
if playMode == .perform {
44-
engine.stop()
43+
sampler.stop()
4544
} else if playMode == .listen {
46-
try? engine.start()
45+
sampler.start()
4746
bardController.allNotesOff()
4847
}
4948
}
@@ -66,11 +65,6 @@ class BardEngine {
6665

6766
instrument.callback = instrumentCallback
6867
controlInstrument.callback = controlCallback
69-
engine.output = sampler
70-
}
71-
72-
deinit {
73-
engine.stop()
7468
}
7569

7670
private func instrumentCallback(_ status: UInt8, _ note: MIDINoteNumber, _ velocity: MIDIVelocity) {

0 commit comments

Comments
 (0)