#!/usr/bin/env python3
from pathlib import Path
from array import array
import datetime,hashlib,json,os,re,subprocess,sys,wave
ROOT=Path('/data/video-pipeline/HaTramAudio/project/017-Tam-Ve-Khong-Ghi-Diem-Den');PID=ROOT.name;RUN='run-20260721T030911Z-51bf7fe4';OWNER='Levy'
def now():return datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00','Z')
def sha(p):
 h=hashlib.sha256()
 with p.open('rb') as f:
  for b in iter(lambda:f.read(8*1024*1024),b''):h.update(b)
 return h.hexdigest()
def guard():
 d=json.loads((ROOT/'.ownership-lock.json').read_text())
 for k,v in {'project_id':PID,'run_id':RUN,'owner':OWNER}.items():
  if d.get(k)!=v:raise RuntimeError('ownership mismatch '+k)
def atomic(p,d):
 guard();t=p.with_name('.'+p.name+'.tmp');t.write_text(json.dumps(d,ensure_ascii=False,indent=2)+'\n');os.replace(t,p)
def main():
 guard();man=json.loads((ROOT/'script/tts-manifest.json').read_text());promo=json.loads((ROOT/'script/promotion-report.json').read_text());out=ROOT/'audio/story-full.wav';narr=ROOT/'story/spoken-narration.txt'
 if man.get('status')!='completed' or man.get('verified') is not True:raise RuntimeError('TTS manifest not completed')
 canon=promo['canon_sha256'];parts=[];dur_total=0.0;correction=man.get('technical_correction') or {}
 for seg in man['segments']:
  tp=Path(seg['text_path']);ap=Path(seg['audio_path']);
  if sha(tp)!=seg['text_sha256'] or sha(ap)!=seg['audio_sha256']:raise RuntimeError('segment hash drift')
  parts.append(tp.read_bytes());dur_total+=float(seg['duration_seconds'])
 reconstruction=b''.join(parts)==narr.read_bytes()
 concat=ROOT/'work/tts'/RUN/'verify-pre-correction.ffconcat';pre=ROOT/'work/tts'/RUN/'.verify-pre-correction.tmp.wav'
 lines=['ffconcat version 1.0']+["file '"+str(Path(seg['audio_path'])).replace("'","'\\''")+"'" for seg in man['segments']]
 concat.write_text('\n'.join(lines)+'\n',encoding='utf-8');pre.unlink(missing_ok=True)
 subprocess.check_call(['ffmpeg','-hide_banner','-loglevel','error','-y','-f','concat','-safe','0','-i',str(concat),'-c','copy',str(pre)])
 pre_hash=sha(pre);pre.unlink();probe=json.loads(subprocess.check_output(['ffprobe','-v','error','-show_entries','format=duration,size:stream=codec_name,sample_rate,channels,bits_per_sample','-of','json',str(out)]));s=probe['streams'][0];duration=float(probe['format']['duration'])
 decode=subprocess.run(['ffmpeg','-v','error','-i',str(out),'-f','null','-'],stdout=subprocess.PIPE,stderr=subprocess.PIPE)
 silence=subprocess.run(['ffmpeg','-hide_banner','-nostats','-i',str(out),'-af','silencedetect=noise=-50dB:d=2','-f','null','-'],stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
 silence_hits=len(re.findall(r'silence_start:',silence.stderr));clipped_samples=0;sample_count=0
 with wave.open(str(out),'rb') as wav:
  if wav.getsampwidth()!=2 or wav.getframerate()!=48000 or wav.getnchannels()!=1:raise RuntimeError('unexpected WAV format during clipping scan')
  while raw_samples:=wav.readframes(48000*10):
   samples=array('h');samples.frombytes(raw_samples)
   if sys.byteorder!='little':samples.byteswap()
   sample_count+=len(samples);clipped_samples+=sum(1 for value in samples if value in (-32768,32767))
 checks={'manifest_completed_verified':True,'canon_binding':man.get('source_canon_sha256')==canon and man.get('spoken_narration_sha256')==sha(narr),'output_hash':man.get('output_sha256')==sha(out),'chunk_reconstruction_byte_identical':reconstruction,'source_segment_concat_hash':pre_hash==correction.get('pre_correction_sha256'),'uniform_gain_correction_recorded':correction.get('type')=='uniform_gain' and correction.get('filter')=='volume=-1dB' and correction.get('post_correction_clipped_samples')==0,'codec_pcm_s16le':s.get('codec_name')=='pcm_s16le','sample_rate_48000':s.get('sample_rate')=='48000','mono':s.get('channels')==1,'duration_40_to_60_minutes':2400<=duration<=3600,'segment_duration_sum_matches':abs(duration-dur_total)<=0.1,'full_decode':decode.returncode==0,'no_silence_2s_or_longer':silence_hits==0,'no_clipping':clipped_samples==0}
 if not all(checks.values()):raise RuntimeError('technical audio QA failed: '+','.join(k for k,v in checks.items() if not v))
 d={'schema_version':1,'project_id':PID,'run_id':RUN,'status':'completed','verified':True,'source_canon_sha256':canon,'spoken_narration_sha256':sha(narr),'output':str(out),'artifact_path':str(out),'artifact_sha256':sha(out),'artifact_bytes':out.stat().st_size,'duration_seconds':duration,'segment_count':len(man['segments']),'segment_duration_total':dur_total,'format':{'codec':'pcm_s16le','sample_rate':48000,'channels':1},'technical_correction':correction,'technical_audio_qa':{'status':'passed','verified':True,'checks':checks,'pre_correction_reconstructed_sha256':pre_hash,'silence_hits_ge_2s':silence_hits,'sample_count':sample_count,'clipped_samples':clipped_samples,'method':'text reconstruction + source-segment concat hash + uniform-gain lineage + ffprobe + full decode + silence scan + direct PCM16 clipping count; no listening, ASR, evaluator, Voice Test or Listening Test'},'completed_at':now()};atomic(ROOT/'log/tts.json',d);print(json.dumps({'status':'completed','verified':True,'duration_seconds':duration,'sha256':d['artifact_sha256'],'checks':len(checks)}))
if __name__=='__main__':main()
