PayPerTranscript 0.3.1__tar.gz → 0.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/PKG-INFO +2 -1
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/PayPerTranscript.egg-info/PKG-INFO +2 -1
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/PayPerTranscript.egg-info/SOURCES.txt +13 -1
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/PayPerTranscript.egg-info/requires.txt +1 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/__init__.py +1 -1
- paypertranscript-0.3.2/paypertranscript/core/atomic_io.py +29 -0
- paypertranscript-0.3.2/paypertranscript/core/clipboard_manager.py +263 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/config.py +10 -8
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/context_detector.py +22 -27
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/hotkey.py +29 -20
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/recorder.py +9 -8
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/session_logger.py +4 -2
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/text_inserter.py +49 -40
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/updater.py +7 -5
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/pipeline/transcription.py +10 -8
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/providers/groq_provider.py +2 -2
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/app.py +27 -6
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/main_window.py +9 -5
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/pages/home_page.py +2 -2
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/pages/settings_page.py +9 -2
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/tray.py +3 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/pyproject.toml +2 -1
- paypertranscript-0.3.2/tests/test_atomic_and_session_logger.py +104 -0
- paypertranscript-0.3.2/tests/test_clipboard_integration.py +73 -0
- paypertranscript-0.3.2/tests/test_clipboard_manager_formats.py +60 -0
- paypertranscript-0.3.2/tests/test_groq_provider_logging.py +42 -0
- paypertranscript-0.3.2/tests/test_home_page_config_key.py +94 -0
- paypertranscript-0.3.2/tests/test_hotkey_settings_and_timer.py +112 -0
- paypertranscript-0.3.2/tests/test_pipeline_privacy_and_transcripts.py +89 -0
- paypertranscript-0.3.2/tests/test_recorder_thread_safety.py +39 -0
- paypertranscript-0.3.2/tests/test_release_script.py +48 -0
- paypertranscript-0.3.2/tests/test_updater_version.py +14 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/LICENSE +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/PayPerTranscript.egg-info/dependency_links.txt +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/PayPerTranscript.egg-info/entry_points.txt +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/PayPerTranscript.egg-info/top_level.txt +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/README.md +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/__main__.py +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/assets/icons/app.ico +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/assets/icons/app.png +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/assets/icons/app_big.png +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/assets/icons/arrow_down.svg +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/assets/icons/tray.png +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/assets/icons/tray_green.png +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/assets/icons/tray_orange.png +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/assets/sounds/start.wav +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/assets/sounds/stop.wav +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/assets/styles/dark.qss +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/__init__.py +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/audio_manager.py +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/cost_tracker.py +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/logging.py +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/paths.py +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/window_detector.py +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/pipeline/__init__.py +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/providers/__init__.py +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/providers/base.py +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/__init__.py +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/animated.py +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/constants.py +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/overlay.py +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/pages/__init__.py +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/pages/statistics_page.py +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/pages/window_mapping_page.py +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/pages/word_list_page.py +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/setup_wizard.py +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/sidebar.py +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/widgets.py +0 -0
- {paypertranscript-0.3.1 → paypertranscript-0.3.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PayPerTranscript
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Open-Source Voice-to-Text mit Pay-per-Use Pricing
|
|
5
5
|
Author: PayPerTranscript Contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,6 +22,7 @@ Requires-Dist: pyperclip
|
|
|
22
22
|
Requires-Dist: pyautogui
|
|
23
23
|
Requires-Dist: keyring
|
|
24
24
|
Requires-Dist: soundfile
|
|
25
|
+
Requires-Dist: packaging
|
|
25
26
|
Provides-Extra: dev
|
|
26
27
|
Requires-Dist: build; extra == "dev"
|
|
27
28
|
Requires-Dist: pytest; extra == "dev"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PayPerTranscript
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Open-Source Voice-to-Text mit Pay-per-Use Pricing
|
|
5
5
|
Author: PayPerTranscript Contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,6 +22,7 @@ Requires-Dist: pyperclip
|
|
|
22
22
|
Requires-Dist: pyautogui
|
|
23
23
|
Requires-Dist: keyring
|
|
24
24
|
Requires-Dist: soundfile
|
|
25
|
+
Requires-Dist: packaging
|
|
25
26
|
Provides-Extra: dev
|
|
26
27
|
Requires-Dist: build; extra == "dev"
|
|
27
28
|
Requires-Dist: pytest; extra == "dev"
|
|
@@ -20,7 +20,9 @@ paypertranscript/assets/sounds/start.wav
|
|
|
20
20
|
paypertranscript/assets/sounds/stop.wav
|
|
21
21
|
paypertranscript/assets/styles/dark.qss
|
|
22
22
|
paypertranscript/core/__init__.py
|
|
23
|
+
paypertranscript/core/atomic_io.py
|
|
23
24
|
paypertranscript/core/audio_manager.py
|
|
25
|
+
paypertranscript/core/clipboard_manager.py
|
|
24
26
|
paypertranscript/core/config.py
|
|
25
27
|
paypertranscript/core/context_detector.py
|
|
26
28
|
paypertranscript/core/cost_tracker.py
|
|
@@ -52,4 +54,14 @@ paypertranscript/ui/pages/home_page.py
|
|
|
52
54
|
paypertranscript/ui/pages/settings_page.py
|
|
53
55
|
paypertranscript/ui/pages/statistics_page.py
|
|
54
56
|
paypertranscript/ui/pages/window_mapping_page.py
|
|
55
|
-
paypertranscript/ui/pages/word_list_page.py
|
|
57
|
+
paypertranscript/ui/pages/word_list_page.py
|
|
58
|
+
tests/test_atomic_and_session_logger.py
|
|
59
|
+
tests/test_clipboard_integration.py
|
|
60
|
+
tests/test_clipboard_manager_formats.py
|
|
61
|
+
tests/test_groq_provider_logging.py
|
|
62
|
+
tests/test_home_page_config_key.py
|
|
63
|
+
tests/test_hotkey_settings_and_timer.py
|
|
64
|
+
tests/test_pipeline_privacy_and_transcripts.py
|
|
65
|
+
tests/test_recorder_thread_safety.py
|
|
66
|
+
tests/test_release_script.py
|
|
67
|
+
tests/test_updater_version.py
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Atomic file writing helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def atomic_write_text(path: Path, content: str, encoding: str = "utf-8") -> None:
|
|
11
|
+
"""Atomically write text content to a file."""
|
|
12
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
14
|
+
prefix=f".{path.name}.",
|
|
15
|
+
suffix=".tmp",
|
|
16
|
+
dir=str(path.parent),
|
|
17
|
+
)
|
|
18
|
+
try:
|
|
19
|
+
with os.fdopen(fd, "w", encoding=encoding, newline="") as tmp_file:
|
|
20
|
+
tmp_file.write(content)
|
|
21
|
+
tmp_file.flush()
|
|
22
|
+
os.fsync(tmp_file.fileno())
|
|
23
|
+
os.replace(tmp_name, path)
|
|
24
|
+
except Exception:
|
|
25
|
+
try:
|
|
26
|
+
os.unlink(tmp_name)
|
|
27
|
+
except OSError:
|
|
28
|
+
pass
|
|
29
|
+
raise
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Windows clipboard snapshot/restore helpers.
|
|
2
|
+
|
|
3
|
+
Designed for low-latency clipboard preservation during context detection and paste
|
|
4
|
+
operations. The implementation preserves clipboard formats best-effort by copying
|
|
5
|
+
raw global-memory payloads for each available format.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ctypes
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Final
|
|
15
|
+
|
|
16
|
+
from paypertranscript.core.logging import get_logger
|
|
17
|
+
|
|
18
|
+
log = get_logger("core.clipboard_manager")
|
|
19
|
+
|
|
20
|
+
if ctypes.sizeof(ctypes.c_void_p) == 8:
|
|
21
|
+
_SIZE_T = ctypes.c_uint64
|
|
22
|
+
else:
|
|
23
|
+
_SIZE_T = ctypes.c_uint32
|
|
24
|
+
|
|
25
|
+
_user32 = ctypes.WinDLL("user32", use_last_error=True)
|
|
26
|
+
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
27
|
+
|
|
28
|
+
_user32.OpenClipboard.argtypes = [ctypes.c_void_p]
|
|
29
|
+
_user32.OpenClipboard.restype = ctypes.c_int
|
|
30
|
+
_user32.CloseClipboard.argtypes = []
|
|
31
|
+
_user32.CloseClipboard.restype = ctypes.c_int
|
|
32
|
+
_user32.EmptyClipboard.argtypes = []
|
|
33
|
+
_user32.EmptyClipboard.restype = ctypes.c_int
|
|
34
|
+
_user32.EnumClipboardFormats.argtypes = [ctypes.c_uint]
|
|
35
|
+
_user32.EnumClipboardFormats.restype = ctypes.c_uint
|
|
36
|
+
_user32.GetClipboardData.argtypes = [ctypes.c_uint]
|
|
37
|
+
_user32.GetClipboardData.restype = ctypes.c_void_p
|
|
38
|
+
_user32.SetClipboardData.argtypes = [ctypes.c_uint, ctypes.c_void_p]
|
|
39
|
+
_user32.SetClipboardData.restype = ctypes.c_void_p
|
|
40
|
+
_user32.IsClipboardFormatAvailable.argtypes = [ctypes.c_uint]
|
|
41
|
+
_user32.IsClipboardFormatAvailable.restype = ctypes.c_int
|
|
42
|
+
_user32.GetClipboardSequenceNumber.argtypes = []
|
|
43
|
+
_user32.GetClipboardSequenceNumber.restype = ctypes.c_uint
|
|
44
|
+
|
|
45
|
+
_kernel32.GlobalAlloc.argtypes = [ctypes.c_uint, _SIZE_T]
|
|
46
|
+
_kernel32.GlobalAlloc.restype = ctypes.c_void_p
|
|
47
|
+
_kernel32.GlobalLock.argtypes = [ctypes.c_void_p]
|
|
48
|
+
_kernel32.GlobalLock.restype = ctypes.c_void_p
|
|
49
|
+
_kernel32.GlobalUnlock.argtypes = [ctypes.c_void_p]
|
|
50
|
+
_kernel32.GlobalUnlock.restype = ctypes.c_int
|
|
51
|
+
_kernel32.GlobalSize.argtypes = [ctypes.c_void_p]
|
|
52
|
+
_kernel32.GlobalSize.restype = _SIZE_T
|
|
53
|
+
_kernel32.GlobalFree.argtypes = [ctypes.c_void_p]
|
|
54
|
+
_kernel32.GlobalFree.restype = ctypes.c_void_p
|
|
55
|
+
|
|
56
|
+
_GMEM_MOVEABLE: Final[int] = 0x0002
|
|
57
|
+
_CF_UNICODETEXT: Final[int] = 13
|
|
58
|
+
|
|
59
|
+
_OPEN_RETRIES: Final[int] = 5
|
|
60
|
+
_OPEN_DELAY_SECONDS: Final[float] = 0.002
|
|
61
|
+
|
|
62
|
+
_lock = threading.RLock()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class ClipboardFormatPayload:
|
|
67
|
+
"""Raw bytes for a single clipboard format."""
|
|
68
|
+
|
|
69
|
+
format_id: int
|
|
70
|
+
data: bytes
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class ClipboardSnapshot:
|
|
75
|
+
"""Clipboard snapshot containing copied format payloads."""
|
|
76
|
+
|
|
77
|
+
sequence_number: int
|
|
78
|
+
formats: tuple[ClipboardFormatPayload, ...]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_EMPTY_SNAPSHOT = ClipboardSnapshot(sequence_number=0, formats=())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _open_clipboard() -> bool:
|
|
85
|
+
for attempt in range(_OPEN_RETRIES):
|
|
86
|
+
if _user32.OpenClipboard(None):
|
|
87
|
+
return True
|
|
88
|
+
if attempt < _OPEN_RETRIES - 1:
|
|
89
|
+
time.sleep(_OPEN_DELAY_SECONDS)
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _copy_hglobal(handle: ctypes.c_void_p) -> bytes | None:
|
|
94
|
+
size = int(_kernel32.GlobalSize(handle))
|
|
95
|
+
if size < 0:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
ptr = _kernel32.GlobalLock(handle)
|
|
99
|
+
if not ptr:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
if size == 0:
|
|
104
|
+
return b""
|
|
105
|
+
raw = (ctypes.c_ubyte * size).from_address(ptr)
|
|
106
|
+
return bytes(raw)
|
|
107
|
+
finally:
|
|
108
|
+
_kernel32.GlobalUnlock(handle)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _alloc_hglobal(data: bytes) -> ctypes.c_void_p:
|
|
112
|
+
size = max(len(data), 1)
|
|
113
|
+
handle = _kernel32.GlobalAlloc(_GMEM_MOVEABLE, size)
|
|
114
|
+
if not handle:
|
|
115
|
+
return ctypes.c_void_p()
|
|
116
|
+
|
|
117
|
+
ptr = _kernel32.GlobalLock(handle)
|
|
118
|
+
if not ptr:
|
|
119
|
+
_kernel32.GlobalFree(handle)
|
|
120
|
+
return ctypes.c_void_p()
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
if data:
|
|
124
|
+
ctypes.memmove(ptr, data, len(data))
|
|
125
|
+
if size > len(data):
|
|
126
|
+
ctypes.memset(ptr + len(data), 0, size - len(data))
|
|
127
|
+
finally:
|
|
128
|
+
_kernel32.GlobalUnlock(handle)
|
|
129
|
+
|
|
130
|
+
return handle
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def snapshot_clipboard() -> ClipboardSnapshot:
|
|
134
|
+
"""Capture the current clipboard in a best-effort, format-preserving way."""
|
|
135
|
+
start = time.perf_counter()
|
|
136
|
+
with _lock:
|
|
137
|
+
if not _open_clipboard():
|
|
138
|
+
log.debug("Clipboard snapshot skipped: OpenClipboard failed")
|
|
139
|
+
return _EMPTY_SNAPSHOT
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
sequence_number = int(_user32.GetClipboardSequenceNumber())
|
|
143
|
+
payloads: list[ClipboardFormatPayload] = []
|
|
144
|
+
|
|
145
|
+
fmt = 0
|
|
146
|
+
while True:
|
|
147
|
+
fmt = int(_user32.EnumClipboardFormats(fmt))
|
|
148
|
+
if fmt == 0:
|
|
149
|
+
break
|
|
150
|
+
|
|
151
|
+
handle = _user32.GetClipboardData(fmt)
|
|
152
|
+
if not handle:
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
copied = _copy_hglobal(handle)
|
|
156
|
+
if copied is None:
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
payloads.append(ClipboardFormatPayload(format_id=fmt, data=copied))
|
|
160
|
+
|
|
161
|
+
snapshot = ClipboardSnapshot(
|
|
162
|
+
sequence_number=sequence_number,
|
|
163
|
+
formats=tuple(payloads),
|
|
164
|
+
)
|
|
165
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
166
|
+
log.debug(
|
|
167
|
+
"Clipboard snapshot: %d formats in %.2fms",
|
|
168
|
+
len(snapshot.formats),
|
|
169
|
+
elapsed_ms,
|
|
170
|
+
)
|
|
171
|
+
return snapshot
|
|
172
|
+
finally:
|
|
173
|
+
_user32.CloseClipboard()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def restore_clipboard(snapshot: ClipboardSnapshot) -> bool:
|
|
177
|
+
"""Restore a previously captured clipboard snapshot."""
|
|
178
|
+
start = time.perf_counter()
|
|
179
|
+
with _lock:
|
|
180
|
+
if not _open_clipboard():
|
|
181
|
+
log.warning("Clipboard restore failed: OpenClipboard failed")
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
if not _user32.EmptyClipboard():
|
|
186
|
+
log.warning("Clipboard restore failed: EmptyClipboard failed")
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
restored_count = 0
|
|
190
|
+
for payload in snapshot.formats:
|
|
191
|
+
handle = _alloc_hglobal(payload.data)
|
|
192
|
+
if not handle:
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
result = _user32.SetClipboardData(payload.format_id, handle)
|
|
196
|
+
if not result:
|
|
197
|
+
_kernel32.GlobalFree(handle)
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
restored_count += 1
|
|
201
|
+
|
|
202
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
203
|
+
log.debug(
|
|
204
|
+
"Clipboard restore: %d/%d formats in %.2fms",
|
|
205
|
+
restored_count,
|
|
206
|
+
len(snapshot.formats),
|
|
207
|
+
elapsed_ms,
|
|
208
|
+
)
|
|
209
|
+
return restored_count == len(snapshot.formats)
|
|
210
|
+
finally:
|
|
211
|
+
_user32.CloseClipboard()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get_clipboard_text() -> str:
|
|
215
|
+
"""Read UTF-16 text from clipboard if available."""
|
|
216
|
+
with _lock:
|
|
217
|
+
if not _open_clipboard():
|
|
218
|
+
return ""
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
if not _user32.IsClipboardFormatAvailable(_CF_UNICODETEXT):
|
|
222
|
+
return ""
|
|
223
|
+
|
|
224
|
+
handle = _user32.GetClipboardData(_CF_UNICODETEXT)
|
|
225
|
+
if not handle:
|
|
226
|
+
return ""
|
|
227
|
+
|
|
228
|
+
ptr = _kernel32.GlobalLock(handle)
|
|
229
|
+
if not ptr:
|
|
230
|
+
return ""
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
return ctypes.wstring_at(ptr) or ""
|
|
234
|
+
finally:
|
|
235
|
+
_kernel32.GlobalUnlock(handle)
|
|
236
|
+
finally:
|
|
237
|
+
_user32.CloseClipboard()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def set_clipboard_text(text: str) -> bool:
|
|
241
|
+
"""Replace clipboard contents with UTF-16 text."""
|
|
242
|
+
utf16 = text.encode("utf-16-le") + b"\x00\x00"
|
|
243
|
+
|
|
244
|
+
with _lock:
|
|
245
|
+
if not _open_clipboard():
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
if not _user32.EmptyClipboard():
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
handle = _alloc_hglobal(utf16)
|
|
253
|
+
if not handle:
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
result = _user32.SetClipboardData(_CF_UNICODETEXT, handle)
|
|
257
|
+
if not result:
|
|
258
|
+
_kernel32.GlobalFree(handle)
|
|
259
|
+
return False
|
|
260
|
+
|
|
261
|
+
return True
|
|
262
|
+
finally:
|
|
263
|
+
_user32.CloseClipboard()
|
|
@@ -11,6 +11,7 @@ import sys
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
14
|
+
from paypertranscript.core.atomic_io import atomic_write_text
|
|
14
15
|
from paypertranscript.core.logging import APPDATA_DIR, get_logger
|
|
15
16
|
|
|
16
17
|
log = get_logger("core.config")
|
|
@@ -164,14 +165,14 @@ def _validate_config(config: dict) -> dict:
|
|
|
164
165
|
|
|
165
166
|
key = parts[-1]
|
|
166
167
|
if key not in node:
|
|
167
|
-
# Fehlender Wert
|
|
168
|
+
# Fehlender Wert -> Default einsetzen
|
|
168
169
|
node[key] = copy.deepcopy(default_node[key])
|
|
169
|
-
log.warning("Config: Fehlender Wert '%s'
|
|
170
|
+
log.warning("Config: Fehlender Wert '%s' -> Default verwendet", path)
|
|
170
171
|
elif not isinstance(node[key], expected_type):
|
|
171
172
|
old_val = node[key]
|
|
172
173
|
node[key] = copy.deepcopy(default_node[key])
|
|
173
174
|
log.warning(
|
|
174
|
-
"Config: Ungültiger Typ für '%s' (%s statt %s)
|
|
175
|
+
"Config: Ungültiger Typ für '%s' (%s statt %s) -> Default verwendet",
|
|
175
176
|
path,
|
|
176
177
|
type(old_val).__name__,
|
|
177
178
|
expected_type,
|
|
@@ -204,13 +205,13 @@ class ConfigManager:
|
|
|
204
205
|
try:
|
|
205
206
|
raw = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
206
207
|
if not isinstance(raw, dict):
|
|
207
|
-
log.warning("Config-Datei enthält kein Dict
|
|
208
|
+
log.warning("Config-Datei enthält kein Dict -> Defaults verwendet")
|
|
208
209
|
raw = {}
|
|
209
210
|
except (json.JSONDecodeError, OSError) as e:
|
|
210
|
-
log.warning("Config-Datei konnte nicht gelesen werden: %s
|
|
211
|
+
log.warning("Config-Datei konnte nicht gelesen werden: %s -> Defaults verwendet", e)
|
|
211
212
|
raw = {}
|
|
212
213
|
else:
|
|
213
|
-
log.info("Keine Config-Datei gefunden
|
|
214
|
+
log.info("Keine Config-Datei gefunden -> Defaults werden verwendet")
|
|
214
215
|
raw = {}
|
|
215
216
|
|
|
216
217
|
merged = _deep_merge(DEFAULT_CONFIG, raw)
|
|
@@ -221,7 +222,8 @@ class ConfigManager:
|
|
|
221
222
|
"""Speichert aktuelle Config in Datei."""
|
|
222
223
|
_ensure_dirs()
|
|
223
224
|
try:
|
|
224
|
-
|
|
225
|
+
atomic_write_text(
|
|
226
|
+
CONFIG_FILE,
|
|
225
227
|
json.dumps(self._config, indent=2, ensure_ascii=False),
|
|
226
228
|
encoding="utf-8",
|
|
227
229
|
)
|
|
@@ -258,7 +260,7 @@ class ConfigManager:
|
|
|
258
260
|
path: Punkt-separierter Pfad (z.B. 'general.language').
|
|
259
261
|
value: Neuer Wert.
|
|
260
262
|
"""
|
|
261
|
-
# Aktuellen Stand von Disk lesen (falls von
|
|
263
|
+
# Aktuellen Stand von Disk lesen (falls von aussen geändert)
|
|
262
264
|
if CONFIG_FILE.exists():
|
|
263
265
|
try:
|
|
264
266
|
disk_config = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
@@ -14,8 +14,14 @@ import time
|
|
|
14
14
|
from concurrent.futures import Future
|
|
15
15
|
|
|
16
16
|
import pyautogui
|
|
17
|
-
import pyperclip
|
|
18
17
|
|
|
18
|
+
from paypertranscript.core.clipboard_manager import (
|
|
19
|
+
ClipboardSnapshot,
|
|
20
|
+
get_clipboard_text,
|
|
21
|
+
restore_clipboard,
|
|
22
|
+
set_clipboard_text,
|
|
23
|
+
snapshot_clipboard,
|
|
24
|
+
)
|
|
19
25
|
from paypertranscript.core.config import ConfigManager
|
|
20
26
|
from paypertranscript.core.logging import get_logger
|
|
21
27
|
from paypertranscript.core.window_detector import WindowInfo
|
|
@@ -58,10 +64,10 @@ def detect_selected_text(
|
|
|
58
64
|
|
|
59
65
|
Ablauf:
|
|
60
66
|
1. Feature-Flag und Terminal-Blocklist pruefen
|
|
61
|
-
2. Clipboard sichern
|
|
67
|
+
2. Clipboard sichern -> Sentinel setzen -> Ctrl+C -> Clipboard lesen -> wiederherstellen
|
|
62
68
|
3. Wenn Clipboard != Sentinel: markierter Text gefunden
|
|
63
69
|
|
|
64
|
-
Gibt in ALLEN Fehler-/Abbruch-Faellen "" zurueck
|
|
70
|
+
Gibt in ALLEN Fehler-/Abbruch-Faellen "" zurueck - wirft nie Exceptions.
|
|
65
71
|
Die Pipeline wird dadurch nie blockiert oder gestoert.
|
|
66
72
|
|
|
67
73
|
Args:
|
|
@@ -108,17 +114,12 @@ def detect_selected_text(
|
|
|
108
114
|
log.debug("Context detection started (no window info)")
|
|
109
115
|
|
|
110
116
|
# 4. Clipboard sichern
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
except Exception:
|
|
114
|
-
original_clipboard = ""
|
|
115
|
-
log.debug("Clipboard backed up (%d chars)", len(original_clipboard))
|
|
117
|
+
original_clipboard = snapshot_clipboard()
|
|
118
|
+
log.debug("Clipboard snapshot captured (%d formats)", len(original_clipboard.formats))
|
|
116
119
|
|
|
117
120
|
# 5. Sentinel auf Clipboard setzen
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
except Exception as e:
|
|
121
|
-
log.warning("Context detection: clipboard write failed: %s", e)
|
|
121
|
+
if not set_clipboard_text(_SENTINEL):
|
|
122
|
+
log.warning("Context detection: clipboard write failed")
|
|
122
123
|
return ""
|
|
123
124
|
log.debug("Sentinel placed on clipboard")
|
|
124
125
|
|
|
@@ -129,7 +130,7 @@ def detect_selected_text(
|
|
|
129
130
|
return ""
|
|
130
131
|
|
|
131
132
|
# 7. Warten bis Modifier-Keys losgelassen sind (noetig fuer Toggle-Hotkey:
|
|
132
|
-
# User haelt noch Ctrl+Alt
|
|
133
|
+
# User haelt noch Ctrl+Alt -> Ctrl+C wuerde als Ctrl+Alt+C ankommen)
|
|
133
134
|
_wait_for_modifiers_released()
|
|
134
135
|
|
|
135
136
|
# 8. Ctrl+C senden
|
|
@@ -141,12 +142,7 @@ def detect_selected_text(
|
|
|
141
142
|
time.sleep(_CLIPBOARD_WAIT_MS / 1000)
|
|
142
143
|
|
|
143
144
|
# 10. Clipboard lesen
|
|
144
|
-
|
|
145
|
-
clipboard_content = pyperclip.paste()
|
|
146
|
-
except Exception as e:
|
|
147
|
-
log.warning("Context detection: clipboard read failed: %s", e)
|
|
148
|
-
_restore_clipboard(original_clipboard)
|
|
149
|
-
return ""
|
|
145
|
+
clipboard_content = get_clipboard_text()
|
|
150
146
|
|
|
151
147
|
t_read = time.perf_counter()
|
|
152
148
|
log.debug(
|
|
@@ -156,7 +152,7 @@ def detect_selected_text(
|
|
|
156
152
|
|
|
157
153
|
# 11. Auswerten: Hat Ctrl+C den Sentinel ueberschrieben?
|
|
158
154
|
if clipboard_content == _SENTINEL:
|
|
159
|
-
# Sentinel unveraendert
|
|
155
|
+
# Sentinel unveraendert -> nichts war markiert
|
|
160
156
|
_restore_clipboard(original_clipboard)
|
|
161
157
|
t_end = time.perf_counter()
|
|
162
158
|
log.debug("No text selected (%.1fms total)", (t_end - t_start) * 1000)
|
|
@@ -197,7 +193,7 @@ def _wait_for_modifiers_released() -> None:
|
|
|
197
193
|
Context-Detection startet. Ctrl+C waehrend Ctrl+Alt gehalten wird,
|
|
198
194
|
wuerde als Ctrl+Alt+C interpretiert und Copy nicht ausloesen.
|
|
199
195
|
|
|
200
|
-
Beim Hold-Hotkey sind die Keys bereits losgelassen
|
|
196
|
+
Beim Hold-Hotkey sind die Keys bereits losgelassen -> returned sofort.
|
|
201
197
|
"""
|
|
202
198
|
user32 = ctypes.windll.user32
|
|
203
199
|
deadline = time.perf_counter() + _MODIFIER_RELEASE_TIMEOUT_MS / 1000
|
|
@@ -207,19 +203,18 @@ def _wait_for_modifiers_released() -> None:
|
|
|
207
203
|
return
|
|
208
204
|
time.sleep(0.01) # 10ms polling
|
|
209
205
|
|
|
210
|
-
# Timeout: Modifier immer noch gehalten
|
|
206
|
+
# Timeout: Modifier immer noch gehalten - trotzdem weitermachen
|
|
211
207
|
log.debug(
|
|
212
208
|
"Modifier keys still held after %dms timeout",
|
|
213
209
|
_MODIFIER_RELEASE_TIMEOUT_MS,
|
|
214
210
|
)
|
|
215
211
|
|
|
216
212
|
|
|
217
|
-
def _restore_clipboard(
|
|
213
|
+
def _restore_clipboard(snapshot: ClipboardSnapshot) -> None:
|
|
218
214
|
"""Stellt den Clipboard-Inhalt wieder her (best-effort)."""
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
log.warning("Context detection: clipboard restore failed: %s", e)
|
|
215
|
+
restored = restore_clipboard(snapshot)
|
|
216
|
+
if not restored and snapshot.formats:
|
|
217
|
+
log.warning("Context detection: clipboard restore incomplete")
|
|
223
218
|
|
|
224
219
|
|
|
225
220
|
def detect_selected_text_async(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
"""Globaler Hotkey-Listener für PayPerTranscript.
|
|
2
2
|
|
|
3
3
|
Nutzt pynput für systemweite Hotkey-Erkennung.
|
|
4
4
|
Unterstützt Hold-to-Record und Toggle-Modus.
|
|
@@ -67,6 +67,9 @@ _ALT_KEYS: set[keyboard.Key] = {keyboard.Key.alt_l, keyboard.Key.alt_r}
|
|
|
67
67
|
# Verhindert Ghost-Toggles durch synthetische Key-Events (z.B. pyautogui Ctrl+C).
|
|
68
68
|
_TOGGLE_DEBOUNCE_S = 0.5
|
|
69
69
|
|
|
70
|
+
# Sentinel fuer "nicht aktualisieren" in update_hotkeys.
|
|
71
|
+
_UNSET = object()
|
|
72
|
+
|
|
70
73
|
|
|
71
74
|
def _resolve_key(key_str: str) -> keyboard.Key | keyboard.KeyCode:
|
|
72
75
|
"""Löst einen Config-String in ein pynput-Key-Objekt auf."""
|
|
@@ -287,32 +290,38 @@ class HotkeyListener:
|
|
|
287
290
|
|
|
288
291
|
def update_hotkeys(
|
|
289
292
|
self,
|
|
290
|
-
hold_hotkey: list[str] | None =
|
|
291
|
-
toggle_hotkey: list[str] | None =
|
|
293
|
+
hold_hotkey: list[str] | None | object = _UNSET,
|
|
294
|
+
toggle_hotkey: list[str] | None | object = _UNSET,
|
|
292
295
|
) -> None:
|
|
293
296
|
"""Aktualisiert die Hotkey-Konfiguration zur Laufzeit."""
|
|
294
297
|
with self._lock:
|
|
295
|
-
if hold_hotkey is not
|
|
298
|
+
if hold_hotkey is not _UNSET:
|
|
296
299
|
self._hold_keys = []
|
|
297
300
|
self._hold_modifier_groups = []
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
301
|
+
if hold_hotkey:
|
|
302
|
+
for key_str in hold_hotkey:
|
|
303
|
+
group = _get_modifier_group(key_str)
|
|
304
|
+
if group:
|
|
305
|
+
self._hold_modifier_groups.append(group)
|
|
306
|
+
self._hold_keys.append(_resolve_key(key_str))
|
|
307
|
+
log.info("Hold-Hotkey aktualisiert: %s", " + ".join(hold_hotkey))
|
|
308
|
+
else:
|
|
309
|
+
log.info("Hold-Hotkey deaktiviert")
|
|
310
|
+
|
|
311
|
+
if toggle_hotkey is not _UNSET:
|
|
306
312
|
self._toggle_keys = []
|
|
307
313
|
self._toggle_modifier_groups = []
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
314
|
+
if toggle_hotkey:
|
|
315
|
+
for key_str in toggle_hotkey:
|
|
316
|
+
group = _get_modifier_group(key_str)
|
|
317
|
+
if group:
|
|
318
|
+
self._toggle_modifier_groups.append(group)
|
|
319
|
+
self._toggle_keys.append(_resolve_key(key_str))
|
|
320
|
+
log.info("Toggle-Hotkey aktualisiert: %s", " + ".join(toggle_hotkey))
|
|
321
|
+
else:
|
|
322
|
+
log.info("Toggle-Hotkey deaktiviert")
|
|
323
|
+
|
|
324
|
+
# State zuruecksetzen
|
|
316
325
|
self._hold_active = False
|
|
317
326
|
self._toggle_combo_held = False
|
|
318
327
|
self._toggle_last_fired = 0.0
|
|
@@ -47,10 +47,11 @@ class AudioRecorder:
|
|
|
47
47
|
"""Callback des InputStream - sammelt Frames während der Aufnahme."""
|
|
48
48
|
if status:
|
|
49
49
|
log.warning("Audio-Callback Status: %s", status)
|
|
50
|
-
|
|
51
|
-
self.
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
with self._lock:
|
|
51
|
+
if self._is_recording:
|
|
52
|
+
self._frames.append(indata.copy())
|
|
53
|
+
# Amplitude berechnen (RMS, normalisiert auf 0-1 fuer int16)
|
|
54
|
+
self._amplitude = float(np.sqrt(np.mean(indata.astype(np.float32) ** 2)) / 32768.0)
|
|
54
55
|
|
|
55
56
|
def start_stream(self) -> None:
|
|
56
57
|
"""Erstellt und startet den persistenten Audio-Stream."""
|
|
@@ -74,13 +75,13 @@ class AudioRecorder:
|
|
|
74
75
|
raise
|
|
75
76
|
|
|
76
77
|
def stop_stream(self) -> None:
|
|
77
|
-
"""Stoppt und
|
|
78
|
+
"""Stoppt und schliesst den persistenten Audio-Stream."""
|
|
78
79
|
if self._stream is not None:
|
|
79
80
|
try:
|
|
80
81
|
self._stream.stop()
|
|
81
82
|
self._stream.close()
|
|
82
83
|
except sd.PortAudioError as e:
|
|
83
|
-
log.warning("Fehler beim
|
|
84
|
+
log.warning("Fehler beim Schliessen des Audio-Streams: %s", e)
|
|
84
85
|
self._stream = None
|
|
85
86
|
log.info("Audio-Stream gestoppt")
|
|
86
87
|
|
|
@@ -110,8 +111,8 @@ class AudioRecorder:
|
|
|
110
111
|
|
|
111
112
|
self._is_recording = False
|
|
112
113
|
duration = time.perf_counter() - self._start_time
|
|
113
|
-
frames = self._frames
|
|
114
|
-
self._frames
|
|
114
|
+
frames = self._frames
|
|
115
|
+
self._frames = []
|
|
115
116
|
self._amplitude = 0.0
|
|
116
117
|
|
|
117
118
|
if not frames:
|