-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathmain.py
More file actions
269 lines (218 loc) · 8.61 KB
/
Copy pathmain.py
File metadata and controls
269 lines (218 loc) · 8.61 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
import multiprocessing
import os
import sys
import atexit
import traceback
import numpy as np
from voirol.core.config import load_config
from voirol.core.pipeline import VoicePipeline
from voirol.gui.theme import Theme, apply_theme, resolve_theme
from voirol.gui.tray import create_tray_icon
from voirol.utils.i18n import t
from voirol.utils.logger import get_logger, setup_logger
from voirol.utils.resources import resource_path
from voirol.voice.model_download import check_model_status
logger = get_logger("main")
pipeline: VoicePipeline | None = None
_LOG_DIR = os.path.join(
os.environ.get("LOCALAPPDATA", os.path.expanduser("~")),
"VoirolClass", "logs",
)
def main():
multiprocessing.freeze_support()
if len(sys.argv) >= 2 and sys.argv[1] == "--splash":
_run_splash(int(sys.argv[2]))
return
# Logger first — before any project code
setup_logger(log_dir=_LOG_DIR, level=os.environ.get("VOIROL_LOG_LEVEL", "INFO"))
config = load_config()
# Re-apply with config values (dedup by handler type, no duplicates)
setup_logger(
log_dir=_LOG_DIR,
level=config.logging.get("level", "INFO"),
)
print(f"""
╔══════════════════════════════════════╗
║ {t('app.banner_line1')} ║
║ {t('app.banner_line2')} ║
╚══════════════════════════════════════╝
""")
logger.info("Starting VoirolClass...")
missing = [mid for mid in ["silero_vad", "sensevoice"]
if check_model_status(mid) == "missing"]
if missing:
logger.warning(f"Models not downloaded: {', '.join(missing)}")
print(t("app.model_missing_hint"))
from PyQt6.QtGui import QFont, QFontDatabase, QSurfaceFormat
from PyQt6.QtWidgets import QApplication
app = QApplication(sys.argv)
app.setApplicationName("VoirolClass")
app.setQuitOnLastWindowClosed(False)
fmt = QSurfaceFormat()
fmt.setAlphaBufferSize(8)
QSurfaceFormat.setDefaultFormat(fmt)
def _crash_handler(exc_type, exc_value, exc_tb):
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_tb)
return
details = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
logger.critical(f"Unhandled exception: {details}")
global pipeline
if pipeline:
try:
pipeline.stop()
except Exception:
pass
from PyQt6.QtCore import QThread
from PyQt6.QtWidgets import QApplication
qapp = QApplication.instance()
if qapp is not None:
thread = QThread.currentThread()
if thread == qapp.thread():
from voirol.gui.crash_dialog import CrashDialog
try:
CrashDialog(details).exec()
except Exception:
pass
sys.exit(1)
sys.excepthook = _crash_handler
def _cleanup_keyboard():
try:
import keyboard
keyboard.unhook_all()
except Exception:
pass
atexit.register(_cleanup_keyboard)
from voirol.gui.splash_spawn import SplashProcess
splash = SplashProcess()
splash.set_status(t("splash.starting"))
global pipeline
pipeline = None
try:
# 初始化主题管理器:从 config 加载主题模式、种子色、动态取色
from voirol.gui.theme import get_theme_manager
mgr = get_theme_manager()
mgr.set_seed(config.ui.get("seed_color", "#A8C7FA"), broadcast=False)
if config.ui.get("dynamic_color", False):
mgr.set_dynamic_color(True, broadcast=False)
mgr.set_mode(config.ui.get("theme", "system"), broadcast=False)
mgr.apply_to(app)
theme = resolve_theme(config.ui.get("theme", "system"))
apply_theme(app, theme, config.ui.get("border_radius", 5))
font_id = QFontDatabase.addApplicationFont(resource_path("fonts/GSF.ttf"))
if font_id >= 0:
families = QFontDatabase.applicationFontFamilies(font_id)
if families:
app.setFont(QFont(families[0], config.ui.get("font_size", 13)))
splash.set_status(t("splash.voice_engine"))
pipeline = VoicePipeline(config)
if not pipeline.verifier.get_active_name():
teachers = pipeline.enrollment.list_profiles()
if teachers:
first = teachers[0]
pipeline.set_teacher(first)
logger.info(f"Auto-selected first teacher: {first}")
else:
logger.warning("No teacher enrolled. Use tray menu to register.")
print(t("app.startup_no_teacher"))
print(t("app.startup_hint"))
if check_model_status("silero_vad") != "missing":
pipeline.start()
else:
logger.info("VAD model not found — service not started. Start manually after download.")
splash.set_status(t("splash.hotkeys"))
ptt_key = config.hotkey.get("push_to_talk", "ctrl+alt+v")
if pipeline.is_running:
pipeline.setup_hotkeys(ptt_key)
splash.set_status(t("splash.interface"))
tray, tray_menu = create_tray_icon(app, pipeline)
splash.set_status(t("splash.ready"))
from voirol.gui.capsule import CapsuleWidget
from voirol.gui.marquee import MarqueeWidget
capsule = CapsuleWidget()
marquee = MarqueeWidget()
splash.close_with_delay(500)
capsule.show()
pipeline.on_state_change(lambda s: capsule.set_state(s))
pipeline.on_audio_level(lambda lv: (
capsule.set_level(lv), capsule.tick_levels()
)[-1])
pipeline.on_asr_text(lambda t: capsule.set_recognized(t))
pipeline.on_action(lambda t: capsule.set_response(t))
pipeline.on_agent_active(lambda a: marquee.set_active(a))
print(t("app.running"))
print(t("app.running_hint") + "\n")
except Exception:
logger.exception("Startup failed")
splash.close()
from voirol.gui.crash_dialog import CrashDialog
CrashDialog(traceback.format_exc()).exec()
exit_code = app.exec()
if pipeline:
pipeline.stop()
try:
import keyboard as kb
kb.unhook_all()
except Exception:
pass
sys.exit(exit_code)
def _run_splash(port: int):
authkey_hex = sys.argv[3] if len(sys.argv) > 3 else ""
authkey = bytes.fromhex(authkey_hex) if authkey_hex else b"voirol"
from multiprocessing.connection import Client
from PyQt6.QtCore import QTimer
from PyQt6.QtGui import QSurfaceFormat
from PyQt6.QtWidgets import QApplication
# splash 子进程用 CREATE_NO_WINDOW 启动,崩溃时错误信息会丢失,
# 这里把异常写入日志文件方便排查
import traceback as _tb
_log_dir = os.path.join(
os.environ.get("LOCALAPPDATA", os.path.expanduser("~")),
"VoirolClass", "logs",
)
os.makedirs(_log_dir, exist_ok=True)
_splash_log = os.path.join(_log_dir, "splash_error.log")
try:
app = QApplication(sys.argv)
fmt = QSurfaceFormat()
fmt.setAlphaBufferSize(8)
QSurfaceFormat.setDefaultFormat(fmt)
from voirol.gui.splash import StartupSplash
splash = StartupSplash()
splash.show()
except Exception:
with open(_splash_log, "w", encoding="utf-8") as f:
f.write("Splash init failed:\n")
f.write(_tb.format_exc())
sys.exit(1)
try:
conn = Client(("localhost", port), authkey=authkey)
except Exception:
app.quit()
sys.exit(1)
close_pending = False
def poll():
nonlocal close_pending
if conn.poll(0.01):
try:
msg = conn.recv()
tp = msg.get("type")
if tp == "status":
splash.set_status(msg.get("text", ""))
elif tp == "error":
splash.set_error(msg.get("text", ""))
elif tp == "close":
delay = msg.get("delay", 0)
if delay > 0 and not close_pending:
close_pending = True
QTimer.singleShot(delay, app.quit)
elif delay == 0:
app.quit()
except (EOFError, ConnectionResetError):
app.quit()
timer = QTimer()
timer.timeout.connect(poll)
timer.start(50)
sys.exit(app.exec())
if __name__ == "__main__":
main()