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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ Notable user visible changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Added
- "Set MIDI Tempo" option in the System Menu to adjust global BPM directly from the hardware encoders.
- Permanent, real-time top-bar MIDI BPM display on the color LCD (v2/v3) that updates instantly on tap-tempo stamps or WebSocket updates, and acts as a clickable shortcut to open the MIDI Tempo editor dialog.

### Fixed
- Fixed tap-tempo lag and timing jitter by routing stomp button BPM updates asynchronously via the WebSocket bridge instead of blocking synchronous HTTP REST requests (removing a 50-200ms stall from the 10ms critical path polling thread).
- Improved BPM adjustment encoder granularity to 1.0 BPM per notch.
- Immediate local hardware tap-tempo sync for BPM updates (fixing delay/out-of-sync LCD footswitches and LED blinking).
- High-efficiency reuse of line widgets in `Parameterdialog` to eliminate memory leaks and SPI rendering lag.
- Legacy MOD REST API exception safety to protect the system main loop from crashing if MOD host is unreachable.
- Thread-safe deferred BPM updates to prevent UI crashes/restarts when adjusting BPM or performing tap-tempo.
- Synced Parameterdialog BPM display to use rounded integers (matching top-bar status and footswitch displays) instead of truncation.
- Isolated modal dialog rendering in the UI library to prevent background panels (like the parent Menu) from leaking visually behind active popups/dialogs.

## [v3.1.0] - 2026-06-20
### Added
- On-device (LCD) strobe tuner with mute (v3 hardware only). Defaults to longpress on footswitch C
Expand Down
96 changes: 85 additions & 11 deletions modalapi/mod.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import requests as req
import subprocess
import sys
import time
import yaml
from typing import Any

Expand Down Expand Up @@ -53,6 +54,7 @@ class TopEncoderMode(Enum):
SYSTEM_MENU = 5
HEADPHONE_VOLUME = 6
INPUT_GAIN = 7
BPM = 8

class BotEncoderMode(Enum):
DEFAULT = 0
Expand All @@ -76,6 +78,7 @@ class UniversalEncoderMode(Enum):
DEEP_EDIT = 13
VALUE_EDIT = 14
LOADING = 15
BPM = 16

class SelectedType(Enum):
PEDALBOARD = 0
Expand Down Expand Up @@ -140,6 +143,8 @@ def __init__(self, audiocard, homedir):

# Stores snapshot index from loading_end until pedalboard change is detected
self.next_pedalboard_preset_index = None
# Thread-safe queueing of BPM updates to be processed on the main thread (avoiding Pygame/SPI concurrent writes)
self._pending_bpm_update = None

self.selected_menu_index = 0
self.menu_items = None
Expand Down Expand Up @@ -239,6 +244,8 @@ def top_encoder_sw(self, value):
self.top_encoder_mode = TopEncoderMode.SYSTEM_MENU
elif mode == TopEncoderMode.INPUT_GAIN:
self.top_encoder_mode = TopEncoderMode.SYSTEM_MENU
elif mode == TopEncoderMode.BPM:
self.top_encoder_mode = TopEncoderMode.SYSTEM_MENU
else:
if len(self.current.presets) > 0:
self.top_encoder_mode = TopEncoderMode.PRESET_SELECT
Expand Down Expand Up @@ -277,12 +284,15 @@ def top_encoder_select(self, direction):
self.parameter_value_change(direction, self.headphone_volume_commit)
elif mode == TopEncoderMode.INPUT_GAIN:
self.parameter_value_change(direction, self.input_gain_commit)
elif mode == TopEncoderMode.BPM:
self.parameter_value_change(direction, self.bpm_commit)

def bottom_encoder_sw(self, value):
# State machine for bottom rotary encoder switch
if (self.top_encoder_mode == TopEncoderMode.SYSTEM_MENU or
self.top_encoder_mode == TopEncoderMode.HEADPHONE_VOLUME or
self.top_encoder_mode == TopEncoderMode.INPUT_GAIN):
self.top_encoder_mode == TopEncoderMode.INPUT_GAIN or
self.top_encoder_mode == TopEncoderMode.BPM):
return # Ignore bottom encoder if top encoder has navigated to the system menu
mode = self.bot_encoder_mode
if value == switchstate.Value.RELEASED:
Expand All @@ -303,7 +313,8 @@ def bottom_encoder_sw(self, value):
def bot_encoder_select(self, direction):
if (self.top_encoder_mode == TopEncoderMode.SYSTEM_MENU or
self.top_encoder_mode == TopEncoderMode.HEADPHONE_VOLUME or
self.top_encoder_mode == TopEncoderMode.INPUT_GAIN):
self.top_encoder_mode == TopEncoderMode.INPUT_GAIN or
self.top_encoder_mode == TopEncoderMode.BPM):
return
mode = self.bot_encoder_mode
if mode == BotEncoderMode.DEFAULT:
Expand Down Expand Up @@ -358,6 +369,9 @@ def universal_encoder_sw(self, value):
elif mode == UniversalEncoderMode.INPUT_GAIN:
self.universal_encoder_mode = UniversalEncoderMode.SYSTEM_MENU
self.system_menu_show()
elif mode == UniversalEncoderMode.BPM:
self.universal_encoder_mode = UniversalEncoderMode.SYSTEM_MENU
self.system_menu_show()
elif mode == UniversalEncoderMode.EQ1_GAIN:
self.universal_encoder_mode = UniversalEncoderMode.SYSTEM_MENU
self.system_audio_menu()
Expand Down Expand Up @@ -410,6 +424,8 @@ def universal_encoder_select(self, direction):
self.parameter_value_change(direction, self.headphone_volume_commit)
elif mode == UniversalEncoderMode.INPUT_GAIN:
self.parameter_value_change(direction, self.input_gain_commit)
elif mode == UniversalEncoderMode.BPM:
self.parameter_value_change(direction, self.bpm_commit)
elif mode == UniversalEncoderMode.EQ1_GAIN:
self.parameter_value_change(direction, self.eq1_gain_commit)
elif mode == UniversalEncoderMode.EQ2_GAIN:
Expand Down Expand Up @@ -491,6 +507,9 @@ def _handle_ws_message(self, msg: WebSocketMessage):
elif isinstance(msg, LoadingEndMessage):
logging.debug(f"WebSocket: Pedalboard loading finished, snapshot={msg.snapshot_id}")
self.next_pedalboard_preset_index = msg.snapshot_id
mod_bundle = self.get_current_pedalboard_bundle_path()
if self.current and mod_bundle == self.current.pedalboard.bundle:
self._is_pedalboard_loading = False

elif isinstance(msg, PedalSnapshotMessage):
if self.next_pedalboard_preset_index is not None:
Expand Down Expand Up @@ -518,10 +537,13 @@ def _handle_ws_message(self, msg: WebSocketMessage):

elif isinstance(msg, TransportMessage):
if self.hardware and self.hardware.taptempo:
self.hardware.taptempo.set_bpm(msg.bpm)
if self.hardware.taptempo.is_enabled():
fs = next((f for f in self.hardware.footswitches if f.taptempo is self.hardware.taptempo), None)
self.update_lcd_fs(footswitch=fs)
# Prevent WebSocket feedback echo from overriding recently adjusted local BPM (0.5s cooldown)
if not hasattr(self, '_last_bpm_change_time') or (time.time() - self._last_bpm_change_time > 0.5):
self.hardware.taptempo.set_bpm(msg.bpm)
self._pending_bpm_update = msg.bpm
if self.hardware.taptempo.is_enabled():
fs = next((f for f in self.hardware.footswitches if f.taptempo is self.hardware.taptempo), None)
self.update_lcd_fs(footswitch=fs)

elif isinstance(msg, ParamSetMessage):
# Mirror mod-ui's live value: refresh the cache (so a later edit opens
Expand All @@ -540,6 +562,17 @@ def _handle_ws_message(self, msg: WebSocketMessage):

def poll_ws_messages(self):
"""Drain and dispatch inbound WebSocket messages (fast ~10ms cadence)."""
# Defer BPM display updates to the main thread's fast 10ms execution loop.
bpm = getattr(self, '_pending_bpm_update', None)
if bpm is not None:
self._pending_bpm_update = None
if self.lcd:
self.lcd.update_bpm(bpm)
if self.hardware and self.hardware.taptempo and self.hardware.taptempo.is_enabled():
fs = next((f for f in self.hardware.footswitches if f.taptempo is self.hardware.taptempo), None)
if fs:
self.update_lcd_fs(footswitch=fs)

for msg in self.ws_bridge.get_received_messages():
try:
self._handle_ws_message(parse_message(msg))
Expand All @@ -552,10 +585,10 @@ def poll_modui_changes(self):

# Check for pedalboard change via last.json
if self.last_json_monitor.check_for_change():
self._is_pedalboard_loading = True
self.lcd.draw_info_message("Loading...")
mod_bundle = read_pedalboard_bundle(self.last_json_monitor.path)
if mod_bundle and mod_bundle != self.current.pedalboard.bundle:
self._is_pedalboard_loading = True
self.lcd.draw_info_message("Loading...")
logging.info(f"Pedalboard changed via MOD from: {self.current.pedalboard.bundle} to: {mod_bundle}")

if mod_bundle not in self.pedalboards:
Expand Down Expand Up @@ -981,7 +1014,8 @@ def system_menu_show(self):
"5": {Token.NAME: "Reload pedalboards", Token.ACTION: self.system_menu_reload},
"6": {Token.NAME: "Restart sound engine", Token.ACTION: self.system_menu_restart_sound},
"7": {Token.NAME: "Audio Options", Token.ACTION: self.system_audio_menu},
"8": {Token.NAME: "Advanced Settings", Token.ACTION: self.system_advanced_menu}}
"8": {Token.NAME: "Advanced Settings", Token.ACTION: self.system_advanced_menu},
"9": {Token.NAME: "Set MIDI Tempo", Token.ACTION: self.system_menu_bpm}}
self.lcd.menu_show("System menu", self.menu_items)
# Trick: we display the wifi status in the menu, Ideally we need a better
# state handling to know what needs to be displayed or not based on whether
Expand Down Expand Up @@ -1318,7 +1352,42 @@ def get_callback(self, callback_name):

def set_mod_tap_tempo(self, bpm):
if bpm is not None:
req.post(self.root_uri + "set_bpm", json={"value": bpm})
self._last_bpm_change_time = time.time()
# Send BPM updates via WebSocket bridge if connected for low-latency; fallback to REST API
if not self.ws_bridge or not self.ws_bridge.is_connected or not self.ws_bridge.send_bpm(bpm):
try:
req.post(self.root_uri + "set_bpm", json={"value": bpm}, timeout=1.0)
except Exception as e:
logging.error("Failed to set BPM: %s", e)
self._pending_bpm_update = bpm

def get_bpm(self):
url = self.root_uri + "get_bpm"
try:
resp = req.get(url, timeout=2.0)
if resp.status_code != 200:
return 0.0
return float(resp.text)
except Exception:
return 0.0

def system_menu_bpm(self):
title = "BPM"
self.top_encoder_mode = TopEncoderMode.BPM
self.universal_encoder_mode = UniversalEncoderMode.BPM
value = self.get_bpm()
info = {"shortName": title, "symbol": "bpm", "ranges": {"minimum": 40.0, "maximum": 240.0}}
self.deep = self.Deep(None)
param = Parameter.Parameter(info, value, None)
self.deep.selected_parameter = param
self.lcd.draw_value_edit_graph(param, value)
self.lcd.draw_info_message(title)

def bpm_commit(self):
value = self.deep.selected_parameter.value
self.set_mod_tap_tempo(value)
if self.hardware and self.hardware.taptempo:
self.hardware.taptempo.set_bpm(value)

#
# Parameter Edit
Expand Down Expand Up @@ -1357,7 +1426,10 @@ def parameter_value_change(self, direction, commit_callback):
param = self.deep.selected_parameter
value = float(param.value)
# TODO tweak value won't change from call to call, cache it
tweak = util.renormalize_float(self.parameter_tweak_amount, 0, 127, param.minimum, param.maximum)
if param.symbol == "bpm":
tweak = 1.0
else:
tweak = util.renormalize_float(self.parameter_tweak_amount, 0, 127, param.minimum, param.maximum)
new_value = round(value + direction * tweak, 2)
if new_value > param.maximum:
new_value = param.maximum
Expand All @@ -1382,6 +1454,8 @@ def update_lcd(self): # TODO rename to imply the home screen
self.lcd.draw_tools(SelectedType.WIFI, SelectedType.EQ, SelectedType.BYPASS, SelectedType.SYSTEM)
self.lcd.update_bypass(self.hardware.relay.enabled)
self.lcd.update_eq(self.eq_status)
if self.lcd:
self.lcd.update_bpm(self.get_bpm())
self.update_lcd_title()
self.lcd.draw_analog_assignments(self.current.analog_controllers)
self.lcd.draw_plugins(self.current.pedalboard.plugins)
Expand Down
Loading