"""云端 gummy 引擎:阿里云百炼 DashScope 流式 ASR(gummy-realtime-v1)。 协议对照 server/internal/asr/gummy.go: run-task → 等 task-started → 推二进制 PCM(3200B/16k/pcm) → finish-task → 收 task-finished。 下行解析 payload.output.transcription(gummy) 或 sentence(paraformer), sentence_end 兼容 bool 与字符串 "true"/"false"(gummy.go 的 flexBool 怪癖)。 """ from __future__ import annotations import json import threading import time import uuid import websocket # websocket-client from ..audio import load_pcm16 from .base import Engine, Transcript WS_URL = "wss://dashscope.aliyuncs.com/api-ws/v1/inference" FRAME = 3200 # 1600 samples = 100ms @16k def _flex_bool(v) -> bool: return v is True or v == "true" class GummyEngine(Engine): name = "gummy" kind = "streaming" is_local = False def __init__( self, name: str = "gummy", api_key: str = "", model: str = "gummy-realtime-v1", cost_per_min: float | None = None, realtime_factor: float = 2.0, ): self.name = name self.api_key = api_key self.model = model self._cost = cost_per_min self.rt = realtime_factor # 推流倍速;2.0 = 2x 实时(同 gummycheck) def cost_per_min(self) -> float | None: return self._cost def _run_task_msg(self, task_id: str, sample_rate: int) -> str: return json.dumps( { "header": {"action": "run-task", "task_id": task_id, "streaming": "duplex"}, "payload": { "task_group": "audio", "task": "asr", "function": "recognition", "model": self.model, "parameters": { "sample_rate": sample_rate, "format": "pcm", "transcription_enabled": True, "translation_enabled": False, }, "input": {}, }, } ) def _finish_task_msg(self, task_id: str) -> str: return json.dumps( { "header": {"action": "finish-task", "task_id": task_id, "streaming": "duplex"}, "payload": {"input": {}}, } ) def transcribe(self, audio_path: str) -> Transcript: pcm, sr = load_pcm16(audio_path, 16000) audio = pcm.tobytes() audio_sec = len(pcm) / sr task_id = uuid.uuid4().hex started = threading.Event() finished = threading.Event() results: list[tuple[float, str, bool]] = [] # (rel_t, text, is_final) err: list[str | None] = [None] first_partial: list[float | None] = [None] t0 = time.monotonic() try: ws = websocket.create_connection( WS_URL, header=[ f"Authorization: bearer {self.api_key}", "X-DashScope-DataInspection: enable", ], timeout=20, ) except Exception as ex: # 连接失败 return Transcript(text="", audio_sec=audio_sec, proc_sec=time.monotonic() - t0, error=f"dial: {ex}") def reader() -> None: try: while True: msg = ws.recv() if not msg: continue ev = json.loads(msg) h = ev.get("header", {}) event = h.get("event") if event == "task-started": started.set() elif event == "result-generated": out = ev.get("payload", {}).get("output", {}) sen = out.get("transcription") or out.get("sentence") if not sen or not sen.get("text"): continue is_final = _flex_bool(sen.get("sentence_end")) or _flex_bool(sen.get("is_sentence_end")) if first_partial[0] is None: first_partial[0] = time.monotonic() - t0 results.append((time.monotonic() - t0, sen["text"], is_final)) elif event == "task-finished": finished.set() break elif event == "task-failed": err[0] = h.get("error_message", "task-failed") finished.set() break except Exception as ex: if not finished.is_set(): err[0] = str(ex) finished.set() rt = threading.Thread(target=reader, daemon=True) rt.start() try: ws.send(self._run_task_msg(task_id, sr)) if not started.wait(timeout=10): ws.close() return Transcript( text="", audio_sec=audio_sec, proc_sec=time.monotonic() - t0, error="task-started timeout" ) # 推流:每帧 100ms 音频,按倍速 sleep(2x → 50ms) per_frame_sleep = 0.1 / self.rt for off in range(0, len(audio), FRAME): ws.send_binary(audio[off : off + FRAME]) time.sleep(per_frame_sleep) last_audio_t = time.monotonic() ws.send(self._finish_task_msg(task_id)) finished.wait(timeout=30) except Exception as ex: err[0] = err[0] or str(ex) finally: try: ws.close() except Exception: pass # gummy 按句给 final;拼接所有 final,无 final 则回退最后一条 partial finals = [t for _, t, f in results if f] text = "".join(finals) if finals else (results[-1][1] if results else "") finalize_sec = None final_rel_times = [rel for rel, _, f in results if f] if final_rel_times: finalize_sec = max(0.0, (t0 + final_rel_times[-1]) - last_audio_t) return Transcript( text=text, audio_sec=audio_sec, proc_sec=time.monotonic() - t0, first_partial_sec=first_partial[0], finalize_sec=finalize_sec, error=err[0], )