add lightcontrol rust tool

Three binaries against the c-base mainhall rig:

- lightcontrol: TUI with 15 animated presets, live matelight-sync via
  the /monitor WebSocket, and a microphone beat detector that drives
  the BeatPulse preset across RGB PARs, CB-100 colour-wheels, and
  TSL-250 scanners.
- panels: one-shot RGB wash for the Showtec LED Par 56 wall panels via
  dmxbackend.
- bars: ArtNet direct driver for the Stairville SonicPulse LED bars,
  with ArtPoll discovery and a universe sweeper.

Shared lib code (fixture model, render pipeline, matelight + beat
modules) lives in src/lib.rs. Mic feature behind the default `mic`
cargo feature so builds without ALSA dev libs work via
--no-default-features.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Yan Minagawa 2026-06-10 01:29:03 +02:00
parent de43fa4ea4
commit 55bd37cd99
10 changed files with 6401 additions and 0 deletions

17
.gitignore vendored Normal file
View file

@ -0,0 +1,17 @@
# Rust build artefacts
/target/
# Editor / OS noise
*.swp
*.swo
*~
.DS_Store
.idea/
.vscode/
# QLC+ autosaves (the canonical .qxw is committed separately if desired)
*.qxw.autosave
*.autosave.qxw
# Local distribution packages — don't drag installers into the repo
*.deb

2781
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

23
Cargo.toml Normal file
View file

@ -0,0 +1,23 @@
[package]
name = "lightcontrol"
version = "0.1.0"
edition = "2021"
[features]
default = ["mic"]
mic = ["dep:cpal"]
[dependencies]
anyhow = "1"
cpal = { version = "0.15", optional = true }
crossterm = "0.28"
futures-util = { version = "0.3", default-features = false, features = ["sink"] }
ratatui = "0.29"
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
tokio-tungstenite = "0.24"
[profile.release]
lto = "thin"

470
src/beat.rs Normal file
View file

@ -0,0 +1,470 @@
//! Microphone-driven beat detection.
//!
//! Captures audio from the default input device via `cpal`, runs an
//! energy-onset detector against the live samples, and exposes the latest
//! beat state through a shared `Arc<std::sync::Mutex<BeatState>>`. Beat
//! state is consumed by the `BeatPulse` preset to drive the hall lights in
//! sync with the room's audio.
//!
//! Detector design — single-band energy onset, robust enough for a club
//! environment:
//!
//! 1. Downmix incoming samples to mono.
//! 2. Compute RMS energy per ~23 ms chunk (1024 samples @ 44.1 kHz).
//! 3. Track two single-pole envelopes: a fast one (~50 ms) following the
//! instantaneous level, and a slow one (~1.5 s) acting as the running
//! baseline.
//! 4. Trigger a beat when the short envelope crosses `threshold ×
//! long envelope`, gated by a refractory period (≥200 ms ⇒ ≤300 BPM).
//! 5. Median of the last 8 inter-beat intervals → BPM estimate.
//!
//! When the `mic` cargo feature is off, this module still compiles but the
//! feeder is a no-op that just reports "mic disabled". The `BeatPulse`
//! preset still works as a "no-mic" placeholder pulse.
use std::sync::Arc;
use std::time::Instant;
/// Snapshot passed to renderers per frame. Computed in `main.rs` from the
/// latest `BeatState` at render time so it has the freshest `time_since_beat`.
#[derive(Clone, Copy, Debug, Default)]
pub struct BeatInput {
/// Seconds since the most recent beat. Large (e.g. 99.0) when no beat
/// has happened yet.
pub time_since_beat: f32,
/// Monotonic counter — increments on every detected beat. Renderers use
/// this to pick a new colour per beat without needing wall-clock time.
pub beat_index: u64,
/// Estimated BPM (median of the last few inter-beat intervals).
pub bpm: f32,
/// Current short-window energy envelope, roughly 0..1.
pub energy: f32,
}
/// Authoritative beat state — written by the mic thread, read by the
/// engine and TUI. Uses `std::sync::Mutex` (not tokio's) because the cpal
/// callback runs on a non-tokio audio thread.
#[derive(Debug, Default)]
pub struct BeatState {
pub last_beat: Option<Instant>,
pub beat_count: u64,
pub bpm: f32,
pub energy: f32,
/// Device label set when capture starts.
pub device_name: Option<String>,
/// Last error from cpal init or stream errors. Surfaced in the TUI.
pub last_error: Option<String>,
}
pub type SharedBeat = Arc<std::sync::Mutex<BeatState>>;
pub fn shared() -> SharedBeat {
Arc::new(std::sync::Mutex::new(BeatState::default()))
}
/// Snapshot the live state into a per-frame `BeatInput`. Returns `None`
/// when nothing useful has been observed yet (mic not up and no beats).
pub fn snapshot(shared: &SharedBeat, now: Instant) -> Option<BeatInput> {
let s = shared.lock().ok()?;
if s.device_name.is_none() && s.last_beat.is_none() {
return None;
}
let time_since_beat = s
.last_beat
.map(|t| now.duration_since(t).as_secs_f32())
.unwrap_or(99.0);
Some(BeatInput {
time_since_beat,
beat_index: s.beat_count,
bpm: s.bpm,
energy: s.energy,
})
}
/// Status string derived from the live state for the TUI mic line.
pub fn status_line(shared: &SharedBeat) -> String {
let s = match shared.lock() {
Ok(g) => g,
Err(_) => return "mic: lock poisoned".to_string(),
};
if let Some(err) = &s.last_error {
return format!("mic: {err}");
}
match &s.device_name {
Some(name) => format!(
"mic: {name} beats={} bpm={:.0} level={:.2}",
s.beat_count, s.bpm, s.energy
),
None => "mic: starting…".to_string(),
}
}
// ---------------------------------------------------------------------------
// Detector — feature-independent, takes raw mono samples
// ---------------------------------------------------------------------------
/// Energy-onset beat detector. Sample-format and capture-backend agnostic —
/// callers downmix to mono f32 and call `feed`.
pub struct EnergyDetector {
pub sample_rate: u32,
chunk_size: usize,
chunk_accum: Vec<f32>,
short_env: f32,
long_env: f32,
last_beat: Option<Instant>,
beat_count: u64,
intervals: std::collections::VecDeque<f32>,
}
#[derive(Debug, Clone, Copy)]
pub struct BeatEvent {
pub at: Instant,
pub index: u64,
pub energy: f32,
pub bpm: f32,
}
impl EnergyDetector {
pub fn new(sample_rate: u32) -> Self {
// ~23ms chunks at 44.1k. Same wall-clock at higher rates so envelope
// time-constants stay roughly correct.
let chunk_size = ((sample_rate as f32) * 0.023).round() as usize;
Self {
sample_rate,
chunk_size: chunk_size.max(64),
chunk_accum: Vec::with_capacity(8192),
short_env: 0.0,
long_env: 0.0,
last_beat: None,
beat_count: 0,
intervals: std::collections::VecDeque::with_capacity(8),
}
}
/// Push a chunk of mono f32 samples (range roughly -1..=1). Returns one
/// `BeatEvent` per processed chunk that crossed the onset condition;
/// callers should drain by calling repeatedly with empty slices is *not*
/// needed — process_chunk is invoked internally.
pub fn feed(&mut self, samples: &[f32], now: Instant) -> Option<BeatEvent> {
let mut event: Option<BeatEvent> = None;
for &s in samples {
self.chunk_accum.push(s);
if self.chunk_accum.len() >= self.chunk_size {
if let Some(b) = self.process_chunk(now) {
event = Some(b);
}
self.chunk_accum.clear();
}
}
event
}
fn process_chunk(&mut self, now: Instant) -> Option<BeatEvent> {
let n = self.chunk_accum.len();
if n == 0 {
return None;
}
// RMS
let sum_sq: f32 = self.chunk_accum.iter().map(|&s| s * s).sum();
let rms = (sum_sq / n as f32).sqrt();
// Single-pole IIR envelopes. dt is the chunk duration in seconds;
// tau is the envelope time-constant.
let dt = n as f32 / self.sample_rate as f32;
let alpha_short = 1.0 - (-dt / 0.050).exp(); // 50ms
let alpha_long = 1.0 - (-dt / 1.500).exp(); // 1.5s
self.short_env += alpha_short * (rms - self.short_env);
self.long_env += alpha_long * (rms - self.long_env);
// Onset condition. Three gates that must all pass:
// 1. Ratio: instantaneous level is meaningfully above the running
// baseline (35% bump).
// 2. Absolute delta: the level *rose* by at least MIN_DELTA in
// absolute terms. This prevents false beats during the warmup
// period when the long-envelope is still climbing — the ratio
// can read huge while both envelopes are tiny.
// 3. Baseline above noise floor: don't trigger on dead silence.
const THRESHOLD: f32 = 1.35;
const MIN_BASELINE: f32 = 0.005; // noise floor
const MIN_DELTA: f32 = 0.020; // absolute rise required (~-34 dBFS)
const MIN_INTERVAL_MS: u64 = 200; // ≤300 BPM
let above = self.short_env > self.long_env * THRESHOLD
&& self.short_env - self.long_env > MIN_DELTA
&& self.long_env > MIN_BASELINE;
let cooled = self
.last_beat
.map(|t| now.duration_since(t).as_millis() as u64 >= MIN_INTERVAL_MS)
.unwrap_or(true);
if above && cooled {
if let Some(prev) = self.last_beat {
let dur = now.duration_since(prev).as_secs_f32();
self.intervals.push_back(dur);
if self.intervals.len() > 8 {
self.intervals.pop_front();
}
}
self.last_beat = Some(now);
self.beat_count += 1;
return Some(BeatEvent {
at: now,
index: self.beat_count,
energy: self.short_env,
bpm: self.estimate_bpm(),
});
}
None
}
fn estimate_bpm(&self) -> f32 {
if self.intervals.is_empty() {
return 0.0;
}
let mut v: Vec<f32> = self.intervals.iter().copied().collect();
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
let median = v[v.len() / 2];
if median > 1e-3 {
60.0 / median
} else {
0.0
}
}
/// Current short-window envelope (for live "level" display).
pub fn energy(&self) -> f32 {
self.short_env
}
}
// ---------------------------------------------------------------------------
// Mic capture — feature-gated cpal integration
// ---------------------------------------------------------------------------
#[cfg(feature = "mic")]
mod capture {
use super::*;
use anyhow::{anyhow, Context, Result};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
/// Build the cpal input stream and drive the detector. The returned
/// `Stream` value must be kept alive — capture stops when it drops.
pub fn build(shared: SharedBeat) -> Result<cpal::Stream> {
let host = cpal::default_host();
let device = host
.default_input_device()
.ok_or_else(|| anyhow!("no default audio input device"))?;
let device_name = device.name().unwrap_or_else(|_| "unknown".to_string());
let supported = device
.default_input_config()
.context("default_input_config")?;
let sample_format = supported.sample_format();
let sample_rate = supported.sample_rate().0;
let channels = supported.channels() as usize;
let stream_config: cpal::StreamConfig = supported.into();
{
let mut s = shared.lock().unwrap();
s.device_name = Some(format!(
"{device_name} ({sample_rate}Hz, {channels}ch, {sample_format:?})"
));
s.last_error = None;
}
let mut detector = EnergyDetector::new(sample_rate);
let shared_for_data = shared.clone();
let shared_for_err = shared.clone();
let mut mono = Vec::with_capacity(4096);
let err_fn = move |err| {
if let Ok(mut s) = shared_for_err.lock() {
s.last_error = Some(format!("stream error: {err}"));
}
};
let stream = match sample_format {
cpal::SampleFormat::F32 => device.build_input_stream(
&stream_config,
move |data: &[f32], _: &_| {
process(&mut detector, &shared_for_data, &mut mono, data, channels);
},
err_fn,
None,
)?,
cpal::SampleFormat::I16 => device.build_input_stream(
&stream_config,
move |data: &[i16], _: &_| {
mono.clear();
mono.extend(data.iter().map(|&s| s as f32 / i16::MAX as f32));
process_mono(&mut detector, &shared_for_data, &mono, channels);
},
err_fn,
None,
)?,
cpal::SampleFormat::U16 => device.build_input_stream(
&stream_config,
move |data: &[u16], _: &_| {
mono.clear();
mono.extend(
data.iter()
.map(|&s| (s as f32 - i16::MAX as f32) / i16::MAX as f32),
);
process_mono(&mut detector, &shared_for_data, &mono, channels);
},
err_fn,
None,
)?,
other => return Err(anyhow!("unsupported sample format: {other:?}")),
};
stream.play().context("stream.play")?;
Ok(stream)
}
fn process(
detector: &mut EnergyDetector,
shared: &SharedBeat,
mono: &mut Vec<f32>,
data: &[f32],
channels: usize,
) {
mono.clear();
if channels <= 1 {
mono.extend_from_slice(data);
} else {
for frame in data.chunks(channels) {
let s: f32 = frame.iter().copied().sum::<f32>() / channels as f32;
mono.push(s);
}
}
process_mono(detector, shared, mono, channels);
}
fn process_mono(
detector: &mut EnergyDetector,
shared: &SharedBeat,
mono: &[f32],
_channels: usize,
) {
let now = Instant::now();
let event = detector.feed(mono, now);
// Update live envelope on every callback; bump beat fields on event.
if let Ok(mut s) = shared.lock() {
s.energy = detector.energy();
if let Some(e) = event {
s.last_beat = Some(e.at);
s.beat_count = e.index;
s.bpm = e.bpm;
}
}
}
}
#[cfg(feature = "mic")]
pub use capture::build as build_stream;
/// Spawn the mic capture thread. The thread owns the cpal `Stream` so it
/// stays alive for the program's lifetime. On error, the failure is
/// surfaced through `shared.last_error` and the thread exits.
pub fn spawn(shared: SharedBeat) {
#[cfg(feature = "mic")]
{
std::thread::Builder::new()
.name("beat-mic".to_string())
.spawn(move || match build_stream(shared.clone()) {
Ok(stream) => {
// Keep stream alive. The cpal callback owns the detector
// and writes to shared state directly.
std::mem::forget(stream);
loop {
std::thread::park();
}
}
Err(e) => {
if let Ok(mut s) = shared.lock() {
s.last_error = Some(format!("init: {e}"));
}
}
})
.expect("spawn beat-mic thread");
}
#[cfg(not(feature = "mic"))]
{
if let Ok(mut s) = shared.lock() {
s.last_error =
Some("disabled at build time — rebuild with --features mic".to_string());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn detector_fires_on_loud_pulse_against_quiet_baseline() {
let mut det = EnergyDetector::new(44_100);
let start = Instant::now();
let chunk_n = det.chunk_size;
// Seed a few seconds of quiet so long_env settles low.
let quiet: Vec<f32> = (0..(chunk_n * 50)).map(|_| 0.01).collect();
det.feed(&quiet, start);
// Then a loud burst — should fire one beat.
let burst: Vec<f32> = (0..chunk_n).map(|_| 0.6).collect();
let later = start + Duration::from_millis(2000);
let beat = det.feed(&burst, later);
assert!(beat.is_some(), "loud burst over quiet baseline must trigger");
assert_eq!(beat.unwrap().index, 1);
}
#[test]
fn detector_respects_refractory_period() {
let mut det = EnergyDetector::new(44_100);
let now = Instant::now();
let chunk_n = det.chunk_size;
// Baseline.
let quiet: Vec<f32> = (0..(chunk_n * 30)).map(|_| 0.005).collect();
det.feed(&quiet, now);
// Two loud bursts within 50 ms — only the first should fire.
let burst: Vec<f32> = (0..chunk_n).map(|_| 0.7).collect();
let t0 = now + Duration::from_millis(1500);
let b1 = det.feed(&burst, t0);
assert!(b1.is_some());
let t1 = t0 + Duration::from_millis(50);
let b2 = det.feed(&burst, t1);
assert!(b2.is_none(), "second burst within refractory window must be suppressed");
// After 250ms, another burst can fire.
let t2 = t0 + Duration::from_millis(250);
let b3 = det.feed(&burst, t2);
assert!(b3.is_some(), "burst past refractory must fire");
}
#[test]
fn detector_estimates_bpm_from_periodic_beats() {
let mut det = EnergyDetector::new(44_100);
let now = Instant::now();
let chunk_n = det.chunk_size;
// Quiet baseline.
let quiet: Vec<f32> = (0..(chunk_n * 40)).map(|_| 0.003).collect();
det.feed(&quiet, now);
// Hit four beats at 120 BPM = 500ms apart.
let burst: Vec<f32> = (0..chunk_n).map(|_| 0.8).collect();
for i in 0..6 {
let t = now + Duration::from_millis(2000 + 500 * i);
det.feed(&burst, t);
// Drop a quiet chunk between bursts so envelope can fall.
let between: Vec<f32> = (0..(chunk_n * 8)).map(|_| 0.003).collect();
det.feed(&between, t + Duration::from_millis(50));
}
let bpm = det.estimate_bpm();
assert!(bpm > 100.0 && bpm < 140.0, "expected ~120 BPM, got {bpm}");
}
}

453
src/bin/bars.rs Normal file
View file

@ -0,0 +1,453 @@
//! Direct-ArtNet tool for the four Stairville SonicPulse LED Bar 10s.
//!
//! Sends a single ArtDMX packet to the ArtNet node, addressing only the 156
//! channels the four bars occupy in U3 (4 × 39ch starting at DMX 1, 40, 79,
//! 118). Channels beyond that aren't included in the packet, so per the
//! ArtNet spec the receiver retains their prior values — "without changing
//! anything else".
//!
//! Channel layout per bar (Stairville 39-channel mode):
//! ch1 master dimmer
//! ch2 shutter / strobe
//! ch3 mode selection (0 = direct pixel control)
//! ch4 colour preset
//! ch5 effect select
//! ch6 sound mode
//! ch7 speed / sensitivity
//! ch8..39 8 segments × RGBW (4 channels each)
use anyhow::{Context, Result};
use std::net::UdpSocket;
use std::time::{Duration, Instant};
const DEFAULT_NODE: &str = "10.0.0.146:6454";
const DEFAULT_UNIVERSE: u16 = 2; // QLC+ "Universe 3" = 0-indexed ArtNet universe 2
// 0-indexed DMX offsets for each bar within the universe (DMX address minus 1).
const BAR_OFFSETS: &[usize] = &[0, 39, 78, 117];
const BAR_CHANNELS: usize = 39;
const SEGMENTS: usize = 8;
struct Args {
node: String,
universe: u16,
color: (u8, u8, u8),
white: u8,
dim: u8,
mode: u8,
repeats: u32,
interval_ms: u64,
dry_run: bool,
poll: bool,
broadcast: bool,
sweep: Option<(u16, u16)>,
}
fn main() -> Result<()> {
let args = parse_args()?;
if args.poll {
return run_poll(&args.node);
}
if let Some((lo, hi)) = args.sweep {
return run_sweep(&args, lo, hi);
}
let dmx = build_dmx(args.color, args.white, args.dim, args.mode);
let pkt = build_artdmx(args.universe, 0, &dmx);
let (r, g, b) = args.color;
println!("node: {}", args.node);
println!(
"universe: {} (Net={}, SubUni={})",
args.universe,
(args.universe >> 8) & 0x7f,
args.universe & 0xff
);
println!(
"colour: rgb=({r},{g},{b}) W={} dim={} mode={}",
args.white, args.dim, args.mode
);
println!(
"bars: {} × 39ch at DMX {} = {}-byte packet (header 18 + data {})",
BAR_OFFSETS.len(),
BAR_OFFSETS
.iter()
.map(|o| (o + 1).to_string())
.collect::<Vec<_>>()
.join(","),
pkt.len(),
dmx.len()
);
if args.dry_run {
println!("(dry run — not sending)");
print_dmx_summary(&dmx);
return Ok(());
}
let sock = UdpSocket::bind("0.0.0.0:0").context("bind udp")?;
if args.broadcast {
sock.set_broadcast(true).context("enable broadcast")?;
}
sock.connect(&args.node)
.with_context(|| format!("connect {}", args.node))?;
for i in 0..args.repeats {
sock.send(&pkt).context("send")?;
if i + 1 < args.repeats {
std::thread::sleep(Duration::from_millis(args.interval_ms));
}
}
println!("sent {} packet(s).", args.repeats);
Ok(())
}
// --- ArtPoll diagnostic ----------------------------------------------------
fn run_poll(target: &str) -> Result<()> {
let sock = UdpSocket::bind("0.0.0.0:6454")
.context("bind UDP 6454 (close QLC+/other ArtNet listeners and retry)")?;
sock.set_broadcast(true)?;
sock.set_read_timeout(Some(Duration::from_millis(300)))?;
let mut pkt = Vec::with_capacity(14);
pkt.extend_from_slice(b"Art-Net\0");
pkt.extend_from_slice(&0x2000u16.to_le_bytes()); // OpPoll
pkt.extend_from_slice(&14u16.to_be_bytes()); // ProtVer
pkt.push(0x02); // TalkToMe: send ArtPollReply on changes too
pkt.push(0); // Priority
// Send to limited broadcast, subnet broadcast, and the supplied target.
let targets = ["255.255.255.255:6454", "10.0.0.255:6454", target];
for t in targets {
if let Err(e) = sock.send_to(&pkt, t) {
eprintln!("warn: send ArtPoll to {t}: {e}");
}
}
println!("listening for ArtPollReply for 2.5s…\n");
let start = Instant::now();
let mut seen = std::collections::BTreeSet::new();
while start.elapsed() < Duration::from_millis(2500) {
let mut buf = [0u8; 1024];
match sock.recv_from(&mut buf) {
Ok((n, addr)) => {
if n >= 14 && &buf[0..8] == b"Art-Net\0" {
let opcode = u16::from_le_bytes([buf[8], buf[9]]);
if opcode == 0x2100 {
if !seen.insert(addr) {
continue;
}
print_poll_reply(addr, &buf[..n]);
}
}
}
Err(_) => continue,
}
}
if seen.is_empty() {
println!("no ArtPollReply received.");
println!("→ check: is the node powered + on this LAN? are you on the same subnet as 10.0.0.0/24?");
}
Ok(())
}
fn print_poll_reply(addr: std::net::SocketAddr, data: &[u8]) {
if data.len() < 200 {
println!("{} — short ArtPollReply ({} bytes), can't decode", addr, data.len());
return;
}
let ip = format!("{}.{}.{}.{}", data[10], data[11], data[12], data[13]);
let net_switch = data[18];
let sub_switch = data[19];
let short_name = cstr(&data[26..44]);
let long_name = cstr(&data[44..108]);
let num_ports = u16::from_be_bytes([data[172], data[173]]).min(4) as usize;
let port_types = &data[174..178];
let sw_out = &data[190..194];
println!("=== ArtPollReply from {} ===", addr);
println!(" ip: {}", ip);
println!(" short name: {:?}", short_name);
println!(" long name: {:?}", long_name);
println!(" net/subnet: net={net_switch} sub={sub_switch}");
println!(" num ports: {num_ports}");
for i in 0..num_ports {
let ptype = port_types[i];
let direction = if ptype & 0x80 != 0 {
"output"
} else if ptype & 0x40 != 0 {
"input"
} else {
"?"
};
let universe = ((net_switch as u16 & 0x7f) << 8)
| ((sub_switch as u16 & 0x0f) << 4)
| (sw_out[i] as u16 & 0x0f);
println!(
" port {} ({}): SwOut=0x{:02x} → ArtNet universe {}",
i, direction, sw_out[i], universe
);
}
println!();
}
fn cstr(b: &[u8]) -> String {
let end = b.iter().position(|&c| c == 0).unwrap_or(b.len());
String::from_utf8_lossy(&b[..end]).into_owned()
}
// --- universe sweep --------------------------------------------------------
fn run_sweep(args: &Args, lo: u16, hi: u16) -> Result<()> {
let sock = UdpSocket::bind("0.0.0.0:0").context("bind udp")?;
if args.broadcast {
sock.set_broadcast(true)?;
}
let dmx = build_dmx(args.color, args.white, args.dim, args.mode);
println!(
"sweep: sending colour={:?} dim={} mode={} to {} universes {}..={}, {} ms each",
args.color, args.dim, args.mode, args.node, lo, hi, args.interval_ms
);
for u in lo..=hi {
let pkt = build_artdmx(u, 0, &dmx);
sock.send_to(&pkt, &args.node)
.with_context(|| format!("send universe {u}"))?;
println!(" universe {u} sent — watch the bars for ~{}ms", args.interval_ms);
std::thread::sleep(Duration::from_millis(args.interval_ms));
}
// After sweep, blank everything we touched
let dark = build_dmx((0, 0, 0), 0, 0, args.mode);
for u in lo..=hi {
let pkt = build_artdmx(u, 1, &dark);
let _ = sock.send_to(&pkt, &args.node);
}
println!("sweep done (all universes blanked).");
Ok(())
}
fn build_dmx(color: (u8, u8, u8), white: u8, dim: u8, mode: u8) -> Vec<u8> {
let last = BAR_OFFSETS.iter().max().copied().unwrap_or(0) + BAR_CHANNELS;
let mut dmx = vec![0u8; last];
for &off in BAR_OFFSETS {
dmx[off] = dim; // ch1 master dimmer
dmx[off + 1] = 0; // ch2 strobe
dmx[off + 2] = mode; // ch3 mode
dmx[off + 3] = 0; // ch4 preset
dmx[off + 4] = 0; // ch5 effect
dmx[off + 5] = 0; // ch6 sound mode
dmx[off + 6] = 0; // ch7 speed
for seg in 0..SEGMENTS {
let base = off + 7 + seg * 4;
dmx[base] = color.0; // R
dmx[base + 1] = color.1; // G
dmx[base + 2] = color.2; // B
dmx[base + 3] = white; // W
}
}
dmx
}
fn build_artdmx(universe: u16, sequence: u8, dmx: &[u8]) -> Vec<u8> {
// ArtNet ArtDMX header is 18 bytes.
let length = dmx.len() as u16;
let mut pkt = Vec::with_capacity(18 + dmx.len());
pkt.extend_from_slice(b"Art-Net\0"); // 8 byte ID
pkt.extend_from_slice(&0x5000u16.to_le_bytes()); // OpDmx (little-endian)
pkt.extend_from_slice(&14u16.to_be_bytes()); // ProtVer Hi/Lo (big-endian)
pkt.push(sequence); // Sequence
pkt.push(0); // Physical
pkt.push((universe & 0xff) as u8); // SubUni
pkt.push(((universe >> 8) & 0x7f) as u8); // Net
pkt.extend_from_slice(&length.to_be_bytes()); // Length (big-endian)
pkt.extend_from_slice(dmx);
pkt
}
fn print_dmx_summary(dmx: &[u8]) {
println!("\nDMX channels (1-indexed):");
for (i, &off) in BAR_OFFSETS.iter().enumerate() {
println!(
" bar {} @ ch{}..{}: master={} strobe={} mode={} preset={} effect={} sound={} speed={}",
i + 1,
off + 1,
off + BAR_CHANNELS,
dmx[off],
dmx[off + 1],
dmx[off + 2],
dmx[off + 3],
dmx[off + 4],
dmx[off + 5],
dmx[off + 6]
);
for seg in 0..SEGMENTS {
let base = off + 7 + seg * 4;
println!(
" seg {} @ ch{}: r={} g={} b={} w={}",
seg + 1,
base + 1,
dmx[base],
dmx[base + 1],
dmx[base + 2],
dmx[base + 3]
);
}
}
}
// --- CLI ---------------------------------------------------------------
fn parse_args() -> Result<Args> {
let mut node: Option<String> = None;
let mut universe: Option<u16> = None;
let mut color_arg: Option<String> = None;
let mut white: u8 = 0;
let mut dim: u8 = 255;
let mut mode: u8 = 0;
let mut repeats: u32 = 3;
let mut interval_ms: u64 = 50;
let mut dry_run = false;
let mut poll = false;
let mut broadcast = false;
let mut sweep: Option<(u16, u16)> = None;
let mut it = std::env::args().skip(1);
while let Some(a) = it.next() {
match a.as_str() {
"-h" | "--help" => {
print_help();
std::process::exit(0);
}
"--node" => node = it.next(),
"--universe" => {
universe = it.next().map(|s| s.parse()).transpose()?;
}
"--color" => color_arg = it.next(),
"--white" => white = it.next().context("--white needs value")?.parse()?,
"--dim" => dim = it.next().context("--dim needs value")?.parse()?,
"--mode" => mode = it.next().context("--mode needs value")?.parse()?,
"--repeats" => repeats = it.next().context("--repeats needs value")?.parse()?,
"--interval" => interval_ms = it.next().context("--interval needs value")?.parse()?,
"--dry-run" => dry_run = true,
"--poll" => poll = true,
"--broadcast" => broadcast = true,
"--sweep" => {
let v = it.next().context("--sweep needs LOW-HIGH")?;
sweep = Some(parse_sweep(&v)?);
if interval_ms < 250 {
interval_ms = 600;
}
}
other if let Some(v) = other.strip_prefix("--sweep=") => {
sweep = Some(parse_sweep(v)?);
if interval_ms < 250 {
interval_ms = 600;
}
}
other if let Some(v) = other.strip_prefix("--node=") => node = Some(v.to_string()),
other if let Some(v) = other.strip_prefix("--universe=") => universe = Some(v.parse()?),
other if let Some(v) = other.strip_prefix("--color=") => color_arg = Some(v.to_string()),
other if let Some(v) = other.strip_prefix("--white=") => white = v.parse()?,
other if let Some(v) = other.strip_prefix("--dim=") => dim = v.parse()?,
other if let Some(v) = other.strip_prefix("--mode=") => mode = v.parse()?,
other if let Some(v) = other.strip_prefix("--repeats=") => repeats = v.parse()?,
other if let Some(v) = other.strip_prefix("--interval=") => interval_ms = v.parse()?,
_ => {
eprintln!("unknown argument: {a}");
print_help();
std::process::exit(2);
}
}
}
Ok(Args {
node: node.unwrap_or_else(|| DEFAULT_NODE.to_string()),
universe: universe.unwrap_or(DEFAULT_UNIVERSE),
color: parse_color(color_arg.as_deref().unwrap_or("red"))?,
white,
dim,
mode,
repeats,
interval_ms,
dry_run,
poll,
broadcast,
sweep,
})
}
fn parse_sweep(s: &str) -> Result<(u16, u16)> {
let (a, b) = s
.split_once('-')
.or_else(|| s.split_once(':'))
.context("--sweep wants LOW-HIGH, e.g. 0-15")?;
let lo: u16 = a.parse()?;
let hi: u16 = b.parse()?;
if lo > hi {
anyhow::bail!("--sweep low > high");
}
Ok((lo, hi))
}
fn parse_color(s: &str) -> Result<(u8, u8, u8)> {
let s = s.trim();
if let Some(hex) = s.strip_prefix('#') {
if hex.len() != 6 {
anyhow::bail!("hex colour must be #RRGGBB");
}
return Ok((
u8::from_str_radix(&hex[0..2], 16)?,
u8::from_str_radix(&hex[2..4], 16)?,
u8::from_str_radix(&hex[4..6], 16)?,
));
}
Ok(match s.to_lowercase().as_str() {
"red" => (255, 0, 0),
"green" => (0, 255, 0),
"blue" => (0, 0, 255),
"white" => (255, 255, 255),
"yellow" => (255, 200, 0),
"orange" => (255, 80, 0),
"magenta" | "pink" => (255, 0, 120),
"cyan" => (0, 255, 255),
"off" | "black" => (0, 0, 0),
other => anyhow::bail!("unknown colour {other:?} (use a name or #RRGGBB)"),
})
}
fn print_help() {
println!(
"bars — colour the four SonicPulse LED Bar 10s via direct ArtNet (one-shot)
USAGE:
bars [OPTIONS]
OPTIONS:
--node HOST:PORT ArtNet node (default {DEFAULT_NODE})
--universe N 0-indexed ArtNet universe (default {DEFAULT_UNIVERSE} = QLC+ \"Universe 3\")
--color C red | green | blue | white | yellow | orange | magenta | cyan |
off | #RRGGBB (default red)
--white N W (0-255) added to each segment alongside RGB (default 0)
--dim N master dimmer 0-255 (default 255)
--mode N ch3 mode value (default 0 direct DMX pixel control)
--repeats N send the packet N times (default 3, mitigates UDP drops)
--interval MS ms between repeats (default 50)
--dry-run print without sending
--broadcast send to the address as a broadcast (e.g. 10.0.0.255)
--poll ArtPoll the LAN, list ArtNet nodes + their universes
--sweep LOW-HIGH sweep through ArtNet universes LOW..=HIGH (e.g. 0-15)
sending the colour to each in turn, then blanking
-h, --help this help
NOTES:
Packet length is set to exactly 156 channels (4 bars × 39ch). Per the
ArtNet spec, channels 157-512 in this universe are NOT included in the
packet and the receiver must retain their previous values.
--poll binds UDP port 6454 to receive ArtPollReply. If another ArtNet
app (QLC+, OLA, dmxbackend) holds that port, close it first.
"
);
}

232
src/bin/panels.rs Normal file
View file

@ -0,0 +1,232 @@
//! One-shot tool to colour the wall-mounted panel fixtures.
//!
//! Defaults to the "Columns" group (Showtec LED Par 56) and sets them solid
//! red. Only sends updates for the matched fixtures' RGB + dimmer (+ zeroing
//! amber/white/uv on those fixtures so the colour reads pure). Nothing else
//! in the room is touched.
use anyhow::{Context, Result};
use futures_util::SinkExt;
use lightcontrol::{fetch_fixtures, http_client, Fixture, Update};
use std::time::Duration;
use tokio_tungstenite::tungstenite::Message;
const DEFAULT_HOST: &str = "dmx.cbrp3.c-base.org:8000";
const DEFAULT_MODEL: &str = "LED Par 56";
struct Args {
host: String,
model: String,
color: (u8, u8, u8),
dim: u8,
dry_run: bool,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = parse_args()?;
let base = format!("http://{}", args.host);
let ws_url = format!("ws://{}/api/v1/websocket_state/", args.host);
let client = http_client()?;
let fixtures = fetch_fixtures(&client, &base)
.await
.context("fetching fixtures")?;
let matched: Vec<&Fixture> = fixtures
.iter()
.filter(|f| f.model.eq_ignore_ascii_case(&args.model))
.collect();
if matched.is_empty() {
eprintln!("no fixtures match model {:?}", args.model);
eprintln!("available models:");
let mut models: std::collections::BTreeMap<&str, usize> =
std::collections::BTreeMap::new();
for f in &fixtures {
*models.entry(f.model.as_str()).or_default() += 1;
}
for (m, n) in models {
eprintln!(" {n:3} x {m}");
}
std::process::exit(2);
}
let updates = build_updates(&matched, args.color, args.dim);
println!(
"host: {} model: {:?} color: ({}, {}, {}) dim: {}",
args.host, args.model, args.color.0, args.color.1, args.color.2, args.dim
);
println!("matched {} fixture(s):", matched.len());
for f in &matched {
println!(" {} ({})", f.fixture_id, f.model);
}
println!("→ sending {} channel update(s)", updates.len());
if args.dry_run {
for u in &updates {
println!(" {} = {}", u.channel_id, u.value);
}
return Ok(());
}
let payload = serde_json::to_string(&updates)?;
let (ws, _) = tokio_tungstenite::connect_async(&ws_url)
.await
.with_context(|| format!("ws connect {ws_url}"))?;
let (mut write, _read) = futures_util::StreamExt::split(ws);
write
.send(Message::Text(payload))
.await
.context("send frame")?;
// Give the server a beat to apply before we close — the dmxbackend
// pushes state to all listeners on receipt, but a graceful close avoids
// a stray ConnectionReset on its side.
tokio::time::sleep(Duration::from_millis(150)).await;
let _ = write.close().await;
println!("done.");
Ok(())
}
fn build_updates(fixtures: &[&Fixture], (r, g, b): (u8, u8, u8), dim: u8) -> Vec<Update> {
let mut out = Vec::new();
for f in fixtures {
for el in &f.elements {
match el.name.as_str() {
"rgb" => {
for c in &el.channels {
let v = match c.name.as_str() {
"r" | "red" => r,
"g" | "green" => g,
"b" | "blue" => b,
_ => continue,
};
out.push(Update {
channel_id: c.channel_id.clone(),
value: v,
});
}
}
"dimmer" => {
for c in &el.channels {
out.push(Update {
channel_id: c.channel_id.clone(),
value: dim,
});
}
}
// Zero competing colour layers on the same fixture so the
// panel actually reads as the requested colour. Everything
// else (strobe / wheels / pan / tilt / etc.) is left alone.
"amber" | "white" | "warmwhite" | "uv" => {
for c in &el.channels {
out.push(Update {
channel_id: c.channel_id.clone(),
value: 0,
});
}
}
_ => {}
}
}
}
out
}
fn parse_args() -> Result<Args> {
let mut host: Option<String> = None;
let mut model: Option<String> = None;
let mut color_arg: Option<String> = None;
let mut dim: u8 = 255;
let mut dry_run = false;
let mut it = std::env::args().skip(1);
while let Some(a) = it.next() {
match a.as_str() {
"-h" | "--help" => {
print_help();
std::process::exit(0);
}
"--host" => host = it.next(),
"--model" => model = it.next(),
"--color" => color_arg = it.next(),
"--dim" => {
if let Some(v) = it.next() {
dim = v.parse().context("--dim expects 0-255")?;
}
}
"--dry-run" => dry_run = true,
other if other.starts_with("--host=") => {
host = Some(other.trim_start_matches("--host=").to_string());
}
other if other.starts_with("--model=") => {
model = Some(other.trim_start_matches("--model=").to_string());
}
other if other.starts_with("--color=") => {
color_arg = Some(other.trim_start_matches("--color=").to_string());
}
other if other.starts_with("--dim=") => {
dim = other.trim_start_matches("--dim=").parse()?;
}
_ => {
eprintln!("unknown argument: {a}");
print_help();
std::process::exit(2);
}
}
}
let host = host
.or_else(|| std::env::var("DMX_HOST").ok())
.unwrap_or_else(|| DEFAULT_HOST.to_string());
let model = model.unwrap_or_else(|| DEFAULT_MODEL.to_string());
let color = parse_color(color_arg.as_deref().unwrap_or("red"))?;
Ok(Args { host, model, color, dim, dry_run })
}
fn parse_color(s: &str) -> Result<(u8, u8, u8)> {
let s = s.trim();
if let Some(hex) = s.strip_prefix('#') {
if hex.len() != 6 {
anyhow::bail!("hex colour must be #RRGGBB");
}
let r = u8::from_str_radix(&hex[0..2], 16)?;
let g = u8::from_str_radix(&hex[2..4], 16)?;
let b = u8::from_str_radix(&hex[4..6], 16)?;
return Ok((r, g, b));
}
Ok(match s.to_lowercase().as_str() {
"red" => (255, 0, 0),
"green" => (0, 255, 0),
"blue" => (0, 0, 255),
"white" => (255, 255, 255),
"yellow" => (255, 200, 0),
"orange" => (255, 80, 0),
"magenta" | "pink" => (255, 0, 120),
"cyan" => (0, 255, 255),
"off" | "black" => (0, 0, 0),
other => anyhow::bail!("unknown colour {other:?} (use a name or #RRGGBB)"),
})
}
fn print_help() {
println!(
"panels — colour the wall panel fixtures (one-shot)
USAGE:
panels [OPTIONS]
OPTIONS:
--host HOST backend host:port (default {DEFAULT_HOST})
--model NAME fixture model to target (default {DEFAULT_MODEL:?})
--color C red | green | blue | white | yellow | orange |
magenta | cyan | off | #RRGGBB (default red)
--dim N dimmer value 0-255 (default 255)
--dry-run print the updates without sending them
-h, --help show this help
"
);
}

64
src/lib.rs Normal file
View file

@ -0,0 +1,64 @@
pub mod beat;
pub mod matelight;
mod presets;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::time::Duration;
pub use beat::{
shared as shared_beat, snapshot as snapshot_beat, status_line as beat_status,
spawn as spawn_beat, BeatInput, BeatState, SharedBeat,
};
pub use matelight::{
shared as shared_matelight, MatelightFrame, SharedMatelight, DEFAULT_MATELIGHT_WS,
};
pub use presets::{render_frame, render_frame_with, Preset, PRESETS};
#[derive(Deserialize, Clone, Debug)]
pub struct Channel {
pub name: String,
pub channel_id: String,
}
#[derive(Deserialize, Clone, Debug)]
pub struct Element {
pub name: String,
pub channels: Vec<Channel>,
}
#[derive(Deserialize, Clone, Debug)]
pub struct Fixture {
pub fixture_id: String,
pub model: String,
#[serde(default)]
pub pos_x: f32,
#[serde(default)]
pub pos_y: f32,
pub elements: Vec<Element>,
}
#[derive(Serialize, Clone, Debug)]
pub struct Update {
pub channel_id: String,
pub value: u8,
}
pub async fn fetch_fixtures(client: &reqwest::Client, base: &str) -> Result<Vec<Fixture>> {
let url = format!("{base}/api/v1/fixtures/");
let resp = client
.get(&url)
.send()
.await
.with_context(|| format!("GET {url}"))?
.error_for_status()?
.json::<Vec<Fixture>>()
.await?;
Ok(resp)
}
pub fn http_client() -> Result<reqwest::Client> {
Ok(reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()?)
}

665
src/main.rs Normal file
View file

@ -0,0 +1,665 @@
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use crossterm::ExecutableCommand;
use futures_util::{SinkExt, StreamExt};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::layout::Rect;
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Sparkline};
use ratatui::{Frame, Terminal};
use std::collections::VecDeque;
use std::io;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use tokio_tungstenite::tungstenite::Message;
use lightcontrol::matelight::{feeder as matelight_feeder, StatusFn};
use lightcontrol::{
beat_status, fetch_fixtures, http_client, render_frame_with, shared_beat, shared_matelight,
snapshot_beat, spawn_beat, Preset, SharedBeat, SharedMatelight, DEFAULT_MATELIGHT_WS, PRESETS,
};
const DEFAULT_HOST: &str = "dmx.cbrp3.c-base.org:8000";
const TARGET_FPS: f32 = 25.0;
#[derive(Clone)]
struct AppState {
host: String,
preset: Preset,
selected_idx: usize,
fps: f32,
frame_count: u64,
connected: bool,
fixture_count: usize,
status: String,
matelight_status: String,
mic_status: String,
// Live beat-detector visualisation state (pumped from the mic thread's
// shared `BeatState` at ~20 Hz so the meter feels responsive).
beat_envelope: f32, // current short envelope, 0..~1
beat_age: Option<f32>, // seconds since last detected beat
beat_count: u64, // monotonic counter
beat_bpm: f32, // median-of-8 BPM estimate
beat_history: VecDeque<u64>, // last 80 envelope samples × 1000
}
const BEAT_HISTORY_LEN: usize = 80;
#[tokio::main]
async fn main() -> Result<()> {
let Args { host, preset, check, matelight_url } = parse_args();
if check {
return check_fixtures(&host).await;
}
let preset_idx = PRESETS.iter().position(|p| *p == preset).unwrap_or(0);
let state = Arc::new(Mutex::new(AppState {
host: host.clone(),
preset,
selected_idx: preset_idx,
fps: 0.0,
frame_count: 0,
connected: false,
fixture_count: 0,
status: "starting…".to_string(),
matelight_status: "matelight: not started".to_string(),
mic_status: "mic: not started".to_string(),
beat_envelope: 0.0,
beat_age: None,
beat_count: 0,
beat_bpm: 0.0,
beat_history: VecDeque::with_capacity(BEAT_HISTORY_LEN),
}));
// Spawn microphone capture + beat detector (own thread, owns cpal stream).
let beat = shared_beat();
spawn_beat(beat.clone());
let matelight = shared_matelight();
let matelight_status_cb: StatusFn = {
let state = state.clone();
Arc::new(move |msg| {
// Stash the latest matelight status string for the TUI footer.
// Brief lock; never awaits user input.
if let Ok(mut s) = state.try_lock() {
s.matelight_status = msg;
}
})
};
let matelight_handle = tokio::spawn(matelight_feeder(
matelight_url.clone(),
matelight.clone(),
matelight_status_cb,
));
let engine_handle = tokio::spawn(engine_task(
state.clone(),
host,
matelight.clone(),
beat.clone(),
));
// Pump beat-detector state into the TUI at 20Hz so the level meter and
// sparkline feel live. The mic_status text and the beat-viz fields are
// updated in the same tick so they don't drift apart.
let mic_status_handle = {
let state = state.clone();
let beat = beat.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_millis(50));
loop {
tick.tick().await;
let now = Instant::now();
let (envelope, age, count, bpm) = {
let bs = beat.lock().unwrap();
let age = bs.last_beat.map(|t| now.duration_since(t).as_secs_f32());
(bs.energy, age, bs.beat_count, bs.bpm)
};
// Status line text — done separately so we don't double-lock.
let line = beat_status(&beat);
let mut s = state.lock().await;
s.mic_status = line;
s.beat_envelope = envelope;
s.beat_age = age;
s.beat_count = count;
s.beat_bpm = bpm;
// Scale 0..1 envelope into 0..1000 for the sparkline buffer.
// Boost a bit (×2.5) so quiet rooms still show some shape —
// the sparkline auto-scales against `max`.
let sample = ((envelope * 2500.0).clamp(0.0, 1000.0)) as u64;
s.beat_history.push_back(sample);
while s.beat_history.len() > BEAT_HISTORY_LEN {
s.beat_history.pop_front();
}
}
})
};
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
std::thread::spawn(move || loop {
match event::poll(Duration::from_millis(100)) {
Ok(true) => match event::read() {
Ok(ev) => {
if tx.send(ev).is_err() {
return;
}
}
Err(_) => return,
},
Ok(false) => {}
Err(_) => return,
}
});
enable_raw_mode()?;
let mut stdout = io::stdout();
stdout.execute(EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut tick = tokio::time::interval(Duration::from_millis(100));
let outcome: Result<()> = async {
loop {
let snap = state.lock().await.clone();
terminal.draw(|f| render_ui(f, &snap))?;
tokio::select! {
_ = tick.tick() => {}
Some(ev) = rx.recv() => {
if let Event::Key(key) = ev {
if (matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C')))
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
break;
}
let mut s = state.lock().await;
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break,
KeyCode::Char('1') => set_preset(&mut s, Preset::Rainbows),
KeyCode::Char('2') => set_preset(&mut s, Preset::BlueHouse),
KeyCode::Char('3') => set_preset(&mut s, Preset::Disco70),
KeyCode::Char('4') => set_preset(&mut s, Preset::Jungle),
KeyCode::Char('5') => set_preset(&mut s, Preset::Lasertec),
KeyCode::Char('6') => set_preset(&mut s, Preset::Police),
KeyCode::Char('7') => set_preset(&mut s, Preset::MirrorBall),
KeyCode::Char('8') => set_preset(&mut s, Preset::MorningLight),
KeyCode::Char('9') => set_preset(&mut s, Preset::OrangeGreenBall),
KeyCode::Char('m') | KeyCode::Char('M') => {
set_preset(&mut s, Preset::Matelight);
}
KeyCode::Char('s') | KeyCode::Char('S') => {
set_preset(&mut s, Preset::GabbaStrobe);
}
KeyCode::Char('r') | KeyCode::Char('R') => {
set_preset(&mut s, Preset::RedStrobe);
}
// Capital L for blue (b/B is blackout).
KeyCode::Char('l') | KeyCode::Char('L') => {
set_preset(&mut s, Preset::BlueStrobe);
}
KeyCode::Char('p') | KeyCode::Char('P') => {
set_preset(&mut s, Preset::PinkStrobe);
}
KeyCode::Char('k') | KeyCode::Char('K') => {
set_preset(&mut s, Preset::BeatPulse);
}
KeyCode::Char('0') | KeyCode::Char('b') | KeyCode::Char('B') => {
set_preset(&mut s, Preset::Off);
}
KeyCode::Up if s.selected_idx > 0 => {
s.selected_idx -= 1;
}
KeyCode::Down if s.selected_idx + 1 < PRESETS.len() => {
s.selected_idx += 1;
}
KeyCode::Enter | KeyCode::Char(' ') => {
s.preset = PRESETS[s.selected_idx];
}
_ => {}
}
}
}
}
}
Ok(())
}
.await;
disable_raw_mode().ok();
terminal.backend_mut().execute(LeaveAlternateScreen).ok();
terminal.show_cursor().ok();
engine_handle.abort();
matelight_handle.abort();
mic_status_handle.abort();
outcome
}
struct Args {
host: String,
preset: Preset,
check: bool,
matelight_url: String,
}
fn parse_args() -> Args {
let mut host: Option<String> = None;
let mut preset = Preset::Off;
let mut check = false;
let mut matelight_url: Option<String> = None;
let mut it = std::env::args().skip(1);
while let Some(a) = it.next() {
match a.as_str() {
"--host" => host = it.next(),
"--preset" => {
if let Some(v) = it.next() {
preset = preset_from_str(&v).unwrap_or(preset);
}
}
"--matelight" => matelight_url = it.next(),
"--check" => check = true,
other if other.starts_with("--host=") => {
host = Some(other.trim_start_matches("--host=").to_string());
}
other if other.starts_with("--preset=") => {
let v = other.trim_start_matches("--preset=");
preset = preset_from_str(v).unwrap_or(preset);
}
other if other.starts_with("--matelight=") => {
matelight_url = Some(other.trim_start_matches("--matelight=").to_string());
}
other if !other.starts_with('-') && host.is_none() => {
host = Some(other.to_string());
}
_ => {}
}
}
let host = host
.or_else(|| std::env::var("DMX_HOST").ok())
.unwrap_or_else(|| DEFAULT_HOST.to_string());
let matelight_url = matelight_url
.or_else(|| std::env::var("MATELIGHT_URL").ok())
.unwrap_or_else(|| DEFAULT_MATELIGHT_WS.to_string());
Args { host, preset, check, matelight_url }
}
fn preset_from_str(s: &str) -> Option<Preset> {
match s.to_lowercase().as_str() {
"off" | "blackout" | "0" => Some(Preset::Off),
"rainbow" | "rainbows" | "1" => Some(Preset::Rainbows),
"blue" | "bluehouse" | "blue-house" | "2" => Some(Preset::BlueHouse),
"disco" | "disco70" | "70th" | "70th-disco" | "3" => Some(Preset::Disco70),
"jungle" | "4" => Some(Preset::Jungle),
"lasertec" | "laser" | "5" => Some(Preset::Lasertec),
"police" | "polizei" | "6" => Some(Preset::Police),
"mirror" | "mirrorball" | "mirror-ball" | "discoball" | "kugel" | "7" => {
Some(Preset::MirrorBall)
}
"morning" | "morninglight" | "morning-light" | "sunrise" | "dawn" | "sonnenaufgang"
| "morgen" | "8" => Some(Preset::MorningLight),
"orangegreen" | "orange-green" | "orange" | "green" | "orangeball" | "ogball"
| "kürbis" | "kuerbis" | "9" => Some(Preset::OrangeGreenBall),
"matelight" | "mate" | "sync" | "m" => Some(Preset::Matelight),
"gabba" | "strobe" | "strobo" | "stroboscope" | "s" => Some(Preset::GabbaStrobe),
"redstrobe" | "red-strobe" | "rot" | "rotstrobe" | "rot-strobe" | "red" | "r"
=> Some(Preset::RedStrobe),
// Note: bare "blue" is already taken by the slow BlueHouse preset (2).
// The fast strobe needs the explicit "-strobe" or German "blau" alias.
"bluestrobe" | "blue-strobe" | "blau" | "blaustrobe" | "blau-strobe" | "l"
=> Some(Preset::BlueStrobe),
"pinkstrobe" | "pink-strobe" | "pink" | "p" | "magenta"
=> Some(Preset::PinkStrobe),
"beat" | "beatpulse" | "beat-pulse" | "kick" | "mic" | "audio" | "k"
=> Some(Preset::BeatPulse),
_ => None,
}
}
fn set_preset(s: &mut AppState, p: Preset) {
s.preset = p;
if let Some(i) = PRESETS.iter().position(|x| *x == p) {
s.selected_idx = i;
}
}
fn render_ui(f: &mut Frame, s: &AppState) {
let area = f.area();
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(7), // header
Constraint::Length(5), // beat detector viz
Constraint::Min(1), // preset list
Constraint::Length(3), // help
])
.split(area);
let conn_color = if s.connected { Color::Green } else { Color::Red };
let conn_text = if s.connected { "connected" } else { "disconnected" };
let header = Paragraph::new(vec![
Line::from(vec![
Span::raw("host: "),
Span::styled(s.host.clone(), Style::default().fg(Color::Cyan)),
Span::raw(" ["),
Span::styled(
conn_text,
Style::default()
.fg(conn_color)
.add_modifier(Modifier::BOLD),
),
Span::raw("]"),
]),
Line::from(format!(
"fixtures: {} frames: {} fps: {:.1}",
s.fixture_count, s.frame_count, s.fps
)),
Line::from(format!("status: {}", s.status)),
Line::from(s.matelight_status.clone()),
Line::from(s.mic_status.clone()),
])
.block(
Block::default()
.borders(Borders::ALL)
.title(" c-base lightcontrol "),
);
f.render_widget(header, outer[0]);
render_beat_panel(f, outer[1], s);
let items: Vec<ListItem> = PRESETS
.iter()
.enumerate()
.map(|(i, p)| {
let active = *p == s.preset;
let hovered = i == s.selected_idx;
let cursor = if hovered { "" } else { " " };
let style = if active {
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else if hovered {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
ListItem::new(Line::from(vec![
Span::raw(format!("{cursor} [{}] ", p.key())),
Span::raw(p.label()),
]))
.style(style)
})
.collect();
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.title(" presets "),
);
f.render_widget(list, outer[2]);
let help = Paragraph::new(Line::from(
" 1-9 / m / s r L p / k(beat) select ↑/↓ + enter 0/b blackout q quit",
))
.block(Block::default().borders(Borders::ALL));
f.render_widget(help, outer[3]);
}
/// Renders the beat-detector panel: flash bar that lights up on every beat,
/// BPM + count text on the left, a rolling sparkline of the audio envelope
/// on the right. The whole thing makes it instantly visible whether the
/// detector is locked onto the music or just chasing room noise.
fn render_beat_panel(f: &mut Frame, area: Rect, s: &AppState) {
let split = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(34), Constraint::Min(20)])
.split(area);
// --- Left: flash bar + BPM + counters -------------------------------
// Flash level decays exponentially from each detected beat. 6/s means
// ~115 ms half-life — bright on the hit, gone in ~400 ms.
let flash = s
.beat_age
.map(|t| (-t * 6.0).exp().clamp(0.0, 1.0))
.unwrap_or(0.0);
// Cycle a hue palette per beat so consecutive flashes are visually
// distinct — mirrors what the BeatPulse preset actually does to the
// lights.
let palette: [Color; 6] = [
Color::Red,
Color::LightYellow,
Color::Green,
Color::Cyan,
Color::Magenta,
Color::LightBlue,
];
let beat_color = palette[(s.beat_count as usize) % palette.len()];
let dim_color = if flash > 0.6 {
beat_color
} else if flash > 0.2 {
Color::Yellow
} else if flash > 0.05 {
Color::DarkGray
} else {
Color::Black
};
// ~13 wide bar made of full blocks; brighter on the hit.
let flash_label = if flash > 0.05 {
Span::styled(
"█████████████",
Style::default().fg(dim_color).add_modifier(Modifier::BOLD),
)
} else {
Span::styled("░░░░░░░░░░░░░", Style::default().fg(Color::DarkGray))
};
// Level bar — the same envelope the sparkline plots, but as an
// instantaneous gauge so you can watch it climb and dip in realtime.
let level_width: usize = 20;
let lvl = (s.beat_envelope * 2.5).clamp(0.0, 1.0); // same boost as sparkline
let filled = (lvl * level_width as f32).round() as usize;
let mut level_bar = String::with_capacity(level_width);
for i in 0..level_width {
level_bar.push(if i < filled { '█' } else { '░' });
}
let level_color = if lvl > 0.85 {
Color::LightRed
} else if lvl > 0.5 {
Color::Yellow
} else {
Color::Green
};
let bpm_text = if s.beat_bpm > 0.0 {
format!("{:6.1} BPM", s.beat_bpm)
} else {
" --- BPM".to_string()
};
let age_text = match s.beat_age {
Some(a) if a < 9.9 => format!("{:.2}s ago", a),
_ => "".to_string(),
};
let left = Paragraph::new(vec![
Line::from(vec![Span::raw("BEAT "), flash_label]),
Line::from(vec![
Span::raw("level "),
Span::styled(level_bar, Style::default().fg(level_color)),
]),
Line::from(format!(
"{} beats={:<5} last {}",
bpm_text, s.beat_count, age_text
)),
])
.block(
Block::default()
.borders(Borders::ALL)
.title(" beat detector "),
);
f.render_widget(left, split[0]);
// --- Right: rolling envelope sparkline ------------------------------
let history: Vec<u64> = s.beat_history.iter().copied().collect();
let spark = Sparkline::default()
.block(
Block::default()
.borders(Borders::ALL)
.title(" envelope (last ~4 s) "),
)
.data(&history)
.style(Style::default().fg(Color::Cyan))
.max(1000);
f.render_widget(spark, split[1]);
}
async fn engine_task(
state: Arc<Mutex<AppState>>,
host: String,
matelight: SharedMatelight,
beat: SharedBeat,
) {
let base = format!("http://{host}");
let ws_url = format!("ws://{host}/api/v1/websocket_state/");
let client = match http_client() {
Ok(c) => c,
Err(e) => {
set_status(&state, format!("client init: {e}"), false).await;
return;
}
};
loop {
set_status(&state, "fetching fixtures…", false).await;
let fixtures = match fetch_fixtures(&client, &base).await {
Ok(f) => f,
Err(e) => {
set_status(&state, format!("fixture fetch: {e}"), false).await;
tokio::time::sleep(Duration::from_secs(3)).await;
continue;
}
};
{
let mut s = state.lock().await;
s.fixture_count = fixtures.len();
}
set_status(&state, "connecting websocket…", false).await;
let stream = match tokio_tungstenite::connect_async(&ws_url).await {
Ok((s, _)) => s,
Err(e) => {
set_status(&state, format!("ws connect: {e}"), false).await;
tokio::time::sleep(Duration::from_secs(3)).await;
continue;
}
};
let (mut write, mut read) = stream.split();
// Server sends a full-state snapshot on connect — drain it.
let _ = tokio::time::timeout(Duration::from_millis(800), read.next()).await;
set_status(&state, "running", true).await;
let start = Instant::now();
let frame_dt = Duration::from_secs_f32(1.0 / TARGET_FPS);
let mut next = Instant::now() + frame_dt;
let mut window_start = Instant::now();
let mut window_frames: u32 = 0;
let drop_err: Option<String> = loop {
let preset = { state.lock().await.preset };
let t = start.elapsed().as_secs_f32();
let now = Instant::now();
let beat_input = snapshot_beat(&beat, now);
let mate_guard = matelight.lock().await;
let updates =
render_frame_with(preset, t, &fixtures, mate_guard.as_ref(), beat_input);
drop(mate_guard);
let payload = match serde_json::to_string(&updates) {
Ok(p) => p,
Err(e) => break Some(format!("encode: {e}")),
};
if let Err(e) = write.send(Message::Text(payload)).await {
break Some(format!("send: {e}"));
}
window_frames += 1;
if window_start.elapsed() >= Duration::from_millis(500) {
let secs = window_start.elapsed().as_secs_f32().max(1e-3);
let fps = window_frames as f32 / secs;
let mut s = state.lock().await;
s.fps = fps;
s.frame_count += window_frames as u64;
window_frames = 0;
window_start = Instant::now();
}
let now = Instant::now();
if next > now {
tokio::time::sleep(next - now).await;
}
next += frame_dt;
let now = Instant::now();
if now > next + frame_dt * 4 {
next = now + frame_dt;
}
};
let _ = write.close().await;
let msg = drop_err.unwrap_or_else(|| "websocket closed".to_string());
set_status(&state, format!("reconnect in 2s — {msg}"), false).await;
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
async fn check_fixtures(host: &str) -> Result<()> {
let client = http_client()?;
let base = format!("http://{host}");
let fixtures = fetch_fixtures(&client, &base).await?;
println!("host: {host}");
println!("fixtures: {}", fixtures.len());
let total_chans: usize = fixtures.iter()
.flat_map(|f| f.elements.iter().map(|e| e.channels.len()))
.sum();
println!("channels total: {total_chans}");
let mut models: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
for f in &fixtures {
*models.entry(f.model.clone()).or_default() += 1;
}
println!("models:");
for (m, n) in &models {
println!(" {n:3} x {m}");
}
let mut elements: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for f in &fixtures {
for e in &f.elements {
elements.insert(e.name.clone());
}
}
println!("element names: {:?}", elements);
println!("\nfirst fixture sample:");
if let Some(f) = fixtures.first() {
println!(" {} ({}) @({},{})", f.fixture_id, f.model, f.pos_x, f.pos_y);
for e in &f.elements {
let chans: Vec<&str> = e.channels.iter().map(|c| c.name.as_str()).collect();
println!(" {}: [{}]", e.name, chans.join(", "));
for c in &e.channels {
println!(" {} -> {}", c.name, c.channel_id);
}
}
}
Ok(())
}
async fn set_status(state: &Arc<Mutex<AppState>>, msg: impl Into<String>, connected: bool) {
let mut s = state.lock().await;
s.status = msg.into();
s.connected = connected;
}

238
src/matelight.rs Normal file
View file

@ -0,0 +1,238 @@
//! Matelight live frame feeder.
//!
//! Connects to the Matelight hardware controller's `/monitor` WebSocket
//! (subprotocol `matemon`), parses JSON config + Brightness messages and
//! binary RGB framebuffer pushes, and keeps a shared `MatelightFrame` up
//! to date. The `Matelight` preset samples from this frame to colour the
//! hall lights in real time.
//!
//! Wire format (reverse-engineered from the live server's `/monitor` HTML):
//! - On connect, server sends JSON: `{"Screen":{"Crates_X":8,"Crates_Y":4,
//! "CrateSize_X":5,"CrateSize_Y":4}}` and `{"Brightness":<f64>}`.
//! - Subsequent binary messages: `width*height*3` bytes of row-major RGB
//! (40×16 = 1920 bytes for the standard rig).
use anyhow::{anyhow, Context, Result};
use futures_util::StreamExt;
use serde::Deserialize;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::Message;
pub const DEFAULT_MATELIGHT_WS: &str = "ws://matelight.cbrp3.c-base.org:8081/monitor";
#[derive(Clone, Debug)]
pub struct MatelightFrame {
pub width: usize,
pub height: usize,
/// 0..=255, mirrors the matelight's master brightness slider.
pub brightness: u8,
/// Row-major RGB triples. `pixels.len() == width * height * 3`.
pub pixels: Vec<u8>,
}
impl MatelightFrame {
fn pixel(&self, x: usize, y: usize) -> (u8, u8, u8) {
let i = (y * self.width + x) * 3;
(self.pixels[i], self.pixels[i + 1], self.pixels[i + 2])
}
/// Sample a vertical strip of the matelight centred on the column that
/// maps to `t` (0.0 = left edge, 1.0 = right edge). Returns the average
/// RGB of that strip across all rows. `half_band` controls the width:
/// 0 = single column, 1 = ±1 col, 2 = ±2 cols, etc.
pub fn sample_column(&self, t: f32, half_band: usize) -> (u8, u8, u8) {
if self.pixels.is_empty() {
return (0, 0, 0);
}
let t = t.clamp(0.0, 1.0);
let center = ((t * (self.width.saturating_sub(1)) as f32).round() as isize)
.clamp(0, self.width as isize - 1) as usize;
let lo = center.saturating_sub(half_band);
let hi = (center + half_band).min(self.width - 1);
let mut r_sum: u32 = 0;
let mut g_sum: u32 = 0;
let mut b_sum: u32 = 0;
let mut count: u32 = 0;
for y in 0..self.height {
for x in lo..=hi {
let (r, g, b) = self.pixel(x, y);
r_sum += r as u32;
g_sum += g as u32;
b_sum += b as u32;
count += 1;
}
}
if count == 0 {
return (0, 0, 0);
}
((r_sum / count) as u8, (g_sum / count) as u8, (b_sum / count) as u8)
}
}
pub type SharedMatelight = Arc<Mutex<Option<MatelightFrame>>>;
pub fn shared() -> SharedMatelight {
Arc::new(Mutex::new(None))
}
#[derive(Deserialize)]
struct ScreenMsg {
#[serde(rename = "Screen")]
screen: Option<ScreenSpec>,
#[serde(rename = "Brightness")]
brightness: Option<f64>,
}
#[derive(Deserialize)]
struct ScreenSpec {
#[serde(rename = "Crates_X")]
crates_x: usize,
#[serde(rename = "Crates_Y")]
crates_y: usize,
#[serde(rename = "CrateSize_X")]
crate_size_x: usize,
#[serde(rename = "CrateSize_Y")]
crate_size_y: usize,
}
/// Status callback signature: receives short human-readable status updates
/// (e.g. "matelight: connected 40x16"). Engine plumbs these into the TUI.
pub type StatusFn = Arc<dyn Fn(String) + Send + Sync>;
/// Connects to the matelight monitor websocket and pushes frames into
/// `shared` until cancelled. Reconnects on error with a 3s backoff.
pub async fn feeder(url: String, shared: SharedMatelight, status: StatusFn) {
loop {
status(format!("matelight: connecting {url}"));
match feeder_once(&url, &shared, &status).await {
Ok(()) => status("matelight: stream ended".to_string()),
Err(e) => status(format!("matelight: {e}")),
}
// Drop the last frame so the renderer goes back to "no signal".
*shared.lock().await = None;
tokio::time::sleep(Duration::from_secs(3)).await;
}
}
async fn feeder_once(url: &str, shared: &SharedMatelight, status: &StatusFn) -> Result<()> {
// Hand-build a request so we can attach the `matemon` subprotocol header
// (the server JS uses `new WebSocket(url, 'matemon')`).
let mut req = url.into_client_request().context("invalid matelight URL")?;
req.headers_mut().insert(
"Sec-WebSocket-Protocol",
"matemon".parse().expect("static header value"),
);
let (stream, _) = tokio_tungstenite::connect_async(req)
.await
.context("matelight ws connect")?;
let (_write, mut read) = stream.split();
let mut width = 0usize;
let mut height = 0usize;
let mut brightness: u8 = 255;
while let Some(msg) = read.next().await {
match msg.context("matelight ws read")? {
Message::Text(t) => {
let parsed: ScreenMsg =
serde_json::from_str(&t).context("matelight ws json decode")?;
if let Some(s) = parsed.screen {
width = s.crates_x * s.crate_size_x;
height = s.crates_y * s.crate_size_y;
status(format!("matelight: connected {width}x{height}"));
}
if let Some(b) = parsed.brightness {
// The server's "Brightness" message is a 0..1 float (the
// hardware controller normalises it from the 0..255 byte).
brightness = (b.clamp(0.0, 1.0) * 255.0).round() as u8;
}
}
Message::Binary(buf) => {
if width == 0 || height == 0 {
// Got pixels before the Screen config — ignore until we
// know the geometry.
continue;
}
let expected = width * height * 3;
if buf.len() != expected {
// Wrong-size frame: don't trust it, but don't kill the
// stream either.
status(format!(
"matelight: bad frame {} bytes (want {})",
buf.len(),
expected
));
continue;
}
let frame = MatelightFrame {
width,
height,
brightness,
pixels: buf.to_vec(),
};
*shared.lock().await = Some(frame);
}
Message::Close(_) => return Err(anyhow!("server closed connection")),
// Pings/pongs/frame fragments — the tungstenite default handler
// takes care of pongs; nothing else to do.
_ => {}
}
}
Err(anyhow!("ws stream ended without close frame"))
}
#[cfg(test)]
mod tests {
use super::*;
fn solid_frame(w: usize, h: usize, r: u8, g: u8, b: u8) -> MatelightFrame {
let mut pixels = Vec::with_capacity(w * h * 3);
for _ in 0..(w * h) {
pixels.push(r);
pixels.push(g);
pixels.push(b);
}
MatelightFrame { width: w, height: h, brightness: 255, pixels }
}
#[test]
fn sample_column_returns_solid_colour() {
let f = solid_frame(40, 16, 200, 80, 30);
assert_eq!(f.sample_column(0.0, 2), (200, 80, 30));
assert_eq!(f.sample_column(0.5, 2), (200, 80, 30));
assert_eq!(f.sample_column(1.0, 2), (200, 80, 30));
}
#[test]
fn sample_column_picks_correct_side() {
// Left half red, right half blue. Half-band 0 → single column.
let w = 40;
let h = 16;
let mut pixels = Vec::with_capacity(w * h * 3);
for _ in 0..h {
for x in 0..w {
if x < w / 2 {
pixels.extend_from_slice(&[255, 0, 0]);
} else {
pixels.extend_from_slice(&[0, 0, 255]);
}
}
}
let f = MatelightFrame { width: w, height: h, brightness: 255, pixels };
let (lr, _, lb) = f.sample_column(0.05, 0);
let (rr, _, rb) = f.sample_column(0.95, 0);
assert!(lr > 200 && lb < 20, "left should be red, got ({lr},_,{lb})");
assert!(rb > 200 && rr < 20, "right should be blue, got ({rr},_,{rb})");
}
#[test]
fn sample_handles_empty_gracefully() {
let f = MatelightFrame { width: 0, height: 0, brightness: 0, pixels: vec![] };
assert_eq!(f.sample_column(0.5, 2), (0, 0, 0));
}
}

1458
src/presets.rs Normal file

File diff suppressed because it is too large Load diff