fix(midi): raise Em, F, G, and Am plucked string voicings

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yuquanjun 2026-10-09 16:55:36 +08:00
parent 53c21eee50
commit d07b14c29d
2 changed files with 256 additions and 8 deletions

View File

@ -58,7 +58,7 @@ STRING_MIDI MIDI_2[] =
STRING_MIDI MIDI_3[] = STRING_MIDI MIDI_3[] =
{ {
{0x28,0x2F,0x34,0x38,0x3B,0x40,"E"}, {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,0x37,0x3B,0x3E,"Em7"},
{0x28,0x2F,0x34,0x38,0x3B,0x3E,"E7"}, {0x28,0x2F,0x34,0x38,0x3B,0x3E,"E7"},
{0x28,0x2F,0x34,0x38,0x3B,0x3F,"EM7"}, {0x28,0x2F,0x34,0x38,0x3B,0x3F,"EM7"},
@ -70,7 +70,7 @@ STRING_MIDI MIDI_3[] =
STRING_MIDI MIDI_4[] = 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,0x41,"Fm"},
{0x29,0x30,0x35,0x38,0x3C,0x3F,"Fm7"}, {0x29,0x30,0x35,0x38,0x3C,0x3F,"Fm7"},
{0x29,0x30,0x35,0x39,0x3C,0x3F,"F7"}, {0x29,0x30,0x35,0x39,0x3C,0x3F,"F7"},
@ -83,7 +83,7 @@ STRING_MIDI MIDI_4[] =
STRING_MIDI MIDI_5[] = 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,0x43,"Gm"},
{0x2B,0x2E,0x32,0x37,0x3A,0x41,"Gm7"}, {0x2B,0x2E,0x32,0x37,0x3A,0x41,"Gm7"},
{0x2B,0x2F,0x32,0x37,0x3B,0x41,"G7"}, {0x2B,0x2F,0x32,0x37,0x3B,0x41,"G7"},
@ -97,7 +97,7 @@ STRING_MIDI MIDI_5[] =
STRING_MIDI MIDI_6[] = STRING_MIDI MIDI_6[] =
{ {
{0x28,0x2D,0x34,0x39,0x3D,0x40,"A"}, {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,0x3C,0x40,"Am7"},
{0x28,0x2D,0x34,0x37,0x3D,0x40,"A7"}, {0x28,0x2D,0x34,0x37,0x3D,0x40,"A7"},
{0x28,0x2D,0x34,0x38,0x3D,0x40,"AM7"}, {0x28,0x2D,0x34,0x38,0x3D,0x40,"AM7"},
@ -150,7 +150,7 @@ const STRING_MIDI OrganMIDI_2[] =
const STRING_MIDI OrganMIDI_3[] = const STRING_MIDI OrganMIDI_3[] =
{ {
{0x28,0x2F,0x34,0x38,0x3B,0x40,"E"}, {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,0x37,0x3B,0x3E,"Em7"},
{0x28,0x2F,0x34,0x38,0x3B,0x3E,"E7"}, {0x28,0x2F,0x34,0x38,0x3B,0x3E,"E7"},
{0x28,0x2F,0x34,0x38,0x3B,0x3F,"EM7"}, {0x28,0x2F,0x34,0x38,0x3B,0x3F,"EM7"},
@ -162,7 +162,7 @@ const STRING_MIDI OrganMIDI_3[] =
const STRING_MIDI OrganMIDI_4[] = 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,0x41,"Fm"},
{0x29,0x30,0x35,0x38,0x3C,0x3F,"Fm7"}, {0x29,0x30,0x35,0x38,0x3C,0x3F,"Fm7"},
{0x29,0x30,0x35,0x39,0x3C,0x3F,"F7"}, {0x29,0x30,0x35,0x39,0x3C,0x3F,"F7"},
@ -175,7 +175,7 @@ const STRING_MIDI OrganMIDI_4[] =
const STRING_MIDI OrganMIDI_5[] = 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,0x43,"Gm"},
{0x2B,0x2E,0x32,0x37,0x3A,0x41,"Gm7"}, {0x2B,0x2E,0x32,0x37,0x3A,0x41,"Gm7"},
{0x2B,0x2F,0x32,0x37,0x3B,0x41,"G7"}, {0x2B,0x2F,0x32,0x37,0x3B,0x41,"G7"},
@ -189,7 +189,7 @@ const STRING_MIDI OrganMIDI_5[] =
const STRING_MIDI OrganMIDI_6[] = const STRING_MIDI OrganMIDI_6[] =
{ {
{0x28,0x2D,0x34,0x39,0x3D,0x40,"A"}, {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,0x3C,0x40,"Am7"},
{0x28,0x2D,0x34,0x37,0x3D,0x40,"A7"}, {0x28,0x2D,0x34,0x37,0x3D,0x40,"A7"},
{0x28,0x2D,0x34,0x38,0x3D,0x40,"AM7"}, {0x28,0x2D,0x34,0x38,0x3D,0x40,"AM7"},

248
tools/test_tone_voicing.py Normal file
View File

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