K1Guitar/tools/mic_pitch_analyze.py

388 lines
13 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Laptop-mic pitch analyzer for K1 accompaniment register checks.
Uses autocorrelation F0 estimate (numpy only + sounddevice).
Helps verify bass/chord octave when MIDI channel logs lack ch8(bass).
Examples:
python tools/mic_pitch_analyze.py --list
python tools/mic_pitch_analyze.py --seconds 8 --out mic_i.log
python tools/mic_pitch_analyze.py --live
python tools/mic_pitch_analyze.py --compare mic_i.wav mic_iii.wav
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
import wave
from collections import Counter
from dataclasses import dataclass
from datetime import datetime
import numpy as np
try:
import sounddevice as sd
except ImportError as exc:
raise SystemExit("Missing sounddevice: pip install sounddevice") from exc
A4_HZ = 440.0
A4_MIDI = 69
@dataclass
class PitchFrame:
t: float
hz: float
midi: float
note: str
conf: float
rms: float
NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
def hz_to_midi(hz: float) -> float:
if hz <= 0:
return float("nan")
return A4_MIDI + 12.0 * np.log2(hz / A4_HZ)
def midi_to_name(midi: float) -> str:
if not np.isfinite(midi):
return "--"
n = int(round(midi))
return f"{NOTE_NAMES[n % 12]}{n // 12 - 1}"
def list_devices() -> None:
if hasattr(sys.stdout, "reconfigure"):
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
for i, d in enumerate(sd.query_devices()):
name = str(d.get("name", "")).encode("utf-8", "replace").decode("utf-8", "replace")
print(
f"{i}: in={d['max_input_channels']} out={d['max_output_channels']} "
f"sr={d['default_samplerate']} {name}",
flush=True,
)
try:
print("default device pair:", sd.default.device, flush=True)
except Exception:
pass
def record(seconds: float, sr: int, device: int | None) -> np.ndarray:
frames = int(seconds * sr)
print(f"Recording {seconds:.1f}s @ {sr} Hz (Ctrl+C to abort)...", flush=True)
audio = sd.rec(frames, samplerate=sr, channels=1, dtype="float32", device=device)
sd.wait()
return audio[:, 0]
def save_wav(path: str, audio: np.ndarray, sr: int) -> None:
pcm = np.clip(audio, -1.0, 1.0)
pcm16 = (pcm * 32767.0).astype(np.int16)
with wave.open(path, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(sr)
w.writeframes(pcm16.tobytes())
def load_wav(path: str) -> tuple[np.ndarray, int]:
with wave.open(path, "rb") as w:
sr = w.getframerate()
nch = w.getnchannels()
raw = w.readframes(w.getnframes())
pcm = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
if nch > 1:
pcm = pcm.reshape(-1, nch).mean(axis=1)
return pcm, sr
def estimate_f0_acorr(
frame: np.ndarray,
sr: int,
fmin: float,
fmax: float,
) -> tuple[float, float]:
"""Return (hz, confidence). confidence in [0,1] from normalized peak."""
x = frame.astype(np.float64)
x = x - np.mean(x)
rms = float(np.sqrt(np.mean(x * x)) + 1e-12)
if rms < 1e-4:
return 0.0, 0.0
# Hamming window
x = x * np.hamming(len(x))
corr = np.correlate(x, x, mode="full")
corr = corr[len(corr) // 2 :]
i_min = max(1, int(sr / fmax))
i_max = min(len(corr) - 1, int(sr / fmin))
if i_max <= i_min:
return 0.0, 0.0
seg = corr[i_min : i_max + 1]
peak_rel = int(np.argmax(seg))
peak = peak_rel + i_min
if corr[0] <= 1e-12:
return 0.0, 0.0
conf = float(corr[peak] / corr[0])
if conf < 0.25:
return 0.0, conf
# parabolic interpolation around peak
if 1 <= peak < len(corr) - 1:
a, b, c = corr[peak - 1], corr[peak], corr[peak + 1]
denom = a - 2 * b + c
if abs(denom) > 1e-12:
peak = peak + 0.5 * (a - c) / denom
hz = float(sr / peak)
if hz < fmin or hz > fmax:
return 0.0, conf
return hz, conf
def analyze_audio(
audio: np.ndarray,
sr: int,
hop_ms: float = 50.0,
win_ms: float = 80.0,
fmin: float = 40.0,
fmax: float = 600.0,
conf_min: float = 0.35,
rms_min: float = 0.01,
) -> list[PitchFrame]:
hop = max(1, int(sr * hop_ms / 1000.0))
win = max(hop, int(sr * win_ms / 1000.0))
out: list[PitchFrame] = []
if len(audio) < win:
return out
for start in range(0, len(audio) - win, hop):
frame = audio[start : start + win]
rms = float(np.sqrt(np.mean(frame.astype(np.float64) ** 2)))
hz, conf = estimate_f0_acorr(frame, sr, fmin, fmax)
t = start / sr
if hz <= 0 or conf < conf_min or rms < rms_min:
out.append(PitchFrame(t, 0.0, float("nan"), "--", conf, rms))
continue
midi = hz_to_midi(hz)
out.append(PitchFrame(t, hz, midi, midi_to_name(midi), conf, rms))
return out
def summarize(frames: list[PitchFrame], label: str = "") -> dict:
voiced = [f for f in frames if f.hz > 0 and np.isfinite(f.midi)]
if not voiced:
return {"label": label, "voiced": 0, "total": len(frames)}
midis = np.array([f.midi for f in voiced], dtype=np.float64)
hz = np.array([f.hz for f in voiced], dtype=np.float64)
notes = [f.note for f in voiced]
# round to nearest MIDI for histogram
rounded = [int(round(m)) for m in midis]
top = Counter(rounded).most_common(8)
# bass-ish: MIDI <= 48 (C3)
bass_ratio = float(np.mean(midis <= 48.0))
low_ratio = float(np.mean(midis <= 40.0)) # <= E2
return {
"label": label,
"voiced": len(voiced),
"total": len(frames),
"hz_median": float(np.median(hz)),
"hz_p10": float(np.percentile(hz, 10)),
"hz_p90": float(np.percentile(hz, 90)),
"midi_median": float(np.median(midis)),
"midi_p10": float(np.percentile(midis, 10)),
"midi_p90": float(np.percentile(midis, 90)),
"note_median": midi_to_name(float(np.median(midis))),
"top_notes": [(midi_to_name(float(n)), c) for n, c in top],
"bass_le_C3_ratio": bass_ratio,
"low_le_E2_ratio": low_ratio,
}
def print_summary(s: dict) -> None:
if s.get("voiced", 0) == 0:
print(f"[{s.get('label','')}] no pitched frames", flush=True)
return
print(
f"[{s.get('label','')}] voiced={s['voiced']}/{s['total']} "
f"median={s['note_median']} ({s['midi_median']:.1f} / {s['hz_median']:.1f}Hz) "
f"p10={s['midi_p10']:.1f} p90={s['midi_p90']:.1f} "
f"<=E2={s['low_le_E2_ratio']*100:.0f}% <=C3={s['bass_le_C3_ratio']*100:.0f}%",
flush=True,
)
tops = ", ".join(f"{n}×{c}" for n, c in s["top_notes"][:5])
print(f" top: {tops}", flush=True)
def write_frame_log(path: str, frames: list[PitchFrame], summary: dict) -> None:
with open(path, "w", encoding="utf-8", newline="\n") as f:
f.write(f"# mic pitch {datetime.now().isoformat(timespec='seconds')}\n")
f.write("# " + json.dumps(summary, ensure_ascii=False) + "\n")
f.write("t_s,hz,midi,note,conf,rms\n")
for fr in frames:
midi = "" if not np.isfinite(fr.midi) else f"{fr.midi:.2f}"
f.write(
f"{fr.t:.3f},{fr.hz:.2f},{midi},{fr.note},{fr.conf:.3f},{fr.rms:.4f}\n"
)
def live_monitor(
sr: int,
device: int | None,
fmin: float,
fmax: float,
seconds: float,
) -> None:
win = int(sr * 0.08)
hop = int(sr * 0.05)
print("Live pitch (Ctrl+C stop)...", flush=True)
buf = np.zeros(0, dtype=np.float32)
t0 = time.time()
def callback(indata, frames, time_info, status): # noqa: ARG001
nonlocal buf
if status:
print(status, flush=True)
buf = np.concatenate([buf, indata[:, 0].copy()])
while len(buf) >= win:
frame = buf[:win]
buf = buf[hop:]
hz, conf = estimate_f0_acorr(frame, sr, fmin, fmax)
rms = float(np.sqrt(np.mean(frame.astype(np.float64) ** 2)))
if hz > 0 and conf >= 0.35 and rms >= 0.01:
midi = hz_to_midi(hz)
print(
f"{time.time()-t0:6.1f}s {midi_to_name(midi):4s} "
f"midi={midi:5.1f} {hz:6.1f}Hz conf={conf:.2f} rms={rms:.3f}",
flush=True,
)
with sd.InputStream(samplerate=sr, channels=1, dtype="float32", device=device, callback=callback):
if seconds > 0:
sd.sleep(int(seconds * 1000))
else:
while True:
sd.sleep(200)
def compare_summaries(a: dict, b: dict) -> None:
print("\n======== COMPARE ========", flush=True)
print_summary(a)
print_summary(b)
if a.get("voiced", 0) == 0 or b.get("voiced", 0) == 0:
print("Need pitched content in both takes.", flush=True)
return
d_midi = b["midi_median"] - a["midi_median"]
print(f"median delta (B-A): {d_midi:+.2f} semitones", flush=True)
if d_midi <= -9:
print("PASS-ish: B is about an octave lower than A (expected III+ vs I/II).", flush=True)
elif d_midi <= -5:
print("PARTIAL: B lower than A but less than full octave.", flush=True)
elif abs(d_midi) < 2:
print("FAIL-ish: medians similar — register fix may not be audible on mic mix.", flush=True)
else:
print("CHECK: unexpected direction/amount; inspect top notes / low_le_E2 ratios.", flush=True)
print(
f"low<=E2: A={a['low_le_E2_ratio']*100:.0f}% B={b['low_le_E2_ratio']*100:.0f}%",
flush=True,
)
def main() -> None:
if hasattr(sys.stdout, "reconfigure"):
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
p = argparse.ArgumentParser(description="Mic pitch analyzer for K1 register tests")
p.add_argument("--list", action="store_true", help="List audio devices")
p.add_argument("--device", type=int, default=None, help="Input device index")
p.add_argument("--sr", type=int, default=16000)
p.add_argument("--seconds", type=float, default=8.0)
p.add_argument("--live", action="store_true")
p.add_argument("--wav", default="", help="Analyze existing wav instead of recording")
p.add_argument("--out", default="", help="Prefix for wav/csv outputs")
p.add_argument("--fmin", type=float, default=40.0, help="Min F0 Hz (bass ~41=E1)")
p.add_argument("--fmax", type=float, default=600.0, help="Max F0 Hz")
p.add_argument(
"--compare",
nargs=2,
metavar=("A", "B"),
help="Compare two wav/csv summary sources (wav preferred)",
)
p.add_argument("--label", default="")
args = p.parse_args()
if args.list:
list_devices()
return
if args.compare:
summaries = []
for i, path in enumerate(args.compare):
label = "A" if i == 0 else "B"
if path.lower().endswith(".wav"):
audio, sr = load_wav(path)
frames = analyze_audio(audio, sr, fmin=args.fmin, fmax=args.fmax)
s = summarize(frames, label=f"{label}:{os.path.basename(path)}")
else:
raise SystemExit("compare expects .wav files")
summaries.append(s)
compare_summaries(summaries[0], summaries[1])
return
if args.live:
live_monitor(args.sr, args.device, args.fmin, args.fmax, args.seconds if args.seconds > 0 else 0)
return
prefix = args.out or f"mic_pitch_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
if args.wav:
audio, sr = load_wav(args.wav)
label = args.label or os.path.basename(args.wav)
else:
audio = record(args.seconds, args.sr, args.device)
sr = args.sr
label = args.label or "rec"
wav_path = prefix if prefix.lower().endswith(".wav") else prefix + ".wav"
save_wav(wav_path, audio, sr)
print(f"Saved wav: {os.path.abspath(wav_path)}", flush=True)
frames = analyze_audio(audio, sr, fmin=args.fmin, fmax=args.fmax)
summary = summarize(frames, label=label)
print_summary(summary)
log_path = prefix if prefix.lower().endswith(".csv") else prefix + ".csv"
# if prefix was .wav, still write .csv alongside
if log_path.lower().endswith(".wav.csv"):
log_path = log_path[:-8] + ".csv"
elif prefix.lower().endswith(".wav"):
log_path = prefix[:-4] + ".csv"
write_frame_log(log_path, frames, summary)
print(f"Saved log: {os.path.abspath(log_path)}", flush=True)
print(
"Tip: record I/II then III+ separately, then:\n"
" python tools/mic_pitch_analyze.py --compare mic_i.wav mic_iii.wav",
flush=True,
)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\nStopped.", flush=True)
sys.exit(130)