Skip to content
Closed
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
2 changes: 1 addition & 1 deletion PenguinWave/RunConfigurations/DevAppRun.run.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="DevAppRun" type="CompoundRunConfigurationType">
<toRun name="RunTauriApp" type="CargoCommandRunConfiguration" />
<toRun name="RunTauriApp" targetId="RsBuildProfile:dev" type="CargoCommandRunConfiguration" />
<toRun name="RunDevServer" type="js.build_tools.npm" />
<method v="2" />
</configuration>
Expand Down
46 changes: 46 additions & 0 deletions PenguinWave/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions PenguinWave/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"tauri:build": "ARCH=x86_64 NO_STRIP=1 npx tauri build"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-shell": "^2.2.1",
Expand Down
33 changes: 30 additions & 3 deletions PenguinWave/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ mod headsets;
mod system;
mod utils;
use crate::event::chatmix_listener::{init_chatmix_monitor, ChatMixMonitoringState};
use crate::system::pipewire::{init_virtual_channels, PipeWireManager};
use crate::system::pipewire::{init_virtual_channels, PipeWireManager, PipeWireNode, SinkInput};
use crate::utils::hardware_utils::*;
use crate::utils::state::AppStateManager;
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -37,8 +37,7 @@ fn greet(name: &str) -> String {
for (i, device_list) in devices.iter().enumerate() {
let battery = device_list.device.request_battery();
let chatmix = device_list.device.request_chatmix();
//let volume = device_list.device.set_microphone_volume(255).unwrap();
//let response = device_list.device.set_sidetone(0).unwrap();

device_info.push_str(&format!(
"Device #{}: VID: 0x{:04X}, PID: 0x{:04X} #{:?} #{} Chatmix #{}\n",
i + 1,
Expand Down Expand Up @@ -73,6 +72,13 @@ struct LinkRequest {
target_port: String,
}

#[derive(Debug, Serialize, Deserialize)]
struct MoveStreamRequest {
application_id: u32,
sink_id: u32,
}


// Struct for HID device lookup
#[derive(Debug, Serialize, Deserialize)]
struct HidDeviceRequest {
Expand Down Expand Up @@ -114,6 +120,24 @@ fn link_ports(request: LinkRequest) -> Result<(), String> {
.map_err(|e| e.to_string())
}

#[tauri::command]
fn get_custom_virtual_sinks() -> Result<Vec<PipeWireNode>, String> {
PipeWireManager::get_custom_virtual_sinks()
.map_err(|e| e.to_string())
}

#[tauri::command]
fn get_sink_inputs() -> Result<Vec<SinkInput>, String> {
PipeWireManager::list_sink_inputs()
.map_err(|e| e.to_string())
}

#[tauri::command]
fn move_application_to_sink(request: MoveStreamRequest) -> Result<(), String> {
PipeWireManager::move_application_to_sink(request.application_id, request.sink_id)
.map_err(|e| e.to_string())
}


#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
Expand Down Expand Up @@ -145,6 +169,9 @@ pub fn run() {
list_nodes,
get_output_devices,
link_ports,
get_custom_virtual_sinks,
get_sink_inputs,
move_application_to_sink
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
Expand Down
181 changes: 175 additions & 6 deletions PenguinWave/src-tauri/src/system/pipewire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub struct PipeWireNode {
pub name: String,
pub description: String,
pub node_type: String,
pub module_id: Option<u32>
}

#[derive(Debug, Serialize, Deserialize, Clone)]
Expand All @@ -27,17 +28,41 @@ pub struct AudioPort {
pub direction: String, // "input" or "output"
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SinkInput{
pub id: u32,
pub name: String,
pub icon: String,
pub assigned_sink_id: u32
}

pub struct PipeWireManager;

impl PipeWireManager {

/// list applications that play sounds (playback streams)
pub fn list_sink_inputs() -> Result<Vec<SinkInput>>{
let output = Command::new("pactl")
.args(&["list", "sink-inputs"])
.output()?;

if !output.status.success() {
return Err(anyhow!("Failed to list sink inputs: {}",
String::from_utf8_lossy(&output.stderr)));
}

let stdout = String::from_utf8_lossy(&output.stdout);

Self::parse_sink_input_details_from_output(&stdout)
}

pub fn list_sinks() -> Result<Vec<String>> {
let output = Command::new("pactl")
.args(&["list", "sinks", "short"])
.output()?;

if !output.status.success() {
return Err(anyhow!("Failed to list sinks: {}",
return Err(anyhow!("Failed to list sinks: {}",
String::from_utf8_lossy(&output.stderr)));
}

Expand All @@ -56,7 +81,72 @@ impl PipeWireManager {

Ok(sinks)
}


/// List custom created sinks, along with their module_id
pub fn get_custom_virtual_sinks() -> Result<Vec<PipeWireNode>> {
// First, get the custom sink IDs using the short format with filtering
let short_output = Command::new("sh")
.args(&["-c", "pactl list sinks short | grep -v -E \"(auto_null|alsa_output|bluez_output|alsa_card)\" | awk '{print $1}'"])
.output()?;

if !short_output.status.success() {
return Err(anyhow!("Failed to get custom sink IDs: {}",
String::from_utf8_lossy(&short_output.stderr)));
}

let sink_ids_str = String::from_utf8_lossy(&short_output.stdout);
let sink_ids: Vec<&str> = sink_ids_str.trim().lines().collect();

if sink_ids.is_empty() {
return Ok(Vec::new());
}

let mut nodes: Vec<PipeWireNode> = Vec::new();

for sink_id in sink_ids {
let sink_id = sink_id.trim();
if sink_id.is_empty() {
continue;
}

// Get detailed info for this specific sink
let detail_cmd = format!("pactl list sinks | awk \"/^Sink #{}$/,/^$/\"", sink_id);
let detail_output = Command::new("sh")
.args(&["-c", &detail_cmd])
.output()?;

if !detail_output.status.success() {
continue; // Skip this sink if we can't get details
}

let detail_stdout = String::from_utf8_lossy(&detail_output.stdout);

if let Some(node) = Self::parse_sink_details_from_output(&detail_stdout, sink_id){
nodes.push(node);
}
}

Ok(nodes)
}

/// Move playback stream to a sink
pub fn move_application_to_sink(application_id: u32, sink_id: u32) -> Result<()>{
let output = Command::new("pactl")
.args(&[
"move-sink-input",
&format!("{}", application_id),
&format!("{}", sink_id)
])
.output()?;

if !output.status.success() {
return Err(anyhow!("Failed to create virtual sink: {}",
String::from_utf8_lossy(&output.stderr)));
}

Ok(())
}

/// Create a new virtual sink
pub fn create_virtual_sink(sink_name: &str, description: &str) -> Result<u32> {
let output = Command::new("pactl")
Expand Down Expand Up @@ -126,7 +216,6 @@ impl PipeWireManager {
//pw-link virtual_sink:monitor_FL alsa_output.usb-SteelSeries_Arctis_Nova_7-00.analog-stereo:playback_FL
}

/// Parse the output of pw-cli ls Node to extract node information
/// Parse the output of pw-cli ls Node to extract node information

fn extract_quoted_value(line: &str, prefix: &str) -> Option<String> {
Expand Down Expand Up @@ -159,6 +248,7 @@ impl PipeWireManager {
name: String::from(name),
description: current_desc.clone().unwrap_or_default(),
node_type: current_type.clone().unwrap_or_default(),
module_id: None,
});
}

Expand Down Expand Up @@ -197,15 +287,13 @@ impl PipeWireManager {
name,
description: current_desc.unwrap_or_default(),
node_type: current_type.unwrap_or_default(),
module_id: None,
});
}

Ok(nodes)
}

/// Helper function to extract quoted values


/// Get audio devices suitable for output
pub fn get_output_devices() -> Result<Vec<AudioDevice>> {
let nodes = Self::list_nodes()?;
Expand Down Expand Up @@ -313,6 +401,87 @@ impl PipeWireManager {

Ok(())
}

fn parse_sink_details_from_output(detail_output: &str, sink_id: &str) -> Option<PipeWireNode>{
// Parse the detailed output
let mut name = String::new();
let mut description = String::new();
let mut node_type = String::new();
let mut module_id: Option<u32> = None;

for line in detail_output.lines() {
let line = line.trim();
if line.starts_with("Name: ") {
name = line.strip_prefix("Name: ").unwrap_or("").to_string();
} else if line.starts_with("Description: ") {
description = line.strip_prefix("Description: ").unwrap_or("").to_string();
} else if line.contains("media.class = ") {
if let Some(value) = Self::extract_quoted_value(line, "media.class = ") {
node_type = value;
}
} else if line.starts_with("Owner Module: ") {
module_id = Some(line.strip_prefix("Owner Module: ").unwrap_or("").parse::<u32>().unwrap_or(0));
}
}

if module_id.is_some() && !name.is_empty() {
let node = PipeWireNode {
id: sink_id.parse::<u32>().unwrap_or(0),
name,
description,
node_type,
module_id
};

return Some(node);
}
None
}

fn parse_sink_input_details_from_output(details_output: &str) -> Result<Vec<SinkInput>>{
let mut sink_inputs = Vec::new();
let mut id: u32 = 0;
let mut name = String::new();
let mut icon = String::new();
let mut assigned_sink_id : u32 = 0;

for line in details_output.lines() {
let line = line.trim();
if line.starts_with("Sink: ") {
assigned_sink_id = line
.strip_prefix("Sink: ")
.and_then(|s| s.trim().parse::<u32>().ok())
.unwrap_or(0);
}
else if line.contains("object.serial = ") {
if let Some(value) = Self::extract_quoted_value(line, "object.serial = ") {
id = value.parse::<u32>().unwrap_or(0);
}
} else if line.contains("application.name = ") {
if let Some(value) = Self::extract_quoted_value(line, "application.name = ") {
name = value;
}
} else if line.contains("application.icon_name = ") {
if let Some(value) = Self::extract_quoted_value(line, "application.icon_name = ") {
icon = value;
}
}
}

if !name.is_empty() && id != 0 {
let sink_input = SinkInput{
id,
name,
icon,
assigned_sink_id
};

sink_inputs.push(sink_input);
}

Ok(sink_inputs)
}

pub fn get_default_sink() -> Result<String> {
let output = Command::new("pactl")
.args(&["get-default-sink"])
Expand Down
Loading