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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<p align="right">(<a href="#readme-top">back to top</a>)</p>

<!-- CONTRIBUTING -->
Expand Down
35 changes: 24 additions & 11 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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);

Expand All @@ -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());
Expand Down
50 changes: 48 additions & 2 deletions src/app/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ pub struct Audio {
projectm: ProjectMWrapped,
current_device_id: Option<AudioDeviceID>,
current_device_name: Option<String>, // Store device name for comparison
requested_device_name: Option<String>, // 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<String>) -> Self {
let audio_subsystem = sdl_context.audio().unwrap();
println!(
"Using audio driver: {}",
Expand All @@ -33,6 +34,7 @@ impl Audio {
current_device_name: None,
recording_stream: None,
projectm,
requested_device_name,
}
}

Expand All @@ -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) {
Expand All @@ -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<AudioDeviceID> {
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<AudioDeviceID>) {
// Stop capturing from current stream/device
Expand Down
106 changes: 91 additions & 15 deletions src/app/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<FrameRate>,
Expand All @@ -23,6 +24,37 @@ pub struct Config {

/// How long to play a preset before switching to a new one (seconds).
pub preset_duration: Option<f64>,

/// Whether to shuffle presets.
pub shuffle_enabled: Option<bool>,

/// Soft-cut (crossfade blend) duration between presets (seconds).
/// Maps to projectM.transitionDuration in .properties files.
pub soft_cut_duration: Option<f64>,

/// Whether the current preset is locked (won't auto-switch).
pub preset_locked: Option<bool>,

/// Audio device name to use for capture.
pub audio_device: Option<String>,

/// Window width.
pub window_width: Option<u32>,

/// Window height.
pub window_height: Option<u32>,

/// Window X position.
pub window_left: Option<i32>,

/// Window Y position.
pub window_top: Option<i32>,

/// Monitor index.
pub window_monitor: Option<u32>,

/// Whether to override default window position.
pub window_override_position: Option<bool>,
}

impl fmt::Display for Config {
Expand Down Expand Up @@ -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())
)
}
}
Expand Down Expand Up @@ -103,22 +154,56 @@ 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
if let Some(frame_rate) = config.frame_rate {
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
Expand All @@ -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 {
Expand Down
Loading