From d07b14c29dd25a72ad59ff1fa8f84d648315952a Mon Sep 17 00:00:00 2001 From: yuquanjun Date: Fri, 9 Oct 2026 16:55:36 +0800 Subject: [PATCH] fix(midi): raise Em, F, G, and Am plucked string voicings Co-authored-by: Cursor --- midi/midi_tables.c | 16 +-- tools/test_tone_voicing.py | 248 +++++++++++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+), 8 deletions(-) create mode 100644 tools/test_tone_voicing.py diff --git a/midi/midi_tables.c b/midi/midi_tables.c index 6655983..ab65cdc 100644 --- a/midi/midi_tables.c +++ b/midi/midi_tables.c @@ -58,7 +58,7 @@ STRING_MIDI MIDI_2[] = STRING_MIDI MIDI_3[] = { {0x28,0x2F,0x34,0x38,0x3B,0x40,"E"}, - {0x28,0x2F,0x34,0x37,0x3B,0x40,"Em"}, + {0x28,0x37,0x3B,0x40,0x3B,0x40,"Em"}, {0x28,0x2F,0x34,0x37,0x3B,0x3E,"Em7"}, {0x28,0x2F,0x34,0x38,0x3B,0x3E,"E7"}, {0x28,0x2F,0x34,0x38,0x3B,0x3F,"EM7"}, @@ -70,7 +70,7 @@ STRING_MIDI MIDI_3[] = STRING_MIDI MIDI_4[] = { - {0x29,0x30,0x35,0x39,0x3C,0x41,"F"}, + {0x29,0x39,0x3C,0x41,0x3C,0x41,"F"}, {0x29,0x30,0x35,0x38,0x3C,0x41,"Fm"}, {0x29,0x30,0x35,0x38,0x3C,0x3F,"Fm7"}, {0x29,0x30,0x35,0x39,0x3C,0x3F,"F7"}, @@ -83,7 +83,7 @@ STRING_MIDI MIDI_4[] = STRING_MIDI MIDI_5[] = { - {0x2B,0x2F,0x32,0x37,0x3B,0x43,"G"}, + {0x2B,0x2F,0x37,0x3B,0x43,0x43,"G"}, {0x2B,0x2E,0x32,0x37,0x3A,0x43,"Gm"}, {0x2B,0x2E,0x32,0x37,0x3A,0x41,"Gm7"}, {0x2B,0x2F,0x32,0x37,0x3B,0x41,"G7"}, @@ -97,7 +97,7 @@ STRING_MIDI MIDI_5[] = STRING_MIDI MIDI_6[] = { {0x28,0x2D,0x34,0x39,0x3D,0x40,"A"}, - {0x28,0x2D,0x34,0x39,0x3C,0x40,"Am"}, + {0x28,0x2D,0x39,0x3C,0x40,0x40,"Am"}, {0x28,0x2D,0x34,0x37,0x3C,0x40,"Am7"}, {0x28,0x2D,0x34,0x37,0x3D,0x40,"A7"}, {0x28,0x2D,0x34,0x38,0x3D,0x40,"AM7"}, @@ -150,7 +150,7 @@ const STRING_MIDI OrganMIDI_2[] = const STRING_MIDI OrganMIDI_3[] = { {0x28,0x2F,0x34,0x38,0x3B,0x40,"E"}, - {0x28,0x2F,0x34,0x37,0x3B,0x40,"Em"}, + {0x28,0x37,0x3B,0x40,0x3B,0x40,"Em"}, {0x28,0x2F,0x34,0x37,0x3B,0x3E,"Em7"}, {0x28,0x2F,0x34,0x38,0x3B,0x3E,"E7"}, {0x28,0x2F,0x34,0x38,0x3B,0x3F,"EM7"}, @@ -162,7 +162,7 @@ const STRING_MIDI OrganMIDI_3[] = const STRING_MIDI OrganMIDI_4[] = { - {0x29,0x30,0x35,0x39,0x3C,0x41,"F"}, + {0x29,0x39,0x3C,0x41,0x3C,0x41,"F"}, {0x29,0x30,0x35,0x38,0x3C,0x41,"Fm"}, {0x29,0x30,0x35,0x38,0x3C,0x3F,"Fm7"}, {0x29,0x30,0x35,0x39,0x3C,0x3F,"F7"}, @@ -175,7 +175,7 @@ const STRING_MIDI OrganMIDI_4[] = const STRING_MIDI OrganMIDI_5[] = { - {0x2B,0x2F,0x32,0x37,0x3B,0x43,"G"}, + {0x2B,0x2F,0x37,0x3B,0x43,0x43,"G"}, {0x2B,0x2E,0x32,0x37,0x3A,0x43,"Gm"}, {0x2B,0x2E,0x32,0x37,0x3A,0x41,"Gm7"}, {0x2B,0x2F,0x32,0x37,0x3B,0x41,"G7"}, @@ -189,7 +189,7 @@ const STRING_MIDI OrganMIDI_5[] = const STRING_MIDI OrganMIDI_6[] = { {0x28,0x2D,0x34,0x39,0x3D,0x40,"A"}, - {0x28,0x2D,0x34,0x39,0x3C,0x40,"Am"}, + {0x28,0x2D,0x39,0x3C,0x40,0x40,"Am"}, {0x28,0x2D,0x34,0x37,0x3C,0x40,"Am7"}, {0x28,0x2D,0x34,0x37,0x3D,0x40,"A7"}, {0x28,0x2D,0x34,0x38,0x3D,0x40,"AM7"}, diff --git a/tools/test_tone_voicing.py b/tools/test_tone_voicing.py new file mode 100644 index 0000000..411d11b --- /dev/null +++ b/tools/test_tone_voicing.py @@ -0,0 +1,248 @@ +# -*- coding: utf-8 -*- +"""音色表六弦音高校对:RTT string 回报 + 麦克风基频。 + +对照 Doc/Bug/音色改善.png(C 调、移调 0)。 +I C / II Dm 保持原把位;III Em / IV F / V G / VI Am 为上移后的把位。 +只走 `string N`,不再额外 midi on 期望音,避免麦测被兜底音骗过。 +""" +from __future__ import annotations + +import argparse +import datetime +import os +import re +import sys +import threading +import time + +import numpy as np + +TOOLS = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, TOOLS) + +from k1_harness.transports.rtt_session import open_session # noqa: E402 +from mic_pitch_analyze import ( # noqa: E402 + analyze_audio, + estimate_f0_acorr, + hz_to_midi, + midi_to_name, + save_wav, +) + +try: + import sounddevice as sd +except ImportError as exc: + raise SystemExit("Missing sounddevice: pip install sounddevice") from exc + +# 六弦→一弦 MIDI。与 midi_tables.c OrganMIDI / MIDI 对应行一致。 +CASES = [ + ("C", 1, (43, 48, 52, 55, 60, 64)), + ("Dm", 4, (41, 45, 50, 57, 62, 65)), + ("Em", 7, (40, 55, 59, 64, 59, 64)), + ("F", 10, (41, 57, 60, 65, 60, 65)), + ("G", 13, (43, 47, 55, 59, 67, 67)), + ("Am", 16, (40, 45, 57, 60, 64, 64)), +] + +SR = 48000 +HOLD_S = 1.6 +GAP_S = 0.45 +FMIN = 70.0 +FMAX = 520.0 +NOTE_RE = re.compile(r"STRING_OK s=(\d+) note=(\d+)") +REPORTS = os.path.abspath(os.path.join(TOOLS, "..", "..", "..", "Doc", "reports")) + + +def record(seconds: float, sr: int, device: int) -> np.ndarray: + frames = int(seconds * sr) + audio = sd.rec(frames, samplerate=sr, channels=1, dtype="float32", device=device) + sd.wait() + return audio[:, 0] + + +def fft_f0(audio: np.ndarray, sr: int) -> float | None: + if len(audio) < sr // 5: + return None + mid = len(audio) // 2 + half = int(0.45 * sr) + seg = audio[max(0, mid - half) : mid + half].astype(np.float64) + seg = seg - np.mean(seg) + if float(np.sqrt(np.mean(seg * seg))) < 0.003: + return None + win = seg * np.hanning(len(seg)) + n = 1 << int(np.ceil(np.log2(len(win)))) + spec = np.abs(np.fft.rfft(win, n=n)) + freqs = np.fft.rfftfreq(n, 1.0 / sr) + mask = (freqs >= FMIN) & (freqs <= FMAX) + if not np.any(mask): + return None + peak_i = int(np.where(mask)[0][0] + int(np.argmax(spec[mask]))) + if 1 <= peak_i < len(spec) - 1: + a, b, c = float(spec[peak_i - 1]), float(spec[peak_i]), float(spec[peak_i + 1]) + denom = a - 2 * b + c + peak_f = peak_i + (0.5 * (a - c) / denom if abs(denom) > 1e-12 else 0.0) + else: + peak_f = float(peak_i) + return float(peak_f * sr / n) + + +def detect_pitch(audio: np.ndarray, sr: int): + frames = analyze_audio( + audio, sr, fmin=FMIN, fmax=FMAX, conf_min=0.22, rms_min=0.0025, hop_ms=40, win_ms=90 + ) + voiced = [f for f in frames if f.hz > 0 and np.isfinite(f.midi)] + if len(voiced) >= 3: + n = len(voiced) + lo, hi = n // 5, max(n // 5 + 1, (4 * n) // 5) + med = float(np.median([f.midi for f in voiced[lo:hi]])) + return med, midi_to_name(med), "acorr" + hz = fft_f0(audio, sr) + if hz is None: + best = (0.0, 0.0) + win = int(0.09 * sr) + hop = int(0.04 * sr) + for i in range(0, max(1, len(audio) - win), hop): + h, c = estimate_f0_acorr(audio[i : i + win], sr, FMIN, FMAX) + if c > best[1] and h > 0: + best = (h, c) + hz = best[0] if best[1] >= 0.2 else None + if hz is None or hz <= 0: + return None, "--", "none" + midi = float(hz_to_midi(hz)) + return midi, midi_to_name(midi), "fft" + + +def parse_string_note(text: str, string_n: int) -> int | None: + found = None + for m in NOTE_RE.finditer(text or ""): + if int(m.group(1)) == string_n: + found = int(m.group(2)) + return found + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--device", type=int, default=1, help="sounddevice input index") + ap.add_argument("--no-mic", action="store_true", help="只核对 RTT STRING_OK") + args = ap.parse_args() + + os.makedirs(REPORTS, exist_ok=True) + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + out_dir = os.path.join(REPORTS, f"tone_voicing_{ts}") + os.makedirs(out_dir, exist_ok=True) + rows = [] + + print("=== 音色表六弦音高:RTT + 麦测 ===", flush=True) + print(f"mic_device={args.device} sr={SR} wav={out_dir}", flush=True) + + with open_session(reset=False) as sess: + sess.cmd("tone pick f", settle=1.0) + sess.cmd_ack("chord xpose 0", "CHORD_XPOSE_OK", timeout_s=2.0, retries=4) + sess.cmd("chord key 23", settle=0.4) + sess.cmd("chord key 0", settle=0.2) + + for name, key, notes in CASES: + sess.cmd_ack(f"chord key {key}", "CHORD_KEY_OK", timeout_s=3.0, retries=4) + time.sleep(0.25) + print(f"\n-- {name} key={key}", flush=True) + for s in range(6, 0, -1): + expect = int(notes[6 - s]) + label = f"{name}_str{s}" + wav = os.path.join(out_dir, f"{label}.wav") + audio_box: list = [] + + def _rec(box=audio_box): + if args.no_mic: + box.append(None) + return + box.append(record(HOLD_S, SR, args.device)) + + th = threading.Thread(target=_rec, daemon=True) + th.start() + time.sleep(0.25) + n0 = len(sess.lines) + sess.cmd_ack(f"string {s} 110", "STRING_OK", timeout_s=2.5, retries=3) + th.join() + sess.cmd(f"string {s} off", settle=0.05) + + rtt_note = parse_string_note("\n".join(sess.lines[n0:]), s) + rtt_ok = rtt_note == expect + heard = heard_name = method = None + delta = None + mic_ok = None + rms = 0.0 + if not args.no_mic: + audio = audio_box[0] if audio_box else np.zeros(1, dtype=np.float32) + save_wav(wav, audio, SR) + rms = float(np.max(np.abs(audio))) if len(audio) else 0.0 + heard, heard_name, method = detect_pitch(audio, SR) + if heard is not None: + delta = heard - float(expect) + mic_ok = abs(delta) <= 0.75 + else: + mic_ok = False + + row = { + "label": label, + "chord": name, + "key": key, + "string": s, + "expect": expect, + "expect_name": midi_to_name(float(expect)), + "rtt": rtt_note, + "rtt_name": midi_to_name(float(rtt_note)) if rtt_note is not None else "--", + "rtt_ok": rtt_ok, + "heard": heard, + "heard_name": heard_name or "--", + "delta": delta, + "mic_ok": mic_ok, + "method": method, + "rms": rms, + } + rows.append(row) + mic_s = "skip" if mic_ok is None else ("PASS" if mic_ok else "FAIL") + print( + f" {label}: table={row['expect_name']}({expect}) " + f"rtt={row['rtt_name']}({rtt_note}) [{'PASS' if rtt_ok else 'FAIL'}] " + f"mic={row['heard_name']}" + f"({None if heard is None else round(heard, 2)}) " + f"d={None if delta is None else round(delta, 2)} " + f"rms={rms:.3f} [{mic_s}]", + flush=True, + ) + time.sleep(GAP_S) + sess.cmd("chord key 0", settle=0.15) + + rtt_fail = [r for r in rows if not r["rtt_ok"]] + mic_fail = [r for r in rows if r["mic_ok"] is False] + md = os.path.join(REPORTS, f"tone_voicing_{ts}.md") + lines = [ + "# 音色表六弦音高核对", + "", + f"- 时间: {datetime.datetime.now():%Y-%m-%d %H:%M:%S}", + "- 期望: C/Dm 原把位;Em/F/G/Am 为音色改善表上移后的六弦", + f"- wav: `{out_dir}`", + "", + "| 和弦 | 弦 | 表 | RTT | RTT结果 | 麦测 | Δ | 麦测结果 |", + "|------|----|----|-----|---------|------|---|----------|", + ] + for r in rows: + mic_cell = "skip" if r["mic_ok"] is None else ("PASS" if r["mic_ok"] else "FAIL") + dlt = "" if r["delta"] is None else f"{r['delta']:.2f}" + lines.append( + f"| {r['chord']} | {r['string']} | {r['expect_name']} | {r['rtt_name']} | " + f"{'PASS' if r['rtt_ok'] else 'FAIL'} | {r['heard_name']} | {dlt} | {mic_cell} |" + ) + lines.append("") + lines.append(f"- RTT 失败 {len(rtt_fail)} / {len(rows)}") + if not args.no_mic: + lines.append(f"- 麦测失败 {len(mic_fail)} / {len(rows)}") + with open(md, "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + print(f"\n报告: {md}", flush=True) + print(f"RTT fail={len(rtt_fail)} mic fail={len(mic_fail)}", flush=True) + return 1 if rtt_fail or mic_fail else 0 + + +if __name__ == "__main__": + raise SystemExit(main())