- 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
This commit is contained in:
trigg 2024-03-25 00:52:20 +00:00
parent ff69a4ed8f
commit 6d92d0f79f
6 changed files with 197 additions and 3 deletions

View file

@ -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 <https://www.gnu.org/licenses/>.
"""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

View file

@ -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):
"""

View file

@ -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)

View file

@ -2620,7 +2620,7 @@
</packing>
</child>
<child>
<!-- n-columns=2 n-rows=7 -->
<!-- n-columns=2 n-rows=8 -->
<object class="GtkGrid">
<property name="name">core_grid</property>
<property name="visible">True</property>
@ -2773,6 +2773,7 @@
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="draw-indicator">True</property>
<signal name="toggled" handler="core_hide_overlay_changed" swapped="no"/>
</object>
<packing>
<property name="left-attach">1</property>
@ -2801,6 +2802,33 @@
<property name="receives-default">True</property>
<signal name="pressed" handler="core_reset_all" swapped="no"/>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">7</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="name">core_audio_assist_label</property>
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">Integrate with Pipewire/Pulseaudio</property>
<property name="xalign">0</property>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">6</property>
</packing>
</child>
<child>
<object class="GtkCheckButton">
<property name="name">core_audio_assist</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="draw-indicator">True</property>
<signal name="toggled" handler="core_audio_assist_changed" swapped="no"/>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">6</property>

View file

@ -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())))
(int(button.get_value())))
def core_audio_assist_changed(self, button):
self.config_set("general", "audio_assist", "%s" % (button.get_active()))

View file

@ -32,7 +32,8 @@ setup(
'requests',
'pillow',
'python-xlib',
'setuptools'
'setuptools',
'pulsectl-asyncio'
],
entry_points={
'console_scripts': [