From bab006928f0be6fb2b4cf3e89c8ed23b40599150 Mon Sep 17 00:00:00 2001 From: Thalys Gomes Date: Mon, 23 Feb 2026 21:23:22 +0000 Subject: [PATCH 01/12] fix(kemper): correct effect type categories, ranges and LED colors - Add CYAN color to colors.py (was missing, used for Compressor/Gate) - Fix effect type number ranges to match Kemper MIDI spec (Appendix B) - Add 4 missing categories: Tremolo, Rotary, Vibrato, Slicer/Autopanner (previously all misidentified as Chorus) - Fix all LED colors to match official Kemper Profiler Manual: Compressor/Gate=Cyan, Looper=Pink, Chorus family=Blue, Dual/Pitch Delays=Light Green, Booster=Red - Fix Delay range (145-166) and Reverb range (177-193) for 14-bit decoded NRPN values - Unknown type values now return CATEGORY_NONE instead of CATEGORY_REVERB --- .../clients/kemper/actions/effect_state.py | 270 ++++++++++++------ content/lib/pyswitch/colors.py | 3 +- .../test_kemper_action_effect_state.py | 160 +++++++---- 3 files changed, 290 insertions(+), 143 deletions(-) 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/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/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, "") From 30a223507f4e4c3faced62c0cf50ca84d198be0c Mon Sep 17 00:00:00 2001 From: Danilo Migliarino Date: Wed, 22 Apr 2026 08:20:33 +0200 Subject: [PATCH 02/12] Add EFFECT_STATE_PER_RIG: per-rig effect slot override Adds a new action EFFECT_STATE_PER_RIG that 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. - content/lib/pyswitch/clients/kemper/actions/effect_state_per_rig.py: KemperEffectEnablePerRigCallback tracks RIG_ID via MIDI and routes LED/display updates and MIDI CC sends to the correct slot per rig. - web/htdocs/definitions/actions.json: register EFFECT_STATE_PER_RIG - web/htdocs/definitions/meta.json: add rig_map parameter type entry - web/htdocs/js/ui/parser/ActionProperties.js: add rig_map UI widget (Bank/Rig/Slot table with add/remove rows, round-trip serialization) Co-Authored-By: Claude Sonnet 4.6 --- .../kemper/actions/effect_state_per_rig.py | 148 ++++++++++++++++++ web/htdocs/definitions/actions.json | 57 +++++++ web/htdocs/definitions/meta.json | 12 ++ web/htdocs/js/ui/parser/ActionProperties.js | 141 ++++++++++++++++- 4 files changed, 355 insertions(+), 3 deletions(-) create mode 100644 content/lib/pyswitch/clients/kemper/actions/effect_state_per_rig.py 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..8296b7c4 --- /dev/null +++ b/content/lib/pyswitch/clients/kemper/actions/effect_state_per_rig.py @@ -0,0 +1,148 @@ +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. +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 + ): + # rig_overrides: dict mapping absolute rig IDs to slot IDs. + # absolute rig ID = (bank - 1) * 5 + (rig - 1) + # where bank is 1-based and rig is 1-5. + # Example: + # 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 + # } + 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). + rig_overrides: Dict mapping absolute rig IDs to slot IDs. + Absolute rig ID = (bank - 1) * 5 + (rig - 1) + where bank is 1-based and rig is 1-5. + + When the Kemper changes rig, the button automatically controls the slot + specified in rig_overrides for that rig, or falls back to slot_id. + LED color and display label update automatically to reflect the new slot's effect type. + """ + + def __init__(self, slot_id, rig_overrides, **kwargs): + super().__init__(slot_id, **kwargs) + + self._default_slot = slot_id + self._rig_overrides = rig_overrides + + # Pre-create state and type mappings for all override slots that differ from the default. + # These are registered so the client tracks their state via bidirectional MIDI. + override_slots = set(rig_overrides.values()) - {slot_id} + self._override_state_maps = {} + self._override_type_maps = {} + for slot in override_slots: + 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_slot(self): + """Return the effective slot ID for the current rig.""" + rig = self._rig_id_mapping.value + if rig is None: + return self._default_slot + return self._rig_overrides.get(rig, self._default_slot) + + def state_changed_by_user(self): + """Send MIDI CC to toggle the effect on the currently active slot.""" + slot = self._current_slot() + if slot == self._default_slot: + super().state_changed_by_user() + else: + mapping = self._override_state_maps[slot] + # Toggle: if the slot is currently on (value=1), turn it off, and vice versa. + value = 0 if (mapping.value == 1) else 1 + self._appl_ref.client.set(mapping, value) + + def update_displays(self): + """Update LED and display label for the currently active slot.""" + slot = self._current_slot() + if slot == self._default_slot: + super().update_displays() + else: + # Reset the internal effect-type cache so EffectEnableCallback re-evaluates + # the label text and color for the override slot. + self._EffectEnableCallback__current_kpp_type = None + + # Temporarily redirect self.mapping and self.mapping_fxtype to the override slot + # so the parent update_displays() reads from the right slot. + orig_mapping = self.mapping + orig_fxtype = self.mapping_fxtype + self.mapping = self._override_state_maps[slot] + self.mapping_fxtype = self._override_type_maps[slot] + super().update_displays() + self.mapping = orig_mapping + self.mapping_fxtype = orig_fxtype + + # Reset again so that when we switch back to the default slot + # the label is re-evaluated instead of being skipped by the cache. + self._EffectEnableCallback__current_kpp_type = None + + 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 override slot changes state. + """ + if mapping is self._rig_id_mapping: + # Rig changed: update display to reflect the new slot. + self.update_displays() + else: + active_slot = self._current_slot() + if active_slot != self._default_slot and ( + mapping is self._override_state_maps.get(active_slot) or + mapping is self._override_type_maps.get(active_slot) + ): + # Active override slot state or type changed: update display. + self.update_displays() + else: + super().parameter_changed(mapping) 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..5b8c1dc4 100644 --- a/web/htdocs/definitions/meta.json +++ b/web/htdocs/definitions/meta.json @@ -394,6 +394,18 @@ "target": "AdafruitSwitch", "parameters": [] }, + { + "entityName": "EFFECT_STATE_PER_RIG", + "parameters": [ + { + "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/ui/parser/ActionProperties.js b/web/htdocs/js/ui/parser/ActionProperties.js index 28e080b9..e6114c4e 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,138 @@ class ActionProperties { await this.#pagers.pages.set(value) break; + case "rig_map": + ActionProperties.#setRigMapValue(input, value, onChange); + 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: "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 = $('
'); + + const table = $('').append( + $('').append( + $('').append( + $(''); + table.append(tbody); + container.append(table); + + const addBtn = $('').append( + $('
').text('Bank'), + $('').text('Rig'), + $('').text('Slot'), + $('') + ) + ) + ); + const tbody = $('
').append(bankInput), + $('').append(rigSelect), + $('').append(slotSelect), + $('').append(removeBtn) + ); + removeBtn.on('click', function() { + row.remove(); + onChange(); + }); + tbody.append(row); + } + + /** + * Reads the rig_map table and returns a Python dict string. + * e.g. {2: KemperEffectSlot.EFFECT_SLOT_ID_C, 5: KemperEffectSlot.EFFECT_SLOT_ID_DLY} + * 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 slot = $(this).find('.rig-slot').val() || ActionProperties.#RIG_MAP_SLOTS[0].value; + const absRig = (bank - 1) * 5 + (rig - 1); + entries.push(absRig + ': ' + slot); + }); + return '{' + entries.join(', ') + '}'; + } + + /** + * Parses a Python dict string and populates the rig_map table. + * e.g. "{2: KemperEffectSlot.EFFECT_SLOT_ID_C, 5: KemperEffectSlot.EFFECT_SLOT_ID_DLY}" + */ + static #setRigMapValue(container, value, onChange) { + const tbody = container.find('tbody'); + tbody.empty(); + + if (!value || value.trim() === '{}') return; + + // Strip braces and split by comma, handling potential spaces + const inner = value.trim().replace(/^\{/, '').replace(/\}$/, '').trim(); + if (!inner) return; + + const pairs = inner.split(',').map(s => s.trim()).filter(s => s.length > 0); + 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); + if (isNaN(absRig)) continue; + const bank = Math.floor(absRig / 5) + 1; + const rig = (absRig % 5) + 1; + ActionProperties.#addRigMapRow(tbody, bank, rig, slotStr, onChange); + } } /** From 8dd994202164821e4c6be00b1b9d0db62efa8949 Mon Sep 17 00:00:00 2001 From: Danilo Migliarino Date: Wed, 22 Apr 2026 08:24:26 +0200 Subject: [PATCH 03/12] Add installation and usage guide for EFFECT_STATE_PER_RIG Self-contained HTML page covering: - Problem explanation with visual slot diagrams - Device installation (file copy path) - Web editor patch installation - Manual inputs.py configuration with rig_overrides syntax - Web editor UI walkthrough with UI mockup - Absolute rig ID formula and reference table - Practical configuration examples Co-Authored-By: Claude Sonnet 4.6 --- docs/effect_state_per_rig.html | 654 +++++++++++++++++++++++++++++++++ 1 file changed, 654 insertions(+) create mode 100644 docs/effect_state_per_rig.html diff --git a/docs/effect_state_per_rig.html b/docs/effect_state_per_rig.html new file mode 100644 index 00000000..958c0e28 --- /dev/null +++ b/docs/effect_state_per_rig.html @@ -0,0 +1,654 @@ + + + + + + EFFECT_STATE_PER_RIG — Guida all'installazione e all'uso + + + +
+ +
+
PySwitch — estensione custom
+

EFFECT_STATE_PER_RIG

+

Assegna slot effetto diversi per rig diversi. Lo stesso pulsante controlla sempre l'effetto giusto, indipendentemente da come sono organizzati gli slot sul Kemper.

+
+ + + + +
+

1 · Il problema che risolve

+

Nel Kemper ogni rig ha gli effetti distribuiti in slot (A, B, C, D, X, MOD, DLY, REV). Con PySwitch, ogni pulsante controlla uno slot specifico — per esempio il pulsante 1 controlla sempre lo slot A. Il problema nasce quando slot diversi contengono tipi di effetti diversi a seconda del 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 + + Pulsante 1 (Slot A): attiva Compressor su Rig 1, ma Distortion su Rig 2 e Wah su Rig 3 +
+ +

Con EFFECT_STATE_PER_RIG puoi specificare uno slot di default e poi override per rig specifici. Quando cambi rig, il pulsante punta automaticamente allo slot corretto, aggiornando LED e display in tempo reale.

+ +
+ Pulsante 1 Pulsante 2 Pulsante 3 Pulsante 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 · Installazione sul device (MIDICaptain)

+

Occorre copiare un unico file Python nella cartella degli effetti Kemper sul device.

+ +
+
+
+
+

Monta il device come USB

+

Tieni premuto il pulsante 1 mentre accendi il MIDICaptain. Sul computer apparirà il drive MIDICAPTAIN.

+
+
+
+
+
+

Copia il file

+

Copia il file dalla cartella del progetto:

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

nella stessa posizione sul drive del device:

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

Smonta e riavvia

+

Smonta il drive correttamente, poi riavvia il controller.

+
+
+
+ +
+ Attenzione + Il file deve trovarsi nella stessa cartella degli altri file di azione Kemper (effect_state.py, rig_select.py, ecc.), altrimenti le importazioni relative falliranno. +
+
+ + +
+

3 · Installazione nel web editor

+

Se usi il PySwitch Emulator (versione online) non puoi modificarlo direttamente. Devi usare una versione locale. Se hai già clonato il progetto in C:\Users\…\Desktop\pyswitch\, i file sono già stati patchati. Per altre installazioni, usa lo script incluso.

+ +

Patch automatica via script

+

Esegui lo script Python dalla cartella web_patch/ (disponibile nel branch feature/effect-state-per-rig):

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

Lo script aggiunge automaticamente le definizioni di EFFECT_STATE_PER_RIG a meta.json e actions.json senza riformattare il resto del file.

+ +

Sostituzione di ActionProperties.js

+

Copia il file patchato nella posizione corretta:

+ +
📁 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
+ +
+ Nel branch feature/effect-state-per-rig + Se hai usato il branch già preparato, tutte e tre le modifiche al web editor (actions.json, meta.json, ActionProperties.js) sono già incluse nel repository. Non serve eseguire nulla — basta aprire il web editor dalla cartella locale. +
+ +

Apertura del web editor locale

+

Apri il file web/htdocs/index.html con un server locale (necessario per il funzionamento del WASM). Con Python:

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

Poi apri http://localhost:8080 nel browser.

+
+ + +
+

4 · Configurazione manuale in inputs.py

+

Aggiungi l'import in cima al file e sostituisci EFFECT_STATE con EFFECT_STATE_PER_RIG per i pulsanti che richiedono override.

+ +
from pyswitch.clients.kemper.actions.effect_state_per_rig import EFFECT_STATE_PER_RIG
+from pyswitch.clients.kemper import KemperEffectSlot
+
+# ID assoluto del rig = (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   ecc.
+
+Inputs = [
+    # Pulsante 1: default Slot A, ma per certi rig usa slot diverso
+    {
+        "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
+            ),
+        ],
+    },
+    # Pulsanti senza override: rimangono con EFFECT_STATE normale
+    {
+        "assignment": PA_MIDICAPTAIN_10_SWITCH_2,
+        "actions": [
+            EFFECT_STATE(slot_id = KemperEffectSlot.EFFECT_SLOT_ID_B, display = DISPLAY_HEADER_2),
+        ],
+    },
+    # ...
+]
+ +
+ Parametri disponibili + EFFECT_STATE_PER_RIG accetta tutti i parametri di EFFECT_STATE (display, mode, text, color, show_slot_names, use_leds, enable_callback) più il parametro obbligatorio rig_overrides. +
+
+ + +
+

5 · Configurazione tramite web editor

+

Dopo aver applicato le patch, la nuova azione compare nella lista degli effetti Kemper con il nome EFFECT_STATE_PER_RIG.

+ +

Come aggiungere l'azione a un pulsante

+
+
+
+
+

Nel pannello di configurazione del pulsante, clicca su + Add action e cerca EFFECT_STATE_PER_RIG nella categoria Effects.

+
+
+
+
+
+

Seleziona il Slot di default dal menu a tendina (Slot A, B, C, D, X, MOD, DLY, REV, ecc.). Questo slot verrà usato per tutti i rig senza override.

+
+
+
+
+
+

Nella sezione Rig Overrides aggiungi le eccezioni rig per rig.

+
+
+
+ +

Interfaccia Rig Overrides

+
+
+ Slot + Slot A +
+
+ Display + DISPLAY_HEADER_1 +
+
+ Rig Overrides +
+
Override per-rig: slot alternativo per specifici bank/rig
+ + + + + + + + + + + + + + + + + + + + + + + +
BankRigSlot
13Slot C
21Slot DLY (spillover)
+ + Add override +
+
+
+ +

Funzionamento della tabella

+
    +
  • Bank: numero del banco (1 – 125)
  • +
  • Rig: posizione del rig nel banco (1 – 5)
  • +
  • Slot: lo slot effetto da usare quando questo rig è attivo
  • +
  • + Add override: aggiunge una nuova riga alla tabella
  • +
  • : rimuove la riga corrispondente
  • +
+ +
+ Round-trip codice ↔ UI + La tabella si popola automaticamente anche quando carichi la configurazione da codice Python esistente. Puoi passare liberamente tra editor grafico e editor di testo senza perdere i dati. +
+ +

Salvataggio

+

Usa il pulsante Save → Connected Controllers (via MIDI) oppure Save → Download (ZIP) e copia i file sul device via USB come descritto nella sezione Installazione.

+
+ + +
+

6 · Come calcolare l'ID assoluto del rig

+

Il web editor chiede Bank e Rig separatamente, quindi non devi fare calcoli. Se invece configuri inputs.py manualmente, il dizionario rig_overrides usa ID assoluti calcolati con questa formula:

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

Dove bank e rig sono entrambi 1-based (il primo banco è 1, il primo rig è 1).

+ +

Tabella di riferimento rapido

+
+ 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 +
+ +
+ Inversione + Dato un ID assoluto n, puoi ricavare bank e rig con:
+ bank = floor(n / 5) + 1    rig = (n % 5) + 1 +
+
+ + +
+

7 · Esempi pratici

+ +

Scenario A — Compressore sempre sul pulsante 1

+

Nella maggior parte dei rig il compressore è nello Slot A. Solo nel Bank 1 Rig 4 e Bank 2 Rig 2 è nello 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 sempre sul pulsante UP (con Reverb)

+

In alcuni rig il delay è nello Slot DLY standard, in altri è nello Slot X.

+ +
# Pulsante UP: Delay + Reverb con override sul delay per certi rig
+{
+    "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 — Override su tutti i pulsanti stomp

+

Configurazione completa dove ogni pulsante ha il proprio set di override:

+ +
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)],  # nessun override necessario
+    },
+]
+ +
+ Suggerimento + Puoi usare EFFECT_STATE normale per i pulsanti che non hanno bisogno di override. I due tipi di azione sono compatibili e possono coesistere nello stesso inputs.py. +
+
+ +
+
+

Estensione sviluppata per PySwitch — branch feature/effect-state-per-rig

+
+ +
+ + From 5bc4482345ef1983260bd948f9df15c8d5657e2f Mon Sep 17 00:00:00 2001 From: Danilo Migliarino Date: Thu, 23 Apr 2026 00:46:41 +0200 Subject: [PATCH 04/12] 2.4.9: Add EFFECT_STATE_PER_RIG with web editor support - None slot support: button disabled for specific rigs (LED off, label cleared) - Web editor: fix rig_map parameter type registration in meta.json (added category/target so action appears in Effects list) - Web editor: fix ActionProperties onChange scope bug - Web editor: fix rig_map round-trip deserialization (parseInt on quoted keys) - Web editor: fix ParameterMeta.getDefaultValue for rig_map type - Web editor: register effect_state_per_rig.py in PySwitchRunner module list - Web editor: bump to 2.4.9.16 - serve.py: local dev server replacing Apache+PHP (Ctrl+C fix, connection reset handling) - docs: translate effect_state_per_rig.html to English - Bump firmware version to 2.4.9 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 8 + .../kemper/actions/effect_state_per_rig.py | 13 +- content/lib/pyswitch/misc.py | 2 +- docs/effect_state_per_rig.html | 180 +++++++++--------- web/htdocs/definitions/meta.json | 26 +-- web/htdocs/js/Controller.js | 4 +- web/htdocs/js/PySwitchRunner.js | 1 + web/htdocs/js/model/parser/ParameterMeta.js | 1 + web/htdocs/js/ui/parser/ActionProperties.js | 7 +- web/serve.py | 179 +++++++++++++++++ 10 files changed, 313 insertions(+), 108 deletions(-) create mode 100644 web/serve.py 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/content/lib/pyswitch/clients/kemper/actions/effect_state_per_rig.py b/content/lib/pyswitch/clients/kemper/actions/effect_state_per_rig.py index 8296b7c4..711c38c2 100644 --- a/content/lib/pyswitch/clients/kemper/actions/effect_state_per_rig.py +++ b/content/lib/pyswitch/clients/kemper/actions/effect_state_per_rig.py @@ -63,8 +63,9 @@ def __init__(self, slot_id, rig_overrides, **kwargs): self._rig_overrides = rig_overrides # Pre-create state and type mappings for all override slots that differ from the default. + # None values mean "button disabled for this rig" — no mapping needed for them. # These are registered so the client tracks their state via bidirectional MIDI. - override_slots = set(rig_overrides.values()) - {slot_id} + override_slots = set(rig_overrides.values()) - {slot_id, None} self._override_state_maps = {} self._override_type_maps = {} for slot in override_slots: @@ -96,6 +97,8 @@ def _current_slot(self): def state_changed_by_user(self): """Send MIDI CC to toggle the effect on the currently active slot.""" slot = self._current_slot() + if slot is None: + return # Button disabled for this rig — do nothing if slot == self._default_slot: super().state_changed_by_user() else: @@ -107,6 +110,14 @@ def state_changed_by_user(self): def update_displays(self): """Update LED and display label for the currently active slot.""" slot = self._current_slot() + if slot 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 slot == self._default_slot: super().update_displays() else: 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/docs/effect_state_per_rig.html b/docs/effect_state_per_rig.html index 958c0e28..5001afc7 100644 --- a/docs/effect_state_per_rig.html +++ b/docs/effect_state_per_rig.html @@ -1,9 +1,9 @@ - + - EFFECT_STATE_PER_RIG — Guida all'installazione e all'uso + EFFECT_STATE_PER_RIG — Installation and Usage Guide