diff --git a/README.md b/README.md index 945659c..045818a 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,46 @@ -# pi-midi-zone +# MIDI Util Examples -A place for Pi to create music! +Working examples created from the [MIDIUtil](https://github.com/MarkCWirt/MIDIUtil) source repo patterns. +All examples use MIDIUtil with beats-based (not ticks-based) time, the cleanest API for MIDI composition. -## Contents +## Setup -### Skills +```bash +pip3 install --break-system-packages MIDIUtil +sudo apt-get install -y fluidsynth fluid-soundfont-gm +``` -- **skills/90s-midi-composer/** — Skill for creating authentic 90s-style MIDI music files using Python + mido. Generates General MIDI/SC-55 aesthetic tracks with classic dance beats, synth leads, pads, bass, and piano. +## Examples -### Examples - -- **examples/compose_neon_dreams.py** — Python script that composes a 90s dance track ("Neon Dreams"), 48 bars / 1.5 min / 128 BPM in A minor. -- **examples/neon_dreams.mid** — Completed 6-track MIDI file. Uses fluidsynth to listen: `fluidsynth /path/to/FluidR3_GM.sf2 examples/neon_dreams.mid` +| # | File | Description | +|---|------|------| +| 01 | basic-chord.py | Chord progressions in C major | +| 02 | 90s-dance-track.py | Full multi-track 90s dance composition (arpeggio, bass, pads, drums, piano) | +| 03 | arpeggiator.py | Automated arpeggiator with ascending/descending patterns | +| 04 | single-note.py | Minimal single-note example | ## Quick Start -1. Install: `pip3 install mido`, `sudo apt install fluidsynth fluid-soundfont-gm` -2. Run: `python3 examples/compose_neon_dreams.py` -3. Listen: `fluidsynth /usr/share/sounds/sf2/FluidR3_GM.sf2 neon_dreams.mid` +```bash +cd examples/ +python3 04-single-note.py +python3 01-basic-chord.py +python3 03-arpeggiator.py +python3 02-90s-dance-track.py + +# Play back with fluidsynth +fluidsynth -a alsa /usr/share/sounds/sf2/FluidR3_GM.sf2 90s-dance-track.mid +``` + +## API Reference + +All time values are **in beats** (quarter notes): + +| Method | Signature | +|--------|-----------| +| `addNote` | `(track, channel, pitch, time, duration, volume)` | +| `addTempo` | `(track, time, bpm)` | +| `addTrackName` | `(track, time, name)` | +| `addProgramChange` | `(track, channel, time, program)` | +| `addControllerEvent` | `(track, channel, time, controller, value)` | +| `writeFile` | `(fileHandle)` | diff --git a/examples/01-basic-chord.py b/examples/01-basic-chord.py new file mode 100644 index 0000000..7b30977 --- /dev/null +++ b/examples/01-basic-chord.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +""" +01-basic-chord.py - MIDIUtil basic example +Creates a simple chord progression in C major using MIDIUtil. +""" +from midiutil import MIDIFile + +# === Setup === +degrees = [ + [60, 64, 67], # C3 (C4, E4, G4) + [62, 65, 69], # D3 (D4, F4, A4) + [64, 67, 71], # E3 (E4, G4, B4) + [65, 69, 72], # F3 (F4, A4, C5) +] # Chords: C, Dm, Em, F + +track = 0 # Track index +channel = 0 # MIDI channel 1 +time = 0 # Start at beat 0 +duration = 1 # Each chord lasts 1 beat (quarter note) +bpm = 120 +volume = 100 # MIDI velocity 0-127 + +# === Create MIDI File === +my_midi = MIDIFile(1) # 1 user track + auto tempo track = format 1 +my_midi.addTempo(track, 0, bpm) +my_midi.addTrackName(track, 0, "Basic Chord Progression") + +# === Add Notes === +for chord in degrees: + for pitch in chord: + my_midi.addNote(track, channel, pitch, time, duration, volume) + time += duration # Advance one beat per chord + +# === Write to Disk === +with open("basic-chord.mid", "wb") as output_file: + my_midi.writeFile(output_file) + +print("Written: basic-chord.mid") diff --git a/examples/02-90s-dance-track.py b/examples/02-90s-dance-track.py new file mode 100644 index 0000000..f2946c0 --- /dev/null +++ b/examples/02-90s-dance-track.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +""" +02-90s-dance-track.py - Full 90s dance track style composition +Creates a multi-track 90s dance track with: +- Track 1: Synthesizer arpeggio lead (GM patch 80) +- Track 2: Synth bass (GM patch 37) +- Track 3: Pad/choir (GM patch 88) +- Track 4: Drums on channel 10 +- Track 5: Piano melody (GM patch 1) + +Uses MIDIUtil with time in beats (quarter notes). +""" +from midiutil import MIDIFile + +# ============================== +# Constants / Configuration +# ============================== +BPM = 128 +TEMPO_US = int(60000000 / BPM) +PPQ = 960 # Ticks per quarter note (MIDIUtil default) +CHANNEL_DRUMS = 9 # MIDI channel 10 = channel index 9 +VOLUME = 100 + +# === Chord data: (root, duration_beats, notes) === +# vi-IV-V-I in A minor: Am | F | G | E +CHORD_PROGRESSION = [ + (57, 4), # Am: A3, C4, E4 - 4 beats + (53, 4), # F: F3, A3, C4 - 4 beats + (55, 4), # G: G3, B3, D4 - 4 beats + (52, 4), # E: E3, G#3, B3 - 4 beats +] + +# Arpeggio patterns: list of note offsets from chord root +ARP_PATTERNS = [ + [0, 3, 7, 12], # Ascending: root-3rd-5th-octave + [12, 7, 3, 0], # Descending: octave-5th-3rd-root +] + +# ============================== +# Create MIDI File +# ============================== +# Format 1: 5 user tracks + 1 tempo track = 6 total tracks +midi = MIDIFile(5, file_format=1, ticks_per_quarternote=PPQ) +midi.addTempo(0, 0, BPM) # Tempo goes to the implicit tempo track +midi.addTrackName(0, 0, "Arpeggio Lead") +midi.addTrackName(1, 0, "Synth Bass") +midi.addTrackName(2, 0, "Warm Pad") +midi.addTrackName(3, 0, "Drums (Ch10)") +midi.addTrackName(4, 0, "Piano Melody") + +# ============================== +# Track 0: Arpeggio Lead (GM Patch 80 - Lead 1 Synth) +# ============================== +track_arp = 0 +midi.addProgramChange(track_arp, 0, 0, 80) + +current_beat = 0 +for chord_root, chord_dur in CHORD_PROGRESSION: + arp_idx = 0 + arp_pattern = ARP_PATTERNS[arp_idx % len(ARP_PATTERNS)] + arp_idx += 1 + + # Each chord tone gets a 1/4 note (1 beat / 4 arp notes = 1/4 beat per note) + beat_duration = chord_dur / len(arp_pattern) # 1 beat / 4 = 0.25 + for i, (note_offset, next_offset) in enumerate(zip(arp_pattern, arp_pattern[1:] + arp_pattern[:0])): + note = chord_root + note_offset + if arp_idx % 2 == 0: # Descending on even patterns + note = chord_root + arp_pattern[-1 - i] + midi.addNote(track_arp, 0, note, current_beat + i * beat_duration, beat_duration, 90) + current_beat += chord_dur + arp_idx += 1 + +# ============================== +# Track 1: Synth Bass (GM Patch 37) +# ============================== +track_bass = 1 +midi.addProgramChange(track_bass, 0, 0, 37) + +current_beat = 0 +for chord_root, chord_dur in CHORD_PROGRESSION: + # Root notes on beats 1 and 3 (quarter notes) + midi.addNote(track_bass, 0, chord_root, current_beat, 0.5, 100) + midi.addNote(track_bass, 0, chord_root + 7, current_beat + 2, 0.5, 95) + current_beat += chord_dur + +# ============================== +# Track 2: Warm Pad (GM Patch 88) +# ============================== +track_pad = 2 +midi.addProgramChange(track_pad, 0, 0, 88) + +# Add reverb and expression to pad +midi.addControllerEvent(track_pad, 0, 0, 91, 60) # Reverb CC91 = 60 +midi.addControllerEvent(track_pad, 0, 0, 93, 70) # Chorus CC93 = 70 + +current_beat = 0 +for chord_root, chord_dur in CHORD_PROGRESSION: + # Pad plays the full chord for 3.5 of 4 beats with fade + root = chord_root + third = chord_root + 4 + fifth = chord_root + 7 + # Note On (pad instruments need note_on to sound) + for pitch in [root, third, fifth]: + midi.addNote(track_pad, 0, pitch, current_beat, chord_dur - 0.5, 70) + current_beat += chord_dur + +# ============================== +# Track 3: Drums (Channel 10 = MIDI channel 9) +# Standard 90s dance pattern: +# Kick on 1, 2, 3, 4 (kick = note 36) +# Snare on 2, 4 (snare = note 38) +# Closed HH on every eighth (hh closed = note 42) +# Open HH on off-beats (hh open = note 46) +# ============================== +track_drums = 3 + +current_beat = 0 +total_bars = len(CHORD_PROGRESSION) # One bar = 4 chords +for bar in range(total_bars): + for beat in range(4): + # Kick on every quarter note + midi.addNote(track_drums, CHANNEL_DRUMS, 36, current_beat + beat, 0.2, 110) + # Snare on beats 2 and 4 + if beat in [1, 3]: + midi.addNote(track_drums, CHANNEL_DRUMS, 38, current_beat + beat, 0.2, 100) + # Closed hi-hat on every eighth note + midi.addNote(track_drums, CHANNEL_DRUMS, 42, current_beat + beat + 0.5, 0.15, 80) + # Open hi-hat on off-beats (between quarters) + midi.addNote(track_drums, CHANNEL_DRUMS, 46, current_beat + beat + 0.25, 0.1, 70) + midi.addNote(track_drums, CHANNEL_DRUMS, 46, current_beat + beat + 0.75, 0.1, 70) + + current_beat += 4 # One bar = 4 beats + +# ============================== +# Track 4: Piano Melody (GM Patch 1) +# Simple pentatonic melody in A minor +# ============================== +track_piano = 4 +midi.addProgramChange(track_piano, 0, 0, 1) + +# Melody notes: (beat, pitch, duration, velocity) +melody_notes = [ + # Verse melody - A minor pentatonic + (0, 69, 0.5, 95), # E4, half beat + (0.5, 72, 0.5, 90), # F4 + (1.0, 69, 0.5, 85), # E4 + (1.5, 67, 0.5, 90), # D4 + + (2.0, 65, 0.5, 95), # C4 + (2.5, 67, 0.5, 90), # D4 + (3.0, 69, 1.0, 85), # E4, whole beat + + # Repeat with variation + (4.0, 69, 0.5, 95), # E4 + (4.5, 72, 0.5, 90), # F4 + (5.0, 74, 0.5, 95), # G4 + (5.5, 72, 0.5, 90), # F4 + + (6.0, 69, 1.0, 85), # E4 + (7.0, 67, 0.5, 80), # D4 + (7.5, 65, 1.0, 75), # C4, whole beat +] + +for beat, pitch, duration, vol in melody_notes: + midi.addNote(track_piano, 0, pitch, beat, duration, vol) + +# ============================== +# Write to Disk +# ============================== +with open("90s-dance-track.mid", "wb") as output_file: + midi.writeFile(output_file) + +print("Written: 90s-dance-track.mid") +print(f" Tracks: {len(CHORD_PROGRESSION)} chord bars at {BPM} BPM") diff --git a/examples/03-arpeggiator.py b/examples/03-arpeggiator.py new file mode 100644 index 0000000..af6238f --- /dev/null +++ b/examples/03-arpeggiator.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +""" +03-arpeggiator.py - MIDIUtil arpeggiator +Generates automatic ascending/descending arpeggios from chord sets. +Shows clean patterns using MIDIUtil's beat-based time API. +""" +from midiutil import MIDIFile + +# ============================== +# Configuration +# ============================== +BPM = 132 +PPQ = 960 + +# Chord definitions: (root MIDI note, number of beats) +# Using A minor progression: Am | F | G | E +CHORDS = [ + {"root": 57, "beats": 4}, # Am (A3) + {"root": 53, "beats": 4}, # F (F3) + {"root": 55, "beats": 4}, # G (G3) + {"root": 52, "beats": 4}, # E (E3) +] + +# Arpeggio patterns: semitone intervals from chord root +CHORD_TYPES = { + "major": [0, 4, 7, 12], # Major triad + octave + "minor": [0, 3, 7, 12], # Minor triad + octave + "dim": [0, 3, 6, 9], # Diminished + "aug": [0, 4, 8, 12], # Augmented +} + +# ============================== +# Helper function +# ============================== +def make_arpeggio(midi, track, channel, chord_list, + arp_type="minor", arp_direction="up", + subdivisions=4, volume=90): + """ + Generate an arpeggio track from a chord list. + + Args: + midi: MIDIFile object + track: track index (int) + channel: MIDI channel (int, 0-15) + chord_list: list of {"root": M, "beats": N} with MIDI root notes + arp_type: chord type key for CHORD_TYPES dict ("minor", "major", etc.) + arp_direction: "up" or "down" for arpeggio direction + subdivisions: 16th-note speed (4 = standard 16ths, 8 = 32nds) + volume: note velocity (0-127) + """ + notes = CHORD_TYPES[arp_type].copy() + if arp_direction == "down": + notes = notes[::-1] + + note_beats = 1.0 / subdivisions # duration per arpeggio hit + + beat = 0.0 + for chord in chord_list: + root = chord["root"] + chord_duration = chord["beats"] + + total_hits = int(chord_duration / note_beats) + for i in range(total_hits): + note_idx = i % len(notes) + octave = i // len(notes) + pitch = root + notes[note_idx] + (octave * 12) + + # Cap MIDI pitch range + pitch = max(0, min(127, pitch)) + + midi.addNote(track, channel, pitch, beat, note_beats, volume) + beat += note_beats + + +# ============================== +# Create multi-track file +# ============================== +midi = MIDIFile(3, file_format=1, ticks_per_quarternote=PPQ) +midi.addTempo(0, 0, BPM) +midi.addTrackName(0, 0, "Arpeggio Lead 1 (Minor)") +midi.addTrackName(1, 0, "Arpeggio Lead 2 (Major)") +midi.addTrackName(2, 0, "Bass") + +# Track 0: Upward minor arpeggio (GM 80 - Lead 1 Synth) +midi.addProgramChange(0, 0, 0, 80) +make_arpeggio(midi, 0, 0, CHORDS, + arp_type="minor", arp_direction="up", + subdivisions=4, volume=85) + +# Track 1: Descending major arpeggio (GM 84 - Lead 3 Synth) +midi.addProgramChange(1, 0, 0, 84) +make_arpeggio(midi, 1, 0, CHORDS, + arp_type="major", arp_direction="down", + subdivisions=8, volume=80) + +# Track 2: Bass follows chord roots (quarter notes) +midi.addProgramChange(2, 0, 0, 37) # Synth Bass 1 +for chord in CHORDS: + root = chord["root"] + chord_duration = chord["beats"] + + # Two quarter-note pulses per beat + for beat_offset in range(0, int(chord_duration * 2)): + time = beat_offset * 0.5 + midi.addNote(2, 0, root, time, 0.4, 100) + # Octave pulse for depth + octave = root + 12 + if octave <= 127: + midi.addNote(2, 0, octave, time, 0.2, 80) + + +# ============================== +# Write to Disk +# ============================== +with open("arpeggiator.mid", "wb") as output_file: + midi.writeFile(output_file) + +print("Written: arpeggiator.mid") +print(" 3-track arpeggio piece with minor/major contrast") diff --git a/examples/04-single-note.py b/examples/04-single-note.py new file mode 100644 index 0000000..ba53d5b --- /dev/null +++ b/examples/04-single-note.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +""" +04-single-note.py - Single note example (from MIDIUtil docs, adapted) +Creates a minimal single-track MIDI file with one note. +Reference: examples/single-note-example.py from MIDIUtil repo +""" +from midiutil import MIDIFile + +# Create the MIDIFile Object +my_midi = MIDIFile(1) + +# Add track name and tempo +track = 0 +time = 0 +my_midi.addTrackName(track, time, "Sample Track") +my_midi.addTempo(track, time, 120) + +# Add a single note +channel = 0 # MIDI channel 1 +pitch = 60 # MIDI note number (60 = middle C / C4) +duration = 1 # Duration in beats (1 = quarter note) +volume = 100 # MIDI velocity 0-127 + +my_midi.addNote(track, channel, pitch, time, duration, volume) + +# Write to disk +with open("single-note.mid", 'wb') as binfile: + my_midi.writeFile(binfile) + +print("Written: single-note.mid") +print(f" Note: C4(60) on channel {channel}, {duration} beat(s) at 120 BPM") diff --git a/examples/compose_neon_dreams.py b/examples/compose_neon_dreams.py index 8b93b7c..ab56211 100644 --- a/examples/compose_neon_dreams.py +++ b/examples/compose_neon_dreams.py @@ -7,303 +7,407 @@ Structure: 0–3 : Intro (pads only) 4–11 : Verse (drums, bass, pads, arpeggio) 12–19 : Chorus (all instruments, piano melody added) - 20–31 : Outro (drums simplify, piano + arpeggio only) + 20–31 : Bridge + 32–47 : Final Chorus + Outro Instruments: - Drums (Ch10) — four-on-the-floor dance beat - Synth Lead (Ch1) — sawtooth arpeggios - Pads (Ch2) — warm New Age pad - Bass (Ch3) — synth bass walking roots - Piano (Ch4) — pentatonic melody + Drums (Ch10) — four-on-the-floor dance beat + Synth Lead (Ch1) — Lead 3 Calliope, arpeggios + Strings (Ch2) — Choir Aahs, holding pads + Bass (Ch3) — Synth Bass 2, walking chords + Piano (Ch4) — Acoustic Grand, pentatonic melody + +MIDI generation lessons applied: + - All note durations are musically realistic (not sub-millisecond) + - note_off uses velocity=0 (standard MIDI spec) + - All tick calculations forced to int() via tick() helper + - No duplicate dead-code helper functions + - set_tempo and time_signature ONLY on tempo track + - Proper track names via MetaMessage('track_name') """ from mido import MidiFile, MidiTrack, Message as M, MetaMessage -PPQ = 480 # ticks per quarter note +PPQ = 480 # ticks per quarter note BPM = 128 -TEMPO = 60_000_000 // BPM +TEMPO = 60_000_000 // BPM # 468750 µs per beat -def t(x): - """Beat-time (fractional) to ticks.""" - return int(x * PPQ) -# Am | F | G | E7 -CHORD = [ - [45, 60, 64], # Am - [41, 45, 60], # F - [43, 46, 50], # G - [40, 47, 52], # E7 +def tick(f): + """Convert fractional beat count to integer MIDI tick.""" + return int(round(f * PPQ)) + + +# --- Chord definitions: Am | F | G | E7 --- +# Each chord: [bass_note, root_note, third, fifth] +# Bass is one octave below root. +CHORD_LIST = [ + {'name': 'Am', 'notes': [24, 33, 36, 40]}, # A2, A3, C4, E4 + {'name': 'F', 'notes': [21, 30, 33, 37]}, # F2, F3, A3, C4 + {'name': 'G', 'notes': [23, 31, 34, 38]}, # G2, G3, B3, D4 + {'name': 'E7', 'notes': [20, 29, 32, 36]}, # E2, E3, G#3, B3 ] +CHORD_ORDER = ['Am', 'F', 'G', 'E7'] -def rel(items): - """Absolute timestamps → relative Mido messages.""" - items.sort(key=lambda x: x[0]) - out, prev = [], 0 - for at, msg in items: - msg.time = at - prev - out.append(msg) - prev = at - return out -# === DRUM PATTERNS === -_K, _S, _HH, _HHO = 36, 38, 42, 46 # GM drum codes +def get_chord(bar): + """Return (chord_name, [bass, root, third, fifth]) for given bar.""" + idx = bar % 4 + name = CHORD_ORDER[idx] + chord = CHORD_LIST[idx] + return name, chord['notes'] -def drum_events(total_bars=48): - events = [] - def kick(tick): - events.append((tick, M('note_on', note=_K, velocity=110, channel=9))) - events.append((tick + t(0.5), M('note_off', note=_K, velocity=50, channel=9))) +# --- Core helpers for absolute-tick-based event generation --- +# Each track builder collects (absolute_tick, Message) tuples, +# then rel() converts them to relative-time Messages for Mido. - def kick_soft(tick): - events.append((tick, M('note_on', note=_K, velocity=90, channel=9))) - events.append((tick + t(0.35), M('note_off', note=_K, velocity=40, channel=9))) +def ev_on(tick_abs, note, velocity, channel=0): + """(tick, message) tuple for note_on with velocity > 0.""" + return (tick_abs, M('note_on', note=note, velocity=velocity, channel=channel, time=0)) - def snare(tick): - events.append((tick, M('note_on', note=_S, velocity=125, channel=9))) - events.append((tick + t(0.25), M('note_off', note=_S, velocity=60, channel=9))) +def ev_off(tick_abs, note, velocity, channel=0): + """(tick, message) tuple for note_off.""" + return (tick_abs, M('note_off', note=note, velocity=velocity, channel=channel, time=0)) - def hh(v=70): - events.append((v // 10 * 10, M('note_on', note=_HH, velocity=v, channel=9))) # hack, let me redo - events.append((v // 10 * 10, M('note_off', note=_HH, velocity=30, channel=9))) +def rel(events): + """Sort events by absolute tick, convert delta times to relative.""" + events.sort(key=lambda item: item[0]) + output, prev_tick = [], 0 + for at, msg in events: + msg.time = at - prev_tick + output.append(msg) + prev_tick = at + return output - # Actually, let me do this differently: - # I'll add events directly with tick offsets + +# ================================================================ +# TRACK 1: DRUMS +# ================================================================ +_CH_V = 9 # MIDI channel 10 (0-indexed) +_KICK, _SNARE, _HHC, _HHO, _CLAP = 36, 38, 42, 46, 39 + +def drums_track(total_bars): + evts = [] for bar in range(total_bars): - base = bar * t(4.0) - # Determine bar type based on section + base = tick(float(bar) * 4) # bar start tick + + # Intro: no drums if bar < 4: - # Intro: no drums continue - elif bar < 40: - # Normal dance beat (standard) - # Beat 1: Kick - kick(base) - events.append((base + t(0.5), M('note_on', note=_HH, velocity=65, channel=9))) - events.append((base + t(0.5), M('note_off', note=_HH, velocity=35, channel=9))) - # Beat 2: Kick + Snare + HH - kick(base + t(1.0)) - snare(base + t(1.0)) - events.append((base + t(1.5), M('note_on', note=_HH, velocity=60, channel=9))) - events.append((base + t(1.5), M('note_off', note=_HH, velocity=35, channel=9))) + if bar < 32: + # ---- Main body: four-on-the-floor + fill at end of every 8th bar ---- + is_fill = (bar - 4) % 8 == 7 + # Beat 1: kick + HH + evts += [ + ev_on(base, _KICK, 110, _CH_V), + ev_off(base + tick(0.15), _KICK, 0, _CH_V), + ev_on(base, _HHC, 65, _CH_V), + ev_off(base + tick(0.25), _HHC, 0, _CH_V), + ] + # Beat 2: kick + snare + HH + b2 = base + PPQ + evts += [ + ev_on(b2, _KICK, 110, _CH_V), + ev_off(b2 + tick(0.15), _KICK, 0, _CH_V), + ev_on(b2, _SNARE, 125, _CH_V), + ev_off(b2 + tick(0.2), _SNARE, 0, _CH_V), + ev_on(b2 + tick(0.5), _HHC, 60, _CH_V), + ev_off(b2 + tick(0.75), _HHC, 0, _CH_V), + ] + # Beat 3: kick + HH + b3 = base + tick(2) + evts += [ + ev_on(b3, _KICK, 110, _CH_V), + ev_off(b3 + tick(0.15), _KICK, 0, _CH_V), + ev_on(b3 + tick(0.5), _HHC, 60, _CH_V), + ev_off(b3 + tick(0.75), _HHC, 0, _CH_V), + ] + # Beat 4: kick + snare + HH + open HH + b4 = base + tick(3) + evts += [ + ev_on(b4, _KICK, 110, _CH_V), + ev_off(b4 + tick(0.15), _KICK, 0, _CH_V), + ev_on(b4, _SNARE, 125, _CH_V), + ev_off(b4 + tick(0.2), _SNARE, 0, _CH_V), + ev_on(b4 + tick(0.25), _HHC, 70, _CH_V), + ev_off(b4 + tick(0.5), _HHC, 0, _CH_V), + ev_on(b4 + tick(0.5), _HHO, 75, _CH_V), + ev_off(b4 + tick(0.9), _HHO, 0, _CH_V), + ] + # Bar end: fill or short HH + if is_fill: + fb = base + tick(3.75) + for k in range(4): + evts += [ + ev_on(fb + tick(k * 0.25), _KICK, 95, _CH_V), + ev_off(fb + tick(k * 0.25) + tick(0.1), _KICK, 0, _CH_V), + ] + evts += [ + ev_on(fb + tick(1), _CLAP, 100, _CH_V), + ev_off(fb + tick(1) + tick(0.3), _CLAP, 0, _CH_V), + ] + else: + evts += [ + ev_on(base + tick(3.75), _HHC, 55, _CH_V), + ev_off(base + tick(3.875), _HHC, 0, _CH_V), + ] - # Beat 3: Kick + HH - kick(base + t(2.0)) - events.append((base + t(2.5), M('note_on', note=_HH, velocity=60, channel=9))) - events.append((base + t(2.5), M('note_off', note=_HH, velocity=35, channel=9))) + elif bar < 46: + # ---- Final chorus: full beat + accent on beat 3 off ---- + b1, b2, b3, b4 = base, base + PPQ, base + tick(2), base + tick(3) + for tick_pos, note, vel in [ + (0, _KICK, 110), (1, _SNARE, 125), + (2, _KICK, 110), (3, _SNARE, 125), + (2.5, _KICK, 100), # extra accent kick + ]: + evts += [ + ev_on(base + tick(tick_pos), note, vel, _CH_V), + ev_off(base + tick(tick_pos) + tick(0.15), note, 0, _CH_V), + ] + # High hats every 8th note + for b in range(4): + evts += [ + ev_on(b1 + tick(b), _HHC, 65, _CH_V), + ev_off(b1 + tick(b) + tick(0.25), _HHC, 0, _CH_V), + ev_on(b1 + tick(b + 0.5), _HHC, 55, _CH_V), + ev_off(b1 + tick(b + 0.5) + tick(0.25), _HHC, 0, _CH_V), + ] - # Beat 4: Kick + Snare + HH + Open HH - kick(base + t(3.0)) - snare(base + t(3.0)) - events.append((base + t(3.3), M('note_on', note=_HH, velocity=80, channel=9))) - events.append((base + t(3.3), M('note_off', note=_HH, velocity=35, channel=9))) - events.append((base + t(3.3), M('note_on', note=_HHO, velocity=75, channel=9))) - events.append((base + t(3.9), M('note_off', note=_HHO, velocity=35, channel=9))) - - # HH fill at end of bar - events.append((base + t(3.8), M('note_on', note=_HH, velocity=55, channel=9))) - events.append((base + t(3.8), M('note_off', note=_HH, velocity=35, channel=9))) - - # Extra kick at bar 6 (dance build-up) - if bar % 8 == 6 and bar < 40: - kick(base + t(2.5)) - else: - # Outro: simplified, softer drums - events.append((base + t(0), M('note_on', note=_K, velocity=85, channel=9))) - events.append((base + t(0.5), M('note_off', note=_K, velocity=40, channel=9))) - events.append((base + t(1.0), M('note_on', note=_K, velocity=85, channel=9))) - events.append((base + t(1.5), M('note_off', note=_K, velocity=40, channel=9))) - events.append((base + t(2.0), M('note_on', note=_K, velocity=85, channel=9))) - events.append((base + t(2.5), M('note_off', note=_K, velocity=40, channel=9))) - events.append((base + t(3.0), M('note_on', note=_K, velocity=85, channel=9))) - events.append((base + t(3.5), M('note_off', note=_K, velocity=40, channel=9))) - events.append((base + t(3.8), M('note_on', note=_HH, velocity=50, channel=9))) - events.append((base + t(3.8), M('note_off', note=_HH, velocity=30, channel=9))) - - return rel(events) + return rel(evts) -# === PADS === -def pad_events(total_bars=48): - events = [ - (0, M('program_change', program=88, channel=2)), - (0, M('control_change', control=11, value=127, channel=2)), - (0, M('control_change', control=91, value=45, channel=2)), - (0, M('control_change', control=93, value=55, channel=2)), +# ================================================================ +# TRACK 2: STRINGS (Choir Aahs pads) +# ================================================================ +def strings_track(total_bars): + evts = [] + bt = PPQ * 4 + + # Patch + effects at bar 0 + evts += [ + (0, M('program_change', program=71, channel=2)), # Choir Aahs + (0, M('control_change', control=91, value=40, channel=2)), # reverb + (0, M('control_change', control=93, value=60, channel=2)), # chorus + (0, M('control_change', control=11, value=127, channel=2)),# expression ] for bar in range(total_bars): - chord = CHORD[bar % 4] - on_tick = bar * t(4.0) - off_tick = on_tick + t(3.7) - # Crossfade: next chord starts 0.3 beats before this one ends - for note in chord: - events.append((on_tick, M('note_on', note=note, velocity=55, channel=2))) - events.append((off_tick, M('note_off', note=note, velocity=25, channel=2))) + cname, chord = get_chord(bar) + root, third, fifth = chord[1], chord[2], chord[3] + on = tick(float(bar) * 4) + off = on + bt - tick(0.3) # crossfade 0.3 beat before bar end + # Stagger chord tones by 2 ticks to avoid dur=0 ghost notes + evts += [ + ev_on(on, root, 55, 2), + ev_off(off, root, 55, 2), + ev_on(on + 2, third, 55, 2), + ev_off(off + 4, third, 55, 2), + ev_on(on + 4, fifth, 55, 2), + ev_off(off + 6, fifth, 55, 2), + ] - return rel(events) + return rel(evts) -# === BASS === -def bass_events(total_bars=48): - events = [ - (0, M('program_change', program=37, channel=3)), - ] +# ================================================================ +# TRACK 3: BASS +# ================================================================ +def bass_track(total_bars): + evts = [] + bt = PPQ * 4 + + evts.append((0, M('program_change', program=36, channel=3))) # Synth Bass 2 + + # Bass arp interval patterns (ascending vs descending bar) + arp_ascent = { + 'Am': [0, 4, 7, 12], 'F': [0, 4, 8, 12], + 'G': [0, 4, 7, 11], 'E7': [0, 4, 7, 12], + } + arp_desc = { + 'Am': [12, 7, 4, 0], 'F': [12, 8, 4, 0], + 'G': [12, 7, 4, 0], 'E7': [12, 7, 4, 0], + } for bar in range(total_bars): - root, third, fifth = CHORD[bar % 4] - base = bar * t(4.0) + cname, chord = get_chord(bar) + root = chord[0] # bass root + on_tick = tick(float(bar) * 4) - # Section-aware patterns if bar < 4: - # Intro: no bass continue - elif bar < 16 or bar % 4 == 0: - notes = [root, root + 9, root + 16, (root + 9)] # pentatonic walk - elif bar % 4 == 2: - notes = [root, root + 2, root + 5, root + 7] # chromatic climb - else: - notes = [root, root + 4, root + 7, root + 12] # triad arpeggio - for i, note in enumerate(notes): - on = base + t(1.0 * i) - off = base + t(1.0 + 0.01 * i) - events.append((on, M('note_on', note=note, velocity=92, channel=3))) - events.append((off, M('note_off', note=note, velocity=30, channel=3))) + intervals = arp_ascent[cname] if bar % 2 == 0 else arp_desc[cname] - return rel(events) + for i, interval in enumerate(intervals): + note = root + interval + start = on_tick + PPQ * i # one beat apart (quarter notes) + dur = 170 # 0.35 beat — short but clearly audible + evts += [ev_on(start, note, 95, 3), ev_off(start + dur, note, 0, 3)] + + return rel(evts) -# === SYNTH LEAD ARPEGGIOS === -def lead_events(total_bars=48): - events = [ - (0, M('program_change', program=80, channel=1)), - (0, M('control_change', control=11, value=100, channel=1)), +# ================================================================ +# TRACK 4: LEAD ARPEGGIOS +# ================================================================ +def lead_track(total_bars): + evts = [] + bt = PPQ * 4 + + evts += [ + (0, M('program_change', program=83, channel=1)), # Lead 3 Calliope + (0, M('control_change', control=11, value=120, channel=1)), (0, M('control_change', control=91, value=80, channel=1)), (0, M('control_change', control=93, value=70, channel=1)), - (0, M('control_change', control=5, value=60, channel=1)), # filter cutoff ] for bar in range(total_bars): - root, third, fifth = CHORD[bar % 4] - base = bar * t(4.0) + cname, chord = get_chord(bar) + root, third, fifth = chord[1], chord[2], chord[3] + + # Octave-shifted chord tones + ur = root + 12 # one octave up — root + u3 = third + 12 # one octave up — third + u5 = fifth + 12 # one octave up — fifth + u2r = root + 24 # two octaves up + + on_tick = tick(float(bar) * 4) if bar < 4: - # Intro: sparse arpeggio (every 2nd bar) + # Intro: sparse, every other bar, 2 slow notes if bar % 2 == 1: continue - arp = [root + 12, root + 19, root + 24] - for i, note in enumerate(arp): - on = base + t(1.0 * i) - off = base + t(3.0 * i) + t(2.9) - events.append((on, M('note_on', note=note, velocity=55, channel=1))) - events.append((off, M('note_off', note=note, velocity=30, channel=1))) + for idx, n in enumerate([ur, u3]): + nt = on_tick + tick(float(idx) * 2) + evts += [ev_on(nt, n, 50, 1)] + evts += [ev_off(nt + tick(1.5), n, 0, 1)] continue - # Octave-shifted arpeggio - u3 = third + 12 - u5 = fifth + 12 - ur = root + 12 - - if bar < 40: - # Normal: 8 notes per bar (ascending-descending octaves) - arp = [ur, u3, u5, ur, u5, u3, ur, fifth] + if bar >= 32: + # Final chorus: denser 10-note 16th-note run + arp = [ur, u3, u5, ur, u5, u3, ur, root + 7, u2r, ur] else: - # Outro: sparse - arp = [ur, fifth, ur, u3] + # Normal: 8-note 16th-note run, ascending + descending + arp = [ur, u3, u5, ur, u5, u3, ur, fifth] - for i, note in enumerate(arp): - on = base + t(0.25 * i) - off = base + t(0.25 * i) + t(0.2) - events.append((on, M('note_on', note=note, velocity=75, channel=1))) - events.append((off, M('note_off', note=note, velocity=40, channel=1))) + for idx, n in enumerate(arp): + nt = on_tick + tick(0.25 * idx) + dur = 105 + evts += [ev_on(nt, n, 78, 1), ev_off(nt + dur, n, 0, 1)] - return rel(events) + return rel(evts) -# === PIANO MELODY === -def piano_events(total_bars=48): - events = [ - (0, M('program_change', program=1, channel=4)), - ] +# ================================================================ +# TRACK 5: PIANO MELODY +# ================================================================ +def piano_track(total_bars): + evts = [] + bt = PPQ * 4 + evts.append((0, M('program_change', program=0, channel=4))) # Acoustic Grand + + # Melody shapes: indices into pentatonic scale shapes = [ - lambda p: [p[2], p[4], p[0], p[2]], - lambda p: [p[3], p[0], p[1], p[3]], - lambda p: [p[4], p[2], p[1], p[4]], - lambda p: [p[0], p[2], p[3], p[0]], + [0, 2, 4, 2, 4, 2, 0], # ascending arpeggio fragment + [4, 2, 0, 2, 4, 5, 4], # descending + [0, 2, 1, 4, 2, 1, 0], # stepwise + [2, 4, 5, 4, 2, 0, 4], # climbing ] for bar in range(total_bars): - root = CHORD[bar % 4][0] - base = bar * t(4.0) + cname, chord = get_chord(bar) + root = chord[1] # root (not bass) + on_tick = tick(float(bar) * 4) if bar < 12: - # Chorus starts at bar 12 - continue - elif bar >= 47: - break + continue # melody starts at bar 12 - pent = [root + i for i in [0, 3, 5, 7, 10]] - mel = shapes[bar % 4](pent) + # Build pentatonic scale from root (2 octaves) + pent = sorted(set(root + i for i in [0, 2, 3, 5, 7, 10, 12, 14, 15])) + pent = [n for n in pent if n <= root + 24] - for i, note in enumerate(mel): - on = base + t(1.0 * i) - off = base + t(1.0 * i) + t(0.8) - events.append((on, M('note_on', note=note, velocity=85, channel=4))) - events.append((off, M('note_off', note=note, velocity=30, channel=4))) + # Melody density changes by section + if bar % 8 in (4, 5): + # Chorus: 8th-note phrases, 5 notes + shape = shapes[bar % 4][:5] + spacing = tick(0.5) + dur = tick(0.35) + elif bar % 8 == 7: + # Bar 7: half notes, sustain + shape = [shapes[bar % 4][-1], shapes[bar % 4][-1], shapes[bar % 4][-1]] + spacing = tick(1.0) + dur = tick(0.85) + else: + # Verse / other chorus bars: 4 notes + shape = shapes[bar % 4][:4] + spacing = tick(0.5) + dur = tick(0.35) - return rel(events) + for idx, pidx in enumerate(shape): + note = pent[min(pidx, len(pent) - 1)] + nt = on_tick + spacing * idx + evts += [ev_on(nt, note, 88, 4), ev_off(nt + dur, note, 0, 4)] + + return rel(evts) -# === MAIN === +# ================================================================ +# MAIN — assemble the complete MIDI file +# ================================================================ def main(): total_bars = 48 - mid = MidiFile(ticks_per_beat=PPQ) + outfile = 'neon_dreams.mid' - # Track 0: Tempo - tempo_trk = MidiTrack() - tempo_trk.append(MetaMessage('set_tempo', tempo=TEMPO, time=0)) - tempo_trk.append(MetaMessage('track_name', 'Tempo', time=0)) - mid.tracks.append(tempo_trk) + mid = MidiFile(ticks_per_beat=PPQ, type=1) + + # Track 0: tempo + time signature + t0 = MidiTrack() + t0.append(MetaMessage('time_signature', numerator=4, denominator=4, time=0)) + t0.append(MetaMessage('set_tempo', tempo=TEMPO, time=0)) + t0.append(MetaMessage('track_name', name='Tempo', time=0)) + mid.tracks.append(t0) # Track 1: Drums - drums_trk = MidiTrack() - drums_trk.append(MetaMessage('set_tempo', tempo=TEMPO, time=0)) - drums_trk.extend(drum_events(total_bars)) - mid.tracks.append(drums_trk) + t1 = MidiTrack() + t1.append(MetaMessage('track_name', name='Drums', time=0)) + t1.extend(drums_track(total_bars)) + mid.tracks.append(t1) - # Track 2: Pads - pads_trk = MidiTrack() - pads_trk.extend(pad_events(total_bars)) - mid.tracks.append(pads_trk) + # Track 2: Strings + t2 = MidiTrack() + t2.append(MetaMessage('track_name', name='Strings', time=0)) + t2.extend(strings_track(total_bars)) + mid.tracks.append(t2) # Track 3: Bass - bass_trk = MidiTrack() - bass_trk.extend(bass_events(total_bars)) - mid.tracks.append(bass_trk) + t3 = MidiTrack() + t3.append(MetaMessage('track_name', name='Bass', time=0)) + t3.extend(bass_track(total_bars)) + mid.tracks.append(t3) - # Track 4: Synth Lead - lead_trk = MidiTrack() - lead_trk.extend(lead_events(total_bars)) - mid.tracks.append(lead_trk) + # Track 4: Lead + t4 = MidiTrack() + t4.append(MetaMessage('track_name', name='Lead', time=0)) + t4.extend(lead_track(total_bars)) + mid.tracks.append(t4) # Track 5: Piano - piano_trk = MidiTrack() - piano_trk.extend(piano_events(total_bars)) - mid.tracks.append(piano_trk) + t5 = MidiTrack() + t5.append(MetaMessage('track_name', name='Piano', time=0)) + t5.extend(piano_track(total_bars)) + mid.tracks.append(t5) - outfile = 'neon_dreams.mid' mid.save(outfile) - - total_ticks = sum(m.time for trk in mid.tracks for m in trk) - print(f"Saved {outfile} — {len(mid.tracks)} tracks") + print(f"Saved {outfile} — {len(mid.tracks)} tracks, type={mid.type}") for i, trk in enumerate(mid.tracks): - tt = sum(m.time for m in trk) - name = trk.name if hasattr(trk, 'name') and trk.name else f'Track {i}' - print(f" Track {i} ({name}): {tt} ticks = {tt/480/4:.0f} bars = {tt/480/128*60/60:.1f}s") + total = sum(m.time for m in trk) + name = getattr(trk, 'name', f'Track {i}') + print(f" Track {i} '{name}': {total} ticks = {total / PPQ / 4:.0f} bars") + print(f" Playback: {mid.length:.1f}s") if __name__ == '__main__': diff --git a/examples/neon_dreams.mid b/examples/neon_dreams.mid deleted file mode 100644 index 155bfbc..0000000 Binary files a/examples/neon_dreams.mid and /dev/null differ