14 Commits

Author SHA1 Message Date
937fb1f2f9 Separate UI and control logic with WebSocket communication
- Create UI client (src/ui_client.py) with MIDI controller integration
- Create control server (src/control_server.py) with lighting logic
- Implement WebSocket protocol between UI and control server
- Add startup script (start_lighting_controller.py) for all components
- Update Pipfile with new scripts for separated architecture
- Add comprehensive documentation (README_SEPARATED.md)
- Fix LED connection stability with heartbeat mechanism
- Fix UI knob display and button highlighting
- Maintain backward compatibility with existing MIDI mappings
2025-09-28 12:36:25 +13:00
ed5bbb8c18 Add MIDI device persistence functionality
- Add configuration file system to remember MIDI device selection
- Load saved MIDI device preference on application startup
- Automatically save MIDI device selection when changed
- Handle device disconnection gracefully with fallback
- Smart initialization with validation and error handling
- Create config.json for storing device preferences
- Improve user experience by eliminating need to re-select device
2025-09-27 23:29:06 +12:00
f9c3d08b0f Add MIDI controller dropdown selection
- Add MIDI port detection and listing functionality
- Create dropdown widget for MIDI controller selection in GUI
- Implement dynamic MIDI controller switching without restart
- Add refresh button to scan for new MIDI devices
- Add status indicator showing connection status
- Add comprehensive error handling for MIDI operations
- Fix initialization timing issues with GUI widgets
- Support graceful fallback when no MIDI devices available
2025-09-27 23:11:42 +12:00
e5cf15d7b0 Fix rainbow pattern step range in lighting controller
- Change step calculation from beat_index % 2 to beat_index % 256
- Provides full 0-255 step range for rainbow pattern color cycling
- Fixes rainbow pattern that was limited to only 0 or 1 step values
- Alternating phase patterns still use % 2 for proper phase offset
2025-09-19 01:29:08 +12:00
c40b5629bf Fix pattern highlighting in lighting controller GUI
- Add pattern name mapping to translate between MIDI handler names and GUI display names
- Fixes highlighting for patterns with underscores (sequential_pulse, alternating_phase, n_chase)
- Now properly highlights selected patterns in the button grid
2025-09-19 01:15:29 +12:00
a4a00021d8 Fix missing n3 attribute and async function issue
- Add missing self.n3 = 1 attribute to MidiHandler initialization
- Fix update_rgb async function to properly yield control with await asyncio.sleep(0)
- Resolves TypeError about expecting coroutine but getting None
- Application now working properly with buttons and dials functional
2025-09-19 00:07:37 +12:00
f2dcdabf29 Fix indentation errors and reduce debug output
- Comment out all debug logging statements to reduce console noise
- Fix empty if/else blocks by adding pass statements
- Remove beat logging, TCP server logging, and MIDI debug messages
- Keep only essential info, warning, and error messages
- Revert radiate delay separation back to using main delay parameter
2025-09-19 00:02:51 +12:00
5f7db51851 Add rate-limited parameter updates and message type system
- Rate limit parameter updates to 100ms minimum interval
- Send immediate updates if rate limit allows, otherwise queue
- Process pending updates during beat handling
- All knob changes (CC30-37) now trigger parameter updates
- Add message type field: 'b' for beats, 'u' for updates
- Optimize message type to single letters to save packet space
- Prevents ESP-NOW network flooding during rapid knob adjustments
- All packets stay under 230-byte limit with automatic splitting
2025-09-18 22:11:17 +12:00
fcbe9e9094 Implement full parameter sending on pattern change and periodic updates
- Send all parameters when pattern changes (may require 2 packets if >200 bytes)
- Send periodic parameter updates every 8 beats to keep bars synchronized
- Beat packets remain minimal for performance
- All packets stay under 230-byte limit
2025-09-18 21:58:39 +12:00
36dfda74b2 Update GUI layout and MIDI CC mappings: CC36=n3, CC37=delay, remove B1/B2 references 2025-09-18 20:35:31 +12:00
8d0c9edf5d ws: adopt nested {'0': {...}} payloads
midi: bind patterns to notes 36+; beat triggers selected pattern; include n index; CC map: 30=R 31=G 32=B 33=brightness 34=n1 35=n2 36=delay; send n1/n2 raw 0-127

gui: show n1 and n2 in status
2025-09-17 20:22:11 +12:00
1da2e30d4c midi: init read of CCs on startup (delay, brightness, RGB, beat enable); track bpm/pattern for GUI\nmain: integrate MidiHandler; add status panel for delay/brightness/RGB/pattern/BPM 2025-09-14 05:23:46 +12:00
9ff38aa875 midi: add CC29 tempo reset, CC37 brightness; local beat flag; logging\nsound: add control server with RESET_TEMPO; logging; always send BPM 2025-09-14 04:53:24 +12:00
3b869851b8 Add patterns 2025-09-09 21:40:27 +12:00
11 changed files with 2775 additions and 901 deletions

View File

@@ -19,5 +19,8 @@ websocket-client = "*"
python_version = "3.12" python_version = "3.12"
[scripts] [scripts]
main = "python main.py" ui = "python src/ui_client.py"
dev = 'watchfiles "python src/main.py" src' control = "python src/control_server.py"
sound = "python src/sound.py"
dev-ui = 'watchfiles "python src/ui_client.py" src'
dev-control = 'watchfiles "python src/control_server.py" src'

296
README_SEPARATED.md Normal file
View File

@@ -0,0 +1,296 @@
# Lighting Controller - Separated Architecture
This version of the lighting controller separates the UI and control logic, communicating via WebSocket. The MIDI controller is now integrated with the UI client.
## Architecture Overview
```
┌─────────────────┐ WebSocket ┌─────────────────┐ WebSocket ┌─────────────────┐
│ UI Client │◄─────────────────►│ Control Server │◄─────────────────►│ LED Server │
│ │ │ │ │ │
│ - MIDI Input │ │ - Lighting Logic│ │ - LED Bars │
│ - User Interface│ │ - Pattern Logic │ │ - ESP-NOW │
│ - Status Display│ │ - Beat Handling │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │
│ │ TCP
│ ▼
│ ┌─────────────────┐
│ │ Sound Detector │
│ │ │
│ │ - Audio Input │
│ │ - Beat Detection│
│ │ - BPM Analysis │
│ └─────────────────┘
│ MIDI
┌─────────────────┐
│ MIDI Controller │
│ │
│ - Knobs/Dials │
│ - Buttons │
│ - Pattern Select│
└─────────────────┘
```
## Components
### 1. UI Client (`src/ui_client.py`)
- **Purpose**: User interface and MIDI controller integration
- **Features**:
- MIDI controller input handling
- Real-time status display
- Pattern selection visualization
- Connection status monitoring
- **Communication**: WebSocket client to control server
### 2. Control Server (`src/control_server.py`)
- **Purpose**: Core lighting control logic
- **Features**:
- Pattern execution
- Beat synchronization
- Parameter management
- LED bar communication
- **Communication**:
- WebSocket server for UI clients
- TCP server for sound detector
- WebSocket client to LED server
### 3. Sound Detector (`src/sound.py`)
- **Purpose**: Audio beat detection and BPM analysis
- **Features**:
- Real-time audio processing
- Beat detection
- BPM calculation
- Tempo reset functionality
- **Communication**: TCP client to control server
## WebSocket Protocol
### UI Client → Control Server Messages
```json
{
"type": "pattern_change",
"data": {
"pattern": "pulse"
}
}
```
```json
{
"type": "color_change",
"data": {
"r": 255,
"g": 0,
"b": 0
}
}
```
```json
{
"type": "brightness_change",
"data": {
"brightness": 80
}
}
```
```json
{
"type": "parameter_change",
"data": {
"n1": 15,
"n2": 20
}
}
```
```json
{
"type": "delay_change",
"data": {
"delay": 150
}
}
```
```json
{
"type": "beat_toggle",
"data": {
"enabled": true
}
}
```
```json
{
"type": "reset_tempo",
"data": {}
}
```
## Running the System
### Option 1: Use the startup script (Recommended)
```bash
python start_lighting_controller.py
```
### Option 2: Start components individually
1. **Start Control Server**:
```bash
pipenv run control
# or
python src/control_server.py
```
2. **Start Sound Detector** (in another terminal):
```bash
pipenv run sound
# or
python src/sound.py
```
3. **Start UI Client** (in another terminal):
```bash
pipenv run ui
# or
python src/ui_client.py
```
### Option 3: Development mode with auto-reload
```bash
# Terminal 1 - Control Server
pipenv run dev-control
# Terminal 2 - Sound Detector
pipenv run sound
# Terminal 3 - UI Client
pipenv run dev-ui
```
## Configuration
### MIDI Controller
- MIDI device preferences are saved in `config.json`
- The UI client automatically detects and connects to MIDI devices
- Use the dropdown to select different MIDI ports
### Network Settings
- **Control Server**: `localhost:8765` (WebSocket)
- **Sound Detector**: `127.0.0.1:65432` (TCP)
- **LED Server**: `192.168.4.1:80/ws` (WebSocket)
### Audio Settings
- Audio input device index: 7 (modify in `src/sound.py`)
- Buffer size: 512 samples
- Sample rate: Auto-detected from device
## MIDI Controller Mapping
### Buttons (Notes 36-51)
- **Row 1**: Pulse, Sequential Pulse
- **Row 2**: Alternating, Alternating Phase
- **Row 3**: N Chase, Rainbow
- **Row 4**: Flicker, Radiate
### Dials (CC30-37)
- **CC30**: Red (0-255)
- **CC31**: Green (0-255)
- **CC32**: Blue (0-255)
- **CC33**: Brightness (0-100)
- **CC34**: N1 parameter
- **CC35**: N2 parameter
- **CC36**: N3 parameter
- **CC37**: Delay (0-508ms)
### Additional Knobs (CC38-45)
- **CC38**: Pulse N1
- **CC39**: Pulse N2
- **CC40**: Alternating N1
- **CC41**: Alternating N2
- **CC42**: Radiate N1
- **CC43**: Radiate Delay
- **CC44**: Knob 7
- **CC45**: Knob 8
### Control Buttons
- **CC27**: Beat sending toggle (127=on, 0=off)
- **CC29**: Reset tempo detection
## Troubleshooting
### Connection Issues
1. **UI Client can't connect to Control Server**:
- Ensure control server is running first
- Check firewall settings
- Verify port 8765 is available
2. **Control Server can't connect to LED Server**:
- Check LED server IP address (192.168.4.1)
- Verify LED server is running
- Check network connectivity
3. **Sound Detector can't connect to Control Server**:
- Ensure control server is running
- Check TCP port 65432 is available
### MIDI Issues
1. **No MIDI devices detected**:
- Check MIDI controller connection
- Install MIDI drivers if needed
- Use "Refresh MIDI Ports" button
2. **MIDI input not working**:
- Verify correct MIDI port is selected
- Check MIDI controller is sending data
- Look for error messages in console
### Performance Issues
1. **High CPU usage**:
- Reduce audio buffer size in sound.py
- Increase parameter update interval
- Check for network latency
2. **Audio dropouts**:
- Increase audio buffer size
- Check audio device settings
- Reduce system load
## Development
### Adding New Patterns
1. Add pattern name to `PATTERN_NAMES` in `control_server.py`
2. Implement pattern logic in `LightingController` class
3. Add pattern to MIDI button mapping in `ui_client.py`
### Adding New MIDI Controls
1. Add control change handler in `MidiController.handle_midi_message()`
2. Add corresponding WebSocket message type
3. Implement handler in `LightingController.handle_ui_command()`
### Modifying UI
- Edit `src/ui_client.py` for UI changes
- Use `pipenv run dev-ui` for auto-reload during development
- UI uses tkinter with dark theme
## Migration from Monolithic Version
The separated architecture maintains compatibility with:
- Existing MIDI controller mappings
- LED bar communication protocol
- Sound detection functionality
- Configuration files
Key differences:
- MIDI controller is now part of UI client
- Control logic is isolated in control server
- Communication via WebSocket instead of direct function calls
- Better separation of concerns and modularity

View File

@@ -1,8 +1,9 @@
{ {
"lights": { "lights": {
"1": { "sign": {
"names": [ "names": [
"10" "tt-sign",
"1"
], ],
"settings": { "settings": {
"colors": [ "colors": [
@@ -11,13 +12,13 @@
"#00ff00" "#00ff00"
], ],
"brightness": 9, "brightness": 9,
"pattern": "on", "pattern": "off",
"delay": 50 "delay": 50
} }
}, },
"2": { "dj": {
"names": [ "names": [
"13" "dj"
], ],
"settings": { "settings": {
"colors": [ "colors": [
@@ -31,9 +32,12 @@
"delay": 520 "delay": 520
} }
}, },
"3": { "middle": {
"names": [ "names": [
"11" "middle1",
"middle2",
"middle3",
"middle4"
], ],
"settings": { "settings": {
"colors": [ "colors": [
@@ -47,11 +51,10 @@
"delay": 520 "delay": 520
} }
}, },
"all": { "sides": {
"names": [ "names": [
"10", "left",
"11", "right"
"13"
], ],
"settings": { "settings": {
"colors": [ "colors": [
@@ -64,6 +67,86 @@
"pattern": "on", "pattern": "on",
"delay": 520 "delay": 520
} }
},
"outside": {
"names": [
"outside"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "on",
"delay": 520
}
},
"middle1": {
"names": [
"middle1"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "flicker",
"delay": 520
}
},
"middle2": {
"names": [
"middle2"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "flicker",
"delay": 520
}
},
"middle3": {
"names": [
"middle3"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "flicker",
"delay": 520
}
},
"middle4": {
"names": [
"middle4"
],
"settings": {
"colors": [
"#0000ff",
"#c30074",
"#00ff00",
"#000000"
],
"brightness": 6,
"pattern": "flicker",
"delay": 520
}
} }
}, },
"patterns": [ "patterns": [

38
src/bar_config.py Normal file
View File

@@ -0,0 +1,38 @@
# LED Bar Configuration
# Modify these names as needed for your setup
# LED Bar Names/IDs - 4 left bars + 4 right bars
LED_BAR_NAMES = [
"100", # Left Bar 1
"101", # Left Bar 2
"102", # Left Bar 3
"103", # Left Bar 4
"104", # Right Bar 1
"105", # Right Bar 2
"106", # Right Bar 3
"107", # Right Bar 4
]
# Left and right bar groups for spatial control
LEFT_BARS = ["100", "101", "102", "103"]
RIGHT_BARS = ["104", "105", "106", "107"]
# Number of LED bars
NUM_BARS = len(LED_BAR_NAMES)
# Default settings for all bars
DEFAULT_BAR_SETTINGS = {
"pattern": "pulse",
"delay": 100,
"colors": [(0, 255, 0)], # Default green
"brightness": 10,
"num_leds": 200,
"n1": 10,
"n2": 10,
"n3": 1,
"n": 0,
}
# ESP-NOW broadcast settings
ESP_NOW_CHANNEL = 1
ESP_NOW_ENCRYPTION = False

443
src/control_server.py Normal file
View File

@@ -0,0 +1,443 @@
#!/usr/bin/env python3
"""
Control Server for Lighting Controller
Handles lighting control logic and communicates with LED bars via WebSocket.
Receives commands from UI client via WebSocket.
"""
import asyncio
import websockets
import json
import logging
import socket
import threading
import time
from bar_config import LED_BAR_NAMES, DEFAULT_BAR_SETTINGS
from color_utils import adjust_brightness
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# Configuration
LED_SERVER_URI = "ws://192.168.4.1:80/ws"
CONTROL_SERVER_PORT = 8765
SOUND_CONTROL_HOST = "127.0.0.1"
SOUND_CONTROL_PORT = 65433
# Pattern name mapping for shorter JSON payloads
PATTERN_NAMES = {
"flicker": "f",
"fill_range": "fr",
"n_chase": "nc",
"alternating": "a",
"pulse": "p",
"rainbow": "r",
"specto": "s",
"radiate": "rd",
"sequential_pulse": "sp",
"alternating_phase": "ap",
}
class LEDController:
"""Handles communication with LED bars via WebSocket."""
def __init__(self, led_server_uri):
self.led_server_uri = led_server_uri
self.websocket = None
self.is_connected = False
self.reconnect_task = None
async def connect(self):
"""Connect to LED server."""
if self.is_connected and self.websocket:
return
try:
logging.info(f"Connecting to LED server at {self.led_server_uri}...")
self.websocket = await websockets.connect(self.led_server_uri)
self.is_connected = True
logging.info("Connected to LED server")
except Exception as e:
logging.error(f"Failed to connect to LED server: {e}")
self.is_connected = False
self.websocket = None
async def send_data(self, data):
"""Send data to LED server."""
if not self.is_connected or not self.websocket:
logging.warning("Not connected to LED server. Attempting to reconnect...")
await self.connect()
if not self.is_connected:
logging.error("Failed to reconnect to LED server. Cannot send data.")
return
try:
await self.websocket.send(json.dumps(data))
logging.debug(f"Sent to LED server: {data}")
except Exception as e:
logging.error(f"Failed to send data to LED server: {e}")
self.is_connected = False
self.websocket = None
# Attempt to reconnect
await self.connect()
async def close(self):
"""Close LED server connection."""
if self.websocket and self.is_connected:
await self.websocket.close()
self.is_connected = False
self.websocket = None
logging.info("Disconnected from LED server")
class SoundController:
"""Handles communication with sound beat detector."""
def __init__(self, sound_host, sound_port):
self.sound_host = sound_host
self.sound_port = sound_port
async def send_reset_tempo(self):
"""Send reset tempo command to sound controller."""
try:
reader, writer = await asyncio.open_connection(self.sound_host, self.sound_port)
cmd = "RESET_TEMPO\n".encode('utf-8')
writer.write(cmd)
await writer.drain()
resp = await reader.read(100)
logging.info(f"Sent RESET_TEMPO, response: {resp.decode().strip()}")
writer.close()
await writer.wait_closed()
except Exception as e:
logging.error(f"Failed to send RESET_TEMPO: {e}")
class LightingController:
"""Main lighting control logic."""
def __init__(self):
self.led_controller = LEDController(LED_SERVER_URI)
self.sound_controller = SoundController(SOUND_CONTROL_HOST, SOUND_CONTROL_PORT)
# Lighting state
self.current_pattern = ""
self.delay = 100
self.brightness = 100
self.color_r = 0
self.color_g = 255
self.color_b = 0
self.n1 = 10
self.n2 = 10
self.n3 = 1
self.beat_index = 0
self.beat_sending_enabled = True
# Rate limiting
self.last_param_update = 0.0
self.param_update_interval = 0.1
self.pending_param_update = False
def _current_color_rgb(self):
"""Get current RGB color tuple."""
r = max(0, min(255, int(self.color_r)))
g = max(0, min(255, int(self.color_g)))
b = max(0, min(255, int(self.color_b)))
return (r, g, b)
async def _send_full_parameters(self):
"""Send all parameters to LED bars."""
full_payload = {
"d": {
"t": "u", # Message type: update
"pt": PATTERN_NAMES.get(self.current_pattern, self.current_pattern),
"dl": self.delay,
"cl": [self._current_color_rgb()],
"br": self.brightness,
"n1": self.n1,
"n2": self.n2,
"n3": self.n3,
"s": self.beat_index % 256,
}
}
# Add empty entries for each bar
for bar_name in LED_BAR_NAMES:
full_payload[bar_name] = {}
await self.led_controller.send_data(full_payload)
async def _request_param_update(self):
"""Request parameter update with rate limiting."""
current_time = time.time()
if current_time - self.last_param_update >= self.param_update_interval:
self.last_param_update = current_time
await self._send_full_parameters()
else:
self.pending_param_update = True
async def _send_normal_pattern(self):
"""Send normal pattern to all bars."""
patterns_needing_params = ["alternating", "flicker", "n_chase", "rainbow", "radiate"]
payload = {
"d": {
"t": "b", # Message type: beat
"pt": PATTERN_NAMES.get(self.current_pattern, self.current_pattern),
}
}
if self.current_pattern in patterns_needing_params:
payload["d"].update({
"n1": self.n1,
"n2": self.n2,
"n3": self.n3,
"dl": self.delay,
"s": self.beat_index % 256,
})
for bar_name in LED_BAR_NAMES:
payload[bar_name] = {}
await self.led_controller.send_data(payload)
async def _handle_sequential_pulse(self):
"""Handle sequential pulse pattern."""
from bar_config import LEFT_BARS, RIGHT_BARS
bar_index = self.beat_index % 4
payload = {
"d": {
"t": "b",
"pt": "o", # off
}
}
left_bar = LEFT_BARS[bar_index]
right_bar = RIGHT_BARS[bar_index]
payload[left_bar] = {"pt": "p"} # pulse
payload[right_bar] = {"pt": "p"} # pulse
await self.led_controller.send_data(payload)
async def _handle_alternating_phase(self):
"""Handle alternating pattern with phase offset."""
payload = {
"d": {
"t": "b",
"pt": "a", # alternating
"n1": self.n1,
"n2": self.n2,
"s": self.beat_index % 2,
}
}
swap_bars = ["101", "103", "105", "107"]
for bar_name in LED_BAR_NAMES:
if bar_name in swap_bars:
payload[bar_name] = {"s": (self.beat_index + 1) % 2}
else:
payload[bar_name] = {}
await self.led_controller.send_data(payload)
async def handle_beat(self, bpm_value):
"""Handle beat from sound detector."""
if not self.beat_sending_enabled or not self.current_pattern:
return
self.beat_index = (self.beat_index + 1) % 1000000
# Send periodic parameter updates every 8 beats
if self.beat_index % 8 == 0:
await self._send_full_parameters()
# Check for pending parameter updates
if self.pending_param_update:
current_time = time.time()
if current_time - self.last_param_update >= self.param_update_interval:
self.last_param_update = current_time
self.pending_param_update = False
await self._send_full_parameters()
# Handle pattern-specific beat logic
if self.current_pattern == "sequential_pulse":
await self._handle_sequential_pulse()
elif self.current_pattern == "alternating_phase":
await self._handle_alternating_phase()
elif self.current_pattern:
await self._send_normal_pattern()
async def handle_ui_command(self, message_type, data):
"""Handle command from UI client."""
if message_type == "pattern_change":
self.current_pattern = data.get("pattern", "")
await self._send_full_parameters()
logging.info(f"Pattern changed to: {self.current_pattern}")
elif message_type == "color_change":
self.color_r = data.get("r", self.color_r)
self.color_g = data.get("g", self.color_g)
self.color_b = data.get("b", self.color_b)
await self._request_param_update()
elif message_type == "brightness_change":
self.brightness = data.get("brightness", self.brightness)
await self._request_param_update()
elif message_type == "parameter_change":
if "n1" in data:
self.n1 = data["n1"]
if "n2" in data:
self.n2 = data["n2"]
if "n3" in data:
self.n3 = data["n3"]
await self._request_param_update()
elif message_type == "delay_change":
self.delay = data.get("delay", self.delay)
await self._request_param_update()
elif message_type == "beat_toggle":
self.beat_sending_enabled = data.get("enabled", True)
logging.info(f"Beat sending {'enabled' if self.beat_sending_enabled else 'disabled'}")
elif message_type == "reset_tempo":
await self.sound_controller.send_reset_tempo()
class ControlServer:
"""WebSocket server for UI client communication and TCP server for sound."""
def __init__(self):
self.lighting_controller = LightingController()
self.clients = set()
self.tcp_server = None
async def handle_ui_client(self, websocket):
"""Handle UI client WebSocket connection."""
self.clients.add(websocket)
client_addr = websocket.remote_address
logging.info(f"UI client connected: {client_addr}")
try:
async for message in websocket:
try:
data = json.loads(message)
message_type = data.get("type")
message_data = data.get("data", {})
await self.lighting_controller.handle_ui_command(message_type, message_data)
except json.JSONDecodeError:
logging.error(f"Invalid JSON from client {client_addr}: {message}")
except Exception as e:
logging.error(f"Error handling message from client {client_addr}: {e}")
except websockets.exceptions.ConnectionClosed:
logging.info(f"UI client disconnected: {client_addr}")
except Exception as e:
logging.error(f"Error in UI client handler: {e}")
finally:
self.clients.discard(websocket)
async def handle_tcp_client(self, reader, writer):
"""Handle TCP client (sound detector) connection."""
addr = writer.get_extra_info('peername')
logging.info(f"Sound client connected: {addr}")
try:
while True:
data = await reader.read(4096)
if not data:
logging.info(f"Sound client disconnected: {addr}")
break
message = data.decode().strip()
if self.lighting_controller.beat_sending_enabled:
try:
bpm_value = float(message)
await self.lighting_controller.handle_beat(bpm_value)
except ValueError:
logging.warning(f"Non-BPM message from {addr}: {message}")
except Exception as e:
logging.error(f"Error processing beat from {addr}: {e}")
except asyncio.CancelledError:
logging.info(f"Sound client handler cancelled: {addr}")
except Exception as e:
logging.error(f"Error handling sound client {addr}: {e}")
finally:
logging.info(f"Closing connection for sound client: {addr}")
writer.close()
await writer.wait_closed()
async def start_tcp_server(self):
"""Start TCP server for sound detector."""
self.tcp_server = await asyncio.start_server(
self.handle_tcp_client, "127.0.0.1", 65432
)
addrs = ', '.join(str(sock.getsockname()) for sock in self.tcp_server.sockets)
logging.info(f"TCP server listening on {addrs}")
async def start_websocket_server(self):
"""Start WebSocket server for UI clients."""
server = await websockets.serve(
self.handle_ui_client, "localhost", CONTROL_SERVER_PORT
)
logging.info(f"WebSocket server listening on localhost:{CONTROL_SERVER_PORT}")
async def run(self):
"""Run the control server."""
# Connect to LED server
await self.lighting_controller.led_controller.connect()
# Start servers and heartbeat task
await asyncio.gather(
self.start_websocket_server(),
self.start_tcp_server(),
self._heartbeat_loop()
)
async def _heartbeat_loop(self):
"""Send periodic heartbeats to keep LED connection alive."""
try:
while True:
await asyncio.sleep(5) # Send heartbeat every 5 seconds
if self.lighting_controller.led_controller.is_connected:
# Send a simple heartbeat to keep connection alive
heartbeat_data = {
"d": {
"t": "h", # heartbeat type
}
}
await self.lighting_controller.led_controller.send_data(heartbeat_data)
except asyncio.CancelledError:
logging.info("Heartbeat loop cancelled")
except Exception as e:
logging.error(f"Heartbeat loop error: {e}")
async def main():
"""Main entry point."""
server = ControlServer()
try:
await server.run()
except KeyboardInterrupt:
logging.info("Server interrupted by user")
except Exception as e:
logging.error(f"Server error: {e}")
finally:
await server.lighting_controller.led_controller.close()
if __name__ == "__main__":
asyncio.run(main())

File diff suppressed because it is too large Load Diff

View File

@@ -1,105 +1,570 @@
import mido import mido
import asyncio import asyncio
import time import networking
import networking # <--- This will now correctly import your module import socket
import json
import logging # Added logging import
import time # Added for initial state read
import tkinter as tk
from tkinter import ttk, messagebox # Import messagebox for confirmations
from bar_config import LED_BAR_NAMES, DEFAULT_BAR_SETTINGS
# Pattern name mapping for shorter JSON payloads
PATTERN_NAMES = {
"flicker": "f",
"fill_range": "fr",
"n_chase": "nc",
"alternating": "a",
"pulse": "p",
"rainbow": "r",
"specto": "s",
"radiate": "rd",
}
async def midi_to_websocket_listener(midi_port_index: int, websocket_uri: str): # Configure logging
DEBUG_MODE = True # Set to False for INFO level logging
logging.basicConfig(level=logging.DEBUG if DEBUG_MODE else logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# TCP Server Configuration
TCP_HOST = "127.0.0.1"
TCP_PORT = 65432
# Sound Control Server Configuration (for sending reset)
SOUND_CONTROL_HOST = "127.0.0.1"
SOUND_CONTROL_PORT = 65433
class MidiHandler:
def __init__(self, midi_port_index: int, websocket_uri: str):
self.midi_port_index = midi_port_index
self.websocket_uri = websocket_uri
self.ws_client = networking.WebSocketClient(websocket_uri)
self.delay = 100 # Default delay value, controlled by MIDI controller
self.brightness = 100 # Default brightness value, controlled by MIDI controller
self.tcp_host = TCP_HOST
self.tcp_port = TCP_PORT
self.beat_sending_enabled = True # New: Local flag for beat sending
self.sound_control_host = SOUND_CONTROL_HOST
self.sound_control_port = SOUND_CONTROL_PORT
# RGB controlled by CC 30/31/32 (default green)
self.color_r = 0
self.color_g = 255
self.color_b = 0
# Generic parameters controlled via CC
# Raw CC-driven parameters (0-127)
self.n1 = 10
self.n2 = 10
self.n3 = 1
# Additional knobs (CC38-45)
self.knob1 = 0
self.knob2 = 0
self.knob3 = 0
self.knob4 = 0
self.knob5 = 0
self.knob6 = 0
self.knob7 = 0
self.knob8 = 0
# Current state for GUI display
self.current_bpm: float | None = None
self.current_pattern: str = ""
self.beat_index: int = 0
# Rate limiting for parameter updates
self.last_param_update: float = 0.0
self.param_update_interval: float = 0.1 # 100ms minimum between updates
self.pending_param_update: bool = False
# Sequential pulse pattern state
self.sequential_pulse_enabled: bool = False
self.sequential_pulse_step: int = 0
def _current_color_rgb(self) -> tuple:
r = max(0, min(255, int(self.color_r)))
g = max(0, min(255, int(self.color_g)))
b = max(0, min(255, int(self.color_b)))
return (r, g, b)
async def _handle_sequential_pulse(self):
"""Handle sequential pulse pattern: each bar pulses for 1 beat, then next bar, mirrored"""
from bar_config import LEFT_BARS, RIGHT_BARS
# Calculate which bar should pulse based on beat (1 beat per bar)
bar_index = self.beat_index % 4 # 0-3, cycles every 4 beats
# Create minimal payload - defaults to off
payload = {
"d": { # Defaults - off for all bars
"t": "b", # Message type: beat
"pt": "o", # off
}
}
# Set specific bars to pulse
left_bar = LEFT_BARS[bar_index]
right_bar = RIGHT_BARS[bar_index]
payload[left_bar] = {"pt": "p"} # pulse
payload[right_bar] = {"pt": "p"} # pulse
# logging.debug(f"[Sequential Pulse] Beat {self.beat_index}, pulsing bars {left_bar} and {right_bar}")
await self.ws_client.send_data(payload)
async def _handle_alternating_phase(self):
"""Handle alternating pattern with phase offset: every second bar uses different step"""
from bar_config import LED_BAR_NAMES
# Create minimal payload - same n1/n2 for all bars
payload = {
"d": { # Defaults - pattern and n1/n2
"t": "b", # Message type: beat
"pt": "a", # alternating
"n1": self.n1,
"n2": self.n2,
"s": self.beat_index % 2, # Default step for in-phase bars
}
}
# Set step offset for every second bar (bars 101, 103, 105, 107)
swap_bars = ["101", "103", "105", "107"]
for bar_name in LED_BAR_NAMES:
if bar_name in swap_bars:
# Send step offset for out-of-phase bars
payload[bar_name] = {"s": (self.beat_index + 1) % 2}
else:
# In-phase bars use defaults (no override needed)
payload[bar_name] = {}
# logging.debug(f"[Alternating Phase] Beat {self.beat_index}, step offset for bars {swap_bars}")
await self.ws_client.send_data(payload)
async def _send_full_parameters(self):
"""Send all parameters to bars - may require multiple packets due to size limit"""
from bar_config import LED_BAR_NAMES
# Calculate packet size for full parameters
full_payload = {
"d": {
"t": "u", # Message type: update
"pt": PATTERN_NAMES.get(self.current_pattern, self.current_pattern),
"dl": self.delay,
"cl": [self._current_color_rgb()],
"br": self.brightness,
"n1": self.n1,
"n2": self.n2,
"n3": self.n3,
"s": self.beat_index % 256, # Use full range for rainbow patterns
}
}
# Estimate size: ~200 bytes for defaults + 8 bars * 2 bytes = ~216 bytes
# This should fit in one packet, but let's be safe
payload_size = len(str(full_payload))
if payload_size > 200: # Split into 2 packets if too large
# Packet 1: Pattern and timing parameters
payload1 = {
"d": {
"t": "u", # Message type: update
"pt": PATTERN_NAMES.get(self.current_pattern, self.current_pattern),
"dl": self.delay,
"br": self.brightness,
}
}
for bar_name in LED_BAR_NAMES:
payload1[bar_name] = {}
# Packet 2: Color and pattern parameters
payload2 = {
"d": {
"t": "u", # Message type: update
"cl": [self._current_color_rgb()],
"n1": self.n1,
"n2": self.n2,
"n3": self.n3,
"s": self.beat_index % 2, # Keep step small (0 or 1) for alternating patterns
}
}
for bar_name in LED_BAR_NAMES:
payload2[bar_name] = {}
# logging.debug(f"[Full Params] Sending in 2 packets due to size ({payload_size} bytes)")
await self.ws_client.send_data(payload1)
await asyncio.sleep(0.01) # Small delay between packets
await self.ws_client.send_data(payload2)
else:
# Single packet
for bar_name in LED_BAR_NAMES:
full_payload[bar_name] = {}
# logging.debug(f"[Full Params] Sending single packet ({payload_size} bytes)")
await self.ws_client.send_data(full_payload)
async def _request_param_update(self):
"""Request a parameter update with rate limiting"""
import time
current_time = time.time()
if current_time - self.last_param_update >= self.param_update_interval:
# Can send immediately
self.last_param_update = current_time
await self._send_full_parameters()
# logging.debug("[Rate Limit] Parameter update sent immediately")
else:
# Rate limited - mark as pending
self.pending_param_update = True
# logging.debug("[Rate Limit] Parameter update queued (rate limited)")
async def _send_normal_pattern(self):
"""Send normal pattern to all bars - include required parameters"""
# Patterns that need specific parameters
patterns_needing_params = ["alternating", "flicker", "n_chase", "rainbow", "radiate"]
payload = {
"d": { # Defaults
"t": "b", # Message type: beat
"pt": PATTERN_NAMES.get(self.current_pattern, self.current_pattern),
}
}
# Add required parameters for patterns that need them
if self.current_pattern in patterns_needing_params:
payload["d"].update({
"n1": self.n1,
"n2": self.n2,
"n3": self.n3,
"dl": self.delay,
"s": self.beat_index % 256, # Use full range for rainbow patterns
})
# Add empty entries for each bar (they'll use defaults)
for bar_name in LED_BAR_NAMES:
payload[bar_name] = {}
# logging.debug(f"[Beat] Triggering '{self.current_pattern}' for {len(LED_BAR_NAMES)} bars")
await self.ws_client.send_data(payload)
async def _send_reset_to_sound(self):
try:
reader, writer = await asyncio.open_connection(self.sound_control_host, self.sound_control_port)
cmd = "RESET_TEMPO\n".encode('utf-8')
writer.write(cmd)
await writer.drain()
resp = await reader.read(100)
logging.info(f"[MidiHandler - Control] Sent RESET_TEMPO, response: {resp.decode().strip()}")
writer.close()
await writer.wait_closed()
except Exception as e:
logging.error(f"[MidiHandler - Control] Failed to send RESET_TEMPO: {e}")
async def _handle_tcp_client(self, reader, writer):
addr = writer.get_extra_info('peername')
logging.info(f"[MidiHandler - TCP Server] Connected by {addr}") # Changed to info
try:
while True:
data = await reader.read(4096) # Read up to 4KB of data
if not data:
logging.info(f"[MidiHandler - TCP Server] Client {addr} disconnected.") # Changed to info
break
message = data.decode().strip()
# logging.debug(f"[MidiHandler - TCP Server] Received from {addr}: {message}") # Changed to debug
if self.beat_sending_enabled:
try:
# Attempt to parse as float (BPM) from sound.py
bpm_value = float(message)
self.current_bpm = bpm_value
# On each beat, trigger currently selected pattern(s)
if not self.current_pattern:
pass # No pattern selected yet; ignoring beat
else:
self.beat_index = (self.beat_index + 1) % 1000000
# Send periodic parameter updates every 8 beats
if self.beat_index % 8 == 0:
await self._send_full_parameters()
# Check for pending parameter updates (rate limited)
if self.pending_param_update:
import time
current_time = time.time()
if current_time - self.last_param_update >= self.param_update_interval:
self.last_param_update = current_time
self.pending_param_update = False
await self._send_full_parameters()
# logging.debug("[Rate Limit] Pending parameter update sent")
if self.current_pattern == "sequential_pulse":
# Sequential pulse pattern: each bar pulses for 1 beat, then next bar, mirrored
await self._handle_sequential_pulse()
elif self.current_pattern == "alternating_phase":
# Alternating pattern with phase offset: every second bar is out of phase
await self._handle_alternating_phase()
elif self.current_pattern:
# Normal pattern mode - run on all bars
await self._send_normal_pattern()
except ValueError:
logging.warning(f"[MidiHandler - TCP Server] Received non-BPM message from {addr}, not forwarding: {message}") # Changed to warning
except Exception as e:
logging.error(f"[MidiHandler - TCP Server] Error processing received message from {addr}: {e}") # Changed to error
else:
pass # Beat sending disabled
except asyncio.CancelledError:
logging.info(f"[MidiHandler - TCP Server] Client handler for {addr} cancelled.") # Changed to info
except Exception as e:
logging.error(f"[MidiHandler - TCP Server] Error handling client {addr}: {e}") # Changed to error
finally:
logging.info(f"[MidiHandler - TCP Server] Closing connection for {addr}") # Changed to info
writer.close()
await writer.wait_closed()
async def _midi_tcp_server(self):
server = await asyncio.start_server(
lambda r, w: self._handle_tcp_client(r, w), self.tcp_host, self.tcp_port)
addrs = ', '.join(str(sock.getsockname()) for sock in server.sockets)
logging.info(f"[MidiHandler - TCP Server] Serving on {addrs}") # Changed to info
async with server:
await server.serve_forever()
async def _read_initial_cc_state(self, port, timeout_s: float = 0.5):
"""Read initial CC values from the MIDI device for a short period to populate state."""
start = time.time()
while time.time() - start < timeout_s:
msg = port.receive(block=False)
if msg and msg.type == 'control_change':
if msg.control == 36:
self.n3 = max(1, msg.value)
logging.info(f"[Init] n3 set to {self.n3} from CC36")
elif msg.control == 37:
self.delay = msg.value * 4
logging.info(f"[Init] Delay set to {self.delay} ms from CC37")
elif msg.control == 39:
self.delay = msg.value * 4
logging.info(f"[Init] Delay set to {self.delay} ms from CC39")
elif msg.control == 33:
self.brightness = round((msg.value / 127) * 100)
logging.info(f"[Init] Brightness set to {self.brightness} from CC33")
elif msg.control == 30:
self.color_r = round((msg.value / 127) * 255)
logging.info(f"[Init] Red set to {self.color_r} from CC30")
elif msg.control == 31:
self.color_g = round((msg.value / 127) * 255)
logging.info(f"[Init] Green set to {self.color_g} from CC31")
elif msg.control == 32:
self.color_b = round((msg.value / 127) * 255)
logging.info(f"[Init] Blue set to {self.color_b} from CC32")
elif msg.control == 34:
self.n1 = int(msg.value)
logging.info(f"[Init] n1 set to {self.n1} from CC34")
elif msg.control == 35:
self.n2 = int(msg.value)
logging.info(f"[Init] n2 set to {self.n2} from CC35")
elif msg.control == 27:
self.beat_sending_enabled = (msg.value == 127)
logging.info(f"[Init] Beat sending {'ENABLED' if self.beat_sending_enabled else 'DISABLED'} from CC27")
await asyncio.sleep(0.001)
async def _midi_listener(self):
logging.info("Midi function") # Changed to info
""" """
Listens to a specific MIDI port and sends data to a WebSocket server Listens to a specific MIDI port and sends data to a WebSocket server
when Note 32 (and 33) is pressed. when Note 32 (and 33) is pressed.
""" """
delay = 100 # Default delay value
# 1. Get MIDI port name # 1. Get MIDI port name
port_names = mido.get_input_names() port_names = mido.get_input_names()
if not port_names: if not port_names:
print("No MIDI input ports found. Please connect your device.") logging.warning("No MIDI input ports found. Please connect your device.") # Changed to warning
return return
if not (0 <= midi_port_index < len(port_names)): if not (0 <= self.midi_port_index < len(port_names)):
print(f"Error: MIDI port index {midi_port_index} out of range. Available ports: {port_names}") logging.error(f"Error: MIDI port index {self.midi_port_index} out of range. Available ports: {port_names}") # Changed to error
print("Available ports:") logging.info("Available ports:") # Changed to info
for i, name in enumerate(port_names): for i, name in enumerate(port_names):
print(f" {i}: {name}") logging.info(f" {i}: {name}") # Changed to info
return return
midi_port_name = port_names[midi_port_index] midi_port_name = port_names[self.midi_port_index]
print(f"Selected MIDI input port: {midi_port_name}") logging.info(f"Selected MIDI input port: {midi_port_name}") # Changed to info
# 2. Initialize WebSocket client (using your actual networking.py)
ws_client = networking.WebSocketClient(websocket_uri)
try: try:
# 3. Connect WebSocket
await ws_client.connect()
print(f"WebSocket client connected to {ws_client.uri}")
# 4. Open MIDI port and start listening loop
with mido.open_input(midi_port_name) as port: with mido.open_input(midi_port_name) as port:
print(f"MIDI port '{midi_port_name}' opened. Press Ctrl+C to stop.") logging.info(f"MIDI port '{midi_port_name}' opened. Press Ctrl+C to stop.") # Changed to info
# Read initial controller state briefly
await self._read_initial_cc_state(port)
while True: while True:
msg = port.receive(block=False) # Non-blocking read msg = port.receive(block=False) # Non-blocking read
if msg: if msg:
# logging.debug(msg) # Changed to debug
match msg.type: match msg.type:
case 'note_on': case 'note_on':
print(f" Note ON: Note={msg.note}, Velocity={msg.velocity}, Channel={msg.channel}") # logging.debug(f" Note ON: Note={msg.note}, Velocity={msg.velocity}, Channel={msg.channel}") # Changed to debug
match msg.note: # Bank1 patterns starting at MIDI note 36
case 32: pattern_bindings: list[str] = [
await ws_client.send_data({ # Pulse patterns (row 1)
"names": ["1"], "pulse",
"settings": { "sequential_pulse",
"pattern": "pulse", # Alternating patterns (row 2)
"delay": delay, "alternating",
"colors": ["#00ff00"], "alternating_phase",
"brightness": 100, # Chase/movement patterns (row 3)
"num_leds": 200, "n_chase",
} "rainbow",
}) # Effect patterns (row 4)
case 33: "flicker",
await ws_client.send_data({ "radiate",
"names": ["2"], ]
"settings": { idx = msg.note - 36
"pattern": "chase", if 0 <= idx < len(pattern_bindings):
"speed": 10, pattern_name = pattern_bindings[idx]
"color": "#00FFFF", self.current_pattern = pattern_name
} logging.info(f"[Select] Pattern selected via note {msg.note}: {self.current_pattern} (n1={self.n1}, n2={self.n2})")
})
# Send full parameters when pattern changes
await self._send_full_parameters()
else:
pass # Note not bound to patterns
case 'control_change': case 'control_change':
match msg.control: match msg.control:
case 36: case 36:
self.n3 = max(1, msg.value) # Update n3 step rate
delay = msg.value * 4 logging.info(f"n3 set to {self.n3} by MIDI controller (CC36)")
print(f"Delay set to {delay} ms") await self._request_param_update()
case 37:
self.delay = msg.value * 4 # Update instance delay
logging.info(f"Delay set to {self.delay} ms by MIDI controller (CC37)")
await self._request_param_update()
case 38:
self.n1 = msg.value # pulse n1 for pulse patterns
logging.info(f"Pulse n1 set to {self.n1} by MIDI controller (CC38)")
await self._request_param_update()
case 39:
self.n2 = msg.value # pulse n2 for pulse patterns
logging.info(f"Pulse n2 set to {self.n2} by MIDI controller (CC39)")
await self._request_param_update()
case 40:
self.n1 = msg.value # n1 for alternating patterns
logging.info(f"Alternating n1 set to {self.n1} by MIDI controller (CC40)")
await self._request_param_update()
case 41:
self.n2 = msg.value # n2 for alternating patterns
logging.info(f"Alternating n2 set to {self.n2} by MIDI controller (CC41)")
await self._request_param_update()
case 42:
self.n1 = msg.value # radiate n1 for radiate patterns
logging.info(f"Radiate n1 set to {self.n1} by MIDI controller (CC42)")
await self._request_param_update()
case 43:
self.delay = msg.value * 4 # delay for radiate patterns
logging.info(f"Delay set to {self.delay} ms by MIDI controller (CC43)")
await self._request_param_update()
case 44:
self.knob7 = msg.value
logging.info(f"Knob7 set to {self.knob7} by MIDI controller (CC44)")
await self._request_param_update()
case 45:
self.knob8 = msg.value
logging.info(f"Knob8 set to {self.knob8} by MIDI controller (CC45)")
await self._request_param_update()
case 27:
if msg.value == 127:
self.beat_sending_enabled = True
logging.info("[MidiHandler - Listener] Beat sending ENABLED by MIDI control.") # Changed to info
elif msg.value == 0:
self.beat_sending_enabled = False
logging.info("[MidiHandler - Listener] Beat sending DISABLED by MIDI control.") # Changed to info
case 29:
if msg.value == 127:
logging.info("[MidiHandler - Listener] RESET_TEMPO requested by control 29.")
await self._send_reset_to_sound()
case 33:
# Map 0-127 to 0-100 brightness scale
self.brightness = round((msg.value / 127) * 100)
logging.info(f"Brightness set to {self.brightness} by MIDI controller (CC33)")
await self._request_param_update()
case 30:
# Red 0-127 -> 0-255
self.color_r = round((msg.value / 127) * 255)
logging.info(f"Red set to {self.color_r}")
await self._request_param_update()
case 31:
# Green 0-127 -> 0-255
self.color_g = round((msg.value / 127) * 255)
logging.info(f"Green set to {self.color_g}")
await self._request_param_update()
case 32:
# Blue 0-127 -> 0-255
self.color_b = round((msg.value / 127) * 255)
logging.info(f"Blue set to {self.color_b}")
await self._request_param_update()
case 34:
self.n1 = int(msg.value)
logging.info(f"n1 set to {self.n1} by MIDI controller (CC34)")
await self._request_param_update()
case 35:
self.n2 = int(msg.value)
logging.info(f"n2 set to {self.n2} by MIDI controller (CC35)")
await self._request_param_update()
await asyncio.sleep(0.001) # Important: Yield control to asyncio event loop await asyncio.sleep(0.001) # Important: Yield control to asyncio event loop
except mido.PortsError as e: except mido.PortsError as e:
print(f"Error opening MIDI port '{midi_port_name}': {e}") logging.error(f"Error opening MIDI port '{midi_port_name}': {e}") # Changed to error
except asyncio.CancelledError: except asyncio.CancelledError:
print(f"MIDI listener cancelled.") logging.info(f"MIDI listener cancelled.") # Changed to info
except Exception as e: except Exception as e:
print(f"An unexpected error occurred: {e}") logging.error(f"An unexpected error occurred in MIDI listener: {e}") # Changed to error
finally:
# 5. Disconnect WebSocket and clean up
# This assumes your WebSocketClient has a ._connected attribute or similar way to check state.
# If your client's disconnect method is safe to call even if not connected, you can simplify.
await ws_client.close()
print("MIDI listener stopped and cleaned up.")
async def run(self):
try:
await self.ws_client.connect()
logging.info(f"[MidiHandler] WebSocket client connected to {self.ws_client.uri}") # Changed to info
# List available MIDI ports for debugging
print(f"Available MIDI input ports: {mido.get_input_names()}")
print(f"Trying to open MIDI port index {self.midi_port_index}")
await asyncio.gather(
self._midi_listener(),
self._midi_tcp_server()
)
except mido.PortsError as e:
logging.error(f"[MidiHandler] Error opening MIDI port: {e}") # Changed to error
print(f"MIDI Port Error: {e}")
print(f"Available MIDI ports: {mido.get_input_names()}")
print("Please check your MIDI device connection and port index")
except asyncio.CancelledError:
logging.info("[MidiHandler] Tasks cancelled due to program shutdown.") # Changed to info
except KeyboardInterrupt:
logging.info("\n[MidiHandler] Program interrupted by user.") # Changed to info
finally:
logging.info("[MidiHandler] Main program finished. Closing WebSocket client...") # Changed to info
await self.ws_client.close()
logging.info("[MidiHandler] WebSocket client closed.") # Changed to info
def print_midi_ports():
logging.info("\n--- Available MIDI Input Ports ---") # Changed to info
port_names = mido.get_input_names()
if not port_names:
logging.warning("No MIDI input ports found.") # Changed to warning
else:
for i, name in enumerate(port_names):
logging.info(f" {i}: {name}") # Changed to info
logging.info("----------------------------------") # Changed to info
async def main(): async def main():
print_midi_ports()
# --- Configuration --- # --- Configuration ---
MIDI_PORT_INDEX = 1 # <--- IMPORTANT: Change this to the correct index for your device MIDI_PORT_INDEX = 1 # <--- IMPORTANT: Change this to the correct index for your device
WEBSOCKET_SERVER_URI = "ws://192.168.4.1:80/ws" WEBSOCKET_SERVER_URI = "ws://192.168.4.1:80/ws"
# --- End Configuration --- # --- End Configuration ---
try: midi_handler = MidiHandler(MIDI_PORT_INDEX, WEBSOCKET_SERVER_URI)
await midi_to_websocket_listener(MIDI_PORT_INDEX, WEBSOCKET_SERVER_URI) await midi_handler.run()
except KeyboardInterrupt:
print("\nProgram interrupted by user.")
finally:
print("Main program finished.")
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) asyncio.run(main())

View File

@@ -1,27 +0,0 @@
import json
class Settings(dict):
SETTINGS_FILE = "settings.json"
def __init__(self):
super().__init__()
self.load() # Load settings from file during initialization
def save(self):
try:
j = json.dumps(self, indent=4)
with open(self.SETTINGS_FILE, 'w') as file:
file.write(j)
print("Settings saved successfully.")
except Exception as e:
print(f"Error saving settings: {e}")
def load(self):
try:
with open(self.SETTINGS_FILE, 'r') as file:
loaded_settings = json.load(file)
self.update(loaded_settings)
print("Settings loaded successfully.")
except Exception as e:
print(f"Error loading settings {e}")
self.save()

View File

@@ -4,121 +4,207 @@ import pyaudio
import aubio import aubio
import numpy as np import numpy as np
from time import sleep from time import sleep
import websocket # pip install websocket-client
import json import json
import socket
import time
import logging # Added logging import
import asyncio # Re-added asyncio import
import threading # Added threading for control server
seconds = 10 # how long this script should run (if not using infinite loop) # Configure logging
DEBUG_MODE = True # Set to False for INFO level logging
logging.basicConfig(level=logging.DEBUG if DEBUG_MODE else logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
bufferSize = 512 # TCP Server Configuration (assuming midi.py runs this)
windowSizeMultiple = 2 # or 4 for higher accuracy, but more computational cost MIDI_TCP_HOST = "127.0.0.1"
MIDI_TCP_PORT = 65432
audioInputDeviceIndex = 7 # use 'arecord -l' to check available audio devices # Sound Control Server Configuration (for midi.py to control sound.py)
audioInputChannels = 1 SOUND_CONTROL_HOST = "127.0.0.1"
SOUND_CONTROL_PORT = 65433
pa = pyaudio.PyAudio() class SoundBeatDetector:
def __init__(self, tcp_host: str, tcp_port: int):
self.tcp_host = tcp_host
self.tcp_port = tcp_port
self.tcp_socket = None
self.connected_to_midi = False
self.reconnect_delay = 1 # seconds
# Note: beat_sending_enabled is not used in this simplified flow
print("Available audio input devices:") self.bufferSize = 512
info = pa.get_host_api_info_by_index(0) self.windowSizeMultiple = 2
num_devices = info.get('deviceCount') self.audioInputDeviceIndex = 7
found_device = False self.audioInputChannels = 1
for i in range(0, num_devices):
device_info = pa.get_device_info_by_host_api_device_index(0, i) self.pa = pyaudio.PyAudio()
logging.info("Available audio input devices:")
info = self.pa.get_host_api_info_by_index(0)
num_devices = info.get('deviceCount')
found_device = False
for i in range(0, num_devices):
device_info = self.pa.get_device_info_by_host_api_device_index(0, i)
if (device_info.get('maxInputChannels')) > 0: if (device_info.get('maxInputChannels')) > 0:
print(f" Input Device id {i} - {device_info.get('name')}") logging.info(f" Input Device id {i} - {device_info.get('name')}")
if i == audioInputDeviceIndex: if i == self.audioInputDeviceIndex:
found_device = True found_device = True
if not found_device: if not found_device:
print(f"Warning: Audio input device index {audioInputDeviceIndex} not found or has no input channels.") logging.warning(f"Audio input device index {self.audioInputDeviceIndex} not found or has no input channels.")
# Consider exiting or picking a default if necessary
try: try:
audioInputDevice = pa.get_device_info_by_index(audioInputDeviceIndex) audioInputDevice = self.pa.get_device_info_by_index(self.audioInputDeviceIndex)
audioInputSampleRate = int(audioInputDevice['defaultSampleRate']) self.audioInputSampleRate = int(audioInputDevice['defaultSampleRate'])
except Exception as e: except Exception as e:
print(f"Error getting audio device info for index {audioInputDeviceIndex}: {e}") logging.error(f"Error getting audio device info for index {self.audioInputDeviceIndex}: {e}")
pa.terminate() self.pa.terminate()
exit() exit()
# create the aubio tempo detection: self.hopSize = self.bufferSize
hopSize = bufferSize self.winSize = self.hopSize * self.windowSizeMultiple
winSize = hopSize * windowSizeMultiple self.tempoDetection = aubio.tempo(method='default', buf_size=self.winSize, hop_size=self.hopSize, samplerate=self.audioInputSampleRate)
tempoDetection = aubio.tempo(method='default', buf_size=winSize, hop_size=hopSize, samplerate=audioInputSampleRate)
# --- WebSocket Setup --- self.inputStream = None
websocket_url = "ws://192.168.4.1:80/ws" self._control_thread = None
ws = None
try:
ws = websocket.create_connection(websocket_url)
print(f"Successfully connected to WebSocket at {websocket_url}")
except Exception as e:
print(f"Failed to connect to WebSocket: {e}. Data will not be sent over WebSocket.")
# --- End WebSocket Setup ---
# this function gets called by the input stream, as soon as enough samples are collected from the audio input: self._connect_to_midi_server()
def readAudioFrames(in_data, frame_count, time_info, status): self._start_control_server() # Start control server in background
global ws # Allow modification of the global ws variable
def reset_tempo_detection(self):
"""Re-initializes the aubio tempo detection object."""
logging.info("[SoundBeatDetector] Resetting tempo detection.")
self.tempoDetection = aubio.tempo(method='default', buf_size=self.winSize, hop_size=self.hopSize, samplerate=self.audioInputSampleRate)
def _control_server_loop(self):
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((SOUND_CONTROL_HOST, SOUND_CONTROL_PORT))
srv.listen(5)
logging.info(f"[SoundBeatDetector - Control] Listening on {SOUND_CONTROL_HOST}:{SOUND_CONTROL_PORT}")
while True:
conn, addr = srv.accept()
with conn:
logging.info(f"[SoundBeatDetector - Control] Connection from {addr}")
try:
data = conn.recv(1024)
if not data:
continue
command = data.decode().strip()
logging.debug(f"[SoundBeatDetector - Control] Received command: {command}")
if command == "RESET_TEMPO":
self.reset_tempo_detection()
response = "OK: Tempo reset\n"
else:
response = "ERROR: Unknown command\n"
conn.sendall(response.encode('utf-8'))
except Exception as e:
logging.error(f"[SoundBeatDetector - Control] Error handling control message: {e}")
except Exception as e:
logging.error(f"[SoundBeatDetector - Control] Server error: {e}")
finally:
try:
srv.close()
except Exception:
pass
def _start_control_server(self):
if self._control_thread and self._control_thread.is_alive():
return
self._control_thread = threading.Thread(target=self._control_server_loop, daemon=True)
self._control_thread.start()
def _connect_to_midi_server(self):
if self.tcp_socket:
self.tcp_socket.close()
self.tcp_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.tcp_socket.settimeout(self.reconnect_delay)
try:
logging.info(f"[SoundBeatDetector] Attempting to connect to MIDI TCP server at {self.tcp_host}:{self.tcp_port}...")
self.tcp_socket.connect((self.tcp_host, self.tcp_port))
self.tcp_socket.setblocking(0)
self.connected_to_midi = True
logging.info(f"[SoundBeatDetector] Successfully connected to MIDI TCP server.")
except (socket.error, socket.timeout) as e:
logging.error(f"[SoundBeatDetector] Failed to connect to MIDI TCP server: {e}")
self.connected_to_midi = False
# Removed _handle_control_client and _control_server (replaced by simple threaded server)
def readAudioFrames(self, in_data, frame_count, time_info, status):
signal = np.frombuffer(in_data, dtype=np.float32) signal = np.frombuffer(in_data, dtype=np.float32)
beat = tempoDetection(signal) beat = self.tempoDetection(signal)
if beat: if beat:
bpm = tempoDetection.get_bpm() bpm = self.tempoDetection.get_bpm()
print(f"beat! (running with {bpm:.2f} bpm)") # Use f-string for cleaner formatting, removed extra bells logging.debug(f"beat! (running with {bpm:.2f} bpm)") # Changed to debug
data_to_send = { bpm_message = str(bpm)
"names": ["1"],
"settings": {
"pattern": "pulse",
"delay": 10,
"colors": ["#00ff00"],
"brightness": 10,
"num_leds": 200,
},
}
if ws: # Only send if the websocket connection is established if self.connected_to_midi and self.tcp_socket:
try: try:
ws.send(json.dumps(data_to_send)) message_bytes = (bpm_message + "\n").encode('utf-8')
# print("Sent data over WebSocket") # Optional: for debugging self.tcp_socket.sendall(message_bytes)
except websocket.WebSocketConnectionClosedException: logging.debug(f"[SoundBeatDetector] Sent BPM to MIDI TCP server: {bpm_message}") # Changed to debug
print("WebSocket connection closed, attempting to reconnect...") except socket.error as e:
ws = None # Mark as closed, connection will need to be re-established if desired logging.error(f"[SoundBeatDetector] Error sending BPM to MIDI TCP server: {e}. Attempting to reconnect...")
except Exception as e: self.connected_to_midi = False
print(f"Error sending over WebSocket: {e}") self._connect_to_midi_server()
elif not self.connected_to_midi:
logging.warning("[SoundBeatDetector] Not connected to MIDI TCP server, attempting to reconnect...") # Changed to warning
self._connect_to_midi_server()
else:
logging.warning("[SoundBeatDetector] TCP socket not initialized, cannot send BPM.") # Changed to warning
return (in_data, pyaudio.paContinue) return (in_data, pyaudio.paContinue)
def start_stream(self):
# create and start the input stream try:
try: self.inputStream = self.pa.open(format=pyaudio.paFloat32,
inputStream = pa.open(format=pyaudio.paFloat32,
input=True, input=True,
channels=audioInputChannels, channels=self.audioInputChannels,
input_device_index=audioInputDeviceIndex, input_device_index=self.audioInputDeviceIndex,
frames_per_buffer=bufferSize, frames_per_buffer=self.bufferSize,
rate=audioInputSampleRate, rate=self.audioInputSampleRate,
stream_callback=readAudioFrames) stream_callback=self.readAudioFrames)
inputStream.start_stream() self.inputStream.start_stream()
print("\nAudio stream started. Detecting beats. Press Ctrl+C to stop.") logging.info("\nAudio stream started. Detecting beats. Press Ctrl+C to stop.")
# Loop to keep the script running, allowing graceful shutdown while self.inputStream.is_active():
while inputStream.is_active(): sleep(0.1)
sleep(0.1) # Small delay to prevent busy-waiting
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nKeyboardInterrupt: Stopping script gracefully.") logging.info("\nKeyboardInterrupt: Stopping script gracefully.")
except Exception as e: except Exception as e:
print(f"An error occurred with the audio stream: {e}") logging.error(f"An error occurred with the audio stream: {e}")
finally: finally:
# Ensure streams and resources are closed self.stop_stream()
if 'inputStream' in locals() and inputStream.is_active():
inputStream.stop_stream()
if 'inputStream' in locals() and not inputStream.is_stopped():
inputStream.close()
pa.terminate()
if ws:
print("Closing WebSocket connection.")
ws.close()
print("Script finished.") def stop_stream(self):
if self.inputStream and self.inputStream.is_active():
self.inputStream.stop_stream()
if self.inputStream and not self.inputStream.is_stopped():
self.inputStream.close()
self.pa.terminate()
if self.tcp_socket and self.connected_to_midi:
logging.info("[SoundBeatDetector] Closing TCP socket.")
self.tcp_socket.close()
self.connected_to_midi = False
logging.info("SoundBeatDetector stopped.")
# Removed async def run(self)
if __name__ == "__main__":
# TCP Server Configuration (should match midi.py)
MIDI_TCP_HOST = "127.0.0.1"
MIDI_TCP_PORT = 65432
sound_detector = SoundBeatDetector(MIDI_TCP_HOST, MIDI_TCP_PORT)
logging.info("Starting SoundBeatDetector...")
try:
sound_detector.start_stream()
except KeyboardInterrupt:
logging.info("\nProgram interrupted by user.")
except Exception as e:
logging.error(f"An error occurred during main execution: {e}")

602
src/ui_client.py Normal file
View File

@@ -0,0 +1,602 @@
#!/usr/bin/env python3
"""
UI Client for Lighting Controller
Handles the user interface and MIDI controller input.
Communicates with the control server via WebSocket.
"""
import asyncio
import tkinter as tk
from tkinter import ttk, messagebox
import json
import os
import mido
import logging
from async_tkinter_loop import async_handler, async_mainloop
import websockets
import websocket
# Configuration
CONFIG_FILE = "config.json"
CONTROL_SERVER_URI = "ws://localhost:8765"
# Dark theme colors
bg_color = "#2e2e2e"
fg_color = "white"
trough_color_red = "#4a0000"
trough_color_green = "#004a00"
trough_color_blue = "#00004a"
trough_color_brightness = "#4a4a4a"
trough_color_delay = "#4a4a4a"
active_bg_color = "#4a4a4a"
highlight_pattern_color = "#6a5acd"
active_palette_color_border = "#FFD700"
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
class WebSocketClient:
"""WebSocket client for communicating with the control server."""
def __init__(self, uri):
self.uri = uri
self.websocket = None
self.is_connected = False
self.reconnect_task = None
async def connect(self):
"""Establish WebSocket connection to control server."""
if self.is_connected and self.websocket:
return
try:
logging.info(f"Connecting to control server at {self.uri}...")
self.websocket = await websockets.connect(self.uri)
self.is_connected = True
logging.info("Connected to control server")
except Exception as e:
logging.error(f"Failed to connect to control server: {e}")
self.is_connected = False
self.websocket = None
async def send_message(self, message_type, data=None):
"""Send a message to the control server."""
if not self.is_connected or not self.websocket:
logging.warning("Not connected to control server")
return
try:
message = {
"type": message_type,
"data": data or {}
}
await self.websocket.send(json.dumps(message))
logging.debug(f"Sent message: {message}")
except Exception as e:
logging.error(f"Failed to send message: {e}")
self.is_connected = False
async def close(self):
"""Close WebSocket connection."""
if self.websocket and self.is_connected:
await self.websocket.close()
self.is_connected = False
self.websocket = None
logging.info("Disconnected from control server")
class MidiController:
"""Handles MIDI controller input and sends commands to control server."""
def __init__(self, websocket_client):
self.websocket_client = websocket_client
self.midi_port_index = 0
self.available_ports = []
self.midi_port = None
self.midi_task = None
# MIDI state
self.current_pattern = ""
self.delay = 100
self.brightness = 100
self.color_r = 0
self.color_g = 255
self.color_b = 0
self.n1 = 10
self.n2 = 10
self.n3 = 1
self.knob7 = 0
self.knob8 = 0
self.beat_sending_enabled = True
def get_midi_ports(self):
"""Get list of available MIDI input ports."""
try:
return mido.get_input_names()
except Exception as e:
logging.error(f"Error getting MIDI ports: {e}")
return []
def load_midi_preference(self):
"""Load saved MIDI device preference."""
try:
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, 'r') as f:
config = json.load(f)
return config.get('midi_device_index', 0)
except Exception as e:
logging.error(f"Error loading MIDI preference: {e}")
return 0
def save_midi_preference(self):
"""Save current MIDI device preference."""
try:
config = {
'midi_device_index': self.midi_port_index,
'midi_device_name': self.available_ports[self.midi_port_index] if self.available_ports else None
}
with open(CONFIG_FILE, 'w') as f:
json.dump(config, f, indent=2)
except Exception as e:
logging.error(f"Error saving MIDI preference: {e}")
async def initialize_midi(self):
"""Initialize MIDI port connection."""
self.available_ports = self.get_midi_ports()
self.midi_port_index = self.load_midi_preference()
if not self.available_ports:
logging.warning("No MIDI ports available")
return False
if not (0 <= self.midi_port_index < len(self.available_ports)):
self.midi_port_index = 0
try:
port_name = self.available_ports[self.midi_port_index]
self.midi_port = mido.open_input(port_name)
logging.info(f"Connected to MIDI port: {port_name}")
return True
except Exception as e:
logging.error(f"Failed to open MIDI port: {e}")
return False
async def start_midi_listener(self):
"""Start listening for MIDI messages."""
if not self.midi_port:
return
try:
while True:
msg = self.midi_port.receive(block=False)
if msg:
await self.handle_midi_message(msg)
await asyncio.sleep(0.001)
except asyncio.CancelledError:
logging.info("MIDI listener cancelled")
except Exception as e:
logging.error(f"MIDI listener error: {e}")
async def handle_midi_message(self, msg):
"""Handle incoming MIDI message and send to control server."""
if msg.type == 'note_on':
# Pattern selection (notes 36-51)
logging.info(f"MIDI Note {msg.note}: {msg.velocity}")
pattern_bindings = [
"pulse", "sequential_pulse", "alternating", "alternating_phase",
"n_chase", "rainbow", "flicker", "radiate"
]
idx = msg.note - 36
if 0 <= idx < len(pattern_bindings):
self.current_pattern = pattern_bindings[idx]
await self.websocket_client.send_message("pattern_change", {
"pattern": self.current_pattern
})
logging.info(f"Pattern changed to: {self.current_pattern}")
elif msg.type == 'control_change':
# Handle control change messages
control = msg.control
value = msg.value
logging.info(f"MIDI CC {control}: {value}")
if control == 30: # Red
self.color_r = round((value / 127) * 255)
await self.websocket_client.send_message("color_change", {
"r": self.color_r, "g": self.color_g, "b": self.color_b
})
elif control == 31: # Green
self.color_g = round((value / 127) * 255)
await self.websocket_client.send_message("color_change", {
"r": self.color_r, "g": self.color_g, "b": self.color_b
})
elif control == 32: # Blue
self.color_b = round((value / 127) * 255)
await self.websocket_client.send_message("color_change", {
"r": self.color_r, "g": self.color_g, "b": self.color_b
})
elif control == 33: # Brightness
self.brightness = round((value / 127) * 100)
await self.websocket_client.send_message("brightness_change", {
"brightness": self.brightness
})
elif control == 34: # n1
self.n1 = int(value)
await self.websocket_client.send_message("parameter_change", {
"n1": self.n1
})
elif control == 35: # n2
self.n2 = int(value)
await self.websocket_client.send_message("parameter_change", {
"n2": self.n2
})
elif control == 36: # n3
self.n3 = max(1, value)
await self.websocket_client.send_message("parameter_change", {
"n3": self.n3
})
elif control == 37: # Delay
self.delay = value * 4
await self.websocket_client.send_message("delay_change", {
"delay": self.delay
})
elif control == 27: # Beat sending toggle
self.beat_sending_enabled = (value == 127)
await self.websocket_client.send_message("beat_toggle", {
"enabled": self.beat_sending_enabled
})
def close(self):
"""Close MIDI connection."""
if self.midi_port:
self.midi_port.close()
self.midi_port = None
class UIClient:
"""Main UI client application."""
def __init__(self):
self.root = tk.Tk()
self.root.configure(bg=bg_color)
self.root.title("Lighting Controller - UI Client")
# WebSocket client
self.websocket_client = WebSocketClient(CONTROL_SERVER_URI)
# MIDI controller
self.midi_controller = MidiController(self.websocket_client)
# UI state
self.current_pattern = ""
self.delay = 100
self.brightness = 100
self.color_r = 0
self.color_g = 255
self.color_b = 0
self.n1 = 10
self.n2 = 10
self.n3 = 1
self.setup_ui()
self.setup_async_tasks()
def setup_ui(self):
"""Setup the user interface."""
# Configure ttk style
style = ttk.Style()
style.theme_use("alt")
style.configure(".", background=bg_color, foreground=fg_color, font=("Arial", 14))
style.configure("TNotebook", background=bg_color, borderwidth=0)
style.configure("TNotebook.Tab", background=bg_color, foreground=fg_color, font=("Arial", 30), padding=[10, 5])
# MIDI Controller Selection
midi_frame = ttk.LabelFrame(self.root, text="MIDI Controller")
midi_frame.pack(padx=16, pady=8, fill="x")
# MIDI port dropdown
self.midi_port_var = tk.StringVar()
midi_dropdown = ttk.Combobox(
midi_frame,
textvariable=self.midi_port_var,
values=[],
state="readonly",
font=("Arial", 12)
)
midi_dropdown.pack(padx=8, pady=4, fill="x")
midi_dropdown.bind("<<ComboboxSelected>>", self.on_midi_port_change)
# Refresh MIDI ports button
refresh_button = ttk.Button(
midi_frame,
text="Refresh MIDI Ports",
command=self.refresh_midi_ports
)
refresh_button.pack(padx=8, pady=4)
# MIDI connection status
self.midi_status_label = tk.Label(
midi_frame,
text="Status: Disconnected",
bg=bg_color,
fg="red",
font=("Arial", 10)
)
self.midi_status_label.pack(padx=8, pady=2)
# Controls overview
controls_frame = ttk.Frame(self.root)
controls_frame.pack(padx=16, pady=8, fill="both")
# Dials display
dials_frame = ttk.LabelFrame(controls_frame, text="Dials (CC30-37)")
dials_frame.pack(side="left", padx=12)
for c in range(2):
dials_frame.grid_columnconfigure(c, minsize=140)
for rr in range(4):
dials_frame.grid_rowconfigure(rr, minsize=70)
self.dials_boxes = []
placeholders = {
(0, 0): "n3\n-", (0, 1): "Delay\n-",
(1, 0): "n1\n-", (1, 1): "n2\n-",
(2, 0): "B\n-", (2, 1): "Bright\n-",
(3, 0): "R\n-", (3, 1): "G\n-",
}
for r in range(4):
for c in range(2):
lbl = tk.Label(
dials_frame,
text=placeholders.get((r, c), "-"),
bg=bg_color,
fg=fg_color,
font=("Arial", 14),
padx=6, pady=6,
borderwidth=2, relief="ridge",
width=14, height=4,
anchor="center", justify="center",
)
lbl.grid(row=r, column=c, padx=6, pady=6, sticky="nsew")
self.dials_boxes.append(lbl)
# Knobs display
knobs_frame = ttk.LabelFrame(controls_frame, text="Knobs (CC38-45)")
knobs_frame.pack(side="left", padx=12)
for c in range(2):
knobs_frame.grid_columnconfigure(c, minsize=140)
for rr in range(4):
knobs_frame.grid_rowconfigure(rr, minsize=70)
self.knobs_boxes = []
knob_placeholders = {
(0, 0): "CC44\n-", (0, 1): "CC45\n-",
(1, 0): "Rad n1\n-", (1, 1): "Rad delay\n-",
(2, 0): "Alt n1\n-", (2, 1): "Alt n2\n-",
(3, 0): "Pulse n1\n-", (3, 1): "Pulse n2\n-",
}
for r in range(4):
for c in range(2):
lbl = tk.Label(
knobs_frame,
text=knob_placeholders.get((r, c), "-"),
bg=bg_color,
fg=fg_color,
font=("Arial", 14),
padx=6, pady=6,
borderwidth=2, relief="ridge",
width=14, height=4,
anchor="center", justify="center",
)
lbl.grid(row=r, column=c, padx=6, pady=6, sticky="nsew")
self.knobs_boxes.append(lbl)
# Buttons display
buttons_frame = ttk.Frame(controls_frame)
buttons_frame.pack(side="left", padx=12)
buttons1_frame = ttk.LabelFrame(buttons_frame, text="Buttons (notes 36-51)")
buttons1_frame.pack(side="top", pady=8)
for c in range(4):
buttons1_frame.grid_columnconfigure(c, minsize=140)
for rr in range(1, 5):
buttons1_frame.grid_rowconfigure(rr, minsize=70)
self.button1_cells = []
for r in range(4):
for c in range(4):
lbl = tk.Label(
buttons1_frame,
text="",
bg=bg_color,
fg=fg_color,
font=("Arial", 14),
padx=6, pady=6,
borderwidth=2, relief="ridge",
width=14, height=4,
anchor="center", justify="center",
)
lbl.grid(row=1 + (3 - r), column=c, padx=6, pady=6, sticky="nsew")
self.button1_cells.append(lbl)
# Connection status
self.connection_status = tk.Label(
self.root,
text="Control Server: Disconnected",
bg=bg_color,
fg="red",
font=("Arial", 12)
)
self.connection_status.pack(pady=8)
# Schedule periodic UI updates
self.root.after(200, self.update_status_labels)
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
def setup_async_tasks(self):
"""Setup async tasks for WebSocket and MIDI."""
# Connect to control server
self.root.after(100, async_handler(self.websocket_client.connect))
# Initialize MIDI
self.root.after(200, async_handler(self.initialize_midi))
@async_handler
async def initialize_midi(self):
"""Initialize MIDI controller."""
success = await self.midi_controller.initialize_midi()
if success:
# Update UI
self.midi_controller.available_ports = self.midi_controller.get_midi_ports()
if self.midi_controller.available_ports:
self.midi_port_var.set(self.midi_controller.available_ports[self.midi_controller.midi_port_index])
# Update dropdown
for child in self.root.winfo_children():
if isinstance(child, ttk.LabelFrame) and child.cget("text") == "MIDI Controller":
for widget in child.winfo_children():
if isinstance(widget, ttk.Combobox):
widget['values'] = self.midi_controller.available_ports
break
break
self.midi_status_label.config(
text=f"Status: Connected to {self.midi_controller.available_ports[self.midi_controller.midi_port_index]}",
fg="green"
)
# Start MIDI listener
self.midi_controller.midi_task = asyncio.create_task(
self.midi_controller.start_midi_listener()
)
def refresh_midi_ports(self):
"""Refresh MIDI ports list."""
old_ports = self.midi_controller.available_ports.copy()
self.midi_controller.available_ports = self.midi_controller.get_midi_ports()
# Update dropdown
for child in self.root.winfo_children():
if isinstance(child, ttk.LabelFrame) and child.cget("text") == "MIDI Controller":
for widget in child.winfo_children():
if isinstance(widget, ttk.Combobox):
widget['values'] = self.midi_controller.available_ports
if (self.midi_controller.available_ports and
self.midi_port_var.get() not in self.midi_controller.available_ports):
self.midi_port_var.set(self.midi_controller.available_ports[0])
self.midi_controller.midi_port_index = 0
self.midi_controller.save_midi_preference()
break
break
def on_midi_port_change(self, event):
"""Handle MIDI port selection change."""
selected_port = self.midi_port_var.get()
if selected_port in self.midi_controller.available_ports:
self.midi_controller.midi_port_index = self.midi_controller.available_ports.index(selected_port)
self.midi_controller.save_midi_preference()
# Restart MIDI connection
asyncio.create_task(self.restart_midi())
@async_handler
async def restart_midi(self):
"""Restart MIDI connection with new port."""
if self.midi_controller.midi_task:
self.midi_controller.midi_task.cancel()
if self.midi_controller.midi_port:
self.midi_controller.midi_port.close()
success = await self.midi_controller.initialize_midi()
if success:
self.midi_controller.midi_task = asyncio.create_task(
self.midi_controller.start_midi_listener()
)
def update_status_labels(self):
"""Update UI status labels."""
# Update connection status
if self.websocket_client.is_connected:
self.connection_status.config(text="Control Server: Connected", fg="green")
else:
self.connection_status.config(text="Control Server: Disconnected", fg="red")
# Update dial displays
dial_values = [
("n3", self.midi_controller.n3), ("Delay", self.midi_controller.delay),
("n1", self.midi_controller.n1), ("n2", self.midi_controller.n2),
("B", self.midi_controller.color_b), ("Brightness", self.midi_controller.brightness),
("R", self.midi_controller.color_r), ("G", self.midi_controller.color_g),
]
for idx, (label, value) in enumerate(dial_values):
if idx < len(self.dials_boxes):
self.dials_boxes[idx].config(text=f"{label}\n{value}")
# Update knobs
knob_values = [
("CC44", self.midi_controller.knob7), ("CC45", self.midi_controller.knob8),
("Rad n1", self.midi_controller.n1), ("Rad delay", self.midi_controller.delay),
("Alt n1", self.midi_controller.n1), ("Alt n2", self.midi_controller.n2),
("Pulse n1", self.midi_controller.n1), ("Pulse n2", self.midi_controller.n2),
]
for idx, (label, value) in enumerate(knob_values):
if idx < len(self.knobs_boxes):
self.knobs_boxes[idx].config(text=f"{label}\n{value}")
# Update buttons
icon_for = {
"pulse": "💥", "flicker": "", "alternating": "↔️",
"n_chase": "🏃", "rainbow": "🌈", "radiate": "🌟",
"sequential_pulse": "🔄", "alternating_phase": "", "-": "",
}
bank1_patterns = [
"pulse", "sequential_pulse", "alternating", "alternating_phase",
"n_chase", "rainbow", "flicker", "radiate",
"-", "-", "-", "-", "-", "-", "-", "-",
]
# Display names for UI (with line breaks for better display)
display_names = {
"pulse": "pulse",
"sequential_pulse": "sequential\npulse",
"alternating": "alternating",
"alternating_phase": "alternating\nphase",
"n_chase": "n chase",
"rainbow": "rainbow",
"flicker": "flicker",
"radiate": "radiate",
}
current_pattern = self.midi_controller.current_pattern
for idx, lbl in enumerate(self.button1_cells):
pattern_name = bank1_patterns[idx]
is_selected = (current_pattern == pattern_name and pattern_name != "-")
display_name = display_names.get(pattern_name, pattern_name)
icon = icon_for.get(pattern_name, "")
text = f"{icon} {display_name}" if pattern_name != "-" else ""
if is_selected:
lbl.config(text=text, bg=highlight_pattern_color)
else:
lbl.config(text=text, bg=bg_color)
# Reschedule
self.root.after(200, self.update_status_labels)
def on_closing(self):
"""Handle application closing."""
logging.info("Closing UI client...")
if self.midi_controller.midi_task:
self.midi_controller.midi_task.cancel()
self.midi_controller.close()
asyncio.create_task(self.websocket_client.close())
self.root.destroy()
def run(self):
"""Run the UI client."""
async_mainloop(self.root)
if __name__ == "__main__":
app = UIClient()
app.run()

View File

@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""
Startup script for the separated lighting controller architecture.
Starts the control server, sound detector, and UI client.
"""
import subprocess
import sys
import time
import signal
import os
from pathlib import Path
def start_process(command, name, cwd=None):
"""Start a subprocess and return the process object."""
print(f"Starting {name}...")
try:
process = subprocess.Popen(
command,
shell=True,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
preexec_fn=os.setsid if os.name != 'nt' else None
)
print(f"{name} started with PID {process.pid}")
return process
except Exception as e:
print(f"Failed to start {name}: {e}")
return None
def main():
"""Main startup function."""
print("Starting Lighting Controller (Separated Architecture)")
print("=" * 50)
# Get the project directory
project_dir = Path(__file__).parent
processes = []
try:
# Start control server
control_process = start_process(
"python src/control_server.py",
"Control Server",
cwd=project_dir
)
if control_process:
processes.append(("Control Server", control_process))
# Wait a moment for the control server to start
time.sleep(2)
# Start sound detector
sound_process = start_process(
"python src/sound.py",
"Sound Detector",
cwd=project_dir
)
if sound_process:
processes.append(("Sound Detector", sound_process))
# Wait a moment for the sound detector to start
time.sleep(1)
# Start UI client
ui_process = start_process(
"python src/ui_client.py",
"UI Client",
cwd=project_dir
)
if ui_process:
processes.append(("UI Client", ui_process))
print("\nAll components started successfully!")
print("Press Ctrl+C to stop all components...")
# Wait for processes
try:
while True:
time.sleep(1)
# Check if any process has died
for name, process in processes:
if process.poll() is not None:
print(f"Warning: {name} has stopped unexpectedly")
except KeyboardInterrupt:
print("\nShutting down all components...")
except Exception as e:
print(f"Error during startup: {e}")
finally:
# Clean up all processes
for name, process in processes:
if process and process.poll() is None:
print(f"Stopping {name}...")
try:
if os.name != 'nt':
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
else:
process.terminate()
process.wait(timeout=5)
except subprocess.TimeoutExpired:
print(f"Force killing {name}...")
if os.name != 'nt':
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
else:
process.kill()
except Exception as e:
print(f"Error stopping {name}: {e}")
print("All components stopped.")
if __name__ == "__main__":
main()