"""Original ensemble music. Audio and composition source GPL-2.0-only.
Requires numpy, scipy, mido, ffmpeg, libfluidsynth and a locally installed
TimGM6mb SoundFont. The SoundFont is not included in this package.
"""
from pathlib import Path
import ctypes as C, ctypes.util, os, json, subprocess
import numpy as np
import mido
from scipy.io.wavfile import write as wavwrite
ROOT=Path(__file__).resolve().parents[1];OUT=ROOT/'audio';OUT.mkdir(exist_ok=True);SR=32000
lib=C.CDLL(ctypes.util.find_library('fluidsynth'))
def bind(name,result,args):
 f=getattr(lib,name);f.restype=result;f.argtypes=args;return f
P=C.c_void_p;I=C.c_int;D=C.c_double;S=C.c_char_p
ns=bind('new_fluid_settings',P,[]);sn=bind('fluid_settings_setnum',I,[P,S,D]);si=bind('fluid_settings_setint',I,[P,S,I]);ss=bind('new_fluid_synth',P,[P]);load=bind('fluid_synth_sfload',I,[P,S,I]);program=bind('fluid_synth_program_change',I,[P,I,I]);cc=bind('fluid_synth_cc',I,[P,I,I,I]);on=bind('fluid_synth_noteon',I,[P,I,I,I]);off=bind('fluid_synth_noteoff',I,[P,I,I]);render=bind('fluid_synth_write_float',I,[P,I,P,I,I,P,I,I]);delete=bind('delete_fluid_synth',None,[P]);ds=bind('delete_fluid_settings',None,[P])
sf=os.environ.get('MOON_SOUNDFONT','/usr/share/sounds/sf2/TimGM6mb.sf2')
def compose(key,title,bpm,bars,root,kind,seed):
 rng=np.random.default_rng(seed);beat=60/bpm;duration=bars*4*beat;events=[]
 def note(ch,pitch,start,length,vel):
  start=max(0,start+float(rng.uniform(-.012,.012)));vel=int(np.clip(vel+rng.integers(-4,5),1,100));pitch=int(pitch)
  events.extend([(start,1,ch,pitch,vel),(min(duration-.02,start+length),0,ch,pitch,0)])
 major=kind=='surface';prog=[(0,[0,4 if major else 3,7]),(-5,[0,4,7]),(-3,[0,3 if major else 4,7]),(-2,[0,4,7])]
 themes=[[7,9,12,11,9,7,4,2],[4,7,9,7,4,2,0,2],[12,11,9,7,9,12,14,11],[9,7,4,2,4,7,2,0]] if major else [[7,10,12,14,12,10,7,5],[3,7,10,7,5,3,2,0],[12,14,15,14,12,10,7,10],[7,5,3,2,0,2,3,7]]
 for bar in range(bars):
  start=bar*4*beat;offset,chord=prog[(bar//2)%4];r=root+offset;section=(bar//8)%4
  if bar%2==0:
   for iv in chord:note(1,r+12+iv,start,8*beat-.1,44+(5 if section==2 else 0))
   if kind in ['hollow','vault','battle']:
    for iv in chord:note(5,r+12+iv,start+.1,8*beat-.2,24 if kind!='battle' else 32)
  note(4,r,start,2.9*beat,49);note(4,r+7,start+3*beat,.8*beat,38)
  if kind in ['surface','crystal']:
   for n,which in enumerate([0,2,1,2,0,2,1,2]):note(0 if kind=='surface' else 8,r+12+chord[which],start+n*.5*beat,.9*beat,41 if kind=='surface' else 30)
  elif kind=='hollow':
   for n in [0,3]:note(0,r+12+chord[n%3],start+n*beat,2.5*beat,27)
  else:
   for n in range(8 if kind=='battle' else 4):note(3,r+12+chord[n%3],start+n*(.5 if kind=='battle' else 1)*beat,.4*beat,53 if kind=='battle' else 34)
  if bar>=2:
   for n in range(2):
    if n==1 and bar%4==3:continue
    note(2 if section in [0,2] else 6,root+24+themes[section][((bar%4)*2+n)%8],start+(n*2+.12)*beat,1.7*beat,52 if major else 45)
   if section==2 and bar%2==0:note(3,root+12+themes[section][(bar%4)*2],start+.18,3.5*beat,34)
  if kind=='battle':
   for n in [0,2]:note(9,36,start+n*beat,.3,68)
   for n in [1,3]:note(9,38,start+n*beat,.2,44)
   for n in range(8):note(9,42,start+n*.5*beat,.1,25)
  elif kind in ['surface','vault'] and bar>3:
   for n in [0,2.5]:note(9,64,start+n*beat,.22,30 if n==0 else 22)
  elif kind=='hollow' and bar%4==0:note(9,41,start,.6,28)
 events.sort();settings=ns();sn(settings,b'synth.sample-rate',SR);sn(settings,b'synth.gain',.55);sn(settings,b'synth.reverb.room-size',.83);sn(settings,b'synth.reverb.damp',.42);sn(settings,b'synth.reverb.width',90);sn(settings,b'synth.reverb.level',.32);si(settings,b'synth.polyphony',192);si(settings,b'synth.cpu-cores',1)
 syn=ss(settings);load(syn,sf.encode(),1);instruments={0:24,1:48,2:73,3:42,4:32,5:52,6:68,8:46};pans={0:32,1:66,2:80,3:46,4:64,5:87,6:53,8:98}
 for ch,p in instruments.items():program(syn,ch,p);cc(syn,ch,10,pans[ch]);cc(syn,ch,91,65)
 n=int((duration+3)*SR);left=np.zeros(n,np.float32);right=np.zeros(n,np.float32);pos=0
 def until(end):
  nonlocal pos
  end=min(n,max(pos,end));num=end-pos
  if num:render(syn,num,left.ctypes.data+pos*4,0,1,right.ctypes.data+pos*4,0,1);pos=end
 for t,typ,ch,pitch,vel in events:
  until(int(t*SR));on(syn,ch,pitch,vel) if typ else off(syn,ch,pitch)
 until(n);delete(syn);ds(settings);audio=np.column_stack([left,right]);audio*=.9/max(.0001,float(np.max(np.abs(audio))))
 fade=int(SR*1.5);audio[:fade]*=np.linspace(0,1,fade)[:,None];audio[-SR*3:]*=np.linspace(1,0,SR*3)[:,None]
 wav=OUT/(key+'.wav');wavwrite(wav,SR,(audio*32767).astype(np.int16))
 subprocess.run(['ffmpeg','-y','-v','error','-i',str(wav),'-af','loudnorm=I=-20:TP=-2:LRA=9','-ar','32000','-ac','2','-c:a','libmp3lame','-b:a','112k',str(OUT/(key+'.mp3'))],check=True);wav.unlink()
 midi=mido.MidiFile();tr=mido.MidiTrack();midi.tracks.append(tr);tr.append(mido.MetaMessage('set_tempo',tempo=mido.bpm2tempo(bpm),time=0))
 for ch,p in instruments.items():tr.append(mido.Message('program_change',channel=ch,program=p,time=0))
 last=0
 for t,typ,ch,pitch,vel in events:
  ticks=round(t/beat*480);tr.append(mido.Message('note_on' if typ else 'note_off',channel=ch,note=pitch,velocity=vel,time=max(0,ticks-last)));last=ticks
 midi.save(OUT/(key+'.mid'))
 result={'id':key,'title':title,'seconds':round(duration+3,2),'file':'audio/'+key+'.mp3','composition':'Original ensemble arrangement for CrownByte Games','rendering':'FluidSynth / TimGM6mb; sampled-instrument rendering, not live musicians','license':'GPL-2.0-only; see licenses/MUSIC-LICENSE.md'};print(result,flush=True);return result
if __name__=='__main__':
 results=[]
 for args in [('surface','The Little Observatory',78,40,50,'surface',241),('hollow','Below the Lanternroots',65,32,52,'hollow',452),('crystal','Stars Beneath Stone',72,36,48,'crystal',845),('vault','The Astronomer’s Promise',75,40,50,'vault',881),('battle','Keepers of the Last Light',114,48,50,'battle',945)]:results.append(compose(*args))
 (OUT/'tracks.json').write_text(json.dumps(results,indent=2))
