POP BOLL

DRAFT · SPECULATIVE

bollardeck API

Version 0.1-draft · Status: speculative draft · Updated 2026-11-07

Speculative draft. No Pop Boll hardware has shipped, and most of this API has not run against a real Boll yet. Message names, fields and limits will change without notice, and nothing here is an offer, a commitment or a promise of compatibility. We would love to hear about partnership, OEM and dev-kit ideas: use the contact form on this site.

bollardeck is the software side of Bollard: everything above the physical connector, which is specified in the Bollard Physical Specification. Docks, cartridges and routines all plug into the Pop Boll, "the deck", through it. The overview page explains how the pieces fit together.

1. Overview#

PartForWhat it covers
A. Docks (§2–§10)anyone building a dock or an accessorywhat a dock can read from a Pop Boll (now playing, MRPH5 stems, LED frames, buttons, battery, corpus labels) and what it can send (audio routing, volume, transport, remix and stem-balance requests, light sync), over USB or BLE
B. Routines (§11–§16)anyone who wants to change how the Boll writes songscustom Python logic that runs inside the song factory on your own Boll, working only with the corpora already on the device
C. Bolltridges (§17–§20)anyone building a cartridge computerthe extra messages for docks that run their own Linux computer: clock, MIDI, voice takeover, stem offers, power and health, and image updates
ItemVersion
This document0.1-draft
Wire protocol (Parts A and C)bollardeck/1
Message envelope"v": 1
Routine API (Part B)api: 1
Bolltridge extension (Part C)0.1

Status. The routine hooks of Part B have a working implementation in development builds of the song factory. The Boll-side service for Parts A and C (dock sessions, the BLE layout, the consent prompts and cartridge support) is specified but not built. §22 lists everything that is still open.

Conventions:

  • The key words MUST, MUST NOT, SHOULD, RECOMMENDED and MAY are used as described in RFC 2119.
  • JSON examples are sometimes wrapped for reading. On the wire every message is a single line.
  • An ellipsis (…) in an example stands for an elided value.
  • Latencies and transfer rates marked estimate have not been measured.

2. Part A: Docks#

A dock is anything that mates with the Boll's foot, on any Bollard profile, or an accessory with no physical connection that talks over BLE. A dock always talks to the Boll's bollardeck service. The song factory itself never talks to a dock.

DockTransport
USB-C profileUSB CDC-ACM (REQUIRED by Bollard rule C3)
Full profile, active-USB moduleUSB CDC-ACM
Full profile, passive moduleBLE only
Lite or Passive profileBLE only: there is no data path on the connector
No physical connectionBLE

A session opens after the physical layer has worked out what docked (Bollard §12):

 physical attach ──► (Full)  CHAIN and I2C header: vid, pid, class bit 11 "bollardeck"
                 └─► (USB-C) USB descriptors: a CDC interface named "bollardeck"
                 └─► (any)   BLE: the DECK_INFO characteristic
 dock  -> hello      vid and pid MUST equal the physical header's, when there is one
 boll  -> welcome    capabilities granted now, and those pending the user's consent
 boll  -> state, nowplaying, …   only the topics that are granted and subscribed

3. Transports#

3.1 USB CDC-ACM#

  • The dock is the USB device and the Boll stays the USB host (Bollard rule U1).
  • The dock exposes a CDC-ACM interface whose interface string is bollardeck. The Boll binds the interface by that string, and the baud rate is ignored.
  • One CDC interface carries one session.
  • Latency is about 1–2 ms (estimate).

3.2 BLE GATT#

  • The dock is a peripheral and the Boll is the central.
  • Pairing uses LE Secure Connections. The first pairing needs a long press of the Boll's inner (play/pause) button.
  • Latency is about 15–30 ms (estimate).
  • Airtime is shared with Bluetooth audio and the Boll's other accessories, so the Boll MAY lower led messages to 4–10 Hz on BLE.

The UUIDs below are placeholders. A random 128-bit base will be allocated, and the final UUIDs registered, with version 1.0.

ItemUUID (placeholder)PropertiesContent
bollardeck serviceb011a2d0-0000-4c6b-9e1a-6f2d0b011a2dprimaryadvertised in the scan response, with the dock's product name
DECK_TX (Boll → dock)b011a2d0-0001-4c6b-9e1a-6f2d0b011a2dnotifythe JSON-lines byte stream in ATT MTU-sized chunks (≤ 244 bytes with Data Length Extension), reassembled at each newline
DECK_RX (dock → Boll)b011a2d0-0002-4c6b-9e1a-6f2d0b011a2dwrite without responsethe same, in the other direction
DECK_INFOb011a2d0-0003-4c6b-9e1a-6f2d0b011a2dread{"v":1,"api":["bollardeck/1"],"vid":…,"pid":…}, so the Boll can filter before connecting
DECK_LED (optional)b011a2d0-0004-4c6b-9e1a-6f2d0b011a2dnotifybinary LED frames (§6.3) for docks that want lights without parsing JSON

4. Envelope and errors#

4.1 Framing and envelope#

  • JSON Lines. Each message is one JSON object on one line, UTF-8, terminated by a newline (\n), at most 64 KiB.
  • Envelope. Every message has "v": 1 (the envelope version) and "t": "<type>". All other fields are the payload, at the top level.
  • Requests and replies. A request MAY carry "id", an integer chosen by the sender. The reply carries "re" with that id.
  • Unknown fields MUST be ignored. An unknown t is answered with an unknown_type error, and the session stays open.
{"v":1,"t":"volume","id":12,"set":0.55}
{"v":1,"t":"ok","re":12}

4.2 Rates#

  • The Boll MAY drop or coalesce messages to keep the link healthy, but it never drops button, remix_status or error.
  • A dock SHOULD keep its own requests to at most 20 per second.

4.3 Errors#

{"v":1,"t":"error","re":7,"code":"not_granted","msg":"stems needs the user's consent"}
CodeMeaning
unknown_typethe message type is not known; the session stays open
bad_requestthe message is malformed or a field is out of range
not_grantedthe capability the message needs has not been granted
pending_consentthe capability is waiting for the user's consent on the Boll
rate_limitedthe dock is sending too fast
no_common_apithe dock and the Boll share no protocol major version
identity_mismatchthe hello identity does not match the physical header, or (for a cartridge) the chain position, power or audio declaration does not match the descriptor
not_foundthe requested song, bundle or item does not exist
corpus_rulesthe corpus does not allow this (for example, stems of a corpus song)
busythe Boll cannot take the request right now

Part C adds unsigned_image, power_budget, chain_depth, thermal, voice_busy, ota_failed and tier_too_low (§19.9).

5. Handshake and capabilities#

5.1 hello#

The dock opens every session with hello.

{"v":1,"t":"hello","id":1,"api":["bollardeck/1"],
 "dock":{"vendor":"Example Labs","product":"Bass Lean","vid":4617,"pid":49152,
         "profile":"usb-c","fw":"0.1","url":"https://example.org/bass-lean"},
 "want":["state","nowplaying","stems","beat","led","buttons","corpus"],
 "can":["remix","stem_balance","volume","transport","audio_out","audio_in","light_sync"]}
FieldMeaning
apievery protocol major the dock speaks; the Boll picks the highest one in common
dock.vendor, dock.productnames shown in the Boll's UI
dock.vid, dock.pidUSB-style vendor and product IDs. A physically attached dock MUST send the same values as its Bollard header
dock.profilethe Bollard profile: full, lite, usb-c or passive
dock.fw, dock.urlfirmware version and a product page
wantthe topics the dock wants to read (capability flags of §5.4)
canthe things the dock wants to send or control

5.2 welcome#

{"v":1,"t":"welcome","re":1,"api":"bollardeck/1",
 "boll":{"model":"Pop Boll Rev B","fw":"…","serial":null},
 "grants":["state","nowplaying","beat","led","buttons","corpus","volume","transport",
           "light_sync"],
 "pending":["stems","remix","stem_balance","audio_in"],
 "limits":{"led_hz":60,"max_line":65536}}
  • grants are the capabilities the dock may use now.
  • pending capabilities wait for the user's consent (§5.3).
  • boll.serial is null unless the user has turned on "share serial with docks".
  • limits gives the LED frame rate the Boll will send and the maximum line length.
  • Granted topics named in want are sent from the start (the example dock in §10 relies on this). subscribe and unsubscribe change the set later (§7.1).
  • A physical dock whose hello vid and pid do not match its header gets an identity_mismatch error, and the Boll then grants only state and nowplaying.

The first time a given dock asks for a consent capability, the user approves it on the Boll with a long press on the medallion, or in the Boll's local web UI. A dock is identified by its vid, pid and its USB serial number or BLE identity. When the grants change, the Boll sends:

{"v":1,"t":"grants","grants":["state","nowplaying","beat","led","buttons","corpus","volume",
 "transport","light_sync","stems","stem_balance"]}

5.4 Capability flags#

FlagDirectionDefaultMeaning
statereadgrantedbattery, charging, volume, mode, online
nowplayingreadgrantedsong metadata and position
corpusreadgrantedlabels and ids of the active corpora (never their files)
beatreadgrantedbeat and bar events
ledreadgrantedpersona colours
led_ringreadgrantedthe full 24-LED light-ring frame
matrixreadgrantedoptional: the 13 × 8 LED matrix frame and its RGB LEDs
buttonsreadgrantedmedallion button events
stemsreadconsentMRPH5 bundles of the current song (§8)
volume, transportwritegrantedvolume and play, pause, stop, next, previous
light_syncwritegrantedthe dock drives or offsets the Boll's lights (§7.7)
remix, stem_balancewriteconsentremix and re-balance requests (§7.4, §7.5)
audio_outwritegrantedthe Boll's mix to the dock over USB Audio (§7.6); not over BLE
audio_inwriteconsentthe dock's audio into the Boll over USB Audio (§7.6); not over BLE

6. Boll to dock messages#

6.1 Song and device state#

tRateGrantContent
stateon changestatebattery, charging and power source, volume (0–1), mode, whether the Boll is online, and what the factory is doing: idle, planning, jamming, overdub, exporting or paused
nowplayingon change, and every 5 snowplayingsong id, title, origin, form, corpora, tempo, key, time signature, the part list, the routines that shaped the song, and the playback position
corpuson changecorpusthe active corpora: id, label, number of songs, whether it is a remote corpus, and the edition
{"v":1,"t":"state","battery_pct":72,"charging":true,"source":"dock_vbus","volume":0.6,
 "mode":"playing","online":false,"factory":"jamming"}

{"v":1,"t":"nowplaying","song_id":"pb0042_d03_s012_…","title":"…","origin":"factory",
 "form":"single","corpus_ids":["0007-DEMO"],"corpus_labels":["PB 0007 — Demo corpus"],
 "bpm":118.0,"key":"A Major (Hexatonic Pop)","time_sig":"4/4",
 "parts":[{"index":1,"label":"Intro","category":"Intro","bar":1,"bars":8}],
 "routines":["mixolydian_bridge"],"position_ms":12345,"duration_ms":201000}

{"v":1,"t":"corpus","active":[{"id":"0007-DEMO","label":"PB 0007 — Demo corpus","songs":40,
 "remote":true}],"edition":"…"}

6.2 Stems, beats and buttons#

tRateGrantContent
stemson song changestemsthe current song's MRPH5 stems: id, MIDI channel, role, musician persona, program, gain and mute, plus the bundle's size and SHA-256. stems is an empty list when the song has no bundle
beatevery beatbeatbar, beat, whether it is a downbeat, tempo, and at_ms on the Boll's monotonic clock (use ping to learn the offset)
buttonon eventbuttonsbtn is inner, middle or outer (ids BTN_1_PLAY_PAUSE, BTN_2_ONLINE_UPLOAD, BTN_3_POWER); ev is tap, double, triple, hold, release or combo. The Boll's own button actions still happen
{"v":1,"t":"stems","song_id":"…","format":"mrph5/1",
 "stems":[{"id":"ch03_benthos","channel":3,"role":"bass","persona":"Benthos","program":33,
           "gain_db":0.0,"mute":false}],
 "bundle_bytes":48213,"sha256":"…"}

{"v":1,"t":"beat","bar":12,"beat":3,"downbeat":false,"bpm":118.0,"at_ms":918273}

{"v":1,"t":"button","btn":"inner","id":"BTN_1_PLAY_PAUSE","ev":"tap"}

6.3 LED frames#

tRateGrantContent
led60 Hz on USB, ≤ 10 Hz as JSON on BLEledthe frame number and 16 persona colours. With led_ring, also ring: a hex string of 24 RGB triples, the 16 outer-ring LEDs first, then the 8 inner LEDs
led_binas ledledthe binary frame below, base64-encoded, for USB docks that subscribe to the led_bin topic
matrix≤ 15 Hz on USB, ≤ 5 Hz on BLE, only while subscribed and only when it changesmatrixoptional. The frame on the Boll's 13 × 8 LED matrix: frame, t_ms, px (208 hex characters: 104 pixels, row-major, 13 wide, row 0 on top, 0–255) and rgb (three colours: LED1, LED3, LED4). A dock with a display can mirror it
{"v":1,"t":"led","frame":4711,"personas":["#ff3355","…"],"ring":"ff3355…"}
{"v":1,"t":"led_bin","b64":"…"}

The binary LED frame is 78 bytes. It is what the optional DECK_LED BLE characteristic notifies, and what led_bin carries on USB.

OffsetSizeField
02frame_no, unsigned, little-endian
21flags: bit 0 beat, bit 1 downbeat
31brightness
47224 × (R, G, B): 16 outer-ring LEDs, then 8 inner LEDs
762reserved

6.4 Replies and status#

tWhenContent
blobreply to getthe size, SHA-256 and number of chunks that follow
chunkafter blobone chunk: sequence number and up to 2,048 raw bytes as base64
remix_statuson changethe state of a stem_balance or remix request: applied, queued, rendering, done (with new_song_id) or refused
grantswhen grants changethe full list of current grants (§5.3)
pongreply to pingthe Boll's monotonic clock in ms
okreplythe request succeeded
errorreply, or unsoliciteda code from §4.3 and an optional message
{"v":1,"t":"blob","re":7,"size":48213,"sha256":"…","chunks":24}
{"v":1,"t":"chunk","re":7,"seq":0,"b64":"…"}
{"v":1,"t":"remix_status","re":4,"state":"done","song_id":"…","new_song_id":"…"}
{"v":1,"t":"pong","re":9,"boll_ms":918300}
{"v":1,"t":"ok","re":12}
{"v":1,"t":"error","re":4,"code":"pending_consent","msg":"remix is waiting for the user"}

7. Dock to Boll messages#

7.1 Session#

tGrantMeaning
hellononeopens the session (§5.1)
subscribe, unsubscribethe topic's ownadd or remove topics, for example led_ring, beat or led_bin
pingnoneask for the Boll's clock, to align at_ms timestamps
{"v":1,"t":"subscribe","topics":["led_ring","beat"]}
{"v":1,"t":"unsubscribe","topics":["beat"]}
{"v":1,"t":"ping","id":9}

7.2 get#

get fetches a file. The Boll answers with blob and then chunk messages (§6.4).

whatFieldsGrant
mrph5song_idstems
routineref, a storefront reference with a digestnot yet defined (§22)
{"v":1,"t":"get","id":7,"what":"mrph5","song_id":"…"}
{"v":1,"t":"get","id":8,"what":"routine","ref":"bollawax.org/examplelabs/bass-lean@sha256:…"}

7.3 Volume and transport#

tPayloadGrant
volumeset (0–1), or step (+1 or −1, one of 16 steps)volume
transportcmd: play, pause, stop, next or prevtransport
{"v":1,"t":"volume","id":12,"set":0.55}
{"v":1,"t":"volume","id":13,"step":1}
{"v":1,"t":"transport","id":14,"cmd":"next"}

7.4 stem_balance#

The Boll plays its songs by synthesizing them from MIDI, so a dock can change how the current song sounds without touching any audio: stem_balance re-balances the stems live, and remix (§7.5) asks the factory for a new version of the song.

stem_balance re-balances the Boll's current playback, stem by stem. It lasts until the song changes, or until the dock sends "reset": true. Gains are clamped to −24…+12 dB. The Boll answers with remix_status (applied). Grant: stem_balance.

{"v":1,"t":"stem_balance","id":3,"song_id":"…",
 "stems":{"ch03_benthos":{"gain_db":4.0},"ch06_gallium":{"mute":true}}}
{"v":1,"t":"stem_balance","id":5,"song_id":"…","reset":true}

7.5 remix#

remix with "mode": "render" asks the factory for a new version of a song. The new version joins the library with the origin dock:<vendor>/<product>, and is uploaded like any song, subject to the user's settings. The factory makes it from the song's own plan and the corpora already on the Boll (never a new corpus), and installed routines apply to it as to any song. The Boll reports progress with remix_status: queued, rendering, then done with the new song id, or refused. Grant: remix.

{"v":1,"t":"remix","id":4,"song_id":"…","mode":"render",
 "stems":{"ch03_benthos":{"gain_db":4.0}},"tempo":1.0,"transpose":0,"note":"bass forward"}

A remix with "mode": "live" MUST be treated as stem_balance.

7.6 Audio routing#

Audio itself flows over USB Audio Class 2 or the analog A-BUS. Only the routing goes over the API, and it is not available over BLE.

dirMeaningGrant
from_bollthe Boll sends its mix to the dock's USB Audio sink (iface, rate, ch)audio_out
to_bollthe Boll takes the dock's USB Audio source; use is replace, mix or recordaudio_in
abusselects the Boll's analog outputs; route is foot, medallion or bothsee §22
{"v":1,"t":"audio","id":5,"dir":"from_boll","iface":"uac2","rate":48000,"ch":2}
{"v":1,"t":"audio","id":6,"dir":"to_boll","use":"mix"}
{"v":1,"t":"audio","id":7,"dir":"abus","route":"foot"}

7.7 Light sync#

light_sync either shifts the Boll's lightshow to match the dock's audio latency (offset_ms), or lets the dock's own palette of 16 colours drive the Boll's light ring until the dock hands control back. Grant: light_sync.

{"v":1,"t":"light_sync","offset_ms":-12}
{"v":1,"t":"light_sync","follow":"dock","palette":["#ff0000","…"]}
{"v":1,"t":"light_sync","follow":"boll"}

7.8 plugin#

plugin tells the Boll which published dock plugin the dock is running, so the Boll's UI can show it. No grant is needed.

{"v":1,"t":"plugin","name":"examplelabs/bass-lean","ref":"sha256:…","state":"running"}

8. Stems and rights#

8.1 MRPH5 bundles#

An MRPH5 bundle is a per-song zip (media type application/x-mrph5, zip comment MRPH5/1). It is written with fixed timestamps, so the same song always gives the same bytes.

EntryContent
mimetypethe text application/x-mrph5, stored uncompressed, first in the zip
combined.midthe full multitrack MIDI, exactly as the song plays
stems/chNN_<label>.midone Standard MIDI File per instrument stem: track 0 is a conductor track with every tempo, time-signature, key-signature, marker and text event; track 1 is that stem's channel messages at their original ticks
meta.yamleverything else, machine-friendly (below)
blueprint.md or song.jsonthe corpus track's blueprint, or the generated song's record
  • Stem names use the MIDI channel as two digits (ch01 to ch16). For a generated song, the label is the musician persona; for a corpus track, it is the track name.
  • Playing every file in stems/ together gives the same notes and tempo map as combined.mid.
  • The proposed stem roles are ch01 lead, ch02 harmony, ch03 bass and ch10 drums, with the other channels named by persona. This mapping is a proposal until MRPH5 is frozen.
  • meta.yaml includes the format and kind, title, tempo, time signature, key, the part list (bars, start and end times, active channels, chords), the channel table, the MIDI tempo, time-signature and key maps, groove descriptors (swing ratio, syncopation per stem, note density, velocity arc, kick and snare pattern) and statistics per stem. Every time is given in both ticks and seconds.

8.2 Who gets which stems#

  • The Boll hands out stems of songs it generated once the user grants stems.
  • It hands out stems of corpus songs only where the corpus allows it (a proposed per-corpus dock_stems setting).
  • A dock never receives corpus audio in any form, or the Boll's account tokens or credentials. A request for them fails with corpus_rules.

8.3 MIDI-only corpora#

Some corpora are MIDI-only: the artist licensed Pop Boll to transcribe the recordings but not to keep or play them, so no audio exists for them anywhere in Pop Boll, not on the Boll and not on the service. A dock or a Bolltridge must not assume that a corpus has recordings.

  • Each entry of the corpus message (§6.1) carries "audio": "licensed" or "audio": "not_licensed". A dock that finds no audio field must treat the corpus as "not_licensed".
  • Everything a dock or cartridge can get for a corpus song, MRPH5 bundles and their stems included, is made from the corpus's MIDI transcriptions. It works the same for MIDI-only corpora.
  • A player cartridge that wants corpus recordings plays nothing for a MIDI-only corpus and should say so ("MIDI-only corpus"). Asking for its audio fails with corpus_rules, as for any corpus.

9. Versioning#

  • The envelope's v changes only if the framing changes.
  • The protocol is bollardeck/<major>. hello lists every major the dock speaks, and the Boll picks the highest one in common, or answers no_common_api.
  • Within a major version, additions are new optional fields, new message types, new error codes and new capability flags, discovered through want, can and grants. Removing a field or changing its meaning requires a new major version.
  • The physical specification and this API move independently. A Bollard v1.0 dock speaks bollardeck/1.

10. Example dock#

Bass Lean is a minimal dock: a Linux single-board computer on the USB-C profile that runs as a USB gadget, whose CDC-ACM function (interface string bollardeck) appears to it as /dev/ttyGS0. It pushes the bass forward on every song. It needs pyserial.

#!/usr/bin/env python3
import json
import serial

port = serial.Serial("/dev/ttyGS0", timeout=1)
PLAN = {"bass": +4.0, "drums": +1.5, "lead": -2.0}   # dB per stem role


def send(t, **payload):
    port.write((json.dumps({"v": 1, "t": t, **payload}, separators=(",", ":")) + "\n").encode())


send("hello", id=1, api=["bollardeck/1"],
     dock={"vendor": "Example Labs", "product": "Bass Lean", "profile": "usb-c", "fw": "0.1"},
     want=["nowplaying", "stems"], can=["stem_balance"])

next_id = 2
for raw in port:
    if not raw.strip():
        continue
    msg = json.loads(raw)
    if msg["t"] in ("welcome", "grants"):
        print("balance allowed" if "stem_balance" in msg.get("grants", []) else "waiting for consent")
    elif msg["t"] == "stems" and msg["stems"]:
        gains = {s["id"]: {"gain_db": PLAN[s["role"]]} for s in msg["stems"] if s.get("role") in PLAN}
        if gains:
            send("stem_balance", id=next_id, song_id=msg["song_id"], stems=gains)
            next_id += 1
    elif msg["t"] == "error":
        print("boll:", msg.get("code"), msg.get("msg", ""))

11. Part B: Routines#

A routine is a small piece of Python that changes how your Pop Boll writes songs. It hooks into the song factory on the Boll, runs only on your own Boll, and works only with the corpora already on the device.

How a song is made, so the hooks make sense:

  1. A planning summit plans the song (a single, or a multi-movement suite): key, tempo, and a list of parts (Intro, Verse, Chorus and so on), each with a chord progression, an energy level and a length in bars.
  2. A band of virtual musicians jams each part in takes.
  3. After each take, seven judges score it. When they vote to lock a take, the part is done. A take ceiling makes sure every part finishes.
  4. When every part is locked, the band votes on overdubs.
  5. The song is exported as MIDI and a song record.
  6. A device can also render a making-of film from the session.

11.1 What a routine can never do (normative)#

  • No network. No sockets, HTTP, DNS or subprocesses.
  • No file writes outside its own folder. A folder routine may write inside its folder. A zipped routine may write inside a <zip name>.data/ folder next to the zip.
  • No corpus creation or change. No API creates a corpus, by design, and corpora are read-only to routines. A routine sees corpus labels, summaries and musical DNA through frozen views, and never gets a handle or a path to a corpus.
  • No score forging. A routine can read every judge's score, and can add directives or veto a lock. It cannot change a score, a vote or the lock rule.
  • No hidden state across songs that matters to the factory. A routine's module stays loaded for the process, but everything it returns is validated on every call.

12. Hooks#

A routine defines one or more of these as plain module-level functions in routine.py.

HookCalledMayMay not
on_plan(plan, ctx)once per song, right after the planning summitre-order the parts of a single; change each part's progression, energy, bars and labeladd or remove parts; change any other plan or part field; re-order the parts of a suite
on_part_candidate(part, notes, ctx)every take of every part, after synthesis and autotune, before the judgesreturn a transformed note list: add, remove or change notes, which are then judged as they areexceed 2 × the input notes + 512; produce a note outside the part or outside MIDI ranges
on_verdict(part, verdicts, ctx)every take, after the judgesadd up to 4 directive codes to the next take; veto a lockchange a score, a vote or a threshold
on_overdub(plan, bus, ctx)once, after the band's overdub votesadd notes to locked parts (≤ 512 per part)remove or change locked notes
on_export(meta, ctx)once, before the song record is writtenreturn a JSON mapping (≤ 16 KiB) stored under the routine's namechange any other part of the record
on_drama(shots, ctx)when a device renders the making-of filmre-order shots; add up to 32 shotsremove or alter an original shot

12.1 on_plan#

  • Receives a copy of the song plan. Return the plan, or None to keep the edits you made to the copy in place.
  • May re-order the parts of a single (not a suite), and change each part's progression (1–8 chords from I, ii, iii, IV, V, vi and bVII), energy (0–1), bars (a multiple of 4 from 4 to 32) and label (truncated to 40 characters). part_index is renumbered in the new order, and the plan's total_bars is recomputed.
  • May not add or remove parts, change any other plan field (key, tempo, teams, taste, corpora, title and so on) or any other part field (category, assigned teams, study-break channel, movement, leitmotif role).
  • A result that breaks any of these rules is rejected as a whole, and the routine is disabled for the rest of the song.

12.2 on_part_candidate#

  • Receives a copy of the part and a list of copied note events.
  • Returns a new list, or None after editing the list in place. The routine owns those notes, and the judges hear them as they are.
  • Limits: at most 2 × the input count + 512 notes, and every note valid: channel_1based 1–16, pitch 0–127, velocity 1–127, duration_ticks greater than 0, and start_tick from 0 up to (not including) bars × 1,920.

12.3 on_verdict#

  • Receives the part and the seven judges' verdicts as frozen views.
  • Returns None, or {"directives": [...], "veto": bool, "reason": str}.
  • Directives: up to 4 codes matching ^[A-Z][A-Z0-9_]{2,47}$, added to the next take of the part (§12.7).
  • Veto: stops the take from locking, so the band goes again. A veto expires at the take ceiling, so a song always finishes.
  • The views are frozen: writing to one raises an error and disables the routine.

12.4 on_overdub#

  • Receives a copy of the plan and a frozen view, {"locked_parts": {part_index: (note, …)}}.
  • Returns {part_index: [note, …]}: notes to add, at most 512 per part. They are tagged with origin_tag routine:<name> and listed in the song's overdub record.
  • Locked notes cannot be removed or changed.

12.5 on_export#

  • Receives the song's metadata, frozen.
  • Returns a JSON-serialisable mapping of at most 16 KiB, stored in the song record at routine_meta.<name>.

12.6 on_drama#

  • Receives a copy of the making-of film's shot list. It runs only on devices.
  • May re-order shots, and add up to 32 shots of the form {"kind": str, "duration_ms": 180–20000, "data": {}}, which are marked by_routine. Timings are recomputed.
  • May not remove or alter an original shot.

12.7 Directive codes#

Directives are advisory codes that the next take of a part reads. The factory currently acts on these:

CodeEffect on the next take
EVOLVE_COUNTERPOINT_AND_VELOCITY_ARCdevelop counterpoint and the velocity arc
EVOLVE_CALL_AND_RESPONSE_DUELdevelop call-and-response between players
DUCK_COMPING_UNDER_LEAD_MELODYkeep accompaniment under the lead melody
REINFORCE_BEAT_2_4_BACKBEATreinforce the backbeat on 2 and 4
ADD_KICK_SNARE_FOUNDATIONadd a kick and snare foundation
REDUCE_CHROMATIC_QUIRKSreduce chromatic notes
AUTOTUNE_SNAP_SOUR_NOTESsnap sour notes to the scale
TIGHTEN_RHYTHMIC_POCKET_GRIDtighten timing to the grid

Any other well-formed code is recorded with the take and carried into the next take's directive set, but it does not change the notes until the factory learns it. The MORE_HOOK code in the judge_heckler example (§15.2) is one of these.

12.8 Data types#

Parts (in plan.parts, and as the part argument):

FieldTypeEditable in on_plan
part_indexint, 1…Nrenumbered
labelstr, for example "Verse 1"yes
categoryIntro, Verse, Chorus, Bridge, Instrumental Break or Outrono
barsint, a multiple of 4yes
progressionlist of chord symbols, for example ["I", "V", "vi", "IV"]yes
energyfloat, 0–1yes
assigned_team_ids, study_break_channel, movement_index, leitmotif_roleband and suite bookkeepingno

The plan also carries read-only fields such as song_id, title, bpm, key_str, form (single or suite), corpus_ids, corpus_labels and total_bars.

Note events:

FieldMeaning
channel_1basedMIDI channel, 1–16
pitchMIDI note number, 0–127
start_tickoffset from the start of the part; 480 ticks per quarter note, 1,920 per bar
duration_tickslength in ticks
velocity1–127
origin_tagwhere the note came from, for example jam, overdub or routine:<name>
was_autotuned, quirk_waiver_grantedfactory bookkeeping

To make a new note, copy one you received with dataclasses.replace(note, pitch=…, start_tick=…).

Verdict views (frozen): judge_id (a string that starts with the judge's code, such as J2 for the Melodic Hook judge), judge_number, domain, score, threshold, lock_vote, metrics (a mapping of names to floats) and directives (a tuple of codes).

13. Context and limits#

13.1 The context#

AttributeWhatWritable
ctx.routine_name, ctx.routine_version, ctx.apiwho is running, and the API version (1)no
ctx.hook, ctx.song_id, ctx.part, ctx.takewhere in the song: part is a frozen summary, and take is the 1-based take for per-take hooksno
ctx.corpusa frozen view with ids, labels, summaries and dna (the merged musical DNA of the active corpora). There is no handle to a corpusno
ctx.roster{"musicians": {channel: …}, "judges": {judge_id: …}}, the evolving roster, frozenno
ctx.taste_dnathe song's taste profile, frozenno
ctx.device{"serial", "song_id_prefix"}; never probes hardwareno
ctx.rnga random.Random seeded from the song seed, the routine name, the hook, the part and the take: deterministic per song, and separate from the factory's own random numbers, so a routine never shifts the band's diceyes (it is the routine's own)
ctx.log(msg)one line (≤ 200 characters, ≤ 50 lines per song) into the song recordappend-only
ctx.folderthe routine's own folder, the only place it may writeyes

13.2 Limits#

A manifest's limits can only tighten these caps.

LimitHard cap
max_ms_per_call250 ms of wall time, measured after the call (Python cannot pre-empt it)
max_directives_per_verdict4
max_notes_factor2.0 (× the input notes, + 512)
max_overdub_notes_per_part512
max_export_bytes16,384
max_added_shots32
max_log_lines50

13.3 Failures and the song record#

Every hook call is wrapped. An exception, a guard refusal, a result that breaks the hook's rules, or a call slower than the time limit disables that routine for the rest of the song. Its results from that call are discarded, and the song carries on. The song record shows what happened:

"routines_applied": [
  {"name": "mixolydian_bridge", "version": "0.1.0", "author": "Pop Boll examples",
   "source": "/data/routines/mixolydian_bridge", "hooks_called": {"on_plan": 1},
   "log": ["part 5 Bridge: vi-iii-IV-V -> I-bVII-IV-I"]}
],
"routines_failed": [
  {"name": "boom", "version": "0.0.1", "hook": "on_part_candidate",
   "where": "on_part_candidate part 1 take 2", "error": "ValueError: kaboom"}
],
"routine_meta": {"calm": {"ok": 1}}
  • routines_failed also lists packages that failed to load (with "hook": "load"): a bad manifest, a banned import or a duplicate name.
  • Each part's take history also records the directives routines added, their vetoes, and whether a lock was vetoed.
  • With no routine installed, none of these keys exist, and the song is byte-identical to one made by a factory without hooks.
  • The record survives pausing and resuming a song.

14. Packaging and installation#

14.1 Package layout#

A routine is a folder, or a .zip of one (with the files at its root or inside one top-level folder):

mixolydian_bridge/
  routine.yaml     manifest (YAML; JSON also parses)
  routine.py       the hooks, as plain module-level functions
  README.md        optional

14.2 Manifest#

name: mixolydian_bridge          # ^[a-z0-9][a-z0-9_.-]{1,63}$, unique on the device
version: 0.1.0
author: Pop Boll examples
api: 1                           # the routine API version
description: Every Bridge plays the Mixolydian jam cell.
hooks: [on_plan]                 # each MUST be defined in routine.py
order: 100                       # optional; lower runs first
limits: {max_ms_per_call: 20}    # optional; can only be tighter than the hard caps

14.3 Load order#

  1. The Boll loads routines from the routines/ folder of its data directory, in name order. Further search paths can be configured on the device; each is one routine or a folder of routines, read in the order given.
  2. The first routine with a given name wins. Later ones become duplicate name load failures.
  3. The loaded routines run in order of (order, discovery position), and every hook calls them in that order. Each routine sees the results of the routines before it.
  4. A device setting turns all routines off.

Routines are picked up when the next song starts.

14.4 Installing on a Boll#

Copy the routine's folder, or a zip of it, into the routines/ folder of the Boll's data directory. Today that means a computer connected to the Boll's medallion USB-C port, which gives the computer a network link to the Boll. A friendlier install flow, and installs from the storefront (§21), are planned. Installing a routine is always the owner's act, on their own device.

15. Example routines#

15.1 mixolydian_bridge#

on_plan: every Bridge's progression becomes the Mixolydian jam cell I – bVII – IV – I (in C: C, B♭, F, C), and each change is logged. Nothing else in the plan changes.

# A bollardeck routine. Its "bollawax file" is this folder, or a zip of it.
name: mixolydian_bridge
version: 0.1.0
author: Pop Boll examples
api: 1
description: >
  Every Bridge plays the factory's Mixolydian jam cell (I - bVII - IV - I) instead of the
  progression the planning summit picked. Nothing else about the plan changes.
hooks: [on_plan]
limits:
  max_ms_per_call: 20
"""mixolydian_bridge: force every Bridge onto the Mixolydian jam cell and log it.

The cell is the "corpus quirk: Mixolydian Jam Break" progression of the factory's
Instrumental Break table, I - bVII - IV - I (in C: C, Bb, F, C).
"""

MIXOLYDIAN_JAM_CELL = ["I", "bVII", "IV", "I"]


def on_plan(plan, ctx):
    for part in plan.parts:
        if part.category == "Bridge" and list(part.progression) != MIXOLYDIAN_JAM_CELL:
            ctx.log(f"part {part.part_index} {part.label}: {'-'.join(part.progression)} -> "
                    f"{'-'.join(MIXOLYDIAN_JAM_CELL)}")
            part.progression = list(MIXOLYDIAN_JAM_CELL)
    return plan

15.2 judge_heckler#

on_verdict: when J2 (the Melodic Hook judge) scores a take below 0.6, it adds the directive MORE_HOOK. It never changes a score, and every take's scores and votes stay identical to a run without it. MORE_HOOK is not yet a code the factory acts on, so it is a heckle you can see in the song record: it does not change the music.

name: judge_heckler
version: 0.1.0
author: Pop Boll examples
api: 1
description: >
  Heckles from the back of the room: whenever J2 (Melodic Hook) scores a take below 0.6 it
  adds a MORE_HOOK directive for the next take. It never changes a score or a vote.
hooks: [on_verdict]
limits:
  max_ms_per_call: 20
  max_directives_per_verdict: 1
"""judge_heckler: shout MORE_HOOK whenever J2's score is below 0.6. Scores stay untouched.

Verdicts arrive as frozen views, so a routine cannot forge a score even by accident; the
only things on_verdict can hand back are directives, a veto and a reason.
"""

THRESHOLD = 0.6


def on_verdict(part, verdicts, ctx):
    j2 = next((v for v in verdicts if v.judge_id.startswith("J2")), None)
    if j2 is None or j2.score >= THRESHOLD:
        return None
    ctx.log(f"part {part.part_index} take {ctx.take}: J2 {j2.score:.3f} < {THRESHOLD} -> MORE_HOOK")
    return {"directives": ["MORE_HOOK"], "reason": f"J2 scored {j2.score:.3f}"}

16. Security#

A routine is ordinary Python running inside the song factory on your Boll. The factory enforces the rules of §11.1 in three layers:

  1. Before loading, statically: imports of network, process and foreign-function modules are refused (socket, ssl, http, urllib, requests, httpx, aiohttp, asyncio, subprocess, multiprocessing, ctypes, cffi and similar), and so are imports of the factory's own corpus and device modules.
  2. During each hook call, at run time: a Python audit hook refuses socket, subprocess, os.system, exec, spawn, fork and ctypes events, and any open-for-write, remove, rename, mkdir or chmod outside the routine's folder.
  3. On every result: the factory validates what comes back and keeps only what each hook is allowed to change.

That is a guard rail for honest code, not a sandbox against hostile code. Python offers no in-process sandbox, and a determined routine can get around audit hooks. Install only routines you have read, or that come from someone you trust.

For the same reason:

  • Routines run only on your own Boll. The website never runs user routines: its song factory loads none, whatever is installed.
  • No API creates corpora. Routines work with the corpora on the device, and cannot add, change or export them.
  • A curated path for the web (routines reviewed by a person and allowlisted, or routines compiled to WebAssembly and run in a WASI sandbox with only the hook payloads as input) is a possible future path, and is not built. Until something like it exists, the website stays routine-free.

17. Part C: Bolltridges#

A Bolltridge is a cartridge for the Pop Boll: a Bollard device with its own Linux computer that runs one approved cartridge image (a synth, a sequencer, a soundfont pack, an effect, a player, a remixer or a visualiser). Its hardware, power, audio and chaining rules are in Bollard §17. This part is the message set. Everything in Part A still applies: a Bolltridge is a dock that also says "kind": "bolltridge".

17.1 Transports#

  • USB CDC-ACM with the interface string bollardeck is REQUIRED, on the Full profile as an active-USB module or on the USB-C profile. There is one CDC session per cartridge.
  • USB-MIDI is OPTIONAL. If present, the Boll binds it by the interface string bolltridge-midi and uses it for live MIDI only (§19.3). Song MIDI always travels as JSON, because USB-MIDI carries no timestamps.
  • USB Audio Class 2 carries audio. Only its routing goes over the API (§19.5).
  • BLE MAY carry the control subset (caps, params, param_set, health, clock). It MUST NOT carry midi, voice_claim, audio routing or image updates.
  • Heartbeat. health at 1 Hz is the heartbeat (§19.6). After 3 s without one, the Boll treats the cartridge as crashed: it drops its audio, returns any channels it had taken over, and shows it as "not responding".

17.2 On the cartridge#

  • The session is owned by a proxy in the cartridge's system container. Image code speaks the same JSON lines on the Unix socket /run/bollardeck/cart.sock.
  • The proxy adds hello, health and the image-update messages itself, and refuses attempts by image code to send them, so an image cannot forge the system's identity.
  • The cartridge base image ships client libraries: for Python, import bollardeck gives bollardeck.Cartridge, an asyncio client with typed messages and on_clock, on_midi, on_stems, on_voice and on_power_low callbacks; for C and real-time engines, libbollardeck with bollardeck.h. Their API reference will be published with the base image.

18. Cartridge session#

18.1 hello and welcome#

{"v":1,"t":"hello","id":1,"api":["bollardeck/1"],
 "dock":{"kind":"bolltridge","vendor":"Example Labs","product":"TR-Boll Puck",
         "vid":4617,"pid":49170,"profile":"full","fw":"0.1",
         "url":"https://example.org/tr-boll"},
 "cart":{"spec":"0.1",
         "image":"bollawax.org/examplelabs/tr-boll:1.2.0",
         "digest":"sha256:4f1c…","version":"1.2.0","name":"examplelabs/tr-boll",
         "base":"bollawax-cartridge-base:0.1@sha256:9ab0…",
         "sig":{"type":"cosign-bundle","b64":"…"},
         "manifest":"sha256:77d2…","affirmation":"aff_01JC6ZK3W0R5",
         "compute":{"board":"rpi-zero-2w","tier":1,"ram_mb":512},
         "power":{"profile":"self","avg_mw":2600,"peak_mw":3500,"vbus_source_ma":1500,
                  "vstack_source_ma":0},
         "audio":{"usb":true,"abus_mix":true,"latency_ms":10},
         "chain":{"position":0,"female_bottom":true}},
 "want":["state","nowplaying","clock","midi_in","beat"],
 "can":["voice","midi_out","audio_usb","abus_mix","cart_params"]}

{"v":1,"t":"welcome","re":1,"api":"bollardeck/1",
 "boll":{"model":"Pop Boll Rev B","fw":"…","serial":null},
 "cart":{"verified":true,"hacker_mode":false,"enabled":false,"chain_depth":2,
         "power_ok":true,"return":"usb","lookahead_ms":30},
 "grants":["state","nowplaying","clock","beat","midi_out","audio_usb","cart_params"],
 "pending":["midi_in","voice"],
 "limits":{"midi_batch_hz":100,"max_line":65536}}

The cart block carries the image reference and digest, the version, the signature bundle, the base image, the manifest digest, the storefront affirmation record, and the compute, power, audio and chain declarations. Registry references in these examples are placeholders until the storefront exists.

18.2 Rules#

  • cart.chain.position and the chain depth MUST match what CHAIN enumeration found, and cart.power and cart.audio MUST match the bolltridge object in the Bollard JSON descriptor. A mismatch gets identity_mismatch, as for the vendor and product IDs. A USB-C-profile cartridge has no CHAIN and reports position 0.
  • welcome.cart.verified is the Boll's offline check of sig against the signing keys it carries. An unverified image gets the unsigned_image error and only the state grant, unless the Boll is in hacker mode (Bollard §17.11): then verified is false, hacker_mode is true, and normal grants follow.
  • enabled stays false until the power budget allows the cartridge and the user has not disabled its image. The Boll then sends cart_enable (§19.2).
  • lookahead_ms is how far ahead the Boll sends song MIDI: the cartridge's declared latency_ms + 20 ms.
  • return is the audio return path the Boll has selected: usb, abus or none (§19.5).

18.3 Capability flags for cartridges#

These add to the flags of §5.4.

FlagDirectionDefaultMeaning
clockreadgrantedtransport and tempo map (§19.2)
midi_inreadgranted for generated songs, consent for corpus songsthe current song's MIDI, streamed ahead of time (§19.3)
midi_outwritegrantedcartridge MIDI into the Boll: played by its synthesizer, recorded, or both
voicewriteconsenttake over one or more song channels (§19.4)
audio_usbwritegranted (mixed at ≤ 0 dB; the Boll's volume applies)the cartridge's USB Audio output into the Boll's mix
audio_inreadconsent (as in Part A)the Boll's mix, or one stem, sent down to the cartridge over USB Audio
abus_mixwritegranted when declaredthe cartridge may be selected as the analog mix-through return (§19.5)
stemsreadconsent (as in Part A; corpus rules apply)MRPH5 bundles, offered or requested (§19.7)
cart_paramsbothgrantedthe Boll's UI shows and sets the cartridge's parameters (§19.1)
cart_otareadgranted when verified or in hacker modeimage updates over the link (§19.8)

19. Cartridge messages#

19.1 What the cartridge declares#

tDirectionWhenContent
capscartridge → Bollafter welcome, and on changeinstruments, patches, soundfonts, sequencer, effects, the maximum number of stems it takes, and any visual output. It mirrors the caps section of the manifest
paramscartridge → Bollafter caps, and whenever a value changes on the cartridgeits parameters. Types: float, int, bool, enum, multi and trigger
{"v":1,"t":"caps","instruments":[{"id":"kit","name":"TR-Boll kits","channels":[10],"programs":3,
 "polyphony":16,"gm_map":"drums"}],"patches":[{"id":"boll-909","name":"Boll 909"}],
 "soundfonts":[{"file":"hall_strings.sf3","programs":18}],
 "sequencer":{"tracks":8,"ppq":96,"patterns":64},
 "effects":[{"id":"grain","type":"granular","inputs":["stems","boll_mix"]}],
 "max_stems":4,"visual":null}

{"v":1,"t":"params","params":[{"id":"swing","type":"float","min":0,"max":0.6,"value":0.12},
 {"id":"kit","type":"enum","values":["boll-909","boll-808","dusty"],"value":"boll-909"}]}

The Boll MAY show at most 16 parameters in its own UI. The rest appear on the cartridge's page in the Boll's local web UI.

19.2 Control and clock#

tDirectionRateGrantMeaning
cart_enableBoll → cartridgeonce—the cartridge may now draw what it declared and leave mute
cart_disableBoll → cartridgeon event—reason is power_budget, user, crashed, thermal, chain_depth or unsigned_image. The cartridge mutes, bypasses A-BUS, releases any channels it took over and stays connected
clockBoll → cartridgeon every transport change, at each bar, and every 2 sclocktransport and tempo map, with the next change ahead, so a sequencer can plan bars in advance
param_setBoll → cartridgeon user actioncart_paramsset a parameter; the cartridge answers ok and re-sends params
abus_readyBoll → cartridgeon requestabus_mixA-BUS is ramped down: switch the relay now (§19.5)
health_reqBoll → cartridgerarely—asks for an immediate health
{"v":1,"t":"cart_enable","power_ma":700,"priority":3}
{"v":1,"t":"cart_disable","reason":"power_budget"}
{"v":1,"t":"clock","song_id":"…","playing":true,"bpm":118.0,"time_sig":"4/4","bar":12,"beat":3,
 "tick":0,"ppq":96,"at_ms":918273,"next_change":{"bar":17,"bpm":118.0,"part":"Chorus"}}
{"v":1,"t":"param_set","id":31,"param":"swing","value":0.2}
{"v":1,"t":"abus_ready","re":40}
{"v":1,"t":"health_req","id":44}

Part A's beat keeps flowing; clock adds the tempo map. On the Full profile the hardware SYNC line gives sub-millisecond beat edges, and ping and pong align at_ms.

19.3 MIDI#

tDirectionGrantMeaning
midiBoll → cartridgemidi_in (only the channels the cartridge claimed or subscribed to)song MIDI, each event [at_ms, status, data1, data2] on the Boll's clock, sent lookahead_ms early in batches of at most 10 ms of music. {"flush": true} on seek, stop or song change
midicartridge → Bollmidi_outcartridge MIDI. use is play (the Boll's synthesizer renders it), record (it joins the song as an overdub on the next remix) or both. An at_ms in the past plays immediately
subscribecartridge → Bollmidi_inlisten to channels the cartridge did not claim: topics midi:ch<N> or midi:all
{"v":1,"t":"midi","song_id":"…","ch":[2,10],
 "ev":[[918300,144,38,96],[918300,153,36,110],[918420,128,38,0]]}
{"v":1,"t":"midi","flush":true}
{"v":1,"t":"midi","id":52,"use":"play","ev":[[918600,153,42,80]]}
{"v":1,"t":"subscribe","topics":["midi:ch10","midi:all"]}

Rates: at most 100 midi lines per second in each direction, and at most 64 events per line. Live MIDI on the optional USB-MIDI function has no timestamps and plays on arrival.

19.4 Voice takeover#

A cartridge can replace, or layer over, the Boll's own synthesizer voice for a song channel. The Boll then mutes that channel locally, streams its MIDI to the cartridge, and mixes the cartridge's audio back (over USB Audio), or lets it sum on A-BUS.

tDirectionGrantMeaning
voice_claimcartridge → Bollvoiceclaim channels; mode is replace or layer
voice_okBoll → cartridgethe takeover starts at the next bar, so it is seamless
voice_releaseeitherthe Boll's own voice returns at the next beat
{"v":1,"t":"voice_claim","id":60,"channels":[2],"mode":"replace",
 "program":"hall:strings_legato"}
{"v":1,"t":"voice_ok","re":60,"channels":[2],"from_bar":13}
{"v":1,"t":"voice_release","channels":[2]}
  • At most 4 claimed channels per cartridge, and one owner per channel: a second claim gets voice_busy.
  • On a crash, a timeout, a bypass or an unplug, the Boll returns every claimed channel to its own voice within one beat, so the song never goes silent.
  • A claim is for playback. The exported song keeps the Boll's own rendering (§22).

19.5 Audio routing#

The Boll selects one return path per cartridge: usb (the default), abus (analog mix-through, only if the cartridge declared abus_mix) or none.

tDirectionGrantMeaning
audioBoll → cartridgeaudio_usb or abus_mixselects the return path and gain. With abus, abus_max_vrms is 1.0 divided by the number of mixing cartridges
audiocartridge → Bollaudio_inasks the Boll to send its mix (source: "mix") or one rendered stem (source: "stem:<id>") down over USB Audio
abus_switchcartridge → Bollabus_mixasks to switch the relay to mix or bypass. The Boll ramps A-BUS down over 20 ms, answers abus_ready, then ramps back up 30 ms later
bypasscartridge → Boll—the supervisor has already put the relay in bypass; reason is watchdog, power, thermal, user or update
{"v":1,"t":"audio","id":70,"return":"usb","gain_db":0.0}
{"v":1,"t":"audio","id":72,"return":"abus","gain_db":-3.0,"abus_max_vrms":0.5}
{"v":1,"t":"audio","id":71,"dir":"from_boll","source":"stem:ch05_pad"}
{"v":1,"t":"abus_switch","id":40,"to":"mix"}
{"v":1,"t":"bypass","reason":"watchdog"}

19.6 Health and thermal#

health is sent by the cartridge at 1 Hz and is its heartbeat. power is ok, low or critical.

{"v":1,"t":"health","uptime_s":3600,"soc_c":58.5,"board_c":41.0,"cpu_pct":63,"mem_mb":301,
 "xruns":0,"throttled":false,"power":"ok","input_mv":5080,"draw_mw":2480,"voices":14,
 "image":"sha256:4f1c…"}

If soc_c is at least 80 °C, or throttled is true, for 10 s, the Boll asks the cartridge to shed load by setting the reserved parameter _quality to low with param_set. At 85 °C or more it sends cart_disable with the reason thermal.

19.7 Stems for cartridges#

tDirectionGrantMeaning
stem_offerBoll → cartridgestemsthe Part A stems payload plus an offer id, sent on song change to cartridges whose caps.max_stems is above 0
stem_requestcartridge → Bollstemsasks for named stems or "all". The Boll answers with blob and chunk (§6.4), carrying the MRPH5 bundle, or only the requested stems' MIDI files plus meta.yaml
{"v":1,"t":"stem_offer","offer":81,"song_id":"…","format":"mrph5/1",
 "stems":[{"id":"ch05_pad","channel":5,"role":"pad","persona":"…","program":89,"gain_db":0.0,
           "mute":false}],"bundle_bytes":48213,"sha256":"…"}
{"v":1,"t":"stem_request","id":82,"song_id":"…","stems":["ch05_pad","ch01_lead"]}

At most caps.max_stems stems per song. Corpus songs follow the same corpus rules as docks (§8.2), and the error is corpus_rules. A cartridge receives MIDI stems only, never audio of corpus songs.

The Boll fetches images from the storefront with its own short-lived token and relays them, so a cartridge never needs a registry credential, or even Wi-Fi.

tDirectionMeaning
image_offerBoll → cartridgea new image: name, version, digest, size, number of chunks, signature bundle, and when to apply it (now, next_song or next_boot)
image_chunkBoll → cartridgeone chunk, up to 2,048 raw bytes as base64. The cartridge MAY pace the transfer with ota_status receiving and the next sequence number it wants
ota_statuscartridge → Bollreceiving, verifying, installed, switched, rolled_back or failed (with error)
{"v":1,"t":"image_offer","id":90,"name":"examplelabs/tr-boll","version":"1.3.0",
 "digest":"sha256:…","size":187654321,"chunks":91629,
 "sig":{"type":"cosign-bundle","b64":"…"},"apply":"next_song"}
{"v":1,"t":"image_chunk","re":90,"seq":0,"b64":"…"}
{"v":1,"t":"ota_status","re":90,"state":"receiving","next":1}
{"v":1,"t":"ota_status","re":90,"state":"verifying"}

Over USB at an effective 4–8 MB/s (estimate), a 600 MiB image takes about 1.5–2.5 minutes. The cartridge verifies the signature before loading, and keeps its previous image until the new one has sent three heartbeats.

19.9 Errors added by Part C#

CodeMeaning
unsigned_imagethe image has no valid signature and the Boll is not in hacker mode
power_budgetenabling the cartridge would exceed the stack's power budget
chain_deptha fifth Bolltridge in the chain, or deeper than USB allows
thermalthe cartridge is too hot
voice_busythe channel is already claimed by another cartridge
ota_failedan image update failed
tier_too_lowthe image needs a bigger compute tier than the board reports

20. Cartridge manifest#

Every cartridge image carries a cartridge.yaml at its root (and as an image label).

bolltridge: 0.1                      # the Bolltridge extension version
name: examplelabs/tr-boll            # ^[a-z0-9][a-z0-9._-]{1,31}/[a-z0-9][a-z0-9._-]{1,63}$
developer: {name: Example Labs, contact: https://example.org/support, url: https://example.org}
version: 1.2.0
kind: sequencer                      # instrument, soundfont, sequencer, effect, player,
                                     # remixer or visualiser; a list is allowed
licence: Apache-2.0                  # SPDX id, or "LicenseRef-<x>" with a LICENSE file
affirmation: aff_01JC6ZK3W0R5        # the storefront affirmation record id (§21)
base: bollawax-cartridge-base:0.1
compute: {tier: 1, min_ram_mb: 384}  # tier 1 = Zero 2 W class, 2 = Pi 4 / CM4, 3 = Pi 5 / CM5
power: {profile: [self, vstack, below, boll], peak_mw: 3500}
audio: {usb: true, abus_mix: optional, latency_ms: 12}
bollard: {profiles: [full, usb-c]}   # the top faces it has been tested on
capabilities:
  want: [clock, midi_in, nowplaying]
  can: [midi_out, audio_usb, cart_params]
caps:                                # shown in the Boll's UI (§19.1)
  sequencer: {tracks: 8, ppq: 96, patterns: 64}
  instruments: []
  effects: []
  max_stems: 0
params:
  - {id: swing, type: float, min: 0.0, max: 0.6, default: 0.12}
  - {id: kit, type: enum, values: [boll-909, boll-808, dusty], default: boll-909}
storage: {data_mib: 64}
entry: ["python3", "/app/main.py"]
FieldMeaning
bolltridgethe Bolltridge extension version the image targets
namedeveloper/image, unique on the storefront
developername, a contact and a URL
version, licencethe image version and its SPDX licence
affirmationthe id of the developer's recorded affirmation (§21)
kindwhat the image is (see the comment above); one kind or a list
basethe published cartridge base image it is built on
computethe compute tier and the minimum RAM it needs
powerthe power profiles it supports, and its peak draw
audioUSB Audio (required if it makes sound), analog mix-through (true, false or optional) and its output latency
bollardthe Bollard profiles it has been tested on
capabilitiesthe capability flags it asks for (want) and uses (can)
capswhat the Boll's UI shows (§19.1)
paramsits parameters: id, type, range or values, default, and an optional unit
storagethe size of its writable /data volume
entrythe command the container runs

The planned automated review refuses an image whose cartridge.yaml does not parse, whose base is not a published base, whose want or can name unknown flags, or whose declared tier does not boot on that tier's reference board.

Three worked examples show the range:

ImageKindTierWhat it does
TR-Bollsequencer, instrument1an 8-track step sequencer with three bundled kits. It follows clock, switches pattern banks at each part boundary from nowplaying.parts, and can claim channel 10 so its kit replaces the Boll's drums
Orchestral Hallsoundfont21.4 GB of redistributable orchestral and choir banks with its own synthesizer, offering 48 programs. The user assigns, for example, strings to channel 2, and the cartridge takes that voice over
Grain Fieldeffect, remixer2requests the current song's stems (with the user's consent), granulates chosen stems into a texture that follows the song's key and beat, and can also take the Boll's live mix down over USB Audio and return a granular wash

21. Distribution (planned)#

None of this exists yet. The plan is that routines, dock plugins and cartridge images will be published through a storefront and registry at bollawax.org, with routines first, because they are small and need no hardware.

  • Packaging. Routines, dock plugins and cartridge images are published as OCI artifacts. Labels on each artifact declare its kind, the API or extension version, the hooks it uses (for routines), its compute tier (for cartridges), its version and its licence. The label names will be published with the storefront.
  • Publishing. A developer records an affirmation (name, contact, licence, and the right to distribute everything inside, including every bundled soundfont and audio file), gets a short-lived push token for their own repository, and pushes. For cartridges that is a linux/arm64 image built on the published base.
  • Automated checks. Routines are loaded with the static checks and run under the guard on three fixture songs (a single, a suite, and a paused-and-resumed song) on a private device, and are rejected if they fail, break a limit or touch the guard. Cartridge images get a vulnerability scan, size and label checks, a manifest check, a check that they are built on a published base, a check for setuid binaries, and a boot test on an emulated cartridge against a simulated Boll that replays a session (hello, three songs with clock, MIDI and stems, and a crash and restart). Tier-1 images are also booted on real Raspberry Pi Zero 2 W boards. Provisional size limits: 600 MiB for tier 1, 2 GiB for tiers 2 and 3.
  • Signing. Images that pass are signed with cosign, and the signature bundle travels with the image.
  • Delivery. A Boll fetches what its owner chose with a short-lived token, verifies the signature, and installs a routine itself or relays an image to a cartridge over bollardeck. No device holds a long-lived credential, and a cartridge never holds one at all.
  • Unsigned images are never distributed. They run only on a Boll whose owner has turned on hacker mode.

22. Open items#

Not yet validated on hardware. Nothing in Part A or Part C has run on a Boll.

  • Not built yet: the Boll's bollardeck service (dock sessions, BLE, consent prompts), the BLE UUID allocation (the UUIDs in §3.2 are placeholders), the binary LED characteristic, remix in render mode, voice takeover in the Boll's player, the image-update relay, the cartridge proxy, the cartridge base image and its client libraries, and the storefront with its routine and cartridge pipelines.
  • Estimates: every latency, rate and transfer time in this document.
  • Undefined grants: the grant that covers get with "what": "routine", and the grant that covers audio with "dir": "abus".
  • The hello profile value for an accessory with no physical connection (BLE only) is not defined.
  • MRPH5: the stem role mapping is a proposal until the format is frozen, and so is the per-corpus dock_stems setting.
  • Routines: the plan and part fields beyond those listed in §12.8 are not frozen, and there is no stable import path yet for creating note objects from scratch (copy a received note instead).
  • Cartridge voices in exported songs: whether a cartridge voice used during playback should also be rendered into the exported song (a "render with cartridge" remix), or stay a playback-only layer as proposed.
  • Cartridge attestation: there is no hardware attestation in this version. A counterfeit cartridge could replay a valid hello, but gains nothing: the Boll gives cartridges no files, tokens or credentials, and stems only with the user's consent.
  • Network for cartridge images: images get no network in this version. If that changes, it will need a consent flag and a storefront policy.
  • Image sizes for soundfont-heavy images (1–2 GiB) and how they are hosted.

23. Licence#

Proposed, and not yet final:

  • This specification text: CC BY 4.0.
  • Code, including the example dock, the example routines and the bollardeck client libraries: Apache-2.0.
  • Third-party packages in the cartridge base image keep their own licences, and the base image will publish their notices. Cartridge images carry whatever licence their developer declares.

These licences cover the documents and code, not the project's names and logos.

CONTACT THE TEAM