Code Lab: Fractal Music Generation
Fractals are structures that exhibit self-similarity across multiple scales. In music, this principle appears naturally: a rhythmic motif may repeat at the level of a beat, a measure, and an entire section. In this lab, you will build three fractal generators that produce music-like sequences, exploring the deep connection between mathematical self-similarity and musical structure.
1/f Noise Generator
Many natural signals, including pitch fluctuations in human music performance, follow a 1/f (pink noise) power spectrum. Unlike white noise (completely random) or brown noise (overly correlated), 1/f noise strikes a balance between predictability and surprise that listeners find musically engaging.
import numpy as np
import matplotlib.pyplot as plt
def generate_1f_noise(n_samples, exponent=1.0):
"""Generate a 1/f^exponent noise sequence via spectral shaping."""
white = np.fft.rfft(np.random.randn(n_samples))
freqs = np.fft.rfftfreq(n_samples, d=1.0)
freqs[0] = 1 # avoid division by zero at DC
shaped = white / freqs ** (exponent / 2.0)
signal = np.fft.irfft(shaped, n=n_samples)
return signal
n = 1024
pink = generate_1f_noise(n, exponent=1.0)
white = generate_1f_noise(n, exponent=0.0)
brown = generate_1f_noise(n, exponent=2.0)
fig, axes = plt.subplots(3, 1, figsize=(10, 6), sharex=True)
for ax, sig, label in zip(axes, [white, pink, brown],
["White (1/f^0)", "Pink (1/f^1)", "Brown (1/f^2)"]):
ax.plot(sig[:256], linewidth=0.8)
ax.set_ylabel(label)
axes[-1].set_xlabel("Sample index")
fig.suptitle("Noise Types Used in Fractal Music")
plt.tight_layout()
plt.show()
The pink noise sequence can be quantized to a musical scale to produce melodies that sound neither random nor monotonous.
def quantize_to_scale(signal, scale_degrees):
"""Map a continuous signal onto discrete scale degrees."""
normalized = (signal - signal.min()) / (signal.max() - signal.min())
indices = (normalized * (len(scale_degrees) - 1)).astype(int)
return np.array([scale_degrees[i] for i in indices])
c_major = [60, 62, 64, 65, 67, 69, 71, 72] # MIDI note numbers
melody = quantize_to_scale(pink[:64], c_major)
plt.figure(figsize=(10, 3))
plt.step(range(len(melody)), melody, where="mid")
plt.yticks(c_major, ["C4", "D4", "E4", "F4", "G4", "A4", "B4", "C5"])
plt.xlabel("Time step")
plt.ylabel("Pitch")
plt.title("1/f Noise Melody on C Major Scale")
plt.tight_layout()
plt.show()
L-System Melody Generator
Lindenmayer systems (L-systems) use rewriting rules to generate self-similar strings. By mapping symbols to musical actions, we can compose melodies that contain structure at every level of magnification.
def l_system(axiom, rules, iterations):
"""Expand an L-system string through repeated rule application."""
current = axiom
for _ in range(iterations):
current = "".join(rules.get(ch, ch) for ch in current)
return current
rules = {"A": "ABA", "B": "BBB"}
melody_string = l_system("A", rules, 4)
print(f"L-system string (first 80 chars): {melody_string[:80]}")
print(f"Total length: {len(melody_string)}")
symbol_to_interval = {"A": 2, "B": -1} # A = step up, B = step down
start_note = 60
notes = [start_note]
for ch in melody_string[:128]:
notes.append(notes[-1] + symbol_to_interval[ch])
plt.figure(figsize=(10, 3))
plt.plot(notes, linewidth=0.8)
plt.xlabel("Time step")
plt.ylabel("MIDI note number")
plt.title("L-System Melody: Rule A→ABA, B→BBB")
plt.tight_layout()
plt.show()
Notice how the melody contains recurring contour patterns at different time scales, a hallmark of fractal self-similarity.
Cantor Set Rhythm Generator
The Cantor set is constructed by repeatedly removing the middle third of each interval. Applied to rhythm, this creates patterns where silence nests inside silence, producing rhythms with a distinctive hierarchical feel.
def cantor_rhythm(levels):
"""Generate a Cantor-set rhythm as a binary array."""
rhythm = np.ones(3 ** levels, dtype=int)
for level in range(1, levels + 1):
segment = 3 ** level
third = segment // 3
for start in range(0, len(rhythm), segment):
rhythm[start + third : start + 2 * third] = 0
return rhythm
for depth in [1, 2, 3, 4]:
r = cantor_rhythm(depth)
plt.figure(figsize=(10, 0.6))
plt.imshow(r.reshape(1, -1), aspect="auto", cmap="Greys",
interpolation="none")
plt.yticks([])
plt.title(f"Cantor Rhythm, depth = {depth}")
plt.tight_layout()
plt.show()
# Sonify the Cantor rhythm by computing inter-onset intervals
depth = 4
rhythm = cantor_rhythm(depth)
onsets = np.where(rhythm == 1)[0]
ioi = np.diff(onsets)
plt.figure(figsize=(10, 3))
plt.bar(range(len(ioi)), ioi, width=1.0, edgecolor="none")
plt.xlabel("Onset index")
plt.ylabel("Inter-onset interval (ticks)")
plt.title("Inter-Onset Intervals in a Cantor Rhythm (depth 4)")
plt.tight_layout()
plt.show()
print(f"Unique IOI values: {sorted(set(ioi))}")
print(f"Total onsets: {len(onsets)} out of {len(rhythm)} time steps")
The inter-onset intervals themselves form a self-similar distribution, with only a few distinct duration values appearing regardless of the depth chosen.
Try It Yourself
-
Explore the 1/f exponent. Generate melodies with exponents of 0.5, 1.0, 1.5, and 2.0. Listen conceptually (or sonify with a MIDI library) and describe how increasing the exponent changes the character of the melody. At what exponent does the melody feel most "musical"?
-
Design your own L-system rules. Create a new set of rewriting rules with at least three symbols. Map each symbol to a pitch interval, and plot the resulting melody for 3, 4, and 5 iterations. Does the contour remain self-similar across iteration depths?
-
Combine fractal rhythm and pitch. Use the Cantor set generator to produce a rhythm and the 1/f noise generator to produce pitches. Combine them so that notes sound only at Cantor-set onsets. Plot the result as a piano-roll-style diagram (time on x-axis, pitch on y-axis, with dots at each note event). How does this hybrid fractal sequence compare to the pitch-only or rhythm-only versions?