#!/usr/bin/env python3
"""
LaBoom Techno v2 — Harmonisch, strukturiert, mit Climax in der Mitte
=====================================================================

Tonart-Analyse: G-Dur (Krumhansl r=0.852)
Material: 309 Original-Noten aus dem Master

ARCHITEKTUR:
  INTRO    [bars 0-7]   G-Dur, Pad + Sub-Drone
  BUILD 1  [bars 8-15]  + Kick + Lead phrase A
  DROP 1   [bars 16-31] Full Techno, G-Dur
  BREAK    [bars 32-39] Modulation G-Dur → g-moll, Counter-Melodie
  CLIMAX   [bars 40-55] *** g-moll → B♭-Dur Climax ***  ← ZIEL
  DROP 2   [bars 56-71] B♭-Dur Triumph, Layer-Maximum
  OUTRO    [bars 72-79] Filter-Down, Rückführung G-Dur

HARMONIK:
  G-Dur:   G - Em - C - D         (I-vi-IV-V)
  g-moll:  Gm - F - E♭ - D7       (i-VII-VI-V, Andalusian)
  B♭-Dur:  B♭ - F - Gm - E♭       (I-V-vi-IV)

VOICE-LEADING zwischen Akkorden: minimaler Halbton-Bewegung
ESSENZ-INTERPRETATION: Top-Line, Counter-Melodie, Bass-Voicing
                      = 3 Stimmen statt nur 1
"""

import mido, os, math, random
from mido import MidiFile, MidiTrack, Message, MetaMessage
from collections import defaultdict, Counter

random.seed(909)  # 909 für Drums

SRC_MASTER = "/root/.openclaw/workspace/downloads/midi-136bpm/laboom.mp3-midi---78aac641-9267-4c37-995f-ba33286c17f4_136bpm.mid"
SRC_OTHER  = "/root/.openclaw/workspace/downloads/midi-136bpm/laboom.mp3-midi-other---6e41fbd5-6201-4610-af7c-2263eb601c90_136bpm.mid"
OUT_DIR = "/root/.openclaw/workspace/downloads/midi-techno-v2"
os.makedirs(OUT_DIR, exist_ok=True)

TPQ = 480
BPM = 128  # House/Techno standard
BAR = 4 * TPQ

# Drums
KICK, SNARE, CLAP = 36, 38, 39
CLOSED_HAT, OPEN_HAT = 42, 46
RIM, RIDE, CRASH = 37, 51, 49
TOM_HIGH, TOM_LOW = 50, 45
TAMBOURINE = 54
COWBELL = 56

# ============================================================================
# CHORD THEORY
# ============================================================================

def chord_pitches(root, quality, base_octave=4):
    """Returns chord tones for a 7-voice spread voicing (root in bass, 
       rest in mid range). quality: 'maj', 'min', 'dom7', 'maj7', 'min7'"""
    intervals = {
        'maj':  [0, 4, 7, 12, 16, 19],
        'min':  [0, 3, 7, 12, 15, 19],
        'dom7': [0, 4, 7, 10, 14, 19],
        'maj7': [0, 4, 7, 11, 14, 19],
        'min7': [0, 3, 7, 10, 14, 17],
        'sus4': [0, 5, 7, 12, 17, 19],
    }[quality]
    base = 12 * base_octave + root  # C4 = 60 → root=0
    return [base + i for i in intervals]


def voice_lead(prev_voicing, target_root, target_quality, base_octave=4):
    """Move target chord notes minimally from previous voicing."""
    target_tones = chord_pitches(target_root, target_quality, base_octave)
    target_pcs = set(t % 12 for t in target_tones)
    
    new_voicing = []
    # For each previous voice, find closest target pitch class
    for prev in prev_voicing:
        candidates = [t for t in target_tones if (t % 12) in target_pcs]
        if not candidates:
            new_voicing.append(prev)
            continue
        best = min(candidates, key=lambda t: abs(t - prev))
        new_voicing.append(best)
    
    # Ensure root is present in bass
    bass_root = 12 * (base_octave - 2) + target_root
    if bass_root not in new_voicing:
        new_voicing[0] = bass_root
    
    return new_voicing


# ============================================================================
# CHORD PROGRESSION (per section, 2 bars per chord = 8 bars per phrase)
# ============================================================================

# Format: list of (root_pc, quality)  -- 4 chords per 8-bar phrase
PROG_G_MAJOR = [(7, 'maj'), (4, 'min'), (0, 'maj'), (2, 'maj')]   # G-Em-C-D
PROG_G_MAJOR_MAJ7 = [(7, 'maj7'), (4, 'min7'), (0, 'maj7'), (2, 'dom7')]
PROG_G_MINOR = [(7, 'min'), (5, 'maj'), (3, 'maj'), (2, 'dom7')]  # Gm-F-Eb-D7
PROG_BB_MAJOR = [(10, 'maj'), (5, 'maj'), (7, 'min'), (3, 'maj')] # Bb-F-Gm-Eb

# Bridge transition: subtle modulation chords between sections
PROG_BREAK_MOD = [(7, 'maj'), (7, 'sus4'), (7, 'min'), (5, 'maj')]  # G-Gsus4-Gm-F (key change!)

# Each section's structure (chord, bars-per-chord)
SECTIONS = [
    ('INTRO',    8,  PROG_G_MAJOR_MAJ7),
    ('BUILD1',   8,  PROG_G_MAJOR_MAJ7),
    ('DROP1',    16, PROG_G_MAJOR),
    ('BREAK',    8,  PROG_BREAK_MOD),
    ('CLIMAX',   16, PROG_G_MINOR),
    ('DROP2',    16, PROG_BB_MAJOR),
    ('OUTRO',    8,  PROG_G_MAJOR_MAJ7),
]

TOTAL_BARS = sum(b for _, b, _ in SECTIONS)


def get_section_at_bar(bar):
    """Returns (section_name, bar_in_section, total_section_bars, prog)"""
    accum = 0
    for name, length, prog in SECTIONS:
        if bar < accum + length:
            return name, bar - accum, length, prog
        accum += length
    return SECTIONS[-1][0], 0, SECTIONS[-1][1], SECTIONS[-1][2]


def get_chord_at_bar(bar):
    name, bib, length, prog = get_section_at_bar(bar)
    # 4 chords across the section → which chord index
    chord_idx = (bib * len(prog)) // length
    chord_idx = min(chord_idx, len(prog) - 1)
    return prog[chord_idx], name


# ============================================================================
# EXTRACT ESSENCE (3-Stimmen-Interpretation)
# ============================================================================

def extract_voices(src_path):
    """Extrahiere drei Stimmen aus dem Master-MIDI:
       - top:    höchste Note pro Beat (Melodie)
       - mid:    Mittelstimme (Counter-Melodie)
       - bass:   tiefste Note (Bass-Bewegung)
    Mit Quantize auf 8tel-Grid und Pitch-Class-Reduktion auf G-Dur-Skala."""
    mid = MidiFile(src_path)
    src_tpq = mid.ticks_per_beat
    scale = TPQ / src_tpq
    
    notes = []
    for tr in mid.tracks:
        active = {}; t = 0
        for msg in tr:
            t += msg.time
            if msg.type == 'note_on' and msg.velocity > 0:
                active[msg.note] = (t, msg.velocity)
            elif msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0):
                if msg.note in active:
                    s, v = active.pop(msg.note)
                    d = t - s
                    if d > 0:
                        notes.append((int(s*scale), int(d*scale), msg.note, v))
    
    # Quantize starts to 8th notes
    eighth = TPQ // 2
    notes = [(round(s/eighth)*eighth, max(d, eighth), p, v) for s,d,p,v in notes]
    
    # Filter very short
    notes = [n for n in notes if n[1] >= eighth]
    
    # Group by start tick
    by_tick = defaultdict(list)
    for n in notes:
        by_tick[n[0]].append(n)
    
    top, mid_voice, bass = [], [], []
    for t in sorted(by_tick.keys()):
        group = by_tick[t]
        group.sort(key=lambda n: n[2])  # by pitch ascending
        bass.append(group[0])
        top.append(group[-1])
        if len(group) >= 3:
            mid_voice.append(group[len(group)//2])
    
    # Scale-snap to G major: G A B C D E F# = pitch classes 7,9,11,0,2,4,6
    g_major_pcs = {7, 9, 11, 0, 2, 4, 6}
    def snap(p):
        if p % 12 in g_major_pcs:
            return p
        # Find nearest scale tone
        for offset in (1, -1, 2, -2):
            if (p + offset) % 12 in g_major_pcs:
                return p + offset
        return p
    
    top = [(s, d, snap(p), v) for s,d,p,v in top]
    mid_voice = [(s, d, snap(p), v) for s,d,p,v in mid_voice]
    bass = [(s, d, snap(p), v) for s,d,p,v in bass]
    
    print(f"  Voices extracted: top={len(top)}, mid={len(mid_voice)}, bass={len(bass)}")
    return top, mid_voice, bass


def make_phrase_from_voice(voice, target_bars=8):
    """Take essence voice and tile/loop it to fill target_bars."""
    if not voice:
        return []
    max_tick = max(s + d for s, d, _, _ in voice)
    src_bars = (max_tick // BAR) + 1
    
    out = []
    repeats = (target_bars // src_bars) + 1
    for r in range(repeats):
        offset = r * src_bars * BAR
        for s, d, p, v in voice:
            new_s = s + offset
            if new_s >= target_bars * BAR:
                break
            out.append((new_s, d, p, v))
    return out


# ============================================================================
# PATTERN GENERATORS (section-aware)
# ============================================================================

def drums_for_section(section, bar_in_section, bar_global):
    """Returns drum events for one bar, intensity scales with section."""
    events = []
    base = bar_global * BAR
    
    intensity = {
        'INTRO':   0.0,
        'BUILD1':  0.4,
        'DROP1':   1.0,
        'BREAK':   0.2,
        'CLIMAX':  0.8,   # build to drop2
        'DROP2':   1.2,   # peak
        'OUTRO':   0.5,
    }[section]
    
    if intensity < 0.1:
        # Intro: very sparse, only soft kick on 1
        events.append((base, Message('note_on', channel=9, note=KICK, velocity=70, time=0)))
        events.append((base + TPQ//4, Message('note_off', channel=9, note=KICK, velocity=0, time=0)))
        return events
    
    # KICK: 4-on-floor for everything except break
    kick_pattern = [1, 1, 1, 1] if intensity >= 0.4 else [1, 0, 1, 0]
    if section == 'BREAK':
        # Break: kick out, just soft kick on 1 and offbeat shaker
        if bar_in_section >= 6:
            # last 2 bars: build kick back
            kick_pattern = [1, 0, 1, 0]
        else:
            kick_pattern = [1, 0, 0, 0]
    
    for beat, hit in enumerate(kick_pattern):
        if hit:
            t = base + beat * TPQ
            vel = int(105 * min(1.0, intensity)) + (10 if beat == 0 else 0)
            vel = min(127, max(60, vel))
            events.append((t, Message('note_on', channel=9, note=KICK, velocity=vel, time=0)))
            events.append((t + TPQ//4, Message('note_off', channel=9, note=KICK, velocity=0, time=0)))
    
    if intensity < 0.3:
        return events
    
    # HATS: 8th notes
    for eighth in range(8):
        t = base + eighth * (TPQ//2)
        vel_base = 55 if eighth % 2 == 0 else 75
        vel = max(1, min(127, int(vel_base * min(1.2, intensity))))
        events.append((t, Message('note_on', channel=9, note=CLOSED_HAT, velocity=vel, time=0)))
        events.append((t + TPQ//8, Message('note_off', channel=9, note=CLOSED_HAT, velocity=0, time=0)))
    
    # OPEN HAT on offbeat of 2 and 4
    if intensity >= 0.5:
        for beat in (1, 3):
            t = base + beat * TPQ + TPQ//2
            events.append((t, Message('note_on', channel=9, note=OPEN_HAT, velocity=85, time=0)))
            events.append((t + TPQ//3, Message('note_off', channel=9, note=OPEN_HAT, velocity=0, time=0)))
    
    # CLAP/SNARE on 2 and 4
    if intensity >= 0.5:
        for beat in (1, 3):
            t = base + beat * TPQ
            events.append((t, Message('note_on', channel=9, note=CLAP, velocity=max(1, min(127, int(100*intensity))), time=0)))
            events.append((t + TPQ//4, Message('note_off', channel=9, note=CLAP, velocity=0, time=0)))
            if intensity >= 0.8:
                events.append((t, Message('note_on', channel=9, note=SNARE, velocity=70, time=0)))
                events.append((t + TPQ//4, Message('note_off', channel=9, note=SNARE, velocity=0, time=0)))
    
    # PERCUSSION/RIDE in high-intensity sections
    if intensity >= 1.0:
        # Ride on every quarter
        for beat in range(4):
            t = base + beat * TPQ + TPQ//4
            if beat in (1, 3):
                continue
            events.append((t, Message('note_on', channel=9, note=RIDE, velocity=50, time=0)))
            events.append((t + TPQ//4, Message('note_off', channel=9, note=RIDE, velocity=0, time=0)))
        # Tambourine offbeats
        for eighth in (1, 3, 5, 7):
            t = base + eighth * (TPQ//2)
            events.append((t, Message('note_on', channel=9, note=TAMBOURINE, velocity=45, time=0)))
            events.append((t + TPQ//4, Message('note_off', channel=9, note=TAMBOURINE, velocity=0, time=0)))
    
    # CRASH at section start (but not intro)
    if bar_in_section == 0 and section not in ('INTRO',):
        events.append((base, Message('note_on', channel=9, note=CRASH, velocity=110, time=0)))
        events.append((base + TPQ, Message('note_off', channel=9, note=CRASH, velocity=0, time=0)))
    
    # Snare-Roll buildup before drops (last 2 bars of BUILD1, BREAK, CLIMAX)
    last_bar_of_section = (bar_in_section >= 6 and section in ('BUILD1', 'BREAK', 'CLIMAX'))
    if last_bar_of_section and bar_in_section == 7:
        # Half-bar accelerating roll
        intervals = [TPQ//4]*4 + [TPQ//8]*8 + [TPQ//16]*8
        t_local = 0
        for i, iv in enumerate(intervals):
            if t_local >= BAR:
                break
            vel = 40 + int((i/len(intervals)) * 60)
            events.append((base + t_local, Message('note_on', channel=9, note=SNARE, velocity=vel, time=0)))
            events.append((base + t_local + iv//2, Message('note_off', channel=9, note=SNARE, velocity=0, time=0)))
            t_local += iv
    
    return events


def chord_pad(bar_global):
    """Soft pad playing the current chord, sustained."""
    (root, quality), section = get_chord_at_bar(bar_global)
    voicing = chord_pitches(root, quality, base_octave=4)
    # Pad only top 4 voices (skip bass)
    pad_notes = voicing[1:5]
    
    events = []
    base = bar_global * BAR
    # Sustain across full bar with smooth attack/release
    for n in pad_notes:
        events.append((base, Message('note_on', channel=4, note=n, velocity=55, time=0)))
        events.append((base + BAR - TPQ//4, Message('note_off', channel=4, note=n, velocity=0, time=0)))
    return events


def bass_for_section(section, bar_in_section, bar_global, prev_pitch=None):
    """Bass line: in DROPs = acid-style 16ths, in BREAK = sustained roots."""
    events = []
    base = bar_global * BAR
    (root, quality), _ = get_chord_at_bar(bar_global)
    bass_root = 24 + root  # C1 area
    while bass_root < 28: bass_root += 12
    while bass_root > 43: bass_root -= 12
    
    if section in ('INTRO', 'OUTRO'):
        # Sub bass drone on root, sustained
        events.append((base, Message('note_on', channel=0, note=bass_root, velocity=80, time=0)))
        events.append((base + BAR - TPQ//8, Message('note_off', channel=0, note=bass_root, velocity=0, time=0)))
        return events, bass_root
    
    if section == 'BREAK':
        # Half-note rhythm, more melodic (uses chord tones)
        chord_tones = chord_pitches(root, quality, base_octave=2)[:3]
        for i, beat in enumerate([0, 2]):
            t = base + beat * TPQ
            note = chord_tones[i % len(chord_tones)]
            events.append((t, Message('note_on', channel=0, note=note, velocity=85, time=0)))
            events.append((t + int(TPQ * 1.7), Message('note_off', channel=0, note=note, velocity=0, time=0)))
        return events, chord_tones[1]
    
    # DROP / BUILD / CLIMAX: 16th-note acid-style pattern
    # Pattern based on intensity
    intensity = {'BUILD1': 0.5, 'DROP1': 1.0, 'CLIMAX': 0.9, 'DROP2': 1.2}.get(section, 0.7)
    sixteenth = TPQ // 4
    
    # Chord-tone walk: root, 5th, root-octave, 3rd, sometimes b7
    chord_intervals = {
        'maj':  [0, 7, 12, 4, 0, 7, -1, 12],
        'min':  [0, 7, 12, 3, 0, 7, -2, 10],
        'dom7': [0, 7, 12, 4, 0, 10, -1, 7],
        'maj7': [0, 7, 12, 4, 0, 11, -1, 7],
        'min7': [0, 7, 12, 3, 0, 10, -2, 7],
        'sus4': [0, 7, 12, 5, 0, 7, -1, 12],
    }
    base_pattern = chord_intervals.get(quality, chord_intervals['maj'])
    
    # 16-step pattern: alternating rest/notes
    # Step types: 'p' = play, 'r' = rest, 'a' = accent, 's' = slide, 'g' = ghost
    if section == 'DROP2':
        steps = ['a', 'p', 'r', 'a', 'r', 'p', 'g', 'a',
                 'p', 's', 'r', 'a', 'r', 'p', 'a', 'p']
    elif section == 'CLIMAX':
        steps = ['a', 'r', 'p', 'a', 'p', 'r', 'p', 's',
                 'a', 'r', 'p', 'a', 'p', 'p', 'a', 'r']
    else:
        steps = ['a', 'r', 'p', 'r', 'a', 'r', 'p', 'a',
                 'r', 'p', 'r', 'a', 'r', 'p', 'a', 'r']
    
    last_pitch = bass_root
    for i, step in enumerate(steps):
        if step == 'r':
            continue
        t = base + i * sixteenth
        interval = base_pattern[i % len(base_pattern)]
        pitch = bass_root + interval
        
        vel = {'a': 115, 'p': 85, 'g': 55, 's': 100}[step]
        dur = int(sixteenth * (1.6 if step == 's' else 0.8))
        
        events.append((t, Message('note_on', channel=0, note=pitch, velocity=max(1, min(127, int(vel*intensity))), time=0)))
        events.append((t + dur, Message('note_off', channel=0, note=pitch, velocity=0, time=0)))
        last_pitch = pitch
        
        # Filter sweep via CC74
        if i % 2 == 0:
            cutoff_base = {'BUILD1': 50, 'DROP1': 85, 'CLIMAX': 75, 'DROP2': 105}.get(section, 70)
            cutoff = cutoff_base + int(15 * math.sin(i / 16 * math.pi * 2)) + random.randint(-5, 5)
            cutoff = max(20, min(127, cutoff))
            events.append((t, Message('control_change', channel=0, control=74, value=cutoff, time=0)))
    
    return events, last_pitch


def lead_for_section(section, bar_in_section, bar_global, essence_top, essence_mid):
    """Lead melody from essence material, scaled to current chord/key.
       In CLIMAX: doubled with counter-melody."""
    events = []
    base = bar_global * BAR
    
    if section == 'INTRO':
        return events
    
    (root, quality), _ = get_chord_at_bar(bar_global)
    is_minor_key = (section in ('BREAK', 'CLIMAX'))
    is_bb_key = (section == 'DROP2')
    
    # Get 4 essence notes for this bar
    essence_per_bar = max(1, len(essence_top) // 32)
    start_idx = (bar_global * essence_per_bar) % len(essence_top) if essence_top else 0
    
    bar_notes = []
    if essence_top:
        for i in range(8):
            n = essence_top[(start_idx + i) % len(essence_top)]
            bar_notes.append(n)
    
    # Place notes within this bar, quantized to 8ths
    # Use only "good" rhythmic positions (1, 1+, 2, 2+, 3, 3+, 4, 4+)
    positions = [0, TPQ//2, TPQ, TPQ + TPQ//2, 2*TPQ, 2*TPQ + TPQ//2, 3*TPQ, 3*TPQ + TPQ//2]
    
    # Phrase density depends on section
    n_notes_to_play = {
        'BUILD1': 3, 'DROP1': 5, 'BREAK': 2, 'CLIMAX': 6, 'DROP2': 5, 'OUTRO': 2
    }.get(section, 3)
    
    # Select positions
    chosen_positions = random.sample(positions, min(n_notes_to_play, len(positions)))
    chosen_positions.sort()
    
    # Choose pitches from chord tones + scale tones
    if quality in ('min', 'min7'):
        scale_intervals = [0, 2, 3, 5, 7, 8, 10, 12]  # natural minor
    elif quality == 'dom7':
        scale_intervals = [0, 2, 4, 5, 7, 9, 10, 12]  # mixolydian
    else:
        scale_intervals = [0, 2, 4, 5, 7, 9, 11, 12]  # major
    
    # Use the essence pitches but snap to current chord/scale
    melody_octave = 5
    if section == 'CLIMAX':
        melody_octave = 5  # mid range for tension
    elif section == 'DROP2':
        melody_octave = 6  # high for triumph
    
    for i, pos in enumerate(chosen_positions):
        # Pitch from essence, shifted to fit current chord
        ess_note = bar_notes[i % len(bar_notes)] if bar_notes else (0, 0, 60, 100)
        ess_pitch_class = ess_note[2] % 12
        
        # Find nearest scale tone of current chord/scale
        scale_pcs = [(root + iv) % 12 for iv in scale_intervals]
        best_pc = min(scale_pcs, key=lambda pc: min((pc - ess_pitch_class) % 12, (ess_pitch_class - pc) % 12))
        
        pitch = 12 * melody_octave + best_pc
        vel = 95 if pos in (0, 2*TPQ) else 80
        dur = TPQ // 2 if section in ('DROP1', 'DROP2', 'CLIMAX') else TPQ
        
        events.append((base + pos, Message('note_on', channel=1, note=pitch, velocity=vel, time=0)))
        events.append((base + pos + dur, Message('note_off', channel=1, note=pitch, velocity=0, time=0)))
        
        # Counter-melody in CLIMAX (a 3rd below)
        if section == 'CLIMAX':
            counter_pitch = pitch - 4 if quality.startswith('min') else pitch - 5
            events.append((base + pos, Message('note_on', channel=5, note=counter_pitch, velocity=70, time=0)))
            events.append((base + pos + dur, Message('note_off', channel=5, note=counter_pitch, velocity=0, time=0)))
        
        # Octave-double in DROP2 for triumph
        if section == 'DROP2' and vel > 85:
            events.append((base + pos, Message('note_on', channel=1, note=pitch - 12, velocity=70, time=0)))
            events.append((base + pos + dur, Message('note_off', channel=1, note=pitch - 12, velocity=0, time=0)))
    
    return events


def riser_for_buildup(bar_global, section, bar_in_section):
    """Pitched up-sweep before drops."""
    events = []
    # Risers before DROP1 (last 2 bars of BUILD1) and DROP2 (last 2 bars of CLIMAX)
    if section == 'BUILD1' and bar_in_section >= 6:
        progress = (bar_in_section - 6) / 2 + (1/16)
        n_steps = 32
        for i in range(n_steps):
            local_t = int((i / n_steps) * BAR)
            t = bar_global * BAR + local_t
            pitch = 36 + int((i / n_steps + progress) * 36)
            vel = 50 + int((i / n_steps) * 50)
            events.append((t, Message('note_on', channel=3, note=pitch, velocity=vel, time=0)))
            events.append((t + TPQ//8, Message('note_off', channel=3, note=pitch, velocity=0, time=0)))
    
    if section == 'CLIMAX' and bar_in_section >= 14:
        # Final epic riser before DROP2 — last 2 bars
        local_progress = (bar_in_section - 14) / 2
        n_steps = 48
        for i in range(n_steps):
            local_t = int((i / n_steps) * BAR)
            t = bar_global * BAR + local_t
            pitch = 30 + int((i / n_steps + local_progress) * 60)
            vel = 60 + int((i / n_steps) * 60)
            events.append((t, Message('note_on', channel=3, note=pitch, velocity=vel, time=0)))
            events.append((t + TPQ//8, Message('note_off', channel=3, note=pitch, velocity=0, time=0)))
    
    return events


# ============================================================================
# BUILD
# ============================================================================

def build():
    print(f"📡 Extracting voices from master MIDI...")
    top, mid_voice, bass = extract_voices(SRC_MASTER)
    
    print(f"\n🛠  Building track:")
    accum = 0
    for name, length, _ in SECTIONS:
        print(f"  bars {accum:3d}-{accum+length-1:3d}  {name}  ({length} bars)")
        accum += length
    print(f"  TOTAL: {TOTAL_BARS} bars = {TOTAL_BARS * 4 * 60 / BPM:.1f}s @ {BPM} BPM\n")
    
    mid_out = MidiFile(ticks_per_beat=TPQ)
    
    # Meta track
    meta = MidiTrack()
    meta.append(MetaMessage('set_tempo', tempo=int(60_000_000 / BPM), time=0))
    meta.append(MetaMessage('time_signature', numerator=4, denominator=4, time=0))
    meta.append(MetaMessage('track_name', name='LaBoom Techno v2', time=0))
    mid_out.tracks.append(meta)
    
    # Collect all events per layer
    drums_events = []
    pad_events = []
    bass_events = []
    lead_events = []
    counter_events = []  # channel 5 (countermelody)
    riser_events = []
    
    prev_bass = None
    for bar in range(TOTAL_BARS):
        name, bib, length, prog = get_section_at_bar(bar)
        
        drums_events.extend(drums_for_section(name, bib, bar))
        pad_events.extend(chord_pad(bar))
        be, prev_bass = bass_for_section(name, bib, bar, prev_bass)
        bass_events.extend(be)
        le = lead_for_section(name, bib, bar, top, mid_voice)
        # split lead and counter
        for t, msg in le:
            if msg.channel == 5:
                counter_events.append((t, msg))
            else:
                lead_events.append((t, msg))
        riser_events.extend(riser_for_buildup(bar, name, bib))
    
    print(f"  ✓ Drums:   {sum(1 for _,m in drums_events if m.type=='note_on')} hits")
    print(f"  ✓ Pad:     {sum(1 for _,m in pad_events if m.type=='note_on')} notes")
    print(f"  ✓ Bass:    {sum(1 for _,m in bass_events if m.type=='note_on')} notes")
    print(f"  ✓ Lead:    {sum(1 for _,m in lead_events if m.type=='note_on')} notes")
    print(f"  ✓ Counter: {sum(1 for _,m in counter_events if m.type=='note_on')} notes")
    print(f"  ✓ Riser:   {sum(1 for _,m in riser_events if m.type=='note_on')} notes")
    
    # Channel assignments:
    # 0 = bass (acid synth)
    # 1 = lead (square/saw lead)
    # 2 = riser sweep
    # 3 = riser (already 3)
    # 4 = pad (warm pad)
    # 5 = counter-melody (string)
    # 9 = drums
    
    # Bass
    bass_track = events_to_track(bass_events, name="Acid Bass")
    bass_track.insert(0, Message('program_change', channel=0, program=38, time=0))  # Synth Bass 1
    mid_out.tracks.append(bass_track)
    
    # Lead
    lead_track = events_to_track(lead_events, name="Lead Synth")
    lead_track.insert(0, Message('program_change', channel=1, program=81, time=0))  # Saw lead
    mid_out.tracks.append(lead_track)
    
    # Pad
    pad_track = events_to_track(pad_events, name="Pad")
    pad_track.insert(0, Message('program_change', channel=4, program=89, time=0))  # Warm pad
    mid_out.tracks.append(pad_track)
    
    # Counter melody (strings)
    counter_track = events_to_track(counter_events, name="Counter Melody")
    counter_track.insert(0, Message('program_change', channel=5, program=48, time=0))  # Strings
    mid_out.tracks.append(counter_track)
    
    # Riser
    riser_track = events_to_track(riser_events, name="Riser")
    riser_track.insert(0, Message('program_change', channel=3, program=84, time=0))  # Lead 5
    mid_out.tracks.append(riser_track)
    
    # Drums (channel 9)
    drum_track = events_to_track(drums_events, name="Drums")
    mid_out.tracks.append(drum_track)
    
    out_path = os.path.join(OUT_DIR, "laboom_TECHNO_v2.mid")
    mid_out.save(out_path)
    print(f"\n  🎶 SAVED → {out_path}")
    return out_path


def events_to_track(events, name="Track"):
    events = sorted(events, key=lambda x: (x[0], 0 if x[1].type == 'note_off' else 1))
    track = MidiTrack()
    track.append(MetaMessage('track_name', name=name, time=0))
    last_t = 0
    for t, msg in events:
        msg = msg.copy()
        msg.time = max(0, t - last_t)
        track.append(msg)
        last_t = t
    return track


if __name__ == '__main__':
    p = build()
    print(f"\n✅ Done: {p}")
