jarvis-cloud / modules /audio_device_control.py
Jarvis2345's picture
Squash history — remove all prior commits (secret hygiene, S4)
a31f556
Raw
History Blame
7.8 kB
"""
modules/audio_device_control.py — Phase 4 (System Control): Default audio I/O device switching (Windows).
Implements CoreAudio endpoint enumeration + default device switching using comtypes.
No fake outputs: returns (ok, message).
References:
- IMMDeviceEnumerator + MMDevice API
- IPolicyConfig (undocumented but widely used for SetDefaultEndpoint)
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
import ctypes
from ctypes import POINTER, wintypes
import comtypes # type: ignore
from comtypes import GUID, HRESULT, IUnknown # type: ignore
from comtypes.client import CreateObject # type: ignore
DataFlow = Literal["render", "capture"]
@dataclass(frozen=True, slots=True)
class AudioDevResult:
ok: bool
message: str
# --- CoreAudio constants ---
eRender = 0
eCapture = 1
eAll = 2
eConsole = 0
eMultimedia = 1
eCommunications = 2
DEVICE_STATE_ACTIVE = 0x00000001
STGM_READ = 0x00000000
class PROPERTYKEY(ctypes.Structure):
_fields_ = [("fmtid", GUID), ("pid", wintypes.DWORD)]
class PROPVARIANT(ctypes.Structure):
_fields_ = [
("vt", wintypes.USHORT),
("wReserved1", wintypes.USHORT),
("wReserved2", wintypes.USHORT),
("wReserved3", wintypes.USHORT),
("p", ctypes.c_void_p),
("p2", ctypes.c_void_p),
]
PKEY_Device_FriendlyName = PROPERTYKEY(
GUID("{A45C254E-DF1C-4EFD-8020-67D146A850E0}"),
14,
)
class IPropertyStore(IUnknown):
_iid_ = GUID("{886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99}")
_methods_ = [
comtypes.COMMETHOD([], HRESULT, "GetCount", (["out"], POINTER(wintypes.DWORD), "cProps")),
comtypes.COMMETHOD(
[], HRESULT, "GetAt", (["in"], wintypes.DWORD, "iProp"), (["out"], POINTER(PROPERTYKEY), "pkey")
),
comtypes.COMMETHOD(
[], HRESULT, "GetValue", (["in"], POINTER(PROPERTYKEY), "key"), (["out"], POINTER(PROPVARIANT), "pv")
),
]
class IMMDevice(IUnknown):
_iid_ = GUID("{D666063F-1587-4E43-81F1-B948E807363F}")
_methods_ = [
comtypes.COMMETHOD([], HRESULT, "Activate"),
comtypes.COMMETHOD([], HRESULT, "OpenPropertyStore", (["in"], wintypes.DWORD, "stgmAccess"), (["out"], POINTER(POINTER(IPropertyStore)), "ppProperties")),
comtypes.COMMETHOD([], HRESULT, "GetId", (["out"], POINTER(wintypes.LPWSTR), "ppstrId")),
comtypes.COMMETHOD([], HRESULT, "GetState", (["out"], POINTER(wintypes.DWORD), "pdwState")),
]
class IMMDeviceCollection(IUnknown):
_iid_ = GUID("{0BD7A1BE-7A1A-44DB-8397-C0F6C1F041C5}")
_methods_ = [
comtypes.COMMETHOD([], HRESULT, "GetCount", (["out"], POINTER(wintypes.UINT), "pcDevices")),
comtypes.COMMETHOD([], HRESULT, "Item", (["in"], wintypes.UINT, "nDevice"), (["out"], POINTER(POINTER(IMMDevice)), "ppDevice")),
]
class IMMDeviceEnumerator(IUnknown):
_iid_ = GUID("{A95664D2-9614-4F35-A746-DE8DB63617E6}")
_methods_ = [
comtypes.COMMETHOD(
[],
HRESULT,
"EnumAudioEndpoints",
(["in"], wintypes.DWORD, "dataFlow"),
(["in"], wintypes.DWORD, "dwStateMask"),
(["out"], POINTER(POINTER(IMMDeviceCollection)), "ppDevices"),
),
comtypes.COMMETHOD([], HRESULT, "GetDefaultAudioEndpoint"),
comtypes.COMMETHOD([], HRESULT, "GetDevice", (["in"], wintypes.LPCWSTR, "pwstrId"), (["out"], POINTER(POINTER(IMMDevice)), "ppDevice")),
comtypes.COMMETHOD([], HRESULT, "RegisterEndpointNotificationCallback"),
comtypes.COMMETHOD([], HRESULT, "UnregisterEndpointNotificationCallback"),
]
# --- PolicyConfig (SetDefaultEndpoint) ---
class IPolicyConfig(IUnknown):
_iid_ = GUID("{F8679F50-850A-41CF-9C72-430F290290C8}")
_methods_ = [
comtypes.COMMETHOD([], HRESULT, "GetMixFormat"),
comtypes.COMMETHOD([], HRESULT, "GetDeviceFormat"),
comtypes.COMMETHOD([], HRESULT, "SetDeviceFormat"),
comtypes.COMMETHOD([], HRESULT, "GetProcessingPeriod"),
comtypes.COMMETHOD([], HRESULT, "SetProcessingPeriod"),
comtypes.COMMETHOD([], HRESULT, "GetShareMode"),
comtypes.COMMETHOD([], HRESULT, "SetShareMode"),
comtypes.COMMETHOD([], HRESULT, "GetPropertyValue"),
comtypes.COMMETHOD([], HRESULT, "SetPropertyValue"),
comtypes.COMMETHOD([], HRESULT, "SetDefaultEndpoint", (["in"], wintypes.LPCWSTR, "pwstrDeviceId"), (["in"], wintypes.DWORD, "role")),
comtypes.COMMETHOD([], HRESULT, "SetEndpointVisibility"),
]
CLSID_MMDeviceEnumerator = GUID("{BCDE0395-E52F-467C-8E3D-C4579291692E}")
CLSID_PolicyConfigClient = GUID("{870AF99C-171D-4F9E-AF0D-E63DF40C2BC9}")
def _get_friendly_name(dev: IMMDevice) -> str:
store_ptr = POINTER(IPropertyStore)()
hr = dev.OpenPropertyStore(STGM_READ, ctypes.byref(store_ptr))
if hr != 0 or not store_ptr:
return ""
pv = PROPVARIANT()
hr2 = store_ptr.GetValue(ctypes.byref(PKEY_Device_FriendlyName), ctypes.byref(pv))
if hr2 != 0:
return ""
# VT_LPWSTR = 31
if pv.vt != 31 or not pv.p:
return ""
return ctypes.wstring_at(pv.p)
def _enum(flow: DataFlow) -> list[tuple[str, str]]:
"""
Returns list of (device_id, friendly_name) for active endpoints.
"""
enumerator = CreateObject(CLSID_MMDeviceEnumerator, interface=IMMDeviceEnumerator)
flow_id = eRender if flow == "render" else eCapture
coll_ptr = POINTER(IMMDeviceCollection)()
hr = enumerator.EnumAudioEndpoints(flow_id, DEVICE_STATE_ACTIVE, ctypes.byref(coll_ptr))
if hr != 0 or not coll_ptr:
return []
count = wintypes.UINT()
coll_ptr.GetCount(ctypes.byref(count))
out: list[tuple[str, str]] = []
for i in range(int(count.value)):
dev_ptr = POINTER(IMMDevice)()
coll_ptr.Item(i, ctypes.byref(dev_ptr))
if not dev_ptr:
continue
did = wintypes.LPWSTR()
dev_ptr.GetId(ctypes.byref(did))
name = _get_friendly_name(dev_ptr)
out.append((str(did), name))
return out
def list_devices(flow: DataFlow) -> AudioDevResult:
devs = _enum(flow)
if not devs:
return AudioDevResult(False, "No audio devices found.")
lines = [f"{name} | {did}" for did, name in devs]
return AudioDevResult(True, "\n".join(lines[:50]))
def set_default_device(flow: DataFlow, name_substring: str) -> AudioDevResult:
s = (name_substring or "").strip().lower()
if not s:
return AudioDevResult(False, "Missing device name.")
devs = _enum(flow)
if not devs:
return AudioDevResult(False, "No audio devices found.")
target_id = None
target_name = None
for did, name in devs:
if s in (name or "").lower():
target_id = did
target_name = name
break
if not target_id:
return AudioDevResult(False, f"No {flow} device matching '{name_substring}'.")
try:
client = CreateObject(CLSID_PolicyConfigClient, interface=IPolicyConfig)
for role in (eConsole, eMultimedia, eCommunications):
client.SetDefaultEndpoint(target_id, role)
return AudioDevResult(True, f"Default {flow} device set to {target_name}.")
except Exception as e:
return AudioDevResult(False, f"Audio device switch failed: {e}")
def set_default_output(name_substring: str) -> AudioDevResult:
return set_default_device("render", name_substring)
def set_default_input(name_substring: str) -> AudioDevResult:
return set_default_device("capture", name_substring)