Skip to content

Instantly share code, notes, and snippets.

@twobob
Last active March 8, 2026 04:07
Show Gist options
  • Select an option

  • Save twobob/cfae818e0f6ed78f57309e5ec5c5f0c1 to your computer and use it in GitHub Desktop.

Select an option

Save twobob/cfae818e0f6ed78f57309e5ec5c5f0c1 to your computer and use it in GitHub Desktop.
Ghost Beats straight to midi. No Deps. >V1 = Midi 1 format and adds chord generation
import struct
import math
import random
import os
import argparse
from typing import List, Tuple, Dict, Optional
# --- MIDI CONSTANTS ---
PPQ = 960
# General MIDI Map
KICK = 36
SNARE = 38
SNARE_RIM = 37
CL_HAT = 42
OP_HAT = 46
PEDAL_HAT = 44
CRASH = 49
SPLASH = 55
RIDE = 51
RIDE_BELL = 53
# Toms
TOM_HI = 50
TOM_MID = 47
TOM_LO = 43
# GM Program numbers
GM_ACOUSTIC_PIANO = 0
GM_ELECTRIC_PIANO = 4
GM_Rhodes = 4
GM_STRING_ENSEMBLE = 48 # GM program 49: String Ensemble 1
# MIDI channels (0-indexed)
CHORD_CHANNEL = 0 # ch 1 — piano
STRINGS_CHANNEL = 1 # ch 2 — strings pad
DRUM_CHANNEL = 9 # ch 10 — drums
# --- HARMONY CONSTANTS ---
NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
# Intervals from root (semitones)
MAJOR_SCALE = [0, 2, 4, 5, 7, 9, 11]
NATURAL_MINOR_SCALE = [0, 2, 3, 5, 7, 8, 10]
# Triad qualities built on each scale degree (3rd and 5th intervals in scale steps)
# For major: I ii iii IV V vi vii°
# For minor: i ii° III iv v VI VII
# Section chord progression templates (Roman numeral degrees, 0-indexed)
# Each entry is a list of scale degrees (0=I, 1=ii, 2=iii, etc.)
SECTION_PROGRESSIONS = {
"I-V-vi-IV": [0, 4, 5, 3],
"I-IV-V-V": [0, 3, 4, 4],
"vi-IV-I-V": [5, 3, 0, 4],
"I-vi-IV-V": [0, 5, 3, 4],
"ii-V-I-I": [1, 4, 0, 0],
"I-IV-vi-V": [0, 3, 5, 4],
"IV-I-V-vi": [3, 0, 4, 5],
"I-iii-IV-V": [0, 2, 3, 4],
"I-V-vi-iii": [0, 4, 5, 2],
"vi-V-IV-I": [5, 4, 3, 0],
"I-I-IV-V": [0, 0, 3, 4],
"I-IV-I-V": [0, 3, 0, 4],
}
# Large-scale form patterns — each letter maps to a section
# Sections are distributed across 128 bars
FORM_PATTERNS = {
"AABB": "AABB",
"ABAB": "ABAB",
"AABA": "AABA",
"ABAC": "ABAC",
"Rondo": "ABACABA",
"ABCBA": "ABCBA",
"AABC": "AABC",
"ABBA": "ABBA",
}
# Type alias: (note, step, velocity, is_fill, micro_offset_ticks)
PatternNote = Tuple[int, int, int, bool, int]
# Chord event: (start_bar, duration_bars, list_of_midi_notes, velocity)
ChordEvent = Tuple[int, int, List[int], int]
def get_variable_length_number(n: int) -> bytes:
"""Convert integer to MIDI variable length bytes."""
if n < 0:
raise ValueError("Variable length MIDI numbers must be >= 0")
if n < 128:
return bytes([n])
output = [n & 0x7F]
n >>= 7
while n > 0:
output.append((n & 0x7F) | 0x80)
n >>= 7
return bytes(reversed(output))
def create_midi_event(delta_time: int, status: int, data1: int, data2: Optional[int] = None) -> bytes:
"""Pack MIDI event data."""
if delta_time < 0:
raise ValueError("Delta time must be >= 0")
if not 0 <= status <= 255:
raise ValueError("Status byte out of range")
if not 0 <= data1 <= 127:
raise ValueError("MIDI data1 out of range")
if data2 is not None and not 0 <= data2 <= 127:
raise ValueError("MIDI data2 out of range")
output = get_variable_length_number(delta_time)
output += bytes([status])
output += bytes([data1])
if data2 is not None:
output += bytes([data2])
return output
def clamp(value: float, low: float, high: float) -> float:
return max(low, min(high, value))
def resolve_directory(target_path: str) -> str:
"""
Attempts to create/access the target path.
If it fails (e.g. drive doesn't exist), falls back to local directory.
"""
try:
os.makedirs(target_path, exist_ok=True)
return target_path
except OSError:
print(f"WARNING: Could not write to '{target_path}'. Defaulting to local folder.")
return "."
class ChordEngine:
"""
Generates a diatonic chord progression spread across 128 bars.
Picks a key, major/minor, a large-scale form, and fills each
section with a diatonic chord progression.
"""
def __init__(self, rng: random.Random, total_bars: int = 128):
self.rng = rng
self.total_bars = total_bars
# Pick root note (0=C .. 11=B) and mode
self.root = self.rng.randint(0, 11)
self.mode = self.rng.choice(["major", "minor"])
self.scale_intervals = MAJOR_SCALE if self.mode == "major" else NATURAL_MINOR_SCALE
# Build the 7 diatonic triads (MIDI note lists at a base octave)
self.base_octave = 4 # middle C region
self.triads = self._build_diatonic_triads()
# Pick a form and assign progressions to sections
self.form_name, self.form_letters = self._pick_form()
self.section_progs = self._assign_section_progressions()
# Generate the full 128-bar chord map (embellished, per-bar)
self.chord_events: List[ChordEvent] = self._generate_chord_map()
# Generate long-held pad events (plain triads, full duration)
self.pad_events: List[ChordEvent] = self._generate_pad_map()
def _scale_note(self, degree: int, octave: int) -> int:
"""Return MIDI note number for a scale degree at a given octave."""
return self.root + (octave * 12) + self.scale_intervals[degree % 7]
def _build_diatonic_triads(self) -> List[List[int]]:
"""Build triads (root, 3rd, 5th) for each of the 7 scale degrees."""
triads = []
for deg in range(7):
root_note = self._scale_note(deg, self.base_octave)
third_note = self._scale_note(deg + 2, self.base_octave)
fifth_note = self._scale_note(deg + 4, self.base_octave)
# Keep third and fifth above root
while third_note <= root_note:
third_note += 12
while fifth_note <= third_note:
fifth_note += 12
triads.append([root_note, third_note, fifth_note])
return triads
def _pick_form(self) -> Tuple[str, str]:
"""Pick a random large-scale form pattern."""
name = self.rng.choice(list(FORM_PATTERNS.keys()))
return name, FORM_PATTERNS[name]
def _assign_section_progressions(self) -> Dict[str, List[int]]:
"""Assign a unique chord progression to each distinct section letter."""
unique_letters = list(dict.fromkeys(self.form_letters)) # preserve order, dedupe
prog_names = list(SECTION_PROGRESSIONS.keys())
self.rng.shuffle(prog_names)
assignments: Dict[str, List[int]] = {}
for i, letter in enumerate(unique_letters):
prog_key = prog_names[i % len(prog_names)]
assignments[letter] = SECTION_PROGRESSIONS[prog_key]
return assignments
def _embellish_voicing(self, degree: int, base_triad: List[int],
bar_in_chord: int, total_bars: int) -> List[int]:
"""
Create per-bar voicing variations within a held chord.
bar_in_chord: 0-based position within the chord's duration.
total_bars: how many bars this chord lasts.
"""
voicing = list(base_triad)
is_last = (bar_in_chord == total_bars - 1) and total_bars > 1
is_first = (bar_in_chord == 0)
if is_first:
# First bar: plain triad, maybe with root doubling
if self.rng.random() < 0.20:
voicing.insert(0, base_triad[0] - 12)
return voicing
if is_last:
# Last bar before change: dominant-function embellishment
# Add 7th to create pull toward next chord
seventh = self._scale_note(degree + 6, self.base_octave)
while seventh <= voicing[-1]:
seventh += 12
if seventh <= 96:
voicing.append(seventh)
# Occasionally also sus4 replacing the 3rd
if self.rng.random() < 0.30:
fourth = self._scale_note(degree + 3, self.base_octave)
while fourth <= voicing[0]:
fourth += 12
# Replace the 3rd with the 4th (sus4)
if len(voicing) >= 2:
voicing[1] = fourth
return voicing
# Middle bars: cycle through embellishments
roll = self.rng.random()
if roll < 0.22:
# Add9: add the 9th (scale degree + 1, octave up)
ninth = self._scale_note(degree + 1, self.base_octave + 1)
while ninth <= voicing[-1]:
ninth += 12
if ninth <= 96:
voicing.append(ninth)
elif roll < 0.40:
# Add6: add the 6th (scale degree + 5)
sixth = self._scale_note(degree + 5, self.base_octave)
while sixth <= voicing[-1]:
sixth += 12
if sixth <= 96:
voicing.append(sixth)
elif roll < 0.55:
# First inversion: move root up an octave
voicing[0] += 12
voicing.sort()
elif roll < 0.68:
# Second inversion: move root and 3rd up
voicing[0] += 12
if len(voicing) >= 2:
voicing[1] += 12
voicing.sort()
elif roll < 0.80:
# Add 7th
seventh = self._scale_note(degree + 6, self.base_octave)
while seventh <= voicing[-1]:
seventh += 12
if seventh <= 96:
voicing.append(seventh)
elif roll < 0.90:
# Root doubled below for weight
voicing.insert(0, base_triad[0] - 12)
# else: plain triad (small chance of repetition for breathing room)
return voicing
def _generate_chord_map(self) -> List[ChordEvent]:
"""
Distribute the form across 128 bars, then fill each section
with its chord progression cycling at a chosen harmonic rhythm.
Each bar within a held chord gets its own voicing variation.
"""
n_sections = len(self.form_letters)
bars_per_section = self.total_bars // n_sections
remainder = self.total_bars % n_sections
events: List[ChordEvent] = []
bar_cursor = 0
for idx, letter in enumerate(self.form_letters):
section_len = bars_per_section + (1 if idx < remainder else 0)
prog = self.section_progs[letter]
# Harmonic rhythm: how many bars per chord
if section_len <= 8:
bars_per_chord = 2
elif section_len <= 16:
bars_per_chord = self.rng.choice([2, 4])
else:
bars_per_chord = self.rng.choice([4, 4, 8])
chord_idx = 0
local_bar = 0
while local_bar < section_len:
degree = prog[chord_idx % len(prog)]
triad = self.triads[degree]
dur = min(bars_per_chord, section_len - local_bar)
# Emit one event per bar with embellished voicings
for bar_in_chord in range(dur):
voicing = self._embellish_voicing(degree, triad, bar_in_chord, dur)
# Clamp all notes to valid MIDI range
voicing = [max(0, min(127, n)) for n in voicing]
vel = self.rng.randint(62, 82)
events.append((bar_cursor + local_bar + bar_in_chord, 1, voicing, vel))
local_bar += dur
chord_idx += 1
bar_cursor += section_len
return events
def _generate_pad_map(self) -> List[ChordEvent]:
"""
Generate long-held plain triads for the strings pad track.
Same harmonic structure as chord_events but one event per
chord change (not per bar), plain voicings.
"""
n_sections = len(self.form_letters)
bars_per_section = self.total_bars // n_sections
remainder = self.total_bars % n_sections
events: List[ChordEvent] = []
bar_cursor = 0
for idx, letter in enumerate(self.form_letters):
section_len = bars_per_section + (1 if idx < remainder else 0)
prog = self.section_progs[letter]
if section_len <= 8:
bars_per_chord = 2
elif section_len <= 16:
bars_per_chord = self.rng.choice([2, 4])
else:
bars_per_chord = self.rng.choice([4, 4, 8])
chord_idx = 0
local_bar = 0
while local_bar < section_len:
degree = prog[chord_idx % len(prog)]
triad = self.triads[degree]
dur = min(bars_per_chord, section_len - local_bar)
voicing = [max(0, min(127, n)) for n in triad]
vel = self.rng.randint(50, 68)
events.append((bar_cursor + local_bar, dur, voicing, vel))
local_bar += dur
chord_idx += 1
bar_cursor += section_len
return events
def description(self) -> str:
root_name = NOTE_NAMES[self.root]
mode_str = self.mode.capitalize()
return f"Key:{root_name} {mode_str} Form:{self.form_name}({self.form_letters})"
class FractalGroove:
def __init__(
self,
bpm: int = 174,
swing_pct: int = 58,
quant_pct: int = 95,
jazziness: float = 0.3,
style_mode: str = "random",
seed: Optional[int] = None
):
self.bpm = bpm
self.swing_pct = swing_pct
self.quant_pct = quant_pct
self.jazziness = clamp(jazziness, 0.0, 1.0)
self.rng = random.Random(seed)
# --- PATTERN STYLES ---
styles = {
"Amen-ish": {
"kick": [0, 10], # 1, 3-and
"snare": [4, 12] # 2, 4
},
"2-Step": {
"kick": [0, 10, 11], # 1, 3-and, 3-a
"snare": [4, 12]
},
"Rolling": {
"kick": [0, 7, 10], # 1, 2-a, 3-and
"snare": [4, 12]
},
"Tech-Step": {
"kick": [0, 2, 10], # 1, 1-and, 3-and
"snare": [4, 12]
}
}
if style_mode == "random":
self.style_name = self.rng.choice(list(styles.keys()))
else:
self.style_name = style_mode if style_mode in styles else "Amen-ish"
self.base_kick = styles[self.style_name]["kick"]
self.base_snare = styles[self.style_name]["snare"]
# Tighter humanisation profile
self.max_jitter_ticks_main = self._compute_max_jitter_ticks(is_fill=False)
self.max_jitter_ticks_fill = self._compute_max_jitter_ticks(is_fill=True)
def _compute_max_jitter_ticks(self, is_fill: bool) -> int:
"""
Compute absolute jitter cap in ticks.
Tighter than before: keeps notes close to grid even at lower quantize values.
"""
effective_quant = min(100, self.quant_pct + (4 if is_fill else 0))
looseness = 100 - effective_quant
# Old behaviour could exceed ~50 ticks very easily.
# This caps main groove around ~2..18 ticks, fills ~2..24 ticks.
if is_fill:
max_ticks = int(round(2 + (looseness * 0.55)))
else:
max_ticks = int(round(1 + (looseness * 0.40)))
return max(0, max_ticks)
def _micro_jitter(self, is_fill: bool, accent_strength: float = 1.0) -> int:
"""
Small random timing shift.
accent_strength below 1.0 tightens even more.
"""
max_jitter = self.max_jitter_ticks_fill if is_fill else self.max_jitter_ticks_main
max_jitter = int(round(max_jitter * clamp(accent_strength, 0.2, 1.2)))
if max_jitter <= 0:
return 0
return self.rng.randint(-max_jitter, max_jitter)
def _ghost_micro_offset(self, step: int, instrument: int) -> int:
"""
Purposeful micro-placement for ghost notes:
- snare ghosts often drag slightly behind beat
- kick ghosts sit a touch early or centred
"""
if instrument == SNARE:
# Gentle late feel, especially near backbeats
bias = 4 if step in (3, 11, 14, 15) else 2
return bias + self.rng.randint(-3, 4)
elif instrument == KICK:
# Kicks tend to sit a bit more centred or slightly early
bias = -2 if step in (1, 2, 9, 10) else 0
return bias + self.rng.randint(-3, 3)
return self.rng.randint(-2, 2)
def get_tick(self, bar: int, step: int, is_fill: bool = False, micro_offset: int = 0) -> int:
"""Calculates absolute tick with swing and tighter quantize logic."""
quarter_note = step // 4
step_in_beat = step % 4
ticks_per_8th = PPQ / 2
# Absolute start
bar_offset = bar * 4 * PPQ
beat_offset = quarter_note * PPQ
# Swing Logic (16th-note interpretation)
ratio = self.swing_pct / 100.0
swing_offset = 0.0
if step_in_beat == 1: # e
swing_offset = ticks_per_8th * ratio
elif step_in_beat == 2: # &
swing_offset = ticks_per_8th
elif step_in_beat == 3: # a
swing_offset = ticks_per_8th + (ticks_per_8th * ratio)
perfect_tick = bar_offset + beat_offset + swing_offset
# Tighter randomisation
jitter = self._micro_jitter(is_fill=is_fill, accent_strength=1.0)
final_tick = int(round(perfect_tick + jitter + micro_offset))
return max(0, final_tick)
def _base_hat_velocity(self, step: int, intensity: int) -> int:
if step % 2 == 0:
base = 82 + (intensity * 3)
else:
base = 48 + self.rng.randint(-4, 4)
return int(clamp(base, 1, 127))
def _add_note(
self,
notes: List[PatternNote],
note: int,
step: int,
vel: int,
is_fill: bool,
micro_offset: int = 0
) -> None:
notes.append((note, step, int(clamp(vel, 1, 127)), is_fill, micro_offset))
def _occupied_by_core(self, notes: List[PatternNote], step: int) -> bool:
return any(n[1] == step and n[0] in (KICK, SNARE) and n[2] >= 70 for n in notes)
def _find_nearby_core_hits(self, notes: List[PatternNote], step: int, radius: int = 1) -> List[PatternNote]:
out = []
for n in notes:
if n[0] in (KICK, SNARE) and abs(n[1] - step) <= radius and n[2] >= 70:
out.append(n)
return out
def _ghost_velocity(self, instrument: int, step: int, distance_to_backbeat: int, intensity: int) -> int:
"""
More expressive ghost velocities:
- snare ghosts cluster around 22..52
- kick ghosts around 40..72
"""
if instrument == SNARE:
base = 28 + self.rng.randint(0, 12)
if distance_to_backbeat == 1:
base += 5
if step in (14, 15):
base += 4
base += intensity
return int(clamp(base, 18, 58))
if instrument == KICK:
base = 42 + self.rng.randint(0, 14)
if step in (1, 2, 9, 10, 11):
base += 4
base += intensity * 2
return int(clamp(base, 35, 76))
return 40
def _ghost_probability(self, step: int, intensity: int, near_backbeat: bool, near_kick: bool) -> float:
"""
Bias ghost placement towards musical positions without turning every bar into soup.
"""
base = 0.04 + (self.jazziness * 0.24)
# Preferred DnB ghost locations
if step in (2, 3, 6, 7, 9, 11, 14, 15):
base += 0.08
if near_backbeat:
base += 0.10
if near_kick:
base += 0.04
base += intensity * 0.015
return clamp(base, 0.0, 0.55)
def _generate_ghost_notes(self, notes: List[PatternNote], intensity: int, is_fill_bar: bool) -> None:
"""
Fully implemented ghost-note engine.
Behaviour:
- Works both on regular bars and end-of-phrase bars
- Prioritises pickup notes around snares and syncopated holes
- Uses kick ghosts sparingly
- Prevents stepping on core groove
- Applies purposeful microtiming rather than random slop
"""
candidate_steps = [1, 2, 3, 5, 6, 7, 8, 9, 11, 13, 14, 15]
# More ghosts on bigger phrase endings, but still controlled
base_limit = 1 + int(self.jazziness * 3.0)
if is_fill_bar:
ghost_limit = base_limit + intensity
else:
ghost_limit = base_limit
ghost_limit = int(clamp(ghost_limit, 1, 5))
used_steps = set()
ghosts_added = 0
backbeats = set(self.base_snare)
# Weight steps: shuffle with musical priority
weighted_candidates = []
for step in candidate_steps:
near_backbeat = any(abs(step - s) <= 1 for s in backbeats)
near_kick = any(abs(step - k) <= 1 for k in self.base_kick)
priority = 1.0
if near_backbeat:
priority += 1.3
if near_kick:
priority += 0.5
if step in (14, 15):
priority += 0.6
if step in (2, 3, 11):
priority += 0.4
weighted_candidates.append((step, priority))
# Weighted random ordering without external libs
ordered_steps = []
pool = weighted_candidates[:]
while pool:
total = sum(weight for _, weight in pool)
pick = self.rng.uniform(0, total)
acc = 0.0
chosen_index = 0
for idx, (_, weight) in enumerate(pool):
acc += weight
if acc >= pick:
chosen_index = idx
break
ordered_steps.append(pool.pop(chosen_index)[0])
for step in ordered_steps:
if ghosts_added >= ghost_limit:
break
if step in used_steps:
continue
if self._occupied_by_core(notes, step):
continue
nearby_core = self._find_nearby_core_hits(notes, step, radius=1)
near_backbeat = any(n[0] == SNARE and n[1] in backbeats for n in nearby_core)
near_kick = any(n[0] == KICK for n in nearby_core)
probability = self._ghost_probability(step, intensity, near_backbeat, near_kick)
if self.rng.random() > probability:
continue
# Instrument choice:
# snare ghost dominates, kick ghost only sometimes in syncopated holes
if near_backbeat:
instrument = SNARE
elif near_kick and self.rng.random() < 0.28:
instrument = KICK
else:
instrument = SNARE if self.rng.random() < 0.78 else KICK
# Avoid too many kick ghosts
if instrument == KICK:
existing_kick_ghosts = sum(1 for n in notes if n[0] == KICK and n[2] < 80)
if existing_kick_ghosts >= 1 and self.rng.random() < 0.75:
instrument = SNARE
distance_to_backbeat = min(abs(step - s) for s in backbeats)
vel = self._ghost_velocity(instrument, step, distance_to_backbeat, intensity)
micro = self._ghost_micro_offset(step, instrument)
self._add_note(notes, instrument, step, vel, is_fill_bar, micro)
used_steps.add(step)
ghosts_added += 1
# Occasional flam-like snare pickup just before a strong backbeat region
if (
instrument == SNARE
and ghosts_added < ghost_limit
and step in (14, 15)
and self.jazziness > 0.45
and self.rng.random() < 0.22
):
neighbour = step - 1
if neighbour >= 0 and not self._occupied_by_core(notes, neighbour) and neighbour not in used_steps:
vel2 = int(clamp(vel - self.rng.randint(4, 10), 16, 48))
micro2 = self._ghost_micro_offset(neighbour, SNARE) - 2
self._add_note(notes, SNARE, neighbour, vel2, is_fill_bar, micro2)
used_steps.add(neighbour)
ghosts_added += 1
def generate_bar_pattern(self, bar_index: int) -> List[PatternNote]:
notes: List[PatternNote] = []
# --- FRACTAL ANALYSIS ---
check = bar_index + 1
is_4_end = (check % 4 == 0)
is_8_end = (check % 8 == 0)
is_16_end = (check % 16 == 0)
is_32_end = (check % 32 == 0)
is_64_end = (check % 64 == 0)
intensity = 0
if is_4_end:
intensity = 1
if is_8_end:
intensity = 2
if is_16_end:
intensity = 3
if is_32_end:
intensity = 4
if is_64_end:
intensity = 5
# --- BASE GRID (Hats & Rides) ---
for step in range(16):
cymbal = CL_HAT
if intensity >= 4:
cymbal = RIDE
if intensity == 5 and step % 4 == 0:
cymbal = CRASH
hat_vel = self._base_hat_velocity(step, intensity)
if step % 2 == 0:
self._add_note(notes, cymbal, step, hat_vel, False, 0)
else:
# Slightly denser off-hat logic, but still controlled
if self.rng.random() > 0.24:
off_micro = self.rng.randint(-3, 3)
self._add_note(notes, cymbal, step, hat_vel, False, off_micro)
# Occasional open-hat / pedal-hat texture
if self.jazziness > 0.35:
for step in (7, 15):
if self.rng.random() < (0.12 + self.jazziness * 0.18):
if not self._occupied_by_core(notes, step):
inst = OP_HAT if self.rng.random() < 0.65 else PEDAL_HAT
vel = 65 + self.rng.randint(-6, 10)
self._add_note(notes, inst, step, vel, False, self.rng.randint(-3, 3))
# --- CORE GROOVE (Kick/Snare) ---
for step in range(16):
if step in self.base_kick:
vel = 118 + self.rng.randint(-4, 5)
self._add_note(notes, KICK, step, vel, False, 0)
if step in self.base_snare:
if not (intensity >= 4 and step == 12):
vel = 124 + self.rng.randint(0, 3)
self._add_note(notes, SNARE, step, vel, False, 0)
# --- FILL ENGINE ---
if is_4_end:
fill_type = "ghosts"
roll = self.rng.random()
if intensity >= 3 and roll < 0.68:
fill_type = "toms"
elif intensity >= 2 and roll < 0.36:
fill_type = "cymbals"
if fill_type == "toms":
# Pull cymbal clutter out of last beat when tom fill happens
notes = [n for n in notes if n[1] < 12 or n[0] not in (CL_HAT, RIDE, OP_HAT, PEDAL_HAT)]
pat = [TOM_HI, TOM_MID, TOM_LO, KICK]
if intensity == 5:
pat = [TOM_HI, TOM_HI, TOM_MID, TOM_LO]
for i, inst in enumerate(pat):
step = 12 + i
vel = 108 + (i * 3) + self.rng.randint(-3, 4)
micro = self.rng.randint(-4, 5)
self._add_note(notes, inst, step, vel, True, micro)
# Small pickup before the fill sometimes
if self.jazziness > 0.35 and self.rng.random() < 0.45 and not self._occupied_by_core(notes, 11):
self._add_note(notes, SNARE, 11, 34 + self.rng.randint(0, 8), True, self._ghost_micro_offset(11, SNARE))
elif fill_type == "cymbals":
positions = [10, 13, 15]
for p in positions:
if self.rng.random() < 0.78:
inst = SPLASH if self.rng.random() < 0.45 else CRASH
vel = 94 + self.rng.randint(0, 12)
self._add_note(notes, inst, p, vel, True, self.rng.randint(-4, 4))
if intensity >= 3 and self.rng.random() < 0.75:
self._add_note(notes, KICK, p, 84 + self.rng.randint(0, 10), True, self.rng.randint(-3, 3))
self._generate_ghost_notes(notes, intensity=intensity, is_fill_bar=True)
else:
self._generate_ghost_notes(notes, intensity=intensity, is_fill_bar=True)
# --- STANDARD BAR GHOSTING ---
else:
self._generate_ghost_notes(notes, intensity=intensity, is_fill_bar=False)
# Remove exact duplicates, keep strongest velocity version
dedup: Dict[Tuple[int, int, int], PatternNote] = {}
for note in notes:
key = (note[0], note[1], note[4])
existing = dedup.get(key)
if existing is None or note[2] > existing[2]:
dedup[key] = note
return list(dedup.values())
def _build_track_chunk(self, track_data: bytearray) -> bytes:
"""Wrap raw track data in an MTrk chunk."""
return b"MTrk" + len(track_data).to_bytes(4, "big") + bytes(track_data)
def _build_tempo_track(self, description: str) -> bytes:
"""Track 0: tempo map and file-level metadata only."""
data = bytearray()
# Track name
name_bytes = description.encode("ascii", "ignore")
data += b"\x00\xFF\x03" + get_variable_length_number(len(name_bytes)) + name_bytes
# Tempo
mspqn = int(60000000 / self.bpm)
data += b"\x00\xFF\x51\x03" + mspqn.to_bytes(3, "big")
# Time signature 4/4
data += b"\x00\xFF\x58\x04\x04\x02\x18\x08"
# End of track
data += b"\x00\xFF\x2F\x00"
return self._build_track_chunk(data)
def _build_drum_track(self, description: str) -> bytes:
"""Track 1: drums on MIDI channel 10 (0x99/0x89)."""
all_events = []
for bar in range(128):
bar_notes = self.generate_bar_pattern(bar)
for (note, step, vel, is_fill, micro_offset) in bar_notes:
start_tick = self.get_tick(bar, step, is_fill=is_fill, micro_offset=micro_offset)
if note in (CRASH, SPLASH, RIDE, OP_HAT):
duration = 100 + self.rng.randint(-10, 20)
elif note in (TOM_HI, TOM_MID, TOM_LO):
duration = 85 + self.rng.randint(-8, 12)
elif vel < 60:
duration = 42 + self.rng.randint(-6, 8)
else:
duration = 58 + self.rng.randint(-6, 10)
end_tick = max(start_tick + 1, start_tick + duration)
all_events.append({"tick": start_tick, "type": "on", "note": note, "vel": vel})
all_events.append({"tick": end_tick, "type": "off", "note": note, "vel": 0})
all_events.sort(key=lambda x: (x["tick"], 0 if x["type"] == "off" else 1, x["note"]))
data = bytearray()
# Track name
name = f"Drums | {description}".encode("ascii", "ignore")
data += b"\x00\xFF\x03" + get_variable_length_number(len(name)) + name
last_tick = 0
for e in all_events:
delta = e["tick"] - last_tick
last_tick = e["tick"]
status = (0x90 | DRUM_CHANNEL) if e["type"] == "on" else (0x80 | DRUM_CHANNEL)
data += create_midi_event(delta, status, e["note"], e["vel"])
data += b"\x00\xFF\x2F\x00"
return self._build_track_chunk(data)
def _build_chord_track(self, chord_engine: 'ChordEngine') -> bytes:
"""Track 2: piano chords on MIDI channel 1 (0x90/0x80)."""
all_events = []
for (start_bar, dur_bars, notes, vel) in chord_engine.chord_events:
start_tick = start_bar * 4 * PPQ
end_tick = (start_bar + dur_bars) * 4 * PPQ - 1 # release just before next chord
for n in notes:
all_events.append({"tick": start_tick, "type": "on", "note": n, "vel": vel})
all_events.append({"tick": end_tick, "type": "off", "note": n, "vel": 0})
all_events.sort(key=lambda x: (x["tick"], 0 if x["type"] == "off" else 1, x["note"]))
data = bytearray()
# Track name
name = f"Piano | {chord_engine.description()}".encode("ascii", "ignore")
data += b"\x00\xFF\x03" + get_variable_length_number(len(name)) + name
# Program change: Acoustic Grand Piano (GM program 0)
data += b"\x00" + bytes([0xC0 | CHORD_CHANNEL, GM_ACOUSTIC_PIANO])
last_tick = 0
for e in all_events:
delta = e["tick"] - last_tick
last_tick = e["tick"]
status = (0x90 | CHORD_CHANNEL) if e["type"] == "on" else (0x80 | CHORD_CHANNEL)
data += create_midi_event(delta, status, e["note"], e["vel"])
data += b"\x00\xFF\x2F\x00"
return self._build_track_chunk(data)
def _build_strings_track(self, chord_engine: 'ChordEngine') -> bytes:
"""Track 3: long-held string pad chords on MIDI channel 2."""
all_events = []
for (start_bar, dur_bars, notes, vel) in chord_engine.pad_events:
start_tick = start_bar * 4 * PPQ
end_tick = (start_bar + dur_bars) * 4 * PPQ - 1
for n in notes:
all_events.append({"tick": start_tick, "type": "on", "note": n, "vel": vel})
all_events.append({"tick": end_tick, "type": "off", "note": n, "vel": 0})
all_events.sort(key=lambda x: (x["tick"], 0 if x["type"] == "off" else 1, x["note"]))
data = bytearray()
name = f"Strings Pad | {chord_engine.description()}".encode("ascii", "ignore")
data += b"\x00\xFF\x03" + get_variable_length_number(len(name)) + name
# Program change: String Ensemble 1
data += b"\x00" + bytes([0xC0 | STRINGS_CHANNEL, GM_STRING_ENSEMBLE])
last_tick = 0
for e in all_events:
delta = e["tick"] - last_tick
last_tick = e["tick"]
status = (0x90 | STRINGS_CHANNEL) if e["type"] == "on" else (0x80 | STRINGS_CHANNEL)
data += create_midi_event(delta, status, e["note"], e["vel"])
data += b"\x00\xFF\x2F\x00"
return self._build_track_chunk(data)
def render(self, output_path: str) -> None:
# Seed chord engine from a separate RNG derived from initial seed,
# so self.rng sequence for drums is not disturbed
chord_rng_seed = hash((id(self), self.bpm, self.swing_pct, self.quant_pct)) & 0x7FFFFFFF
chord_engine = ChordEngine(rng=random.Random(chord_rng_seed))
# Build filename-safe name from all parameters
root_name = NOTE_NAMES[chord_engine.root].replace("#", "s")
mode_tag = "maj" if chord_engine.mode == "major" else "min"
style_tag = self.style_name.replace("-", "").replace(" ", "")
form_tag = chord_engine.form_name.replace(" ", "")
filename = (
f"key_{root_name}{mode_tag}"
f"-form_{form_tag}"
f"-style_{style_tag}"
f"-bpm_{self.bpm}"
f"-sw_{self.swing_pct}"
f"-q_{self.quant_pct}"
f"-jz_{self.jazziness:.2f}"
f".mid"
)
description = (
f"{self.style_name} Sw:{self.swing_pct}% Q:{self.quant_pct}% "
f"Jz:{self.jazziness:.2f} | {chord_engine.description()}"
)
safe_path = resolve_directory(output_path)
full_path = os.path.join(safe_path, filename)
print(f"Generating -> {full_path}")
print(f" Details: {description}")
# Build 4 tracks: tempo, drums, piano chords, strings pad
tempo_track = self._build_tempo_track(description)
drum_track = self._build_drum_track(description)
chord_track = self._build_chord_track(chord_engine)
strings_track = self._build_strings_track(chord_engine)
num_tracks = 4
# MIDI Type 1 header
header = (
b"MThd"
+ (6).to_bytes(4, "big")
+ (1).to_bytes(2, "big") # Format 1
+ num_tracks.to_bytes(2, "big") # 4 tracks
+ PPQ.to_bytes(2, "big")
)
with open(full_path, "wb") as f:
f.write(header)
f.write(tempo_track)
f.write(drum_track)
f.write(chord_track)
f.write(strings_track)
def debug_bar(self, bar_index: int) -> List[PatternNote]:
"""Utility for inspection/testing."""
return sorted(self.generate_bar_pattern(bar_index), key=lambda n: (n[1], n[0], n[2]))
def self_test() -> None:
"""
Lightweight validation:
- variable length numbers encode
- generated pattern has valid MIDI ranges
- timing jitter is kept tight
- chord engine produces valid events
"""
assert get_variable_length_number(0) == b"\x00"
assert get_variable_length_number(127) == b"\x7f"
assert get_variable_length_number(128) == b"\x81\x00"
gen = FractalGroove(bpm=172, swing_pct=58, quant_pct=93, jazziness=0.5, style_mode="Amen-ish", seed=1234)
pattern = gen.debug_bar(3)
assert len(pattern) > 0
for note, step, vel, is_fill, micro in pattern:
assert 0 <= note <= 127
assert 0 <= step <= 15
assert 1 <= vel <= 127
assert isinstance(is_fill, bool)
assert -32 <= micro <= 32
assert gen.max_jitter_ticks_main <= 18
assert gen.max_jitter_ticks_fill <= 24
# Chord engine validation
ce = ChordEngine(rng=random.Random(42), total_bars=128)
assert 0 <= ce.root <= 11
assert ce.mode in ("major", "minor")
assert len(ce.triads) == 7
assert len(ce.chord_events) > 0
total_bars_covered = sum(dur for (_, dur, _, _) in ce.chord_events)
assert total_bars_covered == 128, f"Chord map covers {total_bars_covered} bars, expected 128"
for (start, dur, notes, vel) in ce.chord_events:
assert start >= 0
assert dur > 0
assert 1 <= vel <= 127
for n in notes:
assert 0 <= n <= 127, f"Chord note {n} out of MIDI range"
# --- EXECUTION ---
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Fractal groove pattern generator — produces 16 MIDI files "
"with drums, diatonic piano chords, and string pads."
)
parser.add_argument(
"output",
nargs="?",
default=r"E:\SONGS\midi",
help="Output folder for generated MIDI files (default: E:\\SONGS\\midi)"
)
args = parser.parse_args()
self_test()
target_dir = args.output
print(f"--- STARTING BATCH GENERATION (16 FILES) -> {target_dir} ---")
for _ in range(16):
rnd_bpm = random.randint(160, 178)
rnd_swing = random.randint(52, 62)
rnd_quant = random.randint(88, 98)
rnd_jazz = round(random.uniform(0.25, 0.60), 2)
gen = FractalGroove(
bpm=rnd_bpm,
swing_pct=rnd_swing,
quant_pct=rnd_quant,
jazziness=rnd_jazz,
style_mode="random"
)
gen.render(target_dir)
print("--- BATCH COMPLETE ---")
@twobob
Copy link
Author

twobob commented Mar 8, 2026

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment