From ae66f798ba9a63639e0969d43edd74a8a10eea2c Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:28:59 -0800 Subject: [PATCH 1/3] added docs --- Cargo.lock | 24 ++-- src/app.rs | 76 +++++++++++++ src/audio.rs | 18 +++ src/config.rs | 125 +++++++++++++++++++++ src/event_handler.rs | 17 +++ src/graphical_layout.rs | 235 ++++++++++++++++++++++++++++++++------- src/layout_visualizer.rs | 29 +++++ src/main.rs | 29 +++++ src/nanoleaf.rs | 33 ++++++ src/panic.rs | 5 + src/processing.rs | 39 +++++++ src/ssdp.rs | 18 +++ src/utils.rs | 103 +++++++++++++++++ src/visualizer.rs | 56 ++++++++++ 14 files changed, 755 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dd6dd05..908fb71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -527,9 +527,9 @@ checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" [[package]] name = "flate2" -version = "1.1.7" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2152dbcb980c05735e2a651d96011320a949eb31a0c8b38b72645ce97dec676" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" dependencies = [ "crc32fast", "miniz_oxide", @@ -869,9 +869,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -883,9 +883,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -1675,9 +1675,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "b6eff9328d40131d43bd911d42d79eb6a47312002a4daefc9e37f17e74a7701a" dependencies = [ "base64", "bytes", @@ -1949,9 +1949,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "siphasher" @@ -2257,9 +2257,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.10.0", "bytes", diff --git a/src/app.rs b/src/app.rs index e004ce1..8f7bae3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -93,6 +93,25 @@ pub struct App { } impl App { + /// Constructs a new `App` for TUI-based Nanoleaf effect selection and audio visualizer. + /// + /// Initializes: + /// - Effect list from device API, selects current effect if running. + /// - Visualizer with audio stream, UDP, config params, starts processing thread via `init()`. + /// - State to EffectList view, running effects mode. + /// - Palette names from predefined for switching. + /// - Colorful names from TUI config. + /// - Fetches device global orientation for panel sorting. + /// + /// # Arguments + /// + /// * `nl_device` - Connected Nanoleaf device. + /// * `tui_config` - UI settings like colorful effect names. + /// * `visualizer_config` - Audio/viz params like gain, hues, sorting. + /// + /// # Errors + /// + /// From device API, visualizer new/init, or effect list fetch. pub fn new( nl_device: NlDevice, tui_config: TuiConfig, @@ -166,6 +185,21 @@ impl App { }) } + /// Executes the main TUI application loop. + /// + /// Creates event handler for key/tick events. + /// Loop: draw current view, receive event, map to AppMsg, update app state. + /// Breaks when state=Done (quit). + /// On exit, sends End msg to visualizer to stop audio/UDP thread. + /// + /// # Arguments + /// + /// * `self` - Mutable app state. + /// * `terminal` - Ratatui terminal for rendering frames. + /// + /// # Errors + /// + /// From event recv, update logic, or draw. pub fn run(&mut self, terminal: &mut Terminal) -> Result<()> { let event_handler = event_handler::EventHandler::new(); loop { @@ -181,6 +215,24 @@ impl App { Ok(()) } + /// Converts raw terminal events to `AppMsg` for state updates. + /// + /// Ignores ticks (NoOp). + /// Maps KeyEvent codes to actions: + /// - ESC/Q: Quit + /// - Enter: Play selected effect in list view + /// - Up/Down/j/k: Scroll list + /// - g/G: Scroll top/bottom + /// - V: Toggle EffectList <-> Visualizer view + /// - ?: Toggle help screen + /// - +/-=_ : Adjust visualizer gain by ±0.05 + /// - 0-9: Switch to numbered palette + /// - a/A: Toggle primary axis X/Y + /// - p/P: Toggle primary sort Asc/Desc + /// - s/S: Toggle secondary sort Asc/Desc + /// - Defaults to NoOp for unhandled. + /// + /// View-specific logic, e.g., scroll only in EffectList. fn event_to_msg(&self, event: Event) -> AppMsg { match event { Event::Tick => AppMsg::NoOp, @@ -335,6 +387,22 @@ impl App { } } + /// Applies an `AppMsg` to update application state, views, or external components. + /// + /// Match on msg type: + /// - NoOp/Quit: Idle or set Done state. + /// - Scroll: Adjust effect list selection and scrollbar position. + /// - View change: Switch views, pause/resume visualizer or effects mode, enable UDP if needed. + /// - PlayEffect: Calls device.play_effect by selected name. + /// - ChangeGain/Palette: Send SetGain/SetPalette to visualizer tx. + /// - Toggles (Axis/Sorts): Flip enum, send SetSorting with current params to visualizer. + /// + /// Syncs list state with scrollbar for rendering. + /// Ensures state transitions (e.g., resume viz only if switching to it). + /// + /// # Errors + /// + /// From device API calls or visualizer msg send. fn update(&mut self, msg: AppMsg) -> Result<()> { match msg { AppMsg::NoOp => Ok(()), @@ -467,6 +535,14 @@ impl App { } } + /// Renders the active view (effect list, visualizer, or help) into the terminal frame. + /// + /// Creates bordered main block with device name in magenta left title, right-aligned "? for help". + /// Dispatches to view-specific rendering: + /// - `EffectList`: Stateful list of NlEffect items, optional colorful char styling via palette, + /// highlight with >> symbol, synced scrollbar with padding. + /// - Other views (Visualizer/HelpScreen): Implementation details for spectrum display or key bindings. + /// - Updates scrollbar state post-render if needed. fn render_view(&mut self, frame: &mut Frame) { let main_block = Block::new() .borders(Borders::ALL) diff --git a/src/audio.rs b/src/audio.rs index cc37b50..890c6c5 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -9,6 +9,24 @@ pub struct AudioStream { } impl AudioStream { + /// Creates a new `AudioStream` instance for capturing audio from an input device. + /// + /// Prioritizes loopback/monitor devices for system-wide audio capture (e.g., BlackHole on macOS). + /// Searches common names like "BlackHole", "Loopback Audio", etc. + /// Falls back to the system's default input device (often microphone) with a warning. + /// Supports specifying a custom device name via `device_name`. + /// + /// # Arguments + /// + /// * `device_name` - Optional name of the audio device to use. Defaults to automatic loopback detection. + /// + /// # Returns + /// + /// `Result` with the configured `AudioStream` containing device, sample format, and config. + /// + /// # Errors + /// + /// Propagates `cpal` errors for device discovery or config retrieval. Bail with available devices list if none match. pub fn new(device_name: Option<&str>) -> Result { let device_name = match device_name { Some(name) => name, diff --git a/src/config.rs b/src/config.rs index 3432fea..251c671 100644 --- a/src/config.rs +++ b/src/config.rs @@ -60,6 +60,10 @@ pub struct TuiConfig { } impl TuiConfig { + /// Returns the default TUI configuration. + /// + /// Sets `colorful_effect_names` to the constant `DEFAULT_COLORFUL_EFFECT_NAMES` (typically false, + /// meaning effect names in the effect list are displayed without per-character coloring based on palette). pub fn default() -> Self { TuiConfig { colorful_effect_names: Some(constants::DEFAULT_COLORFUL_EFFECT_NAMES), @@ -95,6 +99,16 @@ pub struct VisualizerConfig { } impl VisualizerConfig { + /// Returns the default visualizer configuration. + /// + /// Initializes with constants: + /// - `audio_backend`: "default" + /// - `freq_range`: (20, 4500) Hz + /// - `hues`: Standard rainbow-like [30,0,330,...] + /// - `default_gain`: 1.0 + /// - `transition_time`: 2 (200ms) + /// - `time_window`: 0.1875 s + /// - Sorting: Y axis ascending, secondary ascending pub fn default() -> Self { VisualizerConfig { audio_backend: Some("default".to_string()), @@ -118,6 +132,15 @@ pub struct Config { } impl Config { + /// Constructs a new `Config` instance with optional component overrides. + /// + /// Uses defaults for unspecified sub-configs via their `default()` methods. + /// + /// # Arguments + /// + /// * `default_nl_device_name` - Optional default Nanoleaf device name for quick selection. + /// * `tui_config` - Optional TUI settings; defaults to `TuiConfig::default()`. + /// * `visualizer_config` - Optional visualizer params; defaults to `VisualizerConfig::default()`. pub fn new( default_nl_device_name: Option, tui_config: Option, @@ -130,6 +153,20 @@ impl Config { } } + /// Parses a TOML table into the fields of a mutable `TuiConfig`. + /// + /// Supports key "colorful_effect_names" as boolean value. + /// Ignores unknown keys? No, bails with error on invalid keys. + /// Updates `tui_config` in place. + /// + /// # Arguments + /// + /// * `tui_config` - Mutable reference to populate. + /// * `t` - TOML table from config section. + /// + /// # Errors + /// + /// `anyhow::Error` for invalid key types or unknown keys. pub fn parse_tui_config(tui_config: &mut TuiConfig, t: toml::Table) -> Result<()> { for (key, val) in t { match (key.as_str(), val) { @@ -144,6 +181,29 @@ impl Config { Ok(()) } + /// Parses a TOML table into the fields of a mutable `VisualizerConfig`. + /// + /// Supports comprehensive field validation and type conversion: + /// - `audio_backend`: String for device name. + /// - `freq_range`: 2-element array of u16 [min_hz, max_hz]. + /// - `hues`: Array of u16 (0-360) or string name of predefined palette (e.g., "ocean-nightclub"). + /// - `default_gain`: f32 or i64, applied to spectrum amplitudes. + /// - `transition_time`: i16 (-1 for instant, positive in 100ms units for Nanoleaf transitions). + /// - `time_window`: f32 seconds for smoothing window. + /// - `primary_axis`: "X" or "Y" enum. + /// - `sort_primary`/`sort_secondary`: "Asc" or "Desc". + /// + /// Validates ranges (e.g., hues 0-360, transition_time >= -1) and bails on errors or unknown keys. + /// Palette names checked against available predefined palettes. + /// + /// # Arguments + /// + /// * `visualizer_config` - Mutable reference to update. + /// * `t` - TOML table from [visualizer_config] section. + /// + /// # Errors + /// + /// `anyhow::Error` for parsing failures, invalid values, or unknown keys. pub fn parse_visualizer_config( visualizer_config: &mut VisualizerConfig, t: toml::Table, @@ -252,6 +312,28 @@ impl Config { Ok(()) } + /// Loads and parses the full application configuration from a TOML file. + /// + /// Reads the file content, deserializes to TOML `Table`, then: + /// - Extracts optional `default_nl_device_name` string. + /// - Parses `[tui_config]` section using `parse_tui_config`. + /// - Parses `[visualizer_config]` section using `parse_visualizer_config`. + /// - Uses defaults for missing sections or fields. + /// - Bails on unknown top-level keys or sub-config parse errors. + /// + /// Debug-logs file path and contents for verification. + /// + /// # Arguments + /// + /// * `path` - Path to the config.toml file. + /// + /// # Returns + /// + /// `Result` - Fully parsed and validated configuration. + /// + /// # Errors + /// + /// File I/O errors, TOML deserialization failures, or validation bails. pub fn parse_from_file(path: &Path) -> Result { eprintln!("DEBUG: Reading config from: {}", path.display()); let mut config_file = File::open(path)?; @@ -286,6 +368,19 @@ impl Config { )) } + /// Serializes and writes the configuration to a TOML file at the given path. + /// + /// Uses `toml::to_string_pretty` for readable formatting. + /// Automatically creates parent directories if they do not exist. + /// + /// # Arguments + /// + /// * `self` - The config to serialize. + /// * `path` - Target file path for config.toml. + /// + /// # Errors + /// + /// Propagates `std::fs` errors for directory creation or file writing, or TOML serialization errors. pub fn write_to_file(&self, path: &Path) -> Result<()> { // Create parent directory if it doesn't exist if let Some(parent) = path.parent() { @@ -298,6 +393,23 @@ impl Config { } } +/// Resolves absolute paths for configuration and devices TOML files. +/// +/// Defaults to XDG config dir (~/.config/audioleaf/) + default filenames if not provided. +/// Checks file existence (returns bool in tuple) and permissions. +/// +/// # Arguments +/// +/// * `config_file_path` - Optional override for config.toml path. +/// * `devices_file_path` - Optional override for nl_devices.toml path. +/// +/// # Returns +/// +/// `Result<((PathBuf, bool), (PathBuf, bool))>` - Resolved paths and their existence flags. +/// +/// # Errors +/// +/// `anyhow::Error` if insufficient permissions to check path existence. pub fn resolve_paths( config_file_path: Option, devices_file_path: Option, @@ -334,6 +446,19 @@ pub fn resolve_paths( )) } +/// Interactively discovers Nanoleaf devices via SSDP or accepts manual IP input. +/// +/// Performs SSDP M-SEARCH to find devices on network, lists names/IPs for user choice. +/// Supports automatic selection by number, 'M' for manual IP entry, 'Q' to quit. +/// Prompts user to enable pairing mode on device before returning selected IP. +/// +/// # Returns +/// +/// `Result` - The chosen device IP address. +/// +/// # Errors +/// +/// Propagates errors from SSDP discovery, IP parsing, or user abort (bail!). pub fn get_ip() -> Result { let (names, ips) = ssdp::ssdp_msearch()?; let choice = utils::choose_ip(&names)?; diff --git a/src/event_handler.rs b/src/event_handler.rs index 2819985..01bb1d6 100644 --- a/src/event_handler.rs +++ b/src/event_handler.rs @@ -13,6 +13,16 @@ pub struct EventHandler { } impl EventHandler { + /// Creates a new `EventHandler` that polls for terminal events and ticks. +/// +/// Spawns a background thread to: +/// - Poll for key events (only processes KeyEventKind::Press to avoid repeats). +/// - Send `Event::Key` or `Event::Tick` via mpsc channel at `constants::TICKRATE` ms intervals. +/// - Registers panic handler in the thread for crash logging. +/// +/// # Returns +/// +/// `EventHandler` with receiver channel for events. pub fn new() -> Self { let tickrate = Duration::from_millis(constants::TICKRATE); let (tx, rx) = mpsc::channel(); @@ -35,6 +45,13 @@ impl EventHandler { EventHandler { rx } } + /// Blocks and receives the next event from the event handler channel. +/// +/// Used in the main loop to process keyboard input or ticks for UI updates. +/// +/// # Returns +/// +/// `Result` - The received event, or error if channel recv fails (e.g., sender dropped). pub fn next(&self) -> Result { Ok(self.rx.recv()?) } diff --git a/src/graphical_layout.rs b/src/graphical_layout.rs index 862b3b1..0e50d22 100644 --- a/src/graphical_layout.rs +++ b/src/graphical_layout.rs @@ -6,20 +6,119 @@ use std::f32::consts::PI; use std::thread; use std::time::Duration; +/// graphical_layout - Graphical Visualization of Nanoleaf Panel Layouts +/// +/// This module provides an interactive graphical interface to visualize and interact with +/// Nanoleaf panel layouts using the macroquad rendering engine. It renders panels as +/// polygons scaled to fit the window, applies global orientation rotations, and supports +/// mouse interaction for flashing individual panels via UDP commands. +/// +/// ## Main Components +/// +/// - `visualize_graphical`: Entry point function to launch the visualization window. +/// - `visualize_loop`: Core async loop handling rendering and input. +/// - `draw_panel`: Renders individual panels or controller trapezoids. +/// - `flash_panel`: Sends UDP color updates to flash a panel white briefly. +/// +/// ## Color Mapping for Panels +/// +/// Panels are colored by shape family: +/// - Triangles (IDs 0,8,9): Red-ish +/// - Squares (2-4): Green-ish +/// - Hexagons (7,14,15): Blue-ish +/// - Skylight panels (30-32): Yellow-ish +/// - Others: Gray +/// +/// Controllers are always yellow trapezoids labeled "C". +/// +/// ## Interaction +/// +/// - Left-click a panel to flash it. +/// - ESC to exit. +/// - Window auto-scales layout with padding. +/// +/// ## Error Handling +/// +/// Warns if UDP controller fails to initialize but continues without interaction. +/// Relies on `pollster::block_on` for async compatibility. +/// Visualizes the Nanoleaf panel layout in a graphical window using the macroquad game engine. +/// +/// This function creates an interactive window displaying the physical arrangement of Nanoleaf panels. +/// Panels are rendered as colored polygons based on their shape type (triangles, squares, hexagons). +/// Controller panels are depicted as yellow trapezoids attached to nearby light panels. +/// The layout can be rotated according to the global orientation. +/// Users can click on panels to briefly flash them white using UDP commands to the device. +/// +/// The window includes: +/// - Title showing global orientation +/// - Scaled and centered layout +/// - Panel IDs labeled in centers +/// - Instructions for interaction +/// +/// Press ESC to close the window. +/// +/// # Arguments +/// +/// * `panels` - Vector of `PanelInfo` structs describing each panel's position, orientation, and shape. +/// * `global_orientation` - The global rotation of the layout in degrees (u16). +/// * `device` - `NlDevice` containing IP and auth token for UDP communication. +/// +/// # Panics +/// +/// Panics if macroquad window creation or async runtime fails. +/// +/// # Examples +/// +/// ``` +/// // Assuming panels and device are obtained from layout parsing +/// visualize_graphical(panels, global_orientation, device); +/// ``` +/// +/// # Dependencies +/// +/// Requires `macroquad` and `palette` crates for rendering and color handling. pub fn visualize_graphical(panels: Vec, global_orientation: u16, device: NlDevice) { - // Run the visualization synchronously + // Synchronously block on the async visualization routine using pollster::block_on, + // bridging the synchronous entry point to macroquad's async window and event loop. pollster::block_on(visualize_async(panels, global_orientation, device)); } +/// Asynchronous function that sets up the macroquad window and runs the visualization loop. +/// +/// This private helper function is called by `visualize_graphical` to handle the async nature +/// of macroquad's event loop. It creates a window titled "Nanoleaf Panel Layout" and awaits +/// the main loop execution. +/// +/// # Arguments +/// +/// * `panels` - The panel layout data. +/// * `global_orientation` - Device global orientation. +/// * `device` - Nanoleaf device for interaction. async fn visualize_async(panels: Vec, global_orientation: u16, device: NlDevice) { - // Initialize macroquad window + // Create and configure the macroquad window with fixed size and title, + // then spawn the async block to run the main visualization loop. macroquad::Window::new("Nanoleaf Panel Layout", async move { visualize_loop(panels, global_orientation, device).await; }); } +/// The core asynchronous loop that runs the interactive visualization. +/// +/// This function implements the main game loop: +/// - Clears background and draws title. +/// - Computes transformed positions applying global rotation. +/// - Draws all panels and controllers. +/// - Handles left mouse clicks to flash panels via UDP if controller available. +/// - Draws instructions and checks for ESC key to exit. +/// +/// # Arguments +/// +/// * `panels` - List of all panels including controllers. +/// * `global_orientation` - Applied as clockwise rotation to layout. +/// * `device` - Used to create UDP controller for flashing. async fn visualize_loop(panels: Vec, global_orientation: u16, device: NlDevice) { - // Find bounds + // Calculate the layout bounds by finding min/max coordinates of all panels, + // used for scaling and centering the visualization. let min_x = panels.iter().map(|p| p.x).min().unwrap_or(0) as f32; let max_x = panels.iter().map(|p| p.x).max().unwrap_or(0) as f32; let min_y = panels.iter().map(|p| p.y).min().unwrap_or(0) as f32; @@ -28,11 +127,12 @@ async fn visualize_loop(panels: Vec, global_orientation: u16, device: let layout_width = max_x - min_x; let layout_height = max_y - min_y; - // Window configuration + // Set fixed window size: 1200x800 pixels for optimal layout display. let window_width = 1200.0; let window_height = 800.0; - // Calculate scale to fit layout in window with padding + // Calculate uniform scaling factor to fit the layout inside the window with padding, + // using the minimum of horizontal and vertical scales to avoid distortion. let padding_top = 100.0; // Extra space at top for title let padding_bottom = 50.0; let padding_sides = 50.0; @@ -43,7 +143,8 @@ async fn visualize_loop(panels: Vec, global_orientation: u16, device: let scale_y = available_height / layout_height; let scale = scale_x.min(scale_y); - // Setup Nanoleaf controller for sending commands + // Initialize optional UDP controller using device IP and token for flashing panels. + // Gracefully handles initialization failure by disabling clicks but continuing render. let nl_controller = match crate::nanoleaf::NlUdp::new(&device) { Ok(controller) => Some(controller), Err(e) => { @@ -55,7 +156,7 @@ async fn visualize_loop(panels: Vec, global_orientation: u16, device: loop { clear_background(Color::from_rgba(20, 20, 30, 255)); - // Draw title + // Draw dynamic title displaying global orientation at top-left with white text. draw_text( &format!( "Nanoleaf Panel Layout - Global Orientation: {}°", @@ -67,11 +168,16 @@ async fn visualize_loop(panels: Vec, global_orientation: u16, device: WHITE, ); - // Center the layout in the window horizontally, offset from top + // Calculate screen offsets to center the scaled layout horizontally, + // vertically centered within available height below title padding. let offset_x = (window_width - layout_width * scale) / 2.0; let offset_y = padding_top + (available_height - layout_height * scale) / 2.0; - // First pass: calculate all transformed positions + // First pass: Precompute screen positions for all panels. + // - Translate to layout-relative coords centered at (0,0) + // - Rotate clockwise by -global_orientation radians around origin + // - Translate back and scale to screen coordinates with offsets + // This enables two-pass rendering: positions first, then drawing with full context. let mut transformed_positions = Vec::new(); for panel in &panels { // Apply global orientation rotation to coordinates @@ -89,7 +195,8 @@ async fn visualize_loop(panels: Vec, global_orientation: u16, device: transformed_positions.push((screen_x, screen_y)); } - // Second pass: draw all panels with access to all positions + // Second pass: Draw each panel using transformed positions, providing full layout + // context needed for controller attachment calculations. for (i, panel) in panels.iter().enumerate() { let (screen_x, screen_y) = transformed_positions[i]; draw_panel( @@ -102,11 +209,12 @@ async fn visualize_loop(panels: Vec, global_orientation: u16, device: ); } - // Handle mouse clicks + // Handle user interaction: On left mouse press with valid controller, + // check distance from mouse to each panel center; if within ~1.2x radius, flash it. if is_mouse_button_pressed(MouseButton::Left) && nl_controller.is_some() { let (mouse_x, mouse_y) = mouse_position(); - // Check which panel was clicked + // Scan panels for mouse hit detection, skipping controllers (no side_length). for (i, panel) in panels.iter().enumerate() { if panel.shape_type.side_length < 1.0 { continue; // Skip controllers @@ -124,10 +232,11 @@ async fn visualize_loop(panels: Vec, global_orientation: u16, device: side_length }; - // Simple distance check for click detection + // Perform radial distance check: mouse within 120% of estimated panel radius + // (approximated from side_length and shape: tri/sqrt(3), sq/sqrt(2), else side). let dist = ((mouse_x - screen_x).powi(2) + (mouse_y - screen_y).powi(2)).sqrt(); if dist < radius * 1.2 { - // Found the clicked panel - flash it + // Panel hit confirmed: send flash command via UDP controller and exit loop. if let Some(ref controller) = nl_controller { flash_panel(controller, &panels, panel.panel_id); } @@ -136,7 +245,7 @@ async fn visualize_loop(panels: Vec, global_orientation: u16, device: } } - // Instructions + // Render interaction instructions at bottom-left in smaller gray text. draw_text( "Press ESC to close | Click panels to flash them", 10.0, @@ -153,6 +262,32 @@ async fn visualize_loop(panels: Vec, global_orientation: u16, device: } } +/// Draws a single panel or controller at the specified screen position. +/// +/// Supports different shape types: +/// - Light panels (side_length >=1): Polygons (triangles=3 sides, squares=4, hex=6) with colors based on shape ID. +/// - Controllers (side_length <1): Yellow trapezoids attached to the nearest light panel's edge. +/// +/// For light panels: +/// - Vertices calculated from radius, orientation. +/// - Filled with semi-transparent color matching shape family. +/// - White outline. +/// - Panel ID text in center. +/// +/// For controllers: +/// - Finds nearest parent panel. +/// - Determines closest edge. +/// - Draws trapezoid protruding outward, narrower at tip. +/// - Outlined and labeled "C". +/// +/// # Arguments +/// +/// * `x` - Center x coordinate on screen. +/// * `y` - Center y coordinate on screen. +/// * `panel` - The `PanelInfo` to draw. +/// * `scale` - Scaling factor for sizes. +/// * `all_panels` - Full list for finding parent for controllers. +/// * `transformed_positions` - Precomputed screen positions of all panels. fn draw_panel( x: f32, y: f32, @@ -161,9 +296,10 @@ fn draw_panel( all_panels: &[PanelInfo], transformed_positions: &[(f32, f32)], ) { - // Handle controllers specially (they have side_length 0) + // Branch for controllers: panels with side_length <1.0 are non-light controllers, + // visualized as yellow trapezoids attached to the nearest light panel's edge for realism. if panel.shape_type.side_length < 1.0 { - // Find the nearest panel to attach to + // Select parent light panel: minimum distance to any valid (light) panel center. let mut min_dist = f32::MAX; let mut nearest_idx = 0; @@ -181,16 +317,17 @@ fn draw_panel( let (parent_x, parent_y) = transformed_positions[nearest_idx]; let parent_panel = &all_panels[nearest_idx]; - // Calculate angle from parent to controller + // Determine angular direction (atan2) from parent to controller for aligning with parent edges. let dx = x - parent_x; let dy = y - parent_y; let angle_to_controller = dy.atan2(dx); - // Get parent shape info + // Extract parent's num_sides and scaled side_length for radius and vertex computation. let num_sides = parent_panel.shape_type.num_sides(); let parent_side_length = parent_panel.shape_type.side_length * scale; - // Calculate parent radius + // Compute distance from center to vertex (circumradius) based on shape: + // triangle: side / sqrt(3), square: side / sqrt(2), default: side. let parent_radius = if num_sides == 3 { parent_side_length / f32::sqrt(3.0) } else if num_sides == 4 { @@ -199,7 +336,8 @@ fn draw_panel( parent_side_length }; - // Find which edge of the parent the controller is closest to + // Select closest parent edge: iterate over vertex angles (adjusted by parent orientation), + // find minimum angular difference to controller direction using shortest arc distance modulo 2π. let parent_orientation = (parent_panel.orientation as f32).to_radians(); let angle_per_side = 2.0 * PI / num_sides as f32; @@ -216,7 +354,7 @@ fn draw_panel( } } - // Calculate the two vertices of the edge + // Position the edge endpoints: v1 and v2 at angles from parent orientation + edge index * angle_per_side. let v1_angle = parent_orientation + (closest_edge as f32 * angle_per_side); let v2_angle = parent_orientation + ((closest_edge + 1) as f32 * angle_per_side); @@ -225,10 +363,11 @@ fn draw_panel( let v2_x = parent_x + parent_radius * v2_angle.cos(); let v2_y = parent_y + parent_radius * v2_angle.sin(); - // Draw trapezoid attached to this edge + // Define trapezoid vertices: top matches edge v1-v2, bottom parallel but shorter and offset + // perpendicular outward by fixed height; fill with yellow triangles, outline in darker yellow. let trapezoid_height = 20.0; - // Calculate perpendicular direction (outward from parent) + // Derive outward normal: vector from center to edge midpoint, normalized for extension. let edge_mid_x = (v1_x + v2_x) / 2.0; let edge_mid_y = (v1_y + v2_y) / 2.0; let perp_dx = edge_mid_x - parent_x; @@ -237,7 +376,9 @@ fn draw_panel( let perp_norm_x = perp_dx / perp_len; let perp_norm_y = perp_dy / perp_len; - // Trapezoid vertices: top edge matches parent edge, bottom edge is narrower + // Assemble trapezoid vertices array: + // - Top: parent edge endpoints v1, v2 + // - Bottom: inset towards midpoint by (1-0.6=0.4), extended along perpendicular normal by height=20px let narrow_ratio = 0.6; // Bottom edge is 60% of top edge let vertices = [ @@ -255,7 +396,7 @@ fn draw_panel( ), ]; - // Draw filled trapezoid + // Render filled trapezoid by splitting into two triangles with solid yellow color. draw_triangle( vertices[0], vertices[1], @@ -269,7 +410,7 @@ fn draw_panel( Color::from_rgba(255, 200, 0, 255), ); - // Draw outline + // Outline trapezoid edges with 2px thick darker yellow lines connecting vertices cyclically. for i in 0..vertices.len() { let next = (i + 1) % vertices.len(); draw_line( @@ -282,7 +423,7 @@ fn draw_panel( ); } - // Draw "C" label in center of trapezoid + // Add 'C' identifier: measure text, center at trapezoid centroid in black 10pt font. let text_size = 10.0; let text_dims = measure_text("C", None, text_size as u16, 1.0); let label_x = (vertices[0].x + vertices[1].x + vertices[2].x + vertices[3].x) / 4.0; @@ -297,10 +438,11 @@ fn draw_panel( return; } + // Light panel rendering: compute polygon sides and scaled side length. let num_sides = panel.shape_type.num_sides(); let side_length = panel.shape_type.side_length * scale; - // Calculate radius from center to vertex + // Calculate circumradius (center to vertex) using geometry formulas per shape type. let radius = if num_sides == 3 { side_length / f32::sqrt(3.0) } else if num_sides == 4 { @@ -309,7 +451,7 @@ fn draw_panel( side_length }; - // Calculate vertices + // Compute vertex positions: for each side, angle = orientation_rad + i * (2π / n), offset from center by radius. let start_angle = (panel.orientation as f32).to_radians(); let mut vertices = Vec::new(); @@ -320,7 +462,7 @@ fn draw_panel( vertices.push(Vec2::new(vx, vy)); } - // Choose color based on shape type + // Select panel fill color by shape ID groups for visual distinction (alpha=200 for transparency). let color = match panel.shape_type.id { 0 | 8 | 9 => Color::from_rgba(255, 100, 100, 200), 2..=4 => Color::from_rgba(100, 255, 100, 200), @@ -329,12 +471,12 @@ fn draw_panel( _ => Color::from_rgba(150, 150, 150, 200), }; - // Draw filled polygon + // Fill the convex polygon using triangle fan: vertex[0] to consecutive pairs. for i in 1..(num_sides - 1) { draw_triangle(vertices[0], vertices[i], vertices[i + 1], color); } - // Draw outline + // Draw polygon boundary: connect consecutive vertices with white 2px lines. for i in 0..num_sides { let next = (i + 1) % num_sides; draw_line( @@ -347,7 +489,7 @@ fn draw_panel( ); } - // Draw panel ID in center + // Render panel ID text: format as string, measure for centering at panel center position. let id_text = format!("{}", panel.panel_id); let text_size = 16.0; let text_dims = measure_text(&id_text, None, text_size as u16, 1.0); @@ -360,32 +502,45 @@ fn draw_panel( ); } +/// Flashes a specific panel white briefly by sending UDP color updates. +/// +/// Sets the clicked panel to white (Hwb(359,0,0)) and all other light panels to black (Hwb(0,0,1)). +/// Updates immediately (transition=1), waits 300ms, then sets all light panels back to black. +/// +/// Only affects panels with side_length >=1.0 (light panels, skips controllers). +/// +/// # Arguments +/// +/// * `controller` - Initialized `NlUdp` instance for sending commands. +/// * `all_panels` - Full panel list to determine which to color. +/// * `clicked_panel_id` - ID of the panel to flash white. fn flash_panel( controller: &crate::nanoleaf::NlUdp, all_panels: &[PanelInfo], clicked_panel_id: u16, ) { - // Create color array - white for clicked panel, black for all others - // Only include actual light panels (skip controllers with side_length < 1.0) + // Construct per-panel Hwb colors for UDP update: white (Hwb::new(359.0, 0.0, 0.0)) for clicked, + // black (Hwb::new(0.0, 0.0, 1.0)) for other light panels; exclude controllers from array. let colors: Vec = all_panels .iter() .filter(|panel| panel.shape_type.side_length >= 1.0) .map(|panel| { if panel.panel_id == clicked_panel_id { - Hwb::new(0.0, 1.0, 0.0) // White + Hwb::new(359.0, 0.0, 0.0) // White } else { Hwb::new(0.0, 0.0, 1.0) // Black } }) .collect(); - // Flash on + // Send 'on' state: set clicked panel white, others black; immediate transition (duration=1). let _ = controller.update_panels(&colors, 1); - // Brief delay + // Sleep 300 milliseconds for visible flash duration. thread::sleep(Duration::from_millis(300)); - // Flash off - set all panels to black to return to normal + // Reset flash: update all light panels to black, effectively turning off the highlight + // (note: this overrides any current effect; in practice, may need to restore original colors for seamless integration). let black_colors: Vec = all_panels .iter() .filter(|panel| panel.shape_type.side_length >= 1.0) diff --git a/src/layout_visualizer.rs b/src/layout_visualizer.rs index a9ae23a..bf44274 100644 --- a/src/layout_visualizer.rs +++ b/src/layout_visualizer.rs @@ -9,6 +9,11 @@ pub struct ShapeType { } impl ShapeType { + /// Constructs a `ShapeType` from Nanoleaf's internal shape ID. + /// + /// Maps known IDs to panel types like triangles, squares, hexagons, controllers, and special shapes (e.g., Elements, Lines). + /// Provides `side_length` approximation for rendering and `name` for display. + /// Unknown IDs default to generic square with side 100.0. pub fn from_id(id: u64) -> Self { match id { 0 => ShapeType { @@ -119,6 +124,11 @@ impl ShapeType { } } + /// Returns the number of sides for this shape type, useful for polygon rendering. + /// + /// - 3 for triangles (IDs 0,8,9) + /// - 4 for squares and most others (default) + /// - 6 for hexagons (IDs 7,14,15) pub fn num_sides(&self) -> usize { match self.id { 0 | 8 | 9 => 3, // Triangles @@ -138,6 +148,19 @@ pub struct PanelInfo { pub shape_type: ShapeType, } +/// Parses the "positionData" array from Nanoleaf's panel layout JSON response. +/// +/// Expects array of objects with "panelId" (u16), "x"/"y" (i16), "o" (orientation u16), "shapeType" (u64 ID). +/// Converts coordinates and creates `PanelInfo` for each, using `ShapeType::from_id`. +/// Defaults missing fields to 0. +/// +/// # Arguments +/// +/// * `layout_json` - serde_json::Value typically from /api/v1/panelLayout endpoint. +/// +/// # Returns +/// +/// `Result>` - List of parsed panel positions and types, or error if no positionData array. pub fn parse_layout(layout_json: &Value) -> Result> { let position_data = layout_json["positionData"] .as_array() @@ -165,6 +188,12 @@ pub fn parse_layout(layout_json: &Value) -> Result> { Ok(panels) } +/// Prints a textual summary and table of the Nanoleaf panel layout to stdout. +/// +/// Computes bounds (min/max x,y), displays global orientation, and tabulates: +/// Panel ID, Shape Type name, X/Y positions, Orientation (degrees), Side length. +/// +/// Intended for CLI 'dump layout' command output. Suggests graphical alt for visual rep. pub fn visualize_layout(panels: &[PanelInfo], global_orientation: u16) { if panels.is_empty() { println!("No panels to visualize"); diff --git a/src/main.rs b/src/main.rs index 495023b..89ae9a8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,10 +16,21 @@ mod ssdp; mod utils; mod visualizer; +/// The main entry point of the Audioleaf application. +/// +/// This function blocks on the asynchronous main logic using `pollster::block_on`. +/// It sets up panic handling and parses CLI options before delegating to `main_async`. fn main() -> Result<()> { pollster::block_on(main_async()) } +/// Asynchronous main logic of the Audioleaf application. +/// +/// Parses CLI options and handles different modes: +/// - Dump commands (layout, palettes, info, graphical layout) without TUI. +/// - Normal mode: loads or discovers Nanoleaf device, sets up config, runs TUI visualizer/effect selector. +/// +/// Ensures device is ready (powered on, brightness set) before running the app. async fn main_async() -> Result<()> { panic::register_backtrace_panic_handler(); let cli_options = config::CliOptions::parse(); @@ -81,6 +92,24 @@ async fn main_async() -> Result<()> { Ok(()) } +/// Handles 'dump' subcommands to display Nanoleaf device information or configuration without launching the TUI. +/// +/// Supported dump types: +/// - `Layout`: Fetches and prints panel layout data and global orientation. +/// - `Palettes`: Lists all predefined color palettes available in the application. +/// - `LayoutGraphical`: Renders an interactive graphical visualization of the panel layout using macroquad. +/// - `Info`: Retrieves and prints basic device information from the /api/v1/ endpoint. +/// +/// In all cases except `Palettes`, it connects to a known device or uses CLI-specified name. +/// +/// # Arguments +/// +/// * `dump_type` - Specifies which type of information to dump. +/// * `cli_options` - Parsed CLI options including config paths and device name. +/// +/// # Errors +/// +/// Returns `anyhow::Error` for issues like missing devices file, connection failures, or JSON parsing errors. async fn handle_dump_command( dump_type: &config::DumpType, cli_options: &config::CliOptions, diff --git a/src/nanoleaf.rs b/src/nanoleaf.rs index 894c2a6..4055193 100644 --- a/src/nanoleaf.rs +++ b/src/nanoleaf.rs @@ -36,12 +36,33 @@ struct NlDevices { } impl From> for NlDevices { + /// Wraps a vector of devices into the TOML-serializable NlDevices struct. + /// + /// Used for saving/loading multiple known devices from nl_devices.toml file. fn from(nl_devices: Vec) -> Self { NlDevices { nl_devices } } } impl NlDevice { + /// Creates a new `NlDevice` instance for a given IP address. + /// + /// Performs API calls to: + /// - Obtain auth token via POST /api/v1/new (requires device in pairing mode). + /// - Fetch device name from GET /api/v1/{token}. + /// - Get current effect name from GET /effects/select, mapping special names to None. + /// + /// # Arguments + /// + /// * `ip` - Local IPv4 address of the Nanoleaf device. + /// + /// # Returns + /// + /// `Result` with name, ip, token, optional cur_effect_name. + /// + /// # Errors + /// + /// From HTTP requests or JSON parsing; bails on connection failure. pub fn new(ip: Ipv4Addr) -> Result { let token = Self::get_token(&ip)?; let name = Self::get_name(&ip, &token)?; @@ -101,6 +122,18 @@ impl NlDevice { } } + /// Retrieves the panel layout configuration from the device API. + /// + /// GET /api/v1/{token}/panelLayout/layout returns JSON with "positionData" array of panel positions/shapes. + /// Used for layout visualization and panel sorting/indexing in UDP. + /// + /// # Returns + /// + /// `Result` - Raw JSON response. + /// + /// # Errors + /// + /// HTTP or parsing errors, bails on connection fail. pub fn get_panel_layout(&self) -> Result { let Ok(res) = utils::request_get(&format!( "http://{}:{}/api/v1/{}/panelLayout/layout", diff --git a/src/panic.rs b/src/panic.rs index 434eef0..fa7a307 100644 --- a/src/panic.rs +++ b/src/panic.rs @@ -5,6 +5,11 @@ use std::{ io::{stdout, Write}, }; +/// Registers a custom panic hook to handle application crashes gracefully. +/// +/// Disables raw mode and leaves alternate screen before printing crash message. +/// Captures and saves backtrace to cache/audioleaf_backtrace.log if possible. +/// Ensures TUI state is restored on panic in threads like event handler. pub fn register_backtrace_panic_handler() { std::panic::set_hook(Box::new(|panic_info| { let _ = terminal::disable_raw_mode(); diff --git a/src/processing.rs b/src/processing.rs index 15a9f2f..f12860b 100644 --- a/src/processing.rs +++ b/src/processing.rs @@ -2,6 +2,14 @@ use crate::utils; use num_complex::Complex32; use palette::Hwb; +/// Recursive in-place radix-2 Cooley-Tukey FFT implementation. +/// +/// Computes the discrete Fourier transform for a signal of length `n`. +/// Modifies input `x` and stores result in `y`. +/// `step` determines the stride between samples in sub-transforms for decimation-in-time approach. +/// +/// Base case: n=1 copies x[0] to y[0]. +/// Recursive: splits into even/odd indices, computes twiddle factors for butterfly combination. fn fft(x: &mut [Complex32], y: &mut [Complex32], n: usize, step: usize) { if n == 1 { y[0] = x[0]; @@ -18,6 +26,19 @@ fn fft(x: &mut [Complex32], y: &mut [Complex32], n: usize, step: usize) { } } +/// Processes raw time-domain audio samples to produce frequency-domain spectrum amplitudes for visualization. +/// +/// Performs FFT on padded input (to next power of two), extracts positive frequencies, +/// normalizes by sqrt(n), applies gain, and clamps amplitudes to [0,1] using x / sqrt(1 + x^2) sigmoid-like function. +/// +/// # Arguments +/// +/// * `samples` - Vec of f32 mono audio samples. +/// * `gain` - Amplification factor applied before clamping. +/// +/// # Returns +/// +/// Vec of amplitude values for each frequency bin (up to Nyquist). pub fn process(samples: Vec, gain: f32) -> Vec { let mut n = samples.len(); let mut complex_samples = samples @@ -44,6 +65,24 @@ pub fn process(samples: Vec, gain: f32) -> Vec { .collect::>() } +/// Updates the blackness component of HWB colors based on audio frequency spectrum for animated visualization. +/// +/// Divides the frequency range [min_freq, max_freq] into `colors.len()` logarithmic intervals. +/// For each interval, tracks maximum amplitude (with equal loudness correction) and updates blackness +/// with velocity-based decay/increase for smooth transitions. Uses cubic easing functions for rates. +/// +/// # Arguments +/// +/// * `spectrum` - FFT-derived amplitudes for frequency bins. +/// * `hz_per_bin` - Frequency resolution (Hz per bin in spectrum). +/// * `min_freq`/`max_freq` - Frequency range to consider for color mapping. +/// * `colors` - Mutable slice of HWB colors; updates their blackness [0,1] (1=white, 0=saturated). +/// * `prev_max` - Previous max amplitudes per interval for delta computation (mutated). +/// * `speed` - Velocity accumulators for blackness changes per interval (mutated). +/// +/// # Panics +/// +/// May panic if spectrum length insufficient or invalid frequency params. pub fn update_colors( spectrum: Vec, hz_per_bin: u32, diff --git a/src/ssdp.rs b/src/ssdp.rs index 59cf9d9..9a6df95 100644 --- a/src/ssdp.rs +++ b/src/ssdp.rs @@ -8,6 +8,9 @@ use std::{ const SSDP_MULTICAST_ADDR: &str = "239.255.255.250"; const SSDP_MULTICAST_PORT: &str = "1900"; +/// Parses an SSDP response string to extract the device name and IP address. +/// +/// Returns `Some((name, ip))` if both are found in the headers, `None` otherwise. fn parse_name_and_ip(s: &str) -> Option<(String, String)> { let headers = s.split("\r\n"); let (mut name, mut ip) = (None, None); @@ -37,6 +40,21 @@ fn parse_name_and_ip(s: &str) -> Option<(String, String)> { } } +/// Discovers Nanoleaf devices on the local network using the SSDP M-SEARCH protocol. +/// +/// Sends multicast search requests for known Nanoleaf device types including Canvas (nl29), +/// Shapes (nl42), Elements (nl52), and Aurora Light Panels. +/// +/// Listens for responses for a timeout of 10 seconds, avoiding duplicate devices based on IP address. +/// +/// # Returns +/// +/// A tuple `(Vec, Vec)` containing the discovered device names and their corresponding IP addresses. +/// +/// # Errors +/// +/// Returns an `anyhow::Error` if socket binding, multicast joining, sending requests, +/// receiving responses, or IP parsing fails. pub fn ssdp_msearch() -> Result<(Vec, Vec)> { let socket = UdpSocket::bind("0.0.0.0:0")?; socket.join_multicast_v4(&SSDP_MULTICAST_ADDR.parse()?, &"0.0.0.0".parse()?)?; diff --git a/src/utils.rs b/src/utils.rs index 2c1f901..aa5b81d 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -24,6 +24,21 @@ pub enum Choice { Quit, } +/// Prompts the user to select an option from a numbered list interactively. +/// +/// Displays the list with indices starting from 1. +/// Accepts input: number (selects index-1), "M" for manual mode, "Q" to quit. +/// Loops until valid input, flushes stdout for immediate prompt visibility. +/// +/// Used for selecting discovered Nanoleaf devices by name. +/// +/// # Arguments +/// +/// * `v` - Slice of displayable items (e.g., device names). +/// +/// # Returns +/// +/// `Result` - Automatic selection with index, Manual, or Quit variant. pub fn choose_ip(v: &[impl Display]) -> Result { for (i, option) in v.iter().enumerate() { println!("{}. {}", i + 1, option); @@ -52,6 +67,14 @@ pub fn choose_ip(v: &[impl Display]) -> Result { } } +/// Interactively prompts for manual IPv4 address input from stdin. +/// +/// Loops reading lines until valid IPv4 or "Q" to quit. +/// Used after SSDP discovery if user chooses manual entry. +/// +/// # Returns +/// +/// `Result>` - Parsed IP or None on quit/cancel. pub fn get_ip_from_stdin() -> Result> { loop { print!("Enter the local IP address of your Nanoleaf device or 'Q' to quit: "); @@ -71,6 +94,11 @@ pub fn get_ip_from_stdin() -> Result> { } } +/// Pauses execution until any key is pressed, with TUI-friendly handling. +/// +/// Temporarily enables raw mode for crossterm event polling. +/// Loops reading events until Key event received (any key). +/// Disables raw mode, prints newline, used for user confirmation prompts (e.g., pairing mode). pub fn wait_for_any_key() -> Result<()> { // Enable raw mode temporarily to detect key presses enable_raw_mode()?; @@ -88,22 +116,57 @@ pub fn wait_for_any_key() -> Result<()> { Ok(()) } +/// Initializes the terminal user interface (TUI) environment. +/// +/// Enables raw mode for input handling, enters alternate screen buffer, +/// creates a new ratatui Terminal with CrosstermBackend on stdout. +/// +/// Called before running the app TUI loop. +/// +/// # Returns +/// +/// `Result>>` wrapped in impl Backend. pub fn init_tui() -> Result> { enable_raw_mode()?; execute!(stdout(), EnterAlternateScreen)?; Terminal::new(CrosstermBackend::new(stdout())).map_err(anyhow::Error::from) } +/// Cleans up the TUI environment after app exit. +/// +/// Disables raw mode and leaves alternate screen to restore normal terminal state. +/// Called after app run to prevent hanging or corrupted display. +/// +/// # Errors +/// +/// Propagates crossterm execution errors. pub fn destroy_tui() -> Result<(), anyhow::Error> { disable_raw_mode()?; execute!(stdout(), LeaveAlternateScreen)?; Ok(()) } +/// Generates a user-friendly error message for failed Nanoleaf device connection. +/// +/// Formats "couldn't connect to the Nanoleaf device at IP {ip}". pub fn generate_connection_error_msg(ip: &Ipv4Addr) -> String { format!("couldn't connect to the Nanoleaf device at IP {}", ip) } +/// Performs a blocking POST request to a Nanoleaf API endpoint. +/// +/// Optionally includes JSON body data. Executes and checks status, returns response text. +/// +/// Used for authenticated API calls requiring POST (e.g., setting effects, panels). +/// +/// # Arguments +/// +/// * `url` - Full API URL like "http://ip:16021/api/v1/{token}/..." . +/// * `data` - Optional JSON value for request body. +/// +/// # Returns +/// +/// `Result` - Response body as string, or error if send/status fails. pub fn request_post(url: &str, data: Option<&serde_json::Value>) -> Result { let mut client = Client::new().post(url); if let Some(data) = data { @@ -116,6 +179,20 @@ pub fn request_post(url: &str, data: Option<&serde_json::Value>) -> Result` - Response text or error on failure. pub fn request_put(url: &str, data: Option<&serde_json::Value>) -> Result { let mut client = Client::new().put(url); if let Some(data) = data { @@ -128,6 +205,18 @@ pub fn request_put(url: &str, data: Option<&serde_json::Value>) -> Result` - JSON response as string or error. pub fn request_get(url: &str) -> Result { let client = Client::new().get(url); let res = client @@ -137,6 +226,12 @@ pub fn request_get(url: &str) -> Result { Ok(res.text()?.to_string()) } +/// Generates HWB colors from a list of hues, expanding or truncating to exact count `n`. +/// +/// Repeats last hue if fewer than `n`, cycles if more? No, resizes vec from hues slice. +/// Hue=360 special-cased as white (H=0.1, W=1.0, B=0.0); others H=hue f32, W=0, B=1 (saturated full brightness). +/// +/// Used to map palette hues to panel colors in visualizer. pub fn colors_from_hues(hues: &[u16], n: usize) -> Vec { let mut res = Vec::from(hues); res.resize(n, *hues.last().unwrap()); @@ -190,10 +285,18 @@ pub fn equalize(a: f32, f: u32) -> f32 { } } +/// Splits a 16-bit unsigned integer into high and low bytes. +/// +/// Computes high = x >> 8 (or /256), low = x & 0xFF (or %256). +/// Used for serializing values in Nanoleaf UDP protocol packets. pub fn split_into_bytes(x: u16) -> (u8, u8) { ((x / 256) as u8, (x % 256) as u8) } +/// Creates a ratatui `Line` with effect name characters styled in cycling colors from a palette. +/// +/// Splits string into individual chars, applies foreground color from `colors` array cycling by index. +/// Used when `config.tui_config.colorful_effect_names` is true to highlight effect list items. pub fn colorful_effect_name<'a>(effect_name: &'a str, colors: &'a [Srgb]) -> Line<'a> { let chars = effect_name.chars().map(|c| c.to_string()); Line::from( diff --git a/src/visualizer.rs b/src/visualizer.rs index 85c578d..e821e27 100644 --- a/src/visualizer.rs +++ b/src/visualizer.rs @@ -49,6 +49,24 @@ pub struct Visualizer { } impl Visualizer { + /// Initializes a new `Visualizer` instance with configuration and resources. + /// + /// Sets up UDP controller for panel updates, fetches global orientation from device for sorting. + /// Applies config values or defaults for gain, time_window, etc. + /// Sorts panels according to primary/secondary axes and sorts. + /// + /// Note: Audio stream is moved in, but actually played in `init()`. + /// Does not start audio capture or visualization loop yet. + /// + /// # Arguments + /// + /// * `config` - VisualizerConfig with params like freq_range, hues, gain. + /// * `audio_stream` - Pre-configured CPAL input stream device/config. + /// * `nl_device` - Connected Nanoleaf device for UDP and API calls. + /// + /// # Errors + /// + /// From `NlUdp::new` if UDP setup fails, or device API for orientation. pub fn new( config: VisualizerConfig, audio_stream: AudioStream, @@ -90,6 +108,16 @@ impl Visualizer { }) } + /// Updates visualizer internal state or parameters based on received message. + /// + /// Dispatched in processing thread loop. + /// Modifies self fields and/or regenerates `colors` vector from hues. + /// For SetSorting, updates UDP panel order with orientation. + /// + /// # Arguments + /// + /// * `event` - Control message type. + /// * `colors` - Mutable reference to current HWB color array for panels (updated if palette/sort changes). fn update_state(&mut self, event: VisualizerMsg, colors: &mut Vec) { match event { VisualizerMsg::Resume => self.state = VisualizerState::Running, @@ -117,6 +145,14 @@ impl Visualizer { } } + /// Converts interleaved multi-channel audio data to mono envelope (max per frame) and sends to channel. + /// + /// Processes audio callback data: for each set of `n_channels` samples, converts to f32, + /// takes maximum absolute value as simplified mono amplitude envelope. + /// Sends Vec of these envelopes to mpsc channel for FFT processing. + /// + /// Generic over sample type T supporting sized conversion to f32. + /// Used in `create_data_callback` closure for CPAL stream. fn send_samples(data: &[T], n_channels: usize, tx: &mpsc::Sender>) where T: SizedSample + ToSample, @@ -133,6 +169,13 @@ impl Visualizer { tx.send(samples).expect("sending samples failed"); } + /// Creates a closure suitable for CPAL `build_input_stream` callback. + /// + /// Captures `n_channels` and `tx` sender, returns `send_samples` bound to them. + /// The closure ignores `InputCallbackInfo` (timestamp not used). + /// Ensures Send + 'static for thread-safe stream usage. + /// + /// Generic T for sample type matching AudioStream format. fn create_data_callback( n_channels: usize, tx: mpsc::Sender>, @@ -143,6 +186,19 @@ impl Visualizer { move |data: &[T], _: &InputCallbackInfo| Self::send_samples(data, n_channels, &tx) } + /// Completes visualizer setup by starting audio capture stream and spawning processing thread. + /// + /// Builds and plays CPAL input stream matched to sample format, sending mono max samples via channel. + /// Spawns thread that: + /// - Registers panic handler. + /// - Loops receiving audio samples and control messages. + /// - Processes FFT spectrum, updates colors with gain/time_window/equalize. + /// - Applies sorting and sends HWB colors to panels via UDP with transition time. + /// - Handles pause/resume/end states. + /// + /// Returns sender for sending `VisualizerMsg` to control runtime behavior. + /// + /// Consumes self (moved into thread closure). pub fn init(mut self) -> mpsc::Sender { let (tx_events, rx_events) = mpsc::channel(); thread::spawn(move || { From bafde14650dd89f390ff3f7dca92133d6bd36973 Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:33:41 -0800 Subject: [PATCH 2/3] added docs --- src/event_handler.rs | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/event_handler.rs b/src/event_handler.rs index 01bb1d6..f6eb1ae 100644 --- a/src/event_handler.rs +++ b/src/event_handler.rs @@ -14,15 +14,15 @@ pub struct EventHandler { impl EventHandler { /// Creates a new `EventHandler` that polls for terminal events and ticks. -/// -/// Spawns a background thread to: -/// - Poll for key events (only processes KeyEventKind::Press to avoid repeats). -/// - Send `Event::Key` or `Event::Tick` via mpsc channel at `constants::TICKRATE` ms intervals. -/// - Registers panic handler in the thread for crash logging. -/// -/// # Returns -/// -/// `EventHandler` with receiver channel for events. + /// + /// Spawns a background thread to: + /// - Poll for key events (only processes KeyEventKind::Press to avoid repeats). + /// - Send `Event::Key` or `Event::Tick` via mpsc channel at `constants::TICKRATE` ms intervals. + /// - Registers panic handler in the thread for crash logging. + /// + /// # Returns + /// + /// `EventHandler` with receiver channel for events. pub fn new() -> Self { let tickrate = Duration::from_millis(constants::TICKRATE); let (tx, rx) = mpsc::channel(); @@ -46,12 +46,12 @@ impl EventHandler { } /// Blocks and receives the next event from the event handler channel. -/// -/// Used in the main loop to process keyboard input or ticks for UI updates. -/// -/// # Returns -/// -/// `Result` - The received event, or error if channel recv fails (e.g., sender dropped). + /// + /// Used in the main loop to process keyboard input or ticks for UI updates. + /// + /// # Returns + /// + /// `Result` - The received event, or error if channel recv fails (e.g., sender dropped). pub fn next(&self) -> Result { Ok(self.rx.recv()?) } From 1de6687e0b02b22093e5005c8345e0a453004761 Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:34:58 -0800 Subject: [PATCH 3/3] added docs --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 908fb71..df4ad8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,7 +109,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "audioleaf" -version = "3.2.0" +version = "3.3.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index ff494a2..6c1e281 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "audioleaf" -version = "3.2.0" +version = "3.3.0" edition = "2021" authors = ["Antoni Zasada", "weekendsuperhero"] description = "Manage your Nanoleaf devices (Canvas, Shapes, Elements, Light Panels) and visualize music straight from the terminal"