Chapter 14 · Live Performance, External Data, and Sync

In Chapter 13 you taught the running track to speak to your gear — continuous controllers, bend, NRPN, program changes, groove, tuning; now we make the track something you can play, react to, and feed from the world outside. This is the capstone, and it closes the loop the whole book has been building toward: explore a piece by writing and seeding it (Parts I–VI), then perform it — swapping patterns without stopping the clock, steering the form by hand, taking in a player’s held chord or an external clock, pulling live data into musical parameters, and locking to other instruments over Ableton Link.

Everything here sits on top of the deterministic, seeded composition you already know how to write. The drums, bass, harmony, and motif/form track from the earlier Parts keep running exactly as seeded; the live layer is an acknowledged overlay. That separation is the chapter’s one big idea: the seed reproduces the skeleton; the performance is what you add live. When you render headlessly there is no keyboard, no network, no clock partner — so the live inputs are simply empty and the seeded output is unchanged.

A note on what runs and what doesn’t. Most of this chapter is genuinely live — it needs a second terminal, a MIDI keyboard, a network peer, or your hands on the keys — so those examples are shown as reference blocks marked live feature and are not executed under our doctest harness. But the explore-to-perform spine that matters most — pulling external data on a schedule, easing it into parameters, and reading it back in a pattern — runs perfectly headless, so those examples are executed and validated, the same as every chapter before.

14.1 Hot-swapping patterns without stopping the clock

The first move of live performance is editing the music while it plays. You already write patterns as functions; the live tools let you redefine those functions — or load a whole new file of them — and have the running composition adopt the change on the next rebuild cycle, with the clock never pausing. The running pattern keeps its cycle count, its seeded RNG state, and its place in the bar; only the logic changes.

There are three ways in, from heaviest to lightest, and all three are live by nature (they need a second terminal, a file on disk, or a network sender), so the snippets below are shown for reference rather than executed here.

Watch a file: composition.watch(path)

The workhorse for working out a piece is composition.watch(path) — point it at a Python file, and every time you save that file Subsequence re-runs it and hot-swaps the patterns in place. The recommended shape is a two-file split: a wrapper that does the one-time setup (device, harmony, form, tempo) and then watches a live file that holds only your @composition.pattern definitions.

Note

Live feature — run it at your instrument; not executed here.

# live_init.py — the wrapper, runs once.
import subsequence

composition = subsequence.Composition(bpm=120, key="A", scale="minor",
                                       output_device="IAC Driver Bus 1")
composition.harmony(style="aeolian_minor", cycle_beats=4)
composition.form([("verse", 8), ("chorus", 8)], loop=True)

composition.watch("live_patterns.py")   # reload this file on every save
composition.display(grid=True)
composition.play()                       # Ctrl-C to stop
# live_patterns.py — edit and save this while it plays.
@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("hi_hat_closed", range(16), velocity=70)

Save live_patterns.py and the next bar carries your edit. Delete a pattern’s function from the file and Subsequence unregisters it automatically — its notes stop and it leaves the running set. A SyntaxError or a runtime error during a reload is logged and skipped: your previous patterns keep playing, so a typo mid-set never drops the music.

Tip

If the two-file split feels heavy for solo work, a single file can watch itself. Put the one-time setup inside if __name__ == "__main__": and call composition.watch(__file__) there; Subsequence detects the self-watch and skips the double-load. Running python my_session.py does the setup once, and each save reloads only the pattern definitions below the guard.

Type one line at a time: composition.live(port)

For quick nudges — change the tempo, mute a part, query what’s playing, redefine a single pattern — composition.live(port) starts a tiny TCP eval server you connect to from another terminal. You type Python at a REPL and it runs inside the playing composition.

Note

Live feature — run it at your instrument; not executed here.

composition.live()        # start the eval server on localhost:5555
composition.display()
composition.play()

Then, in a second terminal, python -m subsequence.live_client connects you:

>>> composition.set_bpm(132)
>>> composition.mute("hats")
>>> composition.live_info()                 # what's playing right now
>>> @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=120)

Warning

The eval server executes arbitrary Python in your process — it is not a sandbox. It binds to localhost and is opt-in, but anything that can reach the port on your machine gains full code execution. The same caution applies to composition.watch() (it execs the watched file) and load_patterns() below. Don’t enable these on a shared host, never expose the port to a network, and only run code you trust.

Load patterns from a string: composition.load_patterns(source)

When the patterns arrive as text rather than from a file on disk — a web upload from a trusted collaborator, a message-queue consumer, a one-shot session load — composition.load_patterns(source, source_label=...) does exactly what a watch() save does, but the source is an in-memory string. It compiles, execs, activates new patterns, and unregisters any running pattern the source omits (the source is treated as the full new truth).

Note

Live feature — run it at your instrument; not executed here.

composition.load_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)
""", source_label="uploaded_beat")
Three ways to swap patterns live

Tool

Reach for it when

composition.watch(path)

You’re iterating on a whole piece in your editor — save to hear. The main live-coding workflow.

composition.live(port)

You want a REPL for one-line tweaks, queries, and single-pattern swaps while the music plays.

composition.load_patterns(source)

Patterns arrive as a string (web upload, queue, generated text) with no file backing.

14.2 Live control: hotkeys, tweaks, mutes, and form jumps

Hot-swapping is for changing the code. The lighter, faster moves of a live set — mute this, drop to the breakdown, push the filter — want to be on a single keypress. composition.hotkey(key, action) binds a one-character key to a zero-argument callable that fires during playback. Turn the listener on first with composition.hotkeys() (the ? key is reserved — it lists your bindings).

Note

Live feature — hotkeys read the live keyboard; not executed here.

composition.hotkeys()                                   # enable the listener

# Each action is a zero-argument callable — usually a lambda.
composition.hotkey("c", lambda: composition.form_jump("chorus"))   # jump now
composition.hotkey("b", lambda: composition.form_next("bridge"))   # at the boundary
composition.hotkey("h", lambda: composition.mute("hats"), quantize=4)
composition.hotkey("H", lambda: composition.unmute("hats"), quantize=4)
composition.hotkey("1", lambda: composition.data.update({"mode": "chill"}))

composition.play()

Important

Most live actions want quantize=0 (the default). A form jump, a composition.data write, or a tweak() is naturally heard at the next pattern rebuild cycle, which gives you automatic musical quantization for free — the change lands on a clean boundary without you timing it. Reserve quantize=N for actions that need an explicit bar guarantee, like a mute() you want to fall exactly on a 4-bar phrase line (quantize=4 above). A jump is heard on the next rebuild, never mid-note: already-queued MIDI is left to finish.

The form-navigation verbs form_jump and form_next are the ones from §10.6form_jump(name) cuts immediately (next rebuild), form_next(name) queues a section for the natural boundary. Wiring them to keys is the whole point of building a navigable or graph form: you steer the arrangement by hand.

Tweaking a parameter live: tweak and p.param

Redefining a whole pattern to change one number is overkill. The lighter path is a tweakable parameter: in the pattern body, read a value through p.param(name, default) instead of hard-coding it; then override it live with composition.tweak(name, **kwargs). The override persists across rebuilds until you change or clear it, and takes effect on the next cycle.

This does run headless — tweak and param operate on the running-pattern set, which render() populates just like play() — so here it is as an executed example, with a scheduled “director” (we meet schedule properly in §14.5) standing in for the live keypresses:

import subsequence
import subsequence.constants.midi_notes as notes

composition = subsequence.Composition(bpm=120, key="C")

@composition.pattern(channel=2, beats=4)
def bass(p):
    # Read the live-tweakable values, each with a sensible default.
    pitches = p.param("pitches", [notes.C2, notes.E2, notes.G2, notes.C3])
    velocity = p.param("velocity", 90)
    p.sequence(steps=[0, 4, 8, 12], pitches=pitches, velocities=velocity)

# A director firing each bar — stands in for the live keypresses you'd bind.
def director(p):
    if p.cycle == 2:
        composition.tweak("bass", pitches=[notes.A1, notes.C2, notes.E2, notes.A2])
    if p.cycle == 4:
        composition.clear_tweak("bass", "pitches")   # revert to the default

composition.schedule(director, cycle_beats=4)

composition.render(bars=8, filename="tweak-bass.mid")

composition.get_tweaks(name) returns a copy of the current overrides for a pattern, and composition.clear_tweak(name) with no parameter names removes them all (with names, just those). In a live set you’d bind these to keys:

Note

Live feature — the hotkey binding reads the live keyboard; not executed here.

composition.hotkey("o", lambda: composition.tweak("bass", velocity=120))  # open up
composition.hotkey("p", lambda: composition.clear_tweak("bass", "velocity"))
The live-control verbs

Verb

What it does

hotkeys() / hotkey(key, action)

Enable the listener; bind a single key to a zero-argument action.

mute(name) / unmute(name)

Silence or restore a running pattern by its function name; it keeps cycling so it stays in sync.

tweak(name, **kwargs)

Override parameters a pattern reads via p.param(); persists until cleared.

clear_tweak(name, *params) / get_tweaks(name)

Clear some/all overrides; inspect the current ones.

form_jump(name) / form_next(name)

Steer the form now / at the next boundary (§10.6).

Warning

mute, unmute, tweak, clear_tweak, and get_tweaks all take a pattern’s function name as a string ("bass" is the function def bass) and raise a ValueError if no running pattern has that name. A performer mute always wins over automatic gating — a part you muted by hand stays muted even if the form’s energy (§10.3) would otherwise open it.

14.3 Live MIDI input and arpeggiation

So far the music has flowed one way — Subsequence out to your instrument. Live performance often wants the reverse too: a MIDI keyboard, clock source, or controller feeding into the running composition. The door is composition.midi_input(device, clock_follow=False), which opens an input port. It does two jobs.

Following an external clock. Pass clock_follow=True and Subsequence slaves its clock to incoming MIDI ticks and respects transport — it waits for a Start, advances one pulse per tick (24 per beat), and halts on Stop. This is how you sync the running track to a DAW or hardware sequencer that owns the tempo.

Note

Live feature — needs a hardware/DAW clock source; not executed here.

composition.midi_input("Your Device:Port", clock_follow=True)
composition.play()    # waits for an incoming MIDI Start before the first note

Warning

Only one input device may have clock_follow=True (a second raises ValueError). While following external clock, set_bpm() has no effect — the clock source is authoritative — and MIDI clock output is automatically disabled to prevent a feedback loop.

Taking in held notes for arpeggiation. The headline live-input feature is the held-note arpeggiator. After opening an input port, call composition.note_input(channel, latch=...) and Subsequence tracks the set of keys you’re holding. Any pattern reads that set with p.held_notes() — sorted ascending, empty when nothing is down — and the natural thing to do with it is hand it straight to p.arpeggio(). The composition still authors the rhythm and motion; your hands supply the pitch set.

Note

Live feature — needs a MIDI keyboard; not executed here.

composition.midi_input("Arturia KeyStep")     # open the input port
composition.note_input(channel=1, release_ms=30)

@composition.pattern(channel=6, beats=4)
def arp(p):
    p.arpeggio(p.held_notes(), direction="up", spacing=0.25)   # rests when silent

Hold a chord and the arp runs over it; lift your hands and it rests. Two knobs keep it musical: release_ms (default 30) keeps a just-released note counting as held for a few milliseconds, so the momentary all-keys-up gap as you change hand position doesn’t drop the arp to silence; and latch=True holds the chord after you lift off until you play a new one, like a hardware arp’s latch.

Important

This is a performance layer over the deterministic skeleton. The seed still reproduces the rhythm and structure exactly; the held notes are an acknowledged live overlay. Because p.held_notes() returns an empty list when no note_input() source exists and when rendering headlessly, a pattern written this way renders to silence on that part and plays live over your chords — the same code, no branching. note_input() requires a midi_input() first, and one note-input source is supported.

Mapping a knob to a parameter: cc_map and cc_forward

Where note_input takes in notes, composition.cc_map(cc, data_key) takes in a CC and writes its scaled value into composition.data for patterns to read at rebuild time. A hardware knob becomes a live generative parameter with no callback code — here a CC opens up the arp’s velocity while your held chord still supplies its pitch set:

Note

Live feature — needs a MIDI controller; not executed here.

composition.midi_input("Arturia KeyStep")
composition.note_input(channel=1)                     # held notes feed the arp
composition.cc_map(74, "filter_cutoff")               # CC 74 → 0.0–1.0 in data
composition.cc_map(7, "volume", min_val=0, max_val=127)

@composition.pattern(channel=6, beats=4)
def arp(p):
    cutoff = p.data.get("filter_cutoff", 0.5)         # same dict as composition.data
    p.arpeggio(p.held_notes(), spacing=0.25,
               velocity=int(60 + 60 * cutoff))         # knob shapes the dynamics

When you instead need the controller to reach your synth immediately — a mod wheel to pitch bend, a pedal to expression — bypass the pattern cycle with composition.cc_forward(cc, output). It routes the incoming CC straight to the output: "cc" forwards it unchanged, "cc:N" remaps the number, "pitchwheel" scales it to a bend, or a callable gives you full control.

Note

Live feature — needs a MIDI controller; not executed here.

composition.cc_forward(1, "pitchwheel", output_channel=1)   # mod wheel → bend, ~1–5 ms
composition.cc_forward(1, "cc:74")                          # also reroute to CC 74

Tip

cc_map is for generative parameters read once per rebuild (a knob shaping note choice or density); cc_forward is for expressive signals that must track your hand in real time. They’re independent — register both for one CC and the value drives a pattern and reaches the synth at once. Both take input_device= to listen to one of several controllers (§13.5 covered the multi-device naming).

14.4 OSC: control and broadcast over the network

MIDI isn’t the only way in. Open Sound Control (OSC) carries named, typed messages over UDP — the lingua franca of TouchOSC layouts, Max/MSP and Pure Data patches, Processing sketches, and modular environments. Enable a bi-directional OSC server with composition.osc(receive_port, send_port) and Subsequence listens for control messages and broadcasts its own state.

Note

Live feature — the OSC server binds real network ports; not executed here.

composition.osc(receive_port=9000, send_port=9001, send_host="127.0.0.1")
composition.play()

Out of the box the server understands a few incoming addresses — /bpm <int> sets the tempo, /mute/<name> and /unmute/<name> toggle a pattern, /data/<key> <value> writes shared data (preserving the existing numeric type) — and broadcasts /bar, /chord, /section, and /bpm at each bar so a controller surface can mirror what’s playing. For anything else, register your own handler with composition.osc_map(address, handler) (call it after osc()):

Note

Live feature — needs an OSC sender; not executed here.

composition.osc()

def on_intensity(address, value):
    composition.data["intensity"] = float(value)

composition.osc_map("/intensity", on_intensity)   # a fader → composition.data

Warning

The OSC listener defaults to receive_host="0.0.0.0"all interfaces — so any machine on your LAN can change your tempo, mute patterns, and write data. On an untrusted network restrict it to loopback: composition.osc(receive_host="127.0.0.1").

OSC also goes the other way as musical output: p.osc(address, *args) sends a message at a beat position, and p.osc_ramp(address, start, end) sweeps a float over a beat range — the OSC twins of p.cc and p.cc_ramp from §13.1, for automating a remote mixer fader, a reverb send, or a Max patch in time with the music. These emit calls are safe to render (with no server configured the events are simply dropped), so unlike the server-side examples above this one is executed:

composition = subsequence.Composition(bpm=120, key="C")

@composition.pattern(channel=1, beats=4)
def pad_with_fx(p):
    p.note(notes.C4, beat=0, velocity=80, duration=4)
    p.osc("/fx/chorus/enable", 1, beat=0.0)                  # flip a switch
    p.osc_ramp("/fx/reverb/wet", 0.0, 0.8, beat_start=0, beat_end=4,
               shape="ease_in")                               # ease a fader up

composition.render(bars=2, filename="osc-emit.mid")

Note

OSC events bypass MIDI entirely — they’re never recorded into a rendered .mid file and aren’t mirrored to other MIDI devices. The block above renders cleanly because the emit calls no-op without a server; point a real composition.osc() at a listener and the same pattern drives it live.

14.5 External-data sonification: schedule, ease, sound

Here is where the chapter’s runnable heart is, and the most musically interesting input of all: the world. Weather, the position of the ISS, a stock ticker, a sensor on your desk — any value you can fetch in Python can steer the music. The pattern is always the same three steps, and all of it runs headless:

  1. Schedule a background task to fetch the value on a beat cycle, writing it into composition.data.

  2. Ease each new reading into the previous one so the music glides rather than jumps.

  3. Read the smoothed value in a pattern and map it to a musical parameter.

Step 1 — fetch on a schedule

composition.schedule(fn, cycle_beats, wait_for_initial=...) registers a function to run every cycle_beats beats. Synchronous functions run in a thread pool so a slow network call never stalls the MIDI clock. Declare a first parameter named p and the function receives a small context whose p.cycle counts the calls (0-indexed). Set wait_for_initial=True and the first fetch runs before playback starts, so composition.data is populated when patterns first build.

Important

schedule() must be called before play() (or render()) — scheduled tasks register at startup. It raises RuntimeError if you call it on a running composition. In the examples below we simulate the external reading with a small table so the chapter stays self-contained and reproducible; in real use the body would be a requests.get(...).json() call or a sensor read.

Step 2 — ease the jumps away with EasedValue

External readings arrive as discrete snapshots, often minutes apart. Jumping the music instantly to each new number sounds jarring. subsequence.easing.EasedValue remembers the previous value and interpolates smoothly toward the new one: create one per field at module level, call .update(value) in the scheduled task, and .get(progress) in the pattern — no manual previous/current bookkeeping.

The bridge from a raw reading to a musical range is scale_clamp(value, in_min, in_max, out_min, out_max) from subsequence.sequence_utils — it maps an input range to an output range and clamps, so an out-of-range reading can never push a velocity past 127 or a count negative.

Step 3 — read it in a pattern

The pattern computes its progress through one fetch cycle from p.cycle, asks the EasedValue for the smoothed reading, and maps it. Here is the whole loop — executed and validated — sonifying a stand-in temperature into the energy of a drum part:

import subsequence
import subsequence.constants.instruments.gm_drums as gm_drums
from subsequence.easing import EasedValue
from subsequence.sequence_utils import scale_clamp

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

# One EasedValue per smoothed field, created at module level.
warmth = EasedValue(initial=0.4)

FETCH_BEATS = 16        # fetch once every 4 bars
BARS_PER_FETCH = FETCH_BEATS // 4

def fetch_weather(p):
    # Stand-in for a real call, e.g. requests.get(API_URL).json()["temp_c"].
    sample_c = [6.0, 12.0, 9.0, 15.0][p.cycle % 4]            # degrees Celsius
    warmth.update(scale_clamp(sample_c, 0.0, 20.0, 0.0, 1.0))  # → 0.0–1.0, clamped

composition.schedule(fetch_weather, cycle_beats=FETCH_BEATS, wait_for_initial=True)

@composition.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
def drums(p):
    progress = (p.cycle % BARS_PER_FETCH) / BARS_PER_FETCH    # 0 → 1 over the fetch cycle
    level = warmth.get(progress)                              # eased, no jumps
    p.hit_steps("kick_1", [0, 4, 8, 12], velocity=int(70 + 40 * level))
    if level >= 0.5:                                          # busier when it's warm
        p.hit_steps("hi_hat_closed", range(16), velocity=int(50 + 40 * level))

composition.render(bars=16, filename="weather-drums.mid")

The .get(progress) call eases with a Hermite smoothstep by default; pass a shape name ("ease_in", "s_curve", …) for a different curve, exactly as the ramps in Chapter 13. And EasedValue exposes the direction of the last change as .delta — positive when the value rose, negative when it fell — which is constant across one fetch cycle and perfect for branching:

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

brightness = EasedValue(initial=0.5)
FETCH_BEATS = 16
BARS_PER_FETCH = FETCH_BEATS // 4

def fetch(p):
    sample = [30, 80, 55, 95][p.cycle % 4]                    # some 0–100 reading
    brightness.update(scale_clamp(sample, 0, 100, 0.0, 1.0))

composition.schedule(fetch, cycle_beats=FETCH_BEATS, wait_for_initial=True)

@composition.pattern(channel=6, beats=4, voice_leading=True)
def arp(p, chord):
    progress = (p.cycle % BARS_PER_FETCH) / BARS_PER_FETCH
    level = brightness.get(progress)
    direction = "up" if brightness.delta >= 0 else "down"    # rising data climbs
    count = 3 + int(round(2 * level))                        # 3..5 voices
    p.arpeggio(chord, root=notes.C4, count=count, spacing=0.25,
               velocity=int(60 + 40 * level), direction=direction)

composition.render(bars=16, filename="data-arp.mid")

Important

Match the read cycle to the fetch cycle. The pattern rebuilds every bar but the data refreshes every FETCH_BEATS beats, so progress must run 0→1 across exactly one fetch window — (p.cycle % BARS_PER_FETCH) / BARS_PER_FETCH. Get the divisor wrong and the ease either finishes early (then holds flat) or never completes. Tying both to one FETCH_BEATS constant keeps them honest.

Note

The same composition.data dict is the single meeting point for every live input in this chapter: schedule() writes to it, cc_map() writes to it, OSC’s /data/<key> writes to it, and a hotkey can write to it — and every pattern reads it as p.data. One dict, many sources, read once per rebuild. That is why the sonification recipe and the knob-mapping recipe are really the same recipe.

A live web dashboard: composition.web_ui

For a richer view than the terminal grid, composition.web_ui() starts a WebSocket server that broadcasts live state — signals, active patterns, timing, notes — to a browser dashboard. It’s read-only and binds to localhost by default. The dashboard is a beta feature, and it currently loads its frontend from a CDN, so it needs an internet connection.

Note

Live feature — serves a browser dashboard; not executed here.

composition.web_ui()    # dashboard on localhost
composition.display()
composition.play()