#!/usr/bin/env python3
"""Write an original 64-bar MIDI composition. Python 3 standard library only.

Usage: python3 generate_midnight_circuit.py --out ./midnight-circuit
No DAW, MIDI library, downloaded samples, or network connection is required.
"""
from __future__ import annotations

import argparse
from dataclasses import asdict, dataclass, replace
import json
from pathlib import Path
import random
import struct

BPM = 130
PPQ = 960
BARS = 64
BEATS = BARS * 4
SEED = 260907
SWING = 0.035  # Beat displacement of odd sixteenths; a 57:43 ratio.

# The D in Dm9 comes from the bass/pad. Shared upper structures are intentional.
CHORDS = (
    ("Dm9", 38, (53, 57, 60, 64)),
    ("Bbmaj7", 34, (53, 57, 58, 62)),
    ("Fmaj7", 41, (53, 57, 60, 64)),
    ("Cadd9", 36, (55, 60, 62, 64)),
)
SECTIONS = ("Intro", "A", "A variation", "B", "Breakdown", "Return", "Climax", "Outro")
# MIDI program numbers are zero-based here; percussion uses channel 10.
TRACKS = {
    "drums": {"channel": 9, "program": None, "volume": 100, "pan": 64},
    "bass": {"channel": 0, "program": 38, "volume": 96, "pan": 64},
    "chords": {"channel": 1, "program": 4, "volume": 88, "pan": 48},
    "lead": {"channel": 2, "program": 80, "volume": 92, "pan": 72},
    "pad": {"channel": 3, "program": 89, "volume": 70, "pan": 64},
}


@dataclass(frozen=True)
class Note:
    track: str
    beat: float
    duration: float
    pitch: int
    velocity: int


def make_song(basic: bool = False) -> list[Note]:
    rng = random.Random(SEED)
    notes: list[Note] = []

    def add(track: str, beat: float, dur: float, pitch: int, vel: int,
            groove: bool = False) -> None:
        if groove and not basic:
            if round((beat % 1) * 4) % 2:
                beat += SWING
            beat += rng.uniform(-0.005, 0.005)
            vel += rng.randint(-3, 3)
        beat = max(0, beat)
        end = min(BEATS - 0.02, beat + dur)
        if end > beat:
            notes.append(Note(track, round(beat, 6), round(end-beat, 6),
                              pitch, max(1, min(127, vel))))

    for bar in range(BARS):
        sec, local = divmod(bar, 8)
        chord_i = (bar // 2) % 4
        _, root, voices = CHORDS[chord_i]
        next_root = CHORDS[(chord_i + 1) % 4][1]
        t = bar * 4
        # A simple, unchanged control loop for the first comparison file.
        if basic:
            for p in (0, 2):
                add("drums", t+p, .11, 36, 96)
            for p in (1, 3):
                add("drums", t+p, .11, 38, 88)
            for p in (0, .5, 1, 1.5, 2, 2.5, 3, 3.5):
                add("drums", t+p, .06, 42, 61)
            add("bass", t, 1.4, root, 91)
            add("bass", t+2, 1.4, root, 91)
            for pitch in voices:
                add("chords", t, 1.5, pitch, 73)
                add("chords", t+2, 1.5, pitch, 73)
            motif = (74, 77, 76, 69) if bar % 2 == 0 else (72, 74, 69, 74)
            for i, pitch in enumerate(motif):
                add("lead", t+i, .65, pitch, 83)
            continue

        # Long pads anchor each two-bar harmonic cell. The final C chord is
        # replaced by a tonic Dm9 to close the piece rather than leave a loop.
        pad_root, pad_voices = root, voices
        if sec == 7 and local >= 6:
            pad_root, pad_voices = CHORDS[0][1:]
        if bar % 2 == 0:
            pad_velocity = 47 if sec in (0, 4, 7) else 32
            for i, pitch in enumerate((pad_root + 12,) + tuple(p+12 for p in pad_voices)):
                add("pad", t+i*.008, 7.65, pitch, pad_velocity + (i % 2)*3)

        full_drums = sec in (1, 2, 3, 5, 6) or (sec == 0 and local >= 4) or (sec == 7 and local < 4)
        if full_drums:
            kicks = [(0, 103), (1.75, 89), (2.5, 97)]
            if local % 2:
                kicks = [(0, 104), (1.5, 85), (2.75, 97)]
            if sec == 6 and local % 4 == 2:
                kicks.append((3.5, 83))
            if local == 7:
                kicks = kicks[:2]  # A breath before the next section.
            for p, v in kicks:
                add("drums", t+p, .11, 36, v, True)
            for p in (1, 3):
                add("drums", t+p, .12, 38, 96 if p == 3 else 90, True)
                if sec in (2, 5, 6):
                    add("drums", t+p+.007, .10, 39, 53, True)
            hat_positions = (0, .5, .75, 1.25, 1.5, 2, 2.5, 2.75, 3.25, 3.5)
            for i, p in enumerate(hat_positions):
                if local == 7 and p >= 3:
                    continue
                if local % 2 and p == 3.5:
                    continue  # Leave space for the open hat's decay.
                add("drums", t+p, .045, 42, (58, 74, 43, 53)[i % 4], True)
            if local % 2 == 1:
                add("drums", t+3.5, .15, 46, 61, True)
            if sec in (2, 3, 5, 6) and local % 4 in (1, 3):
                add("drums", t+2.75, .06, 37, 52, True)
            if local in (3, 7):
                for p, v in ((3.25, 37), (3.5, 48), (3.75, 63)):
                    add("drums", t+p, .07, 38, v, True)
        elif sec == 0 or (sec == 7 and local < 6):
            for p in (.5, 1.5, 2.5, 3.5):
                add("drums", t+p, .05, 42, 45, True)
            if local >= 2:
                add("drums", t+3, .08, 37, 57, True)
        elif sec == 4 and local >= 6:
            # A measured snare pickup, not a continuous EDM roll.
            for p, v in ((1, 43), (2.5, 47), (3, 57), (3.5, 68)):
                add("drums", t+p, .08, 38, v, True)

        bass_active = full_drums and not (sec == 0 and local < 6)
        if bass_active:
            bass_line = [(0, .67, root, 99), (1.75, .26, root, 84),
                         (2.5, .47, root+7, 87), (3.5, .27, root, 83)]
            if local % 2:
                # The last eighth anticipates the coming root on cell endings.
                bass_line = [(0, .73, root, 99), (1.5, .26, root+12, 83),
                             (2.75, .43, root, 90), (3.5, .30, next_root, 78)]
            if sec == 3:
                bass_line = [(0, 1.25, root, 96), (1.75, .34, root+12, 84),
                             (2.75, .62, root+7, 88)]
            if local == 7:
                bass_line = bass_line[:-1]
            for p, d, pitch, v in bass_line:
                add("bass", t+p, d, pitch, v, True)
        elif sec == 4 and local in (0, 4):
            add("bass", t, 2.8, root, 65)
        elif sec == 7 and local == 6:
            add("bass", t, 7.25, CHORDS[0][1], 72)

        # Stagger voices by 6 ms at this tempo; common tones remain near each
        # other rather than block chords jumping through root positions.
        if sec in (1, 2, 5, 6) or (sec == 0 and local >= 2) or (sec == 7 and local < 4):
            stabs = [(.75, .46, 72), (2.25, .50, 78)]
            if local % 2:
                stabs.append((3.5, .28, 59))
            if sec == 6:
                stabs.append((1.5, .25, 56))
            for p, d, v in stabs:
                for i, pitch in enumerate(voices):
                    add("chords", t+p+i*.012, d, pitch, v-(i % 2)*3, True)
        elif sec in (3, 4):
            for i, pitch in enumerate(voices):
                add("chords", t+.5+i*.12, 1.30 if sec == 3 else 2.0, pitch, 62-i*2, True)

        # Two-bar call/answer. Chord-specific answers keep the hook recognizable
        # while landing on tones in the current harmony.
        lead_active = sec in (1, 2, 3, 5, 6) or (sec == 0 and local >= 6) or (sec == 7 and local < 4)
        if lead_active:
            calls = (
                (74, 77, 76, 69, 72),
                (74, 77, 81, 77, 74),
                (72, 69, 67, 76, 77),
                (76, 79, 74, 72, 67),
            )
            answers = (
                (74, 69, 77, 76, 74),
                (77, 74, 72, 69, 70),
                (76, 72, 69, 67, 69),
                (74, 76, 79, 76, 74),
            )
            pitches = calls[chord_i] if bar % 2 == 0 else answers[chord_i]
            times = (.5, 1.25, 1.75, 2.75, 3.5) if bar % 2 == 0 else (.25, 1.25, 2, 2.75, 3.5)
            durations = (.28, .27, .55, .42, .25)
            if sec == 3:
                times, durations = (0, 1.5, 2.25, 3.0, 3.5), (1.05, .48, .34, .27, .25)
                pitches = tuple(p-12 if i in (0, 3) else p for i, p in enumerate(pitches))
            for i, (p, d, pitch) in enumerate(zip(times, durations, pitches)):
                if local == 7 and i >= 3:
                    continue
                if sec == 0 and i % 2:
                    continue
                if sec == 2 and bar % 2 == 1 and i == 3:
                    pitch += 12
                if sec == 6 and i in (0, 4) and local >= 4:
                    pitch += 12
                add("lead", t+p, d, pitch, (89, 74, 83, 72, 77)[i] + (4 if sec == 6 else 0), True)
            if sec in (2, 5, 6) and local in (2, 6):
                add("lead", t+3.0, .16, pitches[3]+12, 53, True)
        elif sec == 4 and local in (0, 2, 4, 6):
            for p, pitch, d in ((.5, 74, 1.25), (2.25, 77 if local < 4 else 76, 1.15)):
                add("lead", t+p, d, pitch, 56, True)
        elif sec == 7 and local == 6:
            add("lead", t+.5, 1.1, 77, 64)
            add("lead", t+2, 3.4, 74, 57)

    return sorted(notes, key=lambda n: (n.beat, n.track, n.pitch))


def vlq(value: int) -> bytes:
    if value < 0:
        raise ValueError("Negative MIDI delta")
    chunks = [value & 127]
    value >>= 7
    while value:
        chunks.append((value & 127) | 128)
        value >>= 7
    return bytes(reversed(chunks))


def meta(kind: int, payload: bytes) -> bytes:
    return bytes((255, kind)) + vlq(len(payload)) + payload


def global_events(bars: int, markers: bool = True) -> list[tuple[int, int, bytes]]:
    tempo = round(60_000_000 / BPM)
    result = [(0, -10, meta(0x51, tempo.to_bytes(3, "big"))),
              (0, -9, meta(0x58, bytes((4, 2, 24, 8)))),
              (0, -8, meta(0x59, bytes((255, 1))))]  # One flat, minor.
    if markers:
        for bar in range(0, bars, 8):
            label = f"{bar+1:02d}-{min(bar+8, bars):02d}: {SECTIONS[(bar//8) % 8]}"
            result.append((bar*4*PPQ, -5, meta(0x06, label.encode("ascii"))))
    return result


def encode_track(events: list[tuple[int, int, bytes]], total_ticks: int) -> bytes:
    body = bytearray()
    previous = 0
    for tick, _, data in sorted(events, key=lambda item: (item[0], item[1], item[2])):
        body.extend(vlq(tick-previous))
        body.extend(data)
        previous = tick
    body.extend(vlq(total_ticks-previous))
    body.extend(b"\xff\x2f\x00")
    return b"MTrk" + struct.pack(">I", len(body)) + body


def instrument_events(name: str, notes: list[Note]) -> list[tuple[int, int, bytes]]:
    cfg = TRACKS[name]
    ch = cfg["channel"]
    events = [(0, -7, meta(3, name.encode("ascii"))),
              (0, -6, bytes((0xB0+ch, 7, cfg["volume"]))),
              (0, -6, bytes((0xB0+ch, 10, cfg["pan"])))]
    if cfg["program"] is not None:
        events.append((0, -6, bytes((0xC0+ch, cfg["program"]))))
    for n in notes:
        if n.track != name:
            continue
        start = round(n.beat * PPQ)
        end = max(start+1, round((n.beat+n.duration)*PPQ))
        events.append((start, 1, bytes((0x90+ch, n.pitch, n.velocity))))
        events.append((end, 0, bytes((0x80+ch, n.pitch, 0))))
    return events


def write_midi(path: Path, notes: list[Note], bars: int = BARS,
               only: str | None = None, markers: bool = True) -> None:
    ticks = bars*4*PPQ
    if only:
        tracks = [encode_track(global_events(bars, markers)+instrument_events(only, notes), ticks)]
        fmt = 0
    else:
        tracks = [encode_track(global_events(bars, markers)+[(0, -11, meta(3, b"Midnight Circuit"))], ticks)]
        tracks += [encode_track(instrument_events(name, notes), ticks) for name in TRACKS]
        fmt = 1
    path.write_bytes(b"MThd" + struct.pack(">IHHH", 6, fmt, len(tracks), PPQ) + b"".join(tracks))


def excerpt(notes: list[Note], first_bar: int, bars: int) -> list[Note]:
    start, end = first_bar*4, (first_bar+bars)*4
    return [replace(n, beat=round(n.beat-start, 6),
                    duration=round(min(n.beat+n.duration, end-.02)-n.beat, 6))
            for n in notes if start <= n.beat < end-.02]


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--out", type=Path, default=Path("midnight-circuit"))
    args = parser.parse_args()
    args.out.mkdir(parents=True, exist_ok=True)
    notes = make_song()
    basic = excerpt(make_song(basic=True), 8, 16)
    revised = excerpt(notes, 8, 16)
    write_midi(args.out / "midnight-circuit-full.mid", notes)
    for track in TRACKS:
        write_midi(args.out / f"midnight-circuit-{track}.mid", notes, only=track)
    write_midi(args.out / "comparison-basic-16-bars.mid", basic, 16, markers=False)
    write_midi(args.out / "comparison-revised-16-bars.mid", revised, 16, markers=False)
    score = {"title": "Midnight Circuit", "bpm": BPM, "ppq": PPQ, "bars": BARS,
             "seed": SEED, "key": "D minor", "notes": [asdict(n) for n in notes],
             "basic_notes": [asdict(n) for n in basic],
             "revised_notes": [asdict(n) for n in revised]}
    (args.out / "score.json").write_text(json.dumps(score, separators=(",", ":")), encoding="utf-8")
    print(f"Wrote 8 MIDI files and score.json to {args.out.resolve()}")
    print(f"{BARS} bars, {BPM} BPM, {len(notes)} notes, {BEATS*60/BPM:.6f} nominal seconds")


if __name__ == "__main__":
    main()
