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,3 @@
1
+ """PayPerTranscript — Voice-to-Text mit Pay-per-Use Pricing."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,51 @@
1
+ """PayPerTranscript — Entry Point.
2
+
3
+ Open-Source Voice-to-Text mit Pay-per-Use Pricing.
4
+ Supports: python -m paypertranscript
5
+ """
6
+
7
+ import ctypes
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ # DPI-Awareness setzen BEVOR Qt oder andere Libraries es tun.
13
+ # Verhindert "SetProcessDpiAwarenessContext() failed: Zugriff verweigert".
14
+ try:
15
+ ctypes.windll.user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4))
16
+ except Exception:
17
+ pass
18
+
19
+
20
+ def _load_dotenv() -> None:
21
+ """Laedt .env-Datei aus dem Arbeitsverzeichnis (falls vorhanden)."""
22
+ env_file = Path.cwd() / ".env"
23
+ if not env_file.exists():
24
+ return
25
+ for line in env_file.read_text(encoding="utf-8").splitlines():
26
+ line = line.strip()
27
+ if not line or line.startswith("#") or "=" not in line:
28
+ continue
29
+ key, _, value = line.partition("=")
30
+ key = key.strip()
31
+ value = value.strip().strip("'\"")
32
+ if key and key not in os.environ:
33
+ os.environ[key] = value
34
+
35
+
36
+ def main() -> None:
37
+ """Hauptfunktion — initialisiert Logging und startet die PySide6-App."""
38
+ _load_dotenv()
39
+
40
+ from paypertranscript.core.logging import setup_logging
41
+
42
+ setup_logging(debug="--debug" in sys.argv)
43
+
44
+ from paypertranscript.ui.app import PayPerTranscriptApp
45
+
46
+ app = PayPerTranscriptApp()
47
+ sys.exit(app.run())
48
+
49
+
50
+ if __name__ == "__main__":
51
+ main()
Binary file
Binary file
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="10" height="6" viewBox="0 0 10 6">
2
+ <path d="M0 0 L5 6 L10 0Z" fill="#a0a0a0"/>
3
+ </svg>
Binary file
Binary file
@@ -0,0 +1,388 @@
1
+ /* PayPerTranscript — Dark Theme Stylesheet
2
+ *
3
+ * Farbpalette (neutral, passend zum Overlay):
4
+ * Hintergrund dunkel: #121218
5
+ * Hintergrund mittel: #1c1c24
6
+ * Hintergrund hell: #2a2a34
7
+ * Akzent Rot/Pink: #e94560
8
+ * Akzent Weiß: #ffffff
9
+ * Text primär: #e0e0e0
10
+ * Text sekundär: #a0a0a0
11
+ * Border: #333340
12
+ */
13
+
14
+ /* === Basis === */
15
+
16
+ QWidget {
17
+ background-color: #121218;
18
+ color: #e0e0e0;
19
+ font-family: "Segoe UI", sans-serif;
20
+ font-size: 10pt;
21
+ }
22
+
23
+ /* === Buttons === */
24
+
25
+ QPushButton {
26
+ background-color: #2a2a34;
27
+ color: #e0e0e0;
28
+ border: 1px solid #333340;
29
+ border-radius: 6px;
30
+ padding: 8px 16px;
31
+ min-height: 20px;
32
+ }
33
+
34
+ QPushButton:hover {
35
+ background-color: #1c1c24;
36
+ border-color: #ffffff;
37
+ }
38
+
39
+ QPushButton:pressed {
40
+ background-color: #0e0e14;
41
+ }
42
+
43
+ QPushButton:disabled {
44
+ background-color: #1c1c24;
45
+ color: #606060;
46
+ border-color: #333340;
47
+ }
48
+
49
+ /* Primärer Button (via property oder objectName) */
50
+ QPushButton[primary="true"] {
51
+ background-color: #e94560;
52
+ border-color: #e94560;
53
+ color: #ffffff;
54
+ font-weight: bold;
55
+ }
56
+
57
+ QPushButton[primary="true"]:hover {
58
+ background-color: #ff5a75;
59
+ border-color: #ff5a75;
60
+ }
61
+
62
+ QPushButton[primary="true"]:pressed {
63
+ background-color: #c73a52;
64
+ }
65
+
66
+ /* === Labels === */
67
+
68
+ QLabel {
69
+ background-color: transparent;
70
+ color: #e0e0e0;
71
+ }
72
+
73
+ QLabel[heading="true"] {
74
+ font-size: 13pt;
75
+ font-weight: bold;
76
+ color: #ffffff;
77
+ }
78
+
79
+ QLabel[subheading="true"] {
80
+ font-size: 11pt;
81
+ color: #a0a0a0;
82
+ }
83
+
84
+ /* === Eingabefelder === */
85
+
86
+ QLineEdit, QTextEdit, QPlainTextEdit {
87
+ background-color: #1c1c24;
88
+ color: #e0e0e0;
89
+ border: 1px solid #333340;
90
+ border-radius: 6px;
91
+ padding: 6px 10px;
92
+ selection-background-color: #2a2a34;
93
+ selection-color: #ffffff;
94
+ }
95
+
96
+ QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus {
97
+ border-color: #ffffff;
98
+ }
99
+
100
+ QLineEdit:disabled, QTextEdit:disabled {
101
+ background-color: #121218;
102
+ color: #606060;
103
+ }
104
+
105
+ /* === Dropdowns === */
106
+
107
+ QComboBox {
108
+ background-color: #1c1c24;
109
+ color: #e0e0e0;
110
+ border: 1px solid #333340;
111
+ border-radius: 6px;
112
+ padding: 6px 10px;
113
+ min-height: 20px;
114
+ }
115
+
116
+ QComboBox:hover {
117
+ border-color: #ffffff;
118
+ }
119
+
120
+ QComboBox::drop-down {
121
+ border: none;
122
+ width: 24px;
123
+ }
124
+
125
+ QComboBox::down-arrow {
126
+ image: url({{ASSETS_DIR}}/icons/arrow_down.svg);
127
+ width: 10px;
128
+ height: 6px;
129
+ margin-right: 8px;
130
+ }
131
+
132
+ QComboBox QAbstractItemView {
133
+ background-color: #1c1c24;
134
+ color: #e0e0e0;
135
+ border: 1px solid #333340;
136
+ selection-background-color: #2a2a34;
137
+ selection-color: #ffffff;
138
+ }
139
+
140
+ /* === Tabs === */
141
+
142
+ QTabWidget::pane {
143
+ background-color: #121218;
144
+ border: 1px solid #333340;
145
+ border-radius: 6px;
146
+ top: -1px;
147
+ }
148
+
149
+ QTabBar::tab {
150
+ background-color: #1c1c24;
151
+ color: #a0a0a0;
152
+ border: 1px solid #333340;
153
+ border-bottom: none;
154
+ border-top-left-radius: 6px;
155
+ border-top-right-radius: 6px;
156
+ padding: 8px 16px;
157
+ margin-right: 2px;
158
+ }
159
+
160
+ QTabBar::tab:selected {
161
+ background-color: #121218;
162
+ color: #ffffff;
163
+ border-bottom: 2px solid #ffffff;
164
+ }
165
+
166
+ QTabBar::tab:hover:!selected {
167
+ color: #e0e0e0;
168
+ background-color: #2a2a34;
169
+ }
170
+
171
+ /* === Checkboxen === */
172
+
173
+ QCheckBox {
174
+ background-color: transparent;
175
+ spacing: 8px;
176
+ }
177
+
178
+ QCheckBox::indicator {
179
+ width: 18px;
180
+ height: 18px;
181
+ border: 1px solid #333340;
182
+ border-radius: 4px;
183
+ background-color: #1c1c24;
184
+ }
185
+
186
+ QCheckBox::indicator:checked {
187
+ background-color: #ffffff;
188
+ border-color: #ffffff;
189
+ }
190
+
191
+ QCheckBox::indicator:hover {
192
+ border-color: #ffffff;
193
+ }
194
+
195
+ /* === RadioButtons === */
196
+
197
+ QRadioButton {
198
+ background-color: transparent;
199
+ spacing: 8px;
200
+ }
201
+
202
+ QRadioButton::indicator {
203
+ width: 18px;
204
+ height: 18px;
205
+ border: 1px solid #333340;
206
+ border-radius: 9px;
207
+ background-color: #1c1c24;
208
+ }
209
+
210
+ QRadioButton::indicator:checked {
211
+ background-color: #ffffff;
212
+ border-color: #ffffff;
213
+ }
214
+
215
+ QRadioButton::indicator:hover {
216
+ border-color: #ffffff;
217
+ }
218
+
219
+ /* === Listen === */
220
+
221
+ QListWidget {
222
+ background-color: #1c1c24;
223
+ color: #e0e0e0;
224
+ border: 1px solid #333340;
225
+ border-radius: 6px;
226
+ }
227
+
228
+ QListWidget::item {
229
+ padding: 4px 8px;
230
+ }
231
+
232
+ QListWidget::item:selected {
233
+ background-color: #2a2a34;
234
+ color: #ffffff;
235
+ }
236
+
237
+ QListWidget::item:hover {
238
+ background-color: #121218;
239
+ }
240
+
241
+ /* === Kontextmenü (Tray) === */
242
+
243
+ QMenu {
244
+ background-color: #1c1c24;
245
+ color: #e0e0e0;
246
+ border: 1px solid #333340;
247
+ border-radius: 6px;
248
+ padding: 4px 0px;
249
+ }
250
+
251
+ QMenu::item {
252
+ padding: 6px 24px;
253
+ }
254
+
255
+ QMenu::item:selected {
256
+ background-color: #2a2a34;
257
+ color: #ffffff;
258
+ }
259
+
260
+ QMenu::separator {
261
+ height: 1px;
262
+ background-color: #333340;
263
+ margin: 4px 8px;
264
+ }
265
+
266
+ QMenu::item:disabled {
267
+ color: #606060;
268
+ }
269
+
270
+ /* === Scrollbars === */
271
+
272
+ QScrollBar:vertical {
273
+ background-color: #121218;
274
+ width: 10px;
275
+ border: none;
276
+ }
277
+
278
+ QScrollBar::handle:vertical {
279
+ background-color: #333340;
280
+ border-radius: 5px;
281
+ min-height: 30px;
282
+ }
283
+
284
+ QScrollBar::handle:vertical:hover {
285
+ background-color: #2a2a34;
286
+ }
287
+
288
+ QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
289
+ height: 0px;
290
+ }
291
+
292
+ QScrollBar:horizontal {
293
+ background-color: #121218;
294
+ height: 10px;
295
+ border: none;
296
+ }
297
+
298
+ QScrollBar::handle:horizontal {
299
+ background-color: #333340;
300
+ border-radius: 5px;
301
+ min-width: 30px;
302
+ }
303
+
304
+ QScrollBar::handle:horizontal:hover {
305
+ background-color: #2a2a34;
306
+ }
307
+
308
+ QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {
309
+ width: 0px;
310
+ }
311
+
312
+ /* === Tooltips === */
313
+
314
+ QToolTip {
315
+ background-color: #1c1c24;
316
+ color: #e0e0e0;
317
+ border: 1px solid #333340;
318
+ border-radius: 4px;
319
+ padding: 4px 8px;
320
+ }
321
+
322
+ /* === GroupBox === */
323
+
324
+ QGroupBox {
325
+ border: 1px solid #333340;
326
+ border-radius: 6px;
327
+ margin-top: 12px;
328
+ padding-top: 16px;
329
+ font-weight: bold;
330
+ }
331
+
332
+ QGroupBox::title {
333
+ subcontrol-origin: margin;
334
+ left: 12px;
335
+ padding: 0 4px;
336
+ color: #ffffff;
337
+ }
338
+
339
+ /* === SpinBox === */
340
+
341
+ QSpinBox, QDoubleSpinBox {
342
+ background-color: #1c1c24;
343
+ color: #e0e0e0;
344
+ border: 1px solid #333340;
345
+ border-radius: 6px;
346
+ padding: 4px 8px;
347
+ }
348
+
349
+ QSpinBox:focus, QDoubleSpinBox:focus {
350
+ border-color: #ffffff;
351
+ }
352
+
353
+ /* === Tabellen === */
354
+
355
+ QTableWidget, QTableView {
356
+ background-color: #1c1c24;
357
+ alternate-background-color: #121218;
358
+ color: #e0e0e0;
359
+ border: 1px solid #333340;
360
+ border-radius: 6px;
361
+ gridline-color: #333340;
362
+ selection-background-color: #2a2a34;
363
+ selection-color: #ffffff;
364
+ }
365
+
366
+ QHeaderView::section {
367
+ background-color: #2a2a34;
368
+ color: #e0e0e0;
369
+ border: 1px solid #333340;
370
+ padding: 6px;
371
+ font-weight: bold;
372
+ }
373
+
374
+ /* === ProgressBar === */
375
+
376
+ QProgressBar {
377
+ background-color: #1c1c24;
378
+ border: 1px solid #333340;
379
+ border-radius: 6px;
380
+ text-align: center;
381
+ color: #e0e0e0;
382
+ height: 16px;
383
+ }
384
+
385
+ QProgressBar::chunk {
386
+ background-color: #ffffff;
387
+ border-radius: 5px;
388
+ }
File without changes
@@ -0,0 +1,142 @@
1
+ """Sound-Playback & Temp-File-Management für PayPerTranscript.
2
+
3
+ Sounds werden beim App-Start in den Speicher vorgeladen (kein Disk-I/O während Aufnahme).
4
+ WAV-Dateien werden im %APPDATA%-Audio-Verzeichnis gespeichert.
5
+ """
6
+
7
+ import time
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+ import sounddevice as sd
12
+ import soundfile as sf
13
+
14
+ from paypertranscript.core.config import AUDIO_DIR
15
+ from paypertranscript.core.logging import get_logger
16
+ from paypertranscript.core.paths import get_sounds_dir
17
+
18
+ log = get_logger("core.audio_manager")
19
+
20
+ SOUNDS_DIR = get_sounds_dir()
21
+
22
+
23
+ class AudioManager:
24
+ """Verwaltet Sound-Playback und temporäre Audio-Dateien."""
25
+
26
+ def __init__(self) -> None:
27
+ self._sounds: dict[str, tuple[np.ndarray, int]] = {}
28
+ AUDIO_DIR.mkdir(parents=True, exist_ok=True)
29
+
30
+ def _generate_default_sounds(self) -> None:
31
+ """Generiert Standard-Sounds falls keine vorhanden."""
32
+ try:
33
+ SOUNDS_DIR.mkdir(parents=True, exist_ok=True)
34
+ except OSError:
35
+ # Package-Verzeichnis kann read-only sein (z.B. site-packages)
36
+ return
37
+
38
+ for name, freq, duration in [("start", 880, 0.08), ("stop", 440, 0.08)]:
39
+ path = SOUNDS_DIR / f"{name}.wav"
40
+ if path.exists():
41
+ continue
42
+ try:
43
+ sr = 44100
44
+ t = np.linspace(0, duration, int(sr * duration), endpoint=False)
45
+ wave = np.sin(2 * np.pi * freq * t).astype(np.float32)
46
+ fade_len = int(sr * 0.01)
47
+ wave[:fade_len] *= np.linspace(0, 1, fade_len).astype(np.float32)
48
+ wave[-fade_len:] *= np.linspace(1, 0, fade_len).astype(np.float32)
49
+ wave *= 0.3
50
+ sf.write(str(path), wave, sr)
51
+ log.info("Standard-Sound generiert: %s", name)
52
+ except Exception as e:
53
+ log.warning("Standard-Sound konnte nicht generiert werden: %s — %s", name, e)
54
+
55
+ def preload_sounds(self) -> None:
56
+ """Lädt alle Sound-Dateien aus assets/sounds/ in den Speicher."""
57
+ self._generate_default_sounds()
58
+
59
+ if not SOUNDS_DIR.exists():
60
+ log.debug("Sound-Verzeichnis existiert nicht: %s", SOUNDS_DIR)
61
+ return
62
+
63
+ for sound_file in SOUNDS_DIR.glob("*.wav"):
64
+ try:
65
+ data, samplerate = sf.read(str(sound_file), dtype="float32")
66
+ self._sounds[sound_file.stem] = (data, samplerate)
67
+ log.debug("Sound vorgeladen: %s (%.1f KB)", sound_file.stem, sound_file.stat().st_size / 1024)
68
+ except Exception as e:
69
+ log.warning("Sound konnte nicht geladen werden: %s — %s", sound_file.name, e)
70
+
71
+ if self._sounds:
72
+ log.info("%d Sound(s) vorgeladen", len(self._sounds))
73
+ else:
74
+ log.debug("Keine Sounds zum Vorladen gefunden")
75
+
76
+ def play_sound(self, name: str) -> None:
77
+ """Spielt einen vorgeladenen Sound ab (non-blocking).
78
+
79
+ Args:
80
+ name: Name des Sounds (ohne .wav Extension).
81
+ """
82
+ if name not in self._sounds:
83
+ log.debug("Sound nicht gefunden: '%s'", name)
84
+ return
85
+
86
+ data, samplerate = self._sounds[name]
87
+ try:
88
+ sd.play(data, samplerate)
89
+ except sd.PortAudioError as e:
90
+ log.warning("Sound-Playback fehlgeschlagen: %s — %s", name, e)
91
+
92
+ def generate_temp_path(self) -> Path:
93
+ """Generiert einen eindeutigen Pfad für eine temporäre WAV-Datei.
94
+
95
+ Returns:
96
+ Pfad im Format: %APPDATA%/PayPerTranscript/audio/rec_<timestamp>.wav
97
+ """
98
+ AUDIO_DIR.mkdir(parents=True, exist_ok=True)
99
+ timestamp = time.strftime("%Y%m%d_%H%M%S")
100
+ # Millisekunden für Eindeutigkeit
101
+ ms = f"{time.time() % 1:.3f}"[2:]
102
+ filename = f"rec_{timestamp}_{ms}.wav"
103
+ return AUDIO_DIR / filename
104
+
105
+ def cleanup_old_files(self, max_age_hours: float) -> int:
106
+ """Löscht Audio-Dateien die älter als max_age_hours sind.
107
+
108
+ Args:
109
+ max_age_hours: Maximales Alter in Stunden. 0 = sofort alles löschen.
110
+ Negative Werte = nichts löschen (Retention deaktiviert).
111
+
112
+ Returns:
113
+ Anzahl gelöschter Dateien.
114
+ """
115
+ if max_age_hours < 0:
116
+ return 0
117
+
118
+ if not AUDIO_DIR.exists():
119
+ return 0
120
+
121
+ now = time.time()
122
+ max_age_seconds = max_age_hours * 3600
123
+ deleted = 0
124
+
125
+ for wav_file in AUDIO_DIR.glob("*.wav"):
126
+ try:
127
+ age = now - wav_file.stat().st_mtime
128
+ if age > max_age_seconds:
129
+ wav_file.unlink()
130
+ deleted += 1
131
+ log.debug("Audio-Datei gelöscht: %s (%.1fh alt)", wav_file.name, age / 3600)
132
+ except OSError as e:
133
+ log.warning("Audio-Datei konnte nicht gelöscht werden: %s — %s", wav_file.name, e)
134
+
135
+ if deleted:
136
+ log.info("%d alte Audio-Datei(en) gelöscht", deleted)
137
+ return deleted
138
+
139
+ @property
140
+ def has_sounds(self) -> bool:
141
+ """Gibt zurück, ob Sounds vorgeladen sind."""
142
+ return bool(self._sounds)