Explorar el Código

语音系统重构:WAV音量调幅 + 双模式降级 + 打包优化

- WAV播放改用采样幅值调节,音量变化直接作用于音频数据
- winsound内存播放失败时自动降级为原文件异步播放
- _voice_worker线程健壮化:SAPI不可用时WAV模式正常工作
- 打包exe默认使用config.prod.ini,源码dev默认config.dev.ini
- 删除根目录空__init__.py,修复PyInstaller模块解析
- 删除hook-hookvision.py,移除--add-data hookvision避免源码暴露
- 添加--hidden-import=winsound/wave确保模块打包
lsfoo hace 3 meses
padre
commit
0426158242

+ 0 - 0
__init__.py


BIN
assets/voices/arrive_1.wav


BIN
assets/voices/arrive_2.wav


BIN
assets/voices/arrive_3.wav


BIN
assets/voices/arrive_4.wav


BIN
assets/voices/arrive_5.wav


BIN
assets/voices/hooking.wav


BIN
assets/voices/ready.wav


BIN
assets/voices/releasing.wav


+ 1 - 1
build_exe.bat

@@ -8,7 +8,7 @@ if %errorlevel% neq 0 exit /b %errorlevel%
 
 REM Step 2: Build the executable
 echo === Building HookVision.exe ===
-pyinstaller --noconsole --name=HookVision --icon="assets\logo.ico" --paths="." --additional-hooks-dir="hooks" --add-data="config.ini;." --add-data="assets;assets" --add-data="hookvision;hookvision" --hidden-import=av --hidden-import=pymodbus --hidden-import=pymodbus.client --hidden-import=paho.mqtt --hidden-import=paho.mqtt.client --hidden-import=cv2 --hidden-import=qt_material --hidden-import=configparser --hidden-import=numpy --hidden-import=win32com --hidden-import=pythoncom --collect-all=pywin32 main.py
+pyinstaller --noconsole --name=HookVision --icon="assets\logo.ico" --paths="." --additional-hooks-dir="hooks" --add-data="config.dev.ini;." --add-data="config.prod.ini;." --add-data="assets;assets" --hidden-import=av --hidden-import=pymodbus --hidden-import=pymodbus.client --hidden-import=paho.mqtt --hidden-import=paho.mqtt.client --hidden-import=cv2 --hidden-import=qt_material --hidden-import=configparser --hidden-import=numpy --hidden-import=win32com --hidden-import=pythoncom --hidden-import=struct --hidden-import=winsound --hidden-import=wave --hidden-import=hookvision --hidden-import=hookvision.config_loader --hidden-import=hookvision.main_window --hidden-import=hookvision.mqtt_client --hidden-import=hookvision.modbus_client --hidden-import=hookvision.rfid_client --hidden-import=hookvision.rtsp_player --hidden-import=hookvision.models --collect-all=pywin32 main.py
 if %errorlevel% neq 0 exit /b %errorlevel%
 
 echo === Build complete! Run the following to install shortcuts: ===

+ 61 - 0
config.dev.ini

@@ -0,0 +1,61 @@
+[App]
+name = 恒达钢包工位视频监测系统
+copyright = 恒莱达
+screen = 0
+
+[Video]
+rtsp_timeout = 10
+
+[Hardware]
+module_type = RFID
+
+[RFID]
+ip = 192.168.1.250
+port = 502
+stability_time = 5
+stability_count = 3
+
+[Modbus]
+ip = 192.168.1.200
+port = 502
+frequency = 100
+stability_count = 3
+
+[AI]
+enabled = true
+tts_enabled = true
+
+[AI:0]
+broker = 192.168.100.100
+port = 18830
+topics = 
+	ds/channel/#
+
+[AI:1]
+broker = 192.168.1.248
+port = 18830
+topics = 
+	ds/channel/#
+
+[Bucket:0]
+id = 1
+description = 1号工位
+code_x = 00000001
+code_y = 
+ai_module = 0
+left_rtsp = rtsp://admin:hd135792468@192.168.1.124:554/Streaming/Channels/101
+right_rtsp = rtsp://admin:hd135792468@192.168.1.125:554/Streaming/Channels/101
+left_topic = ds/channel/0
+right_topic = ds/channel/1
+
+[Bucket:1]
+id = 2
+description = 2号工位
+code_x = 00000002
+code_y = 
+ai_module = 0
+left_rtsp = rtsp://admin:hd135792468@192.168.100.102:554/Streaming/Channels/101
+right_rtsp = rtsp://admin:hd135792468@192.168.100.103:554/Streaming/Channels/101
+left_topic = ds/channel/0
+right_topic = ds/channel/1
+

+ 107 - 0
config.prod.ini

@@ -0,0 +1,107 @@
+[App]
+name = 恒达钢包工位视频监测系统
+copyright = 恒莱达
+screen = 0
+
+[Video]
+rtsp_timeout =  10
+
+[Hardware]
+module_type = RFID
+# module_type = HD3200
+
+[RFID]
+ip = 192.168.100.113
+port = 60000
+stability_time = 5
+stability_count = 3
+
+[Modbus]
+ip = 192.168.1.200
+port = 502
+frequency = 100
+stability_count = 3
+
+[AI]
+enabled = true
+tts_enabled = false
+
+[AI:0]
+broker = 192.168.100.144
+port = 18830
+topics =
+    ds/channel/#
+
+[AI:1]
+broker = 192.168.100.101
+port = 18830
+topics =
+    ds/channel/#
+
+[AI:2]
+broker = 192.168.100.102
+port = 18830
+topics =
+    ds/channel/#
+
+[AI:3]
+broker = 192.168.100.100
+port = 18830
+topics =
+    ds/channel/#
+
+
+[Bucket:0]
+id = 1
+description = 1号工位
+code_x = 00000001
+code_y = 
+ai_module = 0
+left_rtsp = rtsp://admin:hd135792468@192.168.100.122:554/Streaming/Channels/101
+right_rtsp = rtsp://admin:hd135792468@192.168.100.117:554/Streaming/Channels/101
+left_topic = ds/channel/0
+right_topic = ds/channel/1
+
+[Bucket:1]
+id = 2
+description = 2号工位
+code_x = 00000002
+code_y =
+ai_module = 0
+left_rtsp = rtsp://admin:hd135792468@192.168.100.143:554/Streaming/Channels/101
+right_rtsp = rtsp://admin:hd135792468@192.168.100.142:554/Streaming/Channels/101
+left_topic = ds/channel/3
+right_topic = ds/channel/2
+
+[Bucket:2]
+id = 3
+description = 3号工位
+code_x = 00000003
+code_y =
+ai_module = 1
+left_rtsp = rtsp://admin:hd135792468@192.168.100.120:554/Streaming/Channels/101
+right_rtsp = rtsp://admin:hd135792468@192.168.100.119:554/Streaming/Channels/101
+left_topic = ds/channel/0
+right_topic = ds/channel/1
+
+[Bucket:3]
+id = 4
+description = 4号工位
+code_x = 00000004
+code_y =
+ai_module = 2
+left_rtsp = rtsp://admin:hd135792468@192.168.100.115:554/Streaming/Channels/101
+right_rtsp = rtsp://admin:hd135792468@192.168.100.116:554/Streaming/Channels/101
+left_topic = ds/channel/0
+right_topic = ds/channel/1
+
+[Bucket:4]
+id = 5
+description = 5号工位
+code_x = 00000005
+code_y =
+ai_module = 3
+left_rtsp = rtsp://admin:hd135792468@192.168.100.118:554/Streaming/Channels/101
+right_rtsp = rtsp://admin:hd135792468@192.168.100.121:554/Streaming/Channels/101
+left_topic = ds/channel/0
+right_topic = ds/channel/1

+ 0 - 27
hooks/hook-hookvision.py

@@ -1,27 +0,0 @@
-import sys
-import os
-
-_project_root = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
-if _project_root not in sys.path:
-    sys.path.insert(0, _project_root)
-
-_hookvision_dir = os.path.join(_project_root, 'hookvision')
-
-hiddenimports = []
-
-if os.path.isdir(_hookvision_dir):
-    from os import listdir
-    for fname in sorted(listdir(_hookvision_dir)):
-        if fname.endswith('.py') and fname != '__init__.py':
-            hiddenimports.append('hookvision.' + fname[:-3])
-
-    if not hiddenimports:
-        hiddenimports = [
-            'hookvision.models',
-            'hookvision.config_loader',
-            'hookvision.main_window',
-            'hookvision.mqtt_client',
-            'hookvision.modbus_client',
-            'hookvision.rfid_client',
-            'hookvision.rtsp_player',
-        ]

+ 4 - 23
hookvision/config_loader.py

@@ -1,4 +1,5 @@
 import configparser
+import re
 from .models import *
 
 
@@ -37,14 +38,9 @@ def load_config(config_path: str) -> Config:
 
     # AI
     config.ai.enabled = cfg.getboolean("AI", "enabled", fallback=False)
-    if config.ai.enabled:
-        config.ai.broker = cfg.get("AI", "broker")
-        config.ai.port = cfg.getint("AI", "port", fallback=1883)
-        topics_str = cfg.get("AI", "topics", fallback="")
-        config.ai.topics = [t.strip() for t in topics_str.split('\n') if t.strip()]
+    config.ai.tts_enabled = cfg.getboolean("AI", "tts_enabled", fallback=False)
 
-    # AI Modules (自动扫描 AI:N 段,无需依赖 module_count)
-    import re
+    # AI Modules (自动扫描 AI:N 段)
     for section in sorted(
         (s for s in cfg.sections() if re.match(r'^AI:\d+$', s)),
         key=lambda s: int(s.split(':')[1])
@@ -58,22 +54,7 @@ def load_config(config_path: str) -> Config:
             topics=[t.strip() for t in topics_str.split('\n') if t.strip()]
         ))
 
-    # NVRs
-    nvr_count = cfg.getint("NVR", "count", fallback=0)
-    for i in range(nvr_count):
-        section = f"NVR:{i}"
-        nvr = NVRModel(
-            id=i,
-            name=cfg.get(section, "name"),
-            ip=cfg.get(section, "ip"),
-            port=cfg.getint(section, "port", fallback=8000),
-            username=cfg.get(section, "username", fallback="admin"),
-            password=cfg.get(section, "password", fallback="")
-        )
-        config.nvrs.append(nvr)
-
-    # Buckets (自动扫描 Bucket:N 段,无需依赖 count)
-    import re
+    # Buckets (自动扫描 Bucket:N 段)
     bucket_sections = sorted(
         (s for s in cfg.sections() if re.match(r'^Bucket:\d+$', s)),
         key=lambda s: int(s.split(':')[1])

+ 161 - 52
hookvision/main_window.py

@@ -82,6 +82,8 @@ class MainWindow(QMainWindow):
         self._voice_queue = queue.Queue()
         self._voice_thread = threading.Thread(target=self._voice_worker, daemon=True)
         self._voice_thread.start()
+        self._voices_dir = self._get_voices_dir()
+        self._use_tts = self.config.ai.tts_enabled
 
         # RTSP 退避重连
         self._rtsp_retry_left: Optional[QTimer] = None
@@ -229,15 +231,21 @@ class MainWindow(QMainWindow):
                 btn.clicked.connect(lambda checked, b=bucket: self.on_bucket_clicked(b))
                 cell_layout.addWidget(btn)
 
-                # 摄像头信号指示
-                has_rtsp = any(c.rtsp_url for c in bucket.cameras)
-                sig_label = QLabel("" if not has_rtsp else "● --")
-                sig_label.setAlignment(Qt.AlignCenter)
-                sig_label.setFixedHeight(18)
-                if has_rtsp:
-                    sig_label.setStyleSheet("font-size: 12px; color: #909399;")
-                cell_layout.addWidget(sig_label)
-                self._bucket_signal_labels[bucket.id] = sig_label
+                # 摄像头信号指示条(左右两段)
+                bar = QWidget()
+                bar.setFixedHeight(8)
+                bar_layout = QHBoxLayout(bar)
+                bar_layout.setContentsMargins(0, 0, 0, 0)
+                bar_layout.setSpacing(2)
+                left_bar = QFrame()
+                right_bar = QFrame()
+                for fb in (left_bar, right_bar):
+                    fb.setFixedHeight(8)
+                    fb.setStyleSheet("background-color: #dcdfe6; border-radius: 2px;")
+                bar_layout.addWidget(left_bar)
+                bar_layout.addWidget(right_bar)
+                cell_layout.addWidget(bar)
+                self._bucket_signal_labels[bucket.id] = (left_bar, right_bar)
 
                 self.bucket_grid.addWidget(cell, row, col)
                 self.bucket_buttons.append((btn, bucket))
@@ -282,7 +290,7 @@ class MainWindow(QMainWindow):
         # 状态栏
         status_widget = QWidget()
         status_layout = QHBoxLayout(status_widget)
-        status_layout.setContentsMargins(5, 5, 5, 5)
+        status_layout.setContentsMargins(5, 5, 15, 5)
 
         self.module_status = QLabel("通讯状态: 初始化")
         self.module_status.setStyleSheet("color: #606266; font-weight: bold;")
@@ -297,17 +305,22 @@ class MainWindow(QMainWindow):
         self.voice_checkbox.setStyleSheet("color: #606266; font-weight: bold;")
         self.voice_checkbox.stateChanged.connect(self.on_voice_toggle)
 
-        self.volume_slider = QSlider(Qt.Horizontal)
-        self.volume_slider.setRange(0, 100)
-        self.volume_slider.setValue(self._voice_volume)
-        self.volume_slider.setFixedWidth(80)
-        self.volume_slider.setToolTip(f"音量: {self._voice_volume}%")
-        self.volume_slider.valueChanged.connect(self._on_volume_changed)
-        self.volume_slider.setStyleSheet("""
-            QSlider::groove:horizontal { height: 4px; background: #dcdfe6; border-radius: 2px; }
-            QSlider::handle:horizontal { width: 12px; margin: -4px 0; border-radius: 6px; background: #409eff; }
-            QSlider::sub-page:horizontal { background: #409eff; border-radius: 2px; }
+        self.tts_checkbox = QCheckBox("TTS语音")
+        self.tts_checkbox.setChecked(self._use_tts)
+        self.tts_checkbox.setStyleSheet("color: #606266; font-weight: bold;")
+        self.tts_checkbox.toggled.connect(self._on_tts_toggle)
+
+        self.volume_btn = QPushButton(f"音量 {self._voice_volume}%")
+        self.volume_btn.setFixedWidth(80)
+        self.volume_btn.setStyleSheet("""
+            QPushButton {
+                color: #606266; font-weight: bold; font-size: 12px;
+                border: 1px solid #dcdfe6; border-radius: 4px;
+                padding: 2px 6px; background: transparent;
+            }
+            QPushButton:hover { border-color: #409eff; color: #409eff; }
         """)
+        self.volume_btn.clicked.connect(self._show_volume_popup)
 
         self.ai_checkbox = QCheckBox("AI启用")
         self.ai_checkbox.setChecked(True)
@@ -329,7 +342,8 @@ class MainWindow(QMainWindow):
         status_layout.addWidget(self.hook_status_label)
         status_layout.addStretch()
         status_layout.addWidget(self.voice_checkbox)
-        status_layout.addWidget(self.volume_slider)
+        status_layout.addWidget(self.tts_checkbox)
+        status_layout.addWidget(self.volume_btn)
         status_layout.addWidget(self.ai_checkbox)
         status_layout.addWidget(self.auto_switch_btn)
         status_layout.addWidget(self.autostart_checkbox)
@@ -357,9 +371,50 @@ class MainWindow(QMainWindow):
     def on_voice_toggle(self, state):
         self.voice_enabled = (state == Qt.Checked)
 
+    def _on_tts_toggle(self):
+        self._use_tts = self.tts_checkbox.isChecked()
+        self.config.ai.tts_enabled = self._use_tts
+        try:
+            self._save_config()
+        except Exception as e:
+            logger.warning(f"保存 TTS 设置失败: {e}")
+
     def _on_volume_changed(self, value: int):
         self._voice_volume = value
-        self.volume_slider.setToolTip(f"音量: {value}%")
+        self.volume_btn.setText(f"音量 {value}%")
+
+    def _preview_volume(self, vol: int):
+        self._voice_volume = vol
+        self.volume_btn.setText(f"音量 {vol}%")
+        if not hasattr(self, '_preview_timer'):
+            self._preview_timer = QTimer()
+            self._preview_timer.setSingleShot(True)
+            self._preview_timer.timeout.connect(lambda: self._speak("挂包中"))
+        self._preview_timer.start(300)
+
+    def _show_volume_popup(self):
+        from PySide6.QtWidgets import QDialog, QVBoxLayout
+        from PySide6.QtCore import Qt
+        dlg = QDialog(self)
+        dlg.setWindowFlags(Qt.Popup | Qt.FramelessWindowHint)
+        dlg.setStyleSheet("background: #2c2c2c; border: 1px solid #555; border-radius: 6px;")
+        layout = QVBoxLayout(dlg)
+        layout.setContentsMargins(12, 8, 12, 8)
+        slider = QSlider(Qt.Horizontal)
+        slider.setRange(0, 100)
+        slider.setValue(self._voice_volume)
+        slider.setFixedWidth(160)
+        slider.setStyleSheet("""
+            QSlider::groove:horizontal { height: 4px; background: #555; border-radius: 2px; }
+            QSlider::handle:horizontal { width: 14px; margin: -5px 0; border-radius: 7px; background: #409eff; }
+            QSlider::sub-page:horizontal { background: #409eff; border-radius: 2px; }
+        """)
+        slider.valueChanged.connect(self._preview_volume)
+        layout.addWidget(slider)
+        dlg.adjustSize()
+        btn_global = self.volume_btn.mapToGlobal(self.volume_btn.rect().topLeft())
+        dlg.move(btn_global.x(), btn_global.y() - dlg.height() - 4)
+        dlg.exec()
 
     def on_ai_toggle(self, state):
         enabled = (state == Qt.Checked)
@@ -401,30 +456,77 @@ class MainWindow(QMainWindow):
     def _speak_impl(self, msg: str):
         self._voice_queue.put(msg)
 
+    @staticmethod
+    def _get_voices_dir() -> str:
+        if getattr(sys, 'frozen', False):
+            base = sys._MEIPASS
+        else:
+            base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+        return os.path.join(base, "assets", "voices")
+
+    def _voice_msg_to_path(self, msg: str) -> str:
+        mapping = {"挂包中": "hooking", "挂包成功": "ready", "放包中": "releasing"}
+        if msg in mapping:
+            return os.path.join(self._voices_dir, f"{mapping[msg]}.wav")
+        m = re.search(r'到达(\d+)号工位', msg)
+        if m:
+            return os.path.join(self._voices_dir, f"arrive_{m.group(1)}.wav")
+        return ""
+
     def _voice_worker(self):
         try:
             import pythoncom
             pythoncom.CoInitialize()
         except Exception:
             pass
+        speaker = None
         try:
             import win32com.client as _win32com
-        except ImportError:
-            logging.warning("pywin32 未安装,语音播报不可用")
-            return
+            speaker = _win32com.Dispatch("SAPI.SpVoice")
+            speaker.Rate = 2
+        except Exception:
+            logging.warning("SAPI 不可用,仅可使用 WAV 语音")
         while True:
             msg = self._voice_queue.get()
             if msg is None:
                 break
             try:
-                speaker = _win32com.Dispatch("SAPI.SpVoice")
-                speaker.Rate = 2
-                speaker.Volume = self._voice_volume
-                speaker.Speak(msg)
+                if self._use_tts and speaker is not None:
+                    speaker.Volume = self._voice_volume
+                    speaker.Speak(msg)
+                else:
+                    self._play_wav(msg)
             except Exception as e:
                 logging.warning(f"语音播报失败: {e}")
             self._voice_queue.task_done()
 
+    def _play_wav(self, msg: str):
+        path = self._voice_msg_to_path(msg)
+        if not path:
+            return
+        import struct, wave, io, winsound
+        try:
+            with wave.open(path, 'rb') as wf:
+                params = wf.getparams()
+                raw = bytearray(wf.readframes(wf.getnframes()))
+            gain = self._voice_volume / 100.0
+            if params.sampwidth == 2:
+                fmt = '<' + 'h' * (len(raw) // 2)
+                samples = list(struct.unpack_from(fmt, bytes(raw)))
+                samples = [max(-32768, min(32767, int(s * gain))) for s in samples]
+                raw = bytearray(struct.pack(fmt, *samples))
+            elif params.sampwidth == 1:
+                samples = list(raw)
+                samples = [max(0, min(255, int((s - 128) * gain) + 128)) for s in samples]
+                raw = bytearray(samples)
+            buf = io.BytesIO()
+            with wave.open(buf, 'wb') as wf:
+                wf.setparams(params)
+                wf.writeframes(bytes(raw))
+            winsound.PlaySound(buf.getvalue(), winsound.SND_MEMORY)
+        except Exception:
+            winsound.PlaySound(path, winsound.SND_FILENAME | winsound.SND_ASYNC)
+
     # ---------- 工位图标 ----------
     def get_bucket_icon(self, is_active):
         icon_dir = os.path.join(os.path.dirname(__file__), "..", "assets")
@@ -1113,34 +1215,27 @@ class MainWindow(QMainWindow):
     def _on_camera_signal(self, results: dict):
         self._camera_signal.update(results)
         for bucket in self.buckets:
-            label = self._bucket_signal_labels.get(bucket.id)
-            if not label:
+            bars = self._bucket_signal_labels.get(bucket.id)
+            if not bars:
                 continue
-            best = None
-            for cam in bucket.cameras:
+            left_bar, right_bar = bars
+            for bar, cam in zip((left_bar, right_bar), bucket.cameras):
                 m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
                 if not m:
+                    bar.setStyleSheet("background-color: #dcdfe6; border-radius: 2px;")
                     continue
                 ip = m.group(1)
-                if ip in self._camera_signal:
-                    lat = self._camera_signal[ip]
-                    if best is None or (lat >= 0 and (best < 0 or lat < best)):
-                        best = lat
-            if best is None:
-                label.setText("")
-                label.setStyleSheet("font-size: 12px; color: #909399;")
-            elif best < 0:
-                label.setText("● 断开")
-                label.setStyleSheet("font-size: 12px; color: #f56c6c;")
-            elif best < 50:
-                label.setText(f"● {int(best)}ms")
-                label.setStyleSheet("font-size: 12px; color: #67c23a;")
-            elif best < 200:
-                label.setText(f"● {int(best)}ms")
-                label.setStyleSheet("font-size: 12px; color: #e6a23c;")
-            else:
-                label.setText(f"● {int(best)}ms")
-                label.setStyleSheet("font-size: 12px; color: #f56c6c;")
+                lat = self._camera_signal.get(ip)
+                if lat is None:
+                    bar.setStyleSheet("background-color: #dcdfe6; border-radius: 2px;")
+                elif lat < 0:
+                    bar.setStyleSheet("background-color: #f56c6c; border-radius: 2px;")
+                elif lat < 50:
+                    bar.setStyleSheet("background-color: #67c23a; border-radius: 2px;")
+                elif lat < 200:
+                    bar.setStyleSheet("background-color: #e6a23c; border-radius: 2px;")
+                else:
+                    bar.setStyleSheet("background-color: #f56c6c; border-radius: 2px;")
 
     def toggle_switch_mode(self, checked: bool):
         if checked:
@@ -1155,6 +1250,20 @@ class MainWindow(QMainWindow):
             self.auto_switch_btn.setText("手动模式")
 
     # ---------- 开机自启 ----------
+    def _save_config(self):
+        import configparser
+        cfg = configparser.ConfigParser()
+        config_name = "config.dev.ini" if "--prod" not in sys.argv else "config.prod.ini"
+        config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), config_name)
+        cfg.read(config_path, encoding="utf-8")
+        if not cfg.has_section("AI"):
+            cfg.add_section("AI")
+        val = "true" if self.config.ai.tts_enabled else "false"
+        cfg.set("AI", "tts_enabled", val)
+        with open(config_path, "w", encoding="utf-8") as f:
+            cfg.write(f)
+        logger.info(f"TTS 设置已保存: {config_path} → tts_enabled={val}")
+
     @staticmethod
     def _autostart_config_dir():
         if sys.platform == "win32":

+ 1 - 13
hookvision/models.py

@@ -42,15 +42,6 @@ class BucketModel:
     ready_x: bool = False
     ready_y: bool = True   # 无垂直工位时为True
 
-@dataclass
-class NVRModel:
-    id: int
-    name: str
-    ip: str
-    port: int = 8000
-    username: str = "admin"
-    password: str = ""
-
 @dataclass
 class AIModuleModel:
     id: int
@@ -61,9 +52,7 @@ class AIModuleModel:
 @dataclass
 class AIConfig:
     enabled: bool = False
-    broker: str = "127.0.0.1"
-    port: int = 1883
-    topics: List[str] = field(default_factory=list)
+    tts_enabled: bool = False
 
 @dataclass
 class ModbusConfig:
@@ -94,5 +83,4 @@ class Config:
     ai: AIConfig = field(default_factory=AIConfig)
     ai_modules: List[AIModuleModel] = field(default_factory=list)
     buckets: List[BucketModel] = field(default_factory=list)
-    nvrs: List[NVRModel] = field(default_factory=list)
     video: VideoConfig = field(default_factory=VideoConfig)

+ 9 - 2
main.py

@@ -52,10 +52,17 @@ def main():
     app = QApplication(sys.argv)
     apply_stylesheet(app, theme='dark_teal.xml')
 
+    is_frozen = getattr(sys, 'frozen', False)
+    config_name = "config.prod.ini" if is_frozen else "config.dev.ini"
+    if "--prod" in sys.argv:
+        config_name = "config.prod.ini"
+    if "--dev" in sys.argv:
+        config_name = "config.dev.ini"
+
     if getattr(sys, 'frozen', False):
-        config_path = os.path.join(sys._MEIPASS, 'config.ini')
+        config_path = os.path.join(sys._MEIPASS, config_name)
     else:
-        config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.ini')
+        config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), config_name)
     config = load_config(config_path)
     window = MainWindow(config)
     window.show()

+ 43 - 0
tools/generate_voices.py

@@ -0,0 +1,43 @@
+"""使用本机 SAPI 语音合成生成 assets/voices/*.wav"""
+import os
+import sys
+
+OUTPUT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets", "voices")
+
+VOICES = {
+    "hooking.wav": "挂包中",
+    "ready.wav": "挂包成功",
+    "releasing.wav": "放包中",
+    "arrive_1.wav": "到达1号工位",
+    "arrive_2.wav": "到达2号工位",
+    "arrive_3.wav": "到达3号工位",
+    "arrive_4.wav": "到达4号工位",
+    "arrive_5.wav": "到达5号工位",
+}
+
+
+def main():
+    os.makedirs(OUTPUT_DIR, exist_ok=True)
+
+    import win32com.client
+
+    speaker = win32com.client.Dispatch("SAPI.SpVoice")
+
+    SSFMCreateForWrite = 3
+    SAFT16kHz16BitMono = 4
+
+    for filename, text in VOICES.items():
+        path = os.path.join(OUTPUT_DIR, filename)
+        stream = win32com.client.Dispatch("SAPI.SpFileStream")
+        af = win32com.client.Dispatch("SAPI.SpAudioFormat")
+        af.Type = SAFT16kHz16BitMono
+        stream.Format = af
+        stream.Open(path, SSFMCreateForWrite)
+        speaker.AudioOutputStream = stream
+        speaker.Speak(text)
+        stream.Close()
+        print(f"生成: {path}")
+
+
+if __name__ == "__main__":
+    main()