feat(mtc): .mt 脚本编译器落地 + pybot 扬声器静音语义

mtc 新模块:命令式动作脚本(Python DSL)→ .mt 矩阵编译器,非阻塞时间轴 IR(重叠截断/rest 静止值)+ 10ms 栅格化,产物可由 pybot/bot4cj 直接执行;34 测试全绿(含 pybot parser e2e)。

pybot speaker.py:frequency<=0 改为静音节拍(仅 sleep 保时序,不再钳到 min_f 低鸣),对齐仓颉版 sleep 占位语义;mtc Beep 通道 rest=0,段前/段间/段后回落静音,两侧语义闭环。

修复 test_actpred_compatibility 过时断言(99c1bc1 首行分解为重复微步 19→20 帧时漏改,14→15)。

文档三处同步:根 README 回填 memdb/pybot/actpred 并新增 mtc(模块表+端口一览+章节),MODULES/ARCHITECTURE 同步 mtc,新建 mtc/README.md。
This commit is contained in:
qing committed 2026-08-15 13:54:47 +08:00
1 parent 205a60f039
commit 01126a5786
18 files changed
+1125 -7

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
"""mtc —— 命令式动作脚本(Python DSL)→ .mt 矩阵脚本编译器
管线:script.py --exec--> 事件收集 --> 时间轴 IR(相对→绝对,
重叠截断)--> 10ms 栅格化 --> out.mt --> pybot 解析/执行。
用法:
python -m mtc walk.py # 编译到 stdout(管道风格)
python -m mtc walk.py -o walk.mt # 写文件
python -m pybot --dry-run walk.mt # 交给 pybot 验证/执行
"""
__version__ = "0.1.0"
from .dsl import CompileError, compile_script # noqa: F401
+54
View File
@@ -0,0 +1,54 @@
"""CLI 入口:python -m mtc script.py [-o out.mt]
诊断(编译告警/错误)走 stderr,stdout 保持纯净 .mt 文本
(对齐 pybot 的 S02 日志规范,支持 mtc walk.py | pybot --dry-run 管道)。
"""
import argparse
import logging
import sys
from .dsl import CompileError, compile_script
def main(argv=None):
p = argparse.ArgumentParser(
prog="mtc",
description="命令式动作脚本(Python DSL)→ .mt 编译器")
p.add_argument("script", help="DSL 脚本路径(.py)")
p.add_argument("-o", "--output", default=None,
help="输出 .mt 路径(缺省写 stdout)")
p.add_argument("--verbose", action="store_true", help="DEBUG 日志")
args = p.parse_args(argv)
logging.basicConfig(stream=sys.stderr,
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(levelname)s %(name)s: %(message)s")
try:
with open(args.script, encoding="utf-8") as f:
source = f.read()
except OSError as e:
logging.error("无法读取脚本: %s", e)
return 2
try:
text, warnings = compile_script(source, filename=args.script)
except CompileError as e:
logging.error("编译失败: %s", e)
return 2
for w in warnings:
logging.warning("%s", w)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(text)
logging.info("written: %s (%d lines)", args.output,
text.count("\n"))
else:
sys.stdout.write(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+204
View File
@@ -0,0 +1,204 @@
"""mtc DSL —— 命令式动作脚本(Python)→ 事件收集
脚本即 .py,通过 exec(source, make_namespace(collector)) 执行,
命名空间内注入两类函数:
硬件声明(函数名 = .mt 块名,位置参数 = 表头列,严格一一对应):
I2C("i2c-1", "/dev/i2c-1")
PCA9685("pca9685-0", "i2c-1", "0x40")
Servo("s1", 90, 0, 0, "pca9685-0", 0) # name,max,min,offset,controller,channel
Speaker("spk", 2, 48000, 0.6, 5000, 20)
Camera("cam0", 640, 480, 30, 8)
MPU6050_BIND("imu0", "i2c-1", "0x68")
Alias("A", "angle")
register_block("ADS1115", ["name", "I2C", "addr"]) # 开放扩展
时间轴指令(编译期求值,不直接出现在 .mt 行中):
Action(dev, delta, dur) # 相对运动:当前游标处注册,dur ms 内走 delta
SetTo(dev, angle, dur) # 绝对运动(糖:delta = angle - 已注册目标值)
Delay(ms) # 游标推进固定量
Wait() # 游标推进到所有在途运动的最晚完成时刻(汇合)
Beep(freq, dur) # 扬声器频率段(默认设备 spk)
Pose(dev, angle) # 声明编译期初始姿态(默认 0,不产生运动段)
dur/Delay 必须是 10 的倍数(.mt 栅格与 pybot 微步节拍均为 10ms)。
"""
from .timeline import STEP_MS, Channel, total_ms
from .emit import emit_mt
# 已知块 → 表头列(位置参数顺序;首列恒为 name)
_BLOCK_SCHEMAS = {
"Alias": ["name", "alias"],
"I2C": ["name", "path"],
"PCA9685": ["name", "I2C", "address"],
"MPU6050_BIND": ["name", "I2C", "addr"],
"Speaker": ["name", "channels", "sample_rate", "volumn", "max_f", "min_f"],
"Servo": ["name", "max", "min", "offset", "controller", "channel"],
"Camera": ["name", "width", "height", "framerate", "capacity"],
}
class CompileError(Exception):
"""脚本编译错误(携带可读信息,CLI 原样输出)"""
class Collector:
"""一次编译的全部状态:硬件块 + 通道时间轴 + 游标 + 告警"""
def __init__(self):
self.blocks = [] # [(block_name, columns, row_values)]
self.channels = {} # {通道名(dev.param): Channel}(注册序)
self.cursor = 0.0 # 当前时间游标 ms(单调推进)
self.warnings = []
# ---------------- 硬件 ----------------
def add_block(self, block_name, columns, values):
self.blocks.append((block_name, columns, [str(v) for v in values]))
def device_names(self):
"""已声明硬件变量名集合(每块首列恒为 name)"""
return {row[0] for _, _, row in self.blocks}
def first_speaker(self):
"""第一个 Speaker 块声明的设备名(delay 节拍列所属),无则 None"""
for block_name, _, row in self.blocks:
if block_name == "Speaker":
return row[0]
return None
# ---------------- 时间轴 ----------------
def channel(self, name):
ch = self.channels.get(name)
if ch is None:
ch = self.channels[name] = Channel(name)
return ch
def check_device(self, dev):
if dev not in self.device_names():
raise CompileError(
f"设备 '{dev}' 未声明(先用 Servo/Speaker/插件块声明)")
def warn_overlaps(self):
for ch in self.channels.values():
self.warnings.extend(ch.overlap_warns)
# ---------------- 编译 ----------------
def compile(self):
"""归一化 IR 并序列化为 .mt 文本,返回 (mt_text, warnings)"""
self.warn_overlaps()
active = [ch for ch in self.channels.values() if ch.segments]
t_total = total_ms(self.channels, self.cursor)
if not active and t_total <= 0:
raise CompileError("脚本没有任何动作(@Action 将为空)")
if self.first_speaker() is None:
self.warnings.append(
"无 Speaker 块:.mt 无 delay 节拍列,pybot 执行时行间隔"
"不等待(时间轴语义丢失,仅 --loop/解析场景可用)")
text = emit_mt(self.blocks, active, t_total,
speaker=self.first_speaker())
return text, self.warnings
def _check_dur(ms, what):
if float(ms) <= 0 or float(ms) % STEP_MS != 0:
raise CompileError(f"{what} 必须是正的 {STEP_MS} 的倍数,got {ms!r}")
def make_namespace(collector):
"""构建脚本 exec 命名空间(硬件函数 + 时间轴指令)"""
ns = {}
schemas = dict(_BLOCK_SCHEMAS)
def _make_block_fn(block_name, columns):
def fn(*args):
if len(args) != len(columns):
raise CompileError(
f"{block_name}() 需要 {len(columns)} 个参数 "
f"({', '.join(columns)}),got {len(args)}")
collector.add_block(block_name, columns, args)
fn.__name__ = block_name
return fn
for block_name, cols in schemas.items():
ns[block_name] = _make_block_fn(block_name, cols)
def register_block(block_name, columns):
"""注册自定义块(插件扩展):register_block("ADS1115", [...])"""
if not columns or columns[0] != "name":
raise CompileError("register_block: 首列必须是 name")
ns[block_name] = _make_block_fn(block_name, list(columns))
def _register(dev, param, value, dur, absolute):
collector.check_device(dev)
_check_dur(dur, f"{dev}.{param} 的 dur")
if param == "delay":
raise CompileError("spk.delay 列由编译器管理(恒 10),"
"节拍请用 Delay()/Wait()")
ch = collector.channel(f"{dev}.{param}")
v0 = ch.target # 重叠时基准 = 前段目标值(编译期确定)
v1 = float(value) if absolute else v0 + float(value)
ch.add(collector.cursor, float(dur), v0, v1)
def Action(dev, delta, dur, param="angle"):
"""相对运动:dur ms 内走 delta(可正可负)"""
_register(dev, param, delta, dur, absolute=False)
def SetTo(dev, angle, dur, param="angle"):
"""绝对运动:dur ms 内到达 angle"""
_register(dev, param, angle, dur, absolute=True)
def Delay(ms):
"""游标推进固定量"""
_check_dur(ms, "Delay")
collector.cursor += float(ms)
def Wait():
"""汇合:游标推进到所有在途运动的最晚完成时刻"""
busy = [ch.busy_until for ch in collector.channels.values()]
collector.cursor = max([collector.cursor] + busy)
def Beep(freq, dur, dev="spk"):
"""扬声器频率段:dev.frequency 在 dur ms 内恒为 freq(阶跃段)
通道静止值设为 0:段前/段间空隙/段后回落 0 = 静音
(对齐 pybot speaker 静音语义:freq<=0 不出声仅保节拍)。
"""
collector.check_device(dev)
_check_dur(dur, f"Beep({freq}) 的 dur")
ch = collector.channel(f"{dev}.frequency")
ch.rest = 0.0
ch.add(collector.cursor, float(dur), float(freq), float(freq))
def Pose(dev, angle, param="angle"):
"""声明编译期初始姿态(不产生运动段;须在 Action 之前)"""
collector.check_device(dev)
ch = collector.channel(f"{dev}.{param}")
if ch.segments:
raise CompileError(
f"Pose({dev}) 必须在任何 Action 之前声明")
ch.set_initial(angle)
ns.update(Action=Action, SetTo=SetTo, Delay=Delay, Wait=Wait,
Beep=Beep, Pose=Pose, register_block=register_block)
return ns
def compile_script(source, filename="<script>"):
"""编译 DSL 脚本 → (mt_text, warnings)
脚本语法错误包装为 CompileError(携带文件/行号)。
"""
collector = Collector()
ns = make_namespace(collector)
try:
code = compile(source, filename, "exec")
exec(code, ns)
except CompileError:
raise
except Exception as e:
raise CompileError(f"{filename}: {type(e).__name__}: {e}") from e
return collector.compile()
+80
View File
@@ -0,0 +1,80 @@
""".mt 序列化 —— 硬件块(依赖序/声明序)+ @Action 栅格矩阵
输出契约(与 pybot parser 对齐):
@块名 / @ 定界;行 0 表头;行尾 ';';列间空格分隔;
@Action 表头为 '设备.参数' 长名(Q05 校验恰含一个 '.'),
末列 '<speaker>.delay' 恒 10(栅格节拍),无 Speaker 块则无此列。
栅格行语义:t=10,20,…,T,行值为该 10ms 结束时刻的绝对姿态;
pybot run() 对 delay=10 的行分解为 1 个微步且插值系数 1.0,
即直接到位 —— 与栅格语义严格等价。
"""
from .timeline import STEP_MS
def fmt_num(v):
"""数值格式化:整数无小数点,小数保留至多 3 位(栅格插值值)"""
f = float(v)
if f == int(f):
return str(int(f))
return f"{f:.3f}".rstrip("0")
def _fmt_block(lines, name, columns, rows):
lines.append(f"@{name}")
lines.append(" ".join(columns) + ";")
for row in rows:
lines.append(" ".join(str(v) for v in row) + ";")
lines.append("@")
def emit_mt(blocks, active_channels, t_total, speaker=None):
"""生成 .mt 全文
blocks: [(block_name, columns, row_values_str)](声明序)
active_channels: [Channel](有运动段的通道,注册序)
t_total: 编译总时长 ms(10 的倍数)
speaker: delay 节拍列所属设备名;None 则无节拍列
"""
from .dsl import CompileError # 避免环:dsl 依赖 emit
lines = []
merged = [] # 相邻同名块合并为一个块多行(.mt 惯例)
for block_name, columns, row in blocks:
if merged and merged[-1][0] == block_name and merged[-1][1] == columns:
merged[-1][2].append(row)
else:
merged.append((block_name, columns, [row]))
for block_name, columns, rows in merged:
_fmt_block(lines, block_name, columns, rows)
lines.append("") # 块间空行(MatrixSaver 风格)
delay_col = f"{speaker}.delay" if speaker else None
freq_col = f"{speaker}.frequency" if speaker else None
# pybot 契约:SpeakerVar.play 无条件读 frequency+delay 两键,
# delay 节拍列必须与同设备 frequency 列成对出现(std.mt 惯例:
# spk.F spk.delay;无发声需求时 frequency 恒 0,对齐 jump.mt 首行)
active_names = {ch.name for ch in active_channels}
fill_freq = bool(freq_col) and freq_col not in active_names
header = [ch.name for ch in active_channels]
if fill_freq:
header.append(freq_col)
if delay_col:
header.append(delay_col)
if not header:
raise CompileError("@Action 无列(既无运动通道也无 Speaker 节拍列)")
n = int(round(t_total / STEP_MS))
rows = []
for k in range(1, n + 1):
t = k * STEP_MS
row = [fmt_num(ch.value_at(t)) for ch in active_channels]
if fill_freq:
row.append("0")
if delay_col:
row.append(str(STEP_MS))
rows.append(row)
_fmt_block(lines, "Action", header, rows)
return "\n".join(lines) + "\n"
+108
View File
@@ -0,0 +1,108 @@
"""时间轴 IR —— 运动段、重叠截断、10ms 栅格化采样
非阻塞时间轴模型(与 mtc 设计定稿一致):
Action 在当前游标处注册运动段并立即返回,Delay/Wait 推进游标;
.mt 一行即全通道快照,段在不同通道上的重叠天然表达并发。
栅格语义:格点 t=10,20,…,T,每行代表该 10ms 结束时刻的姿态。
与 pybot 执行的等价性:delay=10 的行在 robot.run() 中分解为 1 个
微步且插值系数 1.0(k=0,n=1),即直接到位 —— 栅格行语义严格成立。
"""
from dataclasses import dataclass, field
STEP_MS = 10 # .mt 栅格步长,与 pybot run() 微步节拍一致
@dataclass
class Segment:
"""一个运动段:[t0, t0+dur) 内从 v0 线性运动到 v1(绝对值)"""
t0: float
dur: float
v0: float
v1: float
@property
def end(self):
return self.t0 + self.dur
def value_at(self, t):
"""t 时刻的值;段前取 v0、段后取 v1(保持语义)"""
if t <= self.t0:
return self.v0
if t >= self.end:
return self.v1
return self.v0 + (self.v1 - self.v0) * (t - self.t0) / self.dur
@dataclass
class Channel:
"""单通道时间轴(如 s1.angle / spk.frequency)
段按注册序追加(游标单调 ⇒ t0 单调),因此任何时刻最多一个
未完结段(末尾段)。重叠规则:新段注册时若末尾段未完结,则
截断末尾段至 t0,新段基准取前段目标值 v1(编译期确定,不依赖
运行时实际姿态),由调用方在注册前取 target 作为新段 v0。
rest:静止值。None(舵机等姿态通道)段外保持前段目标值;
数值(如 frequency 通道 = 0)段前/段间空隙/段后统一回落该值
—— 对齐 pybot speaker 静音语义(freq<=0 不出声仅保节拍)。
"""
name: str
initial: float = 0.0 # Pose() 声明的编译期初始姿态
rest: float = None # 静止值(None=保持前段目标值)
segments: list = field(default_factory=list)
overlap_warns: list = field(default_factory=list) # 截断告警文本
@property
def target(self):
"""已注册目标姿态(最后段 v1;无段取 initial)"""
return self.segments[-1].v1 if self.segments else self.initial
@property
def busy_until(self):
"""最晚在途完成时刻"""
return self.segments[-1].end if self.segments else 0.0
def set_initial(self, angle):
if self.segments:
raise ValueError(
f"Pose({self.name}) 必须在任何 Action 之前声明")
self.initial = float(angle)
def add(self, t0, dur, v0, v1):
"""注册运动段;与未完结末尾段重叠时截断之并记录告警"""
if self.segments and self.segments[-1].end > t0:
prev = self.segments[-1]
self.overlap_warns.append(
f"{self.name}: 段 [{prev.t0:g},{prev.end:g}) 与新段 "
f"[{t0:g},{t0 + dur:g}) 重叠,前段截断至 {t0:g}ms "
f"(新段基准取前段目标值 {prev.v1:g})")
prev.dur = t0 - prev.t0 # t0/t0 均为 10 倍数,截断后仍对齐栅格
if prev.dur <= 0:
self.segments.pop()
self.segments.append(Segment(t0, dur, v0, v1))
def _rest_or(self, v):
"""段外取值:设定了 rest 则回落静止值,否则保持前段目标值"""
return self.rest if self.rest is not None else v
def value_at(self, t):
"""格点采样:段内插值;段前/段间空隙/段后按 rest 规则取值"""
if not self.segments:
return self._rest_or(self.initial)
prev_v1 = self.initial
for seg in self.segments:
if t <= seg.t0:
return self._rest_or(prev_v1) # 段前(含段间空隙)
if t <= seg.end:
return seg.value_at(t) # 段内(端点连续)
prev_v1 = seg.v1
return self._rest_or(prev_v1) # 全部段后
def total_ms(channels, cursor):
"""编译总时长:所有通道最晚完成时刻与游标的最大值"""
return max([ch.busy_until for ch in channels.values()] + [cursor])