├── 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 |
](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 | 
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 | 
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="