A flexible, low-latency USB MIDI Control Change router running on a Teensy 4.1. Route, remap, and fan-out incoming CC messages between 4 virtual USB MIDI cables using a simple JSON config file on an SD card — no recompiling required.
If you use the MRCC (MIDI Router Control Center) by Conducive Labs in a dawless setup, you know its limitation: it only provides 6 mod slots for CC remapping. When working with multiple controllers and multi-timbral synthesizers this quickly becomes a hard ceiling — especially if you want to map faders or knobs to specific parameters across many tracks.
The typical workaround is a laptop running a DAW or MIDI utility software. This Teensy-based router eliminates that need entirely. It plugs into the MRCC as a standard USB MIDI device and handles unlimited CC remapping internally, with switchable pages of routes so a single 8-fader controller can address 16, 32, or more synthesizer tracks — no computer, no DAW, no compromise.
MIDI Controllers
│
▼
[ MRCC ] ←─── USB ───→ [ Teensy MIDI Router ] (this project)
│ ↑
│ remaps CCs, switches pages
▼
Synthesizers
The MRCC routes raw controller output into the Teensy over USB. The Teensy remaps CC numbers and channels according to the JSON config, then sends the transformed messages back to the MRCC, which forwards them to the target synthesizers. The Teensy is fully self-contained: config lives on its SD card, page switching is done via CC messages from the controller, and the built-in LED confirms the active page with blinks.
This project was inspired by the RouteCC Mozaic script by wim — a Mozaic (iOS AUv3 MIDI scripting) patch that implements the same concept of unlimited CC remapping with switchable pages. RouteCC proves the idea works beautifully; this project brings the same capability to a standalone hardware device for fully dawless use.
- CC remapping — route any CC number on any channel to a different CC number and/or channel on a different virtual cable
- 4 virtual USB MIDI cables — the Teensy appears as a single USB MIDI device with 4 independent ports
- Wildcard matching — use
"*"to match any channel or any CC number, and to forward source values unchanged to the destination - Fan-out routing — a single incoming message can trigger multiple output routes simultaneously
- Multiple routing tables per cable — define up to 8 independent route sets per cable and switch between them live using CC messages from your controller
- Table switching via CC — dedicate two CC numbers to page up/down through tables; these CCs are consumed by the router and never forwarded to synthesizers
- LED table indication — the built-in LED blinks N times when you switch to a table (1 blink = table 1, 2 blinks = table 2, …)
- Per-cable pass-through control — configure whether unrouted messages are forwarded or silently dropped, per cable
- SD card hot-reload — edit the config on your computer, eject and reinsert the card; the router reloads automatically within 1 second
- LED error feedback — visual indication of config problems without needing a serial monitor
- Non-blocking main loop — MIDI processing, SD polling, and LED updates never block each other
- Teensy 4.1 — built-in SDIO SD card slot is used directly; no wiring required
- SD card — any standard microSD card for the config file
- USB cable — connect Teensy to the host computer as a USB MIDI device
- Install Teensyduino for Arduino IDE
- Set Tools → Board → Teensy 4.1
- Set Tools → USB Type → MIDI x4
- Set Tools → CPU Speed → 600 MHz (default)
Install via Sketch → Include Library → Manage Libraries:
- ArduinoJson v7 by Benoit Blanchon
Format the SD card as FAT32 and place config.json in the root directory.
The router loads the config on boot. If no card is present or the file is missing, the LED indicates the error state and the router waits. Insert a valid card and it reloads automatically.
The config file is /config.json on the SD card root.
{
"cable_0": { ... },
"cable_1": { ... },
"cable_2": { ... },
"cable_3": { ... }
}Cable sections are optional. Missing cables default to pass_unmatched: true with no routes (everything passes through unchanged).
| Field | Type | Default | Description |
|---|---|---|---|
pass_unmatched |
boolean | true |
If true, CC messages that match no route are forwarded unchanged on the same cable. If false, they are silently dropped. Also controls non-CC message forwarding (Note On/Off, Pitch Bend, etc.). |
switch_ch |
integer 1–16 | — | MIDI channel the table-switch CCs must arrive on. Required if using table switching. |
switch_prev_cc |
integer 0–127 | — | CC number that selects the previous table (triggers on value > 0). |
switch_next_cc |
integer 0–127 | — | CC number that selects the next table (triggers on value > 0). |
table_0, table_1, … |
array | — | Named routing tables (up to 8 per cable). Only one is active at a time. |
Note:
switch_ch,switch_prev_cc, andswitch_next_ccmust all be specified together or not at all. Partial specification is a validation error.
Each table is a JSON array of routes. Each route is a 5-element array:
[src_ch, src_cc, dst_cable, dst_ch, dst_cc]
| Position | Field | Valid values | Wildcard "*" meaning |
|---|---|---|---|
| 0 | src_ch |
1–16 or "*" |
Match any incoming MIDI channel |
| 1 | src_cc |
0–127 or "*" |
Match any CC number |
| 2 | dst_cable |
0–3 | No wildcard — destination cable must be explicit |
| 3 | dst_ch |
1–16 or "*" |
Use source channel unchanged |
| 4 | dst_cc |
0–127 or "*" |
Use source CC number unchanged |
- Fan-out: every route that matches an incoming message fires. A single CC can produce output on multiple cables/channels simultaneously.
- Unmatched messages: if no route matches, the
pass_unmatchedsetting decides whether the original message is forwarded or dropped. - Table switching:
switch_prev_ccandswitch_next_ccwrap around (last table → next goes to table 0, and vice versa). Switch CCs are always consumed — they are never routed or passed through to synthesizers. CC value 0 (button release) is also consumed but does not change the active table. - Active table resets to
table_0whenever the config is reloaded. - Tables must be numbered contiguously starting from
table_0. A gap (e.g.,table_0andtable_2withouttable_1) will causetable_2and beyond to be ignored.
JSON does not support comments. Use "_comment" keys anywhere in the object — the router ignores all keys it does not recognise.
{
"_comment": [
"Route syntax: [src_ch, src_cc, dst_cable, dst_ch, dst_cc]",
"Use \"*\" for wildcards. Switch CCs are consumed and never forwarded."
],
"cable_0": {
"_comment": "SMC Mixer -> Digitone II — Mod & Breath, two track banks",
"pass_unmatched": false,
"switch_ch": 1,
"switch_prev_cc": 104,
"switch_next_cc": 105,
"_table_0": "Tracks 1-8 : modwheel (CC1) and breath (CC2)",
"table_0": [
[1, 9, 0, 1, 1],
[2, 19, 0, 2, 1],
[3, 25, 0, 3, 1],
[4, 31, 0, 4, 1],
[5, 49, 0, 5, 1],
[6, 55, 0, 6, 1],
[7, 61, 0, 7, 1],
[8, 82, 0, 8, 1],
[1, 3, 0, 1, 2],
[2, 18, 0, 2, 2],
[3, 24, 0, 3, 2],
[4, 30, 0, 4, 2],
[5, 48, 0, 5, 2],
[6, 54, 0, 6, 2],
[7, 60, 0, 7, 2],
[8, 81, 0, 8, 2]
],
"_table_1": "Tracks 9-16 : modwheel (CC1) and breath (CC2)",
"table_1": [
[1, 9, 0, 9, 1],
[2, 19, 0, 10, 1],
[3, 25, 0, 11, 1],
[4, 31, 0, 12, 1],
[5, 49, 0, 13, 1],
[6, 55, 0, 14, 1],
[7, 61, 0, 15, 1],
[8, 82, 0, 16, 1],
[1, 3, 0, 9, 2],
[2, 18, 0, 10, 2],
[3, 24, 0, 11, 2],
[4, 30, 0, 12, 2],
[5, 48, 0, 13, 2],
[6, 54, 0, 14, 2],
[7, 60, 0, 15, 2],
[8, 81, 0, 16, 2]
]
},
"cable_1": {
"_comment": "Mirror everything from cable 1 back to cable 0",
"pass_unmatched": false,
"table_0": [
["*", "*", 0, "*", "*"]
]
},
"cable_2": {
"_comment": "Pass everything through unchanged (default behavior)",
"pass_unmatched": true
}
}Cable 0 maps an 8-fader mixer to a 16-track synthesizer. CC messages arrive from the mixer on channels 1–8. Two tables map those faders to tracks 1–8 and 9–16 respectively. Pressing the button assigned to CC 105 on channel 1 switches to the next bank; CC 104 goes back. The LED blinks once for bank 1, twice for bank 2.
Cable 1 mirrors all incoming CC messages to cable 0, keeping the original channel and CC number.
Cable 2 passes all messages through without any routing rules.
| LED behaviour | Meaning |
|---|---|
| Off | Normal operation — config loaded successfully |
| Slow blink (500 ms) | No SD card detected, or config.json not found |
| Fast blink (100 ms) | Config parse error or validation error — check the JSON |
| N short blinks (150 ms on / 100 ms gap) | Table switched — blink count = active table number (1-based) |
The table-switch blinks override the off state briefly, then the LED returns to off. Error blinks (slow/fast) are only set during config load and are not interrupted by table switching.
| Parameter | Limit |
|---|---|
| Virtual USB MIDI cables | 4 |
| Routing tables per cable | 8 |
| Routes per table | 64 |
| Total routes | 2048 (4 cables × 8 tables × 64 routes) |
All route data fits in approximately 10 KB of RAM. The Teensy 4.1 has 1024 KB available.
midi_router/
midi_router.ino — Main sketch: setup(), loop(), SD hot-reload
config.h — Data structures and constants
config.cpp — JSON parsing and validation (ArduinoJson v7)
router.h — Function declarations for CC handling
router.cpp — Route matching, fan-out dispatch, table switching
led.h — LED function declarations
led.cpp — Non-blocking LED state machine
config.json — Example config (copy to SD card root)
MIT