From 6d92d0f79fdb521e6f9add16314766871e8c9c56 Mon Sep 17 00:00:00 2001 From: trigg Date: Mon, 25 Mar 2024 00:52:20 +0000 Subject: [PATCH] - Pipewire and Pulseaudio integration - Opt in Core > Integrate - When microphone is muted or 0% set client to show muted - When output is muted or 0% set client to show deafened - Subscribe to client 'voice_settings_update' events to see when mic/output are changed - Cleaner quit on Ctrl-C -Fixes #327 --- discover_overlay/audio_assist.py | 130 ++++++++++++++++++++++++++ discover_overlay/discord_connector.py | 16 ++++ discover_overlay/discover_overlay.py | 13 +++ discover_overlay/glade/settings.glade | 30 +++++- discover_overlay/settings_window.py | 8 +- setup.py | 3 +- 6 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 discover_overlay/audio_assist.py diff --git a/discover_overlay/audio_assist.py b/discover_overlay/audio_assist.py new file mode 100644 index 0000000..f391446 --- /dev/null +++ b/discover_overlay/audio_assist.py @@ -0,0 +1,130 @@ +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""A class to assist with reading pulseaudio changes""" +import os +import logging +import signal +import pulsectl_asyncio +from contextlib import suppress +import asyncio +from threading import Thread, Event + +log = logging.getLogger(__name__) + +class DiscoverAudioAssist: + def __init__(self, discover): + + self.thread=None + self.enabled=False + self.source=None # String containing the name of the PA/PW microphone or other input + self.sink=None # String containing the name of the PA/PW output + + self.discover = discover + + # Keep last known state (or None) so that we don't repeatedly send messages for every little PA/PW signal + self.last_set_mute = None + self.last_set_deaf = None + + def set_enabled(self, enabled): + self.enabled = enabled + if enabled: + self.start() + + def set_devices(self, sink, source): + # Changed devices from client + self.source = source + self.sink = sink + + def start(self): + if not self.enabled: + return + if not self.thread: + self.thread=Thread(target=self.thread_loop) + self.thread.start() + + def thread_loop(self): + # Start an asyncio specific thread. Not the prettiest but I'm not rewriting from ground up for one feature + log.info("Staring Audio subsystem assistance") + loop = asyncio.new_event_loop() + loop.run_until_complete(self.pulse_loop()) + log.info("Stopped Audio subsystem assistance") + + async def listen(self): + # Async to connect to pulse and listen for events + async with pulsectl_asyncio.PulseAsync('Discover-Monitor') as pulse: + await self.get_device_details(pulse) + async for event in pulse.subscribe_events('all'): + await self.print_events(pulse, event) + + async def pulse_loop(self): + # Prep before connecting to pulse + loop = asyncio.get_event_loop() + listen_task = asyncio.create_task(self.listen()) + with suppress(asyncio.CancelledError): + await listen_task + + async def get_device_details(self, pulse): + # Decant information about our chosen devices + # Feed this back to client to change deaf/mute state + mute = None + deaf = None + for sink in await pulse.sink_list(): + if sink.description == self.sink: + if sink.mute == 1 or sink.volume.values[0]==0.0: + deaf = True + elif sink.mute == 0: + deaf = False + + for source in await pulse.source_list(): + if source.description == self.source: + if source.mute == 1 or source.volume.values[0]==0.0: + mute = True + elif sink.mute == 0: + mute = False + + if mute != self.last_set_mute: + self.last_set_mute = mute + self.discover.set_mute_async(mute) + + if deaf != self.last_set_deaf: + self.last_set_deaf = deaf + self.discover.set_deaf_async(deaf) + + async def print_events(self,pulse, ev): + if not self.enabled: + return + # Sink and Source events are fired for changes to output and ints + # Server is fired when default sink or source changes. + match ev.facility: + case 'sink': + await self.get_device_details(pulse) + + case 'source': + await self.get_device_details(pulse) + + case 'server': + await self.get_device_details(pulse) + + case 'source_output': + pass + + case 'sink_input': + pass + + case 'client': + pass + + case _: + # If we need to find more events, this here will do it + #log.info('Pulse event: %s' % ev) + pass \ No newline at end of file diff --git a/discover_overlay/discord_connector.py b/discover_overlay/discord_connector.py index 79da26b..a56f72f 100644 --- a/discover_overlay/discord_connector.py +++ b/discover_overlay/discord_connector.py @@ -347,6 +347,19 @@ class DiscordConnector: self.req_channel_details(j["data"]["id"], 'new') elif j["evt"] == "NOTIFICATION_CREATE": self.discover.notification_overlay.add_notification_message(j) + elif j["evt"] == "VOICE_SETTINGS_UPDATE": + source = j['data']['input']['device_id'] + sink = j['data']['output']['device_id'] + if sink == 'default': + for available_sink in j['data']['output']['available_devices']: + if available_sink['id']=='default': + sink = available_sink['name'][9:] + if source == 'default': + for available_source in j['data']['input']['available_devices']: + if available_source['id']=='default': + source = available_source['name'][9:] + self.discover.audio_assist.set_devices(sink, source) + else: log.warning(j) return @@ -589,6 +602,7 @@ class DiscordConnector: or that reports the users current location """ self.sub_raw("VOICE_CHANNEL_SELECT", {}, "VOICE_CHANNEL_SELECT") + self.sub_raw("VOICE_SETTINGS_UPDATE", {}, "VOICE_SETTINGS_UPDATE") self.sub_raw("VOICE_CONNECTION_STATUS", {}, "VOICE_CONNECTION_STATUS") self.sub_raw("GUILD_CREATE", {}, "GUILD_CREATE") self.sub_raw("CHANNEL_CREATE", {}, "CHANNEL_CREATE") @@ -662,6 +676,7 @@ class DiscordConnector: } if self.websocket: self.websocket.send(json.dumps(cmd)) + return False def set_deaf(self, deaf): cmd = { @@ -671,6 +686,7 @@ class DiscordConnector: } if self.websocket: self.websocket.send(json.dumps(cmd)) + return False def change_voice_room(self, id): """ diff --git a/discover_overlay/discover_overlay.py b/discover_overlay/discover_overlay.py index 75b2b92..03c8f32 100755 --- a/discover_overlay/discover_overlay.py +++ b/discover_overlay/discover_overlay.py @@ -20,6 +20,7 @@ import traceback import logging import pkg_resources import json +import signal import gi from configparser import ConfigParser @@ -28,6 +29,7 @@ from .voice_overlay import VoiceOverlayWindow from .text_overlay import TextOverlayWindow from .notification_overlay import NotificationOverlayWindow from .discord_connector import DiscordConnector +from .audio_assist import DiscoverAudioAssist gi.require_version("Gtk", "3.0") # pylint: disable=wrong-import-position,wrong-import-order @@ -78,6 +80,7 @@ class Discover: self.connection = DiscordConnector(self) self.connection.connect() + self.audio_assist = DiscoverAudioAssist(self) rpc_file = Gio.File.new_for_path(rpc_file) monitor = rpc_file.monitor_file(0, None) @@ -407,6 +410,8 @@ class Discover: self.text_overlay.set_hidden(hidden) self.notification_overlay.set_hidden(hidden) + self.audio_assist.set_enabled(config.getboolean("general", "audio_assist", fallback = False)) + def parse_guild_ids(self, guild_ids_str): """Parse the guild_ids from a str and return them in a list""" @@ -468,6 +473,13 @@ class Discover: if self.notification_overlay: self.notification_overlay.set_task(visible) + def set_mute_async(self, mute): + if mute != None: + GLib.idle_add(self.connection.set_mute, mute) + + def set_deaf_async(self, deaf): + if deaf != None: + GLib.idle_add(self.connection.set_deaf, deaf) def entrypoint(): """ @@ -482,6 +494,7 @@ def entrypoint(): otherwise start overlay """ + signal.signal(signal.SIGINT, signal.SIG_DFL) # Find Config directory config_dir = os.path.join(xdg_config_home, "discover_overlay") os.makedirs(config_dir, exist_ok=True) diff --git a/discover_overlay/glade/settings.glade b/discover_overlay/glade/settings.glade index b721c42..558056e 100644 --- a/discover_overlay/glade/settings.glade +++ b/discover_overlay/glade/settings.glade @@ -2620,7 +2620,7 @@ - + core_grid True @@ -2773,6 +2773,7 @@ True False True + 1 @@ -2801,6 +2802,33 @@ True + + 1 + 7 + + + + + core_audio_assist_label + True + False + Integrate with Pipewire/Pulseaudio + 0 + + + 0 + 6 + + + + + core_audio_assist + True + True + False + True + + 1 6 diff --git a/discover_overlay/settings_window.py b/discover_overlay/settings_window.py index f66975f..113add2 100644 --- a/discover_overlay/settings_window.py +++ b/discover_overlay/settings_window.py @@ -552,6 +552,7 @@ class MainSettingsWindow(): self.start_minimized = config.getboolean( "general", "start_min", fallback=False) + self.widget['core_settings_min'].set_active(self.start_minimized) self.widget['core_settings_min'].set_sensitive(self.show_sys_tray_icon) @@ -559,6 +560,8 @@ class MainSettingsWindow(): if 'XDG_SESSION_DESKTOP' in os.environ and os.environ['XDG_SESSION_DESKTOP']=='cinnamon': self.widget['voice_anchor_to_edge_button'].set_sensitive(False) + self.widget['core_audio_assist'].set_active(config.getboolean("general", "audio_assist", fallback=False)) + self.loading_config = False def make_colour(self, col): @@ -1178,4 +1181,7 @@ class MainSettingsWindow(): def inactive_fade_time_changed(self,button): self.config_set("main", "inactive_fade_time", "%s" % - (int(button.get_value()))) \ No newline at end of file + (int(button.get_value()))) + + def core_audio_assist_changed(self, button): + self.config_set("general", "audio_assist", "%s" % (button.get_active())) diff --git a/setup.py b/setup.py index b6cbcc3..60696e9 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,8 @@ setup( 'requests', 'pillow', 'python-xlib', - 'setuptools' + 'setuptools', + 'pulsectl-asyncio' ], entry_points={ 'console_scripts': [