287 lines
10 KiB
Python
287 lines
10 KiB
Python
# coding: utf-8
|
||
|
||
import os
|
||
import subprocess
|
||
import tempfile
|
||
|
||
from loguru import logger
|
||
from PyQt6.QtCore import QEventLoop, QObject, QThread, pyqtSignal
|
||
from PyQt6.QtCore import QUrl
|
||
from PyQt6.QtMultimedia import QAudioOutput, QMediaPlayer
|
||
|
||
|
||
class LocalTextToSpeechThread(QThread):
|
||
"""本地文本转语音播放线程 - Windows优化版"""
|
||
|
||
# 信号定义
|
||
playback_started = pyqtSignal() # 播放开始
|
||
playback_finished = pyqtSignal() # 播放完成
|
||
playback_error = pyqtSignal(str) # 播放错误
|
||
progress_updated = pyqtSignal(int) # 播放进度更新
|
||
synthesis_completed = pyqtSignal(str) # 语音合成完成(返回文件路径)
|
||
|
||
def __init__(self, text, parent=None):
|
||
super().__init__(parent)
|
||
self.text = text
|
||
self.audio_file_path = None
|
||
self.media_player = None
|
||
self.audio_output = None
|
||
self._stop_requested = False
|
||
|
||
def run(self):
|
||
"""线程执行函数"""
|
||
try:
|
||
# 1. 将文本转换为语音文件
|
||
self.audio_file_path = self._text_to_speech(self.text)
|
||
if not self.audio_file_path or self._stop_requested:
|
||
return
|
||
|
||
# 发射合成完成信号
|
||
self.synthesis_completed.emit(self.audio_file_path)
|
||
|
||
# 2. 播放语音
|
||
self._play_audio(self.audio_file_path)
|
||
|
||
except Exception as e:
|
||
self.playback_error.emit(f"语音播放错误: {str(e)}")
|
||
|
||
def _text_to_speech(self, text):
|
||
"""使用本地TTS引擎将文本转换为语音文件"""
|
||
try:
|
||
# 检查文本长度
|
||
if not text or len(text.strip()) == 0:
|
||
self.playback_error.emit("文本内容为空")
|
||
return None
|
||
|
||
# 限制文本长度,避免合成时间过长
|
||
max_length = 1000
|
||
if len(text) > max_length:
|
||
text = text[:max_length] + "。文本过长,已截断。"
|
||
self.playback_error.emit(f"文本过长,已截断前{max_length}个字符")
|
||
|
||
# 优先使用pyttsx3,效率最高
|
||
try:
|
||
import pyttsx3
|
||
return self._pyttsx3_tts(text)
|
||
except ImportError:
|
||
# 备用方案:使用Windows内置TTS
|
||
return self._windows_tts(text)
|
||
|
||
except Exception as e:
|
||
self.playback_error.emit(f"语音合成失败: {str(e)}")
|
||
return None
|
||
|
||
def _pyttsx3_tts(self, text):
|
||
"""使用pyttsx3合成语音 - 优化版"""
|
||
try:
|
||
import pyttsx3
|
||
|
||
# 初始化TTS引擎
|
||
engine = pyttsx3.init()
|
||
|
||
# 设置语音属性 - 提高效率
|
||
engine.setProperty('rate', 200) # 提高语速
|
||
engine.setProperty('volume', 0.9) # 提高音量
|
||
|
||
# 创建临时文件保存音频
|
||
with tempfile.NamedTemporaryFile(delete=False, suffix='.wav') as temp_file:
|
||
temp_path = temp_file.name
|
||
|
||
# 保存语音到文件
|
||
engine.save_to_file(text, temp_path)
|
||
engine.runAndWait()
|
||
|
||
# 检查文件是否成功创建
|
||
if os.path.exists(temp_path) and os.path.getsize(temp_path) > 0:
|
||
return temp_path
|
||
else:
|
||
logger.error("语音文件生成失败")
|
||
|
||
except Exception as e:
|
||
# 如果pyttsx3失败,尝试Windows TTS
|
||
return self._windows_tts(text)
|
||
|
||
def _windows_tts(self, text):
|
||
"""Windows系统TTS - 优化版"""
|
||
try:
|
||
# 方法1: 使用PowerShell命令 - 最可靠
|
||
return self._powershell_tts(text)
|
||
except Exception as e:
|
||
logger.error(f"Windows TTS失败: {str(e)}")
|
||
|
||
def _powershell_tts(self, text):
|
||
"""使用PowerShell合成语音 - 优化版"""
|
||
try:
|
||
# 创建临时文件
|
||
with tempfile.NamedTemporaryFile(delete=False, suffix='.wav') as temp_file:
|
||
temp_path = temp_file.name
|
||
|
||
# 转义文本中的特殊字符
|
||
escaped_text = text.replace('"', '`"').replace("'", "`'")
|
||
|
||
# 使用PowerShell的SpeechSynthesizer - 简化命令
|
||
ps_script = f"""
|
||
Add-Type -AssemblyName System.Speech
|
||
$speak = New-Object System.Speech.Synthesis.SpeechSynthesizer
|
||
$speak.SetOutputToWaveFile("{temp_path}")
|
||
$speak.Speak("{escaped_text}")
|
||
$speak.Dispose()
|
||
"""
|
||
|
||
# 使用更高效的方式执行PowerShell
|
||
process = subprocess.Popen(
|
||
["powershell", "-Command", ps_script],
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
shell=True
|
||
)
|
||
|
||
# 等待进程完成,设置超时
|
||
try:
|
||
stdout, stderr = process.communicate(timeout=30)
|
||
if process.returncode != 0:
|
||
logger.error(f"PowerShell执行失败: {stderr.decode('gbk', errors='ignore')}")
|
||
except subprocess.TimeoutExpired:
|
||
process.kill()
|
||
logger.error(f"PowerShell超时")
|
||
|
||
if os.path.exists(temp_path) and os.path.getsize(temp_path) > 0:
|
||
return temp_path
|
||
else:
|
||
logger.error("语音文件生成失败")
|
||
|
||
except Exception as e:
|
||
# raise Exception(f"PowerShell TTS失败: {str(e)}")
|
||
logger.error(f"PowerShell TTS失败{e}")
|
||
|
||
def _play_audio(self, file_path):
|
||
"""播放音频文件 - 优化版"""
|
||
if self._stop_requested:
|
||
return
|
||
|
||
try:
|
||
# 创建媒体播放器和音频输出
|
||
self.media_player = QMediaPlayer()
|
||
self.audio_output = QAudioOutput()
|
||
self.media_player.setAudioOutput(self.audio_output)
|
||
|
||
# 设置音量
|
||
self.audio_output.setVolume(1.0)
|
||
|
||
# 连接信号
|
||
self.media_player.playbackStateChanged.connect(self._on_playback_state_changed)
|
||
self.media_player.positionChanged.connect(self._on_position_changed)
|
||
self.media_player.durationChanged.connect(self._on_duration_changed)
|
||
self.media_player.errorOccurred.connect(self._on_player_error)
|
||
|
||
# 设置媒体源并开始播放
|
||
self.media_player.setSource(QUrl.fromLocalFile(file_path))
|
||
self.media_player.play()
|
||
|
||
# 使用事件循环等待播放完成
|
||
loop = QEventLoop()
|
||
self.media_player.playbackStateChanged.connect(
|
||
lambda state: loop.quit() if state == QMediaPlayer.PlaybackState.StoppedState else None
|
||
)
|
||
loop.exec()
|
||
|
||
except Exception as e:
|
||
raise Exception(f"音频播放失败: {str(e)}")
|
||
finally:
|
||
# 清理临时文件
|
||
if file_path and os.path.exists(file_path):
|
||
try:
|
||
os.unlink(file_path)
|
||
except:
|
||
pass
|
||
|
||
def _on_playback_state_changed(self, state):
|
||
"""处理播放状态变化"""
|
||
from PyQt6.QtMultimedia import QMediaPlayer
|
||
if state == QMediaPlayer.PlaybackState.StoppedState:
|
||
self.playback_finished.emit()
|
||
|
||
def _on_position_changed(self, position):
|
||
"""处理播放位置变化"""
|
||
if (self.media_player and
|
||
self.media_player.duration() > 0):
|
||
progress = int((position / self.media_player.duration()) * 100)
|
||
self.progress_updated.emit(progress)
|
||
|
||
def _on_duration_changed(self, duration):
|
||
"""处理时长变化"""
|
||
if duration > 0:
|
||
self.playback_started.emit()
|
||
|
||
def _on_player_error(self, error, error_string):
|
||
"""处理播放器错误"""
|
||
self.playback_error.emit(f"播放器错误: {error_string}")
|
||
|
||
def stop_playback(self):
|
||
"""停止播放"""
|
||
self._stop_requested = True
|
||
|
||
if self.media_player and self.media_player.playbackState() != QMediaPlayer.PlaybackState.StoppedState:
|
||
self.media_player.stop()
|
||
|
||
# 清理临时文件
|
||
if self.audio_file_path and os.path.exists(self.audio_file_path):
|
||
try:
|
||
os.unlink(self.audio_file_path)
|
||
except:
|
||
pass
|
||
|
||
|
||
class LocalSpeechController(QObject):
|
||
"""本地语音播放控制器"""
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.speech_thread = None
|
||
|
||
def play_text(self, text):
|
||
"""播放文本语音"""
|
||
# 停止当前播放
|
||
self.stop_playback()
|
||
|
||
# 创建新的语音线程
|
||
self.speech_thread = LocalTextToSpeechThread(text)
|
||
|
||
# 连接信号
|
||
self.speech_thread.playback_started.connect(self._on_playback_started)
|
||
self.speech_thread.playback_finished.connect(self._on_playback_finished)
|
||
self.speech_thread.playback_error.connect(self._on_playback_error)
|
||
self.speech_thread.progress_updated.connect(self._on_progress_updated)
|
||
self.speech_thread.synthesis_completed.connect(self._on_synthesis_completed)
|
||
|
||
# 开始播放
|
||
self.speech_thread.start()
|
||
|
||
def stop_playback(self):
|
||
"""停止播放"""
|
||
if self.speech_thread and self.speech_thread.isRunning():
|
||
self.speech_thread.stop_playback()
|
||
self.speech_thread.wait(1000) # 等待线程结束,最多1秒
|
||
|
||
def is_playing(self):
|
||
"""检查是否正在播放"""
|
||
return self.speech_thread and self.speech_thread.isRunning()
|
||
|
||
def _on_playback_started(self):
|
||
"""处理播放开始"""
|
||
logger.info("语音播放开始")
|
||
|
||
def _on_playback_finished(self):
|
||
"""处理播放完成"""
|
||
logger.success("语音播放完成")
|
||
|
||
def _on_playback_error(self, error_msg):
|
||
"""处理播放错误"""
|
||
logger.warning(f"语音播放错误: {error_msg}")
|
||
|
||
def _on_progress_updated(self, progress):
|
||
...
|
||
|
||
def _on_synthesis_completed(self, file_path):
|
||
"""处理语音合成完成"""
|
||
logger.info(f"语音合成完成,文件路径: {file_path}") |