K1Guitar/tools/rtt_pitch_reg_test.py

590 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()