Download src/agentcade/speech.py from left0ver/WatcherRobot-com.left0ver.agentcade: direct link, hf CLI and curl.
- Browser
- Download file 10.6 kB
-
https://huggingface.co/spaces/left0ver/WatcherRobot-com.left0ver.agentcade/resolve/main/src/agentcade/speech.py
- Command line
-
hf download hf://spaces/left0ver/WatcherRobot-com.left0ver.agentcade/src/agentcade/speech.py
-
curl -L -o speech.py https://huggingface.co/spaces/left0ver/WatcherRobot-com.left0ver.agentcade/resolve/main/src/agentcade/speech.py
10.6 kB
| import asyncio | |
| import json | |
| from collections import deque | |
| from collections.abc import AsyncIterator, Callable | |
| from contextlib import aclosing, asynccontextmanager | |
| from dataclasses import dataclass | |
| from uuid import uuid4 | |
| from watcherobot.audio import MAX_AUDIO_BYTES, PCMAudio | |
| from websockets.asyncio.client import ClientConnection, connect | |
| from agentcade.audio_stream import CapturedPcm | |
| from agentcade.config import Settings | |
| class RecognizedSpeech: | |
| text: str | |
| started_at: float | |
| ended_at: float | None = None | |
| # 识别结果返回时,已经送入识别服务的最后一段音频的采集时间。 | |
| sent_until: float | None = None | |
| class SpeechTimeline: | |
| def __init__(self): | |
| self.frames = deque(maxlen=30000) | |
| self.sent_samples = 0 | |
| def append(self, frame: CapturedPcm): | |
| if not frame.data or len(frame.data) % 2: | |
| raise ValueError("ASR 输入必须包含完整的 16-bit PCM 采样") | |
| self.frames.append((self.sent_samples / 16, frame.captured_at, frame.muted)) | |
| self.sent_samples += len(frame.data) // 2 | |
| def captured_at(self, position: float) -> float: | |
| offset, captured_at, _ = next( | |
| item for item in reversed(self.frames) if item[0] <= position | |
| ) | |
| return captured_at + (position - offset) / 1000 | |
| def started_at(self, begin: float, end: float) -> float | None: | |
| if not self.frames or begin < self.frames[0][0] or end < begin: | |
| raise ValueError("ASR 句子时间超出保留的音频记录") | |
| offset, captured_at, muted = next( | |
| item for item in reversed(self.frames) if item[0] <= begin | |
| ) | |
| if muted or any(item[2] for item in self.frames if begin <= item[0] < end): | |
| return None | |
| return captured_at + (begin - offset) / 1000 | |
| class AudioTask: | |
| socket: ClientConnection | |
| task_id: str | |
| async def send(self, action: str, payload: dict) -> None: | |
| await self.socket.send(json.dumps({ | |
| "header": {"action": action, "task_id": self.task_id, "streaming": "duplex"}, | |
| "payload": payload, | |
| }, ensure_ascii=False)) | |
| async def receive(self) -> bytes | dict: | |
| message = await self.socket.recv() | |
| if isinstance(message, bytes): | |
| return message | |
| event = json.loads(message) | |
| header = event["header"] | |
| if header["task_id"] != self.task_id: | |
| raise RuntimeError("语音服务返回了其他任务的数据") | |
| if header["event"] == "task-failed": | |
| raise RuntimeError(f"语音服务失败:{header['error_code']},{header['error_message']}") | |
| return event | |
| class QwenSpeech: | |
| def __init__(self, settings: Settings): | |
| self.settings = settings | |
| async def task(self, kind: str, model: str, parameters: dict) -> AsyncIterator[AudioTask]: | |
| async with connect( | |
| self.settings.websocket_url, | |
| additional_headers={"Authorization": f"Bearer {self.settings.dashscope_api_key}"}, | |
| open_timeout=10, close_timeout=0.5, max_size=MAX_AUDIO_BYTES, | |
| ) as socket: | |
| task = AudioTask(socket, str(uuid4())) | |
| await task.send("run-task", { | |
| "task_group": "audio", "task": kind, | |
| "function": "SpeechSynthesizer" if kind == "tts" else "recognition", | |
| "model": model, "parameters": parameters, "input": {}, | |
| }) | |
| async with asyncio.timeout(15): | |
| event = await task.receive() | |
| if isinstance(event, bytes) or event["header"]["event"] != "task-started": | |
| raise RuntimeError("语音服务没有确认任务启动") | |
| yield task | |
| async def synthesize(self, text: str) -> bytes: | |
| audio = bytearray() | |
| async with aclosing(self.synthesize_stream(text)) as chunks: | |
| async for chunk in chunks: | |
| audio.extend(chunk) | |
| return bytes(audio) | |
| async def synthesize_stream(self, text: str) -> AsyncIterator[bytes]: | |
| if not text.strip() or len(text) > 500: | |
| raise ValueError("每次语音反馈必须包含 1 至 500 个字符") | |
| parameters = { | |
| "text_type": "PlainText", "voice": self.settings.tts_voice, | |
| "format": "pcm", "sample_rate": 24000, | |
| "language_hints": ["zh"], "volume": 10, "rate": 1.0, | |
| } | |
| async with asyncio.timeout(self.settings.speech_timeout_seconds): | |
| async with self.task("tts", self.settings.tts_model, parameters) as task: | |
| await task.send("continue-task", {"input": {"text": text}}) | |
| await task.send("finish-task", {"input": {}}) | |
| total_bytes = 0 | |
| pending = bytearray() | |
| while True: | |
| event = await task.receive() | |
| if isinstance(event, bytes): | |
| total_bytes += len(event) | |
| if total_bytes > MAX_AUDIO_BYTES: | |
| raise ValueError("合成音频超过机器人单次播放容量") | |
| pending.extend(event) | |
| complete_bytes = len(pending) // 2 * 2 | |
| if complete_bytes: | |
| chunk = bytes(pending[:complete_bytes]) | |
| del pending[:complete_bytes] | |
| yield PCMAudio(chunk).data | |
| elif event["header"]["event"] == "task-finished": | |
| if not total_bytes or pending: | |
| raise ValueError("合成音频为空或包含不完整的 PCM 采样") | |
| return | |
| def asr_parameters(self, sample_rate=16000, hotwords=()): | |
| return { | |
| "format": "pcm", "sample_rate": sample_rate, "language_hints": ["zh"], | |
| "vad_model": "far_field_meeting_16k", "semantic_punctuation_enabled": False, | |
| "max_sentence_silence": self.settings.asr_silence_ms, "heartbeat": True, | |
| "vocabulary": dict.fromkeys(hotwords, 2), | |
| } | |
| async def recognize( | |
| self, audio: AsyncIterator[bytes], *, sample_rate=16000, hotwords=(), timeout=75, | |
| ) -> str | None: | |
| async with asyncio.timeout(timeout), self.task( | |
| "asr", self.settings.asr_model, self.asr_parameters(sample_rate, hotwords), | |
| ) as task: | |
| complete = asyncio.Event() | |
| text = None | |
| async def send_audio(): | |
| async for frame in audio: | |
| if complete.is_set(): | |
| break | |
| if not frame or len(frame) % 2: | |
| raise ValueError("ASR 输入必须是完整的 16-bit PCM 采样") | |
| await task.socket.send(frame) | |
| await task.send("finish-task", {"input": {}}) | |
| async def receive_text(): | |
| nonlocal text | |
| while True: | |
| event = await task.receive() | |
| if isinstance(event, bytes): | |
| raise TypeError("ASR 返回了非预期的音频数据") | |
| kind = event["header"]["event"] | |
| if kind == "task-finished": | |
| complete.set() | |
| return | |
| if kind == "result-generated": | |
| sentence = event["payload"]["output"]["sentence"] | |
| if (not sentence.get("heartbeat") and sentence.get("sentence_end") | |
| and sentence["text"].strip() and text is None): | |
| text = sentence["text"].strip() | |
| complete.set() | |
| async with asyncio.TaskGroup() as group: | |
| group.create_task(send_audio()) | |
| group.create_task(receive_text()) | |
| return text | |
| async def transcribe( | |
| self, audio: AsyncIterator[CapturedPcm], on_result: Callable[[RecognizedSpeech], None], | |
| *, on_activity: Callable[[bool, str], None], ready: asyncio.Event, hotwords=(), | |
| ) -> None: | |
| timeline = SpeechTimeline() | |
| input_finished = False | |
| async with ( | |
| self.task("asr", self.settings.asr_model, self.asr_parameters(hotwords=hotwords)) as task, | |
| asyncio.timeout(None) as completion, | |
| ): | |
| ready.set() | |
| async def send_audio(): | |
| nonlocal input_finished | |
| async for frame in audio: | |
| timeline.append(frame) | |
| async with asyncio.timeout(10): | |
| await task.socket.send(frame.data) | |
| input_finished = True | |
| completion.reschedule(asyncio.get_running_loop().time() + 30) | |
| await task.send("finish-task", {"input": {}}) | |
| async def receive_text(): | |
| last_sentence = -1 | |
| while True: | |
| event = await task.receive() | |
| if isinstance(event, bytes): | |
| raise TypeError("ASR 返回了非预期的音频数据") | |
| kind = event["header"]["event"] | |
| if kind == "task-finished": | |
| if not input_finished: | |
| raise RuntimeError("持续 ASR 在音频输入结束前终止") | |
| return | |
| if kind != "result-generated": | |
| continue | |
| sentence = event["payload"]["output"]["sentence"] | |
| if sentence.get("heartbeat"): | |
| continue | |
| on_activity(not sentence.get("sentence_end", False), sentence.get("text", "")) | |
| if not sentence.get("sentence_end") or sentence["sentence_id"] <= last_sentence: | |
| continue | |
| last_sentence = sentence["sentence_id"] | |
| text = sentence["text"].strip() | |
| if not text: | |
| continue | |
| started_at = timeline.started_at(sentence["begin_time"], sentence["end_time"]) | |
| if started_at is not None: | |
| on_result(RecognizedSpeech( | |
| text, started_at, timeline.captured_at(sentence["end_time"]), | |
| timeline.captured_at(timeline.sent_samples / 16), | |
| )) | |
| async with asyncio.TaskGroup() as group: | |
| group.create_task(send_audio()) | |
| group.create_task(receive_text()) | |