Files
raspberry-pi-mixer/src/ui/screens/routing.py
T
shawn 9cd8292acc
Lint & Validate / lint (push) Has been cancelled
Phase 1-4: Audio stack, mixer engine, MIDI, and network API
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)
2026-05-19 20:39:17 -04:00

221 lines
7.1 KiB
Python

"""Routing matrix screen — visual patchbay for audio routing.
Shows a grid of sources (rows) vs destinations (columns) with
toggle buttons for each connection. Supports scrolling for
large matrices.
Touch-optimized: large grid cells, pinch-zoom for dense matrices.
"""
from __future__ import annotations
from kivy.clock import Clock
from kivy.properties import ObjectProperty
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.uix.gridlayout import GridLayout
from kivy.uix.label import Label
from kivy.uix.scrollview import ScrollView
from kivy.uix.screenmanager import Screen
from kivy.metrics import dp
from ..theme import Colors, Fonts, Sizes
class RoutingCell(Button):
"""A single routing grid cell — tap to toggle connection."""
def __init__(self, source: str, dest: str, active: bool = False, **kwargs):
super().__init__(**kwargs)
self.source = source
self.dest = dest
self.is_active = active
self.background_normal = ""
self._update_color()
def _update_color(self):
self.background_color = Colors.ROUTE_ACTIVE if self.is_active else Colors.ROUTE_INACTIVE
def on_press(self):
self.is_active = not self.is_active
self._update_color()
class RoutingScreen(Screen):
"""Routing matrix with scrollable grid.
Sources on left (rows), destinations on top (columns).
Each cell toggles a connection between source→dest.
"""
client = ObjectProperty(None)
_cells: dict[tuple[str, str], RoutingCell] = {}
_refresh_event = None
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.name = "routing"
root = BoxLayout(orientation="vertical")
# Header
header = BoxLayout(
orientation="horizontal",
size_hint=(1, None),
height=Sizes.HEADER_HEIGHT,
padding=[Sizes.PAD_MD, Sizes.PAD_XS],
)
header.bg_color = Colors.BG_HEADER
title = Label(
text="ROUTING",
color=Colors.ACCENT,
font_size=Fonts.HEADER,
bold=True,
size_hint=(None, 1),
width=dp(140),
halign="left",
)
title.bind(size=title.setter("text_size"))
header.add_widget(title)
# Grid container
self._grid_container = BoxLayout(orientation="vertical", padding=[Sizes.PAD_SM])
self._grid_header = BoxLayout(
orientation="horizontal",
size_hint=(1, None),
height=Sizes.ROUTE_LABEL_HEIGHT,
)
self._grid_body_scroll = ScrollView(
size_hint=(1, 1),
do_scroll_x=True,
do_scroll_y=True,
bar_width=dp(4),
)
self._grid_body = GridLayout(
cols=1,
size_hint=(None, None),
spacing=Sizes.CELL_GAP,
)
self._grid_body.bind(minimum_height=self._grid_body.setter("height"))
self._grid_body.bind(minimum_width=self._grid_body.setter("width"))
self._grid_body_scroll.add_widget(self._grid_body)
self._grid_container.add_widget(self._grid_header)
self._grid_container.add_widget(self._grid_body_scroll)
root.add_widget(header)
root.add_widget(self._grid_container)
self.add_widget(root)
self._default_sources = [
"Ch1 In", "Ch2 In", "Ch3 In", "Ch4 In",
"Ch5 In", "Ch6 In", "Ch7 In", "Ch8 In",
"USB 1/2", "USB 3/4", "USB 5/6", "USB 7/8",
]
self._default_dests = [
"Master L/R", "Aux A", "Aux B", "Sub 1", "Sub 2",
"Monitor L/R",
]
def on_enter(self, *args):
self._build_grid(self._default_sources, self._default_dests)
self._refresh_event = Clock.schedule_interval(
lambda dt: self._fetch_routing(), 0.5
)
def on_leave(self, *args):
if self._refresh_event:
self._refresh_event.cancel()
self._refresh_event = None
def _build_grid(self, sources: list[str], dests: list[str]) -> None:
"""Build the routing grid from source/destination lists."""
self._grid_header.clear_widgets()
self._grid_body.clear_widgets()
self._cells.clear()
# Header row: empty corner cell + destination labels
corner = Label(
text="SRC \\ DST",
color=Colors.TEXT_MUTED,
font_size=Fonts.TINY,
size_hint=(None, 1),
width=Sizes.ROUTE_LABEL_WIDTH,
)
self._grid_header.add_widget(corner)
for dest in dests:
lbl = Label(
text=dest,
color=Colors.TEXT_SECONDARY,
font_size=Fonts.TINY,
size_hint=(None, 1),
width=Sizes.CELL_SIZE,
halign="center",
)
lbl.bind(size=lbl.setter("text_size"))
self._grid_header.add_widget(lbl)
# Body: one row per source
self._grid_body.cols = len(dests) + 1 # +1 for label column
for src in sources:
# Source label
row_label = Label(
text=src,
color=Colors.TEXT_SECONDARY,
font_size=Fonts.TINY,
size_hint=(None, None),
size=(Sizes.ROUTE_LABEL_WIDTH, Sizes.ROUTE_LABEL_HEIGHT),
halign="right",
)
row_label.bind(size=row_label.setter("text_size"))
self._grid_body.add_widget(row_label)
for dest in dests:
cell = RoutingCell(
source=src,
dest=dest,
active=False,
size_hint=(None, None),
size=(Sizes.CELL_SIZE, Sizes.CELL_SIZE),
)
cell.bind(on_press=self._make_cell_callback(src, dest))
self._cells[(src, dest)] = cell
self._grid_body.add_widget(cell)
def _make_cell_callback(self, src: str, dest: str):
def cb(instance):
if self.client:
# Send routing change via REST
self.client.set_parameter(
"routing",
1.0 if instance.is_active else 0.0,
channel=-1,
)
return cb
def _fetch_routing(self) -> None:
if not self.client:
return
self.client.fetch_routing(
on_success=self._on_routing_received,
on_error=lambda e: None,
)
def _on_routing_received(self, routing: dict) -> None:
"""Update cell states from routing data."""
edges = routing.get("edges", [])
# Reset all cells
for cell in self._cells.values():
cell.is_active = False
cell._update_color()
# Activate connected edges
for edge in edges:
src = edge.get("source", "")
dest = edge.get("dest", "")
is_active = edge.get("is_active", True)
key = (src, dest)
if key in self._cells:
self._cells[key].is_active = is_active
self._cells[key]._update_color()