Skip to content

Commit e7589f3

Browse files
authored
feat(record): add Linux support
* oa_pynput -> pynput * add window/_linux.py * multiprocessing_utils; xcffib * global monitor_width/monitor_height * add capture._linux * cleanup * get_double_click_interval_seconds/pixels on linux * get_xinput_property
1 parent 548dd94 commit e7589f3

16 files changed

+595
-114
lines changed

Diff for: openadapt/build.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import urllib.request
1717

1818
import gradio_client
19-
import oa_pynput
19+
import pynput
2020
import pycocotools
2121
import pydicom
2222
import pyqttoast
@@ -34,7 +34,7 @@
3434
def build_pyinstaller() -> None:
3535
"""Build the application using PyInstaller."""
3636
additional_packages_to_install = [
37-
oa_pynput,
37+
pynput,
3838
pydicom,
3939
spacy_alignments,
4040
gradio_client,
@@ -275,6 +275,8 @@ def main() -> None:
275275
create_macos_dmg()
276276
elif sys.platform == "win32":
277277
create_windows_installer()
278+
else:
279+
print(f"WARNING: openadapt.build is not yet supported on {sys.platform=}")
278280

279281

280282
if __name__ == "__main__":

Diff for: openadapt/build_utils.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@ def get_root_dir_path() -> pathlib.Path:
1717
if not path.exists():
1818
path.mkdir(parents=True, exist_ok=True)
1919
return path
20-
else:
20+
elif sys.platform == "win32":
2121
# if windows, get the path to the %APPDATA% directory and set the path
2222
# for all user preferences
2323
path = pathlib.Path.home() / "AppData" / "Roaming" / "openadapt"
2424
if not path.exists():
2525
path.mkdir(parents=True, exist_ok=True)
2626
return path
27+
else:
28+
print(f"WARNING: openadapt.build_utils is not yet supported on {sys.platform=}")
2729

2830

2931
def is_running_from_executable() -> bool:

Diff for: openadapt/capture/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from . import _macos as impl
1010
elif sys.platform == "win32":
1111
from . import _windows as impl
12+
elif sys.platform.startswith("linux"):
13+
from . import _linux as impl
1214
else:
1315
raise Exception(f"Unsupported platform: {sys.platform}")
1416

Diff for: openadapt/capture/_linux.py

+140
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import subprocess
2+
import os
3+
from datetime import datetime
4+
from sys import platform
5+
import pyaudio
6+
import wave
7+
8+
from openadapt.config import CAPTURE_DIR_PATH
9+
10+
11+
class Capture:
12+
"""Capture the screen, audio, and camera on Linux."""
13+
14+
def __init__(self) -> None:
15+
"""Initialize the capture object."""
16+
if not platform.startswith("linux"):
17+
assert platform == "linux", platform
18+
19+
self.is_recording = False
20+
self.audio_out = None
21+
self.video_out = None
22+
self.audio_stream = None
23+
self.audio_frames = []
24+
25+
# Initialize PyAudio
26+
self.audio = pyaudio.PyAudio()
27+
28+
def get_screen_resolution(self) -> tuple:
29+
"""Get the screen resolution dynamically using xrandr."""
30+
try:
31+
# Get screen resolution using xrandr
32+
output = subprocess.check_output(
33+
"xrandr | grep '*' | awk '{print $1}'", shell=True
34+
)
35+
resolution = output.decode("utf-8").strip()
36+
width, height = resolution.split("x")
37+
return int(width), int(height)
38+
except subprocess.CalledProcessError as e:
39+
raise RuntimeError(f"Failed to get screen resolution: {e}")
40+
41+
def start(self, audio: bool = True, camera: bool = False) -> None:
42+
"""Start capturing the screen, audio, and camera.
43+
44+
Args:
45+
audio (bool, optional): Whether to capture audio (default: True).
46+
camera (bool, optional): Whether to capture the camera (default: False).
47+
"""
48+
if self.is_recording:
49+
raise RuntimeError("Recording is already in progress")
50+
51+
self.is_recording = True
52+
capture_dir = CAPTURE_DIR_PATH
53+
if not os.path.exists(capture_dir):
54+
os.mkdir(capture_dir)
55+
56+
# Get the screen resolution dynamically
57+
screen_width, screen_height = self.get_screen_resolution()
58+
59+
# Start video capture using ffmpeg
60+
video_filename = datetime.now().strftime("%Y-%m-%d-%H-%M-%S") + ".mp4"
61+
self.video_out = os.path.join(capture_dir, video_filename)
62+
self._start_video_capture(screen_width, screen_height)
63+
64+
# Start audio capture
65+
if audio:
66+
audio_filename = datetime.now().strftime("%Y-%m-%d-%H-%M-%S") + ".wav"
67+
self.audio_out = os.path.join(capture_dir, audio_filename)
68+
self._start_audio_capture()
69+
70+
def _start_video_capture(self, width: int, height: int) -> None:
71+
"""Start capturing the screen using ffmpeg with the dynamic resolution."""
72+
cmd = [
73+
"ffmpeg",
74+
"-f",
75+
"x11grab", # Capture X11 display
76+
"-video_size",
77+
f"{width}x{height}", # Use dynamic screen resolution
78+
"-framerate",
79+
"30", # Set frame rate
80+
"-i",
81+
":0.0", # Capture from display 0
82+
"-c:v",
83+
"libx264", # Video codec
84+
"-preset",
85+
"ultrafast", # Speed/quality tradeoff
86+
"-y",
87+
self.video_out, # Output file
88+
]
89+
self.video_proc = subprocess.Popen(cmd)
90+
91+
def _start_audio_capture(self) -> None:
92+
"""Start capturing audio using PyAudio."""
93+
self.audio_stream = self.audio.open(
94+
format=pyaudio.paInt16,
95+
channels=2,
96+
rate=44100,
97+
input=True,
98+
frames_per_buffer=1024,
99+
stream_callback=self._audio_callback,
100+
)
101+
self.audio_frames = []
102+
self.audio_stream.start_stream()
103+
104+
def _audio_callback(
105+
self, in_data: bytes, frame_count: int, time_info: dict, status: int
106+
) -> tuple:
107+
"""Callback function to process audio data."""
108+
self.audio_frames.append(in_data)
109+
return (None, pyaudio.paContinue)
110+
111+
def stop(self) -> None:
112+
"""Stop capturing the screen, audio, and camera."""
113+
if self.is_recording:
114+
# Stop the video capture
115+
self.video_proc.terminate()
116+
117+
# Stop audio capture
118+
if self.audio_stream:
119+
self.audio_stream.stop_stream()
120+
self.audio_stream.close()
121+
self.audio.terminate()
122+
self.save_audio()
123+
124+
self.is_recording = False
125+
126+
def save_audio(self) -> None:
127+
"""Save the captured audio to a WAV file."""
128+
if self.audio_out:
129+
with wave.open(self.audio_out, "wb") as wf:
130+
wf.setnchannels(2)
131+
wf.setsampwidth(self.audio.get_sample_size(pyaudio.paInt16))
132+
wf.setframerate(44100)
133+
wf.writeframes(b"".join(self.audio_frames))
134+
135+
136+
if __name__ == "__main__":
137+
capture = Capture()
138+
capture.start(audio=True, camera=False)
139+
input("Press enter to stop")
140+
capture.stop()

Diff for: openadapt/capture/_macos.py

+1-4
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,7 @@ class Capture:
2323

2424
def __init__(self) -> None:
2525
"""Initialize the capture object."""
26-
if platform != "darwin":
27-
raise NotImplementedError(
28-
"This is the macOS implementation, please use the Windows version"
29-
)
26+
assert platform == "darwin", platform
3027

3128
objc.options.structs_indexable = True
3229

Diff for: openadapt/capture/_windows.py

+2-4
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,8 @@ def __init__(self, pid: int = 0) -> None:
2121
pid (int, optional): The process ID of the window to capture.
2222
Defaults to 0 (the entire screen)
2323
"""
24-
if platform != "win32":
25-
raise NotImplementedError(
26-
"This is the Windows implementation, please use the macOS version"
27-
)
24+
assert platform == "win32", platform
25+
2826
self.is_recording = False
2927
self.video_out = None
3028
self.audio_out = None

Diff for: openadapt/models.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import sys
1010

1111
from bs4 import BeautifulSoup
12-
from oa_pynput import keyboard
12+
from pynput import keyboard
1313
from PIL import Image, ImageChops
1414
import numpy as np
1515
import sqlalchemy as sa
@@ -649,9 +649,9 @@ def to_prompt_dict(
649649
"title",
650650
"help",
651651
]
652-
if sys.platform == "win32":
652+
if sys.platform != "darwin":
653653
logger.warning(
654-
"key_suffixes have not yet been defined on Windows."
654+
"key_suffixes have not yet been defined on {sys.platform=}."
655655
"You can help by uncommenting the lines below and pasting "
656656
"the contents of the window_dict into a new GitHub Issue."
657657
)

Diff for: openadapt/playback.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Utilities for playing back ActionEvents."""
22

3-
from oa_pynput import keyboard, mouse
3+
from pynput import keyboard, mouse
44

55
from openadapt.common import KEY_EVENTS, MOUSE_EVENTS
66
from openadapt.custom_logger import logger

Diff for: openadapt/plotting.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -435,8 +435,10 @@ def plot_performance(
435435
if view_file:
436436
if sys.platform == "darwin":
437437
os.system(f"open {fpath}")
438-
else:
438+
elif sys.platform == "win32":
439439
os.system(f"start {fpath}")
440+
else:
441+
os.system(f"xdg-open {fpath}")
440442
else:
441443
plt.savefig(BytesIO(), format="png") # save fig to void
442444
if view_file:

0 commit comments

Comments
 (0)