one-synthesizer

Signal flow: MIDI in, OSC out

A note never passes through the Linux UI on its way to a voice. The XMOS voice controller takes MIDI in, decides which voices play, and emits OSC to the voice cards.

Sections 1 to 5 describe the instrument as shipped. Section 6 is the modification this project makes to get per-note MPE. Each diagram can be read at three levels: simplified, for anyone who knows MIDI; intermediate, showing the stages; and detailed, showing every step and message.

1. The whole path

flowchart TB
  CTRL(["MIDI controller
USB or DIN"]) KEYS(["Built-in keyboard,
wheels and pad"]) SEQ(["Sequencer and
arpeggiator"]) BRAIN["Voice controller
decides which Synth plays each note
and which voice sounds it"] VOICES[["Analog voices"]] CTRL -->|"notes on a MIDI channel"| BRAIN KEYS -->|"notes with no channel"| BRAIN SEQ -->|"notes on a MIDI channel"| BRAIN BRAIN -->|"start and stop each voice"| VOICES BRAIN -->|"bend, aftertouch and pad,
one value per Synth"| VOICES
flowchart TB
  CTRL(["External controller
USB-MIDI / DIN"]) KEYS(["Built-in keybed,
wheels, XY pad"]) subgraph FE["Linux frontend"] UI["UI and presets"] SEQ["Sequencer / arp"] end subgraph VC["XMOS voice controller"] subgraph ING["Ingress"] RX["MIDI receive"] SCAN["Panel scan"] PERF["Performance port
UDP 7771"] PARAM["Parameter port
UDP 7770"] end subgraph ROUTE["Routing"] R{"Router"} TF["Timbre filters x3
channel, note range"] LF["Local filter x3
note range, mask cache"] end subgraph OUT["Voices"] AL["Voice allocator
per timbre"] GX["Timbre expression"] EM["OSC emitter"] end end CARDS[["Voice cards
2 voices each"]] CTRL -->|"MIDI, channel 0-15"| RX KEYS -->|"key and control state"| SCAN SEQ -->|"/r/sq/0/midi-arp ,iii"| PERF UI -->|"/x ,b parameter records"| PARAM RX -->|"MIDI event"| R PERF -->|"sequencer event"| R SCAN -->|"local event, channel = -1"| LF R -->|"offered to each timbre"| TF TF -->|"notes"| AL LF -->|"local notes"| AL TF -->|"bend, pressure, CC"| GX LF -->|"wheels, pad, aftertouch"| GX PARAM -->|"patch values"| EM AL -->|"voice assignments"| EM GX -->|"one value per timbre"| EM EM -->|"/vN/n /vN/nw /vN/p
per voice"| CARDS EM -->|"/m/pw /m/a /m/xyz
per timbre"| CARDS EM -.->|"note echo, UDP 7772"| UI
Three sources of notes converge on the voice controller. Only the frontend's own sequencer and arp use the OSC performance port; physical MIDI and the built-in keybed never do.

The frontend and the voice controller share an internal USB-Ethernet link. The rear Ethernet jack is also routed through the voice controller, so a hung controller takes the whole box off the network.

2. External controller

USB and DIN MIDI terminate on the voice controller. Each message becomes an internal event carrying its type, input port, MIDI channel, note, raw 14-bit data and velocity. The router offers the event to each of the three timbres; a timbre accepts it if it passes that timbre's filter for that input port. Matching is independent per timbre, so two timbres on the same channel layer.

flowchart TB
  NOTE(["Note on MIDI channel 2"])
  ASK{"Which Synths
listen on channel 2?"} S1["Synth 1
takes a free voice"] S2["Synth 2
takes a free voice"] S3["Synth 3"] BEND(["Pitch bend or aftertouch
on channel 2"]) V[["Voices"]] NOTE --> ASK ASK -->|"set to channel 2"| S1 ASK -->|"also set to channel 2"| S2 ASK -.->|"set to channel 5, ignores it"| S3 S1 -->|"one voice per note"| V S2 -->|"one voice per note"| V BEND -->|"moves every note
Synth 1 and 2 are holding"| V
flowchart TB
  IN(["MIDI in
status | channel, d1, d2"]) EV["Event
type, port, channel,
note, raw14, velocity"] R{"Router"} subgraph T1["Timbre 1"] F1["Filter
channel + note range"] A1["Allocator"] G1["Expression"] end subgraph T2["Timbre 2"] F2["Filter"] A2["Allocator"] G2["Expression"] end subgraph T3["Timbre 3"] F3["Filter"] A3["Allocator"] G3["Expression"] end CARD[["Voice cards"]] IN -->|"parse"| EV EV --> R R -->|"offer"| F1 R -->|"offer"| F2 R -->|"offer"| F3 F1 -->|"note"| A1 F1 -->|"bend, pressure, CC"| G1 F2 -->|"note"| A2 F2 -->|"bend, pressure, CC"| G2 F3 -->|"note"| A3 F3 -->|"bend, pressure, CC"| G3 A1 -->|"/vN/n /vN/nw /vN/p"| CARD A2 -->|"/vN/n /vN/nw /vN/p"| CARD A3 -->|"/vN/n /vN/nw /vN/p"| CARD G1 -->|"/m/pw /m/a /m/xyz"| CARD G2 -->|"/m/pw /m/a /m/xyz"| CARD G3 -->|"/m/pw /m/a /m/xyz"| CARD
The channel selects timbres and is then discarded. Notes go out per voice; bend, pressure and pad go out once per timbre and reach every voice that timbre owns.

A voice v lives on card v / 2, slot 1 + v mod 2, which is why card addresses are only ever /v1 or /v2.

The frontend sees none of this directly. It learns which notes are sounding from the echo the controller sends back on UDP 7772.

3. Built-in keyboard

The keybed and the left-hand controls are not MIDI and do not arrive over the OSC performance port. They enter the voice controller as local events with no channel (the channel field is โˆ’1).

flowchart TB
  KEY(["Key pressed"])
  ASK{"Which Synths cover
this key?"} S1["Synth 1
lower split"] S2["Synth 2
upper split"] WH(["Wheel, pad or aftertouch"]) V[["Voices"]] KEY --> ASK ASK -->|"key is in its range"| S1 ASK -.->|"key is outside its range"| S2 S1 -->|"one voice per note"| V WH -->|"moves every held note"| V
flowchart TB
  subgraph ON["Key down"]
    KD(["Key down"])
    LEV["Local event
channel = -1"] LF["Inline filter per timbre
local note range"] MASK["Cache routing mask
for this key"] AL["Allocator(s)"] end subgraph OFF["Key up"] KU(["Key up"]) RM["Read cached mask"] REL["Release the same timbres"] end subgraph EXPR["Wheels, XY pad, aftertouch"] PAD(["Control moved"]) GRP["Timbre expression"] end CARD[["Voice cards"]] KD --> LEV LEV --> LF LF -->|"timbres that matched"| MASK MASK --> AL KU --> RM RM --> REL PAD --> GRP MASK -.->|"same mask"| RM AL -->|"/vN/n"| CARD REL -->|"/vN/nw"| CARD GRP -->|"/m/pw /m/a /m/xyz"| CARD
The timbres a key triggered are remembered per key, so the release goes to the same timbres even if a split point moved while the key was held.

4. The two paths compared

External controllerBuilt-in keyboard
Enters atMIDI receive (USB / DIN)Panel scan, binary panel plane
Channel0โ€“15 from the status byteNone (โˆ’1)
Timbre selectionShared router; per-timbre MIDI channel and note rangeIts own inline filter; local note range (splits, layers)
Note-off routingNot yet characterisedMask cached at note-on
Visible on UDP 7771NoNo
ExpressionPer timbrePer timbre, global to the instrument
Open question. The panel plane (UDP 9100/9876 towards the controller, 9500/9600 back) carries ~1 kB binary frames and is not decoded. Captures show it busy whenever keys or the pad move and show nothing on 7771, which establishes that local input bypasses the OSC performance port. Which direction carries the control data versus the display updates is not yet pinned down.

5. The frontend's own notes

The sequencer and arpeggiator live in the Linux frontend, so their notes do cross the performance port, as raw MIDI bytes wrapped in OSC:

/r/sq/0/midi-arp ,iii 144 60 100     note-on, channel 1, note 60, velocity 100

The same triple is accepted on several router addresses (/r/midi, /r/midi-force, per-timbre /g/<t>/midi). Details on the protocol page.

6. Per-note MPE

As shipped, bend, pressure and timbre reach every note a Synth is holding, so an MPE controller's per-finger expression collapses into one value. The voice cards, though, already accept those three messages addressed to a single voice; the stock controller never sends them that way. This project changes the voice controller, and nothing else, so that it does.

flowchart TB
  MPE(["MPE controller
each finger on its own channel"]) ZONE["MPE Synth
listens on every channel"] VA["Voice A
remembers channel 2"] VB["Voice B
remembers channel 3"] OUT[["Each note bends, swells
and brightens on its own"]] MPE -->|"notes, one channel per finger"| ZONE ZONE -->|"first note"| VA ZONE -->|"second note"| VB MPE -.->|"bend, pressure, slide
on channel 2"| VA MPE -.->|"bend, pressure, slide
on channel 3"| VB VA --> OUT VB --> OUT
flowchart TB
  IN(["MIDI in, member channel c"])
  R{"Router"}
  subgraph NOTE["Note path"]
    F["MPE timbre filter
channel compare skipped"] AL["Allocator"] TAG["Voice v
tagged with channel c"] end subgraph EXPR["Expression path"] X["Bend, pressure or CC74
on channel c"] M{"Voices tagged c"} SUP["Timbre-wide messages
dropped for this timbre"] end CARD[["Voice cards"]] IN --> R R -->|"note"| F F --> AL AL --> TAG R -->|"expression"| X X --> M X -.->|"/m/pw /m/a /m/xyz"| SUP TAG -.->|"channel tag"| M TAG -->|"/vN/n /vN/nw"| CARD M -->|"/vN/m/pw /vN/m/a /vN/m/pady
to matching voices only"| CARD
The channel a note arrived on is kept with its voice instead of being discarded, and expression on that channel is sent to that voice alone.
  1. One timbre is designated the MPE zone and accepts notes from every channel.
  2. Each allocated voice remembers the channel its note came in on.
  3. Bend, pressure and CC74 on a member channel go only to voices tagged with that channel. The card sums them into pitch, amplitude or filter every tick, independent of gate state.
  4. The timbre-wide messages for that timbre are dropped, since per-voice and timbre-wide writes land in the same slot and the last writer wins.

All three axes have been verified on one instrument, on one firmware version. The other two timbres behave as shipped.

Known boundary: the built-in XY pad still acts on the MPE timbre as a global brightness control, because it arrives by the local path in section 3 and never crosses the point where the timbre-wide messages are dropped.