fix(chord): song-mode LED no longer overwrites chord map (6m/Am -> AM7)

Problem2: in song mode UI shows 6m(Am) but MIDI played AM7
(E2 A2 E3 G#3 C#4 E4). Root cause: BL_Set_led wrote the LED frame's
chord code into chord_type_index_map of the group middle key
(LED_L6 -> key17), so a guidance frame carrying 0x02 (M7) turned the
Am key into AM7. Remapping on the chord page only helped until the
next LED frame.

- BL_Set_led: light LED only, drop all chord_type_index_map writes;
  chord quality is owned by map page 02 03/02 04 (BT_Chord_offset_map)
- handleReadRhythmMap / Return_Light_buff: fix type->protocol read-back
  encoding (m7->0x0A, M7->0x02, ad9->0x04, m7-5->0x0B, sus2->0x1F,
  sus4->0x1E), re-landing the 0917 verified fix
- BT_Chord_offset_map: accept dominant-7 doc code 0x0D (legacy 0x13 kept)
- tests: test_chord_map_fix.py (16/16 PASS on device),
  test_fret_positions.py, test_problem2_song_am.py (7/7 PASS:
  post-LED strum stays E2 A2 E3 A3 C4 E4)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yuquanjun 2026-09-22 11:40:51 +08:00
parent 6d064cb53e
commit ef41eb30fa
5 changed files with 724 additions and 54 deletions

View File

@ -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

View File

@ -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;

287
tools/test_chord_map_fix.py Normal file
View File

@ -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())

View File

@ -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())

View File

@ -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())