diff --git a/Global/Global.c b/Global/Global.c index d1bd387..f37d4f5 100644 --- a/Global/Global.c +++ b/Global/Global.c @@ -101,7 +101,7 @@ int16_t MIC_VOL_MAP[11]= {-9000,-7000,-5000,-3000,-1000,1000,3000,5000,7000,9000 uint8_t TOUCH_AREA_NUM = (sizeof(Touch_Areas)/sizeof(Touch_AreaTypeDef)); -const uint8_t Version[3] = {0,2,20}; /* major.minor.patch — 0.2.20: low-battery <6.2V stop audio + tip UI + 10s power-off; UI0902 LOWBAT_TIP */ +const uint8_t Version[3] = {0,2,21}; /* major.minor.patch — 0.2.21: 问题2 曲目LED不再改写和弦映射(6m/Am 不再变 AM7); 和弦读回编码 m7/M7/ad9/sus 修正, 属七兼容 0x0D */ uint8_t Led = 8; bool PressFlag = 0; @@ -1175,13 +1175,13 @@ void Return_Light_buff(uint8_t key_id_1629) { case 0:buff[5] = 0; break; case 1:buff[5] = 8; break; - case 2:buff[5] = 2; break; - case 3:buff[5] = 13;break; - case 4:buff[5] = 10;break; - case 5:buff[5] = 31;break; - case 6:buff[5] = 30;break; - case 7:buff[5] = 4; break; - case 8:buff[5] = 11;break; + case 2:buff[5] = 10;break; /* m7 -> 0x0A */ + case 3:buff[5] = 13;break; /* 7 -> 0x0D */ + case 4:buff[5] = 2; break; /* M7 -> 0x02 */ + case 5:buff[5] = 4; break; /* ad9 -> 0x04 */ + case 6:buff[5] = 11;break; /* m7-5 -> 0x0B */ + case 7:buff[5] = 31;break; /* sus2 -> 0x1F */ + case 8:buff[5] = 30;break; /* sus4 -> 0x1E */ } if(Led == buff[3]) USART4_SendData(buff,sizeof(buff)); @@ -1345,66 +1345,32 @@ void Loop_Light_Handle(uint8_t measure) void BL_Set_led(uint8_t led, uint8_t chord_type) { - uint8_t chord_offset; + /* 问题2修复:曲目 LED 引导只负责点灯,禁止改写和弦映射表。 + * 否则 App 曲谱显示 6m(Am) 时若 LED 帧带 0x02,会把 VI 中间键写成 AM7, + * 导致 UI 与 MIDI 发声不一致;和弦品质仅由映射页 02 03/02 04 维护。 */ + (void)chord_type; Led = led; - switch(chord_type){ - case 0x00: - chord_offset = 0; - break; - case 0x08: - chord_offset = 1; - break; - case 0x02: - chord_offset = 4; - break; - case 0x13: - chord_offset = 3; - break; - case 0x0A: //A - chord_offset = 2; - break; - case 0x1F: //1F - chord_offset = 7; - break; - case 0x1E: //1E - chord_offset = 8; - break; - case 0x04: - chord_offset = 5; - break; - case 0x0B: //B - chord_offset = 6; - break; - } - switch(led){ case LED_L1: app_tm1629_set_led(LED_L1, LED_COLOR_R); - chord_type_index_map[2].type = chord_offset; break; case LED_L2: app_tm1629_set_led(LED_L2, LED_COLOR_G); - chord_type_index_map[5].type = chord_offset; break; case LED_L3: app_tm1629_set_led(LED_L3, LED_COLOR_B); - chord_type_index_map[8].type = chord_offset; break; case LED_L4: app_tm1629_set_led(LED_L4, LED_COLOR_YELL); - chord_type_index_map[11].type = chord_offset; break; case LED_L5: app_tm1629_set_led(LED_L5, LED_COLOR_PURP); - chord_type_index_map[14].type = chord_offset; break; case LED_L6: app_tm1629_set_led(LED_L6, LED_COLOR_CYA); - chord_type_index_map[17].type = chord_offset; break; case LED_L7: app_tm1629_set_led(LED_L7, LED_COLOR_WHITE); - chord_type_index_map[20].type = chord_offset; break; default: // 鏈煡鐏彿锛屽叏閮ㄥ叧闂? @@ -1442,7 +1408,8 @@ void BT_Chord_offset_map(uint8_t pos_offset,uint8_t chord_offset) case 0x02: chord_APP_Local_offset = 4; break; - case 0x13: + case 0x0D: /* 属七(文档码) */ + case 0x13: /* 属七(旧码兼容) */ chord_APP_Local_offset = 3; break; case 0x0A: //A diff --git a/protocol/bl_uart_parse.c b/protocol/bl_uart_parse.c index ec7ca4a..ac0de6d 100644 --- a/protocol/bl_uart_parse.c +++ b/protocol/bl_uart_parse.c @@ -521,25 +521,25 @@ static void handleReadRhythmMap(uint8_t *data) chord_map = 8; break; case 2: - chord_map = 2; + chord_map = 10; /* m7 -> 0x0A */ break; case 3: - chord_map = 13; + chord_map = 13; /* 7 -> 0x0D */ break; case 4: - chord_map = 10; + chord_map = 2; /* M7 -> 0x02 */ break; case 5: - chord_map = 31; + chord_map = 4; /* ad9 -> 0x04 */ break; case 6: - chord_map = 30; + chord_map = 11; /* m7-5 -> 0x0B */ break; case 7: - chord_map = 4; + chord_map = 31; /* sus2 -> 0x1F */ break; case 8: - chord_map = 11; + chord_map = 30; /* sus4 -> 0x1E */ break; default: chord_map = 0; diff --git a/tools/test_chord_map_fix.py b/tools/test_chord_map_fix.py new file mode 100644 index 0000000..4c1a394 --- /dev/null +++ b/tools/test_chord_map_fix.py @@ -0,0 +1,287 @@ +# -*- coding: utf-8 -*- +""" +test_chord_map_fix.py — 和弦映射 m7/M7 等读回编码修复的专项验证 + +背景: + 固件读路径(handleReadRhythmMap / Return_Light_buff)曾把内部 type 与协议码对调: + m7(type2) 误报 0x02(M7), M7(type4) 误报 0x0A(m7), + ad9/sus2/sus4/m7-5 同样错位; 属七写只认 0x13 不认文档的 0x0D。 + 本脚本验证修复后的固件: 写->读 回环必须逐字节一致。 + +链路: 笔记本 BLE <-> "Smart Guitar MIDI" <-> 吉他 UART4 +用法: + python test_chord_map_fix.py # 协议回环 + 交互按键上报验证 + python test_chord_map_fix.py --skip-manual # 只跑协议回环(无需碰琴) + python test_chord_map_fix.py --transport uart # 自定义串口通道 + +报告: Doc/reports/chord_map_fix_<时间戳>.md +""" +from __future__ import annotations + +import argparse +import datetime +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from test_protocol_app_sim import build, hexs, Results, HEAD, DEV_ID # noqa: E402 +from test_protocol_ble import BleMidiSim # noqa: E402 + +# 协议码 -> (名称, 内部type) 依据 Doc/指令测试.docx 和弦指令映射表 +CHORDS = [ + (0x00, "M 大三"), + (0x08, "m 小三"), + (0x02, "M7 大七"), + (0x0D, "7 属七"), + (0x0A, "m7 小七"), + (0x1F, "su2 挂二"), + (0x1E, "sus4 挂四"), + (0x04, "ad9 增九"), + (0x0B, "m7-5 半减七"), +] + +REPORTS_DIR = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "Doc", "reports" +) + + +def read_map(sim): + """02 01 -> 42B (21 pitch + 21 chord) 或 None""" + r = sim.query(build(0x02, 0x01), timeout=3.0) + if r is None or len(r) != 47 or r[0] != HEAD or r[1] != 0x60 or r[2] != 0x02 or r[3] != 0x01: + return None + return bytes(r[4:-1]) + + +def chord_of(m: bytes, key1: int) -> int: + """key1: 1..21 -> 协议和弦码""" + return m[21 + key1 - 1] + + +def run(report: Results, sim: BleMidiSim, skip_manual: bool, auto: bool = False) -> None: + # ---- 0. 连接与版本 ---- + r = sim.query(build(0x01, 0x01)) + ok = r is not None and r[:3] == bytes([HEAD, 0x60, 0x01]) + report.add("01 01 连接设备", "PASS" if ok else "FAIL", hexs(r)) + if not ok: + report.add("后续用例", "SKIP", "连接失败, 级联跳过") + return + + r = sim.query(build(0x01, 0x0C)) + build_id = bytes(r[4:-1]).decode("ascii", "replace") if r and len(r) > 5 else "?" + report.add("01 0C 固件构建", "PASS" if r else "FAIL", build_id) + + # ---- 1. 读原始映射表并保存(测试完恢复) ---- + orig = read_map(sim) + report.add("02 01 读原始映射表", "PASS" if orig else "FAIL", + hexs(bytes([HEAD, 0x60, 0x02, 0x01]) + orig + bytes([0xF7])) if orig else "无应答") + if not orig: + report.add("后续用例", "SKIP", "读表失败, 级联跳过") + return + + KEY = 1 # 用 1 号键(I 区上C)做逐码回环 + try: + # ---- 2. 单键逐码写->读回环(核心回归: m7/M7/sus2/sus4/ad9/m7-5/属七) ---- + for code, name in CHORDS: + sim.send(build(0x02, 0x03, KEY - 1, code)) + time.sleep(0.15) + m = read_map(sim) + got = chord_of(m, KEY) if m else None + ok = got == code + report.add( + f"02 03 写 {name} (0x{code:02X}) 读回一致", + "PASS" if ok else "FAIL", + f"读回=0x{got:02X}" if got is not None else "读表失败", + ) + + # 属七旧码 0x13 兼容: 写入后应按文档码 0x0D 读回 + sim.send(build(0x02, 0x03, KEY - 1, 0x13)) + time.sleep(0.15) + m = read_map(sim) + got = chord_of(m, KEY) if m else None + report.add( + "02 03 写属七旧码 0x13 -> 读回 0x0D", + "PASS" if got == 0x0D else "FAIL", + f"读回=0x{got:02X}" if got is not None else "读表失败", + ) + + # ---- 3. 02 04 整表写入(含全部 9 种和弦码) -> 回读逐字节一致 ---- + pitch = bytes([0x01] * 21) + codes = bytes(c for c, _ in CHORDS) + chord = bytes(codes[i % len(codes)] for i in range(21)) + full = pitch + chord + r = sim.query(build(0x02, 0x04, *full), timeout=3.0) + ok = r is not None and len(r) == 47 and bytes(r[4:-1]) == full + report.add("02 04 整表写入(回读帧一致)", "PASS" if ok else "FAIL", hexs(r)) + m = read_map(sim) + ok2 = m == full + report.add( + "02 04 写入后独立读回一致", + "PASS" if ok2 else "FAIL", + "42B 全等" if ok2 else f"diff at {[i for i in range(42) if m and m[i] != full[i]]}", + ) + + # ---- 4. F0 51 04 按键上报(Return_Light_buff)编码验证 ---- + if skip_manual: + report.add("F0 51 04 按键上报编码", "SKIP", "--skip-manual") + elif auto: + run_auto(report, sim, orig) + else: + run_manual(report, sim, orig) + finally: + # ---- 5. 恢复原始映射表 ---- + r = sim.query(build(0x02, 0x04, *orig), timeout=3.0) + m = read_map(sim) + ok = m == orig + report.add("恢复原始映射表", "PASS" if ok else "FAIL", + "已恢复" if ok else "恢复失败, 请用 App 重置") + + +def run_manual(report: Results, sim: BleMidiSim, orig: bytes) -> None: + """交互验证 F0 51 04 按键上报编码; 结束后恢复原始映射表""" + try: + for code, name in ((0x02, "M7"), (0x0A, "m7")): + # 04 00: LED_L1 -> Led=0, 且把 2 号键(中间C) type 设为该和弦 + sim.send(build(0x04, 0x00, 0x00, code)) + time.sleep(0.2) + sim.drain() + print(f"\n>>> 下一轮验证 {name}: 5 秒后开始监听, 听到倒计时结束请【按住】I 区中间键(2号键) 并【拨弦】") + for i in range(5, 0, -1): + print(f" {i} ...") + time.sleep(1) + print(f">>> 请操作! 监听中 (30s) ...") + # 先抓任意设备上报帧做诊断, 再判定内容 + f = sim.read_frame(timeout=30.0, want_dev=True) + if f is None: + report.add(f"F0 51 04 按键上报 {name} (期望 0x{code:02X})", "FAIL", + "30s 内无任何设备上报帧") + continue + ok = ( + len(f) == 7 + and f[1] == DEV_ID and f[2] == 0x04 + and f[3] == 0x00 and f[5] == code + ) + report.add( + f"F0 51 04 按键上报 {name} (期望 0x{code:02X})", + "PASS" if ok else "FAIL", + hexs(f), + ) + finally: + sim.query(build(0x02, 0x04, *orig), timeout=3.0) + ok = read_map(sim) == orig + report.add("恢复原始映射表", "PASS" if ok else "FAIL", + "已恢复" if ok else "恢复失败, 请用 App 重置") + + +def run_auto(report: Results, sim: BleMidiSim, orig: bytes) -> None: + """RTT 注入自动验证: 无需人工操作。 + + 流程: RTT `tone pick` 进入专业模式播放态 -> BLE `04 00` 设 Led=0 并改 2 号键和弦 + -> RTT `chord key 2` 注入指板按压(与物理按键同路径 TM1629_Handle) + -> BLE 捕获 F0 51 04 上报帧验证编码; RTT 日志确认和弦切换。 + """ + try: + from k1_harness.transports.rtt_session import open_session + except Exception as e: + report.add("RTT 自动验证", "FAIL", f"无法加载 RTT 传输: {e}") + return + try: + with open_session(reset=False) as sess: + sess.cmd("tone pick", settle=1.0) # 专业+本地曲目, 起奏 I 和弦 + sess.pump(2.0) + for code, name in ((0x02, "M7"), (0x0A, "m7")): + sim.send(build(0x04, 0x00, 0x00, code)) # Led=0, map[2]=该和弦 + time.sleep(0.2) + sim.drain() + sess.cmd("chord key 2", settle=0.5) # 注入按 I 区中间键 + f = sim.read_frame(timeout=5.0, want_dev=True) + ok = ( + f is not None and len(f) == 7 + and f[1] == DEV_ID and f[2] == 0x04 + and f[3] == 0x00 and f[5] == code + ) + report.add( + f"F0 51 04 注入按键上报 {name} (期望 0x{code:02X})", + "PASS" if ok else "FAIL", + hexs(f) if f else "5s 内无设备上报帧", + ) + recent = [l for l in sess.lines[-30:] if "chord" in l or "pick" in l] + report.add("RTT 日志(和弦切换)", "PASS" if recent else "SKIP", + " / ".join(recent[-4:]) if recent else "无相关日志") + except SystemExit as e: + report.add("RTT 自动验证", "FAIL", str(e)) + except Exception as e: + report.add("RTT 自动验证", "FAIL", f"{type(e).__name__}: {e}") + finally: + sim.query(build(0x02, 0x04, *orig), timeout=3.0) + ok = read_map(sim) == orig + report.add("恢复原始映射表", "PASS" if ok else "FAIL", + "已恢复" if ok else "恢复失败, 请用 App 重置") + + +def write_report(R: Results, info: dict, path: str) -> None: + n = {s: sum(1 for r in R.rows if r[1] == s) for s in ("PASS", "FAIL", "SKIP", "SENT")} + lines = [ + "# 和弦映射修复验证报告", + "", + f"- 日期: {datetime.datetime.now():%Y-%m-%d %H:%M:%S}", + f"- 设备: {info.get('name')} ({info.get('address')})", + f"- 传输: {info.get('transport')} / bleak, payload={info.get('max_payload')}", + "- 测试脚本: tools/test_chord_map_fix.py", + "- 修复范围: handleReadRhythmMap / Return_Light_buff 读回编码; 属七 0x0D 兼容", + "", + "## 汇总", + "", + "| PASS | FAIL | SKIP |", + "|------|------|------|", + f"| {n['PASS']} | {n['FAIL']} | {n['SKIP']} |", + "", + "## 明细", + "", + "| # | 用例 | 结果 | 详情 |", + "|---|------|------|------|", + ] + for i, (name, status, detail) in enumerate(R.rows, 1): + lines.append(f"| {i} | {name} | {status} | {str(detail).replace('|', '/')} |") + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + with open(path, "w", encoding="utf-8") as fp: + fp.write("\n".join(lines) + "\n") + + +def main() -> int: + ap = argparse.ArgumentParser(description="和弦映射修复专项验证") + ap.add_argument("--transport", choices=["midi", "uart"], default="midi") + ap.add_argument("--skip-manual", action="store_true", help="跳过按键上报交互验证") + ap.add_argument("--manual-only", action="store_true", help="只跑按键上报交互验证") + ap.add_argument("--auto", action="store_true", + help="用 RTT(J-Link) 注入按键代替人工操作, 全自动") + ap.add_argument("--address", help="直接指定 BLE 地址") + args = ap.parse_args() + + sim = BleMidiSim(transport=args.transport, address=args.address, unpair=False) + try: + if not sim.probe_or_rebind(): + print("设备无应答, 终止") + return 1 + R = Results() + if args.manual_only: + orig = read_map(sim) + if not orig: + print("读映射表失败, 终止") + return 1 + (run_auto if args.auto else run_manual)(R, sim, orig) + else: + run(R, sim, args.skip_manual, auto=args.auto) + print(R.summary()) + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + path = os.path.join(REPORTS_DIR, f"chord_map_fix_{ts}.md") + write_report(R, sim.info, path) + print(f"报告: {os.path.abspath(path)}") + return 1 if any(r[1] == "FAIL" for r in R.rows) else 0 + finally: + sim.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/test_fret_positions.py b/tools/test_fret_positions.py new file mode 100644 index 0000000..76fb6d0 --- /dev/null +++ b/tools/test_fret_positions.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- +""" +test_fret_positions.py — 同区三键(左/中/右)发声一致性的自动化实验 + +针对用户反馈: + 1) App 重置后指板1三键都是 C, 但分别按左/中/右+拨同一根弦, 音高不同 + 2) App 把指板1三键配成不同和弦, 拨同一根弦, 音却相同 + +原理(固件): + 按键 N -> TM1629_Handle -> group=(N-1)/3, type=chord_type_index_map[N].type + -> USE_MIDI = Chord_Midi_Table[group][type] (同组三键共享一张表) + -> 拨弦发声 = USE_MIDI.Midi_x (无任何位置相关偏移) + +判定: + - 三键 map 项相同而 USE_MIDI 不同 => 固件 bug + - 三键 map 项相同且 USE_MIDI 相同 => 固件正常; 听到的差异来自 App 实际写入 + (App 操作后立即用 --snapshot 读设备真实映射表即可见分晓) + - 写入不同和弦后 USE_MIDI 相同 => 固件 bug; 不同 => 固件正常, 查 App 写链路 + +用法: + python test_fret_positions.py # 完整实验(需 BLE + J-Link RTT) + python test_fret_positions.py --snapshot # 只读当前映射表并打印 + +报告: Doc/reports/fret_positions_<时间戳>.md +""" +from __future__ import annotations + +import argparse +import datetime +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from test_protocol_app_sim import build, hexs, Results # noqa: E402 +from test_protocol_ble import BleMidiSim # noqa: E402 + +USE_MIDI_ADDR = 0x20000944 # STRING_MIDI USE_MIDI: Midi_6..Midi_1 + name* +MAP_ADDR = 0x200006DC # CHORD_TYPE_INDEX chord_type_index_map[22] +PROTO_NAME = {0x00: "M", 0x08: "m", 0x02: "M7", 0x0D: "7", 0x0A: "m7", + 0x1F: "su2", 0x1E: "sus4", 0x04: "ad9", 0x0B: "m7-5"} + +REPORTS_DIR = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "Doc", "reports" +) + + +def read_map(sim): + r = sim.query(build(0x02, 0x01), timeout=3.0) + if r is None or len(r) != 47 or r[2] != 0x02 or r[3] != 0x01: + return None + return bytes(r[4:-1]) # 21B pitch + 21B chord + + +def snapshot(sim) -> bool: + m = read_map(sim) + if not m: + print("读映射表失败") + return False + print("键位 | 升降 | 协议码 | 和弦") + for k in range(1, 22): + pitch = m[k - 1] + code = m[21 + k - 1] + pname = PROTO_NAME.get(code, f"?0x{code:02X}") + print(f"{k:3d} | {'升半音' if pitch == 2 else '原位'} | 0x{code:02X} | {pname}") + return True + + +def set_key(sim, key: int, proto_code: int, pitch: int = 1): + """02 02 升降 + 02 03 和弦 (key: 1..21)""" + sim.send(build(0x02, 0x02, key - 1, pitch)) + time.sleep(0.08) + sim.send(build(0x02, 0x03, key - 1, proto_code)) + time.sleep(0.12) + + +def read_use_midi(jlink) -> list[int]: + """USE_MIDI 前 6 字节: [Midi_6, Midi_5, Midi_4, Midi_3, Midi_2, Midi_1]""" + return list(jlink.memory_read8(USE_MIDI_ADDR, 6)) + + +def read_map_entry(jlink, key: int) -> tuple[int, int]: + """chord_type_index_map[key] -> (type, PitchOffset)""" + b = jlink.memory_read8(MAP_ADDR + 4 * key, 4) + return b[0], b[2] + + +def run(report: Results, sim: BleMidiSim) -> None: + from k1_harness.transports.rtt_session import open_session + + r = sim.query(build(0x01, 0x01)) + if not (r and r[:3] == bytes([0xF0, 0x60, 0x01])): + report.add("01 01 连接设备", "FAIL", hexs(r)) + return + report.add("01 01 连接设备", "PASS", "") + + orig = read_map(sim) + if not orig: + report.add("02 01 读原始映射表", "FAIL", "无应答") + return + report.add("02 01 读原始映射表", "PASS", "") + + try: + with open_session(reset=False) as sess: + jlink = sess.jlink + sess.cmd("tone pick f", settle=1.0) # 普通模式, 指板按压即装载 USE_MIDI + sess.pump(1.5) + + # ---- 场景 A: 三键都设 C 大三原位(模拟"重置后都是 C") ---- + for k in (1, 2, 3): + set_key(sim, k, 0x00, 1) + time.sleep(0.3) + rows_a = [] + for k in (1, 2, 3): + sess.cmd(f"chord key {k}", settle=0.4) + notes = read_use_midi(jlink) + typ, pitch = read_map_entry(jlink, k) + rows_a.append((k, typ, pitch, notes)) + report.add( + f"A: 键{k} 按压后 USE_MIDI", + "PASS", + f"type={typ} pitch={pitch} notes[6..1]={notes}", + ) + same_a = rows_a[0][3] == rows_a[1][3] == rows_a[2][3] + report.add( + "A: 三键同配置 -> 发声音符一致", + "PASS" if same_a else "FAIL", + "一致(固件正常)" if same_a else "不一致 => 固件 bug", + ) + + # ---- 场景 B: 三键设不同和弦(M / m / m7) ---- + set_key(sim, 1, 0x00) # M + set_key(sim, 2, 0x08) # m + set_key(sim, 3, 0x0A) # m7 + time.sleep(0.3) + rows_b = [] + for k in (1, 2, 3): + sess.cmd(f"chord key {k}", settle=0.4) + notes = read_use_midi(jlink) + typ, pitch = read_map_entry(jlink, k) + rows_b.append((k, typ, pitch, notes)) + report.add( + f"B: 键{k} 按压后 USE_MIDI", + "PASS", + f"type={typ} pitch={pitch} notes[6..1]={notes}", + ) + diff_b = not (rows_b[0][3] == rows_b[1][3] == rows_b[2][3]) + expect_types = [0, 1, 2] + types_ok = [r[1] for r in rows_b] == expect_types + report.add( + "B: 三键不同配置 -> 发声音符不同", + "PASS" if (diff_b and types_ok) else "FAIL", + f"types={[r[1] for r in rows_b]} " + + ("各不相同(固件正常)" if diff_b else "完全相同 => 固件未应用 => 固件 bug"), + ) + finally: + r = sim.query(build(0x02, 0x04, *orig), timeout=3.0) + ok = read_map(sim) == orig + report.add("恢复原始映射表", "PASS" if ok else "FAIL", + "已恢复" if ok else "恢复失败, 请用 App 重置") + + +def write_report(R: Results, path: str) -> None: + n = {s: sum(1 for r in R.rows if r[1] == s) for s in ("PASS", "FAIL", "SKIP")} + lines = [ + "# 同区三键发声一致性实验报告", + "", + f"- 日期: {datetime.datetime.now():%Y-%m-%d %H:%M:%S}", + "- 测试脚本: tools/test_fret_positions.py", + "", + f"PASS={n['PASS']} FAIL={n['FAIL']} SKIP={n['SKIP']}", + "", + "| # | 用例 | 结果 | 详情 |", + "|---|------|------|------|", + ] + for i, (name, status, detail) in enumerate(R.rows, 1): + lines.append(f"| {i} | {name} | {status} | {str(detail).replace('|', '/')} |") + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + with open(path, "w", encoding="utf-8") as fp: + fp.write("\n".join(lines) + "\n") + + +def main() -> int: + ap = argparse.ArgumentParser(description="同区三键发声一致性实验") + ap.add_argument("--snapshot", action="store_true", help="只打印当前映射表") + args = ap.parse_args() + + sim = BleMidiSim(transport="midi", unpair=False) + try: + if not sim.probe_or_rebind(): + print("设备无应答, 终止") + return 1 + if args.snapshot: + return 0 if snapshot(sim) else 1 + R = Results() + run(R, sim) + print(R.summary()) + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + path = os.path.join(REPORTS_DIR, f"fret_positions_{ts}.md") + write_report(R, path) + print(f"报告: {os.path.abspath(path)}") + return 1 if any(r[1] == "FAIL" for r in R.rows) else 0 + finally: + sim.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/test_problem2_song_am.py b/tools/test_problem2_song_am.py new file mode 100644 index 0000000..35a7f3f --- /dev/null +++ b/tools/test_problem2_song_am.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- +""" +test_problem2_song_am.py — 问题2回归:曲目 LED 引导不得把 Am 改成 AM7 + +场景复现(修复前): + 曲目模式 UI 显示 6m(Am),App 发 LED 引导帧 F0 60 04 05 02 02 F7 + (协议文档示例,p3=0x02=大七),BL_Set_led 把 VI 中间键(17)写成 AM7, + 拨弦发出 E2 A2 E3 G#3 C#4 E4 而非 Am 的 E2 A2 E3 A3 C4 E4。 + +本脚本(等效 MIDI-OX 抓包): + RTT `string N` 与硅胶弦同一 SendNoteOn 路径,ACK 即线上实际音符。 + 1) 映射页路径设键17=Am(0x08) → chord key 17 → string 6..1 抓音符 + 2) BLE 发 LED 帧 04 05 02 02(模拟曲目 6m 引导) + 3) 重新 chord key 17 → string 6..1 再抓,必须仍是 Am + 4) 02 01 读回键17必须仍是 0x08 + +用法: python test_problem2_song_am.py +报告: Doc/reports/problem2_song_am_<时间戳>.md +""" +from __future__ import annotations + +import datetime +import os +import re +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from test_protocol_app_sim import build, hexs, Results # noqa: E402 +from test_protocol_ble import BleMidiSim # noqa: E402 + +REPORTS_DIR = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "Doc", "reports" +) + +KEY = 17 # VI 组中间键(报告问题键) +PROTO_AM = 0x08 # 小三 +# Am 期望: 弦6..1 = E2 A2 E3 A3 C4 E4 +AM_NOTES = {6: 0x28, 5: 0x2D, 4: 0x34, 3: 0x39, 2: 0x3C, 1: 0x40} +# AM7(修复前的错误发声): 弦3=0x38(G#3) 弦2=0x3D(C#4) +AM7_NOTES = {6: 0x28, 5: 0x2D, 4: 0x34, 3: 0x38, 2: 0x3D, 1: 0x40} + +NOTE_NAME = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] + + +def name(n: int) -> str: + return f"{NOTE_NAME[n % 12]}{n // 12 - 1}" + + +def read_map(sim): + r = sim.query(build(0x02, 0x01), timeout=3.0) + if r is None or len(r) != 47 or r[2] != 0x02 or r[3] != 0x01: + return None + return bytes(r[4:-1]) + + +def chord_of(m: bytes, key1: int) -> int: + return m[21 + key1 - 1] + + +def _wait_new(sess, pattern: str, since: int, timeout: float = 3.0) -> str | None: + """在 since 之后的新 RTT 行里等 pattern(伴奏日志洪水下不误判旧行)""" + deadline = time.monotonic() + timeout + rx = re.compile(pattern) + while time.monotonic() < deadline: + sess.pump(0.05) + for line in sess.lines[since:]: + if rx.search(line): + return line + return None + + +def press_key(sess, key: int) -> None: + """chord key N hold, 并等固件真正处理完 inject(USE_MIDI 已装载)""" + since = len(sess.lines) + sess.cmd(f"chord key {key} hold", settle=0.1) + _wait_new(sess, rf"inject key={key}\b", since, 3.0) + sess.pump(0.4) + + +def release_key(sess) -> None: + since = len(sess.lines) + sess.cmd("chord key 0", settle=0.1) + _wait_new(sess, r"inject key=0\b", since, 3.0) + sess.pump(0.2) + + +def strum_all(sess) -> dict[int, int]: + """string 6..1 -> {弦号: 实际 MIDI 音符号}; 逐条等本条 ACK""" + out: dict[int, int] = {} + for s in (6, 5, 4, 3, 2, 1): + since = len(sess.lines) + sess.cmd(f"string {s}", settle=0.1) + line = _wait_new(sess, rf"STRING_OK s={s} note=(\d+)", since, 3.0) + if line: + out[s] = int(re.search(r"note=(\d+)", line).group(1)) + return out + + +def fmt(notes: dict[int, int]) -> str: + return " ".join(f"s{s}={name(n)}({n})" for s, n in sorted(notes.items(), reverse=True)) + + +def run(R: Results, sim: BleMidiSim) -> None: + from k1_harness.transports.rtt_session import open_session + + r = sim.query(build(0x01, 0x01)) + if not (r and r[:3] == bytes([0xF0, 0x60, 0x01])): + R.add("01 01 连接设备", "FAIL", hexs(r)) + return + R.add("01 01 连接设备", "PASS", "") + + orig = read_map(sim) + if not orig: + R.add("02 01 读原始映射表", "FAIL", "无应答") + return + orig_key = chord_of(orig, KEY) + R.add("02 01 读原始映射表", "PASS", f"键{KEY} 原值=0x{orig_key:02X}") + + try: + # 映射页路径: 键17 设为 Am(0x08) + sim.send(build(0x02, 0x03, KEY - 1, PROTO_AM)) + time.sleep(0.15) + m = read_map(sim) + ok = m is not None and chord_of(m, KEY) == PROTO_AM + R.add(f"02 03 设键{KEY}=Am(0x08)", "PASS" if ok else "FAIL", + f"读回=0x{chord_of(m, KEY):02X}" if m else "无应答") + + with open_session(reset=False) as sess: + sess.cmd("tone pick", settle=1.0) # 专业+本地曲目(歌曲模式) + sess.pump(1.0) + + # ---- 第 1 轮: LED 之前, 基线发声 ---- + press_key(sess, KEY) + base = strum_all(sess) + ok = base == AM_NOTES + R.add("基线: 键17 拨弦=Am", "PASS" if ok else "FAIL", + fmt(base) + (" (Am 正确)" if ok else + " (得到 AM7!)" if base == AM7_NOTES else " (异常)")) + + # ---- 模拟曲目 6m 的 LED 引导帧(文档示例 p3=0x02) ---- + sim.send(build(0x04, 0x05, 0x02, 0x02)) + time.sleep(0.3) + m = read_map(sim) + kept = m is not None and chord_of(m, KEY) == PROTO_AM + R.add("LED 04 05 02 02 后映射表不被改写", "PASS" if kept else "FAIL", + f"键{KEY}=0x{chord_of(m, KEY):02X}" if m else "无应答") + + # ---- 第 2 轮: LED 之后, 再按同键拨弦 ---- + release_key(sess) + press_key(sess, KEY) + after = strum_all(sess) + ok = after == AM_NOTES + R.add("问题2: LED 后拨弦仍为 Am", "PASS" if ok else "FAIL", + fmt(after) + (" (Am 正确, 修复生效)" if ok else + " (变成 AM7, 问题仍在!)" if after == AM7_NOTES else " (异常)")) + + release_key(sess) + finally: + sim.send(build(0x02, 0x03, KEY - 1, orig_key)) + time.sleep(0.15) + m = read_map(sim) + ok = m is not None and chord_of(m, KEY) == orig_key + R.add(f"恢复键{KEY} 原始和弦", "PASS" if ok else "FAIL", + "已恢复" if ok else "恢复失败, 请用 App 检查") + + +def write_report(R: Results, path: str) -> None: + n = {s: sum(1 for r in R.rows if r[1] == s) for s in ("PASS", "FAIL", "SKIP")} + lines = [ + "# 问题2回归报告: 曲目 6m(Am) 被 LED 改成 AM7", + "", + f"- 日期: {datetime.datetime.now():%Y-%m-%d %H:%M:%S}", + "- 测试脚本: tools/test_problem2_song_am.py", + "- 方法: RTT `string N` 与硅胶弦同一 SendNoteOn 路径, ACK 即线上实际音符(等效 MIDI-OX 抓包)", + "", + f"PASS={n['PASS']} FAIL={n['FAIL']} SKIP={n['SKIP']}", + "", + "| # | 用例 | 结果 | 详情 |", + "|---|------|------|------|", + ] + for i, (name_, status, detail) in enumerate(R.rows, 1): + lines.append(f"| {i} | {name_} | {status} | {str(detail).replace('|', '/')} |") + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + with open(path, "w", encoding="utf-8") as fp: + fp.write("\n".join(lines) + "\n") + + +def main() -> int: + sim = BleMidiSim(transport="midi", unpair=False) + try: + if not sim.probe_or_rebind(): + print("设备无应答, 终止") + return 1 + R = Results() + run(R, sim) + print(R.summary()) + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + path = os.path.join(REPORTS_DIR, f"problem2_song_am_{ts}.md") + write_report(R, path) + print(f"报告: {os.path.abspath(path)}") + return 1 if any(r[1] == "FAIL" for r in R.rows) else 0 + finally: + sim.close() + + +if __name__ == "__main__": + sys.exit(main())