commit c98bc997c3a3c731195463b10fe52d25abbcb741 Author: arabian Date: Sun Feb 9 13:42:13 2025 +0300 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b169f3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +.idea +Cargo.lock \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9fa70b9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "pwsp" +version = "0.1.0" +edition = "2021" + +[dependencies] +egui = "0.31.0" +eframe = "0.31.0" +egui_material_icons = "0.3.0" +rfd = "0.15.2" +dirs = "6.0.0" +rodio = {version = "0.20.1", default-features = false, features = ["symphonia-all"]} +metadata = "0.1.10" + +[profile.release] +strip = true +lto = true +codegen-units = 1 +opt-level = "z" +panic = "abort" + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a5d46ab --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# PipeWire SoundPad + +This is a simple soundpad application written in Rust using egui for the GUI, pipewire for audio input/output, and rodio for audio decoding. It allows you to play various audio files (mp3, wav, ogg, flac, mp4, aac) through your microphone. + +![ScreenShot](screenshot.png) + +## Features + +* **Audio File Playback:** Supports a wide range of audio formats including mp3, wav, ogg, flac, mp4, and aac. +* **Microphone Output:** Plays selected audio files through the chosen microphone. +* **PipeWire Integration:** Leverages PipeWire for efficient audio routing and device management. +* **egui GUI:** Provides a user-friendly interface for file selection, playback control, and microphone selection. +* **Directory Management:** Allows adding and removing directories for organizing audio files. +* **Search Functionality:** Enables searching for specific audio files within loaded directories. +* **Playback Control:** Offers play/pause functionality, volume control, and a playback position slider. +* **Persistent Configuration:** Saves the list of added directories and the selected microphone for future use. + +## Installation + +1. **Rust and Cargo:** Ensure you have Rust and Cargo installed. You can install them from [https://www.rust-lang.org/tools/install](https://www.rust-lang.org/tools/install). +2. **Dependencies:** This project requires PipeWire to be installed on your system. The specific installation steps will depend on your distribution. +3. **Clone the Repository:** Clone this repository to your local machine. +4. **Build:** Navigate to the project directory in your terminal and run `cargo build --release`. + +## Usage + +1. **Run:** After building, run the application using `cargo run --release`. +2. **Add Directories:** Click the "+" button to add directories containing your audio files. +3. **Select Directory:** Click on a directory in the list to view its contents. +4. **Select Audio File:** Click on an audio file in the list to select it. +5. **Choose Microphone:** Select your desired microphone from the dropdown menu. +6. **Play/Pause:** Use the play/pause button to control playback. +7. **Volume:** Adjust the volume using the volume slider. +8. **Seek:** Use the playback position slider to navigate through the audio file. +9. **Search:** Use the search bar to filter audio files by name. + +## Configuration + +The application saves the list of added directories and the selected microphone in the application's configuration directory. This directory is typically located at `~/.config/pwsp`. + + +## Contributing + +Contributions are welcome! Please open an issue or submit a pull request. + +## License + +[MIT](LICENSE) \ No newline at end of file diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..ee00e6f Binary files /dev/null and b/screenshot.png differ diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..7658b85 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,465 @@ +use eframe::{CreationContext, Frame, NativeOptions}; +use egui::{ + Button, CentralPanel, ComboBox, Context, Label, ScrollArea, Separator, Slider, TextEdit, Ui, + Vec2, +}; +use egui_material_icons::icons; +use metadata::media_file::MediaFileMetadata; +use rfd::FileDialog; +use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink}; +use std::io::Write; +use std::{fs, path::PathBuf}; + +mod pw; + +enum PlayerState { + PLAYING, + PAUSED, +} + +impl Default for PlayerState { + fn default() -> Self { + PlayerState::PAUSED + } +} + +#[derive(Default)] +struct App { + player_position: f32, + prev_player_position: f32, + max_player_position: f32, + + volume: f32, + + player_state: PlayerState, + + directories: Vec, + deleted_directory: Option, + current_directory: Option, + + selected_input_device: String, + available_input_devices: Vec, + + current_file: PathBuf, + + search_query: String, + + _audio_stream: Option, + _audio_stream_handle: Option, + audio_sink: Option, +} + +impl App { + pub fn new(_cc: &CreationContext<'_>) -> Self { + let saved_dirs_path = dirs::config_dir().unwrap().join("pwsp").join("saved_dirs"); + let saved_dirs_content = fs::read_to_string(&saved_dirs_path).unwrap_or_default(); + let saved_dirs: Vec<_> = saved_dirs_content.lines().map(PathBuf::from).collect(); + let current_directory = match saved_dirs.is_empty() { + false => Some(0), + true => None, + }; + + let saved_mic_path = dirs::config_dir().unwrap().join("pwsp").join("saved_mic"); + let saved_mic_content = fs::read_to_string(&saved_mic_path).unwrap_or_default(); + + let (_audio_stream, audio_stream_handle) = OutputStream::try_default().unwrap(); + let audio_sink = Sink::try_new(&audio_stream_handle).unwrap(); + audio_sink.pause(); + + Self { + max_player_position: 1.0, + directories: saved_dirs, + selected_input_device: saved_mic_content, + _audio_stream: Some(_audio_stream), + _audio_stream_handle: Some(audio_stream_handle), + audio_sink: Some(audio_sink), + volume: 1.0, + current_directory, + + ..Default::default() + } + } + + fn upd(&mut self, ui: &mut Ui, ctx: &Context, _frame: &mut Frame) { + self.available_input_devices = pw::get_input_devices().unwrap(); + + let saved_mic_path = dirs::config_dir().unwrap().join("pwsp").join("saved_mic"); + let saved_mic_content = fs::read_to_string(&saved_mic_path).unwrap_or_default(); + if self.selected_input_device != saved_mic_content { + fs::write(saved_mic_path, self.selected_input_device.clone()).ok(); + } + + if let PlayerState::PLAYING = self.player_state { + ctx.request_repaint(); + self.audio_sink.as_ref().unwrap().set_volume(self.volume); + } + + self.player_state = match self.audio_sink.as_ref().unwrap().is_paused() { + true => PlayerState::PAUSED, + false => PlayerState::PLAYING, + }; + + if self.player_position != self.prev_player_position { + let target_pos = core::time::Duration::from_secs_f32(self.player_position); + self.audio_sink + .as_ref() + .unwrap() + .try_seek(target_pos) + .unwrap(); + self.prev_player_position = self.player_position; + } else { + self.player_position = self.audio_sink.as_ref().unwrap().get_pos().as_secs_f32(); + } + + self.prev_player_position = self.player_position; + + self.render_ui(ui); + } + + fn render_ui(&mut self, ui: &mut Ui) { + self.render_player(ui); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + self.render_content(ui); + } + + fn render_player(&mut self, ui: &mut Ui) { + let file_title_label = Label::new( + self.current_file + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""), + ); + + let play_button_icon = match self.player_state { + PlayerState::PLAYING => icons::ICON_PAUSE, + PlayerState::PAUSED => icons::ICON_PLAY_ARROW, + }; + let play_button = Button::new(play_button_icon).corner_radius(15.0); + + let position_minutes = (self.player_position / 60.0) as i32; + let position_seconds = (self.player_position - (position_minutes as f32) * 60.0) as i32; + let position_minutes_str = match position_minutes.to_string().chars().count() { + 1 => "0".to_string() + &position_minutes.to_string(), + 2 => position_minutes.to_string(), + _ => "00".to_string(), + }; + let position_seconds_str = match position_seconds.to_string().chars().count() { + 1 => "0".to_string() + &position_seconds.to_string(), + 2 => position_seconds.to_string(), + _ => "00".to_string(), + }; + let player_position_label = + Label::new(format!("{}:{}", position_minutes_str, position_seconds_str)); + + let player_position_slider = + Slider::new(&mut self.player_position, 0.0..=self.max_player_position) + .show_value(false); + + let volume_slider = Slider::new(&mut self.volume, 0.0..=1.0).show_value(false); + + ui.add_space(10.0); + + ui.horizontal_top(|ui| { + let play_button_response = ui.add_sized([30.0, 30.0], play_button); + if !self.current_file.display().to_string().is_empty() && play_button_response.clicked() + { + match self.player_state { + PlayerState::PLAYING => { + self.audio_sink.as_ref().unwrap().pause(); + } + PlayerState::PAUSED => { + self.audio_sink.as_ref().unwrap().play(); + } + } + } + + ui.vertical(|ui| { + ui.spacing_mut().slider_width = ui.available_width() - 150.0; + ui.add_sized([ui.available_width() - 150.0, 15.0], file_title_label); + ui.add_sized([ui.available_width() - 150.0, 15.0], player_position_slider); + }); + + ui.add_sized([30.0, 30.0], player_position_label); + + let volume_slider_response = ui.add_sized([15.0, 30.0], volume_slider); + if volume_slider_response.changed() { + // println!("{}", self.volume.); + } + }); + } + + fn render_content(&mut self, ui: &mut Ui) { + let dirs_area_size = Vec2::new(120.0, ui.available_height()); + + ui.horizontal(|ui| { + ui.vertical(|ui| { + self.render_directories_list(ui, dirs_area_size); + self.render_mic_selection(ui); + }); + ui.allocate_ui(dirs_area_size, |ui| { + if !self.directories.is_empty() { + ui.add(Separator::default().vertical()); + } + }); + + let music_area_size = Vec2::new(ui.available_width(), ui.available_height()); + self.render_music_list(ui, music_area_size); + }); + + self.handle_directory_deletion(); + } + + fn render_directories_list(&mut self, ui: &mut Ui, scroll_area_size: Vec2) { + let add_dir_button = Button::new(icons::ICON_ADD).frame(false); + + ui.vertical(|ui| { + let add_dir_button_response = ui.add_sized([20.0, 20.0], add_dir_button); + if add_dir_button_response.clicked() { + self.handle_directory_adding(); + } + + ui.add_space(10.0); + + ui.allocate_ui(scroll_area_size, |ui| { + ScrollArea::vertical().id_salt(0).show(ui, |ui| { + for (index, dir) in self.directories.iter().enumerate() { + let dir_name = dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Invalid Directory"); + let dir_button = Button::new(dir_name).frame(false); + + let dir_delete_button = Button::new(icons::ICON_DELETE).frame(false); + + ui.horizontal(|ui| { + let dir_button_response = ui.add(dir_button); + if dir_button_response.clicked() { + self.current_directory = Some(index); + } + + let directory_delete_button_response = ui.add(dir_delete_button); + if directory_delete_button_response.clicked() { + self.deleted_directory = Some(index); + } + }); + ui.separator(); + } + }); + }); + }); + } + + fn handle_directory_adding(&mut self) { + if let Some(dirs) = FileDialog::pick_folders(Default::default()) { + let saved_dirs_path = dirs::config_dir().unwrap().join("pwsp").join("saved_dirs"); + if let Ok(mut file) = fs::OpenOptions::new().append(true).open(saved_dirs_path) { + for path in dirs { + if !self.directories.contains(&path) { + self.directories.push(path.clone()); + writeln!(file, "{}", path.display()).ok(); + } + } + } + } + } + + fn handle_directory_deletion(&mut self) { + if let Some(index) = self.deleted_directory { + if let Some(current_index) = self.current_directory { + if current_index > index { + self.current_directory = Some(current_index - 1); + } else if current_index == index { + self.current_directory = None; + } + } + + self.directories.remove(index); + self.deleted_directory = None; + + let saved_dirs_path = dirs::config_dir().unwrap().join("pwsp").join("saved_dirs"); + let content = self + .directories + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join("\n"); + + fs::write(saved_dirs_path, content).ok(); + } + } + + fn render_music_list(&mut self, ui: &mut Ui, scroll_area_size: Vec2) { + if self.current_directory.is_none() { + return; + } + + let current_path = self + .directories + .get(self.current_directory.unwrap()) + .unwrap(); + if !fs::exists(current_path).ok().unwrap_or(false) { + self.deleted_directory = self.current_directory; + return; + } + + let files = fs::read_dir(current_path).ok().unwrap(); + let mut music_files = Vec::new(); + let music_extensions = ["mp3", "wav", "ogg", "flac", "mp4", "aac"].to_vec(); + for file in files { + let path = file.unwrap().path(); + + if !path.is_file() { + continue; + } + + if let Some(extension) = path.extension().and_then(|n| n.to_str()) { + if music_extensions.contains(&extension.to_lowercase().as_str()) { + if path + .to_str() + .unwrap() + .to_string() + .to_lowercase() + .contains(self.search_query.as_str()) + { + music_files.push(path); + } + } + } + } + ui.vertical(|ui| { + let search_entry = TextEdit::singleline(&mut self.search_query); + ui.add_sized([ui.available_width(), 20.0], search_entry); + + ui.separator(); + + ui.allocate_ui(scroll_area_size, |ui| { + ScrollArea::vertical().id_salt(1).show(ui, |ui| { + for file in music_files.iter() { + let file_name = file.file_name().and_then(|n| n.to_str()).unwrap(); + let file_button = Button::new(file_name).frame(false); + let file_button_response = ui.add(file_button); + if file_button_response.clicked() { + self.current_file = file.to_path_buf(); + self.play_current_file(); + } + ui.separator(); + } + }); + }); + }); + } + + fn render_mic_selection(&mut self, ui: &mut Ui) { + ComboBox::from_label("Choose MIC") + .selected_text(format!("{:?}", self.selected_input_device)) + .show_ui(ui, |ui| { + for device in self.available_input_devices.iter() { + ui.selectable_value( + &mut self.selected_input_device, + device.audio_device.name.clone(), + device.audio_device.nick.clone(), + ); + } + }); + } + + fn play_current_file(&mut self) { + if self.current_file.to_str().unwrap().is_empty() { + return; + } + + if !self.current_file.exists() { + return; + } + + if !self.selected_input_device.is_empty() { + self.link_devices(); + } + + let file = fs::File::open(self.current_file.display().to_string()).unwrap(); + let source = Decoder::new(file).unwrap(); + + let metadata = MediaFileMetadata::new(&self.current_file.as_path()).unwrap(); + self.max_player_position = metadata._duration.unwrap() as f32; + + self.audio_sink.as_ref().unwrap().stop(); + self.audio_sink.as_ref().unwrap().play(); + self.audio_sink.as_ref().unwrap().append(source); + } + + fn link_devices(&self) { + let output_devices = pw::get_output_devices().unwrap(); + let mut pwsp_output: Option<&pw::OutputDevice> = None; + + for device in output_devices.iter() { + if device.audio_device.name == "alsa_playback.pwsp" { + pwsp_output = Some(device); + break; + } + } + + if pwsp_output.is_none() { + return; + } + + let input_devices = pw::get_input_devices().unwrap(); + let mut mic: Option<&pw::InputDevice> = None; + + for device in input_devices.iter() { + if device.audio_device.name == self.selected_input_device { + mic = Some(device); + break; + } + } + + if mic.is_none() { + return; + } + + pwsp_output.unwrap().link(mic.unwrap()); + } +} + +impl eframe::App for App { + fn update(&mut self, ctx: &Context, frame: &mut Frame) { + CentralPanel::default().show(ctx, |ui| self.upd(ui, ctx, frame)); + } +} + +fn main() -> Result<(), eframe::Error> { + let config_dir_path = dirs::config_dir().unwrap().join("pwsp"); + fs::create_dir_all(&config_dir_path).ok(); + + if !fs::exists(config_dir_path.join("saved_dirs")) + .ok() + .unwrap_or(false) + { + fs::File::create(config_dir_path.join("saved_dirs")).ok(); + } + if !fs::exists(config_dir_path.join("saved_mic")) + .ok() + .unwrap_or(false) + { + fs::File::create(config_dir_path.join("saved_mic")).ok(); + } + + let mut options = NativeOptions { + ..Default::default() + }; + options.viewport.min_inner_size = Some(Vec2::new(400.0, 400.0)); + options.vsync = true; + options.hardware_acceleration = eframe::HardwareAcceleration::Preferred; + + eframe::run_native( + "PipeWire SoundPad", + options, + Box::new(|cc| { + egui_material_icons::initialize(&cc.egui_ctx); + Ok(Box::new(App::new(cc))) + }), + ) +} diff --git a/src/pw.rs b/src/pw.rs new file mode 100644 index 0000000..97455b4 --- /dev/null +++ b/src/pw.rs @@ -0,0 +1,216 @@ +use std::error::Error; +use std::process::Command; +use std::collections::HashMap; + + +pub struct AudioDevice { + pub nick: String, + pub name: String +} + + +pub struct InputDevice { + pub audio_device: AudioDevice, + pub input_fl: String, + pub input_fr: String +} + + +pub struct OutputDevice { + pub audio_device: AudioDevice, + pub output_fl: String, + pub output_fr: String +} + + +impl OutputDevice { + pub fn link(&self, input_device: &InputDevice) { + let _ = Command::new("pw-link") + .arg(&self.output_fl) + .arg(&input_device.input_fl) + .status(); + + let _ = Command::new("pw-link") + .arg(&self.output_fr) + .arg(&input_device.input_fr) + .status(); + } +} + + +fn get_pw_entries() -> Result>, Box> { + let output = Command::new("pw-cli") + .args(&["ls", "Node"]) + .output() + .expect("Failed to execute pw-cli ls Node"); + + let output_str = String::from_utf8_lossy(&output.stdout); + + let mut entries = Vec::new(); + let mut current_entry = HashMap::new(); + + for line in output_str.lines() { + let line = line.trim(); + + if line.is_empty() { + continue + } + + if line.starts_with("id ") { + if !current_entry.is_empty() { + entries.push(current_entry); + current_entry = HashMap::new(); + } + } else { + let mut parts = line.splitn(2, " = "); + + let key = match parts.next() { + Some(k) => k.trim().to_string(), + None => continue + }; + + let value = match parts.next() { + Some(k) => k.trim().strip_prefix("\"").unwrap().strip_suffix("\"").unwrap().to_string(), + None => continue + }; + + current_entry.insert(key, value); + } + } + + if !current_entry.is_empty() { + entries.push(current_entry); + } + + Ok(entries) +} + +pub fn get_input_devices() -> Result, Box> { + let entries = get_pw_entries()?; + + let mut input_devices = Vec::new(); + + for entry in entries.iter() { + let media_class = entry.get("media.class").map(String::as_str).unwrap_or(""); + let nick = entry.get("node.nick").map(String::as_str) + .unwrap_or(entry.get("node.description").map(String::as_str) + .unwrap_or(entry.get("node.name").map(String::as_str).unwrap_or(""))); + let name = entry.get("node.name").map(String::as_str).unwrap_or(""); + + if media_class.is_empty() { + continue + } + + if !media_class.starts_with(&"Audio/Source") { + continue + } + + if nick.is_empty() || name.is_empty() { + continue + } + + let audio_device = AudioDevice { + nick: nick.to_string(), + name: name.to_string(), + }; + + let device = InputDevice { + audio_device, + input_fl: String::new(), + input_fr: String::new() + }; + + input_devices.push(device); + } + + let output = Command::new("pw-link") + .arg("-i") + .output() + .expect("Failed to execute pw-link -i"); + + let output_str = String::from_utf8_lossy(&output.stdout); + + for line in output_str.lines() { + let line = line.trim(); + + for device in input_devices.iter_mut() { + if line.starts_with(device.audio_device.name.as_str()) { + if line.ends_with("input_MONO") { + device.input_fl = line.to_string(); + device.input_fr = line.to_string(); + } else if line.ends_with("input_FL") { + device.input_fl = line.to_string(); + } else if line.ends_with("input_FR") { + device.input_fr = line.to_string(); + } + } + } + } + + Ok(input_devices) +} + +pub fn get_output_devices() -> Result, Box> { + let entries = get_pw_entries()?; + + let mut output_devices = Vec::new(); + + for entry in entries.iter() { + let media_class = entry.get("media.class").map(String::as_str).unwrap_or(""); + let nick = entry.get("node.nick").map(String::as_str) + .unwrap_or(entry.get("node.description").map(String::as_str) + .unwrap_or(entry.get("node.name").map(String::as_str).unwrap_or(""))); + let name = entry.get("node.name").map(String::as_str).unwrap_or(""); + + if media_class.is_empty() { + continue + } + + if !media_class.starts_with(&"Stream/Output/Audio") { + continue + } + + if nick.is_empty() || name.is_empty() { + continue + } + + let audio_device = AudioDevice { + nick: nick.to_string(), + name: name.to_string(), + }; + + let device = OutputDevice { + audio_device, + output_fl: String::new(), + output_fr: String::new() + }; + + output_devices.push(device); + } + + let output = Command::new("pw-link") + .arg("-o") + .output() + .expect("Failed to execute pw-link -o"); + + let output_str = String::from_utf8_lossy(&output.stdout); + + for line in output_str.lines() { + let line = line.trim(); + + for device in output_devices.iter_mut() { + if line.starts_with(device.audio_device.name.as_str()) { + if line.ends_with("capture_MONO") { + device.output_fl = line.to_string(); + device.output_fr = line.to_string(); + } else if line.ends_with("output_FL") { + device.output_fl = line.to_string(); + } else if line.ends_with("output_FR") { + device.output_fr = line.to_string(); + } + } + } + } + + Ok(output_devices) +} \ No newline at end of file