启元 T1 Pro 人机相声搭档:语音-触摸双通道的启元机器人节奏引擎设计

机器人和你说相声,它逗哏,你拍背捧哏。本文记录 crosstalk(人机相声搭档)的开发过程:TTS 没有语速参数的情况下节奏如何控、2.5 秒捧哏窗口的两条走向、以及一个在三个技能里犯了四次的 ROS2 定时器错误。全部逻辑经 --sim 验证后打包 .rpk 部署上板。

代码仓库地址: crosstalk:基于 Python 和 TTS 的交互式相声机器人项目 - AtomGit

目录

  • 一、选题:一个会接话的技能

  • 二、演出规则:五种状态和一张表

  • 三、为什么应该把段子设计为数据

  • 四、在官方预设动作上二次调整

  • 五、节奏控制:没有语速参数的引擎

  • 六、--sim 验证:一次四犯的定时器错误

  • 七、部署上板与发布

  • 八、常见问题排查

  • 九、已知限制

  • 结语 / 参考资料

一、选题:一个会接话的技能

前两个技能分别占了数据(robot_checkup)和视觉(color_seek)两个维度,互动维度上 touch_game 已经验证了"触摸事件流 + 多模态反馈"的链路。第三个技能我想从全新的角度:让用户从"操作者"变成"表演者"。

相声这个形式几乎是为此量身定做的------它有固定的捧哏结构(逗哏说一句,捧哏接一句),接不接话是两种完全不同的走向,天然适合"机器人说、人拍背接"的交互。而且它对硬件的要求低到只需要 TTS、表情、灯带三个已经在用的通道,零运动风险。

最终玩法:机器人逗哏说原创相声,每句台词后留 2.5 秒捧哏窗口,拍背 = 接住,不拍 = 它自己吐槽,谢幕统计你的捧哏专业度。

照例交代一下地基。WSL2 + Ubuntu 22.04 + ROS2 Humble 的环境、t1-sdk 四个包的编译、URDF 模型在 RViz/Gazebo 里的加载验证,都是前两个技能留下的存量,crosstalk 开工时零环境成本:

t1-sdk 在 WSL 里 colcon build:4 packages finished------crosstalk 用到的 PlayTts、PlayEmotion、LedStripCommand 服务定义全在这个包里。

RViz 里的 T1 Pro 模型与关节列表。crosstalk 虽然不碰运动,但表情和灯带都挂在模型同一条状态链路上,模型能加载说明状态通道是通的。

更要紧的是通道级验证:TTS、表情、灯带三个接口在 touch_game 里已经实打实调过几百次,超时兜底、is_interrupted 打断了这些细节都有现成答案。crosstalk 的技术风险在动工前就只剩下"节奏怎么控"。

二、演出规则:五种状态和一张表

阶段 触摸 行为
待机 背部单击 开演(随机或指定段子)
说词中 台词结束后 2.5 秒内拍背 接住了------计数 +1,机器人随机夸奖
说词中 不拍 它自己吐槽("行吧,我自己来"),继续说
说词中 乱拍 忽略------防误触,不打断正在说的句子
谢幕 背部单击 返场重讲
谢幕 胸部双击 散场

核心机制:

crosstalk 捧哏窗口与两条走向:窗口内拍背 = 接住(计数+夸奖),窗口内不拍 = 没接(吐槽也是包袱),台词播放中乱拍直接忽略。

窗口判定全部收在 on_touch 一个函数里,按相位分流:

cpp 复制代码
def on_touch(self, msg):
    with self._lock:
        if self.phase == PHASE_APPLAUSE:
            if (msg.event_type == EVENT_SINGLE_CLICK
                    and msg.sensor_id == SENSOR_BACK):
                self._catch()
            return
        if self.phase == PHASE_IDLE:
            if (msg.event_type == EVENT_SINGLE_CLICK
                    and msg.sensor_id == SENSOR_BACK):
                self.start_show()
            return
        if self.phase == PHASE_DONE:
            if msg.event_type == EVENT_SINGLE_CLICK:
                self.bot.say("还要听?行,返场!")
                self._restart()
            elif (msg.event_type == EVENT_DOUBLE_CLICK
                    and msg.sensor_id == SENSOR_CHEST):
                self.bot.say("谢谢大家,我回去杵着了")
                self.bot.play_led(0)
            return

四个相位四种待遇:SPEAKING 相位什么都不接(乱拍忽略,防误触不打断台词)、APPLAUSE 相位只认背部单击、IDLE 等开演、DONE 等返场或散场。加锁是因为 TTS 回调和触摸回调可能同时进,相位读改要互斥。

"不接就吐槽"是这个技能的设计核心------冷场是演出事故,吐槽是兜底包袱。哪怕全程没人拍背,机器人也会自己把节奏走下去,谢幕时统计"这一段你捧了 0 次,专业程度百分之 0",吐槽感拉满,反而成了效果最好的结局。

三、为什么应该把段子设计为数据

两段相声的文本全部放在 src/script_data.py 里,每条台词是一个字典:

cpp 复制代码
{
    "t": "主人给我定的 KPI,就三个字------别摔了",
    "wait": True,       # 这句之后进捧哏窗口
    "emo": 18,          # 播这句时的表情
    "led": "white_breath",
}

如果需要加一段新相声,那么不需要动引擎。这个分离带来的直接好处是:段子可以随时改、随时加,而节奏逻辑只在一处维护。真实的数据长这样:

cpp 复制代码
SCRIPTS = {
    "motian": {
        "title": "机器人也想摸鱼",
        "lines": [
            {"t": "大家好,我是这台桌子上的机器人", "wait": True,
             "emo": 10, "led": 20},
            {"t": "别的机器人满世界跑,我负责在这儿杵着", "wait": True},
            {"t": "主人给我定的 KPI,就三个字------别摔了", "wait": True,
             "emo": 260},
            {"t": "我唯一的激动时刻,是充电", "wait": True},
            {"t": "电量从百分之十九跳到二十,我恨不得给自己鼓掌",
             "wait": True, "emo": 19, "led": 1},
            {"t": "谢谢大家,我回去杵着了", "wait": False, "end": True},
        ],
    },
}

原则写在 script_data.py 的模块注释里:机器人视角自嘲,包袱落在"机器人的物理局限与社会身份错位"上

crosstalk 的三层结构:数据层(段子文本)、节奏引擎(时间戳推进的相位机)、执行层(TTS/表情/灯带),三层之间只靠列表和接口通信。

两段原创段子随仓库开源:

  • 《机器人也想摸鱼》------"主人给我定的 KPI 就三个字:别摔了""电量跳动是我唯一的激动时刻"

  • 《聪明反被聪明误》------"我懂八国语言的问候,就只会一句收到收到""聪明是直接报数据"

四、在官方预设动作上二次调整

PrimeConsole 的动作面板里有 14 项官方预设(打招呼、握手、比心、站起、变形......),看起来"让机器人表演"直接调预设就行。我们评估之后的结论是预设动作解决不了这个场景:

维度 官方预设动作 自写台词引擎
内容 固定动画,文本写死 任意文本,改列表就行
节奏 一段完整播放,不可拆 逐句可控,句间任意间隔
互动 播放中不可插入 夸奖、返场随时插播
表情配合 动画自带 每句可配不同表情 ID

相声需要的是台词级控制------每一句说什么、停顿多久、配什么表情,预设动画给不了。所以我们只用预设动作体系之外的东西:TTS + PlayEmotion + LedStripCommand,三个都是已在 touch_game 里验证过的通道,风险为零。

五、节奏控制:没有语速参数的引擎

相声的灵魂是节奏,而 SDK 的 TTS 接口没有语速参数 ------PlayTtsRequest 里只有文本、优先级和是否打断。下面是节奏设计:

  1. 每句独立 TTS 调用------句与句之间由代码控制间隔,而不是让 TTS 一口气播完

  2. 句间间隔按字数估时 ------间隔 = 字数 ÷ 4.5 字每秒 + 0.9s 缓冲,4.5 是经验值,真机上跑几遍就能标定准

  3. 捧哏窗口固定 2.5s------估时只影响"下一句什么时候开始",不影响窗口本身的判定

整个时间线由一个 5Hz 的心跳按时间戳推进,相位机 SPEAKING → APPLAUSE → SPEAKING → FINISHED。核心只有 _speak_next:

cpp 复制代码
def _speak_next(self):
    line = self.script[self._line_idx]
    self._line_idx += 1
    self.bot.say(line["t"])
    self.bot.play_emotion(line.get("emo"))
    self.bot.play_led(line.get("led"))
​
    if line.get("end"):
        self._finish()
        return
    tts_s = max(1.5, len(line["t"]) / 4.5)
    if line.get("wait"):
        self.phase = PHASE_APPLAUSE
        self._deadline = time.monotonic() + tts_s + self.applause_window
    else:
        self.phase = PHASE_SPEAKING
        self._next_line_at = time.monotonic() + tts_s + 0.4

max(1.5, ...) 是短句下限------四个字的短句按公式算出来不到 1 秒,捧哏还没反应过来窗口就关了,演出节奏直接垮掉。这里又一次用上了 touch_game 的教训:不用周期定时器做状态转换,只用心跳读时间戳。

is_interrupted=True 是------夸奖语、返场、散场随时可能插进来,不能让上一句排队等着播完。

六、--sim 验证:一次四犯的定时器错误

--sim 注入拍背事件走完整流程。第一次运行就撞上了那个熟悉的坑------_run_sim 注册了心跳和注入定时器,但忘了进 spin:

cpp 复制代码
self.node.create_timer(1.0 / TICK_HZ, self._tick)
self.start_show()
self.node.create_timer(3.0, self._sim_applause)
self.node.create_timer(20.0, self._sim_done)
# 然后 run() 返回,main 里 shutdown------一个定时器都没执行

这是同一个错误在三个技能里的第四次发作 (touch_reply 两次、touch_game 一次、crosstalk 一次)。修复一行:_run_sim 末尾补 rclpy.spin()。这次我们没再靠"记住教训"------把它写成了流程:所有 sim 入口的验收清单里加了一条"确认 timer 注册后有 spin 驱动"。

--dry-run 输出:段子《聪明反被聪明误》12 句、捧哏窗口 2.5s、背部单击 = 接话/下一段、胸部双击 = 退出------部署前的行为契约。

--sim 全程实录:12 句台词逐句播报,拍背接话 6 次全部 caught,夸奖、表情、灯带联动准确,谢幕统计后干净退出。

七、部署上板与发布

部署流程和前两个技能完全一致,这次一次通过------cfg/pkg.yaml 的教训已经记进检查清单:

cpp 复制代码
python3 pack_rpk.py     # crosstalk-1.0.0.rpk,8 files

打包输出:包大小与文件数。

包内容:manifest.json(4 项能力)、cfg/pkg.yaml(技能身份)、assets/icon.png、META-INF/checksums.json 加源码五件套。

PrimeConsole 解析结果:skillid = crosstalk,原子能力三项(表情播放 / 语音合成 / LED 灯效)。

部署成功:crosstalk-1.0.0.rpk 安装到机器人,界面显示"部署成功"。

部署后的 PrimeConsole 主界面:机器人三维模型与实时视频同屏,部署链路对机器人本体的状态可见。

发布表单也走同一套元数据:

PrimeHub 发布表单:技能名称"人机相声搭档",分类视觉/语言/动作,标签卧室/亲子/客厅------分类与标签多选,覆盖家庭场景的检索词。

八、常见问题排查

现象 原因 处理
拍背没反应 拍在台词播放中,不在窗口期 等台词结束、灯带给出提示再拍
夸奖语不播 TTS 上一句还没播完,新请求排队 确认 is_interrupted=True
下一句迟迟不来 台词估时偏长 调小 4.5 字/秒 这个经验值
谢幕统计次数不对 窗口期判定用了事件到达时刻而非相位起点 检查心跳 tick 的时间戳来源
--sim 打完招呼就退出 又是没进 spin 验收清单第一条

九、已知限制

  • 台词时长按 4.5 字/秒估算,真人 TTS 语速有快有慢,估偏了捧哏窗口起点会前移或后退,需要真机标定

  • 拍背必须落在窗口期,播放中拍会被忽略------节奏感是玩法的一部分,但确实会让人觉得"我明明拍了"

  • 全部实时 TTS,没有预录音频,TTS 服务的延迟直接体现在演出节奏上

结语

crosstalk 是三个技能里"技术最少、表演最多"的一个:没有视觉、没有运动,全部技术含量集中在节奏引擎那几十行。但它回答了另一个问题------机器人除了"听话干活",还能不能"陪你玩"。段子写在数据里,改文本就能加新活儿;捧哏窗口只有 2.5 秒,但这两秒里人是真的在跟机器互动。

本文使用的 SDK 与消息包均来自启元机器人开发者社区,TTS 与触摸接口以官方 t1-sdk 仓库定义为准。

参考资料

  1. 启元机器人开发者社区(SDK/文档下载):启元机器人开发者社区 · 一站式具身智能研发平台

  2. 官方 t1-sdk 仓库(含 aimdk_msgs 消息包):t1-sdk:基于 ROS2 与 FastDDS 的机器人二次开发 SDK 项目 - AtomGit

  3. 本文配套仓库 crosstalk:crosstalk:基于 Python 和 TTS 的交互式相声机器人项目 - AtomGit

相关推荐
爱吃烧烤的技术渣15 分钟前
别再手写 CRUD 了:我把 Element Plus 的工作量压缩成一份列 schema
前端·vue.js
做科研的周师兄15 分钟前
【R语言实战】MixSIAR 氢氧同位素水源解析全流程:从混合空间校验到贝叶斯 MCMC 收敛诊断(后附完整代码)
java·大数据·开发语言
MetWeave18 分钟前
风的三十年:一个机场的风在变小吗:10,958 天的账(《风不是一个数》系列第 6 篇)
前端·数据可视化
青烟小生21 分钟前
前端实时协同编辑与 CRDT 冲突解决实战:从 OT 到 Yjs 底层原理(完整版)
前端
陈随易23 分钟前
神级 Skill,让 AI 用 HTML 代替 Markdown 回复
前端·后端·程序员
eidnxndrdkdns31 分钟前
PE 自校验的三个坑:恒失败、基址变化、以及被漏在外面的检测器
前端
90后的晨仔35 分钟前
解决 Android 构建报错:Unable to delete file .../file-map.txt
android·前端
Yuhano40 分钟前
W4. RAG 让 Agent 拥有"外部记忆"
前端·aigc
用户308169987648543 分钟前
gzip 和 br 压同一份 JSON:7.6MB 压到 175KB,差 3.8 倍
前端