Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,4 @@ jobs:
- run: swift build
- run: swift test
- run: swift build --product meters
- run: swift build --product MetersApp
6 changes: 4 additions & 2 deletions CREDITS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ device behavior. This includes:
- The TCP handshake (the protocol-GUID negotiation and session preamble).
- MIDI3 stream framing.
- The realtime status / meter block field identities.
- The existence and shape of the native CBOR control channel (documented in
[docs/06](docs/06-cbor-channel.md), not implemented).
- The native CBOR control channel: its wire shape, the state-dump trigger, and
the current bank/rig addresses it returns (documented in
[docs/06](docs/06-cbor-channel.md); `libkp` implements the codec and the
state-dump snapshot, not the wider management grammar).

These findings are labeled "observed experimentation" throughout the docs.
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ languages that stay in lockstep:

Each implementation ships the same example — **`meters`**, a live terminal view
of the current rig, its effect blocks, the tuner strobe, and the output meters.
The Swift implementation additionally ships **`MetersApp`**, the same dashboard
as a native SwiftUI macOS app.

## Documentation

Expand All @@ -26,7 +28,7 @@ The protocol is documented in [`docs/`](docs/):
3. [Handshake](docs/03-handshake.md) — the TCP GUID negotiation
4. [MIDI3 framing](docs/04-midi3-framing.md)
5. [SysEx / NRPN dialect](docs/05-sysex-nrpn.md)
6. [The CBOR channel](docs/06-cbor-channel.md) — experimental
6. [The CBOR channel](docs/06-cbor-channel.md) — the current bank/rig snapshot
7. [Realtime status & meters](docs/07-realtime-status.md)
8. [Control model](docs/08-control-model.md) — CC vs NRPN vs the device model
9. [Parameter registry](docs/09-parameter-registry.md)
Expand Down Expand Up @@ -57,8 +59,10 @@ See [docs/10](docs/10-versioning-and-compatibility.md) for the full mechanism.

`libkp` implements the fully-validated MIDI3 surface: discovery, session,
framing, NRPN read/write, CC control, the realtime meter/tuner decode, a typed
parameter registry, and an observable device model. The device's native CBOR
control channel is documented (docs/06) but not implemented.
parameter registry, and an observable device model. It also speaks the device's
native CBOR channel for one purpose — the state-dump snapshot that reads the
current bank and rig (docs/06); the channel's wider management grammar is not
implemented.

## Attribution

Expand All @@ -67,7 +71,9 @@ the control-change map — follows the official
[Kemper MIDI Parameter Documentation](https://www.kemper-amps.com/downloads/5/User-Manuals),
cross-checked against the excellent [PySwitch](https://github.com/Tunetown/PySwitch)
project, whose Kemper client is credited for confirming addresses and the
bidirectional beacon. The transport envelope — discovery, the handshake, MIDI3
bidirectional beacon. The effect-type **category** blocks are inferred from the
value-range structure of that documentation's Appendix B rather than transcribed
from a printed table. The transport envelope — discovery, the handshake, MIDI3
framing, session encapsulation, and the CBOR channel — was characterized through
observed experimentation. See [CREDITS.md](CREDITS.md).

Expand Down
6 changes: 4 additions & 2 deletions codegen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,13 @@ Swift). Highlights:
`METER_BLOCK_NUMBER`, `TEMPO_BPM_SCALE`, `MORPH_NUMBER`, `TUNER_*`, …).
- **Tables**: `FUNCTION_NAMES`, `PAGE_NAMES`, `EFFECT_SLOTS`, `EFFECT_PARAMS`,
`NON_EFFECT_PARAMS`, `STRING_TAGS`, `PAGE0_NUMERIC`, `EFFECT_TYPES`,
`SLOT_ENABLE_CC`, `METER_FIELDS`, and the `CC_*` control constants.
`EFFECT_CATEGORIES`, `SLOT_ENABLE_CC`, `METER_FIELDS`, and the `CC_*` control
constants.

Tables are emitted as the most natural literal per language: Rust `static`
slices of tuples, Python `dict`/`list`, Swift `Dictionary`/`Array` (with small
`NonEffectKey` / `MeterField` helper structs). Implementations wrap these with
`NonEffectKey` / `MeterField` / `EffectCategory` helper structs).
Implementations wrap these with
thin lookup helpers (`param_name`, `effect_type_name`, …); the `params.json`
vectors verify those helpers agree across languages.

Expand Down
64 changes: 58 additions & 6 deletions codegen/gen_vectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,17 +356,17 @@ def poll(mac):
("tuner_mode", {"on": True}, 0, control_change(0, 31, 1)),
("tuner_mode", {"on": False}, 0, control_change(0, 31, 0)),
("toggle_all_modules", {}, 0, control_change(0, 16, 1)),
("up", {}, 0, control_change(0, 48, 1)),
("down", {}, 0, control_change(0, 49, 1)),
("up", {}, 0, control_change(0, 48, 1) + control_change(0, 48, 0)),
("down", {}, 0, control_change(0, 49, 1) + control_change(0, 49, 0)),
("bank_preselect", {"value": 3}, 0, control_change(0, 47, 3)),
("rotary_fast", {"on": True}, 0, control_change(0, 33, 1)),
("delay_infinity", {"on": True}, 0, control_change(0, 34, 1)),
("freeze", {"on": True}, 0, control_change(0, 35, 1)),
("morph_button", {"on": True}, 0, control_change(0, 80, 1)),
("morph_button", {"on": False}, 0, control_change(0, 80, 0)),
("load_slot", {"n": 3}, 0, control_change(0, 52, 1)),
("load_slot", {"n": 0}, 0, control_change(0, 50, 1)),
("load_slot", {"n": 99}, 0, control_change(0, 54, 1)),
("load_slot", {"n": 3}, 0, control_change(0, 52, 1) + control_change(0, 52, 0)),
("load_slot", {"n": 0}, 0, control_change(0, 50, 1) + control_change(0, 50, 0)),
("load_slot", {"n": 99}, 0, control_change(0, 54, 1) + control_change(0, 54, 0)),
("effect_button", {"n": 4}, 0, control_change(0, 78, 1)),
("effect_button", {"n": 0}, 0, control_change(0, 75, 1)),
("slot_enable", {"slot": "REV", "on": True}, 0, control_change(0, 29, 1)),
Expand All @@ -380,7 +380,8 @@ def poll(mac):
]
w("controls.json", {
"description": "Control op -> raw MIDI bytes. Each impl maps op names to its Control API; "
"values are masked to 7 bits, load_slot clamps to 1..=5, effect_button to 1..=4.",
"values are masked to 7 bits, load_slot clamps to 1..=5, effect_button to 1..=4. "
"Momentary controls (up, down, load_slot) emit a press (value 1) followed by a release (value 0) as one 6-byte message.",
"cases": [{"op": op, "params": pr, "channel": ch, "hex": hx(b)} for op, pr, ch, b in controls],
})

Expand All @@ -389,6 +390,37 @@ def poll(mac):
pa = tomllib.load(fh)
with open(ROOT / "spec" / "effect-types.toml", "rb") as fh:
et = tomllib.load(fh)

def category_of(v: int) -> str | None:
for c in et["effect_categories"]:
if c["min"] <= v <= c["max"]:
return c["name"]
return None

# Ground-truth category expectations, cross-checked against the spec table:
# block interiors, both sides of a block boundary, an unnamed Type value,
# the empty type, and values past the last block.
effect_category_cases = [
{"value": 0, "name": None},
{"value": 1, "name": "Wah"},
{"value": 16, "name": "Wah"},
{"value": 17, "name": "Shaper"},
{"value": 32, "name": "Distortion"},
{"value": 49, "name": "Dynamics"},
{"value": 76, "name": "Modulation"},
{"value": 89, "name": "Phaser & Flanger"},
{"value": 112, "name": "Booster"},
{"value": 121, "name": "Effect Loop"},
{"value": 129, "name": "Pitch"},
{"value": 161, "name": "Delay"},
{"value": 179, "name": "Reverb"},
{"value": 207, "name": "Reverb"},
{"value": 208, "name": None},
{"value": 300, "name": None},
]
for c in effect_category_cases:
assert category_of(c["value"]) == c["name"], c

w("params.json", {
"description": "Offline name lookups. null means no mapping.",
"param_name": [
Expand All @@ -410,6 +442,7 @@ def poll(mac):
{"value": 193, "name": "Spring Reverb"},
{"value": 5, "name": None},
],
"effect_category_name": effect_category_cases,
"page_name": [
{"page": 0x7C, "name": "Realtime/Meters"},
{"page": 0x0A, "name": "Amplifier"},
Expand Down Expand Up @@ -477,6 +510,25 @@ def _state_cases():
cases.append({"name": "morph + tuner note + main vol",
"messages": [hx(morph), hx(note), hx(mainv)],
"expect": {"morph": 4096, "tuner_note": 9, "main_volume": 12000}})
# 7. The device's position: Bank Select LSB (high 7 bits) then Program
# Change (low 7), a flat 0-based rig index that divides by BANK_SLOTS.
# Index 123 = bank 24, slot 3 — the last rig of a 25-bank device.
cases.append({"name": "rig index, single byte",
"messages": [hx(bytes([0xB0, 32, 0])), hx(bytes([0xC0, 123]))],
"expect": {"current_bank": 24, "current_rig_slot": 3,
"current_rig_index": 123}})
# 8. An index past 127 needs the Bank Select byte: 128 * 1 + 72 = 200.
cases.append({"name": "rig index, two bytes",
"messages": [hx(bytes([0xB0, 32, 1])), hx(bytes([0xC0, 72]))],
"expect": {"current_bank": 40, "current_rig_slot": 0,
"current_rig_index": 200}})
# 9. A Program Change with no Bank Select ahead of it is half an index, not
# a position: the trailing lone PC must leave the last one standing.
cases.append({"name": "rig index ignores an unpaired program change",
"messages": [hx(bytes([0xB0, 32, 0])), hx(bytes([0xC0, 123])),
hx(bytes([0xC0, 5]))],
"expect": {"current_bank": 24, "current_rig_slot": 3,
"current_rig_index": 123}})
return cases


Expand Down
Loading
Loading