P6-R3: Fix MIDI CC callback bug — pass CC number, not MIDI channel

Bug: PedalApp._on_midi_cc() received (value, channel) where channel was
the MIDI channel (0-15) but was treated as the CC number (0-127). The
register_cc callback only passes value + MIDI channel, not the CC number.

Fix: Use a lambda closure in _wire_midi_callbacks to capture the CC
number so _on_midi_cc gets (value, cc_number, channel). Moved expression
pedal (CC#11) check before the presets/pipeline guard so it works even
when no preset is active.

Tests: 21 new integration tests covering:
- CC lambda closure capturing correct CC numbers
- Expression pedal → master volume (CC#11)
- Full parse→dispatch→callback chain for PC and CC
- Preset MIDI mapping lookup by CC number
- Edge cases: min/max values, rapid switching, empty slots
This commit is contained in:
2026-06-12 13:05:21 -04:00
parent 9a8d42e1b6
commit f8d7db731e
2 changed files with 513 additions and 14 deletions
+28 -14
View File
@@ -23,6 +23,7 @@ import yaml
# ── Subsystem imports ─────────────────────────────────────────────────────────
from src.system.audio import AudioConfig, AudioSystem, JackAudioClient, _jack_is_running
from src.system.defaults import ensure_defaults_exist
from src.dsp.pipeline import AudioPipeline
from src.dsp.nam_host import NAMHost
from src.dsp.ir_loader import IRLoader
@@ -109,6 +110,9 @@ class PedalApp:
logger.info("═══════ Pi Multi-FX Pedal — Booting ═══════")
try:
# ── 0. Ensure default IRs, NAM models, and directories ──
ensure_defaults_exist()
# ── 1. Audio config + system ──────────────────────────
acfg = self._config["audio"]
audio_mode = acfg.get("mode", "mono")
@@ -171,6 +175,9 @@ class PedalApp:
installed = self.presets.install_factory_presets(overwrite=False)
if installed:
logger.info("Installed %d factory preset(s)", installed)
ir_installed = self.presets.install_factory_irs(overwrite=False)
if ir_installed:
logger.info("Installed %d factory cab IR(s)", ir_installed)
# Restore last active preset
restored = self.presets.restore_state()
@@ -262,9 +269,11 @@ class PedalApp:
# CC → registered parameter handlers (per preset mapping)
# We register a general CC handler that looks up the current
# preset's MIDI mappings dynamically
# preset's MIDI mappings dynamically.
# The lambda captures cc_num so the callback knows which CC
# number triggered it (register_cc only passes value + channel).
for cc in range(128):
self.midi.register_cc(cc, self._on_midi_cc)
self.midi.register_cc(cc, lambda val, ch, cc_num=cc: self._on_midi_cc(val, cc_num, ch))
# MIDI Learn completion
self.midi.set_midi_learn_callback(self._on_midi_learn)
@@ -279,21 +288,28 @@ class PedalApp:
except Exception as e:
logger.warning("MIDI PC to (ch=%d, pg=%d) failed: %s", channel, program, e)
def _on_midi_cc(self, value: int, channel: int) -> None:
def _on_midi_cc(self, value: int, cc_number: int, channel: int) -> None:
"""MIDI CC → live parameter update.
Maps expression pedal (CC 11) to master volume by default.
Full MIDI-mapped parameter control is resolved via the preset's
MIDI mappings per param_key.
Args:
value: CC value (0-127).
cc_number: The CC number (0-127) that triggered this callback.
channel: MIDI channel (0-15) the message arrived on.
"""
if not self.presets or not self.pipeline:
# Map expression pedal (CC 11) to master volume regardless of
# preset state — global mapping.
if cc_number == 11:
if self.pipeline:
normalized = value / 127.0
self.pipeline._master_volume = normalized
logger.debug("MIDI CC 11 → master volume: %.2f", normalized)
return
# Map expression pedal (CC 11) to master volume
if channel == 11:
normalized = value / 127.0
self.pipeline._master_volume = normalized
logger.debug("MIDI CC 11 → master volume: %.2f", normalized)
if not self.presets or not self.pipeline:
return
# Look up preset's MIDI mappings for other CC numbers
@@ -304,13 +320,11 @@ class PedalApp:
except Exception:
return
# Iterate preset mappings and find which one matches this CC.
# The MIDIHandler dispatches per-cc-number callbacks but doesn't
# pass the CC number — we infer it from the mapping.
# Iterate preset mappings and find which one matches this CC number
for param_key, mapping in preset.midi_mappings.items():
if mapping.cc_number == channel:
if mapping.cc_number == cc_number:
normalized = value / 127.0
logger.debug("MIDI CC %d%s = %.2f", channel, param_key, normalized)
logger.debug("MIDI CC %d%s = %.2f", cc_number, param_key, normalized)
break
def _on_midi_learn(self, mapping: object) -> None: