├── .github ├── FUNDING.yml └── workflows │ └── build.yml ├── .gitignore ├── DigiReaper.spec ├── Digico.ReaperOSC ├── LICENSE ├── LocalBuild.spec ├── README.md ├── app_settings.py ├── configure_reaper.py ├── entitlements.plist ├── logger_config.py ├── main.py ├── requirements.txt ├── resources ├── DRLSplash.png ├── rprdigi.icns └── rprdigi.ico └── utilities.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: jms5194 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: justinstasiw 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Cross-Platform Build with PyInstaller 2 | 3 | # Call when commit is tagged with v* 4 | on: 5 | push: 6 | tags: 7 | - 'v*' 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | # Creates a release in Github 14 | create_release: 15 | name: Create Release 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Create Release 19 | id: create_release 20 | uses: softprops/action-gh-release@v1 21 | with: 22 | name: ${{ github.ref_name }} 23 | draft: false 24 | prerelease: false 25 | generate_release_notes: false 26 | 27 | build: 28 | # When create_release is finished 29 | needs: create_release 30 | strategy: 31 | matrix: 32 | # Building for Mac silicon, Mac Intel, and Windows 33 | os: [macos-x86, macos-arm64, windows-latest] 34 | include: 35 | - os: macos-x86 36 | python-version: '3.12' 37 | target: macos-x86 38 | runs-on: macos-13 39 | - os: macos-arm64 40 | python-version: '3.12' 41 | target: macos-arm64 42 | runs-on: macos-latest 43 | - os: windows-latest 44 | python-version: '3.12' 45 | target: windows 46 | runs-on: windows-latest 47 | # Run the build on all of the matrix of systems above 48 | runs-on: ${{ matrix.runs-on }} 49 | 50 | steps: 51 | # Checkout the project from Github 52 | - uses: actions/checkout@v4 53 | # Get python installed on the runner 54 | - name: Set up Python 55 | uses: actions/setup-python@v5 56 | with: 57 | python-version: ${{ matrix.python-version }} 58 | # Upgrade Pip and install dependencies 59 | - name: Install dependencies 60 | run: | 61 | python - pip install --upgrade pip 62 | python -m pip install -r requirements.txt 63 | # Bring in our apple certificate to the keychain of the runner 64 | - name: Import Apple Certificate 65 | if: matrix.os == 'macos-x86' || matrix.os == 'macos-arm64' 66 | run: | 67 | if security list-keychains | grep -q "github_build.keychain"; then 68 | security delete-keychain github_build.keychain 69 | fi 70 | security create-keychain -p "" github_build.keychain 71 | security default-keychain -s github_build.keychain 72 | security set-keychain-settings -lut 21600 github_build.keychain 73 | echo "${{ secrets.APPLE_CERTIFICATE }}" | base64 --decode > apple_certificate.p12 74 | security import apple_certificate.p12 -k github_build.keychain -P "${{ secrets.APPLE_CERTIFICATE_PASSWORD }}" \ 75 | -t cert -f pkcs12 -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/xcrun 76 | security unlock-keychain -p "" github_build.keychain 77 | security set-key-partition-list -S 'apple-tool:,apple:' -s -k "" github_build.keychain 78 | security list-keychain -d user -s github_build.keychain 'login-keychain' 79 | env: 80 | APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} 81 | APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} 82 | 83 | - name: Unlock keychain on Mac 84 | if: matrix.os == 'macos-x86' || matrix.os == 'macos-arm64' 85 | run: | 86 | security unlock-keychain -p "" github_build.keychain 87 | security set-key-partition-list -S apple-tool:,apple: -k "" -D "Developer" -t private github_build.keychain 88 | 89 | - name: List available signing identities 90 | if: matrix.os == 'macos-x86' || matrix.os == 'macos-arm64' 91 | run: | 92 | security find-identity -v -p codesigning 93 | 94 | # write a .env file with the secrets 95 | - name: Write .env file Mac & Linux 96 | if: matrix.os != 'windows-latest' 97 | run: | 98 | echo "LOCAL_RELEASE_TAG=${GITHUB_REF_NAME}" >> .env 99 | echo "LOCAL_RELEASE_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> .env 100 | 101 | - name: Write .env file Windows 102 | if: matrix.os == 'windows-latest' 103 | run: | 104 | @" 105 | LOCAL_RELEASE_TAG=$env:GITHUB_REF_NAME 106 | LOCAL_RELEASE_DATE=$(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ') 107 | "@ | Out-File -FilePath .env -Encoding ASCII 108 | shell: pwsh 109 | # Build app with Pyinstaller on Mac 110 | - name: Build with PyInstaller (MacOS) 111 | if: matrix.os == 'macos-x86' || matrix.os == 'macos-arm64' 112 | run: | 113 | pyinstaller --clean --noconfirm DigiReaper.spec -- --mac_osx 114 | env: 115 | APPLE_APP_DEVELOPER_ID: ${{ secrets.APPLE_APP_DEVELOPER_ID }} 116 | # Build app with Pyinstaller on Windows 117 | - name: Build with PyInstaller (Windows) 118 | if: matrix.os == 'windows-latest' 119 | run: | 120 | if ("${{ github.event_name }}" -eq "pull_request") { 121 | pyinstaller --clean --noconfirm DigiReaper.spec -- --win --debug 122 | } else { 123 | pyinstaller --clean --noconfirm DigiReaper.spec -- --win 124 | } 125 | 126 | - name: Zip Application for Notarization 127 | if: (matrix.os == 'macos-x86' || matrix.os == 'macos-arm64') 128 | run: | 129 | ditto -c -k --keepParent "dist/Digico-Reaper Link.app" "Digico-Reaper Link.zip" 130 | # Send the application zip to Apple for Notarization and stapling 131 | - name: Notarize and Staple 132 | if: (matrix.os == 'macos-x86' || matrix.os == 'macos-arm64') 133 | run: | 134 | xcrun notarytool submit "Digico-Reaper Link.zip" --apple-id \ 135 | "${{ secrets.APPLE_DEVELOPER_ID_USER }}" --password \ 136 | "${{ secrets.APPLE_DEVELOPER_ID_PASSWORD }}" --team-id \ 137 | "${{ secrets.APPLE_DEVELOPER_ID_TEAM }}" --wait --verbose 138 | chmod 755 "dist/Digico-Reaper Link.app" 139 | xcrun stapler staple "dist/Digico-Reaper Link.app" 140 | 141 | - name: Verify Notarization 142 | if: (matrix.os == 'macos-x86' || matrix.os == 'macos-arm64') 143 | run: | 144 | spctl -a -v "dist/Digico-Reaper Link.app" 145 | rm "Digico-Reaper Link.zip" 146 | # Build an installer DMG for MacOS 147 | - name: Create dmg MacOS 148 | if: matrix.os == 'macos-x86' || matrix.os == 'macos-arm64' 149 | run: | 150 | chmod a+x "dist/Digico-Reaper Link.app" 151 | brew update 152 | brew upgrade 153 | brew install create-dmg 154 | create-dmg \ 155 | --volname "Digico-Reaper Link Installer" \ 156 | --app-drop-link 600 185 \ 157 | --window-pos 200 120 \ 158 | --window-size 800 400 \ 159 | --hide-extension "Digico-Reaper Link.app" \ 160 | "Digico-Reaper Link Installer"-${{ matrix.target }}.dmg \ 161 | "dist/Digico-Reaper Link.app" 162 | # Zip the windows applications 163 | - name: Create zip on Windows 164 | if: matrix.os == 'windows-latest' 165 | run: | 166 | Compress-Archive -Path "dist/Digico-Reaper Link.exe" -DestinationPath "./Digico-Reaper Link-win.zip" 167 | shell: pwsh 168 | # Add the built binaries to the release 169 | - name: Release 170 | uses: softprops/action-gh-release@v2 171 | with: 172 | tag_name: ${{ needs.create_release.outputs.tag-name }} 173 | files: | 174 | Digico-Reaper\ Link\ Installer-macos-arm64.dmg 175 | Digico-Reaper\ Link\ Installer-macos-x86.dmg 176 | Digico-Reaper\ Link-win.zip -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | /dist/ 3 | /venv/ 4 | /venv2/ 5 | /.idea/ 6 | /__pycache__/ 7 | .DS_Store 8 | /venv4/ 9 | -------------------------------------------------------------------------------- /DigiReaper.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | import os 3 | from PyInstaller.utils.hooks import collect_all 4 | 5 | # parse command line arguments 6 | import argparse 7 | 8 | parser = argparse.ArgumentParser() 9 | parser.add_argument('--mac_osx', action='store_true') 10 | parser.add_argument('--win', action='store_true') 11 | parser.add_argument('--debug', action='store_true') 12 | 13 | args = parser.parse_args() 14 | 15 | datas = [ 16 | ('.env', '.'), 17 | ('resources/rprdigi.icns', './resources'), 18 | ('resources/rprdigi.ico', './resources'), 19 | ] 20 | 21 | 22 | 23 | numpy_datas, numpy_binaries, numpy_hiddenimports = collect_all('numpy') 24 | ws_hiddenimports=['websockets', 'websockets.legacy'] 25 | 26 | a = Analysis(['main.py'], 27 | pathex=[], 28 | binaries=numpy_binaries, 29 | datas=datas + numpy_datas, 30 | hiddenimports=numpy_hiddenimports + ws_hiddenimports, 31 | hookspath=[], 32 | hooksconfig={}, 33 | runtime_hooks=[], 34 | excludes=[], 35 | win_no_prefer_redirects=False, 36 | win_private_assemblies=False, 37 | noarchive=False, 38 | ) 39 | pyz = PYZ(a.pure) 40 | 41 | if args.win: 42 | exe = EXE( 43 | pyz, 44 | a.binaries, 45 | a.datas, 46 | a.scripts, 47 | name='Digico-Reaper Link', 48 | icon='resources/rprdigi.ico', 49 | debug=args.debug is not None and args.debug, 50 | bootloader_ignore_signals=False, 51 | strip=False, 52 | upx=True, 53 | upx_exclude=[], 54 | console=args.debug is not None and args.debug, 55 | disable_windowed_traceback=False, 56 | argv_emulation=False, 57 | target_arch=None, 58 | codesign_identity=None, 59 | ) 60 | elif args.mac_osx: 61 | exe = EXE( 62 | pyz, 63 | a.binaries, 64 | a.datas, 65 | a.scripts, 66 | name='Digico-Reaper Link', 67 | debug=args.debug is not None and args.debug, 68 | bootloader_ignore_signals=False, 69 | strip=False, 70 | upx=True, 71 | console=False, 72 | disable_windowed_traceback=False, 73 | argv_emulation=False, 74 | target_arch=None, 75 | codesign_identity=os.environ.get('APPLE_APP_DEVELOPER_ID', ''), 76 | entitlements_file='./entitlements.plist', 77 | ) 78 | app = BUNDLE( 79 | exe, 80 | name='Digico-Reaper Link.app', 81 | icon='resources/rprdigi.icns', 82 | bundle_identifier='com.justinstasiw.digicoreaperlink', 83 | version='3.0.0', 84 | info_plist={ 85 | 'NSPrincipalClass': 'NSApplication', 86 | 'NSAppleScriptEnabled': False, 87 | } 88 | ) 89 | else: 90 | exe = EXE( 91 | pyz, 92 | a.binaries, 93 | a.datas, 94 | a.scripts, 95 | name='Digico-Reaper Link', 96 | icon='resources/rprdigi.ico', 97 | debug=args.debug is not None and args.debug, 98 | bootloader_ignore_signals=False, 99 | strip=False, 100 | upx=True, 101 | console=False, 102 | disable_windowed_traceback=False, 103 | argv_emulation=False, 104 | target_arch=None, 105 | ) -------------------------------------------------------------------------------- /Digico.ReaperOSC: -------------------------------------------------------------------------------- 1 | # OSC pattern config file. 2 | 3 | # Make a copy of this file, rename it, and edit the file to create a 4 | # custom pattern configuration. 5 | 6 | # For basic information about OSC and REAPER, see 7 | # http://www.cockos.com/reaper/sdk/osc/osc.php . 8 | 9 | # ---------------------------------------------------------------- 10 | 11 | # Default settings for how this device displays information. 12 | 13 | # (these can all be overridden by the device sending OSC messages, search for these 14 | # names below to see the messages) 15 | # 16 | # DEVICE_TRACK_COUNT is how many tracks this device can display at once 17 | # (the track bank size). 18 | # DEVICE_SEND_COUNT/DEVICE_RECEIVE_COUNT is how many sends and receives this 19 | # device can display at once. 20 | # DEVICE_FX_COUNT is how many track insert FX this device can display at once. 21 | # DEVICE_FX_PARAM_COUNT is how many FX parameters this device can display at once 22 | # (the FX parameter bank size). 23 | # DEVICE_FX_INST_PARAM_COUNT is how many FX instrument parameters this device can 24 | # display at once (the FX instrument parameter bank size). 25 | # DEVICE_MARKER_COUNT is how many markers for which this device would like to 26 | # receive information 27 | # DEVICE_REGION_COUNT is how many regions for which this device would like to 28 | # receive information 29 | 30 | DEVICE_TRACK_COUNT 8 31 | DEVICE_SEND_COUNT 4 32 | DEVICE_RECEIVE_COUNT 4 33 | DEVICE_FX_COUNT 8 34 | DEVICE_FX_PARAM_COUNT 16 35 | DEVICE_FX_INST_PARAM_COUNT 16 36 | DEVICE_MARKER_COUNT 0 37 | DEVICE_REGION_COUNT 0 38 | 39 | # ---------------------------------------------------------------- 40 | 41 | # Default values for how this device behaves. The device has a selected track, bank 42 | # of tracks, and FX, which are not necessarily the same as the selected track or FX 43 | # in the REAPER window. 44 | 45 | # REAPER_TRACK_FOLLOWS determines whether the selected track in REAPER changes 46 | # only when the user changes it in the REAPER window, or if it follows the track 47 | # currently selected in the OSC device. 48 | # Allowed values: REAPER, DEVICE 49 | 50 | # DEVICE_TRACK_FOLLOWS determines whether the selected track in the device changes 51 | # only when the device changes it, or if it follows the last touched track in the 52 | # REAPER window. 53 | # Allowed values: DEVICE, LAST_TOUCHED 54 | 55 | # DEVICE_TRACK_BANK_FOLLOWS determines whether the selected track bank in the device 56 | # changes only when the device changes it, or if it follows the REAPER mixer view. 57 | # Allowed values: DEVICE, MIXER 58 | 59 | # DEVICE_FX_FOLLOWS determines whether the selected FX in the device changes only 60 | # when the device changes it, or if it follows the last touched or currently focused 61 | # FX in the REAPER window. 62 | # Allowed values: DEVICE, LAST_TOUCHED, FOCUSED 63 | 64 | # DEVICE_EQ determines whether sending any FX_EQ message will automatically insert 65 | # ReaEQ on the target track if it does not exist, or the message will only affect 66 | # an existing instance of ReaEQ. 67 | # Allowed values: INSERT, EXISTING 68 | 69 | # DEVICE_ROTARY_CENTER defines the argument that represents no change, for rotary 70 | # controls. 71 | # Allowed values: 0, 0.5 72 | 73 | REAPER_TRACK_FOLLOWS REAPER 74 | DEVICE_TRACK_FOLLOWS DEVICE 75 | DEVICE_TRACK_BANK_FOLLOWS DEVICE 76 | DEVICE_FX_FOLLOWS DEVICE 77 | 78 | DEVICE_EQ INSERT 79 | 80 | DEVICE_ROTARY_CENTER 0 81 | 82 | # ---------------------------------------------------------------- 83 | 84 | # Each line below is an action description in all caps, followed by a number of OSC 85 | # message patterns. You can add, remove, or change patterns, delete lines, or comment 86 | # out lines by adding '#', but do not change the action descriptions. 87 | 88 | # The patterns following the action are the messages that REAPER will send and receive 89 | # to and from the OSC device. An action can have no patterns (and will be ignored), 90 | # one pattern, or many patterns. 91 | 92 | # The patterns may contain the wildcard character '@'. (This is REAPER-only, not part 93 | # of the OSC specification.) The '@' wildcard is used to specify the action target. 94 | 95 | # ---------------------------------------------------------------- 96 | 97 | # The OSC device sends patterns to trigger actions, and REAPER sends patterns to the 98 | # device as feedback. OSC patterns can include arguments, which are be interpreted 99 | # in various ways, defined by a flag immediately before the pattern. 100 | 101 | # n: normalized floating-point argument. 0 means the minimum value, and 1 means the 102 | # maximum value. This can be used for continous controls like sliders and knobs. 103 | 104 | # Example: TRACK_VOLUME n/track/volume n/track/@/volume 105 | # The device sends /track/3/volume 0.5 to set the volume to 0.5 for track 3, or 106 | # /track/volume 0.5 to set the volume for the track that is currently selected in 107 | # the device. REAPER sends /track/3/volume 0.5 when track 3 volume changes to 0.5. 108 | # If track 3 is currently selected in the device, REAPER will also send 109 | # /track/volume 0.5. The floating-point argument represents as the track fader 110 | # position in the REAPER window. 0 sets the fader all the way down, 1 sets the fader 111 | # all the way up, 0.5 sets the fader exactly in the middle. Therefore, the actual 112 | # volume that is set depends on the REAPER track fader preference settings. 113 | 114 | # f: raw floating-point argument. The argument is interpreted directly, to set or 115 | # report a value. 116 | 117 | # Example: TEMPO f/tempo/raw 118 | # The device sends /tempo/raw 100.351 to change the REAPER tempo to 100.351 bpm. 119 | # REAPER sends /tempo/raw 120 when the tempo changes to 120 bpm. 120 | 121 | # Normalized and raw floating-point arguments also support multiple parameters 122 | # sent from the device. 123 | # Example: FX_PARAM_VALUE n/track/@/fx/@/fxparam/@/value 124 | # The device can send /track/3/fx/1,2,5/fxparam/6,7,7/value 0.25 0.5 0.75 125 | # to set three FX parameter values at once, to 0.25, 0.5, 0.75 respectively. 126 | 127 | # b: binary argument, either 0 or 1. The device sets or clears the state when 128 | # sending the message. Can be used to emulate switches or momentary controls. 129 | 130 | # Example: TRACK_MUTE b/track/mute b/track/@/mute 131 | # The device sends /track/3/mute 1 to mute track 3, or /track/mute 1 to mute the 132 | # track that is currently selected in the device. /track/3/mute 0 will unmute 133 | # track 3. REAPER sends /track/3/mute 1 when track 3 is muted, and /track/3/mute 0 134 | # when track 3 is unmuted. If track 3 is currently selected in the device, REAPER 135 | # will also send /track/mute 1 and /track/mute 0. 136 | 137 | # Example: REWIND b/rewind 138 | # The device sends /rewind 1 to begin rewinding, and sends /rewind 0 to stop 139 | # rewinding. REAPER sends /rewind 1 when the rewind button in the REAPER window is 140 | # pressed, and /rewind 0 when the button is released. 141 | 142 | # t: trigger or toggle message. The device triggers the action, or toggles the 143 | # state, when the pattern is sent with no arguments, or with an argument of 1. 144 | # The feedback values REAPER sends are identical to those sent for binary 145 | # arguments. 146 | 147 | # Example: METRONOME t/click 148 | # The device sends /click or /click 1 to toggle the metronome on or off. REAPER 149 | # sends /click 1 when the metronome is enabled, and /click 0 when the metronome 150 | # is disabled. 151 | 152 | # r: rotary. The device triggers the action in the forward direction when sent 153 | # with an argument greater than ROTARY_CENTER, and in the reverse direction when 154 | # sent with an argument less than ROTARY_CENTER. For some messages, the magnitude 155 | # of the argument affects the rate of change. REAPER does not send feedback for 156 | # these messages. 157 | 158 | # Example: SCRUB r/scrub 159 | # The device sends /scrub 1 to scrub forward, and /scrub -1 to scrub in reverse 160 | # (if ROTARY_CENTER is 0). 161 | 162 | # s: string. These messages include a string argument. Many of these messages 163 | # are sent from REAPER to the device for feedback/display, but some can be sent 164 | # from the device to REAPER. 165 | 166 | # Example: TRACK_NAME s/track/name s/track/@/name 167 | # The device sends /track/3/name "vox" to rename track 3 in REAPER, or /track/name 168 | # "vox" to rename the track that is currently selected in the device. REAPER sends 169 | # /track/3/name "vox" to report that name of track 3 is "vox". If track 3 is 170 | # currently selected in the device, REAPER will also send /track/name "vox". 171 | 172 | # Example: DEVICE_FX_FOLLOWS s/fxfollows 173 | # The device sends /fxfollows "FOCUSED" to inform REAPER that the selected FX in the 174 | # device will now follow the FX that is focused in the REAPER window. 175 | 176 | # i: integer. These messages include an integer argument, and are sent from the 177 | # device to REAPER. 178 | 179 | # Example: ACTION i/action t/action/@ 180 | # The device sends /action 40757 or /action/40757 to trigger the REAPER action 181 | # "split items at edit cursor". See the REAPER actions window for a complete list 182 | # of action command ID numbers. 183 | 184 | # Example: DEVICE_TRACK_BANK_SELECT i/bankedit t/bankedit/@ 185 | # The device sends /bankedit 2 or /bankedit/2 to inform REAPER that the active 186 | # track bank is bank 2. If NUM_TRACKS is 8, that means REAPER will now interpret 187 | # a message like /track/1/volume as targeting the volume for track 9, and REAPER 188 | # will only send the device feedback messages for tracks 9-16. 189 | 190 | # ---------------------------------------------------------------- 191 | 192 | # Note: the default configuration includes a lot of feedback messages, which can 193 | # flood the device. Avoid flooding by removing messages (by deleting the patterns, 194 | # or commenting out the lines) that the device does not want, especially the 195 | # TIME, BEAT, SAMPLES, FRAMES, VU, FX_PARAM, LAST_MARKER, LAST_REGION messages. 196 | 197 | # Note: FX parameter feedback will only be sent for the track that is currently 198 | # selected in the device. If messages exist that can target FX on other tracks, 199 | # feedback will be sent whenever the parameter values change. This can be a lot of 200 | # data, so only include those messages if you want the feedback. 201 | # Example: FX_PARAM_VALUE /fxparam/@/value /fx/@/fxparam/@/value 202 | # This action can only target FX on the currently selected track, so feedback will 203 | # only be sent for that track. 204 | # Example: FX_PARAM_VALUE /fxparam/@/value /fx/@/fxparam/@/value /track/@/fx/@/fxparam/@/value 205 | # This action can target FX on any track, so feedback will be sent for all tracks. 206 | 207 | # Note: multiple patterns for a given action can all be listed on the same line, 208 | # or split onto separate lines. 209 | 210 | # ---------------------------------------------------------------- 211 | 212 | # The default REAPER OSC pattern configuration follows. To create a custom 213 | # configuration, copy this file and edit the copy. 214 | 215 | 216 | SCROLL_X- b/scroll/x/- r/scroll/x 217 | SCROLL_X+ b/scroll/x/+ r/scroll/x 218 | SCROLL_Y- b/scroll/y/- r/scroll/y 219 | SCROLL_Y+ b/scroll/y/+ r/scroll/y 220 | ZOOM_X- b/zoom/x/- r/zoom/x 221 | ZOOM_X+ b/zoom/x/+ r/zoom/x 222 | ZOOM_Y- b/zoom/y/- r/zoom/y 223 | ZOOM_Y+ b/zoom/y/+ r/zoom/y 224 | 225 | #TIME f/time s/time/str 226 | #BEAT s/beat/str 227 | #SAMPLES f/samples s/samples/str 228 | #FRAMES s/frames/str 229 | 230 | METRONOME t/click 231 | REPLACE t/replace 232 | REPEAT t/repeat 233 | 234 | RECORD t/record 235 | STOP t/stop 236 | PLAY t/play 237 | PAUSE t/pause 238 | 239 | AUTO_REC_ARM t/autorecarm 240 | SOLO_RESET t/soloreset 241 | ANY_SOLO b/anysolo 242 | 243 | REWIND b/rewind 244 | FORWARD b/forward 245 | 246 | REWIND_FORWARD_BYMARKER t/bymarker 247 | REWIND_FORWARD_SETLOOP t/editloop 248 | GOTO_MARKER i/marker t/marker/@ 249 | GOTO_REGION i/region t/region/@ 250 | 251 | SCRUB r/scrub 252 | 253 | PLAY_RATE n/playrate f/playrate/raw r/playrate/rotary s/playrate/str 254 | TEMPO n/tempo f/tempo/raw r/tempo/rotary s/tempo/str 255 | 256 | # writing a marker or region time may change its index -- you should use the *ID_ versions below if needed 257 | MARKER_NAME s/marker/@/name 258 | MARKER_NUMBER s/marker/@/number/str 259 | MARKER_TIME f/marker/@/time 260 | REGION_NAME s/region/@/name 261 | REGION_NUMBER s/region/@/number/str 262 | REGION_TIME f/region/@/time 263 | REGION_LENGTH f/region/@/length 264 | LAST_MARKER_NAME s/lastmarker/name 265 | LAST_MARKER_NUMBER s/lastmarker/number/str 266 | LAST_MARKER_TIME f/lastmarker/time 267 | LAST_REGION_NAME s/lastregion/name 268 | LAST_REGION_NUMBER s/lastregion/number/str 269 | LAST_REGION_TIME f/lastregion/time 270 | LAST_REGION_LENGTH f/lastregion/length 271 | 272 | 273 | # these are write-only, ID is the "NUMBER" field from above -- if not found, creates the marker/region 274 | MARKERID_NAME s/marker_id/@/name 275 | MARKERID_TIME f/marker_id/@/time 276 | MARKERID_NUMBER i/marker_id/@/number 277 | REGIONID_NAME s/region_id/@/name 278 | REGIONID_TIME f/region_id/@/time 279 | REGIONID_LENGTH f/region_id/@/length 280 | REGIONID_NUMBER i/region_id/@/number 281 | 282 | LOOP_START_TIME f/loop/start/time 283 | LOOP_END_TIME f/loop/end/time 284 | 285 | MASTER_VOLUME n/master/volume s/master/volume/str 286 | MASTER_PAN n/master/pan s/master/pan/str 287 | MASTER_VU n/master/vu 288 | MASTER_VU_L n/master/vu/L 289 | MASTER_VU_R n/master/vu/R 290 | 291 | MASTER_SEND_NAME s/master/send/@/name 292 | MASTER_SEND_VOLUME n/master/send/@/volume s/master/send/@/volume/str 293 | MASTER_SEND_PAN n/master/send/@/pan s/master/send/@/pan/str 294 | 295 | TRACK_NAME s/track/name s/track/@/name 296 | TRACK_NUMBER s/track/number/str s/track/@/number/str 297 | 298 | TRACK_MUTE b/track/mute b/track/@/mute t/track/mute/toggle t/track/@/mute/toggle 299 | TRACK_SOLO b/track/solo b/track/@/solo t/track/solo/toggle t/track/@/solo/toggle 300 | TRACK_REC_ARM b/track/recarm b/track/@/recarm t/track/recarm/toggle t/track/@/recarm/toggle 301 | 302 | TRACK_MONITOR b/track/monitor b/track/@/monitor i/track/monitor i/track/@/monitor 303 | TRACK_SELECT b/track/select b/track/@/select 304 | 305 | TRACK_VU n/track/vu n/track/@/vu 306 | TRACK_VU_L n/track/vu/L n/track/@/vu/L 307 | TRACK_VU_R n/track/vu/R n/track/@/vu/R 308 | TRACK_VOLUME n/track/volume n/track/@/volume 309 | TRACK_VOLUME s/track/volume/str s/track/@/volume/str 310 | TRACK_VOLUME f/track/volume/db f/track/@/volume/db 311 | TRACK_PAN n/track/pan n/track/@/pan s/track/pan/str s/track/@/pan/str 312 | TRACK_PAN2 n/track/pan2 n/track/@/pan2 s/track/pan2/str s/track/@/pan2/str 313 | TRACK_PAN_MODE s/track/panmode s/track/@/panmode 314 | 315 | TRACK_SEND_NAME s/track/send/@/name s/track/@/send/@/name 316 | TRACK_SEND_VOLUME n/track/send/@/volume n/track/@/send/@/volume 317 | TRACK_SEND_VOLUME s/track/send/@/volume/str s/track/@/send/@/volume/str 318 | TRACK_SEND_PAN n/track/send/@/pan n/track/@/send/@/pan 319 | TRACK_SEND_PAN s/track/send/@/pan/str s/track/@/send/@/pan/str 320 | 321 | TRACK_RECV_NAME s/track/recv/@/name s/track/@/recv/@/name 322 | TRACK_RECV_VOLUME n/track/recv/@/volume n/track/@/recv/@/volume 323 | TRACK_RECV_VOLUME s/track/recv/@/volume/str s/track/@/recv/@/volume/str 324 | TRACK_RECV_PAN n/track/recv/@/pan n/track/@/recv/@/pan 325 | TRACK_RECV_PAN s/track/recv/@/pan/str s/track/@/recv/@/pan/str 326 | 327 | TRACK_AUTO s/track/auto 328 | TRACK_AUTO_TRIM t/track/autotrim t/track/@/autotrim 329 | TRACK_AUTO_READ t/track/autoread t/track/@/autoread 330 | TRACK_AUTO_LATCH t/track/autolatch t/track/@/autolatch 331 | TRACK_AUTO_TOUCH t/track/autotouch t/track/@/autotouch 332 | TRACK_AUTO_WRITE t/track/autowrite t/track/@/autowrite 333 | 334 | TRACK_VOLUME_TOUCH b/track/volume/touch b/track/@/volume/touch 335 | TRACK_PAN_TOUCH b/track/pan/touch b/track/@/pan/touch 336 | 337 | FX_NAME s/fx/name s/fx/@/name s/track/@/fx/@/name 338 | FX_NUMBER s/fx/number/str s/fx/@/number/str s/track/@/fx/@/number/str 339 | FX_BYPASS b/fx/bypass b/fx/@/bypass b/track/@/fx/@/bypass 340 | FX_OPEN_UI b/fx/openui b/fx/@/openui b/track/@/fx/@/openui 341 | 342 | FX_PRESET s/fx/preset s/fx/@/preset s/track/@/fx/@/preset 343 | FX_PREV_PRESET t/fx/preset- t/fx/@/preset- t/track/@/fx/@/preset- 344 | FX_NEXT_PRESET t/fx/preset+ t/fx/@/preset+ t/track/@/fx/@/preset+ 345 | 346 | FX_PARAM_NAME s/fxparam/@/name s/fx/@/fxparam/@/name 347 | FX_WETDRY n/fx/wetdry n/fx/@/wetdry n/track/@/fx/@/wetdry 348 | FX_WETDRY s/fx/wetdry/str s/fx/@/wetdry/str s/track/@/fx/@/wetdry/str 349 | FX_PARAM_VALUE n/fxparam/@/value n/fx/@/fxparam/@/value n/track/@/fx/@/fxparam/@/value 350 | FX_PARAM_VALUE s/fxparam/@/value/str s/fx/@/fxparam/@/value/str 351 | 352 | FX_EQ_BYPASS b/fxeq/bypass b/track/@/fxeq/bypass 353 | FX_EQ_OPEN_UI b/fxeq/openui b/track/@/fxeq/openui 354 | 355 | FX_EQ_PRESET s/fxeq/preset s/track/@/fxeq/preset 356 | FX_EQ_PREV_PRESET s/fxeq/preset- s/track/@/fxeq/preset- 357 | FX_EQ_NEXT_PRESET s/fxeq/preset+ s/track/@/fxeq/preset+ 358 | 359 | FX_EQ_MASTER_GAIN n/fxeq/gain n/track/@/fxeq/gain 360 | FX_EQ_MASTER_GAIN f/fxeq/gain/db f/track/@/fxeq/gain/db s/fxeq/gain/str 361 | FX_EQ_WETDRY n/fxeq/wetdry n/track/@/fxeq/wetdry 362 | FX_EQ_WETDRY s/fxeq/wetdry/str s/track/@/fxeq/wetdry/str 363 | 364 | FX_EQ_HIPASS_NAME s/fxeq/hipass/str 365 | FX_EQ_HIPASS_BYPASS b/fxeq/hipass/bypass 366 | FX_EQ_HIPASS_FREQ n/fxeq/hipass/freq n/track/@/fxeq/hipass/freq 367 | FX_EQ_HIPASS_FREQ f/fxeq/hipass/freq/hz f/track/@/fxeq/hipass/freq/hz 368 | FX_EQ_HIPASS_FREQ s/fxeq/hipass/freq/str s/track/@/fxeq/hipass/freq/str 369 | FX_EQ_HIPASS_Q n/fxeq/hipass/q n/track/@/fxeq/hipass/q 370 | FX_EQ_HIPASS_Q f/fxeq/hipass/q/oct f/track/@/fxeq/hipass/q/oct 371 | FX_EQ_HIPASS_Q s/fxeq/hipass/q/str s/track/@/fxeq/hipass/q/str 372 | 373 | FX_EQ_LOSHELF_NAME s/fxeq/loshelf/str 374 | FX_EQ_LOSHELF_BYPASS b/fxeq/loshelf/bypass 375 | FX_EQ_LOSHELF_FREQ n/fxeq/loshelf/freq n/track/@/fxeq/loshelf/freq 376 | FX_EQ_LOSHELF_FREQ f/fxeq/loshelf/freq/hz f/track/@/fxeq/loshelf/freq/hz 377 | FX_EQ_LOSHELF_FREQ s/fxeq/loshelf/freq/str s/track/@/fxeq/loshelf/freq/str 378 | FX_EQ_LOSHELF_GAIN n/fxeq/loshelf/gain n/track/@/fxeq/loshelf/gain 379 | FX_EQ_LOSHELF_GAIN f/fxeq/loshelf/gain/db f/track/@/fxeq/loshelf/gain/db 380 | FX_EQ_LOSHELF_GAIN s/fxeq/loshelf/gain/str s/track/@/fxeq/loshelf/gain/str 381 | FX_EQ_LOSHELF_Q n/fxeq/loshelf/q n/track/@/fxeq/loshelf/q 382 | FX_EQ_LOSHELF_Q f/fxeq/loshelf/q/oct f/track/@/fxeq/loshelf/q/oct 383 | FX_EQ_LOSHELF_Q s/fxeq/loshelf/q/str s/track/@/fxeq/loshelf/q/str 384 | 385 | FX_EQ_BAND_NAME s/fxeq/band/str 386 | FX_EQ_BAND_BYPASS b/fxeq/band/@/bypass 387 | FX_EQ_BAND_FREQ n/fxeq/band/@/freq n/track/@/fxeq/band/@/freq 388 | FX_EQ_BAND_FREQ f/fxeq/band/@/freq/hz f/track/@/fxeq/band/@/freq/hz 389 | FX_EQ_BAND_FREQ s/fxeq/band/@/freq/str s/track/@/fxeq/band/@/freq/str 390 | FX_EQ_BAND_GAIN n/fxeq/band/@/gain n/track/@/fxeq/band/@/gain 391 | FX_EQ_BAND_GAIN f/fxeq/band/@/gain/db f/track/@/fxeq/band/@/gain/db 392 | FX_EQ_BAND_GAIN s/fxeq/band/@/gain/str s/track/@/fxeq/band/@/gain/str 393 | FX_EQ_BAND_Q n/fxeq/band/@/q n/track/@/fxeq/band/@/q 394 | FX_EQ_BAND_Q f/fxeq/band/@/q/oct f/track/@/fxeq/band/@/q/oct 395 | FX_EQ_BAND_Q s/fxeq/band/@/q/str s/track/@/fxeq/band/@/q/str 396 | 397 | FX_EQ_NOTCH_NAME s/fxeq/notch/str 398 | FX_EQ_NOTCH_BYPASS b/fxeq/notch/bypass 399 | FX_EQ_NOTCH_FREQ n/fxeq/notch/freq n/track/@/fxeq/notch/freq 400 | FX_EQ_NOTCH_FREQ f/fxeq/notch/freq/hz f/track/@/fxeq/notch/freq/hz 401 | FX_EQ_NOTCH_FREQ s/fxeq/notch/freq/str s/track/@/fxeq/notch/freq/str 402 | FX_EQ_NOTCH_GAIN n/fxeq/notch/gain n/track/@/fxeq/notch/gain 403 | FX_EQ_NOTCH_GAIN f/fxeq/notch/gain/db f/track/@/fxeq/notch/gain/db 404 | FX_EQ_NOTCH_GAIN s/fxeq/notch/gain/str s/track/@/fxeq/notch/gain/str 405 | FX_EQ_NOTCH_Q n/fxeq/notch/q n/track/@/fxeq/notch/q 406 | FX_EQ_NOTCH_Q f/fxeq/notch/q/oct f/track/@/fxeq/notch/q/oct 407 | FX_EQ_NOTCH_Q s/fxeq/notch/q/str s/track/@/fxeq/notch/q/str 408 | 409 | FX_EQ_HISHELF_NAME s/fxeq/hishelf/str 410 | FX_EQ_HISHELF_BYPASS b/fxeq/hishelf/bypass 411 | FX_EQ_HISHELF_FREQ n/fxeq/hishelf/freq n/track/@/fxeq/hishelf/freq 412 | FX_EQ_HISHELF_FREQ f/fxeq/hishelf/freq/hz f/track/@/fxeq/hishelf/freq/hz 413 | FX_EQ_HISHELF_FREQ s/fxeq/hishelf/freq/str s/track/@/fxeq/hishelf/freq/str 414 | FX_EQ_HISHELF_GAIN n/fxeq/hishelf/gain n/track/@/fxeq/hishelf/gain 415 | FX_EQ_HISHELF_GAIN f/fxeq/hishelf/gain/sb f/track/@/fxeq/hishelf/gain/db 416 | FX_EQ_HISHELF_GAIN s/fxeq/hishelf/gain/str s/track/@/fxeq/hishelf/gain/str 417 | FX_EQ_HISHELF_Q n/fxeq/hishelf/q n/track/@/fxeq/hishelf/q 418 | FX_EQ_HISHELF_Q f/fxeq/hishelf/q/oct f/track/@/fxeq/hishelf/q/oct 419 | FX_EQ_HISHELF_Q s/fxeq/hishelf/q/str s/track/@/fxeq/hishelf/q/str 420 | 421 | FX_EQ_LOPASS_NAME s/fxeq/lopass/str 422 | FX_EQ_LOPASS_BYPASS b/fxeq/lopass/bypass 423 | FX_EQ_LOPASS_FREQ n/fxeq/lopass/freq n/track/@/fxeq/lopass/freq 424 | FX_EQ_LOPASS_FREQ f/fxeq/lopass/freq/hz f/track/@/fxeq/lopass/freq/hz 425 | FX_EQ_LOPASS_FREQ s/fxeq/lopass/freq/str s/track/@/fxeq/lopass/freq/str 426 | FX_EQ_LOPASS_Q n/fxeq/lopass/q n/track/@/fxeq/lopass/q 427 | FX_EQ_LOPASS_Q f/fxeq/lopass/q/oct f/track/@/fxeq/lopass/q/oct 428 | FX_EQ_LOPASS_Q s/fxeq/lopass/q/str s/track/@/fxeq/lopass/q/str 429 | 430 | FX_INST_NAME s/fxinst/name s/track/@/fxinst/name 431 | FX_INST_BYPASS b/fxinst/bypass b/track/@/fxinst/bypass 432 | FX_INST_OPEN_UI b/fxinst/openui b/track/@/fxinst/openui 433 | 434 | FX_INST_PRESET s/fxinst/preset s/track/@/fxinst/preset 435 | FX_INST_PREV_PRESET t/fxinst/preset- t/track/@/fxinst/preset- 436 | FX_INST_NEXT_PRESET t/fxinst/preset+ t/track/@/fxinst/preset+ 437 | 438 | FX_INST_PARAM_NAME s/fxinstparam/@/name 439 | FX_INST_PARAM_VALUE n/fxinstparam/@/value n/track/@/fxinstparam/@/value 440 | FX_INST_PARAM_VALUE s/fxinstparam/@/value/str 441 | 442 | LAST_TOUCHED_FX_TRACK_NAME s/fx/last_touched/track/name 443 | LAST_TOUCHED_FX_TRACK_NUMBER s/fx/last_touched/track/number/str 444 | LAST_TOUCHED_FX_NAME s/fx/last_touched/name 445 | LAST_TOUCHED_FX_NUMBER s/fx/last_touched/number/str 446 | LAST_TOUCHED_FX_PARAM_NAME s/fxparam/last_touched/name 447 | LAST_TOUCHED_FX_PARAM_VALUE n/fxparam/last_touched/value s/fxparam/last_touched/value/str 448 | 449 | # these send MIDI to the vkb MIDI input. parameters are raw MIDI. 450 | 451 | # for notes, if two, first wildcard is channel (0-15). MIDI note number is required (as decimal integer only!) 452 | # if parameter value is 0, note-off, otherwise note-on 453 | VKB_MIDI_NOTE i/vkb_midi/@/note/@ f/vkb_midi/@/note/@ i/vkb_midi/note/@ f/vkb_midi/note/@ 454 | # similar, but for 0xA0 (poly aftertouch) 455 | VKB_MIDI_POLYAFTERTOUCH i/vkb_midi/@/polyaftertouch/@ f/vkb_midi/@/polyaftertouch/@ i/vkb_midi/polyaftertouch/@ f/vkb_midi/polyaftertouch/@ 456 | # for CCs, if two, first wildcard is channel (0-15). MIDI CC number is required (as decimal integer only!) 457 | VKB_MIDI_CC i/vkb_midi/@/cc/@ f/vkb_midi/@/cc/@ i/vkb_midi/cc/@ f/vkb_midi/cc/@ 458 | # program change (0xC0) can take channel as wildcard, or value only 459 | VKB_MIDI_PROGRAM i/vkb_midi/@/program f/vkb_midi/@/program i/vkb_midi/program f/vkb_midi/program 460 | # channel pressure (aftertouch) (0xD0) can take channel as wildcard, or value only 461 | VKB_MIDI_CHANNELPRESSURE i/vkb_midi/@/channelpressure f/vkb_midi/@/channelpressure i/vkb_midi/channelpressure f/vkb_midi/channelpressure 462 | # pitch can take channel as wildcard, or value only 463 | VKB_MIDI_PITCH i/vkb_midi/@/pitch f/vkb_midi/@/pitch i/vkb_midi/pitch f/vkb_midi/pitch 464 | 465 | 466 | ACTION i/action s/action/str t/action/@ f/action/@/cc 467 | ACTION_SOFT f/action/@/cc/soft 468 | ACTION_RELATIVE f/action/@/cc/relative 469 | MIDIACTION i/midiaction t/midiaction/@ 470 | MIDILISTACTION i/midilistaction t/midilistaction/@ 471 | 472 | # ---------------------------------------------------------------- 473 | 474 | # The following messages are sent from the device, to inform REAPER 475 | # of a change in the device state, behavior, or display capabilities. 476 | 477 | DEVICE_TRACK_COUNT i/device/track/count t/device/track/count/@ 478 | DEVICE_SEND_COUNT i/device/send/count t/device/send/count/@ 479 | DEVICE_RECEIVE_COUNT i/device/receive/count t/device/receive/count/@ 480 | DEVICE_FX_COUNT i/device/fx/count t/device/fx/count/@ 481 | DEVICE_FX_PARAM_COUNT i/device/fxparam/count t/device/fxparam/count/@ 482 | DEVICE_FX_INST_PARAM_COUNT i/device/fxinstparam/count t/device/fxinstparam/count/@ 483 | DEVICE_MARKER_COUNT i/device/marker/count t/device/marker/count/@ 484 | DEVICE_REGION_COUNT i/device/region/count t/device/region/count/@ 485 | 486 | REAPER_TRACK_FOLLOWS s/reaper/track/follows 487 | REAPER_TRACK_FOLLOWS_REAPER t/reaper/track/follows/reaper 488 | REAPER_TRACK_FOLLOWS_DEVICE t/reaper/track/follows/device 489 | 490 | DEVICE_TRACK_FOLLOWS s/device/track/follows 491 | DEVICE_TRACK_FOLLOWS_DEVICE t/device/track/follows/device 492 | DEVICE_TRACK_FOLLOWS_LAST_TOUCHED t/device/track/follows/last_touched 493 | 494 | DEVICE_TRACK_BANK_FOLLOWS s/device/track/bank/follows 495 | DEVICE_TRACK_BANK_FOLLOWS_DEVICE t/device/track/bank/follows/device 496 | DEVICE_TRACK_BANK_FOLLOWS_MIXER t/device/track/bank/follows/mixer 497 | 498 | DEVICE_FX_FOLLOWS s/device/fx/follows 499 | DEVICE_FX_FOLLOWS_DEVICE t/device/fx/follows/device 500 | DEVICE_FX_FOLLOWS_LAST_TOUCHED t/device/fx/follows/last_touched 501 | DEVICE_FX_FOLLOWS_FOCUSED t/device/fx/follows/focused 502 | 503 | DEVICE_TRACK_SELECT i/device/track/select t/device/track/select/@ 504 | DEVICE_PREV_TRACK t/device/track/- 505 | DEVICE_NEXT_TRACK t/device/track/+ 506 | 507 | DEVICE_TRACK_BANK_SELECT i/device/track/bank/select t/device/track/bank/select/@ 508 | DEVICE_PREV_TRACK_BANK t/device/track/bank/- 509 | DEVICE_NEXT_TRACK_BANK t/device/track/bank/+ 510 | 511 | DEVICE_FX_SELECT i/device/fx/select t/device/fx/select/@ 512 | DEVICE_PREV_FX t/device/fx/- 513 | DEVICE_NEXT_FX t/device/fx/+ 514 | 515 | DEVICE_FX_PARAM_BANK_SELECT i/device/fxparam/bank/select t/device/fxparam/bank/select/@ 516 | DEVICE_FX_PARAM_BANK_SELECT s/device/fxparam/bank/str 517 | DEVICE_PREV_FX_PARAM_BANK t/device/fxparam/bank/- 518 | DEVICE_NEXT_FX_PARAM_BANK t/device/fxparam/bank/+ 519 | 520 | DEVICE_FX_INST_PARAM_BANK_SELECT i/device/fxinstparam/bank/select t/device/fxinstparam/bank/select/@ 521 | DEVICE_FX_INST_PARAM_BANK_SELECT s/device/fxinstparam/bank/str 522 | DEVICE_PREV_FX_INST_PARAM_BANK t/device/fxinstparam/bank/- 523 | DEVICE_NEXT_FX_INST_PARAM_BANK t/device/fxinstparam/bank/+ 524 | 525 | DEVICE_MARKER_BANK_SELECT i/device/marker/bank/select t/device/marker/bank/select/@ 526 | DEVICE_PREV_MARKER_BANK t/device/marker/bank/- 527 | DEVICE_NEXT_MARKER_BANK t/device/marker/bank/+ 528 | 529 | DEVICE_REGION_BANK_SELECT i/device/region/bank/select t/device/region/bank/select/@ 530 | DEVICE_PREV_REGION_BANK t/device/region/bank/- 531 | DEVICE_NEXT_REGION_BANK t/device/region/bank/+ 532 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Justin Stasiw 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /LocalBuild.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | 4 | block_cipher = None 5 | 6 | 7 | a = Analysis(['main.py'], 8 | pathex=[], 9 | binaries=[], 10 | datas=[], 11 | hiddenimports=[], 12 | hookspath=[], 13 | hooksconfig={}, 14 | runtime_hooks=[], 15 | excludes=[], 16 | win_no_prefer_redirects=False, 17 | win_private_assemblies=False, 18 | cipher=block_cipher, 19 | noarchive=False) 20 | pyz = PYZ(a.pure, a.zipped_data, 21 | cipher=block_cipher) 22 | 23 | exe = EXE(pyz, 24 | a.scripts, 25 | [], 26 | exclude_binaries=True, 27 | name='Digico-Reaper Link', 28 | debug=False, 29 | bootloader_ignore_signals=False, 30 | strip=False, 31 | upx=True, 32 | console=False, 33 | disable_windowed_traceback=False, 34 | codesign_identity=None, 35 | entitlements_file=None , icon='resources/rprdigi.icns') 36 | 37 | coll = COLLECT(exe, 38 | a.binaries, 39 | a.zipfiles, 40 | a.datas, 41 | strip=False, 42 | upx=True, 43 | upx_exclude=[], 44 | name='Digico-Reaper Link') 45 | app = BUNDLE(coll, 46 | name='Digico-Reaper Link.app', 47 | icon='resources/rprdigi.icns', 48 | bundle_identifier=None) 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Digico-Reaper-Link 2 | 3 | Digico-Reaper Link GUI 4 | 5 | This is a small GUI based helper application designed to help with using Digico's Copy Audio function with a Reaper DAW used for recording. 6 | 7 | This Readme is for V3.3.8 and later, which is a signficant new iteration for the application. V1 is still available in releases, but the information below will not apply. 8 | 9 | If you just want to download the software- here's the link: 10 | 11 | Download Here! 12 | 13 | The application is available for macOS (Arm and Intel) as well as Windows. The macOS builds are signed and notarized. The Windows builds are not signed, and may be flagged by SmartScreen. Also, the Windows build may require you to build a firewall rule that allows the application through the firewall, at least my testing machine did. 14 | 15 | Here's what it does: 16 | 17 | In recording mode, if Reaper is actively recording, every time a cue is taken on the console, a marker is dropped into the Reaper. The name of the marker is labeled by the cue number plus the cue name. 18 | 19 | In playback tracking mode, if Reaper is not actively playing, and the console recalls a cue, Reaper will jump the cursor to position of the first marker that exists that matches the cue number/name that was just recalled. If Reaper is playing, Reaper will not react to cue recalls on the desk. 20 | 21 | In playback no tracking mode, Reaper will not do anything in response to cue recalls on the desk. 22 | 23 | How the software works: 24 | 25 | Digico-Reaper Link makes an OSC connection to the Digico console, emulating an iPad interface. It also makes an internal OSC connection to Reaper. The software acts as an OSC translation layer. 26 | 27 | How to set it up: 28 | 29 | On your Digico console, configure External Control for the IP address of your Reaper computer and load the iPad command set. 30 | Set the ports as you desire. Your configuration on the console should look something like this: 31 | 32 | ![external control](https://user-images.githubusercontent.com/79057472/141206529-99671316-4b3b-47c3-96af-803fbd5f8889.jpg) 33 | 34 | This will use your iPad slot on your Digico console. If you are running a dual engine console, you can connect Digico-Reaper Link to one engine and an iPad to another, but on a single engine desk, if you want to run and iPad as well as Digico-Reaper Link, you can use the repeater functionality inside of Digico-Reaper Link. In the preferences you can set up a repeater address, and if you place a iPad at that location (or other relevant OSC device), Digico-Reaper Link will repeater bi-directionally all of the OSC required for that device. 35 | 36 | You don't need to do any configuration in Reaper. When you open Digico-Reaper Link, if Reaper is not running, it will prompt you to open Reaper. The first time Digico-Reaper Link sees Reaper, it will write a new interface to Reaper's OSC interface list. It will then prompt you to close and reopen Reaper, to initialize the new interface. Then every open it will check that the correct interface is in place, and continue to make connections as long as it is. 37 | 38 | When Digico-Reaper Link is open, go to File-->Preferences, and input your consoles IP address and the ports you are using, and you should be all set! 39 | 40 | Digico-Reaper Perfs 41 | 42 | 43 | Features (Updated 5/23/25): 44 | 45 | Name Only Mode- There is now a preference to match on name of Cue/Snapshot only. If you are reordering your snapshots, and want it to jump to the marker disregarding the cue number, this preference will make it match on name only. Obviously, this assumes your cue names are unique, if they are not, it will just go to the first marker that matches the name of the cue. 46 | 47 | Repeater- If you want OSC to pass through this app to another device (such as an ipad)- you can now set that up in the preferences page of the app, and the app will repeat OSC to another IP address/ports. 48 | 49 | Heartbeat with Digico- In the UI window, the red square that says N/C will turn to green and have the type of console in it when a Digico console connection is established. This status is refreshed every 5 seconds, so you should be able to easily tell if you've lost connection with the console. 50 | 51 | Drop Marker Button- Useful for confirming that your connection to Reaper is sound, this will drop a marker into Reaper upon button press in the UI. 52 | 53 | Attempt Reconnect Button- This closes and reopens all of the connections to the OSC Clients/Servers. Useful if you have changed your network configuration or a cable has become disconnected, you can reset the connections without closing and reopening the app. 54 | 55 | Macros from Digico- You can now control Reaper from macros on your Digico console through Digico-Reaper Link. All you have to do is label the macros- they don't have to have any actions in them. Supported behaviors are. 56 | 57 | Play
58 | Stop
59 | Record (This is a safe record, it will always skip to the end of all recordings and then start recording, as well as place you into recording mode)
60 | Drop Marker
61 | Record Mode
62 | Playback Tracking Mode
63 | Playback No Track Mode
64 | 65 | 66 | You can label the macros with any of the options below and Reaper-Digico Link will detect them (any capitalization anywhere will be ignored): 67 | 68 | Reaper,Play- Reaper Play - Play
69 | Reaper,Stop - Reaper Stop - Stop
70 | Reaper,Rec - Reaper Rec - Rec - Record
71 | Reaper,Marker - Reaper Marker - Marker
72 | Mode,Rec - Mode Rec - Mode Recording
73 | Mode,Track - Mode, Tracking - Mode Track - Mode Tracking
74 | Mode,No Track - Mode No Track - Mode No Tracking
75 | 76 | 77 | See an example in the images below: 78 | 79 | ![macro buttons](https://github.com/user-attachments/assets/b23ca08f-a874-4b6a-871b-9007d02613c6)![macros](https://github.com/user-attachments/assets/954f9f07-a841-4ba6-90ad-ab294a9e27c7) 80 | 81 | 82 | If this software has been useful to you, consider making a donation via the github sponsors system below: 83 | 84 | [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/jms5194) 85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /app_settings.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | class ThreadSafeSettings: 4 | def __init__(self): 5 | self._lock = threading.Lock() 6 | self._settings = { 7 | 'console_ip' : "10.10.10.1", 8 | 'reaper_ip' : "127.0.0.1", 9 | 'repeater_ip' : "10.10.10.10", 10 | 'repeater_port' : 9999, 11 | 'repeater_receive_port' : 9998, 12 | 'reaper_port' : 49101, 13 | 'reaper_receive_port' : 49102, 14 | 'console_port' : 8001, 15 | 'receive_port' : 8000, 16 | 'forwarder_enabled' : "False", 17 | 'marker_mode' : "PlaybackTrack", 18 | 'window_loc' : (400, 222), 19 | 'window_size' : (221, 310), 20 | 'name_only_match' : False 21 | } 22 | 23 | @property 24 | def console_ip(self): 25 | with self._lock: 26 | return self._settings['console_ip'] 27 | 28 | @console_ip.setter 29 | def console_ip(self, value): 30 | with self._lock: 31 | self._settings['console_ip'] = value 32 | 33 | @property 34 | def reaper_ip(self): 35 | with self._lock: 36 | return self._settings['reaper_ip'] 37 | 38 | @reaper_ip.setter 39 | def reaper_ip(self, value): 40 | with self._lock: 41 | self._settings['reaper_ip'] = value 42 | 43 | @property 44 | def repeater_ip(self): 45 | with self._lock: 46 | return self._settings['repeater_ip'] 47 | 48 | @repeater_ip.setter 49 | def repeater_ip(self, value): 50 | with self._lock: 51 | self._settings['repeater_ip'] = value 52 | 53 | @property 54 | def repeater_port(self): 55 | with self._lock: 56 | return self._settings['repeater_port'] 57 | 58 | @repeater_port.setter 59 | def repeater_port(self, value): 60 | with self._lock: 61 | port_num = int(value) 62 | if not 1 <= port_num <= 65535: 63 | raise ValueError("Invalid port number") 64 | self._settings['repeater_port'] = port_num 65 | 66 | @property 67 | def repeater_receive_port(self): 68 | with self._lock: 69 | return self._settings['repeater_receive_port'] 70 | 71 | @repeater_receive_port.setter 72 | def repeater_receive_port(self, value): 73 | with self._lock: 74 | port_num = int(value) 75 | if not 1 <= port_num <= 65535: 76 | raise ValueError("Invalid port number") 77 | self._settings['repeater_receive_port'] = port_num 78 | 79 | @property 80 | def reaper_port(self): 81 | with self._lock: 82 | return self._settings['reaper_port'] 83 | 84 | @reaper_port.setter 85 | def reaper_port(self, value): 86 | with self._lock: 87 | port_num = int(value) 88 | if not 1 <= port_num <= 65535: 89 | raise ValueError("Invalid port number") 90 | self._settings['reaper_port'] = port_num 91 | 92 | @property 93 | def reaper_receive_port(self): 94 | with self._lock: 95 | return self._settings['reaper_receive_port'] 96 | 97 | @reaper_receive_port.setter 98 | def reaper_receive_port(self, value): 99 | with self._lock: 100 | port_num = int(value) 101 | if not 1 <= port_num <= 65535: 102 | raise ValueError("Invalid port number") 103 | self._settings['reaper_receive_port'] = port_num 104 | 105 | @property 106 | def console_port(self): 107 | with self._lock: 108 | return self._settings['console_port'] 109 | 110 | @console_port.setter 111 | def console_port(self, value): 112 | with self._lock: 113 | port_num = int(value) 114 | if not 1 <= port_num <= 65535: 115 | raise ValueError("Invalid port number") 116 | self._settings['console_port'] = port_num 117 | 118 | @property 119 | def receive_port(self): 120 | with self._lock: 121 | return self._settings['receive_port'] 122 | 123 | @receive_port.setter 124 | def receive_port(self, value): 125 | with self._lock: 126 | port_num = int(value) 127 | if not 1 <= port_num <= 65535: 128 | raise ValueError("Invalid port number") 129 | self._settings['receive_port'] = port_num 130 | 131 | @property 132 | def forwarder_enabled(self): 133 | with self._lock: 134 | return self._settings['forwarder_enabled'] 135 | 136 | @forwarder_enabled.setter 137 | def forwarder_enabled(self, value): 138 | with self._lock: 139 | self._settings['forwarder_enabled'] = value 140 | 141 | @property 142 | def marker_mode(self): 143 | with self._lock: 144 | return self._settings['marker_mode'] 145 | 146 | @marker_mode.setter 147 | def marker_mode(self, value): 148 | with self._lock: 149 | self._settings['marker_mode'] = value 150 | 151 | @property 152 | def window_loc(self): 153 | with self._lock: 154 | return self._settings['window_loc'] 155 | 156 | @window_loc.setter 157 | def window_loc(self, value): 158 | with self._lock: 159 | self._settings['window_loc'] = value 160 | 161 | @property 162 | def window_size(self): 163 | with self._lock: 164 | return self._settings['window_size'] 165 | 166 | @window_size.setter 167 | def window_size(self, value): 168 | with self._lock: 169 | self._settings['window_size'] = value 170 | 171 | @property 172 | def name_only_match(self): 173 | with self._lock: 174 | return self._settings['name_only_match'] 175 | 176 | @name_only_match.setter 177 | def name_only_match(self, value): 178 | with self._lock: 179 | self._settings['name_only_match'] = value 180 | 181 | def update_from_config(self, config): 182 | # Update settings from a ConfigParser object 183 | with self._lock: 184 | self._settings.update({ 185 | 'console_ip': config["main"]["default_ip"], 186 | 'repeater_ip': config["main"]["repeater_ip"], 187 | 'console_port': int(config["main"]["default_digico_send_port"]), 188 | 'receive_port': int(config["main"]["default_digico_receive_port"]), 189 | 'reaper_port': int(config["main"]["default_reaper_send_port"]), 190 | 'repeater_port': int(config["main"]["default_repeater_send_port"]), 191 | 'repeater_receive_port': int(config["main"]["default_repeater_receive_port"]), 192 | 'reaper_receive_port': int(config["main"]["default_reaper_receive_port"]), 193 | 'forwarder_enabled': config["main"]["forwarder_enabled"], 194 | 'window_loc': ( 195 | int(config["main"]["window_pos_x"]), 196 | int(config["main"]["window_pos_y"]) 197 | ), 198 | 'window_size': ( 199 | int(config["main"]["window_size_x"]), 200 | int(config["main"]["window_size_y"]) 201 | ), 202 | 'name_only_match' : config["main"]["name_only_match"] 203 | }) 204 | 205 | settings = ThreadSafeSettings() 206 | 207 | 208 | 209 | -------------------------------------------------------------------------------- /configure_reaper.py: -------------------------------------------------------------------------------- 1 | from configparser import ConfigParser 2 | from collections import OrderedDict 3 | import os 4 | import pathlib 5 | import shutil 6 | import psutil 7 | import sys 8 | 9 | # Many thanks to the programmers of Reapy and Reapy-boost for much of this code. 10 | 11 | 12 | class CaseInsensitiveDict(OrderedDict): 13 | """OrderedDict with case-insensitive keys.""" 14 | _dict: OrderedDict 15 | 16 | def __init__(self, *args, **kwargs): 17 | super().__init__(*args, **kwargs) 18 | self._dict = OrderedDict(*args, **kwargs) 19 | for key, value in self._dict.items(): 20 | self._dict[key.lower()] = value 21 | 22 | def __contains__(self, key): 23 | return key.lower() in self._dict 24 | 25 | def __getitem__(self, key): 26 | return self._dict[key.lower()] 27 | 28 | def __setitem__(self, key, value): 29 | super().__setitem__(key, value) 30 | self._dict[key.lower()] = value 31 | 32 | 33 | class Config(ConfigParser): 34 | """Parser for REAPER .ini file.""" 35 | 36 | def __init__(self, ini_file): 37 | super().__init__( 38 | strict=False, delimiters="=", dict_type=CaseInsensitiveDict 39 | ) 40 | self.optionxform = str 41 | self.ini_file = ini_file 42 | if not os.path.exists(ini_file): 43 | pathlib.Path(ini_file).touch() 44 | self.read(self.ini_file, encoding='utf8') 45 | 46 | def write(self): 47 | # Backup config state before user has ever tried Reaper-Digico Link 48 | before_rd_file = self.ini_file + '.before-Reaper-Digico.bak' 49 | if not os.path.exists(before_rd_file): 50 | shutil.copy(self.ini_file, before_rd_file) 51 | # Backup current config 52 | shutil.copy(self.ini_file, self.ini_file + '.bak') 53 | # Write config 54 | with open(self.ini_file, "w", encoding='utf8') as f: 55 | super().write(f, False) 56 | 57 | 58 | def add_OSC_interface(resource_path, rcv_port=8000, snd_port=9000): 59 | """Add a REAPER OSC Interface at a specified port. 60 | 61 | It is added by manually editing reaper.ini configuration file, 62 | which is loaded on startup. Thus, the added web interface will 63 | only be available after restarting REAPER. 64 | 65 | Nothing happens in case an osc interface already exists at 66 | ``port``. 67 | 68 | Parameters 69 | ---------- 70 | resource_path : str 71 | Path to REAPER resource directory. Can be obtained with 72 | :func:`reapy_boost.config.resource_path.get_resource_path`. 73 | rcv_port : int 74 | OSC receive port. Default=``8000``. 75 | snd_port : int 76 | OSC device port. Default= ``9000``. 77 | """ 78 | if osc_interface_exists(resource_path, rcv_port, snd_port): 79 | return 80 | config = Config(os.path.join(resource_path, "reaper.ini")) 81 | csurf_count = int(config["reaper"].get("csurf_cnt", "0")) 82 | csurf_count += 1 83 | config["reaper"]["csurf_cnt"] = str(csurf_count) 84 | key = "csurf_{}".format(csurf_count - 1) 85 | config["reaper"][key] = "OSC \"Reaper-Digico Link\" 3 {sndport} \"127.0.0.1\" {rcvport} 1024 10 \"\"".format(rcvport=rcv_port, sndport=snd_port) 86 | config.write() 87 | 88 | 89 | def osc_interface_exists(resource_path, rcv_port, snd_port): 90 | """Return whether a REAPER OSC Interface exists at a given port. 91 | 92 | Parameters 93 | ---------- 94 | resource_path : str 95 | Path to REAPER resource directory. Can be obtained with 96 | :func:`reapy_boost.config.resource_path.get_resource_path`. 97 | rcv_port : int 98 | OSC receive port. Default=``8000``. 99 | snd_port : int 100 | OSC device port. Default= ``9000``. 101 | 102 | Returns 103 | ------- 104 | bool 105 | Whether a REAPER OSC Interface exists at ``port``. 106 | """ 107 | config = Config(os.path.join(resource_path, "reaper.ini")) 108 | csurf_count = int(config["reaper"].get("csurf_cnt", "0")) 109 | for i in range(csurf_count): 110 | string = config["reaper"]["csurf_{}".format(i)] 111 | if string.startswith("OSC"): # It's a web interface 112 | if string.split(" ")[4] == str(snd_port) and string.split(" ")[6] == str(rcv_port): # It's the one 113 | return True 114 | return False 115 | 116 | 117 | def get_resource_path(detect_portable_install): 118 | for i in get_candidate_directories(detect_portable_install): 119 | if os.path.exists(os.path.join(i, 'reaper.ini')): 120 | return i 121 | raise RuntimeError('Cannot find resource path') 122 | 123 | 124 | def get_candidate_directories(detect_portable_install): 125 | if detect_portable_install: 126 | yield get_portable_resource_directory() 127 | if is_apple(): 128 | yield os.path.expanduser('~/Library/Application Support/REAPER') 129 | elif is_windows(): 130 | yield os.path.expandvars(r'$APPDATA\REAPER') 131 | else: 132 | yield os.path.expanduser('~/.config/REAPER') 133 | 134 | 135 | def get_portable_resource_directory(): 136 | process_path = get_reaper_process_path() 137 | if is_apple(): 138 | return '/'.join(process_path.split('/')[:-4]) 139 | return os.path.dirname(process_path) 140 | 141 | 142 | def get_reaper_process_path(): 143 | """Return path to currently running REAPER process. 144 | 145 | Returns 146 | ------- 147 | str 148 | Path to executable file. 149 | 150 | Raises 151 | ------ 152 | RuntimeError 153 | When zero or more than one REAPER instances are currently 154 | running. 155 | """ 156 | processes = [ 157 | p for p in psutil.process_iter(['name', 'exe']) 158 | if os.path.splitext(p.info['name'] # type:ignore 159 | )[0].lower() == 'reaper' 160 | ] 161 | if not processes: 162 | raise RuntimeError('No REAPER instance is currently running.') 163 | elif len(processes) > 1: 164 | raise RuntimeError( 165 | 'More than one REAPER instance is currently running.' 166 | ) 167 | return processes[0].info['exe'] # type:ignore 168 | 169 | 170 | def is_apple() -> bool: 171 | """Return whether OS is macOS or OSX.""" 172 | return sys.platform == "darwin" 173 | 174 | 175 | def is_windows() -> bool: 176 | """Return whether OS is Windows.""" 177 | return os.name == "nt" 178 | -------------------------------------------------------------------------------- /entitlements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.disable-library-validation 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /logger_config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from logging.handlers import RotatingFileHandler 4 | import appdirs 5 | 6 | 7 | def setup_logger(): 8 | # Create logs directory if it doesn't exist 9 | appname = "Digico-Reaper Link" 10 | appauthor = "Justin Stasiw" 11 | log_dir = appdirs.user_log_dir(appname, appauthor) 12 | if os.path.isdir(log_dir): 13 | pass 14 | else: 15 | os.makedirs(log_dir) 16 | 17 | # Create logger 18 | logger = logging.getLogger('Digico-Reaper-Link') 19 | logger.setLevel(logging.DEBUG) 20 | 21 | # Create formatters 22 | file_formatter = logging.Formatter( 23 | '%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s' 24 | ) 25 | console_formatter = logging.Formatter( 26 | '%(asctime)s - %(levelname)s - %(message)s' 27 | ) 28 | 29 | # File handler (rotating log files) 30 | file_handler = RotatingFileHandler( 31 | log_dir + "/" + "digico_reaper_link.log", 32 | maxBytes=1024 * 1024, # 1MB 33 | backupCount=5 34 | ) 35 | file_handler.setLevel(logging.DEBUG) 36 | file_handler.setFormatter(file_formatter) 37 | 38 | # Console handler 39 | console_handler = logging.StreamHandler() 40 | console_handler.setLevel(logging.INFO) 41 | console_handler.setFormatter(console_formatter) 42 | 43 | # Add handlers to logger 44 | logger.addHandler(file_handler) 45 | logger.addHandler(console_handler) 46 | 47 | return logger 48 | 49 | 50 | # Create and configure logger 51 | logger = setup_logger() 52 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | import wx 4 | import ipaddress 5 | from app_settings import settings 6 | from utilities import ReaperDigicoOSCBridge 7 | from pubsub import pub 8 | from logger_config import logger 9 | 10 | 11 | class MainWindow(wx.Frame): 12 | # Bringing the logic from utilities as an attribute of MainWindow 13 | BridgeFunctions = ReaperDigicoOSCBridge() 14 | 15 | def __init__(self): 16 | logger.info("Initializing main window") 17 | wx.Frame.__init__(self, parent=None, title="Digico-Reaper Link") 18 | self.SetPosition(settings.window_loc) 19 | self.SetSize(settings.window_size) 20 | panel = MainPanel(self) 21 | # Build a menubar: 22 | 23 | filemenu = wx.Menu() 24 | about_menuitem = filemenu.Append(wx.ID_ABOUT, "&About", "Info about this program") 25 | filemenu.AppendSeparator() 26 | m_exit = filemenu.Append(wx.ID_EXIT, "&Exit\tAlt-X", "Close window and exit program.") 27 | properties_menuitem = filemenu.Append(wx.ID_PROPERTIES, "Properties", "Program Settings") 28 | menubar = wx.MenuBar() 29 | menubar.Append(filemenu, "&File") 30 | self.SetMenuBar(menubar) 31 | 32 | # Main Window Bindings 33 | self.Bind(wx.EVT_MENU, self.on_close, m_exit) 34 | self.Bind(wx.EVT_MENU, self.on_about, about_menuitem) 35 | self.Bind(wx.EVT_MENU, self.launch_prefs, properties_menuitem) 36 | self.Bind(wx.EVT_CLOSE, self.on_close) 37 | self.Show() 38 | 39 | def on_about(self, event): 40 | # Create the About Dialog Box 41 | dlg = wx.MessageDialog(self, " An OSC Translation tool for Digico and Reaper. Written by Justin Stasiw. ", 42 | "Digico-Reaper Link", wx.OK) 43 | dlg.ShowModal() # Shows it 44 | dlg.Destroy() # Destroy pop-up when finished. 45 | 46 | def launch_prefs(self, event): 47 | # Open the preferences frame 48 | PrefsWindow(parent=wx.GetTopLevelParent(self), title="Digico-Reaper Properties") 49 | 50 | def on_close(self, event): 51 | # Let's close the window and destroy the UI 52 | # But let's remember where we left the window for next time 53 | logger.info("Closing Application") 54 | cur_pos = self.GetTopLevelParent().GetPosition() 55 | cur_size = self.GetTopLevelParent().GetSize() 56 | self.GetTopLevelParent().BridgeFunctions.update_pos_in_config(cur_pos) 57 | self.GetTopLevelParent().BridgeFunctions.update_size_in_config(cur_size) 58 | # Make a dialog to confirm closing. 59 | dlg = wx.MessageDialog(self, 60 | "Do you really want to close Digico-Reaper Link?", 61 | "Confirm Exit", wx.OK | wx.CANCEL | wx.ICON_QUESTION) 62 | result = dlg.ShowModal() 63 | dlg.Destroy() 64 | if result == wx.ID_OK: 65 | closed_complete = self.GetTopLevelParent().BridgeFunctions.close_servers() 66 | if closed_complete: 67 | try: 68 | self.GetTopLevelParent().Destroy() 69 | except Exception as e: 70 | logger.error(f"Error closing application: {e}") 71 | 72 | 73 | class MainPanel(wx.Panel): 74 | # This is our main window UI 75 | def __init__(self, parent): 76 | logger.info("Initializing main panel") 77 | wx.Panel.__init__(self, parent) 78 | self.DigicoTimer = None 79 | panel_sizer = wx.BoxSizer(wx.VERTICAL) 80 | # Font Definitions 81 | header_font = wx.Font(20, family=wx.FONTFAMILY_SWISS, style=0, weight=wx.FONTWEIGHT_BOLD, 82 | underline=False, faceName="", encoding=wx.FONTENCODING_DEFAULT) 83 | sub_header1_font = wx.Font(17, family=wx.FONTFAMILY_SWISS, style=0, weight=wx.FONTWEIGHT_NORMAL, 84 | underline=False, faceName="", encoding=wx.FONTENCODING_DEFAULT) 85 | sub_header2_font = wx.Font(14, family=wx.FONTFAMILY_SWISS, style=0, weight=wx.FONTWEIGHT_NORMAL, 86 | underline=False, faceName="", encoding=wx.FONTENCODING_DEFAULT) 87 | # Button grid for application mode 88 | radio_grid = wx.GridSizer(3, 1, 0, 0) 89 | self.rec_button_cntl = wx.RadioButton(self, label="Recording", style=wx.RB_GROUP) 90 | self.rec_button_cntl.SetFont(header_font) 91 | radio_grid.Add(self.rec_button_cntl, 0, wx.ALL | wx.EXPAND, 5) 92 | self.track_button_cntl = wx.RadioButton(self, label="Playback Tracking") 93 | self.track_button_cntl.SetValue(True) 94 | self.track_button_cntl.SetFont(header_font) 95 | radio_grid.Add(self.track_button_cntl, 0, wx.ALL | wx.EXPAND, 5) 96 | self.notrack_button_cntl = wx.RadioButton(self, label="Playback No Track") 97 | self.notrack_button_cntl.SetFont(header_font) 98 | radio_grid.Add(self.notrack_button_cntl, 0, wx.ALL | wx.EXPAND, 5) 99 | panel_sizer.Add(radio_grid, 0, wx.ALL | wx.EXPAND, 5) 100 | 101 | # Is connected section: 102 | connected_status = wx.StaticText(self) 103 | connected_status.SetLabel("Connection Status") 104 | connected_status.SetFont(sub_header1_font) 105 | 106 | connected_grid = wx.GridSizer(3, 1, 5, 5) 107 | 108 | digico_con_label = wx.StaticText(self) 109 | digico_con_label.SetLabel("Digico") 110 | digico_con_label.SetFont(sub_header2_font) 111 | connected_grid.Add(digico_con_label, flag=wx.ALIGN_CENTER_HORIZONTAL) 112 | 113 | self.digico_connected = wx.TextCtrl(self, size=wx.Size(100, -1), style=wx.TE_CENTER) 114 | self.digico_connected.SetLabel("N/C") 115 | self.digico_connected.SetEditable(False) 116 | self.digico_connected.SetBackgroundColour(wx.RED) 117 | connected_grid.Add(self.digico_connected, flag=wx.ALIGN_CENTER_HORIZONTAL) 118 | 119 | panel_sizer.Add(connected_status, flag=wx.ALIGN_CENTER_HORIZONTAL) 120 | panel_sizer.Add(connected_grid, flag=wx.ALIGN_CENTER_HORIZONTAL) 121 | 122 | # Lower Buttons 123 | button_grid = wx.GridSizer(3, 1, 10, 10) 124 | # Drop Marker Button 125 | marker_button = wx.Button(self, label="Drop Marker") 126 | button_grid.Add(marker_button, flag=wx.ALIGN_CENTER_HORIZONTAL) 127 | # Attempt Reconnect Button 128 | attempt_reconnect_button = wx.Button(self, label="Attempt Reconnect") 129 | button_grid.Add(attempt_reconnect_button, flag=wx.ALIGN_CENTER_HORIZONTAL) 130 | # Exit Button 131 | exit_button = wx.Button(self, label="Exit") 132 | button_grid.Add(exit_button, flag=wx.ALIGN_CENTER_HORIZONTAL) 133 | panel_sizer.Add(button_grid, flag=wx.ALIGN_CENTER_HORIZONTAL) 134 | panel_sizer.AddSpacer(15) 135 | self.SetSizer(panel_sizer) 136 | # Bindings 137 | self.Bind(wx.EVT_BUTTON, self.place_marker, marker_button) 138 | self.Bind(wx.EVT_BUTTON, self.exitapp, exit_button) 139 | self.Bind(wx.EVT_BUTTON, self.attemptreconnect, attempt_reconnect_button) 140 | self.Bind(wx.EVT_RADIOBUTTON, self.recmode, self.rec_button_cntl) 141 | self.Bind(wx.EVT_RADIOBUTTON, self.trackmode, self.track_button_cntl) 142 | self.Bind(wx.EVT_RADIOBUTTON, self.notrackmode, self.notrack_button_cntl) 143 | # Subscribing to the OSC response for console name to reset the timeout timer 144 | pub.subscribe(self.digico_connected_listener, "console_name") 145 | pub.subscribe(self.reaper_disconnected_listener, "reaper_error") 146 | pub.subscribe(self.callforreaperrestart, "reset_reaper") 147 | pub.subscribe(self.update_mode_select_gui_from_osc, "mode_select_osc") 148 | if MainWindow.BridgeFunctions.ValidateReaperPrefs(): 149 | MainWindow.BridgeFunctions.start_threads() 150 | # Start a timer for Digico timeout 151 | self.timer_lock = threading.Lock() 152 | self.configuretimers() 153 | 154 | @staticmethod 155 | def place_marker(e): 156 | # Manually places a marker in Reaper from the UI 157 | MainWindow.BridgeFunctions.place_marker_at_current() 158 | MainWindow.BridgeFunctions.update_last_marker_name("Marker from UI") 159 | 160 | def exitapp(self, e): 161 | # Calls on_close for the parent window 162 | self.GetTopLevelParent().on_close(None) 163 | 164 | def update_mode_select_gui_from_osc(self, selected_mode): 165 | if selected_mode == "Recording": 166 | wx.CallAfter(self.rec_button_cntl.SetValue, True) 167 | elif selected_mode == "PlaybackTrack": 168 | wx.CallAfter(self.track_button_cntl.SetValue, True) 169 | elif selected_mode == "PlaybackNoTrack": 170 | wx.CallAfter(self.notrack_button_cntl.SetValue, True) 171 | 172 | @staticmethod 173 | def recmode(e): 174 | settings.marker_mode = "Recording" 175 | 176 | @staticmethod 177 | def trackmode(e): 178 | settings.marker_mode = "PlaybackTrack" 179 | 180 | @staticmethod 181 | def notrackmode(e): 182 | settings.marker_mode = "PlaybackNoTrack" 183 | 184 | def configuretimers(self): 185 | # Builds a 5-second non-blocking timer for console response timeout. 186 | # Calls self.digico_disconnected if timer runs out. 187 | with self.timer_lock: 188 | def safe_timer_config(): 189 | if self.DigicoTimer and self.DigicoTimer.IsRunning(): 190 | self.DigicoTimer.Stop() 191 | self.DigicoTimer = wx.CallLater(5000, self.digico_disconnected) 192 | self.DigicoTimer.Start() 193 | wx.CallAfter(safe_timer_config) 194 | 195 | def digico_connected_listener(self, consolename, arg2=None): 196 | if self.DigicoTimer.IsRunning(): 197 | # When a response is received from the console, reset the timeout timer if running 198 | wx.CallAfter(self.DigicoTimer.Stop) 199 | # Update the UI to reflect the connected status 200 | wx.CallAfter(self.digico_connected.SetLabel, consolename) 201 | wx.CallAfter(self.digico_connected.SetBackgroundColour,wx.GREEN) 202 | # Restart the timeout timer 203 | self.configuretimers() 204 | 205 | else: 206 | # If the timer was not already running 207 | # Update UI to reflect connected 208 | wx.CallAfter(self.digico_connected.SetLabel,consolename) 209 | wx.CallAfter(self.digico_connected.SetBackgroundColour,wx.GREEN) 210 | # Start the timer 211 | self.configuretimers() 212 | 213 | 214 | def digico_disconnected(self): 215 | # If timer runs out without being reset, update the UI to N/C 216 | logger.info("Digico timer ran out. Updating UI to N/C.") 217 | wx.CallAfter(self.digico_connected.SetLabel,"N/C") 218 | wx.CallAfter(self.digico_connected.SetBackgroundColour,wx.RED) 219 | 220 | def reaper_disconnected_listener(self, reapererror, arg2=None): 221 | logger.info("Reaper not connected. Reporting to user.") 222 | dlg = wx.MessageDialog(self, 223 | "Reaper is not currently open. Please open and press OK.", 224 | "Reaper Disconnected", wx.OK | wx.CANCEL | wx.ICON_QUESTION) 225 | result = dlg.ShowModal() 226 | dlg.Destroy() 227 | if result == wx.ID_CANCEL: 228 | closed_complete = self.GetTopLevelParent().BridgeFunctions.close_servers() 229 | if closed_complete: 230 | try: 231 | self.GetTopLevelParent().Destroy() 232 | except Exception as e: 233 | logger.error(f"Failed to close Reaper disconnected dialog: {e}") 234 | elif result == wx.ID_OK: 235 | if MainWindow.BridgeFunctions.ValidateReaperPrefs(): 236 | MainWindow.BridgeFunctions.start_threads() 237 | 238 | def callforreaperrestart(self, resetreaper, arg2=None): 239 | logger.info("Reaper has been configured. Requesting restart") 240 | dlg = wx.MessageDialog(self, 241 | "Reaper has been configured for use with Digico-Reaper Link. " 242 | "Please restart Reaper and press OK", 243 | "Reaper Configured", wx.OK | wx.ICON_QUESTION) 244 | result = dlg.ShowModal() 245 | dlg.Destroy() 246 | 247 | @staticmethod 248 | def attemptreconnect(e): 249 | logger.info("Manual reconnection requested.") 250 | # Just forces a close/reconnect of the OSC servers by manually updating the configuration. 251 | MainWindow.BridgeFunctions.update_configuration(con_ip=settings.console_ip, 252 | rptr_ip=settings.repeater_ip, con_send=settings.console_port, 253 | con_rcv=settings.receive_port, 254 | fwd_enable=settings.forwarder_enabled, 255 | rpr_send=settings.reaper_port, 256 | rpr_rcv=settings.reaper_receive_port, 257 | rptr_snd=settings.repeater_port, 258 | rptr_rcv=settings.repeater_receive_port) 259 | 260 | 261 | class PrefsWindow(wx.Frame): 262 | # This is our preferences window pane 263 | def __init__(self, title, parent): 264 | logger.info("Creating PrefsWindow") 265 | wx.Frame.__init__(self, parent=parent, size=wx.Size(400, 800), title=title) 266 | panel = PrefsPanel(parent=wx.GetTopLevelParent(self)) 267 | self.Fit() 268 | self.Show() 269 | 270 | 271 | class PrefsPanel(wx.Panel): 272 | def __init__(self, parent): 273 | logger.info("Creating PrefsPanel") 274 | wx.Panel.__init__(self, parent) 275 | # Define Fonts: 276 | self.ip_inspected = False 277 | header_font = wx.Font(20, family=wx.FONTFAMILY_MODERN, style=0, weight=wx.FONTWEIGHT_BOLD, 278 | underline=False, faceName="", encoding=wx.FONTENCODING_DEFAULT) 279 | sub_header_font = wx.Font(16, family=wx.FONTFAMILY_MODERN, style=0, weight=wx.FONTWEIGHT_BOLD, 280 | underline=False, faceName="", encoding=wx.FONTENCODING_DEFAULT) 281 | base_font = wx.Font(12, family=wx.FONTFAMILY_MODERN, style=0, weight=wx.FONTWEIGHT_NORMAL, 282 | underline=False, faceName="", encoding=wx.FONTENCODING_DEFAULT) 283 | # Console IP Label 284 | panel_sizer = wx.BoxSizer(wx.VERTICAL) 285 | console_ip_text = wx.StaticText(self, label="Console IP", style=wx.ALIGN_CENTER) 286 | console_ip_text.SetFont(header_font) 287 | panel_sizer.Add(console_ip_text, 0, wx.ALL | wx.EXPAND, 5) 288 | # Console IP Input 289 | self.console_ip_control = wx.TextCtrl(self, style=wx.TE_CENTER) 290 | self.console_ip_control.SetMaxLength(15) 291 | self.console_ip_control.SetValue(settings.console_ip) 292 | panel_sizer.Add(self.console_ip_control, 0, wx.ALL | wx.EXPAND, 5) 293 | panel_sizer.Add(0, 10) 294 | # Digico Ports Label 295 | digico_ports_text = wx.StaticText(self, label="Digico Ports", style=wx.ALIGN_CENTER) 296 | digico_ports_text.SetFont(header_font) 297 | panel_sizer.Add(digico_ports_text, 0, wx.ALL | wx.EXPAND, 1) 298 | # Digico Ports Input 299 | digico_ports_grid = wx.GridSizer(2, 2, -1, 10) 300 | digico_send_port_text = wx.StaticText(self, label="Send to Console", style=wx.ALIGN_CENTER) 301 | digico_send_port_text.SetFont(base_font) 302 | digico_ports_grid.Add(digico_send_port_text, 0, wx.ALL | wx.EXPAND, 5) 303 | digico_rcv_port_text = wx.StaticText(self, label="Receive from Console", style=wx.ALIGN_CENTER) 304 | digico_rcv_port_text.SetFont(base_font) 305 | digico_ports_grid.Add(digico_rcv_port_text, 0, wx.ALL | wx.EXPAND, 5) 306 | self.digico_send_port_control = wx.TextCtrl(self, style=wx.TE_CENTER) 307 | self.digico_send_port_control.SetMaxLength(5) 308 | self.digico_send_port_control.SetValue(str(settings.console_port)) 309 | digico_ports_grid.Add(self.digico_send_port_control, 0, wx.ALL | wx.EXPAND, -1) 310 | self.digico_rcv_port_control = wx.TextCtrl(self, style=wx.TE_CENTER) 311 | self.digico_rcv_port_control.SetMaxLength(5) 312 | self.digico_rcv_port_control.SetValue(str(settings.receive_port)) 313 | digico_ports_grid.Add(self.digico_rcv_port_control, 0, wx.ALL | wx.EXPAND, -1) 314 | panel_sizer.Add(digico_ports_grid, 0, wx.ALL | wx.EXPAND, 5) 315 | panel_sizer.Add(0, 25) 316 | 317 | # Match mode radio buttons 318 | match_mode_text = wx.StaticText(self, label="Matching Mode", style=wx.ALIGN_CENTER) 319 | match_mode_text.SetFont(header_font) 320 | panel_sizer.Add(match_mode_text, 0, wx.ALL | wx.EXPAND, 5) 321 | match_mode_radio_grid = wx.GridSizer(1,2,0,0) 322 | self.mode_match_all_radio = wx.RadioButton(self, label="Number & Name", style=wx.RB_GROUP) 323 | match_mode_radio_grid.Add(self.mode_match_all_radio, 0, wx.ALL | wx.EXPAND, 5) 324 | self.mode_match_all_radio.SetValue(settings.name_only_match == "False") 325 | self.mode_match_name_radio = wx.RadioButton(self, label="Name Only") 326 | match_mode_radio_grid.Add(self.mode_match_name_radio, 0, wx.ALL | wx.EXPAND, 5) 327 | self.mode_match_name_radio.SetValue(settings.name_only_match == "True") 328 | panel_sizer.Add(match_mode_radio_grid, 0, wx.ALL | wx.EXPAND, 5) 329 | 330 | # OSC Repeater Label 331 | osc_repeater_text = wx.StaticText(self, label="OSC Repeater", style=wx.ALIGN_CENTER) 332 | osc_repeater_text.SetFont(header_font) 333 | panel_sizer.Add(osc_repeater_text, 0, wx.ALL | wx.EXPAND, 5) 334 | repeater_radio_grid = wx.GridSizer(1, 2, 0, 0) 335 | self.repeater_radio_enabled = wx.RadioButton(self, label="Repeater Enabled", style=wx.RB_GROUP) 336 | repeater_radio_grid.Add(self.repeater_radio_enabled, 0, wx.ALL | wx.EXPAND, 5) 337 | self.repeater_radio_enabled.SetValue(settings.forwarder_enabled == "True") 338 | self.repeater_radio_disabled = wx.RadioButton(self, label="Repeater Disabled") 339 | repeater_radio_grid.Add(self.repeater_radio_disabled, 0, wx.ALL | wx.EXPAND, 5) 340 | self.repeater_radio_disabled.SetValue(settings.forwarder_enabled == "False") 341 | panel_sizer.Add(repeater_radio_grid, 0, wx.ALL | wx.EXPAND, 5) 342 | panel_sizer.Add(0, 10) 343 | # Console IP Label 344 | repeater_ip_text = wx.StaticText(self, label="Repeat to IP", style=wx.ALIGN_CENTER) 345 | repeater_ip_text.SetFont(header_font) 346 | panel_sizer.Add(repeater_ip_text, 0, wx.ALL | wx.EXPAND, 5) 347 | # Repeater to IP Input 348 | self.repeater_ip_control = wx.TextCtrl(self, style=wx.TE_CENTER) 349 | self.repeater_ip_control.SetMaxLength(15) 350 | self.repeater_ip_control.SetValue(settings.repeater_ip) 351 | panel_sizer.Add(self.repeater_ip_control, 0, wx.ALL | wx.EXPAND, 5) 352 | panel_sizer.Add(0, 10) 353 | # Repeater Ports Label 354 | repeater_ports_text = wx.StaticText(self, label="Repeater Ports", style=wx.ALIGN_CENTER) 355 | repeater_ports_text.SetFont(sub_header_font) 356 | panel_sizer.Add(repeater_ports_text, 0, wx.ALL | wx.EXPAND, -1) 357 | # Repeater Ports Input 358 | repeater_ports_grid = wx.GridSizer(2, 2, -1, 10) 359 | repeater_send_port_text = wx.StaticText(self, label="Send to Device", style=wx.ALIGN_CENTER) 360 | repeater_send_port_text.SetFont(base_font) 361 | repeater_ports_grid.Add(repeater_send_port_text, 0, wx.ALL | wx.EXPAND, 5) 362 | repeater_rcv_port_text = wx.StaticText(self, label="Receive from Device", style=wx.ALIGN_CENTER) 363 | repeater_rcv_port_text.SetFont(base_font) 364 | repeater_ports_grid.Add(repeater_rcv_port_text, 0, wx.ALL | wx.EXPAND, 5) 365 | self.repeater_send_port_control = wx.TextCtrl(self, style=wx.TE_CENTER) 366 | self.repeater_send_port_control.SetMaxLength(5) 367 | self.repeater_send_port_control.SetValue(str(settings.repeater_port)) 368 | repeater_ports_grid.Add(self.repeater_send_port_control, 0, wx.ALL | wx.EXPAND, -1) 369 | self.repeater_rcv_port_control = wx.TextCtrl(self, style=wx.TE_CENTER) 370 | self.repeater_rcv_port_control.SetMaxLength(5) 371 | self.repeater_rcv_port_control.SetValue(str(settings.repeater_receive_port)) 372 | repeater_ports_grid.Add(self.repeater_rcv_port_control, 0, wx.ALL | wx.EXPAND, -1) 373 | panel_sizer.Add(repeater_ports_grid, 0, wx.ALL | wx.EXPAND, 5) 374 | panel_sizer.Add(0, 25) 375 | # Update Button 376 | update_button = wx.Button(self, -1, "Update") 377 | panel_sizer.Add(update_button, 0, wx.ALL | wx.EXPAND, 5) 378 | panel_sizer.AddSpacer(15) 379 | self.SetSizer(panel_sizer) 380 | self.Fit() 381 | 382 | # Prefs Window Bindings 383 | self.Bind(wx.EVT_BUTTON, self.update_button_pressed, update_button) 384 | self.console_ip_control.Bind(wx.EVT_TEXT, self.changed_console_ip) 385 | self.console_ip_control.Bind(wx.EVT_KILL_FOCUS, self.check_console_ip) 386 | self.Show() 387 | 388 | def update_button_pressed(self, e): 389 | logger.info("Updating configuration settings.") 390 | # Writing the new values from the preferences panel to settings 391 | try: 392 | settings.console_ip = self.console_ip_control.GetValue() 393 | settings.console_port = str(self.digico_send_port_control.GetValue()) 394 | settings.receive_port = str(self.digico_rcv_port_control.GetValue()) 395 | settings.repeater_ip = self.repeater_ip_control.GetValue() 396 | settings.repeater_port = str(self.repeater_send_port_control.GetValue()) 397 | settings.repeater_receive_port = str(self.repeater_rcv_port_control.GetValue()) 398 | if self.mode_match_name_radio.GetValue() is True: 399 | settings.name_only_match = "True" 400 | elif self.mode_match_name_radio.GetValue() is False: 401 | settings.name_only_match = "False" 402 | if self.repeater_radio_enabled.GetValue() is True: 403 | settings.forwarder_enabled = "True" 404 | elif self.repeater_radio_enabled.GetValue() is False: 405 | settings.forwarder_enabled = "False" 406 | # Force a close/reconnect of the OSC servers by pushing the configuration update. 407 | MainWindow.BridgeFunctions.update_configuration(con_ip=settings.console_ip, 408 | rptr_ip=settings.repeater_ip, con_send=settings.console_port, 409 | con_rcv=settings.receive_port, 410 | fwd_enable=settings.forwarder_enabled, 411 | rpr_send=settings.reaper_port, 412 | rpr_rcv=settings.reaper_receive_port, 413 | rptr_snd=settings.repeater_port, 414 | rptr_rcv=settings.repeater_receive_port, 415 | name_only=settings.name_only_match) 416 | # Close the preferences window when update is pressed. 417 | self.Parent.Destroy() 418 | except Exception as e: 419 | logger.error(f"Error updating configuration: {e}") 420 | 421 | def changed_console_ip(self, e): 422 | # Flag to know if the console IP has been modified in the prefs window 423 | self.ip_inspected = False 424 | 425 | def check_console_ip(self, e): 426 | # Validates input into the console IP address field 427 | # Use the ip_address function from the ipaddress module to check if the input is a valid IP address 428 | ip = self.console_ip_control.GetValue() 429 | 430 | if not self.ip_inspected: 431 | self.ip_inspected = True 432 | try: 433 | ipaddress.ip_address(ip) 434 | except ValueError: 435 | logger.warning(f"Invalid IP address entered: {ip}") 436 | # If the input is not a valid IP address, catch the exception and show a dialog 437 | dlg = wx.MessageDialog(self, "This is not a valid IP address for the console. Please try again", 438 | "Digico-Reaper Link", wx.OK) 439 | dlg.ShowModal() # Shows it 440 | dlg.Destroy() # Destroy pop-up when finished. 441 | # Put the focus back on the bad field 442 | wx.CallAfter(self.console_ip_control.SetFocus) 443 | 444 | 445 | if __name__ == "__main__": 446 | try: 447 | logger.info("Starting Digico-Reaper Link Application") 448 | app = wx.App(False) 449 | frame = MainWindow() 450 | app.MainLoop() 451 | except Exception as e: 452 | logger.critical(f"Fatal Error: {e}", exc_info=True) 453 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | altgraph==0.17.4 2 | appdirs~=1.4.4 3 | ConfigUpdater~=3.2 4 | macholib==1.16.3 5 | packaging==25.0 6 | psutil~=7.0.0 7 | pyinstaller==6.13.0 8 | pyinstaller-hooks-contrib==2025.4 9 | Pypubsub~=4.0.3 10 | python-osc~=1.9.3 11 | setuptools==80.3.1 12 | six==1.17.0 13 | wxPython~=4.2.3 14 | -------------------------------------------------------------------------------- /resources/DRLSplash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jms5194/Digico-Reaper-Link/d2a929efcbab964fd314671ab362c567d1f41aef/resources/DRLSplash.png -------------------------------------------------------------------------------- /resources/rprdigi.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jms5194/Digico-Reaper-Link/d2a929efcbab964fd314671ab362c567d1f41aef/resources/rprdigi.icns -------------------------------------------------------------------------------- /resources/rprdigi.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jms5194/Digico-Reaper-Link/d2a929efcbab964fd314671ab362c567d1f41aef/resources/rprdigi.ico -------------------------------------------------------------------------------- /utilities.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import os.path 3 | import configparser 4 | from configupdater import ConfigUpdater 5 | import appdirs 6 | from pythonosc import udp_client 7 | from pythonosc import dispatcher 8 | from pythonosc import osc_server 9 | from pythonosc.dispatcher import Dispatcher 10 | from pythonosc.osc_server import ThreadingOSCUDPServer 11 | import socket 12 | import psutil 13 | from app_settings import settings 14 | import time 15 | from pubsub import pub 16 | import configure_reaper 17 | import ipaddress 18 | import wx 19 | from logger_config import logger 20 | 21 | class RawMessageDispatcher(Dispatcher): 22 | def handle_error(self, OSCAddress, *args): 23 | # Handles malformed OSC messages and forwards on to console 24 | logger.debug(f"Received malformed OSC message at address: {OSCAddress}") 25 | try: 26 | # The last argument contains the raw message data 27 | raw_data = args[-1] if args else None 28 | if raw_data: 29 | # Forward the raw data exactly as received 30 | self.forward_raw_message(raw_data) 31 | except Exception as e: 32 | logger.error(f"Error forwarding malformed OSC message: {e}") 33 | 34 | @staticmethod 35 | def forward_raw_message(raw_data): 36 | # Forwards the raw message data without parsing 37 | logger.debug("Forwarding raw message.") 38 | try: 39 | # Create a raw UDP socket for forwarding 40 | forward_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 41 | # Forward to the Digico console IP and receive port 42 | forward_socket.sendto(raw_data, (settings.console_ip,settings.receive_port)) 43 | forward_socket.close() 44 | except Exception as e: 45 | logger.error(f"Error forwarding raw message: {e}") 46 | 47 | 48 | class RawOSCServer(ThreadingOSCUDPServer): 49 | def handle_request(self): 50 | # Override to get raw data before OSC parsing 51 | try: 52 | data, client_address = self.socket.recvfrom(65535) 53 | # If the raw data is not a multiple of 4 bytes, pad until it is 54 | # Let's at least try to make the data from the iPad valid OSC 55 | while len(data) % 4 != 0: 56 | data += bytes([0x00]) 57 | logger.debug("Padding raw data to make it valid OSC.") 58 | # Try normal OSC handling first 59 | try: 60 | super().handle_request() 61 | except Exception as e: 62 | # If OSC parsing fails, handle as raw data 63 | logger.debug(f"OSC parsing failed, handling as raw data. {e}") 64 | if hasattr(self.dispatcher, 'handle_error'): 65 | self.dispatcher.handle_error("/", data) 66 | except Exception as e: 67 | logger.error(f"Error in raw server handler: {e}") 68 | 69 | 70 | 71 | class ManagedThread(threading.Thread): 72 | # Building threads that we can more easily control 73 | def __init__(self, target, name=None, daemon=True): 74 | super().__init__(target=target, name=name, daemon=daemon) 75 | self._stop_event = threading.Event() 76 | 77 | def stop(self): 78 | self._stop_event.set() 79 | 80 | def stopped(self): 81 | return self._stop_event.is_set() 82 | 83 | 84 | class ReaperDigicoOSCBridge: 85 | 86 | def __init__(self): 87 | logger.info("Initializing ReaperDigicoOSCBridge") 88 | self.repeater_osc_thread = None 89 | self.reaper_osc_thread = None 90 | self.digico_osc_thread = None 91 | self.digico_dispatcher = None 92 | self.reaper_dispatcher = None 93 | self.repeater_dispatcher = None 94 | self.console_client = None 95 | self.reaper_client = None 96 | self.repeater_client = None 97 | self.digico_osc_server = None 98 | self.reaper_osc_server = None 99 | self.repeater_osc_server = None 100 | self.requested_macro_num = None 101 | self.requested_snapshot_number = None 102 | self.snapshot_ignore_flag = False 103 | self.name_to_match = "" 104 | self.is_playing = False 105 | self.is_recording = False 106 | self.ini_prefs = "" 107 | self.config_dir = "" 108 | self.lock = threading.Lock() 109 | self.where_to_put_user_data() 110 | self.check_configuration() 111 | self.console_name_event = threading.Event() 112 | self.reaper_send_lock = threading.Lock() 113 | self.console_send_lock = threading.Lock() 114 | 115 | def where_to_put_user_data(self): 116 | # Find a home for our preferences file 117 | appname = "Digico-Reaper Link" 118 | appauthor = "Justin Stasiw" 119 | self.config_dir = appdirs.user_config_dir(appname, appauthor) 120 | if os.path.isdir(self.config_dir): 121 | pass 122 | else: 123 | os.makedirs(self.config_dir) 124 | self.ini_prefs = self.config_dir + "/settingsV3.ini" 125 | 126 | def check_configuration(self): 127 | # Checking if a .ini config already exists for this app, if not call 128 | # build_initial_ini 129 | try: 130 | if os.path.isfile(self.ini_prefs): 131 | self.set_vars_from_pref(self.ini_prefs) 132 | else: 133 | self.build_initial_ini(self.ini_prefs) 134 | except Exception as e: 135 | logger.error(f"Failed to check/initialize config file: {e}") 136 | self.build_initial_ini(self.ini_prefs) 137 | 138 | @staticmethod 139 | def set_vars_from_pref(config_file_loc): 140 | # Bring in the vars to fill out settings from the preferences file 141 | logger.info("Setting variables from preferences file") 142 | config = configparser.ConfigParser() 143 | config.read(config_file_loc) 144 | settings.update_from_config(config) 145 | 146 | def build_initial_ini(self, location_of_ini): 147 | # Builds a .ini configuration file with default settings. 148 | # What should our defaults be? All zeros? Something technically valid? 149 | logger.info("Building initial .ini config file") 150 | config = configparser.ConfigParser() 151 | config["main"] = {} 152 | config["main"]["default_ip"] = "10.10.13.10" 153 | config["main"]["repeater_ip"] = "10.10.13.11" 154 | config["main"]["default_digico_send_port"] = "8001" 155 | config["main"]["default_digico_receive_port"] = "8000" 156 | config["main"]["default_reaper_send_port"] = "49102" 157 | config["main"]["default_reaper_receive_port"] = "49101" 158 | config["main"]["default_repeater_send_port"] = "9999" 159 | config["main"]["default_repeater_receive_port"] = "9998" 160 | config["main"]["forwarder_enabled"] = "False" 161 | config["main"]["window_pos_x"] = "400" 162 | config["main"]["window_pos_y"] = "222" 163 | config["main"]["window_size_x"] = "221" 164 | config["main"]["window_size_y"] = "310" 165 | config["main"]["name_only_match"] = "False" 166 | 167 | with open(location_of_ini, "w") as configfile: 168 | config.write(configfile) 169 | timeout = 2 170 | start_time = time.time() 171 | # Check to make sure the config file has been created before moving on. 172 | while not os.path.isfile(location_of_ini): 173 | if time.time() - start_time > timeout: 174 | raise TimeoutError(f"Failed to create config file at {location_of_ini}") 175 | time.sleep(0.1) 176 | self.set_vars_from_pref(self.ini_prefs) 177 | 178 | def update_configuration(self, con_ip, rptr_ip, con_send, con_rcv, fwd_enable, rpr_send, rpr_rcv, 179 | rptr_snd, rptr_rcv, name_only): 180 | # Given new values from the GUI, update the config file and restart the OSC Server 181 | logger.info("Updating configuration file") 182 | updater = ConfigUpdater() 183 | updater.read(self.ini_prefs) 184 | try: 185 | updater["main"]["default_ip"] = con_ip 186 | updater["main"]["repeater_ip"] = rptr_ip 187 | updater["main"]["default_digico_send_port"] = str(con_send) 188 | updater["main"]["default_digico_receive_port"] = str(con_rcv) 189 | updater["main"]["default_reaper_send_port"] = str(rpr_send) 190 | updater["main"]["default_reaper_receive_port"] = str(rpr_rcv) 191 | updater["main"]["default_repeater_send_port"] = str(rptr_snd) 192 | updater["main"]["default_repeater_receive_port"] = str(rptr_rcv) 193 | updater["main"]["forwarder_enabled"] = str(fwd_enable) 194 | updater["main"]["name_only_match"] = str(name_only) 195 | except Exception as e: 196 | logger.error(f"Failed to update config file: {e}") 197 | updater.update_file() 198 | self.set_vars_from_pref(self.ini_prefs) 199 | self.close_servers() 200 | self.restart_servers() 201 | 202 | @staticmethod 203 | def CheckReaperPrefs(rpr_rcv, rpr_send): 204 | if configure_reaper.osc_interface_exists(configure_reaper.get_resource_path(True), rpr_rcv, rpr_send): 205 | logger.info("Reaper OSC interface config already exists") 206 | return True 207 | else: 208 | logger.info("Reaper OSC interface config does not exist") 209 | return False 210 | 211 | @staticmethod 212 | def AddReaperPrefs(rpr_rcv, rpr_send): 213 | configure_reaper.add_OSC_interface(configure_reaper.get_resource_path(True), rpr_rcv, rpr_send) 214 | 215 | def update_pos_in_config(self, win_pos_tuple): 216 | # Receives the position of the window from the UI and stores it in the preferences file 217 | logger.info("Updating window position in config file") 218 | updater = ConfigUpdater() 219 | updater.read(self.ini_prefs) 220 | try: 221 | updater["main"]["window_pos_x"] = str(win_pos_tuple[0]) 222 | updater["main"]["window_pos_y"] = str(win_pos_tuple[1]) 223 | except Exception as e: 224 | logger.error(f"Failed to update window position in config file: {e}") 225 | updater.update_file() 226 | 227 | def update_size_in_config(self, win_size_tuple): 228 | logger.info("Updating window size in config file") 229 | updater = ConfigUpdater() 230 | updater.read(self.ini_prefs) 231 | try: 232 | updater["main"]["window_size_x"] = str(win_size_tuple[0]) 233 | updater["main"]["window_size_y"] = str(win_size_tuple[1]) 234 | except Exception as e: 235 | logger.error(f"Failed to update window size in config file: {e}") 236 | updater.update_file() 237 | 238 | def ValidateReaperPrefs(self): 239 | # If the Reaper .ini file does not contain an entry for Digico-Reaper Link, add one. 240 | try: 241 | if not self.CheckReaperPrefs(settings.reaper_receive_port, settings.reaper_port): 242 | self.AddReaperPrefs(settings.reaper_receive_port, settings.reaper_port) 243 | pub.sendMessage("reset_reaper", resetreaper=True) 244 | return True 245 | except RuntimeError as e: 246 | # If reaper is not running, send an error to the UI 247 | logger.debug(f"Reaper not running: {e}") 248 | pub.sendMessage('reaper_error', reapererror=e) 249 | return False 250 | 251 | def start_managed_thread(self, attr_name, target): 252 | # Start a ManagedThread that can be signaled to stop 253 | thread = ManagedThread(target=target, daemon=True) 254 | setattr(self, attr_name, thread) 255 | thread.start() 256 | 257 | def start_threads(self): 258 | # Start all OSC server threads 259 | logger.info("Starting OSC Server threads") 260 | self.start_managed_thread('digico_osc_thread', self.build_digico_osc_servers) 261 | self.start_managed_thread('reaper_osc_thread', self.build_reaper_osc_servers) 262 | if settings.forwarder_enabled == "True": 263 | self.start_managed_thread('repeater_osc_thread', self.build_repeater_osc_servers) 264 | self.start_managed_thread('heartbeat_thread', self.heartbeat_loop) 265 | 266 | def build_digico_osc_servers(self): 267 | # Connect to the Digico console 268 | logger.info("Starting Digico OSC server") 269 | self.console_client = udp_client.SimpleUDPClient(settings.console_ip, settings.console_port) 270 | self.digico_dispatcher = dispatcher.Dispatcher() 271 | self.receive_console_OSC() 272 | try: 273 | local_ip = self.find_local_ip_in_subnet(settings.console_ip) 274 | if not local_ip: 275 | raise RuntimeError("No local ip found in console's subnet") 276 | self.digico_osc_server = osc_server.ThreadingOSCUDPServer((self.find_local_ip_in_subnet 277 | (settings.console_ip), 278 | settings.receive_port), 279 | self.digico_dispatcher) 280 | logger.info("Digico OSC server started") 281 | self.console_type_and_connected_check() 282 | self.digico_osc_server.serve_forever() 283 | except Exception as e: 284 | logger.error(f"Digico OSC server startup error: {e}") 285 | 286 | def build_reaper_osc_servers(self): 287 | # Connect to Reaper via OSC 288 | logger.info("Starting Reaper OSC server") 289 | self.reaper_client = udp_client.SimpleUDPClient(settings.reaper_ip, settings.reaper_port) 290 | self.reaper_dispatcher = dispatcher.Dispatcher() 291 | self.receive_reaper_OSC() 292 | try: 293 | self.reaper_osc_server = osc_server.ThreadingOSCUDPServer(("127.0.0.1", settings.reaper_receive_port), 294 | self.reaper_dispatcher) 295 | logger.info("Reaper OSC server started") 296 | self.reaper_osc_server.serve_forever() 297 | except Exception as e: 298 | logger.error(f"Reaper OSC server startup error: {e}") 299 | 300 | def build_repeater_osc_servers(self): 301 | # Connect to Repeater via OSC 302 | logger.info("Starting Repeater OSC server") 303 | self.repeater_client = udp_client.SimpleUDPClient(settings.repeater_ip, settings.repeater_port) 304 | # Custom dispatcher to deal with corrupted OSC from iPad 305 | self.repeater_dispatcher = RawMessageDispatcher() 306 | self.receive_repeater_OSC() 307 | try: 308 | # Raw OSC Server to deal with corrupted OSC from iPad App 309 | self.repeater_osc_server = RawOSCServer( 310 | (self.find_local_ip_in_subnet(settings.console_ip), settings.repeater_receive_port), 311 | self.repeater_dispatcher) 312 | logger.info("Repeater OSC server started") 313 | self.repeater_osc_server.serve_forever() 314 | except Exception as e: 315 | logger.error(f"Repeater OSC server startup error: {e}") 316 | 317 | @staticmethod 318 | def find_local_ip_in_subnet(console_ip): 319 | # Find our local interface in the same network as the console interface 320 | ipv4_interfaces = [] 321 | # Make a list of all the network interfaces on our machine 322 | for interface, snics in psutil.net_if_addrs().items(): 323 | for snic in snics: 324 | if snic.family == socket.AF_INET: 325 | ipv4_interfaces.append((snic.address, snic.netmask)) 326 | # Iterate through network interfaces to see if any are in the same subnet as console 327 | for i in ipv4_interfaces: 328 | # Convert tuples to strings like 192.168.1.0/255.255.255.0 since thats what ipaddress expects 329 | interface_ip_string = i[0] + "/" + i[1] 330 | # If strict is off, then the user bits of the computer IP will be masked automatically 331 | # Need to add error handling here 332 | if ipaddress.IPv4Address(console_ip) in ipaddress.IPv4Network(interface_ip_string, False): 333 | return i[0] 334 | else: 335 | pass 336 | 337 | # Reaper Functions: 338 | 339 | def place_marker_at_current(self): 340 | # Uses a reaper OSC action to place a marker at the current timeline spot 341 | logger.info("Placing marker at current time") 342 | with self.reaper_send_lock: 343 | self.reaper_client.send_message("/action", 40157) 344 | 345 | def update_last_marker_name(self, name): 346 | with self.reaper_send_lock: 347 | self.reaper_client.send_message("/lastmarker/name", name) 348 | 349 | def get_marker_id_by_name(self, name): 350 | # Asks for current marker information based upon number of markers. 351 | if self.is_playing is False: 352 | self.name_to_match = name 353 | if settings.name_only_match == "True": 354 | self.name_to_match = self.name_to_match.split(" ") 355 | self.name_to_match = self.name_to_match[1:] 356 | self.name_to_match = " ".join(self.name_to_match) 357 | with self.reaper_send_lock: 358 | self.reaper_client.send_message("/device/marker/count", 0) 359 | # Is there a better way to handle this in OSC only? Max of 512 markers. 360 | self.reaper_client.send_message("/device/marker/count", 512) 361 | 362 | def marker_matcher(self, OSCAddress, test_name): 363 | # Matches a marker composite name with its Reaper ID 364 | address_split = OSCAddress.split("/") 365 | marker_id = address_split[2] 366 | if settings.name_only_match == "True": 367 | test_name = test_name.split(" ") 368 | test_name = test_name[1:] 369 | test_name = " ".join(test_name) 370 | if test_name == self.name_to_match: 371 | self.goto_marker_by_id(marker_id) 372 | 373 | def goto_marker_by_id(self, marker_id): 374 | with self.reaper_send_lock: 375 | self.reaper_client.send_message("/marker", int(marker_id)) 376 | 377 | def current_transport_state(self, OSCAddress, val): 378 | # Watches what the Reaper playhead is doing. 379 | playing = None 380 | recording = None 381 | if OSCAddress == "/play": 382 | if val == 0: 383 | playing = False 384 | elif val == 1: 385 | playing = True 386 | elif OSCAddress == "/record": 387 | if val == 0: 388 | recording = False 389 | elif val == 1: 390 | recording = True 391 | if playing is True: 392 | self.is_playing = True 393 | print("reaper is playing") 394 | elif playing is False: 395 | self.is_playing = False 396 | print("reaper is not playing") 397 | if recording is True: 398 | self.is_recording = True 399 | print("reaper is recording") 400 | elif recording is False: 401 | self.is_recording = False 402 | print("reaper is not recording") 403 | 404 | def reaper_play(self): 405 | with self.reaper_send_lock: 406 | self.reaper_client.send_message("/action", 1007) 407 | 408 | def reaper_stop(self): 409 | with self.reaper_send_lock: 410 | self.reaper_client.send_message("/action", 1016) 411 | 412 | def reaper_rec(self): 413 | # Sends action to skip to end of project and then record, to prevent overwrites 414 | settings.marker_mode = "Recording" 415 | pub.sendMessage("mode_select_osc", selected_mode="Recording") 416 | with self.reaper_send_lock: 417 | self.reaper_client.send_message("/action", 40043) 418 | self.reaper_client.send_message("/action", 1013) 419 | 420 | def receive_reaper_OSC(self): 421 | # Receives and distributes OSC from Reaper, based on matching OSC values 422 | self.reaper_dispatcher.map("/marker/*/name", self.marker_matcher) 423 | self.reaper_dispatcher.map("/play", self.current_transport_state) 424 | self.reaper_dispatcher.map("/record", self.current_transport_state) 425 | 426 | # Digico Functions: 427 | def receive_console_OSC(self): 428 | # Receives and distributes OSC from Digico, based on matching OSC values 429 | self.digico_dispatcher.map("/Snapshots/Recall_Snapshot/*", self.request_snapshot_info) 430 | self.digico_dispatcher.map("/Snapshots/name", self.snapshot_OSC_handler) 431 | self.digico_dispatcher.map("/Macros/Recall_Macro/*", self.request_macro_info) 432 | self.digico_dispatcher.map("/Macros/name", self.macro_name_handler) 433 | self.digico_dispatcher.map("/Console/Name", self.console_name_handler) 434 | self.digico_dispatcher.set_default_handler(self.forward_OSC) 435 | 436 | def send_to_console(self, OSCAddress, *args): 437 | # Send an OSC message to the console 438 | with self.console_send_lock: 439 | self.console_client.send_message(OSCAddress, [*args]) 440 | 441 | def console_type_and_connected_check(self): 442 | # Asks the console for its name. This forms the heartbeat function of the UI 443 | with self.console_send_lock: 444 | self.console_client.send_message("/Console/Name/?", None) 445 | 446 | def console_name_handler(self, OSCAddress, ConsoleName): 447 | # Receives the console name response and updates the UI. 448 | if settings.forwarder_enabled == "True": 449 | try: 450 | self.repeater_client.send_message(OSCAddress, ConsoleName) 451 | except Exception as e: 452 | logger.error(f"Console name cannot be repeated: {e}") 453 | try: 454 | wx.CallAfter(pub.sendMessage, "console_name", consolename=ConsoleName) 455 | except Exception as e: 456 | logger.error(f"Console Name Handler Error: {e}") 457 | 458 | def heartbeat_loop(self): 459 | # Periodically requests the console name every 3 seconds 460 | # to verify connection status and update the UI 461 | while not self.console_name_event.is_set(): 462 | try: 463 | self.console_type_and_connected_check() 464 | except Exception as e: 465 | logger.error(f"Heartbeat loop error: {e}") 466 | time.sleep(3) 467 | 468 | def request_snapshot_info(self, OSCAddress, *args): 469 | # Receives the OSC for the Current Snapshot Number and uses that to request the cue number/name 470 | if settings.forwarder_enabled == "True": 471 | try: 472 | self.repeater_client.send_message(OSCAddress, *args) 473 | except Exception as e: 474 | logger.error(f"Snapshot info cannot be repeated: {e}") 475 | logger.info("Requested snapshot info") 476 | CurrentSnapshotNumber = int(OSCAddress.split("/")[3]) 477 | with self.console_send_lock: 478 | self.console_client.send_message("/Snapshots/name/?", CurrentSnapshotNumber) 479 | 480 | 481 | def request_macro_info(self, OSCAddress, pressed): 482 | # When a Macro is pressed, request the name of the macro 483 | self.requested_macro_num = OSCAddress.split("/")[3] 484 | with self.console_send_lock: 485 | self.console_client.send_message("/Macros/name/?", int(self.requested_macro_num)) 486 | 487 | def macro_name_handler(self, OSCAddress, *args): 488 | #If macros match names, then send behavior to Reaper 489 | if settings.forwarder_enabled == "True": 490 | try: 491 | self.repeater_client.send_message(OSCAddress, [*args]) 492 | except Exception as e: 493 | logger.error(f"Macro name cannot be repeated: {e}") 494 | if self.requested_macro_num is not None: 495 | if int(self.requested_macro_num) == int(args[0]): 496 | macro_name = args[1] 497 | macro_name = str(macro_name).lower() 498 | print(macro_name) 499 | if macro_name in ("reaper,rec", "reaper rec", "rec", "record", "reaper, record", "reaper record"): 500 | self.process_transport_macros("rec") 501 | elif macro_name in ("reaper,stop", "reaper stop", "stop"): 502 | self.process_transport_macros("stop") 503 | elif macro_name in ("reaper,play", "reaper play", "play"): 504 | self.process_transport_macros("play") 505 | elif macro_name in ("reaper,marker", "reaper marker", "marker"): 506 | self.process_marker_macro() 507 | elif macro_name in ("mode,rec", "mode,record", "mode,recording", 508 | "mode rec", "mode record", "mode recording"): 509 | settings.marker_mode = "Recording" 510 | pub.sendMessage("mode_select_osc", selected_mode="Recording") 511 | elif macro_name in ("mode,track", "mode,tracking", "mode,PB Track", 512 | "mode track", "mode tracking", "mode PB Track"): 513 | settings.marker_mode = "PlaybackTrack" 514 | pub.sendMessage("mode_select_osc", selected_mode="PlaybackTrack") 515 | elif macro_name in ("mode,no track", "mode,no tracking", "mode no track", 516 | "mode no tracking"): 517 | settings.marker_mode = "PlaybackNoTrack" 518 | pub.sendMessage("mode_select_osc", selected_mode="PlaybackNoTrack") 519 | self.requested_macro_num = None 520 | 521 | def process_marker_macro(self): 522 | self.place_marker_at_current() 523 | self.update_last_marker_name("Marker from Console") 524 | 525 | def snapshot_OSC_handler(self, OSCAddress, *args): 526 | # Processes the current cue number 527 | if settings.forwarder_enabled == "True": 528 | try: 529 | self.repeater_client.send_message(OSCAddress, [*args]) 530 | except Exception as e: 531 | logger.error(f"Snapshot cue number cannot be repeated: {e}") 532 | cue_name = args[3] 533 | cue_number = str(args[1] / 100) 534 | if settings.marker_mode == "Recording" and self.is_recording is True: 535 | self.place_marker_at_current() 536 | self.update_last_marker_name(cue_number + " " + cue_name) 537 | elif settings.marker_mode == "PlaybackTrack" and self.is_playing is False: 538 | self.get_marker_id_by_name(cue_number + " " + cue_name) 539 | 540 | 541 | def process_transport_macros(self, transport): 542 | try: 543 | if transport == "play": 544 | self.reaper_play() 545 | elif transport == "stop": 546 | self.reaper_stop() 547 | elif transport == "rec": 548 | self.reaper_rec() 549 | except Exception as e: 550 | logger.error(f"Could not process transport macro: {e}") 551 | 552 | # Repeater Functions: 553 | 554 | def receive_repeater_OSC(self): 555 | self.repeater_dispatcher.set_default_handler(self.send_to_console) 556 | 557 | def forward_OSC(self, OSCAddress, *args): 558 | if settings.forwarder_enabled == "True": 559 | try: 560 | self.repeater_client.send_message(OSCAddress, [*args]) 561 | except Exception as e: 562 | logger.error(f"Forwarder error: {e}") 563 | 564 | def stop_all_threads(self): 565 | logger.info("Stopping all threads") 566 | for attr in ['digico_osc_thread', 'reaper_osc_thread', 'repeater_osc_thread', 'heartbeat_thread']: 567 | thread = getattr(self, attr, None) 568 | if thread and isinstance(thread, ManagedThread): 569 | thread.stop() 570 | thread.join(timeout=1) 571 | 572 | def close_servers(self): 573 | logger.info("Closing OSC servers...") 574 | self.console_name_event.set() # Signal heartbeat to exit 575 | 576 | try: 577 | if self.digico_osc_server: 578 | self.digico_osc_server.shutdown() 579 | self.digico_osc_server.server_close() 580 | if self.reaper_osc_server: 581 | self.reaper_osc_server.shutdown() 582 | self.reaper_osc_server.server_close() 583 | if self.repeater_osc_server: 584 | self.repeater_osc_server.shutdown() 585 | self.repeater_osc_server.server_close() 586 | except Exception as e: 587 | logger.error(f"Error shutting down server: {e}") 588 | 589 | self.stop_all_threads() 590 | 591 | logger.info("All servers closed and threads joined.") 592 | return True 593 | 594 | def restart_servers(self): 595 | # Restart the OSC server threads. 596 | logger.info("Restarting OSC Server threads") 597 | self.console_name_event = threading.Event() 598 | self.start_threads() 599 | --------------------------------------------------------------------------------