"""Compose and render review candidates; prepare licensed foley in reproducible steps.

Requires numpy, scipy, FluidSynth, a SoundFont, FFmpeg and the downloaded sources.
No hosted generation, API keys, or music copied from existing scores.
"""
from pathlib import Path
import argparse
import hashlib
import json
import math
import shutil
import struct
import subprocess
from zipfile import ZipFile

import numpy as np
from scipy.signal import butter, sosfilt, resample_poly

ROOT = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser()
parser.add_argument('--soundfont', type=Path, default=Path('/usr/share/sounds/sf2/FluidR3_GM.sf2'))
parser.add_argument('--ffmpeg', default=shutil.which('ffmpeg'))
args = parser.parse_args()
if not args.ffmpeg:
    raise SystemExit('Pass --ffmpeg /absolute/path/to/ffmpeg')
RATE, PPQ = 48000, 480
CACHE = ROOT/'exports/audio-cache'
AUDIO = ROOT/'audio'
for folder in [CACHE, AUDIO/'source/midi', AUDIO/'source/kenney', AUDIO/'music', AUDIO/'sfx', AUDIO/'ambience']:
    folder.mkdir(parents=True, exist_ok=True)
is_fluid = args.soundfont.resolve() == Path('/usr/share/sounds/sf2/FluidR3_GM.sf2').resolve()
manifest = {'status':'Unapproved audio direction candidates, v1', 'sample_rate':RATE,
            'composition':'Original note sequences authored in tools/prepare_audio.py; editable MIDI masters included.',
            'renderer':'FluidSynth / '+('FluidR3 GM (Frank Wen and contributors, MIT)' if is_fluid else args.soundfont.name+' (check supplied instrument licence)'),
            'music':[], 'effects':[], 'sources':[], 'preferred_music':'chamber',
            'music_direction_status':'User selected B / Chamber / On the Battlement; recording remains a draft.'}

def command(argv):
    subprocess.run(list(map(str, argv)), check=True, stdout=subprocess.DEVNULL)

def read_audio(path):
    result = subprocess.run([args.ffmpeg,'-v','error','-i',str(path),'-f','f32le','-ar',str(RATE),'-ac','2','pipe:1'], check=True, capture_output=True)
    return np.frombuffer(result.stdout,dtype='<f4').reshape(-1,2).copy()

def encode(path, data):
    path.parent.mkdir(parents=True,exist_ok=True)
    data = np.asarray(data,dtype='<f4')
    if data.ndim == 1:
        data = np.column_stack([data,data])
    peak = float(np.max(np.abs(data)))
    if peak > .94:
        raise ValueError(f'Unexpected clipping risk in {path.name}: {peak}')
    codec = ['-c:a','libvorbis','-q:a','6'] if path.suffix == '.ogg' else ['-c:a','pcm_s16le']
    subprocess.run([args.ffmpeg,'-v','error','-y','-f','f32le','-ar',str(RATE),'-ac','2','-i','pipe:0',*codec,str(path)], input=data.tobytes(), check=True)
    return {'path':str(path.relative_to(ROOT)), 'seconds':round(len(data)/RATE,4),
            'peak_dbfs':round(20*math.log10(max(peak,1e-9)),2),
            'rms_dbfs':round(20*math.log10(max(float(np.sqrt(np.mean(data**2))),1e-9)),2)}

def normalise(data, rms_db=-25, peak_db=-5):
    rms = max(float(np.sqrt(np.mean(data**2))),1e-9)
    peak = max(float(np.max(np.abs(data))),1e-9)
    return data * min(10**(rms_db/20)/rms,10**(peak_db/20)/peak)

def fades(data, start=.004, end=.025):
    data=data.copy()
    a,b=min(len(data),round(start*RATE)),min(len(data),round(end*RATE))
    data[:a] *= np.linspace(0,1,a).reshape((-1,)+(1,)*(data.ndim-1))
    data[-b:] *= np.linspace(1,0,b).reshape((-1,)+(1,)*(data.ndim-1))
    return data

def vlq(value):
    parts=[value&127]
    while value>>7:
        value>>=7
        parts.insert(0,(value&127)|128)
    return bytes(parts)

def midi_file(path, tracks, bpm, title):
    chunks=[]
    tempo=round(60_000_000/bpm)
    meta=b'\0\xff\x51\x03'+tempo.to_bytes(3,'big')+b'\0\xff\x58\x04\x04\x02\x18\x08'
    name=title.encode('ascii')
    meta+=b'\0\xff\x03'+vlq(len(name))+name+b'\0\xff\x2f\0'
    chunks.append(b'MTrk'+struct.pack('>I',len(meta))+meta)
    for channel, track in enumerate(tracks):
        # Same exact score runs three times. Extract middle period after rendering
        # so reverb/release tails from the previous period survive the loop seam.
        events=[]
        for cycle in range(3):
            for beat,pitch,duration,velocity in track['notes']:
                tick=round((beat+cycle*32)*PPQ)
                events.append((tick,1,bytes([0x90|channel,pitch,velocity])))
                events.append((tick+round(duration*PPQ),0,bytes([0x80|channel,pitch,0])))
        output=bytearray(b'\0'+bytes([0xc0|channel,track['program']]))
        for controller,value in [(7,100),(10,track.get('pan',64)),(91,38),(93,0)]:
            output.extend(b'\0'+bytes([0xb0|channel,controller,value]))
        previous=0
        for tick,_,message in sorted(events):
            output.extend(vlq(tick-previous)+message)
            previous=tick
        output.extend(vlq(max(0,96*PPQ-previous))+b'\xff\x2f\0')
        chunks.append(b'MTrk'+struct.pack('>I',len(output))+output)
    path.write_bytes(b'MThd'+struct.pack('>IHHH',6,1,len(chunks),PPQ)+b''.join(chunks))

# D minor: Dm(add9), Bbmaj7, Fmaj7, C(add9), Gm9, Dm/A, Bbmaj7, Asus4/A7.
# The short rising D-E-F-A cell recurs in all three arrangements.
CHORDS=[[50,57,62,65,69],[46,53,57,62,65],[48,53,57,60,64],[48,55,60,62,67],
        [43,50,57,58,62],[45,52,57,62,65],[46,53,57,62,65],[45,52,57,62,64]]
MELODY=[[(.5,74,.5),(1,76,.5),(1.5,77,1),(3,81,.75)],
        [(0,79,1.5),(2,77,.75),(3,74,.75)],
        [(.5,72,.5),(1,76,1),(2.5,77,.5),(3,79,.75)],
        [(0,76,1.5),(2,74,.75),(3,72,.75)],
        [(.5,70,.5),(1,74,.75),(2,77,.75),(3,76,.75)],
        [(0,74,1.5),(2,69,.75),(3,72,.75)],
        [(.5,74,.5),(1,76,.5),(1.5,77,1),(3,74,.75)],
        [(0,73,1),(1.5,71,.5),(2.5,69,.9)]]

def track(program, pan=64):
    return {'program':program,'pan':pan,'notes':[]}

def note(t, beat, pitch, duration, velocity):
    t['notes'].append([round(beat,4),pitch,round(duration,4),velocity])

def score(style):
    if style=='jrpg':
        lead,harp,strings,bass=track(73,72),track(46,42),track(48,65),track(42,58)
        for bar,chord in enumerate(CHORDS):
            at=bar*4
            for offset,pitch,duration in MELODY[bar]:
                note(lead,at+offset,pitch,duration,65 if bar<6 else 60)
            for i,idx in enumerate([1,2,3,4,2,3,4,2]):
                note(harp,at+i*.5,chord[idx]+12,.42,43+[4,0,-3,2][i%4])
            for pitch in chord[1:4]:
                note(strings,at+.03,pitch,3.85,38)
            note(bass,at,chord[0]-12,1.7,53)
            note(bass,at+2,chord[0]-12,1.75,43)
        return [lead,harp,strings,bass]
    if style=='chamber':
        lead,cello,viola,harp=track(69,72),track(42,48),track(41,68),track(46,38)
        for bar,chord in enumerate(CHORDS):
            at=bar*4
            # Longer breaths and fewer accompaniment attacks than the JRPG version.
            for offset,pitch,duration in MELODY[bar]:
                note(lead,at+offset,pitch-12,min(duration*1.08,3.9-offset),56)
            note(cello,at,chord[0],3.7,47)
            for pitch in chord[1:3]:
                note(viola,at+.10,pitch,3.55,36)
            for offset,idx in [(0,2),(1.5,3),(3,4)]:
                note(harp,at+offset,chord[idx]+12,.85,39)
        return [lead,cello,viola,harp]
    strings,bass,colour,lead=track(48,52),track(43,60),track(11,83),track(69,72)
    for bar,chord in enumerate(CHORDS):
        at=bar*4
        note(bass,at,chord[0]-12,3.9,39)
        for pitch in [chord[1],chord[2],chord[4]]:
            note(strings,at+.05,pitch,3.9,30)
        if bar in [0,2,4,6]:
            note(colour,at+1,chord[4]+12,1.6,35)
        if bar in [0,4,6]:
            for offset,pitch,duration in MELODY[bar][:3]:
                note(lead,at+offset+.5,pitch-12,duration,43)
        if bar==7:
            note(lead,at+1.5,61,1.5,38)
    return [strings,bass,colour,lead]

for ident,title,bpm,description in [
    ('jrpg','A - Smoke over Malaz',72,'Melodic JRPG: flute theme, harp movement, warm strings and cello.'),
    ('chamber','B - On the Battlement',64,'Restrained chamber: English horn, viola, cello and sparse harp.'),
    ('ambient','C - An Uneasy Empire',64,'Dark ambient: low strings, contrabass, isolated vibraphone and fragments of the theme.')]:
    tracks=score(ident)
    midi=AUDIO/f'source/midi/{ident}_v1.mid'
    midi_file(midi,tracks,bpm,title)
    (midi.with_suffix('.score.json')).write_text(json.dumps({'title':title,'bpm':bpm,'beats':32,'tracks':tracks},indent=2)+'\n')
    raw=CACHE/f'{ident}_render.wav'
    command(['fluidsynth','-ni','-q','-r',RATE,'-g','.6','-R','1','-C','0','-F',raw,args.soundfont,midi])
    period=round(32*60/bpm*RATE)
    data=normalise(read_audio(raw)[period:period*2],-25,-5)
    info=encode(AUDIO/f'music/{ident}_v1.ogg',data)
    info.update(id=ident,title=title,description=description,bpm=bpm,bars=8,loop=True,
                source=str(midi.relative_to(ROOT)),score=str(midi.with_suffix('.score.json').relative_to(ROOT)))
    manifest['music'].append(info)
    print('Rendered',title,flush=True)

if (CACHE/'kenney-rpg.zip').exists():
    with ZipFile(CACHE/'kenney-rpg.zip') as archive:
        names=[f'footstep{i:02}.ogg' for i in range(4)]+['cloth1.ogg','cloth2.ogg','beltHandle1.ogg','metalClick.ogg','creak1.ogg']
        for name in names:
            (AUDIO/'source/kenney'/name).write_bytes(archive.read('OGG/'+name))
        (AUDIO/'source/kenney/LICENSE.txt').write_bytes(archive.read('license.txt'))

def filtered(data, low=100, high=6000):
    return sosfilt(butter(2,[low,high],btype='bandpass',fs=RATE,output='sos'),data,axis=0)

def add_layer(data, layer, gain, offset=.0):
    start=round(offset*RATE)
    end=min(len(data),start+len(layer))
    if end>start:
        data[start:end] += layer[:end-start]*gain

cloth=filtered(read_audio(AUDIO/'source/kenney/cloth1.ogg'),180,5500)
metal=filtered(read_audio(AUDIO/'source/kenney/metalClick.ogg'),500,6000)
for i in range(4):
    base=filtered(read_audio(AUDIO/f'source/kenney/footstep{i:02}.ogg'),85,6500)
    base=fades(base,.003,.035)
    manifest['effects'].append(encode(AUDIO/f'sfx/step_stone_{i+1}_v1.wav',normalise(base,-24,-10)))
    padded=np.zeros((max(len(base),round(.46*RATE)),2))
    add_layer(padded,base,1)
    add_layer(padded,cloth,.10,.018)
    # Light equipment, never a loud metal clang on each foot contact.
    add_layer(padded,metal,.045,.04)
    manifest['effects'].append(encode(AUDIO/f'sfx/step_armour_{i+1}_v1.wav',normalise(fades(padded),-24,-10)))
manifest['effects'].append(encode(AUDIO/'sfx/cloth_shift_v1.wav',normalise(fades(cloth),-28,-16)))
manifest['effects'].append(encode(AUDIO/'sfx/armour_shift_v1.wav',normalise(fades(metal),-29,-17)))

# Original tiny UI tones: short, warm, unobtrusive, no full voice acting.
for name,frequencies,duration in [('ui_advance',[660,880],.085),('text_tick',[460],.018)]:
    t=np.arange(round(duration*RATE))/RATE
    signal=np.zeros_like(t)
    for i,f in enumerate(frequencies):
        start=i*.023
        age=t-start
        signal+=np.where(age>=0,np.sin(2*np.pi*f*age)*np.exp(-np.maximum(age,0)*65),0)
    signal=fades(signal,.003,.012)
    manifest['effects'].append(encode(AUDIO/f'sfx/{name}_v1.wav',normalise(signal,-34,-23)))

# Rusty iron friction study: non-musical sliding resonances and filtered noise.
# Separate from the wind loop so story cues can place squeals where the book does.
rng=np.random.default_rng(17026)
t=np.arange(round(1.4*RATE))/RATE
phase=2*np.pi*(330*t+34*np.sin(t*4)+10*t*t)
noise=sosfilt(butter(2,[500,3600],btype='bandpass',fs=RATE,output='sos'),rng.normal(0,.2,len(t)))
iron=(np.sin(phase)+.25*np.sin(phase*2.71)+noise)*np.sin(np.pi*t/1.4)**1.8
manifest['effects'].append(encode(AUDIO/'sfx/vane_creak_v1.wav',normalise(fades(iron,.07,.16),-31,-19)))

# Process the source's synthesised wind transparently; do not label it a recording.
wind=filtered(read_audio(AUDIO/'source/wind1.wav'),95,3800)
period=30*RATE
wind=wind[:period+2*RATE]
cross=2*RATE
# Fold a two-second continuation over the start. At loop wrap the continuation
# meets its source at t=30, then transitions back to the unmodified wind at t=2.
blend=np.linspace(0,1,cross)[:,None]
wind[:cross]=wind[period:period+cross]*(1-blend)+wind[:cross]*blend
wind=normalise(wind[:period],-32,-13)
info=encode(AUDIO/'ambience/battlement_wind_v1.ogg',wind)
info.update(loop=True,description='30-second filtered synthesized wind loop; no invented nearby battle or fire.',source='audio/source/wind1.wav')
manifest['ambience']=info

for file,author,url,licence in [
    (AUDIO/'source/wind1.wav','Luke.RUSTLTD','https://opengameart.org/content/wind1','CC0; source is synthesized using PureData'),
    (CACHE/'kenney-rpg.zip','Kenney','https://opengameart.org/content/50-rpg-sound-effects','CC0'),
    (args.soundfont,'Frank Wen and contributors' if is_fluid else 'Check supplied SoundFont credits',
     'https://packages.debian.org/fluid-soundfont-gm' if is_fluid else '',
     'MIT' if is_fluid else 'Not audited; supply the instrument licence before use')]:
    manifest['sources'].append({'file':str(file.relative_to(ROOT)) if file.is_relative_to(ROOT) else str(file),
                                'author':author,'page':url,'license':licence,'sha256':hashlib.sha256(file.read_bytes()).hexdigest() if file.exists() else '3ae398ad63e293f9c450bda22d5d81c3af69c74df66fc1400f33c012c0bbc231'})
(AUDIO/'manifest.json').write_text(json.dumps(manifest,indent=2)+'\n')
if is_fluid:
    shutil.copyfile('/usr/share/doc/fluid-soundfont-gm/copyright',AUDIO/'source/FluidR3-LICENSE.txt')
print('Prepared audio/manifest.json and all audio candidates.',flush=True)
