K1Guitar/tools/rtt_pitch_reg_test.py

590 lines
20 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""K1 bass/chord register auto-test via J-Link RTT.
Requires firmware with:
- [PITCH]/[REG] logs (App_Auto.c)
- RTT cmds: chord key N / chord xpose N / tone pick / log clear
Modes:
python tools/rtt_pitch_reg_test.py # inject + live assert
python tools/rtt_pitch_reg_test.py --listen # you press keys; we assert
python tools/rtt_pitch_reg_test.py --analyze oct_reg3.log
"""
from __future__ import annotations
import argparse
import os
import re
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime
from typing import Iterable
DEFAULT_DEVICES = ("Cortex-M4", "AT32F403AC", "AT32F403A")
MIDI_E1 = 28
MIDI_E2 = 40
MIDI_GSHARP4 = 68
XPOSE_FS = 6
BASS_CH_DISP = 8 # 代码/日志通道BASS_CH=8
DRUM_CH_DISP = 9
PITCH_RE = re.compile(
r"\[PITCH\]\s+(on|off)\s+ch(\d+)\((\w+)\)\s+"
r"(\d+)->(\d+)\s+d=(-?\d+)\s+deg=(\d+)\s+chord=(\d+)\s+"
r"xp=(\d+)\s+xf=(-?\d+)\s+fl=0x([0-9A-Fa-f]+)\s*(.*)$"
)
REG_RE = re.compile(
r"\[REG\s*\]\s+fp\s+(\d+)->(\d+)\s+chord=(\d+)\s+deg=(\d+)\s+xp=(\d+)\s+xf=(-?\d+)"
)
@dataclass
class PitchEvent:
is_on: bool
ch: int
role: str
key_in: int
key_out: int
delta: int
deg: int
chord: int
xp: int
xf: int
flags: int
tags: str
raw: str
@dataclass
class CheckResult:
ok: int = 0
fail: int = 0
skip: int = 0
messages: list[str] = field(default_factory=list)
def add_ok(self, msg: str) -> None:
self.ok += 1
self.messages.append(f"OK {msg}")
def add_fail(self, msg: str) -> None:
self.fail += 1
self.messages.append(f"FAIL {msg}")
def add_skip(self, msg: str) -> None:
self.skip += 1
self.messages.append(f"SKIP {msg}")
def import_deps() -> None:
try:
import pylink # noqa: F401
except ImportError as exc:
raise SystemExit("Missing dependency: pip install pylink-square") from exc
def connect_jlink(device: str):
import pylink
jlink = pylink.JLink()
jlink.open()
try:
jlink.exec_command("HideDeviceSelection = 1")
except Exception:
pass
jlink.set_tif(pylink.enums.JLinkInterfaces.SWD)
last_error = None
for candidate in (device, *DEFAULT_DEVICES):
try:
try:
jlink.exec_command(f"Device = {candidate}")
except Exception:
pass
jlink.connect(candidate)
print(f"Connected as {candidate}", flush=True)
return jlink
except pylink.errors.JLinkException as exc:
last_error = exc
jlink.close()
raise SystemExit(f"Failed to connect J-Link: {last_error}")
def find_rtt_control_block(jlink, ram_base: int = 0x20000000, ram_size: int = 0x18000) -> int | None:
needle = b"SEGGER RTT"
chunk = 0x1000
was_halted = jlink.halted()
if not was_halted:
jlink.halt()
try:
for off in range(0, ram_size, chunk):
data = bytes(jlink.memory_read8(ram_base + off, min(chunk, ram_size - off)))
idx = data.find(needle)
if idx >= 0:
return ram_base + off + idx
finally:
if not was_halted:
if hasattr(jlink, "restart"):
jlink.restart()
else:
jlink.go()
time.sleep(0.1)
return None
def wait_rtt_ready(jlink, timeout_s: float = 15.0) -> None:
deadline = time.time() + timeout_s
while time.time() < deadline:
try:
if jlink.rtt_get_num_up_buffers() > 0:
return
except Exception:
pass
time.sleep(0.2)
raise SystemExit("RTT control block found but buffers not ready")
def send_command(jlink, payload: bytes, retries: int = 30) -> None:
if isinstance(payload, str):
payload = payload.encode("ascii")
if not payload.endswith(b"\n"):
payload += b"\n"
for _ in range(retries):
wrote = jlink.rtt_write(0, list(payload))
if wrote > 0:
print(f">> {payload.decode('ascii', errors='replace').strip()}", flush=True)
return
time.sleep(0.2)
raise SystemExit(f"Failed to send RTT cmd: {payload!r}")
def drain_rtt(jlink, seconds: float = 0.15) -> str:
buf = b""
deadline = time.time() + seconds
while time.time() < deadline:
chunk = jlink.rtt_read(0, 4096)
if chunk:
buf += bytes(chunk)
else:
time.sleep(0.01)
return buf.decode("utf-8", errors="replace")
def parse_pitch_line(line: str) -> PitchEvent | None:
m = PITCH_RE.search(line)
if not m:
return None
return PitchEvent(
is_on=m.group(1) == "on",
ch=int(m.group(2)),
role=m.group(3),
key_in=int(m.group(4)),
key_out=int(m.group(5)),
delta=int(m.group(6)),
deg=int(m.group(7)),
chord=int(m.group(8)),
xp=int(m.group(9)),
xf=int(m.group(10)),
flags=int(m.group(11), 16),
tags=m.group(12).strip(),
raw=line.strip(),
)
def chord_degree(chord: int) -> int:
if chord < 1 or chord > 21:
return 0
return (chord - 1) // 3 + 1
def need_register_fix(chord: int) -> bool:
return 7 <= chord <= 21
def map_bass(key: int, xp: int) -> tuple[int, int]:
"""Return (out, flags) mirroring App_Auto_MapBassNote."""
out = key - 12
flags = 0x01
while out < MIDI_E1:
out += 12
flags |= 0x02
if xp >= XPOSE_FS:
out -= 12
flags |= 0x04
while out < MIDI_E1:
out += 12
flags |= 0x02
return max(0, min(127, out)), flags
def map_chord(key: int, xp: int) -> tuple[int, int, bool]:
"""Return (out_or_clamp_hint, flags, need_near).
When need_near is True, exact out is unknown without chord table;
caller should only require out in [E2, #G4].
"""
out = key - 12
flags = 0x01
while out < MIDI_E2:
out += 12
flags |= 0x08
need_near = out > MIDI_GSHARP4
if need_near:
flags |= 0x10
out = MIDI_GSHARP4 # placeholder; exact nearest checked soft
if xp >= XPOSE_FS:
out -= 12
flags |= 0x04
while out < MIDI_E2:
out += 12
flags |= 0x08
if out > MIDI_GSHARP4:
flags |= 0x10
need_near = True
out = MIDI_GSHARP4
return max(0, min(127, out)), flags, need_near
def expect_for_event(ev: PitchEvent) -> tuple[str, bool]:
"""Return (detail, ok)."""
if not ev.is_on:
return "note-off ignored", True
if ev.delta != ev.key_out - ev.key_in:
return f"d mismatch {ev.delta} != {ev.key_out - ev.key_in}", False
deg_exp = chord_degree(ev.chord)
if deg_exp and ev.deg != deg_exp:
return f"deg {ev.deg} != expected {deg_exp}", False
xf_exp = 1 if ev.xp >= XPOSE_FS else 0
if ev.xf != xf_exp:
return f"xf {ev.xf} != expected {xf_exp} (xp={ev.xp})", False
if ev.ch == DRUM_CH_DISP or ev.role == "drum":
return "drum should not appear in PITCH", False
fix = need_register_fix(ev.chord)
if not fix:
if ev.key_out != ev.key_in or ev.delta != 0:
return f"I/II should PASS {ev.key_in}->{ev.key_in}, got {ev.key_out}", False
if "PASS" not in ev.tags and ev.flags != 0:
# flags may be 0 on pass path
pass
return f"PASS I/II {ev.key_in}", True
if ev.ch == BASS_CH_DISP or ev.role == "bass":
exp, exp_fl = map_bass(ev.key_in, ev.xp)
if ev.key_out != exp:
return f"bass expect {ev.key_in}->{exp}, got {ev.key_out}", False
if ev.key_out < MIDI_E1:
return f"bass below E1: {ev.key_out}", False
# flag bits that must be present
if (exp_fl & 0x01) and not (ev.flags & 0x01):
return f"bass missing -12 flag fl=0x{ev.flags:02X}", False
return f"bass {ev.key_in}->{ev.key_out}", True
# chord path
exp, exp_fl, need_near = map_chord(ev.key_in, ev.xp)
if not (MIDI_E2 <= ev.key_out <= MIDI_GSHARP4):
return f"chord out {ev.key_out} not in [{MIDI_E2},{MIDI_GSHARP4}]", False
if need_near:
if not (ev.flags & 0x10) and "NEAR" not in ev.tags:
# soft: range ok is enough if nearest not flagged but clamped somehow
return f"chord NEAR expected (in={ev.key_in} out={ev.key_out})", True
return f"chord NEAR {ev.key_in}->{ev.key_out}", True
if ev.key_out != exp:
return f"chord expect {ev.key_in}->{exp}, got {ev.key_out}", False
return f"chord {ev.key_in}->{ev.key_out}", True
def analyze_lines(lines: Iterable[str], result: CheckResult | None = None) -> CheckResult:
result = result or CheckResult()
pitch_n = 0
roles: set[str] = set()
chords: set[int] = set()
for line in lines:
line = line.rstrip("\n")
if "[REG" in line and "fp " in line:
m = REG_RE.search(line)
if m:
chord = int(m.group(3))
deg = int(m.group(4))
xp = int(m.group(5))
xf = int(m.group(6))
deg_exp = chord_degree(chord)
xf_exp = 1 if xp >= XPOSE_FS else 0
if deg_exp and deg != deg_exp:
result.add_fail(f"REG deg {deg}!={deg_exp} chord={chord}")
elif xf != xf_exp:
result.add_fail(f"REG xf {xf}!={xf_exp} xp={xp}")
else:
result.add_ok(f"REG fp chord={chord} deg={deg} xp={xp} xf={xf}")
ev = parse_pitch_line(line)
if not ev:
continue
pitch_n += 1
roles.add(ev.role)
chords.add(ev.chord)
detail, ok = expect_for_event(ev)
msg = f"ch{ev.ch}({ev.role}) chord={ev.chord} {ev.key_in}->{ev.key_out} | {detail}"
if ok:
result.add_ok(msg)
else:
result.add_fail(msg)
if pitch_n == 0:
result.add_skip("no [PITCH] lines found")
else:
if "bass" not in roles and not any(
f"ch{BASS_CH_DISP}(" in m for m in result.messages
):
result.add_skip(f"no bass PITCH on ch{BASS_CH_DISP} (style may omit bass, or old FW)")
if not any(need_register_fix(c) for c in chords):
result.add_skip("no III+ chord keys observed")
if not any(not need_register_fix(c) and 1 <= c <= 6 for c in chords):
result.add_skip("no I/II chord keys observed")
return result
def print_report(result: CheckResult, out_path: str | None = None) -> int:
print("\n======== PITCH REG TEST REPORT ========", flush=True)
for msg in result.messages:
# only print fails + summary skips loudly; OK compacted
if msg.startswith("FAIL") or msg.startswith("SKIP"):
print(msg, flush=True)
ok_short = [m for m in result.messages if m.startswith("OK")]
print(f"OK={result.ok} FAIL={result.fail} SKIP={result.skip}", flush=True)
if ok_short:
print(f"(first OK samples: {len(ok_short)} total)", flush=True)
for m in ok_short[:8]:
print(m, flush=True)
if len(ok_short) > 8:
print(f"... +{len(ok_short) - 8} more OK", flush=True)
if out_path:
with open(out_path, "w", encoding="utf-8", newline="\n") as f:
f.write(f"# pitch reg report {datetime.now().isoformat(timespec='seconds')}\n")
f.write(f"OK={result.ok} FAIL={result.fail} SKIP={result.skip}\n")
for m in result.messages:
f.write(m + "\n")
print(f"Report saved: {os.path.abspath(out_path)}", flush=True)
return 0 if result.fail == 0 and result.ok > 0 else 1
class RttSession:
def __init__(self, jlink):
self.jlink = jlink
self.buf = b""
self.lines: list[str] = []
def cmd(self, text: str, settle: float = 0.2) -> None:
send_command(self.jlink, text.encode("ascii"))
self.pump(settle)
def pump(self, seconds: float) -> list[str]:
new_lines: list[str] = []
deadline = time.time() + seconds
while time.time() < deadline:
chunk = self.jlink.rtt_read(0, 4096)
if chunk:
self.buf += bytes(chunk)
while b"\n" in self.buf:
raw, self.buf = self.buf.split(b"\n", 1)
line = raw.decode("utf-8", errors="replace").rstrip("\r")
if line:
self.lines.append(line)
new_lines.append(line)
if (
"[PITCH]" in line
or "[REG" in line
or "[KEY" in line
or "CHORD_" in line
or "TONE_" in line
):
print(line, flush=True)
else:
time.sleep(0.01)
return new_lines
def cmd_ack(self, text: str, ack: str, timeout_s: float = 3.0, retries: int = 6) -> bool:
"""Send command until ack substring appears (handles RTT down loss under load)."""
for attempt in range(1, retries + 1):
# brief quiet drain then send
self.pump(0.15)
send_command(self.jlink, text.encode("ascii"))
deadline = time.time() + timeout_s
while time.time() < deadline:
for line in self.pump(0.1):
if ack in line:
return True
print(f"!! no ack '{ack}' for '{text}' (try {attempt}/{retries})", flush=True)
time.sleep(0.25)
return False
def stop_band(self) -> None:
self.cmd_ack("chord key 23", "CHORD_KEY_OK", timeout_s=2.0, retries=4)
self.pump(0.4)
def set_xpose(self, xp: int) -> bool:
return self.cmd_ack(f"chord xpose {xp}", "CHORD_XPOSE_OK", timeout_s=2.0, retries=5)
def set_key(self, key: int) -> bool:
return self.cmd_ack(f"chord key {key}", "CHORD_KEY_OK", timeout_s=2.0, retries=5)
def phase_switch(self, name: str, key: int, xpose: int | None, hold_s: float) -> None:
"""Switch chord/xpose while accompaniment is already running.
Note: plain `tone pick` clears KEY_ID to 1 do not call it after inject.
"""
print(f"\n=== {name}: key={key} xpose={xpose} ===", flush=True)
if xpose is not None:
if not self.set_xpose(xpose):
print(f"FAIL setup xpose={xpose}", flush=True)
self.set_key(0)
if not self.set_key(key):
print(f"FAIL setup key={key}", flush=True)
self.pump(hold_s)
def run_auto(device: str, hold_s: float, report: str | None, log_path: str | None) -> int:
import_deps()
jlink = connect_jlink(device)
try:
cb = find_rtt_control_block(jlink)
if cb is None:
raise SystemExit("SEGGER RTT CB not found")
print(f"RTT CB @ 0x{cb:08X}", flush=True)
jlink.rtt_start(cb)
wait_rtt_ready(jlink)
sess = RttSession(jlink)
sess.pump(0.3)
sess.cmd("log clear", 0.3)
sess.cmd("tone local", 1.0)
# Start band first (pick forces chord=1), then inject keys while playing
if not sess.set_xpose(2):
print("FAIL initial xpose=2", flush=True)
sess.cmd("tone pick", settle=0.8)
print("\n=== I (default after pick) ===", flush=True)
sess.pump(hold_s)
sess.phase_switch("I/II key2 PASS", key=2, xpose=None, hold_s=hold_s)
sess.phase_switch("III key8 -12", key=8, xpose=None, hold_s=hold_s)
sess.phase_switch("VII key20 -12", key=20, xpose=None, hold_s=hold_s)
sess.phase_switch("VII key20 xpose=#F", key=20, xpose=6, hold_s=hold_s)
print("\n=== stop ===", flush=True)
sess.stop_band()
sess.pump(0.4)
if log_path:
with open(log_path, "w", encoding="utf-8", newline="\n") as f:
f.write(f"# auto pitch test {datetime.now().isoformat(timespec='seconds')}\n")
for line in sess.lines:
f.write(line + "\n")
print(f"Log saved: {os.path.abspath(log_path)}", flush=True)
joined = "\n".join(sess.lines)
if "CHORD_KEY_OK" not in joined and "CHORD_KEY_BAD" not in joined:
print(
"WARNING: no CHORD_KEY_OK — firmware may lack 'chord key' RTT cmd. "
"Rebuild/flash, or use --listen / --analyze.",
flush=True,
)
result = analyze_lines(sess.lines)
has_pass = any("PASS I/II" in m for m in result.messages)
has_iii_raw = any(
"[PITCH]" in line and ("chord=8" in line or "chord=20" in line or "deg=3" in line or "deg=7" in line)
for line in sess.lines
)
has_iii_ok = any(
m.startswith("OK") and "PASS" not in m and "(chord)" in m
for m in result.messages
)
has_xf = any("[PITCH]" in line and "xf=1" in line and "deg=7" in line for line in sess.lines)
if not has_pass:
result.add_fail("coverage: no I/II PASS samples")
if not has_iii_raw:
result.add_fail("coverage: no III+/VII chord in PITCH")
elif not has_iii_ok:
result.add_fail("coverage: III+ present but mapping asserts failed")
if not has_xf:
result.add_skip("coverage: no VII+xf=1 samples")
return print_report(result, report)
finally:
try:
jlink.rtt_stop()
except Exception:
pass
jlink.close()
def run_listen(device: str, seconds: float, report: str | None, log_path: str | None) -> int:
import_deps()
jlink = connect_jlink(device)
try:
cb = find_rtt_control_block(jlink)
if cb is None:
raise SystemExit("SEGGER RTT CB not found")
print(f"RTT CB @ 0x{cb:08X}", flush=True)
jlink.rtt_start(cb)
wait_rtt_ready(jlink)
sess = RttSession(jlink)
sess.pump(0.2)
sess.cmd("log clear", 0.2)
print(
f"Listening {seconds:.0f}s — press I/II then III+ chords; set transpose>=#F if possible.",
flush=True,
)
sess.pump(seconds)
if log_path:
with open(log_path, "w", encoding="utf-8", newline="\n") as f:
f.write(f"# listen pitch test {datetime.now().isoformat(timespec='seconds')}\n")
for line in sess.lines:
f.write(line + "\n")
print(f"Log saved: {os.path.abspath(log_path)}", flush=True)
result = analyze_lines(sess.lines)
return print_report(result, report)
finally:
try:
jlink.rtt_stop()
except Exception:
pass
jlink.close()
def run_analyze(path: str, report: str | None) -> int:
with open(path, encoding="utf-8", errors="replace") as f:
lines = f.readlines()
result = analyze_lines(lines)
return print_report(result, report)
def main() -> None:
parser = argparse.ArgumentParser(description="K1 pitch/register RTT auto-test")
parser.add_argument("--device", default="AT32F403AC")
parser.add_argument("--hold", type=float, default=2.5, help="Seconds to hold each injected key")
parser.add_argument("--seconds", type=float, default=45.0, help="Listen duration")
parser.add_argument("--listen", action="store_true", help="Do not inject; monitor only")
parser.add_argument("--analyze", metavar="LOG", help="Offline analyze a dump log")
parser.add_argument("--out-log", default="", help="Save captured RTT lines")
parser.add_argument("--report", default="", help="Save pass/fail report")
args = parser.parse_args()
report = args.report or None
log_path = args.out_log or None
if args.analyze:
raise SystemExit(run_analyze(args.analyze, report))
if args.listen:
raise SystemExit(run_listen(args.device, args.seconds, report, log_path))
raise SystemExit(run_auto(args.device, args.hold, report, log_path))
if __name__ == "__main__":
main()