9cd8292acc
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)
221 lines
7.1 KiB
Python
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()
|