Guest User

audiodev.py

a guest
Sep 17th, 2026
21
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 16.11 KB | None | 0 0
  1. #!/usr/bin/env python3
  2. """
  3. audiodev.py - inspect, identify, retune and reinitialize a single CoreAudio device
  4. on macOS, without restarting coreaudiod (which disrupts every device and app).
  5.  
  6. No dependencies beyond a working python3: it calls the CoreAudio C API via ctypes.
  7.  
  8. Usage:
  9. ./audiodev.py list # every device: id, UID, transport, rate, state
  10. ./audiodev.py identify [-d SEL] # speak through each match so you can hear which is which
  11. ./audiodev.py rate 48000 [-d SEL]
  12. ./audiodev.py reset [-d SEL] [-m hog|rate|default|all]
  13.  
  14. SEL (-d/--device) accepts any of:
  15. 42 a numeric AudioObjectID, exactly (see `list`)
  16. "uid:AppleHDA..." a substring of the device UID <- stable across reboots
  17. "Studio Display" a substring of the device name
  18. Name matching that hits more than one device is refused, and the candidates are
  19. printed with their ids and UIDs - that is the case to solve with `identify`.
  20.  
  21. What "reset" does, since CoreAudio exposes no public "reinitialize" call:
  22.  
  23. hog Claim kAudioDevicePropertyHogMode (exclusive access), then release it.
  24. Taking it evicts every other client from THIS device; releasing it makes
  25. them re-open and re-negotiate. Closest thing to a per-device power cycle.
  26. rate Bounce the nominal sample rate to another supported value and back,
  27. forcing the driver to rebuild its stream format and IO buffers.
  28. default If the device is the current default output, point the system default at
  29. another device and back, so CoreAudio destroys and recreates the IOProc.
  30.  
  31. For HDMI/DisplayPort outputs, remember the audio endpoint rides on the display link:
  32. if `list` reports the device alive and running but you hear nothing, the fault is
  33. below CoreAudio and you need to relink the display, not reset the audio device.
  34. """
  35.  
  36. import argparse
  37. import ctypes
  38. import os
  39. import struct
  40. import subprocess
  41. import sys
  42. import time
  43.  
  44. ca = ctypes.CDLL("/System/Library/Frameworks/CoreAudio.framework/CoreAudio")
  45. cf = ctypes.CDLL("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")
  46.  
  47.  
  48. def fcc(s):
  49. """CoreAudio four-char-code selector -> uint32. Note some codes contain a space."""
  50. return struct.unpack(">I", s.encode())[0]
  51.  
  52.  
  53. kAudioObjectSystemObject = 1
  54. kAudioHardwarePropertyDevices = fcc("dev#")
  55. kAudioHardwarePropertyDefaultOutputDevice = fcc("dOut")
  56. kAudioDevicePropertyNominalSampleRate = fcc("nsrt")
  57. kAudioDevicePropertyAvailableNominalSampleRates = fcc("nsr#")
  58. kAudioDevicePropertyStreams = fcc("stm#")
  59. kAudioDevicePropertyHogMode = fcc("oink")
  60. kAudioDevicePropertyDeviceIsAlive = fcc("livn")
  61. kAudioDevicePropertyDeviceIsRunning = fcc("goin")
  62. kAudioDevicePropertyDeviceUID = fcc("uid ")
  63. kAudioDevicePropertyModelUID = fcc("muid")
  64. kAudioDevicePropertyTransportType = fcc("tran")
  65. kAudioObjectPropertyName = fcc("lnam")
  66. kAudioObjectPropertyScopeGlobal = fcc("glob")
  67. kAudioObjectPropertyScopeOutput = fcc("outp")
  68. kAudioObjectPropertyElementMain = 0
  69. kCFStringEncodingUTF8 = 0x08000100
  70.  
  71. TRANSPORTS = {
  72. fcc("bltn"): "built-in", fcc("usb "): "USB", fcc("hdmi"): "HDMI",
  73. fcc("dprt"): "DisplayPort", fcc("blue"): "Bluetooth", fcc("airp"): "AirPlay",
  74. fcc("virt"): "virtual", fcc("aggr"): "aggregate", fcc("pci "): "PCI",
  75. fcc("fire"): "FireWire", fcc("thun"): "Thunderbolt", fcc("unkn"): "unknown",
  76. }
  77.  
  78.  
  79. class Addr(ctypes.Structure):
  80. _fields_ = [("mSelector", ctypes.c_uint32),
  81. ("mScope", ctypes.c_uint32),
  82. ("mElement", ctypes.c_uint32)]
  83.  
  84.  
  85. class AudioValueRange(ctypes.Structure):
  86. _fields_ = [("mMinimum", ctypes.c_double), ("mMaximum", ctypes.c_double)]
  87.  
  88.  
  89. ca.AudioObjectGetPropertyDataSize.argtypes = [
  90. ctypes.c_uint32, ctypes.POINTER(Addr), ctypes.c_uint32,
  91. ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint32)]
  92. ca.AudioObjectGetPropertyData.argtypes = [
  93. ctypes.c_uint32, ctypes.POINTER(Addr), ctypes.c_uint32,
  94. ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint32), ctypes.c_void_p]
  95. ca.AudioObjectSetPropertyData.argtypes = [
  96. ctypes.c_uint32, ctypes.POINTER(Addr), ctypes.c_uint32,
  97. ctypes.c_void_p, ctypes.c_uint32, ctypes.c_void_p]
  98. cf.CFStringGetCString.argtypes = [ctypes.c_void_p, ctypes.c_char_p,
  99. ctypes.c_long, ctypes.c_uint32]
  100. cf.CFRelease.argtypes = [ctypes.c_void_p]
  101.  
  102.  
  103. def oserr(code):
  104. raw = struct.pack(">i", code)
  105. if all(32 <= b < 127 for b in raw):
  106. return "%d ('%s')" % (code, raw.decode("ascii"))
  107. return str(code)
  108.  
  109.  
  110. def _addr(selector, scope=kAudioObjectPropertyScopeGlobal):
  111. return Addr(selector, scope, kAudioObjectPropertyElementMain)
  112.  
  113.  
  114. def prop_size(obj, selector, scope=kAudioObjectPropertyScopeGlobal):
  115. a = _addr(selector, scope)
  116. size = ctypes.c_uint32(0)
  117. err = ca.AudioObjectGetPropertyDataSize(obj, ctypes.byref(a), 0, None, ctypes.byref(size))
  118. if err:
  119. raise OSError("CoreAudio error %s reading property size" % oserr(err))
  120. return size.value
  121.  
  122.  
  123. def get_scalar(obj, selector, ctype, scope=kAudioObjectPropertyScopeGlobal):
  124. a = _addr(selector, scope)
  125. size = ctypes.c_uint32(ctypes.sizeof(ctype))
  126. val = ctype()
  127. err = ca.AudioObjectGetPropertyData(obj, ctypes.byref(a), 0, None,
  128. ctypes.byref(size), ctypes.byref(val))
  129. if err:
  130. raise OSError("CoreAudio error %s reading property" % oserr(err))
  131. return val.value
  132.  
  133.  
  134. def set_scalar(obj, selector, value, scope=kAudioObjectPropertyScopeGlobal):
  135. a = _addr(selector, scope)
  136. err = ca.AudioObjectSetPropertyData(obj, ctypes.byref(a), 0, None,
  137. ctypes.sizeof(value), ctypes.byref(value))
  138. if err:
  139. raise OSError("CoreAudio error %s writing property" % oserr(err))
  140.  
  141.  
  142. def get_array(obj, selector, ctype, scope=kAudioObjectPropertyScopeGlobal):
  143. nbytes = prop_size(obj, selector, scope)
  144. count = nbytes // ctypes.sizeof(ctype)
  145. if count == 0:
  146. return []
  147. buf = (ctype * count)()
  148. a = _addr(selector, scope)
  149. size = ctypes.c_uint32(nbytes)
  150. err = ca.AudioObjectGetPropertyData(obj, ctypes.byref(a), 0, None,
  151. ctypes.byref(size), ctypes.byref(buf))
  152. if err:
  153. raise OSError("CoreAudio error %s reading array property" % oserr(err))
  154. return list(buf)
  155.  
  156.  
  157. def get_string(obj, selector):
  158. """Read a CFStringRef property and return it as a Python str."""
  159. a = _addr(selector)
  160. size = ctypes.c_uint32(ctypes.sizeof(ctypes.c_void_p))
  161. ref = ctypes.c_void_p()
  162. if ca.AudioObjectGetPropertyData(obj, ctypes.byref(a), 0, None,
  163. ctypes.byref(size), ctypes.byref(ref)):
  164. return ""
  165. buf = ctypes.create_string_buffer(1024)
  166. ok = cf.CFStringGetCString(ref, buf, 1024, kCFStringEncodingUTF8)
  167. cf.CFRelease(ref)
  168. return buf.value.decode("utf-8", "replace") if ok else ""
  169.  
  170.  
  171. def device_name(dev):
  172. return get_string(dev, kAudioObjectPropertyName) or "<unnamed %d>" % dev
  173.  
  174.  
  175. def device_uid(dev):
  176. return get_string(dev, kAudioDevicePropertyDeviceUID)
  177.  
  178.  
  179. def transport(dev):
  180. try:
  181. return TRANSPORTS.get(get_scalar(dev, kAudioDevicePropertyTransportType,
  182. ctypes.c_uint32), "?")
  183. except OSError:
  184. return "?"
  185.  
  186.  
  187. def has_output(dev):
  188. try:
  189. return prop_size(dev, kAudioDevicePropertyStreams, kAudioObjectPropertyScopeOutput) > 0
  190. except OSError:
  191. return False
  192.  
  193.  
  194. def all_devices():
  195. return get_array(kAudioObjectSystemObject, kAudioHardwarePropertyDevices, ctypes.c_uint32)
  196.  
  197.  
  198. def output_devices():
  199. return [d for d in all_devices() if has_output(d)]
  200.  
  201.  
  202. def default_output():
  203. return get_scalar(kAudioObjectSystemObject,
  204. kAudioHardwarePropertyDefaultOutputDevice, ctypes.c_uint32)
  205.  
  206.  
  207. def set_default_output(dev):
  208. set_scalar(kAudioObjectSystemObject, kAudioHardwarePropertyDefaultOutputDevice,
  209. ctypes.c_uint32(dev))
  210.  
  211.  
  212. def current_rate(dev):
  213. return get_scalar(dev, kAudioDevicePropertyNominalSampleRate, ctypes.c_double)
  214.  
  215.  
  216. def set_rate(dev, rate):
  217. set_scalar(dev, kAudioDevicePropertyNominalSampleRate, ctypes.c_double(float(rate)))
  218.  
  219.  
  220. def supported_rates(dev):
  221. out = []
  222. for r in get_array(dev, kAudioDevicePropertyAvailableNominalSampleRates, AudioValueRange):
  223. out.append(r.mMinimum)
  224. if r.mMaximum != r.mMinimum:
  225. out.append(r.mMaximum)
  226. return sorted(set(out))
  227.  
  228.  
  229. def hog_owner(dev):
  230. return get_scalar(dev, kAudioDevicePropertyHogMode, ctypes.c_int32)
  231.  
  232.  
  233. def fmt(rate):
  234. return "%g kHz" % (rate / 1000.0)
  235.  
  236.  
  237. def describe(dev):
  238. return "id=%-4d %-30s [%s] uid=%s" % (dev, device_name(dev), transport(dev), device_uid(dev))
  239.  
  240.  
  241. def matching(selector, outputs_only=False):
  242. """Resolve a -d selector to a list of devices. Empty selector = all (or all outputs)."""
  243. pool = output_devices() if outputs_only else all_devices()
  244. if not selector:
  245. return pool
  246. if selector.isdigit():
  247. want = int(selector)
  248. return [d for d in pool if d == want]
  249. if selector.lower().startswith("uid:"):
  250. needle = selector[4:].lower()
  251. return [d for d in pool if needle in device_uid(d).lower()]
  252. needle = selector.lower()
  253. return [d for d in pool if needle in device_name(d).lower() or needle in device_uid(d).lower()]
  254.  
  255.  
  256. def pick_device(selector):
  257. if not selector:
  258. return default_output()
  259. hits = matching(selector)
  260. if not hits:
  261. sys.exit("No device matching %r. Try: ./audiodev.py list" % selector)
  262. if len(hits) > 1:
  263. print("%r matches %d devices:" % (selector, len(hits)), file=sys.stderr)
  264. for d in hits:
  265. print(" " + describe(d), file=sys.stderr)
  266. sys.exit("\nThey are indistinguishable by name. Run:\n"
  267. " ./audiodev.py identify -d %r\n"
  268. "to hear which is which, then select the one you want by its id or "
  269. "by 'uid:<fragment>' (the UID survives reboots; the numeric id does not)."
  270. % selector)
  271. return hits[0]
  272.  
  273.  
  274. def cmd_list(args):
  275. default = default_output()
  276. for dev in all_devices():
  277. try:
  278. rate = current_rate(dev)
  279. except OSError:
  280. continue
  281. flags = []
  282. if dev == default:
  283. flags.append("default-out")
  284. hog = hog_owner(dev)
  285. if hog not in (-1, 0):
  286. flags.append("HOGGED by pid %d" % hog)
  287. for sel, label in ((kAudioDevicePropertyDeviceIsAlive, "alive"),
  288. (kAudioDevicePropertyDeviceIsRunning, "running")):
  289. try:
  290. if get_scalar(dev, sel, ctypes.c_uint32):
  291. flags.append(label)
  292. except OSError:
  293. pass
  294. try:
  295. avail = ", ".join(fmt(r) for r in supported_rates(dev))
  296. except OSError:
  297. avail = "?"
  298. print("%s %s" % (describe(dev), "(" + ", ".join(flags) + ")" if flags else ""))
  299. print(" %-4s now %s supports: %s"
  300. % ("out" if has_output(dev) else "in", fmt(rate), avail))
  301.  
  302.  
  303. def cmd_identify(args):
  304. cands = [d for d in matching(args.device, outputs_only=True)]
  305. if not cands:
  306. sys.exit("No output device matching %r." % (args.device or "<all>"))
  307. print("Speaking through each candidate in turn. Listen for which monitor talks.\n")
  308. for n, dev in enumerate(cands, 1):
  309. print(" %d. %s" % (n, describe(dev)))
  310. phrase = "This is device number %d" % n
  311. try:
  312. subprocess.run(["say", "-a", str(dev), "-r", "170", phrase],
  313. check=True, timeout=20)
  314. except subprocess.CalledProcessError:
  315. print(" (could not play through this device - it may be unavailable)")
  316. except FileNotFoundError:
  317. sys.exit("'say' not found; cannot play a test phrase.")
  318. time.sleep(0.5)
  319. print("\nNote the number you heard, then use that entry's id for this session,\n"
  320. "or its 'uid:<fragment>' to select the same monitor after a reboot.")
  321.  
  322.  
  323. def cmd_rate(args):
  324. dev = pick_device(args.device)
  325. before = current_rate(dev)
  326. set_rate(dev, args.value)
  327. print("%s: %s -> %s" % (device_name(dev), fmt(before), fmt(current_rate(dev))))
  328.  
  329.  
  330. def reset_hog(dev, verbose):
  331. owner = hog_owner(dev)
  332. if owner not in (-1, 0) and owner != os.getpid():
  333. print(" hog: already held exclusively by pid %d - skipping" % owner)
  334. return False
  335. try:
  336. set_scalar(dev, kAudioDevicePropertyHogMode, ctypes.c_int32(os.getpid()))
  337. except OSError as e:
  338. print(" hog: could not take exclusive access (%s)" % e)
  339. return False
  340. if verbose:
  341. print(" hog: took exclusive access, other clients evicted")
  342. time.sleep(0.6)
  343. try:
  344. set_scalar(dev, kAudioDevicePropertyHogMode, ctypes.c_int32(-1))
  345. except OSError as e:
  346. print(" hog: WARNING - failed to release exclusive access (%s)" % e)
  347. return False
  348. print(" hog: claimed and released exclusive access")
  349. return True
  350.  
  351.  
  352. def reset_rate(dev, verbose):
  353. try:
  354. rates = supported_rates(dev)
  355. original = current_rate(dev)
  356. except OSError as e:
  357. print(" rate: could not read rates (%s)" % e)
  358. return False
  359. others = [r for r in rates if r != original]
  360. if not others:
  361. print(" rate: only %s supported - nothing to bounce" % fmt(original))
  362. return False
  363. scratch = min(others, key=lambda r: abs(r - original))
  364. try:
  365. set_rate(dev, scratch)
  366. time.sleep(0.8)
  367. set_rate(dev, original)
  368. except OSError as e:
  369. print(" rate: bounce failed (%s)" % e)
  370. return False
  371. print(" rate: bounced %s -> %s -> %s" % (fmt(original), fmt(scratch), fmt(original)))
  372. return True
  373.  
  374.  
  375. def reset_default(dev, verbose):
  376. if default_output() != dev:
  377. print(" default: not the default output - skipping")
  378. return False
  379. others = [d for d in output_devices() if d != dev]
  380. if not others:
  381. print(" default: no other output device to bounce through - skipping")
  382. return False
  383. try:
  384. set_default_output(others[0])
  385. time.sleep(0.5)
  386. set_default_output(dev)
  387. except OSError as e:
  388. print(" default: bounce failed (%s)" % e)
  389. return False
  390. print(" default: bounced via %s" % device_name(others[0]))
  391. return True
  392.  
  393.  
  394. def cmd_reset(args):
  395. dev = pick_device(args.device)
  396. print("Resetting: %s" % describe(dev))
  397. methods = ["hog", "rate", "default"] if args.method == "all" else [args.method]
  398. table = {"hog": reset_hog, "rate": reset_rate, "default": reset_default}
  399. for m in methods:
  400. table[m](dev, args.verbose)
  401. try:
  402. alive = bool(get_scalar(dev, kAudioDevicePropertyDeviceIsAlive, ctypes.c_uint32))
  403. print("Now at %s, alive=%s" % (fmt(current_rate(dev)), alive))
  404. except OSError as e:
  405. print("Device did not come back cleanly: %s" % e)
  406.  
  407.  
  408. def main():
  409. p = argparse.ArgumentParser(description=__doc__,
  410. formatter_class=argparse.RawDescriptionHelpFormatter)
  411. p.add_argument("-v", "--verbose", action="store_true")
  412. sub = p.add_subparsers(dest="cmd", required=True)
  413.  
  414. sub.add_parser("list", help="show every device: id, UID, transport, rate, state")
  415.  
  416. pi = sub.add_parser("identify", help="speak through each matching output device")
  417. pi.add_argument("-d", "--device", default="")
  418.  
  419. pr = sub.add_parser("rate", help="set the nominal sample rate")
  420. pr.add_argument("value", type=float)
  421. pr.add_argument("-d", "--device", default="")
  422.  
  423. ps = sub.add_parser("reset", help="reinitialize one device")
  424. ps.add_argument("-d", "--device", default="")
  425. ps.add_argument("-m", "--method", choices=["hog", "rate", "default", "all"], default="all")
  426.  
  427. args = p.parse_args()
  428. {"list": cmd_list, "identify": cmd_identify,
  429. "rate": cmd_rate, "reset": cmd_reset}[args.cmd](args)
  430.  
  431.  
  432. if __name__ == "__main__":
  433. main()
  434.  
Advertisement
Add Comment
Please, Sign In to add comment