Phase 1-4: Audio stack, mixer engine, MIDI, and network API
Lint & Validate / lint (push) Has been cancelled
Lint & Validate / lint (push) Has been cancelled
P2-R1: ALSA + JACK2 low-latency config (scripts, quirks, tuning) P2-R2: Carla integration (build scripts, 8ch rack config, NAM LV2 support) P2-R3: Plugin manager, categories, blacklist, NAM model support P3-R1: Mixer DSP engine (channel strip, routing matrix, bus mgr, automation) P4-R1: MIDI engine (learn mode, clock sync, HID discovery, mapping store) P4-R2: Network API (OSC server, FastAPI REST, WebSocket, auth, rate limiter) P5-R1: Touchscreen UI evaluation + main entry point docs: Audio stack, Carla integration, MIDI support, UI evaluation tests: Full test suite (292 passing)
This commit is contained in:
+283
@@ -0,0 +1,283 @@
|
||||
"""Kivy application entry point for the touchscreen mixer UI.
|
||||
|
||||
Provides a ScreenManager with four screens:
|
||||
- Mixer surface (faders, meters, mute/solo)
|
||||
- Routing matrix
|
||||
- Plugin chain
|
||||
- Settings (brightness, timeout, DPI)
|
||||
|
||||
Designed for Raspberry Pi 5"/7" touch displays.
|
||||
Runs fullscreen at native resolution with DPI-aware layout.
|
||||
|
||||
Usage:
|
||||
python3 main_touch.py # connect to localhost:8000
|
||||
python3 main_touch.py --host 192.168.1.10 # remote mixer
|
||||
KIVY_DPI=220 python3 main_touch.py # override DPI
|
||||
|
||||
Environment:
|
||||
MIXER_HOST — mixer server hostname (default 127.0.0.1)
|
||||
MIXER_PORT — REST API port (default 8000)
|
||||
MIXER_OSC_PORT — OSC server port (default 9001)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
|
||||
from kivy.app import App
|
||||
from kivy.clock import Clock
|
||||
from kivy.config import Config
|
||||
from kivy.core.window import Window
|
||||
from kivy.properties import ObjectProperty
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.label import Label
|
||||
from kivy.uix.screenmanager import ScreenManager, Screen, FadeTransition, SlideTransition
|
||||
from kivy.metrics import dp
|
||||
|
||||
from .ipc import MixerClient
|
||||
from .theme import Colors, Fonts, Sizes
|
||||
from .screens.mixer import MixerScreen
|
||||
from .screens.routing import RoutingScreen
|
||||
from .screens.plugins import PluginScreen
|
||||
from .screens.settings import SettingsScreen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Touch-optimized Kivy config ──────────────────────────────────────────────
|
||||
|
||||
def _configure_kivy():
|
||||
"""Apply touch-optimized Kivy settings before window creation."""
|
||||
Config.set("input", "mouse", "mouse,disable_multitouch")
|
||||
Config.set("kivy", "window_icon", "")
|
||||
Config.set("kivy", "exit_on_escape", 0) # No accidental exit
|
||||
Config.set("graphics", "fullscreen", "auto")
|
||||
Config.set("graphics", "show_cursor", 0) # Hide cursor on touchscreen
|
||||
Config.set("graphics", "resizable", 0) # Fixed size for embedded
|
||||
|
||||
# Allow environment overrides
|
||||
if os.environ.get("KIVY_SHOW_CURSOR"):
|
||||
Config.set("graphics", "show_cursor", 1)
|
||||
|
||||
|
||||
# ── Tab bar ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class TabBar(BoxLayout):
|
||||
"""Bottom tab bar for screen switching.
|
||||
|
||||
Four tabs: Mixer, Routing, Plugins, Settings.
|
||||
Touch-optimized: large hit targets, clear active indicator.
|
||||
"""
|
||||
|
||||
screen_manager = ObjectProperty(None)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.orientation = "horizontal"
|
||||
self.size_hint = (1, None)
|
||||
self.height = Sizes.TAB_BAR_HEIGHT
|
||||
self.spacing = 0
|
||||
|
||||
self._tabs: dict[str, Button] = {}
|
||||
self._active_tab = "mixer"
|
||||
|
||||
tabs = [
|
||||
("mixer", "MIX"),
|
||||
("routing", "RTG"),
|
||||
("plugins", "PLG"),
|
||||
("settings", "SET"),
|
||||
]
|
||||
for name, label in tabs:
|
||||
btn = Button(
|
||||
text=label,
|
||||
background_normal="",
|
||||
background_color=Colors.BG_HEADER,
|
||||
color=Colors.ACCENT if name == "mixer" else Colors.TEXT_MUTED,
|
||||
font_size=Fonts.SMALL,
|
||||
bold=True,
|
||||
)
|
||||
btn.bind(on_press=self._make_switch(name))
|
||||
self._tabs[name] = btn
|
||||
self.add_widget(btn)
|
||||
|
||||
def _make_switch(self, screen_name: str):
|
||||
def cb(instance):
|
||||
if self.screen_manager:
|
||||
self.screen_manager.current = screen_name
|
||||
self._set_active(screen_name)
|
||||
return cb
|
||||
|
||||
def _set_active(self, name: str):
|
||||
self._active_tab = name
|
||||
for tab_name, btn in self._tabs.items():
|
||||
if tab_name == name:
|
||||
btn.color = Colors.ACCENT
|
||||
btn.font_size = Fonts.BODY
|
||||
else:
|
||||
btn.color = Colors.TEXT_MUTED
|
||||
btn.font_size = Fonts.SMALL
|
||||
|
||||
def switch_to(self, name: str):
|
||||
"""External switch (e.g., from gesture)."""
|
||||
self._set_active(name)
|
||||
if self.screen_manager:
|
||||
self.screen_manager.current = name
|
||||
|
||||
|
||||
# ── Root widget ──────────────────────────────────────────────────────────────
|
||||
|
||||
class MixerRoot(BoxLayout):
|
||||
"""Root layout: header + screen area + tab bar."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.orientation = "vertical"
|
||||
|
||||
# Status bar (top)
|
||||
self._status_bar = BoxLayout(
|
||||
orientation="horizontal",
|
||||
size_hint=(1, None),
|
||||
height=Sizes.HEADER_HEIGHT,
|
||||
padding=[Sizes.PAD_MD, Sizes.PAD_XS],
|
||||
)
|
||||
self._status_bar.bg_color = Colors.BG_HEADER
|
||||
|
||||
self._status_label = Label(
|
||||
text="● Connected",
|
||||
color=Colors.ACTIVE,
|
||||
font_size=Fonts.SMALL,
|
||||
size_hint=(None, 1),
|
||||
width=dp(120),
|
||||
halign="left",
|
||||
)
|
||||
self._status_label.bind(size=self._status_label.setter("text_size"))
|
||||
|
||||
self._time_label = Label(
|
||||
text="",
|
||||
color=Colors.TEXT_MUTED,
|
||||
font_size=Fonts.SMALL,
|
||||
size_hint=(1, 1),
|
||||
halign="right",
|
||||
)
|
||||
|
||||
self._status_bar.add_widget(self._status_label)
|
||||
self._status_bar.add_widget(self._time_label)
|
||||
|
||||
# Screen manager (center)
|
||||
self.screen_manager = ScreenManager(transition=FadeTransition(duration=0.2))
|
||||
|
||||
# Screens
|
||||
self.mixer_screen = MixerScreen(name="mixer")
|
||||
self.routing_screen = RoutingScreen(name="routing")
|
||||
self.plugin_screen = PluginScreen(name="plugins")
|
||||
self.settings_screen = SettingsScreen(name="settings")
|
||||
|
||||
self.screen_manager.add_widget(self.mixer_screen)
|
||||
self.screen_manager.add_widget(self.routing_screen)
|
||||
self.screen_manager.add_widget(self.plugin_screen)
|
||||
self.screen_manager.add_widget(self.settings_screen)
|
||||
|
||||
# Tab bar (bottom)
|
||||
self.tab_bar = TabBar(screen_manager=self.screen_manager)
|
||||
|
||||
self.add_widget(self._status_bar)
|
||||
self.add_widget(self.screen_manager)
|
||||
self.add_widget(self.tab_bar)
|
||||
|
||||
def set_client(self, client: MixerClient):
|
||||
"""Inject the mixer client into all screens."""
|
||||
self.mixer_screen.client = client
|
||||
self.routing_screen.client = client
|
||||
self.plugin_screen.client = client
|
||||
self.settings_screen.client = client
|
||||
|
||||
def update_status(self, connected: bool, info: str = ""):
|
||||
"""Update the status bar."""
|
||||
if connected:
|
||||
self._status_label.text = "● Connected" + (f" {info}" if info else "")
|
||||
self._status_label.color = Colors.ACTIVE
|
||||
else:
|
||||
self._status_label.text = "○ Disconnected"
|
||||
self._status_label.color = Colors.MUTE_ON
|
||||
|
||||
|
||||
# ── Kivy App ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class MixerApp(App):
|
||||
"""Touchscreen mixer application.
|
||||
|
||||
Args to build():
|
||||
host: Mixer server hostname.
|
||||
rest_port: REST API port.
|
||||
osc_port: OSC server port.
|
||||
api_key: API key for authentication.
|
||||
"""
|
||||
|
||||
client: MixerClient = None # type: ignore
|
||||
|
||||
def __init__(self, host="127.0.0.1", rest_port=8000, osc_port=9001, api_key="", **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._host = host
|
||||
self._rest_port = rest_port
|
||||
self._osc_port = osc_port
|
||||
self._api_key = api_key
|
||||
|
||||
def build(self):
|
||||
"""Build the UI and return the root widget."""
|
||||
_configure_kivy()
|
||||
|
||||
# Set window properties
|
||||
Window.clearcolor = Colors.BG_DARK
|
||||
Window.bind(on_key_down=self._on_key_down)
|
||||
|
||||
self.client = MixerClient(
|
||||
host=self._host,
|
||||
rest_port=self._rest_port,
|
||||
osc_port=self._osc_port,
|
||||
api_key=self._api_key,
|
||||
)
|
||||
|
||||
self.root_widget = MixerRoot()
|
||||
self.root_widget.set_client(self.client)
|
||||
|
||||
# Periodic health check
|
||||
Clock.schedule_interval(self._health_check, 5.0)
|
||||
|
||||
# Update clock
|
||||
Clock.schedule_interval(self._update_clock, 1.0)
|
||||
|
||||
return self.root_widget
|
||||
|
||||
def _health_check(self, dt):
|
||||
"""Check mixer server connectivity."""
|
||||
self.client.fetch_state(
|
||||
on_success=lambda state: self.root_widget.update_status(True),
|
||||
on_error=lambda e: self.root_widget.update_status(False, str(e)[:20]),
|
||||
)
|
||||
|
||||
def _update_clock(self, dt):
|
||||
"""Update the time display."""
|
||||
import time
|
||||
t = time.localtime()
|
||||
self.root_widget._time_label.text = time.strftime("%H:%M:%S", t)
|
||||
|
||||
def _on_key_down(self, window, key, scancode, codepoint, modifier):
|
||||
"""Handle keyboard shortcuts."""
|
||||
# ESC to toggle between screens
|
||||
if key == 27: # ESC
|
||||
sm = self.root_widget.screen_manager
|
||||
screens = sm.screen_names
|
||||
idx = screens.index(sm.current)
|
||||
next_idx = (idx + 1) % len(screens)
|
||||
sm.current = screens[next_idx]
|
||||
self.root_widget.tab_bar.switch_to(screens[next_idx])
|
||||
return True
|
||||
return False
|
||||
|
||||
def on_stop(self):
|
||||
"""Cleanup on app exit."""
|
||||
if self.client:
|
||||
self.client.close()
|
||||
Reference in New Issue
Block a user