221 lines
8.2 KiB
Python
221 lines
8.2 KiB
Python
|
|
# -*- coding: utf-8 -*-
|
|||
|
|
"""
|
|||
|
|
语音输入模块
|
|||
|
|
基于 SpeechRecognition + PyAudio 实现麦克风实时识别
|
|||
|
|
在独立 QThread 中运行,不阻塞 Qt 主循环
|
|||
|
|
依赖: pip install SpeechRecognition pyaudio
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import logging
|
|||
|
|
|
|||
|
|
from PyQt5.QtCore import QThread, pyqtSignal, pyqtSlot
|
|||
|
|
from PyQt5.QtWidgets import (
|
|||
|
|
QWidget, QVBoxLayout, QHBoxLayout,
|
|||
|
|
QPushButton, QLabel, QComboBox, QProgressBar
|
|||
|
|
)
|
|||
|
|
from uas.io.base_io import BaseInput, IOType
|
|||
|
|
from uas.io.signals import io_signals
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
import speech_recognition as sr
|
|||
|
|
HAS_SR = True
|
|||
|
|
except ImportError:
|
|||
|
|
HAS_SR = False
|
|||
|
|
logger.warning("⚠️ SpeechRecognition 未安装: pip install SpeechRecognition pyaudio")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─────────────────────────────────────────────────────────────────
|
|||
|
|
# 语音识别工作线程
|
|||
|
|
# ─────────────────────────────────────────────────────────────────
|
|||
|
|
class _SpeechWorkerThread(QThread):
|
|||
|
|
"""在后台线程持续监听麦克风并识别语音"""
|
|||
|
|
|
|||
|
|
recognized = pyqtSignal(str) # 识别成功
|
|||
|
|
error = pyqtSignal(str) # 识别失败
|
|||
|
|
listening = pyqtSignal(bool) # 正在监听状态
|
|||
|
|
|
|||
|
|
def __init__(self, engine: str = "google", language: str = "zh-CN", parent=None):
|
|||
|
|
super().__init__(parent)
|
|||
|
|
self._engine = engine
|
|||
|
|
self._language = language
|
|||
|
|
self._running = False
|
|||
|
|
|
|||
|
|
def run(self):
|
|||
|
|
if not HAS_SR:
|
|||
|
|
self.error.emit("SpeechRecognition 未安装")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
recognizer = sr.Recognizer()
|
|||
|
|
recognizer.energy_threshold = 300
|
|||
|
|
recognizer.dynamic_energy_threshold = True
|
|||
|
|
recognizer.pause_threshold = 0.8
|
|||
|
|
|
|||
|
|
self._running = True
|
|||
|
|
logger.info(f"语音识别线程启动 engine={self._engine} lang={self._language}")
|
|||
|
|
|
|||
|
|
with sr.Microphone() as source:
|
|||
|
|
recognizer.adjust_for_ambient_noise(source, duration=1)
|
|||
|
|
while self._running:
|
|||
|
|
try:
|
|||
|
|
self.listening.emit(True)
|
|||
|
|
audio = recognizer.listen(source, timeout=5, phrase_time_limit=15)
|
|||
|
|
self.listening.emit(False)
|
|||
|
|
|
|||
|
|
# 根据引擎选择识别方式
|
|||
|
|
if self._engine == "google":
|
|||
|
|
text = recognizer.recognize_google(
|
|||
|
|
audio, language=self._language
|
|||
|
|
)
|
|||
|
|
elif self._engine == "whisper":
|
|||
|
|
text = recognizer.recognize_whisper(
|
|||
|
|
audio, language=self._language.split("-")[0]
|
|||
|
|
)
|
|||
|
|
elif self._engine == "sphinx":
|
|||
|
|
text = recognizer.recognize_sphinx(audio)
|
|||
|
|
else:
|
|||
|
|
text = recognizer.recognize_google(
|
|||
|
|
audio, language=self._language
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if text:
|
|||
|
|
logger.info(f"识别结果: {text!r}")
|
|||
|
|
self.recognized.emit(text)
|
|||
|
|
|
|||
|
|
except sr.WaitTimeoutError:
|
|||
|
|
pass # 超时继续等待
|
|||
|
|
except sr.UnknownValueError:
|
|||
|
|
self.error.emit("无法识别语音")
|
|||
|
|
except sr.RequestError as e:
|
|||
|
|
self.error.emit(f"识别服务错误: {e}")
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"语音识别异常: {e}")
|
|||
|
|
self.error.emit(str(e))
|
|||
|
|
|
|||
|
|
logger.info("语音识别线程退出")
|
|||
|
|
|
|||
|
|
@pyqtSlot
|
|||
|
|
def output(self):
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
def stop(self):
|
|||
|
|
self._running = False
|
|||
|
|
|
|||
|
|
STTWorker = _SpeechWorkerThread
|
|||
|
|
# ─────────────────────────────────────────────────────────────────
|
|||
|
|
# 语音输入组件(带 UI)
|
|||
|
|
# ─────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
class SpeechInputWidget(QWidget, BaseInput):
|
|||
|
|
"""
|
|||
|
|
语音输入 UI 组件
|
|||
|
|
包含:引擎选择 + 语言选择 + 开始/停止按钮 + 状态指示
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, parent=None):
|
|||
|
|
QWidget.__init__(self, parent)
|
|||
|
|
BaseInput.__init__(self, "speech_input", IOType.SPEECH)
|
|||
|
|
self._worker: _SpeechWorkerThread = None
|
|||
|
|
self._build_ui()
|
|||
|
|
|
|||
|
|
def _build_ui(self):
|
|||
|
|
layout = QVBoxLayout(self)
|
|||
|
|
layout.setContentsMargins(8, 8, 8, 8)
|
|||
|
|
layout.setSpacing(6)
|
|||
|
|
|
|||
|
|
# 标题
|
|||
|
|
title = QLabel("🎤 语音输入")
|
|||
|
|
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
|||
|
|
layout.addWidget(title)
|
|||
|
|
|
|||
|
|
# 引擎 + 语言选择
|
|||
|
|
row1 = QHBoxLayout()
|
|||
|
|
row1.addWidget(QLabel("引擎:"))
|
|||
|
|
self._engine_box = QComboBox()
|
|||
|
|
self._engine_box.addItems(["google", "whisper", "sphinx"])
|
|||
|
|
row1.addWidget(self._engine_box)
|
|||
|
|
|
|||
|
|
row1.addWidget(QLabel("语言:"))
|
|||
|
|
self._lang_box = QComboBox()
|
|||
|
|
self._lang_box.addItems(["zh-CN", "en-US", "ja-JP", "ko-KR"])
|
|||
|
|
row1.addWidget(self._lang_box)
|
|||
|
|
layout.addLayout(row1)
|
|||
|
|
|
|||
|
|
# 状态指示
|
|||
|
|
self._status_label = QLabel("⏸ 未启动")
|
|||
|
|
self._status_label.setStyleSheet("color: gray;")
|
|||
|
|
layout.addWidget(self._status_label)
|
|||
|
|
|
|||
|
|
# 进度条(监听动画)
|
|||
|
|
self._progress = QProgressBar()
|
|||
|
|
self._progress.setRange(0, 0) # 不确定模式
|
|||
|
|
self._progress.setVisible(False)
|
|||
|
|
self._progress.setFixedHeight(8)
|
|||
|
|
layout.addWidget(self._progress)
|
|||
|
|
|
|||
|
|
# 按钮
|
|||
|
|
btn_row = QHBoxLayout()
|
|||
|
|
self._start_btn = QPushButton("▶ 开始监听")
|
|||
|
|
self._start_btn.setStyleSheet("background:#4CAF50; color:white;")
|
|||
|
|
self._start_btn.clicked.connect(self.start)
|
|||
|
|
btn_row.addWidget(self._start_btn)
|
|||
|
|
|
|||
|
|
self._stop_btn = QPushButton("■ 停止")
|
|||
|
|
self._stop_btn.setStyleSheet("background:#f44336; color:white;")
|
|||
|
|
self._stop_btn.setEnabled(False)
|
|||
|
|
self._stop_btn.clicked.connect(self.stop)
|
|||
|
|
btn_row.addWidget(self._stop_btn)
|
|||
|
|
layout.addLayout(btn_row)
|
|||
|
|
|
|||
|
|
# 识别结果预览
|
|||
|
|
self._result_label = QLabel("")
|
|||
|
|
self._result_label.setWordWrap(True)
|
|||
|
|
self._result_label.setStyleSheet(
|
|||
|
|
"background:#f5f5f5; padding:4px; border-radius:4px;"
|
|||
|
|
)
|
|||
|
|
layout.addWidget(self._result_label)
|
|||
|
|
|
|||
|
|
def start(self) -> None:
|
|||
|
|
engine = self._engine_box.currentText()
|
|||
|
|
language = self._lang_box.currentText()
|
|||
|
|
self._worker = _SpeechWorkerThread(engine, language, self)
|
|||
|
|
self._worker.recognized.connect(self._on_recognized)
|
|||
|
|
self._worker.error.connect(self._on_error)
|
|||
|
|
self._worker.listening.connect(self._on_listening)
|
|||
|
|
self._worker.start()
|
|||
|
|
self._running = True
|
|||
|
|
self._start_btn.setEnabled(False)
|
|||
|
|
self._stop_btn.setEnabled(True)
|
|||
|
|
self._status_label.setText("🔴 监听中...")
|
|||
|
|
self._status_label.setStyleSheet("color: red;")
|
|||
|
|
|
|||
|
|
def stop(self) -> None:
|
|||
|
|
if self._worker:
|
|||
|
|
self._worker.stop()
|
|||
|
|
self._worker.wait(3000)
|
|||
|
|
self._running = False
|
|||
|
|
self._start_btn.setEnabled(True)
|
|||
|
|
self._stop_btn.setEnabled(False)
|
|||
|
|
self._status_label.setText("⏸ 已停止")
|
|||
|
|
self._status_label.setStyleSheet("color: gray;")
|
|||
|
|
self._progress.setVisible(False)
|
|||
|
|
|
|||
|
|
@pyqtSlot(str)
|
|||
|
|
def _on_recognized(self, text: str):
|
|||
|
|
self._result_label.setText(f"识别: {text}")
|
|||
|
|
io_signals.speech_recognized.emit(text)
|
|||
|
|
self._emit_input(text, metadata={"source": "microphone"})
|
|||
|
|
|
|||
|
|
@pyqtSlot(bool)
|
|||
|
|
def _on_listening(self, active: bool):
|
|||
|
|
self._progress.setVisible(active)
|
|||
|
|
if active:
|
|||
|
|
self._status_label.setText("🎙 正在聆听...")
|
|||
|
|
else:
|
|||
|
|
self._status_label.setText("⚙️ 识别中...")
|
|||
|
|
|
|||
|
|
@pyqtSlot(str)
|
|||
|
|
def _on_error(self, msg: str):
|
|||
|
|
self._result_label.setText(f"⚠️ {msg}")
|
|||
|
|
self._status_label.setStyleSheet("color: orange;")
|