stt_service.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  1. """
  2. STT服务模块 - 基于 DashScope OmniRealtimeConversation API
  3. 参考官方demo实现,提供流式语音识别功能
  4. """
  5. import asyncio
  6. import time
  7. import logging
  8. import traceback
  9. from typing import Optional, Callable, Awaitable
  10. import dashscope
  11. from dashscope.audio.qwen_omni import OmniRealtimeConversation, OmniRealtimeCallback
  12. from dashscope.audio.qwen_omni.omni_realtime import MultiModality, TranscriptionParams
  13. from config import settings, get_dashscope_ws_url
  14. logger = logging.getLogger(__name__)
  15. class STTCallback(OmniRealtimeCallback):
  16. """STT识别回调处理器(参考官方demo实现)"""
  17. def __init__(
  18. self,
  19. request_id: str,
  20. on_text_update: Optional[Callable[[str, bool], Awaitable[None]]] = None,
  21. on_error: Optional[Callable[[str], Awaitable[None]]] = None,
  22. event_loop: Optional[asyncio.AbstractEventLoop] = None
  23. ):
  24. """
  25. 初始化STT回调处理器
  26. Args:
  27. request_id: 请求ID(用于日志标识)
  28. on_text_update: 文本更新回调 (text, is_final) -> None
  29. on_error: 错误回调 (error_msg) -> None
  30. event_loop: 事件循环(用于线程安全的异步调用)
  31. """
  32. self.request_id = request_id
  33. self.on_text_update = on_text_update
  34. self.on_error = on_error
  35. self.event_loop = event_loop
  36. # 识别结果存储
  37. self.current_text = ""
  38. self.is_final = False
  39. self.final_event = asyncio.Event() if event_loop else None
  40. # 时间统计
  41. self.last_update_time = 0.0
  42. self.first_text_time = None # 首个文本生成时间
  43. self.session_id: Optional[str] = None
  44. # 语音检测统计
  45. self.speech_detected = False # 是否检测到语音
  46. self.speech_start_count = 0 # 语音开始次数
  47. self.speech_stop_count = 0 # 语音停止次数
  48. # 事件处理器映射(参考官方demo)
  49. self.handlers = {
  50. 'session.created': self._handle_session_created,
  51. 'conversation.item.input_audio_transcription.completed': self._handle_final_text,
  52. 'conversation.item.input_audio_transcription.text': self._handle_stash_text,
  53. 'input_audio_buffer.speech_started': self._handle_speech_start,
  54. 'input_audio_buffer.speech_stopped': self._handle_speech_stop,
  55. 'error': self._handle_error,
  56. }
  57. def on_open(self):
  58. """连接打开回调"""
  59. pass
  60. def on_close(self, code: int, msg: str):
  61. """连接关闭回调"""
  62. # 非正常关闭(1000是正常关闭码,None是主动关闭)
  63. if code is not None and code != 1000 and self.on_error:
  64. error_msg = f"STT连接异常关闭: {msg} (code: {code})"
  65. logger.error(
  66. f"[STT异常] request_id={self.request_id}, "
  67. f"错误类型=连接异常关闭, "
  68. f"关闭码={code}, "
  69. f"错误消息={msg}"
  70. )
  71. self._async_call(self.on_error(error_msg))
  72. def on_event(self, response: dict):
  73. """事件分发(参考官方demo)"""
  74. try:
  75. event_type = response.get('type')
  76. handler = self.handlers.get(event_type)
  77. # 日志记录(过滤掉高频事件)
  78. # if event_type not in ['input_audio_buffer.speech_started', 'input_audio_buffer.speech_stopped']:
  79. # print(f"[{self.request_id}] 📡 STT事件: {event_type}")
  80. if handler:
  81. handler(response)
  82. else:
  83. # 记录未处理的事件(排除常见的已知事件)
  84. known_events = [
  85. 'session.created',
  86. 'session.updated',
  87. 'input_audio_buffer.committed',
  88. 'conversation.item.created' # 会话项创建事件
  89. ]
  90. if event_type not in known_events:
  91. pass
  92. except Exception as e:
  93. logger.error(
  94. f"[STT异常] request_id={self.request_id}, "
  95. f"错误类型=事件处理异常, "
  96. f"异常类型={type(e).__name__}, "
  97. f"错误消息={str(e)}, "
  98. f"事件类型={response.get('type', 'unknown')}, "
  99. f"堆栈跟踪:\n{traceback.format_exc()}"
  100. )
  101. # ========== 事件处理器(参考官方demo) ==========
  102. def _handle_session_created(self, response: dict):
  103. """处理会话创建事件"""
  104. session_id = response.get('session', {}).get('id', '')
  105. self.session_id = session_id
  106. def _handle_final_text(self, response: dict):
  107. """处理最终识别结果(参考官方demo)"""
  108. text = response.get('transcript', '')
  109. # 更新状态
  110. self.current_text = text
  111. self.is_final = True
  112. self.last_update_time = time.time()
  113. # 触发最终结果事件
  114. if self.final_event and self.event_loop:
  115. try:
  116. self.event_loop.call_soon_threadsafe(self.final_event.set)
  117. except Exception as e:
  118. logger.error(
  119. f"[STT异常] request_id={self.request_id}, "
  120. f"错误类型=事件触发异常, "
  121. f"异常类型={type(e).__name__}, "
  122. f"错误消息={str(e)}, "
  123. f"堆栈跟踪:\n{traceback.format_exc()}"
  124. )
  125. # 回调通知
  126. if self.on_text_update:
  127. self._async_call(self.on_text_update(text, True))
  128. def _handle_stash_text(self, response: dict):
  129. """处理中间识别结果(参考官方demo)"""
  130. # 合并 text(已确认部分)和 stash(正在识别部分)
  131. confirmed_text = response.get('text', '')
  132. stash_text = response.get('stash', '')
  133. full_text = confirmed_text + stash_text
  134. # 更新状态(不打印中间识别,减少日志)
  135. if full_text:
  136. self.current_text = full_text
  137. self.is_final = False
  138. current_time = time.time()
  139. self.last_update_time = current_time
  140. # 记录首个文本生成时间
  141. if self.first_text_time is None and full_text:
  142. self.first_text_time = current_time
  143. # 回调通知
  144. if self.on_text_update:
  145. self._async_call(self.on_text_update(full_text, False))
  146. def _handle_speech_start(self, response: dict):
  147. """处理语音开始事件(静默处理,减少日志)"""
  148. self.speech_detected = True
  149. self.speech_start_count += 1
  150. def _handle_speech_stop(self, response: dict):
  151. """处理语音停止事件(静默处理,减少日志)"""
  152. self.speech_stop_count += 1
  153. def _handle_error(self, response: dict):
  154. """处理错误事件"""
  155. error = response.get('error', {})
  156. error_msg = error.get('message', 'Unknown error')
  157. error_code = error.get('code', '')
  158. # 详细记录STT API错误
  159. logger.error(
  160. f"[STT异常] request_id={self.request_id}, "
  161. f"错误类型=STT API错误, "
  162. f"错误码={error_code}, "
  163. f"错误消息={error_msg}, "
  164. f"完整响应={response}"
  165. )
  166. # 回调通知
  167. if self.on_error:
  168. self._async_call(self.on_error(f"{error_msg} (code: {error_code})"))
  169. # ========== 辅助方法 ==========
  170. def _async_call(self, coro):
  171. """线程安全的异步调用"""
  172. if self.event_loop:
  173. try:
  174. asyncio.run_coroutine_threadsafe(coro, self.event_loop)
  175. except Exception as e:
  176. logger.error(
  177. f"[STT异常] request_id={self.request_id}, "
  178. f"错误类型=异步调用异常, "
  179. f"异常类型={type(e).__name__}, "
  180. f"错误消息={str(e)}, "
  181. f"堆栈跟踪:\n{traceback.format_exc()}"
  182. )
  183. def get_text(self) -> str:
  184. """获取当前识别文本"""
  185. return self.current_text
  186. def is_final_result(self) -> bool:
  187. """是否为最终结果"""
  188. return self.is_final
  189. class STTSession:
  190. """STT识别会话管理器(参考官方demo实现)"""
  191. def __init__(
  192. self,
  193. request_id: str,
  194. on_text_update: Optional[Callable[[str, bool], Awaitable[None]]] = None,
  195. on_error: Optional[Callable[[str], Awaitable[None]]] = None,
  196. event_loop: Optional[asyncio.AbstractEventLoop] = None
  197. ):
  198. """
  199. 初始化STT会话
  200. Args:
  201. request_id: 请求ID
  202. on_text_update: 文本更新回调
  203. on_error: 错误回调
  204. event_loop: 事件循环
  205. """
  206. self.request_id = request_id
  207. self.callback = STTCallback(
  208. request_id=request_id,
  209. on_text_update=on_text_update,
  210. on_error=on_error,
  211. event_loop=event_loop
  212. )
  213. self.conversation: Optional[OmniRealtimeConversation] = None
  214. self.is_connected = False
  215. self.is_closed = False
  216. async def connect(self) -> bool:
  217. """
  218. 连接STT服务并初始化会话
  219. Returns:
  220. 是否连接成功
  221. """
  222. try:
  223. start_time = time.time()
  224. # 设置API Key
  225. dashscope.api_key = settings.active_dashscope_api_key
  226. # 创建会话对象(参考官方demo)
  227. def create_conversation():
  228. ws_url = get_dashscope_ws_url()
  229. # print(f"[{self.request_id}] STT WebSocket URL: {ws_url}")
  230. conversation = OmniRealtimeConversation(
  231. model='qwen3-asr-flash-realtime',
  232. url=ws_url,
  233. callback=self.callback
  234. )
  235. # 注入自身引用(回调中可能需要)
  236. self.callback.conversation = conversation
  237. return conversation
  238. # 异步创建会话
  239. self.conversation = await asyncio.to_thread(create_conversation)
  240. # 连接WebSocket
  241. await asyncio.to_thread(self.conversation.connect)
  242. # 配置转录参数(参考官方demo)
  243. # ⚠️ 重要:language参数是必需的!
  244. # 官方支持: 'zh'(中文), 'en'(英文), 'ja'(日文), 'ko'(韩文)
  245. # 尝试支持: 'auto'(自动检测) - 需要测试是否有效
  246. stt_language = getattr(settings, 'STT_LANGUAGE', 'zh')
  247. transcription_params = TranscriptionParams(
  248. language=stt_language,
  249. sample_rate=16000,
  250. input_audio_format="pcm"
  251. # corpus_text="" # 可选:辅助识别的语料
  252. )
  253. # 更新会话配置(禁用VAD,用户通过按钮控制录音)
  254. await asyncio.to_thread(
  255. self.conversation.update_session,
  256. output_modalities=[MultiModality.TEXT],
  257. enable_input_audio_transcription=True,
  258. transcription_params=transcription_params,
  259. enable_turn_detection=False
  260. )
  261. self.is_connected = True
  262. return True
  263. except Exception as e:
  264. logger.error(
  265. f"[STT异常] request_id={self.request_id}, "
  266. f"错误类型=STT连接失败, "
  267. f"异常类型={type(e).__name__}, "
  268. f"错误消息={str(e)}, "
  269. f"API Key={settings.active_dashscope_api_key[:10]}... (已脱敏), "
  270. f"使用国际版={settings.use_dashscope_intl}, "
  271. f"堆栈跟踪:\n{traceback.format_exc()}"
  272. )
  273. if self.callback.on_error:
  274. await self.callback.on_error(f"连接失败: {e}")
  275. return False
  276. async def send_audio(self, audio_data: bytes) -> bool:
  277. """
  278. 发送音频数据(参考官方demo)
  279. Args:
  280. audio_data: PCM音频数据(原始bytes)
  281. Returns:
  282. 是否发送成功
  283. """
  284. if not self.is_connected or not self.conversation:
  285. return False
  286. try:
  287. # Base64编码(API要求)
  288. import base64
  289. audio_b64 = base64.b64encode(audio_data).decode('ascii')
  290. # 发送音频帧(参考官方demo)
  291. await asyncio.to_thread(self.conversation.append_audio, audio_b64)
  292. return True
  293. except Exception as e:
  294. logger.error(
  295. f"[STT异常] request_id={self.request_id}, "
  296. f"错误类型=发送音频失败, "
  297. f"异常类型={type(e).__name__}, "
  298. f"错误消息={str(e)}, "
  299. f"音频数据长度={len(audio_data)} bytes, "
  300. f"连接状态=is_connected={self.is_connected}, "
  301. f"堆栈跟踪:\n{traceback.format_exc()}"
  302. )
  303. if self.callback.on_error:
  304. await self.callback.on_error(f"发送音频失败: {e}")
  305. return False
  306. async def commit_audio(self) -> bool:
  307. """
  308. 提交音频以触发识别(禁用VAD时需要手动调用)
  309. Returns:
  310. 是否提交成功
  311. """
  312. if not self.is_connected or not self.conversation:
  313. return False
  314. try:
  315. await asyncio.to_thread(self.conversation.commit)
  316. return True
  317. except Exception as e:
  318. logger.error(
  319. f"[STT异常] request_id={self.request_id}, "
  320. f"错误类型=提交音频失败, "
  321. f"异常类型={type(e).__name__}, "
  322. f"错误消息={str(e)}, "
  323. f"连接状态=is_connected={self.is_connected}, "
  324. f"堆栈跟踪:\n{traceback.format_exc()}"
  325. )
  326. return False
  327. async def wait_for_final_result(self, timeout: float = 3.0) -> Optional[str]:
  328. """
  329. 等待最终识别结果
  330. Args:
  331. timeout: 超时时间(秒)
  332. Returns:
  333. 识别文本,超时或失败返回None
  334. """
  335. if not self.callback.final_event:
  336. # print(f"[{self.request_id}] ⚠️ 无法等待最终结果(未设置事件循环)")
  337. return self.callback.get_text()
  338. try:
  339. await asyncio.wait_for(self.callback.final_event.wait(), timeout=timeout)
  340. return self.callback.get_text()
  341. except asyncio.TimeoutError:
  342. # 超时后,检查是否有中间结果
  343. current_text = self.callback.get_text()
  344. logger.warning(
  345. f"[STT警告] request_id={self.request_id}, "
  346. f"错误类型=等待识别结果超时, "
  347. f"超时时间={timeout}秒, "
  348. f"当前识别文本={current_text}"
  349. )
  350. return current_text
  351. except Exception as e:
  352. logger.error(
  353. f"[STT异常] request_id={self.request_id}, "
  354. f"错误类型=等待识别结果异常, "
  355. f"异常类型={type(e).__name__}, "
  356. f"错误消息={str(e)}, "
  357. f"超时时间={timeout}秒, "
  358. f"堆栈跟踪:\n{traceback.format_exc()}"
  359. )
  360. return None
  361. async def close(self):
  362. """关闭STT会话(参考官方demo)"""
  363. if self.is_closed:
  364. return
  365. try:
  366. if self.conversation:
  367. # 异步关闭连接
  368. await asyncio.to_thread(self.conversation.close)
  369. self.is_connected = False
  370. self.is_closed = True
  371. except Exception as e:
  372. logger.error(
  373. f"[STT异常] request_id={self.request_id}, "
  374. f"错误类型=关闭会话异常, "
  375. f"异常类型={type(e).__name__}, "
  376. f"错误消息={str(e)}, "
  377. f"连接状态=is_connected={self.is_connected}, is_closed={self.is_closed}, "
  378. f"堆栈跟踪:\n{traceback.format_exc()}"
  379. )
  380. def get_current_text(self) -> str:
  381. """获取当前识别文本"""
  382. return self.callback.get_text()
  383. def get_last_update_time(self) -> float:
  384. """获取最后更新时间"""
  385. return self.callback.last_update_time
  386. def get_first_text_time(self) -> Optional[float]:
  387. """获取首个文本生成时间"""
  388. return self.callback.first_text_time
  389. class STTService:
  390. """STT服务管理器(单例)"""
  391. _instance = None
  392. def __new__(cls):
  393. if cls._instance is None:
  394. cls._instance = super().__new__(cls)
  395. return cls._instance
  396. def __init__(self):
  397. """初始化STT服务"""
  398. if not hasattr(self, '_initialized'):
  399. self._initialized = True
  400. # 设置API Key
  401. dashscope.api_key = settings.active_dashscope_api_key
  402. # 国际版endpoint配置
  403. if settings.use_dashscope_intl:
  404. from config import get_dashscope_domain
  405. dashscope_domain = get_dashscope_domain()
  406. dashscope.base_http_api_url = f"https://{dashscope_domain}/api/v1"
  407. dashscope.base_websocket_api_url = f"wss://{dashscope_domain}/api-ws/v1"
  408. async def create_session(
  409. self,
  410. request_id: str,
  411. on_text_update: Optional[Callable[[str, bool], Awaitable[None]]] = None,
  412. on_error: Optional[Callable[[str], Awaitable[None]]] = None,
  413. event_loop: Optional[asyncio.AbstractEventLoop] = None
  414. ) -> STTSession:
  415. """
  416. 创建STT识别会话
  417. Args:
  418. request_id: 请求ID
  419. on_text_update: 文本更新回调
  420. on_error: 错误回调
  421. event_loop: 事件循环
  422. Returns:
  423. STT会话对象
  424. """
  425. session = STTSession(
  426. request_id=request_id,
  427. on_text_update=on_text_update,
  428. on_error=on_error,
  429. event_loop=event_loop
  430. )
  431. # 自动连接
  432. success = await session.connect()
  433. return session
  434. # 全局STT服务实例
  435. stt_service = STTService()