Last active
March 8, 2026 04:07
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 ---") |
Author
twobob
commented
Mar 8, 2026
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment