/
/
1"""
2CoreAudio hardware volume control for macOS.
3
4Uses ctypes to call CoreAudio's AudioObject API directly,
5controlling the actual device hardware volume rather than the system mixer.
6"""
7
8from __future__ import annotations
9
10import ctypes
11import ctypes.util
12
13# CoreAudio property selectors (FourCC encoded as big-endian uint32)
14_PROP_DEVICES = int.from_bytes(b"dev#", "big")
15_PROP_NAME = int.from_bytes(b"lnam", "big")
16_SCOPE_GLOBAL = int.from_bytes(b"glob", "big")
17_SCOPE_OUTPUT = int.from_bytes(b"outp", "big")
18_PROP_VOLUME_SCALAR = int.from_bytes(b"volm", "big")
19_PROP_MUTE = int.from_bytes(b"mute", "big")
20_SYSTEM_OBJECT = 1
21
22# CoreFoundation UTF-8 encoding
23_CF_ENCODING_UTF8 = 0x08000100
24
25
26class _AudioObjectPropertyAddress(ctypes.Structure):
27 _fields_ = [
28 ("mSelector", ctypes.c_uint32),
29 ("mScope", ctypes.c_uint32),
30 ("mElement", ctypes.c_uint32),
31 ]
32
33
34def _load_frameworks() -> tuple[ctypes.CDLL, ctypes.CDLL]:
35 """Load CoreAudio and CoreFoundation frameworks."""
36 ca_path = ctypes.util.find_library("CoreAudio")
37 cf_path = ctypes.util.find_library("CoreFoundation")
38 if not ca_path or not cf_path:
39 msg = "CoreAudio or CoreFoundation not found"
40 raise OSError(msg)
41 ca = ctypes.cdll.LoadLibrary(ca_path)
42 cf = ctypes.cdll.LoadLibrary(cf_path)
43 # Set up CFString helper signatures
44 cf.CFStringGetCStringPtr.restype = ctypes.c_char_p
45 cf.CFStringGetCStringPtr.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
46 cf.CFStringGetCString.restype = ctypes.c_bool
47 cf.CFStringGetCString.argtypes = [
48 ctypes.c_void_p,
49 ctypes.c_char_p,
50 ctypes.c_long,
51 ctypes.c_uint32,
52 ]
53 return ca, cf
54
55
56def _cfstring_to_str(cf: ctypes.CDLL, cfstr: ctypes.c_void_p) -> str:
57 """Convert a CFStringRef to a Python string."""
58 ptr = cf.CFStringGetCStringPtr(cfstr, _CF_ENCODING_UTF8)
59 if ptr:
60 return str(ptr.decode("utf-8"))
61 buf = ctypes.create_string_buffer(512)
62 if cf.CFStringGetCString(cfstr, buf, 512, _CF_ENCODING_UTF8):
63 return buf.value.decode("utf-8")
64 return ""
65
66
67def _find_device_id(ca: ctypes.CDLL, cf: ctypes.CDLL, device_name: str) -> int | None:
68 """Find a CoreAudio device ID by name."""
69 prop = _AudioObjectPropertyAddress(_PROP_DEVICES, _SCOPE_GLOBAL, 0)
70 size = ctypes.c_uint32(0)
71 if (
72 ca.AudioObjectGetPropertyDataSize(
73 _SYSTEM_OBJECT, ctypes.byref(prop), 0, None, ctypes.byref(size)
74 )
75 != 0
76 ):
77 return None
78
79 n_devices = size.value // 4
80 device_ids = (ctypes.c_uint32 * n_devices)()
81 if (
82 ca.AudioObjectGetPropertyData(
83 _SYSTEM_OBJECT, ctypes.byref(prop), 0, None, ctypes.byref(size), device_ids
84 )
85 != 0
86 ):
87 return None
88
89 for did in device_ids:
90 name_prop = _AudioObjectPropertyAddress(_PROP_NAME, _SCOPE_GLOBAL, 0)
91 cfstr = ctypes.c_void_p()
92 sz = ctypes.c_uint32(ctypes.sizeof(cfstr))
93 if (
94 ca.AudioObjectGetPropertyData(
95 did, ctypes.byref(name_prop), 0, None, ctypes.byref(sz), ctypes.byref(cfstr)
96 )
97 == 0
98 ):
99 name = _cfstring_to_str(cf, cfstr)
100 if name == device_name:
101 return int(did)
102 return None
103
104
105def set_device_volume(device_name: str, volume: int) -> bool:
106 """
107 Set the hardware volume for a named CoreAudio device.
108
109 :param device_name: The device name (must match CoreAudio device name).
110 :param volume: Volume level 0-100.
111 :return: True if successful.
112 """
113 try:
114 ca, cf = _load_frameworks()
115 except OSError:
116 return False
117
118 device_id = _find_device_id(ca, cf, device_name)
119 if device_id is None:
120 return False
121
122 vol_prop = _AudioObjectPropertyAddress(_PROP_VOLUME_SCALAR, _SCOPE_OUTPUT, 0)
123 if not ca.AudioObjectHasProperty(device_id, ctypes.byref(vol_prop)):
124 return False
125
126 scalar = ctypes.c_float(volume / 100.0)
127 size = ctypes.c_uint32(4)
128 result: int = ca.AudioObjectSetPropertyData(
129 device_id, ctypes.byref(vol_prop), 0, None, size, ctypes.byref(scalar)
130 )
131 return result == 0
132
133
134def set_device_mute(device_name: str, muted: bool) -> bool:
135 """
136 Set the hardware mute state for a named CoreAudio device.
137
138 :param device_name: The device name (must match CoreAudio device name).
139 :param muted: Whether to mute or unmute.
140 :return: True if successful.
141 """
142 try:
143 ca, cf = _load_frameworks()
144 except OSError:
145 return False
146
147 device_id = _find_device_id(ca, cf, device_name)
148 if device_id is None:
149 return False
150
151 mute_prop = _AudioObjectPropertyAddress(_PROP_MUTE, _SCOPE_OUTPUT, 0)
152 if not ca.AudioObjectHasProperty(device_id, ctypes.byref(mute_prop)):
153 return False
154
155 mute_val = ctypes.c_uint32(1 if muted else 0)
156 size = ctypes.c_uint32(4)
157 result: int = ca.AudioObjectSetPropertyData(
158 device_id, ctypes.byref(mute_prop), 0, None, size, ctypes.byref(mute_val)
159 )
160 return result == 0
161