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,360 @@
1
+ """Konfigurationsmanagement für PayPerTranscript.
2
+
3
+ JSON-basiert, Merge-on-Write, Schema-Validierung mit Fallback auf Defaults.
4
+ Alle Laufzeit-Daten liegen unter %APPDATA%\\PayPerTranscript\\.
5
+ """
6
+
7
+ import copy
8
+ import json
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from paypertranscript.core.logging import APPDATA_DIR, get_logger
15
+
16
+ log = get_logger("core.config")
17
+
18
+ CONFIG_FILE = APPDATA_DIR / "config.json"
19
+ AUDIO_DIR = APPDATA_DIR / "audio"
20
+ TRACKING_FILE = APPDATA_DIR / "tracking.json"
21
+
22
+ DEFAULT_CONFIG: dict[str, Any] = {
23
+ "general": {
24
+ "language": "de",
25
+ "autostart": False,
26
+ "sound_enabled": False,
27
+ "hold_hotkey": ["ctrl", "cmd"],
28
+ "toggle_hotkey": None,
29
+ "streaming_typing": False,
30
+ "overlay_position": "mouse_cursor",
31
+ },
32
+ "api": {
33
+ "provider": "groq",
34
+ "stt_model": "whisper-large-v3-turbo",
35
+ "llm_model": "openai/gpt-oss-20b",
36
+ },
37
+ "words": {
38
+ "misspelled_words": [],
39
+ },
40
+ "formatting": {
41
+ "window_mappings": {
42
+ "WhatsApp.Root.exe": "casual",
43
+ "Telegram.exe": "casual",
44
+ "Discord.exe": "casual",
45
+ "Outlook": "professional",
46
+ },
47
+ "categories": {
48
+ "casual": {
49
+ "name": "Persönlich",
50
+ "prompt": (
51
+ "Formatiere den folgenden transkribierten Text als lockere "
52
+ "Chat-Nachricht. Alles kleingeschrieben, minimale Interpunktion, "
53
+ "Kommas zur Trennung von Gedanken. Kein Punkt am Ende. "
54
+ "Gib NUR den formatierten Text aus, keine Erklärungen."
55
+ ),
56
+ },
57
+ "professional": {
58
+ "name": "Professionell",
59
+ "prompt": (
60
+ "Formatiere den folgenden transkribierten Text als professionelle "
61
+ "Nachricht. Korrekte Groß-/Kleinschreibung, saubere Interpunktion, "
62
+ "entferne Füllwörter und Wiederholungen. Sachlicher Stil, kurze "
63
+ "Absätze. Gib NUR den formatierten Text aus, keine Erklärungen."
64
+ ),
65
+ },
66
+ },
67
+ },
68
+ "data": {
69
+ "audio_retention_hours": 24,
70
+ "save_transcripts": False,
71
+ },
72
+ }
73
+
74
+ # Schema: Erlaubte Typen pro Pfad für Validierung
75
+ _SCHEMA: dict[str, type | tuple[type, ...]] = {
76
+ "general.language": str,
77
+ "general.autostart": bool,
78
+ "general.sound_enabled": bool,
79
+ "general.hold_hotkey": list,
80
+ "general.toggle_hotkey": (list, type(None)),
81
+ "general.streaming_typing": bool,
82
+ "general.overlay_position": str,
83
+ "api.provider": str,
84
+ "api.stt_model": str,
85
+ "api.llm_model": str,
86
+ "words.misspelled_words": list,
87
+ "formatting.window_mappings": dict,
88
+ "formatting.categories": dict,
89
+ "data.audio_retention_hours": (int, float),
90
+ "data.save_transcripts": bool,
91
+ }
92
+
93
+
94
+ def _deep_merge(base: dict, override: dict) -> dict:
95
+ """Merge override in base (rekursiv). Gibt neues Dict zurück."""
96
+ result = copy.deepcopy(base)
97
+ for key, value in override.items():
98
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
99
+ result[key] = _deep_merge(result[key], value)
100
+ else:
101
+ result[key] = copy.deepcopy(value)
102
+ return result
103
+
104
+
105
+ def _validate_config(config: dict) -> dict:
106
+ """Validiert Config gegen Schema. Ungültige Werte werden durch Defaults ersetzt."""
107
+ validated = copy.deepcopy(config)
108
+
109
+ for path, expected_type in _SCHEMA.items():
110
+ parts = path.split(".")
111
+ # Wert aus Config holen
112
+ node = validated
113
+ default_node = DEFAULT_CONFIG
114
+ valid = True
115
+ for part in parts[:-1]:
116
+ if isinstance(node, dict) and part in node:
117
+ node = node[part]
118
+ default_node = default_node[part]
119
+ else:
120
+ valid = False
121
+ break
122
+
123
+ if not valid:
124
+ continue
125
+
126
+ key = parts[-1]
127
+ if key not in node:
128
+ # Fehlender Wert → Default einsetzen
129
+ node[key] = copy.deepcopy(default_node[key])
130
+ log.warning("Config: Fehlender Wert '%s' → Default verwendet", path)
131
+ elif not isinstance(node[key], expected_type):
132
+ old_val = node[key]
133
+ node[key] = copy.deepcopy(default_node[key])
134
+ log.warning(
135
+ "Config: Ungültiger Typ für '%s' (%s statt %s) → Default verwendet",
136
+ path,
137
+ type(old_val).__name__,
138
+ expected_type,
139
+ )
140
+
141
+ return validated
142
+
143
+
144
+ def _ensure_dirs() -> None:
145
+ """Erstellt alle nötigen Verzeichnisse."""
146
+ APPDATA_DIR.mkdir(parents=True, exist_ok=True)
147
+ AUDIO_DIR.mkdir(parents=True, exist_ok=True)
148
+
149
+
150
+ class ConfigManager:
151
+ """Verwaltet die App-Konfiguration.
152
+
153
+ - Lädt Config aus JSON (mit Fallback auf Defaults)
154
+ - Merge-on-Write: bestehende Config lesen, neue Werte mergen, schreiben
155
+ - Schema-Validierung bei Load
156
+ """
157
+
158
+ def __init__(self) -> None:
159
+ _ensure_dirs()
160
+ self._config: dict[str, Any] = self._load()
161
+
162
+ def _load(self) -> dict[str, Any]:
163
+ """Lädt Config aus Datei, merged mit Defaults, validiert."""
164
+ if CONFIG_FILE.exists():
165
+ try:
166
+ raw = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
167
+ if not isinstance(raw, dict):
168
+ log.warning("Config-Datei enthält kein Dict → Defaults verwendet")
169
+ raw = {}
170
+ except (json.JSONDecodeError, OSError) as e:
171
+ log.warning("Config-Datei konnte nicht gelesen werden: %s → Defaults verwendet", e)
172
+ raw = {}
173
+ else:
174
+ log.info("Keine Config-Datei gefunden → Defaults werden verwendet")
175
+ raw = {}
176
+
177
+ merged = _deep_merge(DEFAULT_CONFIG, raw)
178
+ validated = _validate_config(merged)
179
+ return validated
180
+
181
+ def _save(self) -> None:
182
+ """Speichert aktuelle Config in Datei."""
183
+ _ensure_dirs()
184
+ try:
185
+ CONFIG_FILE.write_text(
186
+ json.dumps(self._config, indent=2, ensure_ascii=False),
187
+ encoding="utf-8",
188
+ )
189
+ log.debug("Config gespeichert: %s", CONFIG_FILE)
190
+ except OSError as e:
191
+ log.error("Config konnte nicht gespeichert werden: %s", e)
192
+
193
+ @property
194
+ def config(self) -> dict[str, Any]:
195
+ """Gibt die komplette Config als Dict zurück (Read-only Kopie)."""
196
+ return copy.deepcopy(self._config)
197
+
198
+ def get(self, path: str, default: Any = None) -> Any:
199
+ """Holt einen Wert per Punkt-Pfad (z.B. 'general.language').
200
+
201
+ Args:
202
+ path: Punkt-separierter Pfad zum Wert.
203
+ default: Fallback, wenn Pfad nicht existiert.
204
+ """
205
+ node = self._config
206
+ for part in path.split("."):
207
+ if isinstance(node, dict) and part in node:
208
+ node = node[part]
209
+ else:
210
+ return default
211
+ return copy.deepcopy(node)
212
+
213
+ def set(self, path: str, value: Any) -> None:
214
+ """Setzt einen Wert per Punkt-Pfad und speichert.
215
+
216
+ Merge-on-Write: Liest aktuelle Datei, merged, speichert.
217
+
218
+ Args:
219
+ path: Punkt-separierter Pfad (z.B. 'general.language').
220
+ value: Neuer Wert.
221
+ """
222
+ # Aktuellen Stand von Disk lesen (falls von außen geändert)
223
+ if CONFIG_FILE.exists():
224
+ try:
225
+ disk_config = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
226
+ if isinstance(disk_config, dict):
227
+ self._config = _deep_merge(DEFAULT_CONFIG, disk_config)
228
+ self._config = _validate_config(self._config)
229
+ except (json.JSONDecodeError, OSError):
230
+ pass
231
+
232
+ # Wert setzen
233
+ parts = path.split(".")
234
+ node = self._config
235
+ for part in parts[:-1]:
236
+ if part not in node or not isinstance(node[part], dict):
237
+ node[part] = {}
238
+ node = node[part]
239
+ node[parts[-1]] = value
240
+
241
+ log.info("Config geändert: %s = %s", path, value)
242
+ self._save()
243
+
244
+ def update(self, updates: dict[str, Any]) -> None:
245
+ """Merged ein Dict in die Config und speichert.
246
+
247
+ Args:
248
+ updates: Dict mit Werten zum Mergen (gleiche Struktur wie Config).
249
+ """
250
+ self._config = _deep_merge(self._config, updates)
251
+ self._config = _validate_config(self._config)
252
+ self._save()
253
+
254
+ def reload(self) -> None:
255
+ """Lädt Config erneut von Disk."""
256
+ self._config = self._load()
257
+ log.info("Config neu geladen")
258
+
259
+ def is_first_run(self) -> bool:
260
+ """Prüft ob dies der erste Start ist (keine Config-Datei vorhanden)."""
261
+ return not CONFIG_FILE.exists()
262
+
263
+ def save_initial(self) -> None:
264
+ """Speichert die initiale Config (nach Setup-Wizard)."""
265
+ self._save()
266
+
267
+
268
+ # -- API-Key-Speicherung via Windows Credential Manager --
269
+
270
+ KEYRING_SERVICE = "PayPerTranscript"
271
+ KEYRING_ACCOUNT = "groq_api_key"
272
+
273
+
274
+ def save_api_key(api_key: str) -> None:
275
+ """Speichert den API-Key im Windows Credential Manager (keyring)."""
276
+ import keyring
277
+
278
+ keyring.set_password(KEYRING_SERVICE, KEYRING_ACCOUNT, api_key)
279
+ log.info("API-Key im Credential Manager gespeichert")
280
+
281
+
282
+ def load_api_key() -> str | None:
283
+ """Lädt den API-Key aus dem Windows Credential Manager (keyring).
284
+
285
+ Returns:
286
+ Den API-Key oder None wenn keiner gespeichert ist.
287
+ """
288
+ import keyring
289
+
290
+ try:
291
+ key = keyring.get_password(KEYRING_SERVICE, KEYRING_ACCOUNT)
292
+ if key:
293
+ log.debug("API-Key aus Credential Manager geladen")
294
+ return key
295
+ except Exception as e:
296
+ log.warning("Keyring-Zugriff fehlgeschlagen: %s", e)
297
+ return None
298
+
299
+
300
+ # -- Autostart via Windows Startup-Folder --
301
+
302
+ _STARTUP_DIR = Path(os.environ.get("APPDATA", "")) / (
303
+ r"Microsoft\Windows\Start Menu\Programs\Startup"
304
+ )
305
+ _SHORTCUT_NAME = "PayPerTranscript.lnk"
306
+
307
+
308
+ def enable_autostart() -> bool:
309
+ """Erstellt Windows-Startup-Shortcut. Returns True bei Erfolg."""
310
+ try:
311
+ import shutil
312
+
313
+ import win32com.client # type: ignore[import-untyped]
314
+
315
+ shortcut_path = _STARTUP_DIR / _SHORTCUT_NAME
316
+ shell = win32com.client.Dispatch("WScript.Shell")
317
+ shortcut = shell.CreateShortCut(str(shortcut_path))
318
+
319
+ # pip-installiertes GUI-Script suchen
320
+ entry_point = shutil.which("paypertranscript")
321
+ if entry_point:
322
+ shortcut.Targetpath = str(Path(entry_point))
323
+ shortcut.WorkingDirectory = str(Path.home())
324
+ shortcut.Arguments = ""
325
+ else:
326
+ # Fallback: pythonw.exe -m paypertranscript (kein CMD-Fenster)
327
+ python_dir = Path(sys.executable).parent
328
+ pythonw = python_dir / "pythonw.exe"
329
+ if not pythonw.exists():
330
+ log.warning("pythonw.exe nicht gefunden — Fallback auf python.exe")
331
+ pythonw = Path(sys.executable)
332
+ shortcut.Targetpath = str(pythonw)
333
+ shortcut.WorkingDirectory = str(Path.home())
334
+ shortcut.Arguments = "-m paypertranscript"
335
+
336
+ shortcut.Description = "PayPerTranscript — Voice-to-Text"
337
+ shortcut.save()
338
+ log.info("Autostart-Shortcut erstellt: %s", shortcut_path)
339
+ return True
340
+ except Exception as e:
341
+ log.error("Autostart konnte nicht aktiviert werden: %s", e)
342
+ return False
343
+
344
+
345
+ def disable_autostart() -> bool:
346
+ """Entfernt Windows-Startup-Shortcut. Returns True bei Erfolg."""
347
+ try:
348
+ shortcut_path = _STARTUP_DIR / _SHORTCUT_NAME
349
+ if shortcut_path.exists():
350
+ shortcut_path.unlink()
351
+ log.info("Autostart-Shortcut entfernt: %s", shortcut_path)
352
+ return True
353
+ except Exception as e:
354
+ log.error("Autostart konnte nicht deaktiviert werden: %s", e)
355
+ return False
356
+
357
+
358
+ def is_autostart_enabled() -> bool:
359
+ """Prüft ob der Autostart-Shortcut existiert."""
360
+ return (_STARTUP_DIR / _SHORTCUT_NAME).exists()
@@ -0,0 +1,87 @@
1
+ """Kostenberechnung fuer PayPerTranscript.
2
+
3
+ Reine Berechnungsfunktionen fuer STT- und LLM-Kosten.
4
+ Keine I/O, keine Seiteneffekte — einfach testbar.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+
9
+ # STT/LLM API-Preise (Stand: 2026-02)
10
+ STT_PRICE_PER_HOUR_USD = 0.04
11
+ STT_MIN_BILLED_SECONDS = 10 # API-seitiges Minimum-Billing
12
+
13
+ LLM_INPUT_PRICE_PER_M_TOKENS = 0.075 # USD per million input tokens
14
+ LLM_OUTPUT_PRICE_PER_M_TOKENS = 0.30 # USD per million output tokens
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class CostResult:
19
+ """Ergebnis einer Kostenberechnung."""
20
+
21
+ audio_duration_seconds: float
22
+ billed_seconds: float
23
+ stt_cost_usd: float
24
+ llm_input_tokens: int
25
+ llm_output_tokens: int
26
+ llm_cost_usd: float
27
+ total_cost_usd: float
28
+
29
+
30
+ def calculate_stt_cost(audio_duration_seconds: float) -> tuple[float, float]:
31
+ """Berechnet STT-Kosten.
32
+
33
+ Args:
34
+ audio_duration_seconds: Tatsaechliche Audio-Dauer in Sekunden.
35
+
36
+ Returns:
37
+ Tuple (billed_seconds, cost_usd).
38
+ """
39
+ billed = max(audio_duration_seconds, STT_MIN_BILLED_SECONDS)
40
+ cost = billed / 3600.0 * STT_PRICE_PER_HOUR_USD
41
+ return billed, cost
42
+
43
+
44
+ def calculate_llm_cost(input_tokens: int, output_tokens: int) -> float:
45
+ """Berechnet LLM-Kosten.
46
+
47
+ Args:
48
+ input_tokens: Anzahl Input-Tokens.
49
+ output_tokens: Anzahl Output-Tokens.
50
+
51
+ Returns:
52
+ Kosten in USD.
53
+ """
54
+ return (
55
+ input_tokens * LLM_INPUT_PRICE_PER_M_TOKENS
56
+ + output_tokens * LLM_OUTPUT_PRICE_PER_M_TOKENS
57
+ ) / 1_000_000
58
+
59
+
60
+ def calculate_total_cost(
61
+ audio_duration_seconds: float,
62
+ llm_input_tokens: int = 0,
63
+ llm_output_tokens: int = 0,
64
+ ) -> CostResult:
65
+ """Berechnet Gesamtkosten einer Transkription.
66
+
67
+ Args:
68
+ audio_duration_seconds: Audio-Dauer in Sekunden.
69
+ llm_input_tokens: LLM Input-Tokens (0 wenn kein LLM).
70
+ llm_output_tokens: LLM Output-Tokens (0 wenn kein LLM).
71
+
72
+ Returns:
73
+ CostResult mit allen Kosten-Details.
74
+ """
75
+ billed, stt_cost = calculate_stt_cost(audio_duration_seconds)
76
+ llm_cost = calculate_llm_cost(llm_input_tokens, llm_output_tokens)
77
+ return CostResult(
78
+ audio_duration_seconds=audio_duration_seconds,
79
+ billed_seconds=billed,
80
+ stt_cost_usd=stt_cost,
81
+ llm_input_tokens=llm_input_tokens,
82
+ llm_output_tokens=llm_output_tokens,
83
+ llm_cost_usd=llm_cost,
84
+ total_cost_usd=stt_cost + llm_cost,
85
+ )
86
+
87
+