Files
blackbird/aibox/voice_client.py
T
qing 3e6e171831 fix(aibox): Batch M 波次5 客户端收尾(M1~M5/M7/M8)
- voice_client: stdin 单守护线程+Queue 替代每行 to_thread(泄漏/丢行);
  arecord 探测 timeout=5 + finally 清理临时文件
- client: main() finally 统一 disconnect websocket
- model_config: 主循环捕获 EOF/KeyboardInterrupt;api 节非 dict 重置;
  fallback 字符串规整(展示/删除清理);api_key_env 正则校验+
  显示字段拒控制字符限长
- 冒烟 7/7 通过;全量 493 passed/4 failed(既有平台项)/1 skipped 零回归
- 审计报告补 §22,Batch M 五波次收束
2026-08-20 14:40:00 +08:00

724 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
AI智能助手 - 语音命令行前端
支持WebSocket直连、语音输入、语音输出、命令系统
"""
import asyncio
import json
import sys
import os
import threading
import warnings
from typing import Optional, Dict, Any
import websockets
from colorama import init, Fore, Style as ColoramaStyle
init(autoreset=True)
warnings.filterwarnings('ignore')
try:
import speech_recognition as sr
except ImportError:
print("❌ 缺少语音识别库,请安装: pip install SpeechRecognition pyaudio")
sys.exit(1)
try:
import pyttsx3
except ImportError:
print("❌ 缺少文本转语音库,请安装: pip install pyttsx3")
sys.exit(1)
class Colors:
USER = Fore.CYAN
AI = Fore.GREEN
TASK = Fore.YELLOW
SYSTEM = Fore.BLUE
ERROR = Fore.RED
INFO = Fore.MAGENTA
WARNING = Fore.LIGHTYELLOW_EX
RESET = ColoramaStyle.RESET_ALL
BOLD = ColoramaStyle.BRIGHT
class TextToSpeech:
def __init__(self):
self.engine = None
self.available = False
old_stderr = None
try:
old_stderr = os.dup(2)
null_fd = os.open('/dev/null', os.O_WRONLY)
os.dup2(null_fd, 2)
os.close(null_fd)
self.engine = pyttsx3.init()
self.engine.setProperty('rate', 150)
voices = self.engine.getProperty('voices')
chinese_voice = None
for voice in voices:
try:
voice_name = str(voice.name).lower() if hasattr(voice, 'name') else ''
lang_attr = ''
if hasattr(voice, 'language'):
lang_attr = str(voice.language).lower()
elif hasattr(voice, 'languages'):
lang_attr = str(voice.languages[0]).lower() if voice.languages else ''
if 'chinese' in voice_name or 'zh' in lang_attr or 'mandarin' in voice_name:
if 'mandarin' in voice_name and 'pinyin' in voice_name:
chinese_voice = voice
break
elif chinese_voice is None:
chinese_voice = voice
except Exception:
continue
if chinese_voice:
self.engine.setProperty('voice', chinese_voice.id)
print(f"{Colors.INFO}🔊 已选择语音: {chinese_voice.name}{Colors.RESET}")
try:
self.engine.say("测试")
self.engine.runAndWait()
self.available = True
except Exception:
self.available = False
except Exception:
self.available = False
finally:
if old_stderr is not None:
os.dup2(old_stderr, 2)
os.close(old_stderr)
def speak(self, text: str):
if not self.available or not self.engine:
return
old_stderr = None
try:
old_stderr = os.dup(2)
null_fd = os.open('/dev/null', os.O_WRONLY)
os.dup2(null_fd, 2)
os.close(null_fd)
self.engine.say(text)
self.engine.runAndWait()
except Exception:
pass
finally:
if old_stderr is not None:
os.dup2(old_stderr, 2)
os.close(old_stderr)
class StreamingMessage:
def __init__(self):
self.content = ""
self.is_active = False
def add_chunk(self, chunk: str):
self.content += chunk
self.is_active = True
def get_content(self) -> str:
return self.content
def clear(self):
self.content = ""
self.is_active = False
class WebSocketClient:
def __init__(self, uri: str):
self.uri = uri
self.websocket = None
self.connected = False
self.streaming_messages: Dict[str, StreamingMessage] = {
'chat': StreamingMessage(),
'tasks': {}
}
self.running = True
async def connect(self):
try:
self.websocket = await websockets.connect(self.uri)
self.connected = True
return True
except Exception as e:
print(f"{Colors.ERROR}❌ 连接失败: {e}{Colors.RESET}")
return False
async def disconnect(self):
if self.websocket:
await self.websocket.close()
self.connected = False
async def send_message(self, message: str):
if not self.websocket or not self.connected:
print(f"{Colors.ERROR}❌ 未连接到服务器{Colors.RESET}")
return False
try:
if message.startswith('/'):
data = {'type': 'command', 'command': message}
else:
data = {'type': 'message', 'content': message}
await self.websocket.send(json.dumps(data))
return True
except Exception as e:
print(f"{Colors.ERROR}❌ 发送失败: {e}{Colors.RESET}")
return False
async def receive_messages(self, tts: TextToSpeech):
try:
async for message in self.websocket:
try:
data = json.loads(message)
await self.handle_message(data, tts)
except json.JSONDecodeError:
await self.handle_stream_chunk(message, 'chat', tts)
except websockets.exceptions.ConnectionClosed:
self.connected = False
print(f"\n{Colors.ERROR}❌ 连接已断开{Colors.RESET}")
except Exception as e:
# J14:异常退出同样标记断开(此前 connected 恒 True,用户继续
# "成功"发送但永远收不到回复,成僵尸连接)
self.connected = False
print(f"\n{Colors.ERROR}❌ 接收消息错误: {e}{Colors.RESET}")
async def handle_message(self, data: dict, tts: TextToSpeech):
msg_type = data.get('type', '')
content = data.get('content', '')
if msg_type == 'chunk':
await self.handle_stream_chunk(content, 'chat', tts)
elif msg_type == 'complete':
await self.handle_stream_complete('chat', tts)
elif msg_type == 'dialogue_ended':
await self.handle_dialogue_ended(tts)
elif msg_type == 'task_process':
msg_subtype = data.get('msg_type', '')
task_content = data.get('content', '')
task_id = data.get('task_id', 'unknown')
execution_id = data.get('execution_id', 'unknown')
task_title = data.get('task_title', '未知任务')
if msg_subtype == 'chunk':
await self.handle_stream_chunk(
task_content, 'task', tts,
{'task_id': task_id, 'execution_id': execution_id, 'title': task_title}
)
elif msg_subtype == 'complete':
await self.handle_stream_complete('task', tts, execution_id)
elif msg_type == 'task_status':
task_id = data.get('task_id', 'unknown')
title = data.get('title', '未知')
status = data.get('status', 'unknown')
message = data.get('message', content)
status_icon = {
'executing': '⚙️', 'complete': '✅', 'failed': '❌',
'added': '➕', 'batch_start': '🚀', 'batch_complete': '🏁'
}.get(status, '📋')
print(f"{Colors.TASK}{status_icon} [任务 {task_id}] {title}: {message}{Colors.RESET}")
elif msg_type == 'task_error':
task_id = data.get('task_id', 'unknown')
execution_id = data.get('execution_id', 'unknown')
task_title = data.get('task_title', '未知')
error = data.get('error', '未知错误')
print(f"{Colors.ERROR}❌ [任务 {task_id}] {task_title}: {error}{Colors.RESET}")
if tts:
tts.speak(f"任务 {task_id} 出错了,错误是 {error}")
if execution_id:
await self.handle_stream_complete('task', tts, execution_id)
elif msg_type == 'error':
print(f"{Colors.ERROR}❌ {content}{Colors.RESET}")
if tts:
tts.speak(f"错误,{content}")
elif msg_type == 'info':
print(f"{Colors.INFO}ℹ️ {content}{Colors.RESET}")
if tts:
tts.speak(content)
elif msg_type == 'command_result':
print(f"{Colors.SYSTEM}📋 {content}{Colors.RESET}")
elif msg_type == 'connection_ack':
print(f"{Colors.AI}✅ {content}{Colors.RESET}")
elif msg_type == 'notification':
print(f"{Colors.INFO}🔔 {content}{Colors.RESET}")
if tts:
tts.speak(content)
else:
if content:
await self.handle_stream_chunk(content, 'chat', tts)
async def handle_stream_chunk(self, content: str, msg_type: str = 'chat',
tts: TextToSpeech = None, task_info: dict = None):
if msg_type == 'chat':
stream = self.streaming_messages['chat']
# J14(审计 2026-08-19):前缀判断必须在 add_chunk 之前——
# add_chunk 会置 is_active=True,原顺序使 "AI助手:" 前缀永不打印
if not stream.is_active:
print(f"\n{Colors.AI}🤖 AI助手: ", end='', flush=True)
stream.is_active = True
stream.add_chunk(content)
print(content, end='', flush=True)
elif msg_type == 'task' and task_info:
execution_id = task_info.get('execution_id', 'unknown')
task_id = task_info.get('task_id', 'unknown')
title = task_info.get('title', '未知任务')
if execution_id not in self.streaming_messages['tasks']:
self.streaming_messages['tasks'][execution_id] = StreamingMessage()
print(f"\n{Colors.TASK}⚡ 定时任务 #{task_id} - {title}{Colors.RESET}")
print(f"{Colors.TASK}📋 ", end='', flush=True)
stream = self.streaming_messages['tasks'][execution_id]
stream.add_chunk(content)
print(content, end='', flush=True)
async def handle_stream_complete(self, msg_type: str = 'chat',
tts: TextToSpeech = None, execution_id: str = None):
if msg_type == 'chat':
stream = self.streaming_messages['chat']
print()
if tts and getattr(tts, 'available', True) and stream.content:
tts.speak(stream.content)
stream.clear()
elif msg_type == 'task' and execution_id:
if execution_id in self.streaming_messages['tasks']:
print()
del self.streaming_messages['tasks'][execution_id]
async def handle_dialogue_ended(self, tts: TextToSpeech):
print(f"\n{Colors.SYSTEM}✅ 对话已结束,可以开始新对话{Colors.RESET}")
if tts:
tts.speak("对话已结束")
self.streaming_messages['chat'].clear()
self.streaming_messages['tasks'].clear()
class VoiceInterface:
def __init__(self):
self.client: Optional[WebSocketClient] = None
self.tts = None
self.recognizer = None
self.microphone = None
self.whisper_model = None
self.has_audio = False
self.has_tts = False
self.has_microphone = False
try:
self.tts = TextToSpeech()
self.has_tts = self.tts.available
if self.has_tts:
print(f"{Colors.INFO}🔊 语音输出已就绪{Colors.RESET}")
else:
print(f"{Colors.WARNING}⚠️ 语音输出不可用{Colors.RESET}")
except Exception as e:
self.tts = None
self.has_tts = False
print(f"{Colors.WARNING}⚠️ 语音输出不可用: {e}{Colors.RESET}")
try:
self.recognizer = sr.Recognizer()
print(f"{Colors.INFO}🎤 尝试检测音频设备...{Colors.RESET}")
import subprocess
import tempfile
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
test_file = f.name
try:
result = subprocess.run(
['arecord', '-f', 'cd', '-d', '1', test_file],
capture_output=True, text=True,
timeout=5, # M7(审计 2026-08-20):设备挂死此前永久卡死
)
finally:
# M7:超时/异常路径同样清理探测临时文件(此前残留 /tmp);
# TimeoutExpired 传播至外层 except,按"麦克风不可用"降级
if os.path.exists(test_file):
os.unlink(test_file)
if result.returncode == 0:
print(f"{Colors.INFO}🎤 arecord 检测到设备,将使用 arecord 录音{Colors.RESET}")
self.has_microphone = True
self.use_arecord = True
print(f"{Colors.INFO}📥 加载 Whisper 模型(离线语音识别)...{Colors.RESET}")
try:
import whisper
self.whisper_model = whisper.load_model("medium", device="cpu")
print(f"{Colors.INFO}✅ Whisper 模型加载成功 (medium 模型){Colors.RESET}")
except Exception as e:
print(f"{Colors.WARNING}⚠️ Whisper 模型加载失败: {e}{Colors.RESET}")
self.whisper_model = None
print(f"{Colors.INFO}🎤 麦克风已就绪{Colors.RESET}")
else:
devices = sr.Microphone.list_microphone_names()
if devices:
print(f"{Colors.INFO}🎤 检测到 {len(devices)} 个音频设备{Colors.RESET}")
self.microphone = sr.Microphone()
with self.microphone as source:
self.recognizer.adjust_for_ambient_noise(source, duration=0.1)
self.has_microphone = True
self.use_arecord = False
print(f"{Colors.INFO}🎤 麦克风已就绪 (使用 speech_recognition){Colors.RESET}")
else:
raise RuntimeError("没有检测到音频设备")
except Exception as e:
print(f"{Colors.WARNING}⚠️ 麦克风不可用: {e}{Colors.RESET}")
self.has_microphone = False
self.use_arecord = False
self.has_audio = self.has_tts or self.has_microphone
if not self.has_audio:
print(f"{Colors.INFO}ℹ️ 将以纯文本模式运行{Colors.RESET}")
self.commands = {
'/help': '显示帮助信息',
'/connect': '连接到服务器',
'/disconnect': '断开连接',
'/quit': '退出程序',
'/clear': '清屏',
'/status': '显示连接状态',
'/list': '列出可用命令',
'/memories': '查看记忆',
'/memos': '查看备忘录',
'/check': '检查提醒',
'/config': '查看配置',
'/new': '新建对话',
'/tasks': '查看任务列表',
}
def print_banner(self):
banner = f"""
{Colors.BOLD}{Colors.AI}
╔══════════════════════════════════════════╗
║ AI智能助手 - 语音前端 ║
║ WebSocket 直连版本 ║
╚══════════════════════════════════════════╝
{Colors.RESET}
"""
print(banner)
print(f"{Colors.INFO}输入 /help 查看可用命令")
print(f"输入语音开始对话,或输入文本命令{Colors.RESET}")
print()
def listen_voice(self) -> Optional[str]:
"""监听语音输入"""
if not self.has_microphone:
print(f"{Colors.WARNING}⚠️ 麦克风不可用,请使用文本输入{Colors.RESET}")
return None
if not self.client or not self.client.connected:
print(f"{Colors.ERROR}❌ 未连接到服务器,请先连接{Colors.RESET}")
return None
print(f"{Colors.INFO}🎤 正在听... (按 Ctrl+C 取消){Colors.RESET}")
temp_file = None # J14:录音临时文件,任何异常路径统一清理
try:
if self.use_arecord:
import subprocess
import tempfile
import wave
import numpy as np
import whisper
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
temp_file = f.name
result = subprocess.run(
['arecord', '-f', 'cd', '-d', '5', temp_file],
capture_output=True, text=True,
timeout=10
)
if result.returncode != 0:
print(f"{Colors.ERROR}❌ 录音失败: {result.stderr}{Colors.RESET}")
if os.path.exists(temp_file):
os.unlink(temp_file)
return None
if not os.path.exists(temp_file) or os.path.getsize(temp_file) == 0:
print(f"{Colors.WARNING}⚠️ 录音文件为空{Colors.RESET}")
if os.path.exists(temp_file):
os.unlink(temp_file)
return None
if self.whisper_model:
with wave.open(temp_file, 'rb') as wav_file:
frames = wav_file.readframes(wav_file.getnframes())
audio_data = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
os.unlink(temp_file)
audio_padded = whisper.pad_or_trim(audio_data)
mel = whisper.log_mel_spectrogram(audio_padded, n_mels=80)
options = whisper.DecodingOptions(language='zh', without_timestamps=True)
result = whisper.decode(self.whisper_model, mel, options)
text = result.text.strip()
if text:
print(f"{Colors.USER}👤 语音输入: {text}{Colors.RESET}")
return text
else:
print(f"{Colors.INFO}⚠️ 未识别到语音内容{Colors.RESET}")
return None
else:
with sr.AudioFile(temp_file) as source:
audio = self.recognizer.record(source)
os.unlink(temp_file)
text = self.recognizer.recognize_google(audio, language='zh-CN')
print(f"{Colors.USER}👤 语音输入: {text}{Colors.RESET}")
return text
else:
with self.microphone as source:
self.recognizer.adjust_for_ambient_noise(source)
audio = self.recognizer.listen(source, timeout=15)
text = self.recognizer.recognize_google(audio, language='zh-CN')
print(f"{Colors.USER}👤 语音输入: {text}{Colors.RESET}")
return text
except sr.WaitTimeoutError:
print(f"{Colors.INFO}⏱️ 等待超时,请重试{Colors.RESET}")
return None
except sr.UnknownValueError:
print(f"{Colors.INFO}😕 无法识别语音,请重试{Colors.RESET}")
return None
except sr.RequestError as e:
print(f"{Colors.ERROR}❌ 语音识别服务错误: {e}{Colors.RESET}")
return None
except Exception as e:
print(f"{Colors.ERROR}❌ 语音输入错误: {e}{Colors.RESET}")
# J14:录音临时文件(含用户语音)任何异常路径都清理——
# 此前 wave.open/whisper 解析失败、arecord 超时等滞留 /tmp
if temp_file and os.path.exists(temp_file):
try:
os.unlink(temp_file)
except OSError:
pass
return None
async def handle_command(self, command: str):
parts = command.split()
cmd = parts[0].lower()
if cmd == '/help':
self.show_help()
elif cmd == '/connect':
if len(parts) < 2:
print(f"{Colors.ERROR}用法: /connect ws://host:port{Colors.RESET}")
return
uri = parts[1]
if self.client and self.client.connected:
await self.client.disconnect()
self.client = WebSocketClient(uri)
if await self.client.connect():
print(f"{Colors.AI}✅ 已连接到 {uri}{Colors.RESET}")
if self.tts and self.has_tts:
self.tts.speak(f"已连接到 {uri}")
asyncio.create_task(self.client.receive_messages(self.tts))
else:
self.client = None
elif cmd == '/disconnect':
if self.client:
await self.client.disconnect()
self.client = None
print(f"{Colors.SYSTEM}已断开连接{Colors.RESET}")
if self.tts and self.has_tts:
self.tts.speak("已断开连接")
else:
print(f"{Colors.ERROR}未连接到服务器{Colors.RESET}")
elif cmd == '/status':
if self.client and self.client.connected:
print(f"{Colors.AI}✅ 已连接: {self.client.uri}{Colors.RESET}")
if self.tts and self.has_tts:
self.tts.speak(f"已连接到 {self.client.uri}")
else:
print(f"{Colors.SYSTEM}未连接{Colors.RESET}")
if self.tts and self.has_tts:
self.tts.speak("未连接")
elif cmd == '/clear':
os.system('clear' if os.name == 'posix' else 'cls')
self.print_banner()
elif cmd == '/quit':
print(f"{Colors.SYSTEM}再见!{Colors.RESET}")
if self.tts:
self.tts.speak("再见")
if self.client:
await self.client.disconnect()
sys.exit(0)
elif cmd in self.commands:
if self.client and self.client.connected:
await self.client.send_message(command)
else:
print(f"{Colors.ERROR}未连接到服务器,请先使用 /connect 连接{Colors.RESET}")
else:
print(f"{Colors.ERROR}未知命令: {cmd}{Colors.RESET}")
def show_help(self):
help_text = f"""
{Colors.BOLD}可用命令:{Colors.RESET}
{Colors.USER}本地命令:{Colors.RESET}
/connect <url> 连接到WebSocket服务器
/disconnect 断开连接
/status 显示连接状态
/clear 清屏
/quit 退出程序
/help 显示此帮助
{Colors.AI}服务器命令 (需要先连接):{Colors.RESET}
"""
for cmd, desc in self.commands.items():
if cmd not in ['/help', '/connect', '/disconnect', '/quit', '/clear', '/status']:
help_text += f" {cmd:<15} {desc}\n"
print(help_text)
async def send_user_message(self, message: str):
if not self.client or not self.client.connected:
print(f"{Colors.ERROR}❌ 未连接到服务器{Colors.RESET}")
return
print(f"\n{Colors.USER}👤 用户: {message}{Colors.RESET}")
await self.client.send_message(message)
def _stdin_reader(self, loop: asyncio.AbstractEventLoop):
"""M1(审计 2026-08-20):单守护线程读 stdin——替代 asyncio.to_thread
每行一个 input 线程的方案:Ctrl+C 取消 await 后旧线程仍阻塞 readline
永久泄漏,并"偷吃"后续输入整行(表现为偶发丢行)。
方案照搬 client.py:735-755(守护线程 + Queue + call_soon_threadsafe)。"""
def _put(item):
try:
loop.call_soon_threadsafe(self._input_queue.put_nowait, item)
except RuntimeError:
pass # 事件循环已关闭(程序退出中)
while self._running:
try:
line = sys.stdin.readline()
except Exception:
break
if not line: # EOF (Ctrl+D)
_put(None)
break
_put(line)
async def run(self):
self.print_banner()
prompt = f"{Colors.USER}>>> {Colors.RESET}"
# M1:进程生命周期只启动一次 stdin 守护线程
self._input_queue = asyncio.Queue()
self._running = True
loop = asyncio.get_running_loop()
if getattr(self, '_stdin_thread', None) is None:
self._stdin_thread = threading.Thread(
target=self._stdin_reader, args=(loop,),
daemon=True, name="stdin-reader")
self._stdin_thread.start()
while True:
try:
print(prompt, end='', flush=True)
line = await self._input_queue.get()
if line is None: # EOF (Ctrl+D)
raise EOFError
user_input = line.strip()
if not user_input:
if self.has_microphone:
print(f"{Colors.INFO}🎤 开始语音输入...{Colors.RESET}")
voice_text = self.listen_voice()
if voice_text:
await self.send_user_message(voice_text)
else:
print(f"{Colors.WARNING}⚠️ 麦克风不可用,请输入文本{Colors.RESET}")
continue
if user_input.startswith('/'):
await self.handle_command(user_input)
else:
await self.send_user_message(user_input)
except KeyboardInterrupt:
print(f"\n{Colors.INFO}⏹️ 已取消{Colors.RESET}")
continue
except EOFError:
print(f"\n{Colors.SYSTEM}再见!{Colors.RESET}")
if self.tts:
self.tts.speak("再见")
if self.client:
await self.client.disconnect()
break
except Exception as e:
print(f"{Colors.ERROR}错误: {e}{Colors.RESET}")
async def main():
cli = VoiceInterface()
default_uri = "ws://localhost:8765"
uri = sys.argv[1] if len(sys.argv) > 1 else default_uri
cli.client = WebSocketClient(uri)
if await cli.client.connect():
print(f"{Colors.AI}✅ 已连接到 {uri}{Colors.RESET}")
if cli.tts and cli.has_tts:
cli.tts.speak(f"已连接到 {uri}")
asyncio.create_task(cli.client.receive_messages(cli.tts))
else:
print(f"{Colors.WARNING}⚠️ 无法连接到 {uri}{Colors.RESET}")
print(f"{Colors.INFO}ℹ️ 你可以通过命令行参数指定地址: python voice_client.py ws://your-server:8765{Colors.RESET}")
print(f"{Colors.INFO}ℹ️ 或者使用 /connect 命令手动连接{Colors.RESET}")
try:
await cli.run()
except asyncio.CancelledError:
pass
finally:
if cli.client:
await cli.client.disconnect()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print(f"\n{Colors.SYSTEM}程序已退出{Colors.RESET}")