Superconductor
A touchscreen control surface for music software.
A small service runs beside your music applications, serves one page to a touchscreen's browser, and holds a single WebSocket to it. Each application dials the service over a socket of its own and declares what it can be controlled by; the page draws widgets bound to those declarations. Tapping the glass changes the music; changing the music changes the glass.
Nothing in this package knows about your studio. An application declares its own controls, and the names in them are yours. MIDI channels, drum note maps and device names live in your own files, never in here.
Status
This is an early release, and it is not polished. It has been played on real hardware most days, by one person - its author - and by nobody else. Expect rough edges, and expect the interface to move: very little here is settled enough to be promised, and a version that changes how something works is more likely than one that does not.
So far it drives Subsequence, and nothing else. The package itself knows nothing about any particular music software - an application dials in and declares what it can be controlled by, and that door is open to anything that can hold a WebSocket and speak the protocol. But Subsequence, a generative MIDI sequencer, is the only application anyone has written an adapter for, so today "an application" means that one. Sampling and radio are intended and not started.
If you do not already run Subsequence, there is nothing here you can play yet. The service will start, serve its page, and wait for an application that is not coming.
What runs today: as many pages as an application declares, holding step grids, pitched note grids with sub-step timing, an instrument's own settings, stacks of generators and transforms that contribute to a pattern, a set of notes chosen on a keyboard, and a transport with a bar-beat-step counter. Blocks are arranged by dragging, and the arrangement - and, if the application asks, everything made on the glass - is kept by the application. A generator is wired to the pattern it builds; a grid that belongs to no instrument, or a set of notes, is patched into as many places as you like by dragging a cable from its outlet - and a grid can be made on the glass as well as declared. Everything below documents one of those.
One thing to know before you play anything into it. A pattern you edit on the glass lives in the running composition, and it is kept across a restart only if the composition asks for that - see Keeping what you make below. Without it, restarting the composition throws away every note you tapped. Either way there are two tools in the repository as a safety net, and the habit is to run the first before restarting anything:
python tools/capture_state.py # before
python tools/restore_state.py # after
A restore puts each grid back exactly: a step the composition seeds on every start, and that you had taken out, stays out - and the restore says which steps it took out.
Those two tools live in the repository rather than in the installed package, as
does compositions/drm1_grid.py below - the only worked example, and the only
place the application-facing API is written down. Installing from a package
index gets you the service and the page; the examples are worth the clone.
What it needs
Genuinely required:
- Python 3.11 or newer for the service.
- A browser that delivers Pointer Events and holds a WebSocket. Any current browser does.
- A network path between the browser, the service and the applications. Loopback is fine if they share a machine.
Everything else is a recommendation with a reason, not a requirement. The development rig is a Raspberry Pi 5 driving a 22-inch capacitive panel over Firefox, with the service and the music applications on a separate machine, but the code assumes none of that: it is where the work was done, not what the product is for.
Running it
pip install superconductor
superconductor # serves on port 8090
superconductor --port 9000 # or wherever you like
Then open http://<that machine>:8090/ on the touchscreen.
The service starts with no configuration at all. A YAML file passed with
--config overrides the defaults:
host: 0.0.0.0 # the interface to listen on
port: 8090 # anything you like; this one is clear of the ports the
# neighbouring applications use
page: grid # which page to serve
It listens on every interface by default, because the usual arrangement has
the panel on a different machine from the service. That is a default chosen for
a use, not a recommendation about your network: there is no authentication in
front of it, so anyone who can reach the port can change your music. Set host
to 127.0.0.1 if the browser is on the same machine, and keep it off any
network you do not trust.
The page shows nothing until an application dials in and declares something, which is the expected state on a fresh start rather than a fault.
The corner of the bar shows the version and the build the page was made from. If the service has newer files than the browser loaded - which happens whenever you change something while a panel is left open - that readout becomes a button saying so, and tapping it loads the new page. It never reloads on its own: somebody may be playing.
Sizing the grid to your hands
The size button on the page sets how big a grid cell is. It starts on fit the glass, which measures your own screen and makes the grid as large as will fit on it - so a panel nothing here was written against still uses all of itself. The named sizes override that:
| Compact, 22 px | Half the tested size. Fits twice the music across the same glass, and frees room for whatever else you want beside it. |
| Snug, 32 px | |
| Tested, 44 px | The size the proof of concept was played at, with taps landing where intended. It is the only one with evidence behind it. |
| Large, 60 px | |
| Huge, 96 px |
None of these is recommended over the others, because the right one depends on your hands, your panel and how far away it is. Someone who wants the most music on the glass and someone who needs a larger target are both served by the same control, and the choice is remembered by the browser that made it - so two people with their own panels do not have to agree.
The chooser itself never shrinks. Whatever size you pick, the way back is the same size it always was.
Drum steps
A step grid is a drum machine's pattern: a row per voice, a cell per step. Press an empty cell to put a step there and press it again to take it away.
Each step carries how hard it is struck. New steps take the velocity set on the slider beneath the grid, which opens at the grid's own default, so set it low and tap in ghost notes, then set it high and tap in accents. A step is lit from the bottom as far as it is struck: a full cell is as loud as it goes, and a half-lit one is half as hard.
To change a step that is already there, use the lane beneath the grid. It shows one row at a time, because every voice shares the same steps: press a row's name to choose it (the name you chose carries a ring), then drag a bar up or down to set that step's velocity. Choose the hi-hat, and its accents can be reshaped without touching anything else.
The slider and the chosen row belong to the panel you are holding, the way a cell size does. Nothing is sent until you place or change a step.
A composition reads each step with its velocity, so its play function places every step at its own:
@composition.pattern(channel=10, steps=16, ...)
def drums (p):
for row, steps in drum_grid.now(p).items(): # {"4": {"velocity": 90}, ...}
for step, shape in steps.items():
p.note(pitch=row, beat=int(step) * STEP_DURATION, velocity=shape["velocity"])
A composition written before steps carried a velocity may still seed its grids
with lists of steps. They are read at default_velocity, which StepGrid takes
and which is 100 unless the composition says otherwise, and so are patterns kept
or captured before this.
A row is kept under the name the composition gives it and drawn with whatever words the composition says for it, so a kit keyed by MIDI note number can read kick rather than 36:
drum_grid = superconductor.subsequence_adapter.StepGrid(
composition, rows=["36", "38", "42"], steps=16,
labels={"36": "kick", "38": "snare", "42": "closed hat"})
A row given no words is drawn as its name, with each underscore as a space. The
words can change while the piece plays, with drum_grid.set_labels(...) called
from a play function, and every step stays where it was: steps are kept under a
row's name, never under its words. The words are not kept with the pattern,
because the composition says them again when it starts.
Pitched patterns
A step grid's steps are there or not, each struck at its own velocity. A note grid's cells are notes: one row per pitch, and a cell that carries its own length and velocity.
Press an empty cell to place a note where your finger is, to the snap chosen beneath the grid, and keep dragging right to make it longer. What you drag is drawn as a ghost until you let go, because the note on the glass is only ever the one the instrument has agreed to. Press a note to select it and press it again to take it away; drag it to move it in time or in pitch, and drag either end of a long one to change where it starts or stops. A selected note's length can also be chosen by name beneath the grid, which is the only way to reach the finest values.
A note always ends inside its pattern. One placed near the end is as long as there is room for, and a length that would carry it past the end cannot be chosen.
Beneath the grid is a velocity lane, one bar to a step, aligned with the grid above so a column is the same moment in both; drag a bar up or down to set how hard that note is struck.
Rows are drawn in the order they are declared, so a pitched part lists its
highest note first and a rising line rises. A part that declares visible_rows
shows a window onto a pattern taller than itself - two octaves is twenty-five
rows, and a block tall enough for all of them crowds everything else off the
page. Only the pitches scroll: the velocity lane and the playhead stay put,
because a column is a moment in time and scrolling up and down does not change
the time.
A part declares how many voices its instrument has, and no more than that many
notes sound at once: one for a monophonic synth, four for a Moog Matriarch in its
four-voice mode, and nothing at all for a part with no limit worth stating.
Placing a note that would exceed the count takes an earlier one away, newest
first, and each one taken is reported so the glass never goes dark unexplained.
That is enforced by the application rather than left to the instrument: a monophonic synth handed two notes at once chooses between them by its own note-priority setting, which the panel cannot see - so the glass would show two notes while one sounded. It is counted by extent rather than by starting position, because a note beginning part-way through another is exactly the case the instrument would have to arbitrate.
Rows are names, as they are everywhere here. compositions/drm1_grid.py builds
them from note names and hands the same list to Subsequence as a note map, so
what the panel calls C2 and what the synthesiser plays cannot drift apart.
An instrument's own settings
A params control is a block of an instrument's settings, in the three shapes they come in: a switch, a number you drag, and a choice of named options. Between them those cover every control-change message a Moog Minitaur answers to, and probably most other instruments.
Nothing in this package knows that a switch is a MIDI control change. A composition declares what shape each setting is and what it may hold, and is given a function to call when one moves - which is where a message gets sent, if that is what the setting stands for:
superconductor.subsequence_adapter.Params(
composition,
parameters=[
superconductor.subsequence_adapter.Parameter("glide", "switch", label="Glide"),
superconductor.subsequence_adapter.Parameter("rate", "number", default=24),
superconductor.subsequence_adapter.Parameter(
"shape", "choice", options=[("lcr", "LCR"), ("exp", "EXP")]),
],
on_change=send_setting,
)
The settings worth putting on glass are usually the ones an instrument has no knob for at all - reachable otherwise only through editor software. On the Minitaur that is most of them.
A set of notes
A pitch set is a block holding notes somebody chose, and it sounds nothing by itself. Drag a cable from its outlet onto a generator that takes pitches - an arpeggio, a chord - and that generator plays those notes. One set can feed as many generators as you like, on as many instruments, and each plays it in its own instrument's register, so the same chord reaches a bass synth and a lead without being chosen twice.
It is drawn as a keyboard, an octave at a time - thirteen notes, C to C - however wide the range it offers. Drag the strip beneath the keys to move along it; marks on the strip show where every chosen note is, including ones out of view. Each C is labelled with its octave.
The composition says which notes exist and where the view opens:
superconductor.subsequence_adapter.PitchSet(
composition,
name="notes",
pitches={"C2": 36, "C#2": 37, "D2": 38}, # every note it offers, and the MIDI note each sounds
opens_at="C2",
)
Where it opens is yours to say because it depends on your instruments: the worked example offers a piano's eighty-eight keys and opens at C2, between its bass synth and its lead.
Arranging a page
A block's title bar is its handle: drag one and it moves a cell at a time, on the same lattice the steps themselves sit on - so two patterns on a page line up step for step rather than nearly. A grid's bottom edge is a handle too: drag it to show more of its rows or fewer. The grids go on playing throughout; only a title bar or an edge moves anything.
The chevron at the right of a title bar collapses the block to that bar, where it stands. It keeps its place and its width, and its cables go on reaching it: they meet the title bar rather than the rows they write. Tap the chevron again to expand the block to the height it had. Collapsing never changes the size of the cells on the page. A collapsed block that is switched off says LIVE OFF on its bar, since the switch itself is out of sight.
Any position is allowed, including on top of another block. The last block you moved is the one on top, and pressing a block brings it to the front - anywhere but where you play notes, so playing never reshuffles the page. That is what makes a busy page workable. Nothing is ever pushed aside to make room: a block you did not touch does not move.
A block can be covered completely. Collapse the block on top of it and it is uncovered, with nothing moved; press it and it comes to the front.
Tap LAYOUT in the bar to hold the arrangement still - the padlock closes, and title bars and edges stop moving anything. That is what stops a stray finger rearranging a page mid-performance. The browser remembers which way you left it. A block still collapses and expands while the arrangement is held, because doing so moves nothing.
An arrangement is saved when you lift your finger from a block that moved, or
when you collapse or expand one, and it is saved to the application that declared
the page, not to your browser. For a composition using PageStore that means a file beside the
composition itself, so a piece and the way you look at it travel together:
link = superconductor.subsequence_adapter.AppLink(
composition,
controls=[...],
pages=[...],
page_store=superconductor.subsequence_adapter.PageStore(
pathlib.Path(__file__).with_suffix(".pages.json")),
)
Leave page_store out and arranging still works - it simply is not kept, and
the panel says so rather than letting you find out at the next reload. Because
the arrangement belongs to the application rather than to one browser, a second
panel sees it too.
Keeping what you make
Whatever you make on the glass can outlive the composition holding it. Give the
link a PatternStore and it keeps every grid's steps and notes, the stacks, the
settings, the set of notes, transpositions, mutes and the grids you made. It
never keeps what a generator played, and never the tempo or a pause:
link = superconductor.subsequence_adapter.AppLink(
composition,
controls=[...],
pattern_store=superconductor.subsequence_adapter.PatternStore.beside(__file__),
)
link.start()
try:
composition.play()
finally:
link.stop() # writes down anything not written yet
It is written a moment after your hands stop, never on the thread that keeps time, and it is read when the composition starts, before any panel sees it. So after your first edit the composition file is no longer the score: its opening pattern and opening values apply only where the store holds nothing.
beside(__file__) keeps it next to the composition. Give a path instead to keep
it anywhere else - the example rig keeps its own on the machine's own disk,
because its compositions sit on a network share.
The bar says when the store last wrote - KEPT · 15:32 - and says STORE · TROUBLE if anything went wrong with it. Tap it for the details, and for START AGAIN FROM THE FILE, which puts every pattern, stack, setting and mute back as the composition file has them and removes the grids made on the glass. It asks first. The store is moved aside rather than deleted, so nothing is lost for good.
If the store cannot be read, it is moved aside untouched, the bar and the log say where, and the piece starts as its file says. Anything the composition no longer accepts - an option renamed, a row taken away - is refused on its own and listed, the rest comes back, and a copy of the store as it was is kept beside it.
Variants
A pattern can hold several versions of its notes, and switch between them while it plays. A grid that has them carries a column down its right-hand edge - a letter and a ▶ to a row, A to D - and each row is one variant, with two things you can do to it:
- Tap the letter to show that variant and edit it. The music does not change: you can write B while A carries the room.
- Tap its ▶ to play it next. It blinks until the switch happens, which is at the end of the pattern's current cycle, and is lit once it has. Tap the blinking ▶ again to change your mind.
The lit row is the one playing and the ringed letter is the one you are looking at; when they are the same row it is both. While you are looking at a variant that is not playing, the grid shows its notes without the dots the playing variant's generators are placing - and if it is empty, its ▶ gives way to a ⧉ that fills it from the one sounding, since an empty variant has nothing to play anyway. Clear works on the variant you are looking at. Which variant you are looking at belongs to your panel; which one plays belongs to the piece, and every panel sees it.
A block with fewer rows than it has variants has nowhere to put a column, and keeps a row of tabs above the pattern instead. That row names the one you are looking at and says which one is playing beside it, in words, because one row can mark which variant is shown or which is playing but not both.
Only the notes are a variant's. The mute, a pitched pattern's transposition and the generators that build the pattern stay the pattern's, so switching variant never changes the key you are in.
A composition says which grids have variants and what they are called, and its play function asks the grid what to play when the pattern is built - which is the moment a cued variant lands:
drum_grid = superconductor.subsequence_adapter.StepGrid(
composition, rows=ROWS, steps=16, pattern="drums",
variants=("A", "B", "C", "D"))
@composition.pattern(channel=10, steps=16, ...)
def drums (p):
for row, steps in drum_grid.now(p).items():
... # each step as in *Drum steps* above
lands_every=2 holds each switch for the end of a two-bar phrase over a one-bar
pattern. A grid given no variants is exactly the grid it always was.
Pattern length
A pattern can play fewer of its steps than its grid has, changed while it plays. A grid that allows it has a length row in its settings: -1 and +1 either side of how many steps play. The change is heard from the pattern's next cycle, so a twelve-step hi-hat against a sixteen-step kick is a few presses away, and the two meet again every three bars.
The grid stays the width it was. Steps past the end are hatched, and they are still there: you can tap them, they are kept with everything else, and they play again when the pattern is lengthened. A step that is set but past the end is hatched in its lit colour rather than filled. The longest a pattern can be is the width of its grid. A pattern's length is its own rather than a variant's, so every variant plays at the one length.
A pattern that has been shortened drifts against the bar, and stays drifted. A hi-hat played at twelve steps for three cycles and set back to sixteen is three steps late, and nothing puts it back by itself, because sometimes that is exactly what you wanted. re-sync puts it back: the pattern starts again from its first step on the first bar line after its current cycle ends, playing the end of the pattern into that bar line so that it arrives on the downbeat. It blinks until it lands, and pressing it again before then changes your mind.
A composition says which grids may change length and how short they may get, and gives the grid its own way of making the pattern that long:
def make_it_this_long (p, steps):
p.set_length(steps=steps)
drum_grid = superconductor.subsequence_adapter.StepGrid(
composition, rows=ROWS, steps=16, pattern="drums",
min_steps=4, resize=make_it_this_long)
resize is called at each build with the pattern builder and a number of the
grid's steps, and makes the pattern being built that many steps long with each
step the size it was. The play function asks the grid what to play with
now(p), as it does for variants, and is handed only what the cycle plays. A
grid given no min_steps is exactly the grid it always was.
In Subsequence, making a pattern a number of its steps long is
set_length(steps=…), as above. Given a length in beats instead, set_length
keeps a pattern's count of steps, which spreads a generator's steps across the
new length rather than dropping some of them. So compositions/drm1_grid.py
offers a length only where set_length can be given a count of steps.
A pattern cannot be shorter than its reschedule_lookahead, which is a beat
unless the pattern says otherwise. Subsequence refuses the length: the build that
asked for it is silent, and the pattern plays on at the length it had until it is
given one it will take. So a sixteen-step grid rebuilt a beat ahead offers four
steps at the fewest, as above. compositions/drm1_grid.py rebuilds its patterns
a pulse ahead, and offers one.
Connecting an application
superconductor/subsequence_adapter.py is the worked example. A composition
builds a link, gives it the controls it wants to offer, and starts it:
link = superconductor.subsequence_adapter.AppLink(
composition,
controls=[
superconductor.subsequence_adapter.StepGrid(composition, rows=ROWS, steps=16),
superconductor.subsequence_adapter.Transport(composition),
],
)
link.start()
compositions/drm1_grid.py is a complete example driving a Vermona DRM1: it is
the file that holds the MIDI port, the channel and which drum voice sits on
which row, and it is the file you would copy and change for your own rig.
The adapter imports nothing from the application it serves - it is written against whatever object it is handed. That is deliberate, and it is what keeps this package free of any dependency on a particular piece of music software.
Keeping it running
None of this is required. Superconductor is an ordinary process: start it from a
terminal, from your window manager's autostart, from a tmux session, or from
whatever you already use. It needs no supervisor, and it does not need systemd
to exist.
If you do want it supervised and you have systemd, there are two shapes and the difference is real. A system unit starts at boot with nobody logged in, which is what you want on a machine that boots into being a studio. A user unit needs no root and shares your own environment and files, which suits a machine that is also somebody's desktop - but it starts only when you log in unless you enable lingering.
Both of these have placeholders in capitals. They will not start until you have replaced them, which is deliberate: a unit file that half-works with someone else's paths in it is worse than one that refuses.
A system unit, at /etc/systemd/system/superconductor.service:
[Unit]
Description=Superconductor control surface
After=network-online.target
[Service]
Type=simple
User=REPLACE_WITH_YOUR_USERNAME
ExecStart=/REPLACE/WITH/YOUR/VENV/bin/superconductor
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target
sudo systemctl enable --now superconductor
A user unit, at ~/.config/systemd/user/superconductor.service:
[Unit]
Description=Superconductor control surface
[Service]
Type=simple
ExecStart=/REPLACE/WITH/YOUR/VENV/bin/superconductor
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.target
systemctl --user enable --now superconductor
loginctl enable-linger $USER # only if it should run before you log in
Order never matters. The applications dial the service on a backoff and the panel reconnects on its own, so any of the three can be started, stopped or restarted without the others being told.
Development
From a checkout, rather than from the package index:
pip install -e ".[dev]"
pytest --browser firefox
mypy superconductor
The suite includes tests that drive the page in a real Firefox, so it needs the browser and its dependencies once:
playwright install firefox
playwright install-deps firefox
Firefox rather than all three browsers - it is a third of the packages, and it
is the browser this is built for. --browser firefox is not optional: the test
plugin defaults to Chromium.
Two files test the worked example against the sequencer it drives,
tests/test_composition.py and tests/test_generators.py, so they import
Subsequence and fail without it. If you do not run Subsequence, leave them out
with --ignore, as the project's own CI does.
The page is plain ES modules with no build step: Preact and htm are vendored
under superconductor/client/vendor/, with their licences recorded there. Edit
the files and reload the browser.
What is in here
superconductor/ |
the package - the service, the protocol, the adapter and the page it serves |
compositions/ |
the worked example, and the only place the application-facing API is written down |
tools/ |
probes that stand in for a browser, a capture and a restore, and the theme separation check |
tests/ |
the suite, including the ones that drive a real Firefox |
research/ |
the probes and raw measurements the design was made from |
reviews/ |
the code reviews it has been through |
licences/ |
notices for the vendored face and icons |
research/ and reviews/ are provenance: they record what was true on the day
they were written and are deliberately not kept up to date. They still say
Superintendent, which is what this was called until September 2026, and they
cite a tracker you cannot reach. They are here because the documents that cite
them should be checkable, not because they describe the code as it stands.
Licence
Functional Source License 1.1 with an Apache 2.0 future licence
(FSL-1.1-ALv2) - see LICENSE. You may read, run, modify and
redistribute it for any purpose except competing with it, and each version
converts to Apache 2.0 two years after its release.
The libraries it carries keep their own permissive licences, recorded in
superconductor/client/vendor/README.md.