Chapter 13 · Expression and Hardware Control

In Chapter 12 you pushed the note-generating engine as far as it goes — chaos, automata, scored melody. This chapter steps off the keyboard and onto the rest of the MIDI cable: the controller sweeps, pitch bends, deep parameter writes, patch changes, groove feel, multi-device routing, and non-12-TET tunings that turn a stream of notes into a performance your gear actually responds to.

Everything here rides alongside the notes you already know how to place. A pattern is still a function on p; you just add a few more verbs — p.cc(...), p.pitch_bend(...), p.nrpn(...) — that emit the non-note MIDI your synth has always understood. Like notes, none of them sound until you render() (or play()); like notes, they’re placed at a beat position inside the bar.

Important

This is the most hardware-specific chapter in the book. CC numbers, NRPN parameters, bank-select layouts, pitch-bend ranges and tuning support all vary by instrument — Subsequence sends exactly what you ask for, but what each message means is defined by your synth’s manual, not by Subsequence. We teach the verbs and the standard conventions; reach for your device’s MIDI implementation chart for the numbers.

13.1 Continuous controllers: cc and cc_ramp

A continuous controller (CC) message carries a value 0–127 on a numbered control — the same mod wheel, filter cutoff, and expression messages your controller already sends. Subsequence places them with two verbs:

  • p.cc(control, value, beat=0.0) — one CC message at a beat.

  • p.cc_ramp(control, start, end, beat_start=0.0, beat_end=None, ...) — a swept CC value, interpolated from start to end across a beat range.

Here’s a synth bass whose filter opens across the bar — the single most common expressive move in electronic music, written as one ramp. The first example shows the imports in full; later blocks rely on them:

import subsequence
import subsequence.constants.instruments.gm_drums as gm_drums

composition = subsequence.Composition(bpm=120, key="A", scale="minor")

@composition.pattern(channel=2, beats=4)
def bass(p):
    p.sequence(steps=[0, 4, 8, 12], pitches=[40, 40, 43, 38],
               velocities=100, durations=0.9)
    p.cc(71, 95, beat=0)                          # resonance up, once, at the top of the bar
    p.cc_ramp(74, 30, 120, beat_start=0, beat_end=4, shape="exponential")  # cutoff sweep

composition.render(bars=2, filename="filter-sweep.mid")

74 is the General MIDI “brightness / filter cutoff” controller and 71 is “resonance” — but on your synth they’re whatever its manual says. The shape= on the ramp is the same easing vocabulary the Conductor used in Chapter 12: "exponential" holds low and then rushes open, which sounds far more like a hand on a filter knob than a flat "linear" climb.

cc_ramp arguments worth knowing

Argument

What it does

control

CC number 0–127 (or a name — see below).

start, end

CC values 0–127 to sweep between. Out-of-range values are clamped.

beat_start, beat_end

The beat window for the sweep. beat_end defaults to the pattern length.

shape

Easing curve: "linear" (default), "ease_in", "ease_out", "ease_in_out", "exponential", "logarithmic", "s_curve".

resolution

Pulses between messages (default 1 — one every ~21 ms at 120 BPM). Raise it (e.g. 2 or 4) to thin MIDI traffic on a busy DIN link.

Naming your controllers: cc_name_map=

Remembering that cutoff is 74 is exactly the chore the drum-note map saved you in Chapter 1. The cure is the same: hand the pattern decorator a cc_name_map= dictionary and then write the name instead of the number.

composition = subsequence.Composition(bpm=120, key="A", scale="minor")

# One place to record what this synth's CCs mean — musical names, not magic numbers.
JUNO = {"cutoff": 74, "resonance": 71, "lfo_rate": 76, "expression": 11}

@composition.pattern(channel=2, beats=4, cc_name_map=JUNO)
def bass(p):
    p.sequence(steps=[0, 4, 8, 12], pitches=[40, 40, 43, 38],
               velocities=100, durations=0.9)
    p.cc("resonance", 95, beat=0)
    p.cc_ramp("cutoff", 30, 120, beat_start=0, beat_end=4, shape="exponential")

composition.render(bars=2, filename="filter-named.mid")

Tip

Keep a cc_name_map per instrument near the top of your script — it’s a tiny patch sheet in code. When you swap synths you change one dict, not every p.cc(...) call. A bare number still works anywhere a name does, so you can mix the two while you’re figuring out a new device.

Note

A CC value is one number from 0 to 127 — it has no idea what a “filter” is. p.cc(74, 100) tells the synth “controller 74 is now 100”; whether that brightens the tone, opens a wah, or does nothing depends entirely on how the synth is patched. This is why the same script can sound transformed on a different instrument: the notes are identical, but the controllers land on different destinations.

Reference

cc(), cc_ramp()

13.2 Pitch bend, portamento, and slide

Pitch bend is its own MIDI message (not a CC) carrying a 14-bit value, which Subsequence exposes to you as a tidy −1.0 … +1.0 float: 0.0 is no bend, +1.0 is the wheel pushed fully up, −1.0 fully down. How many semitones that spans is the synth’s pitch-bend range setting (the near-universal default is ±2 semitones, so +0.5 is one semitone up).

There are two raw verbs and three musical ones built on top:

  • p.pitch_bend(value, beat=0.0) — one bend message at a beat.

  • p.pitch_bend_ramp(start, end, beat_start, beat_end, ...) — a swept bend.

  • p.bend(note, amount, ...) — bend one note by its index.

  • p.portamento(time, ...) — glide between every consecutive note.

  • p.slide(notes=/steps=, ...) — glide into selected notes (the 303 move).

Raw bend and bend ramp

The two raw verbs are the controller equivalents of p.cc / p.cc_ramp — a value at a beat, or a swept value across a range — and they fire independently of the notes. Useful for a dub-style pitch dive on a sustained drone:

composition = subsequence.Composition(bpm=120, key="A", scale="minor")

@composition.pattern(channel=2, beats=4)
def riser(p):
    p.note(48, beat=0, duration=4.0, velocity=100)
    p.pitch_bend(0.0, beat=0)                                       # start in tune
    p.pitch_bend_ramp(0.0, 0.5, beat_start=2, beat_end=4, shape="ease_in")  # bend up over beats 3–4

composition.render(bars=2, filename="bend-ramp.mid")

Bending a single note: p.bend

Raw bends don’t know where your notes are. p.bend(note, amount) does: you name a note by index (0 = first, -1 = last) and Subsequence ramps the bend across that note’s duration, then snaps back to centre at the next note’s onset. amount is the same −1.0…+1.0 float. Because it reads each note’s final duration, call it after any legato() / detached() / duration():

composition = subsequence.Composition(bpm=120, key="A", scale="minor")

@composition.pattern(channel=2, beats=4)
def lead(p):
    p.sequence(steps=[0, 4, 8, 12], pitches=[40, 42, 40, 43],
               velocities=100, durations=0.9)
    p.legato(0.95)                       # finalise durations first…
    p.bend(note=-1, amount=0.5, shape="ease_in")   # …then scoop the last note up a semitone

composition.render(bars=2, filename="single-bend.mid")

Glide everything: p.portamento

A monophonic synth in portamento (glide) mode slurs from one pitch to the next. p.portamento(time) writes that as pitch bend: in the tail of each note it bends toward the next note’s pitch, resetting at the next onset. time is the fraction of each note used for the glide (default 0.15 — the last 15%):

composition = subsequence.Composition(bpm=120, key="A", scale="minor")

@composition.pattern(channel=3, beats=4)
def synth_lead(p):
    p.sequence(steps=[0, 4, 8, 12], pitches=[48, 50, 48, 53],
               velocities=90, durations=0.9)
    p.legato(0.95)
    p.portamento(time=0.15, shape="ease_in_out")   # gentle slur across every transition

composition.render(bars=2, filename="portamento.mid")

Important

Portamento works through pitch bend, so it lives inside the bend range. With the standard ±2 semitones, an interval wider than 2 semitones can’t be reached by the wheel — portamento() simply skips those pairs rather than glide to the wrong note. If your synth’s wheel is set wider, tell Subsequence: bend_range=12 glides across leaps up to an octave. Pass bend_range=None to disable the check entirely (large intervals then clamp to a full ±1.0 sweep). Because pitch bend is per-channel, glide is most convincing on a mono instrument — on a polyphonic patch the bend tilts every sounding voice at once.

The 303 slide: p.slide

Acid basslines don’t glide everywhere — only into the notes flagged “slide,” which also tie through (the note doesn’t retrigger). p.slide is exactly that: name the destinations by note index (notes=[1, 3]) or by grid step (steps=[4, 12]), and only those transitions glide. With extend=True (the default) the preceding note stretches to meet the slide, the 303’s signature legato:

composition = subsequence.Composition(bpm=120, key="A", scale="minor")

@composition.pattern(channel=2, beats=4)
def acid(p):
    p.sequence(steps=[0, 4, 8, 12], pitches=[40, 42, 40, 43],
               velocities=110, durations=0.9)
    p.legato(0.95)
    p.slide(steps=[8], time=0.2, shape="ease_in", bend_range=2)   # slide only into the 3rd note

composition.render(bars=2, filename="acid-slide.mid")
Which pitch-bend verb?

Verb

Reach for it when

p.pitch_bend / p.pitch_bend_ramp

You want a bend at a beat, independent of the notes — a drone dive, a whammy-style sweep.

p.bend(note, amount)

You want to scoop or fall one specific note — an expressive lead inflection.

p.portamento(time)

You want a mono line to glide through every transition — a slurred synth lead.

p.slide(notes=/steps=)

You want to glide into selected notes only — TB-303 acid, slide guitar.

13.3 NRPN/RPN, program change, bank select, and SysEx

The 128 CC slots run out fast on a deep synth. The MIDI spec’s answer is parameter numbers — a CC handshake that addresses thousands of parameters, optionally with 14-bit precision. There are two flavours, and Subsequence gives each its own verb:

  • NRPN (Non-Registered Parameter Number) — vendor-specific. Sequential, Korg, Roland, Elektron and others use NRPN for filter cutoff, envelope amounts, oscillator detune, and the like. The numbers are in your synth’s manual.

  • RPN (Registered Parameter Number) — the small standardised set defined by the MIDI spec (pitch-bend range, master tuning, …), the same on every device.

composition = subsequence.Composition(bpm=120, key="A", scale="minor")

# A vendor patch sheet: parameter names → NRPN numbers (from the synth's manual).
TAKE5 = {"osc1_fine": 9, "filter_cutoff": 110, "filter_resonance": 111}

@composition.pattern(channel=2, beats=4, nrpn_name_map=TAKE5)
def synth(p):
    p.note(48, beat=0, duration=4.0, velocity=100)

    # RPN: set the synth's pitch-bend range to ±12 semitones (a standard name).
    p.rpn("pitch_bend_sensitivity", 12, beat=0)

    # NRPN by name: a fine (14-bit) oscillator detune.
    p.nrpn("osc1_fine", 700, beat=0, fine=True)

    # NRPN sweep: open a deep filter parameter across the whole bar (14-bit).
    p.nrpn_ramp("filter_cutoff", 0, 16383, beat_start=0, beat_end=4)

composition.render(bars=2, filename="nrpn.mid")

A few things are doing quiet work here:

  • fine= chooses the value range. fine=False (the nrpn/rpn default) sends a single 7-bit value, 0–127 — enough for most parameters. fine=True sends the full 14-bit value, 0–16383, for parameters that need it (the nrpn_ramp/rpn_ramp default is already fine=True, since a sweep wants the resolution).

  • RPN names resolve for free. NRPN names need your nrpn_name_map= because the numbers are vendor-specific, but RPN names are standardised, so "pitch_bend_sensitivity", "channel_fine_tuning", "channel_coarse_tuning", "modulation_depth_range" and friends work with no map at all.

  • A parameter write is several CC messages, co-scheduled at the same beat (parameter-select, then data-entry, then a defensive null reset). Subsequence emits them in the right order so the value lands on the right parameter — you just call p.nrpn(...).

Warning

Don’t issue plain p.cc(6, …) or p.cc(38, …) during a nrpn_ramp/rpn_ramp window. A ramp selects the parameter once at the start and then streams bare data-entry messages (CC 6 / CC 38) for the rest of the sweep. A stray CC 6 or 38 on the same channel mid-ramp would be read as more data for the ramped parameter rather than as its own control. Keep other data-entry CCs off that channel until the ramp’s beat_end.

Switching patches: program_change and bank_select()

p.program_change(program, beat=0.0, bank_msb=None, bank_lsb=None) sends a Program Change — “switch to patch N” — on the pattern’s channel. Program numbers follow GM (0 = Acoustic Grand, 48 = Strings, …). Modern synths hold far more than 128 patches, organised into banks; you reach a bank with the two bank-select bytes, sent automatically just before the program change when you pass them.

The free function bank_select(bank) spares you the bit-twiddling: hand it a plain bank number and it returns the (msb, lsb) pair to forward on:

composition = subsequence.Composition(bpm=120, key="A", scale="minor")

@composition.pattern(channel=4, beats=4)
def strings(p):
    msb, lsb = bank_select(81)               # bank 81 → (msb, lsb) bytes
    p.program_change(48, beat=0, bank_msb=msb, bank_lsb=lsb)   # patch 48 in that bank
    p.note(60, beat=0, duration=4.0, velocity=80)

composition.render(bars=2, filename="program-change.mid")

Tip

Put the patch change where the patch should change — usually beat 0 of the first bar of a section. Reading the form (Chapter 10), you can swap sounds at a boundary: if p.section and p.section.first_bar: p.program_change(...). The on_section() callback (also Chapter 10) fires a beat early, which is the right moment to send a program change so the new patch is loaded before the downbeat.

Raw vendor messages: p.sysex

When a parameter has no CC or NRPN — a patch dump, a global setting, a vendor-specific command — the escape hatch is System Exclusive. Pass the inner payload bytes (Subsequence adds the F0 / F7 framing); each byte is 0–127:

composition = subsequence.Composition(bpm=120, key="A", scale="minor")

@composition.pattern(channel=1, beats=4)
def setup(p):
    # "GM System On" — reset a GM-compatible device to its defaults at the downbeat.
    p.sysex([0x7E, 0x7F, 0x09, 0x01], beat=0)
    p.note(60, beat=0, duration=1.0, velocity=80)

composition.render(bars=1, filename="sysex.mid")

Warning

SysEx is the rawest message there is — it’s bytes straight to the device, with no musical meaning Subsequence can check. A wrong byte does nothing at best and mis-configures the synth at worst. Copy the exact byte sequence from your device’s MIDI implementation chart, and test on one parameter before automating it.

13.4 Groove and .agr import

A perfectly quantised beat is the giveaway of a machine. Groove is the cure: a small repeating template of per-step timing pushes (and optional velocity nudges) that you stamp onto a pattern after placing its notes. It’s the same “feel” dial as the hardware sampler’s swing, and the Chapter 1 distinction holds — groove and swing are timing, never pitch.

A Groove is a value (like a Motif or a Progression). The quickest one to make is Groove.swing(percent): 50 is straight, ~67 is triplet swing, 57 is the moderate Ableton-default shuffle. Apply it with p.groove(template, strength=1.0):

composition = subsequence.Composition(bpm=120, key="A", scale="minor")

SHUFFLE = subsequence.Groove.swing(percent=58)   # a value you can reuse across patterns

@composition.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
def drums(p):
    p.hit_steps("kick_1", [0, 4, 8, 12], velocity=100)
    p.hit_steps("snare_1", [4, 12], velocity=90)
    p.hit_steps("hi_hat_closed", range(16), velocity=(55, 85))
    p.groove(SHUFFLE)                      # full-strength shuffle
    p.randomize(timing=0.02, velocity=0.08)  # then a touch of human jitter on top

composition.render(bars=2, filename="shuffle.mid")

strength= blends the groove in: 0.0 leaves the timing untouched, 1.0 applies it fully, anything between dials it back (p.groove(SHUFFLE, strength=0.5) is half a shuffle). And as the example shows, groove pairs naturally with p.randomize() from the production toolkit — the groove gives a repeating, intentional pocket, randomize adds uncorrelated micro-jitter on top, the two layers that make programmed drums breathe.

For a fully bespoke feel, build the Groove by hand — a per-slot list of timing offsets in beats (positive = late, negative = early), an optional matching velocity-scale list, and the grid the slots sit on:

# A hand-built MPC-style pocket: lay the 'e' and 'a' back a hair, duck their velocity.
POCKET = subsequence.Groove(
    grid=0.25,                                  # one slot per sixteenth
    offsets=[0.0, +0.02, 0.0, -0.01],           # repeats every 4 sixteenths
    velocities=[1.0, 0.8, 0.95, 0.75],          # accents on the beat, ghosts off it
)

composition = subsequence.Composition(bpm=120, key="A", scale="minor")

@composition.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
def drums(p):
    p.hit_steps("hi_hat_closed", range(16), velocity=80)
    p.groove(POCKET)

composition.render(bars=2, filename="pocket.mid")

Importing an Ableton groove: Groove.from_agr

If you’ve collected .agr groove files (Ableton’s groove-pool format, ripped from classic drum machines), Groove.from_agr(path) reads one straight into a Groove — timing offsets and velocity scaling lifted from the file, pre-scaled by its own Timing/Velocity amounts. Because it reads a file from disk, it can’t run in our headless check, but the shape is exactly the swing example above with the constructor swapped:

Note

File-reading feature — point it at a real .agr and run it at your instrument; not executed here. The API is Groove.from_agr(path) -> Groove.

# Load a vintage shuffle ripped to an .agr file, then apply it like any Groove.
mpc_swing = subsequence.Groove.from_agr("grooves/MPC-16-Swing-62.agr")

@composition.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
def drums(p):
    p.hit_steps("kick_1", [0, 8], velocity=100)
    p.hit_steps("hi_hat_closed", range(16), velocity=80)
    p.groove(mpc_swing, strength=0.8)   # 80% of the imported feel

13.5 Routing to several devices: mirrors and layer

So far every composition has spoken to one MIDI port. A real rig has several — two synths, a drum machine, an effects unit. Subsequence addresses them by device index: the port you pass to Composition(output_device=...) is device 0 (the primary), and each call to composition.midi_output(device, name=...) registers another, returning its index (1, 2, …). A friendly name= alias lets patterns say device="synth_b" instead of device=2.

# Three ports: the primary (device 0) plus two registered synths.
composition = subsequence.Composition(bpm=120, key="A", scale="minor",
                                       output_device="Dummy MIDI")
composition.midi_output("Dummy MIDI", name="synth_a")   # returns 1
composition.midi_output("Dummy MIDI", name="synth_b")   # returns 2

# Send this pattern to synth_a instead of the primary device.
@composition.pattern(channel=1, beats=4, device="synth_a")
def bass(p):
    p.sequence(steps=[0, 4, 8, 12], pitches=[40, 40, 43, 38], velocities=100)

composition.render(bars=2, filename="multi-device.mid")

Note

The three outputs above all name the same mock port only because this is a headless check with one dummy device. On real hardware each midi_output(...) names a different physical port from mido.get_output_names() (§0.5), and device= picks which one a pattern plays through.

Doubling a part across destinations: mirrors=

Mirroring sends every event a pattern emits — notes, CCs, pitch bend, NRPN/RPN, program changes, SysEx — to one or more extra (device, channel) destinations as well as its own. It’s how you stack two synths on one bassline for a fatter sound, or feed a part to both a synth and a recorder. Declare it with mirrors= on the decorator, a list of destination tuples:

composition = subsequence.Composition(bpm=120, key="A", scale="minor",
                                       output_device="Dummy MIDI")
composition.midi_output("Dummy MIDI", name="synth_b")   # device 1

# Bass plays on the primary device, channel 1, AND is doubled on device 1, channel 2.
@composition.pattern(channel=1, beats=4, mirrors=[(1, 2)])
def bass(p):
    p.sequence(steps=[0, 4, 8, 12], pitches=[40, 40, 43, 38], velocities=100)
    p.cc(74, 90, beat=0)        # the CC is mirrored too — both synths open their filter

composition.render(bars=2, filename="mirrored-bass.mid")

Each mirror entry is (device, channel), or (device, channel, drum_note_map) when the destination is a drum device that lays its sounds out differently — the named hits are re-resolved through that device’s map, so "kick_1" lands on the right note number on each machine:

composition = subsequence.Composition(bpm=120, key="A", scale="minor",
                                       output_device="Dummy MIDI")
composition.midi_output("Dummy MIDI", name="drum_machine")   # device 1

# Drums on the primary GM kit, mirrored to a second machine with its own map.
@composition.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP,
                     mirrors=[(1, 10, gm_drums.GM_DRUM_MAP)])
def drums(p):
    p.hit_steps("kick_1", [0, 4, 8, 12], velocity=100)
    p.hit_steps("snare_1", [4, 12], velocity=90)

composition.render(bars=2, filename="mirrored-drums.mid")

Warning

Each mirror is a full copy of the pattern’s MIDI — every note and controller sent twice. That’s the point (two synths in unison), but it also doubles the traffic on the bus, so don’t mirror a dense CC ramp across five destinations on a slow DIN link. A mirror whose (device, channel) equals the pattern’s own destination would double-fire on one port; Subsequence warns you if you do that by accident. (To toggle a mirror live mid-performance, use composition.mirror(name, device, channel) / unmirror(...).)

Combining builders into one pattern: composition.layer

mirrors= copies one builder to many destinations. composition.layer does the reverse — it merges several builder functions into a single pattern on one channel. It’s the tool for composing a drum part out of reusable pieces (a kick function, a hats function) without each becoming its own scheduled pattern:

composition = subsequence.Composition(bpm=120, key="A", scale="minor")

# Small, reusable builders — each does one job.
def kick(p):
    p.hit_steps("kick_1", [0, 4, 8, 12], velocity=100)

def hats(p):
    p.hit_steps("hi_hat_closed", range(16), velocity=(55, 85))

def perc(p):
    p.hit_steps("hand_clap", [4, 12], velocity=80)

# Fold all three into one pattern on channel 10.
composition.layer(kick, hats, perc, channel=10, beats=4,
                  drum_note_map=gm_drums.GM_DRUM_MAP)

composition.render(bars=2, filename="layered-drums.mid")

layer takes the same device=, mirrors=, cc_name_map= and voice_leading= keywords as pattern — and if any of the builders declares a chord parameter, the merged pattern follows the harmony just like a single chord-aware pattern would.

mirrors= versus layer

Tool

Use it when

mirrors=

You have one part and want it to sound on several destinations at once — unison stacking, doubling to a recorder.

composition.layer

You have several builder functions and want them to be one pattern on one destination — assembling a part from reusable pieces.

13.6 Microtonal tuning

Subsequence speaks 12-tone equal temperament by default, but it can play any tuning on ordinary MIDI gear — no MPE, no special synth — by quietly injecting a pitch bend before each note to nudge it from the nearest 12-TET pitch to the exact microtonal frequency. You describe the tuning as a value, then apply it.

A Tuning is a list of cent offsets from the unison. Build one four ways (Tuning lives in subsequence, so import it):

from subsequence import Tuning

just  = Tuning.from_ratios([9/8, 5/4, 4/3, 3/2, 5/3, 15/8, 2])   # 5-limit just intonation
nine  = Tuning.equal(19)                                          # 19-tone equal temperament
cents = Tuning.from_cents([100, 200, 300, 400, 500, 600,
                           700, 800, 900, 1000, 1100, 1200])      # explicit cents (= 12-TET)

print(just.size, just.period_cents)
7 1200.0
Building a Tuning

Factory

What it makes

Tuning.equal(n)

n-tone equal temperament — equal(12) is standard, equal(19) / equal(31) are the popular microtonal EDOs.

Tuning.from_ratios([...])

A tuning from frequency ratios — just intonation, harmonic scales. 2 is the octave.

Tuning.from_cents([...])

A tuning from explicit cent values for degrees 1…N (the last is usually 1200).

Tuning.from_scl(path)

Parse a Scala .scl file — the standard interchange format, with thousands of historical and world tunings freely available.

Tuning the whole piece: composition.tuning

The usual way is composition.tuning(...) — one call sets a global tuning that Subsequence applies to every melodic pattern automatically. Pass exactly one source (source= for a .scl path, or cents= / ratios= / equal=). Drum patterns are excluded by default (their pitches are fixed GM note numbers, not scale degrees):

composition = subsequence.Composition(bpm=120, key="A", scale="minor")
composition.tuning(equal=19, bend_range=2.0)     # the whole track in 19-TET

@composition.pattern(channel=2, beats=4)
def melody(p):
    p.sequence(steps=[0, 3, 6, 9, 12], pitches=[60, 62, 64, 65, 67], velocities=90)

@composition.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
def drums(p):
    p.hit_steps("kick_1", [0, 4, 8, 12], velocity=100)   # untouched — drums opt out

composition.render(bars=2, filename="tuning-global.mid")

Important

bend_range must match your synth’s pitch-bend setting. Tuning rounds each note to the nearest 12-TET pitch and then bends it the rest of the way, so the correcting bend is never more than half a semitone — the standard ±2 semitones has ample room. The number you pass isn’t there to grant more reach; it’s there so the bend is scaled correctly. If your synth’s wheel is set to ±12 but you leave the default bend_range=2.0, the same correction lands six times too far and the tuning goes sour. So: set the value to whatever your synth’s wheel actually is (bend_range=12 if you’ve set it to ±12). And because the correcting bend is per-channel, a polyphonic part needs a channels= pool so simultaneous notes can each carry their own bend (see below) — otherwise the last note’s bend tilts the whole chord.

Tuning one part: p.apply_tuning

For a single microtonal line over an otherwise 12-TET track, skip the global call and apply the tuning to just that pattern with p.apply_tuning(tuning, bend_range=2.0) — a post-build transform, like groove or randomize:

composition = subsequence.Composition(bpm=120, key="A", scale="minor")

just = Tuning.from_ratios([9/8, 5/4, 4/3, 3/2, 5/3, 15/8, 2])

@composition.pattern(channel=2, beats=4)
def lead(p):
    p.sequence(steps=[0, 4, 8, 12], pitches=[60, 64, 67, 72],
               velocities=90, durations=0.9)
    p.apply_tuning(just, bend_range=2.0)    # this line only, in just intonation

composition.render(bars=2, filename="tuning-part.mid")

For a polyphonic tuned part, hand a channels= pool — a list of MIDI channels Subsequence rotates simultaneous notes across, so each chord voice gets an independent bend. composition.tuning(...) takes the same channels= argument for a globally-tuned pad:

composition = subsequence.Composition(bpm=120, key="A", scale="minor")
composition.harmony(style="aeolian_minor", cycle_beats=4)

# Just intonation across the whole piece; spread chord voices over channels 3–6
# so each voice carries its own tuning bend.
composition.tuning(ratios=[9/8, 5/4, 4/3, 3/2, 5/3, 15/8, 2],
                   channels=[3, 4, 5, 6], bend_range=2.0)

@composition.pattern(channel=3, beats=4, voice_leading=True)
def pad(p, chord):
    p.chord(chord, root=52, velocity=65, sustain=True)

composition.render(bars=2, filename="tuning-poly.mid")

Note

Reading a Scala file (.scl). The classic way to load a historical or exotic tuning is from a .scl file — meantone, Pythagorean, gamelan, hundreds more. It reads a file from disk, so it’s not part of our headless check, but the call is a drop-in for the factories above: comp.tuning("scales/meanquar.scl"), or Tuning.from_scl("scales/young.scl") for a value. (For testing a tuning inline without a file, Tuning.from_scl_string(text) parses the same format from a string.) Not executed here; the API is Tuning.from_scl(path) -> Tuning.

Note

Under the hood: tuning is just pitch bend, automated. Everything in this chapter is the same family of move — a control message placed against the notes. Microtonal tuning is the most striking instance: Subsequence replaces each note’s pitch with the nearest 12-TET note and prepends a pitch-bend event carrying the leftover cents, exactly the pitch_bend you met in §13.2 — only computed from a Tuning table instead of written by hand. Any pitch bends you did write (a portamento, a slide) are shifted to ride on top of the tuning offset rather than fight it. One mechanism — a controller event at a pulse — spans a dub siren, a 303 glide, a filter sweep, and a whole gamelan scale.


That’s the full expressive surface: continuous controllers and pitch bend for gestures, NRPN/RPN/program-change/SysEx for the deep hardware, groove for feel, mirrors and layers for routing, and tunings for the notes between the notes. Your patterns no longer just place notes — they perform them on the gear in front of you.

This closes the main guide. In Chapter 14 we take it on stage — hot-swapping code as it plays, live control by hotkey and MIDI input, OSC, external-data sonification, and Ableton Link — and from there the appendices open the power-user path: the Direct Pattern API, the analysis and set-theory toolkit, the full MIDI-routing reference, and the API quick reference and glossary you’ll keep coming back to.