diff --git a/.gitignore b/.gitignore index 1a1bf8d..ff23426 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,33 @@ Desktop.ini # Jekyll build artifacts .jekyll-cache/ website/_site/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +venv/ +ENV/ +env/ +.venv + +# Python cue files +cues.json diff --git a/DEVELOPER.md b/DEVELOPER_CPP_LEGACY.md similarity index 100% rename from DEVELOPER.md rename to DEVELOPER_CPP_LEGACY.md diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..8947f08 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,259 @@ +# Migration Guide: C++ to Python + +## Overview + +BeamCommander 2.0 is a complete rewrite from C++/openFrameworks to Python. This guide helps users and developers migrate from the legacy C++ version to the new Python version. + +## Key Changes + +### ✅ What Stayed the Same + +- **OSC Commands**: All OSC command addresses and parameters remain compatible +- **OSC Port**: Still uses UDP port 9000 by default +- **Core Features**: All shapes, colors, movements, and effects are preserved +- **Cue System**: Concept remains the same (save/recall) + +### 🔄 What Changed + +#### Platform Independence +- **Before**: macOS only, required Xcode and specific openFrameworks version +- **After**: Works on Linux, macOS, Windows, and any Python-compatible OS + +#### User Interface +- **Before**: Native desktop application with ImGui interface +- **After**: Browser-based UI accessible from any device on the network + +#### Hardware Integration +- **Before**: Built-in support for laser DACs via ofxLaser +- **After**: Abstraction layer provided; requires custom DAC driver implementation + +#### MIDI Support +- **Before**: Built-in MIDI controller support (Akai APC40) +- **After**: Planned for future release; currently OSC-only + +#### Installation +- **Before**: Complex build process with multiple dependencies +- **After**: Simple `pip install` or `./start.sh` + +## Migration Steps + +### For End Users + +1. **Backup your cue files** (if you want to manually recreate them) + ```bash + cp ~/Library/Application\ Support/BeamCommander/cues.json ~/Desktop/cues_backup.json + ``` + +2. **Install Python 3.8+** + - macOS: `brew install python3` + - Linux: `sudo apt install python3 python3-pip` + - Windows: Download from python.org + +3. **Install BeamCommander 2.0** + ```bash + cd beamcommander + pip install -r requirements.txt + ``` + +4. **Start the server** + ```bash + ./start.sh + ``` + +5. **Access the web UI** + - Open http://localhost:8080 in your browser + - Control from any device on your network! + +### For Developers + +#### Adding Custom DAC Support + +Create a `laser_output.py` module: + +```python +from beamcommander.shapes import ShapeGenerator +from beamcommander.app_state import AppState +import your_dac_library + +class YourDACOutput: + def __init__(self): + self.dac = your_dac_library.connect() + + def send_points(self, points): + for x, y, r, g, b in points: + self.dac.add_point(x, y, r, g, b) + self.dac.send() + +# Integration +state = AppState() +generator = ShapeGenerator() +dac = YourDACOutput() + +while True: + points = generator.generate_shape(state, time.time()) + dac.send_points(points) +``` + +#### Porting Custom Shapes + +**Before (C++):** +```cpp +void ofApp::drawMyShape() { + ofPolyline shape; + for (int i = 0; i < 100; i++) { + float x = i * 10; + float y = sin(i * 0.1) * 50; + shape.addVertex(x, y); + } + laser.drawPoly(shape); +} +``` + +**After (Python):** +```python +def _generate_my_shape(self, scale: float, num_points: int = 100): + points = [] + for i in range(num_points): + x = i * 10 * scale + y = math.sin(i * 0.1) * 50 * scale + points.append((x, y)) + return points +``` + +#### Porting OSC Handlers + +**Before (C++):** +```cpp +if (m.getAddress() == "/laser/mycommand") { + float value = m.getArgAsFloat(0); + state->myParameter = value; +} +``` + +**After (Python):** +```python +def _handle_my_command(self, address: str, *args): + if not args: + return + value = float(args[0]) + self.state.my_parameter = value + +# Register in setup_dispatcher(): +disp.map("/laser/mycommand", self._handle_my_command) +``` + +## Feature Parity Matrix + +| Feature | C++ Version | Python Version | Notes | +|---------|------------|----------------|-------| +| OSC Control | ✅ | ✅ | Fully compatible | +| Web UI | ❌ | ✅ | New feature | +| Desktop UI | ✅ | ❌ | Use browser instead | +| Shapes (Circle, Line, etc.) | ✅ | ✅ | All preserved | +| Colors & Rainbow | ✅ | ✅ | Fully compatible | +| Movement Patterns | ✅ | ✅ | All preserved | +| Cue System | ✅ | ✅ | Not file-compatible | +| MIDI Support | ✅ | 🚧 | Planned | +| EtherDream DAC | ✅ | ✅ | Built-in support via --enable-dac | +| Other DACs | ✅ | 🔧 | Custom integration required | +| Cross-platform | ❌ | ✅ | Linux/Mac/Windows | +| Easy Installation | ❌ | ✅ | Single command | + +Legend: ✅ Available | ❌ Not Available | 🚧 In Progress | 🔧 Custom Integration Required + +## Troubleshooting + +### Issue: "My MIDI controller doesn't work" + +**Solution**: MIDI support is not yet available in Python version. Options: +1. Wait for future MIDI support release +2. Use OSC-based control instead +3. Create MIDI-to-OSC bridge using external tools + +### Issue: "No laser output to my DAC" + +**Solution for EtherDream DACs:** +```bash +# Enable DAC output +python3 -m beamcommander.server --enable-dac + +# Or specify DAC IP if auto-discovery fails +python3 -m beamcommander.server --enable-dac --dac-ip 192.168.1.100 +``` + +**Solution for other DAC types:** +Python version provides abstraction layer only. You need to: +1. Identify your DAC model +2. Find/write Python driver for your DAC +3. Integrate with shape generator output + +### Issue: "Cue files don't load" + +**Solution**: Cue file formats are incompatible. Manually recreate cues: +1. Open old version, load cue +2. Note all parameters +3. Open new version, set parameters +4. Save as new cue + +### Issue: "Performance is slower than C++ version" + +**Solution**: +- Python is interpreted, expect some overhead +- For production use, profile and optimize critical paths +- Consider Cython or PyPy for performance-critical code +- The web UI targets 20 FPS, which is sufficient for control + +## Benefits of Migration + +### For Users +- ✅ Access UI from phone, tablet, or computer +- ✅ No compilation required +- ✅ Works on any operating system +- ✅ Easier to install and update +- ✅ More accessible for non-technical users + +### For Developers +- ✅ Python is easier to learn and modify +- ✅ Rich ecosystem of libraries +- ✅ Faster development iteration +- ✅ Better documentation tools +- ✅ Active community support + +## Getting Help + +- **Documentation**: See README.md for full documentation +- **Issues**: https://github.com/oliverbyte/beamcommander/issues +- **Email**: info@OliverByte.de +- **Legacy Version**: C++ code preserved in `openframeworks-src-master/` + +## Roadmap + +Future features planned for Python version: + +- 🚧 MIDI controller support (Akai APC40, etc.) +- 🚧 Built-in EtherDream DAC support +- 🚧 WebSocket-based OSC bridge for browser control +- 🚧 Advanced preset management +- 🚧 Timeline/sequence editor +- 🚧 DMX integration +- 🚧 Audio reactivity + +## Contributing + +The Python version is designed to be more accessible for contributions. If you'd like to help: + +1. Check open issues +2. Fork the repository +3. Make your changes +4. Submit a pull request + +We especially welcome: +- DAC driver implementations +- UI improvements +- Documentation +- Bug reports +- Feature suggestions + +--- + +**Note**: The legacy C++ version remains available in the repository for reference and for users who need specific features not yet available in Python version. diff --git a/README.md b/README.md index f39e7b0..d24e2ae 100644 --- a/README.md +++ b/README.md @@ -1,345 +1,385 @@ -# BeamCommander - Laser Control System +# BeamCommander - Python Edition 🎆 + +**Generic, Cross-Platform Laser Control System** + +[![Python](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) +[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE.md) +[![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey.svg)](https://github.com/oliverbyte/beamcommander) + +BeamCommander 2.0 is a complete rewrite in Python, making it a truly generic and cross-platform laser control system. Control your laser shows in real-time using OSC commands through an intuitive browser-based interface. + +## ✨ What's New in 2.0 + +- **🐍 Pure Python**: No more C++, openFrameworks, or platform-specific dependencies +- **🌐 Browser UI**: Control lasers from any device with a web browser +- **🖥️ Cross-Platform**: Works on Linux, macOS, Windows, and any OS that runs Python +- **📦 Simple Installation**: Just Python 3.8+ and pip +- **🔌 Extensible**: Easy to add new features, shapes, and integrations + +## 🚀 Quick Start + +### Installation + +1. **Clone the repository** + ```bash + git clone https://github.com/oliverbyte/beamcommander.git + cd beamcommander + ``` + +2. **Install BeamCommander** + ```bash + pip install -e . + ``` + + Or manually: + ```bash + pip install -r requirements.txt + ``` + +3. **Run the server** + + Basic mode (visualization only): + ```bash + ./start.sh + ``` + + Or directly: + ```bash + python3 -m beamcommander.server + ``` + + **With EtherDream DAC output:** + ```bash + python3 -m beamcommander.server --enable-dac + ``` + + Or specify DAC IP address: + ```bash + python3 -m beamcommander.server --enable-dac --dac-ip 10.0.1.100 + ``` + +4. **Open the web interface** + - Navigate to http://localhost:8080 in your browser + - Control your laser show from the web UI! + +### System Requirements + +- **Python**: 3.8 or higher +- **Operating System**: Linux, macOS, Windows, or any Python-compatible OS +- **Browser**: Any modern browser (Chrome, Firefox, Safari, Edge) +- **Network**: For OSC control and web interface +- **Optional**: EtherDream DAC for laser hardware output + +## 🎮 Usage + +### Web Interface + +The browser-based UI provides intuitive controls for: + +- **Shape Selection**: Circle, Line, Triangle, Square, Wave patterns +- **Color Control**: Predefined colors (Red, Green, Blue) or custom RGB +- **Movement Patterns**: Circle, Pan, Tilt, Figure-8, Random +- **Rainbow Effects**: Speed and intensity control +- **Transform Controls**: Position, Scale, Rotation +- **Visual Effects**: Brightness, Dot patterns, Blackout + +### OSC Control + +BeamCommander listens for OSC messages on **UDP port 9000**. Send commands from any OSC-compatible software: + +#### Basic Commands -💎 **Free & Open Source** | 🤝 **Community Driven** | ✨ **Live Performance Ready** +```bash +# Set shape +/laser/shape circle|line|triangle|square|wave|staticwave + +# Set color +/laser/color blue|red|green +/laser/color # RGB values 0-1 or 0-255 + +# Brightness and effects +/laser/brightness <0-1> +/laser/dotted <0-1> +/laser/flicker + +# Position and scale +/laser/position # Both -1 to +1 +/laser/shape/scale <-1 to +1> +/laser/rotation/speed + +# Movement +/move/mode none|circle|pan|tilt|eight|random +/move/size <0-1> +/move/speed + +# Rainbow effects +/laser/rainbow/amount <0-1> +/laser/rainbow/speed + +# Cue system +/cue/save # Arm save mode +/cue/<1-30> # Save or recall cue + +# Control +/blackout <0|1> +/flash <0|1> +``` -📚 [Website](https://oliverbyte.github.io/beamcommander/) | 💬 [Discussions](https://github.com/oliverbyte/BeamCommander/discussions) +### Python API -BeamCommander is a free, open-source laser control system that bridges OSC (Open Sound Control) commands with laser hardware, providing real-time visual effects for performances and installations. Developed and supported by a passionate community of artists, developers, and laser enthusiasts. +You can also use BeamCommander programmatically: -**Live Performance Ready**: Control your lasers in real-time using an Akai APC40 MIDI controller and/or intuitive web interface. Designed specifically for live performances, VJ sets, and externally controlled laser shows via OSC commands. Perfect for artists, performers, and installation designers who need responsive, tactile control over complex laser visuals. +```python +from beamcommander.server import BeamCommanderServer -## Demo +# Create server +server = BeamCommanderServer(osc_port=9000, http_port=8080) -![BeamCommander Demo](doc/BeamCommander_Demo.gif) +# Start server (blocking) +server.start() -*Real-time laser control demonstration showing Open Stage Control interface integration with BeamCommander* +# Or access state directly +server.state.master_brightness = 0.8 +server.state.current_shape = Shape.CIRCLE +``` -![BeamCommander Live Demo](doc/BeamCommander_Live_Demo.gif) +## 🏗️ Architecture -*Live performance demonstration with Akai APC40 MIDI controller and iPad (Open Stage Control browser UI) controlling laser effects in real-time* +BeamCommander 2.0 is built with simplicity and extensibility in mind: -## Quick Start (Users) +``` +beamcommander/ +├── __init__.py # Package initialization +├── app_state.py # Application state management +├── osc_receiver.py # OSC message handling +├── shapes.py # Shape generation algorithms +├── cue_manager.py # Cue save/recall system +├── server.py # Main Flask server +├── templates/ # HTML templates +│ └── index.html # Web UI +└── static/ # JavaScript and CSS + └── app.js # Web UI logic +``` -### How to Run BeamCommander +### Key Components -1. **Download the Release Binary** - - Go to the [Releases](https://github.com/oliverbyte/beamcommander/releases) page - - Download the latest release for Mac - - Extract the downloaded archive +- **AppState**: Thread-safe state management for all laser parameters +- **OSCReceiver**: Handles incoming OSC messages and updates state +- **ShapeGenerator**: Generates point data for various laser shapes +- **CueManager**: Manages cue save/recall with disk persistence +- **EtherDreamDAC**: Hardware output driver for EtherDream laser DACs +- **Flask Server**: Serves web UI and provides REST API -2. **Run BeamCommander** - - Double-click `BeamCommander.app` or run it from terminal - - The application will start listening for OSC commands on UDP port 9000 +## 🔧 Configuration -3. **Initial Laser Setup (Required)** - - **First Time**: The application opens with a configuration interface - - **Add Laser Hardware**: Click "Add Laser" to detect your DAC device - - **Zone Mapping**: Create and configure at least one output zone - - **Test Output**: Verify laser output is working before performance use - - **Save Configuration**: Settings are automatically saved for future sessions +### Command-Line Options -4. **Control Options** +```bash +python3 -m beamcommander.server --help + +Options: + --osc-port PORT OSC receiver port (default: 9000) + --http-port PORT HTTP server port (default: 8080) + --log-level LEVEL Logging level: DEBUG|INFO|WARNING|ERROR + --enable-dac Enable EtherDream DAC output + --dac-ip IP EtherDream DAC IP address (default: auto-discover) +``` - **Option A: Akai APC40 MIDI Controller** - - Connect your Akai APC40 via USB - - Use physical knobs and buttons for tactile laser control - - See MIDI Controller Reference section below for button mappings +### EtherDream DAC Setup - **Option B: Custom OSC Client** - - Use any OSC-compatible software or hardware - - Send commands to `localhost:9000` (or the machine's IP address) - - See OSC API Reference section below for complete command list +To output to an EtherDream laser DAC: - **Option C: Open Stage Control Web Interface** - - Install [Open Stage Control](https://openstagecontrol.ammd.net/) on your device - - Use the provided configuration files: - - [`open-stage-control-server.config`](openframeworks-src-master/apps/myApps/BeamCommander/open-stage-control-server.config) - Server configuration - - [`open-stage-control-session.json`](openframeworks-src-master/apps/myApps/BeamCommander/open-stage-control-session.json) - Touch interface layout - - Access the web interface from any device on your network +1. **Connect your EtherDream DAC** to the network +2. **Run with DAC enabled:** + ```bash + # Auto-discover DAC on network + python3 -m beamcommander.server --enable-dac + + # Or specify DAC IP address + python3 -m beamcommander.server --enable-dac --dac-ip 192.168.1.100 + ``` +3. **The server will:** + - Discover/connect to the DAC automatically + - Stream point data at 30 FPS + - Show DAC status in logs -### Prerequisites -- macOS 15.6.1 or later -- **Compatible Laser DAC Hardware** (see Compatible Hardware section below) -- Optional: Akai APC40 MIDI controller for physical control -- Optional: Open Stage Control for web-based touch interface +### Environment Variables -## Compatible Hardware +```bash +# Set log level +export BEAMCOMMANDER_LOG_LEVEL=DEBUG -BeamCommander supports a wide range of laser DAC (Digital-to-Analog Converter) hardware through the powerful [ofxLaser](https://github.com/sebleedelisle/ofxLaser) framework: +# Set ports +export BEAMCOMMANDER_OSC_PORT=9000 +export BEAMCOMMANDER_HTTP_PORT=8080 +``` -### Ethernet DACs -- **EtherDream**: Industry-standard Ethernet laser DAC ✅ **Tested** -- **Laser Dock**: USB and Ethernet laser projector system -- **LaserCube**: Compact wireless laser projector +## 🎨 Extending BeamCommander -### USB DACs -- **Helios**: High-performance USB laser DAC -- **Riya**: USB laser DAC with multiple output channels -- **LaserDock/LaserCube**: USB connectivity options +### Adding New Shapes -### ILDA Standard -- **ILDA Test Patterns**: Support for standard ILDA test patterns and protocols +Edit `beamcommander/shapes.py` and add your shape generation method: -**Note**: Only EtherDream DAC has been tested with BeamCommander. Other DACs are supported by the underlying ofxLaser framework but may require additional setup. BeamCommander automatically detects connected hardware during the initial laser setup process. +```python +def _generate_my_shape(self, scale: float, num_points: int = 100): + points = [] + # Generate your shape points + for i in range(num_points): + x = ... # Calculate x coordinate + y = ... # Calculate y coordinate + points.append((x, y)) + return points +``` -### Control Methods +### Adding New OSC Commands -#### Web Browser (Open Stage Control) -- Access the touch-friendly web interface from any device on your network -- Control laser shapes, colors, movement patterns, and effects -- Perfect for performance control and remote operation +Edit `beamcommander/osc_receiver.py` and add a handler: -#### MIDI Controller (Akai APC40) -- Physical knobs and buttons for tactile control -- Pre-mapped controls for laser brightness, position, colors, and effects -- Momentary buttons for instant cue triggering -- See OSC API Reference section below for complete command details +```python +def _handle_my_command(self, address: str, *args: Any): + """Handle /my/command message""" + if not args: + return + value = float(args[0]) + # Update state based on command + logger.debug(f"My command: {value}") +``` -#### Desktop Application -- Direct laser output configuration -- Zone setup and perspective correction -- Advanced mask management -- Preset system for different venues/setups +Then register it in `setup_dispatcher()`: -### Features +```python +disp.map("/my/command", self._handle_my_command) +``` -- **Multi-Laser Support**: Control multiple laser outputs simultaneously -- **Real-time OSC Control**: Low-latency command processing -- **Shape Generation**: Lines, circles, triangles, squares, wave patterns -- **Color Systems**: Static colors, RGB control, rainbow effects -- **Movement Patterns**: Pan, tilt, circular, figure-8, random movement -- **Visual Effects**: Dotted patterns, brightness control, rotation -- **Cue System**: Pre-programmed sequences and momentary triggers -- **Zone Mapping**: Perspective correction and output transformation - -## OSC API Reference - -BeamCommander listens for OSC commands on **UDP port 9000**. All commands support real-time control for live performance applications. - -### Core Laser Controls - -#### Shape Generation -- `/laser/shape ` - Set laser shape - - **Values**: `line` | `circle` | `triangle` | `square` | `wave` | `staticwave` - -#### Color Control -- `/laser/color ` - Set laser color - - **Named colors**: `"blue"` | `"red"` | `"green"` (disables custom RGB) - - **RGB values**: `r g b` as floats [0..1] or bytes [0..255] (enables custom) - - **Note**: Selecting static colors disables rainbow automation - -#### Brightness & Visual Effects -- `/laser/brightness ` - Master brightness [0..1] or [0..255] - - **Alias**: `/laser/master/brightness` -- `/laser/dotted ` - Dot pattern intensity [0..1] or [0..255] - - **0** = no dots (invisible), **1** = solid line -- `/laser/flicker ` - Visual flicker rate (gates brightness at 50% duty) - - **0** = disabled, **>0** = flicker frequency in Hz - - **Alias**: `/laser/scanrate ` - -#### Positioning & Scaling -- `/laser/position ` - Set laser position (both [-1..+1]) - - **Individual**: `/laser/position/x `, `/laser/position/y ` -- `/laser/shape/scale ` - Shape scale factor [-1..+1] -- `/laser/rotation/speed ` - Rotation speed in rotations/sec - - **Negative** = reverse, **0** = static - -### Wave Pattern Controls -- `/laser/wave/frequency ` - Wave cycles across width (min 0.1) -- `/laser/wave/amplitude ` - Wave height [0..1] as fraction of half-height -- `/laser/wave/speed ` - Wave phase rotation speed (rotations/sec) - -### Rainbow Effects -- `/laser/rainbow/amount ` - Spatial color distribution [0..1] - - **0** = many cycles (short segments), **1** = whole shape one color -- `/laser/rainbow/speed ` - Rainbow animation speed [-1..+1] - - **0** = stopped, **positive** = forward, **negative** = reverse -- `/laser/rainbow/blend ` - Color transition smoothness [0..1] - - **0** = hard steps, **1** = smooth gradient - -### Movement Patterns -- `/move/mode ` - Set movement pattern - - **Values**: `none`|`off` | `circle` | `pan` | `tilt` | `eight`|`figure8`|`8` | `random` -- `/move/size ` - Movement amplitude [0..1] or [0..255] - - **0** = no movement, **1** = full canvas range -- `/move/speed ` - Movement speed in cycles/sec - - **Negative** = reverse direction - -### Flash Controls -- `/flash ` - Flash button control - - **1** = press (force full brightness), **0** = release -- `/flash/release_ms ` - Flash release fade time [0..60000] milliseconds - - **0** = instant return, **>0** = fade to previous brightness over time - -### Cue System -- `/cue/save` - Arm cue saving mode (next `/cue/` will save) -- `/cue/` - Save or recall cue slot (n = 1..16) - - **If save armed**: Store current state to slot n - - **If save not armed**: Recall cue from slot n - -#### Cue Parameters -**Saved with cues**: shape, color (named/RGB), movement, wave settings, rainbow effects, rotation, scale, position, dotted amount, flicker rate - -**Not saved with cues**: master brightness, flash settings, flash button state - -### Usage Examples +## 🔌 Hardware Integration + +### EtherDream DAC (Built-in Support) + +BeamCommander 2.0 includes **native EtherDream DAC support**: ```bash -# Set blue circle with medium brightness -/laser/shape circle -/laser/color blue -/laser/brightness 0.5 - -# Create moving rainbow wave -/laser/shape wave -/laser/wave/frequency 2.0 -/laser/rainbow/amount 0.8 -/laser/rainbow/speed 0.5 -/move/mode circle -/move/size 0.6 -/move/speed 1.2 - -# Flash effect with 2-second fade -/flash/release_ms 2000 -/flash 1 -# ... (later) -/flash 0 - -# Save current state as cue 5 -/cue/save -/cue/5 +# Enable DAC output with auto-discovery +python3 -m beamcommander.server --enable-dac + +# Or specify DAC IP address +python3 -m beamcommander.server --enable-dac --dac-ip 10.0.1.100 ``` -## MIDI Controller Reference (Akai APC40) - -![AKAI APC40 MK2 Mapping](doc/BeamCommander%20AKAI%20APC40%20MK2%20Mapping.jpg) - -*Complete AKAI APC40 MK2 controller mapping for BeamCommander - showing all knobs, buttons, and their corresponding laser control functions* - -The Akai APC40 provides tactile hardware control over BeamCommander's laser parameters. All MIDI controls are mapped to corresponding OSC commands for seamless integration. - -### Setup Instructions - -1. **Connect the Controller**: Plug your AKAI APC40 MK2 into your Mac via USB -2. **Launch BeamCommander**: The application will automatically detect and connect to the MIDI controller -3. **Verify Connection**: LED lights on the controller should illuminate, indicating active connection -4. **Start Controlling**: All knobs and buttons are immediately ready for real-time laser control - -### Controller Layout Overview - -The AKAI APC40 MK2 is organized into several control zones: -- **Top Knobs (1-8)**: Primary laser parameters (brightness, position, effects) -- **Bottom Knobs (9-16)**: Wave patterns and rainbow effects -- **Grid Buttons**: Cue recall system (16 memory slots) -- **Side Buttons**: Shape selection and movement patterns -- **Transport**: Play/stop and emergency controls - -### Knobs (Continuous Controllers) -**Top Row - Shape & Color Controls:** -- **Knob 1**: `/laser/brightness` - Master brightness control [0..1] -- **Knob 2**: `/laser/shape/scale` - Shape scale factor [-1..+1] -- **Knob 3**: `/laser/rotation/speed` - Rotation speed (rotations/sec) -- **Knob 4**: `/laser/position/x` - Horizontal position [-1..+1] -- **Knob 5**: `/laser/position/y` - Vertical position [-1..+1] -- **Knob 6**: `/laser/dotted` - Dot pattern intensity [0..1] -- **Knob 7**: `/laser/flicker` - Visual flicker rate (Hz) -- **Knob 8**: `/laser/color` - RGB color mixing (context-dependent) - -**Bottom Row - Wave & Movement Controls:** -- **Knob 9**: `/laser/wave/frequency` - Wave cycles across width -- **Knob 10**: `/laser/wave/amplitude` - Wave height [0..1] -- **Knob 11**: `/laser/wave/speed` - Wave phase rotation speed -- **Knob 12**: `/move/size` - Movement amplitude [0..1] -- **Knob 13**: `/move/speed` - Movement speed (cycles/sec) -- **Knob 14**: `/laser/rainbow/amount` - Rainbow spatial distribution -- **Knob 15**: `/laser/rainbow/speed` - Rainbow animation speed -- **Knob 16**: `/laser/rainbow/blend` - Color transition smoothness - -### Buttons (Momentary & Toggle) -**Cue Launch Buttons (Grid):** -- **Button 1-16**: `/cue/1` through `/cue/16` - Recall cue presets -- **Rec Arm + Cue Button**: `/cue/save` then `/cue/` - Save to cue slot - -**Transport & Special Functions:** -- **Flash Button**: `/flash 1` (press) / `/flash 0` (release) - Instant full brightness -- **Play Button**: Toggle laser output enable/disable -- **Stop Button**: Emergency stop (brightness to 0) -- **Rec Button**: Arm cue save mode (`/cue/save`) - -### Shape Selection Buttons -**Top Button Row:** -- **Clip Launch 1**: `/laser/shape line` -- **Clip Launch 2**: `/laser/shape circle` -- **Clip Launch 3**: `/laser/shape triangle` -- **Clip Launch 4**: `/laser/shape square` -- **Clip Launch 5**: `/laser/shape wave` -- **Clip Launch 6**: `/laser/shape staticwave` - -### Movement Pattern Buttons -**Side Button Column:** -- **Track 1**: `/move/mode none` - No movement -- **Track 2**: `/move/mode circle` - Circular motion -- **Track 3**: `/move/mode pan` - Horizontal pan -- **Track 4**: `/move/mode tilt` - Vertical tilt -- **Track 5**: `/move/mode eight` - Figure-8 pattern -- **Track 6**: `/move/mode random` - Random movement - -### Color Preset Buttons -**Right Side Buttons:** -- **Scene 1**: `/laser/color red` -- **Scene 2**: `/laser/color green` -- **Scene 3**: `/laser/color blue` -- **Scene 4**: Enable rainbow mode -- **Scene 5**: Custom RGB mode (use knobs for mixing) - -## System Requirements - -- **Operating System**: macOS 15.6.1 (Sequoia) -- **Architecture**: x86_64 (Intel) or ARM64 (Apple Silicon) -- **Compiler**: Apple Clang 16.0.0 -- **Build System**: Make with parallel compilation support -- **Network**: Ethernet connection for laser DACs -- **USB**: For MIDI controllers and USB laser hardware - -## Quick Reference - -- `./build.sh` - Build the application (first time or after code changes) -- `./start_server.sh` - Start BeamCommander laser control server -- `./start_open-stage-control.sh` - Start web control interface -- `DEVELOPER.md` - Technical documentation for developers -- `LICENSE.md` - Complete licensing information and third-party attributions - -## Framework Versions - -BeamCommander is built on modified versions of open-source frameworks: -- **OpenFrameworks**: v0.12.0 (master branch, October 2023) - Modified for enhanced stability and build optimization -- **ofxLaser**: of_11.0.2 branch (legacy for OF 0.11.x) - Modified with joystick removal and ImGui safety improvements - -For detailed modification information, see `DEVELOPER.md`. - -## Acknowledgments - -Special thanks to the **ofxLaser development team** for creating the outstanding laser control framework that makes BeamCommander possible: - -- **[Seb Lee-Delisle](https://github.com/sebleedelisle)** - Lead developer of ofxLaser -- **ofxLaser Contributors** - The community of developers who built and maintain this essential laser control library - -BeamCommander builds upon the excellent foundation provided by ofxLaser, extending it with real-time OSC control, MIDI integration, and performance-focused features. Without ofxLaser's robust hardware abstraction and rendering capabilities, this project would not exist. - -Additional thanks to: -- **OpenFrameworks Community** - For the cross-platform creative coding framework -- **Open Stage Control Developers** - For the flexible OSC control interface -- **Beta Testers and Users** - For feedback and real-world testing - -## License - -BeamCommander incorporates multiple open-source components: -- **BeamCommander application code**: MIT License -- **OpenFrameworks v0.12.0**: MIT License -- **ofxLaser of_11.0.2 branch**: MIT License - -See `LICENSE.md` for complete licensing information and third-party attributions. - -## Developer Contact - -For developer collaboration, contributions, or technical discussions, reach out to [info@OliverByte.de](mailto:info@OliverByte.de). +**Features:** +- Automatic DAC discovery via network broadcast +- 30 FPS point streaming +- Automatic reconnection on connection loss +- Thread-safe operation + +**Protocol Details:** +- Discovery: UDP port 7654 +- Command/Data: TCP port 7765 +- Point rate: 30,000 PPS (configurable) +- Coordinates: Normalized [-1..1] converted to DAC format + +### Custom DAC Integration + +For other DAC types, you can integrate using the shape generator: + +```python +from beamcommander.shapes import ShapeGenerator +from beamcommander.app_state import AppState + +# Initialize +state = AppState() +generator = ShapeGenerator() + +# Generate points +points = generator.generate_shape(state, time.time()) + +# Send to your DAC +for x, y, r, g, b in points: + your_dac.add_point(x, y, r, g, b) +``` + +## 🐛 Troubleshooting + +### Port Already in Use + +If you get "Address already in use" errors: + +```bash +# Check what's using the port +lsof -i :9000 +lsof -i :8080 + +# Kill the process or use different ports +python3 -m beamcommander.server --osc-port 9001 --http-port 8081 +``` + +### Web UI Not Loading + +1. Check the server is running: `curl http://localhost:8080/api/status` +2. Check firewall settings allow connections to port 8080 +3. Try accessing via IP address instead of localhost + +### OSC Messages Not Received + +1. Verify OSC sender is targeting correct IP and port +2. Check firewall allows UDP port 9000 +3. Enable debug logging: `--log-level DEBUG` + +## 📚 Documentation + +- **OSC API Reference**: See comments in `osc_receiver.py` +- **Shape Generation**: See `shapes.py` for algorithms +- **State Management**: See `app_state.py` for all parameters + +## 🤝 Contributing + +Contributions are welcome! Please feel free to submit pull requests or open issues for bugs and feature requests. + +### Development Setup + +```bash +# Clone the repo +git clone https://github.com/oliverbyte/beamcommander.git +cd beamcommander + +# Create virtual environment +python3 -m venv venv +source venv/bin/activate + +# Install in development mode +pip install -e . + +# Run with debug logging +python3 -m beamcommander.server --log-level DEBUG +``` + +## 📋 Migration from v1.x (C++ Version) + +If you're migrating from the old C++/openFrameworks version: + +1. **OSC Commands**: All OSC commands remain the same +2. **Cues**: Cue files are not compatible; you'll need to recreate them +3. **MIDI**: MIDI support is planned for a future release +4. **Hardware**: You'll need to implement your DAC driver (see Hardware Integration) + +The old C++ code is archived in the `openframeworks-src-master` directory. + +## 📄 License + +BeamCommander is released under the MIT License. See [LICENSE.md](LICENSE.md) for details. + +## 🙏 Acknowledgments + +- Original ofxLaser framework by [Seb Lee-Delisle](https://github.com/sebleedelisle) +- OpenFrameworks community for inspiration +- Contributors and users of BeamCommander v1.x + +## 📧 Contact + +For questions, suggestions, or collaboration: +- **Email**: info@OliverByte.de +- **GitHub**: [oliverbyte/beamcommander](https://github.com/oliverbyte/beamcommander) +- **Website**: [oliverbyte.github.io/beamcommander](https://oliverbyte.github.io/beamcommander/) + +--- + +**Made with ❤️ for the laser art community** diff --git a/README_CPP_LEGACY.md b/README_CPP_LEGACY.md new file mode 100644 index 0000000..f39e7b0 --- /dev/null +++ b/README_CPP_LEGACY.md @@ -0,0 +1,345 @@ +# BeamCommander - Laser Control System + +💎 **Free & Open Source** | 🤝 **Community Driven** | ✨ **Live Performance Ready** + +📚 [Website](https://oliverbyte.github.io/beamcommander/) | 💬 [Discussions](https://github.com/oliverbyte/BeamCommander/discussions) + +BeamCommander is a free, open-source laser control system that bridges OSC (Open Sound Control) commands with laser hardware, providing real-time visual effects for performances and installations. Developed and supported by a passionate community of artists, developers, and laser enthusiasts. + +**Live Performance Ready**: Control your lasers in real-time using an Akai APC40 MIDI controller and/or intuitive web interface. Designed specifically for live performances, VJ sets, and externally controlled laser shows via OSC commands. Perfect for artists, performers, and installation designers who need responsive, tactile control over complex laser visuals. + +## Demo + +![BeamCommander Demo](doc/BeamCommander_Demo.gif) + +*Real-time laser control demonstration showing Open Stage Control interface integration with BeamCommander* + +![BeamCommander Live Demo](doc/BeamCommander_Live_Demo.gif) + +*Live performance demonstration with Akai APC40 MIDI controller and iPad (Open Stage Control browser UI) controlling laser effects in real-time* + +## Quick Start (Users) + +### How to Run BeamCommander + +1. **Download the Release Binary** + - Go to the [Releases](https://github.com/oliverbyte/beamcommander/releases) page + - Download the latest release for Mac + - Extract the downloaded archive + +2. **Run BeamCommander** + - Double-click `BeamCommander.app` or run it from terminal + - The application will start listening for OSC commands on UDP port 9000 + +3. **Initial Laser Setup (Required)** + - **First Time**: The application opens with a configuration interface + - **Add Laser Hardware**: Click "Add Laser" to detect your DAC device + - **Zone Mapping**: Create and configure at least one output zone + - **Test Output**: Verify laser output is working before performance use + - **Save Configuration**: Settings are automatically saved for future sessions + +4. **Control Options** + + **Option A: Akai APC40 MIDI Controller** + - Connect your Akai APC40 via USB + - Use physical knobs and buttons for tactile laser control + - See MIDI Controller Reference section below for button mappings + + **Option B: Custom OSC Client** + - Use any OSC-compatible software or hardware + - Send commands to `localhost:9000` (or the machine's IP address) + - See OSC API Reference section below for complete command list + + **Option C: Open Stage Control Web Interface** + - Install [Open Stage Control](https://openstagecontrol.ammd.net/) on your device + - Use the provided configuration files: + - [`open-stage-control-server.config`](openframeworks-src-master/apps/myApps/BeamCommander/open-stage-control-server.config) - Server configuration + - [`open-stage-control-session.json`](openframeworks-src-master/apps/myApps/BeamCommander/open-stage-control-session.json) - Touch interface layout + - Access the web interface from any device on your network + +### Prerequisites +- macOS 15.6.1 or later +- **Compatible Laser DAC Hardware** (see Compatible Hardware section below) +- Optional: Akai APC40 MIDI controller for physical control +- Optional: Open Stage Control for web-based touch interface + +## Compatible Hardware + +BeamCommander supports a wide range of laser DAC (Digital-to-Analog Converter) hardware through the powerful [ofxLaser](https://github.com/sebleedelisle/ofxLaser) framework: + +### Ethernet DACs +- **EtherDream**: Industry-standard Ethernet laser DAC ✅ **Tested** +- **Laser Dock**: USB and Ethernet laser projector system +- **LaserCube**: Compact wireless laser projector + +### USB DACs +- **Helios**: High-performance USB laser DAC +- **Riya**: USB laser DAC with multiple output channels +- **LaserDock/LaserCube**: USB connectivity options + +### ILDA Standard +- **ILDA Test Patterns**: Support for standard ILDA test patterns and protocols + +**Note**: Only EtherDream DAC has been tested with BeamCommander. Other DACs are supported by the underlying ofxLaser framework but may require additional setup. BeamCommander automatically detects connected hardware during the initial laser setup process. + +### Control Methods + +#### Web Browser (Open Stage Control) +- Access the touch-friendly web interface from any device on your network +- Control laser shapes, colors, movement patterns, and effects +- Perfect for performance control and remote operation + +#### MIDI Controller (Akai APC40) +- Physical knobs and buttons for tactile control +- Pre-mapped controls for laser brightness, position, colors, and effects +- Momentary buttons for instant cue triggering +- See OSC API Reference section below for complete command details + +#### Desktop Application +- Direct laser output configuration +- Zone setup and perspective correction +- Advanced mask management +- Preset system for different venues/setups + +### Features + +- **Multi-Laser Support**: Control multiple laser outputs simultaneously +- **Real-time OSC Control**: Low-latency command processing +- **Shape Generation**: Lines, circles, triangles, squares, wave patterns +- **Color Systems**: Static colors, RGB control, rainbow effects +- **Movement Patterns**: Pan, tilt, circular, figure-8, random movement +- **Visual Effects**: Dotted patterns, brightness control, rotation +- **Cue System**: Pre-programmed sequences and momentary triggers +- **Zone Mapping**: Perspective correction and output transformation + +## OSC API Reference + +BeamCommander listens for OSC commands on **UDP port 9000**. All commands support real-time control for live performance applications. + +### Core Laser Controls + +#### Shape Generation +- `/laser/shape ` - Set laser shape + - **Values**: `line` | `circle` | `triangle` | `square` | `wave` | `staticwave` + +#### Color Control +- `/laser/color ` - Set laser color + - **Named colors**: `"blue"` | `"red"` | `"green"` (disables custom RGB) + - **RGB values**: `r g b` as floats [0..1] or bytes [0..255] (enables custom) + - **Note**: Selecting static colors disables rainbow automation + +#### Brightness & Visual Effects +- `/laser/brightness ` - Master brightness [0..1] or [0..255] + - **Alias**: `/laser/master/brightness` +- `/laser/dotted ` - Dot pattern intensity [0..1] or [0..255] + - **0** = no dots (invisible), **1** = solid line +- `/laser/flicker ` - Visual flicker rate (gates brightness at 50% duty) + - **0** = disabled, **>0** = flicker frequency in Hz + - **Alias**: `/laser/scanrate ` + +#### Positioning & Scaling +- `/laser/position ` - Set laser position (both [-1..+1]) + - **Individual**: `/laser/position/x `, `/laser/position/y ` +- `/laser/shape/scale ` - Shape scale factor [-1..+1] +- `/laser/rotation/speed ` - Rotation speed in rotations/sec + - **Negative** = reverse, **0** = static + +### Wave Pattern Controls +- `/laser/wave/frequency ` - Wave cycles across width (min 0.1) +- `/laser/wave/amplitude ` - Wave height [0..1] as fraction of half-height +- `/laser/wave/speed ` - Wave phase rotation speed (rotations/sec) + +### Rainbow Effects +- `/laser/rainbow/amount ` - Spatial color distribution [0..1] + - **0** = many cycles (short segments), **1** = whole shape one color +- `/laser/rainbow/speed ` - Rainbow animation speed [-1..+1] + - **0** = stopped, **positive** = forward, **negative** = reverse +- `/laser/rainbow/blend ` - Color transition smoothness [0..1] + - **0** = hard steps, **1** = smooth gradient + +### Movement Patterns +- `/move/mode ` - Set movement pattern + - **Values**: `none`|`off` | `circle` | `pan` | `tilt` | `eight`|`figure8`|`8` | `random` +- `/move/size ` - Movement amplitude [0..1] or [0..255] + - **0** = no movement, **1** = full canvas range +- `/move/speed ` - Movement speed in cycles/sec + - **Negative** = reverse direction + +### Flash Controls +- `/flash ` - Flash button control + - **1** = press (force full brightness), **0** = release +- `/flash/release_ms ` - Flash release fade time [0..60000] milliseconds + - **0** = instant return, **>0** = fade to previous brightness over time + +### Cue System +- `/cue/save` - Arm cue saving mode (next `/cue/` will save) +- `/cue/` - Save or recall cue slot (n = 1..16) + - **If save armed**: Store current state to slot n + - **If save not armed**: Recall cue from slot n + +#### Cue Parameters +**Saved with cues**: shape, color (named/RGB), movement, wave settings, rainbow effects, rotation, scale, position, dotted amount, flicker rate + +**Not saved with cues**: master brightness, flash settings, flash button state + +### Usage Examples + +```bash +# Set blue circle with medium brightness +/laser/shape circle +/laser/color blue +/laser/brightness 0.5 + +# Create moving rainbow wave +/laser/shape wave +/laser/wave/frequency 2.0 +/laser/rainbow/amount 0.8 +/laser/rainbow/speed 0.5 +/move/mode circle +/move/size 0.6 +/move/speed 1.2 + +# Flash effect with 2-second fade +/flash/release_ms 2000 +/flash 1 +# ... (later) +/flash 0 + +# Save current state as cue 5 +/cue/save +/cue/5 +``` + +## MIDI Controller Reference (Akai APC40) + +![AKAI APC40 MK2 Mapping](doc/BeamCommander%20AKAI%20APC40%20MK2%20Mapping.jpg) + +*Complete AKAI APC40 MK2 controller mapping for BeamCommander - showing all knobs, buttons, and their corresponding laser control functions* + +The Akai APC40 provides tactile hardware control over BeamCommander's laser parameters. All MIDI controls are mapped to corresponding OSC commands for seamless integration. + +### Setup Instructions + +1. **Connect the Controller**: Plug your AKAI APC40 MK2 into your Mac via USB +2. **Launch BeamCommander**: The application will automatically detect and connect to the MIDI controller +3. **Verify Connection**: LED lights on the controller should illuminate, indicating active connection +4. **Start Controlling**: All knobs and buttons are immediately ready for real-time laser control + +### Controller Layout Overview + +The AKAI APC40 MK2 is organized into several control zones: +- **Top Knobs (1-8)**: Primary laser parameters (brightness, position, effects) +- **Bottom Knobs (9-16)**: Wave patterns and rainbow effects +- **Grid Buttons**: Cue recall system (16 memory slots) +- **Side Buttons**: Shape selection and movement patterns +- **Transport**: Play/stop and emergency controls + +### Knobs (Continuous Controllers) +**Top Row - Shape & Color Controls:** +- **Knob 1**: `/laser/brightness` - Master brightness control [0..1] +- **Knob 2**: `/laser/shape/scale` - Shape scale factor [-1..+1] +- **Knob 3**: `/laser/rotation/speed` - Rotation speed (rotations/sec) +- **Knob 4**: `/laser/position/x` - Horizontal position [-1..+1] +- **Knob 5**: `/laser/position/y` - Vertical position [-1..+1] +- **Knob 6**: `/laser/dotted` - Dot pattern intensity [0..1] +- **Knob 7**: `/laser/flicker` - Visual flicker rate (Hz) +- **Knob 8**: `/laser/color` - RGB color mixing (context-dependent) + +**Bottom Row - Wave & Movement Controls:** +- **Knob 9**: `/laser/wave/frequency` - Wave cycles across width +- **Knob 10**: `/laser/wave/amplitude` - Wave height [0..1] +- **Knob 11**: `/laser/wave/speed` - Wave phase rotation speed +- **Knob 12**: `/move/size` - Movement amplitude [0..1] +- **Knob 13**: `/move/speed` - Movement speed (cycles/sec) +- **Knob 14**: `/laser/rainbow/amount` - Rainbow spatial distribution +- **Knob 15**: `/laser/rainbow/speed` - Rainbow animation speed +- **Knob 16**: `/laser/rainbow/blend` - Color transition smoothness + +### Buttons (Momentary & Toggle) +**Cue Launch Buttons (Grid):** +- **Button 1-16**: `/cue/1` through `/cue/16` - Recall cue presets +- **Rec Arm + Cue Button**: `/cue/save` then `/cue/` - Save to cue slot + +**Transport & Special Functions:** +- **Flash Button**: `/flash 1` (press) / `/flash 0` (release) - Instant full brightness +- **Play Button**: Toggle laser output enable/disable +- **Stop Button**: Emergency stop (brightness to 0) +- **Rec Button**: Arm cue save mode (`/cue/save`) + +### Shape Selection Buttons +**Top Button Row:** +- **Clip Launch 1**: `/laser/shape line` +- **Clip Launch 2**: `/laser/shape circle` +- **Clip Launch 3**: `/laser/shape triangle` +- **Clip Launch 4**: `/laser/shape square` +- **Clip Launch 5**: `/laser/shape wave` +- **Clip Launch 6**: `/laser/shape staticwave` + +### Movement Pattern Buttons +**Side Button Column:** +- **Track 1**: `/move/mode none` - No movement +- **Track 2**: `/move/mode circle` - Circular motion +- **Track 3**: `/move/mode pan` - Horizontal pan +- **Track 4**: `/move/mode tilt` - Vertical tilt +- **Track 5**: `/move/mode eight` - Figure-8 pattern +- **Track 6**: `/move/mode random` - Random movement + +### Color Preset Buttons +**Right Side Buttons:** +- **Scene 1**: `/laser/color red` +- **Scene 2**: `/laser/color green` +- **Scene 3**: `/laser/color blue` +- **Scene 4**: Enable rainbow mode +- **Scene 5**: Custom RGB mode (use knobs for mixing) + +## System Requirements + +- **Operating System**: macOS 15.6.1 (Sequoia) +- **Architecture**: x86_64 (Intel) or ARM64 (Apple Silicon) +- **Compiler**: Apple Clang 16.0.0 +- **Build System**: Make with parallel compilation support +- **Network**: Ethernet connection for laser DACs +- **USB**: For MIDI controllers and USB laser hardware + +## Quick Reference + +- `./build.sh` - Build the application (first time or after code changes) +- `./start_server.sh` - Start BeamCommander laser control server +- `./start_open-stage-control.sh` - Start web control interface +- `DEVELOPER.md` - Technical documentation for developers +- `LICENSE.md` - Complete licensing information and third-party attributions + +## Framework Versions + +BeamCommander is built on modified versions of open-source frameworks: +- **OpenFrameworks**: v0.12.0 (master branch, October 2023) - Modified for enhanced stability and build optimization +- **ofxLaser**: of_11.0.2 branch (legacy for OF 0.11.x) - Modified with joystick removal and ImGui safety improvements + +For detailed modification information, see `DEVELOPER.md`. + +## Acknowledgments + +Special thanks to the **ofxLaser development team** for creating the outstanding laser control framework that makes BeamCommander possible: + +- **[Seb Lee-Delisle](https://github.com/sebleedelisle)** - Lead developer of ofxLaser +- **ofxLaser Contributors** - The community of developers who built and maintain this essential laser control library + +BeamCommander builds upon the excellent foundation provided by ofxLaser, extending it with real-time OSC control, MIDI integration, and performance-focused features. Without ofxLaser's robust hardware abstraction and rendering capabilities, this project would not exist. + +Additional thanks to: +- **OpenFrameworks Community** - For the cross-platform creative coding framework +- **Open Stage Control Developers** - For the flexible OSC control interface +- **Beta Testers and Users** - For feedback and real-world testing + +## License + +BeamCommander incorporates multiple open-source components: +- **BeamCommander application code**: MIT License +- **OpenFrameworks v0.12.0**: MIT License +- **ofxLaser of_11.0.2 branch**: MIT License + +See `LICENSE.md` for complete licensing information and third-party attributions. + +## Developer Contact + +For developer collaboration, contributions, or technical discussions, reach out to [info@OliverByte.de](mailto:info@OliverByte.de). diff --git a/beamcommander/__init__.py b/beamcommander/__init__.py new file mode 100644 index 0000000..add4401 --- /dev/null +++ b/beamcommander/__init__.py @@ -0,0 +1,7 @@ +""" +BeamCommander - Laser Control System +A generic Python implementation for OSC-based laser control +""" + +__version__ = "2.0.0" +__author__ = "Oliver Byte" diff --git a/beamcommander/app_state.py b/beamcommander/app_state.py new file mode 100644 index 0000000..482cc63 --- /dev/null +++ b/beamcommander/app_state.py @@ -0,0 +1,260 @@ +""" +Application state management for BeamCommander +""" +from enum import Enum +from dataclasses import dataclass, asdict +from typing import Tuple, Dict, Any +import colorsys +import threading + + +class Shape(Enum): + """Laser shape types""" + CIRCLE = "circle" + LINE = "line" + TRIANGLE = "triangle" + SQUARE = "square" + WAVE = "wave" + STATIC_WAVE = "staticwave" + + +class ColorSel(Enum): + """Predefined color selections""" + BLUE = "blue" + RED = "red" + GREEN = "green" + + +class Movement(Enum): + """Movement pattern types""" + NONE = "none" + CIRCLE = "circle" + PAN = "pan" + TILT = "tilt" + EIGHT = "eight" + RANDOM = "random" + + +class BeamFx(Enum): + """Beam effect types""" + NONE = "none" + PRISMA = "prisma" + + +@dataclass +class AppState: + """ + Central application state for laser control + Thread-safe state management for real-time control + """ + # Shape and color + current_shape: Shape = Shape.CIRCLE + current_color: ColorSel = ColorSel.BLUE + movement: Movement = Movement.NONE + + # Custom color (when enabled, overrides current_color) + use_custom_color: bool = False + custom_r: float = 0.0 + custom_g: float = 0.2 + custom_b: float = 1.0 + + # Wave parameters + wave_frequency: float = 1.0 # cycles across width + wave_amplitude: float = 0.45 # fraction of half-height + wave_speed: float = 0.0 # cycles per second + + # Rainbow effects + rainbow_speed: float = 0.0 # hue cycles per second + rainbow_amount: float = 0.0 # blend amount [0..1] + rainbow_blend: float = 1.0 # smooth gradient [0..1] + + # Movement controls + move_speed: float = 0.30 # cycles per second + move_size: float = 0.50 # amplitude [0..1] + + # Rotation + rotation_speed: float = 0.0 # rotations per second + + # Shape scale and position + shape_scale: float = 0.0 # [-1..1] + pos_norm_x: float = 0.0 # [-1..1] + pos_norm_y: float = 0.0 # [-1..1] + + # Axis controls + invert_x: bool = False + blackout: bool = False + + # Effects + beam_fx: BeamFx = BeamFx.NONE + master_brightness: float = 1.0 # [0..1] + dot_amount: float = 1.0 # [0..1], 0=invisible, 1=solid + + # Flicker/strobe + flicker_hz: float = 0.0 # flicker frequency in Hz + + def __post_init__(self): + """Initialize thread lock for safe concurrent access""" + self._lock = threading.RLock() + + def to_color_rgb(self, rainbow_hue_01: float = -1.0) -> Tuple[int, int, int]: + """ + Convert current color state to RGB values (0-255) + + Args: + rainbow_hue_01: Optional rainbow hue [0..1], -1 to disable + + Returns: + Tuple of (r, g, b) values in range [0, 255] + """ + with self._lock: + if self.use_custom_color: + r = max(0.0, min(1.0, self.custom_r)) + g = max(0.0, min(1.0, self.custom_g)) + b = max(0.0, min(1.0, self.custom_b)) + base_color = (int(r * 255), int(g * 255), int(b * 255)) + else: + # Predefined colors + color_map = { + ColorSel.RED: (255, 0, 20), + ColorSel.GREEN: (0, 220, 80), + ColorSel.BLUE: (0, 50, 255), + } + base_color = color_map.get(self.current_color, (0, 50, 255)) + + # Apply rainbow blend if requested + if rainbow_hue_01 >= 0.0 and self.rainbow_amount > 0.0: + # Convert base color to 0-1 range + br, bg, bb = base_color[0] / 255.0, base_color[1] / 255.0, base_color[2] / 255.0 + + # Get rainbow color from hue + rainbow_r, rainbow_g, rainbow_b = colorsys.hsv_to_rgb(rainbow_hue_01, 1.0, 1.0) + + # Blend + amt = max(0.0, min(1.0, self.rainbow_amount)) + r = br * (1 - amt) + rainbow_r * amt + g = bg * (1 - amt) + rainbow_g * amt + b = bb * (1 - amt) + rainbow_b * amt + + return (int(r * 255), int(g * 255), int(b * 255)) + + return base_color + + def to_dict(self) -> Dict[str, Any]: + """Convert state to dictionary for serialization""" + with self._lock: + return { + 'current_shape': self.current_shape.value, + 'current_color': self.current_color.value, + 'movement': self.movement.value, + 'use_custom_color': self.use_custom_color, + 'custom_r': self.custom_r, + 'custom_g': self.custom_g, + 'custom_b': self.custom_b, + 'wave_frequency': self.wave_frequency, + 'wave_amplitude': self.wave_amplitude, + 'wave_speed': self.wave_speed, + 'rainbow_speed': self.rainbow_speed, + 'rainbow_amount': self.rainbow_amount, + 'rainbow_blend': self.rainbow_blend, + 'move_speed': self.move_speed, + 'move_size': self.move_size, + 'rotation_speed': self.rotation_speed, + 'shape_scale': self.shape_scale, + 'pos_norm_x': self.pos_norm_x, + 'pos_norm_y': self.pos_norm_y, + 'invert_x': self.invert_x, + 'blackout': self.blackout, + 'beam_fx': self.beam_fx.value, + 'master_brightness': self.master_brightness, + 'dot_amount': self.dot_amount, + 'flicker_hz': self.flicker_hz, + } + + +@dataclass +class CueState: + """Snapshot of application state for cue recall""" + shape: str = "circle" + color_sel: str = "blue" + movement: str = "none" + beam_fx: str = "none" + use_custom: bool = False + r: float = 0.0 + g: float = 0.2 + b: float = 1.0 + rainbow_speed: float = 0.0 + rainbow_amount: float = 0.0 + rainbow_blend: float = 1.0 + wave_frequency: float = 1.0 + wave_amplitude: float = 0.45 + wave_speed: float = 0.0 + move_speed: float = 0.30 + move_size: float = 0.50 + rotation_speed: float = 0.0 + shape_scale: float = 0.0 + pos_x: float = 0.0 + pos_y: float = 0.0 + dot_amount: float = 1.0 + flicker_hz: float = 0.0 + populated: bool = False + + @classmethod + def from_app_state(cls, state: AppState) -> 'CueState': + """Create a cue snapshot from current app state""" + return cls( + shape=state.current_shape.value, + color_sel=state.current_color.value, + movement=state.movement.value, + beam_fx=state.beam_fx.value, + use_custom=state.use_custom_color, + r=state.custom_r, + g=state.custom_g, + b=state.custom_b, + rainbow_speed=state.rainbow_speed, + rainbow_amount=state.rainbow_amount, + rainbow_blend=state.rainbow_blend, + wave_frequency=state.wave_frequency, + wave_amplitude=state.wave_amplitude, + wave_speed=state.wave_speed, + move_speed=state.move_speed, + move_size=state.move_size, + rotation_speed=state.rotation_speed, + shape_scale=state.shape_scale, + pos_x=state.pos_norm_x, + pos_y=state.pos_norm_y, + dot_amount=state.dot_amount, + flicker_hz=state.flicker_hz, + populated=True + ) + + def apply_to_app_state(self, state: AppState) -> None: + """Apply this cue to the application state""" + if not self.populated: + return + + state.current_shape = Shape(self.shape) + state.current_color = ColorSel(self.color_sel) + state.movement = Movement(self.movement) + state.beam_fx = BeamFx(self.beam_fx) + state.use_custom_color = self.use_custom + state.custom_r = self.r + state.custom_g = self.g + state.custom_b = self.b + state.rainbow_speed = self.rainbow_speed + state.rainbow_amount = self.rainbow_amount + state.rainbow_blend = self.rainbow_blend + state.wave_frequency = self.wave_frequency + state.wave_amplitude = self.wave_amplitude + state.wave_speed = self.wave_speed + state.move_speed = self.move_speed + state.move_size = self.move_size + state.rotation_speed = self.rotation_speed + state.shape_scale = self.shape_scale + state.pos_norm_x = self.pos_x + state.pos_norm_y = self.pos_y + state.dot_amount = self.dot_amount + state.flicker_hz = self.flicker_hz + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary""" + return asdict(self) diff --git a/beamcommander/cue_manager.py b/beamcommander/cue_manager.py new file mode 100644 index 0000000..403f300 --- /dev/null +++ b/beamcommander/cue_manager.py @@ -0,0 +1,174 @@ +""" +Cue management for BeamCommander +""" +import json +import os +import logging +from typing import Dict, Optional +from .app_state import AppState, CueState + +logger = logging.getLogger(__name__) + + +class CueManager: + """ + Manages cue save/recall and persistence + """ + + def __init__(self, state: AppState, max_cues: int = 30): + """ + Initialize cue manager + + Args: + state: Application state reference + max_cues: Maximum number of cues to support + """ + self.state = state + self.max_cues = max_cues + self.cues: Dict[int, CueState] = {} + self.cues_file = "cues.json" + + def save_cue(self, cue_num: int) -> bool: + """ + Save current state to a cue slot + + Args: + cue_num: Cue number (1-based) + + Returns: + True if saved successfully + """ + if cue_num < 1 or cue_num > self.max_cues: + logger.warning(f"Invalid cue number: {cue_num}") + return False + + try: + cue = CueState.from_app_state(self.state) + self.cues[cue_num] = cue + logger.info(f"Saved cue {cue_num}") + self.save_to_disk() + return True + except Exception as e: + logger.error(f"Error saving cue {cue_num}: {e}") + return False + + def recall_cue(self, cue_num: int) -> bool: + """ + Recall a cue and apply it to current state + + Args: + cue_num: Cue number (1-based) + + Returns: + True if recalled successfully + """ + if cue_num < 1 or cue_num > self.max_cues: + logger.warning(f"Invalid cue number: {cue_num}") + return False + + cue = self.cues.get(cue_num) + if not cue or not cue.populated: + logger.warning(f"Cue {cue_num} is empty") + return False + + try: + cue.apply_to_app_state(self.state) + logger.info(f"Recalled cue {cue_num}") + return True + except Exception as e: + logger.error(f"Error recalling cue {cue_num}: {e}") + return False + + def save_to_disk(self, filepath: Optional[str] = None) -> bool: + """ + Save all cues to disk + + Args: + filepath: Optional custom filepath + + Returns: + True if saved successfully + """ + if filepath is None: + filepath = self.cues_file + + try: + data = { + str(num): cue.to_dict() + for num, cue in self.cues.items() + if cue.populated + } + + with open(filepath, 'w') as f: + json.dump(data, f, indent=2) + + logger.info(f"Saved {len(data)} cues to {filepath}") + return True + except Exception as e: + logger.error(f"Error saving cues to disk: {e}") + return False + + def load_from_disk(self, filepath: Optional[str] = None) -> bool: + """ + Load cues from disk + + Args: + filepath: Optional custom filepath + + Returns: + True if loaded successfully + """ + if filepath is None: + filepath = self.cues_file + + if not os.path.exists(filepath): + logger.info(f"Cues file not found: {filepath}") + return False + + try: + with open(filepath, 'r') as f: + data = json.load(f) + + for num_str, cue_dict in data.items(): + try: + num = int(num_str) + if 1 <= num <= self.max_cues: + cue = CueState(**cue_dict) + self.cues[num] = cue + except (ValueError, TypeError) as e: + logger.warning(f"Invalid cue data for {num_str}: {e}") + + logger.info(f"Loaded {len(self.cues)} cues from {filepath}") + return True + except Exception as e: + logger.error(f"Error loading cues from disk: {e}") + return False + + def clear_cue(self, cue_num: int) -> bool: + """ + Clear a cue slot + + Args: + cue_num: Cue number (1-based) + + Returns: + True if cleared successfully + """ + if cue_num in self.cues: + del self.cues[cue_num] + self.save_to_disk() + logger.info(f"Cleared cue {cue_num}") + return True + return False + + def clear_all(self) -> bool: + """ + Clear all cues + + Returns: + True if cleared successfully + """ + self.cues.clear() + self.save_to_disk() + logger.info("Cleared all cues") + return True diff --git a/beamcommander/etherdream.py b/beamcommander/etherdream.py new file mode 100644 index 0000000..bcdbb28 --- /dev/null +++ b/beamcommander/etherdream.py @@ -0,0 +1,335 @@ +""" +EtherDream DAC output driver for BeamCommander +Implements communication with EtherDream laser DAC hardware +""" +import socket +import struct +import logging +import threading +import time +from typing import List, Tuple, Optional + +logger = logging.getLogger(__name__) + + +class EtherDreamDAC: + """ + EtherDream DAC driver for laser output + + Protocol based on EtherDream specifications: + - Discovery via UDP broadcast on port 7654 + - Command/data via TCP on port 7765 + - Point format: X, Y, R, G, B, I (intensity), U (user data), flags + """ + + # EtherDream protocol constants + BROADCAST_PORT = 7654 + COMMAND_PORT = 7765 + + # Command bytes + CMD_PREPARE_STREAM = b'p' + CMD_BEGIN_STREAM = b'b' + CMD_POINT_RATE = b'q' + CMD_DATA = b'd' + CMD_STOP = b's' + CMD_PING = b'?' + + # Point rate (points per second) + DEFAULT_PPS = 30000 + + def __init__(self, dac_ip: Optional[str] = None, pps: int = DEFAULT_PPS): + """ + Initialize EtherDream DAC connection + + Args: + dac_ip: IP address of DAC (None = auto-discover) + pps: Points per second output rate + """ + self.dac_ip = dac_ip + self.pps = pps + self.sock: Optional[socket.socket] = None + self.connected = False + self.streaming = False + self._lock = threading.RLock() + + logger.info(f"Initializing EtherDream DAC (PPS: {pps})") + + # Try to discover and connect + if not self.dac_ip: + self.dac_ip = self.discover() + + if self.dac_ip: + self.connect() + + def discover(self, timeout: float = 2.0) -> Optional[str]: + """ + Discover EtherDream DAC on network via broadcast + + Args: + timeout: Discovery timeout in seconds + + Returns: + IP address of first discovered DAC, or None + """ + logger.info("Discovering EtherDream DAC...") + + try: + # Create UDP socket for broadcast listening + udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + udp_sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + udp_sock.settimeout(timeout) + udp_sock.bind(('', self.BROADCAST_PORT)) + + # Wait for broadcast message from DAC + try: + data, addr = udp_sock.recvfrom(1024) + dac_ip = addr[0] + logger.info(f"Discovered EtherDream DAC at {dac_ip}") + udp_sock.close() + return dac_ip + except socket.timeout: + logger.warning("No EtherDream DAC found on network") + udp_sock.close() + return None + except Exception as e: + logger.error(f"Error during DAC discovery: {e}") + return None + + def connect(self) -> bool: + """ + Connect to EtherDream DAC via TCP + + Returns: + True if connected successfully + """ + if not self.dac_ip: + logger.error("No DAC IP address available") + return False + + try: + with self._lock: + # Close existing connection if any + if self.sock: + try: + self.sock.close() + except: + pass + + # Create TCP connection + logger.info(f"Connecting to EtherDream DAC at {self.dac_ip}:{self.COMMAND_PORT}") + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.settimeout(5.0) + self.sock.connect((self.dac_ip, self.COMMAND_PORT)) + + # Wait for initial status message + status = self.sock.recv(20) + if len(status) >= 18: + self.connected = True + logger.info("Connected to EtherDream DAC") + + # Prepare for streaming + self.prepare_stream() + return True + else: + logger.error("Invalid status response from DAC") + return False + except Exception as e: + logger.error(f"Error connecting to DAC: {e}") + self.connected = False + return False + + def prepare_stream(self): + """Prepare DAC for streaming""" + if not self.connected or not self.sock: + return + + try: + with self._lock: + # Send prepare command + self.sock.send(self.CMD_PREPARE_STREAM) + + # Set point rate + rate_cmd = self.CMD_POINT_RATE + struct.pack(' bool: + """ + Send point data to DAC + + Args: + points: List of (x, y, r, g, b) tuples + x, y in range [-1..1] (will be converted to DAC coords) + r, g, b in range [0..255] + + Returns: + True if sent successfully + """ + if not self.connected or not self.streaming or not self.sock: + return False + + if not points or len(points) == 0: + return False + + try: + with self._lock: + # Convert points to EtherDream format + dac_points = [] + for x, y, r, g, b in points: + # Convert from normalized [-1..1] to DAC coords [0..65535] + # Center at 32768 + dac_x = int((x + 1.0) * 32767.5) + dac_y = int((y + 1.0) * 32767.5) + + # Clamp to valid range + dac_x = max(0, min(65535, dac_x)) + dac_y = max(0, min(65535, dac_y)) + + # Convert RGB [0..255] to DAC format [0..65535] + dac_r = int(r * 257) # 255 * 257 = 65535 + dac_g = int(g * 257) + dac_b = int(b * 257) + + # Intensity (use max of RGB) + dac_i = max(dac_r, dac_g, dac_b) + + # Pack point: x(2), y(2), r(2), g(2), b(2), i(2), u(2), flags(2) + point_data = struct.pack(' bool: + """Connect to DAC""" + with self._lock: + try: + if self.dac: + self.dac.stop() + + self.dac = EtherDreamDAC(self.dac_ip, self.pps) + return self.dac.connected + except Exception as e: + logger.error(f"Error creating DAC connection: {e}") + return False + + def send_frame(self, points: List[Tuple[float, float, int, int, int]]) -> bool: + """ + Send a frame of points to DAC + + Args: + points: List of (x, y, r, g, b) tuples + + Returns: + True if sent successfully + """ + if not self.enabled: + return False + + with self._lock: + # Reconnect if not connected + if not self.dac or not self.dac.connected: + if not self.connect(): + return False + + # Send points + success = self.dac.send_points(points) + + # Try to reconnect on failure + if not success and self.enabled: + logger.warning("DAC send failed, attempting reconnect...") + time.sleep(0.1) + self.connect() + + return success + + def stop(self): + """Stop DAC output""" + with self._lock: + if self.dac: + self.dac.stop() + self.dac = None + + def set_enabled(self, enabled: bool): + """Enable or disable DAC output""" + with self._lock: + self.enabled = enabled + if not enabled and self.dac: + self.dac.stop() + self.dac = None + elif enabled and not self.dac: + self.connect() diff --git a/beamcommander/osc_receiver.py b/beamcommander/osc_receiver.py new file mode 100644 index 0000000..5c061c6 --- /dev/null +++ b/beamcommander/osc_receiver.py @@ -0,0 +1,372 @@ +""" +OSC message receiver and handler for BeamCommander +""" +import logging +from pythonosc import dispatcher, osc_server +from pythonosc.udp_client import SimpleUDPClient +import threading +from typing import Optional, Callable, List, Any +from .app_state import AppState, Shape, ColorSel, Movement, BeamFx + +logger = logging.getLogger(__name__) + + +class OSCReceiver: + """ + OSC receiver that handles incoming OSC messages and updates application state + """ + + def __init__(self, state: AppState, port: int = 9000): + """ + Initialize OSC receiver + + Args: + state: Application state to update + port: UDP port to listen on (default: 9000) + """ + self.state = state + self.port = port + self.server: Optional[osc_server.ThreadingOSCUDPServer] = None + self.server_thread: Optional[threading.Thread] = None + self.running = False + + # Cue management + self.save_armed = False + self.flash_active = False + self.flash_prev_brightness = 0.0 + self.flash_release_ms = 150 + + # Callback for cue save/recall + self.on_cue_save: Optional[Callable[[int], None]] = None + self.on_cue_recall: Optional[Callable[[int], None]] = None + + def setup_dispatcher(self) -> dispatcher.Dispatcher: + """Create and configure the OSC dispatcher with all message handlers""" + disp = dispatcher.Dispatcher() + + # Shape generation + disp.map("/laser/shape", self._handle_shape) + + # Color control + disp.map("/laser/color", self._handle_color) + + # Brightness & visual effects + disp.map("/laser/brightness", self._handle_brightness) + disp.map("/laser/master/brightness", self._handle_brightness) + disp.map("/laser/dotted", self._handle_dotted) + disp.map("/laser/flicker", self._handle_flicker) + disp.map("/laser/scanrate", self._handle_flicker) # Alias + + # Positioning & scaling + disp.map("/laser/position", self._handle_position) + disp.map("/laser/position/x", self._handle_position_x) + disp.map("/laser/position/y", self._handle_position_y) + disp.map("/laser/shape/scale", self._handle_scale) + disp.map("/laser/rotation/speed", self._handle_rotation_speed) + + # Wave pattern controls + disp.map("/laser/wave/frequency", self._handle_wave_frequency) + disp.map("/laser/wave/amplitude", self._handle_wave_amplitude) + disp.map("/laser/wave/speed", self._handle_wave_speed) + + # Rainbow effects + disp.map("/laser/rainbow/amount", self._handle_rainbow_amount) + disp.map("/laser/rainbow/speed", self._handle_rainbow_speed) + disp.map("/laser/rainbow/blend", self._handle_rainbow_blend) + + # Movement patterns + disp.map("/move/mode", self._handle_move_mode) + disp.map("/move/size", self._handle_move_size) + disp.map("/move/speed", self._handle_move_speed) + + # Flash controls + disp.map("/flash", self._handle_flash) + disp.map("/flash/release_ms", self._handle_flash_release_ms) + + # Cue system + disp.map("/cue/save", self._handle_cue_save) + disp.map("/cue/*", self._handle_cue) + + # Blackout + disp.map("/blackout", self._handle_blackout) + + logger.info("OSC dispatcher configured with all message handlers") + return disp + + def start(self): + """Start the OSC server in a separate thread""" + if self.running: + logger.warning("OSC server already running") + return + + disp = self.setup_dispatcher() + self.server = osc_server.ThreadingOSCUDPServer( + ("0.0.0.0", self.port), disp + ) + + self.server_thread = threading.Thread( + target=self.server.serve_forever, + daemon=True + ) + self.running = True + self.server_thread.start() + logger.info(f"OSC server started on port {self.port}") + + def stop(self): + """Stop the OSC server""" + if not self.running: + return + + self.running = False + if self.server: + self.server.shutdown() + self.server = None + + if self.server_thread: + self.server_thread.join(timeout=2.0) + self.server_thread = None + + logger.info("OSC server stopped") + + # Handler methods + def _handle_shape(self, address: str, *args: Any): + """Handle /laser/shape message""" + if not args: + return + shape_str = str(args[0]).lower() + try: + self.state.current_shape = Shape(shape_str) + logger.debug(f"Shape set to: {shape_str}") + except ValueError: + logger.warning(f"Invalid shape: {shape_str}") + + def _handle_color(self, address: str, *args: Any): + """Handle /laser/color message""" + if not args: + return + + # Check if it's a named color or RGB values + if len(args) == 1 and isinstance(args[0], str): + # Named color + color_str = args[0].lower() + try: + self.state.current_color = ColorSel(color_str) + self.state.use_custom_color = False + logger.debug(f"Color set to: {color_str}") + except ValueError: + logger.warning(f"Invalid color: {color_str}") + elif len(args) >= 3: + # RGB values + r, g, b = float(args[0]), float(args[1]), float(args[2]) + # Normalize if values are > 1 (assume 0-255 range) + if r > 1.0 or g > 1.0 or b > 1.0: + r, g, b = r / 255.0, g / 255.0, b / 255.0 + self.state.custom_r = max(0.0, min(1.0, r)) + self.state.custom_g = max(0.0, min(1.0, g)) + self.state.custom_b = max(0.0, min(1.0, b)) + self.state.use_custom_color = True + logger.debug(f"Custom color set to RGB: ({r:.2f}, {g:.2f}, {b:.2f})") + + def _handle_brightness(self, address: str, *args: Any): + """Handle /laser/brightness or /laser/master/brightness""" + if not args: + return + value = float(args[0]) + # Normalize if value is > 1 (assume 0-255 range) + if value > 1.0: + value = value / 255.0 + self.state.master_brightness = max(0.0, min(1.0, value)) + logger.debug(f"Brightness set to: {self.state.master_brightness:.2f}") + + def _handle_dotted(self, address: str, *args: Any): + """Handle /laser/dotted message""" + if not args: + return + value = float(args[0]) + if value > 1.0: + value = value / 255.0 + self.state.dot_amount = max(0.0, min(1.0, value)) + logger.debug(f"Dotted amount set to: {self.state.dot_amount:.2f}") + + def _handle_flicker(self, address: str, *args: Any): + """Handle /laser/flicker or /laser/scanrate message""" + if not args: + return + hz = float(args[0]) + self.state.flicker_hz = max(0.0, hz) + logger.debug(f"Flicker rate set to: {hz} Hz") + + def _handle_position(self, address: str, *args: Any): + """Handle /laser/position x y message""" + if len(args) < 2: + return + x, y = float(args[0]), float(args[1]) + self.state.pos_norm_x = max(-1.0, min(1.0, x)) + self.state.pos_norm_y = max(-1.0, min(1.0, y)) + logger.debug(f"Position set to: ({x:.2f}, {y:.2f})") + + def _handle_position_x(self, address: str, *args: Any): + """Handle /laser/position/x message""" + if not args: + return + x = float(args[0]) + self.state.pos_norm_x = max(-1.0, min(1.0, x)) + + def _handle_position_y(self, address: str, *args: Any): + """Handle /laser/position/y message""" + if not args: + return + y = float(args[0]) + self.state.pos_norm_y = max(-1.0, min(1.0, y)) + + def _handle_scale(self, address: str, *args: Any): + """Handle /laser/shape/scale message""" + if not args: + return + scale = float(args[0]) + self.state.shape_scale = max(-1.0, min(1.0, scale)) + logger.debug(f"Shape scale set to: {scale:.2f}") + + def _handle_rotation_speed(self, address: str, *args: Any): + """Handle /laser/rotation/speed message""" + if not args: + return + speed = float(args[0]) + self.state.rotation_speed = speed + logger.debug(f"Rotation speed set to: {speed:.2f} rot/sec") + + def _handle_wave_frequency(self, address: str, *args: Any): + """Handle /laser/wave/frequency message""" + if not args: + return + freq = float(args[0]) + self.state.wave_frequency = max(0.1, freq) + + def _handle_wave_amplitude(self, address: str, *args: Any): + """Handle /laser/wave/amplitude message""" + if not args: + return + amp = float(args[0]) + self.state.wave_amplitude = max(0.0, min(1.0, amp)) + + def _handle_wave_speed(self, address: str, *args: Any): + """Handle /laser/wave/speed message""" + if not args: + return + speed = float(args[0]) + self.state.wave_speed = speed + + def _handle_rainbow_amount(self, address: str, *args: Any): + """Handle /laser/rainbow/amount message""" + if not args: + return + amount = float(args[0]) + self.state.rainbow_amount = max(0.0, min(1.0, amount)) + + def _handle_rainbow_speed(self, address: str, *args: Any): + """Handle /laser/rainbow/speed message""" + if not args: + return + speed = float(args[0]) + self.state.rainbow_speed = speed + + def _handle_rainbow_blend(self, address: str, *args: Any): + """Handle /laser/rainbow/blend message""" + if not args: + return + blend = float(args[0]) + self.state.rainbow_blend = max(0.0, min(1.0, blend)) + + def _handle_move_mode(self, address: str, *args: Any): + """Handle /move/mode message""" + if not args: + return + mode_str = str(args[0]).lower() + # Handle aliases + if mode_str in ["off", "none"]: + mode_str = "none" + elif mode_str in ["eight", "figure8", "8"]: + mode_str = "eight" + + try: + self.state.movement = Movement(mode_str) + logger.debug(f"Movement mode set to: {mode_str}") + except ValueError: + logger.warning(f"Invalid movement mode: {mode_str}") + + def _handle_move_size(self, address: str, *args: Any): + """Handle /move/size message""" + if not args: + return + size = float(args[0]) + if size > 1.0: + size = size / 255.0 + self.state.move_size = max(0.0, min(1.0, size)) + + def _handle_move_speed(self, address: str, *args: Any): + """Handle /move/speed message""" + if not args: + return + speed = float(args[0]) + self.state.move_speed = speed + + def _handle_flash(self, address: str, *args: Any): + """Handle /flash message""" + if not args: + return + value = int(args[0]) + if value == 1: + # Flash ON - save current brightness and set to max + self.flash_prev_brightness = self.state.master_brightness + self.state.master_brightness = 1.0 + self.flash_active = True + logger.debug("Flash activated") + else: + # Flash OFF - restore previous brightness + if self.flash_active: + self.state.master_brightness = self.flash_prev_brightness + self.flash_active = False + logger.debug("Flash released") + + def _handle_flash_release_ms(self, address: str, *args: Any): + """Handle /flash/release_ms message""" + if not args: + return + ms = int(args[0]) + self.flash_release_ms = max(0, min(60000, ms)) + + def _handle_cue_save(self, address: str, *args: Any): + """Handle /cue/save message to arm save mode""" + self.save_armed = True + logger.info("Cue save mode armed") + + def _handle_cue(self, address: str, *args: Any): + """Handle /cue/N message for save or recall""" + # Extract cue number from address (e.g., /cue/5 -> 5) + try: + parts = address.split('/') + if len(parts) < 3: + return + cue_num = int(parts[2]) + + if self.save_armed: + # Save current state to cue + if self.on_cue_save: + self.on_cue_save(cue_num) + self.save_armed = False + logger.info(f"Saved cue {cue_num}") + else: + # Recall cue + if self.on_cue_recall: + self.on_cue_recall(cue_num) + logger.info(f"Recalled cue {cue_num}") + except (ValueError, IndexError) as e: + logger.warning(f"Invalid cue address: {address}") + + def _handle_blackout(self, address: str, *args: Any): + """Handle /blackout message""" + if not args: + return + value = int(args[0]) + self.state.blackout = (value != 0) + logger.debug(f"Blackout: {self.state.blackout}") diff --git a/beamcommander/server.py b/beamcommander/server.py new file mode 100644 index 0000000..8ffaeb6 --- /dev/null +++ b/beamcommander/server.py @@ -0,0 +1,321 @@ +""" +Main BeamCommander server application +""" +import logging +import time +import threading +from typing import Optional +from flask import Flask, render_template, jsonify, send_from_directory +from flask_cors import CORS +import os +import sys + +from .app_state import AppState +from .osc_receiver import OSCReceiver +from .shapes import ShapeGenerator +from .cue_manager import CueManager +from .etherdream import DACOutputManager + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +class BeamCommanderServer: + """ + Main server application for BeamCommander + """ + + def __init__(self, osc_port: int = 9000, http_port: int = 8080, enable_dac: bool = False, dac_ip: Optional[str] = None): + """ + Initialize BeamCommander server + + Args: + osc_port: UDP port for OSC messages (default: 9000) + http_port: HTTP port for web interface (default: 8080) + enable_dac: Enable EtherDream DAC output (default: False) + dac_ip: IP address of DAC (None = auto-discover) + """ + self.osc_port = osc_port + self.http_port = http_port + + # Initialize components + self.state = AppState() + self.shape_generator = ShapeGenerator() + self.cue_manager = CueManager(self.state) + + # Initialize OSC receiver + self.osc_receiver = OSCReceiver(self.state, osc_port) + self.osc_receiver.on_cue_save = self.cue_manager.save_cue + self.osc_receiver.on_cue_recall = self.cue_manager.recall_cue + + # Initialize DAC output manager + self.dac_manager = DACOutputManager(dac_ip=dac_ip, enable=enable_dac) + self.enable_dac = enable_dac + + # DAC output thread + self.dac_thread: Optional[threading.Thread] = None + self.dac_running = False + + # Flask app for web interface + self.app = Flask(__name__, + static_folder='static', + template_folder='templates') + CORS(self.app) + self._setup_routes() + + # Animation state + self.running = False + self.start_time = time.time() + + # Load cues from disk + self.cue_manager.load_from_disk() + + logger.info("BeamCommander server initialized") + if enable_dac: + logger.info("EtherDream DAC output ENABLED") + + def _setup_routes(self): + """Setup Flask routes for web interface""" + + @self.app.route('/') + def index(): + """Serve main web interface""" + return render_template('index.html') + + @self.app.route('/api/state') + def get_state(): + """Get current application state""" + return jsonify(self.state.to_dict()) + + @self.app.route('/api/shapes') + def get_shapes(): + """Get current shape points""" + current_time = time.time() - self.start_time + points = self.shape_generator.generate_shape(self.state, current_time) + return jsonify({ + 'points': points, + 'blackout': self.state.blackout + }) + + @self.app.route('/api/cues') + def get_cues(): + """Get all cues""" + cues_data = {} + for num, cue in self.cue_manager.cues.items(): + if cue.populated: + cues_data[num] = cue.to_dict() + return jsonify(cues_data) + + @self.app.route('/api/status') + def get_status(): + """Get server status""" + return jsonify({ + 'running': self.running, + 'osc_port': self.osc_port, + 'uptime': time.time() - self.start_time + }) + + @self.app.route('/api/osc', methods=['POST']) + def send_osc(): + """Send OSC command from web UI""" + from flask import request + data = request.get_json() + if not data or 'address' not in data: + return jsonify({'error': 'Invalid request'}), 400 + + address = data['address'] + args = data.get('args', []) + + # Simulate OSC message by calling the appropriate handler + try: + # Find the handler for this address + if address == '/laser/shape' and args: + self.osc_receiver._handle_shape(address, *args) + elif address == '/laser/color': + self.osc_receiver._handle_color(address, *args) + elif address == '/laser/brightness' and args: + self.osc_receiver._handle_brightness(address, *args) + elif address == '/laser/dotted' and args: + self.osc_receiver._handle_dotted(address, *args) + elif address == '/laser/flicker' and args: + self.osc_receiver._handle_flicker(address, *args) + elif address == '/laser/position': + self.osc_receiver._handle_position(address, *args) + elif address == '/laser/position/x' and args: + self.osc_receiver._handle_position_x(address, *args) + elif address == '/laser/position/y' and args: + self.osc_receiver._handle_position_y(address, *args) + elif address == '/laser/shape/scale' and args: + self.osc_receiver._handle_scale(address, *args) + elif address == '/laser/rotation/speed' and args: + self.osc_receiver._handle_rotation_speed(address, *args) + elif address == '/laser/wave/frequency' and args: + self.osc_receiver._handle_wave_frequency(address, *args) + elif address == '/laser/wave/amplitude' and args: + self.osc_receiver._handle_wave_amplitude(address, *args) + elif address == '/laser/wave/speed' and args: + self.osc_receiver._handle_wave_speed(address, *args) + elif address == '/laser/rainbow/amount' and args: + self.osc_receiver._handle_rainbow_amount(address, *args) + elif address == '/laser/rainbow/speed' and args: + self.osc_receiver._handle_rainbow_speed(address, *args) + elif address == '/laser/rainbow/blend' and args: + self.osc_receiver._handle_rainbow_blend(address, *args) + elif address == '/move/mode' and args: + self.osc_receiver._handle_move_mode(address, *args) + elif address == '/move/size' and args: + self.osc_receiver._handle_move_size(address, *args) + elif address == '/move/speed' and args: + self.osc_receiver._handle_move_speed(address, *args) + elif address == '/flash' and args: + self.osc_receiver._handle_flash(address, *args) + elif address == '/blackout' and args: + self.osc_receiver._handle_blackout(address, *args) + else: + return jsonify({'error': 'Unknown OSC address'}), 400 + + return jsonify({'success': True}) + except Exception as e: + logger.error(f"Error handling OSC command: {e}") + return jsonify({'error': str(e)}), 500 + + def _dac_output_loop(self): + """DAC output thread - sends points to laser hardware at ~30 FPS""" + logger.info("DAC output thread started") + frame_time = 1.0 / 30.0 # 30 FPS target + + while self.dac_running: + try: + start = time.time() + + # Generate current frame + current_time = time.time() - self.start_time + points = self.shape_generator.generate_shape(self.state, current_time) + + # Send to DAC if not in blackout + if not self.state.blackout and points: + self.dac_manager.send_frame(points) + + # Maintain frame rate + elapsed = time.time() - start + sleep_time = max(0, frame_time - elapsed) + if sleep_time > 0: + time.sleep(sleep_time) + except Exception as e: + logger.error(f"Error in DAC output loop: {e}") + time.sleep(0.1) + + logger.info("DAC output thread stopped") + + def start(self): + """Start the BeamCommander server""" + if self.running: + logger.warning("Server already running") + return + + logger.info("Starting BeamCommander server...") + self.running = True + self.start_time = time.time() + + # Start OSC receiver + self.osc_receiver.start() + logger.info(f"OSC receiver listening on port {self.osc_port}") + + # Start DAC output thread if enabled + if self.enable_dac: + self.dac_running = True + self.dac_thread = threading.Thread(target=self._dac_output_loop, daemon=True) + self.dac_thread.start() + logger.info("EtherDream DAC output thread started") + + # Start Flask app + logger.info(f"Starting web interface on http://0.0.0.0:{self.http_port}") + logger.info("=" * 60) + logger.info("BeamCommander is ready!") + logger.info(f" Web Interface: http://localhost:{self.http_port}") + logger.info(f" OSC Port: {self.osc_port}") + if self.enable_dac: + logger.info(f" DAC Output: ENABLED") + logger.info("=" * 60) + + # Run Flask in main thread (disable request logging) + import logging as flask_logging + flask_log = flask_logging.getLogger('werkzeug') + flask_log.setLevel(flask_logging.ERROR) # Only show errors, not every request + + self.app.run(host='0.0.0.0', port=self.http_port, debug=False, threaded=True) + + def stop(self): + """Stop the BeamCommander server""" + if not self.running: + return + + logger.info("Stopping BeamCommander server...") + self.running = False + + # Stop DAC output thread + if self.dac_running: + self.dac_running = False + if self.dac_thread: + self.dac_thread.join(timeout=2.0) + + # Stop DAC manager + if self.dac_manager: + self.dac_manager.stop() + + # Save cues before stopping + self.cue_manager.save_to_disk() + + # Stop OSC receiver + self.osc_receiver.stop() + + logger.info("BeamCommander server stopped") + + +def main(): + """Main entry point""" + import argparse + + parser = argparse.ArgumentParser(description='BeamCommander Laser Control Server') + parser.add_argument('--osc-port', type=int, default=9000, + help='OSC receiver port (default: 9000)') + parser.add_argument('--http-port', type=int, default=8080, + help='HTTP server port for web interface (default: 8080)') + parser.add_argument('--log-level', default='INFO', + choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'], + help='Logging level (default: INFO)') + parser.add_argument('--enable-dac', action='store_true', + help='Enable EtherDream DAC output') + parser.add_argument('--dac-ip', type=str, default=None, + help='EtherDream DAC IP address (default: auto-discover)') + + args = parser.parse_args() + + # Set logging level + logging.getLogger().setLevel(getattr(logging, args.log_level)) + + # Create and start server + server = BeamCommanderServer( + osc_port=args.osc_port, + http_port=args.http_port, + enable_dac=args.enable_dac, + dac_ip=args.dac_ip + ) + + try: + server.start() + except KeyboardInterrupt: + logger.info("\nReceived interrupt signal") + server.stop() + except Exception as e: + logger.error(f"Server error: {e}", exc_info=True) + server.stop() + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/beamcommander/shapes.py b/beamcommander/shapes.py new file mode 100644 index 0000000..606271c --- /dev/null +++ b/beamcommander/shapes.py @@ -0,0 +1,246 @@ +""" +Shape generation for laser output +""" +import math +from typing import List, Tuple +from .app_state import Shape, AppState + + +class ShapeGenerator: + """ + Generates point data for various laser shapes + """ + + def __init__(self, width: int = 800, height: int = 600): + """ + Initialize shape generator + + Args: + width: Canvas width in points + height: Canvas height in points + """ + self.width = width + self.height = height + self.center_x = width / 2 + self.center_y = height / 2 + + def generate_shape(self, state: AppState, time_sec: float) -> List[Tuple[float, float, int, int, int]]: + """ + Generate point list for current shape with position, color + + Args: + state: Current application state + time_sec: Current time in seconds for animations + + Returns: + List of tuples (x, y, r, g, b) representing points + """ + # Calculate scale factor from normalized scale + # Map [-1..1] to a geometric scale factor + scale_norm = state.shape_scale + if scale_norm >= 0: + scale_factor = 1.0 + scale_norm * 2.0 # [1.0 .. 3.0] + else: + scale_factor = 1.0 + scale_norm * 0.7 # [0.3 .. 1.0] + + # Calculate rotation angle + rotation_angle = state.rotation_speed * time_sec * 2 * math.pi + + # Calculate movement offset + move_offset_x, move_offset_y = self._calculate_movement(state, time_sec) + + # Generate base shape points + if state.current_shape == Shape.CIRCLE: + points = self._generate_circle(scale_factor) + elif state.current_shape == Shape.LINE: + points = self._generate_line(scale_factor) + elif state.current_shape == Shape.TRIANGLE: + points = self._generate_triangle(scale_factor) + elif state.current_shape == Shape.SQUARE: + points = self._generate_square(scale_factor) + elif state.current_shape == Shape.WAVE: + points = self._generate_wave(state, time_sec, scale_factor) + elif state.current_shape == Shape.STATIC_WAVE: + points = self._generate_static_wave(state, time_sec, scale_factor) + else: + points = self._generate_circle(scale_factor) + + # Apply rotation + if rotation_angle != 0: + points = self._rotate_points(points, rotation_angle) + + # Apply position offset (manual + movement) + pos_x = state.pos_norm_x * self.width * 0.5 + move_offset_x + pos_y = state.pos_norm_y * self.height * 0.5 + move_offset_y + + # Apply axis inversion + if state.invert_x: + pos_x = -pos_x + + # Calculate rainbow hue for color animation + rainbow_phase = (state.rainbow_speed * time_sec) % 1.0 + + # Generate colored points + colored_points = [] + num_points = len(points) + for i, (x, y) in enumerate(points): + # Calculate position-dependent rainbow hue + hue = (rainbow_phase + i / num_points * state.rainbow_amount) % 1.0 if state.rainbow_amount > 0 else -1.0 + + # Get color + r, g, b = state.to_color_rgb(hue) + + # Apply master brightness + r = int(r * state.master_brightness) + g = int(g * state.master_brightness) + b = int(b * state.master_brightness) + + # Translate to final position + final_x = x + self.center_x + pos_x + final_y = y + self.center_y + pos_y + + colored_points.append((final_x, final_y, r, g, b)) + + # Apply dot amount (reduce points for dotted effect) + if state.dot_amount < 1.0: + # Keep only a fraction of points + keep_ratio = max(0.01, state.dot_amount) + step = int(1.0 / keep_ratio) + colored_points = colored_points[::step] + + return colored_points + + def _calculate_movement(self, state: AppState, time_sec: float) -> Tuple[float, float]: + """Calculate movement offset based on movement mode""" + if state.movement.value == "none": + return (0.0, 0.0) + + phase = state.move_speed * time_sec * 2 * math.pi + amplitude = state.move_size * min(self.width, self.height) * 0.3 + + if state.movement.value == "circle": + x = amplitude * math.cos(phase) + y = amplitude * math.sin(phase) + elif state.movement.value == "pan": + x = amplitude * math.sin(phase) + y = 0.0 + elif state.movement.value == "tilt": + x = 0.0 + y = amplitude * math.sin(phase) + elif state.movement.value == "eight": + # Figure-8 pattern (Lissajous curve) + x = amplitude * math.sin(phase) + y = amplitude * math.sin(2 * phase) + elif state.movement.value == "random": + # Simple pseudo-random using sine waves with different frequencies + x = amplitude * (math.sin(phase * 1.3) + math.sin(phase * 2.7)) / 2 + y = amplitude * (math.sin(phase * 1.7) + math.sin(phase * 3.1)) / 2 + else: + x, y = 0.0, 0.0 + + return (x, y) + + def _rotate_points(self, points: List[Tuple[float, float]], angle: float) -> List[Tuple[float, float]]: + """Rotate points around origin""" + cos_a = math.cos(angle) + sin_a = math.sin(angle) + rotated = [] + for x, y in points: + new_x = x * cos_a - y * sin_a + new_y = x * sin_a + y * cos_a + rotated.append((new_x, new_y)) + return rotated + + def _generate_circle(self, scale: float, num_points: int = 100) -> List[Tuple[float, float]]: + """Generate circle points""" + radius = min(self.width, self.height) * 0.3 * scale + points = [] + for i in range(num_points): + angle = (i / num_points) * 2 * math.pi + x = radius * math.cos(angle) + y = radius * math.sin(angle) + points.append((x, y)) + return points + + def _generate_line(self, scale: float, num_points: int = 50) -> List[Tuple[float, float]]: + """Generate line points""" + length = min(self.width, self.height) * 0.6 * scale + points = [] + for i in range(num_points): + t = (i / (num_points - 1)) - 0.5 # [-0.5 to 0.5] + x = t * length + y = 0 + points.append((x, y)) + return points + + def _generate_triangle(self, scale: float, num_points: int = 75) -> List[Tuple[float, float]]: + """Generate triangle points""" + size = min(self.width, self.height) * 0.3 * scale + points = [] + # Three vertices of equilateral triangle + vertices = [ + (0, -size), + (-size * 0.866, size * 0.5), + (size * 0.866, size * 0.5), + ] + # Draw lines between vertices + points_per_edge = num_points // 3 + for i in range(3): + v1 = vertices[i] + v2 = vertices[(i + 1) % 3] + for j in range(points_per_edge): + t = j / points_per_edge + x = v1[0] * (1 - t) + v2[0] * t + y = v1[1] * (1 - t) + v2[1] * t + points.append((x, y)) + return points + + def _generate_square(self, scale: float, num_points: int = 80) -> List[Tuple[float, float]]: + """Generate square points""" + size = min(self.width, self.height) * 0.3 * scale + points = [] + # Four vertices + vertices = [ + (-size, -size), + (size, -size), + (size, size), + (-size, size), + ] + # Draw lines between vertices + points_per_edge = num_points // 4 + for i in range(4): + v1 = vertices[i] + v2 = vertices[(i + 1) % 4] + for j in range(points_per_edge): + t = j / points_per_edge + x = v1[0] * (1 - t) + v2[0] * t + y = v1[1] * (1 - t) + v2[1] * t + points.append((x, y)) + return points + + def _generate_wave(self, state: AppState, time_sec: float, scale: float, num_points: int = 100) -> List[Tuple[float, float]]: + """Generate animated wave points""" + width = min(self.width, self.height) * 0.6 * scale + amplitude = width * state.wave_amplitude + phase = state.wave_speed * time_sec * 2 * math.pi + + points = [] + for i in range(num_points): + t = (i / (num_points - 1)) - 0.5 # [-0.5 to 0.5] + x = t * width + y = amplitude * math.sin(state.wave_frequency * 2 * math.pi * t + phase) + points.append((x, y)) + return points + + def _generate_static_wave(self, state: AppState, time_sec: float, scale: float, num_points: int = 100) -> List[Tuple[float, float]]: + """Generate static wave points (phase doesn't animate)""" + width = min(self.width, self.height) * 0.6 * scale + amplitude = width * state.wave_amplitude + + points = [] + for i in range(num_points): + t = (i / (num_points - 1)) - 0.5 # [-0.5 to 0.5] + x = t * width + y = amplitude * math.sin(state.wave_frequency * 2 * math.pi * t) + points.append((x, y)) + return points diff --git a/beamcommander/static/app.js b/beamcommander/static/app.js new file mode 100644 index 0000000..b313fbf --- /dev/null +++ b/beamcommander/static/app.js @@ -0,0 +1,337 @@ +// BeamCommander Web Interface JavaScript + +class BeamCommanderUI { + constructor() { + this.canvas = document.getElementById('laser-canvas'); + this.ctx = this.canvas.getContext('2d'); + this.apiBase = ''; + this.updateInterval = 50; // 20 FPS + this.running = true; + + this.setupEventListeners(); + this.startAnimation(); + this.updateStatus(); + } + + setupEventListeners() { + // Shape buttons + document.querySelectorAll('.shape-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const shape = e.target.dataset.shape; + this.sendOSC('/laser/shape', [shape]); + this.setActiveButton('.shape-btn', e.target); + }); + }); + + // Color buttons + document.querySelectorAll('.color-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const color = e.target.dataset.color; + this.sendOSC('/laser/color', [color]); + this.setActiveButton('.color-btn', e.target); + }); + }); + + // Movement buttons + document.querySelectorAll('.move-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const move = e.target.dataset.move; + this.sendOSC('/move/mode', [move]); + this.setActiveButton('.move-btn', e.target); + }); + }); + + // Brightness slider + document.getElementById('brightness').addEventListener('input', (e) => { + const value = e.target.value / 100; + document.getElementById('brightness-val').textContent = e.target.value + '%'; + this.sendOSC('/laser/brightness', [value]); + }); + + // Dot amount slider + document.getElementById('dot-amount').addEventListener('input', (e) => { + const value = e.target.value / 100; + document.getElementById('dot-val').textContent = e.target.value + '%'; + this.sendOSC('/laser/dotted', [value]); + }); + + // Rainbow amount + document.getElementById('rainbow-amount').addEventListener('input', (e) => { + const value = e.target.value / 100; + document.getElementById('rainbow-amount-val').textContent = e.target.value + '%'; + this.sendOSC('/laser/rainbow/amount', [value]); + }); + + // Rainbow speed + document.getElementById('rainbow-speed').addEventListener('input', (e) => { + const value = e.target.value / 100; + document.getElementById('rainbow-speed-val').textContent = value.toFixed(2); + this.sendOSC('/laser/rainbow/speed', [value]); + }); + + // Movement size + document.getElementById('move-size').addEventListener('input', (e) => { + const value = e.target.value / 100; + document.getElementById('move-size-val').textContent = e.target.value + '%'; + this.sendOSC('/move/size', [value]); + }); + + // Movement speed + document.getElementById('move-speed').addEventListener('input', (e) => { + const value = e.target.value / 100; + document.getElementById('move-speed-val').textContent = value.toFixed(2); + this.sendOSC('/move/speed', [value]); + }); + + // Scale + document.getElementById('scale').addEventListener('input', (e) => { + const value = e.target.value / 100; + document.getElementById('scale-val').textContent = value.toFixed(2); + this.sendOSC('/laser/shape/scale', [value]); + }); + + // Rotation speed + document.getElementById('rotation-speed').addEventListener('input', (e) => { + const value = e.target.value / 100; + document.getElementById('rotation-val').textContent = value.toFixed(2); + this.sendOSC('/laser/rotation/speed', [value]); + }); + + // Position X + document.getElementById('pos-x').addEventListener('input', (e) => { + const value = e.target.value / 100; + document.getElementById('pos-x-val').textContent = value.toFixed(2); + this.sendOSC('/laser/position/x', [value]); + }); + + // Position Y + document.getElementById('pos-y').addEventListener('input', (e) => { + const value = e.target.value / 100; + document.getElementById('pos-y-val').textContent = value.toFixed(2); + this.sendOSC('/laser/position/y', [value]); + }); + + // Blackout button + document.getElementById('blackout-btn').addEventListener('click', (e) => { + const isActive = e.target.classList.toggle('active'); + this.sendOSC('/blackout', [isActive ? 1 : 0]); + }); + } + + setActiveButton(selector, activeBtn) { + document.querySelectorAll(selector).forEach(btn => { + btn.classList.remove('active'); + }); + activeBtn.classList.add('active'); + } + + async sendOSC(address, args) { + console.log('OSC:', address, args); + + try { + const response = await fetch(`${this.apiBase}/api/osc`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + address: address, + args: args + }) + }); + + if (!response.ok) { + console.error('Failed to send OSC:', response.statusText); + } + } catch (error) { + console.error('Error sending OSC:', error); + } + } + + async fetchShapes() { + try { + const response = await fetch(`${this.apiBase}/api/shapes`); + const data = await response.json(); + return data; + } catch (error) { + console.error('Error fetching shapes:', error); + return { points: [], blackout: false }; + } + } + + async fetchState() { + try { + const response = await fetch(`${this.apiBase}/api/state`); + const data = await response.json(); + return data; + } catch (error) { + console.error('Error fetching state:', error); + return null; + } + } + + drawShapes(data) { + // Clear canvas with dark background + this.ctx.fillStyle = '#000000'; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + if (data.blackout || !data.points || data.points.length === 0) { + return; + } + + // Realistic 3D perspective - single beam coming at you + // No multiple rings - just one shape with depth perception through blur/glow + + const centerX = this.canvas.width / 2; + const centerY = this.canvas.height / 2; + + this.ctx.lineCap = 'round'; + this.ctx.lineJoin = 'round'; + + // Create perspective effect with motion blur trail + // Draw fading trail from center (far) to current position (near) + const trailSteps = 8; // Smooth motion blur steps + + for (let i = trailSteps; i >= 0; i--) { + const trailFactor = i / trailSteps; // 0 = current position, 1 = far back + + // Scale toward center for depth (further away = smaller, toward center) + const scale = 1.0 - (trailFactor * 0.4); // 0.6 to 1.0 + + // Fade trail exponentially - more recent positions are brighter + const fadeAlpha = Math.pow(1 - trailFactor, 2); // Exponential fade + + // Transform points toward center for perspective + const transformed = data.points.map(([x, y, r, g, b]) => { + const dx = x - centerX; + const dy = y - centerY; + + // Apply perspective scaling + const newX = centerX + dx * scale; + const newY = centerY + dy * scale; + + return [newX, newY, r, g, b]; + }); + + // Draw this trail step with appropriate fade + // Outer glow for depth + if (i <= 2) { + // Most recent positions get intense glow + this.drawPass(transformed, 15, 0.08 * fadeAlpha); + this.drawPass(transformed, 10, 0.15 * fadeAlpha); + } + this.drawPass(transformed, 6, 0.3 * fadeAlpha); + this.drawPass(transformed, 3, 0.6 * fadeAlpha); + this.drawPass(transformed, 1.5, 1.0 * fadeAlpha); + } + + // Draw the main shape at current position (brightest) + // Intense multi-pass glow for laser beam intensity + this.drawPass(data.points, 20, 0.05); // Huge outer glow + this.drawPass(data.points, 12, 0.12); // Outer glow + this.drawPass(data.points, 8, 0.25); // Mid glow + this.drawPass(data.points, 5, 0.5); // Inner glow + this.drawPass(data.points, 3, 0.8); // Bright core + this.drawPass(data.points, 1.5, 1.0); // Pure beam + + // Add vertex sparkle on main shape + data.points.forEach(([x, y, r, g, b]) => { + const gradient = this.ctx.createRadialGradient(x, y, 0, x, y, 6); + gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 1.0)`); + gradient.addColorStop(0.4, `rgba(${r}, ${g}, ${b}, 0.6)`); + gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0.0)`); + + this.ctx.fillStyle = gradient; + this.ctx.beginPath(); + this.ctx.arc(x, y, 6, 0, Math.PI * 2); + this.ctx.fill(); + }); + + // Subtle center bloom for depth atmosphere (much more subtle than before) + const centerGlow = this.ctx.createRadialGradient( + centerX, centerY, 0, + centerX, centerY, Math.min(this.canvas.width, this.canvas.height) * 0.25 + ); + + if (data.points.length > 0) { + const [_, __, r, g, b] = data.points[0]; + centerGlow.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0.08)`); + centerGlow.addColorStop(0.6, `rgba(${r}, ${g}, ${b}, 0.02)`); + centerGlow.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0.0)`); + + this.ctx.fillStyle = centerGlow; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + } + } + + drawPass(points, lineWidth, alpha) { + // Draw lines with specified width and alpha + for (let i = 0; i < points.length - 1; i++) { + const [x1, y1, r1, g1, b1] = points[i]; + const [x2, y2, r2, g2, b2] = points[i + 1]; + + // Create gradient for smooth color transitions + const gradient = this.ctx.createLinearGradient(x1, y1, x2, y2); + gradient.addColorStop(0, `rgba(${r1}, ${g1}, ${b1}, ${alpha})`); + gradient.addColorStop(1, `rgba(${r2}, ${g2}, ${b2}, ${alpha})`); + + this.ctx.strokeStyle = gradient; + this.ctx.lineWidth = lineWidth; + this.ctx.beginPath(); + this.ctx.moveTo(x1, y1); + this.ctx.lineTo(x2, y2); + this.ctx.stroke(); + } + } + + async updateCanvas() { + if (!this.running) return; + + const data = await this.fetchShapes(); + this.drawShapes(data); + + setTimeout(() => this.updateCanvas(), this.updateInterval); + } + + async updateStatus() { + try { + const response = await fetch(`${this.apiBase}/api/status`); + const data = await response.json(); + + document.getElementById('osc-port').textContent = data.osc_port; + + // Update uptime + const uptime = Math.floor(data.uptime); + const hours = Math.floor(uptime / 3600); + const minutes = Math.floor((uptime % 3600) / 60); + const seconds = uptime % 60; + document.getElementById('uptime').textContent = + `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + } catch (error) { + console.error('Error updating status:', error); + } + + // Update every second + setTimeout(() => this.updateStatus(), 1000); + } + + async updateStateDisplay() { + const state = await this.fetchState(); + if (state) { + document.getElementById('current-shape').textContent = state.current_shape; + document.getElementById('current-color').textContent = state.current_color; + } + + setTimeout(() => this.updateStateDisplay(), 1000); + } + + startAnimation() { + this.updateCanvas(); + this.updateStateDisplay(); + } +} + +// Initialize UI when page loads +document.addEventListener('DOMContentLoaded', () => { + new BeamCommanderUI(); +}); diff --git a/beamcommander/static/app_webgl.js b/beamcommander/static/app_webgl.js new file mode 100644 index 0000000..8f979c6 --- /dev/null +++ b/beamcommander/static/app_webgl.js @@ -0,0 +1,379 @@ +// BeamCommander WebGL 3D Interface with Three.js +// Realistic 3D laser beam visualization with lighting, fog, and reflections + +class BeamCommander3D { + constructor() { + this.container = document.getElementById('laser-canvas'); + this.apiBase = ''; + this.updateInterval = 50; // 20 FPS + this.running = true; + + this.initThreeJS(); + this.setupEventListeners(); + this.startAnimation(); + this.updateStatus(); + } + + initThreeJS() { + // Create scene + this.scene = new THREE.Scene(); + this.scene.fog = new THREE.FogExp2(0x000000, 0.002); // Atmospheric fog + + // Setup camera (perspective for 3D depth) + const aspect = this.container.clientWidth / this.container.clientHeight; + this.camera = new THREE.PerspectiveCamera(75, aspect, 0.1, 1000); + this.camera.position.set(0, 0, 15); // Looking into the scene + this.camera.lookAt(0, 0, 0); + + // Create renderer with antialiasing + this.renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: true + }); + this.renderer.setSize(this.container.clientWidth, this.container.clientHeight); + this.renderer.setPixelRatio(window.devicePixelRatio); + this.container.appendChild(this.renderer.domElement); + + // Setup lighting + this.setupLighting(); + + // Initialize laser beam geometry + this.laserGroup = new THREE.Group(); + this.scene.add(this.laserGroup); + + // Handle window resize + window.addEventListener('resize', () => this.onWindowResize()); + } + + setupLighting() { + // Ambient light for base visibility + const ambient = new THREE.AmbientLight(0x222222); + this.scene.add(ambient); + + // Point light at camera position (represents viewer's perspective) + this.viewLight = new THREE.PointLight(0xffffff, 0.3, 100); + this.viewLight.position.copy(this.camera.position); + this.scene.add(this.viewLight); + + // Dynamic lights that will follow laser beams + this.beamLights = []; + for (let i = 0; i < 3; i++) { + const light = new THREE.PointLight(0x0000ff, 2, 20); + light.position.set(0, 0, 0); + this.scene.add(light); + this.beamLights.push(light); + } + } + + createLaserBeam(points, color) { + // Clear previous geometry + while (this.laserGroup.children.length > 0) { + this.laserGroup.remove(this.laserGroup.children[0]); + } + + if (!points || points.length === 0) return; + + // Convert 2D points to 3D with depth + const vertices = []; + const colors = []; + + for (let i = 0; i < points.length; i++) { + const [x, y, r, g, b] = points[i]; + + // Convert from canvas coordinates to 3D space + const x3d = (x - this.container.clientWidth / 2) / 50; + const y3d = -(y - this.container.clientHeight / 2) / 50; + const z3d = 0; // Main beam at z=0 + + vertices.push(x3d, y3d, z3d); + colors.push(r / 255, g / 255, b / 255); + } + + // Create core beam (thin, bright) + this.createBeamPass(vertices, colors, 0.15, 1.0, true); + + // Create glow layers (wider, transparent) + this.createBeamPass(vertices, colors, 0.4, 0.4, false); + this.createBeamPass(vertices, colors, 0.8, 0.2, false); + this.createBeamPass(vertices, colors, 1.5, 0.1, false); + + // Add volumetric light effect + this.addVolumetricEffect(vertices, colors); + + // Update dynamic lights to match beam + this.updateBeamLights(vertices, colors); + } + + createBeamPass(vertices, colors, lineWidth, opacity, additive) { + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)); + geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3)); + + const material = new THREE.LineBasicMaterial({ + vertexColors: true, + linewidth: lineWidth, + opacity: opacity, + transparent: true, + blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending + }); + + const line = new THREE.Line(geometry, material); + this.laserGroup.add(line); + } + + addVolumetricEffect(vertices, colors) { + // Create glowing spheres at each vertex for volumetric light effect + for (let i = 0; i < vertices.length; i += 3) { + const x = vertices[i]; + const y = vertices[i + 1]; + const z = vertices[i + 2]; + const r = colors[i]; + const g = colors[i + 1]; + const b = colors[i + 2]; + + // Create small sphere with emissive material + const geometry = new THREE.SphereGeometry(0.2, 8, 8); + const material = new THREE.MeshBasicMaterial({ + color: new THREE.Color(r, g, b), + transparent: true, + opacity: 0.6, + blending: THREE.AdditiveBlending + }); + + const sphere = new THREE.Mesh(geometry, material); + sphere.position.set(x, y, z); + this.laserGroup.add(sphere); + } + } + + updateBeamLights(vertices, colors) { + // Position lights at key points along the beam + const positions = [0, Math.floor(vertices.length / 6), Math.floor(vertices.length / 3)]; + + positions.forEach((idx, i) => { + if (idx < vertices.length && this.beamLights[i]) { + const realIdx = idx * 3; + this.beamLights[i].position.set( + vertices[realIdx], + vertices[realIdx + 1], + vertices[realIdx + 2] + ); + + // Set light color to match beam + if (colors[realIdx] !== undefined) { + this.beamLights[i].color.setRGB( + colors[realIdx], + colors[realIdx + 1], + colors[realIdx + 2] + ); + } + } + }); + } + + onWindowResize() { + const width = this.container.clientWidth; + const height = this.container.clientHeight; + + this.camera.aspect = width / height; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(width, height); + } + + render() { + // Subtle camera animation for depth perception + const time = Date.now() * 0.0001; + this.camera.position.x = Math.sin(time) * 0.5; + this.camera.position.y = Math.cos(time * 0.7) * 0.3; + this.camera.lookAt(0, 0, 0); + + // Rotate laser group slightly for 3D effect + this.laserGroup.rotation.z = Math.sin(time * 0.5) * 0.02; + + this.renderer.render(this.scene, this.camera); + } + + setupEventListeners() { + // Shape buttons + document.querySelectorAll('.shape-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const shape = e.target.dataset.shape; + this.sendOSC('/laser/shape', [shape]); + this.setActiveButton('.shape-btn', e.target); + }); + }); + + // Color buttons + document.querySelectorAll('.color-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const color = e.target.dataset.color; + this.sendOSC('/laser/color', [color]); + this.setActiveButton('.color-btn', e.target); + }); + }); + + // Movement buttons + document.querySelectorAll('.move-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const move = e.target.dataset.move; + this.sendOSC('/move/mode', [move]); + this.setActiveButton('.move-btn', e.target); + }); + }); + + // Brightness slider + document.getElementById('brightness').addEventListener('input', (e) => { + const value = e.target.value / 100; + document.getElementById('brightness-val').textContent = e.target.value + '%'; + this.sendOSC('/laser/brightness', [value]); + }); + + // Dot amount slider + document.getElementById('dot-amount').addEventListener('input', (e) => { + const value = e.target.value / 100; + document.getElementById('dot-amount-val').textContent = e.target.value + '%'; + this.sendOSC('/laser/points', [value]); + }); + + // Rainbow sliders + document.getElementById('rainbow-amount').addEventListener('input', (e) => { + const value = e.target.value / 100; + document.getElementById('rainbow-amount-val').textContent = e.target.value + '%'; + this.sendOSC('/rainbow/amount', [value]); + }); + + document.getElementById('rainbow-speed').addEventListener('input', (e) => { + const value = e.target.value / 100; + document.getElementById('rainbow-speed-val').textContent = e.target.value; + this.sendOSC('/rainbow/speed', [value]); + }); + + // Movement sliders + document.getElementById('move-size').addEventListener('input', (e) => { + const value = e.target.value / 100; + document.getElementById('move-size-val').textContent = e.target.value + '%'; + this.sendOSC('/move/size', [value]); + }); + + document.getElementById('move-speed').addEventListener('input', (e) => { + const value = e.target.value / 100; + document.getElementById('move-speed-val').textContent = value.toFixed(1); + this.sendOSC('/move/speed', [value]); + }); + + // Transform sliders + document.getElementById('scale').addEventListener('input', (e) => { + const value = e.target.value / 100; + document.getElementById('scale-val').textContent = e.target.value; + this.sendOSC('/transform/scale', [value]); + }); + + document.getElementById('rotation').addEventListener('input', (e) => { + const value = e.target.value / 100; + document.getElementById('rotation-val').textContent = e.target.value; + this.sendOSC('/transform/rotation', [value]); + }); + + document.getElementById('pos-x').addEventListener('input', (e) => { + const value = e.target.value / 100; + document.getElementById('pos-x-val').textContent = e.target.value; + this.sendOSC('/transform/x', [value]); + }); + + document.getElementById('pos-y').addEventListener('input', (e) => { + const value = e.target.value / 100; + document.getElementById('pos-y-val').textContent = e.target.value; + this.sendOSC('/transform/y', [value]); + }); + + // Blackout button + document.getElementById('blackout').addEventListener('click', (e) => { + e.target.classList.toggle('active'); + const isActive = e.target.classList.contains('active'); + this.sendOSC('/laser/blackout', [isActive ? 1 : 0]); + }); + } + + setActiveButton(selector, activeBtn) { + document.querySelectorAll(selector).forEach(btn => btn.classList.remove('active')); + activeBtn.classList.add('active'); + } + + async sendOSC(address, args) { + try { + console.log(`OSC: ${address} [${args.join(', ')}]`); + const response = await fetch(`${this.apiBase}/api/osc`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address, args }) + }); + + if (!response.ok) { + console.error('OSC send failed:', response.statusText); + } + } catch (error) { + console.error('Error sending OSC:', error); + } + } + + async updateShapes() { + try { + const response = await fetch(`${this.apiBase}/api/shapes`); + const data = await response.json(); + this.createLaserBeam(data.points, data.color); + } catch (error) { + console.error('Error fetching shapes:', error); + } + } + + async updateStatus() { + try { + const response = await fetch(`${this.apiBase}/api/status`); + const data = await response.json(); + + document.querySelector('.status strong:nth-child(2)').textContent = data.osc_port; + + const uptime = Math.floor(data.uptime); + const hours = Math.floor(uptime / 3600); + const minutes = Math.floor((uptime % 3600) / 60); + const seconds = uptime % 60; + document.querySelectorAll('.status strong')[1].textContent = + `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + + } catch (error) { + console.error('Error updating status:', error); + } + + if (this.running) { + setTimeout(() => this.updateStatus(), 1000); + } + } + + startAnimation() { + const animate = () => { + if (!this.running) return; + + requestAnimationFrame(animate); + this.render(); + }; + + animate(); + + // Update shapes from server + setInterval(() => { + if (this.running) { + this.updateShapes(); + } + }, this.updateInterval); + } + + destroy() { + this.running = false; + this.renderer.dispose(); + } +} + +// Initialize when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + window.beamCommander = new BeamCommander3D(); +}); diff --git a/beamcommander/templates/index.html b/beamcommander/templates/index.html new file mode 100644 index 0000000..0558c7b --- /dev/null +++ b/beamcommander/templates/index.html @@ -0,0 +1,336 @@ + + + + + + BeamCommander 2.0 - Laser Control + + + +
+
+

⚡ BeamCommander 2.0

+
+
+ OSC Port: 9000 + Uptime: -- +
+
+ +
+
+ +
+ +
+
+

💡 Brightness & Effects

+
+ + +
+
+ + +
+
+ +
+
+ +
+

🔷 Shape

+
+ + + + + + +
+
+ +
+

🎨 Color

+
+ + + +
+
+ +
+

🌈 Rainbow

+
+ + +
+
+ + +
+
+ +
+

↔️ Movement

+
+ + + + + + +
+
+ + +
+
+ + +
+
+ +
+

⚙️ Transform

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ Send OSC to: + localhost:9000 +
+
+ Current Shape: + circle +
+
+ Current Color: + blue +
+
+
+
+ +
+ BeamCommander v2.0.0 - Python Edition | Open Source Laser Control System +
+
+ + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..431cad3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask>=2.3.0 +flask-cors>=4.0.0 +python-osc>=1.8.0 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..8cd7831 --- /dev/null +++ b/setup.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +""" +Setup script for BeamCommander +""" +from setuptools import setup, find_packages +import os + +# Read the README file +def read_file(filename): + with open(filename, 'r', encoding='utf-8') as f: + return f.read() + +setup( + name='beamcommander', + version='2.0.0', + description='Generic Python-based laser control system with OSC support and browser UI', + long_description=read_file('README.md') if os.path.exists('README.md') else '', + long_description_content_type='text/markdown', + author='Oliver Byte', + author_email='info@OliverByte.de', + url='https://github.com/oliverbyte/beamcommander', + packages=find_packages(), + include_package_data=True, + package_data={ + 'beamcommander': [ + 'templates/*.html', + 'static/*.js', + 'static/*.css', + ], + }, + install_requires=[ + 'flask>=2.3.0', + 'flask-cors>=4.0.0', + 'python-osc>=1.8.0', + ], + extras_require={ + 'midi': ['python-rtmidi>=1.5.0', 'mido>=1.2.0'], + }, + entry_points={ + 'console_scripts': [ + 'beamcommander=beamcommander.server:main', + ], + }, + classifiers=[ + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'Intended Audience :: End Users/Desktop', + 'Topic :: Multimedia :: Graphics', + 'Topic :: Multimedia :: Sound/Audio', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Operating System :: OS Independent', + ], + python_requires='>=3.8', + keywords='laser control osc midi performance art', +) diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..a1db0b3 --- /dev/null +++ b/start.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Start BeamCommander Python server + +echo "Starting BeamCommander Python server..." +echo "================================" + +# Check if Python 3 is installed +if ! command -v python3 &> /dev/null; then + echo "Error: Python 3 is not installed" + exit 1 +fi + +# Check if virtual environment exists +if [ ! -d "venv" ]; then + echo "Creating virtual environment..." + python3 -m venv venv +fi + +# Activate virtual environment +source venv/bin/activate + +# Install dependencies if needed +if [ ! -f "venv/.deps_installed" ]; then + echo "Installing dependencies..." + pip install -r requirements.txt + touch venv/.deps_installed +fi + +# Start the server +echo "" +echo "Starting BeamCommander..." +echo "Web UI will be available at: http://localhost:8080" +echo "OSC receiver listening on port: 9000" +echo "" +echo "Press Ctrl+C to stop the server" +echo "================================" +echo "" + +python3 -m beamcommander.server "$@"