diff --git a/CHANGELOG.md b/CHANGELOG.md index 03304897..6c94944f 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# PySwitch v2.4.9 +- Features: + - Added **EFFECT_STATE_PER_RIG** action: works like EFFECT_STATE but allows assigning a different Kemper effect slot per rig. When the active rig changes, the button automatically controls the slot defined in the rig_overrides mapping, falling back to the default slot_id. Supports None as a slot value to disable the button for a specific rig. + +### Emulator 2.4.9.16 +- Features: + - Added **EFFECT_STATE_PER_RIG** to the web editor: new rig_map parameter type renders a Bank/Rig/Slot table with add/remove rows and full round-trip serialization with inputs.py. + # PySwitch v2.4.8 - Bug fixes: - Reset memory of actions after page change (this guarantees correct LED states). Came up with the @GlanzGuitar example, which contained a workaround for this issue. diff --git a/README.md b/README.md index f6fa8c3e..9c2b01f8 100755 --- a/README.md +++ b/README.md @@ -123,6 +123,38 @@ Inputs = [ ] ``` +##### Per-Rig Effect Slot Override (EFFECT_STATE_PER_RIG) + +By default, `EFFECT_STATE` always controls the same slot regardless of which rig is active. This means all rigs must place the same effect type in the same slot — for example, the compressor must always be in Slot A. `EFFECT_STATE_PER_RIG` solves this by routing each button to a different slot depending on the active rig: + +```python +from pyswitch.clients.kemper.actions.effect_state_per_rig import EFFECT_STATE_PER_RIG +from pyswitch.clients.kemper import KemperEffectSlot + +# Absolute rig ID = (bank - 1) * 5 + (rig - 1) +# Bank 1 Rig 1 = 0, Bank 1 Rig 3 = 2, Bank 2 Rig 1 = 5 + +Inputs = [ + { + "assignment": Hardware.PA_MIDICAPTAIN_10_SWITCH_1, + "actions": [ + EFFECT_STATE_PER_RIG( + slot_id = KemperEffectSlot.EFFECT_SLOT_ID_A, # default slot + rig_overrides = { + 2: KemperEffectSlot.EFFECT_SLOT_ID_C, # Bank 1 Rig 3 → Slot C + 5: KemperEffectSlot.EFFECT_SLOT_ID_DLY, # Bank 2 Rig 1 → Slot DLY + }, + display = DISPLAY_HEADER_1 + ) + ] + } +] +``` + +When the rig changes, the button automatically controls the slot defined for that rig (or falls back to `slot_id` if no override is defined). The LED color and display label update in real time to reflect the new slot's effect type. Setting a slot to `None` in `rig_overrides` disables the button for that rig (LED off, label cleared). + +`EFFECT_STATE_PER_RIG` accepts all parameters of `EFFECT_STATE` plus the mandatory `rig_overrides` dict. The web editor exposes a Bank/Rig/Slot table for visual configuration. For full details see [docs/effect_state_per_rig.html](docs/effect_state_per_rig.html). + ##### Actions on Long Press (Hold) You can assign different actions to long pressing of switches. This is done by providing the "holdActions" parameter of the switch definitions: diff --git a/content/display.py b/content/display.py index 3d0e55e0..e8faf16d 100644 --- a/content/display.py +++ b/content/display.py @@ -11,82 +11,52 @@ "font": "/fonts/H20.pcf", "backColor": DEFAULT_LABEL_COLOR, "stroke": 1, - } -_DISPLAY_WIDTH = const( - 240 -) -_DISPLAY_HEIGHT = const( - 240 -) -_SLOT_WIDTH = const( - 120 -) -_SLOT_HEIGHT = const( - 40 -) -_FOOTER_Y = const( - 200 -) -_RIG_NAME_HEIGHT = const( - 160 -) +_DISPLAY_WIDTH = const(240) +_DISPLAY_HEIGHT = const(240) +_SLOT_WIDTH = const(120) +_SLOT_HEIGHT = const(40) +_FOOTER_Y = const(200) +_RIG_NAME_Y = const(40) +_RIG_NAME_H = const(160) -DISPLAY_HEADER_1 = DisplayLabel( - layout = _ACTION_LABEL_LAYOUT, +# Switch 3 (bottom-left of the screen) +DISPLAY_SWITCH_3 = DisplayLabel( + layout = _ACTION_LABEL_LAYOUT, bounds = DisplayBounds( - x = 0, - y = 0, - w = _SLOT_WIDTH, - h = _SLOT_HEIGHT - ) -) -DISPLAY_HEADER_2 = DisplayLabel( - layout = _ACTION_LABEL_LAYOUT, - bounds = DisplayBounds( - x = _SLOT_WIDTH, - y = 0, - w = _SLOT_WIDTH, + x = 0, + y = _FOOTER_Y, + w = _SLOT_WIDTH, h = _SLOT_HEIGHT ) ) - -DISPLAY_FOOTER_1 = DisplayLabel( - layout = _ACTION_LABEL_LAYOUT, - bounds = DisplayBounds( - x = 0, - y = _FOOTER_Y, - w = _SLOT_WIDTH, - h = _SLOT_HEIGHT - ) -) -DISPLAY_FOOTER_2 = DisplayLabel( - layout = _ACTION_LABEL_LAYOUT, +# Switch 4 (bottom-right of the screen) +DISPLAY_SWITCH_4 = DisplayLabel( + layout = _ACTION_LABEL_LAYOUT, bounds = DisplayBounds( - x = _SLOT_WIDTH, - y = _FOOTER_Y, - w = _SLOT_WIDTH, + x = _SLOT_WIDTH, + y = _FOOTER_Y, + w = _SLOT_WIDTH, h = _SLOT_HEIGHT ) ) DISPLAY_RIG_NAME = DisplayLabel( bounds = DisplayBounds( - x = 0, - y = _SLOT_HEIGHT, - w = _DISPLAY_WIDTH, - h = _RIG_NAME_HEIGHT - ), + x = 0, + y = _RIG_NAME_Y, + w = _DISPLAY_WIDTH, + h = _RIG_NAME_H + ), layout = { "font": "/fonts/PTSans-NarrowBold-40.pcf", "lineSpacing": 0.8, "maxTextWidth": 220, "text": KemperRigNameCallback.DEFAULT_TEXT, - - }, + }, callback = KemperRigNameCallback( show_rig_id = True ) @@ -96,26 +66,23 @@ Splashes = TunerDisplayCallback( splash_default = DisplayElement( bounds = DisplayBounds( - x = 0, - y = 0, - w = _DISPLAY_WIDTH, + x = 0, + y = 0, + w = _DISPLAY_WIDTH, h = _DISPLAY_HEIGHT - ), + ), children = [ - DISPLAY_HEADER_1, - DISPLAY_HEADER_2, - DISPLAY_FOOTER_1, - DISPLAY_FOOTER_2, + DISPLAY_SWITCH_3, + DISPLAY_SWITCH_4, DISPLAY_RIG_NAME, BidirectionalProtocolState( DisplayBounds( - x = 0, - y = _SLOT_HEIGHT, - w = _DISPLAY_WIDTH, - h = _RIG_NAME_HEIGHT + x = 0, + y = _RIG_NAME_Y, + w = _DISPLAY_WIDTH, + h = _RIG_NAME_H ) ), - ] ) ) diff --git a/content/inputs.py b/content/inputs.py index 0166d70b..a7c17284 100644 --- a/content/inputs.py +++ b/content/inputs.py @@ -1,189 +1,140 @@ -from pyswitch.clients.kemper.actions.amp import AMP_GAIN -from pyswitch.clients.kemper.actions.tempo import TAP_TEMPO -from pyswitch.clients.kemper.actions.tempo import SHOW_TEMPO -from pyswitch.clients.kemper.actions.effect_state import EFFECT_STATE -from pyswitch.clients.kemper.actions.bank_up_down import BANK_UP -from pyswitch.clients.kemper.actions.bank_up_down import BANK_DOWN -from pyswitch.clients.kemper.actions.rig_select import RIG_SELECT -from pyswitch.clients.kemper.actions.tuner import TUNER_MODE -from pyswitch.clients.local.actions.encoder_button import ENCODER_BUTTON -from pyswitch.clients.kemper.actions.rig_select import RIG_SELECT_DISPLAY_TARGET_RIG -from pyswitch.clients.kemper import KemperEffectSlot -from display import DISPLAY_HEADER_1 -from display import DISPLAY_HEADER_2 -from display import DISPLAY_FOOTER_1 -from display import DISPLAY_FOOTER_2 -from display import DISPLAY_RIG_NAME from pyswitch.hardware.devices.pa_midicaptain_10 import * +from pyswitch.clients.kemper import KemperEffectSlot +from pyswitch.clients.kemper.actions.effect_state_per_rig import EFFECT_STATE_PER_RIG +from pyswitch.clients.kemper.actions.rig_select import RIG_SELECT, RIG_SELECT_DISPLAY_TARGET_RIG +from pyswitch.clients.kemper.actions.bank_up_down import BANK_UP, BANK_DOWN +from display import DISPLAY_SWITCH_3, DISPLAY_SWITCH_4 -_accept = ENCODER_BUTTON() - -_cancel = ENCODER_BUTTON() +# Absolute rig IDs: (bank - 1) * 5 + (rig - 1) +# Bank 1: 0=acou 1=clen 2=crnc 3=heavy 4=lead Inputs = [ - { - "assignment": PA_MIDICAPTAIN_10_WHEEL_ENCODER, - "actions": [ - AMP_GAIN( - accept_action = _accept, - cancel_action = _cancel, - preview_display = DISPLAY_RIG_NAME, - step_width = 40 - ), - - ], - - }, + + # Switch 1 — always disabled { "assignment": PA_MIDICAPTAIN_10_SWITCH_1, - "actions": [ - EFFECT_STATE( - slot_id = KemperEffectSlot.EFFECT_SLOT_ID_A, - display = DISPLAY_HEADER_1 - ), - - ], - + "actions": [] }, + + # Switch 2 — always disabled { "assignment": PA_MIDICAPTAIN_10_SWITCH_2, - "actions": [ - EFFECT_STATE( - slot_id = KemperEffectSlot.EFFECT_SLOT_ID_B, - display = DISPLAY_HEADER_2 - ), - - ], - + "actions": [] }, + + # Switch 3 — flanger (X slot) for rigs clen(1), crnc(2), lead(4) { "assignment": PA_MIDICAPTAIN_10_SWITCH_3, "actions": [ - EFFECT_STATE( - slot_id = KemperEffectSlot.EFFECT_SLOT_ID_C, - display = DISPLAY_FOOTER_1 - ), - - ], - + EFFECT_STATE_PER_RIG( + slot_id = None, + rig_overrides = { + 1: KemperEffectSlot.EFFECT_SLOT_ID_X, # clen + 2: KemperEffectSlot.EFFECT_SLOT_ID_X, # crnc + 4: KemperEffectSlot.EFFECT_SLOT_ID_X, # lead + }, + display = DISPLAY_SWITCH_3 + ) + ] }, + + # Switch 4 — [MOD+C] for acou(0); X for heavy(3) { "assignment": PA_MIDICAPTAIN_10_SWITCH_4, "actions": [ - EFFECT_STATE( - slot_id = KemperEffectSlot.EFFECT_SLOT_ID_D, - display = DISPLAY_FOOTER_2 - ), - - ], - + EFFECT_STATE_PER_RIG( + slot_id = None, + rig_overrides = { + 0: [KemperEffectSlot.EFFECT_SLOT_ID_MOD, KemperEffectSlot.EFFECT_SLOT_ID_C], # acou + 3: KemperEffectSlot.EFFECT_SLOT_ID_X, # heavy + }, + display = DISPLAY_SWITCH_4 + ) + ] }, + + # Switch 5 (UP) — DLY for acou(0); [DLY+REV] for clen(1), crnc(2), heavy(3) { "assignment": PA_MIDICAPTAIN_10_SWITCH_UP, "actions": [ - TAP_TEMPO( - use_leds = False - ), - SHOW_TEMPO( - change_display = DISPLAY_RIG_NAME, - text = '{bpm} bpm' - ), - - ], - "actionsHold": [ - TUNER_MODE( - use_leds = False, - text = 'Tuner' - ), - - ], - + EFFECT_STATE_PER_RIG( + slot_id = None, + rig_overrides = { + 0: KemperEffectSlot.EFFECT_SLOT_ID_DLY, # acou + 1: [KemperEffectSlot.EFFECT_SLOT_ID_DLY, KemperEffectSlot.EFFECT_SLOT_ID_REV], # clen + 2: [KemperEffectSlot.EFFECT_SLOT_ID_DLY, KemperEffectSlot.EFFECT_SLOT_ID_REV], # crnc + 3: [KemperEffectSlot.EFFECT_SLOT_ID_DLY, KemperEffectSlot.EFFECT_SLOT_ID_REV], # heavy + }, + display = None + ) + ] }, + + # Switch A — Rig 1, hold: Bank Down { "assignment": PA_MIDICAPTAIN_10_SWITCH_A, - "actionsHold": [ - BANK_DOWN( - display_mode = RIG_SELECT_DISPLAY_TARGET_RIG, - text = 'Bank dn' - ), - - ], "actions": [ RIG_SELECT( - rig = 1, + rig = 1, display_mode = RIG_SELECT_DISPLAY_TARGET_RIG - ), - + ) ], - + "actionsHold": [ + BANK_DOWN( + display_mode = RIG_SELECT_DISPLAY_TARGET_RIG, + text = "Bank dn" + ) + ] }, + + # Switch B — Rig 2 { "assignment": PA_MIDICAPTAIN_10_SWITCH_B, "actions": [ RIG_SELECT( - rig = 2, + rig = 2, display_mode = RIG_SELECT_DISPLAY_TARGET_RIG - ), - - ], - + ) + ] }, + + # Switch C — Rig 3 { "assignment": PA_MIDICAPTAIN_10_SWITCH_C, "actions": [ RIG_SELECT( - rig = 3, + rig = 3, display_mode = RIG_SELECT_DISPLAY_TARGET_RIG - ), - - ], - + ) + ] }, + + # Switch D — Rig 4 { "assignment": PA_MIDICAPTAIN_10_SWITCH_D, "actions": [ RIG_SELECT( - rig = 4, + rig = 4, display_mode = RIG_SELECT_DISPLAY_TARGET_RIG - ), - - ], - + ) + ] }, + + # Switch E (DOWN) — Rig 5, hold: Bank Up { "assignment": PA_MIDICAPTAIN_10_SWITCH_DOWN, - "actionsHold": [ - BANK_UP( - display_mode = RIG_SELECT_DISPLAY_TARGET_RIG, - text = 'Bank up' - ), - - ], "actions": [ RIG_SELECT( - rig = 5, + rig = 5, display_mode = RIG_SELECT_DISPLAY_TARGET_RIG - ), - - ], - - }, - { - "assignment": PA_MIDICAPTAIN_10_WHEEL_BUTTON, - "actions": [ - _accept, - + ) ], "actionsHold": [ - _cancel, - - ], - - }, - { - "assignment": PA_MIDICAPTAIN_10_EXP_PEDAL_1, - "actions": [], - + BANK_UP( + display_mode = RIG_SELECT_DISPLAY_TARGET_RIG, + text = "Bank up" + ) + ] }, - + ] diff --git a/content/lib/pyswitch/clients/kemper/actions/effect_state.py b/content/lib/pyswitch/clients/kemper/actions/effect_state.py index dc145f87..8052702b 100755 --- a/content/lib/pyswitch/clients/kemper/actions/effect_state.py +++ b/content/lib/pyswitch/clients/kemper/actions/effect_state.py @@ -5,16 +5,16 @@ from ....colors import Colors, DEFAULT_LABEL_COLOR # Switch an effect slot on / off -def EFFECT_STATE(slot_id, - display = None, +def EFFECT_STATE(slot_id, + display = None, mode = PushButtonAction.HOLD_MOMENTARY, show_slot_names = False, id = False, text = None, color = None, - use_leds = True, + use_leds = True, enable_callback = None - ): + ): return PushButtonAction({ "callback": KemperEffectEnableCallback( slot_id = slot_id, @@ -33,67 +33,87 @@ def EFFECT_STATE(slot_id, # Used for effect enable/disable ParameterAction class KemperEffectEnableCallback(EffectEnableCallback): - # Effect types enum (used internally, also for indexing colors, so be sure these are always a row from 0 to n) - CATEGORY_WAH = const(1) - CATEGORY_DISTORTION = const(2) - CATEGORY_COMPRESSOR = const(3) - CATEGORY_NOISE_GATE = const(4) - CATEGORY_SPACE = const(5) - CATEGORY_CHORUS = const(6) + # Effect category enums (used internally, also for indexing colors/names, + # so these MUST always be a consecutive sequence from 0 to n) + # + # Colors match the official Kemper Profiler Main Manual LED color scheme: + # Wah=Orange, Distortion/Booster/Shaper=Red, Compressor/Gate=Cyan, + # Chorus/Vibrato/Rotary/Tremolo/Slicer=Blue, Phaser/Flanger=Purple, + # EQ=Yellow, Pitch=White, Pitch Shifter Delay (Dual)=Light Green, + # Delay/Reverb/Space=Green, Looper=Pink + CATEGORY_WAH = const(1) + CATEGORY_DISTORTION = const(2) # Distortion + Booster + Shaper all = Red + CATEGORY_COMPRESSOR = const(3) + CATEGORY_NOISE_GATE = const(4) + CATEGORY_SPACE = const(5) + CATEGORY_CHORUS = const(6) CATEGORY_PHASER_FLANGER = const(7) - CATEGORY_EQUALIZER = const(8) - CATEGORY_BOOSTER = const(9) - CATEGORY_LOOPER = const(10) - CATEGORY_PITCH = const(11) - CATEGORY_DUAL = const(12) - CATEGORY_DELAY = const(13) - CATEGORY_REVERB = const(14) - - # Effect colors. The order must match the enums for the effect types defined above! + CATEGORY_EQUALIZER = const(8) + CATEGORY_BOOSTER = const(9) + CATEGORY_LOOPER = const(10) + CATEGORY_PITCH = const(11) + CATEGORY_DUAL = const(12) # Pitch Shifter Delays = Light Green + CATEGORY_DELAY = const(13) + CATEGORY_REVERB = const(14) + CATEGORY_TREMOLO = const(15) # Tube Bias/Photocell/Harmonic Tremolo — Blue (same as Chorus) + CATEGORY_ROTARY = const(16) # Rotary Speaker — Blue (same as Chorus) + CATEGORY_VIBRATO = const(17) # Vibrato — Blue (same as Chorus) + CATEGORY_SLICER = const(18) # Pulse/Saw Slicer, Autopanner — Blue (same as Chorus) + + # Effect colors. The order MUST match the category enums defined above (index 0 = CATEGORY_NONE). + # Source: KEMPER PROFILER Main Manual (official LED color designations per effect category) CATEGORY_COLORS = ( - DEFAULT_LABEL_COLOR, # None - Colors.ORANGE, # Wah - Colors.RED, # Distortion - Colors.BLUE, # Comp - Colors.BLUE, # Gate - Colors.GREEN, # Space - Colors.BLUE, # Chorus - Colors.PURPLE, # Phaser/Flanger - Colors.YELLOW, # EQ - Colors.RED, # Booster - Colors.PURPLE, # Looper - Colors.WHITE, # Pitch - Colors.GREEN, # Dual - Colors.GREEN, # Delay - Colors.GREEN, # Reverb + DEFAULT_LABEL_COLOR, # 0 None/Empty + Colors.ORANGE, # 1 Wah — Orange + Colors.RED, # 2 Distortion — Red + Colors.CYAN, # 3 Compressor — Cyan + Colors.CYAN, # 4 Noise Gate — Cyan + Colors.GREEN, # 5 Space — Green + Colors.BLUE, # 6 Chorus — Blue + Colors.PURPLE, # 7 Phaser / Flanger — Purple + Colors.YELLOW, # 8 EQ — Yellow + Colors.RED, # 9 Booster — Red (same family as Distortion) + Colors.PINK, # 10 Looper — Pink + Colors.WHITE, # 11 Pitch — White + Colors.LIGHT_GREEN, # 12 Dual (Pitch Delay)— Light Green + Colors.GREEN, # 13 Delay — Green + Colors.GREEN, # 14 Reverb — Green + Colors.BLUE, # 15 Tremolo — Blue (Kemper groups with Chorus stomps) + Colors.BLUE, # 16 Rotary — Blue (Kemper groups with Chorus stomps) + Colors.BLUE, # 17 Vibrato — Blue (Kemper groups with Chorus stomps) + Colors.BLUE, # 18 Slicer/Autopanner — Blue (Kemper groups with Chorus stomps) ) - # Effect type display names. The order must match the enums for the effect types defined above! + # Effect type display names. The order MUST match the category enums defined above. CATEGORY_NAMES = ( - "-", - "Wah", - "Dist", - "Comp", - "Gate", - "Space", - "Chorus", - "Phaser", - "EQ", - "Boost", - "Looper", - "Pitch", - "Dual", - "Delay", - "Reverb" + "-", # 0 None/Empty + "Wah", # 1 + "Dist", # 2 + "Comp", # 3 + "Gate", # 4 + "Space", # 5 + "Chorus", # 6 + "Phaser", # 7 + "EQ", # 8 + "Boost", # 9 + "Looper", # 10 + "Pitch", # 11 + "Dual", # 12 + "Delay", # 13 + "Reverb", # 14 + "Tremolo", # 15 + "Rotary", # 16 + "Vibrato", # 17 + "Slicer", # 18 ) - def __init__(self, + def __init__(self, slot_id, text = None, color = None, show_slot_names = False, extended_type_names = False - ): + ): super().__init__( mapping_state = KemperMappings.EFFECT_STATE(slot_id), mapping_type = KemperMappings.EFFECT_TYPE(slot_id) @@ -102,57 +122,145 @@ def __init__(self, self.__color = color self.__slot_name = KemperEffectSlot.EFFECT_SLOT_NAME[slot_id] if show_slot_names else None self.__extended_type_names = extended_type_names - - # Must return the effect category for a mapping value + + # Must return the effect category for a mapping value. + # + # Effect type values are decoded from Kemper's 14-bit NRPN representation: + # decoded_value = (MSB * 128) + LSB + # + # Reference: Kemper MIDI Specification (Appendix B), firmware v12+ + # Types below 128 have MSB=0, types 128+ have MSB=1 (add 128 to LSB). + # + # Ranges with no defined effects (gaps in the spec) fall through to + # CATEGORY_NONE so unexpected firmware values don't get misidentified. def get_effect_category(self, kpp_effect_type): - # NOTE: The ranges are defined by Kemper with a lot of unused numbers, so the borders between types - # could need to be adjusted with future Kemper firmware updates! - if (kpp_effect_type == 0): + + # --- Empty slot --- + if kpp_effect_type == 0: return self.CATEGORY_NONE - elif (0 < kpp_effect_type and kpp_effect_type <= 10) or kpp_effect_type == 12: + + # --- Wah family (MSB=0): 1-10, 12, 13 --- + # 1=Wah, 2=Low Pass, 3=High Pass, 4=Vowel Filter, 6=Wah Phaser, + # 7=Wah Flanger, 8=Rate Reducer, 9=Ring Mod, 10=Freq Shifter, + # 12=Formant Shift, 13=Pedal Vinyl Stop + elif (1 <= kpp_effect_type <= 10) or kpp_effect_type in (12, 13): return self.CATEGORY_WAH - elif kpp_effect_type == 11 or kpp_effect_type == 13: + + # --- Pitch Pedal (MSB=0): 11 --- + elif kpp_effect_type == 11: return self.CATEGORY_PITCH - elif (14 < kpp_effect_type and kpp_effect_type <= 45): + + # --- Distortion / Shaper (MSB=0): 17-42 --- + # 17=Bit Shaper, 18=Octa Shaper, 19=Soft Shaper, 20=Hard Shaper, + # 21=Wave Shaper, 32=Kemper Drive, 33=Green Scream, 34=Plus DS, + # 35=One DS, 36=Muffin, 37=Mouse, 38=Kemper Fuzz, 39=Metal DS, 42=Full OC + elif 17 <= kpp_effect_type <= 42: return self.CATEGORY_DISTORTION - elif (45 < kpp_effect_type and kpp_effect_type <= 55): + + # --- Dynamics / Compressor (MSB=0): 49-50 --- + # 49=Compressor, 50=Auto Swell + elif 49 <= kpp_effect_type <= 50: return self.CATEGORY_COMPRESSOR - elif (55 < kpp_effect_type and kpp_effect_type <= 60): - return self.CATEGORY_NOISE_GATE - elif (60 < kpp_effect_type and kpp_effect_type <= 64): - return self.CATEGORY_SPACE - elif (64 < kpp_effect_type and kpp_effect_type <= 80): + + # --- Noise Gate (MSB=0): 57-58 --- + # 57=Gate 2:1, 58=Gate 4:1 + elif 57 <= kpp_effect_type <= 58: + return self.CATEGORY_NOISE_GATE + + # --- Space (MSB=0): 64 --- + # 64=Space + elif kpp_effect_type == 64: + return self.CATEGORY_SPACE + + # --- Chorus (MSB=0): 65-67, 71 --- + # 65=Vintage Chorus, 66=Hyper Chorus, 67=Air Chorus, 71=Micro Pitch + elif kpp_effect_type in (65, 66, 67, 71): return self.CATEGORY_CHORUS - elif (80 < kpp_effect_type and kpp_effect_type <= 95): + + # --- Vibrato (MSB=0): 68 --- + # 68=Vibrato + elif kpp_effect_type == 68: + return self.CATEGORY_VIBRATO + + # --- Rotary (MSB=0): 69 --- + # 69=Rotary Speaker + elif kpp_effect_type == 69: + return self.CATEGORY_ROTARY + + # --- Tremolo (MSB=0): 70, 75, 76 --- + # 70=Tube Bias Tremolo, 75=Photocell Tremolo, 76=Harmonic Tremolo + elif kpp_effect_type in (70, 75, 76): + return self.CATEGORY_TREMOLO + + # --- Slicer / Autopanner (MSB=0): 77-80 --- + # 77=Pulse Slicer, 78=Saw Slicer, 79=Pulse Autopanner, 80=Saw Autopanner + elif 77 <= kpp_effect_type <= 80: + return self.CATEGORY_SLICER + + # --- Phaser / Flanger (MSB=0): 81-91 --- + # 81=Phaser, 82=Phaser Vibe, 83=Phaser Oneway, 89=Flanger, 91=Flanger Oneway + elif 81 <= kpp_effect_type <= 91: return self.CATEGORY_PHASER_FLANGER - elif (95 < kpp_effect_type and kpp_effect_type <= 110): + + # --- EQ / Widener (MSB=0): 97-104 --- + # 97=Graphic EQ, 98=Studio EQ, 99=Metal EQ, 100=Acoustic Sim, + # 101=Stereo Widener, 102=Phase Widener, 103=Delay Widener, 104=Double Tracker + elif 97 <= kpp_effect_type <= 104: return self.CATEGORY_EQUALIZER - elif (110 < kpp_effect_type and kpp_effect_type <= 120): + + # --- Booster (MSB=0): 113-116 --- + # 113=Treble Booster, 114=Lead Booster, 115=Pure Booster, 116=Wah Pedal Booster + elif 113 <= kpp_effect_type <= 116: return self.CATEGORY_BOOSTER - elif (120 < kpp_effect_type and kpp_effect_type <= 125): + + # --- Looper (MSB=0): 121-123 --- + # 121=Loop Mono, 122=Loop Stereo, 123=Loop Distortion + elif 121 <= kpp_effect_type <= 123: return self.CATEGORY_LOOPER - elif (125 < kpp_effect_type and kpp_effect_type <= 135): + + # --- Pitch / Harmony (MSB=1, decoded 129-132) --- + # 129=Transpose, 130=Chromatic Pitch, 131=Harmonic Pitch, 132=Analog Octaver + elif 129 <= kpp_effect_type <= 132: return self.CATEGORY_PITCH - elif (135 < kpp_effect_type and kpp_effect_type <= 143): + + # --- Dual / Pitch+Delay hybrids (MSB=1, decoded 138-140) --- + # 138=Dual Harmonic, 139=Dual Crystal, 140=Dual Loop Pitch + elif 138 <= kpp_effect_type <= 140: return self.CATEGORY_DUAL - elif (143 < kpp_effect_type and kpp_effect_type <= 170): + + # --- Delay (MSB=1, decoded 145-166) --- + # 145=Legacy Delay, 146=Single Delay, 147=Dual Delay, 148=Two Tap Delay, + # 149=Serial TwoTap, 150=Crystal Delay, 151=Loop Pitch Delay, + # 152=Freq Shifter Delay, 161=Rhythm Delay, 162=Melody Chromatic, + # 163=Melody Harmonic, 164=Quad Delay, 165=Quad Chromatic, 166=Quad Harmonic + elif 145 <= kpp_effect_type <= 166: return self.CATEGORY_DELAY - else: + + # --- Reverb (MSB=1, decoded 177-193) --- + # 177=Legacy Reverb, 178=Natural Reverb, 179=Easy Reverb, 180=Echo Reverb, + # 181=Cirrus Reverb, 182=Formant Reverb, 183=Ionosphere Reverb, 193=Spring Reverb + elif 177 <= kpp_effect_type <= 193: return self.CATEGORY_REVERB - - # Must return the color for a category + + # --- Unknown / undefined type number --- + else: + return self.CATEGORY_NONE + + + # Must return the color for a category def get_effect_category_color(self, category, kpp_effect_type): if self.__color: return self.__color - + return self.CATEGORY_COLORS[category] - # Must return the text to show for a category + + # Must return the text to show for a category def get_effect_category_text(self, category, kpp_effect_type): if self.__text: return self.__text - + if self.__extended_type_names: if kpp_effect_type in self.__extended_type_names: name = self.__extended_type_names[kpp_effect_type] @@ -163,5 +271,5 @@ def get_effect_category_text(self, category, kpp_effect_type): if self.__slot_name: return self.__slot_name + " " + name - + return name \ No newline at end of file diff --git a/content/lib/pyswitch/clients/kemper/actions/effect_state_per_rig.py b/content/lib/pyswitch/clients/kemper/actions/effect_state_per_rig.py new file mode 100644 index 00000000..e3b59f0e --- /dev/null +++ b/content/lib/pyswitch/clients/kemper/actions/effect_state_per_rig.py @@ -0,0 +1,264 @@ +from ....controller.actions import PushButtonAction +from ...kemper import KemperMappings +from .effect_state import KemperEffectEnableCallback + + +# Switch an effect slot on / off, with per-rig slot override support. +# When the active rig changes, the button automatically controls a different slot +# according to the rig_overrides mapping. +# +# slot_id: Default effect slot used when the current rig has no entry in +# rig_overrides. Pass None to disable the button by default (it +# will only be active for rigs explicitly listed in rig_overrides). +# +# rig_overrides: dict mapping absolute rig IDs to slot ID(s). +# Values can be: +# - A single slot_id → button controls that slot for this rig +# - A list of slot_ids → button controls all slots simultaneously; +# LED shows ON only when ALL slots are ON (AND logic); +# color/label derived from the first slot in the list +# - None → button is disabled for this rig +# Absolute rig ID = (bank - 1) * 5 + (rig - 1), bank 1-based, rig 1–5. +# +# Examples: +# # Button active only for rig 1, disabled for all others: +# EFFECT_STATE_PER_RIG( +# slot_id = None, +# rig_overrides = { 0: KemperEffectSlot.EFFECT_SLOT_ID_MOD } +# ) +# +# # Button controls MOD+C together on rig 1, disabled on rig 5: +# EFFECT_STATE_PER_RIG( +# slot_id = KemperEffectSlot.EFFECT_SLOT_ID_A, +# rig_overrides = { +# 0: [KemperEffectSlot.EFFECT_SLOT_ID_MOD, +# KemperEffectSlot.EFFECT_SLOT_ID_C], +# 4: None, +# } +# ) +def EFFECT_STATE_PER_RIG( + slot_id, + rig_overrides, + display = None, + mode = PushButtonAction.HOLD_MOMENTARY, + show_slot_names = False, + id = False, + text = None, + color = None, + use_leds = True, + enable_callback = None + ): + return PushButtonAction({ + "callback": KemperEffectEnablePerRigCallback( + slot_id = slot_id, + rig_overrides = rig_overrides, + text = text, + color = color, + show_slot_names = show_slot_names + ), + "mode": mode, + "display": display, + "id": id, + "useSwitchLeds": use_leds, + "enableCallback": enable_callback, + }) + + +class KemperEffectEnablePerRigCallback(KemperEffectEnableCallback): + """ + Like KemperEffectEnableCallback but with per-rig slot override support. + + slot_id: Default effect slot (used when no override exists for the current rig). + Pass None to disable the button by default (active only for rigs + explicitly listed in rig_overrides with a non-None slot). + rig_overrides: Dict mapping absolute rig IDs to slot ID(s) or None. + See EFFECT_STATE_PER_RIG docstring for details. + """ + + def __init__(self, slot_id, rig_overrides, **kwargs): + # Normalize rig_overrides first: convert string keys to int, None as-is, + # scalars to [slot], lists as-is. + # Must happen before super().__init__ so we can resolve the parent slot below. + self._rig_overrides = {} + for rig, slots in rig_overrides.items(): + rig_key = int(rig) if isinstance(rig, str) else rig + if slots is None: + self._rig_overrides[rig_key] = None + elif isinstance(slots, list): + self._rig_overrides[rig_key] = slots + else: + self._rig_overrides[rig_key] = [slots] + + # The parent class requires a valid slot_id to set up its MIDI mappings. + # When slot_id is None ("disabled by default") we pick the first non-None + # override slot as a stand-in for parent initialization only. + _parent_slot = slot_id + if _parent_slot is None: + for slots in self._rig_overrides.values(): + if slots is not None: + _parent_slot = slots[0] + break + + super().__init__(_parent_slot, **kwargs) + + self._default_slot = slot_id # None → disabled when no override matches + + # Register state/type mappings for all override slots that differ from the default. + override_slots = set() + for slots in self._rig_overrides.values(): + if slots is None: + continue + for s in slots: + if s != slot_id: + override_slots.add(s) + + self._override_state_maps = {} + self._override_type_maps = {} + for slot in override_slots: + if slot_id is None and slot == _parent_slot: + # The parent already registered mappings for this slot; reuse them + # instead of creating duplicate registrations for the same parameter. + self._override_state_maps[slot] = self.mapping + self._override_type_maps[slot] = self.mapping_fxtype + else: + sm = KemperMappings.EFFECT_STATE(slot) + tm = KemperMappings.EFFECT_TYPE(slot) + self._override_state_maps[slot] = sm + self._override_type_maps[slot] = tm + self.register_mapping(sm) + self.register_mapping(tm) + + # Track the current rig via the Kemper RIG_ID mapping. + self._rig_id_mapping = KemperMappings.RIG_ID() + self.register_mapping(self._rig_id_mapping) + self._appl_ref = None + + def init(self, appl, listener = None): + super().init(appl, listener) + # Store appl reference separately for use in state_changed_by_user(). + # BinaryParameterCallback stores __appl with name mangling, so we keep our own. + self._appl_ref = appl + + def _current_slots(self): + """ + Return the effective list of slot IDs for the current rig. + Returns None if the button is disabled for this rig. + Returns [self._default_slot] when no override is configured for the rig. + """ + rig = self._rig_id_mapping.value + if rig is None or rig not in self._rig_overrides: + if self._default_slot is None: + return None # Disabled by default + return [self._default_slot] + return self._rig_overrides[rig] # None or list + + def _state_map(self, slot): + """Return the state mapping for the given slot.""" + if slot == self._default_slot: + return self.mapping + return self._override_state_maps[slot] + + def _type_map(self, slot): + """Return the type mapping for the given slot.""" + if slot == self._default_slot: + return self.mapping_fxtype + return self._override_type_maps[slot] + + def state_changed_by_user(self): + """Send MIDI CC to toggle the effect on the currently active slot(s).""" + slots = self._current_slots() + if slots is None: + return # Button disabled for this rig + + if slots == [self._default_slot]: + super().state_changed_by_user() + return + + # Override slot(s): AND logic — all ON → turn all OFF, else → turn all ON. + all_on = all(self._state_map(s).value == 1 for s in slots) + new_value = 0 if all_on else 1 + for s in slots: + self._appl_ref.client.set(self._state_map(s), new_value) + self.update() + + def update_displays(self): + """Update LED and display label for the currently active slot(s).""" + slots = self._current_slots() + + if slots is None: + # Disabled for this rig: turn off LED and clear label. + # Reset cached display state so the next real slot switch forces a redraw. + self.reset() + self.action.switch_brightness = 0 + if self.action.label: + self.action.label.text = "" + return + + if slots == [self._default_slot]: + super().update_displays() + return + + # Override slot(s): derive color/label from the first slot, AND logic for state. + first_slot = slots[0] + + # Reset all display caches so the parent re-evaluates state, color and label + # for the override slot. This is necessary because: + # 1. The AND correction below may set action.state to False after the parent + # has already recorded state=True in its _current_display_state cache. + # Without a reset, subsequent calls where the first-slot value/color is + # unchanged will skip the display update entirely and the LED will be stuck. + # 2. When switching between override slots the color/value caches from the + # previous slot must not suppress the redraw for the new slot. + self.reset() + + # Temporarily redirect self.mapping and self.mapping_fxtype to the first override slot + # so the parent update_displays() reads from the right slot. + orig_mapping = self.mapping + orig_fxtype = self.mapping_fxtype + self.mapping = self._state_map(first_slot) + self.mapping_fxtype = self._type_map(first_slot) + + super().update_displays() # Sets color/label from first slot; state from first slot. + + # AND correction: if controlling multiple slots, override state with AND of all. + if len(slots) > 1 and self.action.state: + all_on = all(self._state_map(s).value == 1 for s in slots) + if not all_on: + self.action.feedback_state(False) + type_val = self.mapping_fxtype.value + cat = self.get_effect_category(type_val) if type_val is not None else self.CATEGORY_NONE + color = self.get_effect_category_color(cat, type_val) + self.set_switch_color(color) + self.set_label_color(color) + + self.mapping = orig_mapping + self.mapping_fxtype = orig_fxtype + + # Reset caches again so the next call always re-evaluates from scratch. + # This ensures that a change in any secondary slot (which is not the "first + # slot" used for color/label) triggers a correct AND re-check instead of + # being silently skipped by the BinaryParameterCallback value cache. + self.reset() + + def parameter_changed(self, mapping): + """ + Called when any registered mapping receives a MIDI update. + Trigger a display refresh on rig change, or when the active slot(s) change state. + """ + if mapping is self._rig_id_mapping: + # Rig changed: update display to reflect the new slot(s). + self.update_displays() + return + + active_slots = self._current_slots() + if active_slots is None or active_slots == [self._default_slot]: + super().parameter_changed(mapping) + return + + # Check if the changed mapping belongs to any active override slot. + for s in active_slots: + if mapping is self._state_map(s) or mapping is self._type_map(s): + self.update_displays() + return + + super().parameter_changed(mapping) diff --git a/content/lib/pyswitch/colors.py b/content/lib/pyswitch/colors.py index 4b18165a..16e9b258 100755 --- a/content/lib/pyswitch/colors.py +++ b/content/lib/pyswitch/colors.py @@ -14,6 +14,7 @@ class Colors: GREEN = (0, 255, 0) DARK_GREEN = (73, 110, 41) TURQUOISE = (64, 242, 208) + CYAN = (0, 255, 255) BLUE = (0, 0, 255) LIGHT_BLUE = (100, 100, 255) DARK_BLUE = (0, 0, 120) @@ -22,7 +23,7 @@ class Colors: BLACK = (0, 0, 0) # Default background color for display slots -DEFAULT_LABEL_COLOR = (50, 50, 50) +DEFAULT_LABEL_COLOR = (50, 50, 50) # Default color for switches DEFAULT_SWITCH_COLOR = (255, 255, 255) diff --git a/content/lib/pyswitch/controller/inputs.py b/content/lib/pyswitch/controller/inputs.py index 60ae0175..7279513b 100755 --- a/content/lib/pyswitch/controller/inputs.py +++ b/content/lib/pyswitch/controller/inputs.py @@ -38,8 +38,8 @@ def __init__(self, appl, config, period_counter_hold = None): self.__colors = [(0, 0, 0) for i in range(len(self.pixels))] self.__brightnesses = array('f', (0 for i in range(len(self.pixels)))) - self.color = Colors.WHITE - self.brightness = 0.5 + self.color = get_option(config, "color", Colors.WHITE) + self.brightness = get_option(config, "brightness", 0) self.__hold_repeat = get_option(config, "holdRepeat", False) self.__hold_active = False @@ -51,9 +51,16 @@ def __init__(self, appl, config, period_counter_hold = None): # Init actions for action in self.__actions + self.__actions_hold: action.init(self.__appl, self) - self.__appl.add_updateable(action) + self.__appl.add_updateable(action) action.update_displays() + # Re-apply the configured brightness for switches where no action controls the + # LEDs. update_displays() above may set a non-zero brightness (ledBrightnessOff) + # even when none of the actions are supposed to drive the LED segments, leaving + # the switch lit at startup contrary to the configured idle state. + if not any(a.uses_switch_leds for a in self.__actions + self.__actions_hold): + self.brightness = get_option(config, "brightness", 0) + # Hold period counter self.__period_hold = period_counter_hold if not self.__period_hold: diff --git a/content/lib/pyswitch/misc.py b/content/lib/pyswitch/misc.py index 05822410..3966f51a 100755 --- a/content/lib/pyswitch/misc.py +++ b/content/lib/pyswitch/misc.py @@ -1,7 +1,7 @@ from time import monotonic # PySwitch version -PYSWITCH_VERSION = "2.4.8" +PYSWITCH_VERSION = "2.4.9" # Read a value from an option dictionary with an optional default value def get_option(config, name, default = False): diff --git a/content/lib/pyswitch/ui/elements.py b/content/lib/pyswitch/ui/elements.py index 8b491b62..6dd4736d 100755 --- a/content/lib/pyswitch/ui/elements.py +++ b/content/lib/pyswitch/ui/elements.py @@ -201,15 +201,18 @@ def text(self, text): def __wrap_text(self, text): if not text: return "" - + if self.__layout.max_text_width: - return DisplayLabel.LINE_FEED.join( - wrap_text_to_pixels( - text, - self.__layout.max_text_width, - self.__font + try: + return DisplayLabel.LINE_FEED.join( + wrap_text_to_pixels( + text, + self.__layout.max_text_width, + self.__font + ) ) - ) + except Exception: + return text else: return text diff --git a/docs/effect_state_per_rig.html b/docs/effect_state_per_rig.html new file mode 100644 index 00000000..5001afc7 --- /dev/null +++ b/docs/effect_state_per_rig.html @@ -0,0 +1,654 @@ + + + + + + EFFECT_STATE_PER_RIG — Installation and Usage Guide + + + +
+ +
+
PySwitch — custom extension
+

EFFECT_STATE_PER_RIG

+

Assign different effect slots per rig. The same button always controls the right effect, regardless of how slots are organized on the Kemper.

+
+ + + + +
+

1 · The problem it solves

+

In the Kemper, each rig distributes its effects across slots (A, B, C, D, X, MOD, DLY, REV). With PySwitch, each button controls a specific slot — for example button 1 always controls slot A. The problem arises when different slots hold different types of effects depending on the rig:

+ +
+ Slot A Slot B Slot C Slot X +Rig 1 ── Compressor Chorus Delay Reverb +Rig 2 ── Distortion Compressor Chorus Delay +Rig 3 ── Wah Delay Compressor Reverb + + Button 1 (Slot A): activates Compressor on Rig 1, but Distortion on Rig 2 and Wah on Rig 3 +
+ +

With EFFECT_STATE_PER_RIG you can specify a default slot and then overrides for specific rigs. When you change rig, the button automatically points to the correct slot, updating the LED and display in real time.

+ +
+ Button 1 Button 2 Button 3 Button 4 +Default ─ Slot A Slot B Slot C Slot X +Rig 2 ─ Slot B Slot A Slot C Slot X ← override +Rig 3 ─ Slot C Slot B Slot A Slot X ← override +
+
+ + +
+

2 · Device installation (MIDICaptain)

+

You need to copy a single Python file to the Kemper effects folder on the device.

+ +
+
+
+
+

Mount the device as USB

+

Hold button 1 while powering on the MIDICaptain. The MIDICAPTAIN drive will appear on your computer.

+
+
+
+
+
+

Copy the file

+

Copy the file from the project folder:

+
📁 pyswitch/content/lib/pyswitch/clients/kemper/actions/effect_state_per_rig.py
+

to the same location on the device drive:

+
💾 MIDICAPTAIN/lib/pyswitch/clients/kemper/actions/effect_state_per_rig.py
+
+
+
+
+
+

Eject and restart

+

Eject the drive safely, then restart the controller.

+
+
+
+ +
+ Warning + The file must be in the same folder as the other Kemper action files (effect_state.py, rig_select.py, etc.), otherwise relative imports will fail. +
+
+ + +
+

3 · Web editor installation

+

If you use the PySwitch Emulator (online version) you cannot modify it directly. You need to use a local version. If you have already cloned the project to C:\Users\…\Desktop\pyswitch\, the files are already patched. For other installations, use the included script.

+ +

Automatic patch via script

+

Run the Python script from the web_patch/ folder (available in the feature/effect-state-per-rig branch):

+ +
python web_patch/apply_patches.py web/htdocs/definitions/
+ +

The script automatically adds the EFFECT_STATE_PER_RIG definitions to meta.json and actions.json without reformatting the rest of the file.

+ +

Replacing ActionProperties.js

+

Copy the patched file to the correct location:

+ +
📁 pyswitch/web_patch/ActionProperties.js
+

+
💾 pyswitch/web/htdocs/js/ui/parser/ActionProperties.js
+ +
copy web_patch\ActionProperties.js web\htdocs\js\ui\parser\ActionProperties.js
+ +
+ In the feature/effect-state-per-rig branch + If you used the prepared branch, all three web editor changes (actions.json, meta.json, ActionProperties.js) are already included in the repository. No need to run anything — just open the web editor from the local folder. +
+ +

Opening the local web editor

+

Open web/htdocs/index.html with a local server (required for WASM). With Python:

+ +
cd web/htdocs
+python -m http.server 8080
+ +

Then open http://localhost:8080 in your browser.

+
+ + +
+

4 · Manual configuration in inputs.py

+

Add the import at the top of the file and replace EFFECT_STATE with EFFECT_STATE_PER_RIG for buttons that require overrides.

+ +
from pyswitch.clients.kemper.actions.effect_state_per_rig import EFFECT_STATE_PER_RIG
+from pyswitch.clients.kemper import KemperEffectSlot
+
+# Absolute rig ID = (bank - 1) * 5 + (rig - 1)
+# Bank 1, Rig 1 → 0   Bank 1, Rig 2 → 1   Bank 1, Rig 3 → 2
+# Bank 2, Rig 1 → 5   Bank 2, Rig 2 → 6   etc.
+
+Inputs = [
+    # Button 1: default Slot A, but uses a different slot for certain rigs
+    {
+        "assignment": PA_MIDICAPTAIN_10_SWITCH_1,
+        "actions": [
+            EFFECT_STATE_PER_RIG(
+                slot_id = KemperEffectSlot.EFFECT_SLOT_ID_A,  # default
+                rig_overrides = {
+                    2: KemperEffectSlot.EFFECT_SLOT_ID_C,   # Bank 1 Rig 3 → Slot C
+                    5: KemperEffectSlot.EFFECT_SLOT_ID_DLY, # Bank 2 Rig 1 → Slot DLY
+                },
+                display = DISPLAY_HEADER_1
+            ),
+        ],
+    },
+    # Buttons without overrides: keep using normal EFFECT_STATE
+    {
+        "assignment": PA_MIDICAPTAIN_10_SWITCH_2,
+        "actions": [
+            EFFECT_STATE(slot_id = KemperEffectSlot.EFFECT_SLOT_ID_B, display = DISPLAY_HEADER_2),
+        ],
+    },
+    # ...
+]
+ +
+ Available parameters + EFFECT_STATE_PER_RIG accepts all parameters of EFFECT_STATE (display, mode, text, color, show_slot_names, use_leds, enable_callback) plus the mandatory rig_overrides parameter. +
+
+ + +
+

5 · Configuration via web editor

+

After applying the patches, the new action appears in the Kemper effects list under the name EFFECT_STATE_PER_RIG.

+ +

How to add the action to a button

+
+
+
+
+

In the button configuration panel, click + Add action and search for EFFECT_STATE_PER_RIG in the Effects category.

+
+
+
+
+
+

Select the default Slot from the dropdown (Slot A, B, C, D, X, MOD, DLY, REV, etc.). This slot will be used for all rigs without an override.

+
+
+
+
+
+

In the Rig Overrides section, add exceptions rig by rig.

+
+
+
+ +

Rig Overrides interface

+
+
+ Slot + Slot A +
+
+ Display + DISPLAY_HEADER_1 +
+
+ Rig Overrides +
+
Per-rig overrides: alternate slot for specific bank/rig combinations
+ + + + + + + + + + + + + + + + + + + + + + + +
BankRigSlot
13Slot C
21Slot DLY (spillover)
+ + Add override +
+
+
+ +

Table usage

+ + +
+ Code ↔ UI round-trip + The table is populated automatically even when loading a configuration from existing Python code. You can freely switch between the graphical editor and the code editor without losing data. +
+ +

Saving

+

Use the Save → Connected Controllers button (via MIDI) or Save → Download (ZIP) and copy the files to the device via USB as described in the Installation section.

+
+ + +
+

6 · How to calculate the absolute rig ID

+

The web editor asks for Bank and Rig separately, so no calculation is needed. If you configure inputs.py manually, the rig_overrides dictionary uses absolute IDs calculated with this formula:

+ +
abs_rig_id = (bank - 1) * 5 + (rig - 1)
+ +

Where bank and rig are both 1-based (first bank is 1, first rig is 1).

+ +

Quick reference table

+
+ Rig 1 Rig 2 Rig 3 Rig 4 Rig 5 +Bank 1 → 0 1 2 3 4 +Bank 2 → 5 6 7 8 9 +Bank 3 → 10 11 12 13 14 +Bank 4 → 15 16 17 18 19 + +Bank 10 → 45 46 47 48 49 +
+ +
+ Reverse lookup + Given an absolute ID n, you can derive bank and rig with:
+ bank = floor(n / 5) + 1    rig = (n % 5) + 1 +
+
+ + +
+

7 · Practical examples

+ +

Scenario A — Compressor always on button 1

+

In most rigs the compressor is in Slot A. Only in Bank 1 Rig 4 and Bank 2 Rig 2 is it in Slot C.

+ +
EFFECT_STATE_PER_RIG(
+    slot_id = KemperEffectSlot.EFFECT_SLOT_ID_A,  # default: Slot A
+    rig_overrides = {
+        3: KemperEffectSlot.EFFECT_SLOT_ID_C,  # Bank 1 Rig 4 → Slot C
+        6: KemperEffectSlot.EFFECT_SLOT_ID_C,  # Bank 2 Rig 2 → Slot C
+    },
+    display = DISPLAY_HEADER_1
+)
+ +

Scenario B — Delay always on the UP button (with Reverb)

+

In some rigs the delay is in the standard Slot DLY, in others it is in Slot X.

+ +
# UP button: Delay + Reverb with delay override for certain rigs
+{
+    "assignment": PA_MIDICAPTAIN_10_SWITCH_UP,
+    "actions": [
+        EFFECT_STATE_PER_RIG(
+            slot_id = KemperEffectSlot.EFFECT_SLOT_ID_DLY,  # default
+            rig_overrides = {
+                7: KemperEffectSlot.EFFECT_SLOT_ID_X,  # Bank 2 Rig 3 → Slot X
+            }
+        ),
+        EFFECT_STATE(slot_id = KemperEffectSlot.EFFECT_SLOT_ID_REV),
+    ],
+}
+ +

Scenario C — Overrides on all stomp buttons

+

Full configuration where each button has its own set of overrides:

+ +
from pyswitch.clients.kemper.actions.effect_state_per_rig import EFFECT_STATE_PER_RIG
+from pyswitch.clients.kemper.actions.effect_state import EFFECT_STATE
+from pyswitch.clients.kemper import KemperEffectSlot
+
+A = KemperEffectSlot.EFFECT_SLOT_ID_A
+B = KemperEffectSlot.EFFECT_SLOT_ID_B
+C = KemperEffectSlot.EFFECT_SLOT_ID_C
+X = KemperEffectSlot.EFFECT_SLOT_ID_X
+
+Inputs = [
+    {
+        "assignment": PA_MIDICAPTAIN_10_SWITCH_1,
+        "actions": [EFFECT_STATE_PER_RIG(A, {2: C, 7: B}, display=DISPLAY_HEADER_1)],
+    },
+    {
+        "assignment": PA_MIDICAPTAIN_10_SWITCH_2,
+        "actions": [EFFECT_STATE_PER_RIG(B, {2: A, 4: C}, display=DISPLAY_HEADER_2)],
+    },
+    {
+        "assignment": PA_MIDICAPTAIN_10_SWITCH_3,
+        "actions": [EFFECT_STATE_PER_RIG(C, {3: X},         display=DISPLAY_FOOTER_1)],
+    },
+    {
+        "assignment": PA_MIDICAPTAIN_10_SWITCH_4,
+        "actions": [EFFECT_STATE(slot_id=X)],  # no override needed
+    },
+]
+ +
+ Tip + You can use normal EFFECT_STATE for buttons that do not need overrides. Both action types are compatible and can coexist in the same inputs.py. +
+
+ +
+ + +
+ + diff --git a/docs/plans/20260423_010731_ui_completeness.md b/docs/plans/20260423_010731_ui_completeness.md new file mode 100644 index 00000000..b13be479 --- /dev/null +++ b/docs/plans/20260423_010731_ui_completeness.md @@ -0,0 +1,291 @@ +# UI Completeness Plan +Branch: `feature/ui-completeness` +Date: 2026-04-23 + +--- + +## Overview + +The web editor lets users configure PySwitch visually without editing Python files. +Currently several features that are fully supported in Python are either absent from the +editor or only partially exposed. This plan lists all gaps, what each one enables for the +user, and a rough implementation note. + +Items are grouped by priority: **critical** (blocks common use cases), **moderate** +(limits power users), **minor** (edge cases / advanced). + +--- + +## ~~Priority 1 — NOT a gap~~ + +### ~~1. Switch hold actions~~ — ALREADY IMPLEMENTED + +Hold actions, `holdTimeMillis`, and `holdRepeat` are fully supported in the editor: +- `ParserFrontendInput.js` renders a separate "hold" slot for each switch +- `ActionProperties.js` has a "hold" checkbox in the action dialog +- `InputSettings.js` exposes `holdTimeMillis` and `holdRepeat` via the wrench button + +--- + +## Priority 1 — Critical + +### 1. Paging system (`PagerAction`, page assignments) + +**What it enables:** +Multi-page layouts. With paging you can assign different sets of actions to the same +physical switches depending on which "page" is active. This multiplies the number of +configurable actions without needing more hardware switches. +Example: page 1 = effect toggles, page 2 = rig selectors, with one dedicated switch +cycling through pages. + +**Python source:** `content/lib/pyswitch/controller/actions/__init__.py` (PagerAction / HoldAction), +referenced in many example configs under `examples/` +**UI gap:** PagerAction is not registered in `meta.json` at all. No concept of pages +exists anywhere in the web editor. + +**Implementation notes:** +- Add `PagerAction` (and `HoldAction` if separate) to `meta.json` +- The page concept needs a UI representation: a page list in the switch editor, with each + page containing its own `actions` array +- This is the most complex item in this list; it may require changes to the switch + editor's data model + +--- + +### 3. `DisplaySplitContainer` in the display editor + +**What it enables:** +A split container divides a display area into two (or more) horizontal or vertical +sections, each showing different information simultaneously. +Example: top half shows the rig name, bottom half shows the current effect names. +Without this in the editor, users who want split displays must edit `display.py` by hand. + +**Python source:** `content/lib/pyswitch/ui/elements.py` (DisplaySplitContainer) +**UI gap:** The display editor only supports `DisplayLabel` elements. There is no way to +add a `DisplaySplitContainer` or nest labels inside it. + +**Implementation notes:** +- Add a new element type in the display editor: "Split Container" +- Parameters: `direction` (HORIZONTAL / VERTICAL), `bounds`, and a list of child elements +- Child elements should themselves be editable inline (recursive panel) + +--- + +### 4. `TunerDisplay` in the display editor + +**What it enables:** +A dedicated tuner display element that shows a chromatic tuner visualization (strobe or +bar style) when the Kemper tuner mode is active. +Without this in the editor, users cannot add the tuner to their display layout visually. + +**Python source:** `content/lib/pyswitch/ui/TunerDisplay.py` (or similar) +**UI gap:** TunerDisplay is not available as an element type in the display editor. +Its specific parameters (`mapping_note`, `mapping_deviance`, `zoom`, `color_in_tune`, +`color_out_of_tune`, `color_neutral`, `calibration_high`, `calibration_low`) are +never exposed. + +**Implementation notes:** +- Add "Tuner Display" as a new element type in the display editor +- Expose the color and calibration parameters as optional fields + +--- + +### 5. `DisplayLabel.callback` parameter + +**What it enables:** +A `callback` attached to a `DisplayLabel` lets you write a Python function that +dynamically controls what the label shows (color, text) based on any runtime state. +This is the escape hatch for anything the built-in actions cannot display on their own. + +**Python source:** `content/lib/pyswitch/ui/elements.py`, `DisplayLabel.__init__` +**UI gap:** The display editor never shows a `callback` field for labels. + +**Implementation notes:** +- Add a `callback` field of type `any` (free-form Python expression) to the DisplayLabel + parameter panel in the display editor, marked as advanced + +--- + +## Priority 2 — Moderate + +### 6. `EncoderAction` missing parameters + +**What it enables:** +`EncoderAction` controls a rotary encoder (e.g. a jog wheel) and sends MIDI values. +The missing parameters let users: +- `min_value` / `max_value`: Clamp the range (e.g. send only CC 10-100 not 0-127) +- `step_width`: How much one encoder click changes the value (e.g. coarse vs. fine control) +- `preview_reset_mapping`: Reset the preview display when a certain mapping changes + (e.g. auto-cancel the pending encoder value when you switch rigs) +- `convert_value`: Display the raw MIDI number as something human-readable + (e.g. `lambda v: str(v) + " BPM"`) + +**Python source:** `content/lib/pyswitch/controller/actions/EncoderAction.py`, lines 9-28 +**UI gap:** meta.json only exposes `mapping`. The five parameters above are absent. + +**Implementation notes:** +- Add the five parameters to the `ENCODER` entry in `meta.json` +- `convert_value` and `preview_reset_mapping` are `any` type (advanced) +- `min_value`, `max_value`, `step_width` are numeric + +--- + +### 7. `AnalogAction` missing parameters + +**What it enables:** +`AnalogAction` controls expression pedals and other analog inputs. +The missing parameters let users: +- `max_value`: Set the MIDI range ceiling (e.g. only use the lower half of the pedal range) +- `auto_calibrate` / `cal_min_window`: Toggle auto-calibration and its sensitivity. + Calibration stretches the pedal's physical range to the full MIDI range; disabling it + is needed if the pedal already outputs a full 0-65535 range. +- `transfer_function`: A custom Python lambda that maps raw hardware values to MIDI values. + Needed for non-linear pedal responses (log volume taper, S-curve, etc.) +- `convert_value`: Human-readable display of the sent value + +**Python source:** `content/lib/pyswitch/controller/actions/AnalogAction.py`, lines 8-25 +**UI gap:** meta.json exposes `mapping` and a few basics; the advanced calibration and +conversion parameters are absent. + +**Implementation notes:** +- Add the missing parameters to the `ANALOG` entry in `meta.json` +- `transfer_function` and `convert_value` are `any` type (advanced) + +--- + +### 8. `RIG_SELECT` missing parameters (`rig_btn_morph`, `momentary_morph`, `color_callback`, `text_callback`) + +**What it enables:** +- `rig_btn_morph`: When you press the rig button for the *currently active* rig a second + time, it toggles the Kemper's internal morph state (no MIDI morph command is sent — it + just flips the display so the button acts as a morph indicator + toggle). + Allows a single button to select a rig AND control morph without a dedicated morph button. +- `momentary_morph`: The morph state reverts as soon as you release the button (like a + momentary footswitch). Needed for users who have set their Kemper rigs to momentary morph. +- `color_callback` / `text_callback`: Python lambdas that dynamically set the button LED + color and label text based on `(action, bank, rig)`. Needed for setups where bank colors + or rig names come from a custom source rather than the default `BANK_COLORS` palette. + +**Python source:** `content/lib/pyswitch/clients/kemper/actions/rig_select.py`, lines 15-30 +**UI gap:** meta.json covers `rig`, `bank`, `rig_off`, `bank_off`, `display_mode` but not +the four parameters above. + +**Implementation notes:** +- `rig_btn_morph` and `momentary_morph`: boolean fields, mark as advanced +- `color_callback` / `text_callback`: `any` type fields, mark as advanced + +--- + +### 9. `EFFECT_STATE` — `show_slot_names` parameter + +**What it enables:** +When `show_slot_names = True`, the button label shows the Kemper effect *slot* name +(e.g. "A", "B", "X", "MOD") instead of the loaded effect name. +Useful in compact layouts where there is no room for long effect names but the slot +position still needs to be visually identified. + +**Python source:** `content/lib/pyswitch/clients/kemper/actions/effect_state.py`, line 11 +**UI gap:** `show_slot_names` is absent from the `EFFECT_STATE` entry in meta.json. + +**Implementation notes:** +- Add a boolean `show_slot_names` parameter to the `EFFECT_STATE` entry in meta.json + +--- + +### 10. `communication.py` — `time_lease_seconds` and other protocol options + +**What it enables:** +`time_lease_seconds` controls how long PySwitch waits for a MIDI response before +re-requesting it. A shorter lease makes the display react faster to changes; a longer +lease reduces MIDI traffic on busy setups. Currently this is hardcoded. + +**Python source:** `content/lib/pyswitch/clients/kemper/communication.py` +**UI gap:** The "Communication" settings panel in the web editor does not expose this or +other protocol-level parameters. + +**Implementation notes:** +- Expose `time_lease_seconds` (and any other non-default protocol options) in the + Communication settings panel (CommunicationSettings.js) + +--- + +## Priority 3 — Minor + +### 11. `HID_KEYBOARD` parameters + +**What it enables:** +`HID_KEYBOARD` sends USB HID keyboard keystrokes from the MIDICaptain to a connected +computer. The action is registered in meta.json but with no parameters — so it cannot +actually do anything from the editor. Adding the keycode parameter lets users bind +buttons to computer shortcuts (transport controls, DAW shortcuts, etc.) + +**UI gap:** `HID_KEYBOARD` entry in meta.json has an empty `arguments` array. + +**Implementation notes:** +- Investigate what parameters the Python implementation accepts, then add them to meta.json + +--- + +### 12. `BINARY_SWITCH` action not in meta.json + +**What it enables:** +`BINARY_SWITCH` sends a MIDI CC or NRPN value that is either fully on or fully off +(no toggle state tracking). Useful for triggering momentary events, arming/disarming +loops, or any parameter that does not report its state back over MIDI. + +**UI gap:** The action exists in Python but is not listed in meta.json at all. + +**Implementation notes:** +- Register the action in meta.json with its required parameters + +--- + +### 13. `config.py` — `enableMidiBridge` + +**What it enables:** +`enableMidiBridge` controls whether the MIDI bridge (used to flash firmware to the +MIDICaptain over MIDI) is active. On some setups disabling it prevents conflicts. +Currently always hardcoded to True. + +**UI gap:** Not exposed in the ConfigFile settings panel. + +**Implementation notes:** +- Add a boolean toggle in the config.py settings panel + +--- + +### 14. `config.py` — `excludeMessageTypes` and `debugMapping` + +**What it enables:** +- `excludeMessageTypes`: Drop specific MIDI message types from processing (performance + optimization; reduces CPU load if certain MIDI messages are noisy on the bus) +- `debugMapping`: Log MIDI traffic for a specific mapping to the console (developer + tool for diagnosing MIDI communication problems) + +Both are developer/advanced options with limited real-world use for typical users. + +**UI gap:** Not exposed anywhere in the web editor. + +**Implementation notes:** +- Add both as "Advanced" fields in the config.py settings panel + +--- + +## Implementation Order + +Suggested order balancing impact vs. effort: + +1. `show_slot_names` for EFFECT_STATE — trivial, one line in meta.json +2. `rig_btn_morph` / `momentary_morph` for RIG_SELECT — two booleans in meta.json +3. `color_callback` / `text_callback` for RIG_SELECT — advanced `any` fields in meta.json +4. EncoderAction missing parameters — meta.json + numeric inputs +5. AnalogAction missing parameters — meta.json + numeric inputs +6. Hold actions on switches — requires UI changes in switch editor +7. `DisplaySplitContainer` in display editor — new element type +8. `TunerDisplay` in display editor — new element type +9. `DisplayLabel.callback` — one advanced field in display editor +10. `BINARY_SWITCH` in meta.json — needs Python investigation first +11. `HID_KEYBOARD` parameters — needs Python investigation first +12. `enableMidiBridge`, `excludeMessageTypes`, `debugMapping` — config panel additions +13. `communication.py` options — communication panel additions +14. Paging system — requires architectural changes (largest item, save for last) diff --git a/docs/plans/20260425_142646_effect_state_per_rig_config.md b/docs/plans/20260425_142646_effect_state_per_rig_config.md new file mode 100644 index 00000000..ca8e7b63 --- /dev/null +++ b/docs/plans/20260425_142646_effect_state_per_rig_config.md @@ -0,0 +1,57 @@ +# Piano: Configurazione EFFECT_STATE_PER_RIG per 5 switch + +**Data**: 2026-04-25 +**Branch**: feature/effect-state-per-rig +**Dispositivo**: MIDICaptain 10 + +## Obiettivo + +Riconfigurare `content/inputs.py` e `content/display.py` da zero per gestire +5 switch con `EFFECT_STATE_PER_RIG`: + +- Switch 1 e 2: sempre disabilitati (nessuna azione) +- Switch 3, 4, 5: disabilitati di default (`slot_id=None`), attivi solo per + i rig specificati + +## Mapping switch → display + +| Switch HW | Label utente | Display slot | +|------------------------|--------------|--------------------| +| PA_MIDICAPTAIN_10_SWITCH_1 | Switch 1 | nessuno (disabilitato) | +| PA_MIDICAPTAIN_10_SWITCH_2 | Switch 2 | nessuno (disabilitato) | +| PA_MIDICAPTAIN_10_SWITCH_3 | Switch 3 | DISPLAY_FOOTER_1 | +| PA_MIDICAPTAIN_10_SWITCH_4 | Switch 4 | DISPLAY_FOOTER_2 | +| PA_MIDICAPTAIN_10_SWITCH_UP | Switch 5 | nessuno | + +## Mapping rig → slot per switch + +Rig ID assoluto = (bank-1)*5 + (rig-1), banco 1 rig 1-5 → ID 0-4. + +| Rig | Nome | Switch 3 | Switch 4 | Switch 5 | +|-----|-------|-------------------|-----------------------|-----------------------| +| 0 | acou | disabilitato | [MOD + C] insieme | DLY | +| 1 | clen | X | disabilitato | [DLY + REV] insieme | +| 2 | crnc | X | disabilitato | [DLY + REV] insieme | +| 3 | heavy | disabilitato | X | [DLY + REV] insieme | +| 4 | lead | X | disabilitato | disabilitato | + +## Slot Kemper + +- `EFFECT_SLOT_ID_X` = 4 (X) +- `EFFECT_SLOT_ID_MOD` = 5 (MOD) +- `EFFECT_SLOT_ID_C` = 2 (C) +- `EFFECT_SLOT_ID_DLY` = 6 (DLY) +- `EFFECT_SLOT_ID_REV` = 7 (REV) + +## File da creare / modificare + +1. `content/inputs.py` — configurazione switch (da zero) +2. `content/display.py` — layout display semplificato (da zero) +3. `test/pyswitch/test_kemper_action_effect_state_per_rig.py` — test unitari + +## Stato + +- [x] display.py +- [x] inputs.py +- [x] test file (26 test) +- [x] test verdi (363/363 OK) diff --git a/test/pyswitch/test_input_controller_switch.py b/test/pyswitch/test_input_controller_switch.py index 70bfe62f..60376d43 100644 --- a/test/pyswitch/test_input_controller_switch.py +++ b/test/pyswitch/test_input_controller_switch.py @@ -249,13 +249,13 @@ def test_default_color_and_brightness(self): }) self.assertEqual(fs.color, Colors.WHITE) - self.assertEqual(fs.brightness, 0.5) + self.assertEqual(fs.brightness, 0) for c in fs.colors: self.assertEqual(c, Colors.WHITE) self.assertEqual(len(fs.colors), 2) - self.assertEqual(fs.brightnesses, [0.5, 0.5]) + self.assertEqual(fs.brightnesses, [0, 0]) ############################################################################## diff --git a/test/pyswitch/test_kemper_action_effect_state.py b/test/pyswitch/test_kemper_action_effect_state.py index e1251a2c..c8889061 100644 --- a/test/pyswitch/test_kemper_action_effect_state.py +++ b/test/pyswitch/test_kemper_action_effect_state.py @@ -59,91 +59,116 @@ def test_effect_state(self): self.assertEqual(action._Action__enable_callback, ecb) self.assertEqual(action._PushButtonAction__mode, PushButtonAction.LATCH) - def test_effect_categories(self): cb = KemperEffectEnableCallback(KemperEffectSlot.EFFECT_SLOT_ID_DLY) # None self.assertEqual(cb.get_effect_category(0), KemperEffectEnableCallback.CATEGORY_NONE) - # Wah (and some pitch) - for i in range(1, 10): + # Wah family: 1-10, 12, 13 + for i in range(1, 11): self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_WAH) + self.assertEqual(cb.get_effect_category(12), KemperEffectEnableCallback.CATEGORY_WAH) + self.assertEqual(cb.get_effect_category(13), KemperEffectEnableCallback.CATEGORY_WAH) + # Pitch Pedal: 11 self.assertEqual(cb.get_effect_category(11), KemperEffectEnableCallback.CATEGORY_PITCH) - self.assertEqual(cb.get_effect_category(12), KemperEffectEnableCallback.CATEGORY_WAH) - self.assertEqual(cb.get_effect_category(13), KemperEffectEnableCallback.CATEGORY_PITCH) - # Dist - for i in range(17, 42): + # Distortion / Shaper: 17-42 (Includes Kemper Drive, Fuzz, etc.) + for i in range(17, 43): self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_DISTORTION) - # Comp - for i in range(49, 56): + # Dynamics / Compressor: 49-50 + for i in range(49, 51): self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_COMPRESSOR) - # Noise Gate - for i in range(57, 58): + # Noise Gate: 57-58 + for i in range(57, 59): self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_NOISE_GATE) - # Space + # Space: 64 self.assertEqual(cb.get_effect_category(64), KemperEffectEnableCallback.CATEGORY_SPACE) - # Chorus - for i in range(65, 71): + # Chorus: 65-67, 71 + for i in (65, 66, 67, 71): self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_CHORUS) - # Phaser / Flanger - for i in range(81, 91): + # Vibrato: 68 + self.assertEqual(cb.get_effect_category(68), KemperEffectEnableCallback.CATEGORY_VIBRATO) + + # Rotary: 69 + self.assertEqual(cb.get_effect_category(69), KemperEffectEnableCallback.CATEGORY_ROTARY) + + # Tremolo: 70, 75, 76 + for i in (70, 75, 76): + self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_TREMOLO) + + # Slicer / Autopanner: 77-80 + for i in range(77, 81): + self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_SLICER) + + # Phaser / Flanger: 81-91 + for i in range(81, 92): self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_PHASER_FLANGER) - # EQ - for i in range(97, 104): + # EQ / Widener: 97-104 + for i in range(97, 105): self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_EQUALIZER) - # Boost - for i in range(113, 116): + # Booster: 113-116 + for i in range(113, 117): self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_BOOSTER) - # Looper - for i in range(121, 123): + # Looper: 121-123 + for i in range(121, 124): self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_LOOPER) - # Pitch - for i in range(129, 132): + # Pitch / Harmony: 129-132 + for i in range(129, 133): self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_PITCH) - # Dual - for i in range(137, 140): + # Dual / Pitch+Delay: 138-140 + for i in range(138, 141): self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_DUAL) - # Delay - for i in range(145, 166): + # Delay: 145-166 + for i in range(145, 167): self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_DELAY) - # Rev - for i in range(177, 193): + # Reverb: 177-193 + for i in range(177, 194): self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_REVERB) - def test_type_colors(self): - # All types have to be mapped + # All categories must have a valid color mapped in CATEGORY_COLORS cb = KemperEffectEnableCallback(KemperEffectSlot.EFFECT_SLOT_ID_DLY) - cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_WAH, 0) - cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_DISTORTION, 0) - cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_COMPRESSOR, 0) - cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_NOISE_GATE, 0) - cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_SPACE, 0) - cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_CHORUS, 0) - cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_PHASER_FLANGER, 0) - cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_EQUALIZER, 0) - cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_BOOSTER, 0) - cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_LOOPER, 0) - cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_PITCH, 0) - cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_DUAL, 0) - cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_DELAY, 0) - cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_REVERB, 0) + categories = [ + KemperEffectEnableCallback.CATEGORY_WAH, + KemperEffectEnableCallback.CATEGORY_DISTORTION, + KemperEffectEnableCallback.CATEGORY_COMPRESSOR, + KemperEffectEnableCallback.CATEGORY_NOISE_GATE, + KemperEffectEnableCallback.CATEGORY_SPACE, + KemperEffectEnableCallback.CATEGORY_CHORUS, + KemperEffectEnableCallback.CATEGORY_PHASER_FLANGER, + KemperEffectEnableCallback.CATEGORY_EQUALIZER, + KemperEffectEnableCallback.CATEGORY_BOOSTER, + KemperEffectEnableCallback.CATEGORY_LOOPER, + KemperEffectEnableCallback.CATEGORY_PITCH, + KemperEffectEnableCallback.CATEGORY_DUAL, + KemperEffectEnableCallback.CATEGORY_DELAY, + KemperEffectEnableCallback.CATEGORY_REVERB, + # New categories from your latest effect_state.py + KemperEffectEnableCallback.CATEGORY_TREMOLO, + KemperEffectEnableCallback.CATEGORY_ROTARY, + KemperEffectEnableCallback.CATEGORY_VIBRATO, + KemperEffectEnableCallback.CATEGORY_SLICER + ] + + for cat in categories: + color = cb.get_effect_category_color(cat, 0) + # Ensure the color returned is not None + self.assertIsNotNone(color) def test_color_override(self): cb = KemperEffectEnableCallback( @@ -190,19 +215,32 @@ def test_type_names(self): # All types have to be mapped cb = KemperEffectEnableCallback(KemperEffectSlot.EFFECT_SLOT_ID_DLY) - cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_WAH, 0) - cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_DISTORTION, 0) - cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_COMPRESSOR, 0) - cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_NOISE_GATE, 0) - cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_SPACE, 0) - cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_CHORUS, 0) - cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_PHASER_FLANGER, 0) - cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_EQUALIZER, 0) - cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_BOOSTER, 0) - cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_LOOPER, 0) - cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_PITCH, 0) - cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_DUAL, 0) - cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_DELAY, 0) - cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_REVERB, 0) - + # List of all categories defined in KemperEffectEnableCallback + categories = [ + KemperEffectEnableCallback.CATEGORY_WAH, + KemperEffectEnableCallback.CATEGORY_DISTORTION, + KemperEffectEnableCallback.CATEGORY_COMPRESSOR, + KemperEffectEnableCallback.CATEGORY_NOISE_GATE, + KemperEffectEnableCallback.CATEGORY_SPACE, + KemperEffectEnableCallback.CATEGORY_CHORUS, + KemperEffectEnableCallback.CATEGORY_PHASER_FLANGER, + KemperEffectEnableCallback.CATEGORY_EQUALIZER, + KemperEffectEnableCallback.CATEGORY_BOOSTER, + KemperEffectEnableCallback.CATEGORY_LOOPER, + KemperEffectEnableCallback.CATEGORY_PITCH, + KemperEffectEnableCallback.CATEGORY_DUAL, + KemperEffectEnableCallback.CATEGORY_DELAY, + KemperEffectEnableCallback.CATEGORY_REVERB, + # New categories added in PR + KemperEffectEnableCallback.CATEGORY_TREMOLO, + KemperEffectEnableCallback.CATEGORY_ROTARY, + KemperEffectEnableCallback.CATEGORY_VIBRATO, + KemperEffectEnableCallback.CATEGORY_SLICER + ] + + for category in categories: + name = cb.get_effect_category_text(category, 0) + # Ensure the name is not the default "-" unless it's CATEGORY_NONE + self.assertIsNotNone(name) + self.assertNotEqual(name, "") diff --git a/test/pyswitch/test_kemper_action_effect_state_per_rig.py b/test/pyswitch/test_kemper_action_effect_state_per_rig.py new file mode 100644 index 00000000..32e4a6fc --- /dev/null +++ b/test/pyswitch/test_kemper_action_effect_state_per_rig.py @@ -0,0 +1,389 @@ +import sys +import unittest +from unittest.mock import patch + +from .mocks_lib import * + +with patch.dict(sys.modules, { + "micropython": MockMicropython, + "displayio": MockDisplayIO(), + "adafruit_display_text": MockAdafruitDisplayText(), + "adafruit_midi.control_change": MockAdafruitMIDIControlChange(), + "adafruit_midi.system_exclusive": MockAdafruitMIDISystemExclusive(), + "adafruit_midi.midi_message": MockAdafruitMIDIMessage(), + "adafruit_midi.program_change": MockAdafruitMIDIProgramChange(), + "adafruit_display_shapes.rect": MockDisplayShapes().rect(), + "gc": MockGC() +}): + from lib.pyswitch.clients.kemper import KemperEffectSlot + from lib.pyswitch.clients.kemper.actions.effect_state_per_rig import ( + EFFECT_STATE_PER_RIG, + KemperEffectEnablePerRigCallback, + ) + from lib.pyswitch.controller.actions import PushButtonAction + + from .mocks_appl import MockClient + + +# Absolute rig IDs (bank 1, rigs 1-5) +_RIG_ACOU = 0 # rig 1 "acou" +_RIG_CLEN = 1 # rig 2 "clen" +_RIG_CRNC = 2 # rig 3 "crnc" +_RIG_HEAVY = 3 # rig 4 "heavy" +_RIG_LEAD = 4 # rig 5 "lead" + +X = KemperEffectSlot.EFFECT_SLOT_ID_X +MOD = KemperEffectSlot.EFFECT_SLOT_ID_MOD +C = KemperEffectSlot.EFFECT_SLOT_ID_C +DLY = KemperEffectSlot.EFFECT_SLOT_ID_DLY +REV = KemperEffectSlot.EFFECT_SLOT_ID_REV + + +class _MockAppl: + """Minimal mock application for callback init.""" + config = {} + + def __init__(self): + self.client = MockClient() + + def add_updateable(self, _): + pass + + +def _sw3_cb(): + """Switch 3: X slot for rigs clen, crnc, lead; disabled otherwise.""" + return KemperEffectEnablePerRigCallback( + slot_id=None, + rig_overrides={ + _RIG_CLEN: X, + _RIG_CRNC: X, + _RIG_LEAD: X, + } + ) + + +def _sw4_cb(): + """Switch 4: [MOD+C] for acou; X for heavy; disabled otherwise.""" + return KemperEffectEnablePerRigCallback( + slot_id=None, + rig_overrides={ + _RIG_ACOU: [MOD, C], + _RIG_HEAVY: X, + } + ) + + +def _sw5_cb(): + """Switch 5: DLY for acou; [DLY+REV] for clen, crnc, heavy; disabled for lead.""" + return KemperEffectEnablePerRigCallback( + slot_id=None, + rig_overrides={ + _RIG_ACOU: DLY, + _RIG_CLEN: [DLY, REV], + _RIG_CRNC: [DLY, REV], + _RIG_HEAVY: [DLY, REV], + } + ) + + +############################################################################## +# Tests for EFFECT_STATE_PER_RIG factory +############################################################################## + +class TestEffectStatePerRigFactory(unittest.TestCase): + + def test_factory_returns_push_button_action(self): + action = EFFECT_STATE_PER_RIG( + slot_id=None, + rig_overrides={_RIG_ACOU: X} + ) + self.assertIsInstance(action, PushButtonAction) + self.assertIsInstance(action.callback, KemperEffectEnablePerRigCallback) + + +############################################################################## +# Switch 3: flanger (X slot) — rigs clen, crnc, lead +############################################################################## + +class TestSwitch3CurrentSlots(unittest.TestCase): + + def setUp(self): + self.cb = _sw3_cb() + + def test_rig_acou_disabled(self): + self.cb._rig_id_mapping.value = _RIG_ACOU + self.assertIsNone(self.cb._current_slots()) + + def test_rig_clen_x(self): + self.cb._rig_id_mapping.value = _RIG_CLEN + self.assertEqual(self.cb._current_slots(), [X]) + + def test_rig_crnc_x(self): + self.cb._rig_id_mapping.value = _RIG_CRNC + self.assertEqual(self.cb._current_slots(), [X]) + + def test_rig_heavy_disabled(self): + self.cb._rig_id_mapping.value = _RIG_HEAVY + self.assertIsNone(self.cb._current_slots()) + + def test_rig_lead_x(self): + self.cb._rig_id_mapping.value = _RIG_LEAD + self.assertEqual(self.cb._current_slots(), [X]) + + def test_rig_none_disabled(self): + self.cb._rig_id_mapping.value = None + self.assertIsNone(self.cb._current_slots()) + + def test_unknown_rig_disabled(self): + # Any rig not in rig_overrides must be disabled (slot_id=None default). + self.cb._rig_id_mapping.value = 99 + self.assertIsNone(self.cb._current_slots()) + + +############################################################################## +# Switch 4: [MOD+C] for acou, X for heavy +############################################################################## + +class TestSwitch4CurrentSlots(unittest.TestCase): + + def setUp(self): + self.cb = _sw4_cb() + + def test_rig_acou_mod_and_c(self): + self.cb._rig_id_mapping.value = _RIG_ACOU + self.assertEqual(self.cb._current_slots(), [MOD, C]) + + def test_rig_clen_disabled(self): + self.cb._rig_id_mapping.value = _RIG_CLEN + self.assertIsNone(self.cb._current_slots()) + + def test_rig_crnc_disabled(self): + self.cb._rig_id_mapping.value = _RIG_CRNC + self.assertIsNone(self.cb._current_slots()) + + def test_rig_heavy_x(self): + self.cb._rig_id_mapping.value = _RIG_HEAVY + self.assertEqual(self.cb._current_slots(), [X]) + + def test_rig_lead_disabled(self): + self.cb._rig_id_mapping.value = _RIG_LEAD + self.assertIsNone(self.cb._current_slots()) + + +############################################################################## +# Switch 5: DLY for acou, [DLY+REV] for clen/crnc/heavy, disabled for lead +############################################################################## + +class TestSwitch5CurrentSlots(unittest.TestCase): + + def setUp(self): + self.cb = _sw5_cb() + + def test_rig_acou_dly(self): + self.cb._rig_id_mapping.value = _RIG_ACOU + self.assertEqual(self.cb._current_slots(), [DLY]) + + def test_rig_clen_dly_and_rev(self): + self.cb._rig_id_mapping.value = _RIG_CLEN + self.assertEqual(self.cb._current_slots(), [DLY, REV]) + + def test_rig_crnc_dly_and_rev(self): + self.cb._rig_id_mapping.value = _RIG_CRNC + self.assertEqual(self.cb._current_slots(), [DLY, REV]) + + def test_rig_heavy_dly_and_rev(self): + self.cb._rig_id_mapping.value = _RIG_HEAVY + self.assertEqual(self.cb._current_slots(), [DLY, REV]) + + def test_rig_lead_disabled(self): + self.cb._rig_id_mapping.value = _RIG_LEAD + self.assertIsNone(self.cb._current_slots()) + + +############################################################################## +# state_changed_by_user — multi-slot AND logic +############################################################################## + +class TestMultiSlotToggle(unittest.TestCase): + """Test the AND-logic toggle for multi-slot buttons.""" + + def _init_cb(self, cb): + appl = _MockAppl() + cb.init(appl) + return appl + + # ---- Switch 4, rig acou: [MOD + C] ----------------------------------- # + + def test_sw4_acou_all_on_turns_all_off(self): + cb = _sw4_cb() + appl = self._init_cb(cb) + cb._rig_id_mapping.value = _RIG_ACOU + + # Both ON → pressing turns both OFF + cb._state_map(MOD).value = 1 + cb._state_map(C).value = 1 + appl.client.set_calls.clear() + + cb.state_changed_by_user() + + sent = {c["mapping"]: c["value"] for c in appl.client.set_calls} + self.assertEqual(sent[cb._state_map(MOD)], 0) + self.assertEqual(sent[cb._state_map(C)], 0) + + def test_sw4_acou_not_all_on_turns_all_on(self): + cb = _sw4_cb() + appl = self._init_cb(cb) + cb._rig_id_mapping.value = _RIG_ACOU + + # MOD ON, C OFF → pressing turns both ON + cb._state_map(MOD).value = 1 + cb._state_map(C).value = 0 + appl.client.set_calls.clear() + + cb.state_changed_by_user() + + sent = {c["mapping"]: c["value"] for c in appl.client.set_calls} + self.assertEqual(sent[cb._state_map(MOD)], 1) + self.assertEqual(sent[cb._state_map(C)], 1) + + def test_sw4_acou_both_off_turns_all_on(self): + cb = _sw4_cb() + appl = self._init_cb(cb) + cb._rig_id_mapping.value = _RIG_ACOU + + cb._state_map(MOD).value = 0 + cb._state_map(C).value = 0 + appl.client.set_calls.clear() + + cb.state_changed_by_user() + + sent = {c["mapping"]: c["value"] for c in appl.client.set_calls} + self.assertEqual(sent[cb._state_map(MOD)], 1) + self.assertEqual(sent[cb._state_map(C)], 1) + + # ---- Switch 5, rig clen: [DLY + REV] --------------------------------- # + + def test_sw5_clen_all_on_turns_all_off(self): + cb = _sw5_cb() + appl = self._init_cb(cb) + cb._rig_id_mapping.value = _RIG_CLEN + + cb._state_map(DLY).value = 1 + cb._state_map(REV).value = 1 + appl.client.set_calls.clear() + + cb.state_changed_by_user() + + sent = {c["mapping"]: c["value"] for c in appl.client.set_calls} + self.assertEqual(sent[cb._state_map(DLY)], 0) + self.assertEqual(sent[cb._state_map(REV)], 0) + + def test_sw5_clen_not_all_on_turns_all_on(self): + cb = _sw5_cb() + appl = self._init_cb(cb) + cb._rig_id_mapping.value = _RIG_CLEN + + cb._state_map(DLY).value = 0 + cb._state_map(REV).value = 1 + appl.client.set_calls.clear() + + cb.state_changed_by_user() + + sent = {c["mapping"]: c["value"] for c in appl.client.set_calls} + self.assertEqual(sent[cb._state_map(DLY)], 1) + self.assertEqual(sent[cb._state_map(REV)], 1) + + # ---- Disabled rig: pressing does nothing ----------------------------- # + + def test_sw3_disabled_rig_does_nothing(self): + cb = _sw3_cb() + appl = self._init_cb(cb) + cb._rig_id_mapping.value = _RIG_ACOU # acou is disabled on switch 3 + appl.client.set_calls.clear() + + cb.state_changed_by_user() + + self.assertEqual(appl.client.set_calls, []) + + def test_sw4_disabled_rig_does_nothing(self): + cb = _sw4_cb() + appl = self._init_cb(cb) + cb._rig_id_mapping.value = _RIG_CLEN # clen is disabled on switch 4 + appl.client.set_calls.clear() + + cb.state_changed_by_user() + + self.assertEqual(appl.client.set_calls, []) + + def test_sw5_disabled_rig_does_nothing(self): + cb = _sw5_cb() + appl = self._init_cb(cb) + cb._rig_id_mapping.value = _RIG_LEAD # lead is disabled on switch 5 + appl.client.set_calls.clear() + + cb.state_changed_by_user() + + self.assertEqual(appl.client.set_calls, []) + + +############################################################################## +# Regression: string keys in rig_overrides must be normalized to int +############################################################################## + +class TestRigOverrideKeyNormalization(unittest.TestCase): + """Verify that rig_overrides accepts both int and string keys. + + The RIG_ID mapping always returns an int. String keys like "0", "1" + must be normalized to int in __init__ so _current_slots() can look them + up correctly. See issue #1 (Git commit: fix-string-keys-in-rig-overrides). + """ + + def test_int_key_matches(self): + cb = KemperEffectEnablePerRigCallback( + slot_id=None, + rig_overrides={0: X} + ) + cb._rig_id_mapping.value = 0 + self.assertEqual(cb._current_slots(), [X]) + + def test_string_key_matches_after_normalization(self): + cb = KemperEffectEnablePerRigCallback( + slot_id=None, + rig_overrides={"0": X} + ) + cb._rig_id_mapping.value = 0 + self.assertEqual(cb._current_slots(), [X]) + + def test_mixed_string_and_int_keys(self): + cb = KemperEffectEnablePerRigCallback( + slot_id=None, + rig_overrides={ + 0: DLY, + "1": [DLY, REV], + "2": [DLY, REV], + } + ) + cb._rig_id_mapping.value = 0 + self.assertEqual(cb._current_slots(), [DLY]) + + cb._rig_id_mapping.value = 1 + self.assertEqual(cb._current_slots(), [DLY, REV]) + + cb._rig_id_mapping.value = 2 + self.assertEqual(cb._current_slots(), [DLY, REV]) + + def test_rig_not_in_overrides_is_still_disabled(self): + cb = KemperEffectEnablePerRigCallback( + slot_id=None, + rig_overrides={"0": X} + ) + cb._rig_id_mapping.value = 99 + self.assertIsNone(cb._current_slots()) + + def test_none_rig_id_is_still_disabled(self): + cb = KemperEffectEnablePerRigCallback( + slot_id=None, + rig_overrides={"0": X} + ) + cb._rig_id_mapping.value = None + self.assertIsNone(cb._current_slots()) diff --git a/web/htdocs/definitions/actions.json b/web/htdocs/definitions/actions.json index 00d35fba..d1a324c1 100644 --- a/web/htdocs/definitions/actions.json +++ b/web/htdocs/definitions/actions.json @@ -1573,6 +1573,63 @@ "comment": "Switch an effect slot on / off. This variant has distinct names for each effect type. \n\nUse with care: This takes quite some RAM memory, so if you run a large configuration you might run into memory allocation failures. In this case, just use the normal Effect State action instead.", "importPath": "pyswitch.clients.kemper.actions.effect_state_extended_names" }, + { + "name": "EFFECT_STATE_PER_RIG", + "parameters": [ + { + "name": "slot_id", + "default": null, + "comment": null + }, + { + "name": "rig_overrides", + "default": "{}", + "comment": null + }, + { + "name": "display", + "default": "None", + "comment": null + }, + { + "name": "mode", + "default": "PushButtonAction.HOLD_MOMENTARY", + "comment": null + }, + { + "name": "show_slot_names", + "default": "False", + "comment": null + }, + { + "name": "id", + "default": "False", + "comment": null + }, + { + "name": "text", + "default": "None", + "comment": null + }, + { + "name": "color", + "default": "None", + "comment": null + }, + { + "name": "use_leds", + "default": "True", + "comment": null + }, + { + "name": "enable_callback", + "default": "None", + "comment": null + } + ], + "comment": "Switch an effect slot on / off, with per-rig slot override", + "importPath": "pyswitch.clients.kemper.actions.effect_state_per_rig" + }, { "name": "RIG_SELECT_AND_MORPH_STATE", "parameters": [ diff --git a/web/htdocs/definitions/meta.json b/web/htdocs/definitions/meta.json index ff3f7214..5625085d 100644 --- a/web/htdocs/definitions/meta.json +++ b/web/htdocs/definitions/meta.json @@ -394,6 +394,70 @@ "target": "AdafruitSwitch", "parameters": [] }, + { + "entityName": "EFFECT_STATE_PER_RIG", + "category": "effects", + "target": "AdafruitSwitch", + "parameters": [ + { + "name": "slot_id", + "type": "select", + "values": [ + { + "name": "None (disabled by default)", + "value": "None" + }, + { + "name": "Slot A", + "value": "KemperEffectSlot.EFFECT_SLOT_ID_A" + }, + { + "name": "Slot B", + "value": "KemperEffectSlot.EFFECT_SLOT_ID_B" + }, + { + "name": "Slot C", + "value": "KemperEffectSlot.EFFECT_SLOT_ID_C" + }, + { + "name": "Slot D", + "value": "KemperEffectSlot.EFFECT_SLOT_ID_D" + }, + { + "name": "Slot X", + "value": "KemperEffectSlot.EFFECT_SLOT_ID_X" + }, + { + "name": "Slot MOD", + "value": "KemperEffectSlot.EFFECT_SLOT_ID_MOD" + }, + { + "name": "Slot DLY (with spillover)", + "value": "KemperEffectSlot.EFFECT_SLOT_ID_DLY" + }, + { + "name": "Slot REV (with spillover)", + "value": "KemperEffectSlot.EFFECT_SLOT_ID_REV" + }, + { + "name": "Slot DLY (no spillover)", + "value": "KemperEffectSlot.EFFECT_SLOT_ID_DLY_NO_SPILL" + }, + { + "name": "Slot REV (no spillover)", + "value": "KemperEffectSlot.EFFECT_SLOT_ID_REV_NO_SPILL" + } + ] + }, + { + "name": "rig_overrides", + "type": "rig_map", + "comment": "Per-rig slot override: for each listed rig, this button will control the chosen slot instead of the default one." + } + ], + "comment": "Switch an effect slot on / off, with per-rig slot override. Assign a different effect slot for specific rigs so the same button always controls the right effect regardless of how rigs are organized on the Kemper.", + "importPath": "pyswitch.clients.kemper.actions.effect_state_per_rig" + }, { "entityName": "EFFECT_BUTTON", "category": "effects", diff --git a/web/htdocs/js/Controller.js b/web/htdocs/js/Controller.js index 01982ac4..9574afc3 100644 --- a/web/htdocs/js/Controller.js +++ b/web/htdocs/js/Controller.js @@ -3,8 +3,8 @@ */ class Controller { - static PYSWITCH_VERSION = "2.4.8"; // Version of PySwitch this emulator is designed to run with - static VERSION = Controller.PYSWITCH_VERSION + ".15"; // PySwitch Emulator Version + static PYSWITCH_VERSION = "2.4.9"; // Version of PySwitch this emulator is designed to run with + static VERSION = Controller.PYSWITCH_VERSION + ".16"; // PySwitch Emulator Version ui = null; // User Interface implementation routing = null; // sammy.js router @@ -89,7 +89,7 @@ class Controller { this.handle(e); } - // Initialize UI (settings panel etc.) + // Initialize UI (settings panel etc.) await this.ui.build(); // Routing handler: Runs routing. see Routing.js for the callbacks which in turn call this controller again. diff --git a/web/htdocs/js/PySwitchRunner.js b/web/htdocs/js/PySwitchRunner.js index 22ae907c..8837f95f 100644 --- a/web/htdocs/js/PySwitchRunner.js +++ b/web/htdocs/js/PySwitchRunner.js @@ -126,6 +126,7 @@ class PySwitchRunner { this.#loadModule("parser/misc/ImportExtractor.py", localPythonPath), this.#loadModule("parser/misc/ReplaceAssignmentTransformer.py", localPythonPath), this.#loadModule("parser/misc/AddAssignmentTransformer.py", localPythonPath), + this.#loadModule("parser/misc/RemoveAssignmentTransformer.py", localPythonPath), this.#loadModule("parser/misc/CodeExtractor.py", localPythonPath), this.#loadModule("parser/misc/CodeGenerator.py", localPythonPath), @@ -191,6 +192,7 @@ class PySwitchRunner { this.#loadModule("pyswitch/clients/kemper/actions/amp.py", circuitpyPath), this.#loadModule("pyswitch/clients/kemper/actions/bank_select_encoder.py", circuitpyPath), this.#loadModule("pyswitch/clients/kemper/actions/fixed_fx.py", circuitpyPath), + this.#loadModule("pyswitch/clients/kemper/actions/effect_state_per_rig.py", circuitpyPath), this.#loadModule("pyswitch/clients/kemper/mappings/__init__.py", circuitpyPath), this.#loadModule("pyswitch/clients/kemper/mappings/amp.py", circuitpyPath), diff --git a/web/htdocs/js/model/parser/ParameterMeta.js b/web/htdocs/js/model/parser/ParameterMeta.js index 152777da..505f2a4a 100644 --- a/web/htdocs/js/model/parser/ParameterMeta.js +++ b/web/htdocs/js/model/parser/ParameterMeta.js @@ -245,6 +245,7 @@ class ParameterMeta { case "select-page": return getSelectDefault(); case "bool": return "False"; case "color": return "(0, 0, 0)"; + case "rig_map": return "{}"; default: throw new Error("Unknown parameter type: " + this.data.type); } } diff --git a/web/htdocs/js/model/parser/ParserInput.js b/web/htdocs/js/model/parser/ParserInput.js index 2d1c46ed..f5e9201e 100644 --- a/web/htdocs/js/model/parser/ParserInput.js +++ b/web/htdocs/js/model/parser/ParserInput.js @@ -78,6 +78,50 @@ class ParserInput extends ParserTreeElement { this.parser.updateConfig(); } + /** + * Returns the LED color of the switch (string like "Colors.RED" or "(255,0,0)"), or null if not set. + */ + color() { + this.checkValid() + const arg = this.getArgument("color"); + return (arg && arg.value) ? arg.value : null; + } + + /** + * Sets the LED color of the switch. Pass null to remove (use firmware default). + */ + setColor(color) { + this.checkValid() + if (color) { + this.setArgument("color", color); + } else { + this.removeArgument("color"); + } + this.parser.updateConfig(); + } + + /** + * Returns the LED brightness of the switch [0..1], or null if not set. + */ + brightness() { + this.checkValid() + const arg = this.getArgument("brightness"); + return (arg && arg.value != null && arg.value !== "") ? parseFloat(arg.value) : null; + } + + /** + * Sets the LED brightness of the switch [0..1]. Pass null to remove (use firmware default). + */ + setBrightness(brightness) { + this.checkValid() + if (brightness !== null && brightness !== undefined && brightness !== "") { + this.setArgument("brightness", "" + parseFloat(brightness)); + } else { + this.removeArgument("brightness"); + } + this.parser.updateConfig(); + } + /** * Returns the actions of the input as arrays of ParserInputActions */ diff --git a/web/htdocs/js/model/parser/ParserTreeElement.js b/web/htdocs/js/model/parser/ParserTreeElement.js index fd64dfd9..57207a7b 100644 --- a/web/htdocs/js/model/parser/ParserTreeElement.js +++ b/web/htdocs/js/model/parser/ParserTreeElement.js @@ -60,4 +60,12 @@ class ParserTreeElement { } ) } + + /** + * Removes a specific argument by name. Does nothing if not found. + */ + removeArgument(name) { + this.checkValid() + this.data.arguments = this.data.arguments.filter((arg) => arg.name != name); + } } \ No newline at end of file diff --git a/web/htdocs/js/ui/misc/ParameterList.js b/web/htdocs/js/ui/misc/ParameterList.js index 8b76dd09..7e8ddc68 100644 --- a/web/htdocs/js/ui/misc/ParameterList.js +++ b/web/htdocs/js/ui/misc/ParameterList.js @@ -125,19 +125,30 @@ class ParameterList { async function onChange() { try { + log.clear(); + + const value = options.getValue ? options.getValue(input) : input.val(); + + if (options.validate) { + const error = options.validate(value); + if (error) { + log.error(error); + return; + } + } + if (options.onChange) { - const value = options.getValue ? options.getValue(input) : input.val(); await options.onChange(value, function(newValue) { if (value == newValue) return; - + if (options.setValue) { options.setValue(input, newValue); return; } input.val(newValue); - }); + }); } - + } catch (e) { that.controller.handle(e); } diff --git a/web/htdocs/js/ui/parser/ActionProperties.js b/web/htdocs/js/ui/parser/ActionProperties.js index 28e080b9..961fc2e4 100644 --- a/web/htdocs/js/ui/parser/ActionProperties.js +++ b/web/htdocs/js/ui/parser/ActionProperties.js @@ -482,7 +482,13 @@ class ActionProperties { // Dedicated type for the pager actions's "pages" parameter return this.#pagers.getPagesList(onChange); } - } + + case 'rig_map': { + // Dedicated type for EFFECT_STATE_PER_RIG's rig_overrides parameter. + // Renders a table where each row maps a Bank/Rig pair to an effect slot. + return ActionProperties.#createRigMapInput(onChange); + } + } return $('') .on('change', onChange) @@ -599,7 +605,8 @@ class ActionProperties { switch(type) { case "bool": return input.prop('checked') ? "True" : "False"; case "pages": return this.#pagers.pages.get(); - } + case "rig_map": return ActionProperties.#getRigMapValue(input); + } let value = input.val(); if (value == "") value = param.meta.getDefaultValue(); @@ -623,10 +630,232 @@ class ActionProperties { await this.#pagers.pages.set(value) break; + case "rig_map": + ActionProperties.#setRigMapValue(input, value); + break; + default: input.val(value.replaceAll('"', "'")); input.trigger('change'); - } + } + } + + // ------------------------------------------------------------------------- + // rig_map type: per-rig slot override table for EFFECT_STATE_PER_RIG + // ------------------------------------------------------------------------- + + static #RIG_MAP_SLOTS = [ + { name: "None (disabled)", value: "None" }, + { name: "Slot A", value: "KemperEffectSlot.EFFECT_SLOT_ID_A" }, + { name: "Slot B", value: "KemperEffectSlot.EFFECT_SLOT_ID_B" }, + { name: "Slot C", value: "KemperEffectSlot.EFFECT_SLOT_ID_C" }, + { name: "Slot D", value: "KemperEffectSlot.EFFECT_SLOT_ID_D" }, + { name: "Slot X", value: "KemperEffectSlot.EFFECT_SLOT_ID_X" }, + { name: "Slot MOD", value: "KemperEffectSlot.EFFECT_SLOT_ID_MOD" }, + { name: "Slot DLY (spillover)",value: "KemperEffectSlot.EFFECT_SLOT_ID_DLY" }, + { name: "Slot REV (spillover)",value: "KemperEffectSlot.EFFECT_SLOT_ID_REV" }, + { name: "Slot DLY (no spill)", value: "KemperEffectSlot.EFFECT_SLOT_ID_DLY_NO_SPILL" }, + { name: "Slot REV (no spill)", value: "KemperEffectSlot.EFFECT_SLOT_ID_REV_NO_SPILL" }, + ]; + + /** + * Creates the rig_map table container element. + * onChange is called whenever the user modifies a row. + */ + static #createRigMapInput(onChange) { + const container = $('
'); + container.data('onChange', onChange); + + const table = $('').append( + $('').append( + $('').append( + $(''); + table.append(tbody); + container.append(table); + + const addBtn = $('').append( + $('
').text('Bank'), + $('').text('Rig'), + $('').text('Slot(s)') + ) + ) + ); + const tbody = $('
').append(bankInput), + $('').append(rigSelect), + $('').append(slotsContainer) + ); + removeBtn.on('click', function() { + row.remove(); + onChange(); + }); + tbody.append(row); + } + + /** + * Reads the rig_map table and returns a Python dict string. + * Single slot → absRig: KemperEffectSlot.EFFECT_SLOT_ID_C + * Disabled → absRig: None + * Multi-slot → absRig: [KemperEffectSlot.EFFECT_SLOT_ID_C, KemperEffectSlot.EFFECT_SLOT_ID_MOD] + * Absolute rig ID = (bank - 1) * 5 + (rig - 1) + */ + static #getRigMapValue(container) { + const entries = []; + container.find('tbody tr').each(function() { + const bank = parseInt($(this).find('.rig-bank').val()) || 1; + const rig = parseInt($(this).find('.rig-rig').val()) || 1; + const absRig = (bank - 1) * 5 + (rig - 1); + const slotSelects = $(this).find('.rig-slot'); + if (slotSelects.length === 1) { + entries.push(absRig + ': ' + (slotSelects.val() || ActionProperties.#RIG_MAP_SLOTS[0].value)); + } else { + const slots = slotSelects.map(function() { + return $(this).val() || ActionProperties.#RIG_MAP_SLOTS[1].value; + }).get(); + entries.push(absRig + ': [' + slots.join(', ') + ']'); + } + }); + return '{' + entries.join(', ') + '}'; + } + + /** + * Parses a Python dict string and populates the rig_map table. + * Supports single slots, None, and list values: + * {0: [KemperEffectSlot.EFFECT_SLOT_ID_C, KemperEffectSlot.EFFECT_SLOT_ID_MOD], 4: None} + */ + static #setRigMapValue(container, value, onChange) { + if (onChange === undefined) onChange = container.data('onChange') || function() {}; + const tbody = container.find('tbody'); + tbody.empty(); + + if (!value || value.trim() === '{}') return; + + const inner = value.trim().replace(/^\{/, '').replace(/\}$/, '').trim(); + if (!inner) return; + + // Split at top-level commas (not inside [...]) + const pairs = []; + let depth = 0, current = ''; + for (const ch of inner) { + if (ch === '[') depth++; + else if (ch === ']') depth--; + if (ch === ',' && depth === 0) { + pairs.push(current.trim()); + current = ''; + } else { + current += ch; + } + } + if (current.trim()) pairs.push(current.trim()); + + for (const pair of pairs) { + const colonIdx = pair.indexOf(':'); + if (colonIdx < 0) continue; + const absRigStr = pair.substring(0, colonIdx).trim(); + const slotStr = pair.substring(colonIdx + 1).trim(); + const absRig = parseInt(absRigStr.replace(/['"]/g, '').trim()); + if (isNaN(absRig)) continue; + const bank = Math.floor(absRig / 5) + 1; + const rig = (absRig % 5) + 1; + + let slotValues; + if (slotStr.startsWith('[') && slotStr.endsWith(']')) { + const listInner = slotStr.slice(1, -1).trim(); + slotValues = listInner.split(',').map(s => s.trim()).filter(s => s.length > 0); + } else { + slotValues = [slotStr]; + } + + ActionProperties.#addRigMapRow(tbody, bank, rig, slotValues, onChange); + } } /** diff --git a/web/htdocs/js/ui/parser/InputSettings.js b/web/htdocs/js/ui/parser/InputSettings.js index 7e7ba0b3..d609ea49 100644 --- a/web/htdocs/js/ui/parser/InputSettings.js +++ b/web/htdocs/js/ui/parser/InputSettings.js @@ -6,8 +6,8 @@ class InputSettings extends ParameterList { #definition = null; #input = null; - constructor(controller, definition, input) { - super(controller) + constructor(controller, definition, input, parser = null) { + super(controller, parser) this.#definition = definition; this.#input = input; } @@ -58,9 +58,52 @@ class InputSettings extends ParameterList { }); } + async function getLedOptions() { + await that.createColorInput({ + name: "color", + displayName: "LED Color", + comment: "Color of the switch LEDs when no action controls them. Leave empty to use the firmware default (white).", + value: that.#input ? (that.#input.color() || "") : "", + onChange: async function(value) { + that.#input.setColor(value || null); + + await that.controller.restart({ + message: "none" + }); + } + }); + + await that.createNumericInput({ + name: "brightness", + displayName: "LED Brightness", + comment: "Brightness of the switch LEDs when no action controls them. Range: [0..1]. Leave empty to use the firmware default (off).", + value: that.#input ? (that.#input.brightness() !== null ? that.#input.brightness() : "") : "", + range: { + min: 0, + max: 1, + step: 0.01 + }, + validate: function(value) { + if (value === "") return null; + const n = parseFloat(value); + if (isNaN(n) || n < 0 || n > 1) return "Must be between 0 and 1"; + return null; + }, + onChange: async function(value) { + const parsed = value !== "" ? parseFloat(value) : null; + that.#input.setBrightness(parsed); + + await that.controller.restart({ + message: "none" + }); + } + }); + } + switch (this.#definition.data.model.type) { - case "AdafruitSwitch": + case "AdafruitSwitch": await getSwitchOptions(); + await getLedOptions(); break; } } diff --git a/web/htdocs/js/ui/parser/ParserFrontendInput.js b/web/htdocs/js/ui/parser/ParserFrontendInput.js index 38b80b54..ffe72cf8 100644 --- a/web/htdocs/js/ui/parser/ParserFrontendInput.js +++ b/web/htdocs/js/ui/parser/ParserFrontendInput.js @@ -187,7 +187,7 @@ class ParserFrontendInput { }), // Input settings button - !(await (new InputSettings(this.#controller, this.definition, this.input).get())) ? null : + !(await (new InputSettings(this.#controller, this.definition, this.input, this.#parserFrontend.parser).get())) ? null : $('
') .on('click', async function() { try { @@ -518,6 +518,7 @@ class ParserFrontendInput { const browser = this.#controller.ui.getPopup({ onReturnKey: commit, + additionalClasses: "medium", buttons: [ { text: "Done", @@ -529,7 +530,8 @@ class ParserFrontendInput { const props = new InputSettings( this.#controller, this.definition, - this.input + this.input, + this.#parserFrontend.parser ); const propsContent = await props.get(); diff --git a/web/htdocs/python/parser/PySwitchParser.py b/web/htdocs/python/parser/PySwitchParser.py index 8e3e8bdb..227d8760 100644 --- a/web/htdocs/python/parser/PySwitchParser.py +++ b/web/htdocs/python/parser/PySwitchParser.py @@ -12,6 +12,7 @@ from .misc.ImportExtractor import ImportExtractor from .misc.ReplaceAssignmentTransformer import ReplaceAssignmentTransformer from .misc.AddAssignmentTransformer import AddAssignmentTransformer +from .misc.RemoveAssignmentTransformer import RemoveAssignmentTransformer from .misc.ClassNameExtractor import ClassNameExtractor class PySwitchParser: @@ -116,18 +117,32 @@ def set_splashes(self, splashes): # Remove first assign as this can lead to endless recursion and is not relevant anyway splashes_py = splashes.to_py() - if "assign" in splashes_py: + if "assign" in splashes_py: splashes_py["assign"] = None + # Collect all assign names referenced in the new tree BEFORE code generation, + # so we can remove stale DisplayLabel constants that are no longer referenced. + referenced_assigns = self._collect_assign_names(splashes_py) + splashes_node = CodeGenerator( - parser = self, - file_id = "display_py", + parser = self, + file_id = "display_py", insert_before_assign = "Splashes", format = True ).generate(splashes_py) - + self.set_assignment("Splashes", splashes_node, "display_py") + # Remove orphaned DisplayLabel assignments that are no longer used in Splashes + # and not referenced in inputs.py (the JS frontend already prevents removal + # of referenced labels, but we double-check here for safety). + current_names = AssignmentNameExtractor().get(self.__csts["display_py"]) + for name in current_names: + if name.startswith("_") or name == "Splashes": + continue + if name not in referenced_assigns: + self.remove_assignment(name, "display_py") + ######################################################################################## # Adds or replaces the given assignment by name. @@ -138,14 +153,33 @@ def set_assignment(self, name, call_node, file_id, insert_before_assign = None): return adder = AddAssignmentTransformer( - name, - call_node, - insert_before_assign = insert_before_assign, + name, + call_node, + insert_before_assign = insert_before_assign, cst = self.__csts[file_id] if not insert_before_assign else None ) self.__csts[file_id] = self.__csts[file_id].visit(adder) + # Remove the given assignment by name. + def remove_assignment(self, name, file_id): + remover = RemoveAssignmentTransformer(name) + self.__csts[file_id] = self.__csts[file_id].visit(remover) + + # Recursively collect all "assign" names from a parsed tree dict. + def _collect_assign_names(self, data, names = None): + if names is None: + names = set() + if isinstance(data, dict): + if "assign" in data and data["assign"]: + names.add(data["assign"]) + for value in data.values(): + self._collect_assign_names(value, names) + elif isinstance(data, list): + for item in data: + self._collect_assign_names(item, names) + return names + ######################################################################################## # Remove unused imports on all files. Does no config update! diff --git a/web/htdocs/python/parser/misc/RemoveAssignmentTransformer.py b/web/htdocs/python/parser/misc/RemoveAssignmentTransformer.py new file mode 100644 index 00000000..ea697380 --- /dev/null +++ b/web/htdocs/python/parser/misc/RemoveAssignmentTransformer.py @@ -0,0 +1,24 @@ +import libcst +from .VisitorsWithStack import TransformerWithStack + + +class RemoveAssignmentTransformer(TransformerWithStack): + """Remove a top-level assignment by name.""" + + def __init__(self, name): + super().__init__() + self._name = name + self.removed = False + + def leave_SimpleStatementLine(self, original_node, updated_node): + # Module body level (stack = [Module, SimpleStatementLine]) + if len(self.stack) != 1: + return updated_node + + if len(updated_node.body) == 1 and isinstance(updated_node.body[0], libcst.Assign): + for target in updated_node.body[0].targets: + if isinstance(target.target, libcst.Name) and target.target.value == self._name: + self.removed = True + return libcst.RemovalSentinel.REMOVE + + return updated_node diff --git a/web/htdocs/python/wrappers/wrap_adafruit_led.py b/web/htdocs/python/wrappers/wrap_adafruit_led.py index 1d147a20..d7f4f489 100644 --- a/web/htdocs/python/wrappers/wrap_adafruit_led.py +++ b/web/htdocs/python/wrappers/wrap_adafruit_led.py @@ -23,13 +23,15 @@ def __setitem__(self, key, value): # Store original value for testing led.dataset.color = [v for v in value] - # When black, make transparent + # When black, show as off (dark) if value == (0, 0, 0): - led.style.backgroundColor = f"rgba(0, 0, 0, 0)" + led.style.backgroundColor = f"rgb(0, 0, 0)" return # Apply gamma correction def trans(x, gamma = 0.28): + if x <= 0: + return 0 return pow((x/255), gamma) * 255 def clip(x): diff --git a/web/htdocs/styles/list-browser.css b/web/htdocs/styles/list-browser.css index 56c931e4..48ab72bc 100644 --- a/web/htdocs/styles/list-browser.css +++ b/web/htdocs/styles/list-browser.css @@ -32,6 +32,10 @@ border-radius: 1.5em; } +.container .list-browser.medium { + max-width: 750px; +} + .container .list-browser.wide { max-width: 1400px; max-height: 1200px; diff --git a/web/htdocs/styles/parser-action-properties.css b/web/htdocs/styles/parser-action-properties.css index e729e7cc..314260b2 100644 --- a/web/htdocs/styles/parser-action-properties.css +++ b/web/htdocs/styles/parser-action-properties.css @@ -133,3 +133,86 @@ .container .action-properties select.parameter-option { max-width: 8em; } + +/**********************************************************************************/ +/* rig_map: per-rig slot override table */ + +.container .action-properties .rig-map-container { + display: flex; + flex-direction: column; + gap: 0.5em; +} + +.container .action-properties .rig-map-table { + border-collapse: collapse; + width: 100%; +} + +.container .action-properties .rig-map-table th, +.container .action-properties .rig-map-table td { + border: none; + padding: 0.25em 0.4em; + vertical-align: middle; +} + +.container .action-properties .rig-map-table th { + font-weight: bold; + font-size: 0.85em; + color: gray; + text-align: left; +} + +.container .action-properties .rig-slots-td { + min-width: 12em; +} + +.container .action-properties .rig-slots-container { + display: flex; + flex-direction: column; + gap: 0.2em; + align-items: flex-start; + position: relative; +} + +.container .action-properties .rig-slot-row { + display: flex; + align-items: center; + gap: 0.3em; +} + +.container .action-properties .rig-slot-add, +.container .action-properties .rig-slot-remove { + font-size: 0.85em; + padding: 0.1em 0.4em; + cursor: pointer; + border-radius: 0.3em; +} + +.container .action-properties .rig-slot-add:disabled { + opacity: 0.4; + cursor: default; +} + +.container .action-properties .rig-map-add { + align-self: flex-start; + font-size: 0.85em; +} + +.container .action-properties .rig-remove { + display: inline-block; + font-size: 0.8em; + padding: 0.1em 0.45em; + cursor: pointer; + border-radius: 0.3em; + color: #555; + border: 1px solid #999; + background: transparent; + line-height: 1.4; + margin-top: 0.15em; + align-self: flex-start; +} + +.container .action-properties .rig-remove:hover { + background: #ddd; + color: #222; +} diff --git a/web/serve.py b/web/serve.py new file mode 100644 index 00000000..f47cfe6d --- /dev/null +++ b/web/serve.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +Minimal HTTP server for PySwitch web editor. +Replaces Apache + PHP: handles toc.php requests and serves static files. + +Usage: + python serve.py [port] default port: 8080 + +Open Chrome at http://localhost:8080 +(Chrome required for Web MIDI API) +""" +import http.server +import json +import mimetypes +import sys +import threading +import time +import urllib.parse +from pathlib import Path + +# Explicit MIME type table — overrides the Windows registry which often maps +# .js to text/html, causing Chrome to refuse script execution. +_MIME_TYPES = { + ".html": "text/html; charset=utf-8", + ".htm": "text/html; charset=utf-8", + ".css": "text/css", + ".js": "application/javascript", + ".mjs": "application/javascript", + ".json": "application/json", + ".wasm": "application/wasm", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2":"font/woff2", + ".ttf": "font/ttf", + ".otf": "font/otf", + ".eot": "application/vnd.ms-fontobject", + ".txt": "text/plain", + ".py": "text/plain", + ".md": "text/plain", + ".map": "application/json", +} + +BASE_DIR = Path(__file__).parent.resolve() +HTDOCS = BASE_DIR / "htdocs" +CONTENT = BASE_DIR.parent / "content" +EXAMPLES = BASE_DIR.parent / "examples" + +# Order matters: longest prefix first +MOUNTS = [ + ("/circuitpy", CONTENT), + ("/examples", EXAMPLES), +] + +def resolve_path(url_path: str) -> Path: + """Map a URL path to a filesystem path, honouring volume mounts.""" + for prefix, fs_root in MOUNTS: + if url_path == prefix or url_path.startswith(prefix + "/"): + rel = url_path[len(prefix):].lstrip("/") + return fs_root / rel + return HTDOCS / url_path.lstrip("/") + + +def make_toc(directory: Path) -> dict: + """ + Replicate the JSON output of toc.php. + {"type":"dir","name":"","path":"","children":[...]} + """ + def fill(d: Path) -> list: + nodes = [] + try: + entries = sorted(d.iterdir(), key=lambda e: (e.is_file(), e.name)) + except PermissionError: + return nodes + for entry in entries: + if entry.name.startswith("."): + continue + if entry.is_dir(): + nodes.append({"type": "dir", "name": entry.name, + "children": fill(entry)}) + elif entry.is_file(): + nodes.append({"type": "file", "name": entry.name}) + return nodes + + return {"type": "dir", "name": "", "path": "", + "children": fill(directory)} + + +class PySwitchHandler(http.server.BaseHTTPRequestHandler): + + def do_HEAD(self): + # Reuse GET logic but discard the body + self._head_only = True + self.do_GET() + self._head_only = False + + def do_GET(self): + if not hasattr(self, '_head_only'): + self._head_only = False + parsed = urllib.parse.urlparse(self.path) + url_path = urllib.parse.unquote(parsed.path) + fs_path = resolve_path(url_path) + + # ---- toc.php ------------------------------------------------------- + if fs_path.name == "toc.php": + directory = fs_path.parent + if not directory.is_dir(): + self._send_error(404, f"Directory not found: {directory}") + return + body = json.dumps(make_toc(directory), separators=(",", ":")).encode() + self._send_bytes(body, "application/json") + return + + # ---- directory → index.html ---------------------------------------- + if fs_path.is_dir(): + fs_path = fs_path / "index.html" + + # ---- static file --------------------------------------------------- + if not fs_path.exists() or not fs_path.is_file(): + self._send_error(404, f"Not found: {url_path}") + return + + suffix = fs_path.suffix.lower() + content_type = _MIME_TYPES.get(suffix) or mimetypes.guess_type(str(fs_path))[0] or "application/octet-stream" + try: + data = fs_path.read_bytes() + except OSError as exc: + self._send_error(500, str(exc)) + return + self._send_bytes(data, content_type) + + # ------------------------------------------------------------------ + def _send_bytes(self, data: bytes, content_type: str): + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + if not self._head_only: + self.wfile.write(data) + + def _send_error(self, code: int, msg: str = ""): + body = msg.encode() + self.send_response(code) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def handle_error(self, request, client_address): + # Silently ignore abrupt client disconnections (e.g. browser reload/cancel). + exc = sys.exc_info()[1] + if isinstance(exc, (ConnectionResetError, BrokenPipeError)): + return + super().handle_error(request, client_address) + + def log_message(self, fmt, *args): + # Log everything so we can spot 404s and errors in the terminal + super().log_message(fmt, *args) + + +if __name__ == "__main__": + port = int(sys.argv[1]) if len(sys.argv) > 1 else 9090 + server = http.server.HTTPServer(("0.0.0.0", port), PySwitchHandler) + url = f"http://localhost:{port}" + print(f"PySwitch editor running at {url}") + print("Open this URL in Chrome (required for Web MIDI)") + print("Press Ctrl+C to stop\n") + t = threading.Thread(target=server.serve_forever, daemon=True) + t.start() + try: + while t.is_alive(): + time.sleep(0.5) + except KeyboardInterrupt: + server.shutdown() + print("\nStopped.")