diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 0e78b0c..dc2b0f6 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -4,10 +4,10 @@ on: [push] jobs: pypi: - runs-on: windows-2019 + runs-on: windows-2022 steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 - name: Install dependencies run: python -m pip install --upgrade setuptools wheel twine - name: Build diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8c487d6..332aef3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,35 +4,51 @@ on: [push, pull_request] jobs: linter: - runs-on: windows-2019 + runs-on: windows-2022 steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 - run: pip install tox - run: tox -e lint-check test: - runs-on: windows-2019 + runs-on: windows-2022 strategy: matrix: - python: ['3.7', '3.8', '3.9', '3.10'] + python: ['3.8', '3.9', '3.10', '3.11', '3.12'] steps: - - uses: actions/checkout@v2 - # Virtual network sound card for Microsoft Windows - - name: Install Scream + - uses: actions/checkout@v4 + - name: Start audio server + run: net start audiosrv + - name: Disable time sync with Hyper-V & setting system date time + # TODO: Remove the time workaround when virtual audio device certificate is valid again, refs: + # https://github.com/duncanthrax/scream/issues/202 + run: | + Set-Service -Name vmictimesync -Status stopped -StartupType disabled + Set-ItemProperty HKLM:\SYSTEM\CurrentControlSet\services\W32Time\Parameters -Name 'Type' -Value 'NoSync' + net stop w32time; Set-Date (Get-Date "2023-07-04 12:00:00") + - name: Install virtual audio device (Scream) + timeout-minutes: 3 + env: + VERSION: '4.0' shell: powershell run: | - Invoke-WebRequest https://github.com/duncanthrax/scream/releases/download/3.8/Scream3.8.zip -OutFile Scream3.8.zip - Expand-Archive -Path Scream3.8.zip -DestinationPath Scream - Import-Certificate -FilePath Scream\Install\driver\x64\Scream.cat -CertStoreLocation Cert:\LocalMachine\TrustedPublisher + Invoke-WebRequest https://github.com/duncanthrax/scream/releases/download/${{ env.VERSION }}/Scream${{ env.VERSION }}.zip -OutFile Scream${{ env.VERSION }}.zip + Expand-Archive -Path Scream${{ env.VERSION }}.zip -DestinationPath Scream + Import-Certificate -FilePath Scream\Install\driver\x64\scream.cat -CertStoreLocation Cert:\LocalMachine\TrustedPublisher Scream\Install\helpers\devcon-x64.exe install Scream\Install\driver\x64\Scream.inf *Scream - - uses: actions/setup-python@v2 + - name: Resetting system date time + run: | + Set-Service -Name vmictimesync -Status running -StartupType automatic + Set-ItemProperty HKLM:\SYSTEM\CurrentControlSet\services\W32Time\Parameters -Name 'Type' -Value 'NTP' + net start w32time; w32tm /resync /force; $currentDate = Get-Date; Write-Host "Current System Date: $currentDate"; + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - run: pip install tox - run: tox -e py - name: Run Coverage - if: matrix.python == '3.10' + if: matrix.python == '3.12' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: tox -e coveralls diff --git a/CHANGELOG.md b/CHANGELOG.md index 947865f..dd8cfec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Change Log +## [20240210] + - fix double free due to using cast rather than QueryInterface, refs #74 (@mrob95) + - add: example for IMMNotificationClient, refs #77 (@Invisi) + - IChannelAudioVolume support, refs #87 (@mltony) + - Format fix (@AndreMiras), refs #89 + - GitHub Actions versions bumps (@AndreMiras), refs #88 + - Bump Python versions (@AndreMiras), refs #90 + - Fix CI virtual audio install (@AndreMiras), refs #91 + ## [20230407] - Fixes memory leak from PROPVARIANT, refs #72 (@KillerBOSS2019) diff --git a/README.md b/README.md index e3633ae..0f7012c 100644 --- a/README.md +++ b/README.md @@ -27,13 +27,12 @@ choco install visualcpp-build-tools ## Usage ```Python -from ctypes import cast, POINTER from comtypes import CLSCTX_ALL from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume devices = AudioUtilities.GetSpeakers() interface = devices.Activate( IAudioEndpointVolume._iid_, CLSCTX_ALL, None) -volume = cast(interface, POINTER(IAudioEndpointVolume)) +volume = interface.QueryInterface(IAudioEndpointVolume) volume.GetMute() volume.GetMasterVolumeLevel() volume.GetVolumeRange() diff --git a/docs/Release.md b/docs/Release.md index aa7322f..9ec27b7 100644 --- a/docs/Release.md +++ b/docs/Release.md @@ -5,7 +5,7 @@ This is documenting the release process. ## Git flow & CHANGELOG.md -Make sure the CHANGELOG.md is up to date and follows the http://keepachangelog.com guidelines. +Make sure the CHANGELOG.md is up to date and follows the https://keepachangelog.com guidelines. Start the release with git flow: ```batch git flow release start vYYYYMMDD diff --git a/examples/audio_controller_class_example.py b/examples/audio_controller_class_example.py index 92adaa9..e87784c 100644 --- a/examples/audio_controller_class_example.py +++ b/examples/audio_controller_class_example.py @@ -2,6 +2,7 @@ Per session GetMute() SetMute() GetMasterVolume() SetMasterVolume() using SimpleAudioVolume. """ + from pycaw.pycaw import AudioUtilities diff --git a/examples/audio_endpoint_volume_example.py b/examples/audio_endpoint_volume_example.py index eb8bd2a..c94e055 100644 --- a/examples/audio_endpoint_volume_example.py +++ b/examples/audio_endpoint_volume_example.py @@ -1,7 +1,6 @@ """ Get and set access to master volume example. """ -from ctypes import POINTER, cast from comtypes import CLSCTX_ALL @@ -11,7 +10,7 @@ def main(): devices = AudioUtilities.GetSpeakers() interface = devices.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None) - volume = cast(interface, POINTER(IAudioEndpointVolume)) + volume = interface.QueryInterface(IAudioEndpointVolume) print("volume.GetMute(): %s" % volume.GetMute()) print("volume.GetMasterVolumeLevel(): %s" % volume.GetMasterVolumeLevel()) print("volume.GetVolumeRange(): (%s, %s, %s)" % volume.GetVolumeRange()) diff --git a/examples/channel_audio_volume_example.py b/examples/channel_audio_volume_example.py new file mode 100644 index 0000000..339bb84 --- /dev/null +++ b/examples/channel_audio_volume_example.py @@ -0,0 +1,22 @@ +""" +Adjusting volume of left channel using IChannelAudioVolume. +""" + +from pycaw.pycaw import AudioUtilities + + +def main(): + sessions = AudioUtilities.GetAllSessions() + for session in sessions: + volume = session.channelAudioVolume() + print(f"Session {session}") + count = volume.GetChannelCount() + volumes = [volume.GetChannelVolume(i) for i in range(count)] + print(f" volumes = {volumes}") + if count == 2: + volume.SetChannelVolume(0, 0.1, None) + print(" Set the volume of left channel to 0.5!") + + +if __name__ == "__main__": + main() diff --git a/examples/notification_client_example.py b/examples/notification_client_example.py new file mode 100644 index 0000000..708d59c --- /dev/null +++ b/examples/notification_client_example.py @@ -0,0 +1,98 @@ +""" +This file contains example usage of MMNotificationClient, the following +callbacks are implemented: + +:: Gets called when the state of an audio endpoint device has changed +IMMNotificationClient.OnDeviceStateChanged() +-> on_device_state_changed() + +:: Gets called when the value of an audio endpoint property has changed +IMMNotificationClient.OnPropertyValueChanged() +-> on_property_value_changed() + +https://learn.microsoft.com/en-us/windows/win32/api/mmdeviceapi/nn-mmdeviceapi-immnotificationclient + +""" + +import time + +from comtypes import GUID, COMError +from comtypes.automation import VT_BLOB +from comtypes.persist import STGM_READ + +from pycaw.callbacks import MMNotificationClient +from pycaw.utils import AudioUtilities + +known_keys = { + # Recording + "{24DBB0FC-9311-4B3D-9CF0-18FF155639D4} 0": "Playback through this device", + "{24DBB0FC-9311-4B3D-9CF0-18FF155639D4} 1": "Listen to this device", + # Playback & Recording + "{9855C4CD-DF8C-449C-A181-8191B68BD06C} 0": "Volume", + "{9855C4CD-DF8C-449C-A181-8191B68BD06C} 1": "Device muted", +} + + +class Client(MMNotificationClient): + def __init__(self): + self.enumerator = AudioUtilities.GetDeviceEnumerator() + + def on_device_state_changed(self, device_id, new_state, new_state_id): + print(f"on_device_state_changed: {device_id} {new_state} {new_state_id}") + + def on_property_value_changed(self, device_id, property_struct, fmtid, pid): + key = f"{fmtid} {pid}" + + value = self._find_property(device_id, fmtid, pid) + print( + f"on_property_value_changed: key={key} " + f"purpose=\"{known_keys.get(key, '?')}\" value={value}", + ) + + def _find_property(self, device_id: str, fmtid: GUID, pid: int) -> str | None: + """Helper function to find the value of a property""" + dev = self.enumerator.GetDevice(device_id) + store = dev.OpenPropertyStore(STGM_READ) + if store is None: + print("no store") + return + + search_value = bytes(fmtid) + for j in range(store.GetCount()): + try: + pk = store.GetAt(j) + + if not (bytes(pk.fmtid) == search_value and pk.pid == pid): + continue + + value = store.GetValue(pk) + if value.vt == VT_BLOB: + return bytes(value).hex(" ") + + return value.GetValue() + except COMError as exc: + print( + f"COMError attempting to get property {j} " + f"from device {dev}: {exc}" + ) + continue + + +def add_callback(): + cb = Client() + enumerator = AudioUtilities.GetDeviceEnumerator() + enumerator.RegisterEndpointNotificationCallback(cb) + print("registered") + + try: + # wait for callbacks + time.sleep(300) + except KeyboardInterrupt: + pass + finally: + print("unregistering") + enumerator.UnregisterEndpointNotificationCallback(cb) + + +if __name__ == "__main__": + add_callback() diff --git a/examples/session_callback_example.py b/examples/session_callback_example.py index 4a2d153..2a0163c 100644 --- a/examples/session_callback_example.py +++ b/examples/session_callback_example.py @@ -20,6 +20,7 @@ https://docs.microsoft.com/en-us/windows/win32/api/audiopolicy/nn-audiopolicy-iaudiosessionevents """ + import time from comtypes import COMError diff --git a/examples/simple_audio_volume_example.py b/examples/simple_audio_volume_example.py index fa94ed9..eed5b78 100644 --- a/examples/simple_audio_volume_example.py +++ b/examples/simple_audio_volume_example.py @@ -1,6 +1,7 @@ """ Per session GetMute() SetMute() using ISimpleAudioVolume. """ + from pycaw.pycaw import AudioUtilities, ISimpleAudioVolume diff --git a/examples/volume_by_process_example.py b/examples/volume_by_process_example.py index a9f1b7c..3ec709e 100644 --- a/examples/volume_by_process_example.py +++ b/examples/volume_by_process_example.py @@ -1,6 +1,7 @@ """ Mutes the volume of all processes, but unmutes chrome.exe process. """ + from pycaw.pycaw import AudioUtilities diff --git a/examples/volume_callback_example.py b/examples/volume_callback_example.py index 13a4293..e27b60c 100644 --- a/examples/volume_callback_example.py +++ b/examples/volume_callback_example.py @@ -2,7 +2,6 @@ IAudioEndpointVolumeCallback.OnNotify() example. The OnNotify() callback method gets called on volume change. """ -from ctypes import POINTER, cast from comtypes import CLSCTX_ALL, COMObject @@ -23,7 +22,7 @@ def OnNotify(self, pNotify): def main(): devices = AudioUtilities.GetSpeakers() interface = devices.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None) - volume = cast(interface, POINTER(IAudioEndpointVolume)) + volume = interface.QueryInterface(IAudioEndpointVolume) callback = AudioEndpointVolumeCallback() volume.RegisterControlChangeNotify(callback) for i in range(3): diff --git a/pycaw/api/audioclient/__init__.py b/pycaw/api/audioclient/__init__.py index 4b55afb..e39d72d 100644 --- a/pycaw/api/audioclient/__init__.py +++ b/pycaw/api/audioclient/__init__.py @@ -137,3 +137,27 @@ class IAudioClient(IUnknown): (["out"], POINTER(POINTER(IUnknown)), "ppv"), ), ) + + +class IChannelAudioVolume(IUnknown): + _iid_ = GUID("{1c158861-b533-4b30-b1cf-e853e51c59b8}") + _methods_ = ( + COMMETHOD( + [], HRESULT, "GetChannelCount", (["out"], POINTER(UINT32), "pnChannelCount") + ), + COMMETHOD( + [], + HRESULT, + "SetChannelVolume", + (["in"], UINT32, "dwIndex"), + (["in"], c_float, "fLevel"), + (["in"], POINTER(GUID), "EventContext"), + ), + COMMETHOD( + [], + HRESULT, + "GetChannelVolume", + (["in"], UINT32, "dwIndex"), + (["out"], POINTER(c_float), "pfLevel"), + ), + ) diff --git a/pycaw/pycaw.py b/pycaw/pycaw.py index 0f2f826..e4339eb 100644 --- a/pycaw/pycaw.py +++ b/pycaw/pycaw.py @@ -1,6 +1,7 @@ """ Python wrapper around the Core Audio Windows API. """ + # import here all newly split up modules, # to keep backwards compatibility diff --git a/pycaw/utils.py b/pycaw/utils.py index 192f82e..e3fd056 100644 --- a/pycaw/utils.py +++ b/pycaw/utils.py @@ -1,11 +1,10 @@ import warnings -from ctypes import POINTER, cast import comtypes import psutil from _ctypes import COMError -from pycaw.api.audioclient import ISimpleAudioVolume +from pycaw.api.audioclient import IChannelAudioVolume, ISimpleAudioVolume from pycaw.api.audiopolicy import IAudioSessionControl2, IAudioSessionManager2 from pycaw.api.endpointvolume import IAudioEndpointVolume from pycaw.api.mmdeviceapi import IMMDeviceEnumerator, IMMEndpoint @@ -22,7 +21,7 @@ class AudioDevice: """ - http://stackoverflow.com/a/20982715/185510 + https://stackoverflow.com/a/20982715/185510 """ def __init__(self, id, state, properties, dev): @@ -49,19 +48,20 @@ def EndpointVolume(self): iface = self._dev.Activate( IAudioEndpointVolume._iid_, comtypes.CLSCTX_ALL, None ) - self._volume = cast(iface, POINTER(IAudioEndpointVolume)) + self._volume = iface.QueryInterface(IAudioEndpointVolume) return self._volume class AudioSession: """ - http://stackoverflow.com/a/20982715/185510 + https://stackoverflow.com/a/20982715/185510 """ def __init__(self, audio_session_control2): self._ctl = audio_session_control2 self._process = None self._volume = None + self._channelVolume = None self._callback = None def __str__(self): @@ -146,6 +146,11 @@ def SimpleAudioVolume(self): self._volume = self._ctl.QueryInterface(ISimpleAudioVolume) return self._volume + def channelAudioVolume(self): + if self._channelVolume is None: + self._channelVolume = self._ctl.QueryInterface(IChannelAudioVolume) + return self._channelVolume + def register_notification(self, callback): if self._callback is None: self._callback = callback @@ -158,7 +163,7 @@ def unregister_notification(self): class AudioUtilities: """ - http://stackoverflow.com/a/20982715/185510 + https://stackoverflow.com/a/20982715/185510 """ @staticmethod diff --git a/setup.py b/setup.py index b300801..2b02824 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ def read(fname): install_requires = ["comtypes", "psutil"] setup( name="pycaw", - version="20230407", + version="20240210", description="Python Core Audio Windows Library", long_description=read("README.md"), long_description_content_type="text/markdown", diff --git a/tests/test_core.py b/tests/test_core.py index 5448c81..437aa4a 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,6 +1,7 @@ """ Verifies core features run as expected. """ + import sys import warnings from contextlib import contextmanager diff --git a/tests/test_examples.py b/tests/test_examples.py index f229c35..8c53e73 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,6 +1,7 @@ """ Verifies examples run as expected. """ + import pytest from examples import ( diff --git a/tox.ini b/tox.ini index c91fe6a..b4a7578 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = lint-check,py{37,38,39,310} +envlist = lint-check,py{38,39,310,311,312} skipsdist = True [testenv]