Windows MIDI Patchbay (“Patchbay”) is the Windows MIDI Services app that connects MIDI devices to each other. You put devices on a canvas and draw connections from one device’s Out to another device’s In. Each connection can let only some messages through, and can change them on the way, such as moving them to another channel or transposing them. A design is called a patch, and each patch is one .midipatch file.
This article is written for AI agents and online AI chats that plan patches for people. It’s also for anyone who wants to write or check a patch file by hand. If you’re asking an AI to build a patch for you, give it the link to this article and ask it to read the whole page before it starts.
MIDI Patchbay is a preview app. The patch file described here is version 1. Later versions can add settings.
For AI agents: Read this article from start to finish before you write anything. Follow Building a patch for someone as your process, and use The patch file as your reference. You usually can’t see Patchbay yourself, so the notes marked For agents point out mistakes that load without an error and give the customer the wrong patch.
The rules that matter most:
"activateAtStartup": false. The customer turns routing on in Patchbay after they’ve looked at it."active": true on every filter and every transform, and "valueScale": "percent" on every transform. Without them, they do nothing.On this page:
Groups. Every MIDI 1.0 device uses group 1. A MIDI 2.0 device can have up to sixteen groups, each with sixteen channels. A connection can start from one group or all of them, and end at one group or all of them:
Ask these before you write anything. Put them in one message, in plain words, and offer a sensible answer for each so the customer can just say yes.
| Ask | Why it matters |
|---|---|
| Which devices are involved? Get each one’s name exactly as Windows shows it, for example in MIDI Settings, or in Patchbay’s Add endpoint list. | The file finds devices by name. A name that’s only close doesn’t match. |
| What should play what? For example, the keyboard plays both synths. | Each pair is a connection. |
| Are any of them apps on the same PC, such as a DAW? | Patchbay reaches an app through a loopback. See What Patchbay can’t do. |
| Which channels does each device send and listen on? | Most synths listen on one channel. A channel move fixes a mismatch. |
| Should the keyboard be split? At which note? | A split is a note range on each connection. Ask for the note by name, such as middle C. |
| Should anything be transposed, by how much, and on which side of the split? | |
| How should playing feel? Too hard to play loudly, too easy, or every note the same? | That’s a velocity change. |
| Does a pedal or a wheel work backwards, or not reach its ends? | That’s a controller value change. |
| Should anything be kept out, such as clock, active sensing, or program changes? | That’s a filter. |
| Should the patch start by itself whenever Patchbay starts? | The customer turns that on in Patchbay. Tell them where. |
Write the plan down as a list before you write the file, and check it for loops: a device’s output that finds its way back to its own input, directly or the long way around, floods everything within a second.
| The customer wants | What to write |
|---|---|
| One device plays another | One connection from the first to the second. |
| One device plays two | Two connections from the same source. |
| Two devices play one | Two connections into the same destination. |
| A keyboard split at middle C | Two connections from the keyboard, one with a note range of 0 to 59 and the other 60 to 127. |
| A layer: both synths play every note | Two connections from the keyboard with no note range. |
| Move channel 1 to channel 2 | A transform with "channelMap": [ { "from": 0, "to": 1 } ]. |
| Transpose down an octave | A transform with "transposeSemitones": -12. |
| Only notes get through | A filter with "messageTypes": 20, "channelVoiceStatuses": 768. |
| Keep out clock and active sensing | A filter with "systemMessages": 751. |
| Only channel 10 | A filter with "channels": 512. |
| A sustain pedal that works backwards | A transform with "controlValueShapes": [ { "controller": 64, "invert": true } ]. |
| The mod wheel moves expression instead | A transform with "controlMap": [ { "from": 1, "to": 11 } ]. |
| Harder to play loud | A transform with "velocityCurve": 1. |
| Every note at velocity 100 | A transform with "velocityCurve": 3, "fixedVelocityPercent": 78.74. |
| Program 1 on the keyboard picks program 41 on the synth | A transform with "programMap": [ { "from": 0, "to": 40 } ]. |
The numbers are explained in Filters and Transforms.
0x.keyboard or keys-to-bass.Don’t save the file into Patchbay’s own folder. Save it somewhere else, such as Downloads, and have the customer import it, as Where patch files go explains. Importing is what makes sure a patch from somewhere else doesn’t start routing on its own.
If you can run commands on the customer’s PC, write the file with PowerShell 7:
$path = Join-Path ([Environment]::GetFolderPath('UserProfile')) 'Downloads\Keyboard split.midipatch'
[IO.File]::WriteAllText($path, $json, [Text.UTF8Encoding]::new($false))
Then midipatchbay "<patch file>" imports it, exactly as a double-click does.
If you’re an online chat, give the customer the file as a download named after the patch, ending in .midipatch. If you can only show text, put the whole file in one code block and tell them how to save it: paste it into Notepad, select File > Save as, set Save as type to All files, type a name that ends in .midipatch, and leave Encoding at UTF-8.
Go through Mistakes that are easy to miss for every file. If you can run PowerShell 7, save this as Test-MidiPatch.ps1 and run pwsh -File Test-MidiPatch.ps1 -Path "<patch file>". It reads the file as strictly as Patchbay does, and lists the mistakes that load without an error.
param([Parameter(Mandatory)][string]$Path)
$text = [IO.File]::ReadAllText($Path)
$null = [Text.Json.JsonDocument]::Parse($text) # throws on a comment, a trailing comma, or a hexadecimal number
$patch = $text | ConvertFrom-Json
$problems = [Collections.Generic.List[string]]::new()
function Test-Range($Value, [int]$Lowest, [int]$Highest) { $null -eq $Value -or ($Value -ge $Lowest -and $Value -le $Highest) }
if ($patch.activateAtStartup -ne $false) { $problems.Add('activateAtStartup isn''t false. If this file is put in the patches folder without being imported, it can start routing when Patchbay starts.') }
$ids = @($patch.endpoints | ForEach-Object { $_.id })
foreach ($id in ($ids | Group-Object -CaseSensitive | Where-Object Count -gt 1).Name) { $problems.Add("Two endpoints have the id '$id'. Patchbay keeps only the first.") }
foreach ($e in $patch.endpoints) {
$mode = $e.matchMode ?? 'endpointDeviceId'
if (-not $e.id) { $problems.Add("The endpoint '$($e.displayName)' has no id, so Patchbay leaves it out.") }
if (-not $e.displayName) { $problems.Add("The endpoint '$($e.id)' has no displayName.") }
if ($mode -cnotin @('endpointDeviceId', 'usbVendorAndProduct', 'endpointName')) { $problems.Add("The endpoint '$($e.displayName)' has the matchMode '$mode'.") }
elseif ($mode -ceq 'endpointDeviceId' -and -not $e.match.endpointDeviceId) { $problems.Add("The endpoint '$($e.displayName)' matches by device ID but has none, so the customer has to pick the device. Use endpointName.") }
}
$pairs = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
foreach ($c in $patch.connections) {
$where = "The connection from '$($c.sourceEndpointId)' to '$($c.destinationEndpointId)'"
if ($c.sourceEndpointId -cnotin $ids -or $c.destinationEndpointId -cnotin $ids) { $problems.Add("$where names an endpoint that isn't in endpoints, so Patchbay leaves it out.") }
if (-not (Test-Range $c.sourceGroup -1 15) -or -not (Test-Range $c.destinationGroup -1 15)) { $problems.Add("$where has a group outside -1 to 15. The file counts groups from 0, and -1 is all groups.") }
if (-not $pairs.Add("$($c.sourceEndpointId)|$($c.sourceGroup ?? -1)|$($c.destinationEndpointId)|$($c.destinationGroup ?? -1)")) { $problems.Add("$where repeats another connection between the same points, so Patchbay keeps only the first.") }
if ($c.muted -eq $true) { $problems.Add("$where is muted, so it passes nothing.") }
$from = $c.sourceGroup ?? -1; $to = $c.destinationGroup ?? -1
if ($c.sourceEndpointId -ceq $c.destinationEndpointId -and ($from -eq $to -or $from -eq -1 -or $to -eq -1)) { $problems.Add("$where sends the endpoint's output straight back into its own input.") }
$f = $c.filter
if ($f) {
if ($f.active -ne $true) { $problems.Add("$where has a filter without `"active`": true, so the filter does nothing.") }
foreach ($key in 'messageTypes', 'channels', 'channelVoiceStatuses') { if (-not (Test-Range $f.$key 0 65535)) { $problems.Add("$where has $key outside 0 to 65535.") } }
if (-not (Test-Range $f.systemMessages 0 1023)) { $problems.Add("$where has systemMessages outside 0 to 1023.") }
if ($f.limitNoteRange -and ($null -eq $f.lowestNote -or $null -eq $f.highestNote -or -not (Test-Range $f.lowestNote 0 127) -or -not (Test-Range $f.highestNote 0 127))) { $problems.Add("$where limits notes but needs lowestNote and highestNote from 0 to 127.") }
if ($null -ne $f.lowestNote -and -not $f.limitNoteRange) { $problems.Add("$where has a note range but limitNoteRange isn't true, so every note gets through.") }
}
$t = $c.transform
if ($t) {
if ($t.active -ne $true) { $problems.Add("$where has a transform without `"active`": true, so the transform does nothing.") }
if ($t.valueScale -cnotin @('percent', 'sevenBit')) { $problems.Add("$where has a transform without `"valueScale`": `"percent`", so its velocity percentages are ignored.") }
if (-not (Test-Range $t.transposeSemitones -48 48)) { $problems.Add("$where transposes outside -48 to 48.") }
if (-not (Test-Range $t.velocityCurve 0 3)) { $problems.Add("$where has a velocityCurve outside 0 to 3.") }
foreach ($key in 'fixedVelocityPercent', 'minimumVelocityPercent', 'maximumVelocityPercent') { if (-not (Test-Range $t.$key 0 100)) { $problems.Add("$where has $key outside 0 to 100.") } }
foreach ($map in 'channelMap', 'noteMap', 'controlMap', 'programMap', 'bankMsbMap', 'bankLsbMap') {
$highest = if ($map -ceq 'channelMap') { 15 } else { 127 }
foreach ($entry in $t.$map) { if ($null -eq $entry.from -or $null -eq $entry.to -or -not (Test-Range $entry.from 0 $highest) -or -not (Test-Range $entry.to 0 $highest)) { $problems.Add("$where has a $map entry outside 0 to $highest. The file counts from 0.") } }
}
foreach ($shape in @($t.controlValueShapes) + @($t.aftertouchShape)) {
if ($null -eq $shape) { continue }
if ($shape -ne $t.aftertouchShape -and -not (Test-Range $shape.controller 0 127)) { $problems.Add("$where shapes a controller outside 0 to 127.") }
if (($shape.curve ?? 'linear') -cnotin @('linear', 'slowRise', 'fastRise')) { $problems.Add("$where has the curve '$($shape.curve)'.") }
foreach ($key in 'inputMinimumPercent', 'inputMaximumPercent', 'outputMinimumPercent', 'outputMaximumPercent') { if (-not (Test-Range $shape.$key 0 100)) { $problems.Add("$where has $key outside 0 to 100.") } }
}
}
}
if ($problems.Count -gt 0) { $problems; exit 1 }
'No problems found.'
A file that passes can still route the wrong thing. Only the customer can tell you that.
Show the customer what the patch does before you hand it over, and ask them to confirm it.
flowchart LR
keyboard[Keyboard] -- "notes below middle C" --> bass[Bass synth]
keyboard -- "middle C and up, an octave lower, softer" --> pad[Pad synth]
The best picture is Patchbay itself. An imported patch doesn’t route until the customer turns it on, so they can import it, look at the canvas, and select each connection to see a summary of what it lets through and changes, before anything is connected.
Tell the customer how to get the file into Patchbay, using Where patch files go, and how to bring one in. Then tell them:
Patchbay keeps its patches in Documents › MIDI Patchbay, one .midipatch file per patch.
midipatchbay "<patch file>" does the same.C:\Users\<name>\Documents. On many PCs it has been moved into OneDrive. In File Explorer, select Documents and look for MIDI Patchbay there."activateAtStartup": true, or doesn’t say, it can start routing as soon as Patchbay starts. That’s why you should hand files over to be imported..midipatch.json. Patchbay renames them to .midipatch when it starts.Tell the customer about these before they find out on their own.
This patch splits a keyboard at middle C. Notes below middle C go to the bass synth, with clock and active sensing kept out. Middle C and up go to the pad synth, an octave lower and played softer. Change the three names to the ones Windows shows for the customer’s devices.
{
"fileVersion": 1,
"name": "Keyboard split",
"description": "Bass below middle C, pad from middle C up.",
"activateAtStartup": false,
"endpoints": [
{ "id": "keyboard", "displayName": "Keyboard", "match": { "transportSuppliedEndpointName": "Keyboard" }, "matchMode": "endpointName", "x": 60, "y": 60 },
{ "id": "bass", "displayName": "Bass synth", "match": { "transportSuppliedEndpointName": "Bass synth" }, "matchMode": "endpointName", "x": 600, "y": 60 },
{ "id": "pad", "displayName": "Pad synth", "match": { "transportSuppliedEndpointName": "Pad synth" }, "matchMode": "endpointName", "x": 600, "y": 260 }
],
"connections": [
{
"id": "keyboard-to-bass",
"sourceEndpointId": "keyboard", "sourceGroup": -1,
"destinationEndpointId": "bass", "destinationGroup": -1,
"filter": { "active": true, "systemMessages": 751, "limitNoteRange": true, "lowestNote": 0, "highestNote": 59 }
},
{
"id": "keyboard-to-pad",
"sourceEndpointId": "keyboard", "sourceGroup": -1,
"destinationEndpointId": "pad", "destinationGroup": -1,
"filter": { "active": true, "systemMessages": 751, "limitNoteRange": true, "lowestNote": 60, "highestNote": 127 },
"transform": { "active": true, "valueScale": "percent", "transposeSemitones": -12, "velocityCurve": 1 }
}
]
}
The tables below list every setting. If left out is what Patchbay uses when a file doesn’t have the key. Patchbay doesn’t keep keys it doesn’t know: they’re gone the next time it saves the patch.
| Key | Values | If left out | What it does |
|---|---|---|---|
fileVersion |
1 | The file format version. Write 1. | |
name |
text | the file name | The patch’s name in Patchbay. |
description |
text | empty | One line about what the patch is for. |
activateAtStartup |
true, false |
true |
Starts routing whenever Patchbay starts. Write false. Importing sets it to false anyway, and the customer turns it on in Patchbay. |
created, modified |
numbers | 0 | Kept by the app. Write 0 or leave them out. |
endpoints |
list, up to 64 | none | The devices on the canvas. |
connections |
list, up to 512 | none | The routes. |
_comment is ignored.
| Key | Values | If left out | What it does |
|---|---|---|---|
id |
text | required | Unique in the patch. Connections name endpoints by id. An endpoint with no id is left out. |
displayName |
text | empty | The name on the canvas. Write the device’s name as Windows shows it. |
match |
object | empty | How Patchbay finds the real device. |
matchMode |
endpointDeviceId, usbVendorAndProduct, endpointName |
endpointDeviceId |
Which part of match it uses. Write endpointName. |
x, y |
numbers | 0 | Where the endpoint sits on the canvas. An endpoint is at least 252 pixels wide, with about 48 pixels for its name and 32 for each row of groups. Put sources on the left, at x 60, and destinations on the right, at x 600, about 200 pixels apart from top to bottom. |
showAllGroups |
true, false |
false |
Shows all sixteen groups on a device that doesn’t say which it uses. |
transportCode |
text | empty | Kept by the app. Leave it out. |
Matching a device by name. You can’t know a device’s ID, so match by name:
{ "id": "keyboard", "displayName": "KeyLab 61 MkII", "match": { "transportSuppliedEndpointName": "KeyLab 61 MkII" }, "matchMode": "endpointName", "x": 60, "y": 60 }
Patchbay compares the name in match with each device’s own name and with the name Windows shows for it, which the customer may have changed in MIDI Settings. Capital letters don’t matter, but everything else must be the same. If match has no name, it compares displayName instead. Two identical devices have the same name, so name matching can’t tell them apart. When Patchbay saves a device the customer added, match also holds the device’s ID and USB details.
| Key | Values | If left out | What it does |
|---|---|---|---|
id |
text | made up when the file loads | Unique in the patch. |
sourceEndpointId |
an endpoint id |
required | Where messages come from: that endpoint’s Out. |
sourceGroup |
-1 to 15 | -1 | The group they come from, counted from 0. -1 is all groups. |
destinationEndpointId |
an endpoint id |
required | Where messages go: that endpoint’s In. |
destinationGroup |
-1 to 15 | -1 | The group they go to, counted from 0. -1 keeps each message’s own group. |
muted |
true, false |
false |
A muted connection passes nothing. Write false. |
filter |
object | lets everything through | See Filters. |
transform |
object | changes nothing | See Transforms. |
A connection that names an endpoint the patch doesn’t have is left out, and so is a second connection between the same two points with the same groups.
A filter lists what’s allowed through. Everything starts allowed, and a message has to pass every part of the filter. Leave out a part to allow everything it covers.
| Key | Values | If left out | What it does |
|---|---|---|---|
active |
true, false |
false |
Turns the filter on. Without it, the filter does nothing. |
messageTypes |
0 to 65535 | 65535 | Which kinds of Universal MIDI Packet get through. |
channels |
0 to 65535 | 65535 | Which channels get through. Applies to channel messages only. |
channelVoiceStatuses |
0 to 65535 | 65535 | Which channel messages get through, such as notes or program changes, for both MIDI 1.0 and MIDI 2.0. |
systemMessages |
0 to 1023 | 1023 | Which system messages get through, such as clock. |
limitNoteRange |
true, false |
false |
Turns the note range on. |
lowestNote, highestNote |
0 to 127 | 0 and 127 | The notes that get through, both ends included. Middle C is 60, which Patchbay shows as C3. Other apps and manuals may call it C4, so go by the number. Applies to note on, note off, poly pressure, and MIDI 2.0 per-note messages. |
The four numbers are sets of switches, one bit each. Add up the values of the ones to allow:
messageTypes |
Value |
|---|---|
| Utility | 1 |
| System, such as clock | 2 |
| MIDI 1.0 channel messages | 4 |
| System exclusive (7-bit) | 8 |
| MIDI 2.0 channel messages | 16 |
| 8-bit data | 32 |
| Flex data | 8192 |
| UMP stream | 32768 |
channels |
Value |
|---|---|
| Channel n, from 1 to 16 | 2 to the power of n − 1: channel 1 is 1, channel 2 is 2, channel 3 is 4, and channel 10 is 512 |
channelVoiceStatuses |
Value |
|---|---|
| MIDI 2.0 registered per-note controller | 1 |
| MIDI 2.0 assignable per-note controller | 2 |
| MIDI 2.0 registered controller (RPN) | 4 |
| MIDI 2.0 assignable controller (NRPN) | 8 |
| MIDI 2.0 relative registered controller | 16 |
| MIDI 2.0 relative assignable controller | 32 |
| MIDI 2.0 per-note pitch bend | 64 |
| Note off | 256 |
| Note on | 512 |
| Poly pressure | 1024 |
| Control change | 2048 |
| Program change | 4096 |
| Channel pressure | 8192 |
| Pitch bend | 16384 |
| MIDI 2.0 per-note management | 32768 |
systemMessages |
Value |
|---|---|
| Time code | 1 |
| Song position | 2 |
| Song select | 4 |
| Tune request | 8 |
| Timing clock | 16 |
| Start | 32 |
| Continue | 64 |
| Stop | 128 |
| Active sensing | 256 |
| Reset | 512 |
For example:
"messageTypes": 20, "channelVoiceStatuses": 768. That’s MIDI 1.0 and MIDI 2.0 channel messages (4 + 16), and note off and note on (256 + 512)."systemMessages": 751. That’s 1023 − 16 − 256."channels": 513. That’s 1 + 512.For agents: A MIDI 1.0 device sends RPNs and NRPNs as control changes 101, 100, 99, 98, 6, and 38, so the RPN and NRPN switches only apply to MIDI 2.0 devices. Keeping control changes out keeps a MIDI 1.0 device’s RPNs and NRPNs out too.
A transform changes what gets through, on the way to its one destination.
| Key | Values | If left out | What it does |
|---|---|---|---|
active |
true, false |
false |
Turns the transform on. Without it, the transform does nothing. |
valueScale |
percent, sevenBit |
Write percent. Without it, the velocity percentages below are ignored. It only changes how Patchbay shows numbers; the file always holds percentages. |
|
channelMap |
list of { "from": 0, "to": 1 } |
none | Moves a channel to another, counted from 0. Done first, before anything else. |
transposeSemitones |
-48 to 48 | 0 | Moves every note up or down, including poly pressure and MIDI 2.0 per-note messages. A note pushed past either end stops at the end. |
noteMap |
list of { "from": 36, "to": 38 } |
none | Sends one note as another, 0 to 127. A note listed here isn’t transposed. |
ignoreExactPitchNotes |
true, false |
false |
Leaves alone MIDI 2.0 notes that carry an exact pitch, for when the note number means a drum pad rather than a pitch. |
velocityCurve |
0 to 3 | 0 | Note on velocity. 0 leaves it alone. 1 is Linear to curved, which makes it harder to play loud. 2 is Curved to linear, which makes it easier. 3 sends every note at fixedVelocityPercent. |
fixedVelocityPercent |
0 to 100 | 78.74 | The velocity for velocityCurve 3. 78.74 is 100 out of 127. |
rescaleVelocity |
true, false |
false |
Fits every velocity into a range. |
minimumVelocityPercent, maximumVelocityPercent |
0 to 100 | 0 and 100 | That range. |
controlMap |
list of { "from": 1, "to": 11 } |
none | Sends one controller as another, 0 to 127. |
controlValueShapes |
list | none | Changes controller values. See below. |
aftertouchShape |
object | none | Changes channel and poly pressure the same way, without invert. A pressure of 0 always stays 0. |
programMap |
list of { "from": 0, "to": 40 } |
none | Picks a different program, 0 to 127, the number sent on the wire. That’s one less than most manuals print. |
bankMsbMap, bankLsbMap |
lists of { "from": 0, "to": 1 } |
none | Picks a different bank, as controller 0 and controller 32, or the bank in a MIDI 2.0 program change. |
The minimumVelocity and maximumVelocity keys Patchbay writes are for older versions. Leave them out.
Each entry in controlValueShapes changes one controller’s value:
| Key | Values | If left out | What it does |
|---|---|---|---|
controller |
0 to 127 | required | The controller, after controlMap has moved it. |
invert |
true, false |
false |
Turns the value upside down, for a pedal that works backwards. Done before the curve. |
curve |
linear, slowRise, fastRise |
linear |
slowRise gives finer control near the bottom, and fastRise near the top. |
inputMinimumPercent, inputMaximumPercent |
0 to 100 | 0 and 100 | The part of the incoming range that counts. A pedal that only reaches 10 to 117 out of 127 is 7.87 to 92.13. |
outputMinimumPercent, outputMaximumPercent |
0 to 100 | 0 and 100 | Where that lands. An output maximum of 50 makes the top of the wheel send half. |
For example, a sustain pedal that works backwards, and an expression pedal that never reaches its ends:
"transform": {
"active": true,
"valueScale": "percent",
"controlValueShapes": [
{ "controller": 64, "invert": true },
{ "controller": 11, "inputMinimumPercent": 7.87, "inputMaximumPercent": 92.13 }
]
}
Patchbay reads a patch up to 4 megabytes, with up to 64 endpoints and 512 connections, and up to 128 entries in each list in a transform. It shows up to 256 patches. Text is cut off at 1,024 characters. Anything past a limit is dropped.
For agents: Each of these loads without an error and gives the customer the wrong patch.
- A filter or a transform has no
"active": true, so it does nothing.- A transform has no
"valueScale": "percent", so its velocity percentages are ignored.- A channel or a group is counted from 1. Channel 2 is
1in the file, and group 1 is0.- A program is counted from 1. Program 1 is
0in the file.- A note range is set without
"limitNoteRange": true, so every note gets through.- A split leaves a gap or an overlap: 0 to 59 on one side and 60 to 127 on the other has neither.
- A device in
matchis a name that’s only close to the real one, so it shows as missing.activateAtStartupis left out and the file is put straight into the patches folder, so it can start routing as soon as Patchbay starts.- A connection sends a device’s output back to its own input, directly or through other devices, which floods them.
- A filter keeps out control changes, which also keeps out a MIDI 1.0 device’s sustain pedal, mod wheel, RPNs, and NRPNs.
- A connection is supposed to reach an app, but the patch names a loopback that doesn’t exist yet.
- The patch depends on something in What Patchbay can’t do.