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:
@@ -0,0 +1,220 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user