From 3eba48ece1948cceaccca229a46fcfb75d60bedf Mon Sep 17 00:00:00 2001 From: yuquanjun Date: Thu, 24 Sep 2026 16:32:18 +0800 Subject: [PATCH] fix(auto): stop stacking xpose -12 on III/IV chords G-major III was collapsing B3 onto B2 after register -12 plus transpose -12. Keep single register drop for III/IV; stack xpose only from V upward. Co-authored-by: Cursor --- MyCode/App_Auto.c | 11 +-- tools/rtt_pitch_reg_test.py | 154 ++++++++++++++++++++++++++++++++++-- 2 files changed, 152 insertions(+), 13 deletions(-) diff --git a/MyCode/App_Auto.c b/MyCode/App_Auto.c index 8fa13cf..16885b4 100644 --- a/MyCode/App_Auto.c +++ b/MyCode/App_Auto.c @@ -23,8 +23,9 @@ static uint8_t pStoreBuffer[PRESET_BUFFER_SIZE]; * 代码通道(0 起,与 MIDI status 低 4 位一致):8=bass,9=鼓;其余=和弦。 * 日志打印同号(ch8=bass)。 * ① 指板 I/II(键1~6):原样;移调≥#F(6) 时表格「整体-12」(bass、和弦)。 - * ② 指板 III+(键7~21):bass -12 地板 E1;和弦 -12 夹在 E2~#G4; - * 移调≥#F 时再叠加 -12(与①同一转调表)。 + * ② 指板 III/IV(键7~12):bass -12 地板 E1;和弦 -12 夹在 E2~#G4; + * 不再叠加转调 -12(避免 G 调 III 把 B3 压回 B2 后与根音重叠)。 + * ③ 指板 V~VII(键13~21):同②的把位 -12;移调≥#F 时再叠加 -12。 * 对照 Doc/吉他和弦表最终版.xlsx / 转调说明表 */ #define CHORD_REG_FIX_EN 1 #define BASS_CH 8 /* 日志 ch8 */ @@ -147,7 +148,7 @@ static int App_Auto_MapBassNote(int key, uint8_t *flags) out += 12; f |= 0x02u; /* bit1: floored up to E1 */ } - if (App_Auto_XposeExtra()) + if (App_Auto_XposeExtra() && App_Auto_ChordDegree() >= 5) { out -= 12; f |= 0x04u; /* bit2: xpose extra -12 */ @@ -178,7 +179,7 @@ static int App_Auto_MapChordNote(int key, uint8_t *flags) f |= 0x10u; /* bit4: nearest chord tone */ } - if (App_Auto_XposeExtra()) + if (App_Auto_XposeExtra() && App_Auto_ChordDegree() >= 5) { out -= 12; f |= 0x04u; @@ -239,7 +240,7 @@ static int App_Auto_MapNote(int channel, int key, uint8_t *flags) if (App_Auto_NeedRegisterFix()) { - /* III+:既有把位音区修正;≥#F 时 Map* 内再叠转调 -12 */ + /* III+:把位音区修正;V~VII 且 ≥#F 时 Map* 内再叠转调 -12 */ if (channel == BASS_CH) out = App_Auto_MapBassNote(key, flags); else diff --git a/tools/rtt_pitch_reg_test.py b/tools/rtt_pitch_reg_test.py index 09c53f3..c1dd09e 100644 --- a/tools/rtt_pitch_reg_test.py +++ b/tools/rtt_pitch_reg_test.py @@ -9,6 +9,7 @@ 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 + python tools/rtt_pitch_reg_test.py --self-test # offline map rules (no J-Link) """ from __future__ import annotations @@ -203,14 +204,17 @@ 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.""" +def map_bass(key: int, xp: int, deg: int) -> tuple[int, int]: + """Return (out, flags) mirroring App_Auto_MapBassNote. + + Xpose extra -12 stacks only from V (deg>=5); III/IV keep register -12 only. + """ out = key - 12 flags = 0x01 while out < MIDI_E1: out += 12 flags |= 0x02 - if xp >= XPOSE_FS: + if xp >= XPOSE_FS and deg >= 5: out -= 12 flags |= 0x04 while out < MIDI_E1: @@ -219,11 +223,12 @@ def map_bass(key: int, xp: int) -> tuple[int, int]: return max(0, min(127, out)), flags -def map_chord(key: int, xp: int) -> tuple[int, int, bool]: +def map_chord(key: int, xp: int, deg: 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]. + Xpose extra -12 stacks only from V (deg>=5); III/IV keep register -12 only. """ out = key - 12 flags = 0x01 @@ -234,7 +239,7 @@ def map_chord(key: int, xp: int) -> tuple[int, int, bool]: if need_near: flags |= 0x10 out = MIDI_GSHARP4 # placeholder; exact nearest checked soft - if xp >= XPOSE_FS: + if xp >= XPOSE_FS and deg >= 5: out -= 12 flags |= 0x04 while out < MIDI_E2: @@ -317,7 +322,7 @@ def expect_for_event(ev: PitchEvent) -> tuple[str, bool]: 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) + exp, exp_fl = map_bass(ev.key_in, ev.xp, deg_exp) if ev.key_out != exp: return f"bass expect {ev.key_in}->{exp}, got {ev.key_out}", False if ev.key_out < MIDI_E1: @@ -325,10 +330,14 @@ def expect_for_event(ev: PitchEvent) -> tuple[str, bool]: # 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 + if deg_exp in (3, 4) and ev.xp >= XPOSE_FS and (ev.flags & 0x04): + return f"III/IV bass should not stack xpose 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 deg_exp in (3, 4) and ev.xp >= XPOSE_FS and (ev.flags & 0x04): + return f"III/IV chord should not stack xpose fl=0x{ev.flags:02X}", False + exp, exp_fl, need_near = map_chord(ev.key_in, ev.xp, deg_exp) 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: @@ -346,7 +355,8 @@ def analyze_lines(lines: Iterable[str], result: CheckResult | None = None) -> Ch pitch_n = 0 roles: set[str] = set() chords: set[int] = set() - for line in lines: + line_list = list(lines) + for line in line_list: line = line.rstrip("\n") if "[REG" in line and "fp " in line: m = REG_RE.search(line) @@ -388,9 +398,48 @@ def analyze_lines(lines: Iterable[str], result: CheckResult | None = None) -> Ch 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") + check_iii_xpose_octave(line_list, result) return result +def check_iii_xpose_octave(lines: Iterable[str], result: CheckResult) -> None: + """III + transpose>=#F: inputs an octave apart must stay an octave apart.""" + by_pc: dict[int, dict[int, int]] = {} + saw = False + for line in lines: + ev = parse_pitch_line(line.rstrip("\n")) + if not ev or not ev.is_on or ev.role != "chord": + continue + if ev.deg != 3 or ev.xp < XPOSE_FS: + continue + saw = True + by_pc.setdefault(ev.key_in % 12, {})[ev.key_in] = ev.key_out + if not saw: + result.add_skip("no III+xpose chord PITCH for octave check") + return + found_pair = False + for pc, inout in by_pc.items(): + ins = sorted(inout) + for i, a in enumerate(ins): + for b in ins[i + 1 :]: + if b - a != 12: + continue + # Lower note below E2+12 hits the E2 floor after register -12; + # that is expected, not the G-III B3/B4→B2/B2 stack bug. + if a < MIDI_E2 + 12: + continue + found_pair = True + oa, ob = inout[a], inout[b] + if ob - oa != 12: + result.add_fail( + f"III xpose octave collapsed pc={pc} {a}->{oa} vs {b}->{ob}" + ) + else: + result.add_ok(f"III xpose octave {a}/{b} -> {oa}/{ob}") + if not found_pair: + result.add_skip("III+xpose present but no octave-apart input pair") + + def print_report(result: CheckResult, out_path: str | None = None) -> int: print("\n======== PITCH REG TEST REPORT ========", flush=True) for msg in result.messages: @@ -520,6 +569,8 @@ def run_auto(device: str, hold_s: float, report: str | None, log_path: str | Non sess.phase_switch("I/II key2 xpose=#F overall-12", key=2, xpose=6, hold_s=hold_s) sess.phase_switch("I/II key2 xpose=F restore", key=2, xpose=2, hold_s=hold_s) sess.phase_switch("III key8 -12", key=8, xpose=None, hold_s=hold_s) + sess.phase_switch("III key8 xpose=#F no stack", key=8, xpose=6, hold_s=hold_s) + sess.phase_switch("III key8 xpose=F restore", key=8, xpose=2, 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) @@ -553,12 +604,17 @@ def run_auto(device: str, hold_s: float, report: str | None, log_path: str | Non 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) + has_iii_xf = any( + "[PITCH]" in line and "xf=1" in line and "deg=3" 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_iii_xf: + result.add_fail("coverage: no III+xpose=#F samples") if not has_xf: result.add_skip("coverage: no VII+xf=1 samples") @@ -605,6 +661,85 @@ def run_listen(device: str, seconds: float, report: str | None, log_path: str | jlink.close() +def self_test_map_rules() -> int: + """Offline: C III / G III / G I / G V mapping (no J-Link).""" + result = CheckResult() + # C III (xp=0, deg=3): register -12, first/third stay an octave + c_iii = [(52, 40), (59, 47), (64, 52)] # E3/B3/E4 -> E2/B2/E3 + for key_in, exp in c_iii: + out, _fl, _near = map_chord(key_in, 0, 3) + if out != exp: + result.add_fail(f"C III {key_in}->{out} expected {exp}") + else: + result.add_ok(f"C III {key_in}->{out}") + if c_iii[2][1] - c_iii[0][1] != 12: + result.add_fail("C III first/third not octave") + else: + result.add_ok("C III first/third octave E2/E3") + + # G III (xp=7, deg=3): register -12 only — B2 F#3 B3, not collapsed B2 F#2 B2 + g_iii = [(59, 47), (66, 54), (71, 59)] # B3/F#4/B4 -> B2/F#3/B3 + outs = [] + for key_in, exp in g_iii: + out, fl, _near = map_chord(key_in, 7, 3) + outs.append(out) + if out != exp: + result.add_fail(f"G III {key_in}->{out} expected {exp}") + elif fl & 0x04: + result.add_fail(f"G III stacked xpose fl=0x{fl:02X}") + else: + result.add_ok(f"G III {key_in}->{out}") + if outs != [47, 54, 59]: + result.add_fail(f"G III pattern {outs} != [47,54,59] B2-F#3-B3") + elif outs[2] - outs[0] != 12: + result.add_fail("G III first/third not octave") + else: + result.add_ok("G III pattern B2-F#3-B3") + + # G I (xp=7): overall -12 only via MapXposeOverall + g_i = [(55, 43), (62, 50), (67, 55)] # G3/D4/G4 -> G2/D3/G3 + for key_in, exp in g_i: + out, _fl, _near = map_xpose_overall_chord(key_in) + if out != exp: + result.add_fail(f"G I {key_in}->{out} expected {exp}") + else: + result.add_ok(f"G I {key_in}->{out}") + + # G V (xp=7, deg=5): still stacks register + xpose + out, fl, _near = map_chord(71, 7, 5) + if out != 47 or not (fl & 0x04): + result.add_fail(f"G V should stack 71->47 fl&0x04, got {out} fl=0x{fl:02X}") + else: + result.add_ok("G V 71->47 stacked") + + # analyze_lines: G III first/third stay an octave (B3/B4 -> B2/B3) + keep = [ + "[PITCH] on ch0(chord) 59->47 d=-12 deg=3 chord=8 xp=7 xf=1 fl=0x01 ", + "[PITCH] on ch0(chord) 66->54 d=-12 deg=3 chord=8 xp=7 xf=1 fl=0x01 ", + "[PITCH] on ch0(chord) 71->59 d=-12 deg=3 chord=8 xp=7 xf=1 fl=0x01 ", + ] + keep_r = analyze_lines(keep) + if keep_r.fail: + result.add_fail(f"analyze G III keep failed: {keep_r.messages}") + elif not any("III xpose octave" in m for m in keep_r.messages): + result.add_fail("analyze G III keep missing octave OK") + else: + result.add_ok("analyze G III keep B2/B3") + + collapse = [ + "[PITCH] on ch0(chord) 59->47 d=-12 deg=3 chord=8 xp=7 xf=1 fl=0x01 ", + "[PITCH] on ch0(chord) 71->47 d=-24 deg=3 chord=8 xp=7 xf=1 fl=0x05 ", + ] + collapse_r = CheckResult() + check_iii_xpose_octave(collapse, collapse_r) + if collapse_r.fail == 0: + result.add_fail("analyze should flag collapsed B2/B2") + else: + result.add_ok("analyze flags collapsed B2/B2") + + return print_report(result) + + def run_analyze(path: str, report: str | None) -> int: with open(path, encoding="utf-8", errors="replace") as f: lines = f.readlines() @@ -619,6 +754,7 @@ def main() -> None: 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("--self-test", action="store_true", help="Offline map-rule checks (no J-Link)") 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() @@ -626,6 +762,8 @@ def main() -> None: report = args.report or None log_path = args.out_log or None + if args.self_test: + raise SystemExit(self_test_map_rules()) if args.analyze: raise SystemExit(run_analyze(args.analyze, report)) if args.listen: