class PulseManager: """Manage connection to PulseAudio and receive updates.""" def __init__(self, volctl): self._volctl = volctl self._pulse_loop_paused = False self._pulse = Pulse(client_name=PROGRAM_NAME, connect=False) self._poller_thread = None self._pulse_lock, self._pulse_hold = threading.Lock(), threading.Lock() signal.signal(signal.SIGALRM, self._handle_pulse_events) # Stream monitoring self._monitor_streams = {} self._read_cb_ctypes = c.PA_STREAM_REQUEST_CB_T(self._read_cb) self._samplespec = c.PA_SAMPLE_SPEC(format=c.PA_SAMPLE_FLOAT32BE, rate=25, channels=1) self._connect() def close(self): """Close the PulseAudio connection and event polling thread.""" self.stop_peak_monitor() self._stop_polling() if self._pulse: self._pulse.close() @contextmanager def pulse(self): """ Yield the pulse object, pausing the pulse event loop. See https://github.com/mk-fg/python-pulse-control#event-handling-code-threads """ if self._pulse_loop_paused: yield self._pulse else: # Pause PulseAudio event loop with self._pulse_hold: for _ in range(int(2.0 / 0.05)): # Event loop might not be started yet, so wait self._pulse.event_listen_stop() if self._pulse_lock.acquire(timeout=0.05): break else: raise RuntimeError("Could not aquire _pulse_lock!") self._pulse_loop_paused = True try: yield self._pulse finally: self._pulse_lock.release() self._pulse_loop_paused = False def _connect(self): self._pulse.connect(wait=True) print("PulseAudio connected") self._start_polling() GLib.idle_add(self._volctl.on_connected) def _handle_pulse_events(self, *_): if self._poller_thread and self._poller_thread.is_alive(): # Remove transient events and duplicates events = OrderedDict() while self._poller_thread.events: event = self._poller_thread.events.popleft() new_tuple = (PulseEventTypeEnum.new, event.facility, event.index) if event.t == PulseEventTypeEnum.remove and events.pop( new_tuple, False): change_tuple = ( PulseEventTypeEnum.change, event.facility, event.index, ) events.pop(change_tuple, None) else: events[event.t, event.facility, event.index] = event for event in events.values(): GLib.idle_add(self._handle_event, event) self._poller_thread.event_timer_set = False # Reconnect on lost connection if not self._pulse.connected: GLib.idle_add(self._volctl.on_disconnected) self._stop_polling() self._connect() def _start_polling(self): self._poller_thread = PulsePoller(self._pulse, self._pulse_lock, self._pulse_hold, self._handle_event) self._poller_thread.start() def _stop_polling(self): if self._poller_thread and self._poller_thread.is_alive(): self._poller_thread.quit = True self._poller_thread.join(timeout=1.0) self._poller_thread = None def _handle_event(self, event): """Handle PulseAudio event.""" fac = "sink" if event.facility == "sink" else "sink_input" if event.t == PulseEventTypeEnum.change: method, obj = None, None with self.pulse() as pulse: obj_list = getattr(pulse, f"{fac}_list")() obj = get_by_attr(obj_list, "index", event.index) if obj: method = getattr(self._volctl, f"{fac}_update") method(event.index, obj.volume.value_flat, obj.mute == 1) elif event.t in (PulseEventTypeEnum.new, PulseEventTypeEnum.remove): self._volctl.slider_count_changed() else: print(f"Warning: Unhandled event type for {fac}: {event.t}") def _read_cb(self, stream, nbytes, idx): data = c.c_void_p() nbytes = c.c_int(nbytes) idx -= 1 c.pa.stream_peek(stream, data, c.byref(nbytes)) try: if not data or nbytes.value < 1: return samples = c.cast(data, c.POINTER(c.c_float)) val = max(samples[i] for i in range(nbytes.value)) finally: # stream_drop() flushes buffered data (incl. buff=NULL "hole" data) # stream.h: "should not be called if the buffer is empty" if nbytes: c.pa.stream_drop(stream) GLib.idle_add(self._volctl.peak_update, idx, min(val, 1.0)) def start_peak_monitor(self): """Start peak monitoring for all sinks and sink inputs.""" with self.pulse() as pulse: for sink in pulse.sink_list(): stream = self._create_peak_stream(sink.index) self._monitor_streams[sink.index] = stream for sink_input in pulse.sink_input_list(): sink_idx = self._pulse.sink_input_info(sink_input.index).sink stream = self._create_peak_stream(sink_idx, sink_input.index) self._monitor_streams[sink_input.index] = stream def stop_peak_monitor(self): """Stop peak monitoring for all sinks and sink inputs.""" with self.pulse(): for idx, stream in self._monitor_streams.items(): try: c.pa.stream_disconnect(stream) except c.pa.CallError: pass # Stream was removed finally: GLib.idle_add(self._volctl.peak_update, idx, 0.0) self._monitor_streams = {} def _create_peak_stream(self, sink_idx, sink_input_idx=None): # Cannot use `get_peak_sample` from python-pulse-control as it would block GUI. proplist = c.pa.proplist_from_string( # Hide this stream in mixer apps "application.id=org.PulseAudio.pavucontrol") pa_context = self._pulse._ctx # pylint: disable=protected-access idx = sink_idx if sink_input_idx is None else sink_input_idx stream = c.pa.stream_new_with_proplist(pa_context, f"peak {idx}", c.byref(self._samplespec), None, proplist) c.pa.proplist_free(proplist) c.pa.stream_set_read_callback(stream, self._read_cb_ctypes, idx + 1) if sink_input_idx is not None: # Monitor single sink input c.pa.stream_set_monitor_stream(stream, sink_input_idx) c.pa.stream_connect_record( stream, str(sink_idx).encode("utf-8"), c.PA_BUFFER_ATTR(fragsize=4, maxlength=2**32 - 1), c.PA_STREAM_DONT_MOVE | c.PA_STREAM_PEAK_DETECT | c.PA_STREAM_ADJUST_LATENCY | c.PA_STREAM_DONT_INHIBIT_AUTO_SUSPEND, ) return stream # Set/get PulseAudio entities def set_main_volume(self, val): """Set default sink volume.""" with self.pulse() as pulse: pulse.volume_set_all_chans(self.default_sink, val) def toggle_main_mute(self): """Toggle default sink mute.""" with self.pulse(): self.sink_set_mute(self.default_sink_idx, not self.default_sink.mute) def sink_set_mute(self, idx, mute): """Set sink mute.""" with self.pulse() as pulse: pulse.sink_mute(idx, mute) def sink_input_set_mute(self, idx, mute): """Set sink input mute.""" with self.pulse() as pulse: pulse.sink_input_mute(idx, mute) @property def volume(self): """Volume of the default sink.""" try: return self.default_sink.volume.value_flat except AttributeError: return 0.0 @property def mute(self): """Mute state of the default sink.""" try: return self.default_sink.mute == 1 except AttributeError: return False @property def default_sink(self): """Default sink.""" with self.pulse() as pulse: sink_name = pulse.server_info().default_sink_name return get_by_attr(pulse.sink_list(), "name", sink_name) @property def default_sink_idx(self): """Default sink index.""" try: return self.default_sink.index except AttributeError: return None
class Py3status: py3: Py3 blocks = u"_▁▂▃▄▅▆▇█" button_down = 5 button_mute = 1 button_up = 4 format = u"{icon} {percentage}%" format_muted = u"{icon} {percentage}%" is_input = False max_volume = 100 thresholds = [(0, "good"), (75, "degraded"), (100, "bad")] volume_delta = 5 def __init__(self, sink_name: Optional[str] = None, volume_boost: bool = False): """ :param sink_name: Sink name to use. Empty uses default sink :param volume_boost: Whether to allow setting volume above 1.0 - uses software boost """ self._sink_name = sink_name self._sink_info: Optional[PulseSinkInfo] self._volume_boost = volume_boost self._pulse_connector = Pulse('py3status-pulse-connector', threading_lock=True) self._pulse_connector_lock = threading.Lock() self._volume: Optional[Volume] = None self._backend_thread = threading.Thread def _get_volume_from_backend(self): """Get a new sink on every call. The sink is not updated when the backed values change. Returned volume is the maximum of all available channels. """ sink_name = self._pulse_connector.server_info().default_sink_name self._sink_info = self._pulse_connector.get_sink_by_name(sink_name) pulse_volume = Volume.from_sink_info(self._sink_info) logger.debug(pulse_volume) if self._volume != pulse_volume: self._volume = pulse_volume self.py3.update() def _callback(self, ev): if ev.t == PulseEventTypeEnum.change and \ (ev.facility == PulseEventFacilityEnum.server or ev.facility == PulseEventFacilityEnum.sink and ev.index == self._sink_info.index): raise PulseLoopStop def _pulse_reader(self): while True: try: self._pulse_connector.event_listen() self._get_volume_from_backend() except PulseDisconnected: logger.debug("Pulse disconnected. Stopping reader.") break def post_config_hook(self): self._pulse_connector.connect() self._get_volume_from_backend() self._pulse_connector.event_mask_set(PulseEventMaskEnum.server, PulseEventMaskEnum.sink) self._pulse_connector.event_callback_set(self._callback) self._backend_thread = threading.Thread( name="pulse_backend", target=self._pulse_reader).start() def kill(self): logger.info("Shutting down") self._pulse_connector.disconnect() def _color_for_output(self) -> str: if self._volume is None: return self.py3.COLOR_BAD if self._volume.mute: return self.py3.COLOR_MUTED or self.py3.COLOR_BAD return self.py3.threshold_get_color(self._volume.level) def _icon_for_output(self) -> str: return self.blocks[min( len(self.blocks) - 1, int(math.ceil(self._volume.level / 100 * (len(self.blocks) - 1))), )] def _format_output(self) -> Union[str, Composite]: return self.py3.safe_format(format_string=self.format_muted if self._volume.mute else self.format, param_dict={ "icon": self._icon_for_output(), "percentage": self._volume.level }) def volume_status(self): response = { "cached_until": self.py3.CACHE_FOREVER, "color": self._color_for_output(), "full_text": self._format_output() } return response