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,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
|
|
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)
|