File size: 8,905 Bytes
92c4ae6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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]