"""Original Relic Hollow score: four complete layered instrumental recordings.
No sampled instruments or third-party music are used. Requires numpy, scipy, ffmpeg.
The composition and synthesis sources are included for future revisions.
"""
from pathlib import Path
import numpy as np
from scipy.io import wavfile
from scipy.signal import butter, sosfilt
import subprocess, json
SR=32000
OUT=Path(__file__).resolve().parents[1]/'audio'
OUT.mkdir(exist_ok=True)
def freq(m): return 440*2**((m-69)/12)
def render(name,bpm,bars,root,kind,seed):
 rng=np.random.default_rng(seed); beat=60/bpm; duration=bars*4*beat; N=int(duration*SR)
 mix=np.zeros((N,2),dtype=np.float32)
 def add(note,start,dur,voice='pad',amp=.12,pan=0):
  dur=max(.09,dur); n=int(dur*SR); t=np.arange(n,dtype=np.float32)/SR; f=freq(note)
  if voice=='pad':
   env=np.minimum(t/1.7,1)*np.minimum((dur-t)/2,1)
   y=np.zeros(n,dtype=np.float32)
   for det in [-.003,0,.0038]:
    ph=2*np.pi*f*(1+det)*t + .015*np.sin(t*2*np.pi*.23)
    for h in range(1,7):y+=np.sin(ph*h+rng.uniform(0,6.28))*(1/h**1.7)
   y/=3; env=np.clip(env,0,1)**.75
  elif voice=='choir':
   env=np.clip(np.minimum(t/2,1)*np.minimum((dur-t)/2,1),0,1)
   phase=2*np.pi*f*t+.023*np.sin(t*27)
   y=(np.sin(phase)+.32*np.sin(phase*2)+.5*np.sin(phase*3)+.13*np.sin(phase*5))*.6
  elif voice=='flute':
   env=np.minimum(t/.16,1)*np.minimum((dur-t)/.45,1);env=np.clip(env,0,1)
   phase=2*np.pi*f*t+.06*np.sin(t*2*np.pi*4.7)*np.minimum(t/1,1)
   y=np.sin(phase)+.11*np.sin(phase*2)+.04*np.sin(phase*3)
  elif voice=='pluck':
   env=(1-np.exp(-t*95))*np.exp(-t*2.5);y=(np.sin(2*np.pi*f*t)+.34*np.sin(4*np.pi*f*t)+.12*np.sin(6*np.pi*f*t))
  elif voice=='bass':
   env=np.minimum(t/.3,1)*np.minimum((dur-t)/.35,1);env=np.clip(env,0,1)
   y=np.sin(2*np.pi*f*t)+.24*np.sin(4*np.pi*f*t)
  elif voice=='horn':
   env=np.minimum(t/.12,1)*np.minimum((dur-t)/.25,1);env=np.clip(env,0,1)
   phase=2*np.pi*f*t+.014*np.sin(22*t);y=sum(np.sin(phase*h)*(1/h**1.35) for h in range(1,9))*.65
  else:
   env=np.minimum(t/.015,1)*np.exp(-t*5);y=np.sin(2*np.pi*f*t)+.28*np.sin(2*np.pi*f*2*t)
  y=(y*env*amp).astype(np.float32);left=np.sqrt((1-pan)/2);right=np.sqrt((1+pan)/2)
  pos=int(start*SR)%N;v=np.column_stack((y*left,y*right))
  end=min(N-pos,n);mix[pos:pos+end]+=v[:end]
  if end<n:mix[:n-end]+=v[end:]
 def drum(start,strong=False):
  dur=.8 if strong else .32;n=int(SR*dur);t=np.arange(n)/SR
  phase=2*np.pi*(52*t+42*(1-np.exp(-t*15))/15)
  y=np.sin(phase)*np.exp(-t*(4 if strong else 12))*.18
  noise=rng.normal(0,1,n);noise=sosfilt(butter(2,1800,fs=SR,output='sos'),noise)
  y+=noise*np.exp(-t*26)*.10
  idx=int(start*SR)%N;end=min(n,N-idx);mix[idx:idx+end]+=y[:end,None]
 # A-B-A-C form; voice-leading keeps the ambient bed continuous.
 prog=[(0,[0,3,7]),(-4,[0,4,7]),(3,[0,4,7]),(-2,[0,4,7]),(5,[0,3,7]),(0,[0,3,7]),(-4,[0,4,7]),(7,[0,4,7])]
 melodies=[[7,10,12,10,7,5,3,5],[12,15,14,10,7,10,5,7],[7,12,10,7,5,3,2,0],[15,14,12,10,7,5,7,12]]
 for bar in range(bars):
  start=bar*4*beat;offset,intervals=prog[(bar//2)%8];chord=root+offset;section=(bar//8)%4
  if bar%2==0:
   for j,iv in enumerate(intervals):
    add(chord+iv+12,start,8*beat+3.6,'pad',.095 if kind!='boss' else .062,[-.65,.2,.65][j])
   add(chord,start,8*beat+.6,'bass',.11,0)
   if kind in ['cave','ember'] or section==3:add(chord+19,start,8*beat+3,'choir',.035,-.15)
  if kind!='ember':
   phrase=melodies[section];note=root+24+phrase[(bar%8)]
   if bar%2==0 and (bar>1):add(note,start+beat,2.9*beat,'flute',.062 if kind!='boss' else .035,.13)
   if bar%4==2:add(root+24+phrase[(bar+1)%8],start+2.5*beat,1.8*beat,'flute',.045,.18)
  if kind=='forest':
   for j in [0,1.5,2.5]:add(chord+12+intervals[int(j)%3],start+j*beat,1.5*beat,'pluck',.035,-.45)
  elif kind=='cave':
   if bar%2==1:
    for j in [0,2]:add(chord+24+intervals[int(j)%3],start+j*beat,3,'pluck',.032,.5)
  elif kind=='ember':
   for j in [0,2.5]:drum(start+j*beat,False)
   if bar%4==0:add(chord+12,start+beat,7*beat,'horn',.035,.1)
  else:
   for j in range(8):add(chord+12+intervals[j%3],start+j*beat*.5,beat*.72,'short',.052,(-.4 if j%2 else .4))
   for j in range(4):drum(start+j*beat,j in [0,2])
   if bar%2==0:
    for j,iv in enumerate(intervals):add(chord+12+iv,start+2*beat,2.5*beat,'horn',.04,[-.35,0,.35][j])
 # Wide, slowly evolving air layer; no short repeating piano phrase.
 air=rng.normal(0,1,(N,2)).astype(np.float32)
 air=sosfilt(butter(2,600 if kind=='ember' else 1700,fs=SR,output='sos'),air,axis=0)
 slow=.65+.35*np.sin(np.arange(N)/SR*.091)
 mix+=air.astype(np.float32)*slow[:,None]*.009
 # Periodic stereo reverb: the tail wraps cleanly into the next loop.
 dry=mix.copy()
 for delay,gain in [(.071,.14),(.113,.12),(.191,.16),(.311,.13),(.487,.11),(.719,.08),(1.09,.055),(1.61,.035),(2.13,.025)]:
  mix+=np.roll(dry[:,::-1] if int(delay*100)%2 else dry,int(delay*SR),axis=0)*gain
 mix=np.tanh(mix*.9).astype(np.float32)
 mix*=.90/max(.9,float(np.max(np.abs(mix))))
 wav=OUT/(name+'.wav');wavfile.write(wav,SR,(mix*32767).astype(np.int16))
 mp3=OUT/(name+'.mp3')
 subprocess.run(['ffmpeg','-y','-v','error','-i',str(wav),'-af','loudnorm=I=-21:TP=-2:LRA=11','-ar',str(SR),'-ac','2','-c:a','libmp3lame','-b:a','96k',str(mp3)],check=True)
 wav.unlink();print(name,round(duration,2),mp3.stat().st_size,flush=True)
 return {'id':name,'title':{'forest':'Whispers Beneath the Leaves','cave':'Echoes of the First Light','ember':'Ashes Remember','battle':'Keepers of the Hollow'}[name],'duration':round(duration,3),'file':'audio/'+name+'.mp3','creation':'Original algorithmic composition and synthesis; full-length stereo recording. No sampled instruments or third-party music.'}
if __name__=='__main__':
 tracks=[]
 for args in [('forest',76,32,50,'forest',714),('cave',68,32,55,'cave',813),('ember',72,32,45,'ember',991),('battle',112,48,50,'boss',663)]:tracks.append(render(*args))
 (OUT/'tracks.json').write_text(json.dumps(tracks,indent=2))
