#!/usr/bin/env python3
"""Heiteres Hochzeitsstück für Solo-Flügel (G-Dur, 4/4, ~104 bpm), nahtlos loopbar.
Gespielt mit echten Flügelaufnahmen: Salamander Grand Piano V3 (Yamaha C5)
von Alexander Holm, Lizenz CC-BY 3.0 – https://sfzinstruments.github.io/pianos/salamander

Aufruf:  python3 musik/erzeuge_musik.py
Benötigt numpy + scipy und die Samples in musik/fluegel/ (siehe SAMPLES unten).
Die AAC-Umwandlung nutzt macOS' afconvert. Ergebnis: ../assets/musik.m4a
"""
import re
import subprocess
from functools import lru_cache
from pathlib import Path

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

SR = 44100
BPM = 104
BEAT = 60 / BPM
BAR = 4 * BEAT
HERE = Path(__file__).resolve().parent
SAMPLES = next((HERE / "fluegel").glob("Salamander*"), HERE / "fluegel")
OUT = HERE.parent / "assets" / "musik.m4a"
rng = np.random.default_rng(15052027)


# ---------------- Flügel: SFZ lesen und Töne abspielen ----------------
def load_regions():
    sfz = next(SAMPLES.glob("*Retuned.sfz"))
    text = re.sub(r"//.*", "", sfz.read_text(errors="ignore"))
    regions, rel_regions = [], []
    group, trigger = {}, "attack"
    for tag, body in re.findall(r"<(\w+)>([^<]*)", text):
        opts = dict(re.findall(r"(\w+)=(\S+(?: (?!\w+=)\S+)*)", body))
        if tag == "group":
            group = opts
            continue
        if tag != "region":
            continue
        o = {**group, **opts}
        r = {
            "sample": SAMPLES / o["sample"].replace("\\", "/"),
            "lokey": int(o.get("lokey", o.get("key", 0))),
            "hikey": int(o.get("hikey", o.get("key", 127))),
            "lovel": int(o.get("lovel", 1)),
            "hivel": int(o.get("hivel", 127)),
            "center": int(o.get("pitch_keycenter", o.get("key", 60))),
            "tune": float(o.get("tune", 0)),
            "vol": float(o.get("volume", 0)),
        }
        (rel_regions if o.get("trigger") == "release" else regions).append(r)
    return regions, rel_regions


REGIONS, REL = load_regions()
REL = [r for r in REL if r["sample"].name.startswith("rel")]   # Hammer-/Dämpfergeräusch


@lru_cache(maxsize=None)
def read_sample(path):
    sr, x = wavfile.read(path)
    x = x.astype(np.float32) / 32768.0
    if x.ndim == 1:
        x = np.stack([x, x], 1)
    assert sr == SR, sr
    return x.T.copy()


def pick(regions, key, vel):
    for r in regions:
        if r["lokey"] <= key <= r["hikey"] and r["lovel"] <= vel <= r["hivel"]:
            return r
    cands = [r for r in regions if r["lokey"] <= key <= r["hikey"]]
    return min(cands, key=lambda r: abs((r["lovel"] + r["hivel"]) / 2 - vel)) if cands else None


@lru_cache(maxsize=None)
def pitched(path, semis):
    """Sample um 'semis' Halbtöne verschieben (hochwertiges Resampling)."""
    x = read_sample(path)
    if abs(semis) < 1e-6:
        return x
    ratio = 2 ** (semis / 12)
    up, down = 1000, int(round(1000 * ratio))
    return resample_poly(x, up, down, axis=1).astype(np.float32)


def note(key, vel, hold):
    """Ein Flügelton: Anschlag mit Velocity-Ebene, klingt 'hold' Sekunden, dann Dämpfer."""
    r = pick(REGIONS, key, vel)
    x = pitched(str(r["sample"]), key - r["center"] + r["tune"] / 100)
    g = 10 ** (r["vol"] / 20) * ((vel / 127) ** 2) ** 0.73        # amp_veltrack=73 wie im SFZ
    n_hold = int(hold * SR)
    n = min(x.shape[1], n_hold + int(0.6 * SR))
    y = x[:, :n] * g
    if n > n_hold and key < 89:                        # Dämpfer (ab Fis6 hat der Flügel keine)
        t = np.arange(n - n_hold) / SR
        y[:, n_hold:] *= np.exp(-t * (9 if key < 80 else 14))
    out = [(0.0, y)]
    rr = pick(REL, key, vel)
    if rr is not None and hold < x.shape[1] / SR:
        z = read_sample(str(rr["sample"])) * 10 ** (rr["vol"] / 20) * (vel / 127)
        out.append((hold, z))
    return out


# ---------------- Komposition ----------------
# Akkord: (Basston, Tonklassen 0=C … 11=H)
G, Gb, Gd, Dfs, D, D7, Em, C, Am, B7 = (
    (43, [7, 11, 2]), (47, [7, 11, 2]), (38, [7, 11, 2]), (42, [2, 6, 9]), (38, [2, 6, 9]),
    (38, [2, 6, 9, 0]), (40, [4, 7, 11]), (36, [0, 4, 7]), (45, [9, 0, 4]), (47, [11, 3, 6, 9]))
A_CH = [G, Dfs, Em, Gd, C, Gb, Am, D]
B_CH = [C, D, B7, Em, C, Am, D, D7]
A_MEL = [[(71, .5), (74, .5), (79, 1.5), (78, .5), (79, .5), (81, .5)],
         [(78, 1.5), (76, .5), (74, 2)],
         [(76, .5), (79, .5), (83, 1.5), (81, .5), (79, 1)],
         [(71, .5), (74, .5), (79, 2), (78, 1)],
         [(76, 1.5), (74, .5), (72, 1), (76, 1)],
         [(74, 1.5), (71, .5), (67, 1), (71, 1)],
         [(72, .5), (76, .5), (81, 1.5), (79, .5), (78, 1)],
         [(81, 2), (78, 1), (74, 1)]]
A_END = [(81, 2), (84, 1), (78, 1)]          # letzter Takt über D7, führt zurück zum Anfang
B_MEL = [[(79, 1), (76, .5), (79, .5), (84, 2)],
         [(81, 1), (78, .5), (81, .5), (86, 2)],
         [(83, 1.5), (81, .5), (78, 1), (75, 1)],
         [(76, 2), (79, 1), (83, 1)],
         [(84, 1.5), (83, .5), (81, 1), (79, 1)],
         [(76, 1), (81, 1), (79, 1), (76, 1)],
         [(74, 1), (78, 1), (81, 1), (84, 1)],
         [(84, .5), (83, .5), (81, 1), (78, 1), (74, 1)]]
def chord_below(m, pcs, gap=3):
    """Höchster Akkordton mindestens 'gap' Halbtöne unter m – für Terzen/Sexten unter der Melodie."""
    return max(x for x in range(m - 12, m - gap + 1) if x % 12 in pcs)


def upper_voicing(pcs, lo=50, hi=66):
    return [m for m in range(lo, hi + 1) if m % 12 in pcs][:3]


# Ablauf: Intro · A · B · A (Terzen) · B (voller) · A (Oktaven)
FORM = [("I", 0), ("A", 1), ("B", 1), ("A", 2), ("B", 2), ("A", 3)]

events = []   # (zeit, taste, velocity, aus-zeit oder None=bis Pedal)
pedal_changes = []
t = 0.0
for sec, lvl in FORM:
    if sec == "I":
        chords, mel = [G, C], [None, None]
    elif sec == "A":
        chords = A_CH[:-1] + ([D7] if lvl == 3 else [D])
        mel = A_MEL[:-1] + [A_END if lvl == 3 else A_MEL[-1]]
    else:
        chords, mel = B_CH, B_MEL
    for i, (bass, pcs) in enumerate(chords):
        bar_t = t
        phrase = np.sin(np.pi * (i + 0.5) / len(chords))        # Phrase: an- und abschwellen
        dyn = [0, 0, -4, 4][lvl]                                 # Abschnitte unterschiedlich laut
        pedal_changes.append(bar_t)
        # linke Hand: fließende Achtel 1-5-10-5-8-5-10-5
        up = upper_voicing(pcs)
        seq = [bass, up[0], up[1], up[2], up[1], up[0], up[1], up[2]]
        for j, k in enumerate(seq):
            v = 44 + 8 * phrase + (10 if j == 0 else 0) + dyn + (4 if j == 4 else 0)
            events.append((bar_t + j * BEAT / 2 + rng.normal(0, .006), k, v, None))
        if lvl >= 2:
            events.append((bar_t - 0.012, bass - 12, 52 + dyn + 8 * phrase, None))   # Bass-Oktave
        # rechte Hand: Melodie, gesanglich hervorgehoben
        if mel[i]:
            b = 0
            for k, d in mel[i]:
                st = bar_t + b * BEAT + rng.normal(0, .008)
                v = 70 + 12 * phrase + dyn + (6 if d >= 1.5 else 0) - (6 if b % 1 else 0)
                off = st + d * BEAT * 0.95
                events.append((st, k, v, off))
                if lvl == 2 and d >= 1:                     # Terz/Sexte darunter
                    events.append((st + .01, chord_below(k, pcs, 3 if b % 2 == 0 else 7), v - 16, off))
                if lvl == 3:                                # Oktaven
                    events.append((st + .006, k - 12, v - 12, off))
                    if d >= 1.5:
                        events.append((st + .01, chord_below(k, pcs), v - 20, off))
                b += d
        elif sec == "I" and i == 1:                         # kleines Glockenspiel-Motiv im Intro
            for b, k in [(0, 79), (.5, 83), (1, 86), (2.5, 84), (3, 83), (3.5, 81)]:
                events.append((bar_t + b * BEAT, k, 52, bar_t + (b + .5) * BEAT))
        t += BAR

TOTAL = t
pedal_changes.append(TOTAL)


def pedal_release(st):
    """Pedal wird bei jedem Akkordwechsel neu getreten: Ton klingt bis zum nächsten Wechsel."""
    nxt = min(p for p in pedal_changes if p > st + 0.05)
    return nxt - 0.02


L = int(TOTAL * SR)
mix = np.zeros((2, L + 25 * SR), dtype=np.float32)
for st, k, v, off in events:
    v = int(np.clip(round(v), 1, 127))
    end = max(pedal_release(st), off or 0)
    for dt, y in note(k, v, end - st):
        s = int(max(0, st + dt) * SR)
        mix[:, s:s + y.shape[1]] += y[:, : mix.shape[1] - s]

# ---------------- dezenter Konzertsaal-Hall ----------------
ir_len = int(2.4 * SR)
ti = np.arange(ir_len) / SR
ir = np.zeros((2, ir_len))
for ch in range(2):
    nz = rng.normal(0, 1, ir_len) * np.exp(-ti * 2.9)
    nz = sosfilt(butter(1, 5500, "low", fs=SR, output="sos"), nz)
    nz[: int(0.02 * SR)] = 0
    ir[ch] = nz / np.sqrt(np.sum(nz ** 2))
N = 1 << int(np.ceil(np.log2(mix.shape[1] + ir_len)))
wet = np.fft.irfft(np.fft.rfft(mix, N) * np.fft.rfft(ir, N), N)[:, : mix.shape[1] + ir_len]

# Nahtloser Loop: alles, was über das Ende hinausklingt, kommt an den Anfang
full = 0.22 * wet
full[:, : mix.shape[1]] += mix
loop = full[:, :L].copy()
rest = full[:, L:]
for k in range(0, rest.shape[1], L):
    seg = rest[:, k:k + L]
    loop[:, : seg.shape[1]] += seg

loop = loop / np.max(np.abs(loop)) * 0.85

wav = HERE / "musik.wav"
wavfile.write(wav, SR, (loop.T * 32767).astype(np.int16))
subprocess.run(["afconvert", "-f", "m4af", "-d", "aac", "-b", "160000", "-q", "127", str(wav), str(OUT)], check=True)
wav.unlink()
print(f"{int(round(TOTAL / BAR))} Takte · {TOTAL:.1f} s · {len(events)} Anschläge → {OUT}")
