├── .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 |
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 | 
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 |
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 | 
80 |
81 |
82 | If this software has been useful to you, consider making a donation via the github sponsors system below:
83 |
84 | [](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 |
--------------------------------------------------------------------------------