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.
Files changed (69) hide show
  1. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/PKG-INFO +2 -1
  2. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/PayPerTranscript.egg-info/PKG-INFO +2 -1
  3. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/PayPerTranscript.egg-info/SOURCES.txt +13 -1
  4. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/PayPerTranscript.egg-info/requires.txt +1 -0
  5. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/__init__.py +1 -1
  6. paypertranscript-0.3.2/paypertranscript/core/atomic_io.py +29 -0
  7. paypertranscript-0.3.2/paypertranscript/core/clipboard_manager.py +263 -0
  8. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/config.py +10 -8
  9. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/context_detector.py +22 -27
  10. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/hotkey.py +29 -20
  11. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/recorder.py +9 -8
  12. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/session_logger.py +4 -2
  13. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/text_inserter.py +49 -40
  14. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/updater.py +7 -5
  15. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/pipeline/transcription.py +10 -8
  16. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/providers/groq_provider.py +2 -2
  17. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/app.py +27 -6
  18. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/main_window.py +9 -5
  19. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/pages/home_page.py +2 -2
  20. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/pages/settings_page.py +9 -2
  21. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/tray.py +3 -0
  22. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/pyproject.toml +2 -1
  23. paypertranscript-0.3.2/tests/test_atomic_and_session_logger.py +104 -0
  24. paypertranscript-0.3.2/tests/test_clipboard_integration.py +73 -0
  25. paypertranscript-0.3.2/tests/test_clipboard_manager_formats.py +60 -0
  26. paypertranscript-0.3.2/tests/test_groq_provider_logging.py +42 -0
  27. paypertranscript-0.3.2/tests/test_home_page_config_key.py +94 -0
  28. paypertranscript-0.3.2/tests/test_hotkey_settings_and_timer.py +112 -0
  29. paypertranscript-0.3.2/tests/test_pipeline_privacy_and_transcripts.py +89 -0
  30. paypertranscript-0.3.2/tests/test_recorder_thread_safety.py +39 -0
  31. paypertranscript-0.3.2/tests/test_release_script.py +48 -0
  32. paypertranscript-0.3.2/tests/test_updater_version.py +14 -0
  33. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/LICENSE +0 -0
  34. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/PayPerTranscript.egg-info/dependency_links.txt +0 -0
  35. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/PayPerTranscript.egg-info/entry_points.txt +0 -0
  36. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/PayPerTranscript.egg-info/top_level.txt +0 -0
  37. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/README.md +0 -0
  38. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/__main__.py +0 -0
  39. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/assets/icons/app.ico +0 -0
  40. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/assets/icons/app.png +0 -0
  41. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/assets/icons/app_big.png +0 -0
  42. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/assets/icons/arrow_down.svg +0 -0
  43. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/assets/icons/tray.png +0 -0
  44. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/assets/icons/tray_green.png +0 -0
  45. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/assets/icons/tray_orange.png +0 -0
  46. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/assets/sounds/start.wav +0 -0
  47. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/assets/sounds/stop.wav +0 -0
  48. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/assets/styles/dark.qss +0 -0
  49. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/__init__.py +0 -0
  50. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/audio_manager.py +0 -0
  51. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/cost_tracker.py +0 -0
  52. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/logging.py +0 -0
  53. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/paths.py +0 -0
  54. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/core/window_detector.py +0 -0
  55. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/pipeline/__init__.py +0 -0
  56. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/providers/__init__.py +0 -0
  57. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/providers/base.py +0 -0
  58. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/__init__.py +0 -0
  59. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/animated.py +0 -0
  60. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/constants.py +0 -0
  61. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/overlay.py +0 -0
  62. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/pages/__init__.py +0 -0
  63. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/pages/statistics_page.py +0 -0
  64. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/pages/window_mapping_page.py +0 -0
  65. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/pages/word_list_page.py +0 -0
  66. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/setup_wizard.py +0 -0
  67. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/sidebar.py +0 -0
  68. {paypertranscript-0.3.1 → paypertranscript-0.3.2}/paypertranscript/ui/widgets.py +0 -0
  69. {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.1
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.1
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
@@ -9,6 +9,7 @@ pyperclip
9
9
  pyautogui
10
10
  keyring
11
11
  soundfile
12
+ packaging
12
13
 
13
14
  [dev]
14
15
  build
@@ -1,3 +1,3 @@
1
1
  """PayPerTranscript - Voice-to-Text mit Pay-per-Use Pricing."""
2
2
 
3
- __version__ = "0.3.1"
3
+ __version__ = "0.3.2"
@@ -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 Default einsetzen
168
+ # Fehlender Wert -> Default einsetzen
168
169
  node[key] = copy.deepcopy(default_node[key])
169
- log.warning("Config: Fehlender Wert '%s' Default verwendet", path)
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) Default verwendet",
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 Defaults verwendet")
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 Defaults verwendet", e)
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 Defaults werden verwendet")
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
- CONFIG_FILE.write_text(
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 außen geändert)
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 Sentinel setzen Ctrl+C Clipboard lesen wiederherstellen
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 wirft nie Exceptions.
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
- try:
112
- original_clipboard = pyperclip.paste()
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
- try:
119
- pyperclip.copy(_SENTINEL)
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 Ctrl+C wuerde als Ctrl+Alt+C ankommen)
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
- try:
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 nichts war markiert
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 returned sofort.
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 trotzdem weitermachen
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(content: str) -> None:
213
+ def _restore_clipboard(snapshot: ClipboardSnapshot) -> None:
218
214
  """Stellt den Clipboard-Inhalt wieder her (best-effort)."""
219
- try:
220
- pyperclip.copy(content)
221
- except Exception as e:
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
- """Globaler Hotkey-Listener für PayPerTranscript.
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 = None,
291
- toggle_hotkey: list[str] | None = 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 None:
298
+ if hold_hotkey is not _UNSET:
296
299
  self._hold_keys = []
297
300
  self._hold_modifier_groups = []
298
- for key_str in hold_hotkey:
299
- group = _get_modifier_group(key_str)
300
- if group:
301
- self._hold_modifier_groups.append(group)
302
- self._hold_keys.append(_resolve_key(key_str))
303
- log.info("Hold-Hotkey aktualisiert: %s", " + ".join(hold_hotkey))
304
-
305
- if toggle_hotkey is not None:
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
- for key_str in toggle_hotkey:
309
- group = _get_modifier_group(key_str)
310
- if group:
311
- self._toggle_modifier_groups.append(group)
312
- self._toggle_keys.append(_resolve_key(key_str))
313
- log.info("Toggle-Hotkey aktualisiert: %s", " + ".join(toggle_hotkey))
314
-
315
- # State zurücksetzen
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
- if self._is_recording:
51
- self._frames.append(indata.copy())
52
- # Amplitude berechnen (RMS, normalisiert auf 0-1 für int16)
53
- self._amplitude = float(np.sqrt(np.mean(indata.astype(np.float32) ** 2)) / 32768.0)
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 schließt den persistenten Audio-Stream."""
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 Schließen des Audio-Streams: %s", e)
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.copy()
114
- self._frames.clear()
114
+ frames = self._frames
115
+ self._frames = []
115
116
  self._amplitude = 0.0
116
117
 
117
118
  if not frames: