- Add configuration file system to remember MIDI device selection - Load saved MIDI device preference on application startup - Automatically save MIDI device selection when changed - Handle device disconnection gracefully with fallback - Smart initialization with validation and error handling - Create config.json for storing device preferences - Improve user experience by eliminating need to re-select device
515 lines
20 KiB
Python
515 lines
20 KiB
Python
import asyncio
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox # Import messagebox for confirmations
|
|
import json
|
|
import os
|
|
from async_tkinter_loop import async_handler, async_mainloop
|
|
from networking import WebSocketClient
|
|
import color_utils
|
|
import mido # Import mido for MIDI port detection
|
|
|
|
import time
|
|
from midi import MidiHandler # Import MidiHandler
|
|
|
|
# Configuration file path
|
|
CONFIG_FILE = "config.json"
|
|
|
|
# Dark theme colors (unchanged)
|
|
bg_color = "#2e2e2e"
|
|
fg_color = "white"
|
|
trough_color_red = "#4a0000"
|
|
trough_color_green = "#004a00"
|
|
trough_color_blue = "#00004a"
|
|
trough_color_brightness = "#4a4a4a"
|
|
trough_color_delay = "#4a4a4a"
|
|
active_bg_color = "#4a4a4a"
|
|
highlight_pattern_color = "#6a5acd"
|
|
# New color for active color in palette
|
|
active_palette_color_border = "#FFD700" # Gold color
|
|
|
|
|
|
class App:
|
|
def __init__(self) -> None:
|
|
self.root = tk.Tk()
|
|
# self.root.attributes("-fullscreen", True)
|
|
self.root.configure(bg=bg_color)
|
|
|
|
# --- WebSocketClient ---
|
|
self.websocket_client = WebSocketClient("ws://192.168.4.1:80/ws")
|
|
self.root.after(100, async_handler(self.websocket_client.connect))
|
|
|
|
# --- MIDI Configuration ---
|
|
self.available_midi_ports = self.get_midi_ports()
|
|
self.current_midi_port_index = self.load_midi_device_preference() # Load saved preference
|
|
self.midi_handler: MidiHandler | None = None
|
|
self.midi_task: asyncio.Task | None = None
|
|
|
|
# Initialize MIDI handler with saved port (will be done after GUI is created)
|
|
self.pending_midi_init = True if self.available_midi_ports else False
|
|
|
|
# Configure ttk style (unchanged)
|
|
style = ttk.Style()
|
|
style.theme_use("alt")
|
|
style.configure(".", background=bg_color, foreground=fg_color, font=("Arial", 14))
|
|
style.configure("TNotebook", background=bg_color, borderwidth=0)
|
|
style.configure(
|
|
"TNotebook.Tab", background=bg_color, foreground=fg_color, font=("Arial", 30), padding=[10, 5]
|
|
)
|
|
|
|
# (Status box removed per request)
|
|
|
|
# MIDI Controller Selection
|
|
midi_frame = ttk.LabelFrame(self.root, text="MIDI Controller")
|
|
midi_frame.pack(padx=16, pady=8, fill="x")
|
|
|
|
# MIDI port dropdown
|
|
self.midi_port_var = tk.StringVar()
|
|
if self.available_midi_ports:
|
|
# Set to saved preference if available, otherwise first port
|
|
if 0 <= self.current_midi_port_index < len(self.available_midi_ports):
|
|
self.midi_port_var.set(self.available_midi_ports[self.current_midi_port_index])
|
|
else:
|
|
self.midi_port_var.set(self.available_midi_ports[0])
|
|
self.current_midi_port_index = 0
|
|
|
|
midi_dropdown = ttk.Combobox(
|
|
midi_frame,
|
|
textvariable=self.midi_port_var,
|
|
values=self.available_midi_ports,
|
|
state="readonly",
|
|
font=("Arial", 12)
|
|
)
|
|
midi_dropdown.pack(padx=8, pady=4, fill="x")
|
|
midi_dropdown.bind("<<ComboboxSelected>>", self.on_midi_port_change)
|
|
|
|
# Refresh MIDI ports button
|
|
refresh_button = ttk.Button(
|
|
midi_frame,
|
|
text="Refresh MIDI Ports",
|
|
command=self.refresh_midi_ports
|
|
)
|
|
refresh_button.pack(padx=8, pady=4)
|
|
|
|
# MIDI connection status
|
|
self.midi_status_label = tk.Label(
|
|
midi_frame,
|
|
text="Status: Disconnected",
|
|
bg=bg_color,
|
|
fg="red",
|
|
font=("Arial", 10)
|
|
)
|
|
self.midi_status_label.pack(padx=8, pady=2)
|
|
|
|
# Initialize MIDI handler now that GUI is ready
|
|
if self.pending_midi_init:
|
|
self.initialize_midi_handler()
|
|
self.pending_midi_init = False
|
|
|
|
# Controls overview (dials grid left, buttons grids right)
|
|
controls_frame = ttk.Frame(self.root)
|
|
controls_frame.pack(padx=16, pady=8, fill="both")
|
|
|
|
# Dials box: 4 rows by 2 columns (top-left origin):
|
|
# Row0: n3 (left), Delay (right)
|
|
# Row1: n1 (left), n2 (right)
|
|
# Row2: B (left), Brightness (right)
|
|
# Row3: R (left), G (right)
|
|
dials_frame = ttk.LabelFrame(controls_frame, text="Dials (CC30-37)")
|
|
dials_frame.pack(side="left", padx=12)
|
|
for c in range(2):
|
|
dials_frame.grid_columnconfigure(c, minsize=140)
|
|
for rr in range(4):
|
|
dials_frame.grid_rowconfigure(rr, minsize=70)
|
|
|
|
self.dials_boxes: list[tk.Label] = []
|
|
# Create with placeholders so they are visible before first update
|
|
placeholders = {
|
|
(0, 0): "n3\n-",
|
|
(0, 1): "Delay\n-",
|
|
(1, 0): "n1\n-",
|
|
(1, 1): "n2\n-",
|
|
(2, 0): "B\n-",
|
|
(2, 1): "Bright\n-",
|
|
(3, 0): "R\n-",
|
|
(3, 1): "G\n-",
|
|
}
|
|
for r in range(4):
|
|
for c in range(2):
|
|
lbl = tk.Label(
|
|
dials_frame,
|
|
text=placeholders.get((r, c), "-"),
|
|
bg=bg_color,
|
|
fg=fg_color,
|
|
font=("Arial", 14),
|
|
padx=6,
|
|
pady=6,
|
|
borderwidth=2,
|
|
relief="ridge",
|
|
width=14,
|
|
height=4,
|
|
anchor="center",
|
|
justify="center",
|
|
)
|
|
lbl.grid(row=r, column=c, padx=6, pady=6, sticky="nsew")
|
|
self.dials_boxes.append(lbl)
|
|
|
|
# Additional knobs box: 4 rows by 2 columns (CC38-45)
|
|
# Row0: K1 (left), K2 (right)
|
|
# Row1: K3 (left), K4 (right)
|
|
# Row2: K5 (left), K6 (right)
|
|
# Row3: K7 (left), K8 (right)
|
|
knobs_frame = ttk.LabelFrame(controls_frame, text="Knobs (CC38-45)")
|
|
knobs_frame.pack(side="left", padx=12)
|
|
for c in range(2):
|
|
knobs_frame.grid_columnconfigure(c, minsize=140)
|
|
for rr in range(4):
|
|
knobs_frame.grid_rowconfigure(rr, minsize=70)
|
|
|
|
self.knobs_boxes: list[tk.Label] = []
|
|
# Create with placeholders so they are visible before first update
|
|
knob_placeholders = {
|
|
(0, 0): "CC44\n-",
|
|
(0, 1): "CC45\n-",
|
|
(1, 0): "Rad n1\n-",
|
|
(1, 1): "Rad delay\n-",
|
|
(2, 0): "Alt n1\n-",
|
|
(2, 1): "Alt n2\n-",
|
|
(3, 0): "Pulse n1\n-",
|
|
(3, 1): "Pulse n2\n-",
|
|
}
|
|
for r in range(4):
|
|
for c in range(2):
|
|
lbl = tk.Label(
|
|
knobs_frame,
|
|
text=knob_placeholders.get((r, c), "-"),
|
|
bg=bg_color,
|
|
fg=fg_color,
|
|
font=("Arial", 14),
|
|
padx=6,
|
|
pady=6,
|
|
borderwidth=2,
|
|
relief="ridge",
|
|
width=14,
|
|
height=4,
|
|
anchor="center",
|
|
justify="center",
|
|
)
|
|
lbl.grid(row=r, column=c, padx=6, pady=6, sticky="nsew")
|
|
self.knobs_boxes.append(lbl)
|
|
|
|
# Buttons bank (single)
|
|
buttons_frame = ttk.Frame(controls_frame)
|
|
buttons_frame.pack(side="left", padx=12)
|
|
|
|
buttons1_frame = ttk.LabelFrame(buttons_frame, text="Buttons (notes 36-51)")
|
|
buttons1_frame.pack(side="top", pady=8)
|
|
for c in range(4):
|
|
buttons1_frame.grid_columnconfigure(c, minsize=140)
|
|
for rr in range(1, 5):
|
|
buttons1_frame.grid_rowconfigure(rr, minsize=70)
|
|
self.button1_cells: list[tk.Label] = []
|
|
for r in range(4):
|
|
for c in range(4):
|
|
lbl = tk.Label(
|
|
buttons1_frame,
|
|
text="",
|
|
bg=bg_color,
|
|
fg=fg_color,
|
|
font=("Arial", 14),
|
|
padx=6,
|
|
pady=6,
|
|
borderwidth=2,
|
|
relief="ridge",
|
|
width=14,
|
|
height=4,
|
|
anchor="center",
|
|
justify="center",
|
|
)
|
|
lbl.grid(row=1 + (3 - r), column=c, padx=6, pady=6, sticky="nsew")
|
|
self.button1_cells.append(lbl)
|
|
|
|
# (No second buttons bank)
|
|
|
|
# (No status labels to pack)
|
|
|
|
# schedule periodic UI updates
|
|
self.root.after(200, self.update_status_labels)
|
|
|
|
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
|
|
|
|
async_mainloop(self.root)
|
|
|
|
def load_midi_device_preference(self):
|
|
"""Load saved MIDI device preference from config file"""
|
|
try:
|
|
if os.path.exists(CONFIG_FILE):
|
|
with open(CONFIG_FILE, 'r') as f:
|
|
config = json.load(f)
|
|
saved_index = config.get('midi_device_index', 0)
|
|
print(f"Loaded MIDI device preference: index {saved_index}")
|
|
return saved_index
|
|
except Exception as e:
|
|
print(f"Error loading MIDI device preference: {e}")
|
|
return 0 # Default to first port
|
|
|
|
def save_midi_device_preference(self):
|
|
"""Save current MIDI device preference to config file"""
|
|
try:
|
|
config = {
|
|
'midi_device_index': self.current_midi_port_index,
|
|
'midi_device_name': self.available_midi_ports[self.current_midi_port_index] if self.available_midi_ports else None
|
|
}
|
|
with open(CONFIG_FILE, 'w') as f:
|
|
json.dump(config, f, indent=2)
|
|
print(f"Saved MIDI device preference: {config['midi_device_name']} (index {config['midi_device_index']})")
|
|
except Exception as e:
|
|
print(f"Error saving MIDI device preference: {e}")
|
|
|
|
def get_midi_ports(self):
|
|
"""Get list of available MIDI input ports"""
|
|
try:
|
|
port_names = mido.get_input_names()
|
|
return port_names
|
|
except Exception as e:
|
|
print(f"Error getting MIDI ports: {e}")
|
|
return []
|
|
|
|
def initialize_midi_handler(self):
|
|
"""Initialize MIDI handler with current port"""
|
|
if not self.available_midi_ports:
|
|
print("No MIDI ports available")
|
|
return
|
|
|
|
try:
|
|
WEBSOCKET_SERVER_URI = "ws://192.168.4.1:80/ws"
|
|
print(f"Initializing MIDI handler with port index {self.current_midi_port_index}")
|
|
self.midi_handler = MidiHandler(self.current_midi_port_index, WEBSOCKET_SERVER_URI)
|
|
print("MIDI handler initialized")
|
|
|
|
# Update status
|
|
port_name = self.available_midi_ports[self.current_midi_port_index]
|
|
if hasattr(self, 'midi_status_label'):
|
|
self.midi_status_label.config(text=f"Status: Connected to {port_name}", fg="green")
|
|
|
|
# Start MIDI in background
|
|
self.root.after(0, async_handler(self.start_midi))
|
|
except Exception as e:
|
|
print(f"Error initializing MIDI handler: {e}")
|
|
messagebox.showerror("MIDI Error", f"Failed to initialize MIDI handler:\n{e}")
|
|
self.midi_handler = None
|
|
if hasattr(self, 'midi_status_label'):
|
|
self.midi_status_label.config(text="Status: Error", fg="red")
|
|
|
|
def refresh_midi_ports(self):
|
|
"""Refresh the list of available MIDI ports"""
|
|
old_ports = self.available_midi_ports.copy()
|
|
self.available_midi_ports = self.get_midi_ports()
|
|
|
|
# Update dropdown values
|
|
midi_dropdown = None
|
|
for child in self.root.winfo_children():
|
|
if isinstance(child, ttk.LabelFrame) and child.cget("text") == "MIDI Controller":
|
|
for widget in child.winfo_children():
|
|
if isinstance(widget, ttk.Combobox):
|
|
midi_dropdown = widget
|
|
break
|
|
break
|
|
|
|
if midi_dropdown:
|
|
midi_dropdown['values'] = self.available_midi_ports
|
|
if self.available_midi_ports and self.midi_port_var.get() not in self.available_midi_ports:
|
|
# Current selection is no longer available, select first available
|
|
self.midi_port_var.set(self.available_midi_ports[0])
|
|
self.current_midi_port_index = 0
|
|
self.save_midi_device_preference() # Save the new preference
|
|
self.restart_midi_handler()
|
|
elif not self.available_midi_ports:
|
|
# No ports available
|
|
self.midi_port_var.set("No MIDI ports found")
|
|
if hasattr(self, 'midi_status_label'):
|
|
self.midi_status_label.config(text="Status: No MIDI ports available", fg="orange")
|
|
# Stop current MIDI handler if running
|
|
if self.midi_task and not self.midi_task.done():
|
|
self.midi_task.cancel()
|
|
self.midi_handler = None
|
|
|
|
print(f"MIDI ports refreshed. Available: {self.available_midi_ports}")
|
|
|
|
def on_midi_port_change(self, event):
|
|
"""Handle MIDI port selection change"""
|
|
selected_port = self.midi_port_var.get()
|
|
if selected_port in self.available_midi_ports:
|
|
self.current_midi_port_index = self.available_midi_ports.index(selected_port)
|
|
print(f"MIDI port changed to: {selected_port} (index: {self.current_midi_port_index})")
|
|
self.save_midi_device_preference() # Save the new preference
|
|
self.restart_midi_handler()
|
|
|
|
@async_handler
|
|
async def restart_midi_handler(self):
|
|
"""Restart MIDI handler with new port"""
|
|
try:
|
|
# Stop current MIDI task
|
|
if self.midi_task and not self.midi_task.done():
|
|
self.midi_task.cancel()
|
|
try:
|
|
await self.midi_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
# Initialize new MIDI handler
|
|
self.initialize_midi_handler()
|
|
except Exception as e:
|
|
print(f"Error restarting MIDI handler: {e}")
|
|
messagebox.showerror("MIDI Error", f"Failed to restart MIDI handler:\n{e}")
|
|
|
|
@async_handler
|
|
async def start_midi(self):
|
|
# Launch MidiHandler.run() as a background task
|
|
if self.midi_handler and (self.midi_task is None or self.midi_task.done()):
|
|
self.midi_task = asyncio.create_task(self.midi_handler.run())
|
|
elif not self.midi_handler:
|
|
print("Cannot start MIDI: no MIDI handler available")
|
|
|
|
def on_closing(self):
|
|
print("Closing application...")
|
|
if self.midi_task and not self.midi_task.done():
|
|
self.midi_task.cancel()
|
|
asyncio.create_task(self.websocket_client.close())
|
|
self.root.destroy()
|
|
|
|
# --- Asynchronous Update Functions ---
|
|
@async_handler
|
|
async def update_rgb(self, tab):
|
|
await asyncio.sleep(0) # Yield control
|
|
|
|
def update_status_labels(self):
|
|
# Check if MIDI handler is available
|
|
if not self.midi_handler:
|
|
# Update dial displays with placeholder values
|
|
placeholders = [
|
|
("n3", "-"), ("Delay", "-"),
|
|
("n1", "-"), ("n2", "-"),
|
|
("B", "-"), ("Brightness", "-"),
|
|
("R", "-"), ("G", "-"),
|
|
]
|
|
for idx, (label, value) in enumerate(placeholders):
|
|
if idx < len(self.dials_boxes):
|
|
self.dials_boxes[idx].config(text=f"{label}\n{value}")
|
|
|
|
# Update knobs with placeholder values
|
|
knob_placeholders = [
|
|
("CC44", "-"), ("CC45", "-"),
|
|
("Rad n1", "-"), ("Rad delay", "-"),
|
|
("Alt n1", "-"), ("Alt n2", "-"),
|
|
("Pulse n1", "-"), ("Pulse n2", "-"),
|
|
]
|
|
for idx, (label, value) in enumerate(knob_placeholders):
|
|
if idx < len(self.knobs_boxes):
|
|
self.knobs_boxes[idx].config(text=f"{label}\n{value}")
|
|
|
|
# Update buttons with no selection
|
|
for lbl in self.button1_cells:
|
|
lbl.config(text="", bg=bg_color)
|
|
|
|
# Reschedule
|
|
self.root.after(200, self.update_status_labels)
|
|
return
|
|
|
|
# Pull values from midi_handler
|
|
delay = self.midi_handler.delay
|
|
brightness = self.midi_handler.brightness
|
|
r = getattr(self.midi_handler, 'color_r', 0)
|
|
g = getattr(self.midi_handler, 'color_g', 0)
|
|
b = getattr(self.midi_handler, 'color_b', 0)
|
|
# Single bank values
|
|
brightness = getattr(self.midi_handler, 'brightness', '-')
|
|
r = getattr(self.midi_handler, 'color_r', 0)
|
|
g = getattr(self.midi_handler, 'color_g', 0)
|
|
b = getattr(self.midi_handler, 'color_b', 0)
|
|
pattern = getattr(self.midi_handler, 'current_pattern', '') or '-'
|
|
n1 = getattr(self.midi_handler, 'n1', '-')
|
|
n2 = getattr(self.midi_handler, 'n2', '-')
|
|
n3 = getattr(self.midi_handler, 'n3', '-')
|
|
|
|
# Update dials 2x4 grid (left→right, top→bottom):
|
|
# Row0: n3, Delay
|
|
# Row1: n1, n2
|
|
# Row2: B, Brightness
|
|
# Row3: R, G
|
|
dial_values = [
|
|
("n3", n3), ("Delay", getattr(self.midi_handler, 'delay', '-')),
|
|
("n1", n1), ("n2", n2),
|
|
("B", b), ("Brightness", brightness),
|
|
("R", r), ("G", g),
|
|
]
|
|
# Update dial displays
|
|
for idx, (label, value) in enumerate(dial_values):
|
|
if idx < len(self.dials_boxes):
|
|
self.dials_boxes[idx].config(text=f"{label}\n{value}")
|
|
|
|
# Update additional knobs (CC38-45)
|
|
knob_values = [
|
|
("CC44", getattr(self.midi_handler, 'knob7', '-')), ("CC45", getattr(self.midi_handler, 'knob8', '-')),
|
|
("Rad n1", getattr(self.midi_handler, 'n1', '-')), ("Rad delay", getattr(self.midi_handler, 'delay', '-')),
|
|
("Alt n1", getattr(self.midi_handler, 'n1', '-')), ("Alt n2", getattr(self.midi_handler, 'n2', '-')),
|
|
("Pulse n1", getattr(self.midi_handler, 'n1', '-')), ("Pulse n2", getattr(self.midi_handler, 'n2', '-')),
|
|
]
|
|
for idx, (label, value) in enumerate(knob_values):
|
|
if idx < len(self.knobs_boxes):
|
|
self.knobs_boxes[idx].config(text=f"{label}\n{value}")
|
|
|
|
# Update buttons bank mappings and selection (single bank)
|
|
# Pattern icons for nicer appearance
|
|
icon_for = {
|
|
"pulse": "💥",
|
|
"flicker": "✨",
|
|
"alternating": "↔️",
|
|
"n chase": "🏃",
|
|
"rainbow": "🌈",
|
|
"radiate": "🌟",
|
|
"sequential\npulse": "🔄",
|
|
"alternating\nphase": "⚡",
|
|
"-": "",
|
|
}
|
|
bank1_patterns = [
|
|
# Pulse patterns (row 1)
|
|
"pulse", "sequential\npulse",
|
|
# Alternating patterns (row 2)
|
|
"alternating", "alternating\nphase",
|
|
# Chase/movement patterns (row 3)
|
|
"n chase", "rainbow",
|
|
# Effect patterns (row 4)
|
|
"flicker", "radiate",
|
|
"-", "-", "-", "-",
|
|
"-", "-", "-", "-",
|
|
]
|
|
|
|
# Map MIDI handler pattern names to GUI display names
|
|
pattern_name_mapping = {
|
|
"sequential_pulse": "sequential\npulse",
|
|
"alternating_phase": "alternating\nphase",
|
|
"n_chase": "n chase",
|
|
}
|
|
|
|
# Get the display name for the current pattern
|
|
display_pattern = pattern_name_mapping.get(pattern, pattern)
|
|
|
|
# notes numbers per cell (bottom-left origin)
|
|
for idx, lbl in enumerate(self.button1_cells):
|
|
name = bank1_patterns[idx]
|
|
sel = (display_pattern == name and name != "-")
|
|
icon = icon_for.get(name, "")
|
|
text = f"{icon} {name}" if name != "-" else ""
|
|
if sel:
|
|
lbl.config(text=text, bg=highlight_pattern_color)
|
|
else:
|
|
lbl.config(text=text, bg=bg_color)
|
|
# (no second bank to update)
|
|
|
|
# reschedule
|
|
self.root.after(200, self.update_status_labels)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app = App()
|