Spaces:
Sleeping
Sleeping
File size: 16,818 Bytes
11330a4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 | #!/usr/bin/env python3
"""Build a compact, causal note alignment view from one NPTrace session.
The raw trace's event ``t`` is the canonical session clock. AudioContext and
worklet clocks are retained as source fields; conversions below are estimates,
never replacement measurements.
"""
from __future__ import annotations
import argparse, csv, json, math, statistics
from pathlib import Path
def _num(x):
return x if isinstance(x, (int, float)) and math.isfinite(x) else None
def _nearest(items, t, pred=lambda e: True, after=-1e9, before=1e9):
xs = [e for e in items if pred(e) and after <= e.get("t", -1e9) <= before]
return min(xs, key=lambda e: abs(e["t"]-t)) if xs else None
def _name(p):
names = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']
return f"{names[p%12]}{p//12-1}" if isinstance(p, int) else "?"
def _cell(v):
return "--" if v is None else (f"{v:.3f}" if isinstance(v, float) else str(v))
def build_alignment(trace):
evs = trace.get("events", [])
for n, e in enumerate(evs): e["_id"] = n
meta = trace.get("meta", {})
lat = meta.get("latency", {}) or {}
sr = (meta.get("follower", {}) or {}).get("sr") or 48000
hop = (meta.get("follower", {}) or {}).get("hop") or 512
frames = [e for e in evs if e.get("type") == "follow.frame"]
frame0 = frames[0] if frames else None
min_midi = (meta.get("follower", {}) or {}).get("options", {}).get("minMidi", 33)
def frame_ms(e):
if not frame0: return None
return round((e.get("at", 0)-frame0.get("at", 0))*1000/sr, 3)
input_events = [e for e in evs if e.get("type") == "input.testEvent"]
audio = [e for e in evs if e.get("type") == "audio.note" and e.get("role") == "solo"]
advances = [e for e in evs if e.get("type") == "follow.advance" and e.get("accepted", True)]
holds = [e for e in evs if e.get("type") == "follow.hold"]
follower_frames = frames
classic = [e for e in evs if e.get("type") == "mic.classic"]
fires = [e for e in evs if e.get("type") == "accomp.fire"]
cancelled = [e for e in evs if e.get("type") == "input.retryCancelled"]
attempts = []
for e in input_events:
idx = e.get("scoreEventId", e.get("index"))
pitches = e.get("pitches") or []
aid = e.get("attemptId") or f"{meta.get('testRun', {}).get('runId', Path('trace').stem)}:e{idx}:a{e.get('attempt',0)}"
a = {"scoreEventId": idx, "beat": e.get("beat"), "expected": pitches,
"attempt": e.get("attempt", 0), "attemptId": aid,
"playerIntentMs": _num(e.get("t")), "rawEventIds": [e["_id"]]}
out = _nearest(audio, e["t"], lambda x: x.get("midi") in pitches, e["t"]-5, e["t"]+80)
if out:
a.update(audioScheduledMs=out["t"], physicalOutputMs=out["t"], audioTime=out.get("audioTime"), audioRawEventId=out["_id"])
a["physicalMicArrivalMs"] = None
a["physicalMicArrivalKnown"] = False
a["detectionLagSnapshotMs"] = _num(lat.get("detectionMs"))
else:
a.update(audioScheduledMs=None, physicalOutputMs=None, audioTime=None,
physicalMicArrivalMs=None, physicalMicArrivalKnown=False,
detectionLagSnapshotMs=_num(lat.get("detectionMs")))
# Frames logged before the synthetic note was emitted are residual
# audio/queue state, not evidence for this attempt.
lo = a.get("physicalOutputMs") if a.get("physicalOutputMs") is not None else e["t"]
later_attempts = [x["t"] for x in input_events if x.get("t",0) > e["t"]]
commit_probe = next((x["t"] for x in advances if x.get("toIndex") == idx and x.get("t",0) >= e["t"]), None)
end_candidates = [x for x in (commit_probe, min(later_attempts, default=None), e["t"] + 3000) if x is not None]
causal_end = min(end_candidates)
a["causalWindow"] = {"startMs": lo, "endMs": causal_end,
"endReason": "commit" if commit_probe == causal_end else "nextAttempt" if later_attempts and min(later_attempts) == causal_end else "timeout"}
fs = [f for f in follower_frames if lo <= f.get("t",0) <= causal_end]
expected_idx = [p-min_midi for p in pitches if isinstance(p, int) and min_midi <= p <= min_midi+len((follower_frames[0].get("sal") if follower_frames else []))-1]
onset = None
for f in fs:
sal = f.get("sal") or []
evidence = min((sal[i] for i in expected_idx), default=999)
if f.get("on", 0) > 0.3 and (f.get("inf") or evidence < 50):
onset = f; break
a["firstMicOnsetMs"] = onset.get("t") if onset else None
a["onsetLatencyMs"] = round(onset["t"]-e["t"], 3) if onset else None
if onset:
sal = onset.get("sal") or []
a["expectedEvidence"] = min((sal[i] for i in expected_idx), default=None)
ranked = sorted(((v, min_midi+i) for i,v in enumerate(sal)), key=lambda x:x[0])[:5]
a["topAcousticCandidates"] = [{"midi":m,"name":_name(m),"attenuationUnits":v,"dbBelowPeak":round(v*0.5,2)} for v,m in ranked]
else:
a["expectedEvidence"] = None; a["topAcousticCandidates"] = None
causal_classic = [x for x in classic if lo <= x.get("t",0) <= causal_end]
c_blind = next((x for x in causal_classic if x.get("yinMidi") is not None), None)
c_shadow = causal_classic[0] if causal_classic else None
post_shadow = next((x for x in classic if causal_end < x.get("t",0) <= causal_end+1000), None)
a["blindPitch"] = ({"midi":c_blind.get("yinMidi"),"name":c_blind.get("yinName"),"clarity":c_blind.get("yinClarity"),"rawEventId":c_blind.get("_id")} if c_blind else None)
a["classicShadow"] = ({k:c_shadow.get(k) for k in ("_id","t","midi","name","freq","clarity","yinMidi","yinName","rescue","reason","source")} if c_shadow else None)
a["scoreBiasedShadow"] = ({k:c_shadow.get(k) for k in ("_id","t","midi","name","freq","clarity","rescue","reason","source")} if c_shadow and c_shadow.get("rescue") else None)
a["postCommitShadow"] = ({k:post_shadow.get(k) for k in ("_id","t","midi","name","freq","clarity","yinMidi","yinName","rescue","reason","source")} if post_shadow else None)
hs = [h for h in holds if h.get("index") == idx and lo <= h.get("t",0) <= causal_end]
a["firstHoldReason"] = hs[0].get("reason") if hs else None
# Preserve every decision snapshot in the causal window. These are
# observations of the existing commit rule, not reconstructed fits.
a["holdTimeline"] = [{k:v for k,v in h.items() if k != "_id"} for h in hs]
hold_candidates = [h.get("candidate") for h in hs if isinstance(h.get("candidate"), dict)]
peak_p = max(hold_candidates, key=lambda c: c.get("p", -math.inf), default=None)
peak_ev = max(hold_candidates, key=lambda c: c.get("ev", -math.inf), default=None)
a["peakPosteriorHold"] = peak_p
a["peakEvidenceHold"] = peak_ev
adv = _nearest(advances, e["t"], lambda x: x.get("toIndex") == idx, e["t"]-20, causal_end)
if adv:
a["commitMs"] = adv["t"]; a["playerToCommitMs"] = round(adv["t"]-e["t"],3); a["advanceRawEventId"] = adv["_id"]
a["attribution"] = adv.get("explained")
a["heardMidi"] = adv.get("heardMidi")
hyps = [h for h in adv.get("hyps",[]) if h.get("i") != idx]
a["strongestCompetitor"] = max(hyps, key=lambda h:h.get("w",-1), default=None)
else:
a.update(commitMs=None, playerToCommitMs=None, advanceRawEventId=None, attribution=None, heardMidi=None, strongestCompetitor=None)
a["retryRequired"] = any(x.get("scoreEventId",x.get("index")) == idx and x.get("attempt",0) > e.get("attempt",0) for x in input_events)
a["retryCancelled"] = any(x.get("scoreEventId",x.get("index")) == idx and x.get("attempt",0) == e.get("attempt",0) for x in cancelled)
fire = _nearest(fires, a.get("commitMs") or e["t"], lambda x: x.get("t",0) >= (a.get("commitMs") or e["t"]), (a.get("commitMs") or e["t"]), (a.get("commitMs") or e["t"])+2500)
a["accompanimentResponseMs"] = fire.get("t") if fire else None
a["accompanimentRawEventId"] = fire.get("_id") if fire else None
# Causal status, deliberately conservative.
ex = a.get("attribution") or {}
reasons = [h.get("reason") for h in hs]
if a["retryCancelled"]: status = "STALE_RETRY_CANCELLED"
elif adv and e.get("attempt",0) == 0 and not a["retryRequired"]: status = "PASS_FIRST"
elif adv: status = "PASS_RETRY"
elif ex.get("bleed",0) >= .5 and ex.get("new",1) < .1: status = "BLEED_DOMINATED"
elif ex.get("prev",0) >= .4 and ex.get("new",1) <= .01: status = "PREVIOUS_DOMINATED"
elif "low-posterior" in reasons: status = "LOW_POSTERIOR"
elif onset: status = "ACOUSTIC_SEEN_FOLLOWER_HOLD"
elif a.get("physicalOutputMs") is not None: status = "MIC_MISS"
else: status = "UNRESOLVED"
a["status"] = status
a["stage"] = ("F" if adv and not fire else "E" if adv else "D" if hs and onset else "C" if onset else "A")
a["heard"] = {"onsetPresent": onset is not None, "expectedEvidence": a.get("expectedEvidence"), "blindPitch": a.get("blindPitch"), "topFrontendRows": a.get("topAcousticCandidates")}
c = peak_p or peak_ev or {}
cx = c.get("explained") or {}
a["recognized"] = {"new": ex.get("new") if ex else cx.get("new"), "prev": ex.get("prev") if ex else cx.get("prev"), "bleed": ex.get("bleed") if ex else cx.get("bleed"), "wrongOther": ex.get("other") if ex else cx.get("other"), "posterior": adv.get("p") if adv else (peak_p or {}).get("p"), "evidence": adv.get("ev") if adv else (peak_ev or {}).get("ev"), "distinct": c.get("distinct"), "pWrong": (hs[-1].get("wrong") or {}).get("p") if hs else None}
a["accepted"] = {"commit": adv is not None, "commitMs": a.get("commitMs"), "firstHoldReason": a.get("firstHoldReason"), "guardRefusal": any(x.get("type")=="follow.guard" and lo <= x.get("t",0) <= causal_end for x in evs)}
attempts.append(a)
# Every accepted advance must have a prior attempt, otherwise it is an orphan.
orphans=[]
for adv in advances:
idx=adv.get("toIndex"); prior=[a for a in attempts if a["scoreEventId"]==idx and a["playerIntentMs"] <= adv["t"]]
if not prior:
future=[a for a in attempts if a["scoreEventId"]==idx and a["playerIntentMs"] > adv["t"]]
orphans.append({"advanceMs":adv["t"],"expectedEvent":idx,"mostRecentPriorAttempt":None,
"timeUntilActualAttemptMs":round(future[0]["playerIntentMs"]-adv["t"],3) if future else None,
"explained":adv.get("explained"),"heardMidi":adv.get("heardMidi"),"rawEventId":adv["_id"]})
for a in attempts:
if any(o["expectedEvent"]==a["scoreEventId"] and o["advanceMs"] < a["playerIntentMs"] for o in orphans): a["status"]="FALSE_ADVANCE"
checks=[]
for a in attempts:
# scoreBiasedShadow is deliberately kept in its own field and never
# copied into blindPitch; this check documents that invariant.
if a.get("blindPitch") and a.get("blindPitch", {}).get("rawEventId") == (a.get("scoreBiasedShadow") or {}).get("_id"):
checks.append({"code":"SCORE_BIASED_PITCH_AS_ACOUSTIC","scoreEventId":a["scoreEventId"],"severity":"error"})
checks += [{"code":"ADVANCE_WITHOUT_PLAYER_ATTEMPT","rawEventId":o["rawEventId"],"severity":"error"} for o in orphans]
checks += [{"code":"ATTEMPT_WITHOUT_OUTCOME","scoreEventId":a["scoreEventId"],"attempt":a["attempt"],"severity":"error"} for a in attempts if not a.get("status")]
md = meta.get("micDistance") or {}
refused = md.get("refused")
run_quality = {"aboveRoomDb": _num(md.get("aboveRoomDb")), "band": md.get("band"), "refused": refused if isinstance(refused, bool) else None, "suitableForTuning": (not refused) if isinstance(refused, bool) else None}
return {"schema":"notepilot.note-alignment.v3","source":"raw NPTrace events","clock":{"canonical":"events[].t (session-relative performance.now ms)","sessionMs":"observed monotonic trace time","audioTimeSec":"raw AudioContext.currentTime, observed","sampleIndex":"follow.frame.at, observed","frameIndex":"follow.frame.k, observed","frame":{"sampleRate":sr,"hop":hop,"cadenceMs":round(hop*1000/sr,3),"frameSampleMs":"(at-first.at)*1000/sr"}},"latencySemantics":{"baseMs":"classic speaker-probe detection/round-trip calibration value and slider fallback","micMs":"legacy alias for detectionMs; not physical mic propagation","detectionMs":"snapshot of detection-lag compensation term","outputMs":"configured output-delay slider","outputUsedMs":"browser output latency applied","effectiveMs":"detection plus output compensation; not an arrival timestamp","physicalMicArrivalKnown":False},"causalWindow":"starts at observed audio.note/player output and ends at earliest successful commit, next attempt, or +3000ms timeout","runQuality":run_quality,"attempts":attempts,"orphanAdvances":orphans,"sanityChecks":checks,"rawEventCount":len(evs)}
def print_report(out, event=None, window=1000, trace=None):
rows=out["attempts"]
print("evt note try heard maxP ev@maxP distinct new prev bleed wrongP maxEv p@maxEv result")
for a in rows:
if event is not None and a["scoreEventId"] != event: continue
note="/".join(_name(p) for p in a["expected"])
rec=a.get("recognized") or {}; pp=a.get("peakPosteriorHold") or {}; pe=a.get("peakEvidenceHold") or {}; ex=pp.get("explained") or pe.get("explained") or {}
vals=[pp.get('p'),pp.get('ev'),pp.get('distinct'),ex.get('new'),ex.get('prev'),ex.get('bleed'),rec.get('pWrong'),pe.get('ev'),pe.get('p')]
print(f"{a['scoreEventId']:>3} {note:<4} {a['attempt']:>3} {'YES' if a.get('heard',{}).get('onsetPresent') else 'NO':>5} " + " ".join(f"{_cell(v):>8}" for v in vals) + f" {a['status']}")
vals=[a["playerToCommitMs"] for a in rows if a.get("playerToCommitMs") is not None]
print(f"\nFALSE ADVANCES: {len(out['orphanAdvances'])}")
print(f"STALE RETRIES: {sum(a.get('retryCancelled',False) for a in rows)}")
print(f"FIRST-STRIKE PASS: {sum(a['status']=='PASS_FIRST' for a in rows)}/{len(rows)}")
print(f"MEDIAN player→commit: {statistics.median(vals):.1f} ms" if vals else "MEDIAN player→commit: --")
print(f"WORST player→commit: {max(vals):.1f} ms" if vals else "WORST player→commit: --")
if event is not None and trace is not None:
target = next((a for a in rows if a["scoreEventId"] == event), None)
if target:
lo = target["playerIntentMs"] - window; hi = target["playerIntentMs"] + window
print(f"\nRAW TIMELINE event {event} ({lo:.1f}..{hi:.1f} ms)")
for i, e in enumerate(trace.get("events", [])):
if lo <= e.get("t", -1e9) <= hi and e.get("type") in {
"input.testEvent","input.key","audio.note","follow.frame","follow.hold",
"follow.advance","follow.guard","mic.classic","mic.display","accomp.fire",
"input.retryCancelled","tracker"
}:
labels={"input.testEvent":"PLAYER","input.key":"PLAYER","audio.note":"AUDIO_OUT","follow.frame":"FOLLOWER","follow.hold":"FOLLOWER","follow.advance":"FOLLOWER","follow.guard":"GUARD","mic.classic":"CLASSIC_SHADOW","mic.display":"CLASSIC_SHADOW","accomp.fire":"ACCOMP","input.retryCancelled":"GUARD","tracker":"TRACKER"}
detail={k:e.get(k) for k in ("index","attempt","scoreEventId","midi","name","toIndex","reason","p","margin","ev","heardMidi","explained","best","pos","on","source","text") if k in e}
print(f" [{i:04d}] {e.get('t'):8.1f} {labels.get(e.get('type'),'TRACE'):<14} {e.get('type'):<22} {detail}")
def main():
ap=argparse.ArgumentParser(); ap.add_argument("trace"); ap.add_argument("--event",type=int); ap.add_argument("--window-ms",type=int,default=1000); ap.add_argument("--no-write",action="store_true")
args=ap.parse_args(); p=Path(args.trace); trace=json.loads(p.read_text()); out=build_alignment(trace)
if not args.no_write:
p.with_name(p.name+".alignment.json").write_text(json.dumps(out,indent=2)+"\n")
fields=sorted({k for a in out['attempts'] for k in a.keys()});
with p.with_name(p.name+".alignment.csv").open('w',newline='') as f:
w=csv.DictWriter(f,fieldnames=fields); w.writeheader();
for a in out['attempts']: w.writerow({k:json.dumps(a.get(k)) if isinstance(a.get(k),(dict,list)) else a.get(k) for k in fields})
print_report(out,args.event,args.window_ms,trace)
if __name__ == '__main__': main()
|