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

Lazy load AudioDevice properties, add additional functions #97

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from

Conversation

timtucker
Copy link

@timtucker timtucker commented Sep 21, 2024

Made a few tweaks:

  • Changed to lazy load AudioDevice properties -- this should be a little faster in cases where the properties otherwise go unused and helps get around some of the issues with exceptions being thrown when accessing the properties on system devices
  • Updated associated tests for CreateDevice to include manually accessing the properties -- with the update to lazy load, one of the tests failed as-is because it was expecting a failure that was no longer thrown until you manually access properties
  • Added a few functions to make it easier to enumerate things & access properties -- as an example, AudioDevice now has a Sessions property that can be used to access the list of sessions for the device
  • Did some refactoring to move duplicate code into helper functions
  • Tried to make variable names more consistent in spots where the same type of object was being referred to with different names
  • Added typing in multiple spots for better IDE auto-completion

Note that in every case where I could I kept the behavior the same.

Changed behavior from the current release:

Getting session by process id. This now looks at all sessions, regardless of which device they're associated with.

Not changed, but probably should change:

"GetAllSessions" -- it doesn't actually return "all" sessions. It just returns sessions associated with the default playback device.

A few of the areas where that might not be all:

  • Recording-only sessions
  • Windows configured to output sounds for different applications to different devices
  • Windows configured to have different output devices as the default for playback vs. communications

…ve redundant code into helper functions, make variable names more consistent, add typing
Copy link
Owner

@AndreMiras AndreMiras left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the refactoring, the main idea is good, the refactoring makes sense and the lazy loading is a nice addition.
The pull request is a bit too big for my taste, I like to break down into smaller pull requests when possible so it's easier to keep focus when reviewing and easier to git bisect it when there's a regression. Also small PRs get less comments and get merged faster.
Thanks for bringing some type hints, I like them too, in the future we would need to lint them to make sure they're not lies, but let's keep it for another PR.

Comment on lines +328 to +329
# TODO: the current behavior here isn't really getting "all sessions"
# Leaving behavior as-is for backward compatibility
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks I appreciate the comment

flow=EDataFlow.eAll.value, deviceState=DEVICE_STATE.ACTIVE.value
) -> List[AudioDevice]:
"""
Get devices based on filteres for flow direction and device state.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/filteres/filters/

Comment on lines 55 to 60
with warnings.catch_warnings(record=True) as w:
AudioUtilities.CreateDevice(dev)
device = AudioUtilities.CreateDevice(dev)
# Properties are lazy-loaded and won't throw exceptions
# until accessed
dev = device.properties
assert len(w) == 1
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for updating the tests too.
Maybe we should take the CreateDevice() call out of the context manager and only put the property access into it to prove that it's indeed raising only with this specific line.
So test would be:

        AudioUtilities.CreateDevice(dev)
        device = AudioUtilities.CreateDevice(dev)
        with warnings.catch_warnings(record=True) as w:
            # Properties are lazy-loaded and won't throw exceptions
            # until accessed
            device.properties
        assert len(w) == 1

Comment on lines +71 to +75
@staticmethod
def getProperty(self, key):
store = self._dev.OpenPropertyStore(STGM.STGM_READ.value)
if store is None:
return None
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given this is a static method, maybe let's call that self obj instead or something to avoid confusion, what do you think?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't remember why I added this as a static method -- this should probably just be
@property def property(self, key):

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes indeed it's really only consumed by the audio device object so that would be the more logical change

Comment on lines +106 to +111
for j in range(propCount):
pk = store.GetAt(j)

value = AudioDevice.getProperty(self, pk)
if value is None:
continue
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that feels like O(n*n/2) given that getProperty will loop all over again. No big deal at performance level, but just got me curious if there's not a more elegant approach

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If getProperty gets changed to just property & directly accesses by the name, that would help


@property
def properties(self) -> dict:
if self._properties is None:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you familiar with guard clauses?
Basically inverting the condition to exit early and deal with the rest of the code one level of indentation lower:

if self._properties is not None:
    return self._properties
store = self._dev.OpenPropertyStore(STGM.STGM_READ.value)
...

And then do not else, but rest of the code.
That saves one indentation level and the brain doesn't have to stack level of logic when tracking the code.
This is relatively simple code of course, but it's a good practice to generalize to make things easier to follow

Comment on lines +243 to 246
def DisplayName(self, value: str) -> str:
s = self._ctl.GetDisplayName()
if s != value:
self._ctl.SetDisplayName(value, IID_Empty)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comments (and typehints) are future lies unless tested.
Are we sure this method returns a string?

@timtucker
Copy link
Author

Thanks for the refactoring, the main idea is good, the refactoring makes sense and the lazy loading is a nice addition. The pull request is a bit too big for my taste, I like to break down into smaller pull requests when possible so it's easier to keep focus when reviewing and easier to git bisect it when there's a regression. Also small PRs get less comments and get merged faster. Thanks for bringing some type hints, I like them too, in the future we would need to lint them to make sure they're not lies, but let's keep it for another PR.

Agreed that this is definitely a lot -- I started working on a small project locally and started doing some late-night tinkering and just figured I'd upload what I had done so far.

IMO, part of the issue is that there's too much in utils right now.

Changes might be easier to keep narrower in scope if it were split up something like:

audio\device.py

  • class to interact with a specific device
  • instances could cache for device properties or interfaces that are unlikely to change

audio\session.py

  • class to interact with a specific session
  • instances could cache of session properties or interfaces that are unlikely to change

audio\controllers.py

  • class to list & interact with devices, with caching of previously seen devices
  • class to list & interact with sessions, with caching of previously seen sessions
  • default instances of the session & device controllers that are aware of one another
  • Example usage would just be: from pycaw.audio.controllers import audio_sessions, audio_devices

audio\types.py

  • type definitions to avoid circular imports

utils.py

  • stubs that use the default controllers + new interfaces for backward compatibility

@AndreMiras
Copy link
Owner

Yes you're right that this should be split like you said.
Let's keep this for a follow up PR, because I don't want to move things around too much and it would be harder to track the rest of the changes.

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

Successfully merging this pull request may close these issues.

2 participants