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 <cursoragent@cursor.com>
This commit is contained in:
yuquanjun 2026-09-24 16:32:18 +08:00
parent f6d9a557c8
commit 3eba48ece1
2 changed files with 152 additions and 13 deletions

View File

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

View File

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