#!/usr/bin/env python3
"""Original synthesized BGM drafts for CUT Studio.
No third-party recordings, samples, or quoted melodies are used.
Dependencies: Python 3, NumPy, and FFmpeg with libvorbis/libmp3lame.
Outputs are original synthesized compositions; this script does not assert CC0.
"""
import argparse,hashlib,json,math,pathlib,re,subprocess,wave
import numpy as np
SR=44100
SPECS=[
 {'id':'quiet-morning','titleKo':'조용한 아침','bpm':72,'bars':16,'seed':72001,'kind':'warm','descriptionKo':'따뜻한 피아노·벨·패드. 조용한 일상과 카페 기록에 어울리는 초안.'},
 {'id':'study-flow','titleKo':'집중하는 시간','bpm':86,'bars':16,'seed':86001,'kind':'study','descriptionKo':'작은 음의 반복과 부드러운 저음. 공부·작업 영상에 어울리는 미니멀 초안.'},
 {'id':'light-trip','titleKo':'가벼운 여행','bpm':104,'bars':24,'seed':104001,'kind':'travel','descriptionKo':'가볍게 튕기는 어쿠스틱 느낌의 합성 플럭. 산책·여행에 어울리는 초안.'},
]
CHORDS={
 'warm':[[48,55,59,62,64],[45,52,55,59,60],[41,48,52,55,57],[43,50,57,60,62]],
 'study':[[45,52,55,59,60],[41,48,52,55,57],[48,55,59,62,64],[43,50,55,57,62]],
 'travel':[[50,57,62,64,69],[43,54,57,62,66],[47,54,57,62,66],[45,52,59,61,64]],
}
def oscillator(midi,duration,kind,rng):
 n=int(duration*SR);t=np.arange(n,dtype=np.float64)/SR;f=440*2**((midi-69)/12)
 if kind=='pad':
  sig=np.sin(2*np.pi*f*t)+.20*np.sin(2*np.pi*f*1.0012*t)+.06*np.sin(2*np.pi*f*2*t)
  attack=.6;release=.9;env=np.minimum(1,t/attack)*np.minimum(1,np.maximum(0,(duration-t)/release))
  sig*=env*.65
 elif kind=='bell':
  sig=np.sin(2*np.pi*f*t)*np.exp(-t/1.45)+.13*np.sin(2*np.pi*f*2*t)*np.exp(-t/.65)+.03*np.sin(2*np.pi*f*3*t)*np.exp(-t/.26)
  sig*=np.minimum(1,t/.018)*np.minimum(1,np.maximum(0,(duration-t)/.3))
 elif kind=='acoustic':
  sig=np.zeros(n)
  # Additive decaying partials simulate a softly plucked string, using no sampled instrument.
  for h in range(1,8):
   phase=rng.uniform(-.12,.12);sig+=(1/h**1.8)*np.sin(2*np.pi*f*h*t+phase)*np.exp(-t/(1.1/(h**.48)))
  sig*=np.minimum(1,t/.013)*np.minimum(1,np.maximum(0,(duration-t)/.22))
 elif kind=='bass':
  sig=np.sin(2*np.pi*f*t)*np.exp(-t/1.3)+.08*np.sin(2*np.pi*f*2*t)*np.exp(-t/.6)
  sig*=np.minimum(1,t/.04)*np.minimum(1,np.maximum(0,(duration-t)/.25))
 else:
  sig=np.sin(2*np.pi*f*t)*np.exp(-t/1.1)+.16*np.sin(2*np.pi*f*2*t)*np.exp(-t/.44)+.03*np.sin(2*np.pi*f*3*t)*np.exp(-t/.24)
  sig*=np.minimum(1,t/.024)*np.minimum(1,np.maximum(0,(duration-t)/.3))
 return sig

def compose(spec):
 rng=np.random.default_rng(spec['seed']);beat=60/spec['bpm'];beats=spec['bars']*4;duration=beats*beat;n=round(duration*SR)
 score=np.zeros((n,2),dtype=np.float64);kind=spec['kind'];chords=CHORDS[kind];events=[]
 def note(midi,start_beat,dur_beats,volume,pan,timbre):
  # Notes and reverberant tails wrap modulo N: the prior cycle is audible at the beginning.
  mono=oscillator(midi,dur_beats*beat,timbre,rng)*volume;idx=(round(start_beat*beat*SR)+np.arange(len(mono)))%n
  left=math.cos((pan+1)*math.pi/4);right=math.sin((pan+1)*math.pi/4)
  np.add.at(score[:,0],idx,mono*left);np.add.at(score[:,1],idx,mono*right)
  events.append({'midi':midi,'beat':round(start_beat,5),'durationBeats':dur_beats,'instrument':timbre})
 chord_span=8 if kind=='travel' else 16
 for block,start in enumerate(range(0,beats,chord_span)):
  c=chords[block%4]
  # Smooth sustained pad: long attack and release, quiet against the foreground instrument.
  for j,m in enumerate(c[1:4]):note(m,start-.15,chord_span+1.25,.016 if kind=='warm' else .009,(j-1)*.28,'pad')
  for b in range(start,start+chord_span,4):note(c[0]-12,b,3.6,.052 if kind=='warm' else .038,0,'bass')
  if kind=='warm':
   offsets=[0,2.0,4.0,5.5,8.0,10.0,12.0,14.0];seq=[1,3,2,4,1,2,4,3]
   for j,(off,pick) in enumerate(zip(offsets,seq)):
    note(c[pick]+12,start+off,3.8,.065 if j%2==0 else .045,(-1)**j*.22,'piano')
   note(c[4]+12,start+7,3,.017,.45,'bell')
  elif kind=='study':
   offsets=[0,1.5,3,4.5,6,8,9.5,11,12.5,14];seq=[1,2,3,2,4,1,3,2,4,2]
   for j,(off,pick) in enumerate(zip(offsets,seq)):
    note(c[pick]+12,start+off,2.3,.047 if j%3==0 else .031,(-1)**j*.16,'piano')
  else:
   offsets=[0,.75,1.5,2.5,3.25,4,4.75,5.5,6.5,7.25];seq=[1,2,4,3,2,1,3,4,2,3]
   for j,(off,pick) in enumerate(zip(offsets,seq)):
    note(c[pick]+12,start+off,2.6,.056 if j in [0,5] else .036,(-1)**j*.32,'acoustic')
   # Small original upper answer at the end of alternating 8-beat phrases.
   if block%2:note(c[3]+24,start+7,2.7,.012,.35,'bell')
 # Short, circular room echoes keep the loop boundary continuous.
 dry=score.copy()
 for delay,gain in [(.073,.12),(.127,.075),(.213,.04)]:
  score+=np.roll(dry,round(delay*SR),axis=0)[:,::-1]*gain
 score-=np.mean(score,axis=0)
 # Gentle spectral rolloff suppresses piercing upper harmonics without a non-periodic filter state.
 freq=np.fft.rfftfreq(n,1/SR);response=1/np.sqrt(1+(freq/4800)**6)
 score=np.fft.irfft(np.fft.rfft(score,axis=0)*response[:,None],n=n,axis=0)
 peak=float(np.max(np.abs(score)));score*=.5/max(.5,peak)
 return score,events,n/SR

def write_wav(path,a):
 with wave.open(str(path),'wb') as w:w.setnchannels(2);w.setsampwidth(2);w.setframerate(SR);w.writeframes((np.clip(a,-.999,.999)*32767).astype('<i2').tobytes())
def analyze(ff,path):
 r=subprocess.run([ff,'-hide_banner','-i',str(path),'-af','loudnorm=I=-20:TP=-3:LRA=9:print_format=json','-f','null','-'],capture_output=True,text=True,check=True)
 return json.JSONDecoder().raw_decode(r.stderr[r.stderr.rfind('{'):])[0]
def sha(p):return hashlib.sha256(p.read_bytes()).hexdigest()
def main():
 parser=argparse.ArgumentParser();parser.add_argument('--output',default=str(pathlib.Path(__file__).parents[1]));parser.add_argument('--ffmpeg',default='ffmpeg');args=parser.parse_args()
 root=pathlib.Path(args.output);audio=root/'audio';prov=root/'provenance';source=root/'source'
 for p in [audio,prov,source]:p.mkdir(exist_ok=True,parents=True)
 manifests=[]
 for spec in SPECS:
  print('Composing',spec['id'],flush=True);a,events,duration=compose(spec);raw=source/(spec['id']+'-unnormalized.wav');write_wav(raw,a)
  measured=analyze(args.ffmpeg,raw)
  gain=min(-20-float(measured['input_i']),-3.5-float(measured['input_tp']))
  a*=10**(gain/20);master=source/(spec['id']+'-master.wav');write_wav(master,a)
  metrics={'sampleRate':SR,'samples':len(a),'sourceIntegratedLufs':float(measured['input_i']),'staticGainDb':gain,'masterPeakDbfs':float(20*np.log10(np.max(np.abs(a)))),'boundaryStepMax':float(np.max(np.abs(a[0]-a[-1]))),'medianAdjacentStep':float(np.median(np.abs(np.diff(a,axis=0)))),'p99AdjacentStep':float(np.percentile(np.abs(np.diff(a,axis=0)),99))}
  files=[]
  for fmt,codecopts in [('ogg',['-c:a','libvorbis','-q:a','3']),('mp3',['-c:a','libmp3lame','-b:a','112k','-write_xing','1'])]:
   dest=audio/(spec['id']+'.'+fmt)
   subprocess.run([args.ffmpeg,'-y','-hide_banner','-loglevel','error','-i',str(master),*codecopts,str(dest)],check=True)
   assert dest.stat().st_size<1000000,(dest,dest.stat().st_size)
   qa=analyze(args.ffmpeg,dest);subprocess.run([args.ffmpeg,'-hide_banner','-loglevel','error','-i',str(dest),'-f','null','-'],check=True)
   files.append({'format':fmt,'file':dest.name,'path':str(dest),'bytes':dest.stat().st_size,'sha256':sha(dest),'integratedLufs':float(qa['input_i']),'truePeakDbtp':float(qa['input_tp']),'loudnessRangeLu':float(qa['input_lra'])})
  m={**spec,'beatsPerBar':4,'beats':spec['bars']*4,'durationSeconds':duration,'exactDurationExpression':f"{spec['bars']*4} * 60 / {spec['bpm']}",'loopDesign':'Circular note placement and room echoes; pad tails wrap across the boundary. No overall intro/outro fade. OGG preferred for continuous looping; decoder behavior may affect encoded seams.','provenance':'Original synthesized composition produced for CUT Studio with no third-party samples, recordings, or quoted melodies. Commercial use intended. No third-party attribution requirement was introduced by the inputs. No claim of CC0 or any particular copyright registration/status.','generationScript':'source/generate_bgm.py','generationDate':'2026-09-12','sourceMasterSha256':sha(master),'metrics':metrics,'files':files}
  manifests.append(m);(prov/(spec['id']+'-events.json')).write_text(json.dumps(events,ensure_ascii=False,indent=2));(root/'manifest.json').write_text(json.dumps(manifests,ensure_ascii=False,indent=2))
  print(spec['id'],'duration',duration,'files',[(f['format'],f['bytes'],f['integratedLufs'],f['truePeakDbtp']) for f in files],flush=True)
 if '__file__' in globals():(prov/'generation-script.sha256').write_text(sha(pathlib.Path(__file__))+'  source/generate_bgm.py\n')
if __name__=='__main__':main()
