first commit

This commit is contained in:
2025-02-09 13:42:13 +03:00
commit c98bc997c3
6 changed files with 753 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/target
.idea
Cargo.lock
+21
View File
@@ -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"
+48
View File
@@ -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)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

+465
View File
@@ -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<PathBuf>,
deleted_directory: Option<usize>,
current_directory: Option<usize>,
selected_input_device: String,
available_input_devices: Vec<pw::InputDevice>,
current_file: PathBuf,
search_query: String,
_audio_stream: Option<OutputStream>,
_audio_stream_handle: Option<OutputStreamHandle>,
audio_sink: Option<Sink>,
}
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::<Vec<_>>()
.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)))
}),
)
}
+216
View File
@@ -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<Vec<HashMap<String, String>>, Box<dyn Error>> {
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<Vec<InputDevice>, Box<dyn Error>> {
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<Vec<OutputDevice>, Box<dyn Error>> {
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)
}