Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ show file format. At the moment this is done purely in code.

- [ ] LTC/SMPTE Timecode
- [ ] Show file live reloading
- [ ] Web Interface

## Concepts

Expand All @@ -51,6 +52,7 @@ No programmer. No editor. Halo is only a playback engine. You do the programming
* Float16 (two channels)
* RGB (color mixer)
* Bool: Boolean value on a single DMX channel: 0-127 means false, 128-255 means true
* Restricted Movement: Limit the movement of a fixture to a specific range.

## Features

Expand Down
13 changes: 13 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# TODO

## Lighting Engine

- [ ] Color Blending
- [ ] Advanced Effects
- [ ] Color Blending/Transitions (independent of the beat)

## Surfaces

- [ ] Master Fader

## Timecode
97 changes: 97 additions & 0 deletions shows/halloween.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
{
"show_name": "Example Show",
"version": "1.0",
"bpm": 120,
"cues": [
{
"name": "Alternating PAR Chase",
"fade_in_time_ms": 0,
"fade_out_time_ms": 0,
"duration_secs": 60.0,
"static_values": [
{
"fixture_name": "PAR Fixture 1",
"channel_name": "Dimmer",
"value": 65535
},
{
"fixture_name": "PAR Fixture 2",
"channel_name": "Dimmer",
"value": 65535
}
],
"chases": [
{
"name": "PAR Alternating Chase 1",
"current_step": 0,
"current_step_elapsed": 0.0,
"accumulated_beats": 0.0,
"steps": [
{
"duration_ms": 1500,
"effect_mappings": [
{
"effect": {
"name": "Sine Fade",
"min": 0,
"max": 255,
"params": {
"interval": "Phrase",
"interval_ratio": 1.0,
"phase": 0.0
}
},
"fixture_names": ["PAR Fixture 1"],
"channel_types": ["Dimmer"],
"distribution": "All"
}
],
"static_values": [
{
"fixture_name": "PAR Fixture 1",
"color": "#FFA500"
}
]
}
],
"loop_count": null
},
{
"name": "PAR Alternating Chase 2",
"current_step": 0,
"current_step_elapsed": 0.0,
"accumulated_beats": 0.0,
"steps": [
{
"duration_ms": 1500,
"effect_mappings": [
{
"effect": {
"name": "Sine Fade",
"min": 0,
"max": 255,
"params": {
"interval": "Bar",
"interval_ratio": 1.0,
"phase": 64.0
}
},
"fixture_names": ["PAR Fixture 2"],
"channel_types": ["Dimmer"],
"distribution": "All"
}
],
"static_values": [
{
"fixture_name": "PAR Fixture 2",
"color": "#FF0000"
}
]
}
],
"loop_count": null
}
]
}
]
}
5 changes: 4 additions & 1 deletion src/artnet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ use std::{
// The IP of the device running this SW
const DEVICE_IP: &str = "0.0.0.0";

const ART_NET_CONTROLLER_IP: &str = "255.255.255.255";
//const ART_NET_CONTROLLER_IP: &str = "255.255.255.255"; // Broadcast (Capture)
//const ART_NET_CONTROLLER_IP: &str = "192.168.1.78";
//const ART_NET_CONTROLLER_IP: &str = "192.168.0.78"; // S-PLAY1
const ART_NET_CONTROLLER_IP: &str = "192.168.1.9"; // ODE MK2

const CHANNELS_PER_UNIVERSE: u16 = 512;

Expand Down
60 changes: 60 additions & 0 deletions src/color.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
use crate::cue::StaticValue;

// Color representation
#[derive(Clone, Copy, Debug)]
pub struct Color {
pub r: u8,
pub g: u8,
pub b: u8,
// TODO - model white as a separate color
}

impl Color {
pub fn from_hex(hex: &str) -> Option<Self> {
if hex.len() != 7 || !hex.starts_with('#') {
return None;
}

let r = u8::from_str_radix(&hex[1..3], 16).ok()?;
let g = u8::from_str_radix(&hex[3..5], 16).ok()?;
let b = u8::from_str_radix(&hex[5..7], 16).ok()?;

Some(Color { r, g, b })
}

pub fn lerp(&self, target: &Color, t: f32) -> Self {
Color {
r: self.lerp_component(self.r, target.r, t),
g: self.lerp_component(self.g, target.g, t),
b: self.lerp_component(self.b, target.b, t),
}
}

pub fn lerp_component(&self, start: u8, end: u8, t: f32) -> u8 {
let t = t.clamp(0.0, 1.0);
let start_f = start as f32;
let end_f = end as f32;
((start_f + (end_f - start_f) * t) as u8).clamp(0, 255)
}

// TODO - this function should probably take into account if the fixture has a white channel
pub fn into_static_values(self, fixture_name: String) -> Vec<StaticValue> {
vec![
StaticValue {
fixture_name: fixture_name.clone(),
channel_name: "Red".to_string(),
value: self.r as u16,
},
StaticValue {
fixture_name: fixture_name.clone(),
channel_name: "Green".to_string(),
value: self.g as u16,
},
StaticValue {
fixture_name: fixture_name,
channel_name: "Blue".to_string(),
value: self.b as u16,
},
]
}
}
111 changes: 97 additions & 14 deletions src/console.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,35 +12,67 @@ use crate::fixture::{Channel, ChannelType, Fixture};
use crate::rhythm::RhythmState;
use crate::{ableton_link, effect};

use std::{
net::{SocketAddr, ToSocketAddrs, UdpSocket},
time::SystemTime,
};

const TARGET_FREQUENCY: f64 = 40.0; // 40Hz DMX Spec (every 25ms)
const TARGET_DELTA: f64 = 1.0 / TARGET_FREQUENCY;
const TARGET_DURATION: f64 = 1.0 / TARGET_FREQUENCY;

pub struct PlaybackState {
pub cue_index: usize,
pub cue_time: f64,
pub beat_time: f64,
pub elapsed_time: Duration,
pub frames_sent: u64,
pub current_cue: usize,
pub show_start_time: Instant,
}

pub struct LightingConsole {
tempo: f64,
fixtures: Vec<Fixture>,
link_state: ableton_link::State,
dmx_output: artnet::ArtNet,
cues: Vec<Cue>,
current_cue: usize,
show_start_time: Instant,
//current_cue: usize,
//show_start_time: Instant,
rhythm_state: RhythmState,
playback_state: PlaybackState,
}

impl LightingConsole {
pub fn new(bpm: f64) -> Result<Self, anyhow::Error> {
let link_state = ableton_link::State::new(bpm);
link_state.link.enable(true);

// Broadcast
let dmx_output = ArtNet::new(ArtNetMode::Broadcast)?;

// Unicast
// let src = ("0.0.0.0", 6453).to_socket_addrs()?.next().unwrap();
// let dest = ("192.168.1.78", 6454).to_socket_addrs()?.next().unwrap();
// let dmx_output = ArtNet::new(ArtNetMode::Unicast(src, dest))?;

Ok(LightingConsole {
tempo: bpm,
fixtures: Vec::new(),
cues: Vec::new(),
current_cue: 0,
show_start_time: Instant::now(),
//current_cue: 0,
//show_start_time: Instant::now(),
link_state: link_state,
dmx_output: dmx_output,
playback_state: PlaybackState {
cue_index: 0,
cue_time: 0.0,
beat_time: 0.0,
elapsed_time: Duration::from_secs(0),
frames_sent: 0,
current_cue: 0,
show_start_time: Instant::now(),
},
rhythm_state: RhythmState {
beat_phase: 0.0,
bar_phase: 0.0,
Expand All @@ -51,6 +83,20 @@ impl LightingConsole {
})
}

// TODO - implement show loading and saving
//
// pub fn save_show(&self, path: &str) -> Result<(), Error> {
// let file = File::create(path)?;
// serde_json::to_writer_pretty(file, &self.cues)?;
// Ok(())
// }

// pub fn load_show(&mut self, path: &str) -> Result<(), Error> {
// let file = File::open(path)?;
// self.cues = serde_json::from_reader(file)?;
// Ok(())
// }

pub fn set_fixtures(&mut self, fixtures: Vec<Fixture>) {
self.fixtures = fixtures;
}
Expand All @@ -77,12 +123,22 @@ impl LightingConsole {
thread::spawn(move || loop {
let mut buffer = [0; 1];
if io::stdin().read_exact(&mut buffer).is_ok() {
if buffer[0] == b'G' || buffer[0] == b'g' {
tx.send(()).unwrap();
match buffer[0] {
b'G' | b'g' => tx.send(KeyCommand::Go).unwrap(),
b'[' => tx.send(KeyCommand::DecreaseBPM).unwrap(),
b']' => tx.send(KeyCommand::IncreaseBPM).unwrap(),
_ => {}
}
}
});

// Add enum for key commands
enum KeyCommand {
Go,
IncreaseBPM,
DecreaseBPM,
}

let mut frames_sent = 0;
let mut last_update = Instant::now();
let mut cue_time = 0.0; // TODO - I think cue time needs to be Instant::now()
Expand All @@ -95,10 +151,37 @@ impl LightingConsole {
last_update = frame_start;

// check for keyboard input
if rx.try_recv().is_ok() {
self.current_cue = (self.current_cue + 1) % self.cues.len();
cue_time = 0.0;
println!("Advanced to cue: {}", self.cues[self.current_cue].name);
// if rx.try_recv().is_ok() {
// self.playback_state.current_cue =
// (self.playback_state.current_cue + 1) % self.cues.len();
// cue_time = 0.0;
// println!(
// "Advanced to cue: {}",
// self.cues[self.playback_state.current_cue].name
// );
// }

if let Ok(cmd) = rx.try_recv() {
match cmd {
KeyCommand::Go => {
self.playback_state.current_cue =
(self.playback_state.current_cue + 1) % self.cues.len();
cue_time = 0.0;
println!(
"Advanced to cue: {}",
self.cues[self.playback_state.current_cue].name
);
}
KeyCommand::IncreaseBPM => {
self.tempo += 1.0;
self.link_state.set_tempo(self.tempo);
self.link_state.session_state.set_tempo(bpm, at_time);
}
KeyCommand::DecreaseBPM => {
self.tempo = (self.tempo - 1.0).max(1.0);
self.link_state.set_tempo(self.tempo);
}
}
}

self.link_state.capture_app_state();
Expand All @@ -116,7 +199,7 @@ impl LightingConsole {
// }

// reset cue time if it's greater than cue duration (loop cue)
if cue_time >= self.cues[self.current_cue].duration {
if cue_time >= self.cues[self.playback_state.current_cue].duration {
cue_time = 0.0; // Reset cue time but don't change the cue
}

Expand All @@ -125,8 +208,8 @@ impl LightingConsole {
self.display_status(
clock,
frames_sent,
&self.cues[self.current_cue].name,
self.show_start_time.elapsed(),
&self.cues[self.playback_state.current_cue].name,
self.playback_state.show_start_time.elapsed(),
cue_time,
self.rhythm_state.beat_phase,
);
Expand All @@ -145,7 +228,7 @@ impl LightingConsole {
self.tempo = self.link_state.session_state.tempo();
self.update_rhythm_state(beat_time);

if let Some(current_cue) = self.cues.get_mut(self.current_cue) {
if let Some(current_cue) = self.cues.get_mut(self.playback_state.current_cue) {
// Apply cue-level static values first
for static_value in &current_cue.static_values {
if let Some(fixture) = self
Expand Down
Loading