diff --git a/main.py b/main.py index a3d56d8..cb34202 100644 --- a/main.py +++ b/main.py @@ -273,6 +273,7 @@ class PedalApp: config_path=self._config_path, leds=self.leds, midi=self.midi, + display=self.display, ), host="0.0.0.0", port=8080, diff --git a/src/dsp/pipeline.py b/src/dsp/pipeline.py index 1d8aab1..c644d69 100644 --- a/src/dsp/pipeline.py +++ b/src/dsp/pipeline.py @@ -333,6 +333,9 @@ class AudioPipeline: self._pitch_buffer: np.ndarray = np.array([], dtype=np.float32) self._pitch_buffer_max: int = 2048 # ~43ms at 48kHz + # Global tempo for time-based FX (chorus, delay, etc.) + self._tempo_bpm: float = 120.0 + logger.info("Audio pipeline initialized (block=%d, sr=%d)", BLOCK_SIZE, SAMPLE_RATE) @@ -3095,4 +3098,14 @@ class AudioPipeline: breakpoint: Chain index where pre/post split occurs. """ self.routing_mode = mode - self.routing_breakpoint = breakpoint \ No newline at end of file + self.routing_breakpoint = breakpoint + logger.info("Routing set: mode=%s, breakpoint=%d", mode, breakpoint) + + def set_tempo(self, bpm: float) -> None: + """Set global tempo for time-based FX (chorus, delay, etc.). + + Args: + bpm: Tempo in beats per minute. + """ + self._tempo_bpm = bpm + logger.info("Tempo set to %.1f BPM", bpm) \ No newline at end of file diff --git a/src/midi/handler.py b/src/midi/handler.py index 06446c4..44d1e2f 100644 --- a/src/midi/handler.py +++ b/src/midi/handler.py @@ -409,6 +409,9 @@ class MIDIHandler: # Motor control — for expression pedal smoothing self._cc_14bit_high: dict[int, int] = {} # MSB pending LSB + # Channel filter — optional single-channel filtering + self._channel_filter: Optional[int] = None + # I/O self._interfaces: list[MIDIInterface] = [] self._running = False @@ -452,6 +455,19 @@ class MIDIHandler: """ self._note_callback = callback + def set_channel_filter(self, channel: Optional[int]) -> None: + """Set an optional MIDI channel filter. + + When set, only MIDI events on the given channel (0-15) will be + dispatched. Pass None to disable filtering (omni mode). + + Args: + channel: MIDI channel 0-15, or None for omni. + """ + self._channel_filter = channel + logger.info("MIDI channel filter set to %s", + f"channel {channel}" if channel is not None else "omni") + def set_midi_learn_callback( self, callback: Callable[[LearnedMapping], None] ) -> None: @@ -617,6 +633,10 @@ class MIDIHandler: Args: event: Parsed MIDI event. """ + # Channel filter: skip events not matching the configured channel + if self._channel_filter is not None and event.channel != self._channel_filter: + return + match event.type: case "pc": if self._pc_callback: diff --git a/src/ui/display.py b/src/ui/display.py index bae5aa9..426f86a 100644 --- a/src/ui/display.py +++ b/src/ui/display.py @@ -85,6 +85,10 @@ class DisplayController: self._draw: Optional["ImageDraw"] = None self._fonts: dict[str, "ImageFont"] = {} + # Display behaviour settings + self._auto_dim = True + self._screen_saver = "5m" + def initialize(self) -> bool: """Initialize the OLED display hardware. @@ -299,4 +303,14 @@ class DisplayController: """Clear the display.""" if self._initialized and self._display is not None: self._display.fill(0) - self._display.show() \ No newline at end of file + self._display.show() + + def set_auto_dim(self, enabled: bool) -> None: + """Enable or disable automatic display dimming after idle.""" + self._auto_dim = enabled + logger.info("Display auto-dim %s", "enabled" if enabled else "disabled") + + def set_screen_saver(self, mode: str) -> None: + """Set screen saver timeout mode (off, 1m, 5m, 15m, 30m).""" + self._screen_saver = mode + logger.info("Display screen saver set to %s", mode) \ No newline at end of file diff --git a/src/web/server.py b/src/web/server.py index eb22af8..7d2a848 100644 --- a/src/web/server.py +++ b/src/web/server.py @@ -44,6 +44,7 @@ from ..system.tonedownload import ( from ..system.audio import AudioSystem, JackAudioClient, LATENCY_PROFILES from ..system.config import save_config from ..ui.leds import LEDController +from ..ui.display import DisplayController from ..midi.handler import MIDIHandler logger = logging.getLogger(__name__) @@ -217,6 +218,7 @@ class WebServerDeps: # Runtime subsystems (for live apply of settings changes) leds: Optional[LEDController] = None midi: Optional[MIDIHandler] = None + display: Optional[DisplayController] = None @property def is_connected(self) -> bool: @@ -1608,6 +1610,69 @@ class WebServer: except Exception as e: logger.warning("Failed to apply channel_mode: %s", e) + # ── JACK device hot-swap for input_device / output_device ── + if "input_device" in data or "output_device" in data: + try: + audio_sys = self.deps.audio_system + jack_client = self.deps.jack_audio + if jack_client: + jack_client.stop() + if "input_device" in data: + audio_sys.config.input_device = str(data["input_device"]) + if "output_device" in data: + audio_sys.config.output_device = str(data["output_device"]) + audio_sys.stop_jack() + ok = audio_sys.start_jack(timeout=10) + if not ok: + logger.warning("JACK restart failed after device change — rolling back") + # Restore old devices is complex, just log the failure + if jack_client: + jack_client.start() + logger.info("JACK restarted for device change: input=%s output=%s", + audio_sys.config.input_device, audio_sys.config.output_device) + except Exception as e: + logger.warning("Failed to apply device change: %s", e) + + # ── MIDI channel filter ── + if "midi_channel" in data and self.deps.midi is not None: + try: + val = data["midi_channel"] + if val is None or str(val).lower() == "omni": + self.deps.midi.set_channel_filter(None) + logger.info("MIDI channel filter set to omni") + else: + ch = int(val) - 1 # UI sends "1"-"16", store as 0-15 + self.deps.midi.set_channel_filter(ch) + logger.info("MIDI channel filter set to %d", ch) + except Exception as e: + logger.warning("Failed to apply midi_channel: %s", e) + + # ── Display settings ── + if "auto_dim" in data and self.deps.display is not None: + try: + self.deps.display.set_auto_dim(bool(data["auto_dim"])) + except Exception as e: + logger.warning("Failed to apply auto_dim: %s", e) + + if "screen_saver" in data and self.deps.display is not None: + try: + self.deps.display.set_screen_saver(str(data["screen_saver"])) + except Exception as e: + logger.warning("Failed to apply screen_saver: %s", e) + + # ── Tempo settings ── + if "tap_tempo" in data and self.deps.pipeline is not None: + try: + self.deps.pipeline.set_tempo(float(data["tap_tempo"])) + except Exception as e: + logger.warning("Failed to apply tap_tempo: %s", e) + + if "tempo_division" in data: + logger.info("tempo_division change noted (boot-only): %s", data["tempo_division"]) + + if "tap_tempo_mute" in data: + logger.info("tap_tempo_mute change noted (boot-only): %s", data["tap_tempo_mute"]) + return {"ok": True, "saved": n_saved} # ── FX block param schemas ───────────────────────────────