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 PulseMixer(object): profiles = { "analog": None, "a2dp": None, "hsp": None, } current_profile = None active_sink = None def __init__(self): self.pulse = Pulse('volume-control') def introspect(self): for k in self.profiles.keys(): self.profiles[k] = None self.current_profile = None for k in self.profiles.keys(): self.profiles[k] = None self.cards = self.pulse.card_list() self.get_active_sink() for card in self.cards: for profile in card.profile_list: if (card.profile_active.name == profile.name and card.name[:4] == self.active_sink.name[:4]): self.current_profile = profile key = PROFILE_MAP.get(profile.name, None) if key: self.profiles[key] = profile def set_profile(self, key): # traverse through cards to determine which ones to turn on/off next_profile = self.profiles[key] next_card = None for card in self.cards: for profile in card.profile_list: if profile == next_profile: next_card = card break off_card = None off_profile = None for card in self.cards: if card == next_card: continue for profile in card.profile_list: if profile.name == PROFILE_OFF: off_card = card off_profile = profile if not next_card or not next_profile: return self.pulse.card_profile_set(next_card, next_profile) if off_card and off_profile: self.pulse.card_profile_set(off_card, off_profile) def get_active_sink(self): sink = None sink_inputs = self.pulse.sink_input_list() # check if a sink input is connected to a sink, if no, use default if len(sink_inputs): sink_id = sink_inputs[0].sink sinks = self.pulse.sink_list() sink = next((s for s in sinks if s.index == sink_id), None) if sink is None: info = self.pulse.server_info() sink = self.pulse.get_sink_by_name(info.default_sink_name) self.active_sink = sink return self.active_sink def get_sink_volume_and_mute(self): mute = True volume = 0 if self.active_sink: volume = self.pulse.volume_get_all_chans(self.active_sink) volume = min(max(volume, 0), 1) * 100 mute = self.active_sink.mute return volume, mute def set_volume(self, value): if self.active_sink: self.pulse.volume_set_all_chans(self.active_sink, value / 100.0) def change_volume(self, value): if self.active_sink: volume = self.pulse.volume_get_all_chans(self.active_sink) volume += value / 100.0 volume = min(max(volume, 0), 1) self.pulse.volume_set_all_chans(self.active_sink, volume) def toggle_mute(self): sink = self.active_sink sink and self.pulse.mute(sink, not sink.mute) def start_listener(self, func): self.callback = func self.thread = threading.Thread( target=self.async_listener ) self.thread.daemon = True self.thread.start() def async_listener(self): self.pulse_d = Pulse('volume-daemon') self.pulse_d.event_mask_set('sink') # Glib.idle_add is to run the callback in the UI thread self.pulse_d.event_callback_set( lambda e: GLib.idle_add(self.callback) ) self.pulse_d.event_listen()
def main(args): pulse = Pulse("pulse-audio-cycle") current_sink = pulse.get_sink_by_name( pulse.server_info().default_sink_name) current_profile = pulse.card_info(current_sink.card).profile_active # card -> holds all profiles and sets the active one # sink uses a card+profile combination and is names accordingly matching_cards_with_profiles = [] card_pattern = re.compile(args.card) logging.debug(f"card_pattern: {card_pattern}") # Get a list of all matching cards for card in pulse.card_list(): # Prepare to also match against sink description sink_for_current_card = None if args.use_sink_description: sink_for_current_card = sink_for_card(card, pulse) card_pattern_matched = False if re.search(card_pattern, card.name): card_pattern_matched = True logging.info(f"Card matched: {card.name}") if sink_for_current_card: logging.info( f"-> Sink Description: {sink_for_current_card.description}" ) elif args.use_sink_description: if sink_for_current_card: if re.search(card_pattern, sink_for_current_card.description): card_pattern_matched = True logging.info(f"Card matched: {card.name}") logging.info( f"-> Sink Description: {sink_for_current_card.description}" ) logging.info("-> matched via Sink Description") # Ignore cards that are not wanted by the user given pattern if not card_pattern_matched: continue matched_profiles = [] # Check if we need filter for certain profiles or leave as is for profile in card.profile_list: # skip unavailable profiles (unless wanted) if not profile.available and not args.with_unavailable: continue # Check every given profile for cp_card_pattern, cp_profile_pattern in args.profile: cp_card_pattern_matched = False if re.search(cp_card_pattern, card.name): cp_card_pattern_matched = True elif args.use_sink_description: if sink_for_current_card: if re.search(cp_card_pattern, sink_for_current_card.description): cp_card_pattern_matched = True # This cp_profile_pattern does not apply to this card if not cp_card_pattern_matched: continue if re.search(cp_profile_pattern, profile.name): logging.info(f" Profile matched: {profile.name} ") matched_profiles.append(profile) if not matched_profiles: logging.info(" No Profile matched – Keeping profile.") # put infos into list matching_cards_with_profiles.append((card, matched_profiles)) # separator betweem cards logging.info("") new_card, new_profile = new_card_and_profile(matching_cards_with_profiles, pulse) # change profile if necessary if new_profile: if args.verbose: logging.info(f"New Profile: {new_profile.description}") if not args.dry: pulse.card_profile_set(new_card, new_profile) else: if args.verbose: logging.info("NO new Profile.") # change sink (has to be done always because card profile also changes sink) new_sink = sink_for_card(new_card, pulse) if args.verbose: logging.info(f"New Card: {new_card.name}") if args.use_sink_description: logging.info(f"-> New Sink: {new_sink.description} ") if not args.dry: pulse.sink_default_set(new_sink) # move all input sinks (apps/X clients) to new output sink for input_sink in pulse.sink_input_list(): if args.verbose: logging.info( f" -> Switching {input_sink.proplist['application.name']}") if not args.dry: pulse.sink_input_move(input_sink.index, new_sink.index) # Show notification if args.notify: details = f"New Sink: {new_sink.description}" if new_profile: details += f"\nNew Profile: {new_profile.description}" notify("Sink Changed", details)
class PulseMixer(object): all_profiles = {} current_profile = None active_sink = None def __init__(self): self.pulse = Pulse('volume-control') def introspect(self): self.all_profiles = {} self.current_profile = None self.cards = self.pulse.card_list() for card in self.cards: description = card.proplist.get('device.description') for profile in card.profile_list: #print(profile) prof_key = PROFILE_MAP.get(profile.name, None) if prof_key and profile.available: key = description + '__' + prof_key self.all_profiles[key] = [card, profile] if (card.profile_active.name == profile.name and self.active_sink and card.name[:4] == self.active_sink.name[:4]): self.current_profile = key def set_profile(self, key): prof = self.all_profiles[key] if not prof: return card, profile = prof for c, p in self.all_profiles.values(): if c != card: self.pulse.card_profile_set(c, PROFILE_OFF) elif p == profile: self.pulse.card_profile_set(card, profile) def get_active_sink(self): sink = None sink_inputs = self.pulse.sink_input_list() # check if a sink input is connected to a sink, if no, use default if len(sink_inputs): sink_id = sink_inputs[0].sink sinks = self.pulse.sink_list() sink = next((s for s in sinks if s.index == sink_id), None) if sink is None: info = self.pulse.server_info() if info.default_sink_name == '@DEFAULT_SINK@': return None sink = self.pulse.get_sink_by_name(info.default_sink_name) self.active_sink = sink return self.active_sink def get_sink_volume_and_mute(self): mute = True volume = 0 if self.active_sink: volume = self.pulse.volume_get_all_chans(self.active_sink) volume = min(max(volume, 0), 1) * 100 mute = self.active_sink.mute return volume, mute def set_volume(self, value): if self.active_sink: self.pulse.volume_set_all_chans(self.active_sink, value / 100.0) def change_volume(self, value): if self.active_sink: volume = self.pulse.volume_get_all_chans(self.active_sink) volume += value / 100.0 volume = min(max(volume, 0), 1) self.pulse.volume_set_all_chans(self.active_sink, volume) def get_mute(self): return self.active_sink.mute def toggle_mute(self): sink = self.active_sink sink and self.pulse.mute(sink, not sink.mute) def start_listener(self, func): self.callback = func self.thread = threading.Thread(target=self.async_listener) self.thread.daemon = True self.thread.start() def async_listener(self): self.pulse_d = Pulse('volume-daemon') self.pulse_d.event_mask_set('sink', 'card') # Glib.idle_add is to run the callback in the UI thread self.pulse_d.event_callback_set(self.callback()) try: self.pulse_d.event_listen() except PulseDisconnected: time.sleep(3) self.pulse = Pulse('volume-control') self.async_listener()
class PulseAudio: """ PulseAudio connection """ BT_CARD_PREFIX = "bluez_card" BT_PROFILE_A2DP = "a2dp_sink" BT_PROFILE_HSP = "headset_head_unit" def __init__(self, config, bt_device, sp_device, hs_device): """ Constructor for PulseAudio connection. :param config: """ self.config = config self.pulse_conn = PulseLib('maxime-manage_connection') self.ladspa_device = self._lookup_sink_input_device("LADSPA Stream") self.bt_device = bt_device self.hs_device = hs_device self.sp_device = sp_device def activate_wireless(self, conn_event=True): """ Activate the wireless device. If it's a (dis)connect event, also mute the speakers so we don't blast audio. :param conn_event: :return: """ logging.debug("Activating wireless.") device_name = self.bt_device.output_device # We need to a wait a few seconds for Pulse to catch up first_run = True while True: try: target_device = self._lookup_sink_output_device(device_name) break except Exception as e: if "not found" in e.message: if first_run is True: DBusHelper.send_notification( "Routing to %s..." % device_name, DBusHelper.ICON_WIRELESS) first_run = False logging.debug( "Sleeping for 1 second so that Pulse can sort itself out." ) time.sleep(1) logging.error("Unable to find wireless device.") logging.debug("Target device is \"%s\"" % target_device.description) # This event check is used to make sure the headphones being # (un)intentionally disconnected don't suddenly blast loud noises # out of the speakers. if conn_event is True: logging.debug("This is a connection event. Unmuting wireless.") self._unmute(self.ladspa_device) self._move_output(self.ladspa_device, target_device, DBusHelper.ICON_WIRELESS) def resync_wireless(self): """ Resync a wireless stream. :return: """ # This uses a card identifer rather than a device. No idea if that matters. card_dev = self._lookup_card(self.BT_CARD_PREFIX) # There is no direct way to resync a stream to the wireless # device, but the folks on this here forum have found a # way to make it sorta work. # https://askubuntu.com/questions/145935/get-rid-of-0-5s-latency-when-playing-audio-over-bluetooth-with-a2dp logging.debug("Setting profile of \"%s\" to \"%s\"" % (card_dev.name, self.BT_PROFILE_HSP)) DBusHelper.send_notification("Resyncing Bluetooth audio stream.", icon=DBusHelper.ICON_GENERIC) self.pulse_conn.card_profile_set(card_dev, self.BT_PROFILE_HSP) # We need to let Pulse catch its breath. time.sleep(1) logging.debug("Setting profile of \"%s\" to \"%s\"" % (card_dev.name, self.BT_PROFILE_A2DP)) self.pulse_conn.card_profile_set(card_dev, self.BT_PROFILE_A2DP) # Switching profiles makes the sinks change, so we need to reroute. # @TODO might need to switch conn_even to true if there are mute issues self.activate_wireless(conn_event=False) def activate_headset(self, conn_event=True): """ Activate the headset device. :param conn_event: :return: """ logging.debug("Activating headset.") out_device_name = self.hs_device.output_device in_device_name = self.hs_device.input_device try: target_output_device = self._lookup_sink_output_device( out_device_name) target_input_device = self._lookup_source_device(in_device_name) except: return logging.debug("Target output device is \"%s\"" % target_output_device.description) self._move_output(self.ladspa_device, target_output_device, DBusHelper.ICON_HEADSET) self._set_input(target_input_device) def activate_speakers(self, conn_event=True): logging.debug("Activating speakers.") device_name = self.sp_device.output_device target_device = self._lookup_sink_output_device(device_name) logging.debug("Target device is \"%s\"" % target_device.description) # This event check is used to make sure the headphones being # (un)intentionally disconnected don't suddenly blast loud noises # out of the speakers. if conn_event is True: logging.debug("This is a connection event. Muting speakers.") self._mute(self.ladspa_device) self._move_output(self.ladspa_device, target_device, DBusHelper.ICON_SPEAKERS) def _lookup_sink_input_device(self, name): """ Return a Pulse sink input device. These are the items in the "Playback" tab in pavucontrol. :param name: :return: """ for device in self.pulse_conn.sink_input_list(): if device.name == name: return device logging.error( "Sink Input device not found! (Was searching for \"%s\")" % name) def _lookup_sink_output_device(self, description): """ Find a Pulse Sink device. These are what show up in the "Output Devices" tab in pavucontrol. :param prefix: :param description: :return: """ for device in self.pulse_conn.sink_list(): if description in device.description: return device logging.error( "Sink Input device not found! (Was searching for \"%s\")" % description) raise Exception( "Sink Input device not found! (Was searching for \"%s\")" % description) def _lookup_source_device(self, description): """ Find a Pulse source device. These are what show up in the "Input Devices" tab in pavucontrol. :param prefix: :param description: :return: """ for device in self.pulse_conn.source_list(): if device.description == description: return device logging.error("Source device not found! (Was searching for \"%s\")" % description) raise Exception("Source device not found! (Was searching for \"%s\")" % description) def _lookup_card(self, name): """ Find a Pulse card. This is the equivalent of pacmd list-cards. :param name: The string to search for in the name of the card. :return: """ for device in self.pulse_conn.card_list(): if name in device.name: return device logging.error("Card \"%s\" not found!" % name) raise Exception("Card \"%s\" not found!" % name) def _move_output(self, source, destination, icon): """ Move a Pulse stream :param source: Source device that we want to redirect. :param destination: Target device that we want to hear from. :return: None """ logging.info("Moving stream of \"%s\" to \"%s\"" % (source.name, destination.description)) self.pulse_conn.sink_input_move(source.index, destination.index) text = "Routed %s to %s" % (source.name, destination.description) DBusHelper.send_notification(text, icon) def _set_input(self, device): """ Set Pulse input device. :param device: :return: """ logging.info("Setting default source device to \"%s\"" % device.description) self.pulse_conn.source_default_set(device.name) def manage_connection(self, conn_state): """ Decide what to activate based on connection event :param conn_state: Boolean of whether the device was connected or not. :return: None """ if conn_state is True: # Connection self.activate_wireless(conn_event=True) elif conn_state is False: # Disconnection self.activate_speakers(conn_event=True) def _mute(self, device): """ Mute a sink device :param device: :return: """ logging.debug("Muting device \"%s\"" % device.name) self.pulse_conn.sink_input_mute(device.index, True) def _unmute(self, device): """ Unmute a sink input device. :param device: :return: """ logging.debug("Unmuting device \"%s\"" % device.name) self.pulse_conn.sink_input_mute(device.index, False)