PayPerTranscript 0.2.0__py3-none-any.whl
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/__init__.py +3 -0
- paypertranscript/__main__.py +51 -0
- paypertranscript/assets/icons/app.ico +0 -0
- paypertranscript/assets/icons/app.png +0 -0
- paypertranscript/assets/icons/arrow_down.svg +3 -0
- paypertranscript/assets/sounds/start.wav +0 -0
- paypertranscript/assets/sounds/stop.wav +0 -0
- paypertranscript/assets/styles/dark.qss +388 -0
- paypertranscript/core/__init__.py +0 -0
- paypertranscript/core/audio_manager.py +142 -0
- paypertranscript/core/config.py +360 -0
- paypertranscript/core/cost_tracker.py +87 -0
- paypertranscript/core/hotkey.py +294 -0
- paypertranscript/core/logging.py +65 -0
- paypertranscript/core/paths.py +28 -0
- paypertranscript/core/recorder.py +167 -0
- paypertranscript/core/session_logger.py +138 -0
- paypertranscript/core/text_inserter.py +131 -0
- paypertranscript/core/window_detector.py +58 -0
- paypertranscript/pipeline/__init__.py +0 -0
- paypertranscript/pipeline/transcription.py +361 -0
- paypertranscript/providers/__init__.py +85 -0
- paypertranscript/providers/base.py +78 -0
- paypertranscript/providers/groq_provider.py +182 -0
- paypertranscript/ui/__init__.py +0 -0
- paypertranscript/ui/app.py +370 -0
- paypertranscript/ui/dashboard.py +92 -0
- paypertranscript/ui/overlay.py +396 -0
- paypertranscript/ui/settings.py +550 -0
- paypertranscript/ui/setup_wizard.py +690 -0
- paypertranscript/ui/statistics.py +412 -0
- paypertranscript/ui/tray.py +256 -0
- paypertranscript/ui/window_mapping.py +460 -0
- paypertranscript/ui/word_list.py +183 -0
- paypertranscript-0.2.0.dist-info/METADATA +159 -0
- paypertranscript-0.2.0.dist-info/RECORD +40 -0
- paypertranscript-0.2.0.dist-info/WHEEL +5 -0
- paypertranscript-0.2.0.dist-info/entry_points.txt +2 -0
- paypertranscript-0.2.0.dist-info/licenses/LICENSE +21 -0
- paypertranscript-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Globaler Hotkey-Listener für PayPerTranscript.
|
|
2
|
+
|
|
3
|
+
Nutzt pynput für systemweite Hotkey-Erkennung.
|
|
4
|
+
Unterstützt Hold-to-Record und Toggle-Modus.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import threading
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pynput import keyboard
|
|
12
|
+
|
|
13
|
+
from paypertranscript.core.logging import get_logger
|
|
14
|
+
|
|
15
|
+
log = get_logger("core.hotkey")
|
|
16
|
+
|
|
17
|
+
# Mapping von Config-Strings zu pynput-Keys
|
|
18
|
+
_KEY_MAP: dict[str, keyboard.Key | str] = {
|
|
19
|
+
"ctrl": keyboard.Key.ctrl_l,
|
|
20
|
+
"ctrl_l": keyboard.Key.ctrl_l,
|
|
21
|
+
"ctrl_r": keyboard.Key.ctrl_r,
|
|
22
|
+
"shift": keyboard.Key.shift_l,
|
|
23
|
+
"shift_l": keyboard.Key.shift_l,
|
|
24
|
+
"shift_r": keyboard.Key.shift_r,
|
|
25
|
+
"alt": keyboard.Key.alt_l,
|
|
26
|
+
"alt_l": keyboard.Key.alt_l,
|
|
27
|
+
"alt_r": keyboard.Key.alt_r,
|
|
28
|
+
"cmd": keyboard.Key.cmd,
|
|
29
|
+
"win": keyboard.Key.cmd,
|
|
30
|
+
"cmd_l": keyboard.Key.cmd_l,
|
|
31
|
+
"cmd_r": keyboard.Key.cmd_r,
|
|
32
|
+
"space": keyboard.Key.space,
|
|
33
|
+
"tab": keyboard.Key.tab,
|
|
34
|
+
"caps_lock": keyboard.Key.caps_lock,
|
|
35
|
+
"f1": keyboard.Key.f1,
|
|
36
|
+
"f2": keyboard.Key.f2,
|
|
37
|
+
"f3": keyboard.Key.f3,
|
|
38
|
+
"f4": keyboard.Key.f4,
|
|
39
|
+
"f5": keyboard.Key.f5,
|
|
40
|
+
"f6": keyboard.Key.f6,
|
|
41
|
+
"f7": keyboard.Key.f7,
|
|
42
|
+
"f8": keyboard.Key.f8,
|
|
43
|
+
"f9": keyboard.Key.f9,
|
|
44
|
+
"f10": keyboard.Key.f10,
|
|
45
|
+
"f11": keyboard.Key.f11,
|
|
46
|
+
"f12": keyboard.Key.f12,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Keys die als Modifier gelten (haben links/rechts Varianten)
|
|
50
|
+
_MODIFIER_GROUPS: dict[str, set[keyboard.Key]] = {
|
|
51
|
+
"ctrl": {keyboard.Key.ctrl_l, keyboard.Key.ctrl_r},
|
|
52
|
+
"shift": {keyboard.Key.shift_l, keyboard.Key.shift_r},
|
|
53
|
+
"alt": {keyboard.Key.alt_l, keyboard.Key.alt_r},
|
|
54
|
+
"cmd": {keyboard.Key.cmd, keyboard.Key.cmd_l, keyboard.Key.cmd_r},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Alt-Keys fuer Menu-Bar-Workaround (Windows aktiviert Menueleiste bei bare Alt-Release)
|
|
58
|
+
_ALT_KEYS: set[keyboard.Key] = {keyboard.Key.alt_l, keyboard.Key.alt_r}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _resolve_key(key_str: str) -> keyboard.Key | keyboard.KeyCode:
|
|
62
|
+
"""Löst einen Config-String in ein pynput-Key-Objekt auf."""
|
|
63
|
+
lower = key_str.lower().strip()
|
|
64
|
+
if lower in _KEY_MAP:
|
|
65
|
+
return _KEY_MAP[lower]
|
|
66
|
+
# Einzelner Buchstabe/Zeichen
|
|
67
|
+
if len(lower) == 1:
|
|
68
|
+
return keyboard.KeyCode.from_char(lower)
|
|
69
|
+
raise ValueError(f"Unbekannter Key: '{key_str}'")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_modifier(key: keyboard.Key | keyboard.KeyCode) -> bool:
|
|
73
|
+
"""Prüft ob ein Key ein Modifier ist."""
|
|
74
|
+
for group in _MODIFIER_GROUPS.values():
|
|
75
|
+
if key in group:
|
|
76
|
+
return True
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _get_modifier_group(key_str: str) -> set[keyboard.Key] | None:
|
|
81
|
+
"""Gibt die Modifier-Gruppe für einen Config-String zurück."""
|
|
82
|
+
lower = key_str.lower().strip()
|
|
83
|
+
# Direkte Zuordnung
|
|
84
|
+
if lower in _MODIFIER_GROUPS:
|
|
85
|
+
return _MODIFIER_GROUPS[lower]
|
|
86
|
+
# Auch "win" → "cmd"
|
|
87
|
+
if lower == "win":
|
|
88
|
+
return _MODIFIER_GROUPS["cmd"]
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class HotkeyListener:
|
|
93
|
+
"""Globaler Hotkey-Listener mit Hold-to-Record und Toggle-Modus.
|
|
94
|
+
|
|
95
|
+
Callbacks:
|
|
96
|
+
on_hold_start: Wird aufgerufen wenn Hold-Hotkey gedrückt wird.
|
|
97
|
+
on_hold_stop: Wird aufgerufen wenn Hold-Hotkey losgelassen wird.
|
|
98
|
+
on_toggle: Wird aufgerufen wenn Toggle-Hotkey gedrückt wird.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
hold_hotkey: list[str] | None = None,
|
|
104
|
+
toggle_hotkey: list[str] | None = None,
|
|
105
|
+
on_hold_start: Callable[[], Any] | None = None,
|
|
106
|
+
on_hold_stop: Callable[[], Any] | None = None,
|
|
107
|
+
on_toggle: Callable[[], Any] | None = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
self._on_hold_start = on_hold_start
|
|
110
|
+
self._on_hold_stop = on_hold_stop
|
|
111
|
+
self._on_toggle = on_toggle
|
|
112
|
+
|
|
113
|
+
# Hold-Hotkey parsen
|
|
114
|
+
self._hold_keys: list[keyboard.Key | keyboard.KeyCode] = []
|
|
115
|
+
self._hold_modifier_groups: list[set[keyboard.Key]] = []
|
|
116
|
+
if hold_hotkey:
|
|
117
|
+
for key_str in hold_hotkey:
|
|
118
|
+
group = _get_modifier_group(key_str)
|
|
119
|
+
if group:
|
|
120
|
+
self._hold_modifier_groups.append(group)
|
|
121
|
+
self._hold_keys.append(_resolve_key(key_str))
|
|
122
|
+
log.info("Hold-Hotkey konfiguriert: %s", " + ".join(hold_hotkey))
|
|
123
|
+
|
|
124
|
+
# Toggle-Hotkey parsen
|
|
125
|
+
self._toggle_keys: list[keyboard.Key | keyboard.KeyCode] = []
|
|
126
|
+
self._toggle_modifier_groups: list[set[keyboard.Key]] = []
|
|
127
|
+
if toggle_hotkey:
|
|
128
|
+
for key_str in toggle_hotkey:
|
|
129
|
+
group = _get_modifier_group(key_str)
|
|
130
|
+
if group:
|
|
131
|
+
self._toggle_modifier_groups.append(group)
|
|
132
|
+
self._toggle_keys.append(_resolve_key(key_str))
|
|
133
|
+
log.info("Toggle-Hotkey konfiguriert: %s", " + ".join(toggle_hotkey))
|
|
134
|
+
|
|
135
|
+
# State-Tracking
|
|
136
|
+
self._pressed_keys: set[keyboard.Key | keyboard.KeyCode] = set()
|
|
137
|
+
self._hold_active = False
|
|
138
|
+
self._toggle_combo_held = False
|
|
139
|
+
self._listener: keyboard.Listener | None = None
|
|
140
|
+
self._lock = threading.Lock()
|
|
141
|
+
self._kb_controller: keyboard.Controller | None = None
|
|
142
|
+
|
|
143
|
+
def _normalize_key(self, key: keyboard.Key | keyboard.KeyCode) -> keyboard.Key | keyboard.KeyCode:
|
|
144
|
+
"""Normalisiert einen Key (z.B. ctrl_l/ctrl_r werden nicht zusammengefasst)."""
|
|
145
|
+
return key
|
|
146
|
+
|
|
147
|
+
def _check_combo(
|
|
148
|
+
self,
|
|
149
|
+
target_keys: list[keyboard.Key | keyboard.KeyCode],
|
|
150
|
+
modifier_groups: list[set[keyboard.Key]],
|
|
151
|
+
) -> bool:
|
|
152
|
+
"""Prüft ob eine Tastenkombination aktuell gedrückt ist."""
|
|
153
|
+
if not target_keys:
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
for i, target_key in enumerate(target_keys):
|
|
157
|
+
# Für Modifier: prüfe ob *irgendein* Key aus der Gruppe gedrückt ist
|
|
158
|
+
if i < len(modifier_groups) and modifier_groups[i]:
|
|
159
|
+
if not (modifier_groups[i] & self._pressed_keys):
|
|
160
|
+
return False
|
|
161
|
+
else:
|
|
162
|
+
if target_key not in self._pressed_keys:
|
|
163
|
+
return False
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
def _combo_uses_alt(self, target_keys: list[keyboard.Key | keyboard.KeyCode]) -> bool:
|
|
167
|
+
"""Prueft ob Alt Teil der Hotkey-Kombination ist."""
|
|
168
|
+
for key in target_keys:
|
|
169
|
+
if key in _ALT_KEYS:
|
|
170
|
+
return True
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
def _cancel_alt_menu(self) -> None:
|
|
174
|
+
"""Sendet einen Shift-Tap um Windows-Menueleisten-Aktivierung nach Alt-Release zu verhindern."""
|
|
175
|
+
try:
|
|
176
|
+
if self._kb_controller is None:
|
|
177
|
+
self._kb_controller = keyboard.Controller()
|
|
178
|
+
self._kb_controller.tap(keyboard.Key.shift)
|
|
179
|
+
log.debug("Alt-Menu-Workaround: Shift-Tap gesendet")
|
|
180
|
+
except Exception as e:
|
|
181
|
+
log.debug("Alt-Menu-Workaround fehlgeschlagen: %s", e)
|
|
182
|
+
|
|
183
|
+
def _on_press(self, key: keyboard.Key | keyboard.KeyCode) -> None:
|
|
184
|
+
"""Callback für Key-Press-Events."""
|
|
185
|
+
with self._lock:
|
|
186
|
+
self._pressed_keys.add(key)
|
|
187
|
+
|
|
188
|
+
# Hold-Hotkey prüfen
|
|
189
|
+
if not self._hold_active and self._check_combo(self._hold_keys, self._hold_modifier_groups):
|
|
190
|
+
self._hold_active = True
|
|
191
|
+
log.debug("Hold-Hotkey gedrückt")
|
|
192
|
+
if self._on_hold_start:
|
|
193
|
+
threading.Thread(target=self._on_hold_start, daemon=True).start()
|
|
194
|
+
|
|
195
|
+
# Toggle-Hotkey prüfen
|
|
196
|
+
if self._toggle_keys and self._check_combo(self._toggle_keys, self._toggle_modifier_groups):
|
|
197
|
+
self._toggle_combo_held = True
|
|
198
|
+
log.debug("Toggle-Hotkey gedrückt")
|
|
199
|
+
if self._on_toggle:
|
|
200
|
+
threading.Thread(target=self._on_toggle, daemon=True).start()
|
|
201
|
+
|
|
202
|
+
def _on_release(self, key: keyboard.Key | keyboard.KeyCode) -> None:
|
|
203
|
+
"""Callback für Key-Release-Events."""
|
|
204
|
+
cancel_alt = False
|
|
205
|
+
|
|
206
|
+
with self._lock:
|
|
207
|
+
self._pressed_keys.discard(key)
|
|
208
|
+
|
|
209
|
+
# Hold-Hotkey: Aufnahme stoppen wenn einer der Hold-Keys losgelassen wird
|
|
210
|
+
if self._hold_active:
|
|
211
|
+
if not self._check_combo(self._hold_keys, self._hold_modifier_groups):
|
|
212
|
+
self._hold_active = False
|
|
213
|
+
log.debug("Hold-Hotkey losgelassen")
|
|
214
|
+
if key in _ALT_KEYS and self._combo_uses_alt(self._hold_keys):
|
|
215
|
+
cancel_alt = True
|
|
216
|
+
if self._on_hold_stop:
|
|
217
|
+
threading.Thread(target=self._on_hold_stop, daemon=True).start()
|
|
218
|
+
|
|
219
|
+
# Toggle-Hotkey: Alt-Workaround wenn Combo losgelassen wird
|
|
220
|
+
if self._toggle_combo_held:
|
|
221
|
+
if not self._check_combo(self._toggle_keys, self._toggle_modifier_groups):
|
|
222
|
+
self._toggle_combo_held = False
|
|
223
|
+
if key in _ALT_KEYS and self._combo_uses_alt(self._toggle_keys):
|
|
224
|
+
cancel_alt = True
|
|
225
|
+
|
|
226
|
+
# Alt-Menu-Workaround AUSSERHALB des Locks ausfuehren
|
|
227
|
+
if cancel_alt:
|
|
228
|
+
self._cancel_alt_menu()
|
|
229
|
+
|
|
230
|
+
def start(self) -> None:
|
|
231
|
+
"""Startet den globalen Hotkey-Listener."""
|
|
232
|
+
if self._listener is not None:
|
|
233
|
+
log.warning("Listener läuft bereits")
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
self._listener = keyboard.Listener(
|
|
237
|
+
on_press=self._on_press,
|
|
238
|
+
on_release=self._on_release,
|
|
239
|
+
)
|
|
240
|
+
self._listener.daemon = True
|
|
241
|
+
self._listener.start()
|
|
242
|
+
log.info("Hotkey-Listener gestartet")
|
|
243
|
+
|
|
244
|
+
def stop(self) -> None:
|
|
245
|
+
"""Stoppt den globalen Hotkey-Listener."""
|
|
246
|
+
if self._listener is not None:
|
|
247
|
+
self._listener.stop()
|
|
248
|
+
self._listener = None
|
|
249
|
+
self._pressed_keys.clear()
|
|
250
|
+
self._hold_active = False
|
|
251
|
+
self._toggle_combo_held = False
|
|
252
|
+
log.info("Hotkey-Listener gestoppt")
|
|
253
|
+
|
|
254
|
+
def update_hotkeys(
|
|
255
|
+
self,
|
|
256
|
+
hold_hotkey: list[str] | None = None,
|
|
257
|
+
toggle_hotkey: list[str] | None = None,
|
|
258
|
+
) -> None:
|
|
259
|
+
"""Aktualisiert die Hotkey-Konfiguration zur Laufzeit."""
|
|
260
|
+
with self._lock:
|
|
261
|
+
if hold_hotkey is not None:
|
|
262
|
+
self._hold_keys = []
|
|
263
|
+
self._hold_modifier_groups = []
|
|
264
|
+
for key_str in hold_hotkey:
|
|
265
|
+
group = _get_modifier_group(key_str)
|
|
266
|
+
if group:
|
|
267
|
+
self._hold_modifier_groups.append(group)
|
|
268
|
+
self._hold_keys.append(_resolve_key(key_str))
|
|
269
|
+
log.info("Hold-Hotkey aktualisiert: %s", " + ".join(hold_hotkey))
|
|
270
|
+
|
|
271
|
+
if toggle_hotkey is not None:
|
|
272
|
+
self._toggle_keys = []
|
|
273
|
+
self._toggle_modifier_groups = []
|
|
274
|
+
for key_str in toggle_hotkey:
|
|
275
|
+
group = _get_modifier_group(key_str)
|
|
276
|
+
if group:
|
|
277
|
+
self._toggle_modifier_groups.append(group)
|
|
278
|
+
self._toggle_keys.append(_resolve_key(key_str))
|
|
279
|
+
log.info("Toggle-Hotkey aktualisiert: %s", " + ".join(toggle_hotkey))
|
|
280
|
+
|
|
281
|
+
# State zurücksetzen
|
|
282
|
+
self._hold_active = False
|
|
283
|
+
self._toggle_combo_held = False
|
|
284
|
+
self._pressed_keys.clear()
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def is_hold_active(self) -> bool:
|
|
288
|
+
"""Gibt zurück, ob der Hold-Hotkey gerade gedrückt wird."""
|
|
289
|
+
return self._hold_active
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def is_running(self) -> bool:
|
|
293
|
+
"""Gibt zurück, ob der Listener läuft."""
|
|
294
|
+
return self._listener is not None and self._listener.is_alive()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Logging-System für PayPerTranscript.
|
|
2
|
+
|
|
3
|
+
Rotierende Log-Dateien in %APPDATA%\\PayPerTranscript\\logs\\,
|
|
4
|
+
RotatingFileHandler mit max. 5MB pro Datei, 3 Backups.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import sys
|
|
9
|
+
from logging.handlers import RotatingFileHandler
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
APP_NAME = "PayPerTranscript"
|
|
13
|
+
APPDATA_DIR = Path.home() / "AppData" / "Roaming" / APP_NAME
|
|
14
|
+
LOG_DIR = APPDATA_DIR / "logs"
|
|
15
|
+
|
|
16
|
+
LOG_FORMAT = "[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s"
|
|
17
|
+
LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
18
|
+
LOG_FILE = "paypertranscript.log"
|
|
19
|
+
MAX_BYTES = 5 * 1024 * 1024 # 5 MB
|
|
20
|
+
BACKUP_COUNT = 3
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def setup_logging(debug: bool = False) -> None:
|
|
24
|
+
"""Initialisiert das Logging-System.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
debug: Wenn True, wird DEBUG-Level geloggt, sonst INFO.
|
|
28
|
+
"""
|
|
29
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
|
|
31
|
+
level = logging.DEBUG if debug else logging.INFO
|
|
32
|
+
formatter = logging.Formatter(LOG_FORMAT, datefmt=LOG_DATE_FORMAT)
|
|
33
|
+
|
|
34
|
+
# Root-Logger konfigurieren
|
|
35
|
+
root_logger = logging.getLogger()
|
|
36
|
+
root_logger.setLevel(level)
|
|
37
|
+
|
|
38
|
+
# Vorhandene Handler entfernen (bei erneutem Aufruf)
|
|
39
|
+
root_logger.handlers.clear()
|
|
40
|
+
|
|
41
|
+
# Datei-Handler (rotierend)
|
|
42
|
+
file_handler = RotatingFileHandler(
|
|
43
|
+
LOG_DIR / LOG_FILE,
|
|
44
|
+
maxBytes=MAX_BYTES,
|
|
45
|
+
backupCount=BACKUP_COUNT,
|
|
46
|
+
encoding="utf-8",
|
|
47
|
+
)
|
|
48
|
+
file_handler.setLevel(level)
|
|
49
|
+
file_handler.setFormatter(formatter)
|
|
50
|
+
root_logger.addHandler(file_handler)
|
|
51
|
+
|
|
52
|
+
# Console-Handler (stderr)
|
|
53
|
+
console_handler = logging.StreamHandler(sys.stderr)
|
|
54
|
+
console_handler.setLevel(level)
|
|
55
|
+
console_handler.setFormatter(formatter)
|
|
56
|
+
root_logger.addHandler(console_handler)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_logger(name: str) -> logging.Logger:
|
|
60
|
+
"""Gibt einen benannten Logger zurück.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
name: Modul-Name (z.B. 'core.config', 'providers.groq').
|
|
64
|
+
"""
|
|
65
|
+
return logging.getLogger(f"{APP_NAME}.{name}")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Pfad-Aufloesung fuer Assets.
|
|
2
|
+
|
|
3
|
+
Assets liegen im paypertranscript-Package-Verzeichnis unter assets/.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_PACKAGE_DIR = Path(__file__).resolve().parent.parent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_assets_dir() -> Path:
|
|
12
|
+
"""Gibt den Pfad zum assets/ Verzeichnis zurueck."""
|
|
13
|
+
return _PACKAGE_DIR / "assets"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_styles_dir() -> Path:
|
|
17
|
+
"""Gibt den Pfad zum assets/styles/ Verzeichnis zurueck."""
|
|
18
|
+
return get_assets_dir() / "styles"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_sounds_dir() -> Path:
|
|
22
|
+
"""Gibt den Pfad zum assets/sounds/ Verzeichnis zurueck."""
|
|
23
|
+
return get_assets_dir() / "sounds"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_icons_dir() -> Path:
|
|
27
|
+
"""Gibt den Pfad zum assets/icons/ Verzeichnis zurueck."""
|
|
28
|
+
return get_assets_dir() / "icons"
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Audio-Aufnahme für PayPerTranscript.
|
|
2
|
+
|
|
3
|
+
Callback-basiertes Recording mit sounddevice (16kHz, Mono, int16).
|
|
4
|
+
Persistenter Stream — wird einmal erstellt und wiederverwendet.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import sounddevice as sd
|
|
13
|
+
import soundfile as sf
|
|
14
|
+
|
|
15
|
+
from paypertranscript.core.logging import get_logger
|
|
16
|
+
|
|
17
|
+
log = get_logger("core.recorder")
|
|
18
|
+
|
|
19
|
+
SAMPLE_RATE = 16000
|
|
20
|
+
CHANNELS = 1
|
|
21
|
+
DTYPE = "int16"
|
|
22
|
+
BLOCKSIZE = 1024 # Frames pro Callback (~64ms bei 16kHz)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AudioRecorder:
|
|
26
|
+
"""Nimmt Audio auf via sounddevice InputStream.
|
|
27
|
+
|
|
28
|
+
Nutzt einen persistenten Stream mit Callback. Frames werden in einer
|
|
29
|
+
Liste gesammelt und bei Stop mit numpy.concatenate() zusammengeführt.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self._frames: list[np.ndarray] = []
|
|
34
|
+
self._is_recording = False
|
|
35
|
+
self._lock = threading.Lock()
|
|
36
|
+
self._stream: sd.InputStream | None = None
|
|
37
|
+
self._start_time: float = 0.0
|
|
38
|
+
self._amplitude: float = 0.0 # Aktuelle Amplitude für Visualizer
|
|
39
|
+
|
|
40
|
+
def _audio_callback(
|
|
41
|
+
self,
|
|
42
|
+
indata: np.ndarray,
|
|
43
|
+
frames: int,
|
|
44
|
+
time_info: object,
|
|
45
|
+
status: sd.CallbackFlags,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Callback des InputStream — sammelt Frames während der Aufnahme."""
|
|
48
|
+
if status:
|
|
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)
|
|
54
|
+
|
|
55
|
+
def start_stream(self) -> None:
|
|
56
|
+
"""Erstellt und startet den persistenten Audio-Stream."""
|
|
57
|
+
if self._stream is not None:
|
|
58
|
+
log.debug("Audio-Stream existiert bereits")
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
self._stream = sd.InputStream(
|
|
63
|
+
samplerate=SAMPLE_RATE,
|
|
64
|
+
channels=CHANNELS,
|
|
65
|
+
dtype=DTYPE,
|
|
66
|
+
blocksize=BLOCKSIZE,
|
|
67
|
+
callback=self._audio_callback,
|
|
68
|
+
)
|
|
69
|
+
self._stream.start()
|
|
70
|
+
log.info("Audio-Stream gestartet (%.0f Hz, %d Kanal, %s)", SAMPLE_RATE, CHANNELS, DTYPE)
|
|
71
|
+
except sd.PortAudioError as e:
|
|
72
|
+
log.error("Audio-Stream konnte nicht gestartet werden: %s", e)
|
|
73
|
+
self._stream = None
|
|
74
|
+
raise
|
|
75
|
+
|
|
76
|
+
def stop_stream(self) -> None:
|
|
77
|
+
"""Stoppt und schließt den persistenten Audio-Stream."""
|
|
78
|
+
if self._stream is not None:
|
|
79
|
+
try:
|
|
80
|
+
self._stream.stop()
|
|
81
|
+
self._stream.close()
|
|
82
|
+
except sd.PortAudioError as e:
|
|
83
|
+
log.warning("Fehler beim Schließen des Audio-Streams: %s", e)
|
|
84
|
+
self._stream = None
|
|
85
|
+
log.info("Audio-Stream gestoppt")
|
|
86
|
+
|
|
87
|
+
def start_recording(self) -> None:
|
|
88
|
+
"""Startet die Aufnahme (sammelt ab jetzt Frames)."""
|
|
89
|
+
with self._lock:
|
|
90
|
+
if self._is_recording:
|
|
91
|
+
log.warning("Aufnahme läuft bereits")
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
self._frames.clear()
|
|
95
|
+
self._amplitude = 0.0
|
|
96
|
+
self._start_time = time.perf_counter()
|
|
97
|
+
self._is_recording = True
|
|
98
|
+
log.info("Aufnahme gestartet")
|
|
99
|
+
|
|
100
|
+
def stop_recording(self) -> np.ndarray | None:
|
|
101
|
+
"""Stoppt die Aufnahme und gibt die aufgenommenen Frames zurück.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
NumPy-Array mit Audio-Daten (int16) oder None wenn keine Daten.
|
|
105
|
+
"""
|
|
106
|
+
with self._lock:
|
|
107
|
+
if not self._is_recording:
|
|
108
|
+
log.warning("Keine aktive Aufnahme zum Stoppen")
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
self._is_recording = False
|
|
112
|
+
duration = time.perf_counter() - self._start_time
|
|
113
|
+
frames = self._frames.copy()
|
|
114
|
+
self._frames.clear()
|
|
115
|
+
self._amplitude = 0.0
|
|
116
|
+
|
|
117
|
+
if not frames:
|
|
118
|
+
log.warning("Aufnahme gestoppt — keine Audio-Daten aufgenommen")
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
audio = np.concatenate(frames)
|
|
122
|
+
actual_duration = len(audio) / SAMPLE_RATE
|
|
123
|
+
log.info(
|
|
124
|
+
"Aufnahme gestoppt — Dauer: %.2fs (%.2fs wall), %d Samples",
|
|
125
|
+
actual_duration,
|
|
126
|
+
duration,
|
|
127
|
+
len(audio),
|
|
128
|
+
)
|
|
129
|
+
return audio
|
|
130
|
+
|
|
131
|
+
def save_wav(self, audio: np.ndarray, path: Path) -> Path:
|
|
132
|
+
"""Speichert Audio-Daten als WAV-Datei.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
audio: NumPy-Array mit Audio-Daten (int16).
|
|
136
|
+
path: Ziel-Pfad für die WAV-Datei.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Der Pfad zur gespeicherten Datei.
|
|
140
|
+
"""
|
|
141
|
+
sf.write(str(path), audio, SAMPLE_RATE, subtype="PCM_16")
|
|
142
|
+
file_size = path.stat().st_size
|
|
143
|
+
duration = len(audio) / SAMPLE_RATE
|
|
144
|
+
log.info(
|
|
145
|
+
"WAV gespeichert: %s (%.2fs, %.1f KB)",
|
|
146
|
+
path.name,
|
|
147
|
+
duration,
|
|
148
|
+
file_size / 1024,
|
|
149
|
+
)
|
|
150
|
+
return path
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def is_recording(self) -> bool:
|
|
154
|
+
"""Gibt zurück, ob gerade aufgenommen wird."""
|
|
155
|
+
return self._is_recording
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def amplitude(self) -> float:
|
|
159
|
+
"""Aktuelle Audio-Amplitude (0.0–1.0) für Visualizer."""
|
|
160
|
+
return self._amplitude
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def recording_duration(self) -> float:
|
|
164
|
+
"""Aktuelle Aufnahmedauer in Sekunden (0 wenn nicht aufgenommen wird)."""
|
|
165
|
+
if self._is_recording:
|
|
166
|
+
return time.perf_counter() - self._start_time
|
|
167
|
+
return 0.0
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Session-Logging fuer PayPerTranscript.
|
|
2
|
+
|
|
3
|
+
Thread-sichere Speicherung von Transkriptions-Sessions in tracking.json.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import threading
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from paypertranscript.core.config import TRACKING_FILE
|
|
13
|
+
from paypertranscript.core.logging import get_logger
|
|
14
|
+
|
|
15
|
+
log = get_logger("core.session_logger")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _empty_totals() -> dict[str, Any]:
|
|
19
|
+
"""Leere Totals-Struktur."""
|
|
20
|
+
return {
|
|
21
|
+
"session_count": 0,
|
|
22
|
+
"total_audio_seconds": 0.0,
|
|
23
|
+
"total_billed_seconds": 0.0,
|
|
24
|
+
"total_stt_cost_usd": 0.0,
|
|
25
|
+
"total_llm_cost_usd": 0.0,
|
|
26
|
+
"total_cost_usd": 0.0,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SessionLogger:
|
|
31
|
+
"""Thread-sicherer Session-Logger fuer tracking.json."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, tracking_file: Path = TRACKING_FILE) -> None:
|
|
34
|
+
self._file = tracking_file
|
|
35
|
+
self._lock = threading.Lock()
|
|
36
|
+
log.info("SessionLogger initialisiert: %s", self._file)
|
|
37
|
+
|
|
38
|
+
def _read_tracking(self) -> dict[str, Any]:
|
|
39
|
+
"""Liest tracking.json (muss unter Lock aufgerufen werden)."""
|
|
40
|
+
if self._file.exists():
|
|
41
|
+
try:
|
|
42
|
+
raw = json.loads(self._file.read_text(encoding="utf-8"))
|
|
43
|
+
if isinstance(raw, dict):
|
|
44
|
+
return raw
|
|
45
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
46
|
+
log.warning("tracking.json konnte nicht gelesen werden: %s", e)
|
|
47
|
+
return {"sessions": [], "totals": _empty_totals()}
|
|
48
|
+
|
|
49
|
+
def _write_tracking(self, data: dict[str, Any]) -> None:
|
|
50
|
+
"""Schreibt tracking.json (muss unter Lock aufgerufen werden)."""
|
|
51
|
+
self._file.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
try:
|
|
53
|
+
self._file.write_text(
|
|
54
|
+
json.dumps(data, indent=2, ensure_ascii=False),
|
|
55
|
+
encoding="utf-8",
|
|
56
|
+
)
|
|
57
|
+
except OSError as e:
|
|
58
|
+
log.error("tracking.json konnte nicht geschrieben werden: %s", e)
|
|
59
|
+
|
|
60
|
+
def log_session(self, session: dict[str, Any]) -> None:
|
|
61
|
+
"""Loggt eine abgeschlossene Transkriptions-Session.
|
|
62
|
+
|
|
63
|
+
Thread-safe. Liest tracking.json, haengt Session an, aktualisiert Totals.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
session: Dict mit Session-Daten (Felder aus PRD 5.7).
|
|
67
|
+
"""
|
|
68
|
+
with self._lock:
|
|
69
|
+
tracking = self._read_tracking()
|
|
70
|
+
|
|
71
|
+
tracking.setdefault("sessions", []).append(session)
|
|
72
|
+
|
|
73
|
+
totals = tracking.setdefault("totals", _empty_totals())
|
|
74
|
+
totals["session_count"] = totals.get("session_count", 0) + 1
|
|
75
|
+
totals["total_audio_seconds"] = (
|
|
76
|
+
totals.get("total_audio_seconds", 0.0)
|
|
77
|
+
+ session.get("audio_duration_seconds", 0.0)
|
|
78
|
+
)
|
|
79
|
+
totals["total_billed_seconds"] = (
|
|
80
|
+
totals.get("total_billed_seconds", 0.0)
|
|
81
|
+
+ session.get("billed_seconds", 0.0)
|
|
82
|
+
)
|
|
83
|
+
totals["total_stt_cost_usd"] = (
|
|
84
|
+
totals.get("total_stt_cost_usd", 0.0)
|
|
85
|
+
+ session.get("stt_cost_usd", 0.0)
|
|
86
|
+
)
|
|
87
|
+
totals["total_llm_cost_usd"] = (
|
|
88
|
+
totals.get("total_llm_cost_usd", 0.0)
|
|
89
|
+
+ session.get("llm_cost_usd", 0.0)
|
|
90
|
+
)
|
|
91
|
+
totals["total_cost_usd"] = (
|
|
92
|
+
totals.get("total_cost_usd", 0.0)
|
|
93
|
+
+ session.get("total_cost_usd", 0.0)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
self._write_tracking(tracking)
|
|
97
|
+
|
|
98
|
+
log.info(
|
|
99
|
+
"Session geloggt: %.1fs Audio, $%.6f total",
|
|
100
|
+
session.get("audio_duration_seconds", 0),
|
|
101
|
+
session.get("total_cost_usd", 0),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def get_tracking_data(self) -> dict[str, Any]:
|
|
105
|
+
"""Gibt alle Tracking-Daten zurueck (thread-safe read)."""
|
|
106
|
+
with self._lock:
|
|
107
|
+
return self._read_tracking()
|
|
108
|
+
|
|
109
|
+
def get_sessions(self, days: int | None = None) -> list[dict[str, Any]]:
|
|
110
|
+
"""Gibt Sessions zurueck, optional gefiltert auf letzte N Tage.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
days: Wenn angegeben, nur Sessions der letzten N Tage.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Liste von Session-Dicts.
|
|
117
|
+
"""
|
|
118
|
+
data = self.get_tracking_data()
|
|
119
|
+
sessions = data.get("sessions", [])
|
|
120
|
+
|
|
121
|
+
if days is not None:
|
|
122
|
+
cutoff = datetime.now(timezone.utc).timestamp() - (days * 86400)
|
|
123
|
+
filtered = []
|
|
124
|
+
for s in sessions:
|
|
125
|
+
try:
|
|
126
|
+
ts = datetime.fromisoformat(s["timestamp"]).timestamp()
|
|
127
|
+
if ts >= cutoff:
|
|
128
|
+
filtered.append(s)
|
|
129
|
+
except (KeyError, ValueError):
|
|
130
|
+
pass
|
|
131
|
+
return filtered
|
|
132
|
+
|
|
133
|
+
return sessions
|
|
134
|
+
|
|
135
|
+
def get_totals(self) -> dict[str, Any]:
|
|
136
|
+
"""Gibt aggregierte Totals zurueck."""
|
|
137
|
+
data = self.get_tracking_data()
|
|
138
|
+
return data.get("totals", _empty_totals())
|