class Pulse: def __init__(self, config): self.config = config self.pa = PulseOriginal("patray") def close(self): self.pa.close() def update(self): logger.debug("update called") self.cards = [ Card.from_pa_card(id=i, pa_card=c) for i, c in enumerate(self.pa.card_list()) ] ends = chain(self.pa.sink_list(), self.pa.source_list()) self.ends = [ End.from_pa_end(id=i, pa_end=e) for i, e in enumerate(ends) ] def set_profile(self, profile: CardProfile): card = self.cards[profile.card_id] self.pa.card_profile_set(card.original, profile.original) logger.info("set profile {!r} on card {!r}", profile.name, card.name) def set_port(self, port: Port): end = self.ends[port.end_id] self.pa.port_set(end.original, port.original) logger.info("set port {!r} on end {!r}", port.name, end.name) def set_volume(self, end: End, volume: float): self.pa.volume_set_all_chans(end.original, volume) logger.info("set volume {} on end {!r}", volume, end.name)
class SinksMonitoring(QtCore.QThread): addSinkInput = QtCore.pyqtSignal(PulseSinkInputInfo) removeSinkInput = QtCore.pyqtSignal(PulseSinkInputInfo) changeVolumeInput = QtCore.pyqtSignal(PulseSinkInputInfo) addSinkOutput = QtCore.pyqtSignal(PulseSourceOutputInfo) removeSinkOutput = QtCore.pyqtSignal(PulseSourceOutputInfo) changeVolumeOutput = QtCore.pyqtSignal(PulseSourceOutputInfo) def __init__(self): QtCore.QThread.__init__(self) self.pulse = Pulse('volume-mixer-monitor') self.__sinksInput = {} self.__sinksOutput = {} def run(self): while True: sinksInputNow = self.pulse.sink_input_list() removeSinksInput = self.__sinksInput.copy() for sinkNow in sinksInputNow: id = sinkNow.index if id not in self.__sinksInput: self.__sinksInput[id] = sinkNow self.addSinkInput.emit(sinkNow) else: if sinkNow.volume.values != self.__sinksInput[ id].volume.values: self.__sinksInput[id] = sinkNow self.changeVolumeInput.emit(sinkNow) del removeSinksInput[id] for key, sink, in removeSinksInput.items(): self.removeSinkInput.emit(sink) del self.__sinksInput[key] sinksOutputNow = self.pulse.source_output_list() removeSinksOutput = self.__sinksOutput.copy() for sinkNow in sinksOutputNow: id = sinkNow.index if id not in self.__sinksOutput: self.__sinksOutput[id] = sinkNow self.addSinkOutput.emit(sinkNow) else: if sinkNow.volume.values != self.__sinksOutput[ id].volume.values: self.__sinksOutput[id] = sinkNow self.changeVolumeOutput.emit(sinkNow) del removeSinksOutput[id] for key, sink, in removeSinksOutput.items(): self.removeSinkOutput.emit(sink) del self.__sinksOutput[key] self.msleep(10) def __del__(self): print("Pulse close") self.pulse.close()
class Dispatcher: """ A simple dispatcher class that receive powermate events and dispatch them to the right controller """ def __init__(self, observer): """ Initialize the super class and define the local members :param path: The path to the powermate device """ self._long_pressed = False self._pulse = Pulse(threading_lock=True) self._stored_app = None self._display = Display() # Connects to the default display self._note = pynotify.Notification("Volume", "0", "/usr/share/icons/Faenza/apps/48/" "gnome-volume-control.png") self._note.set_urgency(0) self._led = PowermateLed() self._led.max() self._rofi = Rofi() self._rofi.hide_scrollbar = True self._rofi.prompt = "App. name?" def short_press(self): """ Manage the short_press event :return: None """ # Get the list of active sinks sinks = self._get_sinks() # Get the names of the apps linked to the sinks app_sinks = {"{} {}".format(sink.proplist.get("application.name"), sink.index): sink for sink in sinks} if len(app_sinks) > 1: # Display a menu to select the application to control try: res = self._rofi(app_sinks) except MenuError: return app_sink = res.value elif len(app_sinks) == 1: _, app_sink = app_sinks.popitem() else: app_sink = None # If successful if app_sink is not None: # Toggle the mute status of the selected sink self._toggle_mute_sinks([app_sink]) # Declare a new notification self._note.update("Toggle Mute status", "{}".format(app_sink.proplist.get("application.name")), "/usr/share/icons/Faenza/apps/48/gnome-volume-control.png") # Show the notification self._note.show() def long_press(self): """ For the moment this method simply toggles the lights on/off :return: A LedEvent class """ if self._long_pressed: # Re-initialize the state of the powermate self._long_pressed = False self._stored_app = None # Just light up the powermate self._led.max() else: # Get the list of active sinks sinks = self._get_sinks() # Get the names of the apps linked to the sinks app_sinks = {sink.proplist.get("application.name"):sink.proplist.get("application.process.binary") for sink in sinks if sink.proplist.get("application.process.binary") not in self._get_active_win_class()} if len(app_sinks) > 1: # Display a menu to select the application to control try: res = self._rofi(app_sinks) except MenuError: return app_name = res.value elif len(app_sinks) == 1: _, app_name = app_sinks.popitem() else: app_name = None # If successful if app_name is not None: # Store the list of sinks corresponding to the app name self._stored_app = app_name # Toggle the long press state self._long_pressed = True # Have the powermate pulse self._led.pulse() else: # Make sure the long press flag is off self._long_pressed = False # Stop the pulse self._led.max() def rotate(self, rotation): """ Manage the rotate event :param rotation: The direction of rotation negative->left, positive->right :return: None """ # Get the class of the active window win_cls = self._get_active_win_class() if win_cls is not None: # Change the volume of the sinks self._change_volume_sinks(self._get_app_sinks(win_cls), rotation) def push_rotate(self, rotation): """ Changes the volume of the sinks registered by the long_press event, according to the given rotation. :param rotation: The direction and amplitude of the rotation. (negative = left, positive = right). :return: Nothing. """ # Change the volume of the current sinks self._change_volume_sinks(self._get_app_sinks(self._stored_app), rotation) def _toggle_mute_sinks(self, sinks): """ Simply toggle the mute status of all given sinks. :param sinks: A list of sink objects. :return: Nothing. """ # Toggle the mute status for sink in sinks: muted = bool(sink.mute) self._pulse.mute(sink, mute=not muted) def _change_volume_sinks(self, sinks, rotation): """ Simple change the volume of all given sinks and display a notification. :param sinks: A list of sink objects. :param rotation: The amount and direction of the rotation. :return: Nothing. """ # Change the volume of the sinks for sink in sinks: self._pulse.volume_change_all_chans(sink, rotation * 0.005) # Show the notification self._display_notification(sink) def _get_active_win_class(self): """ Use the xlib module to get the class of the window that has the focus :return: Return the window class or None if none found """ # Get the window that has the focus focus_win = self._display.get_input_focus().focus # Get the window class win_cls = focus_win.get_wm_class() if win_cls is None: # Get the class of the parent window parent_cls = focus_win.query_tree().parent.get_wm_class() if parent_cls is not None: return str(parent_cls[-1].lower()) else: return None else: return str(win_cls[-1].lower()) def _get_app_sinks(self, app_name): """ Get the sinks corresponding to the given application :param app_name: Name of the application :return: List of sink objects otherwise. """ # Make sure the app_name is a string if isinstance(app_name, str) and app_name is not None: # Get the list of input sinks sinks = self._get_sinks() # Return the list of sinks corresponding to the application return [sink for sink in sinks if sink.proplist.get("application.process.binary").lower() == app_name] else: return [] def _get_sinks(self): """ Get a list of active pulseaudio sinks :return: List. A list containing all the active sink objects. """ # Get the list of input sinks sinks = [sink for sink in self._pulse.sink_input_list() if sink.proplist.get("application.process.binary", None) is not None] # Return the list of active sinks return sinks def _display_notification(self, sink_in): """ Display a notification showing the overall current volume. :param volume: A float representing the value of the current sink input. :return: Nothing. """ # Get the volume of the input sink volume = self._pulse.volume_get_all_chans(sink_in) # Get the main sink for sink in self._pulse.sink_list(): if sink.index == sink_in.sink: main_vol = sink.volume.value_flat break else: main_vol = 1 # Declare a new notification self._note.update("Volume", "{:.2%}".format(volume * main_vol), "/usr/share/icons/Faenza/apps/48/" "gnome-volume-control.png") # Show the notification self._note.show() def _handle_exception(self): """ Close the connection to the pulse server. :return: Nothing """ # Close the connection to the pulse server self._pulse.close() self._led.off()
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