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.
Files changed (40) hide show
  1. paypertranscript/__init__.py +3 -0
  2. paypertranscript/__main__.py +51 -0
  3. paypertranscript/assets/icons/app.ico +0 -0
  4. paypertranscript/assets/icons/app.png +0 -0
  5. paypertranscript/assets/icons/arrow_down.svg +3 -0
  6. paypertranscript/assets/sounds/start.wav +0 -0
  7. paypertranscript/assets/sounds/stop.wav +0 -0
  8. paypertranscript/assets/styles/dark.qss +388 -0
  9. paypertranscript/core/__init__.py +0 -0
  10. paypertranscript/core/audio_manager.py +142 -0
  11. paypertranscript/core/config.py +360 -0
  12. paypertranscript/core/cost_tracker.py +87 -0
  13. paypertranscript/core/hotkey.py +294 -0
  14. paypertranscript/core/logging.py +65 -0
  15. paypertranscript/core/paths.py +28 -0
  16. paypertranscript/core/recorder.py +167 -0
  17. paypertranscript/core/session_logger.py +138 -0
  18. paypertranscript/core/text_inserter.py +131 -0
  19. paypertranscript/core/window_detector.py +58 -0
  20. paypertranscript/pipeline/__init__.py +0 -0
  21. paypertranscript/pipeline/transcription.py +361 -0
  22. paypertranscript/providers/__init__.py +85 -0
  23. paypertranscript/providers/base.py +78 -0
  24. paypertranscript/providers/groq_provider.py +182 -0
  25. paypertranscript/ui/__init__.py +0 -0
  26. paypertranscript/ui/app.py +370 -0
  27. paypertranscript/ui/dashboard.py +92 -0
  28. paypertranscript/ui/overlay.py +396 -0
  29. paypertranscript/ui/settings.py +550 -0
  30. paypertranscript/ui/setup_wizard.py +690 -0
  31. paypertranscript/ui/statistics.py +412 -0
  32. paypertranscript/ui/tray.py +256 -0
  33. paypertranscript/ui/window_mapping.py +460 -0
  34. paypertranscript/ui/word_list.py +183 -0
  35. paypertranscript-0.2.0.dist-info/METADATA +159 -0
  36. paypertranscript-0.2.0.dist-info/RECORD +40 -0
  37. paypertranscript-0.2.0.dist-info/WHEEL +5 -0
  38. paypertranscript-0.2.0.dist-info/entry_points.txt +2 -0
  39. paypertranscript-0.2.0.dist-info/licenses/LICENSE +21 -0
  40. 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())