Download src/agentcade/output.py from left0ver/WatcherRobot-com.left0ver.agentcade: direct link, hf CLI and curl.
- Browser
- Download file 9.81 kB
-
https://huggingface.co/spaces/left0ver/WatcherRobot-com.left0ver.agentcade/resolve/main/src/agentcade/output.py
- Command line
-
hf download hf://spaces/left0ver/WatcherRobot-com.left0ver.agentcade/src/agentcade/output.py
-
curl -L -o output.py https://huggingface.co/spaces/left0ver/WatcherRobot-com.left0ver.agentcade/resolve/main/src/agentcade/output.py
9.81 kB
| import asyncio | |
| import logging | |
| from watcherobot.errors import CommandError | |
| from watcherobot.job import Job | |
| from agentcade.audio_stream import PcmAudioPlayer, sdk_command | |
| from agentcade.eyes import STANDBY_EYES | |
| from agentcade.robot_calls import describe_call | |
| from agentcade.speech import QwenSpeech | |
| from observability.events import bus, describe, failure, scope | |
| logger = logging.getLogger(__name__) | |
| SYNTHESIS_ATTEMPTS = 3 | |
| # 游戏的 SDK 调用返回 Job(动画、动作、音效)时,等它做完再执行下一条,最多等这么久; | |
| # 超时就先执行后面的,Job 在机器人上继续。灯效可以一直循环,不等它。 | |
| JOB_WAIT_SECONDS = 10 | |
| NO_WAIT = {"lights"} | |
| # 这些模块的调用会发出声音或者转动电机,执行时暂停收音;麦克风这时重新打开,机器人还会取消正在做的动作。 | |
| NOISY = {"motion", "audio", "behavior"} | |
| # 连续几次有声音或动作的调用之间不恢复收音,免得反复开关麦克风。 | |
| HUSH_GRACE_SECONDS = 0.5 | |
| # 这些模块会改变屏幕,一局结束后屏幕要回到待机。 | |
| SCREEN = {"expression_runtime", "expressions", "animation", "behavior", "works"} | |
| # 头部的正前方(水平角, 俯仰角),一局结束后转回这里。 | |
| NEUTRAL = (90, 115) | |
| class Hush: | |
| """游戏做动作、放音效时暂停收音;连续的几次调用合成一次暂停。""" | |
| def __init__(self, microphone): | |
| self.input = microphone | |
| self.held = False | |
| self.timer: asyncio.Task | None = None | |
| async def hold(self): | |
| if self.timer is not None: | |
| self.timer.cancel() | |
| self.timer = None | |
| if not self.held: | |
| await self.input.pause(owner=self) | |
| self.held = True | |
| def release(self): | |
| if self.held and self.timer is None: | |
| self.timer = asyncio.create_task(self.release_later()) | |
| async def release_later(self): | |
| await asyncio.sleep(HUSH_GRACE_SECONDS) | |
| self.timer = None | |
| self.resume() | |
| def resume(self): | |
| if self.held: | |
| self.held = False | |
| self.input.resume(owner=self) | |
| def close(self): | |
| if self.timer is not None: | |
| self.timer.cancel() | |
| self.timer = None | |
| self.resume() | |
| class OutputCoordinator: | |
| """按顺序执行主 Agent 的输出:说话、游戏的 SDK 调用,以及一局结束后的复位。""" | |
| def __init__(self, app, settings, microphone): | |
| self.app, self.robot, self.input = app, app.robot, microphone | |
| self.speech = QwenSpeech(settings) | |
| self.last_text = "" | |
| self.audio = PcmAudioPlayer(self.robot.audio) | |
| self.speaking = False | |
| self.output_lock = asyncio.Lock() | |
| self.hush = Hush(microphone) | |
| # 这一局游戏调用过的 SDK 模块,结束时只复位这些。 | |
| self.touched: set[str] = set() | |
| async def execute(self, output): | |
| async with self.output_lock: | |
| try: | |
| performed = await self.present(output) | |
| except BaseException as error: | |
| bus.emit("device.output", {"output": output, **failure(error)}) | |
| raise | |
| else: | |
| if performed is not False: | |
| bus.emit("device.output", {"output": output, "completed": True}) | |
| async def clear(self): | |
| """屏幕回到待机的眼睛。""" | |
| await sdk_command(self.robot.expression_runtime.start, "standby", **STANDBY_EYES) | |
| async def perform(self, call): | |
| """执行游戏的一次 SDK 调用。参数由 SDK 自己检查;返回 Job 的等它做完。""" | |
| domain, method = call.call.split(".") | |
| function = getattr(getattr(self.robot, domain), method) | |
| self.touched.add(domain) | |
| noisy = domain in NOISY | |
| if noisy: | |
| await self.hush.hold() | |
| try: | |
| for attempt in range(3): | |
| try: | |
| result = await sdk_command(function, *call.args, **call.kwargs) | |
| break | |
| except CommandError as error: | |
| # 固件偶尔对连续的表情更新返回 invalid_argument,稍等重试。 | |
| if (error.message_type != "ctrl.expression.runtime.update" | |
| or error.reason != "invalid_argument" or attempt == 2): | |
| raise | |
| logger.warning("表情更新返回 invalid_argument,0.2 秒后重试(%s/2)", attempt + 1) | |
| await asyncio.sleep(0.2) | |
| if isinstance(result, Job) and domain not in NO_WAIT: | |
| try: | |
| await sdk_command(result.wait, JOB_WAIT_SECONDS) | |
| except TimeoutError: | |
| logger.info("%s 超过 %s 秒还没做完,先执行后面的", call.call, JOB_WAIT_SECONDS) | |
| finally: | |
| if noisy: | |
| self.hush.release() | |
| async def reset(self): | |
| """一局结束后把游戏用过的设备复位:停掉动画和行为、关灯、头转回正前方、屏幕回到待机。 | |
| 游戏没有调用过 SDK 时什么也不做,返回 False。 | |
| """ | |
| touched, self.touched = self.touched, set() | |
| if not touched: | |
| return False | |
| steps = [] | |
| if "animation" in touched: | |
| steps.append(("停止动画", self.robot.animation.stop, {}, False)) | |
| if "behavior" in touched: | |
| steps.append(("停止行为", self.robot.behavior.stop, {}, False)) | |
| if "lights" in touched: | |
| steps.append(("关灯", self.robot.lights.off, {}, False)) | |
| if "motion" in touched: | |
| pan, tilt = NEUTRAL | |
| steps.append(("头转回正前方", self.robot.motion.move_to, | |
| {"pan_deg": pan, "tilt_deg": tilt, "duration_ms": 400}, True)) | |
| # 复位紧跟在最后一句话后面,说完收音会重新打开,转头时要暂停收音。 | |
| for name, function, arguments, noisy in steps: | |
| if noisy: | |
| await self.hush.hold() | |
| try: | |
| result = await sdk_command(function, **arguments) | |
| if isinstance(result, Job): | |
| await sdk_command(result.wait, JOB_WAIT_SECONDS) | |
| except Exception as error: | |
| # 复位做不完只影响外观,接着复位其他的。 | |
| logger.warning("一局结束后%s失败:%s", name, describe(error), exc_info=True) | |
| finally: | |
| if noisy: | |
| self.hush.release() | |
| if touched & SCREEN: | |
| await self.clear() | |
| async def synthesize(self, text): | |
| for attempt in range(1, SYNTHESIS_ATTEMPTS + 1): | |
| try: | |
| return await self.speech.synthesize(text) | |
| except ValueError: | |
| raise | |
| except Exception as error: | |
| # 语音服务偶尔连接失败或超时,重试仍然失败再放弃这一条。 | |
| if attempt == SYNTHESIS_ATTEMPTS: | |
| raise | |
| logger.warning("语音合成失败,重试(%s/%s):%s", | |
| attempt, SYNTHESIS_ATTEMPTS - 1, describe(error)) | |
| await asyncio.sleep(0.5 * attempt) | |
| async def present(self, output): | |
| if output.call is not None: | |
| return await self.perform(output.call) | |
| if output.reset: | |
| return await self.reset() | |
| if not output.text: | |
| return False | |
| self.speaking = True | |
| await self.input.pause(owner=self) | |
| try: | |
| audio = await self.synthesize(output.text) | |
| self.last_text = output.text | |
| self.app.logger.info("机器人:%s", output.text) | |
| metrics = await self.audio.play(audio, on_start=lambda seconds: self.started(output, seconds)) | |
| self.app.logger.info( | |
| "语音播放:任务 %s,启动 %.2f 秒,完成 %.2f 秒,PCM %s 字节", | |
| metrics.playback_id, metrics.started_seconds, metrics.completed_seconds, | |
| metrics.pcm_bytes, | |
| ) | |
| finally: | |
| self.speaking = False | |
| self.input.resume(owner=self) | |
| async def started(self, output, seconds): | |
| # 对话页面按这段语音的时长把文字逐步显示出来,和机器人说话的进度一致。 | |
| bus.emit("speech.start", {"text": output.text, "duration_ms": round(seconds * 1000)}) | |
| async def run(self, agent): | |
| while True: | |
| request = await agent.outputs.get() | |
| try: | |
| completed = [] | |
| with scope(**request.trace_context): | |
| for output in request.outputs: | |
| try: | |
| await self.execute(output) | |
| except Exception as error: | |
| # 一条输出失败只跳过这一条,后面的播报照常进行。 | |
| label = (output.text[:30] if output.text | |
| else describe_call(output.call) if output.call else "复位") | |
| logger.warning("输出失败,已跳过“%s”:%s", label, describe(error), exc_info=True) | |
| continue | |
| completed.append(output) | |
| request.outputs = completed | |
| await agent.output_done(request) | |
| finally: | |
| if not request.completed.done(): | |
| request.completed.cancel() | |
| agent.outputs.task_done() | |
| async def close(self): | |
| self.hush.close() | |
| # 退出时还在玩的那一局不会再有结束的回复,直接复位。 | |
| async with self.output_lock: | |
| await self.reset() | |