Skip to content

Commit d0b33ae

Browse files
author
Simon Holliday
committed
- Add ratchet method
1 parent 3010256 commit d0b33ae

3 files changed

Lines changed: 362 additions & 2 deletions

File tree

README.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,26 @@ bias = [1.0 - i / 15 for i in range(16)]
204204
p.thin(strategy=bias, amount=swell, grid=16, rng=p.rng)
205205
```
206206

207+
### Ratchet
208+
209+
Ratcheting is a hardware sequencer technique heard on some hardware sequencers - where a single step fires as a rapid burst of repeated hits rather than one note. `p.ratchet(subdivisions, pitch, probability, velocity_start, velocity_end, shape, gate, steps)` is a post-placement transform: it takes notes already in the pattern and replaces each one with `subdivisions` evenly-spaced sub-hits within the original note's duration window. Call it after note-placement methods and before swing or groove.
210+
211+
Velocity across sub-hits is shaped by multipliers (`velocity_start``velocity_end`) interpolated via an easing curve - the same easing vocabulary used by `cc_ramp()`. `gate` (0–1) sets sub-note duration as a fraction of each slot: `0.5` is staccato, `1.0` is legato. Use `pitch` to target a single instrument; use `steps` (a list of grid indices) to only ratchet specific positions; use `probability` for chance-based subdivision.
212+
213+
```python
214+
# Triplet roll on every hi-hat
215+
p.euclidean("hh_closed", 8).ratchet(3, pitch="hh_closed")
216+
217+
# Crescendo snare roll: quiet → loud over 4 sub-hits
218+
p.hit_steps("snare", [12]).ratchet(4, velocity_start=0.3, velocity_end=1.0, shape="ease_in")
219+
220+
# Probabilistic 2× ratchet with tight gate
221+
p.euclidean("hh_closed", 12).ratchet(2, probability=0.4, gate=0.25)
222+
223+
# Ratchet only the downbeat and midpoint (steps 0 and 8 of 16)
224+
p.euclidean("kick_1", 6).ratchet(2, pitch="kick_1", steps=[0, 8])
225+
```
226+
207227
### Cellular automaton
208228

209229
John von Neumann and Stanislaw Ulam conceived cellular automata in the 1940s as models of self-replicating systems. Stephen Wolfram systematically explored 1D elementary automata in the 1980s, cataloguing all 256 rules - discovering that Rule 110 is Turing-complete and Rule 30 produces output indistinguishable from randomness. `p.cellular_1d(pitch, rule, velocity)` generates rhythm from a 1D automaton where each generation evolves from the previous, so patterns self-organise, grow, glide, and die. `p.cellular_2d(parts, rule, density, velocity)` runs a 2D Life-like CA where rows map to instruments and columns to time steps.
@@ -2504,8 +2524,6 @@ Planned features, roughly in order of priority.
25042524

25052525
### Medium priority
25062526

2507-
- **Ratcheting & Subdivisions.** A unified `p.ratchet()` transform to take existing notes and subdivide them into rolls or ratchets based on probability or secondary patterns, allowing dynamic micro-timing and subdivision of primary algorithmic rhythms without manual coding.
2508-
25092527
- **MIDI File Import & Analysis.** Allow users to load existing `.mid` files and extract their rhythmic or harmonic content to feed into Subsequence algorithms (e.g., generating Markov chains trained on a Bach invention MIDI file).
25102528

25112529
- **Visual Dashboard / Web UI.** A lightweight local web dashboard to provide real-time visual feedback of the current Chord Graph, global Conductor signals, and active patterns.

subsequence/pattern_algorithmic.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import subsequence.constants
1111
import subsequence.constants.velocity
12+
import subsequence.easing
1213
import subsequence.melodic_state
1314
import subsequence.pattern
1415
import subsequence.sequence_utils
@@ -1425,6 +1426,176 @@ def thin (
14251426
del self._pattern.steps[pulse]
14261427
return self
14271428

1429+
def ratchet (
1430+
self,
1431+
subdivisions: int = 2,
1432+
pitch: typing.Optional[typing.Union[int, str]] = None,
1433+
probability: float = 1.0,
1434+
velocity_start: float = 1.0,
1435+
velocity_end: float = 1.0,
1436+
shape: typing.Union[str, typing.Callable[[float], float]] = "linear",
1437+
gate: float = 0.5,
1438+
steps: typing.Optional[typing.List[int]] = None,
1439+
grid: typing.Optional[int] = None,
1440+
rng: typing.Optional[random.Random] = None,
1441+
) -> "PatternAlgorithmicMixin":
1442+
1443+
"""Subdivide existing notes into rapid repeated hits (rolls/ratchets).
1444+
1445+
A post-placement transform: takes notes already in the pattern and
1446+
replaces each one with ``subdivisions`` evenly-spaced sub-hits within
1447+
the original note's duration window. The velocity of each sub-hit is
1448+
interpolated from ``velocity_start`` to ``velocity_end`` (as multipliers
1449+
on the original velocity) using the ``shape`` easing curve, so crescendo
1450+
rolls, decrescendo buzzes, and flat repeats are all one parameter apart.
1451+
1452+
Call ``ratchet()`` after note-placement methods (``euclidean``,
1453+
``hit_steps``, ``arpeggio``, etc.) and before ``swing`` or ``groove``
1454+
so that the subdivisions sit inside the original note slot and swing
1455+
displacement still affects the parent position.
1456+
1457+
Parameters:
1458+
subdivisions: Number of sub-hits replacing each note (default 2).
1459+
If the note's duration is shorter than ``subdivisions`` pulses,
1460+
subdivisions are clamped to ``note.duration`` so they never
1461+
stack on the same pulse.
1462+
pitch: Only ratchet notes matching this pitch (MIDI number or drum
1463+
name). ``None`` (default) ratchets all notes regardless of
1464+
pitch — useful for melodic patterns such as arpeggios.
1465+
probability: Chance (0.0–1.0) that each note gets ratcheted. Notes
1466+
that fail the check are left completely unchanged. Default 1.0
1467+
(every note is ratcheted).
1468+
velocity_start: Velocity multiplier for the first sub-hit (0.0–2.0).
1469+
Default 1.0 (same as the original).
1470+
velocity_end: Velocity multiplier for the last sub-hit (0.0–2.0).
1471+
Default 1.0. Set ``velocity_start=0.3, velocity_end=1.0`` for
1472+
a crescendo roll; ``1.0, 0.2`` for a decrescendo buzz.
1473+
shape: Easing curve applied to the velocity interpolation across
1474+
sub-hits. Accepts any name from ``subsequence.easing`` (e.g.
1475+
``"ease_in"``, ``"ease_out"``, ``"s_curve"``) or a custom
1476+
callable ``f(t) → t`` for t ∈ [0, 1]. Default ``"linear"``.
1477+
gate: Sub-note duration as a fraction of each subdivision slot
1478+
(0.0–1.0). ``1.0`` = legato (sub-hits touch), ``0.5`` =
1479+
staccato (half the slot). Default 0.5.
1480+
steps: Grid positions to ratchet (e.g. ``[0, 4, 12]``). Notes are
1481+
classified to grid zones the same way ``thin()`` works — swing-
1482+
shifted notes remain in their original zone. ``None`` (default)
1483+
applies ratchet to all eligible notes.
1484+
grid: Grid resolution used for ``steps`` zone classification.
1485+
Defaults to the pattern's ``default_grid``.
1486+
rng: Random number generator. Defaults to ``self.rng``.
1487+
1488+
Examples:
1489+
```python
1490+
# Subdivide every hi-hat into a triplet roll
1491+
p.euclidean("hh_closed", 8).ratchet(3, pitch="hh_closed")
1492+
1493+
# Crescendo roll into a snare
1494+
p.hit_steps("snare", [12]).ratchet(4, velocity_start=0.3,
1495+
velocity_end=1.0,
1496+
shape="ease_in")
1497+
1498+
# Probabilistic 2× ratchet on hi-hats only
1499+
p.euclidean("hh_closed", 12).ratchet(2, pitch="hh_closed",
1500+
probability=0.4, gate=0.3)
1501+
1502+
# Ratchet only steps 0 and 8 (downbeats)
1503+
p.euclidean("kick_1", 6).ratchet(2, pitch="kick_1", steps=[0, 8])
1504+
```
1505+
"""
1506+
1507+
if rng is None:
1508+
rng = self.rng
1509+
1510+
if grid is None:
1511+
grid = self._default_grid
1512+
1513+
midi_pitch = self._resolve_pitch(pitch) if pitch is not None else None
1514+
ease_fn = subsequence.easing.get_easing(shape)
1515+
1516+
# Build zone set for steps mask (zone-based, matching thin()'s approach).
1517+
target_zones: typing.Optional[typing.Set[int]] = None
1518+
if steps is not None:
1519+
target_zones = set(steps)
1520+
1521+
total_pulses = self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE
1522+
step_pulses = total_pulses / grid
1523+
1524+
new_steps: typing.Dict[int, subsequence.pattern.Step] = {}
1525+
1526+
for pulse, step in self._pattern.steps.items():
1527+
1528+
# Zone classification for steps mask.
1529+
if target_zones is not None:
1530+
zone = int(pulse / step_pulses)
1531+
if zone >= grid:
1532+
zone = grid - 1
1533+
in_zone = zone in target_zones
1534+
else:
1535+
in_zone = True
1536+
1537+
# Separate targeted notes from passthrough notes.
1538+
if midi_pitch is None:
1539+
targets = list(step.notes) if in_zone else []
1540+
passthrough = [] if in_zone else list(step.notes)
1541+
else:
1542+
if in_zone:
1543+
targets = [n for n in step.notes if n.pitch == midi_pitch]
1544+
passthrough = [n for n in step.notes if n.pitch != midi_pitch]
1545+
else:
1546+
targets = []
1547+
passthrough = list(step.notes)
1548+
1549+
# Passthrough notes keep their original pulse position.
1550+
if passthrough:
1551+
if pulse not in new_steps:
1552+
new_steps[pulse] = subsequence.pattern.Step()
1553+
new_steps[pulse].notes.extend(passthrough)
1554+
1555+
for note in targets:
1556+
1557+
# Probability gate — failed notes are kept unchanged.
1558+
if probability < 1.0 and rng.random() >= probability:
1559+
if pulse not in new_steps:
1560+
new_steps[pulse] = subsequence.pattern.Step()
1561+
new_steps[pulse].notes.append(note)
1562+
continue
1563+
1564+
# Clamp subdivisions so sub-hits never stack on the same pulse.
1565+
effective_subdivs = min(subdivisions, note.duration)
1566+
if effective_subdivs < 1:
1567+
effective_subdivs = 1
1568+
1569+
slot_pulses = note.duration / effective_subdivs
1570+
1571+
for i in range(effective_subdivs):
1572+
sub_pulse = pulse + int(round(i * slot_pulses))
1573+
1574+
# Velocity interpolation via easing.
1575+
if effective_subdivs == 1:
1576+
t = 0.0
1577+
else:
1578+
t = i / (effective_subdivs - 1)
1579+
eased_t = ease_fn(t)
1580+
vel_mul = velocity_start + (velocity_end - velocity_start) * eased_t
1581+
sub_velocity = max(1, min(127, int(round(note.velocity * vel_mul))))
1582+
1583+
sub_duration = max(1, int(round(slot_pulses * gate)))
1584+
1585+
sub_note = subsequence.pattern.Note(
1586+
pitch=note.pitch,
1587+
velocity=sub_velocity,
1588+
duration=sub_duration,
1589+
channel=note.channel,
1590+
)
1591+
1592+
if sub_pulse not in new_steps:
1593+
new_steps[sub_pulse] = subsequence.pattern.Step()
1594+
new_steps[sub_pulse].notes.append(sub_note)
1595+
1596+
self._pattern.steps = new_steps
1597+
return self
1598+
14281599
def evolve (
14291600
self,
14301601
pitches: typing.List[typing.Union[int, str]],

tests/test_pattern_algorithmic.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,3 +240,174 @@ def test_branch_cycle_path_advances() -> None:
240240

241241
# Each path should produce a unique sequence.
242242
assert len(variations) == 2 ** depth, f"Expected {2**depth} unique variations, got {len(variations)}"
243+
244+
245+
# ---------------------------------------------------------------------------
246+
# ratchet()
247+
# ---------------------------------------------------------------------------
248+
249+
PPQN = subsequence.constants.MIDI_QUARTER_NOTE # 24
250+
251+
252+
def test_ratchet_basic_subdivision() -> None:
253+
"""A single note with subdivisions=3 becomes exactly 3 evenly-spaced notes."""
254+
pattern, builder = _make_builder(length=4)
255+
# Place one note at beat 0 with duration 1 beat (24 pulses).
256+
builder.note(60, beat=0, velocity=100, duration=1.0)
257+
builder.ratchet(3)
258+
259+
notes = [(pulse, n) for pulse, step in sorted(pattern.steps.items()) for n in step.notes]
260+
assert len(notes) == 3
261+
262+
pulses = [p for p, _ in notes]
263+
slot = PPQN / 3 # 8 pulses per slot
264+
assert pulses[0] == 0
265+
assert pulses[1] == round(slot)
266+
assert pulses[2] == round(2 * slot)
267+
268+
269+
def test_ratchet_velocity_linear_shaping() -> None:
270+
"""velocity_start/end with linear shape interpolates evenly across sub-hits."""
271+
pattern, builder = _make_builder(length=4)
272+
builder.note(60, beat=0, velocity=100, duration=1.0)
273+
builder.ratchet(4, velocity_start=0.5, velocity_end=1.0, shape="linear")
274+
275+
notes = [n for pulse, step in sorted(pattern.steps.items()) for n in step.notes]
276+
assert len(notes) == 4
277+
278+
velocities = [n.velocity for n in notes]
279+
# t values: 0/3, 1/3, 2/3, 3/3 → multipliers: 0.5, 0.667, 0.833, 1.0
280+
assert velocities[0] == round(100 * 0.5)
281+
assert velocities[3] == 100
282+
283+
284+
def test_ratchet_pitch_filter_leaves_other_notes_unchanged() -> None:
285+
"""With pitch filter, non-matching notes are untouched."""
286+
drum_map = {"kick": 36, "hh": 42}
287+
pattern, builder = _make_builder(length=4)
288+
builder._drum_note_map = drum_map
289+
290+
# Place kick at beat 0, hh at beat 1 — both 1-beat duration.
291+
builder.note(36, beat=0, velocity=100, duration=1.0)
292+
builder.note(42, beat=1, velocity=80, duration=1.0)
293+
294+
builder.ratchet(3, pitch=42)
295+
296+
all_notes = [(pulse, n) for pulse, step in sorted(pattern.steps.items()) for n in step.notes]
297+
kick_notes = [(p, n) for p, n in all_notes if n.pitch == 36]
298+
hh_notes = [(p, n) for p, n in all_notes if n.pitch == 42]
299+
300+
# Kick: unchanged — still 1 note at pulse 0.
301+
assert len(kick_notes) == 1
302+
assert kick_notes[0][0] == 0
303+
assert kick_notes[0][1].velocity == 100
304+
305+
# HH: subdivided into 3.
306+
assert len(hh_notes) == 3
307+
308+
309+
def test_ratchet_probability_zero_leaves_all_unchanged() -> None:
310+
"""probability=0.0 — no note is ratcheted."""
311+
pattern, builder = _make_builder(length=4)
312+
builder.note(60, beat=0, velocity=100, duration=1.0)
313+
builder.note(60, beat=1, velocity=100, duration=1.0)
314+
builder.ratchet(4, probability=0.0)
315+
316+
notes = [n for pulse, step in pattern.steps.items() for n in step.notes]
317+
assert len(notes) == 2
318+
319+
320+
def test_ratchet_probability_one_ratchets_all() -> None:
321+
"""probability=1.0 — every note is ratcheted."""
322+
pattern, builder = _make_builder(length=4)
323+
builder.note(60, beat=0, velocity=100, duration=1.0)
324+
builder.note(60, beat=1, velocity=100, duration=1.0)
325+
builder.ratchet(2, probability=1.0)
326+
327+
notes = [n for pulse, step in pattern.steps.items() for n in step.notes]
328+
assert len(notes) == 4
329+
330+
331+
def test_ratchet_gate_controls_duration() -> None:
332+
"""gate parameter sets sub-note duration as fraction of subdivision slot."""
333+
pattern, builder = _make_builder(length=4)
334+
# 1-beat note = 24 pulses, ratchet(2) → slot = 12 pulses
335+
builder.note(60, beat=0, velocity=100, duration=1.0)
336+
builder.ratchet(2, gate=1.0)
337+
338+
notes = [n for pulse, step in sorted(pattern.steps.items()) for n in step.notes]
339+
# gate=1.0 → duration = max(1, round(12 * 1.0)) = 12
340+
assert all(n.duration == 12 for n in notes)
341+
342+
# Reset and test gate=0.5
343+
pattern2, builder2 = _make_builder(length=4)
344+
builder2.note(60, beat=0, velocity=100, duration=1.0)
345+
builder2.ratchet(2, gate=0.5)
346+
347+
notes2 = [n for pulse, step in sorted(pattern2.steps.items()) for n in step.notes]
348+
assert all(n.duration == 6 for n in notes2)
349+
350+
351+
def test_ratchet_short_note_clamping() -> None:
352+
"""Subdivisions are clamped to note.duration so sub-hits never stack."""
353+
pattern, builder = _make_builder(length=4)
354+
# Place a note with duration=2 pulses (very short) — use pattern.add_note directly.
355+
pattern.add_note(0, pitch=60, velocity=100, duration=2)
356+
builder.ratchet(8)
357+
358+
notes = [n for pulse, step in pattern.steps.items() for n in step.notes]
359+
# Clamped to 2 subdivisions (= note.duration).
360+
assert len(notes) == 2
361+
362+
363+
def test_ratchet_steps_mask_targets_correct_positions() -> None:
364+
"""steps mask only ratchets notes at specified grid zones."""
365+
pattern, builder = _make_builder(length=4) # default_grid=16
366+
# Three notes at beat 0, 1, 2 (grid steps 0, 4, 8 in a 16-step bar).
367+
builder.note(60, beat=0, velocity=100, duration=1.0)
368+
builder.note(60, beat=1, velocity=100, duration=1.0)
369+
builder.note(60, beat=2, velocity=100, duration=1.0)
370+
371+
# Only ratchet grid step 0 (beat 0) and step 8 (beat 2).
372+
builder.ratchet(2, steps=[0, 8])
373+
374+
notes = [(pulse, n) for pulse, step in sorted(pattern.steps.items()) for n in step.notes]
375+
# Beat 0 → 2 subdivisions; beat 1 → 1 note unchanged; beat 2 → 2 subdivisions = 5 total.
376+
assert len(notes) == 5
377+
378+
379+
def test_ratchet_chainable() -> None:
380+
"""ratchet() returns self so it can be chained."""
381+
pattern, builder = _make_builder(length=4)
382+
builder.note(60, beat=0, velocity=100, duration=1.0)
383+
result = builder.ratchet(2).ratchet(1)
384+
assert result is builder
385+
386+
387+
def test_ratchet_deterministic_with_seed() -> None:
388+
"""Same RNG seed + probability < 1.0 produces identical output across calls."""
389+
import random
390+
391+
def run():
392+
pattern, builder = _make_builder(length=4)
393+
builder.note(60, beat=0, velocity=100, duration=1.0)
394+
builder.note(60, beat=1, velocity=100, duration=1.0)
395+
builder.note(60, beat=2, velocity=100, duration=1.0)
396+
builder.ratchet(3, probability=0.5, rng=random.Random(42))
397+
return tuple(
398+
(pulse, n.velocity, n.duration)
399+
for pulse, step in sorted(pattern.steps.items())
400+
for n in step.notes
401+
)
402+
403+
assert run() == run()
404+
405+
406+
def test_ratchet_velocity_preserved_without_shaping() -> None:
407+
"""Default velocity_start=1.0, velocity_end=1.0 keeps original velocity."""
408+
pattern, builder = _make_builder(length=4)
409+
builder.note(60, beat=0, velocity=80, duration=1.0)
410+
builder.ratchet(4)
411+
412+
notes = [n for pulse, step in pattern.steps.items() for n in step.notes]
413+
assert all(n.velocity == 80 for n in notes)

0 commit comments

Comments
 (0)