Files
virtual-controller/server/ws_server.py
T
Paul Lipscomb 0c7b6b4442 JL Cooper MIDI Controller I/O, Focus widget hardware binding, iOS Channel Focus polish
Desktop:
- New dedicated MIDI Controller In port (separate from MIDI In), same CC learn/bind pipeline but never passes notes through; positioned first in the IO column order
- Fixed MIDI In "Not Connected" not persisting across restart (missing save_io_config call)
- Fixed MIDI Out/Transport Out mutual-exclusion incorrectly flagging "Not Connected" as an in-use port
- Renamed "Remote" checkbox to "App" on Fader/Toggle/Transport widgets
- FocusFaderWidget/FocusToggleWidget: new Hardware section (Learn + CC/CH bind) so a physical controller can drive a Channel Focus widget alongside the app/OSC, plus an independent Feedback checkbox that sends translated CC out (for motorized fader / LED sync)
- JL Cooper touch-sense profile ("No Profile" / "MIDI JL Cooper CC Mode") on FaderWidget/FocusFaderWidget: touch channel (value channel - 1, same CC) is filtered out of the value and gates incoming OSC/DAW feedback while touching
- FocusFeedbackWidget: "Custom" checkbox to show a manually-typed static string instead of live OSC feedback, disabling Learn/OSC input and suppressing the iPad broadcast while active
- New daw-config-reaper/ folder with reaper-osc-config-paul-custom.ReaperOSC

iOS:
- New FocusToggleWidget (dedicated Channel Focus toggle, split out of shared ToggleWidget) with colorName-aware darken/border and isMomentary handling — keeps Channel-Focus-only behavior off the regular ToggleWidget
- Arrange mode long-press menu: text alignment (left/center/right) and a 10-color named-palette Text Color picker for TitleWidget/FocusFeedbackWidget, persisted in the saved layout
- FocusFeedbackWidget: Reset Size action, monospaced-digit font fix for timestamp/counter jitter
- New "Cancel Changes" button in Arrange mode — reverts layout/hides/aligns to the state at Arrange-mode entry and restores desktop-side Remote/dest assignments
- Grid line brightness increased for visibility
- Bonjour discovery for desktop auto-connect

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 22:17:37 -04:00

109 lines
4.5 KiB
Python

import json
from PyQt6.QtCore import QObject, pyqtSignal
from PyQt6.QtWebSockets import QWebSocketServer
from PyQt6.QtNetwork import QHostAddress
class WSServer(QObject):
control_received = pyqtSignal(str, int)
control_f_received = pyqtSignal(str, float)
layout_saved = pyqtSignal(str, object)
widget_visibility_received = pyqtSignal(str, int)
client_connected = pyqtSignal()
log_signal = pyqtSignal(str)
raw_in_signal = pyqtSignal(str)
def __init__(self, port=8765, parent=None):
super().__init__(parent)
self._clients = []
self._port = port
self._server = QWebSocketServer("VC", QWebSocketServer.SslMode.NonSecureMode, self)
if self._server.listen(QHostAddress.SpecialAddress.AnyIPv4, port):
self.log_signal.emit(f"WS listening on :{port}")
else:
self.log_signal.emit(f"WS failed to bind :{port}")
self._server.newConnection.connect(self._on_new_connection)
def _on_new_connection(self):
client = self._server.nextPendingConnection()
peer = client.peerAddress().toString()
self._clients.append(client)
client.textMessageReceived.connect(self._on_message)
client.disconnected.connect(lambda c=client, p=peer: self._on_disconnect(c, p))
self.log_signal.emit(f"client connected {peer} ({len(self._clients)} total)")
self.client_connected.emit()
def _on_disconnect(self, client, peer):
if client in self._clients:
self._clients.remove(client)
client.deleteLater()
self.log_signal.emit(f"client disconnected {peer} ({len(self._clients)} remaining)")
def _on_message(self, message):
self.raw_in_signal.emit(message)
try:
data = json.loads(message)
ev = data.get("event")
if ev == "control":
uid = data["uid"]
val = int(data["value"])
self.log_signal.emit(f"control uid={uid[:8]}… val={val}")
self.control_received.emit(uid, val)
elif ev == "control_f":
uid = data["uid"]
val = float(data["value"])
self.log_signal.emit(f"control_f uid={uid[:8]}… val={val:.4f}")
self.control_f_received.emit(uid, val)
elif ev == "save_layout":
n = len(data.get("layout", {}))
self.log_signal.emit(f"layout saved preset={data['preset_uuid'][:8]}{n} widgets")
self.layout_saved.emit(data["preset_uuid"], data["layout"])
elif ev == "widget_visibility":
uid = data["uid"]
dest_id = int(data.get("dest_id", 0))
self.log_signal.emit(f"widget visibility uid={uid[:8]}… dest_id={dest_id}")
self.widget_visibility_received.emit(uid, dest_id)
else:
self.log_signal.emit(f"unknown event: {ev}")
except Exception as e:
self.log_signal.emit(f"bad message: {e}")
@property
def has_clients(self) -> bool:
return len(self._clients) > 0
def broadcast(self, data: dict):
if not self._clients:
return
msg = json.dumps(data)
for client in list(self._clients):
client.sendTextMessage(msg)
def broadcast_preset(self, snapshot: dict, layout: dict):
name = snapshot.get("preset_name", "")
self.log_signal.emit(f"broadcast preset '{name}'{len(self._clients)} client(s)")
# layout omitted — owned and persisted client-side, keyed by preset_uuid
self.broadcast({"event": "preset", "data": snapshot})
def broadcast_widget_update(self, uid: str, value: int):
self.broadcast({"event": "widget_update", "uid": uid, "value": value})
def broadcast_widget_update_f(self, uid: str, value: float):
self.broadcast({"event": "widget_update_f", "uid": uid, "value": value})
def broadcast_widget_label(self, uid: str, label: str):
self.broadcast({"event": "widget_label", "uid": uid, "label": label})
def broadcast_widget_feedback(self, uid: str, text: str):
self.broadcast({"event": "widget_feedback", "uid": uid, "text": text})
def broadcast_daw_state(self, **kwargs):
self.broadcast({"event": "daw_state", **kwargs})
def stop(self):
self._server.close()
for client in list(self._clients):
client.close()
self._clients.clear()
self.log_signal.emit("WS server stopped")