Skip to content

Instantly share code, notes, and snippets.

@bearlikelion
Forked from twobob/ghostBeats.py
Created March 8, 2026 02:16
Show Gist options
  • Select an option

  • Save bearlikelion/a2dbd0c27640e81bd2e350bf3baa8815 to your computer and use it in GitHub Desktop.

Select an option

Save bearlikelion/a2dbd0c27640e81bd2e350bf3baa8815 to your computer and use it in GitHub Desktop.
Ghost Beats straight to midi. No Deps.
import struct
import math
import random
import os
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
# Type alias: (note, step, velocity, is_fill, micro_offset_ticks)
PatternNote = Tuple[int, int, int, bool, 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 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 centered
"""
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 centered 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
):
neighbor = step - 1
if neighbor >= 0 and not self._occupied_by_core(notes, neighbor) and neighbor not in used_steps:
vel2 = int(clamp(vel - self.rng.randint(4, 10), 16, 48))
micro2 = self._ghost_micro_offset(neighbor, SNARE) - 2
self._add_note(notes, SNARE, neighbor, vel2, is_fill_bar, micro2)
used_steps.add(neighbor)
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 render(self, output_path: str, filename_prefix: str, batch_id: int) -> None:
description = f"[{batch_id}] {self.style_name} Sw:{self.swing_pct}% Q:{self.quant_pct}% Jz:{self.jazziness:.2f}"
filename = f"{filename_prefix}_{batch_id:02d}_{self.style_name.replace(' ', '')}.mid"
safe_path = resolve_directory(output_path)
full_path = os.path.join(safe_path, filename)
all_events = []
print(f"Generating -> {full_path}")
print(f" Details: {description}")
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)
# Slightly variable duration, still short and tight
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})
# Sort note-offs before note-ons when ticks match to avoid stuck notes on some parsers
all_events.sort(key=lambda x: (x["tick"], 0 if x["type"] == "off" else 1, x["note"]))
track_data = bytearray()
# Track Name
track_name_bytes = description.encode("ascii", "ignore")
track_data += b"\x00\xFF\x03" + get_variable_length_number(len(track_name_bytes)) + track_name_bytes
# Tempo
mspqn = int(60000000 / self.bpm)
track_data += b"\x00\xFF\x51\x03" + mspqn.to_bytes(3, "big")
last_tick = 0
for e in all_events:
delta = e["tick"] - last_tick
last_tick = e["tick"]
status = 0x99 if e["type"] == "on" else 0x89
track_data += create_midi_event(delta, status, e["note"], e["vel"])
track_data += b"\x00\xFF\x2F\x00"
header = (
b"MThd"
+ (6).to_bytes(4, "big")
+ (0).to_bytes(2, "big")
+ (1).to_bytes(2, "big")
+ PPQ.to_bytes(2, "big")
)
chunk = b"MTrk" + len(track_data).to_bytes(4, "big") + track_data
with open(full_path, "wb") as f:
f.write(header)
f.write(chunk)
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
"""
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
# --- EXECUTION ---
if __name__ == "__main__":
self_test()
# Requested default path
target_dir = r"E:\SONGS\code Project"
print("--- STARTING BATCH GENERATION (16 FILES) ---")
for i in range(1, 17):
rnd_bpm = random.randint(160, 178)
rnd_swing = random.randint(52, 62) # tighter range
rnd_quant = random.randint(88, 98) # tighter to the grid
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, "fractal_batch", i)
print("--- BATCH COMPLETE ---")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment