annator-command-center / audio-utils /_linux_audio_utils.py
techprotrade's picture
Deploy ATOM FastAPI command center runtime (part 3)
92c4ae6 verified
Raw
History Blame Contribute Delete
8.91 kB
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]