-
-
Save bearlikelion/a2dbd0c27640e81bd2e350bf3baa8815 to your computer and use it in GitHub Desktop.
Ghost Beats straight to midi. No Deps.
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 | |
| 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