Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AudioSessionEvents is receiving multiple events for a single real event after refreshing the audio sessions list. #99

Open
TJ-59 opened this issue Oct 8, 2024 · 2 comments

Comments

@TJ-59
Copy link

TJ-59 commented Oct 8, 2024

  • OS version : Windows 10 22H2
  • architecture : 64Bits
  • Python version : 3.11.8
  • pycaw version : 20240210

I'm using pycaw to watch sound volume changes, I have a AudioSessionCallback class that inherit pycaw's AudioSessionEvents, and is given to the session via register_notification().
The callback instance has a few attributes added, like the ID of the corresponding UI, the session instance identifier, AND a pseudo unique ID (generated at __init__) just to be able to differentiate "who" is receiving the events.
This was done after seeing multiple instances of the prints happening for a single event (like, "one single click in the windows audio mixer to change that session's volume" single event).
The sessions are obtained with AudioUtilities.GetAllSessions(), each is given to a custom UI panel instance, and depending on the process the session is from, may or may not have a callback created and registered to it.
The registration is done (in the UI panel code) with :

    self.ASCB = AudioSessionCallback(self, self.session.InstanceIdentifier)
    self.session.register_notification(self.ASCB)

This register_notification() , defined in picaw/utils.py 's AudioSession class, internally uses :

    def register_notification(self, callback):
        if self._callback is None:
            self._callback = callback
            self._ctl.RegisterAudioSessionNotification(self._callback)

Same for the unregister_notification(), done with this code :

    self.session.unregister_notification()
    del self.ASCB

which is defined just after in utils.py :

    def unregister_notification(self):
        if self._callback:
            self._ctl.UnregisterAudioSessionNotification(self._callback)

For various reasons, the user may need to refresh the sessions, when that happens, the callbacks are all removed using the code above, the ui panels are removed and destroyed, and then, the same process, used initially to get the sessions and give each of them a panel, is used again to obtain one panel per session.
But when an event happens, like a volume change, there are now multiple prints happening, all from events very close to each other temporally, but ALL are printed by the NEW callback for that session, proof being the pseudo unique ID it generated during its __init__ being the same for all events.

I'd have understood, if the pseudo unique IDs were different, that the event is still getting picked up by old callbacks that would somehow not be correctly unregistered, but those are all received by the current callback, like "something above it" was counting "how many copies of the single event they're supposed to send out", not caring who's concerned or receiving it.

Any idea how to avoid being spammed by duplicate events ?

@AndreMiras
Copy link
Owner

Thanks for the detailed bug report @TJ-59
Would you mind sharing a minimal reproduction example? It would make it much easier to debug and pinpoint the issue.

@TJ-59
Copy link
Author

TJ-59 commented Dec 10, 2024

It seems it is linked to the sub-components of the session object getting their own reference to the session.
The session object has ._ctl (which is the AudioSessionControl object representing the COM IAudioSessionControl2 iirc).
This one is supposed to have that reference, and to use Release() when you're done with it, before destruction.
BUT, the session object can also have .SimpleAudioVolume and the corresponding private attribute _volume, as a property, which make it exist as soon as you "touch" the name (that is, get a session from AudioUtilities.GetAllSessions(), and check ._volume, it doesn't exist, but as soon as you even try to get into .SimpleAudioVolume, then _volume gets created), and this one used an addref() too, which you need to Release() too (I condition it to something like if session._volume is not None : when doing the removal ).
ALSO, the session has another member that MAY exist with the same problem :
.channelAudioVolume, (which allow for individual channel volume settings, great addition btw) which is NOT defined as a property, and thus require a call to .channelAudioVolume() to exist, with the private attribute ._channelVolume being created as you do so; it adds to the reference counter too and has to .Release() too (which makes the condition something like if self.session._channelVolume is not None : ).
It is not indicated in pycaw, and could probably be done directly by the session object's .unregister_notification() (which currently is only a shortcut to the session object's self._ctl.UnregisterAudioSessionNotification(self._callback), itself a shortcut to the ._ctl.Release() )

On a similar note, please have a look at this bit of code :

Test

from pycaw.pycaw import (AudioUtilities, ISimpleAudioVolume, IAudioEndpointVolume,
                         IMMDeviceEnumerator, DEVICE_STATE, EDataFlow, ERole)
from pycaw.constants import CLSID_MMDeviceEnumerator
from pycaw.callbacks import (AudioEndpointVolumeCallback, AudioSessionEvents,
                             AudioSessionNotification, MMNotificationClient)
import datetime
import secrets

def get_a_random_number_string(size):
    n = 0
    s = ""
    while n < size :
        s += secrets.choice("0123456789")
        n += 1
    return s

target = 'iCUE.exe'  # PLEASE POINT IT TO WHATEVER AUDIO-USING PROGRAM YOU HAVE RUNNING ON YOUR SYSTEM
dings = 0
Adings = 0
Bdings = 0

class AudioSessionCallback(AudioSessionEvents):
    def __init__(self,session_instance_identifier):
        super().__init__()
        #TEST for pseudo uniqueness
        self.PUID = get_a_random_number_string(10)
        #
        self.SII = session_instance_identifier

    def on_display_name_changed(self, new_display_name, event_context):
        print(str(datetime.datetime.now()))
        print("New display name : {0}, event_context : {1}".format(new_display_name, event_context.contents))

    def OnIconPathChanged(self, new_icon_path, event_context):
        print(str(datetime.datetime.now()))
        print("New icon_path : {0}, event_context : {1}".format(new_icon_path, event_context.contents))

    def on_simple_volume_changed(self, new_volume, new_mute, event_context):
        print(str(datetime.datetime.now()))
        print(
            ":: OnSimpleVolumeChanged callback\n"
            f"{self.SII}\n"         # session's instance identifier
            f"PUID : {self.PUID}\n" # We're displaying the callback's pseudo-unique ID
            f"new_volume: {new_volume}; "
            f"new_mute: {new_mute}; "
            f"event_context: {event_context.contents}"
        )
        dingding()

    def on_state_changed(self, new_state, new_state_id):
        print(str(datetime.datetime.now()))
        print(
            ":: OnStateChanged callback\n" f"new_state: {new_state}; id: {new_state_id}"
        )

    def on_session_disconnected(self, disconnect_reason, disconnect_reason_id):
        print(
            ":: OnSessionDisconnected callback\n"
            f"disconnect_reason: {disconnect_reason}; "
            f"id: {disconnect_reason_id}"
        )


class AudioSessionCallbackA(AudioSessionEvents):
    def __init__(self,session_instance_identifier):
        super().__init__()
        #TEST for pseudo uniqueness
        self.PUID = get_a_random_number_string(10)
        #
        self.SII = session_instance_identifier

    def on_display_name_changed(self, new_display_name, event_context):
        print(str(datetime.datetime.now()))
        print("New display name : {0}, event_context : {1}".format(new_display_name, event_context.contents))

    def OnIconPathChanged(self, new_icon_path, event_context):
        print(str(datetime.datetime.now()))
        print("New icon_path : {0}, event_context : {1}".format(new_icon_path, event_context.contents))

    def on_simple_volume_changed(self, new_volume, new_mute, event_context):
        print(str(datetime.datetime.now()))
        print(
            ":: A : "  # We display this is a type "A" callback...
            f"PUID : {self.PUID} "
            f"new_volume: {new_volume}; "
            f"new_mute: {new_mute}; "
            f"event_context: {event_context.contents}"
        )
        dingding("A")

    def on_state_changed(self, new_state, new_state_id):
        print(str(datetime.datetime.now()))
        print(
            ":: OnStateChanged callback\n" f"new_state: {new_state}; id: {new_state_id}"
        )

    def on_session_disconnected(self, disconnect_reason, disconnect_reason_id):
        print(
            ":: OnSessionDisconnected callback\n"
            f"disconnect_reason: {disconnect_reason}; "
            f"id: {disconnect_reason_id}"
        )

class AudioSessionCallbackB(AudioSessionEvents):
    def __init__(self,session_instance_identifier):
        super().__init__()
        #TEST for pseudo uniqueness
        self.PUID = get_a_random_number_string(10)
        #
        self.SII = session_instance_identifier

    def on_display_name_changed(self, new_display_name, event_context):
        print(str(datetime.datetime.now()))
        print("New display name : {0}, event_context : {1}".format(new_display_name, event_context.contents))

    def OnIconPathChanged(self, new_icon_path, event_context):
        print(str(datetime.datetime.now()))
        print("New icon_path : {0}, event_context : {1}".format(new_icon_path, event_context.contents))

    def on_simple_volume_changed(self, new_volume, new_mute, event_context):
        print(str(datetime.datetime.now()))
        print(
            ":: B : "
            f"PUID : {self.PUID} "
            f"new_volume: {new_volume}; "
            f"new_mute: {new_mute}; "
            f"event_context: {event_context.contents}"
        )
        dingding("B")

    def on_state_changed(self, new_state, new_state_id):
        print(str(datetime.datetime.now()))
        print(
            ":: OnStateChanged callback\n" f"new_state: {new_state}; id: {new_state_id}"
        )

    def on_session_disconnected(self, disconnect_reason, disconnect_reason_id):
        print(
            ":: OnSessionDisconnected callback\n"
            f"disconnect_reason: {disconnect_reason}; "
            f"id: {disconnect_reason_id}"
        )


def getaset(): # THIS GIVES YOU A "SESSION + ITS REGISTERED CALLBACK" PAIR OF THE "TARGET"
    allsess = AudioUtilities.GetAllSessions()
    for sess in allsess :
        if sess.Process.name() == target :
            cb = AudioSessionCallback(sess.InstanceIdentifier)
            print(f"cb ID : {cb.PUID}")
            sess.register_notification(cb)
            return sess, cb

def getasetA():
    allsess = AudioUtilities.GetAllSessions()
    for sess in allsess :
        if sess.Process.name() == target :
            cb = AudioSessionCallbackA(sess.InstanceIdentifier)
            print(f"cb ID : {cb.PUID}")
            sess.register_notification(cb)
            return sess, cb

def getasetB():
    allsess = AudioUtilities.GetAllSessions()
    for sess in allsess :
        if sess.Process.name() == target :
            cb = AudioSessionCallbackB(sess.InstanceIdentifier)
            print(f"cb ID : {cb.PUID}")
            sess.register_notification(cb)
            return sess, cb

def dingding(cbtype=None): # THIS IS JUST A QUICK COUNTER WHICH INCREASES WHEN CALLED
    global dings, Adings, Bdings
    dings += 1
    if cbtype == "A":
        Adings += 1
    elif cbtype == "B":
        Bdings += 1

def dong(): # THIS DISPLAYS THE COUNTER'S RESULTS AND SET THEM BACK TO ZERO
    global dings, Adings, Bdings
    print(f"Amount of callback events : {dings} (A : {Adings}, B : {Bdings})")
    dings = 0
    Adings = 0
    Bdings = 0


# NOW TYPE THESE COMMANDS (OR UNCOMMENT THEM) AND DO A SINGLE EVENT IN THE WINDOWS AUDIO MIXER
# OF YOUR TARGET'S (like clicking ONCE under its current volume to reduce it, or if already selected, press up or down once,
# the goal is to generate a single event, THEN USE dong() to get a count of it)
# TRY WITH VARIOUS AMOUNTS OF PAIRS (like only 1, then 1 and 2, then 1,2 and 3, then 1,2,3 and 4...)
##sess_1, cb_1 = getaset()
##sess_2, cb_2 = getaset()
##sess_3, cb_3 = getaset()
##sess_4, cb_4 = getaset()
##sess_5, cb_5 = getaset()

##dong()

# We get 1, 4, 9, 16, or 25 events depending on the amount of active sessions (it's squared)
# Sessions using ._ctl.Release() (and del the pair) are indeed removed entirely since we didn't touch the simple audio volume part.

# THIS PART MIXES TYPE A AND B CALLBACKS, IT IS NO LONGER RELATED TO THE PUIDs, BUT SERVES TO SHOW THE 
# 'SQUARE' NATURE OF WHAT WE ARE WITNESSING SINCE A AND B ARE COUNTED SEPARATELY
##sess_icue_1, cb_icue_1 = getasetA()
##sess_icue_2, cb_icue_2 = getasetB()
##sess_icue_3, cb_icue_3 = getasetA()
##sess_icue_4, cb_icue_4 = getasetB()
##sess_icue_5, cb_icue_5 = getasetA()

##dong()


I don't know why it does that, but it looks like it is clearly the "amount" multiplied by itself.
Maybe someone reused a line made by someone else somewhere in the code, where the initial idea was to quickly see if all factors for an operation were present with a multiplication of them (as in "if I check X * Y * Z, I don't have to individually check X or Y or Z, since anything multiplied by zero is zero"). All it takes is one distracted developer reusing it "as is" to end up with some "square exponential" problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants