Spaces:
Sleeping
Sleeping
File size: 8,905 Bytes
ff0e46c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 | import logging
import re
import subprocess
from typing import Any, Dict, List, Optional
def _parse_pactl_output(output: str) -> List[Dict[str, Any]]:
"""
Parses pactl list output (e.g., sink-inputs or sinks) into a list of property dictionaries.
Each item in the list represents a block (e.g., a "Sink Input" or "Sink").
"""
items = []
current_item = None
# Regex to capture the start of a new block (e.g., "Sink Input #0" or "Sink #0")
# and also to capture key-value pairs within a block.
# Handles cases where property values span multiple lines (though simple for now)
block_start_pattern = re.compile(r"^(Sink Input #|Sink #)(\d+)", re.IGNORECASE)
# Property pattern: Catches indented lines, assumes property key until '=', value after.
# Value can contain spaces and various characters. Strips leading/trailing quotes from value.
property_pattern = re.compile(r"^\s+([\w\.\-]+)\s*=\s*\"?(.*?)\"?$", re.IGNORECASE)
# More specific property pattern for keys that are known and simple
simple_property_pattern = re.compile(r"^\s+([\w\.\s-]+):\s*(.*)", re.IGNORECASE)
for line in output.splitlines():
block_match = block_start_pattern.match(line)
if block_match:
if current_item is not None:
items.append(current_item)
item_type = "sink_input" if block_match.group(1).lower() == "sink input #" else "sink"
current_item = {"_type": item_type, "_index": int(block_match.group(2))}
continue
if current_item is None:
continue # Skip lines until a block starts
prop_match = property_pattern.match(line)
if prop_match:
key = prop_match.group(1).strip().replace(" ", "_").lower() # Normalize key
value = prop_match.group(2).strip()
current_item[key] = value
else:
simple_prop_match = simple_property_pattern.match(line)
if simple_prop_match:
key = simple_prop_match.group(1).strip().replace(" ", "_").lower().replace(":", "")
value = simple_prop_match.group(2).strip()
current_item[key] = value
if current_item is not None:
items.append(current_item)
return items
def _get_linux_app_monitor_source(target_app_names: List[str], logger: logging.Logger) -> Optional[str]:
"""
Attempts to find a PulseAudio monitor source associated with a running application.
This function is specific to Linux systems using PulseAudio (`pactl`).
It tries to find a sink input linked to one of the `target_app_names`
(by checking application.name, application.process.binary, or media.name)
and then finds the monitor source associated with that sink input's sink.
Args:
target_app_names: A list of application names or binary names to search for
(e.g., ['zoom', 'chrome', 'firefox']). Case-insensitive substring match.
logger: A logger instance for logging messages.
Returns:
The name of the monitor source device if found, otherwise None.
"""
if not sys.platform.startswith('linux'):
logger.debug("Not a Linux system, skipping pactl audio detection.")
return None
try:
# 1. List sink inputs to find the one associated with the target application
pactl_sink_inputs_cmd = ["pactl", "list", "short", "sink-inputs"] # Use short form for easier parsing
# Example short output: 0 116 122 protocol-native.c float32le 2ch 48000Hz RUNNING
# The full 'pactl list sink-inputs' is more reliable for properties.
pactl_sink_inputs_cmd_full = ["pactl", "list", "sink-inputs"]
process = subprocess.run(pactl_sink_inputs_cmd_full, capture_output=True, text=True, check=True)
sink_inputs_output = process.stdout
# logger.debug(f"pactl list sink-inputs output:\n{sink_inputs_output}")
parsed_sink_inputs = _parse_pactl_output(sink_inputs_output)
# logger.debug(f"Parsed sink inputs: {parsed_sink_inputs}")
target_sink_index_str: Optional[str] = None
for si in parsed_sink_inputs:
if si.get("_type") != "sink_input": continue
app_name = si.get("properties", {}).get("application.name", "").lower()
app_binary = si.get("properties", {}).get("application.process.binary", "").lower()
media_name = si.get("properties", {}).get("media.name", "").lower() # Often useful, e.g., "Playback Stream"
# Check against each target name
for target_name_part in target_app_names:
target_lower = target_name_part.lower()
if (target_lower in app_name or
target_lower in app_binary or
(media_name and target_lower in media_name)): # Media name can be None
# Simpler parsing for properties block from full output
# Look for "Sink: <digits>"
sink_prop = si.get("sink") # From simple_property_pattern if it worked
if isinstance(sink_prop, str) and sink_prop.isdigit():
target_sink_index_str = sink_prop
logger.info(f"Found matching application sink input #{si.get('_index')} for '{target_name_part}' on sink index {target_sink_index_str} (App: {app_name}, Binary: {app_binary}, Media: {media_name})")
break # Found a match
if target_sink_index_str:
break
if not target_sink_index_str:
logger.info(f"No running sink input found matching target applications: {target_app_names}")
return None
# 2. List sinks to find the monitor source for the identified sink
pactl_sinks_cmd = ["pactl", "list", "sinks"]
process = subprocess.run(pactl_sinks_cmd, capture_output=True, text=True, check=True)
sinks_output = process.stdout
# logger.debug(f"pactl list sinks output:\n{sinks_output}")
parsed_sinks = _parse_pactl_output(sinks_output)
# logger.debug(f"Parsed sinks: {parsed_sinks}")
for sink_info in parsed_sinks:
if sink_info.get("_type") == "sink" and str(sink_info.get("_index")) == target_sink_index_str:
monitor_source_name = sink_info.get("monitor_source")
if monitor_source_name:
logger.info(f"Found monitor source for sink index {target_sink_index_str}: '{monitor_source_name}'")
return monitor_source_name
else:
logger.warning(f"Sink index {target_sink_index_str} found, but it has no 'Monitor Source' property. Properties: {sink_info}")
return None
logger.warning(f"Could not find sink details for index {target_sink_index_str} from application.")
return None
except FileNotFoundError:
logger.warning("`pactl` command not found. Cannot auto-detect Linux application audio source. Please ensure PulseAudio utilities are installed.")
return None
except subprocess.CalledProcessError as e:
logger.error(f"Error executing `pactl` command: {e}. Output: {e.stderr}")
return None
except Exception as e:
logger.error(f"An unexpected error occurred during Linux audio source detection: {e}", exc_info=True)
return None
if __name__ == '__main__':
# Basic test (requires a running audio application, e.g., Chrome playing YouTube)
logging.basicConfig(level=logging.DEBUG)
test_logger = logging.getLogger("LinuxAudioTest")
# Test with common browser names
browser_apps = ['chrome', 'firefox', 'msedge', 'chromium', 'opera']
monitor = _get_linux_app_monitor_source(browser_apps, test_logger)
if monitor:
test_logger.info(f"Test successful: Found monitor source for browsers: {monitor}")
else:
test_logger.warning("Test: No monitor source found for browsers. Ensure a browser is playing audio.")
# Test with a specific application name (if you have one running, e.g., 'Spotify')
# spotify_monitor = _get_linux_app_monitor_source(['spotify'], test_logger)
# if spotify_monitor:
# test_logger.info(f"Test successful: Found monitor source for Spotify: {spotify_monitor}")
# else:
# test_logger.warning("Test: No monitor source found for Spotify. Ensure Spotify is playing audio.")
# Test with a non-existent app
non_existent_monitor = _get_linux_app_monitor_source(['nonexistentapp123'], test_logger)
if not non_existent_monitor:
test_logger.info("Test successful: Correctly found no monitor for non-existent app.")
else:
test_logger.error(f"Test failed: Found monitor for non-existent app: {non_existent_monitor}")
[end of atomic-docker/project/functions/agents/_linux_audio_utils.py]
|