#4753 Crum Hum Generative Synthesizer & Genetic Algorithms/Programming

#4753 Crum Hum Generative Synthesizer & Genetic Algorithms/Programming

https://www.perfectcircuit.com/nystrom-instruments-crum-hum.html?srsltid=AfmBOopTS6FLO6pERdB1fQluFae2_Mv8zcOzdzAQIjvNQFPR4Msf7PYe

#4753 Tell me about Crum Hum from Nystrom Instruments

The Crum Hum is a 3-voice experimental, generative soundscaping synthesizer designed and hand-built in Sweden by Albert Nyström (Nyström Instruments). Following the design philosophy of the popular Crum Drum percussion synthesizer, the Crum Hum shifts focus toward generative melodic patterns, harmonic textures, evolving chord clusters, and ambient drones.

Core Architecture & Voices

  • 3 Independent Melodic Voices: Each voice features dedicated frequency controls, generative probability parameters, and capacitive triangular touch plates.

  • Capacitive Touch Control: The metal triangular pads are touch-responsive, letting you physically swell the volume and timbre of each voice or play expressive sustained drones.

  • Voice Latching: Latch buttons allow you to freeze a voice’s current amplitude/drone level so you can take your hands off the touch plates while tweaking modulation.

Sound Engines & Tuning

  • 12 Digital Synthesis Engines: Accessed via a selector knob (using a shift modifier to reach engines 7–12). Engines include:

    • Low-pass filtered Saw and Square waves

    • Multiple wavetable architectures

    • 2-operator FM variants, plus chaotic/noisy FM

    • Chord engines and split configurations (Bass + Chord + Melody setups)

    • Pure noise generators

  • 12 Musical Scales & Microtonality: Quantization options range from standard Western diatonic/modal scales to the natural harmonic series, microtonal tunings, and fully unquantized continuous pitch.

Generative Sequencer & Modulation

  • Dual-Role Random Controls:

    • Clock Stopped: The RAND knob controls the pitch spread or interval range of new notes.

    • Clock Running: RAND behaves as a probability gate per clock pulse, determining whether that specific voice triggers a new step.

  • Integrated Effects Processor: An onboard multi-FX section includes stereo delay (time and feedback/mix), ambient reverb, and a global master overdrive.

  • Assignable LFO: Offers rate and depth modulation targetable to pitch/frequency, overall timbre/filter cutoff, or amplitude.

Connectivity & Hardware I/O

  • Audio Out: 3.5 mm (1/8") stereo output.

  • Analog Sync & CV: 3.5 mm Clock Input (with rhythmic subdivisions) and a 3.5 mm CV pitch output for interfacing with Eurorack or semi-modular rigs.

  • USB MIDI & Power: Sends and receives MIDI note sequences and MIDI CC data over USB, with each voice assigned its own MIDI channel.

Available both as fully assembled units and as DIY kits through European and US modular retailers, the Crum Hum is aimed at hands-on generative composition, cinematic beds, and ambient sound design.


Would a Python application via MIDI be useful with this device?

Yes, a Python application communicating via MIDI would be exceptionally useful with the Crum Hum.

The Crum Hum has two key design characteristics that make external programmatic control uniquely compelling:

  1. Zero Onboard Preset Storage: The hardware has no internal preset memory—knob positions are purely live and lost on power cycle.

  2. Deep MIDI Implementation Over USB: Nearly every parameter across its 3 voices is mapped directly to standard MIDI Continuous Controllers (CC) and distinct MIDI channels.

Key MIDI Architecture on the Crum Hum

  • 3-Channel Voice Addressing: Voices are mapped across adjacent MIDI channels (by default, Voice 1 = Ch 1, Voice 2 = Ch 2, Voice 3 = Ch 3).

  • Bi-directional Communication: The device both sends and receives note sequencing and parameter CC values over class-compliant USB MIDI.

  • Exposed Parameter Map:

    • Voice Frequencies: CC 20 (Voice 1), CC 21 (Voice 2), CC 22 (Voice 3)

    • Voice Random/Probability: CC 23, 24, 25

    • Touchpad / Amplitude Modulation: CC 26, 27, 28

    • Engine & Tuning: Engine Type (CC 70), Scale (CC 71), Key/Root (CC 3), Tone (CC 74), Tone Rand (CC 75)

    • Effects & Modulation: Delay Time/LFO Rate (CC 91), Delay Mix/LFO Mix (CC 92), Reverb/LFO Dest (CC 93), Drive/Sens (CC 94), Shift Toggle (CC 95)

    • Global: Master Volume (CC 7), BPM/Tempo (CC 9)

High-Value Python Use Cases

1. Software Preset Manager & Patch Librarian

Because the synth lacks onboard saving, a lightweight Python tool can act as a digital patch bank. You can define sound state snapshots (JSON/YAML) and dump the entire parameter state to the synth in a fraction of a second:

import time
import mido

# Example patch data structure
patch = {
    "key": 48,          # CC 3 (Root C)
    "scale": 2,         # CC 71
    "engine_type": 10,  # CC 70
    "tone": 85,         # CC 74
    "voices": {
        0: {"freq": 60, "rand": 30, "level": 100},  # Ch 1 (0-indexed)
        1: {"freq": 64, "rand": 20, "level": 90},   # Ch 2
        2: {"freq": 67, "rand": 45, "level": 80},   # Ch 3
    },
    "delay_time": 40,   # CC 91
    "reverb": 70,       # CC 93
}

def load_patch(outport, p):
    # Global timbre and scale settings
    outport.send(mido.Message('control_change', channel=0, control=3, value=p["key"]))
    outport.send(mido.Message('control_change', channel=0, control=71, value=p["scale"]))
    outport.send(mido.Message('control_change', channel=0, control=70, value=p["engine_type"]))
    outport.send(mido.Message('control_change', channel=0, control=74, value=p["tone"]))
    outport.send(mido.Message('control_change', channel=0, control=91, value=p["delay_time"]))
    outport.send(mido.Message('control_change', channel=0, control=93, value=p["reverb"]))

    # Per-voice levels and random probability
    for ch, params in p["voices"].items():
        outport.send(mido.Message('control_change', channel=ch, control=20 + ch, value=params["freq"]))
        outport.send(mido.Message('control_change', channel=ch, control=23 + ch, value=params["rand"]))
        outport.send(mido.Message('control_change', channel=ch, control=26 + ch, value=params["level"]))

2. Algorithmic Touch Pad Automation (Virtual Swell Engine)

The capacitive plates control voice amplitude (CC 26, 27, 28). In Python, you can write asynchronous or threaded envelopes (e.g., Brownian motion, generative Markov chains, or slow micro-drifting LFOs) that ramp CC 26–28 up and down over minutes, creating evolving, hands-free ambient soundscapes that hardware LFOs cannot easily execute.

3. Macro Morphing & Scene Interpolation

A script can linearly or non-linearly interpolate between two distinct patches over an arbitrary duration (e.g., morph from a calm, clean sine-based drone into a distorted, high-randomness FM texture over 64 bars by slewing CC values over time).

4. Touch-to-MIDI Capture & External Routing

Because the Crum Hum sends CC data when you touch the capacitive plates and tweak the knobs, a Python listener (mido.open_input) can capture your expressive physical performance in real time. You can record these curves to standard .mid files or re-map them to modulate external VSTs or downstream hardware synths.


Can a genetic programming application be written in Python to control the device with semi-real-time play, and user feedback as the fitness measure?

An interactive evolutionary computation framework implemented in Python works well with the Crum Hum. In evolutionary computation literature, this falls specifically into Interactive Genetic Algorithms (IGA) (for evolving fixed parameter vectors/chromosomes) or Interactive Genetic Programming (IGP) (if you evolve expression trees or dynamic modulation routings).

Because the Crum Hum accepts MIDI CCs instantly without requiring a flash write cycle or rebooting, parameter updates take less than a millisecond over USB MIDI, making semi-real-time play and evaluation practical.

Architectural Design

 ┌────────────────┐         MIDI CCs           ┌──────────────────┐
 │ Python Engine  ├───────────────────────────►│    Crum Hum      │
 │                │◄───────────────────────────┤  (Audio Engine)  │
 └───────▲────────┘   Physical Knob Audition   └────────┬─────────┘
         │                                              │ Analog Audio
         │ Keyboard / UI Rating                         ▼
 ┌───────┴────────┐                            ┌──────────────────┐
 │ User Feedback  │◄───────────────────────────┤   User Ears /    │
 │ (Fitness Eval) │     Listens in Real-Time   │  Monitor System  │
 └────────────────┘                            └──────────────────┘

1. Genotype Representation (Chromosome)

The Crum Hum's sound state can be represented as an array of normalized floats [0.0, 1.0] or 7-bit integers [0, 127]:

  • Categorical Genes: Engine Type (CC 70, 0–11) and Scale (CC 71, 0–11).

  • Continuous Timbral Genes: Tone (CC 74), Tone Randomness (CC 75), Delay Time (CC 91), Delay Mix (CC 92), Reverb (CC 93), Drive (CC 94).

  • Voice-Specific Genes: Frequencies (CC 20–22), Probabilities/Spread (CC 23–25), and Base Amplitude/Swell (CC 26–28).

2. Tackling the Human Bottleneck (User Fatigue)

In standard genetic algorithms, populations range from hundreds to thousands over many generations. In interactive evolution, human fatigue sets in within 15–30 evaluations. To make semi-real-time play musically rewarding:

  • Micro-Populations (N = 4 to 8): Evolve small litters of candidates per generation.

  • Tournament or Pairwise Comparison: Instead of rating sounds on a difficult 1–10 scale, the user simply picks their favorite between Option A and Option B, or chooses the top 2 parents from a bank of 4 keys (e.g., keys 1, 2, 3, 4 on a QWERTY keyboard or MIDI pad controller).

  • Audition Loops: The Python application plays each candidate for an audition period (e.g., 4 or 8 clock bars) or allows instant hotkey switching between candidates while the Crum Hum's generative clock runs.

Minimal Prototype Implementation

The following complete script uses mido to manage MIDI CC transmission and implements a continuous interactive genetic algorithm:

import random
import time
import mido

# Target MIDI Controller mappings on Crum Hum
GENE_CC_MAP = [
    # (CC_number, channel_0_indexed, min_val, max_val, name)
    (70, 0, 0, 11, "Engine"),
    (71, 0, 0, 11, "Scale"),
    (74, 0, 0, 127, "Tone"),
    (75, 0, 0, 127, "Tone_Rand"),
    (91, 0, 0, 127, "Delay_Time"),
    (92, 0, 0, 127, "Delay_Mix"),
    (93, 0, 0, 127, "Reverb"),
    (94, 0, 0, 127, "Drive"),
    (20, 0, 0, 127, "V1_Freq"),
    (21, 1, 0, 127, "V2_Freq"),
    (22, 2, 0, 127, "V3_Freq"),
    (23, 0, 0, 127, "V1_Rand"),
    (24, 1, 0, 127, "V2_Rand"),
    (25, 2, 0, 127, "V3_Rand"),
    (26, 0, 40, 127, "V1_Level"),
    (27, 1, 40, 127, "V2_Level"),
    (28, 2, 40, 127, "V3_Level"),
]

def random_individual():
    return [random.randint(low, high) for _, _, low, high, _ in GENE_CC_MAP]

def send_patch(outport, individual):
    """Transmit the entire genome to Crum Hum via MIDI CC."""
    for gene_val, (cc, ch, _, _, _) in zip(individual, GENE_CC_MAP):
        outport.send(mido.Message('control_change', channel=ch, control=cc, value=gene_val))

def crossover(parent_a, parent_b):
    """Uniform crossover between two selected patches."""
    return [
        gene_a if random.random() < 0.5 else gene_b
        for gene_a, gene_b in zip(parent_a, parent_b)
    ]

def mutate(individual, mutation_rate=0.25, step_scale=0.15):
    """Gaussian drift on continuous values; random choice on categorical engines."""
    mutated = []
    for val, (_, _, low, high, name) in zip(individual, GENE_CC_MAP):
        if random.random() < mutation_rate:
            if name in ("Engine", "Scale"):
                val = random.randint(low, high)
            else:
                span = high - low
                delta = int(random.gauss(0, span * step_scale))
                val = max(low, min(high, val + delta))
        mutated.append(val)
    return mutated

def run_interactive_evolution():
    # Detect Crum Hum port
    output_names = mido.get_output_names()
    crum_ports = [p for p in output_names if "Crum" in p or "Hum" in p]
    port_name = crum_ports[0] if crum_ports else output_names[0]
    
    print(f"Connecting to MIDI Port: {port_name}")
    with mido.open_output(port_name) as outport:
        pop_size = 4
        population = [random_individual() for _ in range(pop_size)]
        generation = 1

        while True:
            print(f"\n--- Generation {generation} ---")
            for idx, candidate in enumerate(population):
                print(f"Auditioning Patch [{idx + 1}]...")
                send_patch(outport, candidate)
                time.sleep(3.0)  # Audition window for generative patterns to evolve

            # User evaluation (Fitness feedback)
            print("\nEnter ratings for each patch (1 to 5, e.g., '2 5 1 4'),")
            print("or type 's <num>' to solo/re-listen to a patch, or 'q' to quit:")
            cmd = input("Feedback: ").strip()

            if cmd.lower() == 'q':
                break
            if cmd.lower().startswith('s'):
                solo_idx = int(cmd.split()[1]) - 1
                send_patch(outport, population[solo_idx])
                continue

            ratings = [float(x) for x in cmd.split()]
            if len(ratings) != pop_size:
                print("Invalid count, re-entering generation...")
                continue

            # Selection (Roulette Wheel / Fitness-Proportionate)
            total_fitness = sum(ratings) + 1e-5
            probs = [r / total_fitness for r in ratings]
            
            # Preserve the absolute best individual (Elitism)
            elite_idx = ratings.index(max(ratings))
            new_population = [population[elite_idx]]

            # Breed next generation
            while len(new_population) < pop_size:
                parents = random.choices(population, weights=probs, k=2)
                child = crossover(parents[0], parents[1])
                child = mutate(child)
                new_population.append(child)

            population = new_population
            generation += 1

if __name__ == "__main__":
    run_interactive_evolution()

Extending from GA to Genetic Programming (GP)

If you wish to elevate this from evolving static parameters (GA) to Genetic Programming (GP):

  1. Dynamic Modulation LFO Trees: Instead of sending fixed CCs, let the genetic program evolve functional math trees (e.g., CC_{Tone}(t) = sin(omega_1 t) x {clamp}(cos(omega_2 t), 0, 1)).

  2. Behavioral Chromosomes: You can evaluate functions that manipulate voice relationships—such as evolving the harmonic interval offsets or rhythmic probability gates between Voice 1 and Voices 2 and 3 as mathematical dependencies rather than independent constants.

For an overview of evolutionary computation techniques and optimization implementations, check out this talk on Evolutionary Computation Examples with Inspyred. This presentation walks through practical Python implementations of evolutionary algorithms and explains how selection pressure and operators navigate complex parameter spaces.


Provide Python code to evolve Dynamic Modulation LFO Trees

Here is a complete, runnable Python application using standard tree-based Genetic Programming (GP) to evolve dynamic modulation LFO networks.

The system builds symbolic expression trees, evaluates them over time t, maps the resulting outputs into MIDI Continuous Controller bounds ([0, 127]), and continuously streams them to target parameters on the Crum Hum while you audition, switch between, and score candidates in real time.

Tree Representation & Node Primitives

The expression tree uses three kinds of nodes:

  1. Terminals: Time variable t, numeric constants c in [0.05, 5.0], and randomized phase offsets phi.

  2. Unary Operators: Pure mathematical waveforms and shapers: sin, cos, {abs}, {clamp}_{0,1}, {triangle}, and {square}.

  3. Binary Operators: Functional interactions: addition (+), multiplication (x for AM/ring-mod swells), bounded division, and crossfading/interpolation.

Python Implementation

import copy
import math
import random
import threading
import time
import mido

# =====================================================================
# 1. Target CC Routing Map for Crum Hum
# =====================================================================
TARGET_DESTINATIONS = [
    {"name": "Tone",        "cc": 74, "ch": 0, "min": 10,  "max": 120},
    {"name": "Tone_Rand",   "cc": 75, "ch": 0, "min": 0,   "max": 90},
    {"name": "Delay_Time",  "cc": 91, "ch": 0, "min": 5,   "max": 110},
    {"name": "Delay_Mix",   "cc": 92, "ch": 0, "min": 0,   "max": 95},
    {"name": "V1_Level",    "cc": 26, "ch": 0, "min": 30,  "max": 127},
    {"name": "V2_Level",    "cc": 27, "ch": 1, "min": 30,  "max": 127},
    {"name": "V3_Level",    "cc": 28, "ch": 2, "min": 30,  "max": 127},
]

# =====================================================================
# 2. GP AST Node Architecture
# =====================================================================
class Node:
    def eval(self, t: float) -> float:
        raise NotImplementedError

    def copy(self):
        raise NotImplementedError

    def size(self) -> int:
        raise NotImplementedError

    def get_all_nodes(self):
        nodes = [self]
        if hasattr(self, 'children'):
            for child in self.children:
                nodes.extend(child.get_all_nodes())
        return nodes

class Terminal(Node):
    def __init__(self, term_type: str, value: float = 0.0):
        self.term_type = term_type  # 't' or 'const'
        self.value = value

    def eval(self, t: float) -> float:
        return t if self.term_type == 't' else self.value

    def copy(self):
        return Terminal(self.term_type, self.value)

    def size(self) -> int:
        return 1

    def __repr__(self):
        return "t" if self.term_type == 't' else f"{self.value:.2f}"

class UnaryOp(Node):
    OPS = {
        'sin': lambda x: math.sin(x),
        'cos': lambda x: math.cos(x),
        'abs': lambda x: abs(x),
        'clamp01': lambda x: max(0.0, min(1.0, x)),
        'tri': lambda x: 2.0 * abs(2.0 * (x / (2 * math.pi) - math.floor(x / (2 * math.pi) + 0.5))) - 1.0,
        'sqr': lambda x: 1.0 if math.sin(x) >= 0 else -1.0
    }

    def __init__(self, op_name: str, child: Node):
        self.op_name = op_name
        self.child = child
        self.children = [child]

    def eval(self, t: float) -> float:
        try:
            return self.OPS[self.op_name](self.child.eval(t))
        except (ValueError, OverflowError, ZeroDivisionError):
            return 0.0

    def copy(self):
        return UnaryOp(self.op_name, self.child.copy())

    def size(self) -> int:
        return 1 + self.child.size()

    def __repr__(self):
        return f"{self.op_name}({self.child})"

class BinaryOp(Node):
    OPS = {
        '+': lambda a, b: a + b,
        '*': lambda a, b: a * b,
        '-': lambda a, b: a - b,
        'mix': lambda a, b: 0.5 * (a + b),
    }

    def __init__(self, op_name: str, left: Node, right: Node):
        self.op_name = op_name
        self.left = left
        self.right = right
        self.children = [left, right]

    def eval(self, t: float) -> float:
        try:
            return self.OPS[self.op_name](self.left.eval(t), self.right.eval(t))
        except (ValueError, OverflowError, ZeroDivisionError):
            return 0.0

    def copy(self):
        return BinaryOp(self.op_name, self.left.copy(), self.right.copy())

    def size(self) -> int:
        return 1 + self.left.size() + self.right.size()

    def __repr__(self):
        return f"({self.left} {self.op_name} {self.right})"

# =====================================================================
# 3. Tree Generation, Crossover & Mutation
# =====================================================================
def random_tree(depth: int = 0, max_depth: int = 4) -> Node:
    if depth >= max_depth or (depth > 1 and random.random() < 0.35):
        if random.random() < 0.6:
            return Terminal('t')
        else:
            # Slow modulation frequencies: 0.05 Hz to 3.0 Hz
            return Terminal('const', round(random.uniform(0.05, 3.0), 3))

    r = random.random()
    if r < 0.5:
        op = random.choice(list(BinaryOp.OPS.keys()))
        return BinaryOp(op, random_tree(depth + 1, max_depth), random_tree(depth + 1, max_depth))
    else:
        op = random.choice(list(UnaryOp.OPS.keys()))
        return UnaryOp(op, random_tree(depth + 1, max_depth))

def replace_random_subtree(target_tree: Node, replacement_node: Node) -> Node:
    new_root = target_tree.copy()
    all_nodes = new_root.get_all_nodes()
    if not all_nodes:
        return replacement_node.copy()
    node_to_replace = random.choice(all_nodes)
    node_to_replace.__dict__.clear()
    node_to_replace.__class__ = replacement_node.__class__
    node_to_replace.__dict__.update(replacement_node.copy().__dict__)
    return new_root

def crossover_trees(parent1: Node, parent2: Node) -> Node:
    donor_nodes = parent2.get_all_nodes()
    chosen_donor = random.choice(donor_nodes)
    child = replace_random_subtree(parent1, chosen_donor)
    # Prune overgrown trees
    if child.size() > 25:
        return parent1.copy()
    return child

def mutate_tree(tree: Node, mutation_rate: float = 0.3) -> Node:
    if random.random() > mutation_rate:
        return tree.copy()
    return replace_random_subtree(tree, random_tree(max_depth=2))

# =====================================================================
# 4. Multichannel Organism
# =====================================================================
class LFOOrganism:
    """An individual that owns an independent LFO tree per destination CC."""
    def __init__(self, trees=None):
        if trees:
            self.trees = trees
        else:
            self.trees = [random_tree(max_depth=3) for _ in TARGET_DESTINATIONS]

    def copy(self):
        return LFOOrganism([t.copy() for t in self.trees])

    def evaluate_cc(self, index: int, t: float) -> int:
        raw_val = self.trees[index].eval(t)
        # Normalization via soft clipping sigmoid
        norm = 1.0 / (1.0 + math.exp(-max(-10.0, min(10.0, raw_val))))
        d = TARGET_DESTINATIONS[index]
        return int(d["min"] + norm * (d["max"] - d["min"]))

def crossover_organisms(org_a: LFOOrganism, org_b: LFOOrganism) -> LFOOrganism:
    child_trees = []
    for t_a, t_b in zip(org_a.trees, org_b.trees):
        if random.random() < 0.5:
            child_trees.append(crossover_trees(t_a, t_b))
        else:
            child_trees.append(crossover_trees(t_b, t_a))
    return LFOOrganism(child_trees)

def mutate_organism(org: LFOOrganism) -> LFOOrganism:
    return LFOOrganism([mutate_tree(t, mutation_rate=0.25) for t in org.trees])

# =====================================================================
# 5. Real-Time MIDI Modulation Engine & Interactive Runner
# =====================================================================
class GPInteractiveRuntime:
    def __init__(self, port_name: str, pop_size: int = 4):
        self.port_name = port_name
        self.pop_size = pop_size
        self.population = [LFOOrganism() for _ in range(pop_size)]
        self.active_index = 0
        self.running = True
        self.lock = threading.Lock()

    def midi_stream_worker(self):
        """Asynchronous background thread running at 40 Hz (25 ms interval)."""
        try:
            with mido.open_output(self.port_name) as outport:
                start_time = time.time()
                last_sent = [None] * len(TARGET_DESTINATIONS)

                while self.running:
                    t = time.time() - start_time
                    with self.lock:
                        current_org = self.population[self.active_index]

                    for idx, dest in enumerate(TARGET_DESTINATIONS):
                        cc_val = current_org.evaluate_cc(idx, t)
                        if cc_val != last_sent[idx]:
                            outport.send(mido.Message(
                                'control_change',
                                channel=dest["ch"],
                                control=dest["cc"],
                                value=cc_val
                            ))
                            last_sent[idx] = cc_val

                    time.sleep(0.025)
        except Exception as e:
            print(f"MIDI Worker Exception: {e}")

    def run(self):
        worker = threading.Thread(target=self.midi_stream_worker, daemon=True)
        worker.start()
        print(f"Started MIDI Modulation on: {self.port_name}")

        gen = 1
        while self.running:
            print(f"\n================ GEN {gen} ================")
            for i, org in enumerate(self.population):
                print(f"[{i + 1}] Tone LFO: {org.trees[0]}")
            print("------------------------------------------")
            print("Commands:")
            print("  1-4          -> Switch active audition slot")
            print("  r <1-4> ...  -> Submit fitness ratings (e.g., 'r 1 4 5 2') to evolve")
            print("  q            -> Quit")

            user_cmd = input("Command > ").strip().lower()
            if not user_cmd:
                continue

            if user_cmd == 'q':
                self.running = False
                break

            if user_cmd in [str(i + 1) for i in range(self.pop_size)]:
                target_slot = int(user_cmd) - 1
                with self.lock:
                    self.active_index = target_slot
                print(f">> Now auditioning Patch #{target_slot + 1}")
                continue

            if user_cmd.startswith('r '):
                parts = user_cmd.split()[1:]
                if len(parts) != self.pop_size:
                    print(f"Error: provide {self.pop_size} scores.")
                    continue
                try:
                    scores = [float(p) for p in parts]
                except ValueError:
                    print("Invalid numeric scores.")
                    continue

                total_fit = sum(scores) + 1e-6
                weights = [s / total_fit for s in scores]
                elite_idx = scores.index(max(scores))

                # Elitism: retain top performer
                next_gen = [self.population[elite_idx].copy()]

                while len(next_gen) < self.pop_size:
                    parent_a, parent_b = random.choices(self.population, weights=weights, k=2)
                    child = crossover_organisms(parent_a, parent_b)
                    child = mutate_organism(child)
                    next_gen.append(child)

                with self.lock:
                    self.population = next_gen
                    self.active_index = 0
                gen += 1
                print(">> Evolution complete. Auditioning elite candidate (#1).")

if __name__ == "__main__":
    ports = mido.get_output_names()
    if not ports:
        print("No MIDI ports detected. Please connect your Crum Hum or a virtual MIDI bus.")
        exit(1)

    matching = [p for p in ports if "Crum" in p or "Hum" in p]
    chosen_port = matching[0] if matching else ports[0]

    runtime = GPInteractiveRuntime(port_name=chosen_port, pop_size=4)
    runtime.run()

Architectural Highlights

  • Thread-Isolated Streaming (40 Hz Update Loop): The MIDI dispatch runs on a decoupled daemon thread, evaluating the active organism's tree every 25 milliseconds (1/40,{s}). The CLI input thread never blocks the continuous output stream.

  • Delta Filtering: A value check (if cc_val != last_sent[idx]) prevents MIDI USB flooding; identical CC values are not re-sent.

  • Soft Clamping: Rather than using a hard floor/ceiling function that truncates wave peaks into flat plateaus, a numerical sigmoid (1 / (1 + e^{-x})) smoothly compresses large compound amplitudes into the target [CC_{min}, CC_{\max}] range.

  • Non-destructive Crossover: When two individuals breed, crossover operates on subtrees of equal semantic intent (e.g., the Tone LFO of Parent A swaps subtrees with the Tone LFO of Parent B), preserving functional modularity.


Since the device does not have a built-in sequencer, describe how to implement one in Python

Implementing a multi-track algorithmic sequencer for the Crum Hum in Python requires managing three core engineering challenges: timing jitter, independent polyphonic voice tracking, and parameter locks (P-Locks) / CC automation.

1. The Real-Time Scheduling Model

Standard Python time.sleep() drifts significantly due to OS thread scheduling and garbage collection. To achieve tight, sub-millisecond MIDI clock sync, use a high-resolution monotonic timeline loop (time.perf_counter()) paired with a short sleep and an active spin-wait buffer:

import time

def wait_until(target_time: float):
    """Hybrid sleep and spin-wait to eliminate scheduling jitter."""
    while True:
        diff = target_time - time.perf_counter()
        if diff <= 0:
            break
        elif diff > 0.002:
            time.sleep(diff - 0.001)  # Coarse sleep to yield CPU
        # Sub-millisecond spin loop for sample-accurate dispatch

2. Multi-Voice Step Architecture

Because each of the Crum Hum's 3 voices listens on a separate MIDI channel (Channels 1, 2, and 3; 0-indexed as 0, 1, 2), the sequencer models tracks as independent parallel patterns. Each step contains:

  • Pitch / Note: Target MIDI note (e.g., 36 to 84).

  • Velocity / Gate: Velocity byte and gate length (as a fraction of step duration).

  • Probability: p in [0.0, 1.0] determining if the step triggers.

  • Parameter Locks (CC Automation): Per-step overrides for timbre, delay, or voice level (e.g., locking a harsh FM Tone value on step 4 of an otherwise clean loop).

3. Full Runnable Implementation

The script below provides a complete, threaded 3-track step sequencer with real-time Euclidean rhythm generation, note scheduling, and MIDI clock pulse transmission (0xF8).

import math
import threading
import time
import mido

# ---------------------------------------------------------------------
# Pattern Generation Helpers
# ---------------------------------------------------------------------
def bjorklund_euclidean(pulses: int, steps: int):
    """Generates Euclidean rhythm arrays (e.g., Bjorklund algorithm)."""
    if pulses > steps:
        raise ValueError("Pulses cannot exceed steps")
    pattern = []
    counts = [1] * pulses + [0] * (steps - pulses)
    return [1 if ((i * pulses) % steps) < pulses else 0 for i in range(steps)]

class Step:
    def __init__(self, note: int = 60, velocity: int = 100, gate: float = 0.75, 
                 probability: float = 1.0, p_locks: dict = None):
        self.note = note
        self.velocity = velocity
        self.gate = gate          # Fraction of the step duration
        self.probability = probability
        self.p_locks = p_locks or {}  # e.g., {74: 105} -> Sets CC 74 (Tone)

class VoiceTrack:
    def __init__(self, channel: int, steps: list[Step]):
        self.channel = channel
        self.steps = steps
        self.length = len(steps)

# ---------------------------------------------------------------------
# Sequencer Engine
# ---------------------------------------------------------------------
class CrumHumSequencer:
    def __init__(self, port_name: str, bpm: float = 110.0, ppqn: int = 24):
        self.port_name = port_name
        self.bpm = bpm
        self.ppqn = ppqn  # 24 MIDI clock pulses per quarter note
        self.tracks: list[VoiceTrack] = []
        self.running = False
        self._thread = None

    def add_track(self, track: VoiceTrack):
        self.tracks.append(track)

    def start(self):
        self.running = True
        self._thread = threading.Thread(target=self._run_loop, daemon=True)
        self._thread.start()

    def stop(self):
        self.running = False
        if self._thread:
            self._thread.join()

    def _run_loop(self):
        with mido.open_output(self.port_name) as outport:
            outport.send(mido.Message('start'))
            
            clock_interval = 60.0 / (self.bpm * self.ppqn)
            pulses_per_16th = self.ppqn // 4  # 6 clock ticks per 16th note
            
            next_tick_time = time.perf_counter()
            tick_counter = 0
            current_step = 0
            
            # Active notes awaiting NoteOff: list of (off_time, channel, note)
            scheduled_note_offs = []

            while self.running:
                now = time.perf_counter()

                # 1. Process pending Note-Off events
                remaining_offs = []
                for off_time, ch, n in scheduled_note_offs:
                    if now >= off_time:
                        outport.send(mido.Message('note_off', channel=ch, note=n, velocity=0))
                    else:
                        remaining_offs.append((off_time, ch, n))
                scheduled_note_offs = remaining_offs

                # 2. Send MIDI Clock Pulse (0xF8)
                outport.send(mido.Message('clock'))

                # 3. Fire Steps on 16th Note Boundaries
                if tick_counter % pulses_per_16th == 0:
                    step_duration = clock_interval * pulses_per_16th

                    for track in self.tracks:
                        step_data = track.steps[current_step % track.length]
                        
                        # Probability gate evaluation
                        if step_data.probability >= 1.0 or (import_random := __import__('random')).random() < step_data.probability:
                            
                            # Dispatch Parameter Locks (CCs) prior to note onset
                            for cc_num, cc_val in step_data.p_locks.items():
                                outport.send(mido.Message(
                                    'control_change',
                                    channel=track.channel,
                                    control=cc_num,
                                    value=cc_val
                                ))

                            # Send Note-On
                            outport.send(mido.Message(
                                'note_on',
                                channel=track.channel,
                                note=step_data.note,
                                velocity=step_data.velocity
                            ))

                            # Schedule corresponding Note-Off
                            off_timestamp = now + (step_duration * step_data.gate)
                            scheduled_note_offs.append((off_timestamp, track.channel, step_data.note))

                    current_step += 1

                tick_counter += 1
                next_tick_time += clock_interval

                # High-precision sync wait
                wait_until(next_tick_time)

            outport.send(mido.Message('stop'))

# ---------------------------------------------------------------------
# Pattern Setup & Execution
# ---------------------------------------------------------------------
if __name__ == "__main__":
    ports = mido.get_output_names()
    if not ports:
        print("No MIDI output port found.")
        exit(1)
        
    midi_port = next((p for p in ports if "Crum" in p or "Hum" in p), ports[0])
    print(f"Connecting Sequencer to: {midi_port}")

    seq = CrumHumSequencer(port_name=midi_port, bpm=96.0)

    # Voice 1: Heavy Root Drone / Bass Pulse (Ch 0) - 16 steps
    v1_steps = [
        Step(note=36, velocity=110, gate=0.9, p_locks={74: 40}),  # Tone closed
        Step(note=36, velocity=0),
        Step(note=36, velocity=90, gate=0.5),
        Step(note=48, velocity=115, gate=0.75, p_locks={74: 90}), # Tone opened
    ] * 4

    # Voice 2: Generative Euclidean Melodic Arp (Ch 1) - 16 steps (5 pulses)
    euclid_rhythm = bjorklund_euclidean(pulses=5, steps=16)
    scale_degrees = [48, 51, 53, 55, 58, 60]
    v2_steps = []
    for hit in euclid_rhythm:
        if hit:
            v2_steps.append(Step(
                note=(import_random := __import__('random')).choice(scale_degrees),
                velocity=95,
                gate=0.5,
                probability=0.85
            ))
        else:
            v2_steps.append(Step(note=0, velocity=0))

    # Voice 3: Polymetric Counterpoint (Ch 2) - 12 steps (3/4 cross-rhythm)
    v3_steps = [
        Step(note=60, velocity=85, gate=0.6),
        Step(note=63, velocity=85, gate=0.6),
        Step(note=67, velocity=100, gate=0.8, p_locks={93: 110}), # Reverb swell
    ] * 4

    seq.add_track(VoiceTrack(channel=0, steps=v1_steps))
    seq.add_track(VoiceTrack(channel=1, steps=v2_steps))
    seq.add_track(VoiceTrack(channel=2, steps=v3_steps))

    print("Sequencer running. Press Ctrl+C to terminate.")
    seq.start()
    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        print("\nStopping...")
        seq.stop()

Key Capabilities Provided

  • True Polymetric Phrasing: Track lengths can vary independently (e.g., Track 1 has 16 steps while Track 3 has 12 steps). Over time, the phases constantly slide against each other, generating a non-repeating sonic dialogue across the Crum Hum's voices.

  • Per-Step Parameter Locks (Elektron-Style P-Locks): By dispatching CC messages immediately before note_on, individual steps can manipulate the synth's Tone (CC 74), Tone Randomness (CC 75), Delay Time (CC 91), or Reverb (CC 93).

  • Hardware Sync Transmission: Transmitting raw 0xF8 MIDI clock pulses at 24 PPQN ensures external sequencers, drum machines, or DAW transports connected downstream remain locked to the Python clock.


From <https://gemini.google.com/app/f6c3f7b37a0ae358>  Google Gemini (3.8 Flash)

Comments

Popular posts from this blog

#4297 ISS Astronaut Unable to Speak

#761 New Ways to Access Online Content, particularly Virtual Reality Content

#3356 PHP and JavaScript to Retrieve and Set an API_Key