|
|
@@ -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":
|