"""Measure actual WAV and MIDI files. Dependency: mido==1.3.3.
Run: python3 measure.py --dir run
"""
import argparse,hashlib,json,sys,wave,struct,importlib.metadata
from pathlib import Path
import mido
p=argparse.ArgumentParser();p.add_argument('--dir',default='run');a=p.parse_args();d=Path(a.dir)
def digest(path):return hashlib.sha256(path.read_bytes()).hexdigest()
inputs=[]
for name in ['alternating-stereo.wav','downmix-mono.wav']:
 with wave.open(str(d/name),'rb') as w:
  inputs.append({'file':name,'channels':w.getnchannels(),'sampleRate':w.getframerate(),'frames':w.getnframes(),'bits':w.getsampwidth()*8,'durationSeconds':w.getnframes()/w.getframerate(),'sha256':digest(d/name)})
with wave.open(str(d/'alternating-stereo.wav')) as w:s=struct.unpack('<'+'h'*(w.getnframes()*2),w.readframes(w.getnframes()))
with wave.open(str(d/'downmix-mono.wav')) as w:m=struct.unpack('<'+'h'*w.getnframes(),w.readframes(w.getnframes()))
mono_exact=all((s[i*2]+s[i*2+1])/2==x for i,x in enumerate(m))
expected=json.loads((d/'input-record.json').read_text())['schedule'];outputs=[]
for name in ['alternating-stereo.mid','downmix-mono.mid']:
 mf=mido.MidiFile(d/name);t=0;events=[];ons=[];offs=[];cc=[];channel=set()
 for msg in mf:
  t+=msg.time;events.append({'seconds':t,**msg.dict()})
  if hasattr(msg,'channel'):channel.add(msg.channel)
  if msg.type=='note_on' and msg.velocity>0:ons.append({'pitch':msg.note,'seconds':t,'velocity':msg.velocity,'channel':msg.channel})
  if msg.type=='note_off' or msg.type=='note_on' and msg.velocity==0:offs.append({'pitch':msg.note,'seconds':t})
  if msg.type=='control_change':cc.append({'control':msg.control,'value':msg.value})
 matching=[x['pitch'] for x in ons]==[x['pitch'] for x in expected]
 outputs.append({'file':name,'sha256':digest(d/name),'format':mf.type,'tracks':len(mf.tracks),'ticksPerBeat':mf.ticks_per_beat,'channelNumbersZeroBased':sorted(channel),'notes':len(ons),'pitchSequence':[x['pitch'] for x in ons],'pitchSequenceMatches':matching,'controlChanges':cc,'panCC10Count':sum(x['control']==10 for x in cc),'noteOns':ons,'noteOffs':offs,'maxAbsoluteOnsetErrorMs':max(abs(x['seconds']-y['start'])*1000 for x,y in zip(ons,expected)) if matching else None,'events':events})
report={'python':sys.version,'midoVersion':importlib.metadata.version('mido'),'method':'Read PCM headers and exact samples with wave/struct; parse actual downloaded MIDI using file tempo with mido. No onset alignment or global shift. Count tracks, channels, note-ons and pan controller events. Compare full files byte-for-byte.','inputs':inputs,'monoIsExactStereoAverage':mono_exact,'outputs':outputs,'midiFilesByteIdentical':(d/'alternating-stereo.mid').read_bytes()==(d/'downmix-mono.mid').read_bytes(),'limits':'One original synthetic isolated melody; not a real-song accuracy result. MIDI-to-audio not tested because the site has no such exporter.'}
(d/'measurements.json').write_text(json.dumps(report,indent=2)+'\n');print(json.dumps({k:v for k,v in report.items() if k!='outputs'},indent=2));print([{k:v for k,v in row.items() if k not in ['events','noteOns','noteOffs']} for row in outputs])
