机器人"摸一摸"会说话
一、背景:为什么要做这个功能?
我们的机器人是一款会变形的机器人,有双足人形 和四足小狗两种形态。用户可以通过触摸机器人的外壳来跟机器人互动。
之前的问题是:摸机器人只有表情和动作,不说话。产品希望每次触摸都能听到机器人的反馈语音,让机器人更有人情味。
比如:
- 摸一下前胸 → 机器人做个律动 + 说:"哎呀,我最喜欢被摸啦~"
- 有时候还会追加一句:"既然你已经来了,那就别这么快走嘛。"
30% 的概率 触发"撒骄挽留"话术,70% 的概率只是普通的回应。这样每次摸都有新鲜感。
二、整体设计
2.1 触摸分两种形态,各有 3 种随机组合
触摸事件(SLIDE 滑动)
│
├─ 双足人形(前胸) → 3 选 1:律动/掐腰A/掐腰B
└─ 四足(后背) → 3 选 1:骄傲/聆听/四处看
2.2 每次触摸都说话
100% → 随机说一句(10 句里选 1,不跟上一次重复)
30% → 再追加拿一句(6 句里选 1,不跟上一次重复)
2.3 执行顺序
1. 表情(先笑一个)
2. 动作(开始做动作)
3. 等 0.5 秒
4. 说话(动作开始后 0.5 秒说话,让用户感觉动作和语音同步)
5. 30% 追加(等 5 秒后再来一句)
6. 等待动作完成
三、核心代码详解
3.1 概率模型(30% vs 70%)
这是一个巧妙的"洗牌算法":用一个长度为 10 的数组,放 3 个 true 和 7 个 false,然后随机打乱。每次调用取下一个,取完 10 次再重新洗牌。
保证了每 10 次触摸中刚好 3 次返回 true(30%),7 次返回 false(70%)。
cpp
/// 以 10 次有效触摸为一个统计窗口,其中 3 次返回 true(持续互动),7 次返回 false
static bool ShouldBePersistent() {
static std::array<bool, 10> window = {true, true, true, false, false, false, false, false, false, false};
static int window_idx = 10; // 首次调用时触发重新洗牌
if (window_idx >= 10) {
std::shuffle(window.begin(), window.end(), GetTouchRng());
window_idx = 0;
}
return window[window_idx++];
}
为什么不用 rand() % 10 < 3? 因为真随机可能会出现连续 5 次都是 true 的情况。用洗牌法保证了 10 次中一定是 3 次,分布均匀。
3.2 随机数生成器
cpp
static std::mt19937& GetTouchRng() {
static std::mt19937 rng(std::random_device{}());
return rng;
}
这里用了 C++ 的 Mersenne Twister 算法(std::mt19937),比 rand() 质量高得多,用 std::random_device 生成随机种子(真随机)。
3.3 持续互动话术(30% 触发,6 选 1)
cpp
/// 30% 追加持续互动话术(随机,不与上次相同)
static const std::string& GetPersistentInteractionTts() {
static const std::array<std::string, 6> kPhrases = {
"既然你已经来了,那就别这么快走嘛。",
"都摸摸我了,不陪我聊聊吗?",
"再待一小会儿,好不好?",
"陪我多待一分钟也行呀。",
"别急着走,我还有点舍不得。",
"再陪陪我嘛,我会很安静的。",
};
static int last_idx = -1;
static std::uniform_int_distribution<int> dist(0, 5);
int idx;
do {
idx = dist(GetTouchRng());
} while (idx == last_idx);
last_idx = idx;
return kPhrases[idx];
}
关键设计:do-while (idx == last_idx) 保证两次相邻的触摸不会说出相同的话。
3.4 预设话术(100% 触发,10 选 1)
cpp
/// 每次抚摸必说的预设话术(随机,不与上次相同)
static const std::string& GetPresetTouchTts() {
static const std::array<std::string, 10> kPhrases = {
"哎呀,我最喜欢被摸啦~",
"暖暖的,好舒服。",
"再摸一下,我就要开心到冒泡啦。",
"你摸的时候,我会偷偷变乖。",
"别呀,别摸我啦......",
"等一下,我会害羞的",
"你怎么又突然摸我!",
"不许趁我不注意偷袭。",
"轻一点嘛,我很脆弱的。",
"再摸下去我要融化了。",
};
static int last_idx = -1;
static std::uniform_int_distribution<int> dist(0, 9);
int idx;
do {
idx = dist(GetTouchRng());
} while (idx == last_idx);
last_idx = idx;
return kPhrases[idx];
}
同样用了 do-while 保证不与上次重复。
3.5 入口函数:根据形态和触摸类型分派
cpp
std::shared_ptr<SkillParamList> T1InteractionTaskDescription::GetTouchSkillParamList(int32_t event_type) {
auto motion_state = StateManager::GetInstance()->GetMotionState();
switch (event_type) {
case TOUCH_EVENT_DOUBLE_CLICK: // 点击类:只有四足支持
case TOUCH_EVENT_TRIPLE_CLICK:
if (curr_robot_form != kQuadruped) return nullptr; // 双足不支持点击
return GetClickQuadrupedSkillParamList();
case TOUCH_EVENT_SLIDE_LEFT: // 抚摸类:双足和四足都支持
case TOUCH_EVENT_SLIDE_RIGHT:
if (curr_robot_form == kQuadruped) return GetSlideQuadrupedSkillParamList(); // 四足后背
if (curr_robot_form == kBiped) return GetSlideBipedSkillParamList(); // 人形前胸
break; // 其他形态不支持
default:
return nullptr;
}
return nullptr;
}
3.6 双足人形前胸:3 变体 + 语音
cpp
std::shared_ptr<SkillParamList> T1InteractionTaskDescription::GetSlideBipedSkillParamList() {
auto skill_param_list = std::make_shared<SkillParamList>();
auto curr_action_id = StateManager::GetInstance()->GetMotionState().curr_action_id;
// 3 种组合:(animation_id, emotion_id, wait_ms)
static const std::array<TouchVariant, 3> kVariants = {{
{43, 102, 5500}, // 律动,emotion=102(开心),时长 5.5s
{66, 16, 4000}, // 掐腰,emotion=16(轻微喜悦2),时长 4s
{66, 18, 4000}, // 掐腰,emotion=18(中等喜悦),时长 4s
}};
// 随机选取,不与上次相同
static int last_variant = -1;
static std::uniform_int_distribution<int> dist_var(0, 2);
int variant;
do {
variant = dist_var(GetTouchRng());
} while (variant == last_variant);
last_variant = variant;
const auto& chosen = kVariants[variant];
// 1. 表情(先放)
auto emotion_list = GetSkillParamList(chosen.emotion_id);
if (emotion_list) {
for (const auto& p : emotion_list->GetSkillsParam()) {
skill_param_list->PushSkillParam(p);
}
}
// 2. 如果不在 WBC 模式,先切换到 WBC(预设动作需要 WBC)
if (curr_action_id != BIPED_LOCOMOTION_WBC) {
if (curr_action_id == BIPED_LOCOMOTION_DEFAULT
|| curr_action_id == BIPED_LOCOMOTION_TERRAIN
|| curr_action_id == BIPED_LOCOMOTION_RUN
|| curr_action_id == QUADRUPED_TO_BIPED
|| curr_action_id == QUADRUPED_TO_BIPED_ROTATE) {
// 先切 WBC
PushSkillParam(ActionParams(BIPED_LOCOMOTION_WBC));
PushSkillParam(TimeParams(150)); // 等 150ms
}
}
// 3. 动作(先执行)
auto motion_p = std::make_shared<MotionParams>();
if (chosen.animation_id == 43) {
motion_p->SetMotionId(2031); // 律动
} else {
motion_p->SetMotionId(2037); // 掐腰
}
skill_param_list->PushSkillParam(motion_p);
// 4. 等 0.5 秒后再说话
PushSkillParam(TimeParams(500));
// 5. 自然语言:100% 触发
PushSkillParam(AudioParams(GetPresetTouchTts(), TTS));
// 6. 30% 追加持续互动话术
if (ShouldBePersistent()) {
PushSkillParam(TimeParams(5000)); // 等 5 秒
PushSkillParam(AudioParams(GetPersistentInteractionTts(), TTS));
}
// 7. 等待动作完成
PushSkillParam(TimeParams(chosen.wait_ms));
return skill_param_list;
}
3.7 四足后背:3 变体 + 语音
cpp
std::shared_ptr<SkillParamList> T1InteractionTaskDescription::GetSlideQuadrupedSkillParamList() {
auto skill_param_list = std::make_shared<SkillParamList>();
auto curr_action_id = StateManager::GetInstance()->GetMotionState().curr_action_id;
// 3 种组合
static const std::array<TouchVariant, 3> kVariants = {{
{25, 102}, // 骄傲动作,emotion=102(开心)
{49, 16}, // 聆听动作,emotion=16(轻微喜悦2)
{54, 18}, // 四处看,emotion=18(中等喜悦)
}};
// 随机选取(同双足逻辑)
// ... (省略,见上方双足版本)
// 1. 表情
// ... (省略)
// 2. 动作(先执行)
if (chosen.animation_id == 25) {
// 骄傲:需要先切 STAND,然后设 PRIDE 动作
if (curr_action_id == GET_DOWN || curr_action_id == STORE || curr_action_id == SIT_DOWN) {
PushSkillParam(ActionParams(STAND_DEFAULT, check_set=true));
}
PushSkillParam(ActionParams(PRIDE));
} else {
// 聆听/四处看:全身动作
const std::string motion_name = (chosen.animation_id == 49)
? "QUAD_MIMIC_HEED" : "QUAD_MIMIC_GLANCE";
if (curr_action_id != STAND_DEFAULT) {
PushSkillParam(ActionParams(STAND_DEFAULT));
PushSkillParam(TimeParams(100));
}
PushSkillParam(MotionParams(motion_name, kWholeBodyMotion));
}
// 3. 等 0.5 秒后再说话
PushSkillParam(TimeParams(500));
// 4. 自然语言:100% 触发
PushSkillParam(AudioParams(GetPresetTouchTts(), TTS));
// 5. 30% 追加
if (ShouldBePersistent()) {
PushSkillParam(TimeParams(5000));
PushSkillParam(AudioParams(GetPersistentInteractionTts(), TTS));
}
// 6. 骄傲动作需要等待 6.5 秒后恢复
if (chosen.animation_id == 25) {
PushSkillParam(TimeParams(6500));
PushSkillParam(ActionParams(LOCOMOTION_DEFAULT)); // 恢复到行走模式
}
return skill_param_list;
}
四、技术细节
4.1 SkillParamList:任务执行队列
SkillParamList 是一个技能参数列表,任务按顺序逐个执行其中的每个 Skill:
SkillParamList = [表情, 切WBC, 动作, 等500ms, 说话, (等5s+说话), 等动作完成]
↓
Task::Run() 按顺序执行
每个 Skill 的类型不同:
| Skill | 作用 | 说明 |
|---|---|---|
| EmoticonParams | 播放表情 | 异步,发 RPC 后即返回 |
| ActionParams | 切换 MC 模式 | 同步等待确认 |
| MotionParams | 播放预设动作 | 异步 RPC |
| TimeParams | 等待 | 阻塞线程指定毫秒 |
| AudioParams | 播放语音 | 异步 RPC |
4.2 为什么动作在语音前面?
考虑到用户体验:如果先说话后动作,用户会听到语音但看不到动作,感觉很怪。先动作后说话,动作开始 0.5 秒后语音响起,用户感觉动作和语音是同时发生的。
4.3 为什么不与上次重复?
cpp
do {
idx = dist(GetTouchRng()); // 随机选一个
} while (idx == last_idx); // 如果跟上次一样,重新选
last_idx = idx; // 记住这次选的
用 static 变量记住上次的选择,确保不会"同一个动作连续做两次"。
4.4 WBC 模式守卫
双足的预设动作(PresetMotion)必须在 WBC(Whole Body Control,全身控制) 模式下才能执行。如果当前不在 WBC,需要先发一个 Action 切换到 WBC。这是双足机器人的安全约束。
五、数据流全景
用户摸机器人
│
▼
硬件触摸传感器 → ROS2 topic /aima/hal/touch/state
│
▼
Scheduler 收到 TouchState 消息
│
▼
Dispatcher::DispatchTouchStatus(event_type)
│
├─ 检查冷却时间(四足滑动 7 秒内不重复触发)
├─ 检查 validity(是否有效触摸类型)
│
▼
TaskFactory::CreateTaskTouch(event_type)
│
▼
T1InteractionTaskDescription::GetTouchSkillParamList(event_type)
│ 根据形态分派
├─ kBiped → GetSlideBipedSkillParamList()
└─ kQuadruped → GetSlideQuadrupedSkillParamList()
│
▼
返回 SkillParamList
│
▼
WorkerManager → Worker → Task::Run()
│
▼
按顺序执行 Skill:
1. EmoticonSkill → PlayEmotion RPC → 表情渲染模块
2. ActionSkill → SetMcAction RPC → MC(运动控制)
3. MotionSkill → SetMcPresetMotion RPC → MC
4. TimeSkill → sleep 阻塞
5. AudioSkill → PlayTts RPC → HAL Audio
6. TimeSkill → sleep 阻塞
六、总结
这个模块的核心思想很简单:每次触摸都给用户一段新鲜的语音反馈。
技术实现上用了几个关键技巧:
- 洗牌算法保证概率分布的均匀性
- do-while 去重避免连续两次相同
- SkillParamList 顺序执行确保动作和语音的时序正确
- 异步 RPC 让动作和语音能并发出