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 fromstarttoendacross 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.
Argument |
What it does |
|---|---|
|
CC number 0–127 (or a name — see below). |
|
CC values 0–127 to sweep between. Out-of-range values are clamped. |
|
The beat window for the sweep. |
|
Easing curve: |
|
Pulses between messages (default |
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.
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")
Verb |
Reach for it when |
|---|---|
|
You want a bend at a beat, independent of the notes — a drone dive, a whammy-style sweep. |
|
You want to scoop or fall one specific note — an expressive lead inflection. |
|
You want a mono line to glide through every transition — a slurred synth lead. |
|
You want to glide into selected notes only — TB-303 acid, slide guitar. |
Reference
pitch_bend(),
pitch_bend_ramp(),
bend(),
portamento(),
slide()
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(thenrpn/rpndefault) sends a single 7-bit value,0–127— enough for most parameters.fine=Truesends the full 14-bit value,0–16383, for parameters that need it (thenrpn_ramp/rpn_rampdefault is alreadyfine=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.
Reference
rpn(),
nrpn(),
nrpn_ramp(),
program_change(),
sysex(),
bank_select()
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
Reference
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.
Tool |
Use it when |
|---|---|
|
You have one part and want it to sound on several destinations at once — unison stacking, doubling to a recorder. |
|
You have several builder functions and want them to be one pattern on one destination — assembling a part from reusable pieces. |
Reference
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
Factory |
What it makes |
|---|---|
|
|
|
A tuning from frequency ratios — just intonation, harmonic scales. |
|
A tuning from explicit cent values for degrees 1…N (the last is usually 1200). |
|
Parse a Scala |
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.
Reference
Tuning,
equal(),
from_ratios(),
from_cents(),
from_scl(),
tuning(),
apply_tuning()
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.