├── .githooks └── pre-commit ├── .gitignore ├── README.md ├── bounce_midi.py ├── logic.py ├── transpose.py └── write_midi.py /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | STAGED_PY_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep ".py\{0,1\}$") 4 | BLACK='python -m black' 5 | 6 | for FILE in $STAGED_PY_FILES; do 7 | echo $FILE 8 | $BLACK $FILE 9 | git add $FILE 10 | done 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | __pycache__ 3 | generate_all.py 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Logic Automator 2 | 3 | This has scripts for automating various tasks in Logic Pro X. 4 | 5 | ## Setup 6 | 7 | - Make sure you have `python3` installed and sourced in your `.zprofile` 8 | file (if you are using `zsh` as your shell). 9 | - Make sure Xcode is installed fully. 10 | - Run `pip install pyobjc atomacos` 11 | 12 | ## Running 13 | 14 | - **Transpose**: to transpose a song and bounce it by N semitones, 15 | run the following command. It exports to MP3 with default settings and 16 | overwrites existing files, if any. 17 | 18 | ```sh 19 | python3 transpose.py 20 | # Example: python3 transpose.py '/path/to/some\ project.logicx' 3 transposed_output 21 | ``` 22 | 23 | - **Write MIDI**: to create a midi file with a custom sequence of notes, 24 | as specified by their MIDI note numbers, use the following command: 25 | 26 | ```sh 27 | python3 write_midi.py 28 | # Example: python3 write_midi.py '[0, 4, 5]' '/path/to/some\ midi.mid' 29 | ``` 30 | 31 | - **Bounce MIDI**: to export a midi file as an MP3 file, use this: 32 | 33 | ```sh 34 | python3 bounce_midi.py \ 35 | \ 36 | \ 37 | \ 38 | ( ()) 39 | # Example: python3 bounce_midi.py \ 40 | # '/path/to/some\ project.logicx' \ 41 | # '/path/to/some\ midi.mid' \ 42 | # 'out' \ 43 | # '["AU Instruments", "Native Instruments", "Kontakt", "Stereo"]' \ 44 | # 'my_preset' 45 | ``` 46 | 47 | ## Development 48 | 49 | Unfortunately, the package this depends on (atomacos) has a number of major issues 50 | and Logic's interface keeps changing to make this code obsolete. Since this script 51 | has fulfilled my needs to date, I am leaving things here. If you have fixes that 52 | crop up moving forward, feel free to submit a pull request. 53 | 54 | Known issues: 55 | 56 | - When importing a MIDI file, atomacos sometimes replaces slashes with question 57 | marks and crashes. This is not always the case, but it occasionally occurs. 58 | This is a bug in atomacos, not this. A workaround is necessary in the future. 59 | - Sometimes the bounce menu does not appear. 60 | 61 | ### Linting 62 | 63 | First, set up the git hooks: 64 | 65 | ```sh 66 | git config --local core.hooksPath .githooks/ 67 | ``` 68 | 69 | Then, install dev dependencies so you can lint your files as you go: 70 | 71 | ```sh 72 | pip install black 73 | ``` 74 | 75 | ## Acknowledgements 76 | 77 | Most of the code for bouncing files is adapted from 78 | [psobot's solution](https://gist.github.com/psobot/81635e6cbc933b7e8862), 79 | but it is updated to work with Python 3 and newer versions of Logic. 80 | -------------------------------------------------------------------------------- /bounce_midi.py: -------------------------------------------------------------------------------- 1 | # Supports python 3.X on macOS 2 | # Authored by Graeme Zinck (graemezinck.ca) 3 | # 23 April 2021 4 | # 5 | # Takes a midi file and bounces it to MP3 using an empty logic project. 6 | # Can use a custom instrument and preset. 7 | 8 | import logic 9 | import sys 10 | import json 11 | 12 | 13 | def bounce_midi(project_path, midi_path, output_name, instrument=None, preset=None): 14 | logic.open(project_path) 15 | logic.importMidi(midi_path) 16 | logic.selectLastTrack() 17 | 18 | if instrument: 19 | logic.selectInstrument(instrument) 20 | if preset: 21 | logic.selectPresetSound(preset) 22 | 23 | logic.bounce(output_name) 24 | logic.deleteLastTrack() 25 | logic.close() 26 | 27 | 28 | # If we run this script on the command line 29 | if __name__ == "__main__": 30 | if len(sys.argv) not in [4, 6]: 31 | print( 32 | "Usage: bounce_midi.py ( ())" 33 | ) 34 | print( 35 | 'For , it should be a quoted string like \'["AU Instruments", "Native Instruments", "Kontakt", "Stereo"]\'' 36 | ) 37 | print( 38 | "The preset should be a preset you create and add to your library for the instrument" 39 | ) 40 | sys.exit(1) 41 | 42 | project_path = sys.argv[1] 43 | midi_path = sys.argv[2] 44 | output_name = sys.argv[3] 45 | instrument = None if len(sys.argv) < 5 else json.loads(sys.argv[4]) 46 | preset = None if len(sys.argv) < 6 else sys.argv[5] 47 | 48 | bounce_midi(project_path, midi_path, output_name, instrument, preset) 49 | -------------------------------------------------------------------------------- /logic.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | import atomacos 4 | import subprocess 5 | 6 | bundleID = "com.apple.logic10" 7 | logic = None 8 | 9 | 10 | def open(project): 11 | project_name = project.split("/")[-1].replace(".logicx", "") 12 | print(f"Opening {project_name}...") 13 | subprocess.call(["open", project]) 14 | 15 | print("Activating Logic Pro X...") 16 | global logic 17 | logic = atomacos.getAppRefByBundleId(bundleID) 18 | logic.activate() 19 | 20 | print("Waiting for project to load...") 21 | opening = True 22 | while opening: 23 | try: 24 | logic.windows() 25 | opening = False 26 | except Exception: 27 | opening = True 28 | 29 | while ( 30 | len( 31 | [ 32 | window 33 | for window in logic.windows() 34 | if "AXTitle" in window.getAttributes() 35 | and project_name in window.AXTitle 36 | ] 37 | ) 38 | == 0 39 | ): 40 | time.sleep(0.1) 41 | 42 | # Wait a bit extra for the window to load 43 | time.sleep(1) 44 | 45 | print( 46 | "Closing any popups (in particular the one for not having the audio interface)..." 47 | ) 48 | for popup in [x for x in logic.windows() if x.AXRoleDescription == "dialog"]: 49 | popup.buttons("OK")[0].Press() 50 | 51 | print("Project opened") 52 | 53 | 54 | def __toggle_open__(key, should_open): 55 | num_children = len(logic.windows()[0].AXChildren) 56 | logic.activate() 57 | logic.sendGlobalKey(key) 58 | # If we're supposed to open and the number of panes decreased, reopen it 59 | new_num_children = len(logic.windows()[0].AXChildren) 60 | if (should_open and new_num_children < num_children) or ( 61 | not should_open and new_num_children > num_children 62 | ): 63 | logic.sendGlobalKey(key) 64 | 65 | 66 | def closeMixer(): 67 | print("Closing mixer...") 68 | __toggle_open__("x", False) 69 | print("Mixer closed") 70 | 71 | 72 | def closeLibrary(): 73 | print("Closing library...") 74 | __toggle_open__("x", False) 75 | print("Library closed") 76 | 77 | 78 | def selectAllRegions(): 79 | closeMixer() 80 | # Note that while selecting, we close the mixer (if open) 81 | print("Selecting all regions...") 82 | logic.sendGlobalKeyWithModifiers("a", ["command"]) 83 | print("Regions selected") 84 | 85 | 86 | def transpose(interval): 87 | print("Opening the inspector...") 88 | logic.activate() 89 | 90 | inspectors = [ 91 | x 92 | for x in logic.windows()[0].AXChildren 93 | if "AXDescription" in x.getAttributes() 94 | and x.findFirstR(AXValue="Transpose:") != None 95 | ] 96 | if len(inspectors) == 0: 97 | logic.sendGlobalKey("i") 98 | inspectors = [ 99 | x 100 | for x in logic.windows()[0].AXChildren 101 | if "AXDescription" in x.getAttributes() 102 | and x.findFirstR(AXValue="Transpose:") != None 103 | ] 104 | 105 | print("Transposing the regions...") 106 | inspectors[0].findFirstR(AXValue="Transpose:").AXParent.findFirst( 107 | AXRole="AXSlider" 108 | ).AXValue = interval 109 | print("Regions transposed") 110 | 111 | 112 | def bounce(filepath, bounce_format="MP3"): 113 | # Adapted from https://gist.github.com/psobot/81635e6cbc933b7e8862 114 | print("Opening bounce window...") 115 | logic.activate() 116 | time.sleep(0.1) # To make sure the command works 117 | logic.sendGlobalKeyWithModifiers("b", ["command"]) 118 | 119 | windows = [] 120 | while len(windows) == 0: 121 | time.sleep(0.1) 122 | windows = [ 123 | window 124 | for window in logic.windows() 125 | if ("Output 1-2" in window.AXTitle) or ("Bounce" in window.AXTitle) 126 | ] 127 | bounce_window = windows[0] 128 | 129 | print("Selecting output formats...") 130 | quality_table = bounce_window.findFirst(AXRole="AXScrollArea").findFirst( 131 | AXRole="AXTable" 132 | ) 133 | for row in quality_table.findAll(AXRole="AXRow"): 134 | row_name = row.findFirst(AXRole="AXTextField").AXValue 135 | checkbox = row.findFirst(AXRole="AXCheckBox") 136 | if row_name == bounce_format: 137 | if checkbox.AXValue == 0: 138 | print(f"Selecting {bounce_format} output format...") 139 | checkbox.Press() 140 | else: 141 | print(f"{bounce_format} format already selected.") 142 | elif checkbox.AXValue == 1: 143 | print(f"Deselecting {row_name} format...") 144 | 145 | print("Pressing bounce...") 146 | bounce_window.findFirst(AXRole="AXButton", AXTitle="OK").Press() 147 | 148 | print("Waiting for save window...") 149 | windows = [] 150 | while len(windows) == 0: 151 | time.sleep(0.1) 152 | windows = [ 153 | window 154 | for window in logic.windows() 155 | if ("Output 1-2" in window.AXTitle) or ("Bounce" in window.AXTitle) 156 | ] 157 | save_window = windows[0] 158 | 159 | print("Entering file path...") 160 | logic.activate() 161 | logic.sendGlobalKeyWithModifiers("g", ["command", "shift"]) 162 | logic.sendKeys(f"{filepath}\n") 163 | 164 | print("Waiting for file path window to close...") 165 | logic.activate() 166 | windows = [] 167 | while len(windows) != 1: 168 | time.sleep(0.1) 169 | windows = [ 170 | window 171 | for window in logic.windows() 172 | if ("Output 1-2" in window.AXTitle) or ("Bounce" in window.AXTitle) 173 | ] 174 | save_window = windows[0] 175 | 176 | print("Pressing bounce on save window...") 177 | try: 178 | save_window.buttons("Bounce")[0].Press() 179 | except atomacos.errors.AXErrorAttributeUnsupported as e: 180 | print(f"Error when clicking bounce: {str(e)}") 181 | 182 | time.sleep(0.1) 183 | 184 | # Deal with case where file already exists 185 | for dialog in [ 186 | window for window in logic.windows() if window.AXRoleDescription == "dialog" 187 | ]: 188 | print("Overwriting previous file...") 189 | btns = dialog.AXChildren[-1].buttons("Replace") 190 | for btn in btns: 191 | btn.Press() 192 | time.sleep(0.1) 193 | # Deal with extra messages that could pop up 194 | dialogs = [ 195 | window 196 | for window in logic.windows() 197 | if window.AXRoleDescription == "dialog" 198 | ] 199 | while len(dialogs) > 0: 200 | replace_btn = dialogs[0].buttons("Replace") 201 | if len(replace_btn) == 0: 202 | break 203 | replace_btn[0].Press() 204 | time.sleep(0.1) 205 | dialogs = [ 206 | window 207 | for window in logic.windows() 208 | if window.AXRoleDescription == "dialog" 209 | ] 210 | 211 | print("Bouncing...") 212 | # time.sleep(2) 213 | 214 | logic.activate() # Required for some reason 215 | still_going = True 216 | while still_going: 217 | try: 218 | still_going = len(logic.windows()) > 1 219 | except atomacos.errors.AXErrorInvalidUIElement: 220 | still_going = True 221 | time.sleep(0.1) 222 | 223 | print("File saved") 224 | 225 | 226 | def close(): 227 | print("Closing project...") 228 | logic.sendGlobalKeyWithModifiers("w", ["command"]) 229 | 230 | print("Waiting for the save changes window...") 231 | windows = [] 232 | for _ in range(20): 233 | time.sleep(0.1) 234 | windows = [ 235 | window 236 | for window in logic.windows() 237 | if "AXDescription" in window.getAttributes() 238 | and window.AXDescription == "alert" 239 | ] 240 | if len(windows) > 0: 241 | break 242 | 243 | if len(windows) > 0: 244 | windows[0].buttons("Don’t Save")[0].Press() 245 | 246 | while len(logic.windows()) > 0: 247 | time.sleep(0.1) 248 | 249 | print("Project closed") 250 | 251 | 252 | def importMidi(midiFile): 253 | print(midiFile) 254 | selectLastTrack() 255 | 256 | print("Opening up midi selection window...") 257 | logic.menuItem("File", "Import", "MIDI File…").Press() 258 | windows = [] 259 | while len(windows) == 0: 260 | time.sleep(0.1) 261 | windows = [ 262 | window 263 | for window in logic.windows() 264 | if "AXTitle" in window.getAttributes() and window.AXTitle == "Import" 265 | ] 266 | import_window = windows[0] 267 | 268 | print("Navigating to folder...") 269 | logic.activate() 270 | logic.sendGlobalKeyWithModifiers("g", ["command", "shift"]) 271 | logic.sendKeys(midiFile) 272 | logic.sendKeys("\n") 273 | 274 | print("Pressing import...") 275 | time.sleep(0.1) 276 | import_window.buttons("Import")[0].Press() 277 | 278 | print("Waiting for tempo import message...") 279 | windows = [] 280 | while len(windows) == 0: 281 | time.sleep(0.1) 282 | windows = [ 283 | window 284 | for window in logic.windows() 285 | if "AXDescription" in window.getAttributes() 286 | and window.AXDescription == "alert" 287 | ] 288 | alert = windows[0] 289 | 290 | print("Importing tempo...") 291 | alert.buttons("Import Tempo")[0].Press() 292 | 293 | print("Midi file imported") 294 | 295 | 296 | """ 297 | old_instrument is the old instrument's name (like 'Sampler') 298 | new_instrument_arr is an array like ['AU Instruments', 'Native Instruments', 'Kontakt', 'Stereo'] 299 | (it has the hierarchy of items to find in the menu). 300 | """ 301 | 302 | 303 | def selectInstrument(new_instrument_arr): 304 | print("Opening the appropriate window...") 305 | window = [ 306 | window 307 | for window in logic.windows() 308 | if "AXTitle" in window.getAttributes() and "Tracks" in window.AXTitle 309 | ][0] 310 | 311 | print("Finding the instrument to change...") 312 | # It's at window.AXChildren[-2].AXChildren[0].AXChildren[-1].AXChildren[0].AXChildren[0]... 313 | # The one below could be used if we know the name of the instrument 314 | # strip = window.findFirstR(AXRoleDescription='channel strip group').findFirstR(AXDescription=old_instrument).AXChildren[-1] 315 | strip = ( 316 | window.findFirstR(AXRoleDescription="channel strip group") 317 | .findAllR(AXDescription="open")[-1] 318 | .AXParent.AXChildren[-1] 319 | ) 320 | 321 | # Manually click because Press() throws errors 322 | time.sleep(0.1) 323 | atomacos.mouse.click(x=strip.AXPosition.x + 1, y=strip.AXPosition.y + 1) 324 | 325 | print("Walking through the menu items...") 326 | menuitem = window 327 | for item in new_instrument_arr: 328 | if menuitem.AXChildren[0].AXRole == "AXMenu": 329 | menuitem = menuitem.AXChildren[0] 330 | menuitem = menuitem.findFirstR(AXRole="AXMenu*Item", AXTitle=item) 331 | while menuitem.AXPosition.x == 0: 332 | time.sleep(0.1) 333 | atomacos.mouse.moveTo(x=menuitem.AXPosition.x + 1, y=menuitem.AXPosition.y + 1) 334 | 335 | print("Clicking the instrument name...") 336 | atomacos.mouse.click(x=menuitem.AXPosition.x + 1, y=menuitem.AXPosition.y + 1) 337 | 338 | print("Waiting for the instrument screen...") 339 | windows = [] 340 | i = 0 341 | while len(windows) < 1 and i < 20: 342 | time.sleep(0.1) 343 | i += 1 344 | windows = [ 345 | window 346 | for window in logic.windows() 347 | if "AXTitle" not in window.getAttributes() or "Tracks" not in window.AXTitle 348 | ] 349 | 350 | if i >= 20: 351 | print("Failed to open instrument screen") 352 | raise Exception("Failed to open instrument screen") 353 | 354 | print("Closing the instrument screen...") 355 | for window in windows: 356 | window.AXChildren[0].Press() 357 | 358 | print("Done selecting instrument") 359 | 360 | 361 | def selectPresetSound(sound): 362 | print("Opening the appropriate window...") 363 | window = [ 364 | window 365 | for window in logic.windows() 366 | if "AXTitle" in window.getAttributes() and "Tracks" in window.AXTitle 367 | ][0] 368 | 369 | closeLibrary() 370 | strip = ( 371 | window.findFirstR(AXRoleDescription="channel strip group") 372 | .findAllR(AXDescription="open")[-1] 373 | .AXParent.AXChildren[0] 374 | ) 375 | print("Opening the presets for the instrument") 376 | atomacos.mouse.click(x=strip.AXPosition.x - 20, y=strip.AXPosition.y + 1) 377 | 378 | print("Waiting for search text field to appear...") 379 | fields = [] 380 | while len(fields) == 0: 381 | time.sleep(0.1) 382 | try: 383 | fields = window.findAllR( 384 | AXRole="AXTextField", AXRoleDescription="search text field" 385 | ) 386 | except Exception: 387 | print("Error finding search library field") 388 | fields = [] 389 | search_field = fields[0] 390 | 391 | row = window.findFirstR(AXValue=sound) 392 | 393 | print("Double clicking the sound") 394 | logic.activate() 395 | atomacos.mouse.click( 396 | x=row.AXPosition.x + 30, y=row.AXPosition.y + 10, clicks=1, interval=0.1 397 | ) 398 | time.sleep(0.05) 399 | atomacos.mouse.click( 400 | x=row.AXPosition.x + 30, y=row.AXPosition.y + 10, clicks=2, interval=0.01 401 | ) 402 | 403 | 404 | def selectLastTrack(): 405 | closeMixer() 406 | 407 | print("Moving selection down by 5") 408 | logic.activate() 409 | for i in range(5): 410 | logic.sendGlobalKey("down") 411 | 412 | 413 | def deleteLastTrack(): 414 | selectLastTrack() 415 | 416 | print("Entering delete keyboard shortcut...") 417 | logic.sendGlobalKeyWithModifiers("del", ["command"]) 418 | 419 | print("Confirming deletion...") 420 | windows = [] 421 | while len(windows) == 0: 422 | time.sleep(0.1) 423 | windows = [x for x in logic.windows() if x.AXRoleDescription == "dialog"] 424 | delete_popup = windows[0] 425 | delete_popup.buttons("Delete")[0].Press() 426 | -------------------------------------------------------------------------------- /transpose.py: -------------------------------------------------------------------------------- 1 | # Supports python 3.X on macOS 2 | # Authored by Graeme Zinck (graemezinck.ca) 3 | # 22 April 2021 4 | # 5 | # Takes a logic project, transposes everything, and bounces to MP3. 6 | 7 | import sys 8 | import time 9 | import atomacos 10 | import subprocess 11 | import logic 12 | 13 | 14 | def transpose(project, transpose_by, output_name): 15 | logic.open(project) 16 | logic.selectAllRegions() 17 | logic.transpose(transpose_by) 18 | logic.bounce(output_name) 19 | logic.close() 20 | 21 | 22 | # If we run this script on the command line 23 | if __name__ == "__main__": 24 | if len(sys.argv) != 4: 25 | print( 26 | "Usage: transpose.py " 27 | ) 28 | sys.exit(1) 29 | 30 | project = sys.argv[1] 31 | transpose_by = int(sys.argv[2]) 32 | print(transpose_by) 33 | output_name = sys.argv[3] 34 | 35 | transpose(project, transpose_by, output_name) 36 | -------------------------------------------------------------------------------- /write_midi.py: -------------------------------------------------------------------------------- 1 | from midiutil import MIDIFile 2 | import sys 3 | import json 4 | 5 | 6 | def write_midi(notes, path): 7 | track = 0 8 | channel = 0 9 | time = 0 # In beats 10 | duration = 0.5 # In beats 11 | tempo = 90 # In BPM 12 | volume = 80 # 0-127, as per the MIDI standard 13 | 14 | MyMIDI = MIDIFile(1) # One track, defaults to format 1 15 | MyMIDI.addTempo(track, time, tempo) 16 | 17 | for i, pitch in enumerate(notes): 18 | if i == len(notes) - 1: 19 | duration = 6 20 | MyMIDI.addNote(track, channel, pitch, time + i / 2, duration, volume) 21 | 22 | with open(path, "wb") as output_file: 23 | MyMIDI.writeFile(output_file) 24 | 25 | 26 | # If we run this script on the command line 27 | if __name__ == "__main__": 28 | if len(sys.argv) != 3: 29 | print("Usage: write_midi.py ") 30 | print( 31 | "For , it should be a quoted string like '[0, 4, 5]' where 0 is the MIDI note number 0" 32 | ) 33 | sys.exit(1) 34 | 35 | notes_array = json.loads(sys.argv[1]) 36 | output_name = sys.argv[2] 37 | 38 | write_midi(notes_array, output_name) 39 | --------------------------------------------------------------------------------