#!/usr/bin/env python3
"""Verträumtes, romantisches Stück für Solo-Flügel im Stil englischer Kostümfilm-Musik
(F-Dur, 3/4, ~66 bpm), nahtlos loopbar. (Das vorige heitere Stück: erzeuge_musik_heiter.py)
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 = 66
BEAT = 60 / BPM
BAR = 3 * 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 ----------------
# Stil: verträumtes, frühromantisches Solo-Klavier (englische Landschaft im Morgenlicht).
# Akkord = Liste von Abschnitten (Schläge, Basston, Tonklassen 0=C … 11=H)
def ch(*segs):
    return list(segs)

F    = ch((3, 41, [5, 9, 0]))
Gm7F = ch((3, 41, [7, 10, 2, 5]))
CE   = ch((3, 40, [0, 4, 7]))
Dm   = ch((3, 38, [2, 5, 9]))
Bb   = ch((3, 46, [10, 2, 5]))
Gm7  = ch((3, 43, [7, 10, 2, 5]))
Csus = ch((1.5, 36, [0, 5, 7]), (1.5, 36, [0, 4, 7]))       # Vorhalt 4–3
FA   = ch((3, 45, [5, 9, 0]))
Bbm6 = ch((3, 46, [10, 1, 5, 7]))                          # Moll-Subdominante: der „Seufzer“
FC   = ch((3, 36, [5, 9, 0]))
C7s  = ch((1.5, 36, [0, 5, 7, 10]), (1.5, 36, [0, 4, 7, 10]))
Am   = ch((3, 45, [9, 0, 4]))
Gm   = ch((3, 43, [7, 10, 2]))
DmF  = ch((3, 41, [2, 5, 9]))
Eb   = ch((3, 39, [3, 7, 10]))                              # modale Färbung (bVII)
BbC  = ch((3, 36, [10, 2, 5]))                              # schwebend, führt zurück zum Anfang

R = None   # Pause
A1 = [(F,    [(R, .5), (72, .5), (77, 1.5), (76, .5)]),
      (Gm7F, [(74, 2), (72, .5), (70, .5)]),
      (F,    [(69, 1.5), (72, .5), (77, 1)]),
      (CE,   [(79, 2), (76, 1)]),
      (Dm,   [(77, 1.5), (76, .5), (74, 1)]),
      (Bb,   [(72, 1.5), (74, .5), (77, 1)]),
      (Gm7,  [(77, 1), (74, 1), (72, .5), (70, .5)]),
      (Csus, [(72, 2.5), (R, .5)])]
A2 = [(F,    [(R, .5), (72, .5), (77, 1.5), (79, .5)]),
      (FA,   [(81, 2), (79, .5), (77, .5)]),
      (Bb,   [(74, 1.5), (77, .5), (82, 1)]),
      (Bbm6, [(79, 2), (77, 1)]),
      (FC,   [(77, 1), (76, .5), (77, .5), (81, 1)]),
      (Dm,   [(81, 1.5), (79, .5), (77, 1)]),
      (Gm7,  [(79, 1.5), (77, .5), (74, 1)]),
      (C7s,  [(72, 1.5), (76, 1.5)])]
B1 = [(Dm,   [(81, 1.5), (77, .5), (74, 1)]),
      (Am,   [(76, 1.5), (72, .5), (69, 1)]),
      (Bb,   [(74, 1), (77, 1), (82, 1)]),
      (F,    [(81, 2), (77, 1)]),
      (Gm,   [(82, 1.5), (81, .5), (79, 1)]),
      (DmF,  [(77, 1), (74, 1), (81, 1)]),
      (Eb,   [(79, 1.5), (82, .5), (79, .5), (75, .5)]),
      (Csus, [(77, 1.5), (76, 1.5)])]
A3 = A1[:-1] + [(BbC, [(72, 1.5), (74, .5), (70, 1)])]
INTRO = [(F, [(R, 1), (84, 1), (81, .5), (77, .5)]), (F, [(76, 1.5), (77, 1.5)])]

# Ablauf: Intro · A · A' (Moll-Seufzer) · B (Sehnsucht) · A (voller, leise Oktaven) → zurück zum Intro
FORM = [(INTRO, 0), (A1, 1), (A2, 1), (B1, 2), (A3, 3)]
# Rubato: Phrasen drängen leicht nach vorn und atmen am Ende aus
RUBATO = [1.0, .98, .99, 1.0, .98, 1.0, 1.04, 1.12]


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=48, hi=65):
    return [m for m in range(lo, hi + 1) if m % 12 in pcs][:3]


events = []   # (zeit, taste, velocity, aus-zeit oder None=bis Pedal)
pedal_changes = []
t = 0.0
for section, lvl in FORM:
    n = len(section)
    for i, (chord, mel) in enumerate(section):
        beat = BEAT * (RUBATO[i] if n == 8 else 1.0)
        bar_t = t
        phrase = np.sin(np.pi * (i + 0.5) / n)                 # an- und abschwellen
        dyn = [-6, 0, 3, 4][lvl]
        # linke Hand: fließende Achtel, Bass – Quinte – Terz – Oberton – Terz – Quinte
        b0 = 0.0
        for si, (dur, bass, pcs) in enumerate(chord):
            pedal_changes.append(bar_t + b0 * beat)
            up = upper_voicing(pcs)
            seq = [bass, up[0], up[1], up[2], up[1], up[0]]
            for j in range(int(dur * 2)):
                pos = int(b0 * 2) + j
                k = seq[pos % 6]
                v =36 + 7 * phrase + (9 if pos % 6 == 0 else 0) + (3 if pos % 6 == 3 else 0) + dyn
                events.append((bar_t + pos * beat / 2 + rng.normal(0, .007), k, v, None))
            if lvl >= 2 and si == 0:
                events.append((bar_t - 0.015, bass - 12, 42 + dyn + 6 * phrase, None))   # tiefe Oktave
            b0 += dur
        # rechte Hand: gesangliche Melodie
        b = 0.0
        for k, d in mel:
            if k is not None:
                st = bar_t + b * beat + rng.normal(0, .01) + (0.02 if b == 0 and i % 4 == 0 else 0)
                v = 58 + 12 * phrase + dyn + (5 if d >= 1.5 else 0) - (5 if b % 1 else 0)
                off = st + d * beat * 0.96
                # passenden Akkordabschnitt zu diesem Schlag finden
                acc, pcs = 0.0, chord[0][2]
                for dur, _, pc in chord:
                    if b >= acc - 1e-9:
                        pcs = pc
                    acc += dur
                events.append((st, k, v, off))
                if lvl == 2 and d >= 1:
                    events.append((st + .012, chord_below(k, pcs, 3 if i % 2 == 0 else 7), v - 18, off))
                if section is A3 and lvl == 3:
                    events.append((st + .008, k - 12, v - 14, off))
                    if d >= 1.5:
                        events.append((st + .014, chord_below(k, pcs), v - 22, off))
            b += d
        t += 3 * beat

TOTAL = t
pedal_changes.append(TOTAL)


def pedal_release(st):
    """Pedal wird bei jedem Harmoniewechsel 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(3.0 * 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.3)
    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.3 * 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"{sum(len(x) for x, _ in FORM)} Takte · {TOTAL:.1f} s · {len(events)} Anschläge → {OUT}")
