#!/usr/bin/env python3
"""Compose la musique et les bruitages du template « lueurs », calés image par image sur ses plans.

Usage : python3 musique.py <vars.json | -> <sortie.wav>
  <vars.json> les MÊMES variables que pour le rendu (plans, duree) ; « - » = les plans par défaut du template

Pourquoi ici : le reel copié (instagram.com/reel/DeKOzQRouIf) a une bande son originale de son auteur, synthétisée
par code ; on n'a aucun droit sur elle. On fait donc comme lui, avec nos notes : une nappe d'accords qui change à
chaque plan, des clochettes, des pincements, des souffles et des impacts posés sur les gestes de l'image. Les
instants viennent de la même règle que index.html (poids d, durée répartie, facteur f borné 0,6-1,8) : changer
les plans ou la durée recale tout le son. Rien n'est tiré d'une banque : tout est calculé (numpy, scipy).
"""
import json, os, re, sys
import numpy as np
from scipy.io import wavfile
from scipy.signal import butter, sosfilt, fftconvolve

SR = 44100
ICI = os.path.dirname(os.path.abspath(__file__))


def plans_du_html():
    # les plans par défaut et les durées de référence sont lus dans index.html : une seule source de vérité
    h = open(os.path.join(ICI, 'index.html')).read()
    def objet(nom, fin):
        s = h.split(f'const {nom} = ', 1)[1].split(fin, 1)[0] + fin[0]
        s = re.sub(r'([{,]\s*)([A-Za-z_]\w*)\s*:', r'\1"\2":', s)
        s = re.sub(r'"(grand-mot)"', r'"\1"', s)
        s = re.sub(r',\s*([\]}])', r'\1', s)
        return json.loads(s)
    return objet('PLANS_DEFAUT', '];'), objet('REF', '};')


def hz(n):
    return 440.0 * 2 ** ((n - 69) / 12)


# fa majeur, accords enrichis (ni ceux ni la tonalité du reel, qui est en ré) ; basse + voix de la nappe
ACC = {
    'I': (41, [53, 57, 60, 64, 67]),       # Fa maj9
    'vi': (38, [53, 57, 60, 62, 64]),      # Ré m9
    'IV': (46, [50, 53, 57, 60, 65]),      # Si bémol maj7
    'V': (36, [55, 60, 62, 64, 67]),       # Do 6/9
    'iii': (45, [52, 55, 60, 64, 67]),     # La m7
}
SUITE = ['I', 'vi', 'IV', 'V', 'I', 'iii', 'IV', 'I']
GAMME = [53, 55, 57, 60, 62, 65, 67, 69, 72, 74, 77, 79, 81, 84]   # fa majeur pentatonique, de fa3 à do6


class Piste:
    def __init__(self, duree):
        self.n = int(duree * SR) + SR * 3
        self.sec = np.zeros((2, self.n))    # son sec
        self.rev = np.zeros((2, self.n))    # envoyé à la réverbération

    def poser(self, t, x, gain=1.0, pan=0.0, rev=0.4):
        i = int(t * SR)
        if i >= self.n or len(x) == 0:
            return
        x = x[: self.n - i] * gain
        g, d = np.cos((pan + 1) * np.pi / 4), np.sin((pan + 1) * np.pi / 4)
        for c, k in ((0, g), (1, d)):
            self.sec[c, i:i + len(x)] += x * k * (1 - rev * 0.5)
            self.rev[c, i:i + len(x)] += x * k * rev


def tt(d):
    return np.arange(int(d * SR)) / SR


def cloche(n, d=2.4, eclat=1.0):
    t = tt(d); f = hz(n); s = np.zeros_like(t)
    for r, a, k in ((1, 1, 1.0), (2.0, .45, 1.8), (2.76, .3, 2.6), (5.4, .18 * eclat, 4.5), (8.93, .1 * eclat, 7)):
        s += a * np.sin(2 * np.pi * f * r * t) * np.exp(-t * k * 1.6)
    return s * np.minimum(1, t / 0.003) * 0.35


def pince(n, d=0.9):
    t = tt(d); f = hz(n)
    s = sum(np.sin(2 * np.pi * f * h * t) / h ** 1.6 * np.exp(-t * (5 + h * 3)) for h in range(1, 7))
    return s * np.minimum(1, t / 0.002) * 0.4


def souffle(d, monte=True, haut=6000):
    # bruit filtré dont la brillance monte (ou descend) : passe-bas et passe-haut fondus le long du geste
    t = tt(d); rng = np.random.default_rng(int(d * 1000) + (1 if monte else 2))
    b = rng.standard_normal(len(t))
    bas = sosfilt(butter(2, 700, 'low', fs=SR, output='sos'), b)
    clair = sosfilt(butter(2, [1500, haut], 'band', fs=SR, output='sos'), b)
    p = t / d if monte else 1 - t / d
    env = np.sin(np.pi * np.clip(t / d, 0, 1)) ** 1.5
    return (bas * (1 - p) + clair * p * 1.3) * env * 0.12


def impact(d=1.2, f0=110, f1=42):
    t = tt(d); f = f1 + (f0 - f1) * np.exp(-t * 18)
    ph = 2 * np.pi * np.cumsum(f) / SR
    return np.sin(ph) * np.exp(-t * 4.5) * np.minimum(1, t / 0.004) * 0.6


def glisse(n0, n1, d):
    t = tt(d); f = hz(n0) * (hz(n1) / hz(n0)) ** (t / d) * (1 + 0.004 * np.sin(2 * np.pi * 5.5 * t))
    ph = 2 * np.pi * np.cumsum(f) / SR
    return (np.sin(ph) + 0.25 * np.sin(2 * ph)) * np.sin(np.pi * t / d) ** 0.7 * 0.18


def goutte(n, d=0.35):
    t = tt(d); f = hz(n) * (1 + 0.6 * np.exp(-t * 30))
    return np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-t * 14) * np.minimum(1, t / 0.002) * 0.4


def nappe(piste, plans, duree):
    # un accord par plan, fondu sur le suivant ; basse en sinus, voix en dents de scie douces passées au filtre
    lp = butter(2, 2200, 'low', fs=SR, output='sos')
    for i, (p, deb, dur) in enumerate(plans):
        basse, voix = ACC[SUITE[i % len(SUITE)]]
        if p['f'] == 'clair':
            voix = [v + 12 if v < 60 else v for v in voix]     # le plan clair monte d'un cran
        att, rel = 0.5, 1.4
        t = tt(dur + rel + 0.3)
        env = np.minimum(1, t / att) * np.clip((dur + rel - t) / rel, 0, 1) ** 1.3
        s = np.zeros_like(t)
        for k, v in enumerate(voix):
            for det in (-0.07, 0.0, 0.08):
                f = hz(v + det)
                for h in range(1, 7):
                    s += np.sin(2 * np.pi * f * h * t + k + h) / h ** 1.3 / 18
        s = sosfilt(lp, s) * (0.8 + 0.2 * np.sin(2 * np.pi * 0.25 * t))
        b = np.sin(2 * np.pi * hz(basse) * t) * 0.17 + np.sin(2 * np.pi * hz(basse + 12) * t) * 0.14
        piste.poser(max(0, deb - 0.3), (s * 1.5 + b) * env, gain=0.55, pan=0, rev=0.5)


def gestes(piste, plans, duree):
    for i, (p, deb, dur) in enumerate(plans):
        f = p['_f']; T = lambda x: deb + x * f; r = dur / f; f_ = p['f']
        ton = ACC[SUITE[i % len(SUITE)]][1]
        if f_ == 'eclat':
            piste.poser(T(0), glisse(60, 84, 0.8 * f), 0.7, 0, 0.5)
            piste.poser(T(0.75), impact(), 0.35, 0, 0.2)
            piste.poser(T(0.75), cloche(ton[-1] + 12, 3.0), 0.8, 0, 0.6)
            for k in range(4):
                piste.poser(T(0.8 + k * 0.32), cloche(ton[3 - k] + 12, 1.6, 0.4), 0.35, (-1) ** k * 0.4, 0.7)
        elif f_ == 'orbite':
            for k in range(8):
                piste.poser(T(0.05 + k * 0.06), pince(GAMME[4 + k]), 0.5, np.cos(k / 8 * 2 * np.pi) * 0.7, 0.5)
            piste.poser(T(2.0), souffle(0.6 * f, True, 9000), 0.8, 0, 0.6)
            for k, n in enumerate(ton):
                piste.poser(T(2.0 + k * 0.04), cloche(n + 12, 2.5, 0.7), 0.3, (k - 2) * 0.3, 0.7)
        elif f_ == 'carte':
            piste.poser(T(0), pince(GAMME[9], 0.3), 0.4, 0, 0.3)
            piste.poser(T(0.3), souffle(0.6 * f, True), 0.9, 0, 0.4)
            piste.poser(T(0.85), impact(0.6, 160, 70), 0.4, 0, 0.3)
            piste.poser(T(0.8), pince(ton[2] + 12), 0.4, -0.3, 0.5)
            piste.poser(T(0.95), pince(ton[4] + 12), 0.4, 0.3, 0.5)
            piste.poser(T(1.7), souffle(0.9 * f, True, 12000), 0.6, 0.5, 0.7)
            piste.poser(T(r - 0.4), souffle(0.35 * f, False), 0.6, 0, 0.4)
        elif f_ == 'rythme':
            pas = 60 / 172 / 1 * f                # la cadence du reel (172 battements par minute), en croches
            k, x = 0, T(0.3)
            while x < deb + dur - 0.5:
                n = GAMME[[0, 2, 4, 2, 5, 4, 2, 7][k % 8] + 2]
                piste.poser(x, pince(n, 0.5), 0.45 if k % 2 == 0 else 0.3, ((k % 4) - 1.5) * 0.3, 0.35)
                if k % 4 == 0:
                    piste.poser(x, impact(0.35, 90, 50), 0.15, 0, 0.1)
                k += 1; x += pas
        elif f_ == 'clair':
            piste.poser(T(-0.1), souffle(0.8 * f, True, 10000), 1.1, 0, 0.5)
            n_et = len(p.get('etapes', [])) or 3
            for k in range(n_et):
                piste.poser(T(0.9 + k * 0.5), cloche(GAMME[5 + k * 2] + 12, 2.0), 0.5, (k - 1) * 0.4, 0.6)
            piste.poser(T(r - 0.75), souffle(0.7 * f, False, 8000), 1.0, 0, 0.5)
        elif f_ == 'trace':
            piste.poser(T(0.2), glisse(57, 81, 1.9 * f), 0.8, 0, 0.6)
            piste.poser(T(2.1), cloche(84, 2.6), 0.7, 0.3, 0.6)
        elif f_ == 'fusion':
            piste.poser(T(0), goutte(GAMME[2]), 0.6, 0, 0.3)
            for k in range(4):
                piste.poser(T(0.2 + k * 0.12), goutte(GAMME[5 + k]), 0.45, [-.6, .6, -.4, .5][k], 0.4)
                piste.poser(T(0.9 + k * 0.18 + 0.9), goutte(GAMME[3 + k] - 12, 0.5), 0.5, 0, 0.4)
            piste.poser(T(1.9), impact(1.0, 80, 38), 0.3, 0, 0.3)
            piste.poser(T(1.9), souffle(1.0 * f, False, 5000), 0.5, 0, 0.5)
        elif f_ == 'fin':
            for k, n in enumerate(ton):
                piste.poser(T(0.02 * k), cloche(n + 12, 4.0, 0.8), 0.32, (k - 2) * 0.35, 0.75)
            piste.poser(T(0.6), cloche(ton[0] + 24, 3.0), 0.45, 0, 0.8)
        # passage au plan suivant : un souffle court qui retombe (le plan clair a le sien)
        if i + 1 < len(plans) and f_ != 'clair' and plans[i + 1][0]['f'] != 'clair':
            piste.poser(deb + dur - 0.4, souffle(0.5, False, 7000), 0.55, 0, 0.5)


def reverb(x):
    rng = np.random.default_rng(7); t = tt(2.6)
    ir = [rng.standard_normal(len(t)) * np.exp(-t / 0.55) for _ in range(2)]
    ir = [sosfilt(butter(1, 5000, 'low', fs=SR, output='sos'), i) for i in ir]
    return np.stack([fftconvolve(x[c], ir[c])[: x.shape[1]] for c in range(2)]) * 0.05


def main():
    if len(sys.argv) != 3:
        sys.exit(__doc__)
    vfile, out = sys.argv[1:]
    v = json.load(open(vfile)) if vfile != '-' else {}
    defaut, REF = plans_du_html()
    plans = json.loads(v['plans']) if v.get('plans') else defaut
    duree = float(v.get('duree', 30))
    poids = sum(p.get('d', 1) for p in plans)
    pl, deb = [], 0.0
    for p in plans:
        dur = p.get('d', 1) * duree / poids
        p['_f'] = min(1.8, max(0.6, dur / REF.get(p['f'], dur)))
        pl.append((p, deb, dur)); deb += dur
    piste = Piste(duree)
    nappe(piste, pl, duree)
    gestes(piste, pl, duree)
    x = piste.sec + reverb(piste.rev)
    x = x[:, : int(duree * SR)]
    fin = np.clip((duree - tt(duree)) / 0.8, 0, 1)            # même fondu que l'image sur la dernière demi-seconde
    x *= fin
    x = sosfilt(butter(2, 30, 'high', fs=SR, output='sos'), x)
    rms = np.sqrt(np.mean(x ** 2)); x *= 10 ** (-17 / 20) / rms   # niveau du reel mesuré : environ -17 dB
    pic = np.abs(x).max()
    if pic > 0.89:
        x = np.where(np.abs(x) > 0.6, np.sign(x) * (0.6 + 0.29 * np.tanh((np.abs(x) - 0.6) / 0.29)), x)   # seules les crêtes s'arrondissent, sous 0,89
    wavfile.write(out, SR, (x.T * 32767).astype(np.int16))
    print(f'musique : {out} ({duree:g} s, {len(plans)} plans, niveau {20 * np.log10(np.sqrt(np.mean(x ** 2))):.1f} dB, crête {20 * np.log10(np.abs(x).max()):.1f} dB)')


if __name__ == '__main__':
    main()
