├── .gitignore ├── LICENSE ├── OBS_SETUP.md ├── README.md ├── assets ├── duck.ico └── duck.png ├── build.py ├── ducktrack ├── __init__.py ├── app.py ├── keycomb.py ├── metadata.py ├── obs_client.py ├── playback.py ├── recorder.py └── util.py ├── example ├── README.md ├── events.jsonl └── metadata.json ├── experiments ├── delays │ └── delay.py ├── drawing │ └── drawing.py ├── recaptcha │ └── recaptcha.py ├── sleep_testing │ ├── calc_errors.py │ └── plot_errors.py └── stopwatch │ └── stopwatch.py ├── main.py ├── readme_images ├── Screenshot 2023-06-17 220155.png ├── Screenshot 2023-06-17 221407.png ├── Screenshot 2023-06-17 221553.png ├── Screenshot 2023-06-17 222626.png ├── Screenshot 2023-06-24 103752.png ├── Screenshot 2023-06-24 104203.png ├── Screenshot 2023-06-24 110033.png ├── Screenshot 2023-06-24 110113.png ├── Screenshot 2023-06-24 110823.png ├── Screenshot 2023-06-24 111017.png ├── Screenshot 2023-06-24 111110.png ├── Screenshot 2023-06-24 111422.png ├── Screenshot 2023-06-24 111634.png ├── Screenshot 2023-06-24 111654.png ├── Screenshot 2023-06-24 111809.png ├── Screenshot 2023-06-24 111841.png ├── Screenshot 2023-06-24 112001.png ├── Screenshot 2023-06-24 113548.png ├── Screenshot 2023-06-24 115916.png ├── Screenshot 2023-06-24 120133.png ├── Screenshot 2023-06-24 120347.png ├── Screenshot 2023-06-24 121017.png ├── Screenshot 2023-06-24 121222.png ├── Screenshot 2023-06-24 122006.png └── Screenshot 2023-06-24 162423.png ├── requirements.txt └── tests └── __init__.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | # experiments 163 | experiments/**/*.png 164 | experiments/**/*.csv 165 | experiments/**/*.mp4 166 | experiments/**/*.jsonl 167 | experiments/**/*.json 168 | experiments/**/*.md 169 | experiments/**/*.txt 170 | 171 | # macos 172 | *DS_Store* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 DuckAI 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 | -------------------------------------------------------------------------------- /OBS_SETUP.md: -------------------------------------------------------------------------------- 1 | # OBS Setup 2 | 3 | These are instructions on setting up OBS (Open Broadcaster Software) to record screen activity for creating the multimodal computer dataset. 4 | 5 | ## Installation 6 | 7 | 1. Go to the OBS Project website: [https://obsproject.com/](https://obsproject.com/). 8 | 2. Choose the appropriate installer for your operating system. 9 | 3. 10 | ![Operating System Selection](readme_images/Screenshot%202023-06-17%20220155.png) 11 | 12 | 3. Run the installer from your downloads folder and grant OBS the necessary permissions for installation. 13 | 14 | ![Installer Run](readme_images/Screenshot%202023-06-24%20115916.png) 15 | 16 | 4. Keep the default settings and proceed through the installation wizard by clicking "Next" and then "Finish." 17 | 18 | ![Installation Wizard](readme_images/Screenshot%202023-06-24%20120133.png) 19 | 20 | 5. OBS should now be open. If not, search for and open the application. 21 | 22 | ![Open OBS](readme_images/Screenshot%202023-06-17%20221407.png) 23 | 24 | ## Enabling OBS WebSocket Server 25 | 26 | 1. Click on "Tools" in the Navigation Bar within OBS, and then select "WebSocket Server Settings." A pop-up window will appear. 27 | 28 | ![WebSocket Server Settings](readme_images/Screenshot%202023-06-17%20221553.png) 29 | 30 | 2. Check the box next to "Enable WebSocket server" and uncheck the box next to "Enable Authentication." Click "Apply," then "Ok." You should return to the main OBS page. 31 | 32 | ![Enable WebSocket Server](readme_images/Screenshot%202023-06-24%20120347.png) 33 | 34 | ## Adding Display Capture and Recording 35 | 36 | 1. Now, back on the home page of OBS, select "Scene." Under "Sources," click the "+" button and then click "Display Capture." (in MacOS this is MacOS Screen Capture) 37 | 38 | ![Display Capture](readme_images/Screenshot%202023-06-24%20110823.png) 39 | 40 | 2. Select "Ok." 41 | 42 | ![Confirm Display Capture](readme_images/Screenshot%202023-06-24%20111017.png) 43 | 44 | 3. Make sure the "Display" is set to your main display, and you should see your screen on the canvas. Select "Ok." _(in MacOS if your screen is black with a red square in the top left try to disable then re-enable OBS Screen Recording permissions, this has worked before)_ 45 | 46 | ![Main Display](readme_images/Screenshot%202023-06-24%20112001.png) 47 | 48 | 4. Now you can close OBS and OBS will opened and controlled automatically when you launch the Computer Tracker App. Also, the Computer Tracker app creates a new OBS profile so you don't have to worry about your previous settings being messed up. 49 | 50 | ![Recording in Progress](readme_images/Screenshot%202023-06-24%20113548.png) 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DuckTrack 2 | 3 | This is the repository for the DuckAI DuckTrack app which records all keyboard and mouse input as well as the screen for use in a multimodal computer interaction dataset. 4 | 5 | [Blog Post](https://duckai.org/blog/ducktrack) 6 | 7 | ## Installation & Setup 8 | 9 | ### Download Application 10 | 11 | 12 | Download the pre-built application for your system [here](https://github.com/TheDuckAI/DuckTrack/releases/). 13 | 14 | Make sure you have OBS downloaded with the following configuration: 15 | 1. Have a screen capture source recording your whole main screen. 16 | 2. Enable desktop audio and mute microphone. 17 | 3. Make sure the default websocket is enabled. 18 | 19 | More detailed instructions for OBS setup and installation located [here](OBS_SETUP.md). 20 | 21 | If you are on MacOS, make sure to enable to the following Privacy & Security permissions before running the app: 22 | 23 | 1. Accessibility (for playing back actions) 24 | 2. Input Monitoring (for reading keyboard inputs) 25 | 26 | Make sure to accept all other security permission dialogues to ensure that the app works properly. 27 | 28 | ### Build from source 29 | 30 | Have Python >=3.11. 31 | 32 | Clone this repo and `cd` into it: 33 | ```bash 34 | $ git clone https://github.com/TheDuckAI/DuckTrack 35 | $ cd DuckTrack 36 | ``` 37 | 38 | Install the dependencies for this project: 39 | ```bash 40 | $ pip install -r requirements.txt 41 | ``` 42 | 43 | Build the application: 44 | ```bash 45 | $ python3 build.py 46 | ``` 47 | 48 | The built application should be located in the generated `dist` directory. After this, follow the remaining relevant setup instructions. 49 | 50 | ## Running the App 51 | 52 | You can run the app like any other desktop app on your computer. If you decided to not download the app or build it from source, just run `python main.py` and it should work the same. You will be interacting with the app through an app tray icon or a small window. 53 | 54 | ### Recording 55 | 56 | From the app tray or GUI, you can start and stop a recording as well as pause and resume a recording. Pausing and resuming is important for when you want to hide sensitive information like credit card of login credentials. You can optionally name your recording and give it a description upon stopping a recording. You can also view your recordings by pressing the "Show Recordings" option. 57 | 58 | ### Playback 59 | 60 | You can playback a recording, i.e. simulate the series of events from the recording, by pressing "Play Latest Recording", which plays the latest created recording, or by pressing "Play Custom Recording", which lets you choose a recording to play. You can easily replay the most recently played recording by pressing "Replay Recording". 61 | 62 | To stop the app mid-playback, just press `shift`+`esc` on your keyboard. 63 | 64 | ### Misc 65 | 66 | To quit the app, you just press the "Quit" option. 67 | 68 | ## Recording Format 69 | 70 | Recordings are stored in `Documents/DuckTrack_Recordings`. Each recording is a directory containing: 71 | 72 | 1. `events.jsonl` file - sequence of all computer actions that happened. A sample event may look like this: 73 | ```json 74 | {"time_stamp": 1234567.89, "action": "move", "x": 69.0, "y": 420.0} 75 | ``` 76 | 1. `metadata.json` - stores metadata about the computer that made the recording 77 | 2. `README.md` - stores the description for the recording 78 | 3. MP4 file - the screen recording from OBS of the recording. 79 | 80 | Here is a [sample recording](example) for further reference. 81 | 82 | ## Technical Overview 83 | 84 | 85 | 86 | *TDB* 87 | 88 | ## Known Bugs 89 | 90 | - After doing lots of playbacks on macOS, a segfault will occur. 91 | - Mouse movement is not captured when the current application is using raw input, i.e. video games. 92 | - OBS may not open in the background properly on some Linux machines. 93 | 94 | ## Things To Do 95 | 96 | - Add logging 97 | - Testing 98 | - CI (with builds and testing) 99 | - Add way to hide/show window from the app tray (and it saves that as a preference?) 100 | - Make saving preferences a thing generally, like with natural scrolling too 101 | -------------------------------------------------------------------------------- /assets/duck.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/assets/duck.ico -------------------------------------------------------------------------------- /assets/duck.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/assets/duck.png -------------------------------------------------------------------------------- /build.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import sys 3 | from pathlib import Path 4 | from platform import system 5 | from subprocess import CalledProcessError, run 6 | 7 | project_dir = Path(".") 8 | assets_dir = project_dir / "assets" 9 | main_py = project_dir / "main.py" 10 | icon_file = assets_dir / ("duck.ico" if system() == "Windows" else "duck.png") 11 | 12 | for dir_to_remove in ["dist", "build"]: 13 | dir_path = project_dir / dir_to_remove 14 | if dir_path.exists(): 15 | shutil.rmtree(dir_path) 16 | 17 | pyinstaller_cmd = [ 18 | "pyinstaller", "--onefile", "--windowed", 19 | f"--add-data={assets_dir}{';' if system() == 'Windows' else ':'}{assets_dir}", 20 | f"--name=DuckTrack", f"--icon={icon_file}", str(main_py) 21 | ] 22 | 23 | try: 24 | run(pyinstaller_cmd, check=True) 25 | except CalledProcessError as e: 26 | print("An error occurred while running PyInstaller:", e) 27 | sys.exit(1) -------------------------------------------------------------------------------- /ducktrack/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import MainInterface 2 | -------------------------------------------------------------------------------- /ducktrack/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from platform import system 4 | 5 | from PyQt6.QtCore import QTimer, pyqtSlot 6 | from PyQt6.QtGui import QAction, QIcon 7 | from PyQt6.QtWidgets import (QApplication, QCheckBox, QDialog, QFileDialog, 8 | QFormLayout, QLabel, QLineEdit, QMenu, 9 | QMessageBox, QPushButton, QSystemTrayIcon, 10 | QTextEdit, QVBoxLayout, QWidget) 11 | 12 | from .obs_client import close_obs, is_obs_running, open_obs 13 | from .playback import Player, get_latest_recording 14 | from .recorder import Recorder 15 | from .util import get_recordings_dir, open_file 16 | 17 | 18 | class TitleDescriptionDialog(QDialog): 19 | def __init__(self, parent=None): 20 | super().__init__(parent) 21 | 22 | self.setWindowTitle("Recording Details") 23 | 24 | layout = QVBoxLayout(self) 25 | 26 | self.form_layout = QFormLayout() 27 | 28 | self.title_label = QLabel("Title:") 29 | self.title_input = QLineEdit(self) 30 | self.form_layout.addRow(self.title_label, self.title_input) 31 | 32 | self.description_label = QLabel("Description:") 33 | self.description_input = QTextEdit(self) 34 | self.form_layout.addRow(self.description_label, self.description_input) 35 | 36 | layout.addLayout(self.form_layout) 37 | 38 | self.submit_button = QPushButton("Save", self) 39 | self.submit_button.clicked.connect(self.accept) 40 | layout.addWidget(self.submit_button) 41 | 42 | def get_values(self): 43 | return self.title_input.text(), self.description_input.toPlainText() 44 | 45 | class MainInterface(QWidget): 46 | def __init__(self, app: QApplication): 47 | super().__init__() 48 | self.tray = QSystemTrayIcon(QIcon(resource_path("assets/duck.png"))) 49 | self.tray.show() 50 | 51 | self.app = app 52 | 53 | self.init_tray() 54 | self.init_window() 55 | 56 | if not is_obs_running(): 57 | self.obs_process = open_obs() 58 | 59 | def init_window(self): 60 | self.setWindowTitle("DuckTrack") 61 | layout = QVBoxLayout(self) 62 | 63 | self.toggle_record_button = QPushButton("Start Recording", self) 64 | self.toggle_record_button.clicked.connect(self.toggle_record) 65 | layout.addWidget(self.toggle_record_button) 66 | 67 | self.toggle_pause_button = QPushButton("Pause Recording", self) 68 | self.toggle_pause_button.clicked.connect(self.toggle_pause) 69 | self.toggle_pause_button.setEnabled(False) 70 | layout.addWidget(self.toggle_pause_button) 71 | 72 | self.show_recordings_button = QPushButton("Show Recordings", self) 73 | self.show_recordings_button.clicked.connect(lambda: open_file(get_recordings_dir())) 74 | layout.addWidget(self.show_recordings_button) 75 | 76 | self.play_latest_button = QPushButton("Play Latest Recording", self) 77 | self.play_latest_button.clicked.connect(self.play_latest_recording) 78 | layout.addWidget(self.play_latest_button) 79 | 80 | self.play_custom_button = QPushButton("Play Custom Recording", self) 81 | self.play_custom_button.clicked.connect(self.play_custom_recording) 82 | layout.addWidget(self.play_custom_button) 83 | 84 | self.replay_recording_button = QPushButton("Replay Recording", self) 85 | self.replay_recording_button.clicked.connect(self.replay_recording) 86 | self.replay_recording_button.setEnabled(False) 87 | layout.addWidget(self.replay_recording_button) 88 | 89 | self.quit_button = QPushButton("Quit", self) 90 | self.quit_button.clicked.connect(self.quit) 91 | layout.addWidget(self.quit_button) 92 | 93 | self.natural_scrolling_checkbox = QCheckBox("Natural Scrolling", self, checked=system() == "Darwin") 94 | layout.addWidget(self.natural_scrolling_checkbox) 95 | 96 | self.natural_scrolling_checkbox.stateChanged.connect(self.toggle_natural_scrolling) 97 | 98 | self.setLayout(layout) 99 | 100 | def init_tray(self): 101 | self.menu = QMenu() 102 | self.tray.setContextMenu(self.menu) 103 | 104 | self.toggle_record_action = QAction("Start Recording") 105 | self.toggle_record_action.triggered.connect(self.toggle_record) 106 | self.menu.addAction(self.toggle_record_action) 107 | 108 | self.toggle_pause_action = QAction("Pause Recording") 109 | self.toggle_pause_action.triggered.connect(self.toggle_pause) 110 | self.toggle_pause_action.setVisible(False) 111 | self.menu.addAction(self.toggle_pause_action) 112 | 113 | self.show_recordings_action = QAction("Show Recordings") 114 | self.show_recordings_action.triggered.connect(lambda: open_file(get_recordings_dir())) 115 | self.menu.addAction(self.show_recordings_action) 116 | 117 | self.play_latest_action = QAction("Play Latest Recording") 118 | self.play_latest_action.triggered.connect(self.play_latest_recording) 119 | self.menu.addAction(self.play_latest_action) 120 | 121 | self.play_custom_action = QAction("Play Custom Recording") 122 | self.play_custom_action.triggered.connect(self.play_custom_recording) 123 | self.menu.addAction(self.play_custom_action) 124 | 125 | self.replay_recording_action = QAction("Replay Recording") 126 | self.replay_recording_action.triggered.connect(self.replay_recording) 127 | self.menu.addAction(self.replay_recording_action) 128 | self.replay_recording_action.setVisible(False) 129 | 130 | self.quit_action = QAction("Quit") 131 | self.quit_action.triggered.connect(self.quit) 132 | self.menu.addAction(self.quit_action) 133 | 134 | self.menu.addSeparator() 135 | 136 | self.natural_scrolling_option = QAction("Natural Scrolling", checkable=True, checked=system() == "Darwin") 137 | self.natural_scrolling_option.triggered.connect(self.toggle_natural_scrolling) 138 | self.menu.addAction(self.natural_scrolling_option) 139 | 140 | @pyqtSlot() 141 | def replay_recording(self): 142 | player = Player() 143 | if hasattr(self, "last_played_recording_path"): 144 | player.play(self.last_played_recording_path) 145 | else: 146 | self.display_error_message("No recording has been played yet!") 147 | 148 | @pyqtSlot() 149 | def play_latest_recording(self): 150 | player = Player() 151 | recording_path = get_latest_recording() 152 | self.last_played_recording_path = recording_path 153 | self.replay_recording_action.setVisible(True) 154 | self.replay_recording_button.setEnabled(True) 155 | player.play(recording_path) 156 | 157 | @pyqtSlot() 158 | def play_custom_recording(self): 159 | player = Player() 160 | directory = QFileDialog.getExistingDirectory(None, "Select Recording", get_recordings_dir()) 161 | if directory: 162 | self.last_played_recording_path = directory 163 | self.replay_recording_button.setEnabled(True) 164 | self.replay_recording_action.setVisible(True) 165 | player.play(directory) 166 | 167 | @pyqtSlot() 168 | def quit(self): 169 | if hasattr(self, "recorder_thread"): 170 | self.toggle_record() 171 | if hasattr(self, "obs_process"): 172 | close_obs(self.obs_process) 173 | self.app.quit() 174 | 175 | def closeEvent(self, event): 176 | self.quit() 177 | 178 | @pyqtSlot() 179 | def toggle_natural_scrolling(self): 180 | sender = self.sender() 181 | 182 | if sender == self.natural_scrolling_checkbox: 183 | state = self.natural_scrolling_checkbox.isChecked() 184 | self.natural_scrolling_option.setChecked(state) 185 | else: 186 | state = self.natural_scrolling_option.isChecked() 187 | self.natural_scrolling_checkbox.setChecked(state) 188 | 189 | @pyqtSlot() 190 | def toggle_pause(self): 191 | if self.recorder_thread._is_paused: 192 | self.recorder_thread.resume_recording() 193 | self.toggle_pause_action.setText("Pause Recording") 194 | self.toggle_pause_button.setText("Pause Recording") 195 | else: 196 | self.recorder_thread.pause_recording() 197 | self.toggle_pause_action.setText("Resume Recording") 198 | self.toggle_pause_button.setText("Resume Recording") 199 | 200 | @pyqtSlot() 201 | def toggle_record(self): 202 | if not hasattr(self, "recorder_thread"): 203 | self.recorder_thread = Recorder(natural_scrolling=self.natural_scrolling_checkbox.isChecked()) 204 | self.recorder_thread.recording_stopped.connect(self.on_recording_stopped) 205 | self.recorder_thread.start() 206 | self.update_menu(True) 207 | else: 208 | self.recorder_thread.stop_recording() 209 | self.recorder_thread.terminate() 210 | 211 | recording_dir = self.recorder_thread.recording_path 212 | 213 | del self.recorder_thread 214 | 215 | dialog = TitleDescriptionDialog() 216 | QTimer.singleShot(0, dialog.raise_) 217 | result = dialog.exec() 218 | 219 | if result == QDialog.DialogCode.Accepted: 220 | title, description = dialog.get_values() 221 | 222 | if title: 223 | renamed_dir = os.path.join(os.path.dirname(recording_dir), title) 224 | os.rename(recording_dir, renamed_dir) 225 | 226 | with open(os.path.join(renamed_dir, 'README.md'), 'w') as f: 227 | f.write(description) 228 | 229 | self.on_recording_stopped() 230 | 231 | @pyqtSlot() 232 | def on_recording_stopped(self): 233 | self.update_menu(False) 234 | 235 | def update_menu(self, is_recording: bool): 236 | self.toggle_record_button.setText("Stop Recording" if is_recording else "Start Recording") 237 | self.toggle_record_action.setText("Stop Recording" if is_recording else "Start Recording") 238 | 239 | self.toggle_pause_button.setEnabled(is_recording) 240 | self.toggle_pause_action.setVisible(is_recording) 241 | 242 | def display_error_message(self, message): 243 | QMessageBox.critical(None, "Error", message) 244 | 245 | def resource_path(relative_path: str) -> str: 246 | if hasattr(sys, '_MEIPASS'): 247 | base_path = getattr(sys, "_MEIPASS") 248 | else: 249 | base_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..') 250 | 251 | return os.path.join(base_path, relative_path) -------------------------------------------------------------------------------- /ducktrack/keycomb.py: -------------------------------------------------------------------------------- 1 | from pynput.keyboard import Listener 2 | 3 | from .util import name_to_key 4 | 5 | 6 | class KeyCombinationListener: 7 | """ 8 | Simple and bad key combination listener. 9 | """ 10 | 11 | def __init__(self): 12 | self.current_keys = set() 13 | self.callbacks = {} 14 | self.listener = Listener(on_press=self.on_key_press, on_release=self.on_key_release) 15 | 16 | def add_comb(self, keys, callback): 17 | self.callbacks[tuple([name_to_key(key_name) for key_name in sorted(keys)])] = callback 18 | 19 | def on_key_press(self, key): 20 | self.current_keys.add(key) 21 | for comb, callback in self.callbacks.items(): 22 | if all(k in self.current_keys for k in comb): 23 | return callback() 24 | 25 | def on_key_release(self, key): 26 | if key in self.current_keys: 27 | self.current_keys.remove(key) 28 | 29 | def start(self): 30 | self.listener.start() 31 | 32 | def stop(self): 33 | self.listener.stop() -------------------------------------------------------------------------------- /ducktrack/metadata.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import uuid 4 | from datetime import datetime 5 | from platform import uname 6 | 7 | from screeninfo import get_monitors 8 | 9 | 10 | class MetadataManager: 11 | """ 12 | Handles various system metadata collection. 13 | """ 14 | 15 | def __init__(self, recording_path: str, natural_scrolling: bool): 16 | self.recording_path = recording_path 17 | 18 | self.metadata = uname()._asdict() 19 | 20 | self.metadata["id"] = uuid.getnode() 21 | 22 | main_monitor = get_monitors()[0] 23 | self.metadata["screen_width"] = main_monitor.width 24 | self.metadata["screen_height"] = main_monitor.height 25 | 26 | try: 27 | match self.metadata["system"]: 28 | case "Windows": 29 | import wmi 30 | for item in wmi.WMI().Win32_ComputerSystem(): 31 | self.metadata["model"] = item.Model 32 | break 33 | case "Darwin": 34 | import subprocess 35 | model = subprocess.check_output(["sysctl", "-n", "hw.model"]).decode().strip() 36 | self.metadata["model"] = model 37 | case "Linux": 38 | with open("/sys/devices/virtual/dmi/id/product_name", "r") as f: 39 | self.metadata["model"] = f.read().strip() 40 | except: 41 | self.metadata["model"] = "Unknown" 42 | 43 | self.metadata["scroll_direction"] = -1 if natural_scrolling else 1 44 | 45 | def save_metadata(self): 46 | metadata_path = os.path.join(self.recording_path, "metadata.json") 47 | with open(metadata_path, "w") as f: 48 | json.dump(self.metadata, f, indent=4) 49 | 50 | def collect(self): 51 | self.metadata["start_time"] = self._get_time_stamp() 52 | 53 | def end_collect(self): 54 | self.metadata["stop_time"] = self._get_time_stamp() 55 | 56 | def add_obs_record_state_timings(self, record_state_events: dict[str, float]): 57 | self.metadata["obs_record_state_timings"] = record_state_events 58 | 59 | def _get_time_stamp(self): 60 | return datetime.now().strftime("%Y-%m-%d %H:%M:%S") -------------------------------------------------------------------------------- /ducktrack/obs_client.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import time 4 | from platform import system 5 | 6 | import obsws_python as obs 7 | import psutil 8 | 9 | 10 | def is_obs_running() -> bool: 11 | try: 12 | for process in psutil.process_iter(attrs=["pid", "name"]): 13 | if "obs" in process.info["name"].lower(): 14 | return True 15 | return False 16 | except: 17 | raise Exception("Could not check if OBS is running already. Please check manually.") 18 | 19 | def close_obs(obs_process: subprocess.Popen): 20 | if obs_process: 21 | obs_process.terminate() 22 | try: 23 | obs_process.wait(timeout=5) 24 | except subprocess.TimeoutExpired: 25 | obs_process.kill() 26 | 27 | def find_obs() -> str: 28 | common_paths = { 29 | "Windows": [ 30 | "C:\\Program Files\\obs-studio\\bin\\64bit\\obs64.exe", 31 | "C:\\Program Files (x86)\\obs-studio\\bin\\32bit\\obs32.exe" 32 | ], 33 | "Darwin": [ 34 | "/Applications/OBS.app/Contents/MacOS/OBS", 35 | "/opt/homebrew/bin/obs" 36 | ], 37 | "Linux": [ 38 | "/usr/bin/obs", 39 | "/usr/local/bin/obs" 40 | ] 41 | } 42 | 43 | for path in common_paths.get(system(), []): 44 | if os.path.exists(path): 45 | return path 46 | 47 | try: 48 | if system() == "Windows": 49 | obs_path = subprocess.check_output("where obs", shell=True).decode().strip() 50 | else: 51 | obs_path = subprocess.check_output("which obs", shell=True).decode().strip() 52 | 53 | if os.path.exists(obs_path): 54 | return obs_path 55 | except subprocess.CalledProcessError: 56 | pass 57 | 58 | return "obs" 59 | 60 | def open_obs() -> subprocess.Popen: 61 | try: 62 | obs_path = find_obs() 63 | if system() == "Windows": 64 | # you have to change the working directory first for OBS to find the correct locale on windows 65 | os.chdir(os.path.dirname(obs_path)) 66 | obs_path = os.path.basename(obs_path) 67 | return subprocess.Popen([obs_path, "--startreplaybuffer", "--minimize-to-tray"]) 68 | except: 69 | raise Exception("Failed to find OBS, please open OBS manually.") 70 | 71 | class OBSClient: 72 | """ 73 | Controls the OBS client via the OBS websocket. 74 | Sets all the correct settings for recording. 75 | """ 76 | 77 | def __init__( 78 | self, 79 | recording_path: str, 80 | metadata: dict, 81 | fps=30, 82 | output_width=1280, 83 | output_height=720, 84 | ): 85 | self.metadata = metadata 86 | 87 | self.req_client = obs.ReqClient() 88 | self.event_client = obs.EventClient() 89 | 90 | self.record_state_events = {} 91 | 92 | def on_record_state_changed(data): 93 | output_state = data.output_state 94 | print("record state changed:", output_state) 95 | if output_state not in self.record_state_events: 96 | self.record_state_events[output_state] = [] 97 | self.record_state_events[output_state].append(time.perf_counter()) 98 | 99 | self.event_client.callback.register(on_record_state_changed) 100 | 101 | self.old_profile = self.req_client.get_profile_list().current_profile_name 102 | 103 | if "computer_tracker" not in self.req_client.get_profile_list().profiles: 104 | self.req_client.create_profile("computer_tracker") 105 | else: 106 | self.req_client.set_current_profile("computer_tracker") 107 | self.req_client.create_profile("temp") 108 | self.req_client.remove_profile("temp") 109 | self.req_client.set_current_profile("computer_tracker") 110 | 111 | base_width = metadata["screen_width"] 112 | base_height = metadata["screen_height"] 113 | 114 | if metadata["system"] == "Darwin": 115 | # for retina displays 116 | # TODO: check if external displays are messed up by this 117 | base_width *= 2 118 | base_height *= 2 119 | 120 | scaled_width, scaled_height = _scale_resolution(base_width, base_height, output_width, output_height) 121 | 122 | self.req_client.set_profile_parameter("Video", "BaseCX", str(base_width)) 123 | self.req_client.set_profile_parameter("Video", "BaseCY", str(base_height)) 124 | self.req_client.set_profile_parameter("Video", "OutputCX", str(scaled_width)) 125 | self.req_client.set_profile_parameter("Video", "OutputCY", str(scaled_height)) 126 | self.req_client.set_profile_parameter("Video", "ScaleType", "lanczos") 127 | 128 | self.req_client.set_profile_parameter("AdvOut", "RescaleRes", f"{base_width}x{base_height}") 129 | self.req_client.set_profile_parameter("AdvOut", "RecRescaleRes", f"{base_width}x{base_height}") 130 | self.req_client.set_profile_parameter("AdvOut", "FFRescaleRes", f"{base_width}x{base_height}") 131 | 132 | self.req_client.set_profile_parameter("Video", "FPSCommon", str(fps)) 133 | self.req_client.set_profile_parameter("Video", "FPSInt", str(fps)) 134 | self.req_client.set_profile_parameter("Video", "FPSNum", str(fps)) 135 | self.req_client.set_profile_parameter("Video", "FPSDen", "1") 136 | 137 | self.req_client.set_profile_parameter("SimpleOutput", "RecFormat2", "mp4") 138 | 139 | bitrate = int(_get_bitrate_mbps(scaled_width, scaled_height, fps=fps) * 1000 / 50) * 50 140 | self.req_client.set_profile_parameter("SimpleOutput", "VBitrate", str(bitrate)) 141 | 142 | # do this in order to get pause & resume 143 | self.req_client.set_profile_parameter("SimpleOutput", "RecQuality", "Small") 144 | 145 | self.req_client.set_profile_parameter("SimpleOutput", "FilePath", recording_path) 146 | 147 | # TODO: not all OBS configs have this, maybe just instruct the user to mute themselves 148 | 149 | 150 | try: 151 | self.req_client.set_input_mute("Mic/Aux", muted=True) 152 | except obs.error.OBSSDKRequestError : 153 | # In case there is no Mic/Aux input, this will throw an error 154 | pass 155 | 156 | def start_recording(self): 157 | self.req_client.start_record() 158 | 159 | def stop_recording(self): 160 | self.req_client.stop_record() 161 | self.req_client.set_current_profile(self.old_profile) # restore old profile 162 | 163 | def pause_recording(self): 164 | self.req_client.pause_record() 165 | 166 | def resume_recording(self): 167 | self.req_client.resume_record() 168 | 169 | def _get_bitrate_mbps(width: int, height: int, fps=30) -> float: 170 | """ 171 | Gets the YouTube recommended bitrate in Mbps for a given resolution and framerate. 172 | Refer to https://support.google.com/youtube/answer/1722171?hl=en#zippy=%2Cbitrate 173 | """ 174 | resolutions = { 175 | (7680, 4320): {30: 120, 60: 180}, 176 | (3840, 2160): {30: 40, 60: 60.5}, 177 | (2160, 1440): {30: 16, 60: 24}, 178 | (1920, 1080): {30: 8, 60: 12}, 179 | (1280, 720): {30: 5, 60: 7.5}, 180 | (640, 480): {30: 2.5, 60: 4}, 181 | (480, 360): {30: 1, 60: 1.5} 182 | } 183 | 184 | if (width, height) in resolutions: 185 | return resolutions[(width, height)].get(fps) 186 | else: 187 | # approximate the bitrate using a simple linear model 188 | area = width * height 189 | multiplier = 3.5982188179592543e-06 if fps == 30 else 5.396175171097084e-06 190 | constant = 2.418399836285939 if fps == 30 else 3.742780056500365 191 | return multiplier * area + constant 192 | 193 | def _scale_resolution(base_width: int, base_height: int, target_width: int, target_height: int) -> tuple[int, int]: 194 | target_area = target_width * target_height 195 | aspect_ratio = base_width / base_height 196 | 197 | scaled_height = int((target_area / aspect_ratio) ** 0.5) 198 | scaled_width = int(aspect_ratio * scaled_height) 199 | 200 | return scaled_width, scaled_height -------------------------------------------------------------------------------- /ducktrack/playback.py: -------------------------------------------------------------------------------- 1 | import json 2 | import math 3 | import os 4 | import sys 5 | import time 6 | 7 | import pyautogui 8 | from pynput.keyboard import Controller as KeyboardController 9 | from pynput.keyboard import Key 10 | from pynput.mouse import Button 11 | from pynput.mouse import Controller as MouseController 12 | 13 | from .keycomb import KeyCombinationListener 14 | from .util import (fix_windows_dpi_scaling, get_recordings_dir, name_to_button, 15 | name_to_key) 16 | 17 | pyautogui.PAUSE = 0 18 | pyautogui.DARWIN_CATCH_UP_TIME = 0 19 | 20 | class Player: 21 | """ 22 | Plays back recordings. 23 | """ 24 | 25 | def __init__(self): 26 | self.stop_playback = False 27 | self.listener = KeyCombinationListener() 28 | 29 | def stop_comb_pressed(): 30 | self.stop_playback = True 31 | return False 32 | 33 | self.listener.add_comb(("shift", "esc"), stop_comb_pressed) 34 | self.listener.start() 35 | 36 | def play(self, recording_path: str): 37 | with open(os.path.join(recording_path, "events.jsonl"), "r") as f: 38 | events = [json.loads(line) for line in f.readlines()] 39 | 40 | with open(os.path.join(recording_path, "metadata.json"), "r") as f: 41 | metadata = json.load(f) 42 | 43 | self.playback(events, metadata) 44 | 45 | def playback(self, events: list[dict], metadata: dict): 46 | if metadata["system"] == "Windows": 47 | fix_windows_dpi_scaling() 48 | 49 | mouse_controller = MouseController() 50 | keyboard_controller = KeyboardController() 51 | 52 | if not events: 53 | self.listener.stop() 54 | return 55 | 56 | presses_to_skip = 0 57 | releases_to_skip = 0 58 | 59 | in_click_sequence = False 60 | 61 | for i, event in enumerate(events): 62 | start_time = time.perf_counter() 63 | 64 | if self.stop_playback: 65 | return 66 | 67 | def do_mouse_press(button): 68 | for j, second_event in enumerate(events[i+1:]): 69 | # make sure the time between mouse clicks is less than 500ms 70 | if second_event["time_stamp"] - event["time_stamp"] > 0.5: 71 | break 72 | 73 | if "x" in second_event and "y" in second_event: 74 | # if the mouse moves out of the click radius/rectangle, it is not a click sequence 75 | if math.sqrt((second_event["y"] - event["y"]) ** 2 + 76 | (second_event["x"] - event["x"]) ** 2) > 4: 77 | break 78 | 79 | if second_event["action"] == "click" and second_event["pressed"]: 80 | for k, third_event in enumerate(events[i+j+2:]): 81 | if third_event["time_stamp"] - second_event["time_stamp"] > 0.5: 82 | break 83 | 84 | if "x" in third_event and "y" in third_event: 85 | if math.sqrt((third_event["y"] - event["y"]) ** 2 + 86 | (third_event["x"] - event["x"]) ** 2) > 5: 87 | break 88 | 89 | if third_event["action"] == "click" and third_event["pressed"]: 90 | mouse_controller.click(button, 3) 91 | return 2, 2 92 | 93 | mouse_controller.click(button, 2) 94 | return 1, 1 95 | 96 | mouse_controller.press(button) 97 | return 0, 0 98 | 99 | if event["action"] == "move": 100 | mouse_controller.position = (event["x"], event["y"]) 101 | 102 | elif event["action"] == "click": 103 | button = name_to_button(event["button"]) 104 | 105 | if event["pressed"]: 106 | if presses_to_skip == 0: 107 | presses, releases = do_mouse_press(button) 108 | presses_to_skip += presses 109 | releases_to_skip += releases 110 | 111 | if presses > 0: 112 | in_click_sequence = True 113 | else: 114 | presses_to_skip -= 1 115 | else: 116 | if releases_to_skip == 0: 117 | mouse_controller.release(button) 118 | 119 | if in_click_sequence: 120 | keyboard_controller.press(Key.shift) 121 | mouse_controller.click(Button.left) 122 | keyboard_controller.release(Key.shift) 123 | in_click_sequence = False 124 | else: 125 | releases_to_skip -= 1 126 | 127 | elif event["action"] == "scroll": 128 | if metadata["system"] == "Windows": 129 | # for some reason on windows, pynput scroll is correct but pyautogui is not 130 | mouse_controller.scroll(metadata["scroll_direction"] * event["dx"], metadata["scroll_direction"] * event["dy"]) 131 | else: 132 | pyautogui.hscroll(clicks=metadata["scroll_direction"] * event["dx"]) 133 | pyautogui.vscroll(clicks=metadata["scroll_direction"] * event["dy"]) 134 | 135 | elif event["action"] in ["press", "release"]: 136 | key = name_to_key(event["name"]) 137 | if event["action"] == "press": 138 | keyboard_controller.press(key) 139 | else: 140 | keyboard_controller.release(key) 141 | 142 | # sleep for the correct amount of time 143 | 144 | end_time = time.perf_counter() 145 | execution_time = end_time - start_time 146 | 147 | if i + 1 < len(events): 148 | desired_delay = events[i + 1]["time_stamp"] - event["time_stamp"] 149 | delay = desired_delay - execution_time 150 | if delay < 0: 151 | print(f"warning: behind by {-delay * 1000:.3f} ms") 152 | elif delay != 0: 153 | wait_until = time.perf_counter() + delay 154 | while time.perf_counter() < wait_until: 155 | pass 156 | 157 | self.listener.stop() 158 | 159 | def get_latest_recording() -> str: 160 | recordings_dir = get_recordings_dir() 161 | if not os.path.exists(recordings_dir): 162 | raise Exception("The recordings directory does not exist") 163 | 164 | recordings = [os.path.join(recordings_dir, f) for f in os.listdir(recordings_dir) if os.path.isdir(os.path.join(recordings_dir, f))] 165 | 166 | if len(recordings) == 0: 167 | raise Exception("You have no recordings to play back") 168 | 169 | latest_recording = max(recordings, key=os.path.getctime) 170 | 171 | return latest_recording 172 | 173 | def main(): 174 | player = Player() 175 | 176 | if len(sys.argv) > 1: 177 | recording_path = sys.argv[1] 178 | else: 179 | recording_path = get_latest_recording() 180 | 181 | player.play(recording_path) 182 | 183 | if __name__ == "__main__": 184 | n = 3 185 | print("press shift+esc to stop the playback") 186 | print(f"starting in {n} seconds...") 187 | time.sleep(n) 188 | main() 189 | -------------------------------------------------------------------------------- /ducktrack/recorder.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import time 4 | from datetime import datetime 5 | from platform import system 6 | from queue import Queue 7 | 8 | from pynput import keyboard, mouse 9 | from pynput.keyboard import KeyCode 10 | from PyQt6.QtCore import QThread, pyqtSignal 11 | 12 | from .metadata import MetadataManager 13 | from .obs_client import OBSClient 14 | from .util import fix_windows_dpi_scaling, get_recordings_dir 15 | 16 | 17 | class Recorder(QThread): 18 | """ 19 | Makes recordings. 20 | """ 21 | 22 | recording_stopped = pyqtSignal() 23 | 24 | def __init__(self, natural_scrolling: bool): 25 | super().__init__() 26 | 27 | if system() == "Windows": 28 | fix_windows_dpi_scaling() 29 | 30 | self.recording_path = self._get_recording_path() 31 | 32 | self._is_recording = False 33 | self._is_paused = False 34 | 35 | self.event_queue = Queue() 36 | self.events_file = open(os.path.join(self.recording_path, "events.jsonl"), "a") 37 | 38 | self.metadata_manager = MetadataManager( 39 | recording_path=self.recording_path, 40 | natural_scrolling=natural_scrolling 41 | ) 42 | self.obs_client = OBSClient(recording_path=self.recording_path, 43 | metadata=self.metadata_manager.metadata) 44 | 45 | self.mouse_listener = mouse.Listener( 46 | on_move=self.on_move, 47 | on_click=self.on_click, 48 | on_scroll=self.on_scroll) 49 | 50 | self.keyboard_listener = keyboard.Listener( 51 | on_press=self.on_press, 52 | on_release=self.on_release) 53 | 54 | def on_move(self, x, y): 55 | if not self._is_paused: 56 | self.event_queue.put({"time_stamp": time.perf_counter(), 57 | "action": "move", 58 | "x": x, 59 | "y": y}, block=False) 60 | 61 | def on_click(self, x, y, button, pressed): 62 | if not self._is_paused: 63 | self.event_queue.put({"time_stamp": time.perf_counter(), 64 | "action": "click", 65 | "x": x, 66 | "y": y, 67 | "button": button.name, 68 | "pressed": pressed}, block=False) 69 | 70 | def on_scroll(self, x, y, dx, dy): 71 | if not self._is_paused: 72 | self.event_queue.put({"time_stamp": time.perf_counter(), 73 | "action": "scroll", 74 | "x": x, 75 | "y": y, 76 | "dx": dx, 77 | "dy": dy}, block=False) 78 | 79 | def on_press(self, key): 80 | if not self._is_paused: 81 | self.event_queue.put({"time_stamp": time.perf_counter(), 82 | "action": "press", 83 | "name": key.char if type(key) == KeyCode else key.name}, block=False) 84 | 85 | def on_release(self, key): 86 | if not self._is_paused: 87 | self.event_queue.put({"time_stamp": time.perf_counter(), 88 | "action": "release", 89 | "name": key.char if type(key) == KeyCode else key.name}, block=False) 90 | 91 | def run(self): 92 | self._is_recording = True 93 | 94 | self.metadata_manager.collect() 95 | self.obs_client.start_recording() 96 | 97 | self.mouse_listener.start() 98 | self.keyboard_listener.start() 99 | 100 | while self._is_recording: 101 | event = self.event_queue.get() 102 | self.events_file.write(json.dumps(event) + "\n") 103 | 104 | def stop_recording(self): 105 | if self._is_recording: 106 | self._is_recording = False 107 | 108 | self.metadata_manager.end_collect() 109 | 110 | self.mouse_listener.stop() 111 | self.keyboard_listener.stop() 112 | 113 | self.obs_client.stop_recording() 114 | self.metadata_manager.add_obs_record_state_timings(self.obs_client.record_state_events) 115 | self.events_file.close() 116 | self.metadata_manager.save_metadata() 117 | 118 | self.recording_stopped.emit() 119 | 120 | def pause_recording(self): 121 | if not self._is_paused and self._is_recording: 122 | self._is_paused = True 123 | self.obs_client.pause_recording() 124 | self.event_queue.put({"time_stamp": time.perf_counter(), 125 | "action": "pause"}, block=False) 126 | 127 | def resume_recording(self): 128 | if self._is_paused and self._is_recording: 129 | self._is_paused = False 130 | self.obs_client.resume_recording() 131 | self.event_queue.put({"time_stamp": time.perf_counter(), 132 | "action": "resume"}, block=False) 133 | 134 | def _get_recording_path(self) -> str: 135 | recordings_dir = get_recordings_dir() 136 | 137 | if not os.path.exists(recordings_dir): 138 | os.mkdir(recordings_dir) 139 | 140 | current_time = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") 141 | 142 | recording_path = os.path.join(recordings_dir, f"recording-{current_time}") 143 | os.mkdir(recording_path) 144 | 145 | return recording_path -------------------------------------------------------------------------------- /ducktrack/util.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | import subprocess 4 | from pathlib import Path 5 | 6 | from pynput.keyboard import Key, KeyCode 7 | from pynput.mouse import Button 8 | 9 | 10 | def name_to_key(name: str) -> Key | KeyCode: 11 | try: 12 | return getattr(Key, name) 13 | except AttributeError: 14 | return KeyCode.from_char(name) 15 | 16 | def name_to_button(name: str) -> Button: 17 | return getattr(Button, name) 18 | 19 | def get_recordings_dir() -> str: 20 | documents_folder = Path.home() / 'Documents' / 'DuckTrack_Recordings' 21 | return str(documents_folder) 22 | 23 | def fix_windows_dpi_scaling(): 24 | """ 25 | Fixes DPI scaling issues with legacy windows applications 26 | Reference: https://pynput.readthedocs.io/en/latest/mouse.html#ensuring-consistent-coordinates-between-listener-and-controller-on-windows 27 | """ 28 | import ctypes 29 | PROCESS_PER_MONITOR_DPI_AWARE = 2 30 | ctypes.windll.shcore.SetProcessDpiAwareness(PROCESS_PER_MONITOR_DPI_AWARE) 31 | 32 | def open_file(path): 33 | if platform.system() == "Windows": 34 | os.startfile(path) 35 | elif platform.system() == "Darwin": 36 | subprocess.Popen(["open", path]) 37 | else: 38 | subprocess.Popen(["xdg-open", path]) 39 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | This is an example recording (with the video removed). -------------------------------------------------------------------------------- /example/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "system": "Darwin", 3 | "node": "lawn-143-215-60-123.lawn.gatech.edu", 4 | "release": "23.0.0", 5 | "version": "Darwin Kernel Version 23.0.0: Fri Sep 15 14:43:05 PDT 2023; root:xnu-10002.1.13~1/RELEASE_ARM64_T6020", 6 | "machine": "arm64", 7 | "processor": "arm", 8 | "id": 13939574105829, 9 | "screen_width": 1512, 10 | "screen_height": 982, 11 | "model": "Mac14,9", 12 | "scroll_direction": -1, 13 | "start_time": "2023-10-20 17:19:53", 14 | "stop_time": "2023-10-20 17:20:26", 15 | "obs_record_state_timings": { 16 | "OBS_WEBSOCKET_OUTPUT_STARTING": [ 17 | 150160.86498575 18 | ], 19 | "OBS_WEBSOCKET_OUTPUT_STARTED": [ 20 | 150160.871470625 21 | ], 22 | "OBS_WEBSOCKET_OUTPUT_STOPPING": [ 23 | 150193.79908625 24 | ] 25 | } 26 | } -------------------------------------------------------------------------------- /experiments/delays/delay.py: -------------------------------------------------------------------------------- 1 | import glob 2 | 3 | import matplotlib.pyplot as plt 4 | import numpy as np 5 | import seaborn as sns 6 | from scipy.stats import sem, t 7 | 8 | 9 | def calculate_confidence_interval(data, confidence=0.95): 10 | n = len(data) 11 | m = np.mean(data) 12 | std_err = sem(data) 13 | h = std_err * t.ppf((1 + confidence) / 2, n - 1) 14 | return m, m-h, m+h 15 | 16 | runs = glob.glob("run*.txt") 17 | TOTAL_EVENTS = 22509 18 | percent_delays = [] 19 | all_delays = [] 20 | 21 | for run in runs: 22 | with open(run, "r") as f: 23 | delays = [float(line.split()[3]) for line in f if float(line.split()[3]) > 0] # consider only positive delays 24 | percent_delays.append((len(delays) / TOTAL_EVENTS) * 100) 25 | all_delays.extend(delays) 26 | 27 | average_percent_delays = np.mean(percent_delays) 28 | confidence_interval_percent_delays = calculate_confidence_interval(percent_delays) 29 | print(f"Average percentage of delayed events across all runs: {average_percent_delays:.2f}%") 30 | print(f"95% Confidence interval: ({confidence_interval_percent_delays[1]:.2f}%, {confidence_interval_percent_delays[2]:.2f}%)") 31 | 32 | if all_delays: 33 | mean_delay = np.mean(all_delays) 34 | confidence_interval_delays = calculate_confidence_interval(all_delays) 35 | print(f"Mean delay time: {mean_delay:.2f}") 36 | print(f"95% Confidence interval for delay time: ({confidence_interval_delays[1]:.2f}, {confidence_interval_delays[2]:.2f})") 37 | else: 38 | print("No delay data available for calculation.") 39 | 40 | sns.histplot(all_delays, bins=30, kde=False) 41 | plt.xlabel('Delay Time (ms)') 42 | plt.ylabel('Frequency') 43 | plt.yscale('log') 44 | plt.title('Histogram of Delay Times (macOS)') 45 | 46 | plt.savefig('delays.png', dpi=300) 47 | 48 | plt.show() 49 | -------------------------------------------------------------------------------- /experiments/drawing/drawing.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import os 3 | 4 | import cv2 5 | import matplotlib.pyplot as plt 6 | import numpy as np 7 | import scipy.stats as stats 8 | from skimage.metrics import structural_similarity as ssim 9 | from tqdm import tqdm 10 | 11 | # use this: https://sketch.io 12 | 13 | def calculate_rmse(imageA, imageB): 14 | err = np.sum((imageA - imageB) ** 2) 15 | err /= float(imageA.shape[0] * imageA.shape[1]) 16 | return np.sqrt(err) 17 | 18 | def compare_images(ground_truth_path, sample_paths): 19 | results = [] 20 | gt_image = cv2.imread(ground_truth_path, cv2.IMREAD_GRAYSCALE) 21 | 22 | if gt_image is None: 23 | raise ValueError("Ground truth image could not be read. Please check the file path.") 24 | 25 | gt_image = gt_image.astype("float") / 255.0 26 | 27 | for path in tqdm(sample_paths): 28 | sample_image = cv2.imread(path, cv2.IMREAD_GRAYSCALE) 29 | 30 | if sample_image is None: 31 | print(f"WARNING: Sample image at path {path} could not be read. Skipping this image.") 32 | continue 33 | 34 | sample_image = sample_image.astype("float") / 255.0 35 | 36 | rmse_value = calculate_rmse(gt_image, sample_image) 37 | ssim_value, _ = ssim(gt_image, sample_image, full=True, data_range=1) # Corrected line 38 | 39 | diff_mask = cv2.absdiff(gt_image, sample_image) 40 | 41 | # plt.imshow(diff_mask * 255, cmap='gray') 42 | # plt.title(f'Difference Mask for {os.path.basename(path)}\nRMSE: {rmse_value:.5f} - SSIM: {ssim_value:.5f}') 43 | # plt.show() 44 | 45 | results.append({ 46 | 'path': path, 47 | 'rmse': rmse_value, 48 | 'ssim': ssim_value, 49 | 'diff_mask': diff_mask 50 | }) 51 | 52 | return results 53 | 54 | 55 | ground_truth = 'ground_truth.png' 56 | sample_images = glob.glob("samples/*.png") 57 | 58 | results = compare_images(ground_truth, sample_images) 59 | 60 | for res in results: 61 | print(f"Image: {res['path']} - RMSE: {res['rmse']} - SSIM: {res['ssim']}") 62 | 63 | def calculate_confidence_interval(data, confidence_level=0.95): 64 | mean = np.mean(data) 65 | sem = stats.sem(data) 66 | df = len(data) - 1 67 | me = sem * stats.t.ppf((1 + confidence_level) / 2, df) 68 | return mean - me, mean + me 69 | 70 | rmse_values = [res['rmse'] for res in results] 71 | ssim_values = [res['ssim'] for res in results] 72 | 73 | rmse_mean = np.mean(rmse_values) 74 | rmse_median = np.median(rmse_values) 75 | rmse_stdev = np.std(rmse_values, ddof=1) 76 | 77 | ssim_mean = np.mean(ssim_values) 78 | ssim_median = np.median(ssim_values) 79 | ssim_stdev = np.std(ssim_values, ddof=1) 80 | 81 | rmse_ci = calculate_confidence_interval(rmse_values) 82 | ssim_ci = calculate_confidence_interval(ssim_values) 83 | 84 | print(f"\nRMSE - Mean: {rmse_mean}, Median: {rmse_median}, Std Dev: {rmse_stdev}, 95% CI: {rmse_ci}") 85 | print(f"SSIM - Mean: {ssim_mean}, Median: {ssim_median}, Std Dev: {ssim_stdev}, 95% CI: {ssim_ci}") 86 | 87 | print(f"RMSE: {rmse_mean} ± {rmse_ci[1] - rmse_mean}") 88 | print(f"SSIM: {ssim_mean} ± {ssim_ci[1] - ssim_mean}") 89 | 90 | def save_average_diff_map(results, save_path='average_diff_map.png'): 91 | if not results: 92 | print("No results available to create an average diff map.") 93 | return 94 | 95 | avg_diff_map = None 96 | 97 | for res in results: 98 | if avg_diff_map is None: 99 | avg_diff_map = np.zeros_like(res['diff_mask']) 100 | 101 | avg_diff_map += res['diff_mask'] 102 | 103 | avg_diff_map /= len(results) 104 | 105 | avg_diff_map = (avg_diff_map * 255).astype(np.uint8) 106 | 107 | cv2.imwrite(save_path, avg_diff_map) 108 | 109 | # Usage 110 | save_average_diff_map(results) 111 | -------------------------------------------------------------------------------- /experiments/recaptcha/recaptcha.py: -------------------------------------------------------------------------------- 1 | success = 10 2 | total = 10 3 | 4 | print(success / total) -------------------------------------------------------------------------------- /experiments/sleep_testing/calc_errors.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import time 3 | 4 | import numpy as np 5 | from tqdm import tqdm 6 | 7 | 8 | def check_sleep(duration, sleep_function): 9 | start = time.perf_counter() 10 | sleep_function(duration) 11 | end = time.perf_counter() 12 | elapsed = end - start 13 | return abs(elapsed - duration) 14 | 15 | def busy_sleep(duration): 16 | end_time = time.perf_counter() + duration 17 | while time.perf_counter() < end_time: 18 | pass 19 | 20 | def measure_accuracy(sleep_function, durations, iterations=100): 21 | average_errors = [] 22 | for duration in tqdm(durations): 23 | errors = [check_sleep(duration, sleep_function) for _ in range(iterations)] 24 | average_error = np.mean(errors) 25 | average_errors.append(average_error) 26 | return average_errors 27 | 28 | durations = np.arange(0.001, 0.101, 0.001) # From 1ms to 100ms in 1ms increments 29 | iterations = 100 30 | 31 | sleep_errors = measure_accuracy(time.sleep, durations, iterations) 32 | busy_sleep_errors = measure_accuracy(busy_sleep, durations, iterations) 33 | 34 | def save_to_csv(filename, durations, sleep_errors, busy_sleep_errors): 35 | with open(filename, 'w', newline='') as csvfile: 36 | fieldnames = ['duration', 'sleep_error', 'busy_sleep_error'] 37 | writer = csv.DictWriter(csvfile, fieldnames=fieldnames) 38 | 39 | writer.writeheader() 40 | for duration, sleep_error, busy_sleep_error in zip(durations, sleep_errors, busy_sleep_errors): 41 | writer.writerow({ 42 | 'duration': duration, 43 | 'sleep_error': sleep_error, 44 | 'busy_sleep_error': busy_sleep_error 45 | }) 46 | print("Data saved to", filename) 47 | 48 | save_to_csv('sleep_data.csv', durations * 1000, np.array(sleep_errors) * 1000, np.array(busy_sleep_errors) * 1000) 49 | -------------------------------------------------------------------------------- /experiments/sleep_testing/plot_errors.py: -------------------------------------------------------------------------------- 1 | import csv 2 | 3 | import matplotlib.pyplot as plt 4 | 5 | 6 | def plot_from_csv(filename, save_plot=False): 7 | durations = [] 8 | sleep_errors = [] 9 | busy_sleep_errors = [] 10 | 11 | with open(filename, 'r') as csvfile: 12 | reader = csv.DictReader(csvfile) 13 | for row in reader: 14 | durations.append(float(row['duration'])) 15 | sleep_errors.append(float(row['sleep_error'])) 16 | busy_sleep_errors.append(float(row['busy_sleep_error'])) 17 | 18 | plt.figure(figsize=(10, 5)) 19 | plt.plot(durations, sleep_errors, label='time.sleep()', marker='o') 20 | plt.plot(durations, busy_sleep_errors, label='busy_sleep()', marker='x') 21 | plt.xlabel('Desired Delay (ms)') 22 | plt.ylabel('Average Error (ms)') 23 | plt.title('Sleep Accuracy: time.sleep() vs Busy-Wait Loop (macOS)') 24 | plt.legend() 25 | plt.grid(True) 26 | 27 | if save_plot: 28 | plt.savefig('sleep_accuracy_plot.png', dpi=300) 29 | print("Plot saved as sleep_accuracy_plot.png") 30 | 31 | plt.show() 32 | 33 | plot_from_csv('sleep_data.csv', save_plot=True) 34 | -------------------------------------------------------------------------------- /experiments/stopwatch/stopwatch.py: -------------------------------------------------------------------------------- 1 | import glob 2 | 3 | import matplotlib.pyplot as plt 4 | import numpy as np 5 | import pandas as pd 6 | import scipy.stats as stats 7 | import seaborn as sns 8 | 9 | # use this: https://www.estopwatch.net/ 10 | 11 | def read_file(file_path): 12 | df = pd.read_csv(file_path) 13 | df['Elapsed time'] = pd.to_datetime(df['Elapsed time'], errors='coerce') 14 | return df 15 | 16 | 17 | def analyze_new_error(run_df, groundtruth_df): 18 | cumulative_errors = run_df['Elapsed time'] - groundtruth_df['Elapsed time'] 19 | cumulative_errors_in_seconds = cumulative_errors.dt.total_seconds() 20 | 21 | new_errors_in_seconds = cumulative_errors_in_seconds.diff().fillna(cumulative_errors_in_seconds[0]) 22 | new_error_points = new_errors_in_seconds[new_errors_in_seconds != 0].index.tolist() 23 | 24 | return new_errors_in_seconds[new_error_points] 25 | 26 | def calculate_statistics(errors): 27 | if len(errors) == 0: 28 | return { 29 | 'mean_error': 0, 30 | 'median_error': 0, 31 | 'stddev_error': 0, 32 | 'rmse_error': 0, 33 | 'confidence_interval': (0, 0), 34 | 'error_frequency': 0 35 | } 36 | 37 | mean_error = np.mean(errors) 38 | median_error = np.median(errors) 39 | stddev_error = np.std(errors) 40 | rmse_error = np.sqrt(np.mean(np.square(errors))) 41 | 42 | ci_low, ci_high = stats.t.interval( 43 | confidence=0.95, 44 | df=len(errors) - 1, 45 | loc=mean_error, 46 | scale=stats.sem(errors) if len(errors) > 1 else 0 47 | ) 48 | 49 | return { 50 | 'mean_error': mean_error, 51 | 'median_error': median_error, 52 | 'stddev_error': stddev_error, 53 | 'rmse_error': rmse_error, 54 | 'confidence_interval': (ci_low, ci_high), 55 | } 56 | 57 | 58 | def main(): 59 | groundtruth_file = 'groundtruth.csv' 60 | run_files = glob.glob('runs/*.csv') 61 | 62 | groundtruth_df = read_file(groundtruth_file) 63 | run_dfs = {f'run{i+1}': read_file(file) for i, file in enumerate(run_files)} 64 | 65 | total_errors = [] 66 | total_points = 0 67 | all_errors = [] 68 | 69 | for run, df in run_dfs.items(): 70 | errors = analyze_new_error(df, groundtruth_df) 71 | total_errors.extend(errors) 72 | all_errors.extend(errors) 73 | total_points += len(df) 74 | 75 | results = calculate_statistics(errors) 76 | error_frequency = len(errors) / len(df) 77 | 78 | print(f"Results for {run}:") 79 | print(f"Mean New Error: {results['mean_error']:.5f} seconds") 80 | print(f"Median New Error: {results['median_error']:.5f} seconds") 81 | print(f"Standard Deviation of New Error: {results['stddev_error']:.5f} seconds") 82 | print(f"RMSE of New Error: {results['rmse_error']:.5f} seconds") 83 | print(f"95% Confidence Interval of New Error: ({results['confidence_interval'][0]:.5f}, {results['confidence_interval'][1]:.5f}) seconds") 84 | print(f"New Error Frequency: {error_frequency*100:.5f} %") 85 | print('-----------------------------------------') 86 | 87 | total_results = calculate_statistics(total_errors) 88 | total_error_frequency = len(total_errors) / total_points 89 | 90 | print("Total Statistics:") 91 | print(f"Mean New Error: {total_results['mean_error']:.5f} seconds") 92 | print(f"Median New Error: {total_results['median_error']:.5f} seconds") 93 | print(f"Standard Deviation of New Error: {total_results['stddev_error']:.5f} seconds") 94 | print(f"RMSE of New Error: {total_results['rmse_error']:.5f} seconds") 95 | print(f"95% Confidence Interval of New Error: ({total_results['confidence_interval'][0]:.5f}, {total_results['confidence_interval'][1]:.5f}) seconds") 96 | print(f"New Error Frequency: {total_error_frequency*100:.5f} %") 97 | 98 | # do plus minus 99 | print(f"New Error: {total_results['mean_error']:.5f} ± {total_results['confidence_interval'][1] - total_results['mean_error']:.5f} seconds") 100 | 101 | plt.figure(figsize=(10, 5)) 102 | sns.histplot(all_errors, bins=12, kde=False) 103 | plt.title('Distribution of Newly Introduced Errors (macOS)') 104 | plt.xlabel('Error Duration (seconds)') 105 | plt.ylabel('Frequency') 106 | plt.savefig('error_dist', dpi=300) 107 | plt.show() 108 | 109 | if __name__ == "__main__": 110 | main() 111 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import signal 2 | import sys 3 | import traceback 4 | 5 | from PyQt6.QtWidgets import QApplication 6 | 7 | from ducktrack import MainInterface 8 | 9 | 10 | def main(): 11 | app = QApplication(sys.argv) 12 | app.setQuitOnLastWindowClosed(False) 13 | signal.signal(signal.SIGINT, signal.SIG_DFL) 14 | interface = MainInterface(app) 15 | interface.show() 16 | 17 | # TODO: come up with a better error solution to this 18 | 19 | original_excepthook = sys.excepthook 20 | def handle_exception(exc_type, exc_value, exc_traceback): 21 | print("Exception type:", exc_type) 22 | print("Exception value:", exc_value) 23 | 24 | trace_details = traceback.format_exception(exc_type, exc_value, exc_traceback) 25 | trace_string = "".join(trace_details) 26 | 27 | print("Exception traceback:", trace_string) 28 | 29 | message = f"An error occurred!\n\n{exc_value}\n\n{trace_string}" 30 | interface.display_error_message(message) 31 | 32 | original_excepthook(exc_type, exc_value, exc_traceback) 33 | 34 | sys.excepthook = handle_exception 35 | 36 | sys.exit(app.exec()) 37 | 38 | if __name__ == "__main__": 39 | main() -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-17 220155.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-17 220155.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-17 221407.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-17 221407.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-17 221553.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-17 221553.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-17 222626.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-17 222626.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-24 103752.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-24 103752.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-24 104203.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-24 104203.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-24 110033.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-24 110033.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-24 110113.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-24 110113.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-24 110823.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-24 110823.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-24 111017.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-24 111017.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-24 111110.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-24 111110.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-24 111422.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-24 111422.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-24 111634.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-24 111634.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-24 111654.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-24 111654.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-24 111809.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-24 111809.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-24 111841.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-24 111841.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-24 112001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-24 112001.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-24 113548.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-24 113548.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-24 115916.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-24 115916.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-24 120133.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-24 120133.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-24 120347.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-24 120347.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-24 121017.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-24 121017.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-24 121222.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-24 121222.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-24 122006.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-24 122006.png -------------------------------------------------------------------------------- /readme_images/Screenshot 2023-06-24 162423.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/readme_images/Screenshot 2023-06-24 162423.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | git+https://github.com/moses-palmer/pynput.git@refs/pull/541/head # to make sure that it works on Apple Silicon 2 | pyautogui 3 | obsws-python 4 | PyQt6 5 | Pillow 6 | screeninfo 7 | wmi 8 | psutil 9 | pyinstaller -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheDuckAI/DuckTrack/8b008ca4d9b2acc366ce27afb0387489da83cc8e/tests/__init__.py --------------------------------------------------------------------------------