├── latest_release.txt ├── pointercc ├── version.py ├── resources │ └── logo-small.png ├── win32.py ├── darwin.py └── core.py ├── docs ├── logo.gif ├── obxd-marked.jpg ├── mac-permissions.gif ├── video-snapshot.png └── main-window-unconfigured-win32.png ├── resources ├── icon.icns ├── icons.ico ├── logo.xcf └── logo-big.png ├── certs └── StefanMattingCA.cer ├── instruments ├── tal-jupiter.xcf ├── inst-prophet-5-v.txt └── inst-tal-j-8.txt ├── requirements-stage2.txt ├── scripts ├── makeicons.sh ├── push.sh ├── package-win.ps1 ├── build.ps1 ├── setup-stefan-win.ps1 ├── make-changelog.py ├── build.sh ├── makeicons-mac.sh ├── generate-certs.ps1 ├── setup-stefan-mac.sh └── package-mac.sh ├── .gitignore ├── setup.py ├── LICENSE ├── requirements.txt ├── TODO.md ├── setup.iss ├── .github └── workflows │ └── build.yml ├── README.md └── main.py /latest_release.txt: -------------------------------------------------------------------------------- 1 | 0.0.10 2 | -------------------------------------------------------------------------------- /pointercc/version.py: -------------------------------------------------------------------------------- 1 | version = "0.0.0" 2 | -------------------------------------------------------------------------------- /docs/logo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smatting/pointer-cc/HEAD/docs/logo.gif -------------------------------------------------------------------------------- /resources/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smatting/pointer-cc/HEAD/resources/icon.icns -------------------------------------------------------------------------------- /resources/icons.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smatting/pointer-cc/HEAD/resources/icons.ico -------------------------------------------------------------------------------- /resources/logo.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smatting/pointer-cc/HEAD/resources/logo.xcf -------------------------------------------------------------------------------- /docs/obxd-marked.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smatting/pointer-cc/HEAD/docs/obxd-marked.jpg -------------------------------------------------------------------------------- /docs/mac-permissions.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smatting/pointer-cc/HEAD/docs/mac-permissions.gif -------------------------------------------------------------------------------- /docs/video-snapshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smatting/pointer-cc/HEAD/docs/video-snapshot.png -------------------------------------------------------------------------------- /resources/logo-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smatting/pointer-cc/HEAD/resources/logo-big.png -------------------------------------------------------------------------------- /certs/StefanMattingCA.cer: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smatting/pointer-cc/HEAD/certs/StefanMattingCA.cer -------------------------------------------------------------------------------- /instruments/tal-jupiter.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smatting/pointer-cc/HEAD/instruments/tal-jupiter.xcf -------------------------------------------------------------------------------- /requirements-stage2.txt: -------------------------------------------------------------------------------- 1 | https://github.com/boppreh/mouse/archive/7b773393ed58824b1adf055963a2f9e379f52cc3.zip 2 | -------------------------------------------------------------------------------- /pointercc/resources/logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smatting/pointer-cc/HEAD/pointercc/resources/logo-small.png -------------------------------------------------------------------------------- /scripts/makeicons.sh: -------------------------------------------------------------------------------- 1 | 2 | convert resources/logo-big.png -define icon:auto-resize=16,32,64 -compress zip resources/icons.ico 3 | -------------------------------------------------------------------------------- /docs/main-window-unconfigured-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smatting/pointer-cc/HEAD/docs/main-window-unconfigured-win32.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | __pycache__ 3 | venv* 4 | dist 5 | build 6 | Output 7 | datadir 8 | download 9 | private_certs 10 | instruments/*.png 11 | instruments/*.xcf 12 | *.spec 13 | *.envrc* 14 | -------------------------------------------------------------------------------- /scripts/push.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | rm -f dist/pointer-cc.app.zip 4 | zip -r dist/pointer-cc.app.zip dist/pointer-cc.app 5 | scp dist/pointer-cc.app.zip "stefan:pointer-cc-app.$(date +%Y%m%d%H%M%S).zip" 6 | -------------------------------------------------------------------------------- /scripts/package-win.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = 'Stop' 2 | ISCC .\setup.iss 3 | $items = Get-ChildItem .\Output\ 4 | $firstItem = $items[0] 5 | signtool sign /fd sha256 /v /n "Stefan Matting SPC" /s My /t http://timestamp.digicert.com Output\$firstItem 6 | -------------------------------------------------------------------------------- /scripts/build.ps1: -------------------------------------------------------------------------------- 1 | if (Test-Path -Path build -PathType Container) { 2 | Remove-Item -Force -Recurse build 3 | } 4 | 5 | if (Test-Path -Path dist -PathType Container) { 6 | Remove-Item -Force -Recurse dist 7 | } 8 | 9 | python setup.py put_version 10 | pyinstaller --name pointer-cc ` 11 | --icon ./resources/icons.ico ` 12 | --add-data "./pointercc/resources/logo-small.png:./pointercc/resources/" ` 13 | --windowed main.py 14 | -------------------------------------------------------------------------------- /scripts/setup-stefan-win.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = 'Stop' 2 | Remove-Item -Force -Recurse -ErrorAction SilentlyContinue venv 3 | python -m venv venv 4 | cd .\venv\Scripts 5 | ./Activate.ps1 6 | cd ..\..\ 7 | 8 | pip install --upgrade pip 9 | pip install -r requirements.txt 10 | pip install -r requirements-stage2.txt 11 | 12 | $env:PATH = "C:\Program Files (x86)\Inno Setup 6;C:\Program Files (x86)\Windows Kits\10\bin\10.0.18362.0\x64;" + $env:PATH 13 | 14 | -------------------------------------------------------------------------------- /scripts/make-changelog.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | changelog_dir = 'changelog.d' 4 | 5 | def main(): 6 | lines = [] 7 | for fn in os.listdir(changelog_dir): 8 | p = os.path.join(changelog_dir, fn) 9 | with open(p, 'r') as f: 10 | s = f.read() 11 | line = f'- {s}' 12 | lines.append(line) 13 | head = '# Changelog\n\n' 14 | cl = head + '\n'.join(lines) 15 | print(cl) 16 | 17 | if __name__ == '__main__': 18 | main() 19 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eo pipefail 4 | 5 | rm -rf dist build Output 6 | 7 | # when installing with --target (arm64) venv/bin doesnt get the binaries 8 | PATH="venv/lib/python3.10/site-packages/bin":$PATH 9 | 10 | python setup.py put_version 11 | pyinstaller \ 12 | --name pointer-cc \ 13 | --target-arch "${TARGET_ARCH:-x86_64}" \ 14 | --icon ./resources/icon.icns \ 15 | --add-data "./pointercc/resources/logo-small.png:./pointercc/resources/" \ 16 | --windowed main.py 17 | -------------------------------------------------------------------------------- /scripts/makeicons-mac.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | export ICONDIR="tmp.iconset" 4 | export ORIGICON=resources/logo-big.png 5 | 6 | mkdir -p $ICONDIR 7 | 8 | # Normal screen icons 9 | for SIZE in 16 32 64 128 256 512; do 10 | sips -z $SIZE $SIZE $ORIGICON --out $ICONDIR/icon_${SIZE}x${SIZE}.png ; 11 | done 12 | 13 | # Retina display icons 14 | for SIZE in 32 64 256 512; do 15 | sips -z $SIZE $SIZE $ORIGICON --out $ICONDIR/icon_$(expr $SIZE / 2)x$(expr $SIZE / 2)x2.png ; 16 | done 17 | 18 | iconutil -c icns -o resources/icon.icns $ICONDIR 19 | rm -rf $ICONDIR 20 | 21 | 22 | -------------------------------------------------------------------------------- /scripts/generate-certs.ps1: -------------------------------------------------------------------------------- 1 | # https://stackoverflow.com/questions/84847/how-do-i-create-a-self-signed-certificate-for-code-signing-on-windows 2 | 3 | makecert -r -pe -n "CN=Stefan Matting CA" -ss CA -sr CurrentUser -a sha256 -cy authority -sky signature -sv StefanMattingCA.pvk StefanMattingCA.cer 4 | 5 | makecert -pe -n "CN=Stefan Matting SPC" -a sha256 -cy end ` 6 | -sky signature ` 7 | -ic StefanMattingCA.cer -iv StefanMattingCA.pvk ` 8 | -sv StefanMattingSPC.pvk StefanMattingSPC.cer 9 | 10 | pvk2pfx -pvk StefanMattingSPC.pvk -spc StefanMattingSPC.cer -pfx StefanMattingSPC.pfx 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from setuptools.command.install import install 3 | import os 4 | 5 | class PutVersion(install): 6 | description = "Custom command example" 7 | 8 | def run(self): 9 | version = os.environ['POINTER_CC_VERSION'] 10 | with open('./pointercc/version.py', 'w') as f: 11 | f.write(f'version = "{version}"\n') 12 | 13 | setup( 14 | name='pointer-cc', 15 | version='0.0.0', 16 | description='Control your mouse via MIDI controler to control your software instruments', 17 | author='Stefan Matting', 18 | author_email='pointer-cc@posteo.com', 19 | packages=find_packages(), 20 | cmdclass={ 21 | 'put_version': PutVersion, 22 | }, 23 | ) 24 | -------------------------------------------------------------------------------- /pointercc/win32.py: -------------------------------------------------------------------------------- 1 | import win32gui 2 | from pointercc.core import Window, Box 3 | 4 | def get_windows(name_patterns): 5 | result = {} 6 | def f(hwnd, r): 7 | title = win32gui.GetWindowText(hwnd) 8 | 9 | if not win32gui.IsWindowVisible(hwnd): 10 | return 11 | 12 | if title is None: 13 | return 14 | 15 | for pattern in name_patterns: 16 | if title.find(pattern) > -1: 17 | (xmin, ymin, xmax, ymax) = win32gui.GetWindowRect(hwnd) 18 | box = Box(xmin, xmax, ymin, ymax) 19 | window = Window(pattern, title, box) 20 | result[title] = window 21 | win32gui.EnumWindows(f, result) 22 | return result 23 | 24 | def init(): 25 | pass 26 | -------------------------------------------------------------------------------- /scripts/setup-stefan-mac.sh: -------------------------------------------------------------------------------- 1 | python="/Library/Frameworks/Python.framework/Versions/3.10/Resources/Python.app/Contents/MacOS/Python" 2 | rm -rf venv 3 | "$python" --version 4 | "$python" -m venv venv 5 | source venv/bin/activate 6 | command -v python3 7 | python3 --version 8 | 9 | if [ "$TARGET_ARCH" = "x86_64" ]; then 10 | # --no-deps: prevent wxpython from pulling numpy 11 | pip install --no-deps -r requirements.txt 12 | pip install -r requirements-stage2.txt 13 | else 14 | # --no-deps: prevent wxpython from pulling numpy 15 | # --no-deps also required when specifying platform 16 | pip install --platform macosx_11_0_arm64 --no-deps --target venv/lib/python3.10/site-packages -r requirements.txt 17 | pip install --platform macosx_11_0_arm64 --no-deps --target venv/lib/python3.10/site-packages -r requirements-stage2.txt 18 | fi 19 | -------------------------------------------------------------------------------- /instruments/inst-prophet-5-v.txt: -------------------------------------------------------------------------------- 1 | # This instrument configuration file is of TOML format (https://toml.io). See the pointer-cc documentation for details. 2 | 3 | [window] 4 | contains = "Prophet-5 V" 5 | 6 | [default] 7 | type = "drag" 8 | 9 | [default.drag] 10 | speed = 1.0 11 | 12 | [default.wheel] 13 | speed = 1.0 14 | time_resolution = 100 15 | 16 | [dimensions] 17 | width = 1437 18 | height = 704 19 | 20 | [controls] 21 | [controls.c1] 22 | x = 374 23 | y = 121 24 | m = 6.0 25 | 26 | [controls.c2] 27 | x = 535 28 | y = 123 29 | m = 1.0 30 | 31 | [controls.c3] 32 | x = 435 33 | y = 136 34 | m = 1.0 35 | 36 | [controls.c4] 37 | x = 475 38 | y = 136 39 | m = 1.0 40 | type = "click" 41 | 42 | [controls.c5] 43 | x = 374 44 | y = 210 45 | m = 1.0 46 | 47 | [controls.c6] 48 | x = 437 49 | y = 210 50 | m = 1.0 51 | 52 | [controls.c7] 53 | x = 497 54 | y = 223 55 | m = 1.0 56 | -------------------------------------------------------------------------------- /pointercc/darwin.py: -------------------------------------------------------------------------------- 1 | import Quartz 2 | from pointercc.core import Window, Box, make_box 3 | 4 | def request_access(): 5 | Quartz.CGRequestPostEventAccess() 6 | Quartz.CGRequestScreenCaptureAccess() 7 | Quartz.CGPreflightScreenCaptureAccess() 8 | 9 | def get_windows(name_patterns): 10 | windows = Quartz.CGWindowListCopyWindowInfo(0, Quartz.kCGNullWindowID) 11 | result = {} 12 | for w in windows: 13 | bounds = w.get(Quartz.kCGWindowBounds) 14 | name = str(w.get(Quartz.kCGWindowName)) 15 | if name is None or bounds is None: 16 | continue 17 | 18 | for pattern in name_patterns: 19 | if name.find(pattern) > -1: 20 | box = make_box(int(bounds['X']), int(bounds['Y']), int(bounds['Width']), int(bounds['Height'])) 21 | window = Window(pattern, name, box) 22 | result[name] = window 23 | return result 24 | 25 | def init(): 26 | request_access() 27 | -------------------------------------------------------------------------------- /scripts/package-mac.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | cert_name="Developer ID Application: Stefan Matting (FPDFQ974RQ)" 6 | 7 | destination_dmg="dist/pointer-cc-$POINTER_CC_VERSION-${TARGET_ARCH:-x86_64}.dmg" 8 | rm -rf "$destination_dmg" 9 | 10 | codesign --deep --force --verify --verbose --sign "$cert_name" ./dist/pointer-cc.app 11 | 12 | temp_dir=$(mktemp -d) 13 | 14 | cp -R "./dist/pointer-cc.app" "$temp_dir/pointer-cc.app/" 15 | 16 | ln -s "/Applications" "$temp_dir/Applications" 17 | 18 | tmp_dmg="./tmp.dmg" 19 | 20 | hdiutil create -srcfolder "$temp_dir" -volname "pointer-cc $POINTER_CC_VERSION ${TARGET_ARCH:-x86_64}" \ 21 | -format UDRW -ov -fs HFS+ "$tmp_dmg" 22 | 23 | hdiutil convert "$tmp_dmg" -format UDZO -o "$destination_dmg" 24 | 25 | # Cleanup: Remove the temporary directory 26 | rm -rf "$temp_dir" 27 | 28 | set +e 29 | xattr -d com.apple.FinderInfo "$destination_dmg" 30 | set -e 31 | 32 | codesign --force --verify --verbose --sign "$cert_name" "$destination_dmg" 33 | 34 | echo "DMG created successfully at: $destination_dmg" 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Stefan Matting 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 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | altgraph==0.17.4 2 | appdirs==1.4.4 3 | asttokens==2.4.1 4 | certifi==2024.2.2 5 | charset-normalizer==3.3.2 6 | idna==3.7 7 | urllib3==2.2.1 8 | colorama==0.4.6; sys_platform == "win32" 9 | decorator==5.1.1 10 | exceptiongroup==1.2.0 11 | executing==2.0.1 12 | ipdb==0.13.13 13 | ipython==8.22.2 14 | jedi==0.19.1 15 | macholib==1.16.3 16 | matplotlib-inline==0.1.6 17 | modulegraph==0.19.6 18 | packaging==24.0 19 | parso==0.8.3 20 | pefile==2023.2.7; sys_platform == "win32" 21 | pexpect==4.9.0 22 | pillow==10.2.0 23 | prompt-toolkit==3.0.43 24 | ptyprocess==0.7.0 25 | pure-eval==0.2.2 26 | PyGetWindow==0.0.9 27 | Pygments==2.17.2 28 | pyinstaller==6.6.0 29 | pyinstaller-hooks-contrib==2024.4 30 | pyobjc-core==10.2; sys_platform == "darwin" 31 | pyobjc-framework-Cocoa==10.2; sys_platform == "darwin" 32 | pyobjc-framework-Quartz==10.2; sys_platform == "darwin" 33 | pyperclip==1.8.2 34 | PyRect==0.2.0 35 | python-rtmidi==1.5.8 36 | pywin32==306; sys_platform == "win32" 37 | pywin32-ctypes==0.2.2; sys_platform == "win32" 38 | requests==2.31.0 39 | six==1.16.0 40 | stack-data==0.6.3 41 | tk==0.1.0 42 | tomli==2.0.1 43 | tomlkit==0.12.4 44 | traitlets==5.14.2 45 | wcwidth==0.2.13 46 | wxPython==4.2.1 47 | semver==3.0.2 48 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | First release: 2 | - [x] Use TOML instead of YAML 3 | - [x] free MIDI bindings 4 | - [x] add support for drag-controlled vsts 5 | - [x] fix bug: drag moves the pointer 6 | - [x] respect default speeds 7 | - [x] implement click 8 | - [x] auto-connect MIDI 9 | - [x] make image analysis available in GUI 10 | - [x] remove yaml 11 | - [x] logo 12 | - [x] warn user when no instruments are configured 13 | - [x] fix: ZeroDivisionError: float division by zero 14 | - [x] rename controllers to controls 15 | - [x] windows version (https://github.com/mhammond/pywin32 ?) 16 | - [x] x istrument(s) configured 17 | - [x] fix bug (win32): .png files not shown 18 | - [x] allow for markers in any shape, not just rectangles 19 | - [x] fix: wheel control has low resoluion (fix by removing) 20 | - [x] reload config button / automatic reload 21 | - [x] pick from multiple windows 22 | - [x] control settings: time_resolution etc 23 | - [x] show nice config error messages, handle gracefully 24 | - [x] license 25 | - [x] Sign mac release with developer id 26 | - [x] Installer for win32 27 | - [x] use pyinstaller also for mac 28 | - [x] ci 29 | - [x] Self-sign windows application 30 | - [x] fix huge build for mac 31 | - [x] github: remove that "deployments" (environments) thingy 32 | - [x] icon for windows 33 | - [x] fix bug: app doesn't close 34 | - [x] new icon 35 | - [x] new version available check 36 | - [x] write documentation 37 | - [x] create video 38 | -------------------------------------------------------------------------------- /pointercc/core.py: -------------------------------------------------------------------------------- 1 | class Box: 2 | def __init__(self, xmin, xmax, ymin, ymax): 3 | self.xmin = xmin 4 | self.xmax = xmax 5 | self.ymin = ymin 6 | self.ymax = ymax 7 | 8 | def __eq__(self, other): 9 | return self.totuple() == other.totuple() 10 | 11 | def totuple(self): 12 | return (self.xmin, self.xmax, self.ymin, self.ymax) 13 | 14 | def center(self): 15 | x = self.xmin + (self.xmax - self.xmin) // 2 16 | y = self.ymin + (self.ymax - self.ymin) // 2 17 | return (x, y) 18 | 19 | @property 20 | def width(self): 21 | return self.xmax - self.xmin 22 | 23 | @property 24 | def height(self): 25 | return self.ymax - self.ymin 26 | 27 | def contains_point(self, x, y): 28 | x_inside = self.xmin <= x and x <= self.xmax 29 | y_inside = self.ymin <= y and y <= self.ymax 30 | return x_inside and y_inside 31 | 32 | def make_box(x, y, width, height): 33 | return Box(x, x + width, y, y + height) 34 | 35 | class Window: 36 | def __init__(self, pattern, name, box): 37 | self.pattern = pattern 38 | self.name = name 39 | self.box = box 40 | 41 | def totuple(self): 42 | return (self.pattern, self.name, self.box) 43 | 44 | def __eq__(self, other): 45 | if other is None: 46 | return False 47 | else: 48 | return self.totuple() == other.totuple() 49 | -------------------------------------------------------------------------------- /setup.iss: -------------------------------------------------------------------------------- 1 | ; Script generated by the Inno Setup Script Wizard. 2 | ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! 3 | 4 | #define MyAppName "pointer-cc" 5 | #define MyAppPublisher "Stefan Matting" 6 | #define MyAppURL "https://github.com/smatting/pointer-cc/" 7 | #define MyAppExeName "pointer-cc.exe" 8 | 9 | [Setup] 10 | ; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. 11 | ; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) 12 | AppId={{E514A077-8868-4277-9A7C-7A6D4CC330E4} 13 | AppName={#MyAppName} 14 | AppVersion={#GetEnv('POINTER_CC_VERSION')} 15 | AppPublisher={#MyAppPublisher} 16 | AppPublisherURL={#MyAppURL} 17 | AppSupportURL={#MyAppURL} 18 | AppUpdatesURL={#MyAppURL} 19 | DefaultDirName={autopf}\{#MyAppName} 20 | DisableProgramGroupPage=yes 21 | LicenseFile=LICENSE 22 | ; Uncomment the following line to run in non administrative install mode (install for current user only.) 23 | ;PrivilegesRequired=lowest 24 | OutputBaseFilename=pointer-cc-{#GetEnv('POINTER_CC_VERSION')}-install 25 | Compression=lzma 26 | SolidCompression=yes 27 | WizardStyle=modern 28 | 29 | [Languages] 30 | Name: "english"; MessagesFile: "compiler:Default.isl" 31 | 32 | [Tasks] 33 | Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked 34 | 35 | [Files] 36 | Source: "dist\pointer-cc\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion 37 | Source: "dist\pointer-cc\_internal\*"; DestDir: "{app}\_internal"; Flags: ignoreversion recursesubdirs createallsubdirs 38 | ; NOTE: Don't use "Flags: ignoreversion" on any shared system files 39 | 40 | [Icons] 41 | Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" 42 | Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon 43 | 44 | [Run] 45 | Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent 46 | 47 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | # on: 7 | # workflow_dispatch: 8 | 9 | jobs: 10 | build-win32: 11 | runs-on: windows-2022 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Build 17 | shell: powershell 18 | env: 19 | SPC_B64: ${{ secrets.SPC_B64 }} 20 | POINTER_CC_VERSION: ${{ vars.POINTER_CC_VERSION }} 21 | run: | 22 | $bytes = [System.Convert]::FromBase64String($env:SPC_B64) 23 | Set-Content -Path .\StefanMattingSPC.pfx -Value $bytes -Encoding Byte 24 | Import-PfxCertificate -FilePath .\StefanMattingSPC.pfx -CertStoreLocation 'Cert:\CurrentUser\My' 25 | Remove-Item .\StefanMattingSPC.pfx 26 | 27 | choco install python310 > $null 28 | choco install innosetup 29 | 30 | Remove-Item -Force -Recurse -ErrorAction SilentlyContinue venv 31 | C:\hostedtoolcache\windows\Python\3.10.11\x64\python.exe --version 32 | C:\hostedtoolcache\windows\Python\3.10.11\x64\python.exe -m venv venv 33 | cd .\venv\Scripts 34 | ./Activate.ps1 35 | cd ..\..\ 36 | 37 | # --no-deps: prevent wxpython from pulling numpy 38 | python -m pip install --no-deps -r requirements.txt 39 | python -m pip install -r requirements-stage2.txt 40 | 41 | & .\scripts\build.ps1 42 | $env:PATH = " C:\Program Files (x86)\Windows Kits\10\App Certification Kit;" + $env:PATH 43 | & .\scripts\package-win.ps1 44 | 45 | - name: Upload Artifacts 46 | uses: actions/upload-artifact@v4 47 | with: 48 | name: build-result-win32 49 | path: Output/*.exe 50 | 51 | build-darwin: 52 | runs-on: macos-11 53 | strategy: 54 | matrix: 55 | target_arch: [ x86_64, arm64 ] 56 | steps: 57 | - name: Checkout 58 | uses: actions/checkout@v4 59 | 60 | - name: Import certs 61 | shell: bash 62 | env: 63 | CERTS_P12: ${{ secrets.CERTS_P12 }} 64 | CERTS_P12_PASSWORD: ${{ secrets.CERTS_P12_PASSWORD }} 65 | run: | 66 | set -eo pipefail 67 | # https://github.com/Apple-Actions/import-codesign-certs/blob/master/src/security.ts 68 | 69 | CERTS_PATH=$RUNNER_TEMP/bundle.p12 70 | echo -n "$CERTS_P12" | base64 -d -o "$CERTS_PATH" 71 | KEYCHAIN_PATH=$RUNNER_TEMP/app-signing 72 | KEYCHAIN_PASSWORD=foo 73 | 74 | # create temporary keychain 75 | security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH 76 | security set-keychain-settings -lut 21600 $KEYCHAIN_PATH 77 | security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH 78 | 79 | # import and allow all applications to use it 80 | security import "$CERTS_PATH" -k $KEYCHAIN_PATH -f pkcs12 -A -T /usr/bin/codesign -T /usr/bin/security -P "$CERTS_P12_PASSWORD" 81 | 82 | # magic incantation that is necessary 83 | security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH 84 | 85 | # also necessary, this command actually sets things (even though the name). 86 | security list-keychains -d user -s $KEYCHAIN_PATH login.keychain 87 | 88 | # check that we have a valid codesigning cert 89 | security find-identity -vp codesigning $KEYCHAIN_PATH 90 | 91 | - name: Set architecture variable 92 | run: | 93 | if [ "${{ matrix.target_arch }}" == "x86_64" ]; then 94 | architecture="x64" 95 | elif [ "${{ matrix.target_arch }}" == "arm64" ]; then 96 | architecture="arm64" 97 | fi 98 | echo "architecture=$architecture" >> "$GITHUB_ENV" 99 | 100 | - uses: actions/setup-python@v5 101 | with: 102 | architecture: ${{ env.architecture }} 103 | python-version: '3.10.11' 104 | 105 | - shell: bash 106 | env: 107 | POINTER_CC_VERSION: ${{ vars.POINTER_CC_VERSION }} 108 | TARGET_ARCH: ${{ matrix.target_arch }} 109 | run: | 110 | export TARGET_ARCH 111 | 112 | python3 --version 113 | python3 -m venv venv 114 | source venv/bin/activate 115 | 116 | if [ "$TARGET_ARCH" = "x86_64" ]; then 117 | # --no-deps: prevent wxpython from pulling numpy 118 | pip install --no-deps -r requirements.txt 119 | pip install -r requirements-stage2.txt 120 | else 121 | pip install --platform macosx_11_0_arm64 --no-deps --target venv/lib/python3.10/site-packages -r requirements.txt 122 | pip install --platform macosx_11_0_arm64 --no-deps --target venv/lib/python3.10/site-packages -r requirements-stage2.txt 123 | fi 124 | ./scripts/build.sh 125 | ./scripts/package-mac.sh 126 | 127 | - name: Upload Artifacts 128 | uses: actions/upload-artifact@v4 129 | with: 130 | name: build-result-darwin-${{ matrix.target_arch }} 131 | path: dist/*.dmg 132 | -------------------------------------------------------------------------------- /instruments/inst-tal-j-8.txt: -------------------------------------------------------------------------------- 1 | # This instrument configuration file is of TOML format (https://toml.io). See the pointer-cc documentation for details. 2 | 3 | [window] 4 | contains = "TAL-J-8" 5 | 6 | [default] 7 | type = "wheel" 8 | 9 | [default.drag] 10 | speed = 1.0 11 | 12 | [default.wheel] 13 | speed = 1.0 14 | time_resolution = 100 15 | 16 | [dimensions] 17 | width = 1439 18 | height = 736 19 | 20 | [controls] 21 | [controls.c1] 22 | x = 1074 23 | y = 172 24 | m = 1.0 25 | 26 | [controls.c2] 27 | x = 1033 28 | y = 177 29 | m = 1.0 30 | 31 | [controls.c3] 32 | x = 426 33 | y = 182 34 | m = 1.0 35 | 36 | [controls.c4] 37 | x = 352 38 | y = 183 39 | m = 1.0 40 | 41 | [controls.c5] 42 | x = 960 43 | y = 183 44 | m = 1.0 45 | 46 | [controls.c6] 47 | x = 1215 48 | y = 183 49 | m = 1.0 50 | 51 | [controls.c7] 52 | x = 1293 53 | y = 183 54 | m = 1.0 55 | 56 | [controls.c8] 57 | x = 304 58 | y = 184 59 | m = 1.0 60 | 61 | [controls.c9] 62 | x = 550 63 | y = 184 64 | m = 1.0 65 | 66 | [controls.c10] 67 | x = 592 68 | y = 184 69 | m = 1.0 70 | 71 | [controls.c11] 72 | x = 876 73 | y = 184 74 | m = 1.0 75 | 76 | [controls.c12] 77 | x = 1136 78 | y = 184 79 | m = 1.0 80 | 81 | [controls.c13] 82 | x = 503 83 | y = 185 84 | m = 1.0 85 | 86 | [controls.c14] 87 | x = 670 88 | y = 185 89 | m = 1.0 90 | 91 | [controls.c15] 92 | x = 718 93 | y = 185 94 | m = 1.0 95 | 96 | [controls.c16] 97 | x = 802 98 | y = 185 99 | m = 1.0 100 | 101 | [controls.c17] 102 | x = 1378 103 | y = 185 104 | m = 1.0 105 | 106 | [controls.c18] 107 | x = 242 108 | y = 194 109 | m = 1.0 110 | 111 | [controls.c19] 112 | x = 42 113 | y = 206 114 | m = 1.0 115 | 116 | [controls.c20] 117 | x = 116 118 | y = 206 119 | m = 1.0 120 | 121 | [controls.c21] 122 | x = 191 123 | y = 206 124 | m = 1.0 125 | 126 | [controls.c22] 127 | x = 426 128 | y = 228 129 | m = 1.0 130 | 131 | [controls.c23] 132 | x = 425 133 | y = 253 134 | m = 1.0 135 | 136 | [controls.c24] 137 | x = 736 138 | y = 254 139 | m = 1.0 140 | 141 | [controls.c25] 142 | x = 43 143 | y = 316 144 | m = 1.0 145 | 146 | [controls.c26] 147 | x = 117 148 | y = 316 149 | m = 1.0 150 | 151 | [controls.c27] 152 | x = 570 153 | y = 402 154 | m = 1.0 155 | 156 | [controls.c28] 157 | x = 1130 158 | y = 402 159 | m = 1.0 160 | 161 | [controls.c29] 162 | x = 1388 163 | y = 402 164 | m = 1.0 165 | 166 | [controls.c30] 167 | x = 469 168 | y = 404 169 | m = 1.0 170 | 171 | [controls.c31] 172 | x = 417 173 | y = 412 174 | m = 1.0 175 | 176 | [controls.c32] 177 | x = 518 178 | y = 413 179 | m = 1.0 180 | 181 | [controls.c33] 182 | x = 371 183 | y = 414 184 | m = 1.0 185 | 186 | [controls.c34] 187 | x = 828 188 | y = 414 189 | m = 1.0 190 | 191 | [controls.c35] 192 | x = 1077 193 | y = 414 194 | m = 1.0 195 | 196 | [controls.c36] 197 | x = 305 198 | y = 415 199 | m = 1.0 200 | 201 | [controls.c37] 202 | x = 667 203 | y = 415 204 | m = 1.0 205 | 206 | [controls.c38] 207 | x = 782 208 | y = 415 209 | m = 1.0 210 | 211 | [controls.c39] 212 | x = 875 213 | y = 415 214 | m = 1.0 215 | 216 | [controls.c40] 217 | x = 938 218 | y = 415 219 | m = 1.0 220 | 221 | [controls.c41] 222 | x = 984 223 | y = 415 224 | m = 1.0 225 | 226 | [controls.c42] 227 | x = 1243 228 | y = 415 229 | m = 1.0 230 | 231 | [controls.c43] 232 | x = 1289 233 | y = 415 234 | m = 1.0 235 | 236 | [controls.c44] 237 | x = 1335 238 | y = 415 239 | m = 1.0 240 | 241 | [controls.c45] 242 | x = 621 243 | y = 416 244 | m = 1.0 245 | 246 | [controls.c46] 247 | x = 711 248 | y = 416 249 | m = 1.0 250 | 251 | [controls.c47] 252 | x = 1031 253 | y = 416 254 | m = 1.0 255 | 256 | [controls.c48] 257 | x = 1195 258 | y = 416 259 | m = 1.0 260 | 261 | [controls.c49] 262 | x = 40 263 | y = 521 264 | m = 1.0 265 | 266 | [controls.c50] 267 | x = 97 268 | y = 528 269 | m = 1.0 270 | 271 | [controls.c51] 272 | x = 1035 273 | y = 560 274 | m = 1.0 275 | 276 | [controls.c52] 277 | x = 555 278 | y = 561 279 | m = 1.0 280 | 281 | [controls.c53] 282 | x = 658 283 | y = 561 284 | m = 1.0 285 | 286 | [controls.c54] 287 | x = 985 288 | y = 561 289 | m = 1.0 290 | 291 | [controls.c55] 292 | x = 346 293 | y = 562 294 | m = 1.0 295 | 296 | [controls.c56] 297 | x = 451 298 | y = 562 299 | m = 1.0 300 | 301 | [controls.c57] 302 | x = 505 303 | y = 562 304 | m = 1.0 305 | 306 | [controls.c58] 307 | x = 608 308 | y = 562 309 | m = 1.0 310 | 311 | [controls.c59] 312 | x = 762 313 | y = 562 314 | m = 1.0 315 | 316 | [controls.c60] 317 | x = 865 318 | y = 562 319 | m = 1.0 320 | 321 | [controls.c61] 322 | x = 1184 323 | y = 562 324 | m = 1.0 325 | 326 | [controls.c62] 327 | x = 1287 328 | y = 562 329 | m = 1.0 330 | 331 | [controls.c63] 332 | x = 1342 333 | y = 562 334 | m = 1.0 335 | 336 | [controls.c64] 337 | x = 1390 338 | y = 562 339 | m = 1.0 340 | 341 | [controls.c65] 342 | x = 298 343 | y = 563 344 | m = 1.0 345 | 346 | [controls.c66] 347 | x = 401 348 | y = 563 349 | m = 1.0 350 | 351 | [controls.c67] 352 | x = 712 353 | y = 563 354 | m = 1.0 355 | 356 | [controls.c68] 357 | x = 815 358 | y = 563 359 | m = 1.0 360 | 361 | [controls.c69] 362 | x = 1134 363 | y = 563 364 | m = 1.0 365 | 366 | [controls.c70] 367 | x = 1237 368 | y = 563 369 | m = 1.0 370 | 371 | [controls.c71] 372 | x = 36 373 | y = 647 374 | m = 1.0 375 | 376 | [controls.c72] 377 | x = 74 378 | y = 648 379 | m = 1.0 380 | 381 | [controls.c73] 382 | x = 188 383 | y = 649 384 | m = 1.0 385 | 386 | [controls.c74] 387 | x = 224 388 | y = 649 389 | m = 1.0 390 | 391 | [controls.c75] 392 | x = 145 393 | y = 650 394 | m = 1.0 395 | 396 | [controls.c76] 397 | x = 663 398 | y = 677 399 | m = 1.0 400 | 401 | [controls.c77] 402 | x = 700 403 | y = 677 404 | m = 1.0 405 | 406 | [controls.c78] 407 | x = 601 408 | y = 679 409 | m = 1.0 410 | 411 | [controls.c79] 412 | x = 884 413 | y = 679 414 | m = 1.0 415 | 416 | [controls.c80] 417 | x = 1157 418 | y = 679 419 | m = 1.0 420 | 421 | [controls.c81] 422 | x = 1200 423 | y = 679 424 | m = 1.0 425 | 426 | [controls.c82] 427 | x = 517 428 | y = 680 429 | m = 1.0 430 | 431 | [controls.c83] 432 | x = 561 433 | y = 680 434 | m = 1.0 435 | 436 | [controls.c84] 437 | x = 1244 438 | y = 680 439 | m = 1.0 440 | 441 | [controls.c85] 442 | x = 1287 443 | y = 680 444 | m = 1.0 445 | 446 | [controls.c86] 447 | x = 290 448 | y = 681 449 | m = 1.0 450 | 451 | [controls.c87] 452 | x = 333 453 | y = 681 454 | m = 1.0 455 | 456 | [controls.c88] 457 | x = 754 458 | y = 681 459 | m = 1.0 460 | 461 | [controls.c89] 462 | x = 798 463 | y = 681 464 | m = 1.0 465 | 466 | [controls.c90] 467 | x = 841 468 | y = 681 469 | m = 1.0 470 | 471 | [controls.c91] 472 | x = 942 473 | y = 681 474 | m = 1.0 475 | 476 | [controls.c92] 477 | x = 986 478 | y = 681 479 | m = 1.0 480 | 481 | [controls.c93] 482 | x = 1029 483 | y = 681 484 | m = 1.0 485 | 486 | [controls.c94] 487 | x = 1073 488 | y = 682 489 | m = 1.0 490 | 491 | [controls.c95] 492 | x = 1116 493 | y = 682 494 | m = 1.0 495 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | ` 5 | pointer-cc is an app that enables you to control all the control elements of your software instrument using just 4 knobs or sliders of your MIDI controller. Pointer-cc does this by simulating the mouse pointer inside the instrument's window.` 6 | 7 | NOTE: The app is in early development and may contain a lot of bugs. Please help me improving the software by submitting error repors or feedback as a Github issue or send to [pointer-cc@posteo.com](mailto:pointer-cc@posteo.com). 8 | 9 | See Demo here: 10 | 11 | [](https://youtu.be/hVVDJ-jgU80) 12 | 13 | Some features of pointer-cc: 14 | 15 | - Detects the geometry of the software instruments, i.e. you can resize and move the instruments window around 16 | - Supports software instruments that react to "mouse wheel" AND to "dragging up and down" 17 | - Allows controlling many software instruments at the same time: pointer-cc detects the instrument window the pointer is inside and switches configurations dynamically 18 | - No setup in the DAW / Host-application required: pointer-cc only talks to the OS 19 | - Currently only supports software instruments with control elements at static positions, i.e. doesn't support menus or paged/tabbed interfaces 20 | 21 | ## Installation / Download 22 | 23 | You can download pointer-cc for both Mac and Windows from the [releases page](https://github.com/smatting/pointer-cc/releases). If you have a newer Mac with an M? chip, download the `.dmg` file that ends with `-arm64`. Otherwise, download the `.dmg` file that ends with `-x86_x64`. You can verify your Mac type under `About This Mac` in the Apple menu. 24 | 25 | 26 | When running the Windows installer, you may encounter a warning about the installer coming from an "Unknown Publisher." This is because I (the author) have not bought a verified certificate, which requires an annual fee of 300 EUR. Optionally, you can add the [StefanMattingCA.cer](https://raw.githubusercontent.com/smatting/pointer-cc/main/certs/StefanMattingCA.cer) certificate to your trusted root certificates to remove this warning. 27 | 28 | ## Using pointer-cc 29 | 30 | Upon first launch on a Mac, pointer-cc will request permissions for both "Screen Recording" and "Accessibility" to function properly. Navigate to "Security & Privacy" in your system settings and ensure permissions are enabled. Please restart the app afterward; sometimes, pointer-cc may not request all necessary permissions on the first attempt, requiring a second restart. 31 | 32 | ![Mac permissions needed for pointer-cc](docs/mac-permissions.gif) 33 | 34 | 35 | 36 | At the bottom of the main window, you can select your MIDI device and channel. If everything is set up correctly, MIDI messages should appear at the bottom as you interact. 37 | 38 | To begin using pointer-cc, you'll need to configure instrument settings. Follow these steps: 39 | 40 | 1. Capture a screenshot of your instrument. Make sure you crop to the exact contents of the window, omit the window bar or borders. 41 | 2. Use a paint program (e.g. online [jspaint.app](https://jspaint.app), MS Paint or [GIMP](https://www.gimp.org/)) to mark all controls with points or rectangles in a distinct color not found in the screenshot. Note the exact RGB color code (e.g., `#ff00ff` or `R = 255, G = 0, B = 255`). You can omit controls you're not interested in. Here's an example: 42 | 43 | ![controls marked with pink dots](docs/obxd-marked.jpg) 44 | 45 | Save the marked screenshot as a PNG file. 46 | 47 | 3. In pointer-cc, select `Add Instrument` from the menu and follow the prompts. 48 | 49 | After adding the instruments choose `Open Config Dir`. The configuration directory of pointer-cc contains two types of files: 50 | 51 | - `config.txt`: The main configuration file. 52 | 53 | - `inst-{some name}.txt`: Instrument configuration files (must start with `inst-` and end with `.txt`). 54 | 55 | The config files use the [TOML](https://toml.io/en/) format. 56 | 57 | The **main confguration** file `config.txt` looks like this 58 | 59 | ```toml 60 | [bindings] 61 | [bindings.1] 62 | command = "pan-x" 63 | cc = 77 64 | 65 | [bindings.2] 66 | command = "pan-y" 67 | cc = 78 68 | 69 | [bindings.3] 70 | command = "adjust-control" 71 | cc = 79 72 | 73 | [bindings.4] 74 | command = "freewheel" 75 | cc = 80 76 | 77 | [midi] 78 | port = "Launch Control XL 0" 79 | channel = 0 80 | 81 | ``` 82 | 83 | In the `[bindings]` section, you map MIDI control knobs to pointer-cc commands. To determine the correct `cc` field value, note the control number displayed in the MIDI status bar at the bottom of the pointer-cc window. 84 | 85 | The `command` field specifies the action performed when adjusting the MIDI controller. 86 | 87 | - `pan-x`: pan the cursor horizontally. A CC value `0` pans the pointer all the way left 88 | 89 | - `pan-x-inv` pan the cursor horizonally. A CC value of `127` pans the pointer all the way left 90 | 91 | - `pan-y` pan the cursor vertically. A CC value `0` pans the pointer all the way up 92 | 93 | - `pan-y-inv` pan the cursor vertically. A CC value `127` pans the pointer all the way up 94 | 95 | - `adjust-control` adjust the current control. What mouse pointer action is simulated depends on the configuration of current control element (See instrument configuration below) 96 | 97 | - `freewheel` start freewheeling. While freewheeling you can turn the adjustment knob (knob mapped to `adjust-control`) in one direction without it having any effect. When you turn the adjustment knob in the other direction freewheeling stops and the adjustment knob has its effect again. Freewheeling is useful if you don't have an endless rotary knob on your midi controller. 98 | 99 | The `[midi]` section updates automatically based on your MIDI settings within the application. 100 | 101 | The instrument file that is generated in the "Add Instrument" window is meant to be edited manually after creating it. A typical **instrument configuration** file, e.g. `inst-jupiter8.txt` looks like this 102 | 103 | ```toml 104 | [window] 105 | contains = "TAL-J-8" 106 | 107 | [default] 108 | type = "wheel" 109 | 110 | [default.drag] 111 | speed = 1.0 112 | 113 | [default.wheel] 114 | speed = 0.3 115 | time_resolution = 50 116 | 117 | [dimensions] 118 | width = 1439 119 | height = 736 120 | 121 | [controls] 122 | [controls.c1] 123 | x = 1074 124 | y = 172 125 | m = 1.0 126 | 127 | [controls.c2] 128 | type = "click" 129 | x = 1033 130 | y = 177 131 | m = 1.0 132 | ``` 133 | 134 | - `window.contains` is used by pointer-cc to find the instrument window. Pick a string here that is contained in the window title of the instrument. It's usually the name of the instrument. Note that the case has to also match (comparison is case-sensitive). 135 | 136 | - `controls` The `controls.c1`, `controls.c2`, ... sections correspond to the control elements that you marked in the screenshots. You can see the `c?` number that belongs to acontrol element in the pointer-cc window when you select it via the panning knobs. 137 | 138 | - `controls.c1.x`: x coordinate of the control element (was extracted from screenshot) 139 | 140 | - `controls.c1.y`: y coordinate of the control element (was extracted from screenshot) 141 | 142 | - `controls.c1.type` (optional): Specifies the type of mouse pointer action simulated by pointer-cc when adjusting the knob associated with control `c1`. Available values include: 143 | 144 | - `drag`: Simulates dragging the mouse pointer up or down. 145 | 146 | - `wheel`: Simulates scrolling the mouse wheel up or down. 147 | 148 | - `click`: Simulates a mouse click. To trigger a click, quickly turn the adjustment knob down and then up again. 149 | 150 | If no `type` is specified for a control element (`c1`), the default type specified by `default.type` will be used. 151 | 152 | - `control.c1.m` (optional): This parameter acts as a speed multiplier specifically applicable to `wheel` and `drag` control types. When set to values smaller than `1.0`, it reduces the sensitivity of dragging or wheeling actions, resulting in slower movements. Conversely, values greater than `1.0` increase sensitivity, causing faster dragging or wheeling. Adjust this parameter to fine-tune the responsiveness of the control relative to others. The resulting speed of the controller is calculated as `speed * m`. If `m` is not specified, a default value of `1.0` is used. 153 | 154 | - `control.c1.speed` (optional): This parameter defines the speed specifically for the control element. It is recommended not to set this parameter unless you have a specific reason to do so. Instead, it is preferable to establish consistent base speeds for all knobs by setting `default.drag.speed` and `default.wheel.speed`. Use `control.c1.m` to adjust the relative speed of individual controls based on this standardized base speed. This approach ensures uniform behavior across controls and simplifies configuration management. 155 | 156 | - `default.type` the default type of pointer control used by all controls if not explicitely `type` is defined. Valid values are `drag`, `wheel`, `click` (see above) 157 | 158 | - `default.drag.speed` default setting for`speed` for controls that are of type `drag` 159 | 160 | - `default.wheel.speed` default setting for`speed` for controls that are of type `wheel` 161 | 162 | - `default.wheel.time_resolution`: This setting determines the maximum frequency (in times per second) at which wheel events are sent to the instrument window. If set too high, particularly on Windows systems, rapid adjustment knob turns may cause the operating system to drop wheel events. Conversely, setting it too low can result in overly choppy updates. I recommend experimenting with this value to find an optimal balance. A starting point of `50` times per second often works well to achieve smooth and responsive control. 163 | 164 | - `dimensions.width` and `dimensions.height`. Defines the dimensions of the whole instruments. All `x` and `y` coordinates of control elements relative to it. This is the resolution of the screenshot image. 165 | 166 | ## Acknowledgements 167 | The original idea for this project came from a video by [@WoodyPianoShack](https://www.youtube.com/@WoodyPianoShack) titled [I Fixed The Major Problem With All Software Synths](https://www.youtube.com/watch?v=BPoutltNV_E). 168 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from PIL import Image 2 | import wx 3 | import wx.lib.delayedresult 4 | import requests 5 | import time 6 | import importlib.resources 7 | import datetime 8 | import http.client 9 | import urllib 10 | import semver 11 | import textwrap 12 | import pyperclip 13 | import traceback 14 | import os 15 | import time 16 | import webbrowser 17 | import subprocess 18 | import platform 19 | import glob 20 | import rtmidi 21 | import math 22 | import traceback 23 | import mouse 24 | from rtmidi.midiutil import open_midiport 25 | import threading 26 | import queue 27 | import tomlkit 28 | import copy 29 | import sys 30 | import re 31 | import appdirs 32 | from enum import Enum 33 | from pointercc.core import Window, Box, make_box 34 | from pointercc.version import version 35 | if sys.platform == "win32": 36 | import pointercc.win32 as core_platform 37 | elif sys.platform == "darwin": 38 | import pointercc.darwin as core_platform 39 | else: 40 | raise NotImplemnted(f'Platform {sys.platform} not supported') 41 | 42 | app_name = "pointer-cc" 43 | app_author = "smatting" 44 | app_url = "https://github.com/smatting/pointer-cc/" 45 | app_email = "pointer-cc@posteo.com" 46 | url_latest = "https://raw.githubusercontent.com/smatting/pointer-cc/main/latest_release.txt" 47 | 48 | InternalCommand = Enum('InternalCommand', ['QUIT', 'CHANGE_MIDI_PORT', 'CHANGE_MIDI_CHANNEL', 'UPDATE_WINDOW', 'RELOAD_ALL_CONFIGS']) 49 | 50 | class Bijection: 51 | def __init__(self, a_name, b_name, atob_pairs): 52 | self._d_atob = dict(atob_pairs) 53 | self._d_btoa = dict([(b, a) for (a, b) in atob_pairs]) 54 | 55 | setattr(self, a_name, self._a) 56 | setattr(self, b_name, self._b) 57 | 58 | def _a(self, b): 59 | return self._d_btoa.get(b) 60 | 61 | def _b(self, a): 62 | return self._d_atob.get(a) 63 | 64 | def datadir(): 65 | return appdirs.user_data_dir(app_name, app_author) 66 | 67 | def userfile(p): 68 | return os.path.join(datadir(), p) 69 | 70 | def configfile(): 71 | return userfile('config.txt') 72 | 73 | def initialize_config(): 74 | os.makedirs(datadir(), exist_ok=True) 75 | cf = configfile() 76 | if not os.path.exists(cf): 77 | with open(cf, 'w') as f: 78 | conf = default_config_toml() 79 | f.write(conf.as_string()) 80 | 81 | ControlType = Enum('ControlType', ['WHEEL', 'DRAG','CLICK']) 82 | 83 | class ConfigError(Exception): 84 | def __init__(self, msg): 85 | self.msg = msg 86 | super(ConfigError, self).__init__(self.msg) 87 | 88 | def in_context(f, context): 89 | try: 90 | return f() 91 | except ConfigError as ce: 92 | ce.msg = ce.msg + f", {context}" 93 | raise ce 94 | 95 | control_type_bij = Bijection('str', 'enum', [('wheel', ControlType.WHEEL), ('drag', ControlType.DRAG), ('click', ControlType.CLICK)]) 96 | 97 | def squared_dist(p1, p2): 98 | x1, y1 = p1 99 | x2, y2 = p2 100 | return (x1 - x2)**2 + (y1 - y2)**2 101 | 102 | class Control: 103 | def __init__(self, type_, i, x, y, speed, m, time_resolution): 104 | self.type_ = type_ 105 | self.i = i 106 | self.x = x 107 | self.y = y 108 | self.speed = speed 109 | self.m = m 110 | self.time_resolution = time_resolution 111 | 112 | def __eq__(self, other): 113 | return self.i == other.i 114 | 115 | def __str__(self): 116 | type_str = control_type_bij.str(self.type_) 117 | return f'{self.i}({type_str})' 118 | 119 | @staticmethod 120 | def parse(d, i, default, context): 121 | try: 122 | x_s = expect_value(d, "x") 123 | x = expect_int(x_s, "x") 124 | 125 | y_s = expect_value(d, "y") 126 | y = expect_int(y_s, "y") 127 | 128 | default_type = control_type_bij.enum(default['type']) 129 | type_= maybe(d.get('type'), control_type_bij.enum, default_type) 130 | 131 | m = maybe(d.get('m'), lambda s: expect_float(s, 'm'), 1.0) 132 | 133 | if type_ == ControlType.WHEEL: 134 | d = default['wheel'] 135 | default_speed = d['speed'] 136 | default_time_resolution = d['time_resolution'] 137 | else: 138 | d = default['drag'] 139 | default_speed = d['speed'] 140 | default_time_resolution = 100 141 | 142 | time_resolution = maybe(d.get('time_resolution'), lambda s: expect_int(s, 'time_resolution'), default_time_resolution) 143 | speed = maybe(d.get('speed'), lambda s: expect_float(s, 'speed'), default_speed) 144 | 145 | return Control(type_, i, x, y, speed, m, time_resolution) 146 | 147 | except ConfigError as ce: 148 | ce.msg = ce.msg + f", {context}" 149 | raise ce 150 | 151 | def maybe(mv, f, default): 152 | if mv is None: 153 | return default 154 | else: 155 | return f(mv) 156 | 157 | class Instrument: 158 | def __init__(self, pattern, box, controls): 159 | self.pattern = pattern 160 | self.box = box 161 | self.controls = controls 162 | 163 | @staticmethod 164 | def load(path, instrument_context): 165 | try: 166 | controls = [] 167 | 168 | with open(path, 'r') as f: 169 | d = tomlkit.load(f) 170 | 171 | dimensions = expect_value(d, 'dimensions') 172 | width_s = expect_value(dimensions, 'width', 'dimensions') 173 | width = expect_int(width_s, 'dimensions.width') 174 | height_s = expect_value(dimensions, 'height', 'dimensions') 175 | height = expect_int(height_s, 'dimensions.height') 176 | 177 | box = Box(0, width, 0, height) 178 | 179 | default = expect_value(d, 'default') 180 | type_s = expect_value(default, 'type', 'default') 181 | 182 | default_wheel = expect_value(default, 'wheel', 'default') 183 | expect_value(default_wheel, 'speed', 'default.wheel') 184 | expect_value(default_wheel, 'time_resolution', 'default.wheel') 185 | 186 | default_drag = expect_value(default, 'drag', 'default') 187 | expect_value(default_drag, 'speed', 'default.drag') 188 | 189 | controls_unparsed = expect_value(d, 'controls') 190 | for control_id, v in controls_unparsed.items(): 191 | context = f"in control \"{control_id}\"" 192 | c = Control.parse(v, control_id, default, context) 193 | controls.append(c) 194 | 195 | window = expect_value(d, 'window') 196 | pattern = expect_value(window, 'contains', 'window') 197 | 198 | return Instrument(pattern, box, controls) 199 | 200 | except tomlkit.exceptions.TOMLKitError as tk: 201 | msg = f'Not a valid TOML file: {tk}' 202 | raise ConfigError(msg + f" in \"{instrument_context}\"") 203 | 204 | except ConfigError as ce: 205 | raise ConfigError(ce.msg + f" in \"{instrument_context}\"") 206 | 207 | 208 | def find_closest_control(self, mx, my): 209 | c_best = None 210 | d_best = math.inf 211 | for c in self.controls: 212 | d = math.pow(c.x - mx, 2.0) + math.pow(c.y - my, 2.0) 213 | if d < d_best: 214 | c_best = c 215 | d_best = d 216 | return c_best 217 | 218 | def overlaps(p1, p2): 219 | if p1[0] > p2[0]: 220 | p1, p2 = p2, p1 221 | return p2[0] <= p1[1] 222 | 223 | def find_overlapping(spans, p): 224 | for p_span in spans: 225 | if overlaps(p_span, p): 226 | return spans[p_span] 227 | return None 228 | 229 | def color_diff(c1, c2): 230 | return max(abs(c1[0] - c2[0]), abs(c1[1] - c2[1]), abs(c1[2] - c2[2])) 231 | 232 | def find_markings(im, marker_color, threshold, update_percent): 233 | boxes = [] 234 | spans_last = {} 235 | for y in range(0, im.height): 236 | update_percent(int(100*y//im.height)) 237 | xmin = None 238 | xmax = None 239 | spans = {} 240 | for x in range(0, im.width): 241 | color = im.getpixel((x, y)) 242 | diff = color_diff(marker_color, color) 243 | in_marker = diff <= threshold 244 | if in_marker: 245 | if xmin is None: 246 | xmin = x 247 | xmax = x 248 | 249 | if (not in_marker and xmax is not None) or (in_marker and x == im.width - 1): 250 | b = find_overlapping(spans_last, (xmin, xmax)) 251 | if b is not None: 252 | b.ymax = y 253 | b.xmin = min(b.xmin, xmin) 254 | b.xmax = max(b.xmax, xmax) 255 | else: 256 | b = Box(xmin, xmax, y, y) 257 | boxes.append(b) 258 | spans[(xmin, xmax)] = b 259 | xmin = None 260 | xmax = None 261 | spans_last = spans 262 | return boxes 263 | 264 | def analyze(self, filename, marker_color, threshold): 265 | d = {} 266 | im = Image.open(filename) 267 | 268 | dimensions = {} 269 | d['dimensions'] = dimensions 270 | dimensions['width'] = im.width 271 | dimensions['height'] = im.height 272 | 273 | def update_percent(i): 274 | wx.CallAfter(self.update_progress, i) 275 | 276 | controls = {} 277 | d['controls'] = controls 278 | markings = find_markings(im, marker_color, threshold, update_percent) 279 | for i, box in enumerate(markings): 280 | c = {} 281 | x, y = box.center() 282 | c['x'] = x 283 | c['y'] = y 284 | controls[f'c{i+1}'] = c 285 | return d 286 | 287 | def toml_instrument_config(extract_result, window_contains, control_type): 288 | doc = tomlkit.document() 289 | doc.add(tomlkit.comment(f'The is pointer-cc instrument configuration file. Please edit it to change it, then pick "Reload Config" from the menu for your changes to take effect. See the pointer-cc documentation for details: {app_url}.')) 290 | 291 | window = tomlkit.table() 292 | window.add('contains', window_contains) 293 | doc.add('window', window) 294 | 295 | dimensions = tomlkit.table() 296 | dimensions.add('width', extract_result['dimensions']['width']) 297 | dimensions.add('height', extract_result['dimensions']['height']) 298 | doc.add('dimensions', dimensions) 299 | 300 | default_control = tomlkit.table() 301 | default_control.add('type', control_type) 302 | 303 | default_drag = tomlkit.table() 304 | default_drag.add('speed', 1.0) 305 | default_control.add('drag', default_drag) 306 | 307 | default_wheel = tomlkit.table() 308 | default_wheel.add('speed', 1.0) 309 | default_wheel.add('time_resolution', 100) 310 | default_control.add('wheel', default_wheel) 311 | 312 | doc.add('default', default_control) 313 | 314 | controls = tomlkit.table() 315 | for cid, c in extract_result['controls'].items(): 316 | control = tomlkit.table() 317 | control.add('x', c['x']) 318 | control.add('y', c['y']) 319 | control.add('m', 1.0) 320 | controls.add(cid, control) 321 | doc.add('controls', controls) 322 | return doc 323 | 324 | midi_type_bij = Bijection('midi', 'display', [(0x80, "NOTEOFF"), (0x90, "NOTEON"), (0xA0, "KPRESS"), (0xB0, "CC"), (0xC0, "PROG"), (0xC0, "PROG"), (0xD0, "CHPRESS"), (0xE0, "PBEND"), (0xF0, "SYSEX")]) 325 | 326 | def fmt_hex(i): 327 | s = hex(i)[2:].upper() 328 | prefix = "".join(((2 - len(s)) * ["0"])) 329 | return prefix + s 330 | 331 | def fmt_midi(msg): 332 | if len(msg) == 0: 333 | return None 334 | else: 335 | parts = [] 336 | t = midi_type_bij.display(msg[0] & 0xf0) 337 | if t is not None: 338 | chan = (msg[0] & 0x0f) + 1 339 | parts.append(f'Ch.{chan}') 340 | parts.append(t) 341 | else: 342 | parts.append(fmt_hex(msg[0])) 343 | parts = parts + [str(i) for i in msg[1:]] 344 | return ' '.join(parts) 345 | 346 | class Dispatcher(threading.Thread): 347 | def __init__(self, midiin, queue, frame): 348 | super(Dispatcher, self).__init__() 349 | self.midiin = midiin 350 | self.queue = queue 351 | self.frame = frame 352 | self.port_name = None 353 | self.midi_channel= 0 354 | self.controllers = {} 355 | self.polling = None 356 | self.config = default_config() 357 | self.instruments = {} 358 | self.reload_all_configs() 359 | 360 | def reload_all_configs(self): 361 | try: 362 | config = load_config() 363 | except ConfigError as e: 364 | msg = f'Configuration error: {e}' 365 | wx.CallAfter(self.frame.show_error, msg) 366 | else: 367 | instruments, inst_exceptions = load_instruments() 368 | 369 | self.config = config 370 | self.set_instruments(instruments) 371 | 372 | if config.preferred_midi_port is not None: 373 | self.queue.put((InternalCommand.CHANGE_MIDI_PORT, config.preferred_midi_port, False)) 374 | 375 | if config.preferred_midi_channel is not None: 376 | self.queue.put((InternalCommand.CHANGE_MIDI_CHANNEL, config.preferred_midi_channel)) 377 | 378 | self.stop_window_polling() 379 | self.start_window_polling() 380 | 381 | if len(inst_exceptions) > 0: 382 | msgs = [] 383 | for e in inst_exceptions: 384 | msgs.append(str(e)) 385 | msg = 'Configuration error: ' + ' '.join(msgs) 386 | wx.CallAfter(self.frame.show_error, msg) 387 | 388 | def get_active_controller(self): 389 | if len(self.controllers) == 0: 390 | return None 391 | 392 | elif len(self.controllers) == 1: 393 | return list(self.controllers.values())[0] 394 | 395 | else: 396 | 397 | x, y = mouse.get_position() 398 | 399 | def sort_key(controller): 400 | box = controller.window.box 401 | key_contains = 0 if box.contains_point(x, y) else 1 402 | key_distance = squared_dist(box.center(), (x, y)) 403 | tpl = (key_contains, key_distance) 404 | return tpl 405 | 406 | controllers = list(self.controllers.values()) 407 | controllers.sort(key=sort_key) 408 | if len(controllers) > 0: 409 | return controllers[0] 410 | 411 | def __call__(self, event, data=None): 412 | try: 413 | message, deltatime = event 414 | 415 | ch = message[0] & 0x0f 416 | ignore = False 417 | if self.midi_channel != 0: 418 | if ch != self.midi_channel - 1: 419 | return 420 | 421 | controller = self.get_active_controller() 422 | 423 | midi_msg_text_parts = [] 424 | m = fmt_midi(message) 425 | if m is not None: 426 | midi_msg_text_parts.append(m) 427 | 428 | # note off(?) 429 | if message[0] & 0xf0 == 0x80: 430 | pass 431 | 432 | ctrl_info_parts = [] 433 | if controller: 434 | if controller.current_control is not None: 435 | ctrl_info_parts.append(str(controller.current_control)) 436 | 437 | if controller.freewheeling: 438 | ctrl_info_parts.append('(freewheeling)') 439 | 440 | if message[0] & 0xf0 == 0xb0: 441 | for binding in self.config.bindings: 442 | if binding.cc == message[1]: 443 | 444 | c = cmd_str.str(binding.command) 445 | midi_msg_text_parts.append(f'({c})') 446 | 447 | if binding.command == Command.PAN_X: 448 | x_normed = message[2] / 127.0 449 | controller.pan_x(x_normed) 450 | 451 | elif binding.command == Command.PAN_X_INV: 452 | x_normed = message[2] / 127.0 453 | controller.pan_x(1.0 - x_normed) 454 | 455 | elif binding.command == Command.PAN_Y: 456 | y_normed = message[2] / 127.0 457 | controller.pan_y(y_normed) 458 | 459 | elif binding.command == Command.PAN_Y_INV: 460 | y_normed = message[2] / 127.0 461 | controller.pan_y(1.0 - y_normed) 462 | 463 | elif binding.command == Command.ADJUST_CONTROL: 464 | cc = message[2] 465 | status = controller.turn(cc) 466 | if status is not None: 467 | ctrl_info_parts.append(status) 468 | 469 | elif binding.command == Command.FREEWHEEL: 470 | controller.freewheel() 471 | 472 | ctrl_info = ' '.join(ctrl_info_parts) 473 | midi_msg_text = ' '.join(midi_msg_text_parts) 474 | wx.CallAfter(self.frame.update_view, midi_msg_text, ctrl_info) 475 | 476 | except Exception as e: 477 | traceback.print_exception(e) 478 | 479 | def set_instruments(self, instruments): 480 | if len(instruments) == 0: 481 | msg = "No instruments configured, please read the docs" 482 | else: 483 | msg = f'{len(instruments)} instruments configured' 484 | 485 | self.instruments = instruments 486 | wx.CallAfter(self.frame.set_window_text, msg) 487 | 488 | def start_window_polling(self): 489 | self.polling = WindowPolling(self.queue, list(self.instruments.keys())) 490 | self.polling.start() 491 | 492 | def stop_window_polling(self): 493 | if self.polling: 494 | self.polling.event.set() 495 | self.polling.join() 496 | 497 | def run(self): 498 | self.midiin.set_callback(self) 499 | 500 | running = True 501 | while running: 502 | item = self.queue.get() 503 | cmd = item[0] 504 | if cmd == InternalCommand.QUIT: 505 | running = False 506 | self.stop_window_polling() 507 | 508 | elif cmd == InternalCommand.CHANGE_MIDI_CHANNEL: 509 | self.midi_channel = item[1] 510 | if self.midiin.is_port_open() and self.port_name is not None: 511 | self.config.set_preferred_midi(self.port_name, self.midi_channel) 512 | 513 | elif cmd == InternalCommand.CHANGE_MIDI_PORT: 514 | port_name = item[1] 515 | triggered_by_user = item[2] 516 | (success, exc) = open_midi_port(self.midiin, port_name) 517 | if success: 518 | self.port_name = port_name 519 | self.config.set_preferred_midi(port_name, self.midi_channel) 520 | self.midiin.set_callback(self) 521 | wx.CallAfter(self.frame.set_midi_selection, self.port_name, self.midi_channel) 522 | else: 523 | if triggered_by_user: 524 | msg = f"Could not open MIDI port \"{port_name}\"" 525 | wx.CallAfter(self.frame.show_error, msg) 526 | # TODO: 527 | # wx.CallAfter(self.frame.set_midi_selection, None, None) 528 | 529 | elif cmd == InternalCommand.UPDATE_WINDOW: 530 | name = item[1] 531 | window = item[2] 532 | if window is None: 533 | if name in self.controllers: 534 | del self.controllers[name] 535 | else: 536 | self.controllers[name] = InstrumentController(self.instruments[window.pattern], window) 537 | 538 | c = self.get_active_controller() 539 | if c is None: 540 | self.frame.set_window_text('No window found') 541 | else: 542 | self.frame.set_window_text(c.window.name) 543 | 544 | elif cmd == InternalCommand.RELOAD_ALL_CONFIGS: 545 | self.reload_all_configs() 546 | 547 | def unit_affine(): 548 | return Affine(1.0, 1.0, 0.0, 0.0) 549 | 550 | # affine transform that can be scaling and translation 551 | class Affine: 552 | def __init__(self, sx, sy, dx, dy): 553 | self.sx = sx 554 | self.sy = sy 555 | self.dx = dx 556 | self.dy = dy 557 | 558 | def inverse(self): 559 | small = 1e-2 560 | if abs(self.sx) < small or abs(self.sy) < small: 561 | return unit_affine() 562 | sx_inv = 1.0 / self.sx 563 | sy_inv = 1.0 / self.sy 564 | return Affine(sx_inv, sy_inv, - sx_inv * self.dx, - sy_inv * self.dy) 565 | 566 | def multiply_right(self, other): 567 | sx = self.sx * other.sx 568 | sy = self.sy * other.sy 569 | dx = sx * other.dx + self.dx 570 | dy = sy * other.dy + self.dy 571 | self.sx = sx 572 | self.sy = sy 573 | self.dx = dx 574 | self.dy = dy 575 | 576 | def apply(self, x, y): 577 | x_ = self.sx * x + self.dx 578 | y_ = self.sy * y + self.dy 579 | return (x_, y_) 580 | 581 | def screen_to_window(window_box): 582 | b = window_box 583 | return Affine(1.0, 1.0, -b.xmin, -b.ymin) 584 | 585 | def window_to_screen(window_box): 586 | return screen_to_window(window_box).inverse() 587 | 588 | def model_to_window(window_box, model_box): 589 | if model_box.width == 0 or model_box.height == 0: 590 | return unit_affine() 591 | sx = window_box.width / model_box.width 592 | sy = window_box.height / model_box.height 593 | s = min(sx, sy) 594 | excess_x = window_box.width - s * model_box.width 595 | excess_y = window_box.height - s * model_box.height 596 | return Affine(s, s, excess_x / 2.0, excess_y) 597 | 598 | def window_to_model(window_box, model_box): 599 | return model_to_window(window_box, model_box).inverse() 600 | 601 | class InstrumentController: 602 | def __init__(self, instrument, window): 603 | self.instrument = instrument 604 | self.set_window(window) 605 | 606 | self.mx = 0.0 607 | self.my = 0.0 608 | self.current_control = None 609 | 610 | self.freewheeling = False 611 | self.freewheeling_direction = None 612 | 613 | self.last_cc = None 614 | self.last_control = None 615 | self.last_control_accum = 0.0 616 | self.last_t = None 617 | self.dragging = False 618 | 619 | self.click_sm = ClickStateMachine(1.0, 2) 620 | 621 | def set_window(self, window): 622 | window_box = window.box 623 | t = window_to_model(window_box, self.instrument.box) 624 | s2w = screen_to_window(window_box) 625 | t.multiply_right(s2w) 626 | self.screen_to_instrument = t 627 | self.instrument_to_screen = self.screen_to_instrument.inverse() 628 | self.window = window 629 | 630 | def pan_x(self, x_normed): 631 | self.mx = self.instrument.box.width * x_normed 632 | self.current_control = self.move_pointer_to_closest() 633 | 634 | def pan_y(self, y_normed): 635 | self.my = self.instrument.box.height * y_normed 636 | self.current_control = self.move_pointer_to_closest() 637 | 638 | def turn(self, cc): 639 | status = None 640 | 641 | screen_x, screen_y = mouse.get_position() 642 | if not self.dragging: 643 | self.mx, self.my = self.screen_to_instrument.apply(screen_x, screen_y) 644 | 645 | self.current_control = self.instrument.find_closest_control(self.mx, self.my) 646 | 647 | if self.last_control is not None: 648 | if self.current_control != self.last_control: 649 | self.click_sm.reset() 650 | self.last_cc = cc 651 | self.last_control_accum = 0.0 652 | self.last_t = None 653 | 654 | if self.last_cc is None: 655 | self.last_cc = cc 656 | 657 | delta = cc - self.last_cc 658 | t = time.time() 659 | 660 | if self.freewheeling: 661 | if self.freewheeling_direction is None: 662 | self.freewheeling_direction = delta > 0 663 | elif self.freewheeling_direction != (delta > 0): 664 | self.freewheeling = False 665 | self.freewheeling_direction = None 666 | 667 | else: 668 | if self.current_control is not None: 669 | m = self.current_control.m 670 | speed = self.current_control.speed * self.current_control.m 671 | 672 | self.last_control_accum += delta * speed 673 | 674 | if self.current_control.type_ == ControlType.WHEEL: 675 | if self.last_t is None: 676 | self.last_t = t 677 | else: 678 | time_delta = t - self.last_t 679 | time_delta_res = 1.0 / self.current_control.time_resolution 680 | if time_delta > time_delta_res: 681 | if time_delta < 5 * time_delta_res: 682 | mouse.wheel(self.last_control_accum) 683 | status = f'wheel! {"+" if self.last_control_accum > 0 else ""}{self.last_control_accum:.2f} (x{speed:.2f})' 684 | self.last_control_accum = 0 685 | self.last_t = t 686 | elif self.current_control.type_ == ControlType.DRAG: 687 | if self.dragging: 688 | pass 689 | else: 690 | mouse.press() 691 | self.dragging = True 692 | k_whole = int(self.last_control_accum) 693 | mouse.move(screen_x, screen_y - k_whole) 694 | status = f'drag! {"+" if k_whole > 0 else ""}{k_whole} (x{speed:.2f})' 695 | self.last_control_accum -= k_whole 696 | elif self.current_control.type_ == ControlType.CLICK: 697 | is_click = self.click_sm.on_cc(cc, time.time()) 698 | if is_click: 699 | mouse.click() 700 | status = f'click! (x{speed:.2f})' 701 | 702 | self.last_control = self.current_control 703 | self.last_cc = cc 704 | return status 705 | 706 | def freewheel(self): 707 | self.freewheeling = True 708 | self.freewheeling_direction = None 709 | 710 | def move_pointer_to_closest(self): 711 | c = self.instrument.find_closest_control(self.mx, self.my) 712 | if self.dragging: 713 | mouse.release() 714 | self.dragging = False 715 | x, y = self.instrument_to_screen.apply(c.x, c.y) 716 | mouse.move(int(x), int(y)) 717 | return c 718 | 719 | class AddInstrumentDialog(wx.Dialog): 720 | def __init__(self, parent, title, queue): 721 | wx.Frame.__init__(self, parent, title=title) 722 | self.queue = queue 723 | 724 | self.extract_result = None 725 | topbottommargin = 25 726 | intermargin = 25 727 | smallmargin = 4 728 | horizontal_margin = 20 729 | 730 | sizer = wx.BoxSizer(wx.VERTICAL) 731 | sizer.AddSpacer(topbottommargin) 732 | 733 | introText = wx.StaticText(self) 734 | t = textwrap.dedent('''\ 735 | Here you can create a instrument configuration .txt file for your VST / Software Instrument. 736 | After you've created it you need to edit the .txt file with a text editor to change the details 737 | of each control, e.g. speed multiplier etc. Please read the pointer documentation on how to do this. 738 | ''') 739 | introText.SetLabelMarkup(t) 740 | sizer.Add(introText, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, border=horizontal_margin) 741 | 742 | controlPosLabel = wx.StaticText(self) 743 | controlPosLabel.SetLabelMarkup('Control positions') 744 | controlPosDescr = wx.StaticText(self) 745 | controlPosDescr.SetLabelMarkup('Take a screenshot of the instrument window. Crop the screen to contents, but\nexclude all window decoration, e.g. the window title bar or borders.\nThen mark the controls by drawing rectangles in the marker color with any image app (e.g. GIMP).') 746 | # cpLabel = wx.StaticText(self, label="Marker color #FF00FF, rgb(255,0,255)") 747 | self.cpLabel = wx.StaticText(self) 748 | self.colorPickerCtrl = wx.ColourPickerCtrl(self) 749 | self.colorPickerCtrl.SetColour(wx.Colour(255, 0, 255)) 750 | self.colorPickerCtrl.Bind(wx.EVT_COLOURPICKER_CHANGED, self.on_color_changed) 751 | 752 | self.set_color_text() 753 | cpSizer = wx.BoxSizer(wx.HORIZONTAL) 754 | cpSizer.Add(self.colorPickerCtrl, 0) 755 | cpSizer.Add(self.cpLabel, wx.SizerFlags().Bottom().Border(wx.LEFT, borderinpixels=smallmargin)) 756 | sizer.Add(controlPosLabel, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, border=horizontal_margin) 757 | sizer.AddSpacer(smallmargin) 758 | sizer.Add(controlPosDescr, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, border=horizontal_margin) 759 | sizer.AddSpacer(smallmargin) 760 | sizer.Add(cpSizer, 0, wx.LEFT | wx.RIGHT, border=horizontal_margin) 761 | sizer.AddSpacer(smallmargin) 762 | 763 | 764 | self.colorThreshold = wx.TextCtrl(self, value="30") 765 | colorThresholdSizer = wx.BoxSizer(wx.HORIZONTAL) 766 | colorThresholdSizer.Add(self.colorThreshold) 767 | 768 | thresholdLabel = wx.StaticText(self, label="Marker color threshold (0-255)") 769 | colorThresholdSizer.Add(thresholdLabel, 0, wx.LEFT, border=smallmargin) 770 | sizer.Add(colorThresholdSizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, border=horizontal_margin) 771 | 772 | self.selectScreenshot = wx.FilePickerCtrl(self, style=wx.FLP_OPEN, message="Select a cropped screenshot of instrument", wildcard="*.png") 773 | self.selectScreenshot.Bind(wx.EVT_FILEPICKER_CHANGED, self.on_screenshot_selected) 774 | filepicker_set_button_label(self.selectScreenshot, 'Analyze Screenshot') 775 | 776 | 777 | self.analyzeText = wx.StaticText(self, label="(no positions extracted yet)") 778 | analyzeSizer = wx.BoxSizer(wx.HORIZONTAL) 779 | analyzeSizer.Add(self.selectScreenshot) 780 | analyzeSizer.Add(self.analyzeText, 0, wx.LEFT, border=smallmargin) 781 | sizer.Add(analyzeSizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, border=horizontal_margin) 782 | 783 | sizer.AddSpacer(intermargin) 784 | 785 | windowPatternLabel = wx.StaticText(self) 786 | windowPatternLabel.SetLabelMarkup('Window Pattern') 787 | windowPatternDescr = wx.StaticText(self, style=wx.LB_MULTIPLE) 788 | windowPatternDescr.SetLabelMarkup('What string is always contained in the instrument\'s window title (usually the name)? This is needed to detect its window.') 789 | self.window_pattern_ctrl = wx.TextCtrl(self) 790 | sizer.Add(windowPatternLabel, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, border=horizontal_margin) 791 | sizer.AddSpacer(smallmargin) 792 | sizer.Add(windowPatternDescr, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, border=horizontal_margin) 793 | sizer.AddSpacer(smallmargin) 794 | sizer.Add(self.window_pattern_ctrl, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, border=horizontal_margin) 795 | 796 | sizer.AddSpacer(intermargin) 797 | 798 | mouseControlLabel = wx.StaticText(self) 799 | mouseControlLabel.SetLabelMarkup('Mouse control') 800 | mouseControlDescr = wx.StaticText(self) 801 | mouseControlDescr.SetLabelMarkup('How does the mouse adjust controls?') 802 | sizer.Add(mouseControlLabel, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, border=horizontal_margin) 803 | sizer.AddSpacer(smallmargin) 804 | sizer.Add(mouseControlDescr, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, border=horizontal_margin) 805 | sizer.AddSpacer(smallmargin) 806 | choices = [ 807 | "mouse drag up and down", 808 | "mouse wheel" 809 | ] 810 | self.mousectrl_combo = wx.ComboBox(self, id=wx.ID_ANY, value="mouse wheel", choices=choices, style=wx.CB_READONLY) 811 | sizer.Add(self.mousectrl_combo, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, border=horizontal_margin) 812 | 813 | sizer.AddSpacer(intermargin) 814 | 815 | filenameDescr = wx.StaticText(self) 816 | filenameDescr.SetLabelMarkup('Choose instrument configuration save file. The filename must start with "inst-" and end with ".txt".') 817 | self.chooseSaveFile = wx.FilePickerCtrl(self, style=wx.FLP_SAVE, message="Save instrument text file") 818 | filepicker_set_button_label(self.chooseSaveFile, 'Save Instrument') 819 | self.chooseSaveFile.SetInitialDirectory(datadir()) 820 | self.chooseSaveFile.SetPath(userfile('inst-renameme.txt')) 821 | self.chooseSaveFile.Bind(wx.EVT_FILEPICKER_CHANGED, self.on_save_file_picked) 822 | 823 | sizer.Add(filenameDescr, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, border=horizontal_margin) 824 | sizer.AddSpacer(smallmargin) 825 | sizer.Add(self.chooseSaveFile, 0, wx.CENTER | wx.LEFT | wx.RIGHT, border=horizontal_margin) 826 | sizer.AddSpacer(topbottommargin) 827 | 828 | self.progress = None 829 | 830 | self.SetSizer(sizer) 831 | self.Fit() 832 | 833 | def on_color_changed(self, _): 834 | self.set_color_text() 835 | 836 | def set_color_text(self): 837 | c = self.colorPickerCtrl.GetColour() 838 | r, g, b, _ = c.Get() 839 | msg = 'Marker color: #' + fmt_hex(r) + fmt_hex(g) + fmt_hex(b) + ", " + f'rgb({r},{g},{b})' 840 | self.cpLabel.SetLabel(msg) 841 | 842 | def on_extract_done(self, delayed_result, **kwargs): 843 | if self.progress: 844 | self.progress.Destroy() 845 | self.progress = None 846 | self.set_extract_result(delayed_result.get(), kwargs['path']) 847 | 848 | def update_progress(self, i): 849 | if self.progress: 850 | self.progress.Update(i) 851 | 852 | def on_screenshot_selected(self, event): 853 | v = self.colorThreshold.GetValue() 854 | msg = None 855 | try: 856 | v_int = int(v) 857 | except Exception as e: 858 | msg = f'Color threshold {v} is not an integer' 859 | if not (0 <= v_int and v_int <= 255): 860 | msg = f'Color threshold {v} is not in range 0-255' 861 | if msg: 862 | wx.CallAfter(self.frame.show_error, msg) 863 | return 864 | 865 | path = self.selectScreenshot.GetPath() 866 | marker_color = self.colorPickerCtrl.GetColour().Get() 867 | self.progress = wx.ProgressDialog("In Progress", message="Analyzing screenshot...") 868 | wx.lib.delayedresult.startWorker(self.on_extract_done, workerFn=analyze, ckwargs=dict(path=path), wargs=[self, path, marker_color, v_int]) 869 | 870 | def set_extract_result(self, extract_result, path): 871 | self.extract_result = extract_result 872 | dim = extract_result['dimensions'] 873 | width = dim['width'] 874 | height = dim['height'] 875 | n = len(extract_result['controls']) 876 | filename = os.path.basename(path) 877 | msg = f'{n} controls found in {filename}, {width}x{height}.' 878 | self.analyzeText.SetLabel(msg) 879 | 880 | def on_save_file_picked(self, event): 881 | path = self.chooseSaveFile.GetPath() 882 | 883 | problems = [] 884 | if os.path.dirname(path) != datadir(): 885 | problems.append('Instrument file is not chosen in the configuration directory') 886 | 887 | if not re.match('^inst-(.*)\.txt$', os.path.basename(path)): 888 | problems.append('Instrument file is not named in format inst-{name}.txt . It must start with "inst-" and end in ".txt".') 889 | 890 | if self.extract_result is None: 891 | problems.append('Screenshot analysis is missing') 892 | 893 | window_contains = self.window_pattern_ctrl.GetValue() 894 | if len(window_contains) == 0: 895 | problems.append('Window title pattern is missing') 896 | 897 | if len(problems) > 0: 898 | message = "Did not save the instrument because:\n" 899 | message += '\n'.join([f"{i+1}: {p}" for i, p in enumerate(problems)]) 900 | dlg = wx.MessageDialog(None, message, 'Instrument not saved', wx.OK | wx.ICON_ERROR) 901 | dlg.ShowModal() 902 | dlg.Destroy() 903 | return 904 | 905 | mouse_control = control_type_bij.str([ControlType.DRAG, ControlType.WHEEL][self.mousectrl_combo.GetCurrentSelection()]) 906 | doc = toml_instrument_config(self.extract_result, window_contains, mouse_control) 907 | with open(path, 'w') as f: 908 | f.write(doc.as_string()) 909 | 910 | self.queue.put((InternalCommand.RELOAD_ALL_CONFIGS, None)) 911 | 912 | message = f'"{os.path.basename(path)}" was successfully saved to the configuration directory.\nTo further adjust it you need to open it with a text editor.\nPlease read the documentation on the details of the instrument configuration file. Please "Reload Config" in the menu after you\'ve changed config files to take effect.' 913 | dlg = wx.MessageDialog(None, message, f'Instrument successfully saved', wx.OK | wx.CANCEL) 914 | dlg.SetOKLabel("Open configuration directory") 915 | response = dlg.ShowModal() 916 | dlg.Destroy() 917 | 918 | if response == wx.ID_OK: 919 | open_directory(datadir()) 920 | 921 | self.EndModal(0) 922 | 923 | class AboutWindow(wx.Frame): 924 | def __init__(self, parent): 925 | super(AboutWindow, self).__init__(parent) 926 | msg = f'pointer-cc, Version {version}\nby Stefan Matting\nPlease send feedback to pointer-cc@posteo.com or the github site.' 927 | 928 | sizer = wx.BoxSizer(wx.VERTICAL) 929 | 930 | margin_outside = 20 931 | 932 | sizer.AddSpacer(40) 933 | 934 | self.version_label = wx.StaticText(self, label=msg, style=wx.ALIGN_CENTRE_HORIZONTAL) 935 | sizer.Add(self.version_label, 0, wx.LEFT | wx.RIGHT, margin_outside) 936 | 937 | sizer.AddSpacer(10) 938 | 939 | self.go_website = wx.Button(self, label='Go to website') 940 | self.go_website.Bind(wx.EVT_BUTTON, self.on_go_website) 941 | sizer.Add(self.go_website, 0, wx.LEFT | wx.RIGHT, margin_outside) 942 | 943 | sizer.AddSpacer(10) 944 | 945 | self.copy_email = wx.Button(self, label='Copy email address to clipboard') 946 | self.copy_email.Bind(wx.EVT_BUTTON, self.on_copy_email) 947 | sizer.Add(self.copy_email, 0, wx.LEFT | wx.RIGHT, margin_outside) 948 | 949 | sizer.AddSpacer(10) 950 | 951 | self.close_button = wx.Button(self, label='Close window') 952 | self.close_button.Bind(wx.EVT_BUTTON, self.on_close) 953 | sizer.Add(self.close_button, 0, wx.LEFT | wx.RIGHT, margin_outside) 954 | 955 | sizer.AddSpacer(40) 956 | 957 | self.SetSizer(sizer) 958 | sizer.SetMinSize((300, 0)) 959 | sizer.Fit(self) 960 | 961 | def on_go_website(self, event): 962 | webbrowser.open(app_url) 963 | 964 | def on_copy_email(self, event): 965 | pyperclip.copy(app_email) 966 | 967 | def on_close(self, event): 968 | self.Destroy() 969 | 970 | 971 | class MainWindow(wx.Frame): 972 | def __init__(self, parent, title, q, ports): 973 | wx.Frame.__init__(self, parent, title=title) 974 | 975 | self.panel = wx.Panel(self, wx.ID_ANY) 976 | 977 | self.Bind(wx.EVT_CLOSE, self.on_close) 978 | 979 | self.queue = q 980 | 981 | self.ports = ports 982 | 983 | files = importlib.resources.files('pointercc') 984 | with files.joinpath('resources/logo-small.png').open('rb') as f: 985 | png = wx.Image(f, wx.BITMAP_TYPE_ANY).ConvertToBitmap() 986 | logo = wx.StaticBitmap(self.panel, -1, png, (10, 10), (png.GetWidth(), png.GetHeight())) 987 | 988 | self.version_label = wx.StaticText(self.panel, label=f'', style=wx.ALIGN_CENTRE_HORIZONTAL) 989 | 990 | value = "" 991 | self.port_dropdown = wx.ComboBox(self.panel, id=wx.ID_ANY, value=value, choices=self.ports, style=wx.CB_READONLY) 992 | self.Bind(wx.EVT_COMBOBOX, self.handle_midi_port_choice, self.port_dropdown) 993 | 994 | channel_choices = ["All"] + [f"Ch. {i}" for i in range(1, 17)] 995 | value = "" 996 | # value = channel_choices[config.preferred_midi_channel] 997 | self.channel_dropdown = wx.ComboBox(self.panel, id=wx.ID_ANY, value=value, choices=channel_choices, style=wx.CB_READONLY) 998 | self.Bind(wx.EVT_COMBOBOX, self.handle_midi_channel_choice, self.channel_dropdown) 999 | 1000 | self.window_text_ctrl = wx.TextCtrl(self.panel, style=wx.TE_READONLY) 1001 | 1002 | self.ctrlinfo_text_ctrl = wx.TextCtrl(self.panel, style=wx.TE_READONLY) 1003 | 1004 | # self.midi_msg_text = wx.StaticText(self.panel, label="", style=wx.ALIGN_CENTER) 1005 | 1006 | filemenu= wx.Menu() 1007 | 1008 | 1009 | about = filemenu.Append(wx.ID_ABOUT, "&About"," Information about this program") 1010 | self.Bind(wx.EVT_MENU, self.on_about, about) 1011 | 1012 | create_instrument = filemenu.Append(wx.ID_ANY, "&Add Instrument"," Add instrument configuration") 1013 | self.Bind(wx.EVT_MENU, self.on_create_instrument, create_instrument) 1014 | 1015 | open_config = filemenu.Append(wx.ID_ANY, "&Open Config Dir"," Open configuartion directory") 1016 | self.Bind(wx.EVT_MENU, self.on_open_config, open_config) 1017 | 1018 | reload_config = filemenu.Append(wx.ID_ANY, "&Reload config"," Reload all configuration") 1019 | self.Bind(wx.EVT_MENU, self.reload_config, reload_config) 1020 | 1021 | exitMenutItem = filemenu.Append(wx.ID_EXIT,"E&xit"," Terminate the program") 1022 | self.Bind(wx.EVT_MENU, self.on_exit, exitMenutItem) 1023 | 1024 | helpmenu = wx.Menu() 1025 | get_help = helpmenu.Append(wx.ID_HELP, "Open Documentation", "Documentation") 1026 | self.Bind(wx.EVT_MENU, self.on_help, get_help) 1027 | 1028 | menuBar = wx.MenuBar() 1029 | menuBar.Append(filemenu,"&File") # Adding the "filemenu" to the MenuBar 1030 | menuBar.Append(helpmenu,"&Help") # Adding the "filemenu" to the MenuBar 1031 | self.SetMenuBar(menuBar) # Adding the MenuBar to the Frame content. 1032 | 1033 | midiChoice = wx.BoxSizer(wx.HORIZONTAL) 1034 | midiChoice.Add(self.port_dropdown, 0, wx.ALL, 0) 1035 | midiChoice.Add(self.channel_dropdown, 0, wx.ALL, 0) 1036 | 1037 | self.midi_msg_ctrl = wx.TextCtrl(self.panel, style=wx.TE_READONLY) 1038 | 1039 | horizontal_margin = 20 1040 | topbottommargin = 25 1041 | intermargin = 10 1042 | 1043 | sizer = wx.BoxSizer(wx.VERTICAL) 1044 | sizer.AddSpacer(topbottommargin) 1045 | sizer.Add(logo, 0, wx.EXPAND) 1046 | sizer.AddSpacer(intermargin) 1047 | sizer.Add(self.version_label, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, border=horizontal_margin) 1048 | sizer.AddSpacer(intermargin) 1049 | sizer.Add(self.window_text_ctrl, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, border=horizontal_margin) 1050 | sizer.AddSpacer(intermargin) 1051 | sizer.Add(self.ctrlinfo_text_ctrl, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, border=horizontal_margin) 1052 | sizer.AddSpacer(intermargin) 1053 | sizer.Add(midiChoice, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, border=horizontal_margin) 1054 | sizer.Add(self.midi_msg_ctrl, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, border=horizontal_margin) 1055 | sizer.AddSpacer(topbottommargin) 1056 | 1057 | # TODO:maybe 1058 | self.sizer = sizer 1059 | 1060 | self.panel.SetSizer(sizer) 1061 | sizer.SetMinSize((300, 0)) 1062 | sizer.Fit(self) 1063 | 1064 | self.update_view('(no MIDI received yet)', '') 1065 | 1066 | self.Show() 1067 | 1068 | def on_help(self, event): 1069 | webbrowser.open(app_url) 1070 | 1071 | def on_about(self, event): 1072 | about_window = AboutWindow(self) 1073 | about_window.Show() 1074 | 1075 | def reload_config(self, event): 1076 | self.queue.put((InternalCommand.RELOAD_ALL_CONFIGS, None)) 1077 | 1078 | def on_open_config(self, event): 1079 | open_directory(datadir()) 1080 | 1081 | def on_create_instrument(self, event): 1082 | w = AddInstrumentDialog(None, "Create Instrument", self.queue) 1083 | w.ShowModal() 1084 | 1085 | def on_close(self, event): 1086 | self.queue.put((InternalCommand.QUIT, None)) 1087 | wx.GetApp().ExitMainLoop() 1088 | event.Skip() 1089 | 1090 | def update_view(self, midi, ctrlinfo): 1091 | self.midi_msg_ctrl.SetLabel(midi) 1092 | self.ctrlinfo_text_ctrl.SetLabel(ctrlinfo) 1093 | 1094 | def set_window_text(self, text): 1095 | self.window_text_ctrl.SetLabel(text) 1096 | 1097 | def on_exit(self, event): 1098 | self.Close() 1099 | 1100 | def handle_midi_port_choice(self, event): 1101 | v = self.port_dropdown.GetValue() 1102 | self.queue.put((InternalCommand.CHANGE_MIDI_PORT, v, True)) 1103 | 1104 | def set_version_label(self, msg): 1105 | self.version_label.SetLabel(msg) 1106 | self.sizer.Layout() 1107 | 1108 | def handle_midi_channel_choice(self, event): 1109 | i = event.GetInt() 1110 | self.queue.put((InternalCommand.CHANGE_MIDI_CHANNEL, i)) 1111 | 1112 | def show_error(self, msg): 1113 | dlg = wx.MessageDialog(None, msg, 'Error', wx.OK | wx.ICON_ERROR) 1114 | dlg.ShowModal() 1115 | dlg.Destroy() 1116 | 1117 | def set_midi_selection(self, port_name, port_number): 1118 | self.port_dropdown.SetValue(port_name) 1119 | self.channel_dropdown.SetSelection(port_number) 1120 | 1121 | 1122 | def matches_name(window, name_pattern): 1123 | name = window.get(Quartz.kCGWindowName) 1124 | if name is None: 1125 | return False 1126 | else: 1127 | for pattern in name_patterns: 1128 | if name.find(pattern) > -1: 1129 | return True 1130 | return False 1131 | 1132 | class WindowPolling(threading.Thread): 1133 | def __init__(self, queue, patterns): 1134 | super(WindowPolling, self).__init__() 1135 | self.queue = queue 1136 | 1137 | self.event = threading.Event() 1138 | self.windows = {} 1139 | self.set_patterns(patterns) 1140 | 1141 | def set_patterns(self, patterns): 1142 | self.patterns = patterns 1143 | self.windows = {} 1144 | 1145 | def run(self): 1146 | running = True 1147 | while running: 1148 | windows = core_platform.get_windows(self.patterns) 1149 | for name, window in windows.items(): 1150 | if self.windows.get(name) != windows[name]: 1151 | self.queue.put((InternalCommand.UPDATE_WINDOW, name, windows[name])) 1152 | self.windows[name] = windows[name] 1153 | 1154 | todelete = [] 1155 | for name in self.windows: 1156 | if windows.get(name) is None: 1157 | self.queue.put((InternalCommand.UPDATE_WINDOW, name, None)) 1158 | todelete.append(name) 1159 | 1160 | 1161 | for name in todelete: 1162 | del self.windows[name] 1163 | 1164 | time.sleep(1) 1165 | if self.event.is_set(): 1166 | running = False 1167 | 1168 | def default_config_toml(): 1169 | doc = tomlkit.document() 1170 | 1171 | doc.add(tomlkit.comment(f'The is pointer-cc configuration file. Please edit it to change configuration, then pick "Reload Config" from the menu for your changes to take effect. See the pointer-cc documentation for details: {app_url}.')) 1172 | 1173 | bindings = tomlkit.table() 1174 | 1175 | t1 = tomlkit.table() 1176 | t1.add('command', 'pan-x') 1177 | t1.add('cc', 77) 1178 | bindings.add('1', t1) 1179 | 1180 | t2 = tomlkit.table() 1181 | t2.add('command', 'pan-y') 1182 | t2.add('cc', 78) 1183 | bindings.add('2', t2) 1184 | 1185 | t3 = tomlkit.table() 1186 | t3.add('command', 'adjust-control') 1187 | t3.add('cc', 79) 1188 | bindings.add('3', t3) 1189 | 1190 | t4 = tomlkit.table() 1191 | t4.add('command', 'freewheel') 1192 | t4.add('cc', 80) 1193 | bindings.add('4', t4) 1194 | 1195 | doc.add('bindings', bindings) 1196 | return doc 1197 | 1198 | def default_config(): 1199 | d = default_config_toml() 1200 | path = userfile('config.txt') 1201 | return Config.parse(d, path) 1202 | 1203 | Command = Enum('Command', ['PAN_X', 'PAN_X_INV', 'PAN_Y', 'PAN_Y_INV', 'ADJUST_CONTROL', 'FREEWHEEL']) 1204 | 1205 | cmd_str = Bijection('command', 'str', [(Command.PAN_X, 'pan-x'), 1206 | (Command.PAN_X_INV, 'pan-x-inv'), 1207 | (Command.PAN_Y, 'pan-y'), 1208 | (Command.PAN_Y_INV, 'pan-y-inv'), 1209 | (Command.ADJUST_CONTROL, 'adjust-control'), 1210 | (Command.FREEWHEEL, 'freewheel') 1211 | ]) 1212 | 1213 | class ClickStateMachine: 1214 | def __init__(self, timeout_secs, delta_cc): 1215 | self.setting_timeout_secs = timeout_secs 1216 | self.setting_delta_cc = delta_cc 1217 | 1218 | self.start_cc = None 1219 | self.start_t = None 1220 | self.down_cc = None 1221 | self.isdown = False 1222 | 1223 | def __repr__(self): 1224 | return str((self.start_cc, self.start_t, self.down_cc, self.isdown)) 1225 | 1226 | def on_time(self, t): 1227 | return self.on_cc(None, t) 1228 | 1229 | def reset(self): 1230 | self.start_cc = None 1231 | self.start_t = None 1232 | self.down_cc = None 1233 | self.isdown = False 1234 | 1235 | def on_cc(self, cc_value, t): 1236 | ''' 1237 | cc_value may be empty 1238 | ''' 1239 | event_start = False 1240 | event_timeout = False 1241 | event_moveup = False 1242 | event_movedown = False 1243 | event_click = False 1244 | 1245 | # self.start_cc state machine 1246 | if self.start_cc is None: 1247 | if cc_value is not None: 1248 | event_start = True 1249 | self.start_cc = cc_value 1250 | self.start_t = t 1251 | else: 1252 | if t - self.start_t > self.setting_timeout_secs: 1253 | self.start_cc = None 1254 | event_timeout = True 1255 | if self.start_cc is not None: 1256 | if cc_value is not None: 1257 | if cc_value <= self.start_cc - self.setting_delta_cc: 1258 | event_movedown = True 1259 | 1260 | # self.down_cc state machine 1261 | if self.down_cc is None: 1262 | if event_movedown and cc_value is not None: 1263 | self.down_cc = cc_value 1264 | else: 1265 | if cc_value < self.down_cc: 1266 | self.down_cc = cc_value 1267 | 1268 | if event_timeout: 1269 | self.down_cc = None 1270 | if cc_value is not None and self.down_cc is not None: 1271 | if cc_value >= self.down_cc + self.setting_delta_cc: 1272 | event_moveup = True 1273 | 1274 | # self.isup state machine 1275 | if not self.isdown: 1276 | if event_movedown: 1277 | self.isdown = True 1278 | else: 1279 | if event_moveup: 1280 | event_click = True 1281 | if event_timeout: 1282 | self.isown = False 1283 | 1284 | if event_click: 1285 | self.reset() 1286 | 1287 | return event_click 1288 | 1289 | class Binding: 1290 | def __init__(self, command, cc): 1291 | self.command = command 1292 | self.cc = cc 1293 | 1294 | @staticmethod 1295 | def parse(d, context): 1296 | try: 1297 | command_name = expect_value(d, 'command') 1298 | cmd = cmd_str.command(command_name) 1299 | cc_s = expect_value(d, 'cc') 1300 | cc = expect_int(cc_s, 'cc') 1301 | return Binding(cmd, cc) 1302 | except ConfigError as ce: 1303 | ce.msg = ce.msg + f", {context}" 1304 | raise ce 1305 | 1306 | def expect_value(d, k, context=''): 1307 | v = d.get(k) 1308 | if v is not None: 1309 | return v 1310 | else: 1311 | msg = f'Missing key: \"{k}\"' 1312 | if context != '': 1313 | msg += f' in "{context}"' 1314 | raise ConfigError(msg) 1315 | 1316 | def expect_float(v, context=''): 1317 | try: 1318 | return float(v) 1319 | except: 1320 | raise ConfigError(f'Not a float: \"{str(v)}\" in \"{context}\"') 1321 | 1322 | def expect_int(s, context): 1323 | try: 1324 | return int(s) 1325 | except: 1326 | raise ConfigError(f"Not a decimal: \"{s}\" in \"{context}\"") 1327 | 1328 | class Config: 1329 | def __init__(self, config_path, bindings, preferred_midi_port, preferred_midi_channel): 1330 | self.config_path = config_path 1331 | self.bindings = bindings 1332 | self.preferred_midi_port = preferred_midi_port 1333 | self.preferred_midi_channel = preferred_midi_channel 1334 | 1335 | def set_preferred_midi(self, midi_port, midi_channel): 1336 | with open(self.config_path, 'r') as f: 1337 | d = tomlkit.load(f) 1338 | 1339 | midi = tomlkit.table() 1340 | if midi_port is not None: 1341 | midi.add('port', midi_port) 1342 | 1343 | if midi_channel is not None: 1344 | midi.add('channel', midi_channel) 1345 | 1346 | d['midi'] = midi 1347 | 1348 | with open(self.config_path, 'w') as f: 1349 | f.write(d.as_string()) 1350 | 1351 | @staticmethod 1352 | def parse(d, path): 1353 | bindings_cfg = expect_value(d, 'bindings') 1354 | 1355 | midi = d.get('midi') 1356 | preferred_midi_port = None 1357 | preferred_midi_channel = None 1358 | if midi is not None: 1359 | preferred_midi_port = midi.get('port') 1360 | preferred_midi_channel = midi.get('channel') 1361 | 1362 | bindings = [] 1363 | for bi, b in bindings_cfg.items(): 1364 | bing = Binding.parse(b, f'parsing binding \"{bi}\"') 1365 | bindings.append(bing) 1366 | 1367 | return Config(path, bindings, preferred_midi_port, preferred_midi_channel) 1368 | 1369 | @staticmethod 1370 | def load(path): 1371 | with open(path, 'r') as f: 1372 | d = tomlkit.load(f) 1373 | return Config.parse(d, path) 1374 | 1375 | def load_instruments(): 1376 | root_dir = datadir() 1377 | filenames = glob.glob('inst-*.txt', root_dir=root_dir) 1378 | d = {} 1379 | exceptions = [] 1380 | for filename in filenames: 1381 | p = os.path.join(root_dir, filename) 1382 | try: 1383 | inst = Instrument.load(p, filename) 1384 | except ConfigError as e: 1385 | exceptions.append(e) 1386 | else: 1387 | d[inst.pattern] = inst 1388 | return d, exceptions 1389 | 1390 | def load_config(): 1391 | return Config.load(userfile('config.txt')) 1392 | 1393 | def open_directory(path): 1394 | if sys.platform == "win32": 1395 | os.startfile(path) 1396 | elif sys.platform == "darwin": 1397 | subprocess.Popen(["open", path]) 1398 | else: 1399 | subprocess.Popen(["xdg-open", path]) 1400 | 1401 | def open_midi_port(midiin, port_name): 1402 | try: 1403 | if midiin.is_port_open(): 1404 | midiin.close_port() 1405 | ports = midiin.get_ports() 1406 | i = ports.index(port_name) 1407 | midiin.open_port(i) 1408 | return (True, None) 1409 | except Exception as e: 1410 | return (False, e) 1411 | 1412 | def filepicker_set_button_label(picker, label): 1413 | buttons = list(filter(lambda c: isinstance(c, wx.Button), picker.GetChildren())) 1414 | if len(buttons) == 1: 1415 | button = buttons[0] 1416 | button.SetLabel(label) 1417 | button.SetMinSize(button.GetBestSize()) 1418 | 1419 | 1420 | def https_get(url): 1421 | res = requests.get(url) 1422 | 1423 | # try: 1424 | url_parts = urllib.parse.urlparse(url) 1425 | connection = http.client.HTTPSConnection(url_parts.netloc) 1426 | connection.request("GET", url_parts.path) 1427 | response = connection.getresponse() 1428 | data = response.read().decode("utf-8") 1429 | return data 1430 | # except Exception as e: 1431 | # return None 1432 | 1433 | 1434 | class UpdateCheck(threading.Thread): 1435 | def __init__(self, frame): 1436 | super(UpdateCheck, self).__init__() 1437 | self.frame = frame 1438 | self.event = threading.Event() 1439 | 1440 | def run(self): 1441 | running = True 1442 | t_last = None 1443 | time.sleep(2) 1444 | while running: 1445 | 1446 | do_check = False 1447 | t = datetime.datetime.now() 1448 | if t_last is None: 1449 | do_check = True 1450 | else: 1451 | if t - t_last > datetime.timedelta(hours=24): 1452 | do_check = True 1453 | t_last = t 1454 | 1455 | if do_check: 1456 | newer_version = self.check_latest_bigger_version() 1457 | if newer_version is not None: 1458 | msg = f'Newer version {newer_version} is available!' 1459 | wx.CallAfter(self.frame.set_version_label, msg) 1460 | 1461 | if self.event.is_set(): 1462 | running = False 1463 | 1464 | time.sleep(0.5) 1465 | 1466 | def check_latest_bigger_version(self): 1467 | if version == "0.0.0": 1468 | return 1469 | latest_version = None 1470 | try: 1471 | r = requests.get(url_latest) 1472 | assert r.status_code == 200 1473 | latest_version = r.text.strip() 1474 | except Exception: 1475 | pass 1476 | 1477 | if latest_version is not None: 1478 | try: 1479 | if semver.compare(version, latest_version) < 0: 1480 | return latest_version 1481 | except: 1482 | pass 1483 | 1484 | def main(): 1485 | app = wx.App(True) 1486 | polling = None 1487 | dispatcher = None 1488 | update_check = None 1489 | try: 1490 | initialize_config() 1491 | 1492 | core_platform.init() 1493 | 1494 | q = queue.Queue() 1495 | 1496 | midiin = rtmidi.MidiIn() 1497 | ports = midiin.get_ports() 1498 | 1499 | frame = MainWindow(None, "pointer-cc", q, ports) 1500 | 1501 | dispatcher = Dispatcher(midiin, q, frame) 1502 | dispatcher.start() 1503 | 1504 | update_check = UpdateCheck(frame) 1505 | update_check.start() 1506 | 1507 | 1508 | except Exception as e: 1509 | traceback.print_exc() 1510 | 1511 | app.MainLoop() 1512 | 1513 | if update_check: 1514 | update_check.event.set() 1515 | update_check.join() 1516 | 1517 | if dispatcher: 1518 | dispatcher.join() 1519 | 1520 | def main2(): 1521 | q = queue.Queue() 1522 | polling = WindowPolling(q, [ "TAL-J-8", "Prophet-5 V"]) 1523 | polling.start() 1524 | 1525 | if __name__ == '__main__': 1526 | main() 1527 | --------------------------------------------------------------------------------