diff --git a/README.md b/README.md index 3360e35..8312a60 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,37 @@ RUSTFLAGS="-L$(brew --prefix sdl3)/lib" cargo run cargo run --release ``` +### Configuration file + +You can pass a config file with `-c`: + +``` +./projectm -c config.toml +./projectm -c config.properties +``` + +Supported formats: `.toml`, `.json`, `.yaml`, `.properties` + +The `.properties` format is compatible with config files from the C++ SDL frontend (projectMSDL). Example: + +``` +audio.device: BlackHole 2ch +projectM.presetPath: /path/to/your/presets +projectM.shuffleEnabled: true +projectM.transitionDuration: 10 +projectM.displayDuration: 60 +window.width: 1280 +window.height: 720 +``` + +### macOS: Microphone / audio capture permission + +On macOS, the application needs permission to capture audio. If the visualizer doesn't react to audio, check: + +**System Settings → Privacy & Security → Microphone** + +Grant access to your terminal application (e.g. Terminal.app, iTerm2) or the projectm binary directly. macOS silently blocks audio capture without this permission — no error will be shown. +

(back to top)

diff --git a/src/app.rs b/src/app.rs index 72555b2..477088c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -44,9 +44,13 @@ impl App { assert_eq!(gl_attr.context_profile(), GLProfile::Core); assert_eq!(gl_attr.context_version(), (3, 3)); + // Determine initial window size from config or use defaults + let initial_width = config.window_width.unwrap_or(1024); + let initial_height = config.window_height.unwrap_or(768); + // create window let mut window = video_subsystem - .window("ProjectM", 1024, 768) + .window("ProjectM", initial_width, initial_height) .opengl() .build() .expect("could not initialize video subsystem"); @@ -61,19 +65,27 @@ impl App { // and a preset playlist let playlist = projectm::playlist::Playlist::create(&pm); - // make window full-size - let primary_display = video_subsystem.get_primary_display().unwrap(); - let display_bounds = primary_display.get_usable_bounds().unwrap(); - window - .set_size(display_bounds.width(), display_bounds.height()) - .unwrap(); - window.set_position(WindowPos::Centered, WindowPos::Centered); + // Apply window position if override is requested + if config.window_override_position.unwrap_or(false) { + let x = config.window_left.unwrap_or(0); + let y = config.window_top.unwrap_or(0); + window.set_position(WindowPos::Positioned(x), WindowPos::Positioned(y)); + } else if config.window_width.is_none() { + // Only go full-size if no explicit window size was given + let primary_display = video_subsystem.get_primary_display().unwrap(); + let display_bounds = primary_display.get_usable_bounds().unwrap(); + window + .set_size(display_bounds.width(), display_bounds.height()) + .unwrap(); + window.set_position(WindowPos::Centered, WindowPos::Centered); + } + window .set_display_mode(None) .expect("could not set display mode"); - // initialize audio - let audio = audio::Audio::new(&sdl_context, Rc::clone(&pm)); + // initialize audio, passing optional device name + let audio = audio::Audio::new(&sdl_context, Rc::clone(&pm), config.audio_device.clone()); println!("Application initialized with configuration:\n{}", config); @@ -90,7 +102,8 @@ impl App { pub fn init(&mut self) { // load config - self.apply_config(&self.config); + let config = self.config.clone(); + self.apply_config(&config); // initialize audio self.audio.init(self.get_frame_rate()); diff --git a/src/app/audio.rs b/src/app/audio.rs index 0dfc506..2951787 100644 --- a/src/app/audio.rs +++ b/src/app/audio.rs @@ -15,10 +15,11 @@ pub struct Audio { projectm: ProjectMWrapped, current_device_id: Option, current_device_name: Option, // Store device name for comparison + requested_device_name: Option, // Device name from config } impl Audio { - pub fn new(sdl_context: &sdl3::Sdl, projectm: ProjectMWrapped) -> Self { + pub fn new(sdl_context: &sdl3::Sdl, projectm: ProjectMWrapped, requested_device_name: Option) -> Self { let audio_subsystem = sdl_context.audio().unwrap(); println!( "Using audio driver: {}", @@ -33,6 +34,7 @@ impl Audio { current_device_name: None, recording_stream: None, projectm, + requested_device_name, } } @@ -42,7 +44,25 @@ impl Audio { self.frame_rate = Some(frame_rate); #[cfg(not(feature = "dummy_audio"))] - self.begin_audio_recording(None); + { + // If a device name was requested, find it and use it + if let Some(ref name) = self.requested_device_name { + if let Some(device_id) = self.find_device_by_name(name) { + println!("Found requested audio device: {}", name); + self.begin_audio_recording(Some(device_id)); + } else { + println!( + "Warning: Requested audio device '{}' not found. Available devices:", + name + ); + self.list_devices(); + println!("Falling back to default device."); + self.begin_audio_recording(None); + } + } else { + self.begin_audio_recording(None); + } + } } pub fn list_devices(&self) { @@ -58,6 +78,32 @@ impl Audio { } } + /// Find an audio device by name (case-insensitive substring match). + fn find_device_by_name(&self, name: &str) -> Option { + let devices = self.get_device_list(); + let name_lower = name.to_lowercase(); + + // Try exact match first + for device in &devices { + if let Ok(device_name) = device.name() { + if device_name.to_lowercase() == name_lower { + return Some(*device); + } + } + } + + // Fall back to substring match + for device in &devices { + if let Ok(device_name) = device.name() { + if device_name.to_lowercase().contains(&name_lower) { + return Some(*device); + } + } + } + + None + } + /// Start capturing audio from device_id. pub fn begin_audio_recording(&mut self, device_id: Option) { // Stop capturing from current stream/device diff --git a/src/app/config.rs b/src/app/config.rs index 1b84b28..ab4f2c0 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -8,6 +8,7 @@ const RESOURCE_DIR_DEFAULT: &str = "/usr/local/share/projectM"; /// Configuration for the application /// Parameters are defined here: https://github.com/projectM-visualizer/projectm/blob/master/src/api/include/projectM-4/parameters.h +#[derive(Clone)] pub struct Config { /// Frame rate to render at. Defaults to 60. pub frame_rate: Option, @@ -23,6 +24,37 @@ pub struct Config { /// How long to play a preset before switching to a new one (seconds). pub preset_duration: Option, + + /// Whether to shuffle presets. + pub shuffle_enabled: Option, + + /// Soft-cut (crossfade blend) duration between presets (seconds). + /// Maps to projectM.transitionDuration in .properties files. + pub soft_cut_duration: Option, + + /// Whether the current preset is locked (won't auto-switch). + pub preset_locked: Option, + + /// Audio device name to use for capture. + pub audio_device: Option, + + /// Window width. + pub window_width: Option, + + /// Window height. + pub window_height: Option, + + /// Window X position. + pub window_left: Option, + + /// Window Y position. + pub window_top: Option, + + /// Monitor index. + pub window_monitor: Option, + + /// Whether to override default window position. + pub window_override_position: Option, } impl fmt::Display for Config { @@ -57,11 +89,30 @@ impl fmt::Display for Config { self.beat_sensitivity .map_or("Not specified".to_string(), |s| s.to_string()) )?; - write!( + writeln!( f, " Preset Duration: {}", self.preset_duration .map_or("Not specified".to_string(), |d| d.to_string()) + )?; + writeln!( + f, + " Shuffle: {}", + self.shuffle_enabled + .map_or("Not specified".to_string(), |s| s.to_string()) + )?; + writeln!( + f, + " Soft-cut Duration: {}", + self.soft_cut_duration + .map_or("Not specified".to_string(), |d| d.to_string()) + )?; + write!( + f, + " Audio Device: {}", + self.audio_device + .as_ref() + .map_or("default".to_string(), |d| d.clone()) ) } } @@ -103,12 +154,22 @@ impl Default for Config { frame_rate: Some(60), beat_sensitivity: Some(1.0), preset_duration: Some(10.0), + shuffle_enabled: None, + soft_cut_duration: None, + preset_locked: None, + audio_device: None, + window_width: None, + window_height: None, + window_left: None, + window_top: None, + window_monitor: None, + window_override_position: None, } } } impl App { - pub fn apply_config(&self, config: &Config) { + pub fn apply_config(&mut self, config: &Config) { let pm = &self.pm; // set frame rate if provided @@ -116,9 +177,33 @@ impl App { pm.set_fps(frame_rate); } - // load presets if provided + // set beat sensitivity if provided + if let Some(beat_sensitivity) = config.beat_sensitivity { + pm.set_beat_sensitivity(beat_sensitivity); + } + + // set preset duration if provided (must be set before play_next so the + // correct duration is in effect when the first preset starts its timer) + if let Some(preset_duration) = config.preset_duration { + pm.set_preset_duration(preset_duration); + } + + // set soft-cut (crossfade) duration if provided + // maps to projectM.transitionDuration in .properties files + if let Some(soft_cut_duration) = config.soft_cut_duration { + pm.set_soft_cut_duration(soft_cut_duration); + } + + // set shuffle mode + if let Some(shuffle) = config.shuffle_enabled { + self.playlist.set_shuffle(shuffle); + } + + // load presets and start playback (after duration is configured) if let Some(preset_path) = &config.preset_path { self.add_preset_path(preset_path); + // Trigger playback of the first preset from the loaded path + self.playlist.play_next(); } // load textures if provided @@ -127,18 +212,9 @@ impl App { pm.set_texture_search_paths(&paths, 1); } - // set beat sensitivity if provided - if let Some(beat_sensitivity) = config.beat_sensitivity { - pm.set_beat_sensitivity(beat_sensitivity); - } - - // set preset duration if provided - if let Some(preset_duration) = config.preset_duration { - pm.set_preset_duration(preset_duration); - } - - // set preset shuffle mode - // self.playlist.set_shuffle(true); + // Note: preset_locked from .properties is acknowledged but the projectM + // playlist API doesn't expose a direct lock method in the Rust bindings. + // Users can press the preset lock key at runtime instead. } pub fn get_frame_rate(&self) -> FrameRate { diff --git a/src/main.rs b/src/main.rs index 4b94c8a..9b12290 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod app; mod dummy_audio; +mod properties; use std::path::PathBuf; use crate::app::config::Config; @@ -16,7 +17,7 @@ use confique::Config as ConfiqueConfig; /// Need help? Join discord: https://discord.gg/uSSggaMBrv struct Settings { #[arg(short, long = "config")] - /// Path to a config file + /// Path to a config file (supports .toml, .json, .yaml, .properties) config_path: Option, #[arg(short, long)] @@ -36,21 +37,29 @@ struct Settings { texture_path: Option, #[arg(short, long)] - #[arg(default_value = "1.0")] #[arg(env = "PM_BEAT_SENSITIVITY")] /// Sensitivity of the beat detection beat_sensitivity: Option, #[arg(short = 'd', long)] - #[arg(default_value = "10")] #[arg(env = "PM_PRESET_DURATION")] /// Duration (seconds) each preset will play preset_duration: Option, - // TODO: Add option for specifying audio device - // #[arg(short, long)] - // #[arg(env = "PM_AUDIO_INPUT")] - // /// Audio input device (name or index) - // audio_input: Option, + + #[arg(short, long)] + #[arg(env = "PM_AUDIO_INPUT")] + /// Audio input device (name or substring match) + audio_device: Option, + + #[arg(long)] + #[arg(env = "PM_SHUFFLE")] + /// Enable preset shuffling + shuffle_enabled: Option, + + #[arg(long)] + #[arg(env = "PM_TRANSITION_DURATION")] + /// Duration (seconds) of transitions between presets + transition_duration: Option, } impl Default for Settings { @@ -62,6 +71,9 @@ impl Default for Settings { texture_path: None, beat_sensitivity: None, preset_duration: None, + audio_device: None, + shuffle_enabled: None, + transition_duration: None, } } } @@ -87,6 +99,15 @@ impl Settings { if let Some(preset_duration) = other.preset_duration { self.preset_duration = Some(preset_duration); } + if let Some(audio_device) = &other.audio_device { + self.audio_device = Some(audio_device.clone()); + } + if let Some(shuffle_enabled) = other.shuffle_enabled { + self.shuffle_enabled = Some(shuffle_enabled); + } + if let Some(transition_duration) = other.transition_duration { + self.transition_duration = Some(transition_duration); + } } } @@ -97,12 +118,20 @@ fn load_settings_file(path: Option) -> Result { if !path.exists() { return Err(format!("config path invalid: {}", path.display())); } - // ensure extention is valid - match path.extension().and_then(|ext| ext.to_str()) { - Some("toml") | Some("json") | Some("yaml)") => {} + + let extension = path.extension().and_then(|ext| ext.to_str()); + + match extension { + // .properties format — use custom parser + Some("properties") => { + println!("Loading .properties config from: {}", path.display()); + return load_properties_settings(&path); + } + // TOML/JSON/YAML — use confique + Some("toml") | Some("json") | Some("yaml") => {} _ => { return Err(format!( - "invalid config file extension: {:?}", + "invalid config file extension: {:?}. Supported: .toml, .json, .yaml, .properties", path.extension() )) } @@ -120,14 +149,27 @@ fn load_settings_file(path: Option) -> Result { } // No path, return empty settings - return Ok(Settings { - config_path: None, - frame_rate: None, - preset_path: None, - texture_path: None, - beat_sensitivity: None, - preset_duration: None, - }); + return Ok(Settings::default()); +} + +/// Load settings from a projectMSDL .properties file. +fn load_properties_settings(path: &std::path::Path) -> Result { + let props = properties::parse_properties_file(path)?; + let ps = properties::apply_properties(&props); + + Ok(Settings { + config_path: Some(path.to_path_buf()), + frame_rate: None, // Not in .properties format + preset_path: ps.preset_path, + texture_path: None, // Not in .properties format + beat_sensitivity: None, // Not in .properties format + // displayDuration → preset_duration (how long a preset plays before switching) + preset_duration: ps.display_duration, + audio_device: ps.audio_device, + shuffle_enabled: ps.shuffle_enabled, + // transitionDuration → soft_cut_duration (crossfade blend time) + transition_duration: ps.transition_duration, + }) } fn load_settings() -> Result { @@ -146,12 +188,34 @@ fn load_settings() -> Result { fn main() -> Result<(), String> { let settings = load_settings()?; + // Build window config from properties if loaded + let props_window = if let Some(ref config_path) = settings.config_path { + if config_path.extension().and_then(|e| e.to_str()) == Some("properties") { + let props = properties::parse_properties_file(config_path).ok(); + props.map(|p| properties::apply_properties(&p)) + } else { + None + } + } else { + None + }; + let app_config = Config { frame_rate: settings.frame_rate, preset_path: settings.preset_path, texture_path: settings.texture_path, beat_sensitivity: settings.beat_sensitivity, preset_duration: settings.preset_duration, + shuffle_enabled: settings.shuffle_enabled, + soft_cut_duration: settings.transition_duration, + preset_locked: props_window.as_ref().and_then(|p| p.preset_locked), + audio_device: settings.audio_device, + window_width: props_window.as_ref().and_then(|p| p.window_width), + window_height: props_window.as_ref().and_then(|p| p.window_height), + window_left: props_window.as_ref().and_then(|p| p.window_left), + window_top: props_window.as_ref().and_then(|p| p.window_top), + window_monitor: props_window.as_ref().and_then(|p| p.window_monitor), + window_override_position: props_window.as_ref().and_then(|p| p.window_override_position), }; // Initialize the application @@ -220,4 +284,40 @@ mod tests { std::env::remove_var("PM_PRESET_DURATION"); std::env::remove_var("PM_AUDIO_INPUT"); } + + #[test] + fn test_load_properties() { + use std::fs; + use std::io::Write; + + let dir = std::env::temp_dir(); + let path = dir.join("test_load.properties"); + let mut f = fs::File::create(&path).unwrap(); + write!( + f, + r#"audio.device: BlackHole 2ch +projectM.displayDuration: 60.549 +projectM.shuffleEnabled: true +projectM.transitionDuration: 10 +projectM.presetPath: /Users/delta/presets +window.width: 850 +window.height: 448 +"# + ) + .unwrap(); + + let settings = + crate::load_settings_file(Some(path.clone())).expect("Properties should load"); + + assert_eq!( + settings.preset_path.as_ref().map(|p| p.to_str().unwrap()), + Some("/Users/delta/presets") + ); + assert_eq!(settings.shuffle_enabled, Some(true)); + assert_eq!(settings.transition_duration, Some(10.0)); + assert_eq!(settings.preset_duration, Some(60.549)); + assert_eq!(settings.audio_device.as_deref(), Some("BlackHole 2ch")); + + fs::remove_file(&path).ok(); + } } diff --git a/src/properties.rs b/src/properties.rs new file mode 100644 index 0000000..fe6a787 --- /dev/null +++ b/src/properties.rs @@ -0,0 +1,239 @@ +//! Parser for projectMSDL .properties config files. +//! +//! The C++ SDL frontend uses a `key: value` format with dotted namespaces: +//! - `projectM.*` keys for visualizer settings +//! - `window.*` keys for window geometry +//! - `audio.*` keys for audio device selection +//! +//! This module parses these in two passes (window first, then projectM/audio) +//! and maps them into Settings. + +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +/// Raw parsed properties from a .properties file, split by namespace. +pub struct ParsedProperties { + pub window: HashMap, + pub projectm: HashMap, + pub audio: HashMap, +} + +/// Parse a .properties file into namespaced groups. +/// +/// Format: `key: value` per line. Lines starting with `#` or `!` are comments. +/// Empty lines are ignored. +pub fn parse_properties_file(path: &Path) -> Result { + let content = fs::read_to_string(path) + .map_err(|e| format!("Failed to read properties file: {}", e))?; + + let mut window = HashMap::new(); + let mut projectm = HashMap::new(); + let mut audio = HashMap::new(); + + for line in content.lines() { + let line = line.trim(); + + // Skip empty lines and comments + if line.is_empty() || line.starts_with('#') || line.starts_with('!') { + continue; + } + + // Split on first `: ` or `=` + let (key, value) = if let Some(idx) = line.find(": ") { + (&line[..idx], line[idx + 2..].trim()) + } else if let Some(idx) = line.find('=') { + (&line[..idx], line[idx + 1..].trim()) + } else { + continue; // Malformed line, skip + }; + + let key = key.trim(); + + // Route to the appropriate namespace + if let Some(suffix) = key.strip_prefix("window.") { + window.insert(suffix.to_string(), value.to_string()); + } else if let Some(suffix) = key.strip_prefix("projectM.") { + projectm.insert(suffix.to_string(), value.to_string()); + } else if let Some(suffix) = key.strip_prefix("audio.") { + audio.insert(suffix.to_string(), value.to_string()); + } + } + + Ok(ParsedProperties { + window, + projectm, + audio, + }) +} + +/// Parse a boolean value from a properties file. +/// Accepts "true"/"false" (case-insensitive). +fn parse_bool(value: &str) -> Option { + match value.to_lowercase().as_str() { + "true" => Some(true), + "false" => Some(false), + _ => None, + } +} + +/// Convert parsed properties into Settings fields. +/// +/// This is done in two passes as requested: +/// 1. Window pass: extract window geometry settings +/// 2. ProjectM/Audio pass: extract visualizer and audio settings +pub fn apply_properties( + props: &ParsedProperties, +) -> PropertiesSettings { + let mut settings = PropertiesSettings::default(); + + // --- Pass 1: Window properties --- + if let Some(v) = props.window.get("width") { + settings.window_width = v.parse().ok(); + } + if let Some(v) = props.window.get("height") { + settings.window_height = v.parse().ok(); + } + if let Some(v) = props.window.get("left") { + settings.window_left = v.parse().ok(); + } + if let Some(v) = props.window.get("top") { + settings.window_top = v.parse().ok(); + } + if let Some(v) = props.window.get("monitor") { + settings.window_monitor = v.parse().ok(); + } + if let Some(v) = props.window.get("overridePosition") { + settings.window_override_position = parse_bool(v); + } + + // --- Pass 2: ProjectM and Audio properties --- + if let Some(v) = props.projectm.get("presetPath") { + // Normalize Windows-style backslashes to forward slashes + let normalized = v.replace("\\\\", "/").replace('\\', "/"); + // Remove trailing slash if present + let normalized = normalized.trim_end_matches('/').to_string(); + settings.preset_path = Some(normalized.into()); + } + if let Some(v) = props.projectm.get("shuffleEnabled") { + settings.shuffle_enabled = parse_bool(v); + } + if let Some(v) = props.projectm.get("transitionDuration") { + settings.transition_duration = v.parse().ok(); + } + if let Some(v) = props.projectm.get("displayDuration") { + settings.display_duration = v.parse().ok(); + } + if let Some(v) = props.projectm.get("presetLocked") { + settings.preset_locked = parse_bool(v); + } + if let Some(v) = props.projectm.get("enableSplash") { + settings.enable_splash = parse_bool(v); + } + + if let Some(v) = props.audio.get("device") { + settings.audio_device = Some(v.to_string()); + } + + settings +} + +/// All settings that can be extracted from a .properties file. +#[derive(Debug, Default)] +pub struct PropertiesSettings { + // Window + pub window_width: Option, + pub window_height: Option, + pub window_left: Option, + pub window_top: Option, + pub window_monitor: Option, + pub window_override_position: Option, + + // ProjectM + pub preset_path: Option, + pub shuffle_enabled: Option, + pub transition_duration: Option, + pub display_duration: Option, + pub preset_locked: Option, + pub enable_splash: Option, + + // Audio + pub audio_device: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn test_parse_properties() { + let dir = std::env::temp_dir(); + let path = dir.join("test_projectm.properties"); + let mut f = fs::File::create(&path).unwrap(); + write!( + f, + r#"audio.device: BlackHole 2ch +projectM.displayDuration: 60.549 +projectM.enableSplash: false +projectM.presetLocked: false +projectM.presetPath: /Users/delta/presets +projectM.shuffleEnabled: true +projectM.transitionDuration: 10 +window.height: 448 +window.left: 666 +window.monitor: 2 +window.overridePosition: false +window.top: 212 +window.width: 850 +"# + ) + .unwrap(); + + let props = parse_properties_file(&path).unwrap(); + let settings = apply_properties(&props); + + assert_eq!(settings.window_width, Some(850)); + assert_eq!(settings.window_height, Some(448)); + assert_eq!(settings.window_left, Some(666)); + assert_eq!(settings.window_top, Some(212)); + assert_eq!(settings.window_monitor, Some(2)); + assert_eq!(settings.window_override_position, Some(false)); + + assert_eq!( + settings.preset_path.as_ref().map(|p| p.to_str().unwrap()), + Some("/Users/delta/presets") + ); + assert_eq!(settings.shuffle_enabled, Some(true)); + assert_eq!(settings.transition_duration, Some(10.0)); + assert_eq!(settings.display_duration, Some(60.549)); + assert_eq!(settings.preset_locked, Some(false)); + assert_eq!(settings.enable_splash, Some(false)); + + assert_eq!(settings.audio_device.as_deref(), Some("BlackHole 2ch")); + + fs::remove_file(&path).ok(); + } + + #[test] + fn test_windows_path_normalization() { + let dir = std::env::temp_dir(); + let path = dir.join("test_winpath.properties"); + let mut f = fs::File::create(&path).unwrap(); + write!( + f, + r#"projectM.presetPath: C:\\Users\\Dumbfuck\\presets\\"# + ) + .unwrap(); + + let props = parse_properties_file(&path).unwrap(); + let settings = apply_properties(&props); + + assert_eq!( + settings.preset_path.as_ref().map(|p| p.to_str().unwrap()), + Some("C:/Users/Dumbfuck/presets") + ); + + fs::remove_file(&path).ok(); + } +} diff --git a/test-data/config.properties b/test-data/config.properties new file mode 100644 index 0000000..5ed0e74 --- /dev/null +++ b/test-data/config.properties @@ -0,0 +1,13 @@ +audio.device: BlackHole 2ch +projectM.displayDuration: 60.54899978637695 +projectM.enableSplash: false +projectM.presetLocked: false +projectM.presetPath: /Users/delta/doritostash/deltaryz assets/deltamix stream/projectm presets +projectM.shuffleEnabled: true +projectM.transitionDuration: 10 +window.height: 448 +window.left: 666 +window.monitor: 2 +window.overridePosition: false +window.top: 212 +window.width: 850