
一、从一个字段名说起AudioFile.msg里有这么一行注释# 可选项(1~100) priority priority_weight% is final priority uint32 priority_weight而PlayTtsRequest.msg里同样叫priority_weight的字段注释是这样的# 可选非产品要求勿用优先级加权参数(0-99)最小单位为 1 uint32 priority_weight两个字段同名、同类型都是uint32含义却差得很远。前者的取值范围是 1–100是个百分比后者是 0–99是个绝对偏移。更关键的是PlayTts的最终权重算法在《接口说明.md》里写得很明确最终权重 主级 * 100 子权也就是说TTS 的priority_weight直接加到百位以上而音频文件那边它是对priority做一个百分比调整。同样是「微调优先级」一处加在百位一处加在个位还带着百分号。我最初以为这是文档写错了。把三套机制的定义都取出来放在一起之后才看清楚不是写错是三套独立设计的优先级机制恰好共享了两个字段名。这篇文章拆的就是这三套机制。它们分别属于运控输入源、TTS 播报、音频文件播放各有各的取值空间、比较方式和失败行为。搞混任何一处代码都会在没有任何报错的情况下做出错误行为——这是我在实测里反复撞到的同一类问题。二、三套机制并排看先把三套机制放在一张表里。机制接口字段取值空间主级形态比较方式低优先级方的结果运控输入源SetMcInputSourcepriority0–100 连续整数连续排他锁先到先得帧被静默丢弃1000 ms 看门狗脱锁TTS 播报PlayTtspriority_levelpriority_weight主级 6 档 × 子权 0–99跳段复合权重比大小同步返回is_successfalse高权重可中途掐断音频文件PlayAudioFileprioritypriority_weightmedia_rolepriority1–10weight1–100(%)连续百分比加权不终止靠音量闪避duck让位三套机制连「失败」的定义都不同运控那边输的一方什么都不会收到只能靠自己周期性查询才发现控制权没了TTS 那边输的一方会收到一个明确的false音频那边输的一方既没有错误也没有终止只是音量被压低。把它们画成刻度形状的差异更直观。图 1 中三条刻度各自独立归一化横向长度不代表数值大小只用来看结构运控是均质的连续刻度TTS 是跳段的离散块跳过 3、5、9音频是连续刻度上再叠一套四档角色。图中 L1–L10 的权重区间、priority的 1–10 取值、media_role的 40/60/80/100 四档全部取自对应.msg文件与《接口说明.md》§2.1.1.4、§2.2.1.2、§2.2.1.3 三节。有一处值得单独指出音频文件播放那一行priority是 1–10而 TTS 的主级恰好也是 1、2、4、6、8、10 这几个数字。数值看起来一样含义毫无关系。AudioFile.priority是连续的 1–10默认 6相邻值只差一档TtsPriorityLevel的主级是枚举语义层跳过的那些值是为将来插入新档位预留的位置。把priority_level 6的经验搬到AudioFile.priority 6读起来像实际语义完全不同。三、TTS 的权重空间99 与 100 的那 1 之隔TTS 这套最值得细看因为它的算术规则写得最明确而边界恰好踩在一个很微妙的位置上。TtsPriorityLevel.msg在接口里实际只定义了 6 个有效档位uint8 value uint8 UNKNOWN 0x00 # 后台服务层 uint8 BACKGROUND_L1 0x01 # 主动服务层 uint8 SERVICE_L2 0x02 # 任务执行层 uint8 MISSION_L4 0x04 # 交互相应层 uint8 INTERACTION_L6 0x06 # 危险预警层 uint8 WARNING_L8 0x08 # 生命安全层 uint8 SAFETY_L10 0x0a主级取 1、2、4、6、8、10步长 2跳过 3、5、7、9。配合「最终权重 主级 × 100 子权」就得到下面这张取值表。常量value最终权重区间来源BACKGROUND_L10x01100 – 199接口 文档SERVICE_L20x02200 – 299接口 文档MISSION_L40x04400 – 499接口 文档INTERACTION_L60x06600 – 699接口 文档WARNING_L80x08800 – 899接口 文档SAFETY_L100x0a1000 – 1099接口 文档SYSTEM_L77700 – 799仅文档接口无此常量表中最后一行的SYSTEM_L7是这篇文章的一个关键发现后面第七节会给出实测证据。先看一个更基础的性质。子权上限是 99主级步长是 100。相差 1这个 1 不是巧合。图 2 的横轴是最终权重1 单位对应 1 像素。可以直观看到L4用满子权封顶 499L6一个子权都不给也有 600两者相差 101。中间那 100 个整数位500–599没有任何档位覆盖。这意味着子权的作用半径被精确限制在同档内部。任何一个「低一档但更紧急」的播报都没有任何可用手段能顶到上一档去——把priority_weight调到 99也只是让它在本档里排到最前面。这是设计上的精确也是设计上的代价。它换来的是排序的绝对可预测性主级一旦确定跨档关系就锁死了不会因为某个调用方擅自把子权拉满而产生档位穿透。代价是分层变得刚性——如果产品上确实需要「低一档的紧急播报压过高档的例行播报」这套算术给不出解法只能改主级。再看那些空档。接口里的主级是 1、2、4、6、8、10跳过 3、5、7、9对应 300–399、500–599、700–799、900–999 四段。其中 700–799 被文档的SYSTEM_L7填上了另外三段仍然空着。这不是浪费是预留往这些区间插入新档位不会扰动任何已有排序。SYSTEM_L7的位置完全合理——它就在L6与L8之间那个天然的空位里问题只在于接口没给它常量。四、仲裁矩阵两格与字面意思相反权重算出来之后谁先播、谁被打断由《接口说明.md》§2.2.1.2 的四格矩阵决定。文档原文是这样的权重对比is_interrupted: trueis_interrupted: false新任务权重 ≥ 当前任务当前播报被立即掐断且不可恢复新语音开始播放仲裁优先级相同时存入缓冲区仲裁优先级大于当前任务时强制打断新任务权重 当前任务请求丢弃is_success: false请求丢弃is_success: false这张表里藏着两个与字段名字面意思相反的结论我在实测中逐一确认过。图 3 把四格矩阵与实测样本画在一起。格内样本编号对应第七节 8 个场景的序号服务端为按该矩阵实现的 mock非真机行为。第一个反直觉点is_interrupted true不是强制插队开关。按字面理解「打断true」应该意味着「我有话要说现在就说」。但矩阵写得清楚这一列只在「新权重 ≥ 当前」那一行才起作用权重不足时填true一样被丢弃。实测中第 5 条请求L62 602当前播报是 700即使is_interruptedtrue返回的仍是is_successfalseerror_message写着「权重 602 当前 700」。第二个反直觉点is_interrupted false不保证不被打断。同样按字面理解「不打断」应该意味着「别掐我」。但矩阵在false这一列写的是「大于则强制打断」。实测第 3 条请求L660 660当前是 600is_interruptedfalse结果仍是PREEMPT_NOW——非抢占模式下权重更高照样掐断。两点合起来这个字段的真实语义是「权重足够时同档要不要让」。它管的是同级仲裁不是插队权。而文档对这个字段有三处描述只有一处与矩阵一致出处原文与矩阵是否一致PlayTtsRequest.msg注释是否打断同等优先级播报true打断false不打断进入队列播报一致§2.2.1.2 字段表是否打断现有任务true: 立即抢占false: 自动排队不一致§2.2.1.2 仲裁矩阵见上表—.msg注释说的是「同等优先级」与矩阵吻合字段表说的是「现有任务」把范围放大了。差别就在这一行照字段表理解会写出一个自己以为安全的调用——以为什么都不填就万事大吉实际只要权重够高就会被掐。五、音频一处未闭合的歧义AudioFile.msg那一行的原文是# 可选项(1~100) priority priority_weight% is final priority uint32 priority_weight它给了表达式没给运算次序。至少有两种都能自圆其说的读法读法 A加性final priority weight / 100读法 B乘性final priority × (1 weight / 100)两种读法在权重较小时结果相近在权重接近上限时结论相反。输入 1A 值输入 2A 值A 判定B 值 1B 值 2B 判定是否分歧p6 50%6.50p7 0%7.00后者9.007.00前者分歧p6 100%7.00p7 0%7.00平手12.007.00前者分歧p6 100%7.00p10 0%10.00后者12.0010.00前者分歧p6 10%6.10p7 0%7.00后者6.607.00后者一致读法 A 的跨档上限是固定的增量恒 ≤ 1.0所以priority6用满权重最多追平 7永远超不过相当于「一档之内微调」。读法 B 在weight100时直接翻倍priority6可以压过 7 甚至 10相当于「变相增加一到两档」。同一个字段两种读法跨档能力差了两档。这不是笔误级别的歧义是会影响排序结果的歧义。我没有替它下结论因为这需要真机实测才能确定用两个不同priority的音频文件同时播放观察实际输出顺序。在没有真机的情况下断言其中一种就是在编数据。这一处我留在第九节的「未决问题」里。顺带一提AudioFile还有第三个维度media_role。# 可选项PulseAudio media.role不传默认 Music # 可选值Alert(系统告警,优先级100) | Notification(系统提示音,优先级80) | Communication(对话语音,优先级60) | Music(音乐/媒体,优先级40,默认) # 非 Music 角色播放时会自动降低(duck)音乐音量 string media_role这是第四套编号40/60/80/100由 PulseAudio 的角色体系决定与priority完全并行。所以严格说音频这一路内部同时跑着两套排序一套是应用层的priority 百分比一套是系统音频服务层的media_role。前者决定谁先播后者决定谁压低谁。这两套之间如何交互文档没有展开。六、验证一把规则写成代码三套机制的规则都是纯算术可以脱离 ROS 完整复现。我把它们写成了一个零依赖的模块只用标准库。下面节选自taxonomy/levels.py完整文件在仓库里。TTS_LEVEL_STRIDE 100 TTS_WEIGHT_MAX 99 def tts_final_weight(level_value: int, priority_weight: int) - int: 最终权重 主级 * 100 子权。 return level_value * TTS_LEVEL_STRIDE priority_weight def sub_weight_can_cross_level() - bool: 子权能否跨越主级子权上界 99主级步长 100。 return (TTS_WEIGHT_MAX 1) TTS_LEVEL_STRIDE这个函数不做范围钳制因为接口层同样不做——priority_weight声明为uint320–99 只是文档约定类型系统挡不住 100 以上的输入。这一点第七节的实测会确认。跑一遍边界场景命令与输出都在图 4 里。从这一处起凡是程序输出一律给真实终端截图不再手抄——写稿时把输出贴进代码块最容易出的错不是抄错数字而是抄错一个自己没注意到的对齐空格。python3 scenarios/run.py weight图 4 是容器pgc/primebot-humble:1.0内的真实终端画面Xvfb 上起一个 80×32 的 xterm用伪终端把命令逐条敲进交互式 bash等输出定格后用 ImageMagick 抓窗口未做任何裁剪与修饰。画面上半部分是七个档位的取值区间下半部分是相邻两级的临界值——L1满子权 199 对上L2零子权 200只差 1其余各相邻对都差 101。这个「差 1」正是子权跨不了级的算术来源。抓图脚本在仓库tools/shot/下all_shots.sh一次跑完本文全部五张终端图可以原样复现。除了验证算术我还用穷举确认了一个文档没写、但可以从矩阵推出来的结论。问题是is_interruptedfalse时入队的任务按什么顺序出队文档只写「存入缓冲区在当前任务播放完成后再进行处理」没说排序键。# 节选自 scenarios/exhaustive_queue.py 的 compute() def compute(): 返回 (入队总数, 差值-次数, 主级-入队数)。纯函数供 run.py 复用。 queued_diffs: dict {} per_level: dict {lv: 0 for lv in TTS_VALID_LEVEL_VALUES} total_queued 0 for level in TTS_VALID_LEVEL_VALUES: # 6 个合法主级 for w in range(0, TTS_WEIGHT_MAX 1): # 子权 0-99 req TtsRequest(probe, level, w, False) for current in range(CURRENT_MIN, CURRENT_MAX 1): # 100-1099 if arbitrate(req, current) is Verdict.QUEUED: total_queued 1 per_level[level] 1 diff req.weight - current queued_diffs[diff] queued_diffs.get(diff, 0) 1 return total_queued, queued_diffs, per_level图 5 与图 4 出自同一个容器、同一次会话、同一套抓图流程。画面里先确认解释器版本为 Python 3.10.12再执行scenarios/exhaustive_queue.py600 个合法请求 × 1000 个当前权重共 600000 种组合判为 QUEUED 的只有 600 组「新权重 − 当前权重」的取值集合只有一个元素[0]。画面下半部分按主级拆成六行每行 100 组正好等于该主级下合法的子权个数——每个请求只在「与自己权重相等」的那一个当前权重上入队。也就是说能被入队的任务权重必然与当前播报权重严格相等——更高会被立即抢占更低会被丢弃只有相等才进缓冲区。于是缓冲区里所有任务的权重全都相同FIFO 与「按权重排序」给出完全一样的出队顺序。文档没写排序键但在这个矩阵下写不写都不影响结果。这类推断比「文档没写所以不知道」有用得多。七、验证二用官方接口跑一次真实回环纯算术只能验证规则验证不了契约。真正要确认的是三件事接口里到底有哪些常量、越界值能不能发出去、矩阵的实际裁决是什么样。先在容器里核对枚举的可用性。这段代码不需要真机、不需要启动节点只要 import 消息类型。# 节选自 ros2_ws/src/priority_lab/priority_lab/enum_probe.py # 为便于展示省略了「与文档值是否相等」的比较分支 from aimdk_msgs.msg import TtsPriorityLevel # 依据《接口说明.md》2.2.1.2「TtsPriorityLevel (推荐主级常量列表)」逐条核对 DOC_TABLE [(BACKGROUND_L1, 1), (SERVICE_L2, 2), (MISSION_L4, 4), (INTERACTION_L6, 6), (SYSTEM_L7, 7), (WARNING_L8, 8), (SAFETY_L10, 10)] for name, doc_value in DOC_TABLE: try: actual int(getattr(TtsPriorityLevel, name)) print(f{name:16}{doc_value:8}0x{actual:02x}) except AttributeError: print(f{name:16}{doc_value:8}接口无此常量)图 6 是ros2 run priority_lab enum_probe的原样输出。上半段逐条核对文档 §2.2.1.2 的七个常量六个能取到SYSTEM_L7取不到分隔线以下的两段是同一个脚本紧接着做的越界试验下面会用到。SYSTEM_L7在文档 §2.2.1.2 的推荐常量表里带着完整语义——「系统重要提示如电量低、网络断开」但它不在ros2 interface show里也不在aimdk_msgs/interaction/msg/、install/、build/三处.msg副本的任何一处。写TtsPriorityLevel.SYSTEM_L7会直接抛AttributeError。那能不能硬填数字可以。类型层完全不校验——图 6 下半段就是这件事value填 7、5、9、11 全部写入成功并原样读回priority_weight填 100、250 也被接受按公式算出的最终权重分别是 700 与 850。weight 250这一条值得停一下850 直接落进WARNING_L8的 800–899 区间——一个本该「微调」的参数越界之后能顶掉整档。接口不拦只能靠调用方自觉。接下来是端到端。我按第四节那张矩阵实现了一个PlayTts服务端挂到官方aimdk_msgs/srv/PlayTts上然后依次发 8 个请求。# 节选自 ros2_ws/src/priority_lab/priority_lab/tts_client.py def build_request(level: int, weight: int, interrupt: bool, text: str) - PlayTts.Request: req PlayTts.Request() req.header.header.stamp.sec 0 req.tts_req.text text req.tts_req.priority_level.value level req.tts_req.priority_weight weight req.tts_req.domain priority_lab req.tts_req.trace_id flab-{level}-{weight}-{int(interrupt)} req.tts_req.is_interrupted interrupt return req客户端看到的回执图 7 是ros2 run priority_lab tts_client的原样输出。code与state两列取自aimdk_msgs通用响应头成功是 0/1失败是 1/2时长列对应estimated_duration八条全是 0。第 5、6 条被丢弃时客户端多打一行└─ error_message把服务端给的原因原样带回来。服务端侧的裁决日志真实时序[#n]是请求序号图 8 是同一个容器里cat /tmp/mock.log的结果客户端刚跑完日志就是那一刻写下的。默认的日志前缀级别、纳秒时间戳、节点名被RCUTILS_CONSOLE_OUTPUT_FORMAT{message}去掉——带上它每行超过 100 列终端里必然折行消息体一个字没改。这份日志确认了四件事第 4 条把value7填进去接口照收且按 700 参与了排序成功抢占了 660。文档档位在类型层可用在枚举层不可见——两种状态同时成立。第 3 条is_interruptedfalse仍被掐断与矩阵一致与字段名字面相反。第 5 条is_interruptedtrue仍被丢弃同上。排队任务最终自行出队mock 里把单次播报占用设为 3 秒这是我设定的占位时长不是官方数值但客户端全程收不到任何通知。这是个结构性的缺口下面单独说。关于最后一点的成因文档写得很直白时长字段局限由于 TTS 采用云端/本地流式合成当前版本下estimated_duration固定返回0。任务闭环建议系统暂未开放播报结束的话题通知。若业务逻辑需在语音播报后执行如先说话后走动建议通过字数预估约 200ms/字进行延时处理。回执里的estimated_duration字段实测确实是0图 7 的「时长」列八行全为 0。也就是说TTS 是一条开环链路调用方知道「请求进队了」但永远不知道「说完了没有」只能拿字数乘以 200 ms 去估。相比之下运控的CommonTaskResponse带task_id可以追踪任务状态。同一个 SDK 里一个异步接口有任务 ID另一个没有——这属于接口层的空白不是使用者的疏忽。需要说明的是这个PlayTts服务端是我按文档矩阵实现的不是启元的实现。它的作用是让请求走真实的 ROS 2 序列化通道验证契约能通、字段能填、回执能解析。真机上「第 3 条请求到底会不会被掐断」「value7会不会被接受」仍需真机确认。凡是 mock 得出的结论我在上文都标了来源。八、为什么会分成三套把三处定义的文件路径摆出来原因就清楚了aimdk_msgs/interaction/msg/PlayTtsRequest.msg—— TTS属于交互控制模块aimdk_msgs/hal/audio/msg/AudioFile.msg—— 音频文件属于硬件抽象模块的audio子包aimdk_msgs/mc/msg/McInputSource.msg—— 输入源属于运动控制模块三套机制分属三个包、三个模块、三拨人。它们各自解决各自领域里的排序问题各自设计了字段最终在字段名上撞了车。这不是谁抄错了谁而是分层架构下同一概念在不同层各自演化的必然结果。从这个角度看三套设计其实各自都很合理运控要的是安全兜底。控制权必须唯一否则两路指令同时作用于关节后果是物理的。所以它用排他锁还用 1000 ms 超时做看门狗——发流的一方一旦挂掉控制权必须自动释放不能让机器人保持最后一条指令继续动。这里「优先级」回答的是「谁在控制」。TTS 要的是顺序与可打断。语音是流式的可以排队、可以中途掐断掐断了也不会有物理后果。所以它用复合权重主级管档、子权管同档内的先后还单独给了is_interrupted控制同级是否让位。这里「优先级」回答的是「谁先说」。音频要的是混音。提示音和背景音乐可以同时响不需要一方静音。所以它用百分比微调加音量闪避——警报声响起时把音乐压低而不是掐掉音乐。这里「优先级」回答的是「谁盖过谁」。三种「优先级」对应三种完全不同的资源模型独占资源、队列资源、混音资源。资源模型不同仲裁方式自然不能通用。硬要统一成一套字段反而会把三处都做坏。真正的问题不在设计在命名。三个模块都用了priority和priority_weight这两个词而它们的量纲、算法、边界各不相同。跨模块开发时前一处的直觉会直接带到后一处出问题的地方往往连报错都没有——运控那边静默丢帧音频那边悄悄 duck只有 TTS 会给一个false。九、还没解决的问题按重要程度排有五处AudioFile.priority_weight的运算次序未定。第五节那两种读法跨档能力差两档需要真机同时播两个不同priority的文件、观察实际输出顺序才能确定。无真机时断言任何一种都是编数据。SYSTEM_L7是预留还是遗漏无法判断。它在文档里有完整语义在接口里没有常量在权重空间里有位置。三种可能都存在正在实现中、文档先写了、或者实现时被砍了但文档没同步。没有官方说明只能标注现状。缓冲区容量与溢出策略未定义。第六节能推出「缓冲区内的任务权重全都相同」但推不出缓冲区能装几个、装满了丢新还是丢旧。TTS与音频文件两路之间如何交互文档未展开。一个 TTS 播报INTERACTION_L6权重 600与一个media_roleAlert的告警音角色优先级 100同时触发时谁让谁两者在不同模块、不同层各自的排序键无法直接比较。media_role与priority的优先级关系未说明。文档只说「非 Music 角色播放时会自动降低duck音乐音量」没说priority高的 Music 会不会拒绝被 duck。这五处我都留在原样没有替它补。补出来的东西看着完整实际是错的——尤其第 1 条和第 4 条随便写一个「应该是这样」读的人照着做问题会出现在几个月后的现场调试里。十、接手这套接口时先做四件事如果要在启元 Q1 上写播报相关的业务按这个顺序过一遍能避开大部分坑第一先确定你要用的是哪一套。是让机器人「说一句话」PlayTts还是「放一个音频文件」PlayAudioFile前者走交互模块后者走硬件抽象模块两套的priority_weight含义不同。选错了接口后面调参数全是白调。第二priority_level只用接口里那 6 个常量。不要照着文档写SYSTEM_L7——编译期就会失败。如果确实需要「系统重要提示」这一档用INTERACTION_L6或WARNING_L8或者硬填value7并接受它是一个接口不承认的档位。第三不要把is_interrupted当成插队开关。它只决定同级是否让位。想让一句话压过正在播的内容唯一可靠的手段是提高priority_level——但记住子权跨不了级L499仍然低于L60。第四给播报留出时间余量。PlayTts返回is_successtrue只代表进队了estimated_duration恒为 0没有播报结束话题。要在播报后执行动作比如先说话再走动只能按字数估——文档给的经验值是约 200 ms/字单次播报建议控制在 100 字以内。这四条不需要真机就能验证前三条第四条在真机上用秒表对一次字数与时长就能把估算公式校准成自己的系数。三套优先级并存的局面短期不会改变——它们分属三个模块各自服务不同的资源模型统一命名的收益远小于改动的风险。对开发者来说能做的事只有一件把它们的差异记清楚别用一套的直觉去写另一套的代码。本文用到的规则复现、枚举核对、矩阵验证脚本都放在 AtomGit 上无需真机即可复现https://atomgit.com/2301_76341691/primebot-priority-taxonomy-lab如果你也在启元机器人上做二次开发欢迎到启元机器人开发者社区 https://dev.primebot.com/ 交流那里有官方文档、SDK 仓库入口和开发者讨论区。文中若有不准确的地方也欢迎在仓库提 Issue 指出来。